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}