1use alloc::string::String;
27
28use crate::analysis::bayes::compute_bayes_gibbs;
29use crate::constants::{
30 DEFAULT_FAIL_THRESHOLD, DEFAULT_MAX_SAMPLES, DEFAULT_PASS_THRESHOLD, DEFAULT_SEED,
31};
32use crate::statistics::{compute_deciles_inplace, compute_midquantile_deciles};
33
34use super::{
35 check_quality_gates, compute_achievable_at_max, is_threshold_elevated, AdaptiveState,
36 Calibration, InconclusiveReason, Posterior, QualityGateCheckInputs, QualityGateConfig,
37 QualityGateResult,
38};
39
40#[derive(Debug, Clone)]
44pub struct AdaptiveStepConfig {
45 pub pass_threshold: f64,
47
48 pub fail_threshold: f64,
50
51 pub time_budget_secs: f64,
53
54 pub max_samples: usize,
56
57 pub theta_ns: f64,
59
60 pub seed: u64,
62
63 pub quality_gates: QualityGateConfig,
65}
66
67impl Default for AdaptiveStepConfig {
68 fn default() -> Self {
69 Self {
70 pass_threshold: DEFAULT_PASS_THRESHOLD,
71 fail_threshold: DEFAULT_FAIL_THRESHOLD,
72 time_budget_secs: 30.0,
73 max_samples: DEFAULT_MAX_SAMPLES,
74 theta_ns: 100.0,
75 seed: DEFAULT_SEED,
76 quality_gates: QualityGateConfig::default(),
77 }
78 }
79}
80
81impl AdaptiveStepConfig {
82 pub fn with_theta(theta_ns: f64) -> Self {
84 let mut config = Self {
85 theta_ns,
86 ..Self::default()
87 };
88 config.quality_gates.pass_threshold = config.pass_threshold;
89 config.quality_gates.fail_threshold = config.fail_threshold;
90 config.quality_gates.time_budget_secs = config.time_budget_secs;
91 config
92 }
93
94 pub fn pass_threshold(mut self, threshold: f64) -> Self {
96 self.pass_threshold = threshold;
97 self.quality_gates.pass_threshold = threshold;
98 self
99 }
100
101 pub fn fail_threshold(mut self, threshold: f64) -> Self {
103 self.fail_threshold = threshold;
104 self.quality_gates.fail_threshold = threshold;
105 self
106 }
107
108 pub fn time_budget_secs(mut self, secs: f64) -> Self {
110 self.time_budget_secs = secs;
111 self.quality_gates.time_budget_secs = secs;
112 self
113 }
114
115 pub fn max_samples(mut self, max: usize) -> Self {
117 self.max_samples = max;
118 self.quality_gates.max_samples = max;
119 self
120 }
121}
122
123#[derive(Debug, Clone)]
127pub enum AdaptiveOutcome {
128 LeakDetected {
130 posterior: Posterior,
132 samples_per_class: usize,
134 elapsed_secs: f64,
136 },
137
138 NoLeakDetected {
140 posterior: Posterior,
142 samples_per_class: usize,
144 elapsed_secs: f64,
146 },
147
148 Inconclusive {
150 reason: InconclusiveReason,
152 posterior: Option<Posterior>,
154 samples_per_class: usize,
156 elapsed_secs: f64,
158 },
159
160 ThresholdElevated {
166 posterior: Posterior,
168 theta_user: f64,
170 theta_eff: f64,
172 theta_tick: f64,
174 achievable_at_max: bool,
176 samples_per_class: usize,
178 elapsed_secs: f64,
180 },
181}
182
183impl AdaptiveOutcome {
184 pub fn leak_probability(&self) -> Option<f64> {
186 match self {
187 AdaptiveOutcome::LeakDetected { posterior, .. } => Some(posterior.leak_probability),
188 AdaptiveOutcome::NoLeakDetected { posterior, .. } => Some(posterior.leak_probability),
189 AdaptiveOutcome::ThresholdElevated { posterior, .. } => {
190 Some(posterior.leak_probability)
191 }
192 AdaptiveOutcome::Inconclusive { posterior, .. } => {
193 posterior.as_ref().map(|p| p.leak_probability)
194 }
195 }
196 }
197
198 pub fn is_leak_detected(&self) -> bool {
200 matches!(self, AdaptiveOutcome::LeakDetected { .. })
201 }
202
203 pub fn is_conclusive(&self) -> bool {
208 matches!(
209 self,
210 AdaptiveOutcome::LeakDetected { .. } | AdaptiveOutcome::NoLeakDetected { .. }
211 )
212 }
213
214 pub fn is_threshold_elevated(&self) -> bool {
216 matches!(self, AdaptiveOutcome::ThresholdElevated { .. })
217 }
218
219 pub fn samples_per_class(&self) -> usize {
221 match self {
222 AdaptiveOutcome::LeakDetected {
223 samples_per_class, ..
224 } => *samples_per_class,
225 AdaptiveOutcome::NoLeakDetected {
226 samples_per_class, ..
227 } => *samples_per_class,
228 AdaptiveOutcome::ThresholdElevated {
229 samples_per_class, ..
230 } => *samples_per_class,
231 AdaptiveOutcome::Inconclusive {
232 samples_per_class, ..
233 } => *samples_per_class,
234 }
235 }
236
237 pub fn elapsed_secs(&self) -> f64 {
239 match self {
240 AdaptiveOutcome::LeakDetected { elapsed_secs, .. } => *elapsed_secs,
241 AdaptiveOutcome::NoLeakDetected { elapsed_secs, .. } => *elapsed_secs,
242 AdaptiveOutcome::ThresholdElevated { elapsed_secs, .. } => *elapsed_secs,
243 AdaptiveOutcome::Inconclusive { elapsed_secs, .. } => *elapsed_secs,
244 }
245 }
246
247 pub fn to_summary(
252 &self,
253 calibration: &super::Calibration,
254 ) -> crate::ffi_summary::OutcomeSummary {
255 use crate::ffi_summary::{EffectSummary, InconclusiveReasonKind, OutcomeSummary, OutcomeType};
256 use crate::result::{Exploitability, MeasurementQuality};
257
258 let cal_summary = calibration.to_summary();
259
260 match self {
261 AdaptiveOutcome::LeakDetected {
262 posterior,
263 samples_per_class,
264 elapsed_secs,
265 } => {
266 let post_summary = posterior.to_summary();
267 let effect = build_effect_summary(&post_summary);
268 let diagnostics = build_diagnostics(&post_summary, &cal_summary);
269
270 OutcomeSummary {
271 outcome_type: OutcomeType::Fail,
272 leak_probability: post_summary.leak_probability,
273 samples_per_class: *samples_per_class,
274 elapsed_secs: *elapsed_secs,
275 effect,
276 quality: post_summary.measurement_quality(),
277 exploitability: post_summary.exploitability(),
278 inconclusive_reason: InconclusiveReasonKind::None,
279 recommendation: String::new(),
280 theta_user: cal_summary.theta_ns,
281 theta_eff: cal_summary.theta_eff,
282 theta_floor: cal_summary.theta_floor_initial,
283 theta_tick: cal_summary.theta_tick,
284 achievable_at_max: true,
285 diagnostics,
286 mde_shift_ns: cal_summary.mde_shift_ns,
287 mde_tail_ns: cal_summary.mde_tail_ns,
288 }
289 }
290
291 AdaptiveOutcome::NoLeakDetected {
292 posterior,
293 samples_per_class,
294 elapsed_secs,
295 } => {
296 let post_summary = posterior.to_summary();
297 let effect = build_effect_summary(&post_summary);
298 let diagnostics = build_diagnostics(&post_summary, &cal_summary);
299
300 OutcomeSummary {
301 outcome_type: OutcomeType::Pass,
302 leak_probability: post_summary.leak_probability,
303 samples_per_class: *samples_per_class,
304 elapsed_secs: *elapsed_secs,
305 effect,
306 quality: post_summary.measurement_quality(),
307 exploitability: Exploitability::SharedHardwareOnly,
308 inconclusive_reason: InconclusiveReasonKind::None,
309 recommendation: String::new(),
310 theta_user: cal_summary.theta_ns,
311 theta_eff: cal_summary.theta_eff,
312 theta_floor: cal_summary.theta_floor_initial,
313 theta_tick: cal_summary.theta_tick,
314 achievable_at_max: true,
315 diagnostics,
316 mde_shift_ns: cal_summary.mde_shift_ns,
317 mde_tail_ns: cal_summary.mde_tail_ns,
318 }
319 }
320
321 AdaptiveOutcome::ThresholdElevated {
322 posterior,
323 theta_user,
324 theta_eff,
325 theta_tick,
326 achievable_at_max,
327 samples_per_class,
328 elapsed_secs,
329 } => {
330 let post_summary = posterior.to_summary();
331 let effect = build_effect_summary(&post_summary);
332 let diagnostics = build_diagnostics(&post_summary, &cal_summary);
333
334 let recommendation = if *achievable_at_max {
335 alloc::format!(
336 "Threshold elevated from {:.0}ns to {:.1}ns. More samples could achieve the requested threshold.",
337 theta_user, theta_eff
338 )
339 } else {
340 alloc::format!(
341 "Threshold elevated from {:.0}ns to {:.1}ns. Use a cycle counter for better resolution.",
342 theta_user, theta_eff
343 )
344 };
345
346 OutcomeSummary {
347 outcome_type: OutcomeType::ThresholdElevated,
348 leak_probability: post_summary.leak_probability,
349 samples_per_class: *samples_per_class,
350 elapsed_secs: *elapsed_secs,
351 effect,
352 quality: post_summary.measurement_quality(),
353 exploitability: Exploitability::SharedHardwareOnly,
354 inconclusive_reason: InconclusiveReasonKind::ThresholdElevated,
355 recommendation,
356 theta_user: *theta_user,
357 theta_eff: *theta_eff,
358 theta_floor: cal_summary.theta_floor_initial,
359 theta_tick: *theta_tick,
360 achievable_at_max: *achievable_at_max,
361 diagnostics,
362 mde_shift_ns: cal_summary.mde_shift_ns,
363 mde_tail_ns: cal_summary.mde_tail_ns,
364 }
365 }
366
367 AdaptiveOutcome::Inconclusive {
368 reason,
369 posterior,
370 samples_per_class,
371 elapsed_secs,
372 } => {
373 let (post_summary, effect, quality, diagnostics) = match posterior {
374 Some(p) => {
375 let ps = p.to_summary();
376 let eff = build_effect_summary(&ps);
377 let qual = ps.measurement_quality();
378 let diag = build_diagnostics(&ps, &cal_summary);
379 (Some(ps), eff, qual, diag)
380 }
381 None => (
382 None,
383 EffectSummary::default(),
384 MeasurementQuality::TooNoisy,
385 build_diagnostics_from_calibration(&cal_summary),
386 ),
387 };
388
389 let (inconclusive_reason, recommendation) = convert_inconclusive_reason(reason);
390
391 OutcomeSummary {
392 outcome_type: OutcomeType::Inconclusive,
393 leak_probability: post_summary
394 .as_ref()
395 .map(|p| p.leak_probability)
396 .unwrap_or(0.5),
397 samples_per_class: *samples_per_class,
398 elapsed_secs: *elapsed_secs,
399 effect,
400 quality,
401 exploitability: Exploitability::SharedHardwareOnly,
402 inconclusive_reason,
403 recommendation,
404 theta_user: cal_summary.theta_ns,
405 theta_eff: cal_summary.theta_eff,
406 theta_floor: cal_summary.theta_floor_initial,
407 theta_tick: cal_summary.theta_tick,
408 achievable_at_max: false,
409 diagnostics,
410 mde_shift_ns: cal_summary.mde_shift_ns,
411 mde_tail_ns: cal_summary.mde_tail_ns,
412 }
413 }
414 }
415 }
416}
417
418fn build_effect_summary(
420 post: &crate::ffi_summary::PosteriorSummary,
421) -> crate::ffi_summary::EffectSummary {
422 crate::ffi_summary::EffectSummary {
423 shift_ns: post.shift_ns,
424 tail_ns: post.tail_ns,
425 ci_low_ns: post.ci_low_ns,
426 ci_high_ns: post.ci_high_ns,
427 pattern: post.pattern,
428 interpretation_caveat: None,
429 }
430}
431
432fn build_diagnostics(
434 post: &crate::ffi_summary::PosteriorSummary,
435 cal: &crate::ffi_summary::CalibrationSummary,
436) -> crate::ffi_summary::DiagnosticsSummary {
437 crate::ffi_summary::DiagnosticsSummary {
438 dependence_length: cal.block_length,
439 effective_sample_size: post.n,
440 stationarity_ratio: 1.0,
441 stationarity_ok: true,
442 projection_mismatch_q: post.projection_mismatch_q,
443 projection_mismatch_ok: post.projection_mismatch_q <= cal.projection_mismatch_thresh,
444 discrete_mode: cal.discrete_mode,
445 timer_resolution_ns: cal.timer_resolution_ns,
446 lambda_mean: post.lambda_mean,
447 lambda_mixing_ok: post.lambda_mixing_ok,
448 kappa_mean: post.kappa_mean,
449 kappa_cv: post.kappa_cv,
450 kappa_ess: post.kappa_ess,
451 kappa_mixing_ok: post.kappa_mixing_ok,
452 }
453}
454
455fn build_diagnostics_from_calibration(
457 cal: &crate::ffi_summary::CalibrationSummary,
458) -> crate::ffi_summary::DiagnosticsSummary {
459 crate::ffi_summary::DiagnosticsSummary {
460 dependence_length: cal.block_length,
461 effective_sample_size: 0,
462 stationarity_ratio: 1.0,
463 stationarity_ok: true,
464 projection_mismatch_q: 0.0,
465 projection_mismatch_ok: true,
466 discrete_mode: cal.discrete_mode,
467 timer_resolution_ns: cal.timer_resolution_ns,
468 lambda_mean: 1.0,
469 lambda_mixing_ok: true,
470 kappa_mean: 1.0,
471 kappa_cv: 0.0,
472 kappa_ess: 0.0,
473 kappa_mixing_ok: true,
474 }
475}
476
477fn convert_inconclusive_reason(
479 reason: &super::InconclusiveReason,
480) -> (crate::ffi_summary::InconclusiveReasonKind, String) {
481 use crate::ffi_summary::InconclusiveReasonKind;
482 use super::InconclusiveReason;
483
484 match reason {
485 InconclusiveReason::DataTooNoisy { guidance, .. } => {
486 (InconclusiveReasonKind::DataTooNoisy, guidance.clone())
487 }
488 InconclusiveReason::NotLearning { guidance, .. } => {
489 (InconclusiveReasonKind::NotLearning, guidance.clone())
490 }
491 InconclusiveReason::WouldTakeTooLong { guidance, .. } => {
492 (InconclusiveReasonKind::WouldTakeTooLong, guidance.clone())
493 }
494 InconclusiveReason::TimeBudgetExceeded { .. } => (
495 InconclusiveReasonKind::TimeBudgetExceeded,
496 String::from("Increase time budget or reduce threshold"),
497 ),
498 InconclusiveReason::SampleBudgetExceeded { .. } => (
499 InconclusiveReasonKind::SampleBudgetExceeded,
500 String::from("Increase sample budget or reduce threshold"),
501 ),
502 InconclusiveReason::ConditionsChanged { guidance, .. } => {
503 (InconclusiveReasonKind::ConditionsChanged, guidance.clone())
504 }
505 InconclusiveReason::ThresholdElevated { guidance, .. } => {
506 (InconclusiveReasonKind::ThresholdElevated, guidance.clone())
507 }
508 }
509}
510
511#[derive(Debug, Clone)]
513pub enum StepResult {
514 Decision(AdaptiveOutcome),
516
517 Continue {
519 posterior: Posterior,
521 samples_per_class: usize,
523 },
524}
525
526impl StepResult {
527 pub fn is_decision(&self) -> bool {
529 matches!(self, StepResult::Decision(_))
530 }
531
532 pub fn into_decision(self) -> Option<AdaptiveOutcome> {
534 match self {
535 StepResult::Decision(outcome) => Some(outcome),
536 StepResult::Continue { .. } => None,
537 }
538 }
539
540 pub fn leak_probability(&self) -> Option<f64> {
542 match self {
543 StepResult::Decision(outcome) => outcome.leak_probability(),
544 StepResult::Continue { posterior, .. } => Some(posterior.leak_probability),
545 }
546 }
547}
548
549pub fn adaptive_step(
570 calibration: &Calibration,
571 state: &mut AdaptiveState,
572 ns_per_tick: f64,
573 elapsed_secs: f64,
574 config: &AdaptiveStepConfig,
575) -> StepResult {
576 let posterior = match compute_posterior(state, calibration, ns_per_tick, config) {
578 Some(p) => p,
579 None => {
580 if state.n_total() < 20 {
582 return StepResult::Decision(AdaptiveOutcome::Inconclusive {
586 reason: InconclusiveReason::DataTooNoisy {
587 message: String::from("Insufficient samples for posterior computation"),
588 guidance: String::from("Need at least 20 samples per class"),
589 variance_ratio: 1.0,
590 },
591 posterior: None,
592 samples_per_class: state.n_total(),
593 elapsed_secs,
594 });
595 }
596 return StepResult::Decision(AdaptiveOutcome::Inconclusive {
598 reason: InconclusiveReason::DataTooNoisy {
599 message: String::from("Could not compute posterior from samples"),
600 guidance: String::from("Check timer resolution and sample count"),
601 variance_ratio: 1.0,
602 },
603 posterior: None,
604 samples_per_class: state.n_total(),
605 elapsed_secs,
606 });
607 }
608 };
609
610 let _kl = state.update_posterior(posterior.clone());
612
613 if posterior.leak_probability > config.fail_threshold {
618 return StepResult::Decision(AdaptiveOutcome::LeakDetected {
619 posterior,
620 samples_per_class: state.n_total(),
621 elapsed_secs,
622 });
623 }
624
625 if posterior.leak_probability < config.pass_threshold {
627 let theta_user = config.theta_ns;
629 let theta_eff = calibration.theta_eff;
630 let theta_tick = calibration.theta_tick;
631
632 if is_threshold_elevated(theta_eff, theta_user, theta_tick) {
633 let achievable_at_max = compute_achievable_at_max(
635 calibration.c_floor,
636 theta_tick,
637 theta_user,
638 config.max_samples,
639 calibration.block_length, );
641
642 return StepResult::Decision(AdaptiveOutcome::ThresholdElevated {
643 posterior,
644 theta_user,
645 theta_eff,
646 theta_tick,
647 achievable_at_max,
648 samples_per_class: state.n_total(),
649 elapsed_secs,
650 });
651 }
652
653 return StepResult::Decision(AdaptiveOutcome::NoLeakDetected {
655 posterior,
656 samples_per_class: state.n_total(),
657 elapsed_secs,
658 });
659 }
660
661 let current_stats = state.get_stats_snapshot();
663 let gate_inputs = QualityGateCheckInputs {
664 posterior: &posterior,
665 prior_cov_marginal: &calibration.prior_cov_marginal,
666 theta_ns: config.theta_ns,
667 n_total: state.n_total(),
668 elapsed_secs,
669 recent_kl_sum: if state.has_kl_history() {
670 Some(state.recent_kl_sum())
671 } else {
672 None
673 },
674 samples_per_second: calibration.samples_per_second,
675 calibration_snapshot: Some(&calibration.calibration_snapshot),
676 current_stats_snapshot: current_stats.as_ref(),
677 c_floor: calibration.c_floor,
678 theta_tick: calibration.theta_tick,
679 projection_mismatch_q: if posterior.projection_mismatch_q.is_nan() {
680 None
681 } else {
682 Some(posterior.projection_mismatch_q)
683 },
684 projection_mismatch_thresh: calibration.projection_mismatch_thresh,
685 lambda_mixing_ok: posterior.lambda_mixing_ok,
686 };
687
688 match check_quality_gates(&gate_inputs, &config.quality_gates) {
689 QualityGateResult::Continue => StepResult::Continue {
690 posterior,
691 samples_per_class: state.n_total(),
692 },
693 QualityGateResult::Stop(reason) => StepResult::Decision(AdaptiveOutcome::Inconclusive {
694 reason,
695 posterior: Some(posterior),
696 samples_per_class: state.n_total(),
697 elapsed_secs,
698 }),
699 }
700}
701
702fn compute_posterior(
706 state: &AdaptiveState,
707 calibration: &Calibration,
708 ns_per_tick: f64,
709 config: &AdaptiveStepConfig,
710) -> Option<Posterior> {
711 let n = state.n_total();
712 if n < 20 {
713 return None; }
715
716 let baseline_ns = state.baseline_ns(ns_per_tick);
718 let sample_ns = state.sample_ns(ns_per_tick);
719
720 let observed_diff = if calibration.discrete_mode {
722 let q_baseline = compute_midquantile_deciles(&baseline_ns);
723 let q_sample = compute_midquantile_deciles(&sample_ns);
724 q_baseline - q_sample
725 } else {
726 let mut baseline_sorted = baseline_ns;
727 let mut sample_sorted = sample_ns;
728 let q_baseline = compute_deciles_inplace(&mut baseline_sorted);
729 let q_sample = compute_deciles_inplace(&mut sample_sorted);
730 q_baseline - q_sample
731 };
732
733 let sigma_n = calibration.covariance_for_n(n);
735
736 let bayes_result = compute_bayes_gibbs(
738 &observed_diff,
739 &sigma_n,
740 calibration.sigma_t,
741 &calibration.l_r,
742 config.theta_ns,
743 Some(config.seed),
744 );
745
746 Some(Posterior::new_with_gibbs(
747 bayes_result.delta_post,
748 bayes_result.lambda_post,
749 bayes_result.beta_proj,
750 bayes_result.beta_proj_cov,
751 bayes_result.beta_draws,
752 bayes_result.leak_probability,
753 bayes_result.projection_mismatch_q,
754 n,
755 bayes_result.lambda_mean,
756 bayes_result.lambda_mixing_ok,
757 bayes_result.kappa_mean,
759 bayes_result.kappa_cv,
760 bayes_result.kappa_ess,
761 bayes_result.kappa_mixing_ok,
762 ))
763}
764
765#[cfg(test)]
766mod tests {
767 use super::*;
768 use crate::statistics::StatsSnapshot;
769 use crate::types::{Matrix2, Matrix9, Vector2, Vector9};
770
771 fn make_test_calibration() -> Calibration {
772 use crate::adaptive::CalibrationSnapshot;
773
774 let snapshot = CalibrationSnapshot::new(
775 StatsSnapshot {
776 count: 5000,
777 mean: 1000.0,
778 variance: 25.0,
779 autocorr_lag1: 0.1,
780 },
781 StatsSnapshot {
782 count: 5000,
783 mean: 1000.0,
784 variance: 25.0,
785 autocorr_lag1: 0.1,
786 },
787 );
788
789 Calibration::new(
791 Matrix9::identity() * 1000.0, 10, 100.0, Matrix9::identity(), 100.0, 5000, false, 5.0, 10.0, snapshot, 1.0, 100_000.0, 10.0, 18.48, 0.001, 100.0, 0.1, 42, 1, )
811 }
812
813 fn make_test_posterior(leak_prob: f64) -> Posterior {
814 Posterior::new(
815 Vector9::zeros(),
816 Matrix9::identity(),
817 Vector2::new(10.0, 5.0),
818 Matrix2::new(1.0, 0.0, 0.0, 1.0),
819 Vec::new(), leak_prob,
821 1.0, 1000,
823 )
824 }
825
826 #[test]
827 fn test_adaptive_step_config_default() {
828 let config = AdaptiveStepConfig::default();
829 assert!((config.pass_threshold - 0.05).abs() < 1e-10);
830 assert!((config.fail_threshold - 0.95).abs() < 1e-10);
831 assert!((config.time_budget_secs - 30.0).abs() < 1e-10);
832 }
833
834 #[test]
835 fn test_adaptive_step_config_builder() {
836 let config = AdaptiveStepConfig::with_theta(50.0)
837 .pass_threshold(0.01)
838 .fail_threshold(0.99)
839 .time_budget_secs(60.0)
840 .max_samples(500_000);
841
842 assert!((config.theta_ns - 50.0).abs() < 1e-10);
843 assert!((config.pass_threshold - 0.01).abs() < 1e-10);
844 assert!((config.fail_threshold - 0.99).abs() < 1e-10);
845 assert!((config.time_budget_secs - 60.0).abs() < 1e-10);
846 assert_eq!(config.max_samples, 500_000);
847 }
848
849 #[test]
850 fn test_adaptive_outcome_accessors() {
851 let posterior = make_test_posterior(0.95);
852
853 let outcome = AdaptiveOutcome::LeakDetected {
854 posterior: posterior.clone(),
855 samples_per_class: 1000,
856 elapsed_secs: 1.5,
857 };
858
859 assert!(outcome.is_leak_detected());
860 assert!(outcome.is_conclusive());
861 assert_eq!(outcome.leak_probability(), Some(0.95));
862 assert_eq!(outcome.samples_per_class(), 1000);
863 assert!((outcome.elapsed_secs() - 1.5).abs() < 1e-10);
864
865 let outcome = AdaptiveOutcome::NoLeakDetected {
866 posterior,
867 samples_per_class: 2000,
868 elapsed_secs: 2.5,
869 };
870
871 assert!(!outcome.is_leak_detected());
872 assert!(outcome.is_conclusive());
873 assert_eq!(outcome.samples_per_class(), 2000);
874 }
875
876 #[test]
877 fn test_step_result_accessors() {
878 let posterior = make_test_posterior(0.5);
879
880 let result = StepResult::Continue {
881 posterior: posterior.clone(),
882 samples_per_class: 1000,
883 };
884
885 assert!(!result.is_decision());
886 assert_eq!(result.leak_probability(), Some(0.5));
887
888 let result = StepResult::Decision(AdaptiveOutcome::LeakDetected {
889 posterior,
890 samples_per_class: 1000,
891 elapsed_secs: 1.0,
892 });
893
894 assert!(result.is_decision());
895 }
896
897 #[test]
898 fn test_adaptive_step_insufficient_samples() {
899 let calibration = make_test_calibration();
900 let mut state = AdaptiveState::new();
901 state.add_batch(vec![100; 10], vec![101; 10]); let config = AdaptiveStepConfig::default();
904 let result = adaptive_step(&calibration, &mut state, 1.0, 0.1, &config);
905
906 assert!(result.is_decision());
908 if let StepResult::Decision(AdaptiveOutcome::Inconclusive { reason, .. }) = result {
909 assert!(matches!(reason, InconclusiveReason::DataTooNoisy { .. }));
910 } else {
911 panic!("Expected Inconclusive with DataTooNoisy");
912 }
913 }
914
915 #[test]
916 fn test_compute_posterior_no_difference() {
917 let calibration = make_test_calibration();
918 let mut state = AdaptiveState::new();
919
920 let baseline: Vec<u64> = (0..1000).map(|i| 1000 + (i % 10)).collect();
922 let sample: Vec<u64> = (0..1000).map(|i| 1000 + (i % 10)).collect();
923 state.add_batch(baseline, sample);
924
925 let config = AdaptiveStepConfig::with_theta(100.0);
926
927 let posterior = compute_posterior(&state, &calibration, 1.0, &config);
928
929 assert!(posterior.is_some());
930 let p = posterior.unwrap();
931
932 assert!(
934 p.leak_probability < 0.5,
935 "Identical distributions should have low leak probability, got {}",
936 p.leak_probability
937 );
938 }
939
940 #[test]
941 fn test_compute_posterior_with_difference() {
942 let calibration = make_test_calibration();
943 let mut state = AdaptiveState::new();
944
945 let baseline: Vec<u64> = (0..1000).map(|i| 1000 + (i % 10)).collect();
947 let sample: Vec<u64> = (0..1000).map(|i| 1200 + (i % 10)).collect();
948 state.add_batch(baseline, sample);
949
950 let config = AdaptiveStepConfig::with_theta(100.0); let posterior = compute_posterior(&state, &calibration, 1.0, &config);
953
954 assert!(posterior.is_some());
955 let p = posterior.unwrap();
956
957 assert!(
959 p.leak_probability > 0.5,
960 "Clear difference should have high leak probability, got {}",
961 p.leak_probability
962 );
963 }
964
965 #[test]
966 fn test_adaptive_step_detects_leak() {
967 let calibration = make_test_calibration();
968 let mut state = AdaptiveState::new();
969
970 let baseline: Vec<u64> = (0..1000).map(|i| 1000 + (i % 10)).collect();
972 let sample: Vec<u64> = (0..1000).map(|i| 1500 + (i % 10)).collect();
973 state.add_batch(baseline, sample);
974
975 let config = AdaptiveStepConfig::with_theta(100.0);
976 let result = adaptive_step(&calibration, &mut state, 1.0, 1.0, &config);
977
978 assert!(result.is_decision());
980 if let StepResult::Decision(outcome) = result {
981 assert!(outcome.is_leak_detected());
982 } else {
983 panic!("Expected Decision");
984 }
985 }
986}