1use std::collections::HashMap;
7
8use crate::error::AnalyticsError;
9
10#[derive(Debug, Clone, PartialEq)]
14pub struct Variant {
15 pub id: String,
16 pub name: String,
17 pub allocation_weight: f32,
19}
20
21#[derive(Debug, Clone)]
23pub struct Experiment {
24 pub id: String,
25 pub name: String,
26 pub variants: Vec<Variant>,
27 pub start_ms: i64,
29 pub end_ms: Option<i64>,
31 pub min_sample_size: u32,
33}
34
35impl Experiment {
36 fn weight_sum(&self) -> f32 {
38 self.variants.iter().map(|v| v.allocation_weight).sum()
39 }
40}
41
42#[derive(Debug, Clone, Copy, PartialEq, Eq)]
44pub enum AssignmentMethod {
45 Deterministic,
48 Random,
51}
52
53fn 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
65pub 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 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 experiment
105 .variants
106 .last()
107 .ok_or_else(|| AnalyticsError::NoVariants(experiment.id.clone()))
108}
109
110#[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 pub watch_duration_sum_ms: u64,
121 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#[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 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 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 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 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 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
190pub 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
200pub 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
208pub 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
216pub 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
224pub 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 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 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
255pub 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
273pub enum OptimisationMetric {
274 Ctr,
275 Conversion,
276 Completion,
277 WatchDuration,
278}
279
280pub fn winning_variant<'r>(results: &'r ExperimentResults, metric: &str) -> Option<&'r str> {
289 winning_variant_with_alpha(results, metric, 0.05)
290}
291
292pub 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 if candidates.len() == 1 {
332 return Some(candidates[0].variant_id.as_str());
333 }
334
335 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 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 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
368pub 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#[derive(Debug, Clone, PartialEq)]
400pub struct BayesianAbResult {
401 pub variant_a_id: String,
403 pub variant_b_id: String,
405 pub prob_b_beats_a: f64,
408 pub expected_uplift: f64,
410 pub posterior_mean_a: f64,
412 pub posterior_mean_b: f64,
414}
415
416pub 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 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 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
476fn 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), }
483}
484
485fn 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
521struct 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
545fn sample_normal(rng: &mut Xoshiro256) -> f64 {
549 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
557fn 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 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 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 let x2 = x * x;
584 if u < 1.0 - 0.0331 * x2 * x2 {
585 return d * v;
586 }
587 if u.ln() < 0.5 * x2 + d * (1.0 - v + v.ln()) {
589 return d * v;
590 }
591 }
592}
593
594fn 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 return alpha / (alpha + beta);
605 }
606 x / s
607}
608
609#[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 #[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 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 #[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 #[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 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 #[test]
838 fn winning_variant_by_ctr() {
839 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 #[test]
919 fn winning_variant_with_alpha_same_result_as_default() {
920 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 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 #[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 #[test]
1002 fn bayesian_winner_b_clearly_beats_a() {
1003 let exp = two_variant_experiment();
1004 let mut results = ExperimentResults::new(exp);
1005 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 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 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}