1use 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#[derive(Debug, Clone)]
24pub enum QualityGateResult {
25 Continue,
27
28 Stop(InconclusiveReason),
30}
31
32#[derive(Debug, Clone)]
34pub enum InconclusiveReason {
35 DataTooNoisy {
37 message: String,
39 guidance: String,
41 variance_ratio: f64,
43 },
44
45 NotLearning {
47 message: String,
49 guidance: String,
51 recent_kl_sum: f64,
53 },
54
55 WouldTakeTooLong {
57 estimated_time_secs: f64,
59 samples_needed: usize,
61 guidance: String,
63 },
64
65 TimeBudgetExceeded {
67 current_probability: f64,
69 samples_collected: usize,
71 elapsed_secs: f64,
73 },
74
75 SampleBudgetExceeded {
77 current_probability: f64,
79 samples_collected: usize,
81 },
82
83 ConditionsChanged {
89 message: String,
91 guidance: String,
93 drift_description: String,
95 },
96
97 ThresholdElevated {
106 theta_user: f64,
108 theta_eff: f64,
110 leak_probability_at_eff: f64,
112 meets_pass_criterion_at_eff: bool,
114 achievable_at_max: bool,
116 message: String,
118 guidance: String,
120 },
121}
122
123#[derive(Debug, Clone)]
125pub struct QualityGateConfig {
126 pub max_variance_ratio: f64,
129
130 pub min_kl_sum: f64,
133
134 pub max_time_multiplier: f64,
137
138 pub time_budget_secs: f64,
140
141 pub max_samples: usize,
143
144 pub pass_threshold: f64,
146
147 pub fail_threshold: f64,
149
150 pub enable_drift_detection: bool,
153
154 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#[derive(Debug)]
179pub struct QualityGateCheckInputs<'a> {
180 pub posterior: &'a Posterior,
182
183 pub prior_cov_marginal: &'a Matrix9,
187
188 pub theta_ns: f64,
190
191 pub n_total: usize,
193
194 pub elapsed_secs: f64,
196
197 pub recent_kl_sum: Option<f64>,
200
201 pub samples_per_second: f64,
203
204 pub calibration_snapshot: Option<&'a CalibrationSnapshot>,
207
208 pub current_stats_snapshot: Option<&'a CalibrationSnapshot>,
211
212 pub c_floor: f64,
215
216 pub theta_tick: f64,
219
220 pub projection_mismatch_q: Option<f64>,
223
224 pub projection_mismatch_thresh: f64,
226
227 pub lambda_mixing_ok: Option<bool>,
233}
234
235pub fn check_quality_gates(
259 inputs: &QualityGateCheckInputs,
260 config: &QualityGateConfig,
261) -> QualityGateResult {
262 if let Some(reason) = check_kl_divergence(inputs, config) {
264 return QualityGateResult::Stop(reason);
265 }
266
267 if let Some(reason) = check_learning_rate(inputs, config) {
269 return QualityGateResult::Stop(reason);
270 }
271
272 if let Some(reason) = check_extrapolated_time(inputs, config) {
274 return QualityGateResult::Stop(reason);
275 }
276
277 if let Some(reason) = check_time_budget(inputs, config) {
279 return QualityGateResult::Stop(reason);
280 }
281
282 if let Some(reason) = check_sample_budget(inputs, config) {
284 return QualityGateResult::Stop(reason);
285 }
286
287 if let Some(reason) = check_condition_drift(inputs, config) {
289 return QualityGateResult::Stop(reason);
290 }
291
292 QualityGateResult::Continue
293}
294
295pub 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 if theta_user <= 0.0 {
314 return true;
315 }
316
317 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 let theta_floor_at_max = libm::fmax(
326 c_floor / libm::sqrt(n_eff_max as f64),
327 theta_tick,
328 );
329
330 let epsilon = libm::fmax(theta_tick, 1e-6 * theta_user);
332
333 theta_floor_at_max <= theta_user + epsilon
335}
336
337pub fn is_threshold_elevated(theta_eff: f64, theta_user: f64, theta_tick: f64) -> bool {
347 if theta_user <= 0.0 {
349 return false;
350 }
351
352 let epsilon = libm::fmax(theta_tick, 1e-6 * theta_user);
354
355 theta_eff > theta_user + epsilon
357}
358
359const KL_MIN: f64 = 0.7;
361
362fn check_kl_divergence(
376 inputs: &QualityGateCheckInputs,
377 _config: &QualityGateConfig,
378) -> Option<InconclusiveReason> {
379 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 let kl = match compute_kl_divergence(prior_cov, post_cov, post_mean) {
389 Some(kl) => kl,
390 None => {
391 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 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, });
417 }
418
419 None
420}
421
422fn 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 let prior_chol = try_cholesky_with_jitter(prior_cov)?;
434 let post_chol = try_cholesky_with_jitter(post_cov)?;
435
436 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 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 let solved_mean = prior_chol.solve(post_mean);
458 let quad_term = post_mean.dot(&solved_mean);
459
460 let kl = 0.5 * (trace_term + quad_term - 9.0 + prior_log_det - post_log_det);
462
463 Some(kl.max(0.0))
465}
466
467fn try_cholesky_with_jitter(
471 matrix: &crate::types::Matrix9,
472) -> Option<nalgebra::Cholesky<f64, nalgebra::Const<9>>> {
473 if let Some(chol) = nalgebra::Cholesky::new(*matrix) {
475 return Some(chol);
476 }
477
478 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
490fn 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
510fn check_extrapolated_time(
512 inputs: &QualityGateCheckInputs,
513 config: &QualityGateConfig,
514) -> Option<InconclusiveReason> {
515 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 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
544fn 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
560fn 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
575fn extrapolate_samples_to_decision(
581 inputs: &QualityGateCheckInputs,
582 config: &QualityGateConfig,
583) -> usize {
584 let p = inputs.posterior.leak_probability;
585
586 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; }
595
596 let current_std = libm::sqrt(inputs.posterior.beta_proj_cov.trace());
598
599 if current_std < 1e-9 {
600 return inputs.n_total; }
602
603 let std_reduction_needed = current_std / margin;
606
607 if std_reduction_needed <= 1.0 {
608 return inputs.n_total;
610 }
611
612 let sample_multiplier = std_reduction_needed * std_reduction_needed;
613
614 let multiplier = libm::fmin(sample_multiplier, 100.0);
616
617 libm::ceil(inputs.n_total as f64 * multiplier) as usize
618}
619
620fn check_condition_drift(
629 inputs: &QualityGateCheckInputs,
630 config: &QualityGateConfig,
631) -> Option<InconclusiveReason> {
632 if !config.enable_drift_detection {
634 return None;
635 }
636
637 let cal_snapshot = inputs.calibration_snapshot?;
639 let post_snapshot = inputs.current_stats_snapshot?;
640
641 let drift = ConditionDrift::compute(cal_snapshot, post_snapshot);
643
644 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(), leak_prob,
673 1.0, 1000,
675 )
676 }
677
678 fn make_prior_cov_marginal() -> Matrix9 {
679 Matrix9::identity() * 100.0 }
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, 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 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 let posterior = make_posterior(0.5, 95.0); 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); 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); 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; 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; 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 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, 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}