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, 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; }
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#[derive(Debug, Clone)]
189pub struct AlertEvidence {
190 pub observation_idx: u64,
192 pub value: f64,
194 pub residual: f64,
196 pub z_score: f64,
198 pub conformal_threshold: f64,
200 pub conformal_score: f64,
202 pub e_value: f64,
204 pub e_threshold: f64,
206 pub lambda: f64,
208 pub conformal_alert: bool,
210 pub eprocess_alert: bool,
212 pub is_alert: bool,
214 pub reason: AlertReason,
216}
217
218impl AlertEvidence {
219 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
238pub enum AlertReason {
239 Normal,
241 ConformalExceeded,
243 EProcessExceeded,
245 BothExceeded,
247 InCooldown,
249 InsufficientCalibration,
251 InvalidObservation,
253}
254
255#[derive(Debug, Clone)]
257pub struct AlertDecision {
258 pub is_alert: bool,
260 pub evidence: AlertEvidence,
262 pub observations_since_alert: u64,
264}
265
266impl AlertDecision {
267 pub fn evidence_summary(&self) -> String {
269 self.evidence.summary()
270 }
271}
272
273#[derive(Debug, Clone)]
275pub struct AlertStats {
276 pub total_observations: u64,
278 pub calibration_samples: usize,
280 pub total_alerts: u64,
282 pub conformal_alerts: u64,
284 pub eprocess_alerts: u64,
286 pub both_alerts: u64,
288 pub current_e_value: f64,
290 pub current_threshold: f64,
292 pub current_lambda: f64,
294 pub calibration_mean: f64,
296 pub calibration_std: f64,
298 pub empirical_fpr: f64,
300}
301
302#[derive(Debug)]
304pub struct ConformalAlert {
305 config: AlertConfig,
306
307 calibration: VecDeque<f64>,
309
310 stats: CalibrationStats,
312
313 e_value: f64,
315
316 e_threshold: f64,
318
319 lambda: f64,
321
322 observation_count: u64,
324
325 observations_since_alert: u64,
327
328 in_cooldown: bool,
330
331 total_alerts: u64,
333
334 conformal_alerts: u64,
336 eprocess_alerts: u64,
337 both_alerts: u64,
338
339 logs: Vec<AlertEvidence>,
341}
342
343impl ConformalAlert {
344 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 pub fn calibrate(&mut self, value: f64) {
371 self.stats.update(value);
372 let residual = (value - self.stats.mean).abs();
375
376 self.calibration.push_back(residual);
378
379 while self.calibration.len() > self.config.max_calibration {
381 self.calibration.pop_front();
382 }
383 }
384
385 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 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 if self.calibration.len() < self.config.min_calibration {
402 return self.no_alert_decision(value, AlertReason::InsufficientCalibration);
403 }
404
405 let residual = value - self.stats.mean;
407 let abs_residual = residual.abs();
408 let z_score = residual / self.stats.std().max(EPSILON);
409
410 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 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 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 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 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 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 if self.config.enable_logging {
463 self.logs.push(evidence.clone());
464 }
465
466 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 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 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 let target = (1.0 - alpha) * (n + 1) as f64;
502 let idx = (target.ceil() as usize).saturating_sub(1).min(n - 1);
503
504 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 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 (count_geq + 1) as f64 / (n + 1) as f64
526 }
527
528 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 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 pub fn clear_calibration(&mut self) {
562 self.calibration.clear();
563 self.stats = CalibrationStats::new();
564 self.reset_eprocess();
565 }
566
567 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 pub fn logs(&self) -> &[AlertEvidence] {
593 &self.logs
594 }
595
596 pub fn clear_logs(&mut self) {
598 self.logs.clear();
599 }
600
601 #[inline]
603 pub fn e_value(&self) -> f64 {
604 self.e_value
605 }
606
607 pub fn threshold(&self) -> f64 {
609 self.compute_conformal_threshold()
610 }
611
612 #[inline]
614 pub fn mean(&self) -> f64 {
615 self.stats.mean
616 }
617
618 #[inline]
620 pub fn std(&self) -> f64 {
621 self.stats.std()
622 }
623
624 #[inline]
626 pub fn calibration_count(&self) -> usize {
627 self.calibration.len()
628 }
629
630 #[inline]
632 pub fn alpha(&self) -> f64 {
633 self.config.alpha
634 }
635}
636
637#[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, grapa_eta: 0.1,
655 enable_logging: true,
656 hysteresis: 1.0,
657 alert_cooldown: 0,
658 }
659 }
660
661 #[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 #[test]
703 fn conformal_threshold_increases_with_calibration() {
704 let mut alerter = ConformalAlert::new(test_config());
705
706 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; config.min_calibration = 3;
721 let mut alerter = ConformalAlert::new(config);
722
723 for v in [50.0, 60.0, 70.0, 40.0, 30.0] {
727 alerter.calibrate(v);
728 }
729
730 let threshold = alerter.threshold();
732 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 for v in [100.0, 110.0, 120.0, 130.0, 140.0] {
743 alerter.calibrate(v);
744 }
745
746 let score_low = alerter.compute_conformal_score(0.0);
751 assert!(score_low > 0.8);
752
753 let score_high = alerter.compute_conformal_score(100.0);
755 assert!(score_high < 0.3);
756 }
757
758 #[test]
763 fn evalue_grows_on_extreme_observation() {
764 let mut config = test_config();
765 config.hysteresis = 1e10; let mut alerter = ConformalAlert::new(config);
767
768 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 let decision = alerter.observe(100.0);
777
778 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 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 let _ = alerter.observe(50.0);
805
806 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 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; 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 for _ in 0..50 {
841 let decision = alerter.observe(200.0);
842 if decision.is_alert {
843 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 assert!(
854 alerter.stats().total_alerts > 0,
855 "Should have triggered alert"
856 );
857 }
858
859 #[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 for v in [50.0, 50.1, 49.9, 50.0, 49.8, 50.2] {
871 alerter.calibrate(v);
872 }
873
874 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 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(); let mut alerter = ConformalAlert::new(config);
899
900 alerter.calibrate(50.0);
901 alerter.calibrate(51.0);
902 let decision = alerter.observe(1000.0); 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; 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 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 for _ in 0..3 {
939 let decision = alerter.observe(200.0);
940 if decision.evidence.reason == AlertReason::InCooldown {
941 return; }
943 }
944 }
946
947 #[test]
952 fn evidence_contains_all_fields() {
953 let mut alerter = ConformalAlert::new(test_config());
954
955 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 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 #[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 for v in [45.0, 50.0, 55.0, 48.0, 52.0] {
1023 alerter.calibrate(v);
1024 }
1025
1026 for _ in 0..5 {
1028 alerter.observe(50.0);
1029 }
1030
1031 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 assert!(stats.current_threshold >= 0.0);
1043 }
1044
1045 #[test]
1050 fn property_fpr_controlled_under_null() {
1051 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 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 let u = (*state >> 33) as f64 / (1u64 << 31) as f64;
1068 50.0 + (u - 0.5) * 10.0
1069 };
1070
1071 for _ in 0..100 {
1073 alerter.calibrate(lcg_next(&mut rng_state));
1074 }
1075
1076 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 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 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 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 #[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 #[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 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 assert!(alerter.std() < 0.1);
1193
1194 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; let mut alerter = ConformalAlert::new(config);
1207
1208 for v in [45.0, 50.0, 55.0, 48.0, 52.0] {
1210 alerter.calibrate(v);
1211 }
1212
1213 let decision = alerter.observe(200.0);
1215 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 let mut config = test_config();
1263 config.hysteresis = f64::MAX; config.alert_cooldown = 0;
1265 let mut alerter = ConformalAlert::new(config);
1266
1267 for _ in 0..10 {
1269 alerter.calibrate(0.0);
1270 }
1271
1272 let decision = alerter.observe(1e100);
1275
1276 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 let mut config = test_config();
1298 config.hysteresis = f64::MAX;
1299 let mut alerter = ConformalAlert::new(config);
1300
1301 for _ in 0..10 {
1303 alerter.calibrate(1e100);
1304 }
1305
1306 let decision = alerter.observe(0.0);
1308
1309 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 #[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 let decision = alerter.observe(f64::NAN);
1334 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 assert!(decision.evidence.conformal_alert);
1348 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 assert!(decision.evidence.conformal_alert);
1361 }
1362
1363 #[test]
1364 fn edge_calibrate_nan() {
1365 let mut alerter = ConformalAlert::new(test_config());
1366 alerter.calibrate(f64::NAN);
1368 assert_eq!(alerter.calibration_count(), 1);
1369 }
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 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 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 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 let stats = alerter.stats();
1411 assert!(stats.current_threshold >= 0.0);
1412 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 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 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 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 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 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 alerter.calibrate(50.0);
1491 let decision = alerter.observe(55.0);
1492 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; 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 alerter.observe(100.0);
1528
1529 let lambda_after = alerter.stats().current_lambda;
1530 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; 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 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; 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 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 alerter.calibrate(50.0);
1697 alerter.calibrate(50.0);
1698
1699 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 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 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 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 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 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 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; 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 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}