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>,
232}
233
234pub fn check_quality_gates(
258 inputs: &QualityGateCheckInputs,
259 config: &QualityGateConfig,
260) -> QualityGateResult {
261 if let Some(reason) = check_kl_divergence(inputs, config) {
263 return QualityGateResult::Stop(reason);
264 }
265
266 if let Some(reason) = check_learning_rate(inputs, config) {
268 return QualityGateResult::Stop(reason);
269 }
270
271 if let Some(reason) = check_extrapolated_time(inputs, config) {
273 return QualityGateResult::Stop(reason);
274 }
275
276 if let Some(reason) = check_time_budget(inputs, config) {
278 return QualityGateResult::Stop(reason);
279 }
280
281 if let Some(reason) = check_sample_budget(inputs, config) {
283 return QualityGateResult::Stop(reason);
284 }
285
286 if let Some(reason) = check_condition_drift(inputs, config) {
288 return QualityGateResult::Stop(reason);
289 }
290
291 QualityGateResult::Continue
292}
293
294pub 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 if theta_user <= 0.0 {
313 return true;
314 }
315
316 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 let theta_floor_at_max = libm::fmax(c_floor / libm::sqrt(n_eff_max as f64), theta_tick);
325
326 let epsilon = libm::fmax(theta_tick, 1e-6 * theta_user);
328
329 theta_floor_at_max <= theta_user + epsilon
331}
332
333pub fn is_threshold_elevated(theta_eff: f64, theta_user: f64, theta_tick: f64) -> bool {
343 if theta_user <= 0.0 {
345 return false;
346 }
347
348 let epsilon = libm::fmax(theta_tick, 1e-6 * theta_user);
350
351 theta_eff > theta_user + epsilon
353}
354
355const KL_MIN: f64 = 0.7;
357
358fn check_kl_divergence(
372 inputs: &QualityGateCheckInputs,
373 _config: &QualityGateConfig,
374) -> Option<InconclusiveReason> {
375 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 let kl = match compute_kl_divergence(prior_cov, post_cov, post_mean) {
385 Some(kl) => kl,
386 None => {
387 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 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, });
414 }
415
416 None
417}
418
419fn 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 let prior_chol = try_cholesky_with_jitter(prior_cov)?;
431 let post_chol = try_cholesky_with_jitter(post_cov)?;
432
433 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 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 let solved_mean = prior_chol.solve(post_mean);
457 let quad_term = post_mean.dot(&solved_mean);
458
459 let kl = 0.5 * (trace_term + quad_term - 9.0 + prior_log_det - post_log_det);
461
462 Some(kl.max(0.0))
464}
465
466fn try_cholesky_with_jitter(
470 matrix: &crate::types::Matrix9,
471) -> Option<nalgebra::Cholesky<f64, nalgebra::Const<9>>> {
472 if let Some(chol) = nalgebra::Cholesky::new(*matrix) {
474 return Some(chol);
475 }
476
477 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
489fn 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
509fn check_extrapolated_time(
511 inputs: &QualityGateCheckInputs,
512 config: &QualityGateConfig,
513) -> Option<InconclusiveReason> {
514 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 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
543fn 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
559fn 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
574fn extrapolate_samples_to_decision(
580 inputs: &QualityGateCheckInputs,
581 config: &QualityGateConfig,
582) -> usize {
583 let p = inputs.posterior.leak_probability;
584
585 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; }
594
595 let current_std = libm::sqrt(inputs.posterior.beta_proj_cov.trace());
597
598 if current_std < 1e-9 {
599 return inputs.n_total; }
601
602 let std_reduction_needed = current_std / margin;
605
606 if std_reduction_needed <= 1.0 {
607 return inputs.n_total;
609 }
610
611 let sample_multiplier = std_reduction_needed * std_reduction_needed;
612
613 let multiplier = libm::fmin(sample_multiplier, 100.0);
615
616 libm::ceil(inputs.n_total as f64 * multiplier) as usize
617}
618
619fn check_condition_drift(
628 inputs: &QualityGateCheckInputs,
629 config: &QualityGateConfig,
630) -> Option<InconclusiveReason> {
631 if !config.enable_drift_detection {
633 return None;
634 }
635
636 let cal_snapshot = inputs.calibration_snapshot?;
638 let post_snapshot = inputs.current_stats_snapshot?;
639
640 let drift = ConditionDrift::compute(cal_snapshot, post_snapshot);
642
643 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(), leak_prob,
672 1.0, 1000,
674 )
675 }
676
677 fn make_prior_cov_marginal() -> Matrix9 {
678 Matrix9::identity() * 100.0 }
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, 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 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 let posterior = make_posterior(0.5, 95.0); 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); 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); 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; 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; 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 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, 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}