Skip to main content

asupersync/lab/
conformal.rs

1//! Distribution-free conformal calibration for lab metrics.
2//!
3//! Conformal prediction provides finite-sample, distribution-free coverage
4//! guarantees for prediction sets. Given a target miscoverage rate `alpha`,
5//! the conformal prediction set `C(X)` satisfies:
6//!
7//!   `P(Y ∈ C(X)) ≥ 1 - alpha`
8//!
9//! for **any** joint distribution of (X, Y), with no parametric assumptions.
10//!
11//! # Algorithm: Split Conformal Prediction
12//!
13//! 1. **Calibration phase**: Accumulate conformity scores `s_1, ..., s_n` from
14//!    past oracle reports. A conformity score measures how "normal" an observation
15//!    is — lower scores indicate more conforming behavior.
16//!
17//! 2. **Prediction phase**: For a new observation, compute the `(1 - alpha)(1 + 1/n)`
18//!    quantile of the calibration scores. The prediction set is all values with
19//!    conformity score ≤ this threshold.
20//!
21//! 3. **Coverage guarantee**: By the exchangeability assumption (all runs are drawn
22//!    from the same program under varying seeds), Vovk et al. (2005) show that
23//!    `P(s_{n+1} ≤ q_hat) ≥ 1 - alpha`.
24//!
25//! # Conformity Scores for Oracle Metrics
26//!
27//! We define conformity scores from `OracleReport` statistics:
28//!
29//! - **Violation score**: 0 if passed, 1 if violated (binary nonconformity).
30//! - **Entity score**: Normalized entity count relative to running median.
31//! - **Event density score**: Events per entity relative to calibration set.
32//!
33//! # References
34//!
35//! - Vovk, Gammerman, Shafer, "Algorithmic Learning in a Random World" (2005)
36//! - Lei et al., "Distribution-Free Predictive Inference for Regression" (JASA 2018)
37//! - Angelopoulos & Bates, "A Gentle Introduction to Conformal Prediction" (2022)
38
39use crate::lab::oracle::{OracleEntryReport, OracleReport};
40use serde::{Deserialize, Serialize};
41use std::collections::BTreeMap;
42
43fn count_to_f64(count: usize) -> f64 {
44    f64::from(count.min(u32::MAX as usize) as u32)
45}
46
47fn assert_valid_alpha(alpha: f64) {
48    assert!(
49        alpha.is_finite() && alpha > 0.0 && alpha < 1.0,
50        "alpha must be finite and in (0, 1), got {alpha}"
51    );
52}
53
54fn assert_valid_min_samples(min_samples: usize) {
55    assert!(min_samples > 0, "min_calibration_samples must be > 0");
56}
57
58/// Configuration for the conformal calibrator.
59#[derive(Debug, Clone)]
60pub struct ConformalConfig {
61    /// Target miscoverage rate (e.g., 0.05 for 95% coverage).
62    pub alpha: f64,
63    /// Minimum calibration samples before producing prediction sets.
64    pub min_calibration_samples: usize,
65}
66
67impl Default for ConformalConfig {
68    fn default() -> Self {
69        Self {
70            alpha: 0.05,
71            min_calibration_samples: 5,
72        }
73    }
74}
75
76impl ConformalConfig {
77    /// Create a config with the given miscoverage rate.
78    #[must_use]
79    pub fn new(alpha: f64) -> Self {
80        assert_valid_alpha(alpha);
81        Self {
82            alpha,
83            ..Default::default()
84        }
85    }
86
87    /// Set the minimum calibration samples.
88    #[must_use]
89    pub fn min_samples(mut self, n: usize) -> Self {
90        assert_valid_min_samples(n);
91        self.min_calibration_samples = n;
92        self
93    }
94}
95
96/// A conformity score for a single oracle observation.
97#[derive(Debug, Clone, Copy, PartialEq)]
98pub struct ConformityScore {
99    /// The nonconformity value (higher = more unusual).
100    pub value: f64,
101    /// Whether the oracle violated its invariant.
102    pub violated: bool,
103}
104
105/// Per-invariant calibration state.
106#[derive(Debug, Clone, Default)]
107struct InvariantCalibration {
108    /// Accumulated conformity scores (sorted for quantile computation).
109    scores: Vec<f64>,
110    /// Running sum of entity counts for normalization.
111    entity_sum: f64,
112    /// Running sum of event counts for normalization.
113    event_sum: f64,
114    /// Number of violations observed.
115    violation_count: usize,
116}
117
118impl InvariantCalibration {
119    fn n(&self) -> usize {
120        self.scores.len()
121    }
122
123    fn mean_entities(&self) -> f64 {
124        let n = self.n();
125        if n == 0 {
126            1.0
127        } else {
128            (self.entity_sum / count_to_f64(n)).max(1.0)
129        }
130    }
131
132    fn mean_events(&self) -> f64 {
133        let n = self.n();
134        if n == 0 {
135            1.0
136        } else {
137            (self.event_sum / count_to_f64(n)).max(1.0)
138        }
139    }
140
141    fn empirical_violation_rate(&self) -> f64 {
142        let n = self.n();
143        if n == 0 {
144            0.0
145        } else {
146            count_to_f64(self.violation_count) / count_to_f64(n)
147        }
148    }
149}
150
151/// A prediction set for a single invariant.
152#[derive(Debug, Clone, Serialize, Deserialize)]
153pub struct PredictionSet {
154    /// The invariant name.
155    pub invariant: String,
156    /// The conformity threshold (quantile).
157    pub threshold: f64,
158    /// Whether a new observation is within the prediction set (conforming).
159    pub conforming: bool,
160    /// The new observation's conformity score.
161    pub score: f64,
162    /// Number of calibration samples used.
163    pub calibration_n: usize,
164    /// Target coverage level (1 - alpha).
165    pub coverage_target: f64,
166}
167
168/// Empirical coverage tracking for calibration diagnostics.
169#[derive(Debug, Clone, Default, Serialize, Deserialize)]
170pub struct CoverageTracker {
171    /// Total predictions made.
172    pub total: usize,
173    /// Predictions where the observation was within the prediction set.
174    pub covered: usize,
175}
176
177impl CoverageTracker {
178    /// Empirical coverage rate.
179    #[must_use]
180    pub fn rate(&self) -> f64 {
181        if self.total == 0 {
182            1.0
183        } else {
184            count_to_f64(self.covered) / count_to_f64(self.total)
185        }
186    }
187}
188
189/// Calibration report with coverage diagnostics.
190#[derive(Debug, Clone, Serialize, Deserialize)]
191pub struct CalibrationReport {
192    /// Per-invariant prediction sets from the latest observation.
193    pub prediction_sets: Vec<PredictionSet>,
194    /// Per-invariant empirical coverage tracking.
195    pub coverage: BTreeMap<String, CoverageTracker>,
196    /// Overall empirical coverage across all invariants.
197    pub overall_coverage: CoverageTracker,
198    /// Target miscoverage rate.
199    pub alpha: f64,
200    /// Total calibration observations.
201    pub calibration_samples: usize,
202}
203
204impl CalibrationReport {
205    /// Returns true if all observed coverage rates are above the target.
206    ///
207    /// br-asupersync-9u4ext: tolerance is now alpha-derived (1/5 of
208    /// the configured alpha) rather than a fixed 5-percentage-point
209    /// absolute slack. With the default alpha=0.05 (95% target),
210    /// well-calibrated now means observed rate is at least
211    /// `0.95 - 0.01 = 0.94` rather than the previous `0.95 - 0.05
212    /// = 0.90`. The fixed 0.05 cushion was roughly 5x the alpha
213    /// itself — wide enough that a system whose anomaly rate had
214    /// climbed to 9% was still reported as 'well-calibrated',
215    /// defeating the conformal-prediction guarantee operators
216    /// believe they are getting.
217    ///
218    /// Tolerance derivation: scaling at `alpha / 5` means the slack
219    /// stays proportional to the prediction guarantee — strict
220    /// (alpha=0.01 → 0.2pp slack) and looser (alpha=0.20 → 4pp
221    /// slack) configurations both get a band that's a fixed
222    /// fraction of their stated risk budget. The floor of
223    /// `f64::EPSILON` keeps the comparison strictly correct even
224    /// when alpha is configured to extreme values.
225    #[must_use]
226    pub fn is_well_calibrated(&self) -> bool {
227        if self.overall_coverage.total == 0 {
228            return true;
229        }
230        let target = 1.0 - self.alpha;
231        self.overall_coverage.rate() >= target - self.calibration_tolerance()
232    }
233
234    /// br-asupersync-9u4ext: tolerance band used by
235    /// `is_well_calibrated` and `miscalibrated_invariants`. Exposed so
236    /// operators / harness reports can show the same number that
237    /// drives the pass/fail decision.
238    #[must_use]
239    pub fn calibration_tolerance(&self) -> f64 {
240        (self.alpha / 5.0).max(f64::EPSILON)
241    }
242
243    /// Invariants whose empirical coverage falls below the target.
244    #[must_use]
245    pub fn miscalibrated_invariants(&self) -> Vec<String> {
246        let target = 1.0 - self.alpha;
247        let tolerance = self.calibration_tolerance();
248        self.coverage
249            .iter()
250            .filter(|(_, tracker)| tracker.total > 0 && tracker.rate() < target - tolerance)
251            .map(|(name, _)| name.clone())
252            .collect()
253    }
254
255    /// Render as structured text.
256    #[must_use]
257    pub fn to_text(&self) -> String {
258        use std::fmt::Write;
259        let mut out = String::new();
260        out.push_str("CONFORMAL CALIBRATION REPORT\n");
261        let _ = writeln!(
262            out,
263            "target coverage: {:.1}% (alpha={:.3})",
264            (1.0 - self.alpha) * 100.0,
265            self.alpha
266        );
267        let _ = writeln!(out, "calibration samples: {}", self.calibration_samples);
268        let _ = writeln!(
269            out,
270            "overall empirical coverage: {:.1}% ({}/{})\n",
271            self.overall_coverage.rate() * 100.0,
272            self.overall_coverage.covered,
273            self.overall_coverage.total,
274        );
275
276        for ps in &self.prediction_sets {
277            let status = if ps.conforming { "OK" } else { "ANOMALOUS" };
278            let _ = writeln!(
279                out,
280                "  {}: score={:.4} threshold={:.4} [{}] (n={})",
281                ps.invariant, ps.score, ps.threshold, status, ps.calibration_n
282            );
283        }
284
285        let miscal = self.miscalibrated_invariants();
286        if miscal.is_empty() {
287            out.push_str("\ncalibration: WELL-CALIBRATED\n");
288        } else {
289            let _ = writeln!(
290                out,
291                "\ncalibration: MISCALIBRATED on: {}",
292                miscal.join(", ")
293            );
294        }
295
296        out
297    }
298
299    /// Serialize to JSON.
300    #[must_use]
301    pub fn to_json(&self) -> serde_json::Value {
302        serde_json::json!({
303            "alpha": self.alpha,
304            "coverage_target": 1.0 - self.alpha,
305            "calibration_samples": self.calibration_samples,
306            "overall_coverage": {
307                "total": self.overall_coverage.total,
308                "covered": self.overall_coverage.covered,
309                "rate": self.overall_coverage.rate(),
310            },
311            "well_calibrated": self.is_well_calibrated(),
312            "prediction_sets": self.prediction_sets,
313            "per_invariant_coverage": self.coverage.iter().map(|(name, t)| {
314                serde_json::json!({
315                    "invariant": name,
316                    "total": t.total,
317                    "covered": t.covered,
318                    "rate": t.rate(),
319                })
320            }).collect::<Vec<_>>(),
321        })
322    }
323}
324
325/// Distribution-free conformal calibrator for oracle metrics.
326///
327/// Accumulates conformity scores from oracle reports during a calibration
328/// phase, then produces prediction sets with guaranteed marginal coverage
329/// for new observations.
330///
331/// # Coverage Guarantee
332///
333/// For exchangeable observations (same program, varying seeds), the
334/// prediction set `C(X_{n+1})` satisfies:
335///
336///   `P(Y_{n+1} ∈ C(X_{n+1})) ≥ 1 - alpha`
337///
338/// This is a finite-sample, distribution-free guarantee.
339#[derive(Debug, Clone)]
340pub struct ConformalCalibrator {
341    config: ConformalConfig,
342    /// Per-invariant calibration state.
343    calibrations: BTreeMap<String, InvariantCalibration>,
344    /// Per-invariant coverage tracking.
345    coverage_trackers: BTreeMap<String, CoverageTracker>,
346    /// Overall coverage tracker.
347    overall_coverage: CoverageTracker,
348    /// Total calibration observations.
349    n_calibration: usize,
350}
351
352impl ConformalCalibrator {
353    /// Create a new calibrator with the given config.
354    #[must_use]
355    pub fn new(config: ConformalConfig) -> Self {
356        assert_valid_alpha(config.alpha);
357        assert_valid_min_samples(config.min_calibration_samples);
358        Self {
359            config,
360            calibrations: BTreeMap::new(),
361            coverage_trackers: BTreeMap::new(),
362            overall_coverage: CoverageTracker::default(),
363            n_calibration: 0,
364        }
365    }
366
367    /// Create a calibrator with the default config (alpha=0.05).
368    #[must_use]
369    pub fn default_calibrator() -> Self {
370        Self::new(ConformalConfig::default())
371    }
372
373    /// Number of calibration observations accumulated.
374    #[must_use]
375    pub fn calibration_samples(&self) -> usize {
376        self.n_calibration
377    }
378
379    /// Whether enough calibration samples have been collected.
380    #[must_use]
381    pub fn is_calibrated(&self) -> bool {
382        self.n_calibration >= self.config.min_calibration_samples
383    }
384
385    /// Add a calibration observation from an oracle report.
386    ///
387    /// During the calibration phase, conformity scores are accumulated
388    /// but no predictions are made.
389    pub fn calibrate(&mut self, report: &OracleReport) {
390        for entry in &report.entries {
391            let cal = self
392                .calibrations
393                .entry(entry.invariant.clone())
394                .or_default();
395            let score = conformity_score(entry, cal);
396            cal.scores.push(score);
397            cal.entity_sum += count_to_f64(entry.stats.entities_tracked);
398            cal.event_sum += count_to_f64(entry.stats.events_recorded);
399            if !entry.passed {
400                cal.violation_count += 1;
401            }
402        }
403        self.n_calibration += 1;
404    }
405
406    /// Observe a new report and produce prediction sets.
407    ///
408    /// If not yet calibrated, returns `None`. Otherwise, returns a
409    /// `CalibrationReport` with prediction sets and coverage diagnostics.
410    #[must_use]
411    pub fn predict(&mut self, report: &OracleReport) -> Option<CalibrationReport> {
412        let was_already_calibrated = self.is_calibrated();
413
414        if !was_already_calibrated {
415            // Add to calibration set first.
416            self.calibrate(report);
417            // Whether we just became calibrated or still need more data,
418            // skip the prediction for this observation: it is part of the
419            // calibration set and testing it against the same set violates
420            // the exchangeability assumption of split conformal prediction.
421            return None;
422        }
423
424        let mut prediction_sets = Vec::new();
425
426        for entry in &report.entries {
427            let Some(cal) = self.calibrations.get(&entry.invariant) else {
428                continue;
429            };
430
431            // Compute conformity score for the new observation.
432            let score = conformity_score(entry, cal);
433
434            // Compute the conformal quantile threshold.
435            let threshold = conformal_quantile(&cal.scores, self.config.alpha);
436
437            let conforming = score <= threshold;
438
439            // Update coverage tracking.
440            let tracker = self
441                .coverage_trackers
442                .entry(entry.invariant.clone())
443                .or_default();
444            tracker.total += 1;
445            if conforming {
446                tracker.covered += 1;
447            }
448            self.overall_coverage.total += 1;
449            if conforming {
450                self.overall_coverage.covered += 1;
451            }
452
453            prediction_sets.push(PredictionSet {
454                invariant: entry.invariant.clone(),
455                threshold,
456                conforming,
457                score,
458                calibration_n: cal.n(),
459                coverage_target: 1.0 - self.config.alpha,
460            });
461        }
462
463        // Grow the calibration set with this observation for future predictions,
464        // unless it was already added above during the uncalibrated→calibrated transition.
465        if was_already_calibrated {
466            self.calibrate(report);
467        }
468
469        Some(CalibrationReport {
470            prediction_sets,
471            coverage: self.coverage_trackers.clone(),
472            overall_coverage: self.overall_coverage.clone(),
473            alpha: self.config.alpha,
474            calibration_samples: self.n_calibration,
475        })
476    }
477
478    /// Per-invariant empirical violation rates from calibration data.
479    #[must_use]
480    pub fn violation_rates(&self) -> BTreeMap<String, f64> {
481        self.calibrations
482            .iter()
483            .map(|(name, cal)| (name.clone(), cal.empirical_violation_rate()))
484            .collect()
485    }
486
487    /// Per-invariant coverage rates from prediction tracking.
488    #[must_use]
489    pub fn coverage_rates(&self) -> BTreeMap<String, f64> {
490        self.coverage_trackers
491            .iter()
492            .map(|(name, tracker)| (name.clone(), tracker.rate()))
493            .collect()
494    }
495}
496
497/// Compute a conformity score for an oracle entry.
498///
499/// The score combines:
500/// 1. Violation indicator (0/1) — dominates for invariant violations
501/// 2. Entity count deviation from mean (normalized)
502/// 3. Event density anomaly (events/entity vs mean)
503///
504/// Lower scores indicate more conforming behavior.
505fn conformity_score(entry: &OracleEntryReport, cal: &InvariantCalibration) -> f64 {
506    let violation_component = if entry.passed { 0.0 } else { 1.0 };
507
508    // When calibration has no data, deviations are undefined — treat as zero.
509    if cal.n() == 0 {
510        return violation_component;
511    }
512
513    let mean_entities = cal.mean_entities();
514    let entity_deviation = if mean_entities > 0.0 {
515        ((count_to_f64(entry.stats.entities_tracked) - mean_entities) / mean_entities).abs()
516    } else {
517        0.0
518    };
519
520    let mean_events = cal.mean_events();
521    let event_deviation = if mean_events > 0.0 {
522        ((count_to_f64(entry.stats.events_recorded) - mean_events) / mean_events).abs()
523    } else {
524        0.0
525    };
526
527    // Weighted combination: violations dominate, deviations are secondary.
528    0.1_f64.mul_add(
529        event_deviation,
530        0.1_f64.mul_add(entity_deviation, violation_component),
531    )
532}
533
534/// Compute the conformal quantile from calibration scores.
535///
536/// Returns the `ceil((1-alpha)(n+1))`-th smallest calibration score (1-indexed),
537/// which yields the split-conformal finite-sample guarantee
538/// `P(score_{n+1} <= threshold) >= 1 - alpha` under exchangeability. When that
539/// rank exceeds `n` (i.e. `alpha < 1/(n+1)`), no finite score attains the
540/// target coverage, so the threshold is `+inf` (cover everything) — the same
541/// convention as the empty-calibration case.
542fn conformal_quantile(scores: &[f64], alpha: f64) -> f64 {
543    if scores.is_empty() {
544        return f64::INFINITY;
545    }
546
547    let n = scores.len();
548    let mut sorted = scores.to_vec();
549    sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
550
551    // The conformal threshold is the ceil((1-alpha)(n+1))-th order statistic
552    // (1-indexed). br-asupersync-mdym3s: when that rank exceeds n the correct
553    // threshold is +inf — clamping to the largest score (min(n)) instead only
554    // attains n/(n+1) < 1-alpha worst-case coverage (the test point can be the
555    // new maximum) and silently violates the finite-sample guarantee.
556    let level = (1.0 - alpha) * (count_to_f64(n) + 1.0);
557    #[allow(clippy::cast_sign_loss)]
558    let rank = level.ceil() as usize;
559    if rank > n {
560        return f64::INFINITY;
561    }
562
563    sorted[rank.saturating_sub(1)]
564}
565
566// ============================================================================
567// Health threshold conformal calibration
568// ============================================================================
569
570/// How the threshold bounds anomalous values.
571#[derive(Debug, Clone, Copy, PartialEq, Eq)]
572pub enum ThresholdMode {
573    /// Only values above the threshold are anomalous (e.g., queue depth,
574    /// restart intensity). Uses the (1-α)(n+1)-th order statistic directly.
575    Upper,
576    /// Both unusually high and unusually low values are anomalous.
577    /// Uses |value - median| as the nonconformity score.
578    TwoSided,
579}
580
581/// Configuration for health threshold calibration.
582#[derive(Debug, Clone)]
583pub struct HealthThresholdConfig {
584    /// Target miscoverage rate (e.g., 0.05 for 95% coverage).
585    pub alpha: f64,
586    /// Minimum calibration samples before producing thresholds.
587    pub min_calibration_samples: usize,
588    /// Threshold direction.
589    pub mode: ThresholdMode,
590}
591
592impl Default for HealthThresholdConfig {
593    fn default() -> Self {
594        Self {
595            alpha: 0.05,
596            min_calibration_samples: 5,
597            mode: ThresholdMode::Upper,
598        }
599    }
600}
601
602impl HealthThresholdConfig {
603    /// Create a config with the given miscoverage rate and mode.
604    #[must_use]
605    pub fn new(alpha: f64, mode: ThresholdMode) -> Self {
606        assert_valid_alpha(alpha);
607        Self {
608            alpha,
609            mode,
610            ..Default::default()
611        }
612    }
613
614    /// Set the minimum calibration samples.
615    #[must_use]
616    pub fn min_samples(mut self, n: usize) -> Self {
617        assert_valid_min_samples(n);
618        self.min_calibration_samples = n;
619        self
620    }
621}
622
623/// Result of checking a health metric against a conformal threshold.
624#[derive(Debug, Clone)]
625pub struct ThresholdCheck {
626    /// The metric name.
627    pub metric: String,
628    /// The observed value.
629    pub value: f64,
630    /// The conformal threshold.
631    pub threshold: f64,
632    /// Whether the observation is within the prediction set (conforming).
633    pub conforming: bool,
634    /// The nonconformity score.
635    pub nonconformity_score: f64,
636    /// Number of calibration samples used.
637    pub calibration_n: usize,
638    /// Target coverage level (1 - alpha).
639    pub coverage_target: f64,
640}
641
642/// Per-metric calibration state.
643#[derive(Debug, Clone, Default)]
644struct MetricCalibration {
645    /// Raw observations for direct upper-bound thresholding.
646    values: Vec<f64>,
647}
648
649impl MetricCalibration {
650    fn n(&self) -> usize {
651        self.values.len()
652    }
653
654    fn median(&self) -> f64 {
655        if self.values.is_empty() {
656            return 0.0;
657        }
658        let mut sorted = self.values.clone();
659        sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
660        let mid = sorted.len() / 2;
661        if sorted.len().is_multiple_of(2) && sorted.len() >= 2 {
662            (sorted[mid - 1]).midpoint(sorted[mid])
663        } else {
664            sorted[mid]
665        }
666    }
667}
668
669/// Conformal calibrator for health metrics (queue depth, restart latency, etc.).
670///
671/// Accumulates observations during a calibration phase, then produces
672/// adaptive thresholds with finite-sample, distribution-free coverage
673/// guarantees.
674///
675/// # Coverage Guarantee
676///
677/// For exchangeable observations, P(new observation conforming) ≥ 1 - alpha.
678/// This holds without distributional assumptions (Vovk et al. 2005).
679///
680/// # Modes
681///
682/// - [`ThresholdMode::Upper`]: Flags values above the conformal quantile.
683///   Good for metrics where only high values are problematic (queue depth,
684///   restart intensity).
685///
686/// - [`ThresholdMode::TwoSided`]: Uses |value - median| as the nonconformity
687///   score. Flags observations that deviate from the calibration distribution
688///   in either direction.
689///
690/// # Example
691///
692/// ```
693/// use asupersync::lab::conformal::{
694///     HealthThresholdCalibrator, HealthThresholdConfig, ThresholdMode,
695/// };
696///
697/// let config = HealthThresholdConfig::new(0.05, ThresholdMode::Upper).min_samples(5);
698/// let mut cal = HealthThresholdCalibrator::new(config);
699///
700/// // Calibrate with normal observations
701/// for depth in (1..=20).map(f64::from) {
702///     cal.calibrate("queue_depth", depth);
703/// }
704///
705/// // Check a new observation
706/// let result = cal.check("queue_depth", 100.0).unwrap();
707/// assert!(!result.conforming); // queue depth 100 is anomalous
708/// ```
709#[derive(Debug, Clone)]
710pub struct HealthThresholdCalibrator {
711    config: HealthThresholdConfig,
712    metrics: BTreeMap<String, MetricCalibration>,
713    coverage_trackers: BTreeMap<String, CoverageTracker>,
714    n_calibration: usize,
715}
716
717impl HealthThresholdCalibrator {
718    /// Create a new calibrator with the given config.
719    #[must_use]
720    pub fn new(config: HealthThresholdConfig) -> Self {
721        assert_valid_alpha(config.alpha);
722        assert_valid_min_samples(config.min_calibration_samples);
723        Self {
724            config,
725            metrics: BTreeMap::new(),
726            coverage_trackers: BTreeMap::new(),
727            n_calibration: 0,
728        }
729    }
730
731    /// Number of calibration observations accumulated (total across all metrics).
732    #[must_use]
733    pub fn calibration_samples(&self) -> usize {
734        self.n_calibration
735    }
736
737    /// Whether a named metric has enough samples for prediction.
738    #[must_use]
739    pub fn is_metric_calibrated(&self, metric: &str) -> bool {
740        self.metrics
741            .get(metric)
742            .is_some_and(|m| m.n() >= self.config.min_calibration_samples)
743    }
744
745    /// Add a calibration observation for a named metric.
746    pub fn calibrate(&mut self, metric: &str, value: f64) {
747        // Non-finite calibration values can poison quantile computation.
748        // Ignore them so thresholds remain stable and deterministic.
749        if !value.is_finite() {
750            return;
751        }
752
753        let cal = self.metrics.entry(metric.to_string()).or_default();
754
755        cal.values.push(value);
756
757        self.n_calibration += 1;
758    }
759
760    /// Check if a new observation exceeds the conformal threshold.
761    ///
762    /// Returns `None` if the metric is not yet calibrated.
763    #[must_use]
764    pub fn check(&self, metric: &str, value: f64) -> Option<ThresholdCheck> {
765        let cal = self.metrics.get(metric)?;
766        if cal.n() < self.config.min_calibration_samples {
767            return None;
768        }
769
770        // Non-finite observations are always anomalous; report explicitly
771        // without mutating calibration state.
772        if !value.is_finite() {
773            return Some(ThresholdCheck {
774                metric: metric.to_string(),
775                value,
776                threshold: self.threshold(metric)?,
777                conforming: false,
778                nonconformity_score: f64::INFINITY,
779                calibration_n: cal.n(),
780                coverage_target: 1.0 - self.config.alpha,
781            });
782        }
783
784        let (nonconformity_score, threshold) = match self.config.mode {
785            ThresholdMode::Upper => {
786                let score = value;
787                let threshold = conformal_quantile(&cal.values, self.config.alpha);
788                (score, threshold)
789            }
790            ThresholdMode::TwoSided => {
791                // Recompute nonconformity scores from the current full median so
792                // that both calibration and test scores use the same reference
793                // point, preserving exchangeability for the conformal guarantee.
794                let median = cal.median();
795                let scores: Vec<f64> = cal.values.iter().map(|v| (v - median).abs()).collect();
796                let score = (value - median).abs();
797                let threshold = conformal_quantile(&scores, self.config.alpha);
798                (score, threshold)
799            }
800        };
801
802        let conforming = nonconformity_score <= threshold;
803
804        Some(ThresholdCheck {
805            metric: metric.to_string(),
806            value,
807            threshold,
808            conforming,
809            nonconformity_score,
810            calibration_n: cal.n(),
811            coverage_target: 1.0 - self.config.alpha,
812        })
813    }
814
815    /// Check a metric and update coverage tracking.
816    pub fn check_and_track(&mut self, metric: &str, value: f64) -> Option<ThresholdCheck> {
817        let result = self.check(metric, value)?;
818
819        let tracker = self
820            .coverage_trackers
821            .entry(metric.to_string())
822            .or_default();
823        tracker.total += 1;
824        if result.conforming {
825            tracker.covered += 1;
826        }
827
828        Some(result)
829    }
830
831    /// Get the current adaptive threshold for a metric.
832    ///
833    /// Returns `None` if not yet calibrated.
834    #[must_use]
835    pub fn threshold(&self, metric: &str) -> Option<f64> {
836        let cal = self.metrics.get(metric)?;
837        if cal.n() < self.config.min_calibration_samples {
838            return None;
839        }
840
841        match self.config.mode {
842            ThresholdMode::Upper => Some(conformal_quantile(&cal.values, self.config.alpha)),
843            ThresholdMode::TwoSided => {
844                let median = cal.median();
845                let scores: Vec<f64> = cal.values.iter().map(|v| (v - median).abs()).collect();
846                Some(conformal_quantile(&scores, self.config.alpha))
847            }
848        }
849    }
850
851    /// Per-metric coverage rates from prediction tracking.
852    #[must_use]
853    pub fn coverage_rates(&self) -> BTreeMap<String, f64> {
854        self.coverage_trackers
855            .iter()
856            .map(|(name, tracker)| (name.clone(), tracker.rate()))
857            .collect()
858    }
859
860    /// Per-metric calibration sample counts.
861    #[must_use]
862    pub fn metric_counts(&self) -> BTreeMap<String, usize> {
863        self.metrics
864            .iter()
865            .map(|(name, cal)| (name.clone(), cal.n()))
866            .collect()
867    }
868
869    /// Check multiple metrics at once and return all results.
870    #[must_use]
871    pub fn check_all(&self, observations: &[(&str, f64)]) -> Vec<ThresholdCheck> {
872        observations
873            .iter()
874            .filter_map(|(metric, value)| self.check(metric, *value))
875            .collect()
876    }
877
878    /// Returns true if any checked metric is non-conforming.
879    #[must_use]
880    pub fn any_anomalous(&self, observations: &[(&str, f64)]) -> bool {
881        observations
882            .iter()
883            .filter_map(|(metric, value)| self.check(metric, *value))
884            .any(|r| !r.conforming)
885    }
886}
887
888impl std::fmt::Display for ThresholdCheck {
889    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
890        let status = if self.conforming { "OK" } else { "ANOMALOUS" };
891        write!(
892            f,
893            "{}: value={:.4} threshold={:.4} [{}] (n={})",
894            self.metric, self.value, self.threshold, status, self.calibration_n
895        )
896    }
897}
898
899#[cfg(test)]
900mod tests {
901    #![allow(
902        clippy::pedantic,
903        clippy::nursery,
904        clippy::expect_fun_call,
905        clippy::map_unwrap_or,
906        clippy::cast_possible_wrap,
907        clippy::future_not_send
908    )]
909    use super::*;
910    use crate::lab::OracleStats;
911
912    fn make_clean_report(entities: usize, events: usize) -> OracleReport {
913        OracleReport {
914            entries: vec![OracleEntryReport {
915                invariant: "test_oracle".to_string(),
916                passed: true,
917                violation: None,
918                stats: OracleStats {
919                    entities_tracked: entities,
920                    events_recorded: events,
921                },
922            }],
923            total: 1,
924            passed: 1,
925            failed: 0,
926            check_time_nanos: 0,
927        }
928    }
929
930    fn make_violated_report(entities: usize, events: usize) -> OracleReport {
931        OracleReport {
932            entries: vec![OracleEntryReport {
933                invariant: "test_oracle".to_string(),
934                passed: false,
935                violation: Some("test violation".to_string()),
936                stats: OracleStats {
937                    entities_tracked: entities,
938                    events_recorded: events,
939                },
940            }],
941            total: 1,
942            passed: 0,
943            failed: 1,
944            check_time_nanos: 0,
945        }
946    }
947
948    #[test]
949    fn conformal_quantile_empty() {
950        assert!(conformal_quantile(&[], 0.05).is_infinite());
951    }
952
953    #[test]
954    fn conformal_quantile_single() {
955        // n=1, alpha=0.05: rank = ceil(0.95*2) = ceil(1.9) = 2 > n=1, so no
956        // finite score attains 95% coverage with a single calibration point
957        // (alpha < 1/(n+1) = 0.5) — the threshold is +inf (cover everything).
958        let scores = [0.5];
959        assert!(conformal_quantile(&scores, 0.05).is_infinite());
960
961        // n=1, alpha=0.5: rank = ceil(0.5*2) = 1 <= n, so the single score is
962        // the (finite) threshold.
963        let q = conformal_quantile(&scores, 0.5);
964        assert!((q - 0.5).abs() < f64::EPSILON);
965    }
966
967    #[test]
968    fn conformal_quantile_sorted() {
969        let scores = [0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0];
970        // (1-0.05)(10+1) = 10.45, ceil = 11 > n=10 => +inf (no finite score
971        // attains 95% coverage; clamping to the max would only give 10/11).
972        assert!(conformal_quantile(&scores, 0.05).is_infinite());
973
974        let q80 = conformal_quantile(&scores, 0.20);
975        // (1-0.20)(10+1) = 8.8, ceil = 9 <= n=10, -1 = 8 => scores[8] = 0.9
976        assert!((q80 - 0.9).abs() < f64::EPSILON);
977    }
978
979    #[test]
980    fn conformal_quantile_infinite_when_rank_exceeds_n() {
981        // br-asupersync-mdym3s boundary: the finite-sample guarantee needs the
982        // ceil((n+1)(1-alpha))-th order statistic; when that rank exceeds n the
983        // threshold must be +inf, not the largest score. Both levels below are
984        // clearly non-integer to avoid float-edge fragility.
985        let twenty: Vec<f64> = (1..=20).map(f64::from).collect();
986        // n=20, alpha=0.05: rank = ceil(0.95*21) = ceil(19.95) = 20 == n =>
987        // finite (the 20th order statistic).
988        let q = conformal_quantile(&twenty, 0.05);
989        assert!(
990            (q - 20.0).abs() < f64::EPSILON,
991            "rank==n is finite, got {q}"
992        );
993
994        // n=20, alpha=0.04: rank = ceil(0.96*21) = ceil(20.16) = 21 > n => +inf.
995        assert!(
996            conformal_quantile(&twenty, 0.04).is_infinite(),
997            "rank>n must be +inf to preserve >= 1-alpha coverage"
998        );
999    }
1000
1001    #[test]
1002    fn calibrator_starts_uncalibrated() {
1003        let cal = ConformalCalibrator::default_calibrator();
1004        assert!(!cal.is_calibrated());
1005        assert_eq!(cal.calibration_samples(), 0);
1006    }
1007
1008    #[test]
1009    fn calibrator_becomes_calibrated() {
1010        let config = ConformalConfig::new(0.10).min_samples(3);
1011        let mut cal = ConformalCalibrator::new(config);
1012
1013        for _ in 0..3 {
1014            cal.calibrate(&make_clean_report(10, 50));
1015        }
1016        assert!(cal.is_calibrated());
1017        assert_eq!(cal.calibration_samples(), 3);
1018    }
1019
1020    #[test]
1021    fn predict_returns_none_before_calibrated() {
1022        let config = ConformalConfig::new(0.10).min_samples(5);
1023        let mut cal = ConformalCalibrator::new(config);
1024
1025        // First 4 reports: not yet calibrated.
1026        for _ in 0..4 {
1027            assert!(cal.predict(&make_clean_report(10, 50)).is_none());
1028        }
1029        // 5th: completes calibration, but returns None to avoid testing
1030        // the calibration-completing observation against a set that
1031        // includes it (exchangeability requirement).
1032        let report = cal.predict(&make_clean_report(10, 50));
1033        assert!(
1034            report.is_none(),
1035            "calibration-completing observation must be skipped"
1036        );
1037
1038        // 6th: now truly post-calibration — returns a prediction.
1039        let report = cal.predict(&make_clean_report(10, 50));
1040        assert!(
1041            report.is_some(),
1042            "post-calibration observation should produce prediction"
1043        );
1044    }
1045
1046    #[test]
1047    fn clean_observations_are_conforming() {
1048        let config = ConformalConfig::new(0.10).min_samples(3);
1049        let mut cal = ConformalCalibrator::new(config);
1050
1051        // Calibrate with clean reports.
1052        for _ in 0..5 {
1053            cal.calibrate(&make_clean_report(10, 50));
1054        }
1055
1056        // New clean observation should be conforming.
1057        let report = cal.predict(&make_clean_report(10, 50)).unwrap();
1058        assert_eq!(report.prediction_sets.len(), 1);
1059        assert!(
1060            report.prediction_sets[0].conforming,
1061            "clean observation should be conforming"
1062        );
1063    }
1064
1065    #[test]
1066    fn sparse_calibration_uses_infinite_threshold_for_high_coverage() {
1067        let config = ConformalConfig::new(0.05).min_samples(3);
1068        let mut cal = ConformalCalibrator::new(config);
1069
1070        for _ in 0..3 {
1071            cal.calibrate(&make_clean_report(10, 50));
1072        }
1073
1074        let report = cal.predict(&make_violated_report(10_000, 50_000)).unwrap();
1075        assert_eq!(report.prediction_sets.len(), 1);
1076        let prediction = &report.prediction_sets[0];
1077        assert!(
1078            prediction.threshold.is_infinite(),
1079            "n=3 alpha=0.05 requires +inf threshold, got {}",
1080            prediction.threshold
1081        );
1082        assert!(
1083            prediction.conforming,
1084            "high-coverage sparse calibration must cover all scores"
1085        );
1086    }
1087
1088    #[test]
1089    fn violation_is_anomalous() {
1090        let config = ConformalConfig::new(0.10).min_samples(3);
1091        let mut cal = ConformalCalibrator::new(config);
1092
1093        // Calibrate with clean reports.
1094        for _ in 0..10 {
1095            cal.calibrate(&make_clean_report(10, 50));
1096        }
1097
1098        // Violated observation should be anomalous.
1099        let report = cal.predict(&make_violated_report(10, 50)).unwrap();
1100        assert!(!report.prediction_sets[0].conforming);
1101    }
1102
1103    #[test]
1104    fn coverage_tracking() {
1105        let config = ConformalConfig::new(0.10).min_samples(3);
1106        let mut cal = ConformalCalibrator::new(config);
1107
1108        // Calibrate.
1109        for _ in 0..5 {
1110            cal.calibrate(&make_clean_report(10, 50));
1111        }
1112
1113        // Predict multiple clean observations.
1114        for _ in 0..10 {
1115            let _ = cal.predict(&make_clean_report(10, 50));
1116        }
1117
1118        let rates = cal.coverage_rates();
1119        let rate = rates.get("test_oracle").copied().unwrap_or(0.0);
1120        assert!(
1121            rate >= 0.8,
1122            "coverage rate should be high for clean data, got {rate:.2}"
1123        );
1124    }
1125
1126    #[test]
1127    fn calibration_report_text_output() {
1128        let config = ConformalConfig::new(0.05).min_samples(3);
1129        let mut cal = ConformalCalibrator::new(config);
1130
1131        for _ in 0..5 {
1132            cal.calibrate(&make_clean_report(10, 50));
1133        }
1134        let report = cal.predict(&make_clean_report(10, 50)).unwrap();
1135        let text = report.to_text();
1136
1137        assert!(text.contains("CONFORMAL CALIBRATION REPORT"));
1138        assert!(text.contains("95.0%"));
1139        assert!(text.contains("alpha=0.050"));
1140        assert!(text.contains("test_oracle"));
1141    }
1142
1143    #[test]
1144    fn calibration_report_json_roundtrip() {
1145        let config = ConformalConfig::new(0.05).min_samples(3);
1146        let mut cal = ConformalCalibrator::new(config);
1147
1148        for _ in 0..5 {
1149            cal.calibrate(&make_clean_report(10, 50));
1150        }
1151        let report = cal.predict(&make_clean_report(10, 50)).unwrap();
1152        let json = report.to_json();
1153
1154        assert!(json.is_object());
1155        assert_eq!(json["alpha"], 0.05);
1156        assert!(json["well_calibrated"].as_bool().unwrap());
1157        assert!(json["prediction_sets"].is_array());
1158    }
1159
1160    #[test]
1161    fn well_calibrated_with_clean_data() {
1162        let config = ConformalConfig::new(0.10).min_samples(3);
1163        let mut cal = ConformalCalibrator::new(config);
1164
1165        for _ in 0..5 {
1166            cal.calibrate(&make_clean_report(10, 50));
1167        }
1168
1169        let mut last_report = None;
1170        for _ in 0..20 {
1171            last_report = cal.predict(&make_clean_report(10, 50));
1172        }
1173        let report = last_report.unwrap();
1174        assert!(report.is_well_calibrated());
1175        assert!(report.miscalibrated_invariants().is_empty());
1176    }
1177
1178    #[test]
1179    fn violation_rates_tracked() {
1180        let config = ConformalConfig::new(0.10).min_samples(2);
1181        let mut cal = ConformalCalibrator::new(config);
1182
1183        cal.calibrate(&make_clean_report(10, 50));
1184        cal.calibrate(&make_violated_report(10, 50));
1185        cal.calibrate(&make_clean_report(10, 50));
1186
1187        let rates = cal.violation_rates();
1188        let rate = rates.get("test_oracle").copied().unwrap_or(0.0);
1189        assert!(
1190            (rate - 1.0 / 3.0).abs() < 0.01,
1191            "expected ~0.33 violation rate, got {rate:.3}"
1192        );
1193    }
1194
1195    #[test]
1196    fn conformity_score_clean_is_low() {
1197        let cal = InvariantCalibration::default();
1198        let entry = OracleEntryReport {
1199            invariant: "test".to_string(),
1200            passed: true,
1201            violation: None,
1202            stats: OracleStats {
1203                entities_tracked: 10,
1204                events_recorded: 50,
1205            },
1206        };
1207        let score = conformity_score(&entry, &cal);
1208        assert!(score < 1.0, "clean score should be < 1.0, got {score}");
1209    }
1210
1211    #[test]
1212    fn conformity_score_violation_is_high() {
1213        let cal = InvariantCalibration::default();
1214        let entry = OracleEntryReport {
1215            invariant: "test".to_string(),
1216            passed: false,
1217            violation: Some("leak".to_string()),
1218            stats: OracleStats {
1219                entities_tracked: 10,
1220                events_recorded: 50,
1221            },
1222        };
1223        let score = conformity_score(&entry, &cal);
1224        assert!(
1225            score >= 1.0,
1226            "violation score should be >= 1.0, got {score}"
1227        );
1228    }
1229
1230    #[test]
1231    fn deterministic_calibration() {
1232        let run = || {
1233            let config = ConformalConfig::new(0.05).min_samples(3);
1234            let mut cal = ConformalCalibrator::new(config);
1235            for i in 0..5 {
1236                cal.calibrate(&make_clean_report(10 + i, 50 + i * 5));
1237            }
1238            cal.predict(&make_clean_report(10, 50))
1239        };
1240
1241        let r1 = run().unwrap();
1242        let r2 = run().unwrap();
1243        assert_eq!(r1.prediction_sets.len(), r2.prediction_sets.len());
1244        for (a, b) in r1.prediction_sets.iter().zip(r2.prediction_sets.iter()) {
1245            assert!((a.score - b.score).abs() < f64::EPSILON);
1246            assert_eq!(a.threshold, b.threshold);
1247            assert_eq!(a.conforming, b.conforming);
1248        }
1249    }
1250
1251    // ========================================================================
1252    // HealthThresholdCalibrator tests
1253    // ========================================================================
1254
1255    #[test]
1256    fn health_threshold_uncalibrated_returns_none() {
1257        let config = HealthThresholdConfig::new(0.05, ThresholdMode::Upper).min_samples(5);
1258        let cal = HealthThresholdCalibrator::new(config);
1259        assert!(cal.check("queue_depth", 10.0).is_none());
1260        assert!(!cal.is_metric_calibrated("queue_depth"));
1261    }
1262
1263    #[test]
1264    fn health_threshold_upper_normal_conforming() {
1265        let config = HealthThresholdConfig::new(0.05, ThresholdMode::Upper).min_samples(5);
1266        let mut cal = HealthThresholdCalibrator::new(config);
1267
1268        // Calibrate with queue depths 1..=10.
1269        for i in 1..=10 {
1270            cal.calibrate("queue_depth", f64::from(i));
1271        }
1272        assert!(cal.is_metric_calibrated("queue_depth"));
1273
1274        // A value within the calibration range should be conforming.
1275        let result = cal.check("queue_depth", 5.0).unwrap();
1276        assert!(result.conforming, "normal depth should be conforming");
1277    }
1278
1279    #[test]
1280    fn health_threshold_upper_extreme_anomalous() {
1281        let config = HealthThresholdConfig::new(0.05, ThresholdMode::Upper).min_samples(5);
1282        let mut cal = HealthThresholdCalibrator::new(config);
1283
1284        // Calibrate with small queue depths.
1285        for i in 1..=20 {
1286            cal.calibrate("queue_depth", f64::from(i));
1287        }
1288
1289        // A value far above the calibration range should be anomalous.
1290        let result = cal.check("queue_depth", 1000.0).unwrap();
1291        assert!(
1292            !result.conforming,
1293            "extreme depth should be anomalous, got threshold={:.2}",
1294            result.threshold
1295        );
1296    }
1297
1298    #[test]
1299    fn health_threshold_two_sided_normal_conforming() {
1300        let config = HealthThresholdConfig::new(0.05, ThresholdMode::TwoSided).min_samples(5);
1301        let mut cal = HealthThresholdCalibrator::new(config);
1302
1303        // Calibrate with values centered around 50.
1304        for v in [48.0, 50.0, 52.0, 49.0, 51.0, 50.0, 48.0, 52.0, 49.0, 51.0] {
1305            cal.calibrate("latency", v);
1306        }
1307
1308        // A value near the median should be conforming.
1309        let result = cal.check("latency", 50.0).unwrap();
1310        assert!(result.conforming, "near-median value should be conforming");
1311    }
1312
1313    #[test]
1314    fn health_threshold_two_sided_extreme_anomalous() {
1315        let config = HealthThresholdConfig::new(0.20, ThresholdMode::TwoSided).min_samples(5);
1316        let mut cal = HealthThresholdCalibrator::new(config);
1317
1318        // Calibrate with values centered around 50.
1319        for v in [48.0, 50.0, 52.0, 49.0, 51.0, 50.0, 48.0, 52.0, 49.0, 51.0] {
1320            cal.calibrate("latency", v);
1321        }
1322
1323        // A value far from the median should be anomalous.
1324        let result = cal.check("latency", 500.0).unwrap();
1325        assert!(
1326            !result.conforming,
1327            "far-from-median value should be anomalous"
1328        );
1329    }
1330
1331    #[test]
1332    fn health_threshold_adaptive_grows_with_data() {
1333        let config = HealthThresholdConfig::new(0.20, ThresholdMode::Upper).min_samples(5);
1334        let mut cal = HealthThresholdCalibrator::new(config);
1335
1336        // Phase 1: calibrate with small values.
1337        for i in 1..=10 {
1338            cal.calibrate("metric", f64::from(i));
1339        }
1340        let t1 = cal.threshold("metric").unwrap();
1341
1342        // Phase 2: add larger values.
1343        for i in 11..=20 {
1344            cal.calibrate("metric", f64::from(i));
1345        }
1346        let t2 = cal.threshold("metric").unwrap();
1347
1348        assert!(
1349            t2 >= t1,
1350            "threshold should grow as calibration expands, t1={t1}, t2={t2}"
1351        );
1352    }
1353
1354    #[test]
1355    fn health_threshold_coverage_tracking() {
1356        let config = HealthThresholdConfig::new(0.10, ThresholdMode::Upper).min_samples(5);
1357        let mut cal = HealthThresholdCalibrator::new(config);
1358
1359        for i in 1..=20 {
1360            cal.calibrate("depth", f64::from(i));
1361        }
1362
1363        // Check several normal values.
1364        for i in 1..=10 {
1365            let _ = cal.check_and_track("depth", f64::from(i));
1366        }
1367
1368        let rates = cal.coverage_rates();
1369        let rate = rates.get("depth").copied().unwrap_or(0.0);
1370        assert!(
1371            rate >= 0.8,
1372            "coverage rate for normal data should be high, got {rate:.2}"
1373        );
1374    }
1375
1376    #[test]
1377    fn health_threshold_multiple_metrics() {
1378        let config = HealthThresholdConfig::new(0.05, ThresholdMode::Upper).min_samples(3);
1379        let mut cal = HealthThresholdCalibrator::new(config);
1380
1381        for i in 1..=10 {
1382            cal.calibrate("queue_depth", f64::from(i));
1383            cal.calibrate("restart_rate", f64::from(i) * 0.01);
1384        }
1385
1386        assert!(cal.is_metric_calibrated("queue_depth"));
1387        assert!(cal.is_metric_calibrated("restart_rate"));
1388
1389        let results = cal.check_all(&[("queue_depth", 5.0), ("restart_rate", 0.05)]);
1390        assert_eq!(results.len(), 2);
1391        assert!(results.iter().all(|r| r.conforming));
1392    }
1393
1394    #[test]
1395    fn health_threshold_any_anomalous() {
1396        let config = HealthThresholdConfig::new(0.20, ThresholdMode::Upper).min_samples(3);
1397        let mut cal = HealthThresholdCalibrator::new(config);
1398
1399        for i in 1..=10 {
1400            cal.calibrate("queue_depth", f64::from(i));
1401        }
1402
1403        assert!(!cal.any_anomalous(&[("queue_depth", 5.0)]));
1404        assert!(cal.any_anomalous(&[("queue_depth", 10000.0)]));
1405    }
1406
1407    #[test]
1408    fn health_threshold_display() {
1409        let config = HealthThresholdConfig::new(0.05, ThresholdMode::Upper).min_samples(3);
1410        let mut cal = HealthThresholdCalibrator::new(config);
1411
1412        for i in 1..=10 {
1413            cal.calibrate("queue_depth", f64::from(i));
1414        }
1415
1416        let result = cal.check("queue_depth", 5.0).unwrap();
1417        let display = format!("{result}");
1418        assert!(display.contains("queue_depth"));
1419        assert!(display.contains("OK") || display.contains("ANOMALOUS"));
1420    }
1421
1422    #[test]
1423    fn health_threshold_deterministic() {
1424        let run = || {
1425            let config = HealthThresholdConfig::new(0.05, ThresholdMode::Upper).min_samples(3);
1426            let mut cal = HealthThresholdCalibrator::new(config);
1427            for i in 1..=10 {
1428                cal.calibrate("m", f64::from(i));
1429            }
1430            cal.check("m", 7.5).unwrap()
1431        };
1432
1433        let r1 = run();
1434        let r2 = run();
1435        assert_eq!(r1.threshold, r2.threshold);
1436        assert!((r1.nonconformity_score - r2.nonconformity_score).abs() < f64::EPSILON);
1437        assert_eq!(r1.conforming, r2.conforming);
1438    }
1439
1440    #[test]
1441    fn health_threshold_ignores_non_finite_calibration_values() {
1442        let config = HealthThresholdConfig::new(0.20, ThresholdMode::Upper).min_samples(3);
1443        let mut cal = HealthThresholdCalibrator::new(config);
1444
1445        for i in 1..=10 {
1446            cal.calibrate("metric", f64::from(i));
1447        }
1448        cal.calibrate("metric", f64::NAN);
1449        cal.calibrate("metric", f64::INFINITY);
1450        cal.calibrate("metric", f64::NEG_INFINITY);
1451
1452        let counts = cal.metric_counts();
1453        assert_eq!(counts.get("metric"), Some(&10));
1454        let threshold = cal
1455            .threshold("metric")
1456            .expect("metric should be calibrated");
1457        assert!(threshold.is_finite());
1458    }
1459
1460    #[test]
1461    fn health_threshold_non_finite_check_is_anomalous() {
1462        let config = HealthThresholdConfig::new(0.20, ThresholdMode::Upper).min_samples(3);
1463        let mut cal = HealthThresholdCalibrator::new(config);
1464        for i in 1..=10 {
1465            cal.calibrate("metric", f64::from(i));
1466        }
1467
1468        let result = cal
1469            .check("metric", f64::NAN)
1470            .expect("metric should be calibrated");
1471        assert!(!result.conforming);
1472        assert!(result.nonconformity_score.is_infinite());
1473        assert!(result.threshold.is_finite());
1474    }
1475
1476    #[test]
1477    fn health_threshold_metric_counts() {
1478        let config = HealthThresholdConfig::new(0.05, ThresholdMode::Upper).min_samples(3);
1479        let mut cal = HealthThresholdCalibrator::new(config);
1480
1481        cal.calibrate("a", 1.0);
1482        cal.calibrate("a", 2.0);
1483        cal.calibrate("b", 10.0);
1484
1485        let counts = cal.metric_counts();
1486        assert_eq!(counts.get("a"), Some(&2));
1487        assert_eq!(counts.get("b"), Some(&1));
1488    }
1489
1490    // ========================================================================
1491    // Deterministic observability: conformal coverage diagnostics (bd-npn8e)
1492    // ========================================================================
1493
1494    #[test]
1495    fn obs_conformal_coverage_guarantee_holds() {
1496        // Verify the finite-sample coverage guarantee:
1497        // P(new observation conforming) ≥ 1 - alpha under exchangeability.
1498        let alpha = 0.10;
1499        let config = ConformalConfig::new(alpha).min_samples(10);
1500        let mut cal = ConformalCalibrator::new(config);
1501
1502        // Calibrate with clean reports (10 samples).
1503        for i in 0..10 {
1504            cal.calibrate(&make_clean_report(10 + i, 50 + i * 3));
1505        }
1506
1507        // Predict on 100 clean observations. Coverage should be ≥ (1 - alpha).
1508        let mut conforming_count = 0;
1509        let total = 100;
1510        for _ in 0..total {
1511            if let Some(report) = cal.predict(&make_clean_report(10, 50)) {
1512                if report.prediction_sets.iter().all(|ps| ps.conforming) {
1513                    conforming_count += 1;
1514                }
1515            }
1516        }
1517
1518        let coverage = f64::from(conforming_count) / f64::from(total);
1519        assert!(
1520            coverage >= 1.0 - alpha - 0.05,
1521            "coverage {coverage:.2} should be ≥ {:.2}",
1522            1.0 - alpha - 0.05
1523        );
1524    }
1525
1526    #[test]
1527    fn obs_health_threshold_coverage_guarantee_holds() {
1528        let alpha = 0.10;
1529        let config = HealthThresholdConfig::new(alpha, ThresholdMode::Upper).min_samples(20);
1530        let mut cal = HealthThresholdCalibrator::new(config);
1531
1532        // Calibrate with values 1..=20.
1533        for i in 1..=20 {
1534            cal.calibrate("depth", f64::from(i));
1535        }
1536
1537        // Check 50 values within the calibration range.
1538        let mut conforming = 0;
1539        let total = 50;
1540        for i in 0..total {
1541            let value = f64::from((i % 20) + 1);
1542            if let Some(result) = cal.check("depth", value) {
1543                if result.conforming {
1544                    conforming += 1;
1545                }
1546            }
1547        }
1548
1549        let coverage = f64::from(conforming) / f64::from(total);
1550        assert!(
1551            coverage >= 1.0 - alpha - 0.05,
1552            "health threshold coverage {coverage:.2} should be ≥ {:.2}",
1553            1.0 - alpha - 0.05
1554        );
1555    }
1556
1557    #[test]
1558    fn obs_conformal_anomaly_detection_deterministic() {
1559        // Same calibration + prediction sequence must produce identical results.
1560        let run = || {
1561            let config = ConformalConfig::new(0.05).min_samples(5);
1562            let mut cal = ConformalCalibrator::new(config);
1563
1564            for i in 0..8 {
1565                cal.calibrate(&make_clean_report(10 + i, 50 + i * 3));
1566            }
1567
1568            let clean = cal.predict(&make_clean_report(10, 50)).unwrap();
1569            let anomalous = cal.predict(&make_violated_report(10, 50)).unwrap();
1570            (clean, anomalous)
1571        };
1572
1573        let (c1, a1) = run();
1574        let (c2, a2) = run();
1575
1576        // Clean predictions must be identical.
1577        assert_eq!(c1.prediction_sets.len(), c2.prediction_sets.len());
1578        for (p1, p2) in c1.prediction_sets.iter().zip(c2.prediction_sets.iter()) {
1579            assert!((p1.score - p2.score).abs() < f64::EPSILON);
1580            assert_eq!(p1.threshold, p2.threshold);
1581            assert_eq!(p1.conforming, p2.conforming);
1582        }
1583
1584        // Anomalous predictions must be identical.
1585        assert_eq!(a1.prediction_sets.len(), a2.prediction_sets.len());
1586        for (p1, p2) in a1.prediction_sets.iter().zip(a2.prediction_sets.iter()) {
1587            assert!((p1.score - p2.score).abs() < f64::EPSILON);
1588            assert_eq!(p1.conforming, p2.conforming);
1589        }
1590    }
1591
1592    #[test]
1593    fn obs_conformal_report_well_calibrated_diagnostics() {
1594        let config = ConformalConfig::new(0.05).min_samples(5);
1595        let mut cal = ConformalCalibrator::new(config);
1596
1597        // Calibrate.
1598        for i in 0..10 {
1599            cal.calibrate(&make_clean_report(10 + i, 50 + i * 2));
1600        }
1601
1602        // Predict many clean observations.
1603        let mut last_report = None;
1604        for _ in 0..30 {
1605            last_report = cal.predict(&make_clean_report(10, 50));
1606        }
1607
1608        let report = last_report.unwrap();
1609
1610        // Should be well-calibrated.
1611        assert!(report.is_well_calibrated());
1612        assert!(report.miscalibrated_invariants().is_empty());
1613
1614        // Report text should contain expected fields.
1615        let text = report.to_text();
1616        assert!(text.contains("CONFORMAL CALIBRATION REPORT"));
1617        assert!(text.contains("WELL-CALIBRATED"));
1618
1619        // JSON roundtrip.
1620        let json = report.to_json();
1621        assert!(json["well_calibrated"].as_bool().unwrap());
1622        assert_eq!(json["alpha"], 0.05);
1623    }
1624
1625    #[test]
1626    fn conformal_config_debug_clone_default() {
1627        let c = ConformalConfig::default();
1628        let dbg = format!("{c:?}");
1629        assert!(dbg.contains("ConformalConfig"));
1630
1631        let c2 = c;
1632        assert!((c2.alpha - 0.05).abs() < f64::EPSILON);
1633        assert_eq!(c2.min_calibration_samples, 5);
1634    }
1635
1636    #[test]
1637    #[should_panic(expected = "alpha must be finite and in (0, 1)")]
1638    fn conformal_config_rejects_invalid_alpha() {
1639        let _ = ConformalConfig::new(1.0);
1640    }
1641
1642    #[test]
1643    #[should_panic(expected = "min_calibration_samples must be > 0")]
1644    fn conformal_calibrator_rejects_zero_min_samples() {
1645        let mut cfg = ConformalConfig::new(0.05);
1646        cfg.min_calibration_samples = 0;
1647        let _ = ConformalCalibrator::new(cfg);
1648    }
1649
1650    #[test]
1651    #[should_panic(expected = "min_calibration_samples must be > 0")]
1652    fn conformal_config_builder_rejects_zero_min_samples() {
1653        let _ = ConformalConfig::new(0.05).min_samples(0);
1654    }
1655
1656    #[test]
1657    #[should_panic(expected = "alpha must be finite and in (0, 1)")]
1658    fn health_threshold_config_rejects_invalid_alpha() {
1659        let _ = HealthThresholdConfig::new(0.0, ThresholdMode::Upper);
1660    }
1661
1662    #[test]
1663    #[should_panic(expected = "min_calibration_samples must be > 0")]
1664    fn health_threshold_calibrator_rejects_zero_min_samples() {
1665        let mut cfg = HealthThresholdConfig::new(0.05, ThresholdMode::Upper);
1666        cfg.min_calibration_samples = 0;
1667        let _ = HealthThresholdCalibrator::new(cfg);
1668    }
1669
1670    #[test]
1671    #[should_panic(expected = "min_calibration_samples must be > 0")]
1672    fn health_threshold_config_builder_rejects_zero_min_samples() {
1673        let _ = HealthThresholdConfig::new(0.05, ThresholdMode::Upper).min_samples(0);
1674    }
1675
1676    #[test]
1677    fn conformity_score_debug_clone_copy_eq() {
1678        let s = ConformityScore {
1679            value: 0.42,
1680            violated: false,
1681        };
1682        let dbg = format!("{s:?}");
1683        assert!(dbg.contains("ConformityScore"));
1684
1685        let s2 = s;
1686        assert_eq!(s, s2);
1687
1688        // Copy
1689        let s3 = s;
1690        assert_eq!(s, s3);
1691    }
1692
1693    #[test]
1694    fn threshold_mode_debug_clone_copy_eq() {
1695        let m = ThresholdMode::Upper;
1696        let dbg = format!("{m:?}");
1697        assert!(dbg.contains("Upper"));
1698
1699        let m2 = m;
1700        assert_eq!(m, m2);
1701
1702        let m3 = m;
1703        assert_eq!(m, m3);
1704
1705        assert_ne!(ThresholdMode::Upper, ThresholdMode::TwoSided);
1706    }
1707
1708    #[test]
1709    fn coverage_tracker_debug_clone() {
1710        let t = CoverageTracker {
1711            total: 10,
1712            covered: 9,
1713        };
1714        let dbg = format!("{t:?}");
1715        assert!(dbg.contains("CoverageTracker"));
1716
1717        let t2 = t;
1718        assert_eq!(t2.total, 10);
1719        assert_eq!(t2.covered, 9);
1720    }
1721
1722    // ===================================================================
1723    // br-asupersync-9u4ext: tightened tolerance from 5pp absolute to
1724    // alpha/5 (1pp at the default alpha=0.05).
1725    // ===================================================================
1726
1727    fn report_with(alpha: f64, total: usize, covered: usize) -> CalibrationReport {
1728        CalibrationReport {
1729            prediction_sets: Vec::new(),
1730            coverage: BTreeMap::new(),
1731            overall_coverage: CoverageTracker { total, covered },
1732            alpha,
1733            calibration_samples: total,
1734        }
1735    }
1736
1737    #[test]
1738    fn _9u4ext_tolerance_is_alpha_derived() {
1739        let r = report_with(0.05, 1, 1);
1740        // alpha=0.05 → tolerance = 0.05/5 = 0.01.
1741        assert!((r.calibration_tolerance() - 0.01).abs() < 1e-12);
1742        let r = report_with(0.20, 1, 1);
1743        // alpha=0.20 → tolerance = 0.04.
1744        assert!((r.calibration_tolerance() - 0.04).abs() < 1e-12);
1745    }
1746
1747    #[test]
1748    fn _9u4ext_well_calibrated_strict_at_default_alpha() {
1749        // 90% coverage with alpha=0.05 (95% target) was historically
1750        // accepted (5pp slack). With the tightened tolerance it is
1751        // now rejected — operators get the calibration guarantee
1752        // they actually requested.
1753        let r = report_with(0.05, 100, 90);
1754        assert!(
1755            !r.is_well_calibrated(),
1756            "90% coverage at alpha=0.05 should now be flagged miscalibrated"
1757        );
1758        // 94% coverage is exactly at target - 0.01 = 0.94.
1759        let r = report_with(0.05, 100, 94);
1760        assert!(r.is_well_calibrated(), "94% should sit on the new boundary");
1761    }
1762
1763    #[test]
1764    fn _9u4ext_well_calibrated_target_met() {
1765        // 95% coverage at alpha=0.05 → exactly target, well within.
1766        let r = report_with(0.05, 1000, 950);
1767        assert!(r.is_well_calibrated());
1768    }
1769}