Skip to main content

tacet_core/analysis/
mde.rs

1//! Minimum Detectable Effect (MDE) estimation (spec §3.3.4).
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:
11//! ```text
12//! MDE_μ = z_{1-α/2} × sqrt((1ᵀ Σ₀⁻¹ 1)⁻¹)
13//! MDE_τ = z_{1-α/2} × sqrt((bᵀ Σ₀⁻¹ b)⁻¹)
14//! ```
15//! where z_{1-α/2} is the (1-α/2) percentile of the standard normal.
16
17extern crate alloc;
18
19use alloc::vec::Vec;
20
21use rand::SeedableRng;
22use rand_distr::{Distribution, StandardNormal};
23use rand_xoshiro::Xoshiro256PlusPlus;
24
25use crate::constants::{B_TAIL, ONES};
26use crate::math;
27use crate::result::MinDetectableEffect;
28use crate::types::{Matrix9, Vector9};
29
30use super::effect::decompose_effect;
31
32/// Result from MDE estimation.
33#[derive(Debug, Clone)]
34pub struct MdeEstimate {
35    /// Minimum detectable uniform shift in nanoseconds.
36    pub shift_ns: f64,
37    /// Minimum detectable tail effect in nanoseconds.
38    pub tail_ns: f64,
39    /// Number of null simulations used.
40    pub n_simulations: usize,
41}
42
43impl From<MdeEstimate> for MinDetectableEffect {
44    fn from(mde: MdeEstimate) -> Self {
45        MinDetectableEffect {
46            shift_ns: mde.shift_ns,
47            tail_ns: mde.tail_ns,
48        }
49    }
50}
51
52/// Compute MDE analytically using single-effect projection formulas (spec §3.3.4).
53///
54/// Formula:
55/// ```text
56/// MDE_μ = z_{1-α/2} × sqrt((1ᵀ Σ₀⁻¹ 1)⁻¹)
57/// MDE_τ = z_{1-α/2} × sqrt((bᵀ Σ₀⁻¹ b)⁻¹)
58/// ```
59///
60/// # Arguments
61///
62/// * `covariance` - Pooled covariance matrix of quantile differences (Σ₀)
63/// * `alpha` - Significance level (e.g., 0.05 for 95% confidence)
64///
65/// # Returns
66///
67/// A tuple `(mde_shift, mde_tail)` with minimum detectable effects in nanoseconds.
68pub fn analytical_mde(covariance: &Matrix9, alpha: f64) -> (f64, f64) {
69    // Use Cholesky decomposition for numerically stable solve of Σ₀⁻¹ v
70    let chol = safe_cholesky(covariance);
71
72    // For shift MDE: Var(μ̂) = (1ᵀ Σ₀⁻¹ 1)⁻¹
73    let ones_vec = Vector9::from_iterator(ONES.iter().cloned());
74    let sigma_inv_ones = chol.solve(&ones_vec);
75    let precision_shift = ones_vec.dot(&sigma_inv_ones);
76    let var_shift = if precision_shift.abs() > 1e-12 {
77        1.0 / precision_shift
78    } else {
79        // Near-singular: return huge MDE (conservative fallback)
80        1e12
81    };
82
83    // For tail MDE: Var(τ̂) = (bᵀ Σ₀⁻¹ b)⁻¹
84    let b_tail_vec = Vector9::from_iterator(B_TAIL.iter().cloned());
85    let sigma_inv_b = chol.solve(&b_tail_vec);
86    let precision_tail = b_tail_vec.dot(&sigma_inv_b);
87    let var_tail = if precision_tail.abs() > 1e-12 {
88        1.0 / precision_tail
89    } else {
90        1e12
91    };
92
93    // MDE with 50% power: z_{1-α/2} × SE (spec §3.3.4)
94    let z = probit(1.0 - alpha / 2.0);
95    let mde_shift = z * math::sqrt(var_shift);
96    let mde_tail = z * math::sqrt(var_tail);
97
98    (mde_shift, mde_tail)
99}
100
101/// Inverse normal CDF (probit function).
102///
103/// Computes Φ⁻¹(p) using the Abramowitz & Stegun approximation (26.2.23).
104/// Accurate to ~4.5×10⁻⁴ for p ∈ (0, 1).
105fn probit(p: f64) -> f64 {
106    if p <= 0.0 {
107        return f64::NEG_INFINITY;
108    }
109    if p >= 1.0 {
110        return f64::INFINITY;
111    }
112
113    // Use symmetry: for p < 0.5, compute -probit(1-p)
114    let (sign, q) = if p < 0.5 { (-1.0, 1.0 - p) } else { (1.0, p) };
115
116    // Rational approximation constants (Abramowitz & Stegun 26.2.23)
117    const C0: f64 = 2.515517;
118    const C1: f64 = 0.802853;
119    const C2: f64 = 0.010328;
120    const D1: f64 = 1.432788;
121    const D2: f64 = 0.189269;
122    const D3: f64 = 0.001308;
123
124    let t = math::sqrt(-2.0 * math::ln(1.0 - q));
125    let z = t - (C0 + C1 * t + C2 * t * t) / (1.0 + D1 * t + D2 * t * t + D3 * t * t * t);
126
127    sign * z
128}
129
130/// Monte Carlo MDE estimation (kept for benchmarking).
131///
132/// This is the original implementation using Monte Carlo sampling.
133/// Kept as a separate function for comparison and validation purposes.
134#[allow(dead_code)]
135pub fn estimate_mde_monte_carlo(
136    covariance: &Matrix9,
137    n_simulations: usize,
138    prior_sigmas: (f64, f64),
139) -> MdeEstimate {
140    let mut rng = Xoshiro256PlusPlus::seed_from_u64(42);
141
142    // Cache Cholesky decomposition (compute once, reuse for all samples)
143    let chol = match nalgebra::Cholesky::new(*covariance) {
144        Some(c) => c,
145        None => {
146            // Regularize if not positive definite
147            let regularized = covariance + Matrix9::identity() * 1e-10;
148            nalgebra::Cholesky::new(regularized)
149                .expect("Regularized covariance should be positive definite")
150        }
151    };
152
153    // Collect effect estimates from null samples
154    let mut shift_effects = Vec::with_capacity(n_simulations);
155    let mut tail_effects = Vec::with_capacity(n_simulations);
156
157    for _ in 0..n_simulations {
158        // Sample from null distribution using cached Cholesky
159        let z: Vector9 = Vector9::from_fn(|_, _| StandardNormal.sample(&mut rng));
160        let null_sample = chol.l() * z;
161
162        // Fit effect model
163        let decomp = decompose_effect(&null_sample, covariance, prior_sigmas);
164
165        // Collect absolute effects
166        shift_effects.push(decomp.posterior_mean[0].abs());
167        tail_effects.push(decomp.posterior_mean[1].abs());
168    }
169
170    // Compute 95th percentiles
171    let shift_mde = percentile(&mut shift_effects, 0.95);
172    let tail_mde = percentile(&mut tail_effects, 0.95);
173
174    MdeEstimate {
175        shift_ns: shift_mde,
176        tail_ns: tail_mde,
177        n_simulations,
178    }
179}
180
181/// Estimate the minimum detectable effect (spec §3.3.4).
182///
183/// # Arguments
184///
185/// * `covariance` - Pooled covariance matrix of quantile differences (Σ₀)
186/// * `alpha` - Significance level (e.g., 0.01 for 99% confidence)
187///
188/// # Returns
189///
190/// An `MdeEstimate` with shift and tail MDE in nanoseconds.
191pub fn estimate_mde(covariance: &Matrix9, alpha: f64) -> MdeEstimate {
192    let (shift_ns, tail_ns) = analytical_mde(covariance, alpha);
193
194    MdeEstimate {
195        shift_ns,
196        tail_ns,
197        n_simulations: 0, // Analytical method doesn't use simulations
198    }
199}
200
201/// Compute the p-th percentile of a vector.
202///
203/// Modifies the input vector by sorting it.
204fn percentile(values: &mut [f64], p: f64) -> f64 {
205    if values.is_empty() {
206        return 0.0;
207    }
208
209    values.sort_by(|a, b| a.total_cmp(b));
210
211    let idx = math::round(p * (values.len() - 1) as f64) as usize;
212    let idx = idx.min(values.len() - 1);
213
214    values[idx]
215}
216
217/// Safe Cholesky decomposition with adaptive jitter for near-singular matrices.
218///
219/// Uses the same regularization strategy as covariance estimation.
220fn safe_cholesky(matrix: &Matrix9) -> nalgebra::Cholesky<f64, nalgebra::Const<9>> {
221    // Try decomposition first
222    if let Some(chol) = nalgebra::Cholesky::new(*matrix) {
223        return chol;
224    }
225
226    // Add adaptive jitter for near-singular matrices
227    let trace = matrix.trace();
228    let base_jitter = 1e-10;
229    let adaptive_jitter = (trace / 9.0) * 1e-8;
230    let jitter = base_jitter + adaptive_jitter;
231
232    let mut regularized = *matrix;
233    for i in 0..9 {
234        regularized[(i, i)] += jitter;
235    }
236
237    nalgebra::Cholesky::new(regularized).expect("Cholesky failed even after regularization")
238}
239
240#[cfg(test)]
241mod tests {
242    use super::*;
243
244    #[test]
245    fn test_percentile_basic() {
246        let mut values = vec![1.0, 2.0, 3.0, 4.0, 5.0];
247        assert!((percentile(&mut values, 0.5) - 3.0).abs() < 0.01);
248    }
249
250    #[test]
251    fn test_percentile_95() {
252        let mut values: Vec<f64> = (1..=100).map(|x| x as f64).collect();
253        let p95 = percentile(&mut values, 0.95);
254        assert!((p95 - 95.0).abs() < 1.0);
255    }
256
257    #[test]
258    fn test_mde_positive() {
259        // With identity covariance, MDE should be positive
260        let cov = Matrix9::identity();
261        let mde = estimate_mde(&cov, 0.05);
262
263        assert!(mde.shift_ns > 0.0, "MDE shift should be positive");
264        assert!(mde.tail_ns > 0.0, "MDE tail should be positive");
265    }
266
267    #[test]
268    fn test_probit_accuracy() {
269        // Test against known values
270        assert!((probit(0.5) - 0.0).abs() < 1e-3, "probit(0.5) should be 0");
271        assert!(
272            (probit(0.975) - 1.96).abs() < 1e-2,
273            "probit(0.975) should be ~1.96"
274        );
275        assert!(
276            (probit(0.995) - 2.576).abs() < 1e-2,
277            "probit(0.995) should be ~2.576"
278        );
279        assert!(
280            (probit(0.025) + 1.96).abs() < 1e-2,
281            "probit(0.025) should be ~-1.96"
282        );
283    }
284
285    #[test]
286    fn test_analytical_mde_iid_sanity_check() {
287        // For i.i.d. quantiles with Σ₀ = σ² I (σ = 1) and α = 0.05, with 50% power:
288        // - z_{0.975} ≈ 1.96
289        // - 1ᵀ Σ⁻¹ 1 = 9, Var(μ̂) = 1/9
290        // - MDE_μ = 1.96 / 3 ≈ 0.653
291        //
292        // - bᵀ Σ⁻¹ b = 0.9375, Var(τ̂) = 1/0.9375
293        // - MDE_τ = 1.96 * sqrt(1/0.9375) ≈ 2.02
294
295        let cov = Matrix9::identity();
296        let (mde_shift, mde_tail) = analytical_mde(&cov, 0.05);
297
298        let z = 1.96; // z_alpha for 50% power
299        let expected_shift = z / 3.0;
300        let expected_tail = z * (1.0 / 0.9375_f64).sqrt();
301
302        assert!(
303            (mde_shift - expected_shift).abs() < 0.05,
304            "shift MDE should be ~{:.3}, got {:.3}",
305            expected_shift,
306            mde_shift
307        );
308        assert!(
309            (mde_tail - expected_tail).abs() < 0.1,
310            "tail MDE should be ~{:.3}, got {:.3}",
311            expected_tail,
312            mde_tail
313        );
314    }
315
316    #[test]
317    fn test_analytical_mde_alpha_scaling() {
318        // MDE should increase with stricter alpha (smaller α → larger z → larger MDE)
319        let cov = Matrix9::identity();
320        let (mde_05, _) = analytical_mde(&cov, 0.05); // z ≈ 1.96
321        let (mde_01, _) = analytical_mde(&cov, 0.01); // z ≈ 2.58
322
323        assert!(
324            mde_01 > mde_05,
325            "MDE at α=0.01 ({:.3}) should be larger than α=0.05 ({:.3})",
326            mde_01,
327            mde_05
328        );
329    }
330
331    #[test]
332    fn test_analytical_mde_diagonal_covariance() {
333        // Diagonal covariance (uncorrelated quantiles)
334        let mut cov = Matrix9::zeros();
335        for i in 0..9 {
336            cov[(i, i)] = (i + 1) as f64;
337        }
338
339        let (mde_shift, mde_tail) = analytical_mde(&cov, 0.05);
340
341        assert!(
342            mde_shift.is_finite() && mde_shift > 0.0,
343            "shift MDE not finite or positive: {}",
344            mde_shift
345        );
346        assert!(
347            mde_tail.is_finite() && mde_tail > 0.0,
348            "tail MDE not finite or positive: {}",
349            mde_tail
350        );
351    }
352
353    #[test]
354    fn test_analytical_mde_near_singular() {
355        // Nearly rank-deficient covariance
356        let mut cov = Matrix9::identity() * 1e-6;
357        cov[(0, 0)] = 1.0;
358
359        let (mde_shift, mde_tail) = analytical_mde(&cov, 0.05);
360
361        assert!(mde_shift.is_finite(), "shift MDE not finite: {}", mde_shift);
362        assert!(mde_tail.is_finite(), "tail MDE not finite: {}", mde_tail);
363    }
364
365    #[test]
366    #[cfg(feature = "std")]
367    #[cfg_attr(debug_assertions, ignore)] // Run in release mode for accurate timing
368    fn test_analytical_mde_performance() {
369        let mut cov = Matrix9::identity();
370        for i in 0..9 {
371            for j in 0..9 {
372                let dist = (i as f64 - j as f64).abs();
373                cov[(i, j)] = (-dist / 2.0).exp();
374            }
375        }
376
377        let start = std::time::Instant::now();
378        for _ in 0..1000 {
379            let _ = analytical_mde(&cov, 0.05);
380        }
381        let analytical_time = start.elapsed();
382
383        // Threshold of 3µs per call accounts for CPU variance while still
384        // ensuring Cholesky + 2 solves on 9×9 remains fast
385        assert!(
386            analytical_time.as_micros() < 3000,
387            "analytical MDE too slow: {:.1}µs per call (threshold: 3µs)",
388            analytical_time.as_micros() as f64 / 1000.0
389        );
390    }
391
392    // Sample-size scaling is handled by the covariance estimate; no direct test here.
393}