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}