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}