nimbus/
evaluator.rs

1/* This Source Code Form is subject to the terms of the Mozilla Public
2 * License, v. 2.0. If a copy of the MPL was not distributed with this
3 * file, You can obtain one at http://mozilla.org/MPL/2.0/.
4 */
5
6use serde_derive::*;
7use serde_json::Value;
8
9use crate::enrollment::{
10    EnrolledReason, EnrollmentStatus, ExperimentEnrollment, NotEnrolledReason,
11};
12use crate::error::{NimbusError, Result, debug, info};
13use crate::sampling;
14#[cfg(feature = "stateful")]
15pub use crate::stateful::evaluator::*;
16#[cfg(not(feature = "stateful"))]
17pub use crate::stateless::evaluator::*;
18use crate::{AvailableRandomizationUnits, Branch, Experiment, NimbusTargetingHelper};
19
20#[derive(Serialize, Deserialize, Debug, Clone, Default)]
21pub struct Bucket {}
22
23impl Bucket {
24    #[allow(unused)]
25    pub fn new() -> Self {
26        unimplemented!();
27    }
28}
29
30fn prefer_none_to_empty(s: Option<&str>) -> Option<String> {
31    let s = s?;
32    if s.is_empty() {
33        None
34    } else {
35        Some(s.to_string())
36    }
37}
38
39pub fn split_locale(locale: String) -> (Option<String>, Option<String>) {
40    if locale.contains('-') {
41        let mut parts = locale.split('-');
42        (
43            prefer_none_to_empty(parts.next()),
44            prefer_none_to_empty(parts.next()),
45        )
46    } else {
47        (Some(locale), None)
48    }
49}
50
51/// Determine the enrolment status for an experiment.
52///
53/// # Errors
54///
55/// The function can return an error when branch selection fails due to an
56/// invalid bucketing configuration.
57pub fn evaluate_enrollment(
58    available_randomization_units: &AvailableRandomizationUnits,
59    experiment: &Experiment,
60    targeting_helper: &NimbusTargetingHelper,
61) -> Result<ExperimentEnrollment> {
62    let status = match can_enroll(available_randomization_units, targeting_helper, experiment) {
63        CanEnrollResult::Unavailable { reason } => EnrollmentStatus::NotEnrolled { reason },
64        CanEnrollResult::NotTargeted => EnrollmentStatus::NotEnrolled {
65            reason: NotEnrolledReason::NotTargeted,
66        },
67        CanEnrollResult::NotSelected => EnrollmentStatus::NotEnrolled {
68            reason: NotEnrolledReason::NotSelected,
69        },
70        CanEnrollResult::TargetingError { reason } => EnrollmentStatus::Error { reason },
71        CanEnrollResult::NoRandomizationUnit => {
72            info!(
73                "Could not find a suitable randomization unit for {}. Skipping experiment.",
74                experiment.slug,
75            );
76            EnrollmentStatus::Error {
77                reason: "No randomization unit".into(),
78            }
79        }
80
81        CanEnrollResult::Enrollable { randomization_id } => EnrollmentStatus::new_enrolled(
82            EnrolledReason::Qualified,
83            &choose_branch(&experiment.slug, &experiment.branches, randomization_id)?.slug,
84        ),
85    };
86
87    Ok(ExperimentEnrollment {
88        slug: experiment.slug.clone(),
89        status,
90    })
91}
92
93/// Whether or not an experiment can be enrolled.
94pub enum CanEnrollResult<'aru> {
95    /// The experiment is enrollable.
96    Enrollable {
97        /// The randomization ID that should be used for branch selection.
98        randomization_id: &'aru str,
99    },
100
101    /// The experiment is not available for a reason outlined in [`NotEnrolledReason`]
102    Unavailable {
103        /// The reason the enrollment is not available.
104        reason: NotEnrolledReason,
105    },
106
107    /// The experiment is not enrollable due to a targeting error.
108    TargetingError {
109        /// The stringified error.
110        reason: String,
111    },
112
113    /// The experiment is not enrollable because targeting expression evaluated
114    /// to false.
115    NotTargeted,
116
117    /// The experiment is not enrollable because randomization ID did not fall
118    /// into a selected bucket.
119    NotSelected,
120
121    /// The experiment is not enrollable because it requires a randomization
122    /// unit that is not available.
123    NoRandomizationUnit,
124}
125
126/// Determine whether or not it is possible to enroll in the given experiment.
127pub fn can_enroll<'aru>(
128    available_randomization_units: &'aru AvailableRandomizationUnits,
129    targeting_helper: &NimbusTargetingHelper,
130    experiment: &Experiment,
131) -> CanEnrollResult<'aru> {
132    if let ExperimentAvailable::Unavailable { reason } =
133        is_experiment_available(targeting_helper, experiment, true)
134    {
135        return CanEnrollResult::Unavailable { reason };
136    }
137
138    if let Some(targeting_expression) = &experiment.targeting {
139        match targeting_helper.eval_jexl(targeting_expression) {
140            Err(e) => {
141                return CanEnrollResult::TargetingError {
142                    reason: e.to_string(),
143                };
144            }
145            Ok(false) => return CanEnrollResult::NotTargeted,
146            Ok(true) => {}
147        };
148    }
149
150    let Some(randomization_id) =
151        available_randomization_units.get_value(&experiment.bucket_config.randomization_unit)
152    else {
153        return CanEnrollResult::NoRandomizationUnit;
154    };
155
156    let Ok(is_sampled) = sampling::bucket_sample(
157        [randomization_id, &experiment.bucket_config.namespace],
158        experiment.bucket_config.start,
159        experiment.bucket_config.count,
160        experiment.bucket_config.total,
161    ) else {
162        return CanEnrollResult::NoRandomizationUnit;
163    };
164
165    if is_sampled {
166        CanEnrollResult::Enrollable { randomization_id }
167    } else {
168        CanEnrollResult::NotSelected
169    }
170}
171
172/// Whether or not an experiment is available.
173#[derive(Debug, Eq, PartialEq)]
174pub enum ExperimentAvailable {
175    /// The experiment is available (i.e., it is for this application and channel).
176    Available,
177
178    /// The experiment is not available (i.e., it is either not for this
179    /// application or not for this channel).
180    Unavailable { reason: NotEnrolledReason },
181}
182
183/// Check if an experiment is available for this app defined by this `AppContext`.
184///
185/// # Arguments:
186/// - `app_context` The application parameters to use for targeting purposes
187/// - `exp` The `Experiment` to evaluate
188/// - `is_release` Supports two modes:
189///   if `true`, available means available for enrollment: i.e. does the `app_name` and `channel` match.
190///   if `false`, available means available for testing: i.e. does only the `app_name` match.
191///
192/// # Returns:
193/// Returns `true` if the experiment matches the targeting
194pub fn is_experiment_available(
195    th: &NimbusTargetingHelper,
196    exp: &Experiment,
197    is_release: bool,
198) -> ExperimentAvailable {
199    // Verify the app_name matches the application being targeted
200    // by the experiment.
201    match (&exp.app_name, th.context.get("app_name".to_string())) {
202        (Some(exp), Some(Value::String(mine))) => {
203            if !exp.eq(mine) {
204                return ExperimentAvailable::Unavailable {
205                    reason: NotEnrolledReason::DifferentAppName,
206                };
207            }
208        }
209        (_, _) => debug!("Experiment missing app_name, skipping it as a targeting parameter"),
210    }
211
212    if !is_release {
213        return ExperimentAvailable::Available;
214    }
215
216    // Verify the channel matches the application being targeted
217    // by the experiment.  Note, we are intentionally comparing in a case-insensitive way.
218    // See https://jira.mozilla.com/browse/SDK-246 for more info.
219    match (&exp.channel, th.context.get("channel".to_string())) {
220        (Some(exp), Some(Value::String(mine))) => {
221            if !exp.to_lowercase().eq(&mine.to_lowercase()) {
222                return ExperimentAvailable::Unavailable {
223                    reason: NotEnrolledReason::DifferentChannel,
224                };
225            }
226        }
227        (_, _) => debug!("Experiment missing channel, skipping it as a targeting parameter"),
228    }
229
230    ExperimentAvailable::Available
231}
232
233/// Chooses a branch randomly from a set of branches
234/// based on the ratios set in the branches
235///
236/// It is important that the input to the sampling algorithm be:
237/// - Unique per-user (no one is bucketed alike)
238/// - Unique per-experiment (bucketing differs across multiple experiments)
239/// - Differs from the input used for sampling the recipe (otherwise only
240///   branches that contain the same buckets as the recipe sampling will
241///   receive users)
242///
243/// # Arguments:
244/// - `slug` the slug associated with the experiment
245/// - `branches` the branches to pick from
246/// - `id` the user id used to pick a branch
247///
248/// # Returns:
249/// Returns the slug for the selected branch
250///
251/// # Errors:
252///
253/// An error could occur if something goes wrong while sampling the ratios
254pub(crate) fn choose_branch<'a>(
255    slug: &str,
256    branches: &'a [Branch],
257    id: &str,
258) -> Result<&'a Branch> {
259    // convert from i32 to u32 to work around SDK-175.
260    let ratios = branches.iter().map(|b| b.ratio as u32).collect::<Vec<_>>();
261    // Note: The "experiment-manager" here comes from
262    // https://searchfox.org/mozilla-central/rev/1843375acbbca68127713e402be222350ac99301/toolkit/components/messaging-system/experiments/ExperimentManager.jsm#469
263    // TODO: Change it to be something more related to the SDK if it is needed
264    let input = format!("{:}-{:}-{:}-branch", "experimentmanager", id, slug);
265    let index = sampling::ratio_sample(input, &ratios)?;
266    branches.get(index).ok_or(NimbusError::OutOfBoundsError)
267}
268
269#[cfg(test)]
270mod unit_tests {
271    use super::*;
272
273    #[test]
274    fn test_splitting_locale() -> Result<()> {
275        assert_eq!(
276            split_locale("en-US".to_string()),
277            (Some("en".to_string()), Some("US".to_string()))
278        );
279        assert_eq!(
280            split_locale("es".to_string()),
281            (Some("es".to_string()), None)
282        );
283
284        assert_eq!(
285            split_locale("-unknown".to_string()),
286            (None, Some("unknown".to_string()))
287        );
288        Ok(())
289    }
290}