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::{
256 EffectSummary, InconclusiveReasonKind, OutcomeSummary, OutcomeType,
257 };
258 use crate::result::{Exploitability, MeasurementQuality};
259
260 let cal_summary = calibration.to_summary();
261
262 match self {
263 AdaptiveOutcome::LeakDetected {
264 posterior,
265 samples_per_class,
266 elapsed_secs,
267 } => {
268 let post_summary = posterior.to_summary();
269 let effect = build_effect_summary(&post_summary);
270 let diagnostics = build_diagnostics(&post_summary, &cal_summary);
271
272 OutcomeSummary {
273 outcome_type: OutcomeType::Fail,
274 leak_probability: post_summary.leak_probability,
275 samples_per_class: *samples_per_class,
276 elapsed_secs: *elapsed_secs,
277 effect,
278 quality: post_summary.measurement_quality(),
279 exploitability: post_summary.exploitability(),
280 inconclusive_reason: InconclusiveReasonKind::None,
281 recommendation: String::new(),
282 theta_user: cal_summary.theta_ns,
283 theta_eff: cal_summary.theta_eff,
284 theta_floor: cal_summary.theta_floor_initial,
285 theta_tick: cal_summary.theta_tick,
286 achievable_at_max: true,
287 diagnostics,
288 mde_shift_ns: cal_summary.mde_shift_ns,
289 mde_tail_ns: cal_summary.mde_tail_ns,
290 }
291 }
292
293 AdaptiveOutcome::NoLeakDetected {
294 posterior,
295 samples_per_class,
296 elapsed_secs,
297 } => {
298 let post_summary = posterior.to_summary();
299 let effect = build_effect_summary(&post_summary);
300 let diagnostics = build_diagnostics(&post_summary, &cal_summary);
301
302 OutcomeSummary {
303 outcome_type: OutcomeType::Pass,
304 leak_probability: post_summary.leak_probability,
305 samples_per_class: *samples_per_class,
306 elapsed_secs: *elapsed_secs,
307 effect,
308 quality: post_summary.measurement_quality(),
309 exploitability: Exploitability::SharedHardwareOnly,
310 inconclusive_reason: InconclusiveReasonKind::None,
311 recommendation: String::new(),
312 theta_user: cal_summary.theta_ns,
313 theta_eff: cal_summary.theta_eff,
314 theta_floor: cal_summary.theta_floor_initial,
315 theta_tick: cal_summary.theta_tick,
316 achievable_at_max: true,
317 diagnostics,
318 mde_shift_ns: cal_summary.mde_shift_ns,
319 mde_tail_ns: cal_summary.mde_tail_ns,
320 }
321 }
322
323 AdaptiveOutcome::ThresholdElevated {
324 posterior,
325 theta_user,
326 theta_eff,
327 theta_tick,
328 achievable_at_max,
329 samples_per_class,
330 elapsed_secs,
331 } => {
332 let post_summary = posterior.to_summary();
333 let effect = build_effect_summary(&post_summary);
334 let diagnostics = build_diagnostics(&post_summary, &cal_summary);
335
336 let recommendation = if *achievable_at_max {
337 alloc::format!(
338 "Threshold elevated from {:.0}ns to {:.1}ns. More samples could achieve the requested threshold.",
339 theta_user, theta_eff
340 )
341 } else {
342 alloc::format!(
343 "Threshold elevated from {:.0}ns to {:.1}ns. Use a cycle counter for better resolution.",
344 theta_user, theta_eff
345 )
346 };
347
348 OutcomeSummary {
349 outcome_type: OutcomeType::ThresholdElevated,
350 leak_probability: post_summary.leak_probability,
351 samples_per_class: *samples_per_class,
352 elapsed_secs: *elapsed_secs,
353 effect,
354 quality: post_summary.measurement_quality(),
355 exploitability: Exploitability::SharedHardwareOnly,
356 inconclusive_reason: InconclusiveReasonKind::ThresholdElevated,
357 recommendation,
358 theta_user: *theta_user,
359 theta_eff: *theta_eff,
360 theta_floor: cal_summary.theta_floor_initial,
361 theta_tick: *theta_tick,
362 achievable_at_max: *achievable_at_max,
363 diagnostics,
364 mde_shift_ns: cal_summary.mde_shift_ns,
365 mde_tail_ns: cal_summary.mde_tail_ns,
366 }
367 }
368
369 AdaptiveOutcome::Inconclusive {
370 reason,
371 posterior,
372 samples_per_class,
373 elapsed_secs,
374 } => {
375 let (post_summary, effect, quality, diagnostics) = match posterior {
376 Some(p) => {
377 let ps = p.to_summary();
378 let eff = build_effect_summary(&ps);
379 let qual = ps.measurement_quality();
380 let diag = build_diagnostics(&ps, &cal_summary);
381 (Some(ps), eff, qual, diag)
382 }
383 None => (
384 None,
385 EffectSummary::default(),
386 MeasurementQuality::TooNoisy,
387 build_diagnostics_from_calibration(&cal_summary),
388 ),
389 };
390
391 let (inconclusive_reason, recommendation) = convert_inconclusive_reason(reason);
392
393 OutcomeSummary {
394 outcome_type: OutcomeType::Inconclusive,
395 leak_probability: post_summary
396 .as_ref()
397 .map(|p| p.leak_probability)
398 .unwrap_or(0.5),
399 samples_per_class: *samples_per_class,
400 elapsed_secs: *elapsed_secs,
401 effect,
402 quality,
403 exploitability: Exploitability::SharedHardwareOnly,
404 inconclusive_reason,
405 recommendation,
406 theta_user: cal_summary.theta_ns,
407 theta_eff: cal_summary.theta_eff,
408 theta_floor: cal_summary.theta_floor_initial,
409 theta_tick: cal_summary.theta_tick,
410 achievable_at_max: false,
411 diagnostics,
412 mde_shift_ns: cal_summary.mde_shift_ns,
413 mde_tail_ns: cal_summary.mde_tail_ns,
414 }
415 }
416 }
417 }
418}
419
420fn build_effect_summary(
422 post: &crate::ffi_summary::PosteriorSummary,
423) -> crate::ffi_summary::EffectSummary {
424 crate::ffi_summary::EffectSummary {
425 shift_ns: post.shift_ns,
426 tail_ns: post.tail_ns,
427 ci_low_ns: post.ci_low_ns,
428 ci_high_ns: post.ci_high_ns,
429 pattern: post.pattern,
430 interpretation_caveat: None,
431 }
432}
433
434fn build_diagnostics(
436 post: &crate::ffi_summary::PosteriorSummary,
437 cal: &crate::ffi_summary::CalibrationSummary,
438) -> crate::ffi_summary::DiagnosticsSummary {
439 crate::ffi_summary::DiagnosticsSummary {
440 dependence_length: cal.block_length,
441 effective_sample_size: post.n,
442 stationarity_ratio: 1.0,
443 stationarity_ok: true,
444 projection_mismatch_q: post.projection_mismatch_q,
445 projection_mismatch_ok: post.projection_mismatch_q <= cal.projection_mismatch_thresh,
446 discrete_mode: cal.discrete_mode,
447 timer_resolution_ns: cal.timer_resolution_ns,
448 lambda_mean: post.lambda_mean,
449 lambda_mixing_ok: post.lambda_mixing_ok,
450 kappa_mean: post.kappa_mean,
451 kappa_cv: post.kappa_cv,
452 kappa_ess: post.kappa_ess,
453 kappa_mixing_ok: post.kappa_mixing_ok,
454 }
455}
456
457fn build_diagnostics_from_calibration(
459 cal: &crate::ffi_summary::CalibrationSummary,
460) -> crate::ffi_summary::DiagnosticsSummary {
461 crate::ffi_summary::DiagnosticsSummary {
462 dependence_length: cal.block_length,
463 effective_sample_size: 0,
464 stationarity_ratio: 1.0,
465 stationarity_ok: true,
466 projection_mismatch_q: 0.0,
467 projection_mismatch_ok: true,
468 discrete_mode: cal.discrete_mode,
469 timer_resolution_ns: cal.timer_resolution_ns,
470 lambda_mean: 1.0,
471 lambda_mixing_ok: true,
472 kappa_mean: 1.0,
473 kappa_cv: 0.0,
474 kappa_ess: 0.0,
475 kappa_mixing_ok: true,
476 }
477}
478
479fn convert_inconclusive_reason(
481 reason: &super::InconclusiveReason,
482) -> (crate::ffi_summary::InconclusiveReasonKind, String) {
483 use super::InconclusiveReason;
484 use crate::ffi_summary::InconclusiveReasonKind;
485
486 match reason {
487 InconclusiveReason::DataTooNoisy { guidance, .. } => {
488 (InconclusiveReasonKind::DataTooNoisy, guidance.clone())
489 }
490 InconclusiveReason::NotLearning { guidance, .. } => {
491 (InconclusiveReasonKind::NotLearning, guidance.clone())
492 }
493 InconclusiveReason::WouldTakeTooLong { guidance, .. } => {
494 (InconclusiveReasonKind::WouldTakeTooLong, guidance.clone())
495 }
496 InconclusiveReason::TimeBudgetExceeded { .. } => (
497 InconclusiveReasonKind::TimeBudgetExceeded,
498 String::from("Increase time budget or reduce threshold"),
499 ),
500 InconclusiveReason::SampleBudgetExceeded { .. } => (
501 InconclusiveReasonKind::SampleBudgetExceeded,
502 String::from("Increase sample budget or reduce threshold"),
503 ),
504 InconclusiveReason::ConditionsChanged { guidance, .. } => {
505 (InconclusiveReasonKind::ConditionsChanged, guidance.clone())
506 }
507 InconclusiveReason::ThresholdElevated { guidance, .. } => {
508 (InconclusiveReasonKind::ThresholdElevated, guidance.clone())
509 }
510 }
511}
512
513#[derive(Debug, Clone)]
515pub enum StepResult {
516 Decision(AdaptiveOutcome),
518
519 Continue {
521 posterior: Posterior,
523 samples_per_class: usize,
525 },
526}
527
528impl StepResult {
529 pub fn is_decision(&self) -> bool {
531 matches!(self, StepResult::Decision(_))
532 }
533
534 pub fn into_decision(self) -> Option<AdaptiveOutcome> {
536 match self {
537 StepResult::Decision(outcome) => Some(outcome),
538 StepResult::Continue { .. } => None,
539 }
540 }
541
542 pub fn leak_probability(&self) -> Option<f64> {
544 match self {
545 StepResult::Decision(outcome) => outcome.leak_probability(),
546 StepResult::Continue { posterior, .. } => Some(posterior.leak_probability),
547 }
548 }
549}
550
551pub fn adaptive_step(
572 calibration: &Calibration,
573 state: &mut AdaptiveState,
574 ns_per_tick: f64,
575 elapsed_secs: f64,
576 config: &AdaptiveStepConfig,
577) -> StepResult {
578 let posterior = match compute_posterior(state, calibration, ns_per_tick, config) {
580 Some(p) => p,
581 None => {
582 if state.n_total() < 20 {
584 return StepResult::Decision(AdaptiveOutcome::Inconclusive {
588 reason: InconclusiveReason::DataTooNoisy {
589 message: String::from("Insufficient samples for posterior computation"),
590 guidance: String::from("Need at least 20 samples per class"),
591 variance_ratio: 1.0,
592 },
593 posterior: None,
594 samples_per_class: state.n_total(),
595 elapsed_secs,
596 });
597 }
598 return StepResult::Decision(AdaptiveOutcome::Inconclusive {
600 reason: InconclusiveReason::DataTooNoisy {
601 message: String::from("Could not compute posterior from samples"),
602 guidance: String::from("Check timer resolution and sample count"),
603 variance_ratio: 1.0,
604 },
605 posterior: None,
606 samples_per_class: state.n_total(),
607 elapsed_secs,
608 });
609 }
610 };
611
612 let _kl = state.update_posterior(posterior.clone());
614
615 if posterior.leak_probability > config.fail_threshold {
620 return StepResult::Decision(AdaptiveOutcome::LeakDetected {
621 posterior,
622 samples_per_class: state.n_total(),
623 elapsed_secs,
624 });
625 }
626
627 if posterior.leak_probability < config.pass_threshold {
629 let theta_user = config.theta_ns;
631 let theta_eff = calibration.theta_eff;
632 let theta_tick = calibration.theta_tick;
633
634 if is_threshold_elevated(theta_eff, theta_user, theta_tick) {
635 let achievable_at_max = compute_achievable_at_max(
637 calibration.c_floor,
638 theta_tick,
639 theta_user,
640 config.max_samples,
641 calibration.block_length, );
643
644 return StepResult::Decision(AdaptiveOutcome::ThresholdElevated {
645 posterior,
646 theta_user,
647 theta_eff,
648 theta_tick,
649 achievable_at_max,
650 samples_per_class: state.n_total(),
651 elapsed_secs,
652 });
653 }
654
655 return StepResult::Decision(AdaptiveOutcome::NoLeakDetected {
657 posterior,
658 samples_per_class: state.n_total(),
659 elapsed_secs,
660 });
661 }
662
663 let current_stats = state.get_stats_snapshot();
665 let gate_inputs = QualityGateCheckInputs {
666 posterior: &posterior,
667 prior_cov_marginal: &calibration.prior_cov_marginal,
668 theta_ns: config.theta_ns,
669 n_total: state.n_total(),
670 elapsed_secs,
671 recent_kl_sum: if state.has_kl_history() {
672 Some(state.recent_kl_sum())
673 } else {
674 None
675 },
676 samples_per_second: calibration.samples_per_second,
677 calibration_snapshot: Some(&calibration.calibration_snapshot),
678 current_stats_snapshot: current_stats.as_ref(),
679 c_floor: calibration.c_floor,
680 theta_tick: calibration.theta_tick,
681 projection_mismatch_q: if posterior.projection_mismatch_q.is_nan() {
682 None
683 } else {
684 Some(posterior.projection_mismatch_q)
685 },
686 projection_mismatch_thresh: calibration.projection_mismatch_thresh,
687 lambda_mixing_ok: posterior.lambda_mixing_ok,
688 };
689
690 match check_quality_gates(&gate_inputs, &config.quality_gates) {
691 QualityGateResult::Continue => StepResult::Continue {
692 posterior,
693 samples_per_class: state.n_total(),
694 },
695 QualityGateResult::Stop(reason) => StepResult::Decision(AdaptiveOutcome::Inconclusive {
696 reason,
697 posterior: Some(posterior),
698 samples_per_class: state.n_total(),
699 elapsed_secs,
700 }),
701 }
702}
703
704fn compute_posterior(
708 state: &AdaptiveState,
709 calibration: &Calibration,
710 ns_per_tick: f64,
711 config: &AdaptiveStepConfig,
712) -> Option<Posterior> {
713 let n = state.n_total();
714 if n < 20 {
715 return None; }
717
718 let baseline_ns = state.baseline_ns(ns_per_tick);
720 let sample_ns = state.sample_ns(ns_per_tick);
721
722 let observed_diff = if calibration.discrete_mode {
724 let q_baseline = compute_midquantile_deciles(&baseline_ns);
725 let q_sample = compute_midquantile_deciles(&sample_ns);
726 q_baseline - q_sample
727 } else {
728 let mut baseline_sorted = baseline_ns;
729 let mut sample_sorted = sample_ns;
730 let q_baseline = compute_deciles_inplace(&mut baseline_sorted);
731 let q_sample = compute_deciles_inplace(&mut sample_sorted);
732 q_baseline - q_sample
733 };
734
735 let sigma_n = calibration.covariance_for_n(n);
737
738 let bayes_result = compute_bayes_gibbs(
740 &observed_diff,
741 &sigma_n,
742 calibration.sigma_t,
743 &calibration.l_r,
744 config.theta_ns,
745 Some(config.seed),
746 );
747
748 Some(Posterior::new_with_gibbs(
749 bayes_result.delta_post,
750 bayes_result.lambda_post,
751 bayes_result.beta_proj,
752 bayes_result.beta_proj_cov,
753 bayes_result.beta_draws,
754 bayes_result.leak_probability,
755 bayes_result.projection_mismatch_q,
756 n,
757 bayes_result.lambda_mean,
758 bayes_result.lambda_mixing_ok,
759 bayes_result.kappa_mean,
761 bayes_result.kappa_cv,
762 bayes_result.kappa_ess,
763 bayes_result.kappa_mixing_ok,
764 ))
765}
766
767#[cfg(test)]
768mod tests {
769 use super::*;
770 use crate::statistics::StatsSnapshot;
771 use crate::types::{Matrix2, Matrix9, Vector2, Vector9};
772
773 fn make_test_calibration() -> Calibration {
774 use crate::adaptive::CalibrationSnapshot;
775
776 let snapshot = CalibrationSnapshot::new(
777 StatsSnapshot {
778 count: 5000,
779 mean: 1000.0,
780 variance: 25.0,
781 autocorr_lag1: 0.1,
782 },
783 StatsSnapshot {
784 count: 5000,
785 mean: 1000.0,
786 variance: 25.0,
787 autocorr_lag1: 0.1,
788 },
789 );
790
791 Calibration::new(
793 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, )
813 }
814
815 fn make_test_posterior(leak_prob: f64) -> Posterior {
816 Posterior::new(
817 Vector9::zeros(),
818 Matrix9::identity(),
819 Vector2::new(10.0, 5.0),
820 Matrix2::new(1.0, 0.0, 0.0, 1.0),
821 Vec::new(), leak_prob,
823 1.0, 1000,
825 )
826 }
827
828 #[test]
829 fn test_adaptive_step_config_default() {
830 let config = AdaptiveStepConfig::default();
831 assert!((config.pass_threshold - 0.05).abs() < 1e-10);
832 assert!((config.fail_threshold - 0.95).abs() < 1e-10);
833 assert!((config.time_budget_secs - 30.0).abs() < 1e-10);
834 }
835
836 #[test]
837 fn test_adaptive_step_config_builder() {
838 let config = AdaptiveStepConfig::with_theta(50.0)
839 .pass_threshold(0.01)
840 .fail_threshold(0.99)
841 .time_budget_secs(60.0)
842 .max_samples(500_000);
843
844 assert!((config.theta_ns - 50.0).abs() < 1e-10);
845 assert!((config.pass_threshold - 0.01).abs() < 1e-10);
846 assert!((config.fail_threshold - 0.99).abs() < 1e-10);
847 assert!((config.time_budget_secs - 60.0).abs() < 1e-10);
848 assert_eq!(config.max_samples, 500_000);
849 }
850
851 #[test]
852 fn test_adaptive_outcome_accessors() {
853 let posterior = make_test_posterior(0.95);
854
855 let outcome = AdaptiveOutcome::LeakDetected {
856 posterior: posterior.clone(),
857 samples_per_class: 1000,
858 elapsed_secs: 1.5,
859 };
860
861 assert!(outcome.is_leak_detected());
862 assert!(outcome.is_conclusive());
863 assert_eq!(outcome.leak_probability(), Some(0.95));
864 assert_eq!(outcome.samples_per_class(), 1000);
865 assert!((outcome.elapsed_secs() - 1.5).abs() < 1e-10);
866
867 let outcome = AdaptiveOutcome::NoLeakDetected {
868 posterior,
869 samples_per_class: 2000,
870 elapsed_secs: 2.5,
871 };
872
873 assert!(!outcome.is_leak_detected());
874 assert!(outcome.is_conclusive());
875 assert_eq!(outcome.samples_per_class(), 2000);
876 }
877
878 #[test]
879 fn test_step_result_accessors() {
880 let posterior = make_test_posterior(0.5);
881
882 let result = StepResult::Continue {
883 posterior: posterior.clone(),
884 samples_per_class: 1000,
885 };
886
887 assert!(!result.is_decision());
888 assert_eq!(result.leak_probability(), Some(0.5));
889
890 let result = StepResult::Decision(AdaptiveOutcome::LeakDetected {
891 posterior,
892 samples_per_class: 1000,
893 elapsed_secs: 1.0,
894 });
895
896 assert!(result.is_decision());
897 }
898
899 #[test]
900 fn test_adaptive_step_insufficient_samples() {
901 let calibration = make_test_calibration();
902 let mut state = AdaptiveState::new();
903 state.add_batch(vec![100; 10], vec![101; 10]); let config = AdaptiveStepConfig::default();
906 let result = adaptive_step(&calibration, &mut state, 1.0, 0.1, &config);
907
908 assert!(result.is_decision());
910 if let StepResult::Decision(AdaptiveOutcome::Inconclusive { reason, .. }) = result {
911 assert!(matches!(reason, InconclusiveReason::DataTooNoisy { .. }));
912 } else {
913 panic!("Expected Inconclusive with DataTooNoisy");
914 }
915 }
916
917 #[test]
918 fn test_compute_posterior_no_difference() {
919 let calibration = make_test_calibration();
920 let mut state = AdaptiveState::new();
921
922 let baseline: Vec<u64> = (0..1000).map(|i| 1000 + (i % 10)).collect();
924 let sample: Vec<u64> = (0..1000).map(|i| 1000 + (i % 10)).collect();
925 state.add_batch(baseline, sample);
926
927 let config = AdaptiveStepConfig::with_theta(100.0);
928
929 let posterior = compute_posterior(&state, &calibration, 1.0, &config);
930
931 assert!(posterior.is_some());
932 let p = posterior.unwrap();
933
934 assert!(
936 p.leak_probability < 0.5,
937 "Identical distributions should have low leak probability, got {}",
938 p.leak_probability
939 );
940 }
941
942 #[test]
943 fn test_compute_posterior_with_difference() {
944 let calibration = make_test_calibration();
945 let mut state = AdaptiveState::new();
946
947 let baseline: Vec<u64> = (0..1000).map(|i| 1000 + (i % 10)).collect();
949 let sample: Vec<u64> = (0..1000).map(|i| 1200 + (i % 10)).collect();
950 state.add_batch(baseline, sample);
951
952 let config = AdaptiveStepConfig::with_theta(100.0); let posterior = compute_posterior(&state, &calibration, 1.0, &config);
955
956 assert!(posterior.is_some());
957 let p = posterior.unwrap();
958
959 assert!(
961 p.leak_probability > 0.5,
962 "Clear difference should have high leak probability, got {}",
963 p.leak_probability
964 );
965 }
966
967 #[test]
968 fn test_adaptive_step_detects_leak() {
969 let calibration = make_test_calibration();
970 let mut state = AdaptiveState::new();
971
972 let baseline: Vec<u64> = (0..1000).map(|i| 1000 + (i % 10)).collect();
974 let sample: Vec<u64> = (0..1000).map(|i| 1500 + (i % 10)).collect();
975 state.add_batch(baseline, sample);
976
977 let config = AdaptiveStepConfig::with_theta(100.0);
978 let result = adaptive_step(&calibration, &mut state, 1.0, 1.0, &config);
979
980 assert!(result.is_decision());
982 if let StepResult::Decision(outcome) = result {
983 assert!(outcome.is_leak_detected());
984 } else {
985 panic!("Expected Decision");
986 }
987 }
988}