use alloc::string::String;
use crate::analysis::bayes::compute_bayes_gibbs;
use crate::constants::{
DEFAULT_FAIL_THRESHOLD, DEFAULT_MAX_SAMPLES, DEFAULT_PASS_THRESHOLD, DEFAULT_SEED,
};
use crate::statistics::{compute_deciles_inplace, compute_midquantile_deciles};
use super::{
check_quality_gates, compute_achievable_at_max, is_threshold_elevated, AdaptiveState,
Calibration, InconclusiveReason, Posterior, QualityGateCheckInputs, QualityGateConfig,
QualityGateResult,
};
#[derive(Debug, Clone)]
pub struct AdaptiveStepConfig {
pub pass_threshold: f64,
pub fail_threshold: f64,
pub time_budget_secs: f64,
pub max_samples: usize,
pub theta_ns: f64,
pub seed: u64,
pub quality_gates: QualityGateConfig,
}
impl Default for AdaptiveStepConfig {
fn default() -> Self {
Self {
pass_threshold: DEFAULT_PASS_THRESHOLD,
fail_threshold: DEFAULT_FAIL_THRESHOLD,
time_budget_secs: 30.0,
max_samples: DEFAULT_MAX_SAMPLES,
theta_ns: 100.0,
seed: DEFAULT_SEED,
quality_gates: QualityGateConfig::default(),
}
}
}
impl AdaptiveStepConfig {
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.quality_gates.time_budget_secs = config.time_budget_secs;
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_secs(mut self, secs: f64) -> Self {
self.time_budget_secs = secs;
self.quality_gates.time_budget_secs = secs;
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_secs: f64,
},
NoLeakDetected {
posterior: Posterior,
samples_per_class: usize,
elapsed_secs: f64,
},
Inconclusive {
reason: InconclusiveReason,
posterior: Option<Posterior>,
samples_per_class: usize,
elapsed_secs: f64,
},
ThresholdElevated {
posterior: Posterior,
theta_user: f64,
theta_eff: f64,
theta_tick: f64,
achievable_at_max: bool,
samples_per_class: usize,
elapsed_secs: f64,
},
}
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::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 samples_per_class(&self) -> usize {
match self {
AdaptiveOutcome::LeakDetected {
samples_per_class, ..
} => *samples_per_class,
AdaptiveOutcome::NoLeakDetected {
samples_per_class, ..
} => *samples_per_class,
AdaptiveOutcome::ThresholdElevated {
samples_per_class, ..
} => *samples_per_class,
AdaptiveOutcome::Inconclusive {
samples_per_class, ..
} => *samples_per_class,
}
}
pub fn elapsed_secs(&self) -> f64 {
match self {
AdaptiveOutcome::LeakDetected { elapsed_secs, .. } => *elapsed_secs,
AdaptiveOutcome::NoLeakDetected { elapsed_secs, .. } => *elapsed_secs,
AdaptiveOutcome::ThresholdElevated { elapsed_secs, .. } => *elapsed_secs,
AdaptiveOutcome::Inconclusive { elapsed_secs, .. } => *elapsed_secs,
}
}
pub fn to_summary(
&self,
calibration: &super::Calibration,
) -> crate::ffi_summary::OutcomeSummary {
use crate::ffi_summary::{
EffectSummary, InconclusiveReasonKind, OutcomeSummary, OutcomeType,
};
use crate::result::{Exploitability, MeasurementQuality};
let cal_summary = calibration.to_summary();
match self {
AdaptiveOutcome::LeakDetected {
posterior,
samples_per_class,
elapsed_secs,
} => {
let post_summary = posterior.to_summary();
let effect = build_effect_summary(&post_summary);
let diagnostics = build_diagnostics(&post_summary, &cal_summary);
OutcomeSummary {
outcome_type: OutcomeType::Fail,
leak_probability: post_summary.leak_probability,
samples_per_class: *samples_per_class,
elapsed_secs: *elapsed_secs,
effect,
quality: post_summary.measurement_quality(),
exploitability: post_summary.exploitability(),
inconclusive_reason: InconclusiveReasonKind::None,
recommendation: String::new(),
theta_user: cal_summary.theta_ns,
theta_eff: cal_summary.theta_eff,
theta_floor: cal_summary.theta_floor_initial,
theta_tick: cal_summary.theta_tick,
achievable_at_max: true,
diagnostics,
mde_ns: cal_summary.mde_ns,
}
}
AdaptiveOutcome::NoLeakDetected {
posterior,
samples_per_class,
elapsed_secs,
} => {
let post_summary = posterior.to_summary();
let effect = build_effect_summary(&post_summary);
let diagnostics = build_diagnostics(&post_summary, &cal_summary);
OutcomeSummary {
outcome_type: OutcomeType::Pass,
leak_probability: post_summary.leak_probability,
samples_per_class: *samples_per_class,
elapsed_secs: *elapsed_secs,
effect,
quality: post_summary.measurement_quality(),
exploitability: Exploitability::SharedHardwareOnly,
inconclusive_reason: InconclusiveReasonKind::None,
recommendation: String::new(),
theta_user: cal_summary.theta_ns,
theta_eff: cal_summary.theta_eff,
theta_floor: cal_summary.theta_floor_initial,
theta_tick: cal_summary.theta_tick,
achievable_at_max: true,
diagnostics,
mde_ns: cal_summary.mde_ns,
}
}
AdaptiveOutcome::ThresholdElevated {
posterior,
theta_user,
theta_eff,
theta_tick,
achievable_at_max,
samples_per_class,
elapsed_secs,
} => {
let post_summary = posterior.to_summary();
let effect = build_effect_summary(&post_summary);
let diagnostics = build_diagnostics(&post_summary, &cal_summary);
let recommendation = if *achievable_at_max {
alloc::format!(
"Threshold elevated from {:.0}ns to {:.1}ns. More samples could achieve the requested threshold.",
theta_user, theta_eff
)
} else {
alloc::format!(
"Threshold elevated from {:.0}ns to {:.1}ns. Use a cycle counter for better resolution.",
theta_user, theta_eff
)
};
OutcomeSummary {
outcome_type: OutcomeType::ThresholdElevated,
leak_probability: post_summary.leak_probability,
samples_per_class: *samples_per_class,
elapsed_secs: *elapsed_secs,
effect,
quality: post_summary.measurement_quality(),
exploitability: Exploitability::SharedHardwareOnly,
inconclusive_reason: InconclusiveReasonKind::ThresholdElevated,
recommendation,
theta_user: *theta_user,
theta_eff: *theta_eff,
theta_floor: cal_summary.theta_floor_initial,
theta_tick: *theta_tick,
achievable_at_max: *achievable_at_max,
diagnostics,
mde_ns: cal_summary.mde_ns,
}
}
AdaptiveOutcome::Inconclusive {
reason,
posterior,
samples_per_class,
elapsed_secs,
} => {
let (post_summary, effect, quality, diagnostics) = match posterior {
Some(p) => {
let ps = p.to_summary();
let eff = build_effect_summary(&ps);
let qual = ps.measurement_quality();
let diag = build_diagnostics(&ps, &cal_summary);
(Some(ps), eff, qual, diag)
}
None => (
None,
EffectSummary::default(),
MeasurementQuality::TooNoisy,
build_diagnostics_from_calibration(&cal_summary),
),
};
let (inconclusive_reason, recommendation) = convert_inconclusive_reason(reason);
OutcomeSummary {
outcome_type: OutcomeType::Inconclusive,
leak_probability: post_summary
.as_ref()
.map(|p| p.leak_probability)
.unwrap_or(0.5),
samples_per_class: *samples_per_class,
elapsed_secs: *elapsed_secs,
effect,
quality,
exploitability: Exploitability::SharedHardwareOnly,
inconclusive_reason,
recommendation,
theta_user: cal_summary.theta_ns,
theta_eff: cal_summary.theta_eff,
theta_floor: cal_summary.theta_floor_initial,
theta_tick: cal_summary.theta_tick,
achievable_at_max: false,
diagnostics,
mde_ns: cal_summary.mde_ns,
}
}
}
}
}
fn build_effect_summary(
post: &crate::ffi_summary::PosteriorSummary,
) -> crate::ffi_summary::EffectSummary {
crate::ffi_summary::EffectSummary {
max_effect_ns: post.max_effect_ns,
ci_low_ns: post.ci_low_ns,
ci_high_ns: post.ci_high_ns,
}
}
fn build_diagnostics(
post: &crate::ffi_summary::PosteriorSummary,
cal: &crate::ffi_summary::CalibrationSummary,
) -> crate::ffi_summary::DiagnosticsSummary {
crate::ffi_summary::DiagnosticsSummary {
dependence_length: cal.block_length,
effective_sample_size: post.n,
stationarity_ratio: 1.0,
stationarity_ok: true,
discrete_mode: cal.discrete_mode,
timer_resolution_ns: cal.timer_resolution_ns,
lambda_mean: post.lambda_mean,
lambda_mixing_ok: post.lambda_mixing_ok,
kappa_mean: post.kappa_mean,
kappa_cv: post.kappa_cv,
kappa_ess: post.kappa_ess,
kappa_mixing_ok: post.kappa_mixing_ok,
}
}
fn build_diagnostics_from_calibration(
cal: &crate::ffi_summary::CalibrationSummary,
) -> crate::ffi_summary::DiagnosticsSummary {
crate::ffi_summary::DiagnosticsSummary {
dependence_length: cal.block_length,
effective_sample_size: 0,
stationarity_ratio: 1.0,
stationarity_ok: true,
discrete_mode: cal.discrete_mode,
timer_resolution_ns: cal.timer_resolution_ns,
lambda_mean: 1.0,
lambda_mixing_ok: true,
kappa_mean: 1.0,
kappa_cv: 0.0,
kappa_ess: 0.0,
kappa_mixing_ok: true,
}
}
fn convert_inconclusive_reason(
reason: &super::InconclusiveReason,
) -> (crate::ffi_summary::InconclusiveReasonKind, String) {
use super::InconclusiveReason;
use crate::ffi_summary::InconclusiveReasonKind;
match reason {
InconclusiveReason::DataTooNoisy { guidance, .. } => {
(InconclusiveReasonKind::DataTooNoisy, guidance.clone())
}
InconclusiveReason::NotLearning { guidance, .. } => {
(InconclusiveReasonKind::NotLearning, guidance.clone())
}
InconclusiveReason::WouldTakeTooLong { guidance, .. } => {
(InconclusiveReasonKind::WouldTakeTooLong, guidance.clone())
}
InconclusiveReason::TimeBudgetExceeded { .. } => (
InconclusiveReasonKind::TimeBudgetExceeded,
String::from("Increase time budget or reduce threshold"),
),
InconclusiveReason::SampleBudgetExceeded { .. } => (
InconclusiveReasonKind::SampleBudgetExceeded,
String::from("Increase sample budget or reduce threshold"),
),
InconclusiveReason::ConditionsChanged { guidance, .. } => {
(InconclusiveReasonKind::ConditionsChanged, guidance.clone())
}
InconclusiveReason::ThresholdElevated { guidance, .. } => {
(InconclusiveReasonKind::ThresholdElevated, guidance.clone())
}
}
}
#[derive(Debug, Clone)]
pub enum StepResult {
Decision(AdaptiveOutcome),
Continue {
posterior: Posterior,
samples_per_class: usize,
},
}
impl StepResult {
pub fn is_decision(&self) -> bool {
matches!(self, StepResult::Decision(_))
}
pub fn into_decision(self) -> Option<AdaptiveOutcome> {
match self {
StepResult::Decision(outcome) => Some(outcome),
StepResult::Continue { .. } => None,
}
}
pub fn leak_probability(&self) -> Option<f64> {
match self {
StepResult::Decision(outcome) => outcome.leak_probability(),
StepResult::Continue { posterior, .. } => Some(posterior.leak_probability),
}
}
}
pub fn adaptive_step(
calibration: &Calibration,
state: &mut AdaptiveState,
ns_per_tick: f64,
elapsed_secs: f64,
config: &AdaptiveStepConfig,
) -> StepResult {
let posterior = match compute_posterior(state, calibration, ns_per_tick, config) {
Some(p) => p,
None => {
if state.n_total() < 20 {
return StepResult::Decision(AdaptiveOutcome::Inconclusive {
reason: InconclusiveReason::DataTooNoisy {
message: String::from("Insufficient samples for posterior computation"),
guidance: String::from("Need at least 20 samples per class"),
variance_ratio: 1.0,
},
posterior: None,
samples_per_class: state.n_total(),
elapsed_secs,
});
}
return StepResult::Decision(AdaptiveOutcome::Inconclusive {
reason: InconclusiveReason::DataTooNoisy {
message: String::from("Could not compute posterior from samples"),
guidance: String::from("Check timer resolution and sample count"),
variance_ratio: 1.0,
},
posterior: None,
samples_per_class: state.n_total(),
elapsed_secs,
});
}
};
let _kl = state.update_posterior(posterior.clone());
if posterior.leak_probability > config.fail_threshold {
return StepResult::Decision(AdaptiveOutcome::LeakDetected {
posterior,
samples_per_class: state.n_total(),
elapsed_secs,
});
}
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 StepResult::Decision(AdaptiveOutcome::ThresholdElevated {
posterior,
theta_user,
theta_eff,
theta_tick,
achievable_at_max,
samples_per_class: state.n_total(),
elapsed_secs,
});
}
return StepResult::Decision(AdaptiveOutcome::NoLeakDetected {
posterior,
samples_per_class: state.n_total(),
elapsed_secs,
});
}
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,
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::Continue => StepResult::Continue {
posterior,
samples_per_class: state.n_total(),
},
QualityGateResult::Stop(reason) => StepResult::Decision(AdaptiveOutcome::Inconclusive {
reason,
posterior: Some(posterior),
samples_per_class: state.n_total(),
elapsed_secs,
}),
}
}
fn compute_posterior(
state: &AdaptiveState,
calibration: &Calibration,
ns_per_tick: f64,
config: &AdaptiveStepConfig,
) -> Option<Posterior> {
let n = state.n_total();
if n < 20 {
return None; }
let baseline_ns = state.baseline_ns(ns_per_tick);
let sample_ns = state.sample_ns(ns_per_tick);
let observed_diff = if calibration.discrete_mode {
let q_baseline = compute_midquantile_deciles(&baseline_ns);
let q_sample = compute_midquantile_deciles(&sample_ns);
q_baseline - q_sample
} 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_baseline - q_sample
};
let sigma_n = calibration.covariance_for_n(n);
let bayes_result = compute_bayes_gibbs(
&observed_diff,
&sigma_n,
calibration.sigma_t,
&calibration.l_r,
config.theta_ns,
Some(config.seed),
);
Some(Posterior::new_with_gibbs(
bayes_result.delta_post,
bayes_result.lambda_post,
bayes_result.delta_draws,
bayes_result.leak_probability,
config.theta_ns,
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,
))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::statistics::StatsSnapshot;
use crate::types::{Matrix9, Vector9};
fn make_test_calibration() -> Calibration {
use crate::adaptive::CalibrationSnapshot;
let snapshot = CalibrationSnapshot::new(
StatsSnapshot {
count: 5000,
mean: 1000.0,
variance: 25.0,
autocorr_lag1: 0.1,
},
StatsSnapshot {
count: 5000,
mean: 1000.0,
variance: 25.0,
autocorr_lag1: 0.1,
},
);
Calibration::new(
Matrix9::identity() * 1000.0, 10, 100.0, Matrix9::identity(), 100.0, 5000, false, 5.0, snapshot, 1.0, 100_000.0, 10.0, 18.48, 0.001, 100.0, 0.1, 42, 1, )
}
fn make_test_posterior(leak_prob: f64) -> Posterior {
Posterior::new(
Vector9::zeros(),
Matrix9::identity(),
Vec::new(), leak_prob,
1.0, 1000,
)
}
#[test]
fn test_adaptive_step_config_default() {
let config = AdaptiveStepConfig::default();
assert!((config.pass_threshold - 0.05).abs() < 1e-10);
assert!((config.fail_threshold - 0.95).abs() < 1e-10);
assert!((config.time_budget_secs - 30.0).abs() < 1e-10);
}
#[test]
fn test_adaptive_step_config_builder() {
let config = AdaptiveStepConfig::with_theta(50.0)
.pass_threshold(0.01)
.fail_threshold(0.99)
.time_budget_secs(60.0)
.max_samples(500_000);
assert!((config.theta_ns - 50.0).abs() < 1e-10);
assert!((config.pass_threshold - 0.01).abs() < 1e-10);
assert!((config.fail_threshold - 0.99).abs() < 1e-10);
assert!((config.time_budget_secs - 60.0).abs() < 1e-10);
assert_eq!(config.max_samples, 500_000);
}
#[test]
fn test_adaptive_outcome_accessors() {
let posterior = make_test_posterior(0.95);
let outcome = AdaptiveOutcome::LeakDetected {
posterior: posterior.clone(),
samples_per_class: 1000,
elapsed_secs: 1.5,
};
assert!(outcome.is_leak_detected());
assert!(outcome.is_conclusive());
assert_eq!(outcome.leak_probability(), Some(0.95));
assert_eq!(outcome.samples_per_class(), 1000);
assert!((outcome.elapsed_secs() - 1.5).abs() < 1e-10);
let outcome = AdaptiveOutcome::NoLeakDetected {
posterior,
samples_per_class: 2000,
elapsed_secs: 2.5,
};
assert!(!outcome.is_leak_detected());
assert!(outcome.is_conclusive());
assert_eq!(outcome.samples_per_class(), 2000);
}
#[test]
fn test_step_result_accessors() {
let posterior = make_test_posterior(0.5);
let result = StepResult::Continue {
posterior: posterior.clone(),
samples_per_class: 1000,
};
assert!(!result.is_decision());
assert_eq!(result.leak_probability(), Some(0.5));
let result = StepResult::Decision(AdaptiveOutcome::LeakDetected {
posterior,
samples_per_class: 1000,
elapsed_secs: 1.0,
});
assert!(result.is_decision());
}
#[test]
fn test_adaptive_step_insufficient_samples() {
let calibration = make_test_calibration();
let mut state = AdaptiveState::new();
state.add_batch(vec![100; 10], vec![101; 10]);
let config = AdaptiveStepConfig::default();
let result = adaptive_step(&calibration, &mut state, 1.0, 0.1, &config);
assert!(result.is_decision());
if let StepResult::Decision(AdaptiveOutcome::Inconclusive { reason, .. }) = result {
assert!(matches!(reason, InconclusiveReason::DataTooNoisy { .. }));
} else {
panic!("Expected Inconclusive with DataTooNoisy");
}
}
#[test]
fn test_compute_posterior_no_difference() {
let calibration = make_test_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 = AdaptiveStepConfig::with_theta(100.0);
let posterior = compute_posterior(&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_test_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 = AdaptiveStepConfig::with_theta(100.0);
let posterior = compute_posterior(&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
);
}
#[test]
fn test_adaptive_step_detects_leak() {
let calibration = make_test_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| 1500 + (i % 10)).collect();
state.add_batch(baseline, sample);
let config = AdaptiveStepConfig::with_theta(100.0);
let result = adaptive_step(&calibration, &mut state, 1.0, 1.0, &config);
assert!(result.is_decision());
if let StepResult::Decision(outcome) = result {
assert!(outcome.is_leak_detected());
} else {
panic!("Expected Decision");
}
}
}