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}