use std::time::Duration;
use crate::adaptive::{
AdaptiveState, Calibration, InconclusiveReason, Posterior, QualityGateCheckInputs,
QualityGateConfig, QualityGateResult,
};
use crate::analysis::bayes::compute_bayes_gibbs;
use crate::constants::{
DEFAULT_BATCH_SIZE, DEFAULT_FAIL_THRESHOLD, DEFAULT_MAX_SAMPLES, DEFAULT_PASS_THRESHOLD,
DEFAULT_SEED,
};
use crate::measurement::winsorize_f64;
use crate::statistics::{compute_deciles_inplace, compute_midquantile_deciles};
use tacet_core::adaptive::{check_quality_gates, compute_achievable_at_max, is_threshold_elevated};
#[derive(Debug, Clone)]
pub struct AdaptiveConfig {
pub batch_size: usize,
pub pass_threshold: f64,
pub fail_threshold: f64,
pub time_budget: Duration,
pub max_samples: usize,
pub theta_ns: f64,
pub seed: u64,
pub quality_gates: QualityGateConfig,
pub outlier_percentile: f64,
}
impl Default for AdaptiveConfig {
fn default() -> Self {
Self {
batch_size: DEFAULT_BATCH_SIZE,
pass_threshold: DEFAULT_PASS_THRESHOLD,
fail_threshold: DEFAULT_FAIL_THRESHOLD,
time_budget: Duration::from_secs(30),
max_samples: DEFAULT_MAX_SAMPLES,
theta_ns: 100.0,
seed: DEFAULT_SEED,
quality_gates: QualityGateConfig::default(),
outlier_percentile: 0.9999, }
}
}
impl AdaptiveConfig {
pub fn with_theta(theta_ns: f64) -> Self {
let mut config = Self {
theta_ns,
..Self::default()
};
config.quality_gates.pass_threshold = config.pass_threshold;
config.quality_gates.fail_threshold = config.fail_threshold;
config
}
pub fn pass_threshold(mut self, threshold: f64) -> Self {
self.pass_threshold = threshold;
self.quality_gates.pass_threshold = threshold;
self
}
pub fn fail_threshold(mut self, threshold: f64) -> Self {
self.fail_threshold = threshold;
self.quality_gates.fail_threshold = threshold;
self
}
pub fn time_budget(mut self, budget: Duration) -> Self {
self.time_budget = budget;
self.quality_gates.time_budget_secs = budget.as_secs_f64();
self
}
pub fn max_samples(mut self, max: usize) -> Self {
self.max_samples = max;
self.quality_gates.max_samples = max;
self
}
}
#[derive(Debug, Clone)]
pub enum AdaptiveOutcome {
LeakDetected {
posterior: Posterior,
samples_per_class: usize,
elapsed: Duration,
},
NoLeakDetected {
posterior: Posterior,
samples_per_class: usize,
elapsed: Duration,
},
Inconclusive {
reason: InconclusiveReason,
posterior: Option<Posterior>,
samples_per_class: usize,
elapsed: Duration,
},
Continue {
posterior: Posterior,
samples_per_class: usize,
elapsed: Duration,
},
ThresholdElevated {
posterior: Posterior,
theta_user: f64,
theta_eff: f64,
theta_tick: f64,
achievable_at_max: bool,
samples_per_class: usize,
elapsed: Duration,
},
}
impl AdaptiveOutcome {
pub fn leak_probability(&self) -> Option<f64> {
match self {
AdaptiveOutcome::LeakDetected { posterior, .. } => Some(posterior.leak_probability),
AdaptiveOutcome::NoLeakDetected { posterior, .. } => Some(posterior.leak_probability),
AdaptiveOutcome::ThresholdElevated { posterior, .. } => {
Some(posterior.leak_probability)
}
AdaptiveOutcome::Continue { posterior, .. } => Some(posterior.leak_probability),
AdaptiveOutcome::Inconclusive { posterior, .. } => {
posterior.as_ref().map(|p| p.leak_probability)
}
}
}
pub fn is_leak_detected(&self) -> bool {
matches!(self, AdaptiveOutcome::LeakDetected { .. })
}
pub fn is_conclusive(&self) -> bool {
matches!(
self,
AdaptiveOutcome::LeakDetected { .. } | AdaptiveOutcome::NoLeakDetected { .. }
)
}
pub fn is_threshold_elevated(&self) -> bool {
matches!(self, AdaptiveOutcome::ThresholdElevated { .. })
}
}
pub fn run_adaptive(
calibration: &Calibration,
state: &mut AdaptiveState,
ns_per_tick: f64,
config: &AdaptiveConfig,
) -> AdaptiveOutcome {
let posterior = match compute_posterior_from_state(state, calibration, ns_per_tick, config) {
Some(p) => p,
None => {
return AdaptiveOutcome::Inconclusive {
reason: InconclusiveReason::DataTooNoisy {
message: "Could not compute posterior from samples".to_string(),
guidance: "Check timer resolution and sample count".to_string(),
variance_ratio: 1.0,
},
posterior: None,
samples_per_class: state.n_total(),
elapsed: state.elapsed(),
};
}
};
let _kl = state.update_posterior(posterior.clone());
let current_stats = state.get_stats_snapshot();
let gate_inputs = QualityGateCheckInputs {
posterior: &posterior,
prior_cov_marginal: &calibration.prior_cov_marginal,
theta_ns: config.theta_ns,
n_total: state.n_total(),
elapsed_secs: state.elapsed().as_secs_f64(),
recent_kl_sum: if state.has_kl_history() {
Some(state.recent_kl_sum())
} else {
None
},
samples_per_second: calibration.samples_per_second,
calibration_snapshot: Some(&calibration.calibration_snapshot),
current_stats_snapshot: current_stats.as_ref(),
c_floor: calibration.c_floor,
theta_tick: calibration.theta_tick,
projection_mismatch_q: None, projection_mismatch_thresh: calibration.projection_mismatch_thresh,
lambda_mixing_ok: posterior.lambda_mixing_ok,
};
match check_quality_gates(&gate_inputs, &config.quality_gates) {
QualityGateResult::Stop(reason) => {
return AdaptiveOutcome::Inconclusive {
reason,
posterior: Some(posterior),
samples_per_class: state.n_total(),
elapsed: state.elapsed(),
};
}
QualityGateResult::Continue => {
}
}
if posterior.leak_probability > config.fail_threshold {
return AdaptiveOutcome::LeakDetected {
posterior,
samples_per_class: state.n_total(),
elapsed: state.elapsed(),
};
}
if posterior.leak_probability < config.pass_threshold {
let theta_user = config.theta_ns;
let theta_eff = calibration.theta_eff;
let theta_tick = calibration.theta_tick;
if is_threshold_elevated(theta_eff, theta_user, theta_tick) {
let achievable_at_max = compute_achievable_at_max(
calibration.c_floor,
theta_tick,
theta_user,
config.max_samples,
calibration.block_length, );
return AdaptiveOutcome::ThresholdElevated {
posterior,
theta_user,
theta_eff,
theta_tick,
achievable_at_max,
samples_per_class: state.n_total(),
elapsed: state.elapsed(),
};
}
return AdaptiveOutcome::NoLeakDetected {
posterior,
samples_per_class: state.n_total(),
elapsed: state.elapsed(),
};
}
AdaptiveOutcome::Continue {
posterior,
samples_per_class: state.n_total(),
elapsed: state.elapsed(),
}
}
fn compute_posterior_from_state(
state: &AdaptiveState,
calibration: &Calibration,
ns_per_tick: f64,
config: &AdaptiveConfig,
) -> Option<Posterior> {
let n = state.n_total();
if n < 20 {
return None; }
let mut baseline_ns = state.baseline_ns(ns_per_tick);
let mut sample_ns = state.sample_ns(ns_per_tick);
let _ = winsorize_f64(&mut baseline_ns, &mut sample_ns, config.outlier_percentile);
let observed_diff = if calibration.discrete_mode {
let q_baseline = compute_midquantile_deciles(&baseline_ns);
let q_sample = compute_midquantile_deciles(&sample_ns);
q_sample - q_baseline
} else {
let mut baseline_sorted = baseline_ns;
let mut sample_sorted = sample_ns;
let q_baseline = compute_deciles_inplace(&mut baseline_sorted);
let q_sample = compute_deciles_inplace(&mut sample_sorted);
q_sample - q_baseline
};
let sigma_n = calibration.sigma_rate / (n as f64);
let bayes_result = compute_bayes_gibbs(
&observed_diff,
&sigma_n,
calibration.sigma_t,
&calibration.l_r,
calibration.theta_eff,
Some(config.seed),
);
Some(Posterior::new_with_gibbs(
bayes_result.delta_post,
bayes_result.lambda_post,
bayes_result.delta_draws.clone(),
bayes_result.leak_probability,
calibration.theta_eff,
n,
bayes_result.lambda_mean,
bayes_result.lambda_mixing_ok,
bayes_result.kappa_mean,
bayes_result.kappa_cv,
bayes_result.kappa_ess,
bayes_result.kappa_mixing_ok,
))
}
#[allow(dead_code)]
pub fn adaptive_step(
calibration: &Calibration,
state: &mut AdaptiveState,
ns_per_tick: f64,
config: &AdaptiveConfig,
) -> Result<Option<AdaptiveOutcome>, InconclusiveReason> {
let posterior = match compute_posterior_from_state(state, calibration, ns_per_tick, config) {
Some(p) => p,
None => {
return Ok(None);
}
};
let _kl = state.update_posterior(posterior.clone());
let current_stats = state.get_stats_snapshot();
let gate_inputs = QualityGateCheckInputs {
posterior: &posterior,
prior_cov_marginal: &calibration.prior_cov_marginal,
theta_ns: config.theta_ns,
n_total: state.n_total(),
elapsed_secs: state.elapsed().as_secs_f64(),
recent_kl_sum: if state.has_kl_history() {
Some(state.recent_kl_sum())
} else {
None
},
samples_per_second: calibration.samples_per_second,
calibration_snapshot: Some(&calibration.calibration_snapshot),
current_stats_snapshot: current_stats.as_ref(),
c_floor: calibration.c_floor,
theta_tick: calibration.theta_tick,
projection_mismatch_q: None, projection_mismatch_thresh: calibration.projection_mismatch_thresh,
lambda_mixing_ok: posterior.lambda_mixing_ok,
};
match check_quality_gates(&gate_inputs, &config.quality_gates) {
QualityGateResult::Stop(reason) => {
return Ok(Some(AdaptiveOutcome::Inconclusive {
reason,
posterior: Some(posterior),
samples_per_class: state.n_total(),
elapsed: state.elapsed(),
}));
}
QualityGateResult::Continue => {
}
}
if posterior.leak_probability > config.fail_threshold {
return Ok(Some(AdaptiveOutcome::LeakDetected {
posterior,
samples_per_class: state.n_total(),
elapsed: state.elapsed(),
}));
}
if posterior.leak_probability < config.pass_threshold {
let theta_user = config.theta_ns;
let theta_eff = calibration.theta_eff;
let theta_tick = calibration.theta_tick;
if is_threshold_elevated(theta_eff, theta_user, theta_tick) {
let achievable_at_max = compute_achievable_at_max(
calibration.c_floor,
theta_tick,
theta_user,
config.max_samples,
calibration.block_length, );
return Ok(Some(AdaptiveOutcome::ThresholdElevated {
posterior,
theta_user,
theta_eff,
theta_tick,
achievable_at_max,
samples_per_class: state.n_total(),
elapsed: state.elapsed(),
}));
}
return Ok(Some(AdaptiveOutcome::NoLeakDetected {
posterior,
samples_per_class: state.n_total(),
elapsed: state.elapsed(),
}));
}
Ok(None)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::{Matrix9, Vector9};
fn make_calibration() -> Calibration {
use crate::adaptive::CalibrationSnapshot;
use crate::statistics::StatsSnapshot;
let default_stats = StatsSnapshot {
mean: 1000.0,
variance: 25.0,
autocorr_lag1: 0.1,
count: 5000,
};
let calibration_snapshot = CalibrationSnapshot::new(default_stats, default_stats);
let l_r = Matrix9::identity(); let prior_cov_marginal = Matrix9::identity() * 20000.0; Calibration {
sigma_rate: Matrix9::identity() * 1000.0,
block_length: 10,
sigma_t: 100.0, l_r, prior_cov_marginal, timer_resolution_ns: 1.0,
samples_per_second: 100_000.0,
discrete_mode: false,
theta_ns: 100.0,
calibration_samples: 5000,
mde_ns: 5.0,
preflight_result: tacet_core::preflight::PreflightResult::new(),
calibration_snapshot,
c_floor: 3535.5, projection_mismatch_thresh: 18.48, theta_floor_initial: 50.0, theta_eff: 100.0, theta_tick: 1.0, rng_seed: 42, batch_k: 1, }
}
#[test]
fn test_adaptive_config_builder() {
let config = AdaptiveConfig::with_theta(50.0)
.pass_threshold(0.01)
.fail_threshold(0.99)
.time_budget(Duration::from_secs(60))
.max_samples(500_000);
assert_eq!(config.theta_ns, 50.0);
assert_eq!(config.pass_threshold, 0.01);
assert_eq!(config.fail_threshold, 0.99);
assert_eq!(config.time_budget, Duration::from_secs(60));
assert_eq!(config.max_samples, 500_000);
}
#[test]
fn test_adaptive_outcome_accessors() {
let posterior = Posterior::new(
Vector9::zeros(), Matrix9::identity(), Vec::new(), 0.95, 100.0, 1000, );
let outcome = AdaptiveOutcome::LeakDetected {
posterior: posterior.clone(),
samples_per_class: 1000,
elapsed: Duration::from_secs(1),
};
assert!(outcome.is_leak_detected());
assert!(outcome.is_conclusive());
assert_eq!(outcome.leak_probability(), Some(0.95));
let outcome = AdaptiveOutcome::NoLeakDetected {
posterior,
samples_per_class: 1000,
elapsed: Duration::from_secs(1),
};
assert!(!outcome.is_leak_detected());
assert!(outcome.is_conclusive());
}
#[test]
fn test_adaptive_step_insufficient_samples() {
let calibration = make_calibration();
let mut state = AdaptiveState::new();
state.add_batch(vec![100; 10], vec![101; 10]);
let config = AdaptiveConfig::default();
let result = adaptive_step(&calibration, &mut state, 1.0, &config);
assert!(matches!(result, Ok(None)));
}
#[test]
fn test_compute_posterior_basic() {
let calibration = make_calibration();
let mut state = AdaptiveState::new();
let baseline: Vec<u64> = (0..1000).map(|i| 1000 + (i % 10)).collect();
let sample: Vec<u64> = (0..1000).map(|i| 1000 + (i % 10)).collect();
state.add_batch(baseline, sample);
let config = AdaptiveConfig::with_theta(100.0);
let posterior = compute_posterior_from_state(&state, &calibration, 1.0, &config);
assert!(posterior.is_some());
let p = posterior.unwrap();
assert!(
p.leak_probability < 0.5,
"Identical distributions should have low leak probability, got {}",
p.leak_probability
);
}
#[test]
fn test_compute_posterior_with_difference() {
let calibration = make_calibration();
let mut state = AdaptiveState::new();
let baseline: Vec<u64> = (0..1000).map(|i| 1000 + (i % 10)).collect();
let sample: Vec<u64> = (0..1000).map(|i| 1200 + (i % 10)).collect();
state.add_batch(baseline, sample);
let config = AdaptiveConfig::with_theta(100.0);
let posterior = compute_posterior_from_state(&state, &calibration, 1.0, &config);
assert!(posterior.is_some());
let p = posterior.unwrap();
assert!(
p.leak_probability > 0.5,
"Clear difference should have high leak probability, got {}",
p.leak_probability
);
}
}