Skip to main content

kaizen/experiment/stats/
power.rs

1// SPDX-License-Identifier: AGPL-3.0-or-later
2//! Minimum Detectable Effect (MDE) at 80% power / 95% CI.
3//!
4//! Analytical formula for a two-sample test assuming normal approximation
5//! (reasonable for n ≥ 30). Use as a sizing guide, not a guarantee.
6
7/// z-score for α = 0.05 (two-tailed).
8const Z_ALPHA: f64 = 1.96;
9/// z-score for 80% power.
10const Z_BETA: f64 = 0.84;
11
12#[derive(Debug)]
13pub struct PowerResult {
14    /// Smallest detectable absolute effect at 80% power.
15    pub mde_absolute: f64,
16    /// MDE as a percentage of the baseline mean.
17    pub mde_pct: Option<f64>,
18    /// Estimated standard deviation of the metric.
19    pub sigma: f64,
20    /// Sample size per arm used in the calculation.
21    pub n_per_arm: usize,
22}
23
24/// Compute MDE given `n_per_arm` and observed metric values.
25///
26/// Uses `values` to estimate σ. Returns `None` when `values` is empty or
27/// `n_per_arm` is zero.
28pub 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}