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.lambda_post.trace() / 9.0);
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::Vector9;
663
664 fn make_posterior(leak_prob: f64, variance: f64) -> Posterior {
665 Posterior::new(
666 Vector9::zeros(),
667 Matrix9::identity() * variance,
668 Vec::new(), leak_prob,
670 1.0, 1000,
672 )
673 }
674
675 fn make_prior_cov_marginal() -> Matrix9 {
676 Matrix9::identity() * 100.0 }
678
679 fn make_inputs<'a>(
680 posterior: &'a Posterior,
681 prior_cov_marginal: &'a Matrix9,
682 ) -> QualityGateCheckInputs<'a> {
683 QualityGateCheckInputs {
684 posterior,
685 prior_cov_marginal,
686 theta_ns: 100.0,
687 n_total: 5000,
688 elapsed_secs: 5.0,
689 recent_kl_sum: Some(0.05),
690 samples_per_second: 100_000.0,
691 calibration_snapshot: None,
692 current_stats_snapshot: None,
693 c_floor: 3535.5, theta_tick: 1.0,
695 projection_mismatch_q: None,
696 projection_mismatch_thresh: 18.48,
697 lambda_mixing_ok: None,
698 }
699 }
700
701 #[test]
702 fn test_kl_divergence_gate_passes() {
703 let posterior = make_posterior(0.5, 10.0);
706 let prior_cov_marginal = make_prior_cov_marginal();
707 let inputs = make_inputs(&posterior, &prior_cov_marginal);
708 let config = QualityGateConfig::default();
709
710 let result = check_kl_divergence(&inputs, &config);
711 assert!(
712 result.is_none(),
713 "Low posterior variance should give high KL (pass)"
714 );
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!(
728 matches!(result, Some(InconclusiveReason::DataTooNoisy { .. })),
729 "Posterior ≈ prior should give low KL (fail)"
730 );
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}