1#![forbid(unsafe_code)]
2
3use std::collections::VecDeque;
70
71const E_MIN: f64 = 1e-12;
73
74const E_MAX: f64 = 1e12;
78
79const MIN_CALIBRATION: usize = 10;
81
82const FALLBACK_THRESHOLD: f64 = f64::MAX;
84
85const EPSILON: f64 = 1e-10;
87
88#[derive(Debug, Clone)]
90pub struct AlertConfig {
91 pub alpha: f64,
94
95 pub min_calibration: usize,
98
99 pub max_calibration: usize,
101
102 pub lambda: f64,
104
105 pub mu_0: f64,
107
108 pub sigma_0: f64,
110
111 pub adaptive_lambda: bool,
113
114 pub grapa_eta: f64,
116
117 pub enable_logging: bool,
119
120 pub hysteresis: f64,
123
124 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#[derive(Debug, Clone)]
149struct CalibrationStats {
150 n: u64,
151 mean: f64,
152 m2: f64, }
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; }
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#[derive(Debug, Clone)]
186pub struct AlertEvidence {
187 pub observation_idx: u64,
189 pub value: f64,
191 pub residual: f64,
193 pub z_score: f64,
195 pub conformal_threshold: f64,
197 pub conformal_score: f64,
199 pub e_value: f64,
201 pub e_threshold: f64,
203 pub lambda: f64,
205 pub conformal_alert: bool,
207 pub eprocess_alert: bool,
209 pub is_alert: bool,
211 pub reason: AlertReason,
213}
214
215impl AlertEvidence {
216 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
235pub enum AlertReason {
236 Normal,
238 ConformalExceeded,
240 EProcessExceeded,
242 BothExceeded,
244 InCooldown,
246 InsufficientCalibration,
248}
249
250#[derive(Debug, Clone)]
252pub struct AlertDecision {
253 pub is_alert: bool,
255 pub evidence: AlertEvidence,
257 pub observations_since_alert: u64,
259}
260
261impl AlertDecision {
262 pub fn evidence_summary(&self) -> String {
264 self.evidence.summary()
265 }
266}
267
268#[derive(Debug, Clone)]
270pub struct AlertStats {
271 pub total_observations: u64,
273 pub calibration_samples: usize,
275 pub total_alerts: u64,
277 pub conformal_alerts: u64,
279 pub eprocess_alerts: u64,
281 pub both_alerts: u64,
283 pub current_e_value: f64,
285 pub current_threshold: f64,
287 pub current_lambda: f64,
289 pub calibration_mean: f64,
291 pub calibration_std: f64,
293 pub empirical_fpr: f64,
295}
296
297#[derive(Debug)]
299pub struct ConformalAlert {
300 config: AlertConfig,
301
302 calibration: VecDeque<f64>,
304
305 stats: CalibrationStats,
307
308 e_value: f64,
310
311 e_threshold: f64,
313
314 lambda: f64,
316
317 observation_count: u64,
319
320 observations_since_alert: u64,
322
323 in_cooldown: bool,
325
326 total_alerts: u64,
328
329 conformal_alerts: u64,
331 eprocess_alerts: u64,
332 both_alerts: u64,
333
334 logs: Vec<AlertEvidence>,
336}
337
338impl ConformalAlert {
339 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 pub fn calibrate(&mut self, value: f64) {
366 self.stats.update(value);
367
368 let residual = (value - self.stats.mean).abs();
370 self.calibration.push_back(residual);
371
372 while self.calibration.len() > self.config.max_calibration {
374 self.calibration.pop_front();
375 }
376 }
377
378 pub fn observe(&mut self, value: f64) -> AlertDecision {
380 self.observation_count += 1;
381 self.observations_since_alert += 1;
382
383 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 if self.calibration.len() < self.config.min_calibration {
391 return self.no_alert_decision(value, AlertReason::InsufficientCalibration);
392 }
393
394 let residual = value - self.stats.mean;
396 let abs_residual = residual.abs();
397 let z_score = residual / self.stats.std().max(EPSILON);
398
399 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 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 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 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 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 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 if self.config.enable_logging {
452 self.logs.push(evidence.clone());
453 }
454
455 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 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 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 let target = (1.0 - alpha) * (n + 1) as f64;
491 let idx = (target.ceil() as usize).saturating_sub(1).min(n - 1);
492
493 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 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 (count_geq + 1) as f64 / (n + 1) as f64
515 }
516
517 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 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 pub fn clear_calibration(&mut self) {
551 self.calibration.clear();
552 self.stats = CalibrationStats::new();
553 self.reset_eprocess();
554 }
555
556 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 pub fn logs(&self) -> &[AlertEvidence] {
582 &self.logs
583 }
584
585 pub fn clear_logs(&mut self) {
587 self.logs.clear();
588 }
589
590 #[inline]
592 pub fn e_value(&self) -> f64 {
593 self.e_value
594 }
595
596 pub fn threshold(&self) -> f64 {
598 self.compute_conformal_threshold()
599 }
600
601 #[inline]
603 pub fn mean(&self) -> f64 {
604 self.stats.mean
605 }
606
607 #[inline]
609 pub fn std(&self) -> f64 {
610 self.stats.std()
611 }
612
613 #[inline]
615 pub fn calibration_count(&self) -> usize {
616 self.calibration.len()
617 }
618
619 #[inline]
621 pub fn alpha(&self) -> f64 {
622 self.config.alpha
623 }
624}
625
626#[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, grapa_eta: 0.1,
644 enable_logging: true,
645 hysteresis: 1.0,
646 alert_cooldown: 0,
647 }
648 }
649
650 #[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 #[test]
692 fn conformal_threshold_increases_with_calibration() {
693 let mut alerter = ConformalAlert::new(test_config());
694
695 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; config.min_calibration = 3;
710 let mut alerter = ConformalAlert::new(config);
711
712 for v in [50.0, 60.0, 70.0, 40.0, 30.0] {
717 alerter.calibrate(v);
718 }
719
720 let threshold = alerter.threshold();
722 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 for v in [100.0, 110.0, 120.0, 130.0, 140.0] {
733 alerter.calibrate(v);
734 }
735
736 let score_low = alerter.compute_conformal_score(0.0);
741 assert!(score_low > 0.8);
742
743 let score_high = alerter.compute_conformal_score(100.0);
745 assert!(score_high < 0.3);
746 }
747
748 #[test]
753 fn evalue_grows_on_extreme_observation() {
754 let mut config = test_config();
755 config.hysteresis = 1e10; let mut alerter = ConformalAlert::new(config);
757
758 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 let decision = alerter.observe(100.0);
767
768 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 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 let _ = alerter.observe(50.0);
795
796 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 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; 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 for _ in 0..50 {
831 let decision = alerter.observe(200.0);
832 if decision.is_alert {
833 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 assert!(
844 alerter.stats().total_alerts > 0,
845 "Should have triggered alert"
846 );
847 }
848
849 #[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 for v in [50.0, 50.1, 49.9, 50.0, 49.8, 50.2] {
861 alerter.calibrate(v);
862 }
863
864 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 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(); let mut alerter = ConformalAlert::new(config);
889
890 alerter.calibrate(50.0);
891 alerter.calibrate(51.0);
892 let decision = alerter.observe(1000.0); 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; 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 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 for _ in 0..3 {
929 let decision = alerter.observe(200.0);
930 if decision.evidence.reason == AlertReason::InCooldown {
931 return; }
933 }
934 }
936
937 #[test]
942 fn evidence_contains_all_fields() {
943 let mut alerter = ConformalAlert::new(test_config());
944
945 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 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 #[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 for v in [45.0, 50.0, 55.0, 48.0, 52.0] {
1013 alerter.calibrate(v);
1014 }
1015
1016 for _ in 0..5 {
1018 alerter.observe(50.0);
1019 }
1020
1021 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 assert!(stats.current_threshold >= 0.0);
1033 }
1034
1035 #[test]
1040 fn property_fpr_controlled_under_null() {
1041 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 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 let u = (*state >> 33) as f64 / (1u64 << 31) as f64;
1058 50.0 + (u - 0.5) * 10.0
1059 };
1060
1061 for _ in 0..100 {
1063 alerter.calibrate(lcg_next(&mut rng_state));
1064 }
1065
1066 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 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 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 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 #[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 #[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 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 assert!(alerter.std() < 0.1);
1183
1184 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; let mut alerter = ConformalAlert::new(config);
1197
1198 for v in [45.0, 50.0, 55.0, 48.0, 52.0] {
1200 alerter.calibrate(v);
1201 }
1202
1203 let decision = alerter.observe(200.0);
1205 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 let mut config = test_config();
1253 config.hysteresis = f64::MAX; config.alert_cooldown = 0;
1255 let mut alerter = ConformalAlert::new(config);
1256
1257 for _ in 0..10 {
1259 alerter.calibrate(0.0);
1260 }
1261
1262 let decision = alerter.observe(1e100);
1265
1266 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 let mut config = test_config();
1288 config.hysteresis = f64::MAX;
1289 let mut alerter = ConformalAlert::new(config);
1290
1291 for _ in 0..10 {
1293 alerter.calibrate(1e100);
1294 }
1295
1296 let decision = alerter.observe(0.0);
1298
1299 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 #[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 let decision = alerter.observe(f64::NAN);
1324 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 assert!(decision.evidence.conformal_alert);
1338 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 assert!(decision.evidence.conformal_alert);
1351 }
1352
1353 #[test]
1354 fn edge_calibrate_nan() {
1355 let mut alerter = ConformalAlert::new(test_config());
1356 alerter.calibrate(f64::NAN);
1358 assert_eq!(alerter.calibration_count(), 1);
1359 }
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 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 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 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 let stats = alerter.stats();
1401 assert!(stats.current_threshold >= 0.0);
1402 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 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 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 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 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 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 alerter.calibrate(50.0);
1481 let decision = alerter.observe(55.0);
1482 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; 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 alerter.observe(100.0);
1518
1519 let lambda_after = alerter.stats().current_lambda;
1520 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; 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 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; 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 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 alerter.calibrate(50.0);
1687 alerter.calibrate(50.0);
1688
1689 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 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 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 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 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 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 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; 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 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}