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}