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 crate::{
7    enrollment::{EnrolledReason, EnrollmentStatus, ExperimentEnrollment, NotEnrolledReason},
8    error::{debug, info, NimbusError, Result},
9    sampling, AvailableRandomizationUnits, Branch, Experiment, NimbusTargetingHelper,
10};
11use serde_derive::*;
12use serde_json::Value;
13
14cfg_if::cfg_if! {
15    if #[cfg(feature = "stateful")] {
16        pub use crate::stateful::evaluator::*;
17    } else {
18        pub use crate::stateless::evaluator::*;
19    }
20}
21
22#[derive(Serialize, Deserialize, Debug, Clone, Default)]
23pub struct Bucket {}
24
25impl Bucket {
26    #[allow(unused)]
27    pub fn new() -> Self {
28        unimplemented!();
29    }
30}
31
32fn prefer_none_to_empty(s: Option<&str>) -> Option<String> {
33    let s = s?;
34    if s.is_empty() {
35        None
36    } else {
37        Some(s.to_string())
38    }
39}
40
41pub fn split_locale(locale: String) -> (Option<String>, Option<String>) {
42    if locale.contains('-') {
43        let mut parts = locale.split('-');
44        (
45            prefer_none_to_empty(parts.next()),
46            prefer_none_to_empty(parts.next()),
47        )
48    } else {
49        (Some(locale), None)
50    }
51}
52
53/// Determine the enrolment status for an experiment.
54///
55/// # Arguments:
56/// - `available_randomization_units` The app provided available randomization units
57/// - `targeting_attributes` The attributes to use when evaluating targeting
58/// - `exp` The `Experiment` to evaluate.
59///
60/// # Returns:
61/// An `ExperimentEnrollment` -  you need to inspect the EnrollmentStatus to
62/// determine if the user is actually enrolled.
63///
64/// # Errors:
65///
66/// The function can return errors in one of the following cases (but not limited to):
67///
68/// - If the bucket sampling failed (i.e we could not find if the user should or should not be enrolled in the experiment based on the bucketing)
69/// - If an error occurs while determining the branch the user should be enrolled in any of the experiments
70pub fn evaluate_enrollment(
71    available_randomization_units: &AvailableRandomizationUnits,
72    exp: &Experiment,
73    th: &NimbusTargetingHelper,
74) -> Result<ExperimentEnrollment> {
75    if !is_experiment_available(th, exp, true) {
76        return Ok(ExperimentEnrollment {
77            slug: exp.slug.clone(),
78            status: EnrollmentStatus::NotEnrolled {
79                reason: NotEnrolledReason::NotTargeted,
80            },
81        });
82    }
83
84    // Get targeting out of the way - "if let chains" are experimental,
85    // otherwise we could improve this.
86    if let Some(expr) = &exp.targeting {
87        if let Some(status) = targeting(expr, th) {
88            return Ok(ExperimentEnrollment {
89                slug: exp.slug.clone(),
90                status,
91            });
92        }
93    }
94    Ok(ExperimentEnrollment {
95        slug: exp.slug.clone(),
96        status: {
97            let bucket_config = exp.bucket_config.clone();
98            match available_randomization_units.get_value(&bucket_config.randomization_unit) {
99                Some(id) => {
100                    if sampling::bucket_sample(
101                        vec![id.to_owned(), bucket_config.namespace],
102                        bucket_config.start,
103                        bucket_config.count,
104                        bucket_config.total,
105                    )? {
106                        EnrollmentStatus::new_enrolled(
107                            EnrolledReason::Qualified,
108                            &choose_branch(&exp.slug, &exp.branches, id)?.clone().slug,
109                        )
110                    } else {
111                        EnrollmentStatus::NotEnrolled {
112                            reason: NotEnrolledReason::NotSelected,
113                        }
114                    }
115                }
116                None => {
117                    // XXX: When we link in glean, it would be nice if we could emit
118                    // a failure telemetry event here.
119                    info!(
120                        "Could not find a suitable randomization unit for {}. Skipping experiment.",
121                        &exp.slug
122                    );
123                    EnrollmentStatus::Error {
124                        reason: "No randomization unit".into(),
125                    }
126                }
127            }
128        },
129    })
130}
131
132/// Check if an experiment is available for this app defined by this `AppContext`.
133///
134/// # Arguments:
135/// - `app_context` The application parameters to use for targeting purposes
136/// - `exp` The `Experiment` to evaluate
137/// - `is_release` Supports two modes:
138///   if `true`, available means available for enrollment: i.e. does the `app_name` and `channel` match.
139///   if `false`, available means available for testing: i.e. does only the `app_name` match.
140///
141/// # Returns:
142/// Returns `true` if the experiment matches the targeting
143pub fn is_experiment_available(
144    th: &NimbusTargetingHelper,
145    exp: &Experiment,
146    is_release: bool,
147) -> bool {
148    // Verify the app_name matches the application being targeted
149    // by the experiment.
150    match (&exp.app_name, th.context.get("app_name".to_string())) {
151        (Some(exp), Some(Value::String(mine))) => {
152            if !exp.eq(mine) {
153                return false;
154            }
155        }
156        (_, _) => debug!("Experiment missing app_name, skipping it as a targeting parameter"),
157    }
158
159    if !is_release {
160        return true;
161    }
162
163    // Verify the channel matches the application being targeted
164    // by the experiment.  Note, we are intentionally comparing in a case-insensitive way.
165    // See https://jira.mozilla.com/browse/SDK-246 for more info.
166    match (&exp.channel, th.context.get("channel".to_string())) {
167        (Some(exp), Some(Value::String(mine))) => {
168            if !exp.to_lowercase().eq(&mine.to_lowercase()) {
169                return false;
170            }
171        }
172        (_, _) => debug!("Experiment missing channel, skipping it as a targeting parameter"),
173    }
174    true
175}
176
177/// Chooses a branch randomly from a set of branches
178/// based on the ratios set in the branches
179///
180/// It is important that the input to the sampling algorithm be:
181/// - Unique per-user (no one is bucketed alike)
182/// - Unique per-experiment (bucketing differs across multiple experiments)
183/// - Differs from the input used for sampling the recipe (otherwise only
184///   branches that contain the same buckets as the recipe sampling will
185///   receive users)
186///
187/// # Arguments:
188/// - `slug` the slug associated with the experiment
189/// - `branches` the branches to pick from
190/// - `id` the user id used to pick a branch
191///
192/// # Returns:
193/// Returns the slug for the selected branch
194///
195/// # Errors:
196///
197/// An error could occur if something goes wrong while sampling the ratios
198pub(crate) fn choose_branch<'a>(
199    slug: &str,
200    branches: &'a [Branch],
201    id: &str,
202) -> Result<&'a Branch> {
203    // convert from i32 to u32 to work around SDK-175.
204    let ratios = branches.iter().map(|b| b.ratio as u32).collect::<Vec<_>>();
205    // Note: The "experiment-manager" here comes from
206    // https://searchfox.org/mozilla-central/rev/1843375acbbca68127713e402be222350ac99301/toolkit/components/messaging-system/experiments/ExperimentManager.jsm#469
207    // TODO: Change it to be something more related to the SDK if it is needed
208    let input = format!("{:}-{:}-{:}-branch", "experimentmanager", id, slug);
209    let index = sampling::ratio_sample(input, &ratios)?;
210    branches.get(index).ok_or(NimbusError::OutOfBoundsError)
211}
212
213/// Checks if the client is targeted by an experiment
214/// This api evaluates the JEXL statement retrieved from the server
215/// against the application context provided by the client
216///
217/// # Arguments
218/// - `expression_statement`: The JEXL statement provided by the server
219/// - `targeting_attributes`: The client attributes to target against
220///
221/// If this app can not be targeted, returns an EnrollmentStatus to indicate
222/// why. Returns None if we should continue to evaluate the enrollment status.
223///
224/// In practice, if this returns an EnrollmentStatus, it will be either
225/// EnrollmentStatus::NotEnrolled, or EnrollmentStatus::Error in the following
226/// cases (But not limited to):
227/// - The `expression_statement` is not a valid JEXL statement
228/// - The `expression_statement` expects fields that do not exist in the AppContext definition
229/// - The result of evaluating the statement against the context is not a boolean
230/// - jexl-rs returned an error
231pub(crate) fn targeting(
232    expression_statement: &str,
233    targeting_helper: &NimbusTargetingHelper,
234) -> Option<EnrollmentStatus> {
235    match targeting_helper.eval_jexl(expression_statement.to_string()) {
236        Ok(res) => match res {
237            true => None,
238            false => Some(EnrollmentStatus::NotEnrolled {
239                reason: NotEnrolledReason::NotTargeted,
240            }),
241        },
242        Err(e) => Some(EnrollmentStatus::Error {
243            reason: e.to_string(),
244        }),
245    }
246}
247
248#[cfg(test)]
249mod unit_tests {
250    use super::*;
251
252    #[test]
253    fn test_splitting_locale() -> Result<()> {
254        assert_eq!(
255            split_locale("en-US".to_string()),
256            (Some("en".to_string()), Some("US".to_string()))
257        );
258        assert_eq!(
259            split_locale("es".to_string()),
260            (Some("es".to_string()), None)
261        );
262
263        assert_eq!(
264            split_locale("-unknown".to_string()),
265            (None, Some("unknown".to_string()))
266        );
267        Ok(())
268    }
269}