Skip to main content

oximedia_analytics/
ab_testing.rs

1//! A/B testing framework for media content experiments.
2//!
3//! Provides variant assignment (deterministic FNV-1a or random), per-variant
4//! metric collection, statistical significance testing, and winner selection.
5
6use std::collections::HashMap;
7
8use crate::error::AnalyticsError;
9
10// ─── Experiment model ─────────────────────────────────────────────────────────
11
12/// One treatment arm in an experiment.
13#[derive(Debug, Clone, PartialEq)]
14pub struct Variant {
15    pub id: String,
16    pub name: String,
17    /// Relative weight used during assignment (will be normalised to sum=1.0).
18    pub allocation_weight: f32,
19}
20
21/// A configured A/B experiment.
22#[derive(Debug, Clone)]
23pub struct Experiment {
24    pub id: String,
25    pub name: String,
26    pub variants: Vec<Variant>,
27    /// Wall-clock start of the experiment (Unix epoch ms).
28    pub start_ms: i64,
29    /// Optional end time; `None` means the experiment is still running.
30    pub end_ms: Option<i64>,
31    /// Minimum sample size per variant before results are considered reliable.
32    pub min_sample_size: u32,
33}
34
35impl Experiment {
36    /// Sum of all variant allocation weights (for normalisation).
37    fn weight_sum(&self) -> f32 {
38        self.variants.iter().map(|v| v.allocation_weight).sum()
39    }
40}
41
42/// How to assign a user to a variant.
43#[derive(Debug, Clone, Copy, PartialEq, Eq)]
44pub enum AssignmentMethod {
45    /// Hash the user ID deterministically — the same user always gets the
46    /// same variant.
47    Deterministic,
48    /// Not truly random in this context; provided for API completeness.  Uses
49    /// the same FNV-1a path because we have no PRNG state here.
50    Random,
51}
52
53/// FNV-1a 32-bit hash of a byte slice.
54fn fnv1a_32(data: &[u8]) -> u32 {
55    const FNV_OFFSET: u32 = 2_166_136_261;
56    const FNV_PRIME: u32 = 16_777_619;
57    let mut hash = FNV_OFFSET;
58    for &byte in data {
59        hash ^= u32::from(byte);
60        hash = hash.wrapping_mul(FNV_PRIME);
61    }
62    hash
63}
64
65/// Assign a user to a variant in the given experiment.
66///
67/// For `Deterministic` (and `Random` — see note on `AssignmentMethod`), the
68/// assignment is derived from the FNV-1a hash of the `user_id`, which means
69/// the same user always lands in the same bucket.  Variants with higher
70/// `allocation_weight` receive proportionally more users.
71///
72/// Returns an error if the experiment has no variants or all weights are zero.
73pub fn assign_variant<'e>(
74    experiment: &'e Experiment,
75    user_id: &str,
76    _method: AssignmentMethod,
77) -> Result<&'e Variant, AnalyticsError> {
78    if experiment.variants.is_empty() {
79        return Err(AnalyticsError::NoVariants(experiment.id.clone()));
80    }
81
82    let weight_sum = experiment.weight_sum();
83    if weight_sum <= 0.0 {
84        return Err(AnalyticsError::InvalidWeights(experiment.id.clone()));
85    }
86
87    let hash = fnv1a_32(user_id.as_bytes());
88    // Map hash to [0, weight_sum).
89    // Use f64 to retain precision for large weight sums.
90    let pos = (hash as f64 / u32::MAX as f64) * weight_sum as f64;
91
92    let mut cumulative = 0.0f64;
93    for variant in &experiment.variants {
94        cumulative += variant.allocation_weight as f64;
95        if pos < cumulative {
96            return Ok(variant);
97        }
98    }
99
100    // Fallback: return the last variant (handles floating-point edge cases).
101    // Safety: this line is only reachable when `experiment.variants` is non-empty
102    // (checked at the beginning of the function).  `last()` therefore always
103    // returns `Some`.
104    experiment
105        .variants
106        .last()
107        .ok_or_else(|| AnalyticsError::NoVariants(experiment.id.clone()))
108}
109
110// ─── Metrics ──────────────────────────────────────────────────────────────────
111
112/// Collected metrics for a single experiment variant.
113#[derive(Debug, Clone, Default)]
114pub struct VariantMetrics {
115    pub variant_id: String,
116    pub impressions: u32,
117    pub clicks: u32,
118    pub conversions: u32,
119    /// Total watch time (ms) summed across all impressions.
120    pub watch_duration_sum_ms: u64,
121    /// Number of sessions that completed the content.
122    pub completion_count: u32,
123}
124
125impl VariantMetrics {
126    pub fn new(variant_id: impl Into<String>) -> Self {
127        Self {
128            variant_id: variant_id.into(),
129            ..Default::default()
130        }
131    }
132}
133
134/// Aggregate experiment results keyed by variant ID.
135#[derive(Debug, Clone)]
136pub struct ExperimentResults {
137    pub experiment: Experiment,
138    pub variant_metrics: HashMap<String, VariantMetrics>,
139}
140
141impl ExperimentResults {
142    pub fn new(experiment: Experiment) -> Self {
143        let mut variant_metrics = HashMap::new();
144        for variant in &experiment.variants {
145            variant_metrics.insert(variant.id.clone(), VariantMetrics::new(variant.id.clone()));
146        }
147        Self {
148            experiment,
149            variant_metrics,
150        }
151    }
152
153    /// Record an impression for a variant.
154    pub fn record_impression(&mut self, variant_id: &str) {
155        if let Some(m) = self.variant_metrics.get_mut(variant_id) {
156            m.impressions += 1;
157        }
158    }
159
160    /// Record a click for a variant.
161    pub fn record_click(&mut self, variant_id: &str) {
162        if let Some(m) = self.variant_metrics.get_mut(variant_id) {
163            m.clicks += 1;
164        }
165    }
166
167    /// Record a conversion for a variant.
168    pub fn record_conversion(&mut self, variant_id: &str) {
169        if let Some(m) = self.variant_metrics.get_mut(variant_id) {
170            m.conversions += 1;
171        }
172    }
173
174    /// Record a completed view for a variant.
175    pub fn record_completion(&mut self, variant_id: &str, watch_duration_ms: u64) {
176        if let Some(m) = self.variant_metrics.get_mut(variant_id) {
177            m.completion_count += 1;
178            m.watch_duration_sum_ms += watch_duration_ms;
179        }
180    }
181
182    /// Record a watch session (non-completing) for a variant.
183    pub fn record_watch(&mut self, variant_id: &str, watch_duration_ms: u64) {
184        if let Some(m) = self.variant_metrics.get_mut(variant_id) {
185            m.watch_duration_sum_ms += watch_duration_ms;
186        }
187    }
188}
189
190// ─── Rate helpers ─────────────────────────────────────────────────────────────
191
192/// Click-through rate: `clicks / impressions` (0.0 if no impressions).
193pub fn click_through_rate(metrics: &VariantMetrics) -> f32 {
194    if metrics.impressions == 0 {
195        return 0.0;
196    }
197    metrics.clicks as f32 / metrics.impressions as f32
198}
199
200/// Conversion rate: `conversions / impressions` (0.0 if no impressions).
201pub fn conversion_rate(metrics: &VariantMetrics) -> f32 {
202    if metrics.impressions == 0 {
203        return 0.0;
204    }
205    metrics.conversions as f32 / metrics.impressions as f32
206}
207
208/// Average watch duration per impression in milliseconds.
209pub fn average_watch_duration(metrics: &VariantMetrics) -> f32 {
210    if metrics.impressions == 0 {
211        return 0.0;
212    }
213    metrics.watch_duration_sum_ms as f32 / metrics.impressions as f32
214}
215
216/// Completion rate: `completion_count / impressions`.
217pub fn completion_rate(metrics: &VariantMetrics) -> f32 {
218    if metrics.impressions == 0 {
219        return 0.0;
220    }
221    metrics.completion_count as f32 / metrics.impressions as f32
222}
223
224// ─── Statistical significance ─────────────────────────────────────────────────
225
226/// Compute the two-proportion z-score for rates `p1` (from `n1` observations)
227/// and `p2` (from `n2` observations).
228///
229/// `p1`, `p2` should be in [0, 1].  Returns `0.0` when either sample is empty
230/// or when the pooled proportion is degenerate (0 or 1).
231pub fn z_test(p1: f32, n1: u32, p2: f32, n2: u32) -> f32 {
232    if n1 == 0 || n2 == 0 {
233        return 0.0;
234    }
235
236    // Reconstruct integer counts for pooled proportion.
237    let x1 = (p1 * n1 as f32).round() as u32;
238    let x2 = (p2 * n2 as f32).round() as u32;
239
240    let p_pool = (x1 + x2) as f32 / (n1 + n2) as f32;
241
242    // If the pooled proportion is at the boundary the variance is 0 → undefined.
243    if p_pool <= 0.0 || p_pool >= 1.0 {
244        return 0.0;
245    }
246
247    let variance = p_pool * (1.0 - p_pool) * (1.0 / n1 as f32 + 1.0 / n2 as f32);
248    if variance <= 0.0 {
249        return 0.0;
250    }
251
252    (p1 - p2) / variance.sqrt()
253}
254
255/// Determine whether a z-score is statistically significant at the given
256/// significance level `alpha`.
257///
258/// Supported `alpha` values: 0.05 (critical z = 1.96) and 0.01 (z = 2.576).
259/// For all other values the function uses 1.96 as the critical value.
260pub fn is_significant(z_score: f32, alpha: f32) -> bool {
261    let critical_z = if (alpha - 0.01).abs() < 1e-6 {
262        2.576
263    } else {
264        1.96
265    };
266    z_score.abs() >= critical_z
267}
268
269// ─── Winner selection ─────────────────────────────────────────────────────────
270
271/// Supported optimisation metrics for winner selection.
272#[derive(Debug, Clone, Copy, PartialEq, Eq)]
273pub enum OptimisationMetric {
274    Ctr,
275    Conversion,
276    Completion,
277    WatchDuration,
278}
279
280/// Return the variant ID that wins on the given metric, or `None` if there are
281/// no variants with data.
282///
283/// `metric` is a string: `"ctr"`, `"conversion"`, `"completion"`, or
284/// `"watch_duration"`.  Unrecognised strings fall back to `"ctr"`.
285///
286/// Uses a hardcoded significance level of α = 0.05 (critical z = 1.96).
287/// Use [`winning_variant_with_alpha`] for a configurable significance level.
288pub fn winning_variant<'r>(results: &'r ExperimentResults, metric: &str) -> Option<&'r str> {
289    winning_variant_with_alpha(results, metric, 0.05)
290}
291
292/// Return the variant ID that wins on the given metric at the specified
293/// significance level `alpha`, or `None` if no variant has data or if the
294/// best-scoring variant does not beat the runner-up with statistical
295/// significance at `alpha`.
296///
297/// The winner is the variant with the highest score on `metric`.  A winner
298/// is only declared when the two-proportion z-test comparing the best variant
299/// against every other variant with data yields a z-score that clears the
300/// critical value `alpha_to_critical_z(alpha)`.  If the best variant does not
301/// beat all others significantly, `None` is returned (no significant winner).
302///
303/// For experiments with a single variant that has data that variant is always
304/// returned (no comparison is possible).
305///
306/// Supported `alpha` values: 0.10, 0.05, 0.01, 0.001.  Any other value
307/// maps to the closest standard level via [`alpha_to_critical_z`].
308pub fn winning_variant_with_alpha<'r>(
309    results: &'r ExperimentResults,
310    metric: &str,
311    alpha: f32,
312) -> Option<&'r str> {
313    let opt_metric = match metric {
314        "conversion" => OptimisationMetric::Conversion,
315        "completion" => OptimisationMetric::Completion,
316        "watch_duration" => OptimisationMetric::WatchDuration,
317        _ => OptimisationMetric::Ctr,
318    };
319
320    let candidates: Vec<&VariantMetrics> = results
321        .variant_metrics
322        .values()
323        .filter(|m| m.impressions > 0)
324        .collect();
325
326    if candidates.is_empty() {
327        return None;
328    }
329
330    // Single-variant shortcut — no comparison possible.
331    if candidates.len() == 1 {
332        return Some(candidates[0].variant_id.as_str());
333    }
334
335    // Find the highest-scoring variant.
336    let best = candidates.iter().copied().max_by(|a, b| {
337        let score_a = variant_score(a, opt_metric);
338        let score_b = variant_score(b, opt_metric);
339        score_a
340            .partial_cmp(&score_b)
341            .unwrap_or(std::cmp::Ordering::Equal)
342    })?;
343
344    // Check that the best variant is significantly better than all others.
345    let critical_z = alpha_to_critical_z(alpha);
346    let best_score = variant_score(best, opt_metric);
347    let best_n = best.impressions;
348
349    for other in candidates.iter().copied() {
350        if other.variant_id == best.variant_id {
351            continue;
352        }
353        let other_score = variant_score(other, opt_metric);
354        let other_n = other.impressions;
355
356        // z > 0 means `best` outperforms `other`; we require |z| >= critical_z
357        // and z in the correct direction (best_score >= other_score is already
358        // guaranteed by max_by, so we just need z >= critical_z).
359        let z = z_test(best_score, best_n, other_score, other_n);
360        if z < critical_z {
361            return None;
362        }
363    }
364
365    Some(best.variant_id.as_str())
366}
367
368/// Convert an alpha significance level to a two-tailed critical z-value.
369///
370/// Supported levels: 0.10 → 1.645, 0.05 → 1.96, 0.01 → 2.576, 0.001 → 3.291.
371/// Values outside these levels are clamped to the nearest supported level.
372pub fn alpha_to_critical_z(alpha: f32) -> f32 {
373    if alpha <= 0.001 {
374        3.291
375    } else if alpha <= 0.01 {
376        2.576
377    } else if alpha <= 0.05 {
378        1.96
379    } else {
380        1.645
381    }
382}
383
384fn variant_score(metrics: &VariantMetrics, opt: OptimisationMetric) -> f32 {
385    match opt {
386        OptimisationMetric::Ctr => click_through_rate(metrics),
387        OptimisationMetric::Conversion => conversion_rate(metrics),
388        OptimisationMetric::Completion => completion_rate(metrics),
389        OptimisationMetric::WatchDuration => average_watch_duration(metrics),
390    }
391}
392
393// ─── Bayesian A/B testing ─────────────────────────────────────────────────────
394
395/// Result of a Bayesian A/B test comparison between two variants.
396///
397/// Uses Beta-Binomial conjugate updates to estimate the probability that
398/// variant B outperforms variant A on the selected metric.
399#[derive(Debug, Clone, PartialEq)]
400pub struct BayesianAbResult {
401    /// ID of the "control" variant (variant A).
402    pub variant_a_id: String,
403    /// ID of the "treatment" variant (variant B).
404    pub variant_b_id: String,
405    /// Estimated probability that variant B has a higher true rate than A.
406    /// In [0.0, 1.0]; values > 0.95 are conventionally considered "significant".
407    pub prob_b_beats_a: f64,
408    /// Expected uplift: E\[`rate_B`\] − E\[`rate_A`\] using posterior means.
409    pub expected_uplift: f64,
410    /// Posterior mean of variant A's rate.
411    pub posterior_mean_a: f64,
412    /// Posterior mean of variant B's rate.
413    pub posterior_mean_b: f64,
414}
415
416/// Compute a Bayesian A/B test for two variants.
417///
418/// Uses Beta-Binomial conjugacy with a non-informative Jeffreys prior
419/// Beta(0.5, 0.5) for both variants.  The probability that B beats A is
420/// approximated via Monte Carlo sampling with the given `rng_seed` and
421/// `num_samples`.
422///
423/// `metric` selects which count to treat as "successes":
424/// * `"ctr"` or `"click"` — clicks / impressions
425/// * `"conversion"` — conversions / impressions
426/// * `"completion"` — completion_count / impressions
427///
428/// Returns an error if either variant has zero impressions.
429pub fn bayesian_winner(
430    results: &ExperimentResults,
431    variant_a_id: &str,
432    variant_b_id: &str,
433    metric: &str,
434    num_samples: usize,
435    rng_seed: u64,
436) -> Result<BayesianAbResult, crate::error::AnalyticsError> {
437    let ma = results.variant_metrics.get(variant_a_id).ok_or_else(|| {
438        crate::error::AnalyticsError::ConfigError(format!("variant '{variant_a_id}' not found"))
439    })?;
440    let mb = results.variant_metrics.get(variant_b_id).ok_or_else(|| {
441        crate::error::AnalyticsError::ConfigError(format!("variant '{variant_b_id}' not found"))
442    })?;
443
444    if ma.impressions == 0 || mb.impressions == 0 {
445        return Err(crate::error::AnalyticsError::InsufficientData(
446            "both variants require at least one impression for Bayesian test".to_string(),
447        ));
448    }
449
450    let (successes_a, n_a) = extract_metric_counts(ma, metric);
451    let (successes_b, n_b) = extract_metric_counts(mb, metric);
452
453    // Jeffreys prior: Beta(0.5, 0.5).
454    let alpha_a = 0.5 + successes_a as f64;
455    let beta_a = 0.5 + (n_a - successes_a) as f64;
456    let alpha_b = 0.5 + successes_b as f64;
457    let beta_b = 0.5 + (n_b - successes_b) as f64;
458
459    let posterior_mean_a = alpha_a / (alpha_a + beta_a);
460    let posterior_mean_b = alpha_b / (alpha_b + beta_b);
461
462    // Monte Carlo estimate: P(rate_B > rate_A).
463    let prob_b_beats_a =
464        monte_carlo_prob_b_beats_a(alpha_a, beta_a, alpha_b, beta_b, num_samples, rng_seed);
465
466    Ok(BayesianAbResult {
467        variant_a_id: variant_a_id.to_string(),
468        variant_b_id: variant_b_id.to_string(),
469        prob_b_beats_a,
470        expected_uplift: posterior_mean_b - posterior_mean_a,
471        posterior_mean_a,
472        posterior_mean_b,
473    })
474}
475
476/// Extract (successes, total) counts from a `VariantMetrics` for the named metric.
477fn extract_metric_counts(m: &VariantMetrics, metric: &str) -> (u32, u32) {
478    match metric {
479        "conversion" => (m.conversions, m.impressions),
480        "completion" => (m.completion_count, m.impressions),
481        _ => (m.clicks, m.impressions), // "ctr" / "click" / default
482    }
483}
484
485/// Monte Carlo estimate of P(Beta(α_B, β_B) > Beta(α_A, β_A)).
486///
487/// Uses an xoshiro256** PRNG seeded from `rng_seed` and draws `num_samples`
488/// pairs of Beta samples.
489fn monte_carlo_prob_b_beats_a(
490    alpha_a: f64,
491    beta_a: f64,
492    alpha_b: f64,
493    beta_b: f64,
494    num_samples: usize,
495    seed: u64,
496) -> f64 {
497    if num_samples == 0 {
498        return 0.5;
499    }
500    let mut rng = Xoshiro256 {
501        state: [
502            seed.wrapping_add(0x9e37_79b9_7f4a_7c15),
503            seed.wrapping_mul(6_364_136_223_846_793_005)
504                .wrapping_add(1_442_695_040_888_963_407),
505            seed ^ 0xdead_beef_cafe_babe,
506            seed.rotate_left(17).wrapping_add(0x0123_4567_89ab_cdef),
507        ],
508    };
509
510    let mut b_wins = 0u64;
511    for _ in 0..num_samples {
512        let sa = sample_beta(&mut rng, alpha_a, beta_a);
513        let sb = sample_beta(&mut rng, alpha_b, beta_b);
514        if sb > sa {
515            b_wins += 1;
516        }
517    }
518    b_wins as f64 / num_samples as f64
519}
520
521/// Minimal xoshiro256** state for Bayesian sampling.
522struct Xoshiro256 {
523    state: [u64; 4],
524}
525
526impl Xoshiro256 {
527    fn next_u64(&mut self) -> u64 {
528        let [s0, s1, s2, s3] = self.state;
529        let result = s1.wrapping_mul(5).rotate_left(7).wrapping_mul(9);
530        let t = s1 << 17;
531        self.state[2] ^= s0;
532        self.state[3] ^= s1;
533        self.state[1] ^= s2;
534        self.state[0] ^= s3;
535        self.state[2] ^= t;
536        self.state[3] = s3.rotate_left(45);
537        result
538    }
539
540    fn next_f64(&mut self) -> f64 {
541        (self.next_u64() >> 11) as f64 * (1.0 / (1u64 << 53) as f64)
542    }
543}
544
545/// Sample one value from N(0,1) using the Box-Muller transform.
546///
547/// Consumes two uniform samples from `rng` and returns one standard-normal variate.
548fn sample_normal(rng: &mut Xoshiro256) -> f64 {
549    // Box-Muller: use a small epsilon to avoid log(0).
550    let u1 = rng.next_f64().max(f64::MIN_POSITIVE);
551    let u2 = rng.next_f64();
552    let r = (-2.0 * u1.ln()).sqrt();
553    let theta = std::f64::consts::TAU * u2;
554    r * theta.cos()
555}
556
557/// Sample one value from Gamma(shape, 1) using the Marsaglia-Tsang (2000) method.
558///
559/// Valid for all `shape` > 0.  For `shape` < 1 the boost trick
560/// `Gamma(shape+1, 1) * U^(1/shape)` is applied.
561fn sample_gamma(rng: &mut Xoshiro256, shape: f64) -> f64 {
562    debug_assert!(shape > 0.0, "Gamma shape must be positive");
563
564    if shape < 1.0 {
565        // Boost: Gamma(shape) = Gamma(shape+1) * U^(1/shape).
566        let g = sample_gamma(rng, shape + 1.0);
567        let u = rng.next_f64().max(f64::MIN_POSITIVE);
568        return g * u.powf(1.0 / shape);
569    }
570
571    // Marsaglia-Tsang squeeze-and-accept for shape >= 1.
572    let d = shape - 1.0 / 3.0;
573    let c = 1.0 / (9.0 * d).sqrt();
574    loop {
575        let x = sample_normal(rng);
576        let vc = 1.0 + c * x;
577        if vc <= 0.0 {
578            continue;
579        }
580        let v = vc * vc * vc;
581        let u = rng.next_f64().max(f64::MIN_POSITIVE);
582        // Squeeze test (avoids log most of the time).
583        let x2 = x * x;
584        if u < 1.0 - 0.0331 * x2 * x2 {
585            return d * v;
586        }
587        // Full acceptance test.
588        if u.ln() < 0.5 * x2 + d * (1.0 - v + v.ln()) {
589            return d * v;
590        }
591    }
592}
593
594/// Sample one value from Beta(alpha, beta) using the ratio-of-Gamma-samples method.
595///
596/// `Beta(α, β) = X / (X + Y)` where `X ~ Gamma(α, 1)` and `Y ~ Gamma(β, 1)`.
597/// This is numerically stable and correct for all positive α, β.
598fn sample_beta(rng: &mut Xoshiro256, alpha: f64, beta: f64) -> f64 {
599    let x = sample_gamma(rng, alpha);
600    let y = sample_gamma(rng, beta);
601    let s = x + y;
602    if s <= 0.0 {
603        // Degenerate: fall back to posterior mean.
604        return alpha / (alpha + beta);
605    }
606    x / s
607}
608
609// ─── Tests ────────────────────────────────────────────────────────────────────
610
611#[cfg(test)]
612mod tests {
613    use super::*;
614
615    fn two_variant_experiment() -> Experiment {
616        Experiment {
617            id: "exp1".to_string(),
618            name: "Thumbnail Test".to_string(),
619            variants: vec![
620                Variant {
621                    id: "A".to_string(),
622                    name: "Control".to_string(),
623                    allocation_weight: 1.0,
624                },
625                Variant {
626                    id: "B".to_string(),
627                    name: "Treatment".to_string(),
628                    allocation_weight: 1.0,
629                },
630            ],
631            start_ms: 0,
632            end_ms: None,
633            min_sample_size: 100,
634        }
635    }
636
637    // ── assign_variant ───────────────────────────────────────────────────────
638
639    #[test]
640    fn assign_variant_deterministic_same_user() {
641        let exp = two_variant_experiment();
642        let v1 = assign_variant(&exp, "user_42", AssignmentMethod::Deterministic)
643            .expect("assign variant should succeed");
644        let v2 = assign_variant(&exp, "user_42", AssignmentMethod::Deterministic)
645            .expect("assign variant should succeed");
646        assert_eq!(v1.id, v2.id);
647    }
648
649    #[test]
650    fn assign_variant_different_users_may_differ() {
651        let exp = two_variant_experiment();
652        let ids: Vec<_> = (0..100)
653            .map(|i| {
654                assign_variant(&exp, &format!("user_{i}"), AssignmentMethod::Deterministic)
655                    .expect("value should be present should succeed")
656                    .id
657                    .clone()
658            })
659            .collect();
660        let has_a = ids.iter().any(|id| id == "A");
661        let has_b = ids.iter().any(|id| id == "B");
662        assert!(has_a, "expected some users in variant A");
663        assert!(has_b, "expected some users in variant B");
664    }
665
666    #[test]
667    fn assign_variant_no_variants_returns_error() {
668        let exp = Experiment {
669            id: "empty".to_string(),
670            name: "Empty".to_string(),
671            variants: vec![],
672            start_ms: 0,
673            end_ms: None,
674            min_sample_size: 10,
675        };
676        let result = assign_variant(&exp, "u1", AssignmentMethod::Deterministic);
677        assert!(result.is_err());
678    }
679
680    #[test]
681    fn assign_variant_zero_weight_returns_error() {
682        let exp = Experiment {
683            id: "zero".to_string(),
684            name: "Zero".to_string(),
685            variants: vec![Variant {
686                id: "A".to_string(),
687                name: "A".to_string(),
688                allocation_weight: 0.0,
689            }],
690            start_ms: 0,
691            end_ms: None,
692            min_sample_size: 10,
693        };
694        let result = assign_variant(&exp, "u1", AssignmentMethod::Deterministic);
695        assert!(result.is_err());
696    }
697
698    #[test]
699    fn assign_variant_single_variant() {
700        let exp = Experiment {
701            id: "single".to_string(),
702            name: "Single".to_string(),
703            variants: vec![Variant {
704                id: "only".to_string(),
705                name: "Only".to_string(),
706                allocation_weight: 1.0,
707            }],
708            start_ms: 0,
709            end_ms: None,
710            min_sample_size: 10,
711        };
712        let v = assign_variant(&exp, "u1", AssignmentMethod::Deterministic)
713            .expect("assign variant should succeed");
714        assert_eq!(v.id, "only");
715    }
716
717    #[test]
718    fn assign_variant_weighted_distribution() {
719        // 90 % to A, 10 % to B — over 1000 users B should get ~100 (±50 for tolerance).
720        let exp = Experiment {
721            id: "weighted".to_string(),
722            name: "Weighted".to_string(),
723            variants: vec![
724                Variant {
725                    id: "A".to_string(),
726                    name: "A".to_string(),
727                    allocation_weight: 9.0,
728                },
729                Variant {
730                    id: "B".to_string(),
731                    name: "B".to_string(),
732                    allocation_weight: 1.0,
733                },
734            ],
735            start_ms: 0,
736            end_ms: None,
737            min_sample_size: 100,
738        };
739        let b_count = (0..1000)
740            .filter(|i| {
741                assign_variant(&exp, &format!("u{i}"), AssignmentMethod::Deterministic)
742                    .expect("value should be present should succeed")
743                    .id
744                    == "B"
745            })
746            .count();
747        assert!(b_count < 250, "too many in B: {b_count}");
748    }
749
750    // ── Rate calculations ────────────────────────────────────────────────────
751
752    #[test]
753    fn click_through_rate_basic() {
754        let m = VariantMetrics {
755            variant_id: "A".to_string(),
756            impressions: 100,
757            clicks: 5,
758            ..Default::default()
759        };
760        assert!((click_through_rate(&m) - 0.05).abs() < 1e-6);
761    }
762
763    #[test]
764    fn click_through_rate_zero_impressions() {
765        let m = VariantMetrics::new("A");
766        assert_eq!(click_through_rate(&m), 0.0);
767    }
768
769    #[test]
770    fn conversion_rate_basic() {
771        let m = VariantMetrics {
772            variant_id: "B".to_string(),
773            impressions: 200,
774            conversions: 10,
775            ..Default::default()
776        };
777        assert!((conversion_rate(&m) - 0.05).abs() < 1e-6);
778    }
779
780    #[test]
781    fn average_watch_duration_basic() {
782        let m = VariantMetrics {
783            variant_id: "A".to_string(),
784            impressions: 4,
785            watch_duration_sum_ms: 40_000,
786            ..Default::default()
787        };
788        assert!((average_watch_duration(&m) - 10_000.0).abs() < 1e-3);
789    }
790
791    #[test]
792    fn completion_rate_basic() {
793        let m = VariantMetrics {
794            variant_id: "A".to_string(),
795            impressions: 10,
796            completion_count: 3,
797            ..Default::default()
798        };
799        assert!((completion_rate(&m) - 0.3).abs() < 1e-6);
800    }
801
802    // ── z_test ───────────────────────────────────────────────────────────────
803
804    #[test]
805    fn z_test_no_difference() {
806        let z = z_test(0.05, 1000, 0.05, 1000);
807        assert!(z.abs() < 1e-3, "z={z}");
808    }
809
810    #[test]
811    fn z_test_large_difference_significant() {
812        // p1=0.10, p2=0.05 with n=5000 each should be very significant.
813        let z = z_test(0.10, 5000, 0.05, 5000);
814        assert!(z > 1.96, "z={z}");
815    }
816
817    #[test]
818    fn z_test_zero_sample_returns_zero() {
819        assert_eq!(z_test(0.05, 0, 0.05, 100), 0.0);
820        assert_eq!(z_test(0.05, 100, 0.05, 0), 0.0);
821    }
822
823    #[test]
824    fn is_significant_alpha_05() {
825        assert!(is_significant(2.0, 0.05));
826        assert!(!is_significant(1.5, 0.05));
827    }
828
829    #[test]
830    fn is_significant_alpha_01() {
831        assert!(is_significant(2.6, 0.01));
832        assert!(!is_significant(2.0, 0.01));
833    }
834
835    // ── winning_variant ──────────────────────────────────────────────────────
836
837    #[test]
838    fn winning_variant_by_ctr() {
839        // Use n=500 each so z≈3.0 (significant at α=0.05 critical_z=1.96).
840        // B: 50/500 = 10% CTR, A: 25/500 = 5% CTR.
841        let exp = two_variant_experiment();
842        let mut results = ExperimentResults::new(exp);
843        results.variant_metrics.insert(
844            "A".to_string(),
845            VariantMetrics {
846                variant_id: "A".to_string(),
847                impressions: 500,
848                clicks: 25,
849                ..Default::default()
850            },
851        );
852        results.variant_metrics.insert(
853            "B".to_string(),
854            VariantMetrics {
855                variant_id: "B".to_string(),
856                impressions: 500,
857                clicks: 50,
858                ..Default::default()
859            },
860        );
861        let winner = winning_variant(&results, "ctr");
862        assert_eq!(winner, Some("B"));
863    }
864
865    #[test]
866    fn winning_variant_no_impressions_returns_none() {
867        let exp = two_variant_experiment();
868        let results = ExperimentResults::new(exp);
869        let winner = winning_variant(&results, "ctr");
870        assert!(winner.is_none());
871    }
872
873    #[test]
874    fn winning_variant_by_completion() {
875        let exp = two_variant_experiment();
876        let mut results = ExperimentResults::new(exp);
877        results.variant_metrics.insert(
878            "A".to_string(),
879            VariantMetrics {
880                variant_id: "A".to_string(),
881                impressions: 100,
882                completion_count: 30,
883                ..Default::default()
884            },
885        );
886        results.variant_metrics.insert(
887            "B".to_string(),
888            VariantMetrics {
889                variant_id: "B".to_string(),
890                impressions: 100,
891                completion_count: 50,
892                ..Default::default()
893            },
894        );
895        let winner = winning_variant(&results, "completion");
896        assert_eq!(winner, Some("B"));
897    }
898
899    #[test]
900    fn experiment_results_record_methods() {
901        let exp = two_variant_experiment();
902        let mut results = ExperimentResults::new(exp);
903        results.record_impression("A");
904        results.record_impression("A");
905        results.record_click("A");
906        results.record_conversion("A");
907        results.record_completion("A", 5000);
908        let m = &results.variant_metrics["A"];
909        assert_eq!(m.impressions, 2);
910        assert_eq!(m.clicks, 1);
911        assert_eq!(m.conversions, 1);
912        assert_eq!(m.completion_count, 1);
913        assert_eq!(m.watch_duration_sum_ms, 5000);
914    }
915
916    // ── winning_variant_with_alpha ────────────────────────────────────────────
917
918    #[test]
919    fn winning_variant_with_alpha_same_result_as_default() {
920        // Use n=500 so z≈3.0 (significant at α=0.05).  Both `winning_variant`
921        // (which uses α=0.05) and `winning_variant_with_alpha(..., 0.05)` must
922        // agree and declare B the winner.
923        let exp = two_variant_experiment();
924        let mut results = ExperimentResults::new(exp);
925        results.variant_metrics.insert(
926            "A".to_string(),
927            VariantMetrics {
928                variant_id: "A".to_string(),
929                impressions: 500,
930                clicks: 25,
931                ..Default::default()
932            },
933        );
934        results.variant_metrics.insert(
935            "B".to_string(),
936            VariantMetrics {
937                variant_id: "B".to_string(),
938                impressions: 500,
939                clicks: 50,
940                ..Default::default()
941            },
942        );
943        let w_default = winning_variant(&results, "ctr");
944        let w_alpha = winning_variant_with_alpha(&results, "ctr", 0.05);
945        assert_eq!(w_default, w_alpha);
946        assert_eq!(w_default, Some("B"));
947    }
948
949    #[test]
950    fn winning_variant_with_alpha_01() {
951        let exp = two_variant_experiment();
952        let mut results = ExperimentResults::new(exp);
953        results.variant_metrics.insert(
954            "A".to_string(),
955            VariantMetrics {
956                variant_id: "A".to_string(),
957                impressions: 1000,
958                conversions: 50,
959                ..Default::default()
960            },
961        );
962        results.variant_metrics.insert(
963            "B".to_string(),
964            VariantMetrics {
965                variant_id: "B".to_string(),
966                impressions: 1000,
967                conversions: 80,
968                ..Default::default()
969            },
970        );
971        // B should win at both common alpha levels.
972        assert_eq!(
973            winning_variant_with_alpha(&results, "conversion", 0.05),
974            Some("B")
975        );
976        assert_eq!(
977            winning_variant_with_alpha(&results, "conversion", 0.01),
978            Some("B")
979        );
980    }
981
982    #[test]
983    fn winning_variant_with_alpha_no_impressions() {
984        let exp = two_variant_experiment();
985        let results = ExperimentResults::new(exp);
986        assert!(winning_variant_with_alpha(&results, "ctr", 0.05).is_none());
987    }
988
989    // ── alpha_to_critical_z ──────────────────────────────────────────────────
990
991    #[test]
992    fn alpha_to_critical_z_values() {
993        assert!((alpha_to_critical_z(0.05) - 1.96).abs() < 0.01);
994        assert!((alpha_to_critical_z(0.01) - 2.576).abs() < 0.01);
995        assert!((alpha_to_critical_z(0.10) - 1.645).abs() < 0.01);
996        assert!((alpha_to_critical_z(0.001) - 3.291).abs() < 0.01);
997    }
998
999    // ── bayesian_winner ──────────────────────────────────────────────────────
1000
1001    #[test]
1002    fn bayesian_winner_b_clearly_beats_a() {
1003        let exp = two_variant_experiment();
1004        let mut results = ExperimentResults::new(exp);
1005        // A: 5 clicks / 100, B: 30 clicks / 100 — B should dominate.
1006        results.variant_metrics.insert(
1007            "A".to_string(),
1008            VariantMetrics {
1009                variant_id: "A".to_string(),
1010                impressions: 100,
1011                clicks: 5,
1012                ..Default::default()
1013            },
1014        );
1015        results.variant_metrics.insert(
1016            "B".to_string(),
1017            VariantMetrics {
1018                variant_id: "B".to_string(),
1019                impressions: 100,
1020                clicks: 30,
1021                ..Default::default()
1022            },
1023        );
1024        let res = bayesian_winner(&results, "A", "B", "ctr", 10_000, 42)
1025            .expect("bayesian winner should succeed");
1026        assert!(
1027            res.prob_b_beats_a > 0.95,
1028            "expected high prob that B beats A, got {}",
1029            res.prob_b_beats_a
1030        );
1031        assert!(res.expected_uplift > 0.0, "uplift should be positive");
1032    }
1033
1034    #[test]
1035    fn bayesian_winner_equal_variants_around_50pct() {
1036        let exp = two_variant_experiment();
1037        let mut results = ExperimentResults::new(exp);
1038        // Identical click rates — prob should be near 0.5.
1039        results.variant_metrics.insert(
1040            "A".to_string(),
1041            VariantMetrics {
1042                variant_id: "A".to_string(),
1043                impressions: 1000,
1044                clicks: 100,
1045                ..Default::default()
1046            },
1047        );
1048        results.variant_metrics.insert(
1049            "B".to_string(),
1050            VariantMetrics {
1051                variant_id: "B".to_string(),
1052                impressions: 1000,
1053                clicks: 100,
1054                ..Default::default()
1055            },
1056        );
1057        let res = bayesian_winner(&results, "A", "B", "ctr", 20_000, 99)
1058            .expect("bayesian winner should succeed");
1059        assert!(
1060            (res.prob_b_beats_a - 0.5).abs() < 0.05,
1061            "equal variants should give ~50% prob, got {}",
1062            res.prob_b_beats_a
1063        );
1064    }
1065
1066    #[test]
1067    fn bayesian_winner_missing_variant_returns_error() {
1068        let exp = two_variant_experiment();
1069        let results = ExperimentResults::new(exp);
1070        let err = bayesian_winner(&results, "A", "nonexistent", "ctr", 100, 0);
1071        assert!(err.is_err());
1072    }
1073
1074    #[test]
1075    fn bayesian_winner_zero_impressions_returns_error() {
1076        let exp = two_variant_experiment();
1077        let results = ExperimentResults::new(exp);
1078        // A and B are initialised with 0 impressions.
1079        let err = bayesian_winner(&results, "A", "B", "ctr", 100, 0);
1080        assert!(err.is_err());
1081    }
1082
1083    #[test]
1084    fn bayesian_winner_conversion_metric() {
1085        let exp = two_variant_experiment();
1086        let mut results = ExperimentResults::new(exp);
1087        results.variant_metrics.insert(
1088            "A".to_string(),
1089            VariantMetrics {
1090                variant_id: "A".to_string(),
1091                impressions: 200,
1092                conversions: 10,
1093                ..Default::default()
1094            },
1095        );
1096        results.variant_metrics.insert(
1097            "B".to_string(),
1098            VariantMetrics {
1099                variant_id: "B".to_string(),
1100                impressions: 200,
1101                conversions: 50,
1102                ..Default::default()
1103            },
1104        );
1105        let res = bayesian_winner(&results, "A", "B", "conversion", 10_000, 7)
1106            .expect("bayesian winner should succeed");
1107        assert!(res.prob_b_beats_a > 0.95);
1108        assert!((res.posterior_mean_b - 0.25).abs() < 0.05);
1109    }
1110}