Skip to main content

tacet_core/adaptive/
quality_gates.rs

1//! Quality gates for the adaptive sampling loop.
2//!
3//! Quality gates detect conditions where continuing to sample is unlikely to
4//! yield a definitive answer. They prevent wasted computation and provide
5//! actionable feedback to users.
6//!
7//! Gates are checked in order of priority:
8//! 1. Posterior too close to prior (data uninformative)
9//! 2. Learning rate collapsed (posterior stopped updating)
10//! 3. Would take too long (extrapolated time exceeds budget)
11//! 4. Time budget exceeded
12//! 5. Sample budget exceeded
13//! 6. Condition drift detected (calibration assumptions violated)
14
15use alloc::string::String;
16
17use super::drift::{CalibrationSnapshot, ConditionDrift, DriftThresholds};
18use super::Posterior;
19use crate::constants::{DEFAULT_FAIL_THRESHOLD, DEFAULT_MAX_SAMPLES, DEFAULT_PASS_THRESHOLD};
20use crate::types::Matrix9;
21
22/// Result of quality gate checks.
23#[derive(Debug, Clone)]
24pub enum QualityGateResult {
25    /// All gates passed, continue sampling.
26    Continue,
27
28    /// A gate triggered, stop with inconclusive result.
29    Stop(InconclusiveReason),
30}
31
32/// Reason why the adaptive loop stopped inconclusively.
33#[derive(Debug, Clone)]
34pub enum InconclusiveReason {
35    /// Posterior is too close to prior - data isn't informative.
36    DataTooNoisy {
37        /// Human-readable message.
38        message: String,
39        /// Suggested remediation.
40        guidance: String,
41        /// Variance ratio (posterior/prior).
42        variance_ratio: f64,
43    },
44
45    /// Posterior stopped updating despite new data.
46    NotLearning {
47        /// Human-readable message.
48        message: String,
49        /// Suggested remediation.
50        guidance: String,
51        /// Sum of recent KL divergences.
52        recent_kl_sum: f64,
53    },
54
55    /// Estimated time to decision exceeds acceptable limit.
56    WouldTakeTooLong {
57        /// Estimated time in seconds.
58        estimated_time_secs: f64,
59        /// Estimated samples needed.
60        samples_needed: usize,
61        /// Suggested remediation.
62        guidance: String,
63    },
64
65    /// Time budget exceeded without reaching decision.
66    TimeBudgetExceeded {
67        /// Current leak probability.
68        current_probability: f64,
69        /// Samples collected so far.
70        samples_collected: usize,
71        /// Time spent.
72        elapsed_secs: f64,
73    },
74
75    /// Sample budget exceeded without reaching decision.
76    SampleBudgetExceeded {
77        /// Current leak probability.
78        current_probability: f64,
79        /// Samples collected.
80        samples_collected: usize,
81    },
82
83    /// Measurement conditions changed during the test (Gate 6).
84    ///
85    /// Detected by comparing calibration statistics with post-test statistics.
86    /// This can indicate environmental interference (CPU frequency scaling,
87    /// concurrent processes, etc.) that invalidates the covariance estimate.
88    ConditionsChanged {
89        /// Human-readable description of what changed.
90        message: String,
91        /// Suggested remediation.
92        guidance: String,
93        /// The specific drift metrics that were detected.
94        drift_description: String,
95    },
96
97    /// Threshold was elevated and pass criterion was met at effective threshold.
98    ///
99    /// The measurement floor exceeded the user's requested threshold, so inference
100    /// was performed at an elevated effective threshold. The posterior probability
101    /// dropped below pass_threshold at θ_eff, but since θ_eff > θ_user + ε, we
102    /// cannot guarantee the user's original requirement is met.
103    ///
104    /// This is NOT a quality gate - it's checked at decision time in loop_runner.
105    ThresholdElevated {
106        /// User's requested threshold in nanoseconds (θ_user).
107        theta_user: f64,
108        /// Effective threshold used for inference (θ_eff = max(θ_user, θ_floor)).
109        theta_eff: f64,
110        /// Posterior probability at θ_eff (was < pass_threshold).
111        leak_probability_at_eff: f64,
112        /// True: P(leak > θ_eff) < pass_threshold.
113        meets_pass_criterion_at_eff: bool,
114        /// True: θ_floor at max_samples would be ≤ θ_user + ε.
115        achievable_at_max: bool,
116        /// Human-readable message.
117        message: String,
118        /// Suggested remediation.
119        guidance: String,
120    },
121}
122
123/// Configuration for quality gate thresholds.
124#[derive(Debug, Clone)]
125pub struct QualityGateConfig {
126    /// Maximum variance ratio (posterior/prior) before declaring data uninformative.
127    /// Default: 0.5 (posterior variance must be at most 50% of prior).
128    pub max_variance_ratio: f64,
129
130    /// Minimum sum of recent KL divergences before declaring learning stalled.
131    /// Default: 0.001
132    pub min_kl_sum: f64,
133
134    /// Maximum extrapolated time as multiple of budget.
135    /// Default: 10.0 (stop if estimated time > 10x budget).
136    pub max_time_multiplier: f64,
137
138    /// Time budget for adaptive sampling in seconds.
139    pub time_budget_secs: f64,
140
141    /// Maximum samples per class.
142    pub max_samples: usize,
143
144    /// Pass threshold for leak probability.
145    pub pass_threshold: f64,
146
147    /// Fail threshold for leak probability.
148    pub fail_threshold: f64,
149
150    /// Whether to enable condition drift detection (Gate 6).
151    /// Default: true
152    pub enable_drift_detection: bool,
153
154    /// Thresholds for condition drift detection.
155    pub drift_thresholds: DriftThresholds,
156}
157
158impl Default for QualityGateConfig {
159    fn default() -> Self {
160        Self {
161            max_variance_ratio: 0.5,
162            min_kl_sum: 0.001,
163            max_time_multiplier: 10.0,
164            time_budget_secs: 30.0,
165            max_samples: DEFAULT_MAX_SAMPLES,
166            pass_threshold: DEFAULT_PASS_THRESHOLD,
167            fail_threshold: DEFAULT_FAIL_THRESHOLD,
168            enable_drift_detection: true,
169            drift_thresholds: DriftThresholds::default(),
170        }
171    }
172}
173
174/// Inputs required to check quality gates.
175///
176/// This is a stateless struct that contains all the information needed to
177/// check quality gates, avoiding the need for mutable state or time tracking.
178#[derive(Debug)]
179pub struct QualityGateCheckInputs<'a> {
180    /// Current posterior distribution.
181    pub posterior: &'a Posterior,
182
183    /// Marginal prior covariance matrix Λ₀^marginal = 2σ²R (for ν=4).
184    /// This is the unconditional prior variance of δ under the t-prior.
185    /// Used by Gate 1 for the KL divergence check (spec §3.5.2).
186    pub prior_cov_marginal: &'a Matrix9,
187
188    /// Effect threshold θ in nanoseconds (user's requested threshold).
189    pub theta_ns: f64,
190
191    /// Total samples per class collected so far.
192    pub n_total: usize,
193
194    /// Elapsed time in seconds since adaptive phase started.
195    pub elapsed_secs: f64,
196
197    /// Sum of recent KL divergences (last 5 batches).
198    /// Pass `None` if fewer than 5 batches have been collected.
199    pub recent_kl_sum: Option<f64>,
200
201    /// Samples per second (throughput from calibration).
202    pub samples_per_second: f64,
203
204    /// Calibration snapshot for drift detection.
205    /// Pass `None` to skip drift detection.
206    pub calibration_snapshot: Option<&'a CalibrationSnapshot>,
207
208    /// Current stats snapshot for drift detection.
209    /// Pass `None` to skip drift detection.
210    pub current_stats_snapshot: Option<&'a CalibrationSnapshot>,
211
212    /// Floor-rate constant (c_floor) from calibration.
213    /// Used to compute theta_floor(n) = c_floor / sqrt(n).
214    pub c_floor: f64,
215
216    /// Timer tick floor (theta_tick) from calibration.
217    /// The floor below which timer quantization dominates.
218    pub theta_tick: f64,
219
220    /// Projection mismatch Q statistic (r^T Σ^{-1} r).
221    /// Pass `None` if not yet computed.
222    pub projection_mismatch_q: Option<f64>,
223
224    /// Projection mismatch threshold from bootstrap calibration.
225    pub projection_mismatch_thresh: f64,
226
227    // ==================== v5.4 Gibbs sampler fields ====================
228    /// Whether the Gibbs sampler's lambda chain mixed well (v5.4).
229    /// `None` if not using Gibbs sampler (mixture mode).
230    /// When `Some(false)`, indicates potential posterior unreliability.
231    pub lambda_mixing_ok: Option<bool>,
232}
233
234/// Check all quality gates and return result.
235///
236/// Gates are checked in priority order. Returns `Continue` if all pass,
237/// or `Stop` with the reason if any gate triggers.
238///
239/// **Gate order (spec Section 3.5.2, v5.5):**
240/// 1. Posterior too close to prior (data not informative)
241/// 2. Learning rate collapsed
242/// 3. Would take too long
243/// 4. Time budget exceeded
244/// 5. Sample budget exceeded
245/// 6. Condition drift detected
246///
247/// **Note**: Threshold elevation (v5.5) is NOT a quality gate. It's checked at
248/// decision time in loop_runner.rs. The decision rule requires:
249/// - Pass: P < pass_threshold AND θ_eff ≤ θ_user + ε
250/// - Fail: P > fail_threshold (propagates regardless of elevation)
251/// - ThresholdElevated: P < pass_threshold AND θ_eff > θ_user + ε
252///
253/// # Arguments
254///
255/// * `inputs` - All inputs needed for gate checks (stateless)
256/// * `config` - Quality gate configuration
257pub fn check_quality_gates(
258    inputs: &QualityGateCheckInputs,
259    config: &QualityGateConfig,
260) -> QualityGateResult {
261    // Gate 1: Posterior too close to prior (data not informative) - v5.6: uses KL divergence
262    if let Some(reason) = check_kl_divergence(inputs, config) {
263        return QualityGateResult::Stop(reason);
264    }
265
266    // Gate 2: Learning rate collapsed
267    if let Some(reason) = check_learning_rate(inputs, config) {
268        return QualityGateResult::Stop(reason);
269    }
270
271    // Gate 3: Would take too long
272    if let Some(reason) = check_extrapolated_time(inputs, config) {
273        return QualityGateResult::Stop(reason);
274    }
275
276    // Gate 4: Time budget exceeded
277    if let Some(reason) = check_time_budget(inputs, config) {
278        return QualityGateResult::Stop(reason);
279    }
280
281    // Gate 5: Sample budget exceeded
282    if let Some(reason) = check_sample_budget(inputs, config) {
283        return QualityGateResult::Stop(reason);
284    }
285
286    // Gate 6: Condition drift detected
287    if let Some(reason) = check_condition_drift(inputs, config) {
288        return QualityGateResult::Stop(reason);
289    }
290
291    QualityGateResult::Continue
292}
293
294/// Check if the requested threshold is achievable at max_samples (helper for v5.5).
295///
296/// Returns `true` if theta_floor at max_samples would be ≤ theta_user + epsilon,
297/// meaning more samples could eventually achieve the user's threshold.
298///
299/// This is NOT a verdict-blocking gate in v5.5. It's used by the decision logic
300/// to populate the `achievable_at_max` field in ThresholdElevated outcomes.
301///
302/// v5.6: Uses n_eff = max(1, floor(max_samples / block_length)) for proper
303/// scaling under temporal dependence.
304pub fn compute_achievable_at_max(
305    c_floor: f64,
306    theta_tick: f64,
307    theta_user: f64,
308    max_samples: usize,
309    block_length: usize,
310) -> bool {
311    // Research mode (theta_user = 0) is always "achievable" (no user target)
312    if theta_user <= 0.0 {
313        return true;
314    }
315
316    // v5.6: Use n_eff for achievability check (spec §3.3.4)
317    let n_eff_max = if block_length > 0 {
318        (max_samples / block_length).max(1)
319    } else {
320        max_samples.max(1)
321    };
322
323    // Compute theta_floor at max_samples using n_eff
324    let theta_floor_at_max = libm::fmax(c_floor / libm::sqrt(n_eff_max as f64), theta_tick);
325
326    // Compute epsilon: max(theta_tick, 1e-6 * theta_user)
327    let epsilon = libm::fmax(theta_tick, 1e-6 * theta_user);
328
329    // Achievable if floor at max_samples would be within tolerance of user threshold
330    theta_floor_at_max <= theta_user + epsilon
331}
332
333/// Check if the threshold is elevated beyond tolerance (v5.5).
334///
335/// Returns `true` if θ_eff > θ_user + ε, meaning the effective threshold
336/// is elevated beyond the tolerance band around the user's requested threshold.
337///
338/// The epsilon tolerance is: ε = max(θ_tick, 1e-6 * θ_user)
339///
340/// This check is used at decision time: if P < pass_threshold but the threshold
341/// is elevated, we return ThresholdElevated instead of Pass.
342pub fn is_threshold_elevated(theta_eff: f64, theta_user: f64, theta_tick: f64) -> bool {
343    // Research mode (theta_user <= 0) is never "elevated"
344    if theta_user <= 0.0 {
345        return false;
346    }
347
348    // Compute epsilon: max(theta_tick, 1e-6 * theta_user)
349    let epsilon = libm::fmax(theta_tick, 1e-6 * theta_user);
350
351    // Elevated if effective threshold exceeds user threshold + tolerance
352    theta_eff > theta_user + epsilon
353}
354
355/// Minimum KL divergence threshold (nats) for Gate 1 (spec §3.5.2 v5.6).
356const KL_MIN: f64 = 0.7;
357
358/// Gate 1: Check if posterior learned from prior using KL divergence (spec §3.5.2 v5.6).
359///
360/// v5.6 CHANGE: Replaces the log-det variance ratio with full KL divergence.
361///
362/// KL = 0.5 * (tr(Λ₀⁻¹Λ_post) + μ_postᵀΛ₀⁻¹μ_post - 9 + ln|Λ₀|/|Λ_post|)
363///
364/// Gate triggers when KL < KL_min where KL_min := 0.7 nats.
365///
366/// v5.6 CHANGE: NO decisive probability bypass - the bypass logic is removed entirely.
367/// This ensures consistent treatment regardless of observed leak probability.
368///
369/// **Cholesky fallback (spec §3.5.2)**: If Cholesky fails after jitter ladder,
370/// uses trace ratio as conservative fallback.
371fn check_kl_divergence(
372    inputs: &QualityGateCheckInputs,
373    _config: &QualityGateConfig,
374) -> Option<InconclusiveReason> {
375    // v5.6: NO decisive probability bypass - removed per spec
376    // The gate checks KL divergence unconditionally
377
378    // v5.4: Use the marginal prior covariance Λ₀^marginal = 2σ²R (spec §3.5.2)
379    let prior_cov = inputs.prior_cov_marginal;
380    let post_cov = &inputs.posterior.lambda_post;
381    let post_mean = &inputs.posterior.delta_post;
382
383    // Try to compute KL divergence via Cholesky
384    let kl = match compute_kl_divergence(prior_cov, post_cov, post_mean) {
385        Some(kl) => kl,
386        None => {
387            // Fall back to trace ratio if KL computation fails
388            let trace_ratio = post_cov.trace() / prior_cov.trace();
389            if trace_ratio > 0.5 {
390                return Some(InconclusiveReason::DataTooNoisy {
391                    message: alloc::format!(
392                        "Posterior variance is {:.0}% of prior; data not informative (KL computation failed)",
393                        trace_ratio * 100.0
394                    ),
395                    guidance: String::from("Try: cycle counter, reduce system load, increase batch size"),
396                    variance_ratio: trace_ratio,
397                });
398            }
399            return None;
400        }
401    };
402
403    // Trigger when KL < KL_min (spec §3.5.2 v5.6)
404    if kl < KL_MIN {
405        return Some(InconclusiveReason::DataTooNoisy {
406            message: alloc::format!(
407                "KL divergence {:.2} nats < {:.1} threshold; posterior ≈ prior",
408                kl,
409                KL_MIN
410            ),
411            guidance: String::from("Try: cycle counter, reduce system load, increase batch size"),
412            variance_ratio: kl / KL_MIN, // Report KL ratio for diagnostics
413        });
414    }
415
416    None
417}
418
419/// Compute KL(N(μ_post, Λ_post) || N(0, Λ₀)) via Cholesky (spec §3.5.2 v5.6).
420///
421/// KL = 0.5 * (tr(Λ₀⁻¹Λ_post) + μ_postᵀΛ₀⁻¹μ_post - d + ln|Λ₀| - ln|Λ_post|)
422///
423/// Returns `None` if Cholesky fails even after jitter ladder.
424fn compute_kl_divergence(
425    prior_cov: &crate::types::Matrix9,
426    post_cov: &crate::types::Matrix9,
427    post_mean: &crate::types::Vector9,
428) -> Option<f64> {
429    // Try Cholesky with jitter ladder (spec §3.5.2)
430    let prior_chol = try_cholesky_with_jitter(prior_cov)?;
431    let post_chol = try_cholesky_with_jitter(post_cov)?;
432
433    // Log determinants from Cholesky: log|A| = 2 * sum(log(L_ii))
434    let prior_log_det: f64 = (0..9)
435        .map(|i| libm::log(prior_chol.l()[(i, i)]))
436        .sum::<f64>()
437        * 2.0;
438    let post_log_det: f64 = (0..9)
439        .map(|i| libm::log(post_chol.l()[(i, i)]))
440        .sum::<f64>()
441        * 2.0;
442
443    if !prior_log_det.is_finite() || !post_log_det.is_finite() {
444        return None;
445    }
446
447    // tr(Λ₀⁻¹Λ_post) via Cholesky solves
448    let mut trace_term = 0.0;
449    for j in 0..9 {
450        let col = post_cov.column(j).into_owned();
451        let solved = prior_chol.solve(&col);
452        trace_term += solved[j];
453    }
454
455    // μ_postᵀΛ₀⁻¹μ_post
456    let solved_mean = prior_chol.solve(post_mean);
457    let quad_term = post_mean.dot(&solved_mean);
458
459    // KL = 0.5 * (tr + quad - d + ln|Λ₀| - ln|Λ_post|)
460    let kl = 0.5 * (trace_term + quad_term - 9.0 + prior_log_det - post_log_det);
461
462    // KL should be non-negative
463    Some(kl.max(0.0))
464}
465
466/// Try Cholesky decomposition with jitter ladder (spec §3.5.2 v5.6).
467///
468/// Starts at 10⁻¹⁰ and increases by factors of 10 until success or 10⁻⁴.
469fn try_cholesky_with_jitter(
470    matrix: &crate::types::Matrix9,
471) -> Option<nalgebra::Cholesky<f64, nalgebra::Const<9>>> {
472    // Try without jitter first
473    if let Some(chol) = nalgebra::Cholesky::new(*matrix) {
474        return Some(chol);
475    }
476
477    // Jitter ladder: 10⁻¹⁰, 10⁻⁹, ..., 10⁻⁴
478    for exp in -10..=-4 {
479        let jitter = libm::pow(10.0, exp as f64);
480        let jittered = matrix + crate::types::Matrix9::identity() * jitter;
481        if let Some(chol) = nalgebra::Cholesky::new(jittered) {
482            return Some(chol);
483        }
484    }
485
486    None
487}
488
489/// Gate 2: Check if learning has stalled (posterior stopped updating).
490fn check_learning_rate(
491    inputs: &QualityGateCheckInputs,
492    config: &QualityGateConfig,
493) -> Option<InconclusiveReason> {
494    let recent_kl_sum = inputs.recent_kl_sum?;
495
496    if recent_kl_sum < config.min_kl_sum {
497        return Some(InconclusiveReason::NotLearning {
498            message: String::from("Posterior stopped updating despite new data"),
499            guidance: String::from(
500                "Measurement may have systematic issues or effect is very close to boundary",
501            ),
502            recent_kl_sum,
503        });
504    }
505
506    None
507}
508
509/// Gate 3: Check if reaching a decision would take too long.
510fn check_extrapolated_time(
511    inputs: &QualityGateCheckInputs,
512    config: &QualityGateConfig,
513) -> Option<InconclusiveReason> {
514    // Need at least some samples to extrapolate
515    if inputs.n_total < 100 {
516        return None;
517    }
518
519    let samples_needed = extrapolate_samples_to_decision(inputs, config);
520
521    if samples_needed == usize::MAX {
522        // Can't extrapolate
523        return None;
524    }
525
526    let additional_samples = samples_needed.saturating_sub(inputs.n_total);
527    let time_needed_secs = additional_samples as f64 / inputs.samples_per_second;
528
529    if time_needed_secs > config.time_budget_secs * config.max_time_multiplier {
530        return Some(InconclusiveReason::WouldTakeTooLong {
531            estimated_time_secs: time_needed_secs,
532            samples_needed,
533            guidance: alloc::format!(
534                "Effect may be very close to threshold; consider adjusting theta (current: {:.1}ns)",
535                inputs.theta_ns
536            ),
537        });
538    }
539
540    None
541}
542
543/// Gate 4: Check if time budget is exceeded.
544fn check_time_budget(
545    inputs: &QualityGateCheckInputs,
546    config: &QualityGateConfig,
547) -> Option<InconclusiveReason> {
548    if inputs.elapsed_secs > config.time_budget_secs {
549        return Some(InconclusiveReason::TimeBudgetExceeded {
550            current_probability: inputs.posterior.leak_probability,
551            samples_collected: inputs.n_total,
552            elapsed_secs: inputs.elapsed_secs,
553        });
554    }
555
556    None
557}
558
559/// Gate 5: Check if sample budget is exceeded.
560fn check_sample_budget(
561    inputs: &QualityGateCheckInputs,
562    config: &QualityGateConfig,
563) -> Option<InconclusiveReason> {
564    if inputs.n_total >= config.max_samples {
565        return Some(InconclusiveReason::SampleBudgetExceeded {
566            current_probability: inputs.posterior.leak_probability,
567            samples_collected: inputs.n_total,
568        });
569    }
570
571    None
572}
573
574/// Extrapolate how many samples are needed to reach a decision.
575///
576/// Uses the fact that posterior standard deviation decreases as sqrt(n).
577/// If current std is much larger than the margin to threshold, we can
578/// estimate how many more samples are needed.
579fn extrapolate_samples_to_decision(
580    inputs: &QualityGateCheckInputs,
581    config: &QualityGateConfig,
582) -> usize {
583    let p = inputs.posterior.leak_probability;
584
585    // Distance to nearest threshold
586    let margin = libm::fmin(
587        libm::fabs(p - config.pass_threshold),
588        libm::fabs(config.fail_threshold - p),
589    );
590
591    if margin < 1e-9 {
592        return usize::MAX; // Already at threshold
593    }
594
595    // Posterior std (use trace of 2D projection as proxy for overall uncertainty)
596    let current_std = libm::sqrt(inputs.posterior.beta_proj_cov.trace());
597
598    if current_std < 1e-9 {
599        return inputs.n_total; // Already very certain
600    }
601
602    // Std scales as 1/sqrt(n), so to reduce std by factor k we need k^2 more samples
603    // We need std to be comparable to margin for a clear decision
604    let std_reduction_needed = current_std / margin;
605
606    if std_reduction_needed <= 1.0 {
607        // Current uncertainty is already small enough
608        return inputs.n_total;
609    }
610
611    let sample_multiplier = std_reduction_needed * std_reduction_needed;
612
613    // Cap at 100x current to avoid overflow
614    let multiplier = libm::fmin(sample_multiplier, 100.0);
615
616    libm::ceil(inputs.n_total as f64 * multiplier) as usize
617}
618
619/// Gate 6: Check if measurement conditions changed during the test.
620///
621/// Compares the calibration statistics snapshot with the current state's
622/// online statistics to detect environmental interference (CPU frequency
623/// scaling, concurrent processes, etc.) that would invalidate the covariance
624/// estimate.
625///
626/// See spec Section 2.6, Gate 6.
627fn check_condition_drift(
628    inputs: &QualityGateCheckInputs,
629    config: &QualityGateConfig,
630) -> Option<InconclusiveReason> {
631    // Skip if drift detection is disabled
632    if !config.enable_drift_detection {
633        return None;
634    }
635
636    // Need both snapshots to detect drift
637    let cal_snapshot = inputs.calibration_snapshot?;
638    let post_snapshot = inputs.current_stats_snapshot?;
639
640    // Compute drift between calibration and post-test
641    let drift = ConditionDrift::compute(cal_snapshot, post_snapshot);
642
643    // Check if drift exceeds thresholds
644    if drift.is_significant(&config.drift_thresholds) {
645        return Some(InconclusiveReason::ConditionsChanged {
646            message: String::from("Measurement conditions changed during test"),
647            guidance: String::from(
648                "Ensure stable environment: disable CPU frequency scaling, \
649                minimize concurrent processes, use performance CPU governor",
650            ),
651            drift_description: drift.description(&config.drift_thresholds),
652        });
653    }
654
655    None
656}
657
658#[cfg(test)]
659mod tests {
660    use super::*;
661    use crate::statistics::StatsSnapshot;
662    use crate::types::{Matrix2, Vector2, Vector9};
663
664    fn make_posterior(leak_prob: f64, variance: f64) -> Posterior {
665        Posterior::new(
666            Vector9::zeros(),
667            Matrix9::identity() * variance,
668            Vector2::new(5.0, 3.0),
669            Matrix2::new(variance, 0.0, 0.0, variance),
670            Vec::new(), // beta_draws
671            leak_prob,
672            1.0, // projection_mismatch_q
673            1000,
674        )
675    }
676
677    fn make_prior_cov_marginal() -> Matrix9 {
678        Matrix9::identity() * 100.0 // Prior variance = 100 per dimension
679    }
680
681    fn make_inputs<'a>(
682        posterior: &'a Posterior,
683        prior_cov_marginal: &'a Matrix9,
684    ) -> QualityGateCheckInputs<'a> {
685        QualityGateCheckInputs {
686            posterior,
687            prior_cov_marginal,
688            theta_ns: 100.0,
689            n_total: 5000,
690            elapsed_secs: 5.0,
691            recent_kl_sum: Some(0.05),
692            samples_per_second: 100_000.0,
693            calibration_snapshot: None,
694            current_stats_snapshot: None,
695            c_floor: 3535.5, // ~50 * sqrt(5000)
696            theta_tick: 1.0,
697            projection_mismatch_q: None,
698            projection_mismatch_thresh: 18.48,
699            lambda_mixing_ok: None,
700        }
701    }
702
703    #[test]
704    fn test_kl_divergence_gate_passes() {
705        // v5.6: Use KL divergence instead of variance ratio
706        // Small posterior variance = large covariance contraction = high KL
707        let posterior = make_posterior(0.5, 10.0);
708        let prior_cov_marginal = make_prior_cov_marginal();
709        let inputs = make_inputs(&posterior, &prior_cov_marginal);
710        let config = QualityGateConfig::default();
711
712        let result = check_kl_divergence(&inputs, &config);
713        assert!(
714            result.is_none(),
715            "Low posterior variance should give high KL (pass)"
716        );
717    }
718
719    #[test]
720    fn test_kl_divergence_gate_fails() {
721        // v5.6: Use KL divergence instead of variance ratio
722        // High posterior variance ≈ prior = low KL
723        let posterior = make_posterior(0.5, 95.0); // Close to prior variance of 100
724        let prior_cov_marginal = make_prior_cov_marginal();
725        let inputs = make_inputs(&posterior, &prior_cov_marginal);
726        let config = QualityGateConfig::default();
727
728        let result = check_kl_divergence(&inputs, &config);
729        assert!(
730            matches!(result, Some(InconclusiveReason::DataTooNoisy { .. })),
731            "Posterior ≈ prior should give low KL (fail)"
732        );
733    }
734
735    #[test]
736    fn test_learning_rate_gate_passes() {
737        let posterior = make_posterior(0.5, 10.0);
738        let prior_cov_marginal = make_prior_cov_marginal();
739        let mut inputs = make_inputs(&posterior, &prior_cov_marginal);
740        inputs.recent_kl_sum = Some(0.05); // Sum > 0.001
741        let config = QualityGateConfig::default();
742
743        let result = check_learning_rate(&inputs, &config);
744        assert!(result.is_none());
745    }
746
747    #[test]
748    fn test_learning_rate_gate_fails() {
749        let posterior = make_posterior(0.5, 10.0);
750        let prior_cov_marginal = make_prior_cov_marginal();
751        let mut inputs = make_inputs(&posterior, &prior_cov_marginal);
752        inputs.recent_kl_sum = Some(0.0005); // Sum < 0.001
753        let config = QualityGateConfig::default();
754
755        let result = check_learning_rate(&inputs, &config);
756        assert!(matches!(
757            result,
758            Some(InconclusiveReason::NotLearning { .. })
759        ));
760    }
761
762    #[test]
763    fn test_time_budget_gate() {
764        let posterior = make_posterior(0.5, 10.0);
765        let prior_cov_marginal = make_prior_cov_marginal();
766        let mut inputs = make_inputs(&posterior, &prior_cov_marginal);
767        inputs.elapsed_secs = 35.0; // Exceeds 30s budget
768        let config = QualityGateConfig::default();
769
770        let result = check_time_budget(&inputs, &config);
771        assert!(matches!(
772            result,
773            Some(InconclusiveReason::TimeBudgetExceeded { .. })
774        ));
775    }
776
777    #[test]
778    fn test_sample_budget_gate() {
779        let posterior = make_posterior(0.5, 10.0);
780        let prior_cov_marginal = make_prior_cov_marginal();
781        let mut inputs = make_inputs(&posterior, &prior_cov_marginal);
782        inputs.n_total = 1_000_001; // Exceeds 1M budget
783        let config = QualityGateConfig::default();
784
785        let result = check_sample_budget(&inputs, &config);
786        assert!(matches!(
787            result,
788            Some(InconclusiveReason::SampleBudgetExceeded { .. })
789        ));
790    }
791
792    #[test]
793    fn test_condition_drift_gate_no_snapshots() {
794        let posterior = make_posterior(0.5, 10.0);
795        let prior_cov_marginal = make_prior_cov_marginal();
796        let inputs = make_inputs(&posterior, &prior_cov_marginal);
797        // No snapshots provided
798        let config = QualityGateConfig::default();
799
800        let result = check_condition_drift(&inputs, &config);
801        assert!(result.is_none());
802    }
803
804    #[test]
805    fn test_condition_drift_gate_no_drift() {
806        let posterior = make_posterior(0.5, 10.0);
807        let prior_cov_marginal = make_prior_cov_marginal();
808
809        let stats = StatsSnapshot {
810            mean: 100.0,
811            variance: 25.0,
812            autocorr_lag1: 0.1,
813            count: 5000,
814        };
815        let cal_snapshot = CalibrationSnapshot::new(stats, stats);
816        let post_snapshot = CalibrationSnapshot::new(stats, stats);
817
818        let mut inputs = make_inputs(&posterior, &prior_cov_marginal);
819        inputs.calibration_snapshot = Some(&cal_snapshot);
820        inputs.current_stats_snapshot = Some(&post_snapshot);
821
822        let config = QualityGateConfig::default();
823
824        let result = check_condition_drift(&inputs, &config);
825        assert!(result.is_none());
826    }
827
828    #[test]
829    fn test_condition_drift_gate_detects_variance_change() {
830        let posterior = make_posterior(0.5, 10.0);
831        let prior_cov_marginal = make_prior_cov_marginal();
832
833        let cal_stats = StatsSnapshot {
834            mean: 100.0,
835            variance: 25.0,
836            autocorr_lag1: 0.1,
837            count: 5000,
838        };
839        let post_stats = StatsSnapshot {
840            mean: 100.0,
841            variance: 75.0, // 3x variance increase
842            autocorr_lag1: 0.1,
843            count: 5000,
844        };
845        let cal_snapshot = CalibrationSnapshot::new(cal_stats, cal_stats);
846        let post_snapshot = CalibrationSnapshot::new(post_stats, post_stats);
847
848        let mut inputs = make_inputs(&posterior, &prior_cov_marginal);
849        inputs.calibration_snapshot = Some(&cal_snapshot);
850        inputs.current_stats_snapshot = Some(&post_snapshot);
851
852        let config = QualityGateConfig::default();
853
854        let result = check_condition_drift(&inputs, &config);
855        assert!(matches!(
856            result,
857            Some(InconclusiveReason::ConditionsChanged { .. })
858        ));
859    }
860
861    #[test]
862    fn test_full_quality_gates_pass() {
863        let posterior = make_posterior(0.5, 10.0);
864        let prior_cov_marginal = make_prior_cov_marginal();
865        let inputs = make_inputs(&posterior, &prior_cov_marginal);
866        let config = QualityGateConfig::default();
867
868        let result = check_quality_gates(&inputs, &config);
869        assert!(matches!(result, QualityGateResult::Continue));
870    }
871}