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::{EffectSummary, InconclusiveReasonKind, OutcomeSummary, OutcomeType};
256        use crate::result::{Exploitability, MeasurementQuality};
257
258        let cal_summary = calibration.to_summary();
259
260        match self {
261            AdaptiveOutcome::LeakDetected {
262                posterior,
263                samples_per_class,
264                elapsed_secs,
265            } => {
266                let post_summary = posterior.to_summary();
267                let effect = build_effect_summary(&post_summary);
268                let diagnostics = build_diagnostics(&post_summary, &cal_summary);
269
270                OutcomeSummary {
271                    outcome_type: OutcomeType::Fail,
272                    leak_probability: post_summary.leak_probability,
273                    samples_per_class: *samples_per_class,
274                    elapsed_secs: *elapsed_secs,
275                    effect,
276                    quality: post_summary.measurement_quality(),
277                    exploitability: post_summary.exploitability(),
278                    inconclusive_reason: InconclusiveReasonKind::None,
279                    recommendation: String::new(),
280                    theta_user: cal_summary.theta_ns,
281                    theta_eff: cal_summary.theta_eff,
282                    theta_floor: cal_summary.theta_floor_initial,
283                    theta_tick: cal_summary.theta_tick,
284                    achievable_at_max: true,
285                    diagnostics,
286                    mde_shift_ns: cal_summary.mde_shift_ns,
287                    mde_tail_ns: cal_summary.mde_tail_ns,
288                }
289            }
290
291            AdaptiveOutcome::NoLeakDetected {
292                posterior,
293                samples_per_class,
294                elapsed_secs,
295            } => {
296                let post_summary = posterior.to_summary();
297                let effect = build_effect_summary(&post_summary);
298                let diagnostics = build_diagnostics(&post_summary, &cal_summary);
299
300                OutcomeSummary {
301                    outcome_type: OutcomeType::Pass,
302                    leak_probability: post_summary.leak_probability,
303                    samples_per_class: *samples_per_class,
304                    elapsed_secs: *elapsed_secs,
305                    effect,
306                    quality: post_summary.measurement_quality(),
307                    exploitability: Exploitability::SharedHardwareOnly,
308                    inconclusive_reason: InconclusiveReasonKind::None,
309                    recommendation: String::new(),
310                    theta_user: cal_summary.theta_ns,
311                    theta_eff: cal_summary.theta_eff,
312                    theta_floor: cal_summary.theta_floor_initial,
313                    theta_tick: cal_summary.theta_tick,
314                    achievable_at_max: true,
315                    diagnostics,
316                    mde_shift_ns: cal_summary.mde_shift_ns,
317                    mde_tail_ns: cal_summary.mde_tail_ns,
318                }
319            }
320
321            AdaptiveOutcome::ThresholdElevated {
322                posterior,
323                theta_user,
324                theta_eff,
325                theta_tick,
326                achievable_at_max,
327                samples_per_class,
328                elapsed_secs,
329            } => {
330                let post_summary = posterior.to_summary();
331                let effect = build_effect_summary(&post_summary);
332                let diagnostics = build_diagnostics(&post_summary, &cal_summary);
333
334                let recommendation = if *achievable_at_max {
335                    alloc::format!(
336                        "Threshold elevated from {:.0}ns to {:.1}ns. More samples could achieve the requested threshold.",
337                        theta_user, theta_eff
338                    )
339                } else {
340                    alloc::format!(
341                        "Threshold elevated from {:.0}ns to {:.1}ns. Use a cycle counter for better resolution.",
342                        theta_user, theta_eff
343                    )
344                };
345
346                OutcomeSummary {
347                    outcome_type: OutcomeType::ThresholdElevated,
348                    leak_probability: post_summary.leak_probability,
349                    samples_per_class: *samples_per_class,
350                    elapsed_secs: *elapsed_secs,
351                    effect,
352                    quality: post_summary.measurement_quality(),
353                    exploitability: Exploitability::SharedHardwareOnly,
354                    inconclusive_reason: InconclusiveReasonKind::ThresholdElevated,
355                    recommendation,
356                    theta_user: *theta_user,
357                    theta_eff: *theta_eff,
358                    theta_floor: cal_summary.theta_floor_initial,
359                    theta_tick: *theta_tick,
360                    achievable_at_max: *achievable_at_max,
361                    diagnostics,
362                    mde_shift_ns: cal_summary.mde_shift_ns,
363                    mde_tail_ns: cal_summary.mde_tail_ns,
364                }
365            }
366
367            AdaptiveOutcome::Inconclusive {
368                reason,
369                posterior,
370                samples_per_class,
371                elapsed_secs,
372            } => {
373                let (post_summary, effect, quality, diagnostics) = match posterior {
374                    Some(p) => {
375                        let ps = p.to_summary();
376                        let eff = build_effect_summary(&ps);
377                        let qual = ps.measurement_quality();
378                        let diag = build_diagnostics(&ps, &cal_summary);
379                        (Some(ps), eff, qual, diag)
380                    }
381                    None => (
382                        None,
383                        EffectSummary::default(),
384                        MeasurementQuality::TooNoisy,
385                        build_diagnostics_from_calibration(&cal_summary),
386                    ),
387                };
388
389                let (inconclusive_reason, recommendation) = convert_inconclusive_reason(reason);
390
391                OutcomeSummary {
392                    outcome_type: OutcomeType::Inconclusive,
393                    leak_probability: post_summary
394                        .as_ref()
395                        .map(|p| p.leak_probability)
396                        .unwrap_or(0.5),
397                    samples_per_class: *samples_per_class,
398                    elapsed_secs: *elapsed_secs,
399                    effect,
400                    quality,
401                    exploitability: Exploitability::SharedHardwareOnly,
402                    inconclusive_reason,
403                    recommendation,
404                    theta_user: cal_summary.theta_ns,
405                    theta_eff: cal_summary.theta_eff,
406                    theta_floor: cal_summary.theta_floor_initial,
407                    theta_tick: cal_summary.theta_tick,
408                    achievable_at_max: false,
409                    diagnostics,
410                    mde_shift_ns: cal_summary.mde_shift_ns,
411                    mde_tail_ns: cal_summary.mde_tail_ns,
412                }
413            }
414        }
415    }
416}
417
418/// Build an EffectSummary from a PosteriorSummary.
419fn build_effect_summary(
420    post: &crate::ffi_summary::PosteriorSummary,
421) -> crate::ffi_summary::EffectSummary {
422    crate::ffi_summary::EffectSummary {
423        shift_ns: post.shift_ns,
424        tail_ns: post.tail_ns,
425        ci_low_ns: post.ci_low_ns,
426        ci_high_ns: post.ci_high_ns,
427        pattern: post.pattern,
428        interpretation_caveat: None,
429    }
430}
431
432/// Build DiagnosticsSummary from posterior and calibration summaries.
433fn build_diagnostics(
434    post: &crate::ffi_summary::PosteriorSummary,
435    cal: &crate::ffi_summary::CalibrationSummary,
436) -> crate::ffi_summary::DiagnosticsSummary {
437    crate::ffi_summary::DiagnosticsSummary {
438        dependence_length: cal.block_length,
439        effective_sample_size: post.n,
440        stationarity_ratio: 1.0,
441        stationarity_ok: true,
442        projection_mismatch_q: post.projection_mismatch_q,
443        projection_mismatch_ok: post.projection_mismatch_q <= cal.projection_mismatch_thresh,
444        discrete_mode: cal.discrete_mode,
445        timer_resolution_ns: cal.timer_resolution_ns,
446        lambda_mean: post.lambda_mean,
447        lambda_mixing_ok: post.lambda_mixing_ok,
448        kappa_mean: post.kappa_mean,
449        kappa_cv: post.kappa_cv,
450        kappa_ess: post.kappa_ess,
451        kappa_mixing_ok: post.kappa_mixing_ok,
452    }
453}
454
455/// Build DiagnosticsSummary from calibration only (when no posterior available).
456fn build_diagnostics_from_calibration(
457    cal: &crate::ffi_summary::CalibrationSummary,
458) -> crate::ffi_summary::DiagnosticsSummary {
459    crate::ffi_summary::DiagnosticsSummary {
460        dependence_length: cal.block_length,
461        effective_sample_size: 0,
462        stationarity_ratio: 1.0,
463        stationarity_ok: true,
464        projection_mismatch_q: 0.0,
465        projection_mismatch_ok: true,
466        discrete_mode: cal.discrete_mode,
467        timer_resolution_ns: cal.timer_resolution_ns,
468        lambda_mean: 1.0,
469        lambda_mixing_ok: true,
470        kappa_mean: 1.0,
471        kappa_cv: 0.0,
472        kappa_ess: 0.0,
473        kappa_mixing_ok: true,
474    }
475}
476
477/// Convert InconclusiveReason to FFI kind and recommendation string.
478fn convert_inconclusive_reason(
479    reason: &super::InconclusiveReason,
480) -> (crate::ffi_summary::InconclusiveReasonKind, String) {
481    use crate::ffi_summary::InconclusiveReasonKind;
482    use super::InconclusiveReason;
483
484    match reason {
485        InconclusiveReason::DataTooNoisy { guidance, .. } => {
486            (InconclusiveReasonKind::DataTooNoisy, guidance.clone())
487        }
488        InconclusiveReason::NotLearning { guidance, .. } => {
489            (InconclusiveReasonKind::NotLearning, guidance.clone())
490        }
491        InconclusiveReason::WouldTakeTooLong { guidance, .. } => {
492            (InconclusiveReasonKind::WouldTakeTooLong, guidance.clone())
493        }
494        InconclusiveReason::TimeBudgetExceeded { .. } => (
495            InconclusiveReasonKind::TimeBudgetExceeded,
496            String::from("Increase time budget or reduce threshold"),
497        ),
498        InconclusiveReason::SampleBudgetExceeded { .. } => (
499            InconclusiveReasonKind::SampleBudgetExceeded,
500            String::from("Increase sample budget or reduce threshold"),
501        ),
502        InconclusiveReason::ConditionsChanged { guidance, .. } => {
503            (InconclusiveReasonKind::ConditionsChanged, guidance.clone())
504        }
505        InconclusiveReason::ThresholdElevated { guidance, .. } => {
506            (InconclusiveReasonKind::ThresholdElevated, guidance.clone())
507        }
508    }
509}
510
511/// Result of a single adaptive step.
512#[derive(Debug, Clone)]
513pub enum StepResult {
514    /// A decision was reached (Pass, Fail, or Inconclusive).
515    Decision(AdaptiveOutcome),
516
517    /// Continue sampling - no decision yet.
518    Continue {
519        /// Current posterior distribution.
520        posterior: Posterior,
521        /// Number of samples collected per class so far.
522        samples_per_class: usize,
523    },
524}
525
526impl StepResult {
527    /// Check if a decision was reached.
528    pub fn is_decision(&self) -> bool {
529        matches!(self, StepResult::Decision(_))
530    }
531
532    /// Get the outcome if a decision was reached.
533    pub fn into_decision(self) -> Option<AdaptiveOutcome> {
534        match self {
535            StepResult::Decision(outcome) => Some(outcome),
536            StepResult::Continue { .. } => None,
537        }
538    }
539
540    /// Get the current leak probability.
541    pub fn leak_probability(&self) -> Option<f64> {
542        match self {
543            StepResult::Decision(outcome) => outcome.leak_probability(),
544            StepResult::Continue { posterior, .. } => Some(posterior.leak_probability),
545        }
546    }
547}
548
549/// Single-iteration adaptive step (no_std compatible).
550///
551/// This function performs one iteration of the adaptive sampling loop:
552/// 1. Computes the posterior from current samples
553/// 2. Checks if decision thresholds are met
554/// 3. Checks quality gates
555/// 4. Returns whether to continue or stop
556///
557/// # Arguments
558///
559/// * `calibration` - Results from calibration phase
560/// * `state` - Current adaptive state with accumulated samples
561/// * `ns_per_tick` - Conversion factor from native units to nanoseconds
562/// * `elapsed_secs` - Elapsed time in seconds (provided by caller)
563/// * `config` - Adaptive step configuration
564///
565/// # Returns
566///
567/// - `StepResult::Decision(outcome)` - Decision reached or quality gate triggered
568/// - `StepResult::Continue { .. }` - Need more samples
569pub fn adaptive_step(
570    calibration: &Calibration,
571    state: &mut AdaptiveState,
572    ns_per_tick: f64,
573    elapsed_secs: f64,
574    config: &AdaptiveStepConfig,
575) -> StepResult {
576    // Compute posterior from current samples
577    let posterior = match compute_posterior(state, calibration, ns_per_tick, config) {
578        Some(p) => p,
579        None => {
580            // Not enough samples for reliable posterior
581            if state.n_total() < 20 {
582                // Need more samples - not a failure, just need to continue
583                // We can't return Continue without a posterior, so return a dummy
584                // Actually, let's just return Inconclusive with a clear message
585                return StepResult::Decision(AdaptiveOutcome::Inconclusive {
586                    reason: InconclusiveReason::DataTooNoisy {
587                        message: String::from("Insufficient samples for posterior computation"),
588                        guidance: String::from("Need at least 20 samples per class"),
589                        variance_ratio: 1.0,
590                    },
591                    posterior: None,
592                    samples_per_class: state.n_total(),
593                    elapsed_secs,
594                });
595            }
596            // Computation failed for other reasons
597            return StepResult::Decision(AdaptiveOutcome::Inconclusive {
598                reason: InconclusiveReason::DataTooNoisy {
599                    message: String::from("Could not compute posterior from samples"),
600                    guidance: String::from("Check timer resolution and sample count"),
601                    variance_ratio: 1.0,
602                },
603                posterior: None,
604                samples_per_class: state.n_total(),
605                elapsed_secs,
606            });
607        }
608    };
609
610    // Track KL divergence for learning rate monitoring
611    let _kl = state.update_posterior(posterior.clone());
612
613    // Check decision boundaries (v5.5 threshold elevation decision rule)
614    //
615    // Fail propagates regardless of threshold elevation: if P > fail_threshold,
616    // we detected a leak even at the elevated threshold.
617    if posterior.leak_probability > config.fail_threshold {
618        return StepResult::Decision(AdaptiveOutcome::LeakDetected {
619            posterior,
620            samples_per_class: state.n_total(),
621            elapsed_secs,
622        });
623    }
624
625    // Pass requires both P < pass_threshold AND θ_eff ≤ θ_user + ε (v5.5)
626    if posterior.leak_probability < config.pass_threshold {
627        // Check if threshold is elevated beyond tolerance
628        let theta_user = config.theta_ns;
629        let theta_eff = calibration.theta_eff;
630        let theta_tick = calibration.theta_tick;
631
632        if is_threshold_elevated(theta_eff, theta_user, theta_tick) {
633            // Threshold elevated: return ThresholdElevated instead of Pass
634            let achievable_at_max = compute_achievable_at_max(
635                calibration.c_floor,
636                theta_tick,
637                theta_user,
638                config.max_samples,
639                calibration.block_length, // v5.6: block_length for n_eff computation
640            );
641
642            return StepResult::Decision(AdaptiveOutcome::ThresholdElevated {
643                posterior,
644                theta_user,
645                theta_eff,
646                theta_tick,
647                achievable_at_max,
648                samples_per_class: state.n_total(),
649                elapsed_secs,
650            });
651        }
652
653        // Threshold not elevated: true Pass
654        return StepResult::Decision(AdaptiveOutcome::NoLeakDetected {
655            posterior,
656            samples_per_class: state.n_total(),
657            elapsed_secs,
658        });
659    }
660
661    // Check quality gates
662    let current_stats = state.get_stats_snapshot();
663    let gate_inputs = QualityGateCheckInputs {
664        posterior: &posterior,
665        prior_cov_marginal: &calibration.prior_cov_marginal,
666        theta_ns: config.theta_ns,
667        n_total: state.n_total(),
668        elapsed_secs,
669        recent_kl_sum: if state.has_kl_history() {
670            Some(state.recent_kl_sum())
671        } else {
672            None
673        },
674        samples_per_second: calibration.samples_per_second,
675        calibration_snapshot: Some(&calibration.calibration_snapshot),
676        current_stats_snapshot: current_stats.as_ref(),
677        c_floor: calibration.c_floor,
678        theta_tick: calibration.theta_tick,
679        projection_mismatch_q: if posterior.projection_mismatch_q.is_nan() {
680            None
681        } else {
682            Some(posterior.projection_mismatch_q)
683        },
684        projection_mismatch_thresh: calibration.projection_mismatch_thresh,
685        lambda_mixing_ok: posterior.lambda_mixing_ok,
686    };
687
688    match check_quality_gates(&gate_inputs, &config.quality_gates) {
689        QualityGateResult::Continue => StepResult::Continue {
690            posterior,
691            samples_per_class: state.n_total(),
692        },
693        QualityGateResult::Stop(reason) => StepResult::Decision(AdaptiveOutcome::Inconclusive {
694            reason,
695            posterior: Some(posterior),
696            samples_per_class: state.n_total(),
697            elapsed_secs,
698        }),
699    }
700}
701
702/// Compute posterior distribution from current state.
703///
704/// Uses scaled covariance: Sigma_n = Sigma_rate / n
705fn compute_posterior(
706    state: &AdaptiveState,
707    calibration: &Calibration,
708    ns_per_tick: f64,
709    config: &AdaptiveStepConfig,
710) -> Option<Posterior> {
711    let n = state.n_total();
712    if n < 20 {
713        return None; // Need minimum samples for stable quantiles
714    }
715
716    // Convert samples to nanoseconds
717    let baseline_ns = state.baseline_ns(ns_per_tick);
718    let sample_ns = state.sample_ns(ns_per_tick);
719
720    // Compute quantile differences
721    let observed_diff = if calibration.discrete_mode {
722        let q_baseline = compute_midquantile_deciles(&baseline_ns);
723        let q_sample = compute_midquantile_deciles(&sample_ns);
724        q_baseline - q_sample
725    } else {
726        let mut baseline_sorted = baseline_ns;
727        let mut sample_sorted = sample_ns;
728        let q_baseline = compute_deciles_inplace(&mut baseline_sorted);
729        let q_sample = compute_deciles_inplace(&mut sample_sorted);
730        q_baseline - q_sample
731    };
732
733    // Scale covariance: Sigma_n = Sigma_rate / n
734    let sigma_n = calibration.covariance_for_n(n);
735
736    // Run 9D Bayesian inference with v5.4 Gibbs sampler
737    let bayes_result = compute_bayes_gibbs(
738        &observed_diff,
739        &sigma_n,
740        calibration.sigma_t,
741        &calibration.l_r,
742        config.theta_ns,
743        Some(config.seed),
744    );
745
746    Some(Posterior::new_with_gibbs(
747        bayes_result.delta_post,
748        bayes_result.lambda_post,
749        bayes_result.beta_proj,
750        bayes_result.beta_proj_cov,
751        bayes_result.beta_draws,
752        bayes_result.leak_probability,
753        bayes_result.projection_mismatch_q,
754        n,
755        bayes_result.lambda_mean,
756        bayes_result.lambda_mixing_ok,
757        // v5.6: kappa diagnostics
758        bayes_result.kappa_mean,
759        bayes_result.kappa_cv,
760        bayes_result.kappa_ess,
761        bayes_result.kappa_mixing_ok,
762    ))
763}
764
765#[cfg(test)]
766mod tests {
767    use super::*;
768    use crate::statistics::StatsSnapshot;
769    use crate::types::{Matrix2, Matrix9, Vector2, Vector9};
770
771    fn make_test_calibration() -> Calibration {
772        use crate::adaptive::CalibrationSnapshot;
773
774        let snapshot = CalibrationSnapshot::new(
775            StatsSnapshot {
776                count: 5000,
777                mean: 1000.0,
778                variance: 25.0,
779                autocorr_lag1: 0.1,
780            },
781            StatsSnapshot {
782                count: 5000,
783                mean: 1000.0,
784                variance: 25.0,
785                autocorr_lag1: 0.1,
786            },
787        );
788
789        // v5.4+ t-prior
790        Calibration::new(
791            Matrix9::identity() * 1000.0, // sigma_rate
792            10,                           // block_length
793            100.0,                        // sigma_t
794            Matrix9::identity(),          // l_r (identity for tests)
795            100.0,                        // theta_ns
796            5000,                         // calibration_samples
797            false,                        // discrete_mode
798            5.0,                          // mde_shift_ns
799            10.0,                         // mde_tail_ns
800            snapshot,                     // calibration_snapshot
801            1.0,                          // timer_resolution_ns
802            100_000.0,                    // samples_per_second
803            10.0,                         // c_floor
804            18.48,                        // projection_mismatch_thresh
805            0.001,                        // theta_tick
806            100.0,                        // theta_eff
807            0.1,                          // theta_floor_initial
808            42,                           // rng_seed
809            1,                            // batch_k (no batching in tests)
810        )
811    }
812
813    fn make_test_posterior(leak_prob: f64) -> Posterior {
814        Posterior::new(
815            Vector9::zeros(),
816            Matrix9::identity(),
817            Vector2::new(10.0, 5.0),
818            Matrix2::new(1.0, 0.0, 0.0, 1.0),
819            Vec::new(), // beta_draws
820            leak_prob,
821            1.0, // projection_mismatch_q
822            1000,
823        )
824    }
825
826    #[test]
827    fn test_adaptive_step_config_default() {
828        let config = AdaptiveStepConfig::default();
829        assert!((config.pass_threshold - 0.05).abs() < 1e-10);
830        assert!((config.fail_threshold - 0.95).abs() < 1e-10);
831        assert!((config.time_budget_secs - 30.0).abs() < 1e-10);
832    }
833
834    #[test]
835    fn test_adaptive_step_config_builder() {
836        let config = AdaptiveStepConfig::with_theta(50.0)
837            .pass_threshold(0.01)
838            .fail_threshold(0.99)
839            .time_budget_secs(60.0)
840            .max_samples(500_000);
841
842        assert!((config.theta_ns - 50.0).abs() < 1e-10);
843        assert!((config.pass_threshold - 0.01).abs() < 1e-10);
844        assert!((config.fail_threshold - 0.99).abs() < 1e-10);
845        assert!((config.time_budget_secs - 60.0).abs() < 1e-10);
846        assert_eq!(config.max_samples, 500_000);
847    }
848
849    #[test]
850    fn test_adaptive_outcome_accessors() {
851        let posterior = make_test_posterior(0.95);
852
853        let outcome = AdaptiveOutcome::LeakDetected {
854            posterior: posterior.clone(),
855            samples_per_class: 1000,
856            elapsed_secs: 1.5,
857        };
858
859        assert!(outcome.is_leak_detected());
860        assert!(outcome.is_conclusive());
861        assert_eq!(outcome.leak_probability(), Some(0.95));
862        assert_eq!(outcome.samples_per_class(), 1000);
863        assert!((outcome.elapsed_secs() - 1.5).abs() < 1e-10);
864
865        let outcome = AdaptiveOutcome::NoLeakDetected {
866            posterior,
867            samples_per_class: 2000,
868            elapsed_secs: 2.5,
869        };
870
871        assert!(!outcome.is_leak_detected());
872        assert!(outcome.is_conclusive());
873        assert_eq!(outcome.samples_per_class(), 2000);
874    }
875
876    #[test]
877    fn test_step_result_accessors() {
878        let posterior = make_test_posterior(0.5);
879
880        let result = StepResult::Continue {
881            posterior: posterior.clone(),
882            samples_per_class: 1000,
883        };
884
885        assert!(!result.is_decision());
886        assert_eq!(result.leak_probability(), Some(0.5));
887
888        let result = StepResult::Decision(AdaptiveOutcome::LeakDetected {
889            posterior,
890            samples_per_class: 1000,
891            elapsed_secs: 1.0,
892        });
893
894        assert!(result.is_decision());
895    }
896
897    #[test]
898    fn test_adaptive_step_insufficient_samples() {
899        let calibration = make_test_calibration();
900        let mut state = AdaptiveState::new();
901        state.add_batch(vec![100; 10], vec![101; 10]); // Only 10 samples
902
903        let config = AdaptiveStepConfig::default();
904        let result = adaptive_step(&calibration, &mut state, 1.0, 0.1, &config);
905
906        // Should return Inconclusive (need more samples)
907        assert!(result.is_decision());
908        if let StepResult::Decision(AdaptiveOutcome::Inconclusive { reason, .. }) = result {
909            assert!(matches!(reason, InconclusiveReason::DataTooNoisy { .. }));
910        } else {
911            panic!("Expected Inconclusive with DataTooNoisy");
912        }
913    }
914
915    #[test]
916    fn test_compute_posterior_no_difference() {
917        let calibration = make_test_calibration();
918        let mut state = AdaptiveState::new();
919
920        // Add samples with no timing difference
921        let baseline: Vec<u64> = (0..1000).map(|i| 1000 + (i % 10)).collect();
922        let sample: Vec<u64> = (0..1000).map(|i| 1000 + (i % 10)).collect();
923        state.add_batch(baseline, sample);
924
925        let config = AdaptiveStepConfig::with_theta(100.0);
926
927        let posterior = compute_posterior(&state, &calibration, 1.0, &config);
928
929        assert!(posterior.is_some());
930        let p = posterior.unwrap();
931
932        // With identical distributions, leak probability should be low
933        assert!(
934            p.leak_probability < 0.5,
935            "Identical distributions should have low leak probability, got {}",
936            p.leak_probability
937        );
938    }
939
940    #[test]
941    fn test_compute_posterior_with_difference() {
942        let calibration = make_test_calibration();
943        let mut state = AdaptiveState::new();
944
945        // Add samples with clear timing difference (200ns)
946        let baseline: Vec<u64> = (0..1000).map(|i| 1000 + (i % 10)).collect();
947        let sample: Vec<u64> = (0..1000).map(|i| 1200 + (i % 10)).collect();
948        state.add_batch(baseline, sample);
949
950        let config = AdaptiveStepConfig::with_theta(100.0); // Effect is 200ns, threshold is 100ns
951
952        let posterior = compute_posterior(&state, &calibration, 1.0, &config);
953
954        assert!(posterior.is_some());
955        let p = posterior.unwrap();
956
957        // With 200ns difference vs 100ns threshold, leak probability should be high
958        assert!(
959            p.leak_probability > 0.5,
960            "Clear difference should have high leak probability, got {}",
961            p.leak_probability
962        );
963    }
964
965    #[test]
966    fn test_adaptive_step_detects_leak() {
967        let calibration = make_test_calibration();
968        let mut state = AdaptiveState::new();
969
970        // Add samples with large timing difference
971        let baseline: Vec<u64> = (0..1000).map(|i| 1000 + (i % 10)).collect();
972        let sample: Vec<u64> = (0..1000).map(|i| 1500 + (i % 10)).collect();
973        state.add_batch(baseline, sample);
974
975        let config = AdaptiveStepConfig::with_theta(100.0);
976        let result = adaptive_step(&calibration, &mut state, 1.0, 1.0, &config);
977
978        // Should detect a leak
979        assert!(result.is_decision());
980        if let StepResult::Decision(outcome) = result {
981            assert!(outcome.is_leak_detected());
982        } else {
983            panic!("Expected Decision");
984        }
985    }
986}