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_ns: cal_summary.mde_ns,
289 }
290 }
291
292 AdaptiveOutcome::NoLeakDetected {
293 posterior,
294 samples_per_class,
295 elapsed_secs,
296 } => {
297 let post_summary = posterior.to_summary();
298 let effect = build_effect_summary(&post_summary);
299 let diagnostics = build_diagnostics(&post_summary, &cal_summary);
300
301 OutcomeSummary {
302 outcome_type: OutcomeType::Pass,
303 leak_probability: post_summary.leak_probability,
304 samples_per_class: *samples_per_class,
305 elapsed_secs: *elapsed_secs,
306 effect,
307 quality: post_summary.measurement_quality(),
308 exploitability: Exploitability::SharedHardwareOnly,
309 inconclusive_reason: InconclusiveReasonKind::None,
310 recommendation: String::new(),
311 theta_user: cal_summary.theta_ns,
312 theta_eff: cal_summary.theta_eff,
313 theta_floor: cal_summary.theta_floor_initial,
314 theta_tick: cal_summary.theta_tick,
315 achievable_at_max: true,
316 diagnostics,
317 mde_ns: cal_summary.mde_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_ns: cal_summary.mde_ns,
363 }
364 }
365
366 AdaptiveOutcome::Inconclusive {
367 reason,
368 posterior,
369 samples_per_class,
370 elapsed_secs,
371 } => {
372 let (post_summary, effect, quality, diagnostics) = match posterior {
373 Some(p) => {
374 let ps = p.to_summary();
375 let eff = build_effect_summary(&ps);
376 let qual = ps.measurement_quality();
377 let diag = build_diagnostics(&ps, &cal_summary);
378 (Some(ps), eff, qual, diag)
379 }
380 None => (
381 None,
382 EffectSummary::default(),
383 MeasurementQuality::TooNoisy,
384 build_diagnostics_from_calibration(&cal_summary),
385 ),
386 };
387
388 let (inconclusive_reason, recommendation) = convert_inconclusive_reason(reason);
389
390 OutcomeSummary {
391 outcome_type: OutcomeType::Inconclusive,
392 leak_probability: post_summary
393 .as_ref()
394 .map(|p| p.leak_probability)
395 .unwrap_or(0.5),
396 samples_per_class: *samples_per_class,
397 elapsed_secs: *elapsed_secs,
398 effect,
399 quality,
400 exploitability: Exploitability::SharedHardwareOnly,
401 inconclusive_reason,
402 recommendation,
403 theta_user: cal_summary.theta_ns,
404 theta_eff: cal_summary.theta_eff,
405 theta_floor: cal_summary.theta_floor_initial,
406 theta_tick: cal_summary.theta_tick,
407 achievable_at_max: false,
408 diagnostics,
409 mde_ns: cal_summary.mde_ns,
410 }
411 }
412 }
413 }
414}
415
416fn build_effect_summary(
418 post: &crate::ffi_summary::PosteriorSummary,
419) -> crate::ffi_summary::EffectSummary {
420 crate::ffi_summary::EffectSummary {
421 max_effect_ns: post.max_effect_ns,
422 ci_low_ns: post.ci_low_ns,
423 ci_high_ns: post.ci_high_ns,
424 }
425}
426
427fn build_diagnostics(
429 post: &crate::ffi_summary::PosteriorSummary,
430 cal: &crate::ffi_summary::CalibrationSummary,
431) -> crate::ffi_summary::DiagnosticsSummary {
432 crate::ffi_summary::DiagnosticsSummary {
433 dependence_length: cal.block_length,
434 effective_sample_size: post.n,
435 stationarity_ratio: 1.0,
436 stationarity_ok: true,
437 discrete_mode: cal.discrete_mode,
438 timer_resolution_ns: cal.timer_resolution_ns,
439 lambda_mean: post.lambda_mean,
440 lambda_mixing_ok: post.lambda_mixing_ok,
441 kappa_mean: post.kappa_mean,
442 kappa_cv: post.kappa_cv,
443 kappa_ess: post.kappa_ess,
444 kappa_mixing_ok: post.kappa_mixing_ok,
445 }
446}
447
448fn build_diagnostics_from_calibration(
450 cal: &crate::ffi_summary::CalibrationSummary,
451) -> crate::ffi_summary::DiagnosticsSummary {
452 crate::ffi_summary::DiagnosticsSummary {
453 dependence_length: cal.block_length,
454 effective_sample_size: 0,
455 stationarity_ratio: 1.0,
456 stationarity_ok: true,
457 discrete_mode: cal.discrete_mode,
458 timer_resolution_ns: cal.timer_resolution_ns,
459 lambda_mean: 1.0,
460 lambda_mixing_ok: true,
461 kappa_mean: 1.0,
462 kappa_cv: 0.0,
463 kappa_ess: 0.0,
464 kappa_mixing_ok: true,
465 }
466}
467
468fn convert_inconclusive_reason(
470 reason: &super::InconclusiveReason,
471) -> (crate::ffi_summary::InconclusiveReasonKind, String) {
472 use super::InconclusiveReason;
473 use crate::ffi_summary::InconclusiveReasonKind;
474
475 match reason {
476 InconclusiveReason::DataTooNoisy { guidance, .. } => {
477 (InconclusiveReasonKind::DataTooNoisy, guidance.clone())
478 }
479 InconclusiveReason::NotLearning { guidance, .. } => {
480 (InconclusiveReasonKind::NotLearning, guidance.clone())
481 }
482 InconclusiveReason::WouldTakeTooLong { guidance, .. } => {
483 (InconclusiveReasonKind::WouldTakeTooLong, guidance.clone())
484 }
485 InconclusiveReason::TimeBudgetExceeded { .. } => (
486 InconclusiveReasonKind::TimeBudgetExceeded,
487 String::from("Increase time budget or reduce threshold"),
488 ),
489 InconclusiveReason::SampleBudgetExceeded { .. } => (
490 InconclusiveReasonKind::SampleBudgetExceeded,
491 String::from("Increase sample budget or reduce threshold"),
492 ),
493 InconclusiveReason::ConditionsChanged { guidance, .. } => {
494 (InconclusiveReasonKind::ConditionsChanged, guidance.clone())
495 }
496 InconclusiveReason::ThresholdElevated { guidance, .. } => {
497 (InconclusiveReasonKind::ThresholdElevated, guidance.clone())
498 }
499 }
500}
501
502#[derive(Debug, Clone)]
504pub enum StepResult {
505 Decision(AdaptiveOutcome),
507
508 Continue {
510 posterior: Posterior,
512 samples_per_class: usize,
514 },
515}
516
517impl StepResult {
518 pub fn is_decision(&self) -> bool {
520 matches!(self, StepResult::Decision(_))
521 }
522
523 pub fn into_decision(self) -> Option<AdaptiveOutcome> {
525 match self {
526 StepResult::Decision(outcome) => Some(outcome),
527 StepResult::Continue { .. } => None,
528 }
529 }
530
531 pub fn leak_probability(&self) -> Option<f64> {
533 match self {
534 StepResult::Decision(outcome) => outcome.leak_probability(),
535 StepResult::Continue { posterior, .. } => Some(posterior.leak_probability),
536 }
537 }
538}
539
540pub fn adaptive_step(
561 calibration: &Calibration,
562 state: &mut AdaptiveState,
563 ns_per_tick: f64,
564 elapsed_secs: f64,
565 config: &AdaptiveStepConfig,
566) -> StepResult {
567 let posterior = match compute_posterior(state, calibration, ns_per_tick, config) {
569 Some(p) => p,
570 None => {
571 if state.n_total() < 20 {
573 return StepResult::Decision(AdaptiveOutcome::Inconclusive {
577 reason: InconclusiveReason::DataTooNoisy {
578 message: String::from("Insufficient samples for posterior computation"),
579 guidance: String::from("Need at least 20 samples per class"),
580 variance_ratio: 1.0,
581 },
582 posterior: None,
583 samples_per_class: state.n_total(),
584 elapsed_secs,
585 });
586 }
587 return StepResult::Decision(AdaptiveOutcome::Inconclusive {
589 reason: InconclusiveReason::DataTooNoisy {
590 message: String::from("Could not compute posterior from samples"),
591 guidance: String::from("Check timer resolution and sample count"),
592 variance_ratio: 1.0,
593 },
594 posterior: None,
595 samples_per_class: state.n_total(),
596 elapsed_secs,
597 });
598 }
599 };
600
601 let _kl = state.update_posterior(posterior.clone());
603
604 if posterior.leak_probability > config.fail_threshold {
609 return StepResult::Decision(AdaptiveOutcome::LeakDetected {
610 posterior,
611 samples_per_class: state.n_total(),
612 elapsed_secs,
613 });
614 }
615
616 if posterior.leak_probability < config.pass_threshold {
618 let theta_user = config.theta_ns;
620 let theta_eff = calibration.theta_eff;
621 let theta_tick = calibration.theta_tick;
622
623 if is_threshold_elevated(theta_eff, theta_user, theta_tick) {
624 let achievable_at_max = compute_achievable_at_max(
626 calibration.c_floor,
627 theta_tick,
628 theta_user,
629 config.max_samples,
630 calibration.block_length, );
632
633 return StepResult::Decision(AdaptiveOutcome::ThresholdElevated {
634 posterior,
635 theta_user,
636 theta_eff,
637 theta_tick,
638 achievable_at_max,
639 samples_per_class: state.n_total(),
640 elapsed_secs,
641 });
642 }
643
644 return StepResult::Decision(AdaptiveOutcome::NoLeakDetected {
646 posterior,
647 samples_per_class: state.n_total(),
648 elapsed_secs,
649 });
650 }
651
652 let current_stats = state.get_stats_snapshot();
654 let gate_inputs = QualityGateCheckInputs {
655 posterior: &posterior,
656 prior_cov_marginal: &calibration.prior_cov_marginal,
657 theta_ns: config.theta_ns,
658 n_total: state.n_total(),
659 elapsed_secs,
660 recent_kl_sum: if state.has_kl_history() {
661 Some(state.recent_kl_sum())
662 } else {
663 None
664 },
665 samples_per_second: calibration.samples_per_second,
666 calibration_snapshot: Some(&calibration.calibration_snapshot),
667 current_stats_snapshot: current_stats.as_ref(),
668 c_floor: calibration.c_floor,
669 theta_tick: calibration.theta_tick,
670 projection_mismatch_q: None, projection_mismatch_thresh: calibration.projection_mismatch_thresh,
672 lambda_mixing_ok: posterior.lambda_mixing_ok,
673 };
674
675 match check_quality_gates(&gate_inputs, &config.quality_gates) {
676 QualityGateResult::Continue => StepResult::Continue {
677 posterior,
678 samples_per_class: state.n_total(),
679 },
680 QualityGateResult::Stop(reason) => StepResult::Decision(AdaptiveOutcome::Inconclusive {
681 reason,
682 posterior: Some(posterior),
683 samples_per_class: state.n_total(),
684 elapsed_secs,
685 }),
686 }
687}
688
689fn compute_posterior(
693 state: &AdaptiveState,
694 calibration: &Calibration,
695 ns_per_tick: f64,
696 config: &AdaptiveStepConfig,
697) -> Option<Posterior> {
698 let n = state.n_total();
699 if n < 20 {
700 return None; }
702
703 let baseline_ns = state.baseline_ns(ns_per_tick);
705 let sample_ns = state.sample_ns(ns_per_tick);
706
707 let observed_diff = if calibration.discrete_mode {
709 let q_baseline = compute_midquantile_deciles(&baseline_ns);
710 let q_sample = compute_midquantile_deciles(&sample_ns);
711 q_baseline - q_sample
712 } else {
713 let mut baseline_sorted = baseline_ns;
714 let mut sample_sorted = sample_ns;
715 let q_baseline = compute_deciles_inplace(&mut baseline_sorted);
716 let q_sample = compute_deciles_inplace(&mut sample_sorted);
717 q_baseline - q_sample
718 };
719
720 let sigma_n = calibration.covariance_for_n(n);
722
723 let bayes_result = compute_bayes_gibbs(
725 &observed_diff,
726 &sigma_n,
727 calibration.sigma_t,
728 &calibration.l_r,
729 config.theta_ns,
730 Some(config.seed),
731 );
732
733 Some(Posterior::new_with_gibbs(
734 bayes_result.delta_post,
735 bayes_result.lambda_post,
736 bayes_result.delta_draws,
737 bayes_result.leak_probability,
738 config.theta_ns,
739 n,
740 bayes_result.lambda_mean,
741 bayes_result.lambda_mixing_ok,
742 bayes_result.kappa_mean,
743 bayes_result.kappa_cv,
744 bayes_result.kappa_ess,
745 bayes_result.kappa_mixing_ok,
746 ))
747}
748
749#[cfg(test)]
750mod tests {
751 use super::*;
752 use crate::statistics::StatsSnapshot;
753 use crate::types::{Matrix9, Vector9};
754
755 fn make_test_calibration() -> Calibration {
756 use crate::adaptive::CalibrationSnapshot;
757
758 let snapshot = CalibrationSnapshot::new(
759 StatsSnapshot {
760 count: 5000,
761 mean: 1000.0,
762 variance: 25.0,
763 autocorr_lag1: 0.1,
764 },
765 StatsSnapshot {
766 count: 5000,
767 mean: 1000.0,
768 variance: 25.0,
769 autocorr_lag1: 0.1,
770 },
771 );
772
773 Calibration::new(
775 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, )
794 }
795
796 fn make_test_posterior(leak_prob: f64) -> Posterior {
797 Posterior::new(
798 Vector9::zeros(),
799 Matrix9::identity(),
800 Vec::new(), leak_prob,
802 1.0, 1000,
804 )
805 }
806
807 #[test]
808 fn test_adaptive_step_config_default() {
809 let config = AdaptiveStepConfig::default();
810 assert!((config.pass_threshold - 0.05).abs() < 1e-10);
811 assert!((config.fail_threshold - 0.95).abs() < 1e-10);
812 assert!((config.time_budget_secs - 30.0).abs() < 1e-10);
813 }
814
815 #[test]
816 fn test_adaptive_step_config_builder() {
817 let config = AdaptiveStepConfig::with_theta(50.0)
818 .pass_threshold(0.01)
819 .fail_threshold(0.99)
820 .time_budget_secs(60.0)
821 .max_samples(500_000);
822
823 assert!((config.theta_ns - 50.0).abs() < 1e-10);
824 assert!((config.pass_threshold - 0.01).abs() < 1e-10);
825 assert!((config.fail_threshold - 0.99).abs() < 1e-10);
826 assert!((config.time_budget_secs - 60.0).abs() < 1e-10);
827 assert_eq!(config.max_samples, 500_000);
828 }
829
830 #[test]
831 fn test_adaptive_outcome_accessors() {
832 let posterior = make_test_posterior(0.95);
833
834 let outcome = AdaptiveOutcome::LeakDetected {
835 posterior: posterior.clone(),
836 samples_per_class: 1000,
837 elapsed_secs: 1.5,
838 };
839
840 assert!(outcome.is_leak_detected());
841 assert!(outcome.is_conclusive());
842 assert_eq!(outcome.leak_probability(), Some(0.95));
843 assert_eq!(outcome.samples_per_class(), 1000);
844 assert!((outcome.elapsed_secs() - 1.5).abs() < 1e-10);
845
846 let outcome = AdaptiveOutcome::NoLeakDetected {
847 posterior,
848 samples_per_class: 2000,
849 elapsed_secs: 2.5,
850 };
851
852 assert!(!outcome.is_leak_detected());
853 assert!(outcome.is_conclusive());
854 assert_eq!(outcome.samples_per_class(), 2000);
855 }
856
857 #[test]
858 fn test_step_result_accessors() {
859 let posterior = make_test_posterior(0.5);
860
861 let result = StepResult::Continue {
862 posterior: posterior.clone(),
863 samples_per_class: 1000,
864 };
865
866 assert!(!result.is_decision());
867 assert_eq!(result.leak_probability(), Some(0.5));
868
869 let result = StepResult::Decision(AdaptiveOutcome::LeakDetected {
870 posterior,
871 samples_per_class: 1000,
872 elapsed_secs: 1.0,
873 });
874
875 assert!(result.is_decision());
876 }
877
878 #[test]
879 fn test_adaptive_step_insufficient_samples() {
880 let calibration = make_test_calibration();
881 let mut state = AdaptiveState::new();
882 state.add_batch(vec![100; 10], vec![101; 10]); let config = AdaptiveStepConfig::default();
885 let result = adaptive_step(&calibration, &mut state, 1.0, 0.1, &config);
886
887 assert!(result.is_decision());
889 if let StepResult::Decision(AdaptiveOutcome::Inconclusive { reason, .. }) = result {
890 assert!(matches!(reason, InconclusiveReason::DataTooNoisy { .. }));
891 } else {
892 panic!("Expected Inconclusive with DataTooNoisy");
893 }
894 }
895
896 #[test]
897 fn test_compute_posterior_no_difference() {
898 let calibration = make_test_calibration();
899 let mut state = AdaptiveState::new();
900
901 let baseline: Vec<u64> = (0..1000).map(|i| 1000 + (i % 10)).collect();
903 let sample: Vec<u64> = (0..1000).map(|i| 1000 + (i % 10)).collect();
904 state.add_batch(baseline, sample);
905
906 let config = AdaptiveStepConfig::with_theta(100.0);
907
908 let posterior = compute_posterior(&state, &calibration, 1.0, &config);
909
910 assert!(posterior.is_some());
911 let p = posterior.unwrap();
912
913 assert!(
915 p.leak_probability < 0.5,
916 "Identical distributions should have low leak probability, got {}",
917 p.leak_probability
918 );
919 }
920
921 #[test]
922 fn test_compute_posterior_with_difference() {
923 let calibration = make_test_calibration();
924 let mut state = AdaptiveState::new();
925
926 let baseline: Vec<u64> = (0..1000).map(|i| 1000 + (i % 10)).collect();
928 let sample: Vec<u64> = (0..1000).map(|i| 1200 + (i % 10)).collect();
929 state.add_batch(baseline, sample);
930
931 let config = AdaptiveStepConfig::with_theta(100.0); let posterior = compute_posterior(&state, &calibration, 1.0, &config);
934
935 assert!(posterior.is_some());
936 let p = posterior.unwrap();
937
938 assert!(
940 p.leak_probability > 0.5,
941 "Clear difference should have high leak probability, got {}",
942 p.leak_probability
943 );
944 }
945
946 #[test]
947 fn test_adaptive_step_detects_leak() {
948 let calibration = make_test_calibration();
949 let mut state = AdaptiveState::new();
950
951 let baseline: Vec<u64> = (0..1000).map(|i| 1000 + (i % 10)).collect();
953 let sample: Vec<u64> = (0..1000).map(|i| 1500 + (i % 10)).collect();
954 state.add_batch(baseline, sample);
955
956 let config = AdaptiveStepConfig::with_theta(100.0);
957 let result = adaptive_step(&calibration, &mut state, 1.0, 1.0, &config);
958
959 assert!(result.is_decision());
961 if let StepResult::Decision(outcome) = result {
962 assert!(outcome.is_leak_detected());
963 } else {
964 panic!("Expected Decision");
965 }
966 }
967}