use std::collections::BTreeMap;
#[derive(Clone, Debug, PartialEq)]
pub struct Experiment {
pub variants: Vec<String>,
pub weights: Vec<f64>,
}
impl Experiment {
pub fn new(variants: impl IntoIterator<Item = impl Into<String>>, weights: impl IntoIterator<Item = f64>) -> Self {
Self {
variants: variants.into_iter().map(Into::into).collect(),
weights: weights.into_iter().collect(),
}
}
pub fn uniform(variants: impl IntoIterator<Item = impl Into<String>>) -> Self {
let variants: Vec<String> = variants.into_iter().map(Into::into).collect();
let weights = vec![1.0; variants.len()];
Self { variants, weights }
}
}
pub fn cookie_name(key: &str) -> String {
format!("ab_{key}")
}
pub fn pick_variant(exp: &Experiment, mut rng: impl FnMut() -> f64) -> String {
let total: f64 = exp.weights.iter().copied().filter(|w| *w > 0.0).sum();
if total <= 0.0 || exp.variants.is_empty() {
return exp.variants.first().cloned().unwrap_or_default();
}
let mut threshold = rng() * total;
for (i, weight) in exp.weights.iter().enumerate() {
threshold -= weight;
if threshold < 0.0
&& let Some(v) = exp.variants.get(i)
{
return v.clone();
}
}
exp.variants.last().cloned().unwrap_or_default()
}
pub fn resolve_variant(exp: &Experiment, raw: Option<&str>) -> String {
match raw {
Some(value) if exp.variants.iter().any(|v| v == value) => value.to_string(),
_ => exp.variants.first().cloned().unwrap_or_default(),
}
}
pub fn next_variant(exp: &Experiment, current: &str, step: i32) -> String {
if exp.variants.is_empty() {
return String::new();
}
let len = exp.variants.len() as i32;
let idx = exp.variants.iter().position(|v| v == current).map(|i| i as i32).unwrap_or(0);
let next = (((idx + step) % len) + len) % len;
exp.variants[next as usize].clone()
}
pub fn exposed_event(experiment: &str) -> String {
format!("{experiment}_exposed")
}
pub fn action_event(experiment: &str, action: &str) -> String {
format!("{experiment}_{action}")
}
#[derive(Clone, Debug, PartialEq)]
pub struct TrackedEvent {
pub name: String,
pub variant: String,
pub props: BTreeMap<String, String>,
}
impl TrackedEvent {
pub fn exposure(experiment: &str, variant: &str) -> Self {
Self {
name: exposed_event(experiment),
variant: variant.to_string(),
props: BTreeMap::new(),
}
}
pub fn action(experiment: &str, variant: &str, action: &str, props: BTreeMap<String, String>) -> Self {
Self {
name: action_event(experiment, action),
variant: variant.to_string(),
props,
}
}
}
#[cfg(test)]
mod tests {
use std::collections::BTreeMap;
use super::*;
fn fixed(v: f64) -> impl FnMut() -> f64 {
move || v
}
#[test]
fn cookie_name_is_prefixed() {
assert_eq!(cookie_name("hero"), "ab_hero");
assert_eq!(cookie_name(""), "ab_");
}
#[test]
fn experiment_new_keeps_variants_and_weights() {
let exp = Experiment::new(["a", "b"], [0.5, 0.5]);
assert_eq!(exp.variants, vec!["a".to_string(), "b".to_string()]);
assert_eq!(exp.weights, vec![0.5, 0.5]);
}
#[test]
fn experiment_new_empty_is_empty() {
let exp = Experiment::new(Vec::<String>::new(), Vec::<f64>::new());
assert!(exp.variants.is_empty());
assert!(exp.weights.is_empty());
}
#[test]
fn experiment_uniform_matches_variant_count() {
let exp = Experiment::uniform(["a", "b", "c"]);
assert_eq!(exp.weights, vec![1.0, 1.0, 1.0]);
assert_eq!(exp.weights.len(), exp.variants.len());
}
#[test]
fn experiment_uniform_empty_has_no_weights() {
let exp = Experiment::uniform(Vec::<String>::new());
assert!(exp.variants.is_empty());
assert!(exp.weights.is_empty());
}
#[test]
fn pick_variant_respects_weight_boundaries() {
let exp = Experiment::new(["a", "b"], [0.3, 0.7]);
assert_eq!(pick_variant(&exp, fixed(0.0)), "a");
assert_eq!(pick_variant(&exp, fixed(0.29)), "a");
assert_eq!(pick_variant(&exp, fixed(0.31)), "b");
assert_eq!(pick_variant(&exp, fixed(0.999)), "b");
}
#[test]
fn pick_variant_at_exact_cumulative_boundary_lands_on_next() {
let exp = Experiment::new(["a", "b"], [0.3, 0.7]);
assert_eq!(pick_variant(&exp, fixed(0.3)), "b");
assert_eq!(pick_variant(&exp, fixed(0.2999)), "a");
}
#[test]
fn pick_variant_rng_one_falls_through_to_last() {
let exp = Experiment::new(["a", "b", "c"], [1.0, 1.0, 1.0]);
assert_eq!(pick_variant(&exp, fixed(1.0)), "c");
}
#[test]
fn pick_variant_normalises_unnormalised_weights() {
let exp = Experiment::new(["a", "b", "c"], [1.0, 1.0, 2.0]);
assert_eq!(pick_variant(&exp, fixed(0.1)), "a");
assert_eq!(pick_variant(&exp, fixed(0.3)), "b");
assert_eq!(pick_variant(&exp, fixed(0.6)), "c");
}
#[test]
fn pick_variant_maps_each_subinterval_to_its_variant() {
let exp = Experiment::new(["a", "b", "c"], [2.0, 1.0, 1.0]);
for (r, expected) in [(0.0, "a"), (0.49, "a"), (0.5, "b"), (0.74, "b"), (0.75, "c"), (0.99, "c")] {
assert_eq!(pick_variant(&exp, fixed(r)), expected, "rng={r}");
}
}
#[test]
fn pick_variant_single_variant_always_picks_it() {
let exp = Experiment::new(["only"], [1.0]);
assert_eq!(pick_variant(&exp, fixed(0.0)), "only");
assert_eq!(pick_variant(&exp, fixed(0.5)), "only");
assert_eq!(pick_variant(&exp, fixed(1.0)), "only");
}
#[test]
fn pick_variant_empty_experiment_returns_empty_string() {
let exp = Experiment::new(Vec::<String>::new(), Vec::<f64>::new());
assert_eq!(pick_variant(&exp, fixed(0.5)), "");
}
#[test]
fn pick_variant_falls_back_when_no_weight() {
let exp = Experiment::new(["a", "b"], [0.0, 0.0]);
assert_eq!(pick_variant(&exp, fixed(0.5)), "a");
}
#[test]
fn pick_variant_zero_total_falls_back_to_control() {
let exp = Experiment::new(["control", "b", "c"], [0.0, 0.0, 0.0]);
assert_eq!(pick_variant(&exp, fixed(0.0)), "control");
assert_eq!(pick_variant(&exp, fixed(0.99)), "control");
}
#[test]
fn pick_variant_negative_weights_are_ignored_in_total() {
let exp = Experiment::new(["a", "b"], [-1.0, 1.0]);
assert_eq!(pick_variant(&exp, fixed(0.5)), "b");
}
#[test]
fn pick_variant_more_variants_than_weights_stays_valid() {
let exp = Experiment::new(["a", "b", "c"], [1.0]);
assert_eq!(pick_variant(&exp, fixed(0.0)), "a");
assert_eq!(pick_variant(&exp, fixed(0.99)), "a");
assert!(exp.variants.contains(&pick_variant(&exp, fixed(0.5))));
}
#[test]
fn pick_variant_more_weights_than_variants_stays_valid() {
let exp = Experiment::new(["a"], [1.0, 1.0]);
assert_eq!(pick_variant(&exp, fixed(0.0)), "a");
assert_eq!(pick_variant(&exp, fixed(0.9)), "a");
let exp2 = Experiment::new(["a", "b"], [1.0, 1.0, 1.0]);
assert_eq!(pick_variant(&exp2, fixed(0.1)), "a");
assert_eq!(pick_variant(&exp2, fixed(0.5)), "b");
assert_eq!(pick_variant(&exp2, fixed(0.9)), "b");
}
#[test]
fn resolve_variant_valid_unknown_none_and_empty() {
let exp = Experiment::new(["a", "b"], [0.5, 0.5]);
assert_eq!(resolve_variant(&exp, Some("b")), "b");
assert_eq!(resolve_variant(&exp, Some("garbage")), "a");
assert_eq!(resolve_variant(&exp, Some("")), "a");
assert_eq!(resolve_variant(&exp, None), "a");
let empty = Experiment::new(Vec::<String>::new(), Vec::<f64>::new());
assert_eq!(resolve_variant(&empty, Some("anything")), "");
assert_eq!(resolve_variant(&empty, None), "");
}
#[test]
fn next_variant_wraps_both_directions() {
let exp = Experiment::new(["a", "b", "c"], [1.0, 1.0, 1.0]);
assert_eq!(next_variant(&exp, "a", 1), "b");
assert_eq!(next_variant(&exp, "c", 1), "a");
assert_eq!(next_variant(&exp, "a", -1), "c");
assert_eq!(next_variant(&exp, "unknown", 1), "b");
}
#[test]
fn next_variant_step_zero_is_identity() {
let exp = Experiment::new(["a", "b", "c"], [1.0, 1.0, 1.0]);
assert_eq!(next_variant(&exp, "b", 0), "b");
assert_eq!(next_variant(&exp, "unknown", 0), "a");
}
#[test]
fn next_variant_large_steps_wrap_via_modulo() {
let exp = Experiment::new(["a", "b", "c"], [1.0, 1.0, 1.0]);
assert_eq!(next_variant(&exp, "a", 5), "c");
assert_eq!(next_variant(&exp, "a", -5), "b");
assert_eq!(next_variant(&exp, "b", 6), "b");
assert_eq!(next_variant(&exp, "b", -6), "b");
}
#[test]
fn next_variant_single_variant_is_always_itself() {
let exp = Experiment::new(["only"], [1.0]);
assert_eq!(next_variant(&exp, "only", 1), "only");
assert_eq!(next_variant(&exp, "only", -1), "only");
assert_eq!(next_variant(&exp, "unknown", 3), "only");
}
#[test]
fn next_variant_empty_experiment_returns_empty_string() {
let exp = Experiment::new(Vec::<String>::new(), Vec::<f64>::new());
assert_eq!(next_variant(&exp, "a", 1), "");
assert_eq!(next_variant(&exp, "", 0), "");
}
#[test]
fn event_names_match_taxonomy() {
assert_eq!(exposed_event("hero"), "hero_exposed");
assert_eq!(action_event("team", "cta_clicked"), "team_cta_clicked");
}
#[test]
fn tracked_event_exposure_carries_name_and_variant() {
let ev = TrackedEvent::exposure("hero", "a");
assert_eq!(ev.name, "hero_exposed");
assert_eq!(ev.variant, "a");
assert!(ev.props.is_empty());
}
#[test]
fn tracked_event_action_carries_scoped_name_variant_and_props() {
let mut props = BTreeMap::new();
props.insert("cta".to_string(), "careers".to_string());
let ev = TrackedEvent::action("team", "b", "cta_clicked", props.clone());
assert_eq!(ev.name, "team_cta_clicked");
assert_eq!(ev.variant, "b");
assert_eq!(ev.props, props);
}
}