Skip to main content

tacet_core/analysis/
mde.rs

1//! Minimum Detectable Effect (MDE) estimation (spec §3.3).
2//!
3//! The MDE answers: "given the noise level in this measurement, what's the
4//! smallest effect I could reliably detect?"
5//!
6//! This is critical for interpreting negative results. If MDE is 50ns and you're
7//! concerned about 10ns effects, a passing test doesn't mean the code is safe—
8//! it means the measurement wasn't sensitive enough.
9//!
10//! Formula with 50% power (spec §3.3):
11//! ```text
12//! MDE = z_{1-α/2} × max_k sqrt(Σ₀[k,k])
13//! ```
14//! where z_{1-α/2} is the (1-α/2) percentile of the standard normal.
15
16extern crate alloc;
17
18use crate::math;
19use crate::result::MinDetectableEffect;
20use crate::types::Matrix9;
21
22/// Result from MDE estimation.
23#[derive(Debug, Clone)]
24pub struct MdeEstimate {
25    /// Minimum detectable effect in nanoseconds.
26    pub mde_ns: f64,
27    /// Number of simulations used (0 for analytical method).
28    pub n_simulations: usize,
29}
30
31impl From<MdeEstimate> for MinDetectableEffect {
32    fn from(mde: MdeEstimate) -> Self {
33        MinDetectableEffect { mde_ns: mde.mde_ns }
34    }
35}
36
37/// Compute MDE analytically (spec §3.3).
38///
39/// For max|δ| detection, MDE is determined by the largest marginal variance:
40/// ```text
41/// MDE = z_{1-α/2} × max_k sqrt(Σ₀[k,k])
42/// ```
43///
44/// # Arguments
45///
46/// * `covariance` - Pooled covariance matrix of quantile differences (Σ₀)
47/// * `alpha` - Significance level (e.g., 0.05 for 95% confidence)
48///
49/// # Returns
50///
51/// The minimum detectable effect in nanoseconds.
52pub fn analytical_mde(covariance: &Matrix9, alpha: f64) -> f64 {
53    // Find maximum marginal variance
54    let max_var = (0..9).map(|i| covariance[(i, i)]).fold(0.0_f64, f64::max);
55
56    // MDE with 50% power: z_{1-α/2} × sqrt(max variance)
57    let z = probit(1.0 - alpha / 2.0);
58    z * math::sqrt(max_var)
59}
60
61/// Inverse normal CDF (probit function).
62///
63/// Computes Φ⁻¹(p) using the Abramowitz & Stegun approximation (26.2.23).
64/// Accurate to ~4.5×10⁻⁴ for p ∈ (0, 1).
65fn probit(p: f64) -> f64 {
66    if p <= 0.0 {
67        return f64::NEG_INFINITY;
68    }
69    if p >= 1.0 {
70        return f64::INFINITY;
71    }
72
73    // Use symmetry: for p < 0.5, compute -probit(1-p)
74    let (sign, q) = if p < 0.5 { (-1.0, 1.0 - p) } else { (1.0, p) };
75
76    // Rational approximation constants (Abramowitz & Stegun 26.2.23)
77    const C0: f64 = 2.515517;
78    const C1: f64 = 0.802853;
79    const C2: f64 = 0.010328;
80    const D1: f64 = 1.432788;
81    const D2: f64 = 0.189269;
82    const D3: f64 = 0.001308;
83
84    let t = math::sqrt(-2.0 * math::ln(1.0 - q));
85    let z = t - (C0 + C1 * t + C2 * t * t) / (1.0 + D1 * t + D2 * t * t + D3 * t * t * t);
86
87    sign * z
88}
89
90/// Estimate the minimum detectable effect (spec §3.3).
91///
92/// # Arguments
93///
94/// * `covariance` - Pooled covariance matrix of quantile differences (Σ₀)
95/// * `alpha` - Significance level (e.g., 0.01 for 99% confidence)
96///
97/// # Returns
98///
99/// An `MdeEstimate` with MDE in nanoseconds.
100pub fn estimate_mde(covariance: &Matrix9, alpha: f64) -> MdeEstimate {
101    let mde_ns = analytical_mde(covariance, alpha);
102
103    MdeEstimate {
104        mde_ns,
105        n_simulations: 0, // Analytical method doesn't use simulations
106    }
107}
108
109/// Safe Cholesky decomposition with adaptive jitter for near-singular matrices.
110///
111/// Uses the same regularization strategy as covariance estimation.
112#[allow(dead_code)]
113fn safe_cholesky(matrix: &Matrix9) -> nalgebra::Cholesky<f64, nalgebra::Const<9>> {
114    // Try decomposition first
115    if let Some(chol) = nalgebra::Cholesky::new(*matrix) {
116        return chol;
117    }
118
119    // Add adaptive jitter for near-singular matrices
120    let trace = matrix.trace();
121    let base_jitter = 1e-10;
122    let adaptive_jitter = (trace / 9.0) * 1e-8;
123    let jitter = base_jitter + adaptive_jitter;
124
125    let mut regularized = *matrix;
126    for i in 0..9 {
127        regularized[(i, i)] += jitter;
128    }
129
130    nalgebra::Cholesky::new(regularized).expect("Cholesky failed even after regularization")
131}
132
133#[cfg(test)]
134mod tests {
135    use super::*;
136
137    #[test]
138    fn test_mde_positive() {
139        // With identity covariance, MDE should be positive
140        let cov = Matrix9::identity();
141        let mde = estimate_mde(&cov, 0.05);
142
143        assert!(mde.mde_ns > 0.0, "MDE should be positive");
144    }
145
146    #[test]
147    fn test_probit_accuracy() {
148        // Test against known values
149        assert!((probit(0.5) - 0.0).abs() < 1e-3, "probit(0.5) should be 0");
150        assert!(
151            (probit(0.975) - 1.96).abs() < 1e-2,
152            "probit(0.975) should be ~1.96"
153        );
154        assert!(
155            (probit(0.995) - 2.576).abs() < 1e-2,
156            "probit(0.995) should be ~2.576"
157        );
158        assert!(
159            (probit(0.025) + 1.96).abs() < 1e-2,
160            "probit(0.025) should be ~-1.96"
161        );
162    }
163
164    #[test]
165    fn test_analytical_mde_sanity_check() {
166        // For i.i.d. quantiles with Σ₀ = σ² I (σ = 1) and α = 0.05:
167        // - z_{0.975} ≈ 1.96
168        // - max variance = 1.0
169        // - MDE = 1.96 * sqrt(1.0) = 1.96
170
171        let cov = Matrix9::identity();
172        let mde = analytical_mde(&cov, 0.05);
173
174        let expected = 1.96;
175        assert!(
176            (mde - expected).abs() < 0.05,
177            "MDE should be ~{:.3}, got {:.3}",
178            expected,
179            mde
180        );
181    }
182
183    #[test]
184    fn test_analytical_mde_alpha_scaling() {
185        // MDE should increase with stricter alpha (smaller α → larger z → larger MDE)
186        let cov = Matrix9::identity();
187        let mde_05 = analytical_mde(&cov, 0.05); // z ≈ 1.96
188        let mde_01 = analytical_mde(&cov, 0.01); // z ≈ 2.58
189
190        assert!(
191            mde_01 > mde_05,
192            "MDE at α=0.01 ({:.3}) should be larger than α=0.05 ({:.3})",
193            mde_01,
194            mde_05
195        );
196    }
197
198    #[test]
199    fn test_analytical_mde_diagonal_covariance() {
200        // Diagonal covariance with varying variances
201        let mut cov = Matrix9::zeros();
202        for i in 0..9 {
203            cov[(i, i)] = (i + 1) as f64;
204        }
205
206        let mde = analytical_mde(&cov, 0.05);
207
208        // MDE should be based on max variance (9.0)
209        let expected = 1.96 * math::sqrt(9.0);
210        assert!(
211            (mde - expected).abs() < 0.1,
212            "MDE should be ~{:.3}, got {:.3}",
213            expected,
214            mde
215        );
216    }
217}