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 let ExperimentAvailable::Unavailable { reason } = is_experiment_available(th, exp, true) {
76        return Ok(ExperimentEnrollment {
77            slug: exp.slug.clone(),
78            status: EnrollmentStatus::NotEnrolled { reason },
79        });
80    }
81
82    // Get targeting out of the way - "if let chains" are experimental,
83    // otherwise we could improve this.
84    if let Some(expr) = &exp.targeting {
85        if let Some(status) = targeting(expr, th) {
86            return Ok(ExperimentEnrollment {
87                slug: exp.slug.clone(),
88                status,
89            });
90        }
91    }
92    Ok(ExperimentEnrollment {
93        slug: exp.slug.clone(),
94        status: {
95            let bucket_config = exp.bucket_config.clone();
96            match available_randomization_units.get_value(&bucket_config.randomization_unit) {
97                Some(id) => {
98                    if sampling::bucket_sample(
99                        vec![id.to_owned(), bucket_config.namespace],
100                        bucket_config.start,
101                        bucket_config.count,
102                        bucket_config.total,
103                    )? {
104                        EnrollmentStatus::new_enrolled(
105                            EnrolledReason::Qualified,
106                            &choose_branch(&exp.slug, &exp.branches, id)?.clone().slug,
107                        )
108                    } else {
109                        EnrollmentStatus::NotEnrolled {
110                            reason: NotEnrolledReason::NotSelected,
111                        }
112                    }
113                }
114                None => {
115                    // XXX: When we link in glean, it would be nice if we could emit
116                    // a failure telemetry event here.
117                    info!(
118                        "Could not find a suitable randomization unit for {}. Skipping experiment.",
119                        &exp.slug
120                    );
121                    EnrollmentStatus::Error {
122                        reason: "No randomization unit".into(),
123                    }
124                }
125            }
126        },
127    })
128}
129
130/// Whether or not an experiment is available.
131#[derive(Debug, Eq, PartialEq)]
132pub enum ExperimentAvailable {
133    /// The experiment is available (i.e., it is for this application and channel).
134    Available,
135
136    /// The experiment is not available (i.e., it is either not for this
137    /// application or not for this channel).
138    Unavailable { reason: NotEnrolledReason },
139}
140
141/// Check if an experiment is available for this app defined by this `AppContext`.
142///
143/// # Arguments:
144/// - `app_context` The application parameters to use for targeting purposes
145/// - `exp` The `Experiment` to evaluate
146/// - `is_release` Supports two modes:
147///   if `true`, available means available for enrollment: i.e. does the `app_name` and `channel` match.
148///   if `false`, available means available for testing: i.e. does only the `app_name` match.
149///
150/// # Returns:
151/// Returns `true` if the experiment matches the targeting
152pub fn is_experiment_available(
153    th: &NimbusTargetingHelper,
154    exp: &Experiment,
155    is_release: bool,
156) -> ExperimentAvailable {
157    // Verify the app_name matches the application being targeted
158    // by the experiment.
159    match (&exp.app_name, th.context.get("app_name".to_string())) {
160        (Some(exp), Some(Value::String(mine))) => {
161            if !exp.eq(mine) {
162                return ExperimentAvailable::Unavailable {
163                    reason: NotEnrolledReason::DifferentAppName,
164                };
165            }
166        }
167        (_, _) => debug!("Experiment missing app_name, skipping it as a targeting parameter"),
168    }
169
170    if !is_release {
171        return ExperimentAvailable::Available;
172    }
173
174    // Verify the channel matches the application being targeted
175    // by the experiment.  Note, we are intentionally comparing in a case-insensitive way.
176    // See https://jira.mozilla.com/browse/SDK-246 for more info.
177    match (&exp.channel, th.context.get("channel".to_string())) {
178        (Some(exp), Some(Value::String(mine))) => {
179            if !exp.to_lowercase().eq(&mine.to_lowercase()) {
180                return ExperimentAvailable::Unavailable {
181                    reason: NotEnrolledReason::DifferentChannel,
182                };
183            }
184        }
185        (_, _) => debug!("Experiment missing channel, skipping it as a targeting parameter"),
186    }
187
188    ExperimentAvailable::Available
189}
190
191/// Chooses a branch randomly from a set of branches
192/// based on the ratios set in the branches
193///
194/// It is important that the input to the sampling algorithm be:
195/// - Unique per-user (no one is bucketed alike)
196/// - Unique per-experiment (bucketing differs across multiple experiments)
197/// - Differs from the input used for sampling the recipe (otherwise only
198///   branches that contain the same buckets as the recipe sampling will
199///   receive users)
200///
201/// # Arguments:
202/// - `slug` the slug associated with the experiment
203/// - `branches` the branches to pick from
204/// - `id` the user id used to pick a branch
205///
206/// # Returns:
207/// Returns the slug for the selected branch
208///
209/// # Errors:
210///
211/// An error could occur if something goes wrong while sampling the ratios
212pub(crate) fn choose_branch<'a>(
213    slug: &str,
214    branches: &'a [Branch],
215    id: &str,
216) -> Result<&'a Branch> {
217    // convert from i32 to u32 to work around SDK-175.
218    let ratios = branches.iter().map(|b| b.ratio as u32).collect::<Vec<_>>();
219    // Note: The "experiment-manager" here comes from
220    // https://searchfox.org/mozilla-central/rev/1843375acbbca68127713e402be222350ac99301/toolkit/components/messaging-system/experiments/ExperimentManager.jsm#469
221    // TODO: Change it to be something more related to the SDK if it is needed
222    let input = format!("{:}-{:}-{:}-branch", "experimentmanager", id, slug);
223    let index = sampling::ratio_sample(input, &ratios)?;
224    branches.get(index).ok_or(NimbusError::OutOfBoundsError)
225}
226
227/// Checks if the client is targeted by an experiment
228/// This api evaluates the JEXL statement retrieved from the server
229/// against the application context provided by the client
230///
231/// # Arguments
232/// - `expression_statement`: The JEXL statement provided by the server
233/// - `targeting_attributes`: The client attributes to target against
234///
235/// If this app can not be targeted, returns an EnrollmentStatus to indicate
236/// why. Returns None if we should continue to evaluate the enrollment status.
237///
238/// In practice, if this returns an EnrollmentStatus, it will be either
239/// EnrollmentStatus::NotEnrolled, or EnrollmentStatus::Error in the following
240/// cases (But not limited to):
241/// - The `expression_statement` is not a valid JEXL statement
242/// - The `expression_statement` expects fields that do not exist in the AppContext definition
243/// - The result of evaluating the statement against the context is not a boolean
244/// - jexl-rs returned an error
245pub(crate) fn targeting(
246    expression_statement: &str,
247    targeting_helper: &NimbusTargetingHelper,
248) -> Option<EnrollmentStatus> {
249    match targeting_helper.eval_jexl(expression_statement.to_string()) {
250        Ok(res) => match res {
251            true => None,
252            false => Some(EnrollmentStatus::NotEnrolled {
253                reason: NotEnrolledReason::NotTargeted,
254            }),
255        },
256        Err(e) => Some(EnrollmentStatus::Error {
257            reason: e.to_string(),
258        }),
259    }
260}
261
262#[cfg(test)]
263mod unit_tests {
264    use super::*;
265
266    #[test]
267    fn test_splitting_locale() -> Result<()> {
268        assert_eq!(
269            split_locale("en-US".to_string()),
270            (Some("en".to_string()), Some("US".to_string()))
271        );
272        assert_eq!(
273            split_locale("es".to_string()),
274            (Some("es".to_string()), None)
275        );
276
277        assert_eq!(
278            split_locale("-unknown".to_string()),
279            (None, Some("unknown".to_string()))
280        );
281        Ok(())
282    }
283}