qualia_core_db/solvers/learning/experiment/
ab_test.rs1use crate::solvers::statistics::distributions::normal;
7
8#[derive(Debug, Clone, Copy, PartialEq)]
10pub struct AbResult {
11 pub rate_a: f64,
12 pub rate_b: f64,
13 pub difference: f64,
15 pub z_statistic: f64,
16 pub p_value: f64,
18 pub ci: (f64, f64),
20 pub confidence: f64,
21}
22
23pub fn ab_test(conv_a: u64, n_a: u64, conv_b: u64, n_b: u64, alpha: f64) -> Option<AbResult> {
26 if n_a == 0
27 || n_b == 0
28 || conv_a > n_a
29 || conv_b > n_b
30 || !(0.0..1.0).contains(&alpha)
31 || alpha <= 0.0
32 {
33 return None;
34 }
35 let (na, nb) = (n_a as f64, n_b as f64);
36 let pa = conv_a as f64 / na;
37 let pb = conv_b as f64 / nb;
38 let diff = pb - pa;
39
40 let pooled = (conv_a + conv_b) as f64 / (na + nb);
42 let se_pooled = (pooled * (1.0 - pooled) * (1.0 / na + 1.0 / nb)).sqrt();
43 let z = if se_pooled > 0.0 {
44 diff / se_pooled
45 } else {
46 0.0
47 };
48 let p_value = 2.0 * (1.0 - normal::standard_cdf(z.abs()));
49
50 let se_diff = (pa * (1.0 - pa) / na + pb * (1.0 - pb) / nb).sqrt();
52 let zc = normal::standard_quantile(1.0 - alpha / 2.0);
53 let margin = zc * se_diff;
54
55 Some(AbResult {
56 rate_a: pa,
57 rate_b: pb,
58 difference: diff,
59 z_statistic: z,
60 p_value,
61 ci: (diff - margin, diff + margin),
62 confidence: 1.0 - alpha,
63 })
64}
65
66#[cfg(test)]
67mod tests {
68 use super::*;
69
70 #[test]
71 fn detects_a_real_lift() {
72 let r = ab_test(1000, 10_000, 1200, 10_000, 0.05).unwrap();
74 assert!((r.rate_a - 0.10).abs() < 1e-9 && (r.rate_b - 0.12).abs() < 1e-9);
75 assert!(r.difference > 0.0);
76 assert!(r.p_value < 0.01, "p={}", r.p_value);
77 assert!(r.ci.0 > 0.0, "CI lower {} should exclude 0", r.ci.0);
78 }
79
80 #[test]
81 fn no_real_difference_is_not_significant() {
82 let r = ab_test(500, 5000, 505, 5000, 0.05).unwrap();
83 assert!(r.p_value > 0.2, "p={}", r.p_value);
84 assert!(r.ci.0 < 0.0 && r.ci.1 > 0.0, "CI should straddle 0");
85 }
86
87 #[test]
88 fn small_sample_is_underpowered() {
89 let r = ab_test(10, 100, 12, 100, 0.05).unwrap();
91 assert!(r.p_value > 0.05);
92 }
93
94 #[test]
95 fn guards() {
96 assert_eq!(ab_test(10, 0, 5, 100, 0.05), None);
97 assert_eq!(ab_test(150, 100, 5, 100, 0.05), None); }
99}