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