Skip to main content

tacet_core/adaptive/
step.rs

1//! Adaptive sampling step logic (no_std compatible).
2//!
3//! This module provides the core adaptive step logic that can be used in
4//! no_std environments. Time tracking is caller-provided rather than using
5//! `std::time::Instant`.
6//!
7//! # Usage Pattern
8//!
9//! ```ignore
10//! let mut state = AdaptiveState::new();
11//! let calibration = /* ... */;
12//! let config = AdaptiveStepConfig::default();
13//!
14//! loop {
15//!     // Caller collects samples and tracks time
16//!     state.add_batch(baseline, sample);
17//!     let elapsed_secs = /* measured by caller */;
18//!
19//!     match adaptive_step(&calibration, &mut state, ns_per_tick, elapsed_secs, &config) {
20//!         StepResult::Decision(outcome) => return outcome,
21//!         StepResult::Continue { .. } => continue,
22//!     }
23//! }
24//! ```
25
26use 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/// Configuration for the adaptive step (no_std compatible).
41///
42/// Uses `f64` for time values instead of `std::time::Duration`.
43#[derive(Debug, Clone)]
44pub struct AdaptiveStepConfig {
45    /// Threshold below which we pass (no significant leak).
46    pub pass_threshold: f64,
47
48    /// Threshold above which we fail (leak detected).
49    pub fail_threshold: f64,
50
51    /// Time budget in seconds.
52    pub time_budget_secs: f64,
53
54    /// Maximum samples per class.
55    pub max_samples: usize,
56
57    /// Effect threshold (theta) in nanoseconds.
58    pub theta_ns: f64,
59
60    /// Random seed for Monte Carlo integration.
61    pub seed: u64,
62
63    /// Quality gate configuration.
64    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    /// Create a new config with the given theta threshold.
83    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    /// Builder method to set pass threshold.
95    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    /// Builder method to set fail threshold.
102    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    /// Builder method to set time budget in seconds.
109    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    /// Builder method to set max samples.
116    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/// Outcome of an adaptive sampling decision (no_std compatible).
124///
125/// Uses `f64` for elapsed time instead of `std::time::Duration`.
126#[derive(Debug, Clone)]
127pub enum AdaptiveOutcome {
128    /// Leak probability exceeded fail threshold - timing leak detected.
129    LeakDetected {
130        /// Final posterior distribution.
131        posterior: Posterior,
132        /// Number of samples collected per class.
133        samples_per_class: usize,
134        /// Time spent in seconds.
135        elapsed_secs: f64,
136    },
137
138    /// Leak probability dropped below pass threshold - no significant leak.
139    NoLeakDetected {
140        /// Final posterior distribution.
141        posterior: Posterior,
142        /// Number of samples collected per class.
143        samples_per_class: usize,
144        /// Time spent in seconds.
145        elapsed_secs: f64,
146    },
147
148    /// A quality gate triggered before reaching a decision.
149    Inconclusive {
150        /// Reason for stopping.
151        reason: InconclusiveReason,
152        /// Final posterior distribution (if available).
153        posterior: Option<Posterior>,
154        /// Number of samples collected per class.
155        samples_per_class: usize,
156        /// Time spent in seconds.
157        elapsed_secs: f64,
158    },
159
160    /// Threshold was elevated and pass criterion was met at effective threshold (v5.5).
161    ///
162    /// P < pass_threshold at θ_eff, but θ_eff > θ_user + ε. This is semantically
163    /// distinct from both Pass (can't guarantee user threshold) and Inconclusive
164    /// (not a quality issue - we got a clear statistical result).
165    ThresholdElevated {
166        /// Final posterior distribution.
167        posterior: Posterior,
168        /// User's requested threshold (θ_user).
169        theta_user: f64,
170        /// Effective threshold used (θ_eff).
171        theta_eff: f64,
172        /// Timer tick floor (θ_tick).
173        theta_tick: f64,
174        /// Whether threshold is achievable at max_samples.
175        achievable_at_max: bool,
176        /// Number of samples collected per class.
177        samples_per_class: usize,
178        /// Time spent in seconds.
179        elapsed_secs: f64,
180    },
181}
182
183impl AdaptiveOutcome {
184    /// Get the final leak probability, if available.
185    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    /// Check if the outcome indicates a leak was detected.
199    pub fn is_leak_detected(&self) -> bool {
200        matches!(self, AdaptiveOutcome::LeakDetected { .. })
201    }
202
203    /// Check if the outcome is conclusive (either pass or fail).
204    ///
205    /// Note: ThresholdElevated is NOT considered conclusive in v5.5 - it means
206    /// we got a statistical result but can't guarantee the user's threshold.
207    pub fn is_conclusive(&self) -> bool {
208        matches!(
209            self,
210            AdaptiveOutcome::LeakDetected { .. } | AdaptiveOutcome::NoLeakDetected { .. }
211        )
212    }
213
214    /// Check if the threshold was elevated beyond tolerance (v5.5).
215    pub fn is_threshold_elevated(&self) -> bool {
216        matches!(self, AdaptiveOutcome::ThresholdElevated { .. })
217    }
218
219    /// Get the number of samples per class.
220    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    /// Get elapsed time in seconds.
238    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    /// Convert to an FFI-friendly summary containing only scalar fields.
248    ///
249    /// This centralizes the conversion logic that was previously duplicated
250    /// across NAPI and C bindings, using canonical effect pattern classification.
251    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
416/// Build an EffectSummary from a PosteriorSummary.
417fn 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
427/// Build DiagnosticsSummary from posterior and calibration summaries.
428fn 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
448/// Build DiagnosticsSummary from calibration only (when no posterior available).
449fn 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
468/// Convert InconclusiveReason to FFI kind and recommendation string.
469fn 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/// Result of a single adaptive step.
503#[derive(Debug, Clone)]
504pub enum StepResult {
505    /// A decision was reached (Pass, Fail, or Inconclusive).
506    Decision(AdaptiveOutcome),
507
508    /// Continue sampling - no decision yet.
509    Continue {
510        /// Current posterior distribution.
511        posterior: Posterior,
512        /// Number of samples collected per class so far.
513        samples_per_class: usize,
514    },
515}
516
517impl StepResult {
518    /// Check if a decision was reached.
519    pub fn is_decision(&self) -> bool {
520        matches!(self, StepResult::Decision(_))
521    }
522
523    /// Get the outcome if a decision was reached.
524    pub fn into_decision(self) -> Option<AdaptiveOutcome> {
525        match self {
526            StepResult::Decision(outcome) => Some(outcome),
527            StepResult::Continue { .. } => None,
528        }
529    }
530
531    /// Get the current leak probability.
532    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
540/// Single-iteration adaptive step (no_std compatible).
541///
542/// This function performs one iteration of the adaptive sampling loop:
543/// 1. Computes the posterior from current samples
544/// 2. Checks if decision thresholds are met
545/// 3. Checks quality gates
546/// 4. Returns whether to continue or stop
547///
548/// # Arguments
549///
550/// * `calibration` - Results from calibration phase
551/// * `state` - Current adaptive state with accumulated samples
552/// * `ns_per_tick` - Conversion factor from native units to nanoseconds
553/// * `elapsed_secs` - Elapsed time in seconds (provided by caller)
554/// * `config` - Adaptive step configuration
555///
556/// # Returns
557///
558/// - `StepResult::Decision(outcome)` - Decision reached or quality gate triggered
559/// - `StepResult::Continue { .. }` - Need more samples
560pub 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    // Compute posterior from current samples
568    let posterior = match compute_posterior(state, calibration, ns_per_tick, config) {
569        Some(p) => p,
570        None => {
571            // Not enough samples for reliable posterior
572            if state.n_total() < 20 {
573                // Need more samples - not a failure, just need to continue
574                // We can't return Continue without a posterior, so return a dummy
575                // Actually, let's just return Inconclusive with a clear message
576                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            // Computation failed for other reasons
588            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    // Track KL divergence for learning rate monitoring
602    let _kl = state.update_posterior(posterior.clone());
603
604    // Check decision boundaries (v5.5 threshold elevation decision rule)
605    //
606    // Fail propagates regardless of threshold elevation: if P > fail_threshold,
607    // we detected a leak even at the elevated threshold.
608    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    // Pass requires both P < pass_threshold AND θ_eff ≤ θ_user + ε (v5.5)
617    if posterior.leak_probability < config.pass_threshold {
618        // Check if threshold is elevated beyond tolerance
619        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            // Threshold elevated: return ThresholdElevated instead of Pass
625            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, // v5.6: block_length for n_eff computation
631            );
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        // Threshold not elevated: true Pass
645        return StepResult::Decision(AdaptiveOutcome::NoLeakDetected {
646            posterior,
647            samples_per_class: state.n_total(),
648            elapsed_secs,
649        });
650    }
651
652    // Check quality gates
653    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, // Removed in v6.0
671        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
689/// Compute posterior distribution from current state.
690///
691/// Uses scaled covariance: Sigma_n = Sigma_rate / n
692fn 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; // Need minimum samples for stable quantiles
701    }
702
703    // Convert samples to nanoseconds
704    let baseline_ns = state.baseline_ns(ns_per_tick);
705    let sample_ns = state.sample_ns(ns_per_tick);
706
707    // Compute quantile differences
708    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    // Scale covariance: Sigma_n = Sigma_rate / n
721    let sigma_n = calibration.covariance_for_n(n);
722
723    // Run 9D Bayesian inference with v5.4 Gibbs sampler
724    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        // v5.4+ t-prior
774        Calibration::new(
775            Matrix9::identity() * 1000.0, // sigma_rate
776            10,                           // block_length
777            100.0,                        // sigma_t
778            Matrix9::identity(),          // l_r (identity for tests)
779            100.0,                        // theta_ns
780            5000,                         // calibration_samples
781            false,                        // discrete_mode
782            5.0,                          // mde_ns
783            snapshot,                     // calibration_snapshot
784            1.0,                          // timer_resolution_ns
785            100_000.0,                    // samples_per_second
786            10.0,                         // c_floor
787            18.48,                        // projection_mismatch_thresh
788            0.001,                        // theta_tick
789            100.0,                        // theta_eff
790            0.1,                          // theta_floor_initial
791            42,                           // rng_seed
792            1,                            // batch_k
793        )
794    }
795
796    fn make_test_posterior(leak_prob: f64) -> Posterior {
797        Posterior::new(
798            Vector9::zeros(),
799            Matrix9::identity(),
800            Vec::new(), // delta_draws
801            leak_prob,
802            1.0, // theta
803            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]); // Only 10 samples
883
884        let config = AdaptiveStepConfig::default();
885        let result = adaptive_step(&calibration, &mut state, 1.0, 0.1, &config);
886
887        // Should return Inconclusive (need more samples)
888        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        // Add samples with no timing difference
902        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        // With identical distributions, leak probability should be low
914        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        // Add samples with clear timing difference (200ns)
927        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); // Effect is 200ns, threshold is 100ns
932
933        let posterior = compute_posterior(&state, &calibration, 1.0, &config);
934
935        assert!(posterior.is_some());
936        let p = posterior.unwrap();
937
938        // With 200ns difference vs 100ns threshold, leak probability should be high
939        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        // Add samples with large timing difference
952        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        // Should detect a leak
960        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}