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_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
420/// Build an EffectSummary from a PosteriorSummary.
421fn 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
434/// Build DiagnosticsSummary from posterior and calibration summaries.
435fn 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
457/// Build DiagnosticsSummary from calibration only (when no posterior available).
458fn 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
479/// Convert InconclusiveReason to FFI kind and recommendation string.
480fn 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/// Result of a single adaptive step.
514#[derive(Debug, Clone)]
515pub enum StepResult {
516    /// A decision was reached (Pass, Fail, or Inconclusive).
517    Decision(AdaptiveOutcome),
518
519    /// Continue sampling - no decision yet.
520    Continue {
521        /// Current posterior distribution.
522        posterior: Posterior,
523        /// Number of samples collected per class so far.
524        samples_per_class: usize,
525    },
526}
527
528impl StepResult {
529    /// Check if a decision was reached.
530    pub fn is_decision(&self) -> bool {
531        matches!(self, StepResult::Decision(_))
532    }
533
534    /// Get the outcome if a decision was reached.
535    pub fn into_decision(self) -> Option<AdaptiveOutcome> {
536        match self {
537            StepResult::Decision(outcome) => Some(outcome),
538            StepResult::Continue { .. } => None,
539        }
540    }
541
542    /// Get the current leak probability.
543    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
551/// Single-iteration adaptive step (no_std compatible).
552///
553/// This function performs one iteration of the adaptive sampling loop:
554/// 1. Computes the posterior from current samples
555/// 2. Checks if decision thresholds are met
556/// 3. Checks quality gates
557/// 4. Returns whether to continue or stop
558///
559/// # Arguments
560///
561/// * `calibration` - Results from calibration phase
562/// * `state` - Current adaptive state with accumulated samples
563/// * `ns_per_tick` - Conversion factor from native units to nanoseconds
564/// * `elapsed_secs` - Elapsed time in seconds (provided by caller)
565/// * `config` - Adaptive step configuration
566///
567/// # Returns
568///
569/// - `StepResult::Decision(outcome)` - Decision reached or quality gate triggered
570/// - `StepResult::Continue { .. }` - Need more samples
571pub 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    // Compute posterior from current samples
579    let posterior = match compute_posterior(state, calibration, ns_per_tick, config) {
580        Some(p) => p,
581        None => {
582            // Not enough samples for reliable posterior
583            if state.n_total() < 20 {
584                // Need more samples - not a failure, just need to continue
585                // We can't return Continue without a posterior, so return a dummy
586                // Actually, let's just return Inconclusive with a clear message
587                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            // Computation failed for other reasons
599            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    // Track KL divergence for learning rate monitoring
613    let _kl = state.update_posterior(posterior.clone());
614
615    // Check decision boundaries (v5.5 threshold elevation decision rule)
616    //
617    // Fail propagates regardless of threshold elevation: if P > fail_threshold,
618    // we detected a leak even at the elevated threshold.
619    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    // Pass requires both P < pass_threshold AND θ_eff ≤ θ_user + ε (v5.5)
628    if posterior.leak_probability < config.pass_threshold {
629        // Check if threshold is elevated beyond tolerance
630        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            // Threshold elevated: return ThresholdElevated instead of Pass
636            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, // v5.6: block_length for n_eff computation
642            );
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        // Threshold not elevated: true Pass
656        return StepResult::Decision(AdaptiveOutcome::NoLeakDetected {
657            posterior,
658            samples_per_class: state.n_total(),
659            elapsed_secs,
660        });
661    }
662
663    // Check quality gates
664    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
704/// Compute posterior distribution from current state.
705///
706/// Uses scaled covariance: Sigma_n = Sigma_rate / n
707fn 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; // Need minimum samples for stable quantiles
716    }
717
718    // Convert samples to nanoseconds
719    let baseline_ns = state.baseline_ns(ns_per_tick);
720    let sample_ns = state.sample_ns(ns_per_tick);
721
722    // Compute quantile differences
723    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    // Scale covariance: Sigma_n = Sigma_rate / n
736    let sigma_n = calibration.covariance_for_n(n);
737
738    // Run 9D Bayesian inference with v5.4 Gibbs sampler
739    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        // v5.6: kappa diagnostics
760        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        // v5.4+ t-prior
792        Calibration::new(
793            Matrix9::identity() * 1000.0, // sigma_rate
794            10,                           // block_length
795            100.0,                        // sigma_t
796            Matrix9::identity(),          // l_r (identity for tests)
797            100.0,                        // theta_ns
798            5000,                         // calibration_samples
799            false,                        // discrete_mode
800            5.0,                          // mde_shift_ns
801            10.0,                         // mde_tail_ns
802            snapshot,                     // calibration_snapshot
803            1.0,                          // timer_resolution_ns
804            100_000.0,                    // samples_per_second
805            10.0,                         // c_floor
806            18.48,                        // projection_mismatch_thresh
807            0.001,                        // theta_tick
808            100.0,                        // theta_eff
809            0.1,                          // theta_floor_initial
810            42,                           // rng_seed
811            1,                            // batch_k (no batching in tests)
812        )
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(), // beta_draws
822            leak_prob,
823            1.0, // projection_mismatch_q
824            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]); // Only 10 samples
904
905        let config = AdaptiveStepConfig::default();
906        let result = adaptive_step(&calibration, &mut state, 1.0, 0.1, &config);
907
908        // Should return Inconclusive (need more samples)
909        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        // Add samples with no timing difference
923        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        // With identical distributions, leak probability should be low
935        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        // Add samples with clear timing difference (200ns)
948        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); // Effect is 200ns, threshold is 100ns
953
954        let posterior = compute_posterior(&state, &calibration, 1.0, &config);
955
956        assert!(posterior.is_some());
957        let p = posterior.unwrap();
958
959        // With 200ns difference vs 100ns threshold, leak probability should be high
960        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        // Add samples with large timing difference
973        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        // Should detect a leak
981        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}