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