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
229    /// Whether the Gibbs sampler's lambda chain mixed well (v5.4).
230    /// `None` if not using Gibbs sampler (mixture mode).
231    /// When `Some(false)`, indicates potential posterior unreliability.
232    pub lambda_mixing_ok: Option<bool>,
233}
234
235/// Check all quality gates and return result.
236///
237/// Gates are checked in priority order. Returns `Continue` if all pass,
238/// or `Stop` with the reason if any gate triggers.
239///
240/// **Gate order (spec Section 3.5.2, v5.5):**
241/// 1. Posterior too close to prior (data not informative)
242/// 2. Learning rate collapsed
243/// 3. Would take too long
244/// 4. Time budget exceeded
245/// 5. Sample budget exceeded
246/// 6. Condition drift detected
247///
248/// **Note**: Threshold elevation (v5.5) is NOT a quality gate. It's checked at
249/// decision time in loop_runner.rs. The decision rule requires:
250/// - Pass: P < pass_threshold AND θ_eff ≤ θ_user + ε
251/// - Fail: P > fail_threshold (propagates regardless of elevation)
252/// - ThresholdElevated: P < pass_threshold AND θ_eff > θ_user + ε
253///
254/// # Arguments
255///
256/// * `inputs` - All inputs needed for gate checks (stateless)
257/// * `config` - Quality gate configuration
258pub fn check_quality_gates(
259    inputs: &QualityGateCheckInputs,
260    config: &QualityGateConfig,
261) -> QualityGateResult {
262    // Gate 1: Posterior too close to prior (data not informative) - v5.6: uses KL divergence
263    if let Some(reason) = check_kl_divergence(inputs, config) {
264        return QualityGateResult::Stop(reason);
265    }
266
267    // Gate 2: Learning rate collapsed
268    if let Some(reason) = check_learning_rate(inputs, config) {
269        return QualityGateResult::Stop(reason);
270    }
271
272    // Gate 3: Would take too long
273    if let Some(reason) = check_extrapolated_time(inputs, config) {
274        return QualityGateResult::Stop(reason);
275    }
276
277    // Gate 4: Time budget exceeded
278    if let Some(reason) = check_time_budget(inputs, config) {
279        return QualityGateResult::Stop(reason);
280    }
281
282    // Gate 5: Sample budget exceeded
283    if let Some(reason) = check_sample_budget(inputs, config) {
284        return QualityGateResult::Stop(reason);
285    }
286
287    // Gate 6: Condition drift detected
288    if let Some(reason) = check_condition_drift(inputs, config) {
289        return QualityGateResult::Stop(reason);
290    }
291
292    QualityGateResult::Continue
293}
294
295/// Check if the requested threshold is achievable at max_samples (helper for v5.5).
296///
297/// Returns `true` if theta_floor at max_samples would be ≤ theta_user + epsilon,
298/// meaning more samples could eventually achieve the user's threshold.
299///
300/// This is NOT a verdict-blocking gate in v5.5. It's used by the decision logic
301/// to populate the `achievable_at_max` field in ThresholdElevated outcomes.
302///
303/// v5.6: Uses n_eff = max(1, floor(max_samples / block_length)) for proper
304/// scaling under temporal dependence.
305pub fn compute_achievable_at_max(
306    c_floor: f64,
307    theta_tick: f64,
308    theta_user: f64,
309    max_samples: usize,
310    block_length: usize,
311) -> bool {
312    // Research mode (theta_user = 0) is always "achievable" (no user target)
313    if theta_user <= 0.0 {
314        return true;
315    }
316
317    // v5.6: Use n_eff for achievability check (spec §3.3.4)
318    let n_eff_max = if block_length > 0 {
319        (max_samples / block_length).max(1)
320    } else {
321        max_samples.max(1)
322    };
323
324    // Compute theta_floor at max_samples using n_eff
325    let theta_floor_at_max = libm::fmax(
326        c_floor / libm::sqrt(n_eff_max as f64),
327        theta_tick,
328    );
329
330    // Compute epsilon: max(theta_tick, 1e-6 * theta_user)
331    let epsilon = libm::fmax(theta_tick, 1e-6 * theta_user);
332
333    // Achievable if floor at max_samples would be within tolerance of user threshold
334    theta_floor_at_max <= theta_user + epsilon
335}
336
337/// Check if the threshold is elevated beyond tolerance (v5.5).
338///
339/// Returns `true` if θ_eff > θ_user + ε, meaning the effective threshold
340/// is elevated beyond the tolerance band around the user's requested threshold.
341///
342/// The epsilon tolerance is: ε = max(θ_tick, 1e-6 * θ_user)
343///
344/// This check is used at decision time: if P < pass_threshold but the threshold
345/// is elevated, we return ThresholdElevated instead of Pass.
346pub fn is_threshold_elevated(theta_eff: f64, theta_user: f64, theta_tick: f64) -> bool {
347    // Research mode (theta_user <= 0) is never "elevated"
348    if theta_user <= 0.0 {
349        return false;
350    }
351
352    // Compute epsilon: max(theta_tick, 1e-6 * theta_user)
353    let epsilon = libm::fmax(theta_tick, 1e-6 * theta_user);
354
355    // Elevated if effective threshold exceeds user threshold + tolerance
356    theta_eff > theta_user + epsilon
357}
358
359/// Minimum KL divergence threshold (nats) for Gate 1 (spec §3.5.2 v5.6).
360const KL_MIN: f64 = 0.7;
361
362/// Gate 1: Check if posterior learned from prior using KL divergence (spec §3.5.2 v5.6).
363///
364/// v5.6 CHANGE: Replaces the log-det variance ratio with full KL divergence.
365///
366/// KL = 0.5 * (tr(Λ₀⁻¹Λ_post) + μ_postᵀΛ₀⁻¹μ_post - 9 + ln|Λ₀|/|Λ_post|)
367///
368/// Gate triggers when KL < KL_min where KL_min := 0.7 nats.
369///
370/// v5.6 CHANGE: NO decisive probability bypass - the bypass logic is removed entirely.
371/// This ensures consistent treatment regardless of observed leak probability.
372///
373/// **Cholesky fallback (spec §3.5.2)**: If Cholesky fails after jitter ladder,
374/// uses trace ratio as conservative fallback.
375fn check_kl_divergence(
376    inputs: &QualityGateCheckInputs,
377    _config: &QualityGateConfig,
378) -> Option<InconclusiveReason> {
379    // v5.6: NO decisive probability bypass - removed per spec
380    // The gate checks KL divergence unconditionally
381
382    // v5.4: Use the marginal prior covariance Λ₀^marginal = 2σ²R (spec §3.5.2)
383    let prior_cov = inputs.prior_cov_marginal;
384    let post_cov = &inputs.posterior.lambda_post;
385    let post_mean = &inputs.posterior.delta_post;
386
387    // Try to compute KL divergence via Cholesky
388    let kl = match compute_kl_divergence(prior_cov, post_cov, post_mean) {
389        Some(kl) => kl,
390        None => {
391            // Fall back to trace ratio if KL computation fails
392            let trace_ratio = post_cov.trace() / prior_cov.trace();
393            if trace_ratio > 0.5 {
394                return Some(InconclusiveReason::DataTooNoisy {
395                    message: alloc::format!(
396                        "Posterior variance is {:.0}% of prior; data not informative (KL computation failed)",
397                        trace_ratio * 100.0
398                    ),
399                    guidance: String::from("Try: cycle counter, reduce system load, increase batch size"),
400                    variance_ratio: trace_ratio,
401                });
402            }
403            return None;
404        }
405    };
406
407    // Trigger when KL < KL_min (spec §3.5.2 v5.6)
408    if kl < KL_MIN {
409        return Some(InconclusiveReason::DataTooNoisy {
410            message: alloc::format!(
411                "KL divergence {:.2} nats < {:.1} threshold; posterior ≈ prior",
412                kl, KL_MIN
413            ),
414            guidance: String::from("Try: cycle counter, reduce system load, increase batch size"),
415            variance_ratio: kl / KL_MIN, // Report KL ratio for diagnostics
416        });
417    }
418
419    None
420}
421
422/// Compute KL(N(μ_post, Λ_post) || N(0, Λ₀)) via Cholesky (spec §3.5.2 v5.6).
423///
424/// KL = 0.5 * (tr(Λ₀⁻¹Λ_post) + μ_postᵀΛ₀⁻¹μ_post - d + ln|Λ₀| - ln|Λ_post|)
425///
426/// Returns `None` if Cholesky fails even after jitter ladder.
427fn compute_kl_divergence(
428    prior_cov: &crate::types::Matrix9,
429    post_cov: &crate::types::Matrix9,
430    post_mean: &crate::types::Vector9,
431) -> Option<f64> {
432    // Try Cholesky with jitter ladder (spec §3.5.2)
433    let prior_chol = try_cholesky_with_jitter(prior_cov)?;
434    let post_chol = try_cholesky_with_jitter(post_cov)?;
435
436    // Log determinants from Cholesky: log|A| = 2 * sum(log(L_ii))
437    let prior_log_det: f64 = (0..9)
438        .map(|i| libm::log(prior_chol.l()[(i, i)]))
439        .sum::<f64>() * 2.0;
440    let post_log_det: f64 = (0..9)
441        .map(|i| libm::log(post_chol.l()[(i, i)]))
442        .sum::<f64>() * 2.0;
443
444    if !prior_log_det.is_finite() || !post_log_det.is_finite() {
445        return None;
446    }
447
448    // tr(Λ₀⁻¹Λ_post) via Cholesky solves
449    let mut trace_term = 0.0;
450    for j in 0..9 {
451        let col = post_cov.column(j).into_owned();
452        let solved = prior_chol.solve(&col);
453        trace_term += solved[j];
454    }
455
456    // μ_postᵀΛ₀⁻¹μ_post
457    let solved_mean = prior_chol.solve(post_mean);
458    let quad_term = post_mean.dot(&solved_mean);
459
460    // KL = 0.5 * (tr + quad - d + ln|Λ₀| - ln|Λ_post|)
461    let kl = 0.5 * (trace_term + quad_term - 9.0 + prior_log_det - post_log_det);
462
463    // KL should be non-negative
464    Some(kl.max(0.0))
465}
466
467/// Try Cholesky decomposition with jitter ladder (spec §3.5.2 v5.6).
468///
469/// Starts at 10⁻¹⁰ and increases by factors of 10 until success or 10⁻⁴.
470fn try_cholesky_with_jitter(
471    matrix: &crate::types::Matrix9,
472) -> Option<nalgebra::Cholesky<f64, nalgebra::Const<9>>> {
473    // Try without jitter first
474    if let Some(chol) = nalgebra::Cholesky::new(*matrix) {
475        return Some(chol);
476    }
477
478    // Jitter ladder: 10⁻¹⁰, 10⁻⁹, ..., 10⁻⁴
479    for exp in -10..=-4 {
480        let jitter = libm::pow(10.0, exp as f64);
481        let jittered = matrix + crate::types::Matrix9::identity() * jitter;
482        if let Some(chol) = nalgebra::Cholesky::new(jittered) {
483            return Some(chol);
484        }
485    }
486
487    None
488}
489
490/// Gate 2: Check if learning has stalled (posterior stopped updating).
491fn check_learning_rate(
492    inputs: &QualityGateCheckInputs,
493    config: &QualityGateConfig,
494) -> Option<InconclusiveReason> {
495    let recent_kl_sum = inputs.recent_kl_sum?;
496
497    if recent_kl_sum < config.min_kl_sum {
498        return Some(InconclusiveReason::NotLearning {
499            message: String::from("Posterior stopped updating despite new data"),
500            guidance: String::from(
501                "Measurement may have systematic issues or effect is very close to boundary",
502            ),
503            recent_kl_sum,
504        });
505    }
506
507    None
508}
509
510/// Gate 3: Check if reaching a decision would take too long.
511fn check_extrapolated_time(
512    inputs: &QualityGateCheckInputs,
513    config: &QualityGateConfig,
514) -> Option<InconclusiveReason> {
515    // Need at least some samples to extrapolate
516    if inputs.n_total < 100 {
517        return None;
518    }
519
520    let samples_needed = extrapolate_samples_to_decision(inputs, config);
521
522    if samples_needed == usize::MAX {
523        // Can't extrapolate
524        return None;
525    }
526
527    let additional_samples = samples_needed.saturating_sub(inputs.n_total);
528    let time_needed_secs = additional_samples as f64 / inputs.samples_per_second;
529
530    if time_needed_secs > config.time_budget_secs * config.max_time_multiplier {
531        return Some(InconclusiveReason::WouldTakeTooLong {
532            estimated_time_secs: time_needed_secs,
533            samples_needed,
534            guidance: alloc::format!(
535                "Effect may be very close to threshold; consider adjusting theta (current: {:.1}ns)",
536                inputs.theta_ns
537            ),
538        });
539    }
540
541    None
542}
543
544/// Gate 4: Check if time budget is exceeded.
545fn check_time_budget(
546    inputs: &QualityGateCheckInputs,
547    config: &QualityGateConfig,
548) -> Option<InconclusiveReason> {
549    if inputs.elapsed_secs > config.time_budget_secs {
550        return Some(InconclusiveReason::TimeBudgetExceeded {
551            current_probability: inputs.posterior.leak_probability,
552            samples_collected: inputs.n_total,
553            elapsed_secs: inputs.elapsed_secs,
554        });
555    }
556
557    None
558}
559
560/// Gate 5: Check if sample budget is exceeded.
561fn check_sample_budget(
562    inputs: &QualityGateCheckInputs,
563    config: &QualityGateConfig,
564) -> Option<InconclusiveReason> {
565    if inputs.n_total >= config.max_samples {
566        return Some(InconclusiveReason::SampleBudgetExceeded {
567            current_probability: inputs.posterior.leak_probability,
568            samples_collected: inputs.n_total,
569        });
570    }
571
572    None
573}
574
575/// Extrapolate how many samples are needed to reach a decision.
576///
577/// Uses the fact that posterior standard deviation decreases as sqrt(n).
578/// If current std is much larger than the margin to threshold, we can
579/// estimate how many more samples are needed.
580fn extrapolate_samples_to_decision(
581    inputs: &QualityGateCheckInputs,
582    config: &QualityGateConfig,
583) -> usize {
584    let p = inputs.posterior.leak_probability;
585
586    // Distance to nearest threshold
587    let margin = libm::fmin(
588        libm::fabs(p - config.pass_threshold),
589        libm::fabs(config.fail_threshold - p),
590    );
591
592    if margin < 1e-9 {
593        return usize::MAX; // Already at threshold
594    }
595
596    // Posterior std (use trace of 2D projection as proxy for overall uncertainty)
597    let current_std = libm::sqrt(inputs.posterior.beta_proj_cov.trace());
598
599    if current_std < 1e-9 {
600        return inputs.n_total; // Already very certain
601    }
602
603    // Std scales as 1/sqrt(n), so to reduce std by factor k we need k^2 more samples
604    // We need std to be comparable to margin for a clear decision
605    let std_reduction_needed = current_std / margin;
606
607    if std_reduction_needed <= 1.0 {
608        // Current uncertainty is already small enough
609        return inputs.n_total;
610    }
611
612    let sample_multiplier = std_reduction_needed * std_reduction_needed;
613
614    // Cap at 100x current to avoid overflow
615    let multiplier = libm::fmin(sample_multiplier, 100.0);
616
617    libm::ceil(inputs.n_total as f64 * multiplier) as usize
618}
619
620/// Gate 6: Check if measurement conditions changed during the test.
621///
622/// Compares the calibration statistics snapshot with the current state's
623/// online statistics to detect environmental interference (CPU frequency
624/// scaling, concurrent processes, etc.) that would invalidate the covariance
625/// estimate.
626///
627/// See spec Section 2.6, Gate 6.
628fn check_condition_drift(
629    inputs: &QualityGateCheckInputs,
630    config: &QualityGateConfig,
631) -> Option<InconclusiveReason> {
632    // Skip if drift detection is disabled
633    if !config.enable_drift_detection {
634        return None;
635    }
636
637    // Need both snapshots to detect drift
638    let cal_snapshot = inputs.calibration_snapshot?;
639    let post_snapshot = inputs.current_stats_snapshot?;
640
641    // Compute drift between calibration and post-test
642    let drift = ConditionDrift::compute(cal_snapshot, post_snapshot);
643
644    // Check if drift exceeds thresholds
645    if drift.is_significant(&config.drift_thresholds) {
646        return Some(InconclusiveReason::ConditionsChanged {
647            message: String::from("Measurement conditions changed during test"),
648            guidance: String::from(
649                "Ensure stable environment: disable CPU frequency scaling, \
650                minimize concurrent processes, use performance CPU governor",
651            ),
652            drift_description: drift.description(&config.drift_thresholds),
653        });
654    }
655
656    None
657}
658
659#[cfg(test)]
660mod tests {
661    use super::*;
662    use crate::statistics::StatsSnapshot;
663    use crate::types::{Matrix2, Vector2, Vector9};
664
665    fn make_posterior(leak_prob: f64, variance: f64) -> Posterior {
666        Posterior::new(
667            Vector9::zeros(),
668            Matrix9::identity() * variance,
669            Vector2::new(5.0, 3.0),
670            Matrix2::new(variance, 0.0, 0.0, variance),
671            Vec::new(), // beta_draws
672            leak_prob,
673            1.0, // projection_mismatch_q
674            1000,
675        )
676    }
677
678    fn make_prior_cov_marginal() -> Matrix9 {
679        Matrix9::identity() * 100.0 // Prior variance = 100 per dimension
680    }
681
682    fn make_inputs<'a>(
683        posterior: &'a Posterior,
684        prior_cov_marginal: &'a Matrix9,
685    ) -> QualityGateCheckInputs<'a> {
686        QualityGateCheckInputs {
687            posterior,
688            prior_cov_marginal,
689            theta_ns: 100.0,
690            n_total: 5000,
691            elapsed_secs: 5.0,
692            recent_kl_sum: Some(0.05),
693            samples_per_second: 100_000.0,
694            calibration_snapshot: None,
695            current_stats_snapshot: None,
696            c_floor: 3535.5, // ~50 * sqrt(5000)
697            theta_tick: 1.0,
698            projection_mismatch_q: None,
699            projection_mismatch_thresh: 18.48,
700            lambda_mixing_ok: None,
701        }
702    }
703
704    #[test]
705    fn test_kl_divergence_gate_passes() {
706        // v5.6: Use KL divergence instead of variance ratio
707        // Small posterior variance = large covariance contraction = high KL
708        let posterior = make_posterior(0.5, 10.0);
709        let prior_cov_marginal = make_prior_cov_marginal();
710        let inputs = make_inputs(&posterior, &prior_cov_marginal);
711        let config = QualityGateConfig::default();
712
713        let result = check_kl_divergence(&inputs, &config);
714        assert!(result.is_none(), "Low posterior variance should give high KL (pass)");
715    }
716
717    #[test]
718    fn test_kl_divergence_gate_fails() {
719        // v5.6: Use KL divergence instead of variance ratio
720        // High posterior variance ≈ prior = low KL
721        let posterior = make_posterior(0.5, 95.0); // Close to prior variance of 100
722        let prior_cov_marginal = make_prior_cov_marginal();
723        let inputs = make_inputs(&posterior, &prior_cov_marginal);
724        let config = QualityGateConfig::default();
725
726        let result = check_kl_divergence(&inputs, &config);
727        assert!(matches!(
728            result,
729            Some(InconclusiveReason::DataTooNoisy { .. })
730        ), "Posterior ≈ prior should give low KL (fail)");
731    }
732
733    #[test]
734    fn test_learning_rate_gate_passes() {
735        let posterior = make_posterior(0.5, 10.0);
736        let prior_cov_marginal = make_prior_cov_marginal();
737        let mut inputs = make_inputs(&posterior, &prior_cov_marginal);
738        inputs.recent_kl_sum = Some(0.05); // Sum > 0.001
739        let config = QualityGateConfig::default();
740
741        let result = check_learning_rate(&inputs, &config);
742        assert!(result.is_none());
743    }
744
745    #[test]
746    fn test_learning_rate_gate_fails() {
747        let posterior = make_posterior(0.5, 10.0);
748        let prior_cov_marginal = make_prior_cov_marginal();
749        let mut inputs = make_inputs(&posterior, &prior_cov_marginal);
750        inputs.recent_kl_sum = Some(0.0005); // Sum < 0.001
751        let config = QualityGateConfig::default();
752
753        let result = check_learning_rate(&inputs, &config);
754        assert!(matches!(
755            result,
756            Some(InconclusiveReason::NotLearning { .. })
757        ));
758    }
759
760    #[test]
761    fn test_time_budget_gate() {
762        let posterior = make_posterior(0.5, 10.0);
763        let prior_cov_marginal = make_prior_cov_marginal();
764        let mut inputs = make_inputs(&posterior, &prior_cov_marginal);
765        inputs.elapsed_secs = 35.0; // Exceeds 30s budget
766        let config = QualityGateConfig::default();
767
768        let result = check_time_budget(&inputs, &config);
769        assert!(matches!(
770            result,
771            Some(InconclusiveReason::TimeBudgetExceeded { .. })
772        ));
773    }
774
775    #[test]
776    fn test_sample_budget_gate() {
777        let posterior = make_posterior(0.5, 10.0);
778        let prior_cov_marginal = make_prior_cov_marginal();
779        let mut inputs = make_inputs(&posterior, &prior_cov_marginal);
780        inputs.n_total = 1_000_001; // Exceeds 1M budget
781        let config = QualityGateConfig::default();
782
783        let result = check_sample_budget(&inputs, &config);
784        assert!(matches!(
785            result,
786            Some(InconclusiveReason::SampleBudgetExceeded { .. })
787        ));
788    }
789
790    #[test]
791    fn test_condition_drift_gate_no_snapshots() {
792        let posterior = make_posterior(0.5, 10.0);
793        let prior_cov_marginal = make_prior_cov_marginal();
794        let inputs = make_inputs(&posterior, &prior_cov_marginal);
795        // No snapshots provided
796        let config = QualityGateConfig::default();
797
798        let result = check_condition_drift(&inputs, &config);
799        assert!(result.is_none());
800    }
801
802    #[test]
803    fn test_condition_drift_gate_no_drift() {
804        let posterior = make_posterior(0.5, 10.0);
805        let prior_cov_marginal = make_prior_cov_marginal();
806
807        let stats = StatsSnapshot {
808            mean: 100.0,
809            variance: 25.0,
810            autocorr_lag1: 0.1,
811            count: 5000,
812        };
813        let cal_snapshot = CalibrationSnapshot::new(stats, stats);
814        let post_snapshot = CalibrationSnapshot::new(stats, stats);
815
816        let mut inputs = make_inputs(&posterior, &prior_cov_marginal);
817        inputs.calibration_snapshot = Some(&cal_snapshot);
818        inputs.current_stats_snapshot = Some(&post_snapshot);
819
820        let config = QualityGateConfig::default();
821
822        let result = check_condition_drift(&inputs, &config);
823        assert!(result.is_none());
824    }
825
826    #[test]
827    fn test_condition_drift_gate_detects_variance_change() {
828        let posterior = make_posterior(0.5, 10.0);
829        let prior_cov_marginal = make_prior_cov_marginal();
830
831        let cal_stats = StatsSnapshot {
832            mean: 100.0,
833            variance: 25.0,
834            autocorr_lag1: 0.1,
835            count: 5000,
836        };
837        let post_stats = StatsSnapshot {
838            mean: 100.0,
839            variance: 75.0, // 3x variance increase
840            autocorr_lag1: 0.1,
841            count: 5000,
842        };
843        let cal_snapshot = CalibrationSnapshot::new(cal_stats, cal_stats);
844        let post_snapshot = CalibrationSnapshot::new(post_stats, post_stats);
845
846        let mut inputs = make_inputs(&posterior, &prior_cov_marginal);
847        inputs.calibration_snapshot = Some(&cal_snapshot);
848        inputs.current_stats_snapshot = Some(&post_snapshot);
849
850        let config = QualityGateConfig::default();
851
852        let result = check_condition_drift(&inputs, &config);
853        assert!(matches!(
854            result,
855            Some(InconclusiveReason::ConditionsChanged { .. })
856        ));
857    }
858
859    #[test]
860    fn test_full_quality_gates_pass() {
861        let posterior = make_posterior(0.5, 10.0);
862        let prior_cov_marginal = make_prior_cov_marginal();
863        let inputs = make_inputs(&posterior, &prior_cov_marginal);
864        let config = QualityGateConfig::default();
865
866        let result = check_quality_gates(&inputs, &config);
867        assert!(matches!(result, QualityGateResult::Continue));
868    }
869}