use alloc::string::String;
use super::drift::{CalibrationSnapshot, ConditionDrift, DriftThresholds};
use super::Posterior;
use crate::constants::{DEFAULT_FAIL_THRESHOLD, DEFAULT_MAX_SAMPLES, DEFAULT_PASS_THRESHOLD};
use crate::types::Matrix9;
#[derive(Debug, Clone)]
pub enum QualityGateResult {
Continue,
Stop(InconclusiveReason),
}
#[derive(Debug, Clone)]
pub enum InconclusiveReason {
DataTooNoisy {
message: String,
guidance: String,
variance_ratio: f64,
},
NotLearning {
message: String,
guidance: String,
recent_kl_sum: f64,
},
WouldTakeTooLong {
estimated_time_secs: f64,
samples_needed: usize,
guidance: String,
},
TimeBudgetExceeded {
current_probability: f64,
samples_collected: usize,
elapsed_secs: f64,
},
SampleBudgetExceeded {
current_probability: f64,
samples_collected: usize,
},
ConditionsChanged {
message: String,
guidance: String,
drift_description: String,
},
ThresholdElevated {
theta_user: f64,
theta_eff: f64,
leak_probability_at_eff: f64,
meets_pass_criterion_at_eff: bool,
achievable_at_max: bool,
message: String,
guidance: String,
},
}
#[derive(Debug, Clone)]
pub struct QualityGateConfig {
pub max_variance_ratio: f64,
pub min_kl_sum: f64,
pub max_time_multiplier: f64,
pub time_budget_secs: f64,
pub max_samples: usize,
pub pass_threshold: f64,
pub fail_threshold: f64,
pub enable_drift_detection: bool,
pub drift_thresholds: DriftThresholds,
}
impl Default for QualityGateConfig {
fn default() -> Self {
Self {
max_variance_ratio: 0.5,
min_kl_sum: 0.001,
max_time_multiplier: 10.0,
time_budget_secs: 30.0,
max_samples: DEFAULT_MAX_SAMPLES,
pass_threshold: DEFAULT_PASS_THRESHOLD,
fail_threshold: DEFAULT_FAIL_THRESHOLD,
enable_drift_detection: true,
drift_thresholds: DriftThresholds::default(),
}
}
}
#[derive(Debug)]
pub struct QualityGateCheckInputs<'a> {
pub posterior: &'a Posterior,
pub prior_cov_marginal: &'a Matrix9,
pub theta_ns: f64,
pub n_total: usize,
pub elapsed_secs: f64,
pub recent_kl_sum: Option<f64>,
pub samples_per_second: f64,
pub calibration_snapshot: Option<&'a CalibrationSnapshot>,
pub current_stats_snapshot: Option<&'a CalibrationSnapshot>,
pub c_floor: f64,
pub theta_tick: f64,
pub projection_mismatch_q: Option<f64>,
pub projection_mismatch_thresh: f64,
pub lambda_mixing_ok: Option<bool>,
}
pub fn check_quality_gates(
inputs: &QualityGateCheckInputs,
config: &QualityGateConfig,
) -> QualityGateResult {
if let Some(reason) = check_kl_divergence(inputs, config) {
return QualityGateResult::Stop(reason);
}
if let Some(reason) = check_learning_rate(inputs, config) {
return QualityGateResult::Stop(reason);
}
if let Some(reason) = check_extrapolated_time(inputs, config) {
return QualityGateResult::Stop(reason);
}
if let Some(reason) = check_time_budget(inputs, config) {
return QualityGateResult::Stop(reason);
}
if let Some(reason) = check_sample_budget(inputs, config) {
return QualityGateResult::Stop(reason);
}
if let Some(reason) = check_condition_drift(inputs, config) {
return QualityGateResult::Stop(reason);
}
QualityGateResult::Continue
}
pub fn compute_achievable_at_max(
c_floor: f64,
theta_tick: f64,
theta_user: f64,
max_samples: usize,
block_length: usize,
) -> bool {
if theta_user <= 0.0 {
return true;
}
let n_eff_max = if block_length > 0 {
(max_samples / block_length).max(1)
} else {
max_samples.max(1)
};
let theta_floor_at_max = libm::fmax(c_floor / libm::sqrt(n_eff_max as f64), theta_tick);
let epsilon = libm::fmax(theta_tick, 1e-6 * theta_user);
theta_floor_at_max <= theta_user + epsilon
}
pub fn is_threshold_elevated(theta_eff: f64, theta_user: f64, theta_tick: f64) -> bool {
if theta_user <= 0.0 {
return false;
}
let epsilon = libm::fmax(theta_tick, 1e-6 * theta_user);
theta_eff > theta_user + epsilon
}
const KL_MIN: f64 = 0.7;
fn check_kl_divergence(
inputs: &QualityGateCheckInputs,
_config: &QualityGateConfig,
) -> Option<InconclusiveReason> {
let prior_cov = inputs.prior_cov_marginal;
let post_cov = &inputs.posterior.lambda_post;
let post_mean = &inputs.posterior.delta_post;
let kl = match compute_kl_divergence(prior_cov, post_cov, post_mean) {
Some(kl) => kl,
None => {
let trace_ratio = post_cov.trace() / prior_cov.trace();
if trace_ratio > 0.5 {
return Some(InconclusiveReason::DataTooNoisy {
message: alloc::format!(
"Posterior variance is {:.0}% of prior; data not informative (KL computation failed)",
trace_ratio * 100.0
),
guidance: String::from("Try: cycle counter, reduce system load, increase batch size"),
variance_ratio: trace_ratio,
});
}
return None;
}
};
if kl < KL_MIN {
return Some(InconclusiveReason::DataTooNoisy {
message: alloc::format!(
"KL divergence {:.2} nats < {:.1} threshold; posterior ≈ prior",
kl,
KL_MIN
),
guidance: String::from("Try: cycle counter, reduce system load, increase batch size"),
variance_ratio: kl / KL_MIN, });
}
None
}
fn compute_kl_divergence(
prior_cov: &crate::types::Matrix9,
post_cov: &crate::types::Matrix9,
post_mean: &crate::types::Vector9,
) -> Option<f64> {
let prior_chol = try_cholesky_with_jitter(prior_cov)?;
let post_chol = try_cholesky_with_jitter(post_cov)?;
let prior_log_det: f64 = (0..9)
.map(|i| libm::log(prior_chol.l()[(i, i)]))
.sum::<f64>()
* 2.0;
let post_log_det: f64 = (0..9)
.map(|i| libm::log(post_chol.l()[(i, i)]))
.sum::<f64>()
* 2.0;
if !prior_log_det.is_finite() || !post_log_det.is_finite() {
return None;
}
let mut trace_term = 0.0;
for j in 0..9 {
let col = post_cov.column(j).into_owned();
let solved = prior_chol.solve(&col);
trace_term += solved[j];
}
let solved_mean = prior_chol.solve(post_mean);
let quad_term = post_mean.dot(&solved_mean);
let kl = 0.5 * (trace_term + quad_term - 9.0 + prior_log_det - post_log_det);
Some(kl.max(0.0))
}
fn try_cholesky_with_jitter(
matrix: &crate::types::Matrix9,
) -> Option<nalgebra::Cholesky<f64, nalgebra::Const<9>>> {
if let Some(chol) = nalgebra::Cholesky::new(*matrix) {
return Some(chol);
}
for exp in -10..=-4 {
let jitter = libm::pow(10.0, exp as f64);
let jittered = matrix + crate::types::Matrix9::identity() * jitter;
if let Some(chol) = nalgebra::Cholesky::new(jittered) {
return Some(chol);
}
}
None
}
fn check_learning_rate(
inputs: &QualityGateCheckInputs,
config: &QualityGateConfig,
) -> Option<InconclusiveReason> {
let recent_kl_sum = inputs.recent_kl_sum?;
if recent_kl_sum < config.min_kl_sum {
return Some(InconclusiveReason::NotLearning {
message: String::from("Posterior stopped updating despite new data"),
guidance: String::from(
"Measurement may have systematic issues or effect is very close to boundary",
),
recent_kl_sum,
});
}
None
}
fn check_extrapolated_time(
inputs: &QualityGateCheckInputs,
config: &QualityGateConfig,
) -> Option<InconclusiveReason> {
if inputs.n_total < 100 {
return None;
}
let samples_needed = extrapolate_samples_to_decision(inputs, config);
if samples_needed == usize::MAX {
return None;
}
let additional_samples = samples_needed.saturating_sub(inputs.n_total);
let time_needed_secs = additional_samples as f64 / inputs.samples_per_second;
if time_needed_secs > config.time_budget_secs * config.max_time_multiplier {
return Some(InconclusiveReason::WouldTakeTooLong {
estimated_time_secs: time_needed_secs,
samples_needed,
guidance: alloc::format!(
"Effect may be very close to threshold; consider adjusting theta (current: {:.1}ns)",
inputs.theta_ns
),
});
}
None
}
fn check_time_budget(
inputs: &QualityGateCheckInputs,
config: &QualityGateConfig,
) -> Option<InconclusiveReason> {
if inputs.elapsed_secs > config.time_budget_secs {
return Some(InconclusiveReason::TimeBudgetExceeded {
current_probability: inputs.posterior.leak_probability,
samples_collected: inputs.n_total,
elapsed_secs: inputs.elapsed_secs,
});
}
None
}
fn check_sample_budget(
inputs: &QualityGateCheckInputs,
config: &QualityGateConfig,
) -> Option<InconclusiveReason> {
if inputs.n_total >= config.max_samples {
return Some(InconclusiveReason::SampleBudgetExceeded {
current_probability: inputs.posterior.leak_probability,
samples_collected: inputs.n_total,
});
}
None
}
fn extrapolate_samples_to_decision(
inputs: &QualityGateCheckInputs,
config: &QualityGateConfig,
) -> usize {
let p = inputs.posterior.leak_probability;
let margin = libm::fmin(
libm::fabs(p - config.pass_threshold),
libm::fabs(config.fail_threshold - p),
);
if margin < 1e-9 {
return usize::MAX; }
let current_std = libm::sqrt(inputs.posterior.lambda_post.trace() / 9.0);
if current_std < 1e-9 {
return inputs.n_total; }
let std_reduction_needed = current_std / margin;
if std_reduction_needed <= 1.0 {
return inputs.n_total;
}
let sample_multiplier = std_reduction_needed * std_reduction_needed;
let multiplier = libm::fmin(sample_multiplier, 100.0);
libm::ceil(inputs.n_total as f64 * multiplier) as usize
}
fn check_condition_drift(
inputs: &QualityGateCheckInputs,
config: &QualityGateConfig,
) -> Option<InconclusiveReason> {
if !config.enable_drift_detection {
return None;
}
let cal_snapshot = inputs.calibration_snapshot?;
let post_snapshot = inputs.current_stats_snapshot?;
let drift = ConditionDrift::compute(cal_snapshot, post_snapshot);
if drift.is_significant(&config.drift_thresholds) {
return Some(InconclusiveReason::ConditionsChanged {
message: String::from("Measurement conditions changed during test"),
guidance: String::from(
"Ensure stable environment: disable CPU frequency scaling, \
minimize concurrent processes, use performance CPU governor",
),
drift_description: drift.description(&config.drift_thresholds),
});
}
None
}
#[cfg(test)]
mod tests {
use super::*;
use crate::statistics::StatsSnapshot;
use crate::types::Vector9;
fn make_posterior(leak_prob: f64, variance: f64) -> Posterior {
Posterior::new(
Vector9::zeros(),
Matrix9::identity() * variance,
Vec::new(), leak_prob,
1.0, 1000,
)
}
fn make_prior_cov_marginal() -> Matrix9 {
Matrix9::identity() * 100.0 }
fn make_inputs<'a>(
posterior: &'a Posterior,
prior_cov_marginal: &'a Matrix9,
) -> QualityGateCheckInputs<'a> {
QualityGateCheckInputs {
posterior,
prior_cov_marginal,
theta_ns: 100.0,
n_total: 5000,
elapsed_secs: 5.0,
recent_kl_sum: Some(0.05),
samples_per_second: 100_000.0,
calibration_snapshot: None,
current_stats_snapshot: None,
c_floor: 3535.5, theta_tick: 1.0,
projection_mismatch_q: None,
projection_mismatch_thresh: 18.48,
lambda_mixing_ok: None,
}
}
#[test]
fn test_kl_divergence_gate_passes() {
let posterior = make_posterior(0.5, 10.0);
let prior_cov_marginal = make_prior_cov_marginal();
let inputs = make_inputs(&posterior, &prior_cov_marginal);
let config = QualityGateConfig::default();
let result = check_kl_divergence(&inputs, &config);
assert!(
result.is_none(),
"Low posterior variance should give high KL (pass)"
);
}
#[test]
fn test_kl_divergence_gate_fails() {
let posterior = make_posterior(0.5, 95.0); let prior_cov_marginal = make_prior_cov_marginal();
let inputs = make_inputs(&posterior, &prior_cov_marginal);
let config = QualityGateConfig::default();
let result = check_kl_divergence(&inputs, &config);
assert!(
matches!(result, Some(InconclusiveReason::DataTooNoisy { .. })),
"Posterior ≈ prior should give low KL (fail)"
);
}
#[test]
fn test_learning_rate_gate_passes() {
let posterior = make_posterior(0.5, 10.0);
let prior_cov_marginal = make_prior_cov_marginal();
let mut inputs = make_inputs(&posterior, &prior_cov_marginal);
inputs.recent_kl_sum = Some(0.05); let config = QualityGateConfig::default();
let result = check_learning_rate(&inputs, &config);
assert!(result.is_none());
}
#[test]
fn test_learning_rate_gate_fails() {
let posterior = make_posterior(0.5, 10.0);
let prior_cov_marginal = make_prior_cov_marginal();
let mut inputs = make_inputs(&posterior, &prior_cov_marginal);
inputs.recent_kl_sum = Some(0.0005); let config = QualityGateConfig::default();
let result = check_learning_rate(&inputs, &config);
assert!(matches!(
result,
Some(InconclusiveReason::NotLearning { .. })
));
}
#[test]
fn test_time_budget_gate() {
let posterior = make_posterior(0.5, 10.0);
let prior_cov_marginal = make_prior_cov_marginal();
let mut inputs = make_inputs(&posterior, &prior_cov_marginal);
inputs.elapsed_secs = 35.0; let config = QualityGateConfig::default();
let result = check_time_budget(&inputs, &config);
assert!(matches!(
result,
Some(InconclusiveReason::TimeBudgetExceeded { .. })
));
}
#[test]
fn test_sample_budget_gate() {
let posterior = make_posterior(0.5, 10.0);
let prior_cov_marginal = make_prior_cov_marginal();
let mut inputs = make_inputs(&posterior, &prior_cov_marginal);
inputs.n_total = 1_000_001; let config = QualityGateConfig::default();
let result = check_sample_budget(&inputs, &config);
assert!(matches!(
result,
Some(InconclusiveReason::SampleBudgetExceeded { .. })
));
}
#[test]
fn test_condition_drift_gate_no_snapshots() {
let posterior = make_posterior(0.5, 10.0);
let prior_cov_marginal = make_prior_cov_marginal();
let inputs = make_inputs(&posterior, &prior_cov_marginal);
let config = QualityGateConfig::default();
let result = check_condition_drift(&inputs, &config);
assert!(result.is_none());
}
#[test]
fn test_condition_drift_gate_no_drift() {
let posterior = make_posterior(0.5, 10.0);
let prior_cov_marginal = make_prior_cov_marginal();
let stats = StatsSnapshot {
mean: 100.0,
variance: 25.0,
autocorr_lag1: 0.1,
count: 5000,
};
let cal_snapshot = CalibrationSnapshot::new(stats, stats);
let post_snapshot = CalibrationSnapshot::new(stats, stats);
let mut inputs = make_inputs(&posterior, &prior_cov_marginal);
inputs.calibration_snapshot = Some(&cal_snapshot);
inputs.current_stats_snapshot = Some(&post_snapshot);
let config = QualityGateConfig::default();
let result = check_condition_drift(&inputs, &config);
assert!(result.is_none());
}
#[test]
fn test_condition_drift_gate_detects_variance_change() {
let posterior = make_posterior(0.5, 10.0);
let prior_cov_marginal = make_prior_cov_marginal();
let cal_stats = StatsSnapshot {
mean: 100.0,
variance: 25.0,
autocorr_lag1: 0.1,
count: 5000,
};
let post_stats = StatsSnapshot {
mean: 100.0,
variance: 75.0, autocorr_lag1: 0.1,
count: 5000,
};
let cal_snapshot = CalibrationSnapshot::new(cal_stats, cal_stats);
let post_snapshot = CalibrationSnapshot::new(post_stats, post_stats);
let mut inputs = make_inputs(&posterior, &prior_cov_marginal);
inputs.calibration_snapshot = Some(&cal_snapshot);
inputs.current_stats_snapshot = Some(&post_snapshot);
let config = QualityGateConfig::default();
let result = check_condition_drift(&inputs, &config);
assert!(matches!(
result,
Some(InconclusiveReason::ConditionsChanged { .. })
));
}
#[test]
fn test_full_quality_gates_pass() {
let posterior = make_posterior(0.5, 10.0);
let prior_cov_marginal = make_prior_cov_marginal();
let inputs = make_inputs(&posterior, &prior_cov_marginal);
let config = QualityGateConfig::default();
let result = check_quality_gates(&inputs, &config);
assert!(matches!(result, QualityGateResult::Continue));
}
}