Skip to main content

tacet_core/adaptive/
calibration.rs

1//! Calibration data for adaptive sampling (no_std compatible).
2//!
3//! This module defines the calibration results needed for the adaptive sampling loop.
4//! The actual calibration computation lives in the `tacet` crate; this module
5//! provides the data structures that can be used in no_std environments.
6//!
7//! For no_std compatibility:
8//! - No `std::time::Instant` (throughput measured by caller)
9//! - No preflight checks (those require std features)
10//! - Uses only `alloc` for heap allocation
11
12extern crate alloc;
13
14use alloc::vec::Vec;
15use core::f64::consts::PI;
16
17use nalgebra::Cholesky;
18use rand::prelude::*;
19use rand::SeedableRng;
20use rand_xoshiro::Xoshiro256PlusPlus;
21
22use crate::constants::DEFAULT_SEED;
23use crate::math;
24use crate::types::{Matrix9, Vector9};
25
26use super::CalibrationSnapshot;
27
28#[cfg(feature = "parallel")]
29use rayon::prelude::*;
30
31/// Counter-based RNG seed generation using SplitMix64.
32/// Provides deterministic, well-distributed seeds for parallel MC sampling.
33#[cfg(feature = "parallel")]
34#[inline]
35fn counter_rng_seed(base_seed: u64, counter: u64) -> u64 {
36    let mut z = base_seed.wrapping_add(counter.wrapping_mul(0x9e3779b97f4a7c15));
37    z = (z ^ (z >> 30)).wrapping_mul(0xbf58476d1ce4e5b9);
38    z = (z ^ (z >> 27)).wrapping_mul(0x94d049bb133111eb);
39    z ^ (z >> 31)
40}
41
42/// Conservative prior scale factor used as fallback.
43/// Chosen to give higher exceedance probability than the target 62%.
44const CONSERVATIVE_PRIOR_SCALE: f64 = 1.5;
45
46/// Target prior exceedance probability.
47const TARGET_EXCEEDANCE: f64 = 0.62;
48
49/// Number of Monte Carlo samples for prior calibration.
50const PRIOR_CALIBRATION_SAMPLES: usize = 50_000;
51
52/// Maximum iterations for prior calibration root-finding.
53const MAX_CALIBRATION_ITERATIONS: usize = 20;
54
55/// Condition number threshold for triggering robust shrinkage (§3.3.5).
56const CONDITION_NUMBER_THRESHOLD: f64 = 1e4;
57
58/// Minimum diagonal floor for regularization (prevents division by zero).
59const DIAGONAL_FLOOR: f64 = 1e-12;
60
61/// Degrees of freedom for Student's t prior (v5.4).
62pub const NU: f64 = 4.0;
63
64
65/// Calibration results from the initial measurement phase (no_std compatible).
66///
67/// This struct contains the essential statistical data needed for the adaptive
68/// sampling loop. It is designed for use in no_std environments like SGX enclaves.
69///
70/// For full calibration with preflight checks, see `tacet::Calibration`.
71///
72/// ## Student's t Prior (v5.4+)
73///
74/// The prior is a Student's t distribution with ν=4 degrees of freedom:
75/// δ ~ t_ν(0, σ_t²R)
76///
77/// This is implemented via scale mixture: λ ~ Gamma(ν/2, ν/2), δ|λ ~ N(0, (σ_t²/λ)R).
78/// The marginal variance is (ν/(ν-2)) σ_t² R = 2σ_t² R for ν=4.
79#[derive(Debug, Clone)]
80pub struct Calibration {
81    /// Covariance "rate" - multiply by 1/n to get Sigma_n for n samples.
82    /// Computed as Sigma_cal * n_cal where Sigma_cal is calibration covariance.
83    /// This allows O(1) covariance scaling as samples accumulate.
84    pub sigma_rate: Matrix9,
85
86    /// Block length from Politis-White algorithm.
87    /// Used for block bootstrap to preserve autocorrelation structure.
88    pub block_length: usize,
89
90    /// Calibrated Student's t prior scale (v5.4).
91    /// This is the σ in δ|λ ~ N(0, (σ²/λ)R).
92    pub sigma_t: f64,
93
94    /// Cholesky factor L_R of correlation matrix R.
95    /// Used for Gibbs sampling: δ = (σ/√λ) L_R z.
96    pub l_r: Matrix9,
97
98    /// Marginal prior covariance: 2σ²R (for ν=4).
99    /// This is the unconditional prior variance of δ under the t-prior.
100    pub prior_cov_marginal: Matrix9,
101
102    /// The theta threshold being used (in nanoseconds).
103    pub theta_ns: f64,
104
105    /// Number of calibration samples collected per class.
106    pub calibration_samples: usize,
107
108    /// Whether discrete mode is active (< 10% unique values).
109    /// When true, use mid-quantile estimators and m-out-of-n bootstrap.
110    pub discrete_mode: bool,
111
112    /// Minimum detectable effect (shift component) from calibration.
113    pub mde_shift_ns: f64,
114
115    /// Minimum detectable effect (tail component) from calibration.
116    pub mde_tail_ns: f64,
117
118    /// Statistics snapshot from calibration phase for drift detection.
119    pub calibration_snapshot: CalibrationSnapshot,
120
121    /// Timer resolution in nanoseconds.
122    pub timer_resolution_ns: f64,
123
124    /// Measured throughput for time estimation (samples per second).
125    /// The caller is responsible for measuring this during calibration.
126    pub samples_per_second: f64,
127
128    /// Floor-rate constant.
129    /// Computed once at calibration: 95th percentile of max_k|Z_k| where Z ~ N(0, Σ_rate).
130    /// Used for analytical theta_floor computation: theta_floor_stat(n) = c_floor / sqrt(n).
131    pub c_floor: f64,
132
133    /// Projection mismatch threshold.
134    /// 99th percentile of bootstrap Q_proj distribution.
135    pub projection_mismatch_thresh: f64,
136
137    /// Timer resolution floor component.
138    /// theta_tick = (1 tick in ns) / K where K is the batch size.
139    pub theta_tick: f64,
140
141    /// Effective threshold for this run.
142    /// theta_eff = max(theta_user, theta_floor) or just theta_floor in research mode.
143    pub theta_eff: f64,
144
145    /// Initial measurement floor at calibration time.
146    pub theta_floor_initial: f64,
147
148    /// Deterministic RNG seed used for this run.
149    pub rng_seed: u64,
150
151    /// Batch size K for adaptive batching.
152    /// When K > 1, samples contain K iterations worth of timing.
153    /// Effect estimates must be divided by K to report per-call differences.
154    pub batch_k: u32,
155}
156
157impl Calibration {
158    /// Create a new Calibration with v5.4+ Student's t prior.
159    #[allow(clippy::too_many_arguments)]
160    pub fn new(
161        sigma_rate: Matrix9,
162        block_length: usize,
163        sigma_t: f64,
164        l_r: Matrix9,
165        theta_ns: f64,
166        calibration_samples: usize,
167        discrete_mode: bool,
168        mde_shift_ns: f64,
169        mde_tail_ns: f64,
170        calibration_snapshot: CalibrationSnapshot,
171        timer_resolution_ns: f64,
172        samples_per_second: f64,
173        c_floor: f64,
174        projection_mismatch_thresh: f64,
175        theta_tick: f64,
176        theta_eff: f64,
177        theta_floor_initial: f64,
178        rng_seed: u64,
179        batch_k: u32,
180    ) -> Self {
181        // Compute marginal prior covariance: 2σ²R (for ν=4)
182        // The marginal variance of t_ν(0, σ²R) is (ν/(ν-2)) σ²R = 2σ²R for ν=4
183        let r = &l_r * l_r.transpose();
184        let prior_cov_marginal = r * (2.0 * sigma_t * sigma_t);
185
186        Self {
187            sigma_rate,
188            block_length,
189            sigma_t,
190            l_r,
191            prior_cov_marginal,
192            theta_ns,
193            calibration_samples,
194            discrete_mode,
195            mde_shift_ns,
196            mde_tail_ns,
197            calibration_snapshot,
198            timer_resolution_ns,
199            samples_per_second,
200            c_floor,
201            projection_mismatch_thresh,
202            theta_tick,
203            theta_eff,
204            theta_floor_initial,
205            rng_seed,
206            batch_k,
207        }
208    }
209
210    /// Compute effective sample size accounting for dependence (spec §3.3.2 v5.6).
211    ///
212    /// Under strong temporal dependence, n samples do not provide n independent observations.
213    /// The effective sample size approximates the number of effectively independent blocks.
214    ///
215    /// n_eff = max(1, floor(n / block_length))
216    ///
217    /// where block_length is the estimated dependence length from Politis-White selector.
218    pub fn n_eff(&self, n: usize) -> usize {
219        if self.block_length == 0 {
220            return n.max(1);
221        }
222        (n / self.block_length).max(1)
223    }
224
225    /// Scale sigma_rate to get covariance for n samples using effective sample size (v5.6).
226    ///
227    /// Σ_n = Σ_rate / n_eff
228    ///
229    /// where n_eff = max(1, floor(n / block_length)) to correctly account for reduced
230    /// information under temporal dependence.
231    pub fn covariance_for_n(&self, n: usize) -> Matrix9 {
232        if n == 0 {
233            return self.sigma_rate; // Avoid division by zero
234        }
235        let n_eff = self.n_eff(n);
236        self.sigma_rate / (n_eff as f64)
237    }
238
239    /// Scale sigma_rate to get covariance using raw n samples (ignoring dependence).
240    ///
241    /// Σ_n = Σ_rate / n
242    ///
243    /// Use this only when you need the raw scaling without n_eff correction.
244    pub fn covariance_for_n_raw(&self, n: usize) -> Matrix9 {
245        if n == 0 {
246            return self.sigma_rate; // Avoid division by zero
247        }
248        self.sigma_rate / (n as f64)
249    }
250
251    /// Estimate time to collect `n` additional samples based on calibration throughput.
252    ///
253    /// Returns estimated seconds. Caller should add any overhead.
254    pub fn estimate_collection_time_secs(&self, n: usize) -> f64 {
255        if self.samples_per_second <= 0.0 {
256            return 0.0;
257        }
258        n as f64 / self.samples_per_second
259    }
260
261    /// Convert to an FFI-friendly summary containing only scalar fields.
262    pub fn to_summary(&self) -> crate::ffi_summary::CalibrationSummary {
263        crate::ffi_summary::CalibrationSummary {
264            block_length: self.block_length,
265            calibration_samples: self.calibration_samples,
266            discrete_mode: self.discrete_mode,
267            timer_resolution_ns: self.timer_resolution_ns,
268            theta_ns: self.theta_ns,
269            theta_eff: self.theta_eff,
270            theta_floor_initial: self.theta_floor_initial,
271            theta_tick: self.theta_tick,
272            mde_shift_ns: self.mde_shift_ns,
273            mde_tail_ns: self.mde_tail_ns,
274            projection_mismatch_thresh: self.projection_mismatch_thresh,
275            samples_per_second: self.samples_per_second,
276        }
277    }
278}
279
280/// Configuration for calibration phase (no_std compatible).
281///
282/// This contains parameters for calibration that don't require std features.
283/// For full configuration with preflight options, see `tacet::CalibrationConfig`.
284#[derive(Debug, Clone)]
285pub struct CalibrationConfig {
286    /// Number of samples to collect per class during calibration.
287    pub calibration_samples: usize,
288
289    /// Number of bootstrap iterations for covariance estimation.
290    pub bootstrap_iterations: usize,
291
292    /// Theta threshold in nanoseconds.
293    pub theta_ns: f64,
294
295    /// Alpha level for MDE computation.
296    pub alpha: f64,
297
298    /// Random seed for bootstrap.
299    pub seed: u64,
300}
301
302impl Default for CalibrationConfig {
303    fn default() -> Self {
304        Self {
305            calibration_samples: 5000,
306            bootstrap_iterations: 2000, // Full bootstrap for accurate covariance
307            theta_ns: 100.0,
308            alpha: 0.01,
309            seed: DEFAULT_SEED,
310        }
311    }
312}
313
314/// Compute correlation-shaped 9D prior covariance (spec v5.1).
315///
316/// Λ₀ = σ²_prior × R where R = Corr(Σ_rate) = D^(-1/2) Σ_rate D^(-1/2)
317///
318/// Since diag(R) = 1, σ_prior equals the marginal prior SD for all coordinates.
319/// This eliminates hidden heteroskedasticity that could cause pathological
320/// shrinkage for certain effect patterns.
321///
322/// # Arguments
323/// * `sigma_rate` - Covariance rate matrix from bootstrap
324/// * `sigma_prior` - Prior scale (calibrated for 62% exceedance)
325/// * `discrete_mode` - Whether discrete timer mode is active
326///
327/// # Returns
328/// The prior covariance matrix Λ₀.
329pub fn compute_prior_cov_9d(
330    sigma_rate: &Matrix9,
331    sigma_prior: f64,
332    discrete_mode: bool,
333) -> Matrix9 {
334    // Compute correlation matrix R = D^(-1/2) Σ_rate D^(-1/2)
335    let r = compute_correlation_matrix(sigma_rate);
336
337    // Apply two-step regularization (§3.3.5):
338    // 1. Robust shrinkage (if in fragile regime)
339    // 2. Numerical jitter (always, as needed)
340    let r = apply_correlation_regularization(&r, discrete_mode);
341
342    // Λ₀ = σ²_prior × R
343    r * (sigma_prior * sigma_prior)
344}
345
346/// Compute correlation matrix R = D^(-1/2) Σ D^(-1/2) from covariance matrix.
347///
348/// The correlation matrix has unit diagonal (diag(R) = 1) and encodes
349/// the correlation structure of Σ without the variance magnitudes.
350fn compute_correlation_matrix(sigma: &Matrix9) -> Matrix9 {
351    // Extract diagonal and apply floor for numerical stability
352    let mut d_inv_sqrt = [0.0_f64; 9];
353    for i in 0..9 {
354        let var = sigma[(i, i)].max(DIAGONAL_FLOOR);
355        d_inv_sqrt[i] = 1.0 / math::sqrt(var);
356    }
357
358    // R = D^(-1/2) × Σ × D^(-1/2)
359    let mut r = *sigma;
360    for i in 0..9 {
361        for j in 0..9 {
362            r[(i, j)] *= d_inv_sqrt[i] * d_inv_sqrt[j];
363        }
364    }
365
366    r
367}
368
369/// Estimate condition number of a symmetric matrix via power iteration.
370///
371/// Returns the ratio of largest to smallest eigenvalue magnitude.
372/// Uses a simple approach: max(|diag|) / min(|diag|) as a quick estimate,
373/// plus check for Cholesky failure which indicates poor conditioning.
374fn estimate_condition_number(r: &Matrix9) -> f64 {
375    // Quick estimate from diagonal ratio (exact for diagonal matrices)
376    let diag: Vec<f64> = (0..9).map(|i| r[(i, i)].abs()).collect();
377    let max_diag = diag.iter().cloned().fold(0.0_f64, f64::max);
378    let min_diag = diag.iter().cloned().fold(f64::INFINITY, f64::min);
379
380    if min_diag < DIAGONAL_FLOOR {
381        return f64::INFINITY;
382    }
383
384    // For correlation matrices, this underestimates the true condition number,
385    // but we also check Cholesky failure separately.
386    max_diag / min_diag
387}
388
389/// Check if we're in a "fragile regime" requiring robust shrinkage (§3.3.5).
390///
391/// Fragile regime is detected when:
392/// - Discrete timer mode is active, OR
393/// - Condition number of R exceeds 10⁴, OR
394/// - Cholesky factorization fails
395fn is_fragile_regime(r: &Matrix9, discrete_mode: bool) -> bool {
396    if discrete_mode {
397        return true;
398    }
399
400    let cond = estimate_condition_number(r);
401    if cond > CONDITION_NUMBER_THRESHOLD {
402        return true;
403    }
404
405    // Check if Cholesky would fail
406    Cholesky::new(*r).is_none()
407}
408
409/// Apply two-step regularization to correlation matrix (§3.3.5).
410///
411/// Step 1 (conditional): Robust shrinkage R ← (1-λ)R + λI for fragile regimes.
412/// Step 2 (always): Numerical jitter R ← R + εI to ensure SPD.
413fn apply_correlation_regularization(r: &Matrix9, discrete_mode: bool) -> Matrix9 {
414    let mut r = *r;
415
416    // Step 1: Robust shrinkage (conditional on fragile regime)
417    if is_fragile_regime(&r, discrete_mode) {
418        // Choose λ based on severity (§3.3.5 allows [0.01, 0.2])
419        let cond = estimate_condition_number(&r);
420        let lambda = if cond > CONDITION_NUMBER_THRESHOLD * 10.0 {
421            0.2 // Severe: aggressive shrinkage
422        } else if cond > CONDITION_NUMBER_THRESHOLD {
423            0.1 // Moderate
424        } else if discrete_mode {
425            0.05 // Mild: just discrete mode
426        } else {
427            0.01 // Minimal
428        };
429
430        let identity = Matrix9::identity();
431        r = r * (1.0 - lambda) + identity * lambda;
432    }
433
434    // Step 2: Numerical jitter (always, as needed for Cholesky)
435    // Try increasingly large epsilon until Cholesky succeeds
436    for &eps in &[1e-10, 1e-9, 1e-8, 1e-7, 1e-6] {
437        let r_jittered = r + Matrix9::identity() * eps;
438        if Cholesky::new(r_jittered).is_some() {
439            return r_jittered;
440        }
441    }
442
443    // Fallback: aggressive jitter
444    r + Matrix9::identity() * 1e-5
445}
446
447/// Compute median standard error from sigma_rate.
448fn compute_median_se(sigma_rate: &Matrix9, n_cal: usize) -> f64 {
449    let mut ses: Vec<f64> = (0..9)
450        .map(|i| {
451            let var = sigma_rate[(i, i)].max(DIAGONAL_FLOOR);
452            math::sqrt(var / n_cal.max(1) as f64)
453        })
454        .collect();
455    ses.sort_by(|a, b| a.total_cmp(b));
456    ses[4] // Median of 9 values
457}
458
459/// Calibrate Student's t prior scale σ so that P(max_k |δ_k| > θ_eff | δ ~ t_4(0, σ²R)) = 0.62 (v5.4).
460///
461/// Uses binary search to find σ such that the marginal exceedance probability
462/// matches the target 62%. The t-prior is sampled via scale mixture:
463/// - λ ~ Gamma(ν/2, ν/2) = Gamma(2, 2) for ν=4
464/// - z ~ N(0, I₉)
465/// - δ = (σ/√λ) L_R z
466///
467/// # Arguments
468/// * `sigma_rate` - Covariance rate matrix from calibration
469/// * `theta_eff` - Effective threshold in nanoseconds
470/// * `n_cal` - Number of calibration samples (for SE computation)
471/// * `discrete_mode` - Whether discrete timer mode is active
472/// * `seed` - Deterministic RNG seed
473///
474/// # Returns
475/// A tuple of (sigma_t, l_r) where sigma_t is the calibrated scale and l_r is
476/// the Cholesky factor of the regularized correlation matrix.
477pub fn calibrate_t_prior_scale(
478    sigma_rate: &Matrix9,
479    theta_eff: f64,
480    n_cal: usize,
481    discrete_mode: bool,
482    seed: u64,
483) -> (f64, Matrix9) {
484    let median_se = compute_median_se(sigma_rate, n_cal);
485
486    // Compute and cache L_R (Cholesky of regularized correlation matrix)
487    let r = compute_correlation_matrix(sigma_rate);
488    let r_reg = apply_correlation_regularization(&r, discrete_mode);
489    let l_r = match Cholesky::new(r_reg) {
490        Some(c) => c.l().into_owned(),
491        None => Matrix9::identity(),
492    };
493
494    // Precompute normalized effect magnitudes for sample reuse across bisection.
495    //
496    // For t_ν prior with scale mixture representation:
497    //   λ ~ Gamma(ν/2, ν/2), z ~ N(0, I₉), δ = (σ/√λ) L_R z
498    //
499    // So: max|δ| = σ · max|L_R z|/√λ = σ · m
500    // where m_i = max|L_R z_i|/√λ_i is precomputed once.
501    //
502    // Then: P(max|δ| > θ) = P(σ·m > θ) = P(m > θ/σ)
503    //
504    // This allows O(1) exceedance computation per bisection iteration
505    // instead of O(PRIOR_CALIBRATION_SAMPLES).
506    let normalized_effects = precompute_t_prior_effects(&l_r, seed);
507
508    // Search bounds (same as v5.1)
509    let mut lo = theta_eff * 0.05;
510    let mut hi = (theta_eff * 50.0).max(10.0 * median_se);
511
512    for _ in 0..MAX_CALIBRATION_ITERATIONS {
513        let mid = (lo + hi) / 2.0;
514
515        // Compute exceedance using precomputed samples: count(m_i > θ/σ)
516        let threshold = theta_eff / mid;
517        let count = normalized_effects.iter().filter(|&&m| m > threshold).count();
518        let exceedance = count as f64 / normalized_effects.len() as f64;
519
520        if (exceedance - TARGET_EXCEEDANCE).abs() < 0.01 {
521            return (mid, l_r); // Close enough
522        }
523
524        if exceedance > TARGET_EXCEEDANCE {
525            // Too much exceedance -> reduce scale
526            hi = mid;
527        } else {
528            // Too little exceedance -> increase scale
529            lo = mid;
530        }
531    }
532
533    // Fallback to conservative value
534    (theta_eff * CONSERVATIVE_PRIOR_SCALE, l_r)
535}
536
537/// Precompute normalized effect magnitudes for t-prior calibration.
538///
539/// Returns a vector of m_i = max_k |L_R z_i|_k / √λ_i where:
540/// - λ_i ~ Gamma(ν/2, ν/2) for ν=4
541/// - z_i ~ N(0, I₉)
542///
543/// These can be reused across bisection iterations since:
544/// P(max|δ| > θ | σ) = P(σ·m > θ) = P(m > θ/σ)
545fn precompute_t_prior_effects(l_r: &Matrix9, seed: u64) -> Vec<f64> {
546    use rand_distr::Gamma;
547
548    #[cfg(feature = "parallel")]
549    {
550        let l_r = l_r.clone();
551        (0..PRIOR_CALIBRATION_SAMPLES)
552            .into_par_iter()
553            .map(|i| {
554                let mut rng = Xoshiro256PlusPlus::seed_from_u64(counter_rng_seed(seed, i as u64));
555                let gamma_dist = Gamma::new(NU / 2.0, 2.0 / NU).unwrap();
556
557                // Sample λ ~ Gamma(ν/2, ν/2)
558                let lambda: f64 = gamma_dist.sample(&mut rng);
559                let inv_sqrt_lambda = 1.0 / math::sqrt(lambda.max(DIAGONAL_FLOOR));
560
561                // Sample z ~ N(0, I_9)
562                let mut z = Vector9::zeros();
563                for j in 0..9 {
564                    z[j] = sample_standard_normal(&mut rng);
565                }
566
567                // Compute m = max|L_R z| / √λ
568                let w = &l_r * z;
569                let max_w = w.iter().map(|x| x.abs()).fold(0.0_f64, f64::max);
570                max_w * inv_sqrt_lambda
571            })
572            .collect()
573    }
574
575    #[cfg(not(feature = "parallel"))]
576    {
577        let mut rng = Xoshiro256PlusPlus::seed_from_u64(seed);
578        let gamma_dist = Gamma::new(NU / 2.0, 2.0 / NU).unwrap();
579        let mut effects = Vec::with_capacity(PRIOR_CALIBRATION_SAMPLES);
580
581        for _ in 0..PRIOR_CALIBRATION_SAMPLES {
582            let lambda: f64 = gamma_dist.sample(&mut rng);
583            let inv_sqrt_lambda = 1.0 / math::sqrt(lambda.max(DIAGONAL_FLOOR));
584
585            let mut z = Vector9::zeros();
586            for i in 0..9 {
587                z[i] = sample_standard_normal(&mut rng);
588            }
589
590            let w = l_r * z;
591            let max_w = w.iter().map(|x| x.abs()).fold(0.0_f64, f64::max);
592            effects.push(max_w * inv_sqrt_lambda);
593        }
594        effects
595    }
596}
597
598/// Compute floor-rate constant c_floor for 9D model.
599///
600/// c_floor = q_95(max_k |Z_k|) where Z ~ N(0, Σ_rate)
601///
602/// Used for theta_floor computation: theta_floor_stat(n) = c_floor / sqrt(n)
603pub fn compute_c_floor_9d(sigma_rate: &Matrix9, seed: u64) -> f64 {
604    let chol = match Cholesky::new(*sigma_rate) {
605        Some(c) => c,
606        None => {
607            // Fallback: use trace-based approximation
608            let trace: f64 = (0..9).map(|i| sigma_rate[(i, i)]).sum();
609            return math::sqrt(trace / 9.0) * 2.5; // Approximate 95th percentile
610        }
611    };
612    let l = chol.l().into_owned();
613
614    // Parallel MC sampling when feature enabled
615    #[cfg(feature = "parallel")]
616    let mut max_effects: Vec<f64> = (0..PRIOR_CALIBRATION_SAMPLES)
617        .into_par_iter()
618        .map(|i| {
619            let mut rng = Xoshiro256PlusPlus::seed_from_u64(counter_rng_seed(seed, i as u64));
620            let mut z = Vector9::zeros();
621            for j in 0..9 {
622                z[j] = sample_standard_normal(&mut rng);
623            }
624            let sample = &l * z;
625            sample.iter().map(|x| x.abs()).fold(0.0_f64, f64::max)
626        })
627        .collect();
628
629    #[cfg(not(feature = "parallel"))]
630    let mut max_effects: Vec<f64> = {
631        let mut rng = Xoshiro256PlusPlus::seed_from_u64(seed);
632        let mut effects = Vec::with_capacity(PRIOR_CALIBRATION_SAMPLES);
633        for _ in 0..PRIOR_CALIBRATION_SAMPLES {
634            let mut z = Vector9::zeros();
635            for i in 0..9 {
636                z[i] = sample_standard_normal(&mut rng);
637            }
638            let sample = &l * z;
639            let max_effect = sample.iter().map(|x| x.abs()).fold(0.0_f64, f64::max);
640            effects.push(max_effect);
641        }
642        effects
643    };
644
645    // 95th percentile using O(n) selection instead of O(n log n) sort
646    let idx = ((PRIOR_CALIBRATION_SAMPLES as f64 * 0.95) as usize).min(PRIOR_CALIBRATION_SAMPLES - 1);
647    let (_, &mut percentile_95, _) = max_effects.select_nth_unstable_by(idx, |a, b| a.total_cmp(b));
648    percentile_95
649}
650
651/// Sample from standard normal using Box-Muller transform.
652fn sample_standard_normal<R: Rng>(rng: &mut R) -> f64 {
653    let u1: f64 = rng.random();
654    let u2: f64 = rng.random();
655    math::sqrt(-2.0 * math::ln(u1.max(1e-12))) * math::cos(2.0 * PI * u2)
656}
657
658#[cfg(test)]
659mod tests {
660    use super::*;
661    use crate::statistics::StatsSnapshot;
662
663    fn make_test_calibration() -> Calibration {
664        let snapshot = CalibrationSnapshot::new(
665            StatsSnapshot {
666                count: 1000,
667                mean: 100.0,
668                variance: 25.0,
669                autocorr_lag1: 0.1,
670            },
671            StatsSnapshot {
672                count: 1000,
673                mean: 105.0,
674                variance: 30.0,
675                autocorr_lag1: 0.12,
676            },
677        );
678
679        let sigma_rate = Matrix9::identity() * 1000.0;
680        let theta_eff = 100.0;
681
682        Calibration::new(
683            sigma_rate,
684            10,                  // block_length
685            100.0,               // sigma_t
686            Matrix9::identity(), // l_r (identity for tests)
687            theta_eff,           // theta_ns
688            5000,                // calibration_samples
689            false,               // discrete_mode
690            5.0,                 // mde_shift_ns
691            10.0,                // mde_tail_ns
692            snapshot,            // calibration_snapshot
693            1.0,                 // timer_resolution_ns
694            10000.0,             // samples_per_second
695            10.0,                // c_floor
696            18.48,               // projection_mismatch_thresh
697            0.001,               // theta_tick
698            theta_eff,           // theta_eff
699            0.1,                 // theta_floor_initial
700            42,                  // rng_seed
701            1,                   // batch_k (no batching in tests)
702        )
703    }
704
705    #[test]
706    fn test_covariance_scaling() {
707        let cal = make_test_calibration();
708        // make_test_calibration uses sigma_rate[(0,0)] = 1000.0 and block_length = 10
709
710        // v5.6: covariance_for_n uses n_eff = n / block_length
711        // At n=1000 with block_length=10: n_eff = 100
712        // sigma_n[(0,0)] = sigma_rate[(0,0)] / n_eff = 1000 / 100 = 10.0
713        let cov_1000 = cal.covariance_for_n(1000);
714        assert!(
715            (cov_1000[(0, 0)] - 10.0).abs() < 1e-10,
716            "expected 10.0, got {}",
717            cov_1000[(0, 0)]
718        );
719
720        // At n=2000 with block_length=10: n_eff = 200
721        // sigma_n[(0,0)] = 1000 / 200 = 5.0
722        let cov_2000 = cal.covariance_for_n(2000);
723        assert!(
724            (cov_2000[(0, 0)] - 5.0).abs() < 1e-10,
725            "expected 5.0, got {}",
726            cov_2000[(0, 0)]
727        );
728    }
729
730    #[test]
731    fn test_n_eff() {
732        let cal = make_test_calibration();
733        // make_test_calibration uses block_length = 10
734
735        // n_eff = max(1, floor(n / block_length))
736        assert_eq!(cal.n_eff(100), 10);
737        assert_eq!(cal.n_eff(1000), 100);
738        assert_eq!(cal.n_eff(10), 1);
739        assert_eq!(cal.n_eff(5), 1); // Clamped to 1 when n < block_length
740        assert_eq!(cal.n_eff(0), 1); // Edge case: n=0 returns 1
741    }
742
743    #[test]
744    fn test_covariance_zero_n() {
745        let cal = make_test_calibration();
746        let cov = cal.covariance_for_n(0);
747        // Should return sigma_rate unchanged (avoid NaN)
748        assert!((cov[(0, 0)] - 1000.0).abs() < 1e-10);
749    }
750
751    #[test]
752    fn test_estimate_collection_time() {
753        let cal = make_test_calibration();
754
755        // 10000 samples/sec -> 1000 samples takes 0.1 sec
756        let time = cal.estimate_collection_time_secs(1000);
757        assert!((time - 0.1).abs() < 1e-10);
758    }
759
760    #[test]
761    fn test_compute_prior_cov_9d_unit_diagonal() {
762        // Use identity matrix - correlation of identity is identity
763        let sigma_rate = Matrix9::identity();
764        let prior = compute_prior_cov_9d(&sigma_rate, 10.0, false);
765
766        // R = Corr(I) = I (identity has unit diagonal, no off-diagonal correlation)
767        // Λ₀ = σ²_prior × R = 100 × I
768        // Each diagonal should be ~100 (σ² = 100)
769        // Note: jitter adds ~1e-10 so expect ~100
770        let expected = 100.0;
771        for i in 0..9 {
772            assert!(
773                (prior[(i, i)] - expected).abs() < 1.0,
774                "Diagonal {} was {}, expected ~{}",
775                i,
776                prior[(i, i)],
777                expected
778            );
779        }
780    }
781
782    #[test]
783    fn test_c_floor_computation() {
784        let sigma_rate = Matrix9::identity() * 100.0;
785        let c_floor = compute_c_floor_9d(&sigma_rate, 42);
786
787        // c_floor should be roughly sqrt(100) * 2 to 3 for 95th percentile of max
788        assert!(c_floor > 15.0, "c_floor {} should be > 15", c_floor);
789        assert!(c_floor < 40.0, "c_floor {} should be < 40", c_floor);
790    }
791
792    #[test]
793    fn test_calibration_config_default() {
794        let config = CalibrationConfig::default();
795        assert_eq!(config.calibration_samples, 5000);
796        assert_eq!(config.bootstrap_iterations, 2000);
797        assert!((config.theta_ns - 100.0).abs() < 1e-10);
798    }
799
800    // =========================================================================
801    // Reference implementations for validation (original non-optimized versions)
802    // =========================================================================
803
804    /// Reference implementation: compute t-prior exceedance without sample reuse.
805    /// This generates fresh samples for each call, matching the original implementation.
806    fn reference_t_prior_exceedance(l_r: &Matrix9, sigma: f64, theta: f64, seed: u64) -> f64 {
807        use rand_distr::Gamma;
808
809        let mut rng = Xoshiro256PlusPlus::seed_from_u64(seed);
810        let gamma_dist = Gamma::new(NU / 2.0, 2.0 / NU).unwrap();
811        let mut count = 0usize;
812
813        for _ in 0..PRIOR_CALIBRATION_SAMPLES {
814            let lambda: f64 = gamma_dist.sample(&mut rng);
815            let scale = sigma / crate::math::sqrt(lambda.max(DIAGONAL_FLOOR));
816
817            let mut z = Vector9::zeros();
818            for i in 0..9 {
819                z[i] = sample_standard_normal(&mut rng);
820            }
821
822            let delta = l_r * z * scale;
823            let max_effect = delta.iter().map(|x| x.abs()).fold(0.0_f64, f64::max);
824            if max_effect > theta {
825                count += 1;
826            }
827        }
828
829        count as f64 / PRIOR_CALIBRATION_SAMPLES as f64
830    }
831
832    /// Helper: compute exceedance using precomputed t-prior effects
833    fn optimized_t_prior_exceedance(normalized_effects: &[f64], sigma: f64, theta: f64) -> f64 {
834        let threshold = theta / sigma;
835        let count = normalized_effects.iter().filter(|&&m| m > threshold).count();
836        count as f64 / normalized_effects.len() as f64
837    }
838
839    // =========================================================================
840    // Tests verifying optimized implementations match reference
841    // =========================================================================
842
843    #[test]
844    fn test_t_prior_precompute_exceedance_matches_reference() {
845        // Test that the optimized exceedance computation matches reference
846        // for various sigma values
847        let l_r = Matrix9::identity();
848        let theta = 10.0;
849        let seed = 12345u64;
850
851        // Precompute effects using the optimized method
852        let normalized_effects = precompute_t_prior_effects(&l_r, seed);
853
854        // Test at multiple sigma values
855        for sigma in [5.0, 10.0, 15.0, 20.0, 30.0] {
856            let optimized = optimized_t_prior_exceedance(&normalized_effects, sigma, theta);
857            let reference = reference_t_prior_exceedance(&l_r, sigma, theta, seed);
858
859            // Allow some tolerance due to different RNG sequences
860            // The key property is that exceedance should be monotonically increasing with sigma
861            assert!(
862                optimized >= 0.0 && optimized <= 1.0,
863                "Optimized exceedance {} out of range for sigma={}",
864                optimized,
865                sigma
866            );
867            assert!(
868                reference >= 0.0 && reference <= 1.0,
869                "Reference exceedance {} out of range for sigma={}",
870                reference,
871                sigma
872            );
873
874            // Both should be in similar ballpark (within 0.1 of each other)
875            // Note: They won't be exactly equal because the optimized version
876            // uses different random samples
877            println!(
878                "sigma={}: optimized={:.4}, reference={:.4}",
879                sigma, optimized, reference
880            );
881        }
882    }
883
884    #[test]
885    fn test_t_prior_exceedance_monotonicity() {
886        // Key property: exceedance should increase with sigma
887        let l_r = Matrix9::identity();
888        let theta = 10.0;
889        let seed = 42u64;
890
891        let normalized_effects = precompute_t_prior_effects(&l_r, seed);
892
893        let mut prev_exceedance = 0.0;
894        for sigma in [1.0, 2.0, 5.0, 10.0, 20.0, 50.0, 100.0] {
895            let exceedance = optimized_t_prior_exceedance(&normalized_effects, sigma, theta);
896
897            assert!(
898                exceedance >= prev_exceedance,
899                "Exceedance should increase with sigma: sigma={}, exc={}, prev={}",
900                sigma,
901                exceedance,
902                prev_exceedance
903            );
904            prev_exceedance = exceedance;
905        }
906
907        // At very large sigma, exceedance should approach 1
908        let large_sigma_exc = optimized_t_prior_exceedance(&normalized_effects, 1000.0, theta);
909        assert!(
910            large_sigma_exc > 0.99,
911            "Exceedance at large sigma should be ~1, got {}",
912            large_sigma_exc
913        );
914
915        // At very small sigma, exceedance should approach 0
916        let small_sigma_exc = optimized_t_prior_exceedance(&normalized_effects, 0.1, theta);
917        assert!(
918            small_sigma_exc < 0.01,
919            "Exceedance at small sigma should be ~0, got {}",
920            small_sigma_exc
921        );
922    }
923
924    #[test]
925    fn test_calibrate_t_prior_scale_finds_target_exceedance() {
926        // Test that the calibration finds a sigma_t that achieves ~62% exceedance
927        let sigma_rate = Matrix9::identity() * 100.0;
928        let theta_eff = 10.0;
929        let n_cal = 5000;
930        let discrete_mode = false;
931        let seed = 42u64;
932
933        let (sigma_t, l_r) =
934            calibrate_t_prior_scale(&sigma_rate, theta_eff, n_cal, discrete_mode, seed);
935
936        // Verify the calibrated sigma_t achieves target exceedance
937        let normalized_effects = precompute_t_prior_effects(&l_r, seed);
938        let exceedance = optimized_t_prior_exceedance(&normalized_effects, sigma_t, theta_eff);
939
940        assert!(
941            (exceedance - TARGET_EXCEEDANCE).abs() < 0.05,
942            "Calibrated t-prior exceedance {} should be near target {}",
943            exceedance,
944            TARGET_EXCEEDANCE
945        );
946    }
947
948    #[test]
949    fn test_calibration_determinism() {
950        // Same seed should give same results
951        let sigma_rate = Matrix9::identity() * 100.0;
952        let theta_eff = 10.0;
953        let n_cal = 5000;
954        let discrete_mode = false;
955        let seed = 12345u64;
956
957        let (sigma_t_1, _) =
958            calibrate_t_prior_scale(&sigma_rate, theta_eff, n_cal, discrete_mode, seed);
959        let (sigma_t_2, _) =
960            calibrate_t_prior_scale(&sigma_rate, theta_eff, n_cal, discrete_mode, seed);
961
962        assert!(
963            (sigma_t_1 - sigma_t_2).abs() < 1e-10,
964            "Same seed should give same sigma_t: {} vs {}",
965            sigma_t_1,
966            sigma_t_2
967        );
968    }
969
970    #[test]
971    fn test_precomputed_effects_distribution() {
972        // Test that precomputed effects follow expected distribution
973        let l_r = Matrix9::identity();
974        let seed = 42u64;
975
976        let effects = precompute_t_prior_effects(&l_r, seed);
977
978        // All effects should be positive (they're max of absolute values)
979        assert!(
980            effects.iter().all(|&m| m > 0.0),
981            "All effects should be positive"
982        );
983
984        // Compute mean and check it's reasonable
985        let mean: f64 = effects.iter().sum::<f64>() / effects.len() as f64;
986        // For t_4 with identity L_R, mean of max|z|/sqrt(lambda) should be roughly 2-4
987        assert!(
988            mean > 1.0 && mean < 10.0,
989            "Mean effect {} should be in reasonable range",
990            mean
991        );
992
993        // Check variance is non-zero (samples are diverse)
994        let variance: f64 = effects.iter().map(|&m| (m - mean).powi(2)).sum::<f64>()
995            / (effects.len() - 1) as f64;
996        assert!(variance > 0.1, "Effects should have non-trivial variance");
997    }
998
999    #[test]
1000    #[ignore] // Slow benchmark - run with `cargo test -- --ignored`
1001    fn bench_calibration_timing() {
1002        use std::time::Instant;
1003
1004        let sigma_rate = Matrix9::identity() * 10000.0;
1005        let theta_eff = 100.0;
1006        let n_cal = 5000;
1007        let discrete_mode = false;
1008
1009        // Warm up
1010        let _ = calibrate_t_prior_scale(&sigma_rate, theta_eff, n_cal, discrete_mode, 1);
1011
1012        // Benchmark t-prior calibration
1013        let iterations = 10;
1014        let start = Instant::now();
1015        for i in 0..iterations {
1016            let _ = calibrate_t_prior_scale(&sigma_rate, theta_eff, n_cal, discrete_mode, i as u64);
1017        }
1018        let t_prior_time = start.elapsed();
1019
1020        println!(
1021            "\n=== Calibration Performance ===\n\
1022             \n\
1023             T-prior calibration: {:?} per call\n\
1024             ({} iterations averaged)",
1025            t_prior_time / iterations as u32,
1026            iterations
1027        );
1028    }
1029}