Skip to main content

ftui_runtime/
conformal_alert.rs

1#![forbid(unsafe_code)]
2
3//! Conformal alert threshold calibration with anytime-valid e-process control.
4//!
5//! This module provides change-point detection for action timeline events using:
6//! 1. **Conformal thresholding** - Distribution-free threshold calibration
7//! 2. **E-process layer** - Anytime-valid FPR control via test martingales
8//! 3. **Evidence ledger** - Explainable alert decisions with full provenance
9//!
10//! # Mathematical Model
11//!
12//! ## Conformal Thresholding (Primary)
13//!
14//! Given calibration residuals R = {r_1, ..., r_n}, the conformal threshold is:
15//!
16//! ```text
17//! q = quantile_{(1-alpha)(n+1)/n}(R)
18//! ```
19//!
20//! This is the (n+1) rule: we pretend the new observation is one of (n+1) equally
21//! likely positions, ensuring finite-sample coverage P(r_{n+1} <= q) >= 1-alpha.
22//!
23//! ## E-Process Layer (Anytime-Valid)
24//!
25//! For early stopping without FPR inflation, we maintain an e-process:
26//!
27//! ```text
28//! e_t = exp(lambda * (z_t - mu_0) - lambda^2 * sigma_0^2 / 2)
29//! E_t = prod_{s=1}^{t} e_s
30//! ```
31//!
32//! where z_t = (r_t - mean) / std is the standardized residual. Alert when E_t > 1/alpha.
33//!
34//! # Key Invariants
35//!
36//! 1. **Coverage guarantee**: P(FP) <= alpha under H_0 for conformal threshold
37//! 2. **Anytime-valid**: E_t is a supermartingale, so P(exists t: E_t >= 1/alpha) <= alpha
38//! 3. **Non-negative wealth**: E_t >= 0 always (floored at epsilon)
39//! 4. **Calibration monotonicity**: Threshold is non-decreasing in calibration set size
40//!
41//! # Failure Modes
42//!
43//! | Condition | Behavior | Rationale |
44//! |-----------|----------|-----------|
45//! | n < min_calibration | Use fallback threshold | Insufficient data |
46//! | sigma = 0 | Use epsilon floor | Degenerate data |
47//! | E_t underflow | Floor at E_MIN | Prevent permanent zero-lock |
48//! | All residuals identical | Wide threshold | No variance to detect |
49//!
50//! # Usage
51//!
52//! ```ignore
53//! use ftui_runtime::conformal_alert::{ConformalAlert, AlertConfig};
54//!
55//! let mut alerter = ConformalAlert::new(AlertConfig::default());
56//!
57//! // Calibration phase: feed baseline residuals
58//! for baseline_value in baseline_data {
59//!     alerter.calibrate(baseline_value);
60//! }
61//!
62//! // Detection phase: check new observations
63//! let decision = alerter.observe(new_value);
64//! if decision.is_alert() {
65//!     println!("Alert: {}", decision.evidence_summary());
66//! }
67//! ```
68
69use std::collections::VecDeque;
70
71/// Minimum e-value floor to prevent permanent zero-lock.
72const E_MIN: f64 = 1e-12;
73
74/// Maximum e-value ceiling to prevent overflow to infinity.
75/// This is the inverse of E_MIN for symmetry - if we reach this value,
76/// we're already well above any reasonable alert threshold.
77const E_MAX: f64 = 1e12;
78
79/// Minimum calibration samples before using conformal threshold.
80const MIN_CALIBRATION: usize = 10;
81
82/// Default fallback threshold when calibration is insufficient.
83const FALLBACK_THRESHOLD: f64 = f64::MAX;
84
85/// Epsilon for numerical stability.
86const EPSILON: f64 = 1e-10;
87
88/// Configuration for conformal alert calibration.
89#[derive(Debug, Clone)]
90pub struct AlertConfig {
91    /// Significance level alpha. FPR is controlled at this level.
92    /// Lower alpha = more conservative (fewer false alarms). Default: 0.05.
93    pub alpha: f64,
94
95    /// Minimum calibration samples before using conformal threshold.
96    /// Default: 10.
97    pub min_calibration: usize,
98
99    /// Maximum calibration samples to retain. Default: 500.
100    pub max_calibration: usize,
101
102    /// E-process betting fraction lambda. Default: 0.5.
103    pub lambda: f64,
104
105    /// Null hypothesis mean for standardized residuals (usually 0). Default: 0.0.
106    pub mu_0: f64,
107
108    /// Null hypothesis std for standardized residuals (usually 1). Default: 1.0.
109    pub sigma_0: f64,
110
111    /// Use adaptive lambda via GRAPA. Default: true.
112    pub adaptive_lambda: bool,
113
114    /// GRAPA learning rate. Default: 0.1.
115    pub grapa_eta: f64,
116
117    /// Enable JSONL-compatible logging. Default: false.
118    pub enable_logging: bool,
119
120    /// Hysteresis factor: require E_t > (1/alpha) * hysteresis to alert.
121    /// Prevents alert flicker at boundary. Default: 1.1.
122    pub hysteresis: f64,
123
124    /// Cooldown observations after alert before allowing another.
125    /// Default: 5.
126    pub alert_cooldown: u64,
127}
128
129impl Default for AlertConfig {
130    fn default() -> Self {
131        Self {
132            alpha: 0.05,
133            min_calibration: MIN_CALIBRATION,
134            max_calibration: 500,
135            lambda: 0.5,
136            mu_0: 0.0,
137            sigma_0: 1.0,
138            adaptive_lambda: true,
139            grapa_eta: 0.1,
140            enable_logging: false,
141            hysteresis: 1.1,
142            alert_cooldown: 5,
143        }
144    }
145}
146
147/// Running statistics for calibration using Welford's algorithm.
148#[derive(Debug, Clone)]
149struct CalibrationStats {
150    n: u64,
151    mean: f64,
152    m2: f64, // Sum of squared deviations
153}
154
155impl CalibrationStats {
156    fn new() -> Self {
157        Self {
158            n: 0,
159            mean: 0.0,
160            m2: 0.0,
161        }
162    }
163
164    fn update(&mut self, x: f64) {
165        self.n += 1;
166        let delta = x - self.mean;
167        self.mean += delta / self.n as f64;
168        let delta2 = x - self.mean;
169        self.m2 += delta * delta2;
170    }
171
172    fn variance(&self) -> f64 {
173        if self.n < 2 {
174            return 1.0; // Fallback
175        }
176        (self.m2 / (self.n - 1) as f64).max(EPSILON)
177    }
178
179    fn std(&self) -> f64 {
180        self.variance().sqrt()
181    }
182}
183
184/// Evidence ledger entry for a single observation.
185#[derive(Debug, Clone)]
186pub struct AlertEvidence {
187    /// Observation index.
188    pub observation_idx: u64,
189    /// Raw observation value.
190    pub value: f64,
191    /// Residual (value - calibration_mean).
192    pub residual: f64,
193    /// Standardized residual (z-score).
194    pub z_score: f64,
195    /// Current conformal threshold q.
196    pub conformal_threshold: f64,
197    /// Conformal score (proportion of calibration residuals >= this one).
198    pub conformal_score: f64,
199    /// Current e-value (wealth).
200    pub e_value: f64,
201    /// E-value threshold (1/alpha).
202    pub e_threshold: f64,
203    /// Current lambda (betting fraction).
204    pub lambda: f64,
205    /// Alert triggered by conformal threshold?
206    pub conformal_alert: bool,
207    /// Alert triggered by e-process?
208    pub eprocess_alert: bool,
209    /// Combined alert decision.
210    pub is_alert: bool,
211    /// Reason for alert (or non-alert).
212    pub reason: AlertReason,
213}
214
215impl AlertEvidence {
216    /// Generate a summary string for the evidence.
217    pub fn summary(&self) -> String {
218        format!(
219            "obs={} val={:.2} res={:.2} z={:.2} q={:.2} conf_p={:.3} E={:.2}/{:.2} alert={}",
220            self.observation_idx,
221            self.value,
222            self.residual,
223            self.z_score,
224            self.conformal_threshold,
225            self.conformal_score,
226            self.e_value,
227            self.e_threshold,
228            self.is_alert
229        )
230    }
231}
232
233/// Reason for alert decision.
234#[derive(Debug, Clone, Copy, PartialEq, Eq)]
235pub enum AlertReason {
236    /// No alert: observation within normal bounds.
237    Normal,
238    /// Alert: conformal threshold exceeded.
239    ConformalExceeded,
240    /// Alert: e-process threshold exceeded.
241    EProcessExceeded,
242    /// Alert: both thresholds exceeded.
243    BothExceeded,
244    /// No alert: in cooldown period after recent alert.
245    InCooldown,
246    /// No alert: insufficient calibration data.
247    InsufficientCalibration,
248}
249
250/// Decision returned after observing a new value.
251#[derive(Debug, Clone)]
252pub struct AlertDecision {
253    /// Whether to trigger an alert.
254    pub is_alert: bool,
255    /// Full evidence for this observation.
256    pub evidence: AlertEvidence,
257    /// Observations since last alert.
258    pub observations_since_alert: u64,
259}
260
261impl AlertDecision {
262    /// Summary string for the decision.
263    pub fn evidence_summary(&self) -> String {
264        self.evidence.summary()
265    }
266}
267
268/// Aggregate statistics for the alerter.
269#[derive(Debug, Clone)]
270pub struct AlertStats {
271    /// Total observations processed.
272    pub total_observations: u64,
273    /// Total calibration samples.
274    pub calibration_samples: usize,
275    /// Total alerts triggered.
276    pub total_alerts: u64,
277    /// Conformal-only alerts.
278    pub conformal_alerts: u64,
279    /// E-process-only alerts.
280    pub eprocess_alerts: u64,
281    /// Both-threshold alerts.
282    pub both_alerts: u64,
283    /// Current e-value.
284    pub current_e_value: f64,
285    /// Current conformal threshold.
286    pub current_threshold: f64,
287    /// Current lambda.
288    pub current_lambda: f64,
289    /// Calibration mean.
290    pub calibration_mean: f64,
291    /// Calibration std.
292    pub calibration_std: f64,
293    /// Empirical FPR (alerts / observations under H0 assumption).
294    pub empirical_fpr: f64,
295}
296
297/// Conformal alert threshold calibrator with e-process control.
298#[derive(Debug)]
299pub struct ConformalAlert {
300    config: AlertConfig,
301
302    /// Calibration residuals (sorted for quantile computation).
303    calibration: VecDeque<f64>,
304
305    /// Running calibration statistics.
306    stats: CalibrationStats,
307
308    /// Current e-value (wealth).
309    e_value: f64,
310
311    /// E-value threshold (1/alpha * hysteresis).
312    e_threshold: f64,
313
314    /// Current adaptive lambda.
315    lambda: f64,
316
317    /// Total observation count.
318    observation_count: u64,
319
320    /// Observations since last alert.
321    observations_since_alert: u64,
322
323    /// In cooldown period.
324    in_cooldown: bool,
325
326    /// Total alerts.
327    total_alerts: u64,
328
329    /// Alert type counters.
330    conformal_alerts: u64,
331    eprocess_alerts: u64,
332    both_alerts: u64,
333
334    /// Evidence log (if logging enabled).
335    logs: Vec<AlertEvidence>,
336}
337
338impl ConformalAlert {
339    /// Create a new conformal alerter with given configuration.
340    pub fn new(config: AlertConfig) -> Self {
341        let e_threshold = (1.0 / config.alpha) * config.hysteresis;
342        let lambda = config.lambda.clamp(EPSILON, 1.0 - EPSILON);
343
344        Self {
345            config,
346            calibration: VecDeque::new(),
347            stats: CalibrationStats::new(),
348            e_value: 1.0,
349            e_threshold,
350            lambda,
351            observation_count: 0,
352            observations_since_alert: 0,
353            in_cooldown: false,
354            total_alerts: 0,
355            conformal_alerts: 0,
356            eprocess_alerts: 0,
357            both_alerts: 0,
358            logs: Vec::new(),
359        }
360    }
361
362    /// Add a calibration sample.
363    ///
364    /// Call this during the baseline/training phase to build the null distribution.
365    pub fn calibrate(&mut self, value: f64) {
366        self.stats.update(value);
367
368        // Store residual for quantile computation
369        let residual = (value - self.stats.mean).abs();
370        self.calibration.push_back(residual);
371
372        // Enforce max calibration size
373        while self.calibration.len() > self.config.max_calibration {
374            self.calibration.pop_front();
375        }
376    }
377
378    /// Observe a new value and return alert decision with evidence.
379    pub fn observe(&mut self, value: f64) -> AlertDecision {
380        self.observation_count += 1;
381        self.observations_since_alert += 1;
382
383        // Check cooldown
384        if self.in_cooldown && self.observations_since_alert <= self.config.alert_cooldown {
385            return self.no_alert_decision(value, AlertReason::InCooldown);
386        }
387        self.in_cooldown = false;
388
389        // Check calibration sufficiency
390        if self.calibration.len() < self.config.min_calibration {
391            return self.no_alert_decision(value, AlertReason::InsufficientCalibration);
392        }
393
394        // Compute residual and z-score
395        let residual = value - self.stats.mean;
396        let abs_residual = residual.abs();
397        let z_score = residual / self.stats.std().max(EPSILON);
398
399        // Conformal threshold using (n+1) rule
400        let conformal_threshold = self.compute_conformal_threshold();
401        let conformal_score = self.compute_conformal_score(abs_residual);
402        let conformal_alert = abs_residual > conformal_threshold;
403
404        // E-process update
405        let z_centered = z_score - self.config.mu_0;
406        let exponent =
407            self.lambda * z_centered - (self.lambda.powi(2) * self.config.sigma_0.powi(2)) / 2.0;
408        // Clamp exponent to prevent exp() overflow to infinity (exp(709) ≈ 8.2e307)
409        let e_factor = exponent.clamp(-700.0, 700.0).exp();
410        self.e_value = (self.e_value * e_factor).clamp(E_MIN, E_MAX);
411
412        let eprocess_alert = self.e_value > self.e_threshold;
413
414        // Adaptive lambda update (GRAPA)
415        if self.config.adaptive_lambda {
416            let denominator = 1.0 + self.lambda * z_centered;
417            if denominator.abs() > EPSILON {
418                let grad = z_centered / denominator;
419                self.lambda =
420                    (self.lambda + self.config.grapa_eta * grad).clamp(EPSILON, 1.0 - EPSILON);
421            }
422        }
423
424        // Combined decision
425        let is_alert = conformal_alert || eprocess_alert;
426        let reason = match (conformal_alert, eprocess_alert) {
427            (true, true) => AlertReason::BothExceeded,
428            (true, false) => AlertReason::ConformalExceeded,
429            (false, true) => AlertReason::EProcessExceeded,
430            (false, false) => AlertReason::Normal,
431        };
432
433        // Build evidence
434        let evidence = AlertEvidence {
435            observation_idx: self.observation_count,
436            value,
437            residual,
438            z_score,
439            conformal_threshold,
440            conformal_score,
441            e_value: self.e_value,
442            e_threshold: self.e_threshold,
443            lambda: self.lambda,
444            conformal_alert,
445            eprocess_alert,
446            is_alert,
447            reason,
448        };
449
450        // Log if enabled
451        if self.config.enable_logging {
452            self.logs.push(evidence.clone());
453        }
454
455        // Update alert stats
456        if is_alert {
457            self.total_alerts += 1;
458            match reason {
459                AlertReason::ConformalExceeded => self.conformal_alerts += 1,
460                AlertReason::EProcessExceeded => self.eprocess_alerts += 1,
461                AlertReason::BothExceeded => self.both_alerts += 1,
462                _ => {}
463            }
464            self.observations_since_alert = 0;
465            self.in_cooldown = true;
466            // Reset e-value after alert
467            self.e_value = 1.0;
468        }
469
470        AlertDecision {
471            is_alert,
472            evidence,
473            observations_since_alert: self.observations_since_alert,
474        }
475    }
476
477    /// Compute the conformal threshold using (n+1) rule.
478    ///
479    /// Returns the (1-alpha) quantile of calibration residuals, adjusted
480    /// for finite sample coverage.
481    fn compute_conformal_threshold(&self) -> f64 {
482        if self.calibration.is_empty() {
483            return FALLBACK_THRESHOLD;
484        }
485
486        let n = self.calibration.len();
487        let alpha = self.config.alpha;
488
489        // (n+1) rule: index = ceil((1-alpha) * (n+1)) - 1
490        let target = (1.0 - alpha) * (n + 1) as f64;
491        let idx = (target.ceil() as usize).saturating_sub(1).min(n - 1);
492
493        // Sort calibration for quantile
494        let mut sorted: Vec<f64> = self.calibration.iter().copied().collect();
495        sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
496
497        sorted[idx]
498    }
499
500    /// Compute conformal p-value (proportion of calibration >= this residual).
501    fn compute_conformal_score(&self, abs_residual: f64) -> f64 {
502        if self.calibration.is_empty() {
503            return 1.0;
504        }
505
506        let n = self.calibration.len();
507        let count_geq = self
508            .calibration
509            .iter()
510            .filter(|&&r| r >= abs_residual)
511            .count();
512
513        // (n+1) rule: (count + 1) / (n + 1)
514        (count_geq + 1) as f64 / (n + 1) as f64
515    }
516
517    /// Helper to create a no-alert decision with given reason.
518    fn no_alert_decision(&self, value: f64, reason: AlertReason) -> AlertDecision {
519        let evidence = AlertEvidence {
520            observation_idx: self.observation_count,
521            value,
522            residual: 0.0,
523            z_score: 0.0,
524            conformal_threshold: FALLBACK_THRESHOLD,
525            conformal_score: 1.0,
526            e_value: self.e_value,
527            e_threshold: self.e_threshold,
528            lambda: self.lambda,
529            conformal_alert: false,
530            eprocess_alert: false,
531            is_alert: false,
532            reason,
533        };
534
535        AlertDecision {
536            is_alert: false,
537            evidence,
538            observations_since_alert: self.observations_since_alert,
539        }
540    }
541
542    /// Reset the e-process state (but keep calibration).
543    pub fn reset_eprocess(&mut self) {
544        self.e_value = 1.0;
545        self.observations_since_alert = 0;
546        self.in_cooldown = false;
547    }
548
549    /// Clear calibration data.
550    pub fn clear_calibration(&mut self) {
551        self.calibration.clear();
552        self.stats = CalibrationStats::new();
553        self.reset_eprocess();
554    }
555
556    /// Get current statistics.
557    pub fn stats(&self) -> AlertStats {
558        let empirical_fpr = if self.observation_count > 0 {
559            self.total_alerts as f64 / self.observation_count as f64
560        } else {
561            0.0
562        };
563
564        AlertStats {
565            total_observations: self.observation_count,
566            calibration_samples: self.calibration.len(),
567            total_alerts: self.total_alerts,
568            conformal_alerts: self.conformal_alerts,
569            eprocess_alerts: self.eprocess_alerts,
570            both_alerts: self.both_alerts,
571            current_e_value: self.e_value,
572            current_threshold: self.compute_conformal_threshold(),
573            current_lambda: self.lambda,
574            calibration_mean: self.stats.mean,
575            calibration_std: self.stats.std(),
576            empirical_fpr,
577        }
578    }
579
580    /// Get evidence logs (if logging enabled).
581    pub fn logs(&self) -> &[AlertEvidence] {
582        &self.logs
583    }
584
585    /// Clear evidence logs.
586    pub fn clear_logs(&mut self) {
587        self.logs.clear();
588    }
589
590    /// Current e-value.
591    #[inline]
592    pub fn e_value(&self) -> f64 {
593        self.e_value
594    }
595
596    /// Current conformal threshold.
597    pub fn threshold(&self) -> f64 {
598        self.compute_conformal_threshold()
599    }
600
601    /// Calibration mean.
602    #[inline]
603    pub fn mean(&self) -> f64 {
604        self.stats.mean
605    }
606
607    /// Calibration std.
608    #[inline]
609    pub fn std(&self) -> f64 {
610        self.stats.std()
611    }
612
613    /// Number of calibration samples.
614    #[inline]
615    pub fn calibration_count(&self) -> usize {
616        self.calibration.len()
617    }
618
619    /// Alpha (significance level).
620    #[inline]
621    pub fn alpha(&self) -> f64 {
622        self.config.alpha
623    }
624}
625
626// =============================================================================
627// Unit Tests (bd-1rzr)
628// =============================================================================
629
630#[cfg(test)]
631mod tests {
632    use super::*;
633
634    fn test_config() -> AlertConfig {
635        AlertConfig {
636            alpha: 0.05,
637            min_calibration: 5,
638            max_calibration: 100,
639            lambda: 0.5,
640            mu_0: 0.0,
641            sigma_0: 1.0,
642            adaptive_lambda: false, // Fixed for deterministic tests
643            grapa_eta: 0.1,
644            enable_logging: true,
645            hysteresis: 1.0,
646            alert_cooldown: 0,
647        }
648    }
649
650    // =========================================================================
651    // Basic construction and invariants
652    // =========================================================================
653
654    #[test]
655    fn initial_state() {
656        let alerter = ConformalAlert::new(test_config());
657        assert!((alerter.e_value() - 1.0).abs() < f64::EPSILON);
658        assert_eq!(alerter.calibration_count(), 0);
659        assert!((alerter.mean() - 0.0).abs() < f64::EPSILON);
660    }
661
662    #[test]
663    fn calibration_updates_stats() {
664        let mut alerter = ConformalAlert::new(test_config());
665
666        alerter.calibrate(10.0);
667        alerter.calibrate(20.0);
668        alerter.calibrate(30.0);
669
670        assert_eq!(alerter.calibration_count(), 3);
671        assert!((alerter.mean() - 20.0).abs() < f64::EPSILON);
672    }
673
674    #[test]
675    fn calibration_window_enforced() {
676        let mut config = test_config();
677        config.max_calibration = 5;
678        let mut alerter = ConformalAlert::new(config);
679
680        for i in 1..=10 {
681            alerter.calibrate(i as f64);
682        }
683
684        assert_eq!(alerter.calibration_count(), 5);
685    }
686
687    // =========================================================================
688    // Conformal threshold tests
689    // =========================================================================
690
691    #[test]
692    fn conformal_threshold_increases_with_calibration() {
693        let mut alerter = ConformalAlert::new(test_config());
694
695        // Calibrate with increasing residuals
696        for i in 1..=20 {
697            alerter.calibrate(i as f64);
698        }
699
700        let threshold = alerter.threshold();
701        assert!(threshold > 0.0, "Threshold should be positive");
702        assert!(threshold < f64::MAX, "Threshold should be finite");
703    }
704
705    #[test]
706    fn conformal_threshold_n_plus_1_rule() {
707        let mut config = test_config();
708        config.alpha = 0.1; // 90% coverage
709        config.min_calibration = 3;
710        let mut alerter = ConformalAlert::new(config);
711
712        // Note: residuals are computed as |value - current_mean| at calibration time.
713        // With evolving mean, residuals don't directly correspond to absolute deviations
714        // from the final mean. The key property is that threshold is computed correctly
715        // from whatever residuals are stored.
716        for v in [50.0, 60.0, 70.0, 40.0, 30.0] {
717            alerter.calibrate(v);
718        }
719
720        // With n=5, alpha=0.1: idx = ceil(0.9 * 6) - 1 = 5 - 1 = 4
721        let threshold = alerter.threshold();
722        // Threshold should be reasonable (non-negative and finite)
723        assert!(threshold >= 0.0, "Threshold should be non-negative");
724        assert!(threshold < f64::MAX, "Threshold should be finite");
725    }
726
727    #[test]
728    fn conformal_score_correct() {
729        let mut alerter = ConformalAlert::new(test_config());
730
731        // Calibrate with known residuals (centered at 100)
732        for v in [100.0, 110.0, 120.0, 130.0, 140.0] {
733            alerter.calibrate(v);
734        }
735
736        // Mean is ~120, so residuals are: 20, 10, 0, 10, 20
737        // Sorted: [0, 10, 10, 20, 20]
738
739        // Score for residual=0: (5+1)/(5+1) = 1.0
740        let score_low = alerter.compute_conformal_score(0.0);
741        assert!(score_low > 0.8);
742
743        // Score for residual=100: (0+1)/(5+1) = 1/6
744        let score_high = alerter.compute_conformal_score(100.0);
745        assert!(score_high < 0.3);
746    }
747
748    // =========================================================================
749    // E-process tests
750    // =========================================================================
751
752    #[test]
753    fn evalue_grows_on_extreme_observation() {
754        let mut config = test_config();
755        config.hysteresis = 1e10; // Very high threshold so we don't trigger alert
756        let mut alerter = ConformalAlert::new(config);
757
758        // Calibrate with low variance data around 50
759        for v in [49.0, 50.0, 51.0, 50.0, 49.5, 50.5] {
760            alerter.calibrate(v);
761        }
762
763        let e_before = alerter.e_value();
764
765        // Observe extreme value (many std devs away)
766        let decision = alerter.observe(100.0);
767
768        // E-value from evidence should show growth
769        // Note: if alert triggers, e_value resets to 1.0 after
770        // So check the evidence e_value instead
771        assert!(
772            decision.evidence.e_value > e_before,
773            "E-value should grow on extreme observation: {} vs {}",
774            decision.evidence.e_value,
775            e_before
776        );
777    }
778
779    #[test]
780    fn evalue_shrinks_on_normal_observation() {
781        let mut config = test_config();
782        config.mu_0 = 0.0;
783        config.sigma_0 = 1.0;
784        let mut alerter = ConformalAlert::new(config);
785
786        // Calibrate with data around 50
787        for v in [48.0, 49.0, 50.0, 51.0, 52.0] {
788            alerter.calibrate(v);
789        }
790
791        let e_before = alerter.e_value();
792
793        // Observe normal value (close to mean)
794        let _ = alerter.observe(50.0);
795
796        // E-value should shrink or stay similar
797        assert!(
798            alerter.e_value() <= e_before * 2.0,
799            "E-value should not explode on normal observation"
800        );
801    }
802
803    #[test]
804    fn evalue_stays_positive() {
805        let mut alerter = ConformalAlert::new(test_config());
806
807        for v in [45.0, 50.0, 55.0, 50.0, 45.0, 55.0] {
808            alerter.calibrate(v);
809        }
810
811        // Many normal observations
812        for _ in 0..100 {
813            let _ = alerter.observe(50.0);
814            assert!(alerter.e_value() > 0.0, "E-value must stay positive");
815        }
816    }
817
818    #[test]
819    fn evalue_resets_after_alert() {
820        let mut config = test_config();
821        config.alert_cooldown = 0;
822        config.hysteresis = 0.5; // Easy trigger
823        let mut alerter = ConformalAlert::new(config);
824
825        for v in [49.0, 50.0, 51.0, 50.0, 49.5] {
826            alerter.calibrate(v);
827        }
828
829        // Drive to alert with extreme values
830        for _ in 0..50 {
831            let decision = alerter.observe(200.0);
832            if decision.is_alert {
833                // E-value should reset to 1.0 after alert
834                assert!(
835                    (alerter.e_value() - 1.0).abs() < 0.01,
836                    "E-value should reset after alert, got {}",
837                    alerter.e_value()
838                );
839                return;
840            }
841        }
842        // Should have triggered by now
843        assert!(
844            alerter.stats().total_alerts > 0,
845            "Should have triggered alert"
846        );
847    }
848
849    // =========================================================================
850    // Alert triggering tests
851    // =========================================================================
852
853    #[test]
854    fn extreme_value_triggers_conformal_alert() {
855        let mut config = test_config();
856        config.alert_cooldown = 0;
857        let mut alerter = ConformalAlert::new(config);
858
859        // Calibrate with tight distribution
860        for v in [50.0, 50.1, 49.9, 50.0, 49.8, 50.2] {
861            alerter.calibrate(v);
862        }
863
864        // Observe extreme value
865        let decision = alerter.observe(100.0);
866        assert!(
867            decision.evidence.conformal_alert,
868            "Extreme value should trigger conformal alert"
869        );
870    }
871
872    #[test]
873    fn normal_value_no_alert() {
874        let mut alerter = ConformalAlert::new(test_config());
875
876        for v in [45.0, 50.0, 55.0, 45.0, 55.0, 50.0] {
877            alerter.calibrate(v);
878        }
879
880        // Normal observation
881        let decision = alerter.observe(48.0);
882        assert!(!decision.is_alert, "Normal value should not trigger alert");
883    }
884
885    #[test]
886    fn insufficient_calibration_no_alert() {
887        let config = test_config(); // min_calibration = 5
888        let mut alerter = ConformalAlert::new(config);
889
890        alerter.calibrate(50.0);
891        alerter.calibrate(51.0);
892        // Only 2 samples, need 5
893
894        let decision = alerter.observe(1000.0); // Extreme value
895        assert!(
896            !decision.is_alert,
897            "Should not alert with insufficient calibration"
898        );
899        assert_eq!(
900            decision.evidence.reason,
901            AlertReason::InsufficientCalibration
902        );
903    }
904
905    #[test]
906    fn cooldown_prevents_rapid_alerts() {
907        let mut config = test_config();
908        config.alert_cooldown = 5;
909        config.hysteresis = 0.1; // Easy trigger
910        let mut alerter = ConformalAlert::new(config);
911
912        for v in [50.0, 50.0, 50.0, 50.0, 50.0] {
913            alerter.calibrate(v);
914        }
915
916        // Trigger first alert
917        let mut first_alert_obs = 0;
918        for i in 1..=10 {
919            let decision = alerter.observe(200.0);
920            if decision.is_alert {
921                first_alert_obs = i;
922                break;
923            }
924        }
925        assert!(first_alert_obs > 0, "Should trigger first alert");
926
927        // Next few should be cooldown
928        for _ in 0..3 {
929            let decision = alerter.observe(200.0);
930            if decision.evidence.reason == AlertReason::InCooldown {
931                return; // Test passed
932            }
933        }
934        // If we reach here, cooldown might have expired
935    }
936
937    // =========================================================================
938    // Evidence ledger tests
939    // =========================================================================
940
941    #[test]
942    fn evidence_contains_all_fields() {
943        let mut alerter = ConformalAlert::new(test_config());
944
945        // Use values with variance so residuals and threshold are positive
946        for v in [45.0, 50.0, 55.0, 48.0, 52.0] {
947            alerter.calibrate(v);
948        }
949
950        let decision = alerter.observe(75.0);
951        let ev = &decision.evidence;
952
953        assert_eq!(ev.observation_idx, 1);
954        assert!((ev.value - 75.0).abs() < f64::EPSILON);
955        assert!(ev.residual.abs() > 0.0 || ev.z_score.abs() > 0.0);
956        // Threshold is non-negative (can be 0 for identical calibration data)
957        assert!(ev.conformal_threshold >= 0.0);
958        assert!(ev.conformal_score > 0.0 && ev.conformal_score <= 1.0);
959        assert!(ev.e_value > 0.0);
960        assert!(ev.e_threshold > 0.0);
961        assert!(ev.lambda > 0.0);
962    }
963
964    #[test]
965    fn logs_captured_when_enabled() {
966        let mut config = test_config();
967        config.enable_logging = true;
968        let mut alerter = ConformalAlert::new(config);
969
970        for v in [50.0, 50.0, 50.0, 50.0, 50.0] {
971            alerter.calibrate(v);
972        }
973
974        alerter.observe(60.0);
975        alerter.observe(70.0);
976        alerter.observe(80.0);
977
978        assert_eq!(alerter.logs().len(), 3);
979        assert_eq!(alerter.logs()[0].observation_idx, 1);
980        assert_eq!(alerter.logs()[2].observation_idx, 3);
981
982        alerter.clear_logs();
983        assert!(alerter.logs().is_empty());
984    }
985
986    #[test]
987    fn logs_not_captured_when_disabled() {
988        let mut config = test_config();
989        config.enable_logging = false;
990        let mut alerter = ConformalAlert::new(config);
991
992        for v in [50.0, 50.0, 50.0, 50.0, 50.0] {
993            alerter.calibrate(v);
994        }
995
996        alerter.observe(60.0);
997        assert!(alerter.logs().is_empty());
998    }
999
1000    // =========================================================================
1001    // Statistics tests
1002    // =========================================================================
1003
1004    #[test]
1005    fn stats_reflect_state() {
1006        let mut config = test_config();
1007        config.alert_cooldown = 0;
1008        config.hysteresis = 0.1;
1009        let mut alerter = ConformalAlert::new(config);
1010
1011        // Use values with variance for realistic calibration
1012        for v in [45.0, 50.0, 55.0, 48.0, 52.0] {
1013            alerter.calibrate(v);
1014        }
1015
1016        // Some normal observations
1017        for _ in 0..5 {
1018            alerter.observe(50.0);
1019        }
1020
1021        // Some extreme observations
1022        for _ in 0..5 {
1023            alerter.observe(200.0);
1024        }
1025
1026        let stats = alerter.stats();
1027        assert_eq!(stats.total_observations, 10);
1028        assert_eq!(stats.calibration_samples, 5);
1029        assert!(stats.calibration_mean > 0.0);
1030        assert!(stats.calibration_std >= 0.0);
1031        // Threshold is non-negative (can be 0 for identical data)
1032        assert!(stats.current_threshold >= 0.0);
1033    }
1034
1035    // =========================================================================
1036    // FPR control property tests
1037    // =========================================================================
1038
1039    #[test]
1040    fn property_fpr_controlled_under_null() {
1041        // Under H0 (observations from same distribution as calibration),
1042        // the FPR should be approximately <= alpha.
1043        let mut config = test_config();
1044        config.alpha = 0.10;
1045        config.alert_cooldown = 0;
1046        config.hysteresis = 1.0;
1047        config.adaptive_lambda = false;
1048        let mut alerter = ConformalAlert::new(config);
1049
1050        // LCG for deterministic pseudo-random
1051        let mut rng_state: u64 = 12345;
1052        let lcg_next = |state: &mut u64| -> f64 {
1053            *state = state
1054                .wrapping_mul(6364136223846793005)
1055                .wrapping_add(1442695040888963407);
1056            // Map to roughly N(50, 5)
1057            let u = (*state >> 33) as f64 / (1u64 << 31) as f64;
1058            50.0 + (u - 0.5) * 10.0
1059        };
1060
1061        // Calibration
1062        for _ in 0..100 {
1063            alerter.calibrate(lcg_next(&mut rng_state));
1064        }
1065
1066        // Observation under H0
1067        let n_obs = 500;
1068        let mut alerts = 0;
1069        for _ in 0..n_obs {
1070            let decision = alerter.observe(lcg_next(&mut rng_state));
1071            if decision.is_alert {
1072                alerts += 1;
1073            }
1074        }
1075
1076        let empirical_fpr = alerts as f64 / n_obs as f64;
1077        // Allow 3x slack for finite sample
1078        assert!(
1079            empirical_fpr < alerter.alpha() * 3.0 + 0.05,
1080            "Empirical FPR {} should be <= 3*alpha + slack",
1081            empirical_fpr
1082        );
1083    }
1084
1085    #[test]
1086    fn property_conformal_threshold_monotonic() {
1087        // The (1-alpha) quantile should increase with calibration set size
1088        // (more data = better estimate of tail, but also more extreme values seen)
1089        let mut alerter = ConformalAlert::new(test_config());
1090
1091        let mut rng_state: u64 = 54321;
1092        let lcg_next = |state: &mut u64| -> f64 {
1093            *state = state
1094                .wrapping_mul(6364136223846793005)
1095                .wrapping_add(1442695040888963407);
1096            50.0 + ((*state >> 33) as f64 / (1u64 << 31) as f64 - 0.5) * 20.0
1097        };
1098
1099        let mut thresholds = Vec::new();
1100        for _ in 0..50 {
1101            alerter.calibrate(lcg_next(&mut rng_state));
1102            if alerter.calibration_count() >= alerter.config.min_calibration {
1103                thresholds.push(alerter.threshold());
1104            }
1105        }
1106
1107        // Not strictly monotonic due to sampling, but should be bounded
1108        assert!(!thresholds.is_empty());
1109        let max_threshold = *thresholds
1110            .iter()
1111            .max_by(|a, b| a.partial_cmp(b).unwrap())
1112            .unwrap();
1113        let min_threshold = *thresholds
1114            .iter()
1115            .min_by(|a, b| a.partial_cmp(b).unwrap())
1116            .unwrap();
1117        assert!(
1118            max_threshold < min_threshold * 10.0,
1119            "Thresholds should be reasonably stable"
1120        );
1121    }
1122
1123    // =========================================================================
1124    // Determinism tests
1125    // =========================================================================
1126
1127    #[test]
1128    fn deterministic_behavior() {
1129        let config = test_config();
1130
1131        let run = |config: &AlertConfig| {
1132            let mut alerter = ConformalAlert::new(config.clone());
1133            for v in [50.0, 51.0, 49.0, 52.0, 48.0] {
1134                alerter.calibrate(v);
1135            }
1136            let mut decisions = Vec::new();
1137            for v in [55.0, 45.0, 100.0, 50.0] {
1138                decisions.push(alerter.observe(v).is_alert);
1139            }
1140            (decisions, alerter.e_value(), alerter.threshold())
1141        };
1142
1143        let (d1, e1, t1) = run(&config);
1144        let (d2, e2, t2) = run(&config);
1145
1146        assert_eq!(d1, d2, "Decisions must be deterministic");
1147        assert!((e1 - e2).abs() < 1e-10, "E-value must be deterministic");
1148        assert!((t1 - t2).abs() < 1e-10, "Threshold must be deterministic");
1149    }
1150
1151    // =========================================================================
1152    // Edge cases
1153    // =========================================================================
1154
1155    #[test]
1156    fn empty_calibration() {
1157        let alerter = ConformalAlert::new(test_config());
1158        let threshold = alerter.threshold();
1159        assert_eq!(threshold, FALLBACK_THRESHOLD);
1160    }
1161
1162    #[test]
1163    fn single_calibration_value() {
1164        let mut alerter = ConformalAlert::new(test_config());
1165        alerter.calibrate(50.0);
1166
1167        // With single sample, mean=50, residual=0, so threshold=0
1168        // This is expected behavior
1169        let threshold = alerter.threshold();
1170        assert!(threshold >= 0.0, "Threshold should be non-negative");
1171        assert!(threshold < f64::MAX, "Should not be fallback");
1172    }
1173
1174    #[test]
1175    fn all_same_calibration() {
1176        let mut alerter = ConformalAlert::new(test_config());
1177        for _ in 0..10 {
1178            alerter.calibrate(50.0);
1179        }
1180
1181        // Std should be 0 (or epsilon)
1182        assert!(alerter.std() < 0.1);
1183
1184        // Any deviation should alert
1185        let decision = alerter.observe(51.0);
1186        assert!(
1187            decision.evidence.conformal_alert,
1188            "Any deviation from constant calibration should alert"
1189        );
1190    }
1191
1192    #[test]
1193    fn reset_clears_eprocess() {
1194        let mut config = test_config();
1195        config.hysteresis = 1e10; // Prevent alert from triggering
1196        let mut alerter = ConformalAlert::new(config);
1197
1198        // Use values with variance
1199        for v in [45.0, 50.0, 55.0, 48.0, 52.0] {
1200            alerter.calibrate(v);
1201        }
1202
1203        // Drive e-value up with extreme observation
1204        let decision = alerter.observe(200.0);
1205        // Check the evidence e_value, not the final e_value (which may reset on alert)
1206        assert!(
1207            decision.evidence.e_value > 1.0,
1208            "E-value in evidence should be > 1.0: {}",
1209            decision.evidence.e_value
1210        );
1211
1212        alerter.reset_eprocess();
1213        assert!((alerter.e_value() - 1.0).abs() < f64::EPSILON);
1214    }
1215
1216    #[test]
1217    fn clear_calibration_resets_all() {
1218        let mut alerter = ConformalAlert::new(test_config());
1219
1220        for v in [50.0, 50.0, 50.0, 50.0, 50.0] {
1221            alerter.calibrate(v);
1222        }
1223        alerter.observe(75.0);
1224
1225        alerter.clear_calibration();
1226        assert_eq!(alerter.calibration_count(), 0);
1227        assert!((alerter.mean() - 0.0).abs() < f64::EPSILON);
1228        assert!((alerter.e_value() - 1.0).abs() < f64::EPSILON);
1229    }
1230
1231    #[test]
1232    fn evidence_summary_format() {
1233        let mut alerter = ConformalAlert::new(test_config());
1234
1235        for v in [50.0, 50.0, 50.0, 50.0, 50.0] {
1236            alerter.calibrate(v);
1237        }
1238
1239        let decision = alerter.observe(75.0);
1240        let summary = decision.evidence_summary();
1241
1242        assert!(summary.contains("obs="));
1243        assert!(summary.contains("val="));
1244        assert!(summary.contains("res="));
1245        assert!(summary.contains("E="));
1246        assert!(summary.contains("alert="));
1247    }
1248
1249    #[test]
1250    fn evalue_ceiling_prevents_overflow() {
1251        // Test that extremely large z-scores don't cause e-value to overflow to infinity
1252        let mut config = test_config();
1253        config.hysteresis = f64::MAX; // Prevent alerts from resetting e-value
1254        config.alert_cooldown = 0;
1255        let mut alerter = ConformalAlert::new(config);
1256
1257        // Calibrate with tight distribution around 0
1258        for _ in 0..10 {
1259            alerter.calibrate(0.0);
1260        }
1261
1262        // Observe astronomically large value that would cause overflow without ceiling
1263        // Without the fix, exp(lambda * z_score) would be infinity
1264        let decision = alerter.observe(1e100);
1265
1266        // E-value should be capped at E_MAX (1e12), not infinity
1267        assert!(
1268            decision.evidence.e_value.is_finite(),
1269            "E-value should be finite, got {}",
1270            decision.evidence.e_value
1271        );
1272        assert!(
1273            decision.evidence.e_value <= E_MAX,
1274            "E-value {} should be <= E_MAX {}",
1275            decision.evidence.e_value,
1276            E_MAX
1277        );
1278        assert!(
1279            decision.evidence.e_value > 0.0,
1280            "E-value should be positive"
1281        );
1282    }
1283
1284    #[test]
1285    fn evalue_floor_prevents_underflow() {
1286        // Test that extremely negative z-scores don't cause e-value to underflow to zero
1287        let mut config = test_config();
1288        config.hysteresis = f64::MAX;
1289        let mut alerter = ConformalAlert::new(config);
1290
1291        // Calibrate with values around a large number
1292        for _ in 0..10 {
1293            alerter.calibrate(1e100);
1294        }
1295
1296        // Observe zero - this creates a massive negative z-score
1297        let decision = alerter.observe(0.0);
1298
1299        // E-value should be floored at E_MIN, not zero or subnormal
1300        assert!(
1301            decision.evidence.e_value >= E_MIN,
1302            "E-value {} should be >= E_MIN {}",
1303            decision.evidence.e_value,
1304            E_MIN
1305        );
1306        assert!(
1307            decision.evidence.e_value.is_finite(),
1308            "E-value should be finite"
1309        );
1310    }
1311
1312    // =========================================================================
1313    // Edge-case tests (bd-1uzxz)
1314    // =========================================================================
1315
1316    #[test]
1317    fn edge_observe_nan() {
1318        let mut alerter = ConformalAlert::new(test_config());
1319        for v in [50.0, 51.0, 49.0, 50.0, 50.0] {
1320            alerter.calibrate(v);
1321        }
1322        // NaN observation should not panic
1323        let decision = alerter.observe(f64::NAN);
1324        // NaN comparisons are always false, so conformal_alert should be false
1325        assert!(!decision.evidence.conformal_alert);
1326        assert_eq!(alerter.stats().total_observations, 1);
1327    }
1328
1329    #[test]
1330    fn edge_observe_infinity() {
1331        let mut alerter = ConformalAlert::new(test_config());
1332        for v in [50.0, 51.0, 49.0, 50.0, 50.0] {
1333            alerter.calibrate(v);
1334        }
1335        let decision = alerter.observe(f64::INFINITY);
1336        // Infinite residual should trigger conformal alert
1337        assert!(decision.evidence.conformal_alert);
1338        // E-value should be clamped, not Inf
1339        assert!(decision.evidence.e_value.is_finite() || decision.evidence.e_value <= E_MAX);
1340    }
1341
1342    #[test]
1343    fn edge_observe_neg_infinity() {
1344        let mut alerter = ConformalAlert::new(test_config());
1345        for v in [50.0, 51.0, 49.0, 50.0, 50.0] {
1346            alerter.calibrate(v);
1347        }
1348        let decision = alerter.observe(f64::NEG_INFINITY);
1349        // Negative infinite residual should trigger conformal alert
1350        assert!(decision.evidence.conformal_alert);
1351    }
1352
1353    #[test]
1354    fn edge_calibrate_nan() {
1355        let mut alerter = ConformalAlert::new(test_config());
1356        // NaN calibration should not panic
1357        alerter.calibrate(f64::NAN);
1358        assert_eq!(alerter.calibration_count(), 1);
1359        // Mean will be NaN, which is handled gracefully
1360    }
1361
1362    #[test]
1363    fn edge_calibrate_infinity() {
1364        let mut alerter = ConformalAlert::new(test_config());
1365        alerter.calibrate(f64::INFINITY);
1366        assert_eq!(alerter.calibration_count(), 1);
1367    }
1368
1369    #[test]
1370    fn edge_alpha_one() {
1371        // alpha=1.0 means 100% FPR tolerance -> threshold should be very low
1372        let mut config = test_config();
1373        config.alpha = 1.0;
1374        let mut alerter = ConformalAlert::new(config);
1375
1376        for v in [50.0, 51.0, 49.0, 50.0, 50.0] {
1377            alerter.calibrate(v);
1378        }
1379
1380        // With alpha=1.0: idx = ceil(0.0 * 6) - 1 = saturating_sub -> 0
1381        // Threshold is the minimum residual
1382        let threshold = alerter.threshold();
1383        assert!(threshold >= 0.0);
1384        assert!(threshold < f64::MAX);
1385    }
1386
1387    #[test]
1388    fn edge_alpha_very_small() {
1389        // Very small alpha -> very high threshold, very few alerts
1390        let mut config = test_config();
1391        config.alpha = 1e-10;
1392        config.hysteresis = 1.0;
1393        let mut alerter = ConformalAlert::new(config);
1394
1395        for v in [50.0, 51.0, 49.0, 50.0, 50.0] {
1396            alerter.calibrate(v);
1397        }
1398
1399        // E-threshold = 1/alpha = 1e10 -> extremely hard to trigger
1400        let stats = alerter.stats();
1401        assert!(stats.current_threshold >= 0.0);
1402        // Normal observation should not alert
1403        let decision = alerter.observe(52.0);
1404        assert!(!decision.evidence.eprocess_alert);
1405    }
1406
1407    #[test]
1408    fn edge_lambda_clamped_at_zero() {
1409        let mut config = test_config();
1410        config.lambda = 0.0;
1411        config.adaptive_lambda = false;
1412        let alerter = ConformalAlert::new(config);
1413        // Lambda should be clamped to EPSILON, not 0.0
1414        assert!(alerter.stats().current_lambda > 0.0);
1415    }
1416
1417    #[test]
1418    fn edge_lambda_clamped_at_one() {
1419        let mut config = test_config();
1420        config.lambda = 1.0;
1421        config.adaptive_lambda = false;
1422        let alerter = ConformalAlert::new(config);
1423        // Lambda should be clamped to 1-EPSILON, not 1.0
1424        assert!(alerter.stats().current_lambda < 1.0);
1425    }
1426
1427    #[test]
1428    fn edge_sigma_0_zero() {
1429        let mut config = test_config();
1430        config.sigma_0 = 0.0;
1431        config.adaptive_lambda = false;
1432        let mut alerter = ConformalAlert::new(config);
1433
1434        for v in [50.0, 51.0, 49.0, 50.0, 50.0] {
1435            alerter.calibrate(v);
1436        }
1437
1438        // With sigma_0=0, e-process exponent = lambda * z - 0
1439        // Should not panic
1440        let decision = alerter.observe(55.0);
1441        assert!(decision.evidence.e_value.is_finite());
1442    }
1443
1444    #[test]
1445    fn edge_hysteresis_zero() {
1446        let mut config = test_config();
1447        config.hysteresis = 0.0;
1448        config.alert_cooldown = 0;
1449        let mut alerter = ConformalAlert::new(config);
1450
1451        for v in [50.0, 50.0, 50.0, 50.0, 50.0] {
1452            alerter.calibrate(v);
1453        }
1454
1455        // E-threshold = 1/alpha * 0 = 0, so any positive e-value triggers e-process alert
1456        let decision = alerter.observe(51.0);
1457        assert!(decision.evidence.eprocess_alert);
1458    }
1459
1460    #[test]
1461    fn edge_max_calibration_zero() {
1462        let mut config = test_config();
1463        config.max_calibration = 0;
1464        let mut alerter = ConformalAlert::new(config);
1465
1466        alerter.calibrate(50.0);
1467        // With max_calibration=0, the value is immediately evicted
1468        assert_eq!(alerter.calibration_count(), 0);
1469    }
1470
1471    #[test]
1472    fn edge_min_calibration_zero() {
1473        let mut config = test_config();
1474        config.min_calibration = 0;
1475        config.alert_cooldown = 0;
1476        let mut alerter = ConformalAlert::new(config);
1477
1478        // Even with no calibration data, min_calibration=0 allows observation
1479        // But empty calibration returns FALLBACK_THRESHOLD
1480        alerter.calibrate(50.0);
1481        let decision = alerter.observe(55.0);
1482        // Should not return InsufficientCalibration
1483        assert_ne!(
1484            decision.evidence.reason,
1485            AlertReason::InsufficientCalibration
1486        );
1487    }
1488
1489    #[test]
1490    fn edge_stats_no_observations() {
1491        let alerter = ConformalAlert::new(test_config());
1492        let stats = alerter.stats();
1493        assert_eq!(stats.total_observations, 0);
1494        assert_eq!(stats.total_alerts, 0);
1495        assert_eq!(stats.conformal_alerts, 0);
1496        assert_eq!(stats.eprocess_alerts, 0);
1497        assert_eq!(stats.both_alerts, 0);
1498        assert_eq!(stats.empirical_fpr, 0.0);
1499        assert_eq!(stats.calibration_samples, 0);
1500    }
1501
1502    #[test]
1503    fn edge_adaptive_lambda_grapa() {
1504        let mut config = test_config();
1505        config.adaptive_lambda = true;
1506        config.grapa_eta = 0.5;
1507        config.hysteresis = 1e10; // Prevent alert reset
1508        let mut alerter = ConformalAlert::new(config);
1509
1510        for v in [50.0, 51.0, 49.0, 50.0, 50.0] {
1511            alerter.calibrate(v);
1512        }
1513
1514        let lambda_before = alerter.stats().current_lambda;
1515
1516        // Extreme observation should shift lambda via GRAPA
1517        alerter.observe(100.0);
1518
1519        let lambda_after = alerter.stats().current_lambda;
1520        // Lambda should have changed (GRAPA gradient update)
1521        assert!(
1522            (lambda_after - lambda_before).abs() > 1e-10,
1523            "Lambda should change with GRAPA: before={} after={}",
1524            lambda_before,
1525            lambda_after
1526        );
1527    }
1528
1529    #[test]
1530    fn edge_adaptive_lambda_stays_bounded() {
1531        let mut config = test_config();
1532        config.adaptive_lambda = true;
1533        config.grapa_eta = 1.0; // Aggressive learning rate
1534        config.hysteresis = 1e10;
1535        let mut alerter = ConformalAlert::new(config);
1536
1537        for v in [50.0, 51.0, 49.0, 50.0, 50.0] {
1538            alerter.calibrate(v);
1539        }
1540
1541        // Many extreme observations
1542        for _ in 0..100 {
1543            alerter.observe(1000.0);
1544        }
1545
1546        let lambda = alerter.stats().current_lambda;
1547        assert!(lambda > 0.0, "Lambda should be positive");
1548        assert!(lambda < 1.0, "Lambda should be < 1.0");
1549    }
1550
1551    #[test]
1552    fn edge_alert_reason_equality() {
1553        assert_eq!(AlertReason::Normal, AlertReason::Normal);
1554        assert_eq!(
1555            AlertReason::ConformalExceeded,
1556            AlertReason::ConformalExceeded
1557        );
1558        assert_eq!(AlertReason::EProcessExceeded, AlertReason::EProcessExceeded);
1559        assert_eq!(AlertReason::BothExceeded, AlertReason::BothExceeded);
1560        assert_eq!(AlertReason::InCooldown, AlertReason::InCooldown);
1561        assert_eq!(
1562            AlertReason::InsufficientCalibration,
1563            AlertReason::InsufficientCalibration
1564        );
1565        assert_ne!(AlertReason::Normal, AlertReason::InCooldown);
1566    }
1567
1568    #[test]
1569    fn edge_alert_config_clone_debug() {
1570        let config = AlertConfig::default();
1571        let cloned = config.clone();
1572        assert_eq!(cloned.alpha, config.alpha);
1573        assert_eq!(cloned.min_calibration, config.min_calibration);
1574        let debug = format!("{:?}", config);
1575        assert!(debug.contains("AlertConfig"));
1576    }
1577
1578    #[test]
1579    fn edge_alert_evidence_clone_debug() {
1580        let mut alerter = ConformalAlert::new(test_config());
1581        for v in [50.0, 51.0, 49.0, 50.0, 50.0] {
1582            alerter.calibrate(v);
1583        }
1584        let decision = alerter.observe(60.0);
1585        let cloned = decision.evidence.clone();
1586        assert_eq!(cloned.observation_idx, decision.evidence.observation_idx);
1587        assert_eq!(cloned.is_alert, decision.evidence.is_alert);
1588        let debug = format!("{:?}", decision.evidence);
1589        assert!(debug.contains("AlertEvidence"));
1590    }
1591
1592    #[test]
1593    fn edge_alert_decision_clone_debug() {
1594        let mut alerter = ConformalAlert::new(test_config());
1595        for v in [50.0, 51.0, 49.0, 50.0, 50.0] {
1596            alerter.calibrate(v);
1597        }
1598        let decision = alerter.observe(60.0);
1599        let cloned = decision.clone();
1600        assert_eq!(cloned.is_alert, decision.is_alert);
1601        assert_eq!(
1602            cloned.observations_since_alert,
1603            decision.observations_since_alert
1604        );
1605        let debug = format!("{:?}", decision);
1606        assert!(debug.contains("AlertDecision"));
1607    }
1608
1609    #[test]
1610    fn edge_alert_stats_clone_debug() {
1611        let alerter = ConformalAlert::new(test_config());
1612        let stats = alerter.stats();
1613        let cloned = stats.clone();
1614        assert_eq!(cloned.total_observations, stats.total_observations);
1615        let debug = format!("{:?}", stats);
1616        assert!(debug.contains("AlertStats"));
1617    }
1618
1619    #[test]
1620    fn edge_conformal_alert_debug() {
1621        let alerter = ConformalAlert::new(test_config());
1622        let debug = format!("{:?}", alerter);
1623        assert!(debug.contains("ConformalAlert"));
1624    }
1625
1626    #[test]
1627    fn edge_evidence_is_alert_matches_decision() {
1628        let mut alerter = ConformalAlert::new(test_config());
1629        for v in [50.0, 51.0, 49.0, 50.0, 50.0] {
1630            alerter.calibrate(v);
1631        }
1632
1633        for obs in [50.0, 100.0, 50.5, 200.0, 49.0] {
1634            let decision = alerter.observe(obs);
1635            assert_eq!(
1636                decision.is_alert, decision.evidence.is_alert,
1637                "Decision.is_alert should match evidence.is_alert for obs={}",
1638                obs
1639            );
1640        }
1641    }
1642
1643    #[test]
1644    fn edge_alert_counters_correct() {
1645        let mut config = test_config();
1646        config.alert_cooldown = 0;
1647        config.hysteresis = 0.1; // Easy trigger
1648        let mut alerter = ConformalAlert::new(config);
1649
1650        for v in [50.0, 50.0, 50.0, 50.0, 50.0] {
1651            alerter.calibrate(v);
1652        }
1653
1654        let mut total = 0u64;
1655        for _ in 0..20 {
1656            let decision = alerter.observe(200.0);
1657            if decision.is_alert {
1658                total += 1;
1659            }
1660        }
1661
1662        let stats = alerter.stats();
1663        assert_eq!(stats.total_alerts, total);
1664        assert_eq!(
1665            stats.conformal_alerts + stats.eprocess_alerts + stats.both_alerts,
1666            stats.total_alerts
1667        );
1668    }
1669
1670    #[test]
1671    fn edge_interleaved_calibrate_observe() {
1672        let mut config = test_config();
1673        config.min_calibration = 3;
1674        config.alert_cooldown = 0;
1675        let mut alerter = ConformalAlert::new(config);
1676
1677        // Calibrate enough to observe
1678        alerter.calibrate(50.0);
1679        alerter.calibrate(51.0);
1680        alerter.calibrate(49.0);
1681
1682        let d1 = alerter.observe(50.0);
1683        assert!(!d1.is_alert);
1684
1685        // Add more calibration
1686        alerter.calibrate(50.0);
1687        alerter.calibrate(50.0);
1688
1689        // Observe again - should still work
1690        let d2 = alerter.observe(50.0);
1691        assert!(!d2.is_alert);
1692        assert_eq!(alerter.calibration_count(), 5);
1693        assert_eq!(alerter.stats().total_observations, 2);
1694    }
1695
1696    #[test]
1697    fn edge_clear_then_recalibrate() {
1698        let mut alerter = ConformalAlert::new(test_config());
1699
1700        for v in [50.0, 51.0, 49.0, 50.0, 50.0] {
1701            alerter.calibrate(v);
1702        }
1703        alerter.observe(60.0);
1704        alerter.clear_calibration();
1705
1706        // Re-calibrate with completely different distribution
1707        for v in [0.0, 1.0, 2.0, 3.0, 4.0] {
1708            alerter.calibrate(v);
1709        }
1710
1711        assert_eq!(alerter.calibration_count(), 5);
1712        assert!((alerter.mean() - 2.0).abs() < f64::EPSILON);
1713        assert!((alerter.e_value() - 1.0).abs() < f64::EPSILON);
1714    }
1715
1716    #[test]
1717    fn edge_cooldown_max_u64() {
1718        let mut config = test_config();
1719        config.alert_cooldown = u64::MAX;
1720        config.hysteresis = 0.1;
1721        let mut alerter = ConformalAlert::new(config);
1722
1723        for v in [50.0, 50.0, 50.0, 50.0, 50.0] {
1724            alerter.calibrate(v);
1725        }
1726
1727        // First extreme observation triggers alert
1728        let mut got_alert = false;
1729        for _ in 0..10 {
1730            let d = alerter.observe(200.0);
1731            if d.is_alert {
1732                got_alert = true;
1733                break;
1734            }
1735        }
1736        assert!(got_alert, "Should get first alert");
1737
1738        // All subsequent observations should be in cooldown
1739        for _ in 0..10 {
1740            let d = alerter.observe(200.0);
1741            assert_eq!(d.evidence.reason, AlertReason::InCooldown);
1742        }
1743    }
1744
1745    #[test]
1746    fn edge_welford_variance_single_sample() {
1747        let mut stats = CalibrationStats::new();
1748        stats.update(42.0);
1749        // With n=1, variance falls back to 1.0
1750        assert!((stats.variance() - 1.0).abs() < f64::EPSILON);
1751    }
1752
1753    #[test]
1754    fn edge_welford_variance_zero_samples() {
1755        let stats = CalibrationStats::new();
1756        // With n=0, variance falls back to 1.0
1757        assert!((stats.variance() - 1.0).abs() < f64::EPSILON);
1758        assert!((stats.std() - 1.0).abs() < f64::EPSILON);
1759    }
1760
1761    #[test]
1762    fn edge_welford_known_variance() {
1763        let mut stats = CalibrationStats::new();
1764        // Values [2, 4, 6, 8, 10]: mean=6, var=10
1765        for v in [2.0, 4.0, 6.0, 8.0, 10.0] {
1766            stats.update(v);
1767        }
1768        assert!((stats.mean - 6.0).abs() < f64::EPSILON);
1769        assert!((stats.variance() - 10.0).abs() < 1e-10);
1770    }
1771
1772    #[test]
1773    fn edge_conformal_score_empty_calibration() {
1774        let alerter = ConformalAlert::new(test_config());
1775        let score = alerter.compute_conformal_score(42.0);
1776        assert!((score - 1.0).abs() < f64::EPSILON);
1777    }
1778
1779    #[test]
1780    fn edge_long_run_evalue_bounded() {
1781        let mut config = test_config();
1782        config.hysteresis = 1e10; // Prevent alert reset
1783        config.adaptive_lambda = false;
1784        let mut alerter = ConformalAlert::new(config);
1785
1786        for v in [50.0, 51.0, 49.0, 50.0, 50.0] {
1787            alerter.calibrate(v);
1788        }
1789
1790        // 1000 normal observations - e-value should remain bounded
1791        for _ in 0..1000 {
1792            alerter.observe(50.0);
1793            let ev = alerter.e_value();
1794            assert!(ev >= E_MIN, "E-value should be >= E_MIN: {}", ev);
1795            assert!(ev <= E_MAX, "E-value should be <= E_MAX: {}", ev);
1796            assert!(ev.is_finite(), "E-value should be finite");
1797        }
1798    }
1799
1800    #[test]
1801    fn edge_default_config_valid() {
1802        let config = AlertConfig::default();
1803        assert!(config.alpha > 0.0 && config.alpha < 1.0);
1804        assert!(config.min_calibration > 0);
1805        assert!(config.max_calibration > 0);
1806        assert!(config.lambda > 0.0 && config.lambda < 1.0);
1807        assert!(config.sigma_0 > 0.0);
1808        assert!(config.hysteresis >= 1.0);
1809        assert!(config.grapa_eta > 0.0);
1810    }
1811}