kaizen/experiment/stats/
power.rs1const Z_ALPHA: f64 = 1.96;
9const Z_BETA: f64 = 0.84;
11
12#[derive(Debug)]
13pub struct PowerResult {
14 pub mde_absolute: f64,
16 pub mde_pct: Option<f64>,
18 pub sigma: f64,
20 pub n_per_arm: usize,
22}
23
24pub fn mde(values: &[f64], n_per_arm: usize) -> Option<PowerResult> {
29 if values.is_empty() || n_per_arm == 0 {
30 return None;
31 }
32 let sigma = std_dev(values);
33 let baseline_mean = values.iter().sum::<f64>() / values.len() as f64;
34 let mde_absolute = (Z_ALPHA + Z_BETA) * sigma * (2.0_f64 / n_per_arm as f64).sqrt();
35 let mde_pct = if baseline_mean.abs() > f64::EPSILON {
36 Some(100.0 * mde_absolute / baseline_mean.abs())
37 } else {
38 None
39 };
40 Some(PowerResult {
41 mde_absolute,
42 mde_pct,
43 sigma,
44 n_per_arm,
45 })
46}
47
48fn std_dev(xs: &[f64]) -> f64 {
49 if xs.len() < 2 {
50 return 0.0;
51 }
52 let mean = xs.iter().sum::<f64>() / xs.len() as f64;
53 let var = xs.iter().map(|v| (v - mean).powi(2)).sum::<f64>() / (xs.len() - 1) as f64;
54 var.sqrt()
55}
56
57#[cfg(test)]
58mod tests {
59 use super::*;
60
61 #[test]
62 fn mde_shrinks_with_larger_n() {
63 let values: Vec<f64> = (0..100).map(|i| i as f64).collect();
64 let r50 = mde(&values, 50).unwrap();
65 let r200 = mde(&values, 200).unwrap();
66 assert!(
67 r200.mde_absolute < r50.mde_absolute,
68 "larger n → smaller MDE"
69 );
70 }
71
72 #[test]
73 fn mde_none_on_empty() {
74 assert!(mde(&[], 100).is_none());
75 }
76
77 #[test]
78 fn mde_none_on_zero_n() {
79 assert!(mde(&[1.0, 2.0], 0).is_none());
80 }
81}