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