1use crate::lab::oracle::{OracleEntryReport, OracleReport};
40use serde::{Deserialize, Serialize};
41use std::collections::BTreeMap;
42
43fn count_to_f64(count: usize) -> f64 {
44 f64::from(count.min(u32::MAX as usize) as u32)
45}
46
47fn assert_valid_alpha(alpha: f64) {
48 assert!(
49 alpha.is_finite() && alpha > 0.0 && alpha < 1.0,
50 "alpha must be finite and in (0, 1), got {alpha}"
51 );
52}
53
54fn assert_valid_min_samples(min_samples: usize) {
55 assert!(min_samples > 0, "min_calibration_samples must be > 0");
56}
57
58#[derive(Debug, Clone)]
60pub struct ConformalConfig {
61 pub alpha: f64,
63 pub min_calibration_samples: usize,
65}
66
67impl Default for ConformalConfig {
68 fn default() -> Self {
69 Self {
70 alpha: 0.05,
71 min_calibration_samples: 5,
72 }
73 }
74}
75
76impl ConformalConfig {
77 #[must_use]
79 pub fn new(alpha: f64) -> Self {
80 assert_valid_alpha(alpha);
81 Self {
82 alpha,
83 ..Default::default()
84 }
85 }
86
87 #[must_use]
89 pub fn min_samples(mut self, n: usize) -> Self {
90 assert_valid_min_samples(n);
91 self.min_calibration_samples = n;
92 self
93 }
94}
95
96#[derive(Debug, Clone, Copy, PartialEq)]
98pub struct ConformityScore {
99 pub value: f64,
101 pub violated: bool,
103}
104
105#[derive(Debug, Clone, Default)]
107struct InvariantCalibration {
108 scores: Vec<f64>,
110 entity_sum: f64,
112 event_sum: f64,
114 violation_count: usize,
116}
117
118impl InvariantCalibration {
119 fn n(&self) -> usize {
120 self.scores.len()
121 }
122
123 fn mean_entities(&self) -> f64 {
124 let n = self.n();
125 if n == 0 {
126 1.0
127 } else {
128 (self.entity_sum / count_to_f64(n)).max(1.0)
129 }
130 }
131
132 fn mean_events(&self) -> f64 {
133 let n = self.n();
134 if n == 0 {
135 1.0
136 } else {
137 (self.event_sum / count_to_f64(n)).max(1.0)
138 }
139 }
140
141 fn empirical_violation_rate(&self) -> f64 {
142 let n = self.n();
143 if n == 0 {
144 0.0
145 } else {
146 count_to_f64(self.violation_count) / count_to_f64(n)
147 }
148 }
149}
150
151#[derive(Debug, Clone, Serialize, Deserialize)]
153pub struct PredictionSet {
154 pub invariant: String,
156 pub threshold: f64,
158 pub conforming: bool,
160 pub score: f64,
162 pub calibration_n: usize,
164 pub coverage_target: f64,
166}
167
168#[derive(Debug, Clone, Default, Serialize, Deserialize)]
170pub struct CoverageTracker {
171 pub total: usize,
173 pub covered: usize,
175}
176
177impl CoverageTracker {
178 #[must_use]
180 pub fn rate(&self) -> f64 {
181 if self.total == 0 {
182 1.0
183 } else {
184 count_to_f64(self.covered) / count_to_f64(self.total)
185 }
186 }
187}
188
189#[derive(Debug, Clone, Serialize, Deserialize)]
191pub struct CalibrationReport {
192 pub prediction_sets: Vec<PredictionSet>,
194 pub coverage: BTreeMap<String, CoverageTracker>,
196 pub overall_coverage: CoverageTracker,
198 pub alpha: f64,
200 pub calibration_samples: usize,
202}
203
204impl CalibrationReport {
205 #[must_use]
226 pub fn is_well_calibrated(&self) -> bool {
227 if self.overall_coverage.total == 0 {
228 return true;
229 }
230 let target = 1.0 - self.alpha;
231 self.overall_coverage.rate() >= target - self.calibration_tolerance()
232 }
233
234 #[must_use]
239 pub fn calibration_tolerance(&self) -> f64 {
240 (self.alpha / 5.0).max(f64::EPSILON)
241 }
242
243 #[must_use]
245 pub fn miscalibrated_invariants(&self) -> Vec<String> {
246 let target = 1.0 - self.alpha;
247 let tolerance = self.calibration_tolerance();
248 self.coverage
249 .iter()
250 .filter(|(_, tracker)| tracker.total > 0 && tracker.rate() < target - tolerance)
251 .map(|(name, _)| name.clone())
252 .collect()
253 }
254
255 #[must_use]
257 pub fn to_text(&self) -> String {
258 use std::fmt::Write;
259 let mut out = String::new();
260 out.push_str("CONFORMAL CALIBRATION REPORT\n");
261 let _ = writeln!(
262 out,
263 "target coverage: {:.1}% (alpha={:.3})",
264 (1.0 - self.alpha) * 100.0,
265 self.alpha
266 );
267 let _ = writeln!(out, "calibration samples: {}", self.calibration_samples);
268 let _ = writeln!(
269 out,
270 "overall empirical coverage: {:.1}% ({}/{})\n",
271 self.overall_coverage.rate() * 100.0,
272 self.overall_coverage.covered,
273 self.overall_coverage.total,
274 );
275
276 for ps in &self.prediction_sets {
277 let status = if ps.conforming { "OK" } else { "ANOMALOUS" };
278 let _ = writeln!(
279 out,
280 " {}: score={:.4} threshold={:.4} [{}] (n={})",
281 ps.invariant, ps.score, ps.threshold, status, ps.calibration_n
282 );
283 }
284
285 let miscal = self.miscalibrated_invariants();
286 if miscal.is_empty() {
287 out.push_str("\ncalibration: WELL-CALIBRATED\n");
288 } else {
289 let _ = writeln!(
290 out,
291 "\ncalibration: MISCALIBRATED on: {}",
292 miscal.join(", ")
293 );
294 }
295
296 out
297 }
298
299 #[must_use]
301 pub fn to_json(&self) -> serde_json::Value {
302 serde_json::json!({
303 "alpha": self.alpha,
304 "coverage_target": 1.0 - self.alpha,
305 "calibration_samples": self.calibration_samples,
306 "overall_coverage": {
307 "total": self.overall_coverage.total,
308 "covered": self.overall_coverage.covered,
309 "rate": self.overall_coverage.rate(),
310 },
311 "well_calibrated": self.is_well_calibrated(),
312 "prediction_sets": self.prediction_sets,
313 "per_invariant_coverage": self.coverage.iter().map(|(name, t)| {
314 serde_json::json!({
315 "invariant": name,
316 "total": t.total,
317 "covered": t.covered,
318 "rate": t.rate(),
319 })
320 }).collect::<Vec<_>>(),
321 })
322 }
323}
324
325#[derive(Debug, Clone)]
340pub struct ConformalCalibrator {
341 config: ConformalConfig,
342 calibrations: BTreeMap<String, InvariantCalibration>,
344 coverage_trackers: BTreeMap<String, CoverageTracker>,
346 overall_coverage: CoverageTracker,
348 n_calibration: usize,
350}
351
352impl ConformalCalibrator {
353 #[must_use]
355 pub fn new(config: ConformalConfig) -> Self {
356 assert_valid_alpha(config.alpha);
357 assert_valid_min_samples(config.min_calibration_samples);
358 Self {
359 config,
360 calibrations: BTreeMap::new(),
361 coverage_trackers: BTreeMap::new(),
362 overall_coverage: CoverageTracker::default(),
363 n_calibration: 0,
364 }
365 }
366
367 #[must_use]
369 pub fn default_calibrator() -> Self {
370 Self::new(ConformalConfig::default())
371 }
372
373 #[must_use]
375 pub fn calibration_samples(&self) -> usize {
376 self.n_calibration
377 }
378
379 #[must_use]
381 pub fn is_calibrated(&self) -> bool {
382 self.n_calibration >= self.config.min_calibration_samples
383 }
384
385 pub fn calibrate(&mut self, report: &OracleReport) {
390 for entry in &report.entries {
391 let cal = self
392 .calibrations
393 .entry(entry.invariant.clone())
394 .or_default();
395 let score = conformity_score(entry, cal);
396 cal.scores.push(score);
397 cal.entity_sum += count_to_f64(entry.stats.entities_tracked);
398 cal.event_sum += count_to_f64(entry.stats.events_recorded);
399 if !entry.passed {
400 cal.violation_count += 1;
401 }
402 }
403 self.n_calibration += 1;
404 }
405
406 #[must_use]
411 pub fn predict(&mut self, report: &OracleReport) -> Option<CalibrationReport> {
412 let was_already_calibrated = self.is_calibrated();
413
414 if !was_already_calibrated {
415 self.calibrate(report);
417 return None;
422 }
423
424 let mut prediction_sets = Vec::new();
425
426 for entry in &report.entries {
427 let Some(cal) = self.calibrations.get(&entry.invariant) else {
428 continue;
429 };
430
431 let score = conformity_score(entry, cal);
433
434 let threshold = conformal_quantile(&cal.scores, self.config.alpha);
436
437 let conforming = score <= threshold;
438
439 let tracker = self
441 .coverage_trackers
442 .entry(entry.invariant.clone())
443 .or_default();
444 tracker.total += 1;
445 if conforming {
446 tracker.covered += 1;
447 }
448 self.overall_coverage.total += 1;
449 if conforming {
450 self.overall_coverage.covered += 1;
451 }
452
453 prediction_sets.push(PredictionSet {
454 invariant: entry.invariant.clone(),
455 threshold,
456 conforming,
457 score,
458 calibration_n: cal.n(),
459 coverage_target: 1.0 - self.config.alpha,
460 });
461 }
462
463 if was_already_calibrated {
466 self.calibrate(report);
467 }
468
469 Some(CalibrationReport {
470 prediction_sets,
471 coverage: self.coverage_trackers.clone(),
472 overall_coverage: self.overall_coverage.clone(),
473 alpha: self.config.alpha,
474 calibration_samples: self.n_calibration,
475 })
476 }
477
478 #[must_use]
480 pub fn violation_rates(&self) -> BTreeMap<String, f64> {
481 self.calibrations
482 .iter()
483 .map(|(name, cal)| (name.clone(), cal.empirical_violation_rate()))
484 .collect()
485 }
486
487 #[must_use]
489 pub fn coverage_rates(&self) -> BTreeMap<String, f64> {
490 self.coverage_trackers
491 .iter()
492 .map(|(name, tracker)| (name.clone(), tracker.rate()))
493 .collect()
494 }
495}
496
497fn conformity_score(entry: &OracleEntryReport, cal: &InvariantCalibration) -> f64 {
506 let violation_component = if entry.passed { 0.0 } else { 1.0 };
507
508 if cal.n() == 0 {
510 return violation_component;
511 }
512
513 let mean_entities = cal.mean_entities();
514 let entity_deviation = if mean_entities > 0.0 {
515 ((count_to_f64(entry.stats.entities_tracked) - mean_entities) / mean_entities).abs()
516 } else {
517 0.0
518 };
519
520 let mean_events = cal.mean_events();
521 let event_deviation = if mean_events > 0.0 {
522 ((count_to_f64(entry.stats.events_recorded) - mean_events) / mean_events).abs()
523 } else {
524 0.0
525 };
526
527 0.1_f64.mul_add(
529 event_deviation,
530 0.1_f64.mul_add(entity_deviation, violation_component),
531 )
532}
533
534fn conformal_quantile(scores: &[f64], alpha: f64) -> f64 {
543 if scores.is_empty() {
544 return f64::INFINITY;
545 }
546
547 let n = scores.len();
548 let mut sorted = scores.to_vec();
549 sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
550
551 let level = (1.0 - alpha) * (count_to_f64(n) + 1.0);
557 #[allow(clippy::cast_sign_loss)]
558 let rank = level.ceil() as usize;
559 if rank > n {
560 return f64::INFINITY;
561 }
562
563 sorted[rank.saturating_sub(1)]
564}
565
566#[derive(Debug, Clone, Copy, PartialEq, Eq)]
572pub enum ThresholdMode {
573 Upper,
576 TwoSided,
579}
580
581#[derive(Debug, Clone)]
583pub struct HealthThresholdConfig {
584 pub alpha: f64,
586 pub min_calibration_samples: usize,
588 pub mode: ThresholdMode,
590}
591
592impl Default for HealthThresholdConfig {
593 fn default() -> Self {
594 Self {
595 alpha: 0.05,
596 min_calibration_samples: 5,
597 mode: ThresholdMode::Upper,
598 }
599 }
600}
601
602impl HealthThresholdConfig {
603 #[must_use]
605 pub fn new(alpha: f64, mode: ThresholdMode) -> Self {
606 assert_valid_alpha(alpha);
607 Self {
608 alpha,
609 mode,
610 ..Default::default()
611 }
612 }
613
614 #[must_use]
616 pub fn min_samples(mut self, n: usize) -> Self {
617 assert_valid_min_samples(n);
618 self.min_calibration_samples = n;
619 self
620 }
621}
622
623#[derive(Debug, Clone)]
625pub struct ThresholdCheck {
626 pub metric: String,
628 pub value: f64,
630 pub threshold: f64,
632 pub conforming: bool,
634 pub nonconformity_score: f64,
636 pub calibration_n: usize,
638 pub coverage_target: f64,
640}
641
642#[derive(Debug, Clone, Default)]
644struct MetricCalibration {
645 values: Vec<f64>,
647}
648
649impl MetricCalibration {
650 fn n(&self) -> usize {
651 self.values.len()
652 }
653
654 fn median(&self) -> f64 {
655 if self.values.is_empty() {
656 return 0.0;
657 }
658 let mut sorted = self.values.clone();
659 sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
660 let mid = sorted.len() / 2;
661 if sorted.len().is_multiple_of(2) && sorted.len() >= 2 {
662 (sorted[mid - 1]).midpoint(sorted[mid])
663 } else {
664 sorted[mid]
665 }
666 }
667}
668
669#[derive(Debug, Clone)]
710pub struct HealthThresholdCalibrator {
711 config: HealthThresholdConfig,
712 metrics: BTreeMap<String, MetricCalibration>,
713 coverage_trackers: BTreeMap<String, CoverageTracker>,
714 n_calibration: usize,
715}
716
717impl HealthThresholdCalibrator {
718 #[must_use]
720 pub fn new(config: HealthThresholdConfig) -> Self {
721 assert_valid_alpha(config.alpha);
722 assert_valid_min_samples(config.min_calibration_samples);
723 Self {
724 config,
725 metrics: BTreeMap::new(),
726 coverage_trackers: BTreeMap::new(),
727 n_calibration: 0,
728 }
729 }
730
731 #[must_use]
733 pub fn calibration_samples(&self) -> usize {
734 self.n_calibration
735 }
736
737 #[must_use]
739 pub fn is_metric_calibrated(&self, metric: &str) -> bool {
740 self.metrics
741 .get(metric)
742 .is_some_and(|m| m.n() >= self.config.min_calibration_samples)
743 }
744
745 pub fn calibrate(&mut self, metric: &str, value: f64) {
747 if !value.is_finite() {
750 return;
751 }
752
753 let cal = self.metrics.entry(metric.to_string()).or_default();
754
755 cal.values.push(value);
756
757 self.n_calibration += 1;
758 }
759
760 #[must_use]
764 pub fn check(&self, metric: &str, value: f64) -> Option<ThresholdCheck> {
765 let cal = self.metrics.get(metric)?;
766 if cal.n() < self.config.min_calibration_samples {
767 return None;
768 }
769
770 if !value.is_finite() {
773 return Some(ThresholdCheck {
774 metric: metric.to_string(),
775 value,
776 threshold: self.threshold(metric)?,
777 conforming: false,
778 nonconformity_score: f64::INFINITY,
779 calibration_n: cal.n(),
780 coverage_target: 1.0 - self.config.alpha,
781 });
782 }
783
784 let (nonconformity_score, threshold) = match self.config.mode {
785 ThresholdMode::Upper => {
786 let score = value;
787 let threshold = conformal_quantile(&cal.values, self.config.alpha);
788 (score, threshold)
789 }
790 ThresholdMode::TwoSided => {
791 let median = cal.median();
795 let scores: Vec<f64> = cal.values.iter().map(|v| (v - median).abs()).collect();
796 let score = (value - median).abs();
797 let threshold = conformal_quantile(&scores, self.config.alpha);
798 (score, threshold)
799 }
800 };
801
802 let conforming = nonconformity_score <= threshold;
803
804 Some(ThresholdCheck {
805 metric: metric.to_string(),
806 value,
807 threshold,
808 conforming,
809 nonconformity_score,
810 calibration_n: cal.n(),
811 coverage_target: 1.0 - self.config.alpha,
812 })
813 }
814
815 pub fn check_and_track(&mut self, metric: &str, value: f64) -> Option<ThresholdCheck> {
817 let result = self.check(metric, value)?;
818
819 let tracker = self
820 .coverage_trackers
821 .entry(metric.to_string())
822 .or_default();
823 tracker.total += 1;
824 if result.conforming {
825 tracker.covered += 1;
826 }
827
828 Some(result)
829 }
830
831 #[must_use]
835 pub fn threshold(&self, metric: &str) -> Option<f64> {
836 let cal = self.metrics.get(metric)?;
837 if cal.n() < self.config.min_calibration_samples {
838 return None;
839 }
840
841 match self.config.mode {
842 ThresholdMode::Upper => Some(conformal_quantile(&cal.values, self.config.alpha)),
843 ThresholdMode::TwoSided => {
844 let median = cal.median();
845 let scores: Vec<f64> = cal.values.iter().map(|v| (v - median).abs()).collect();
846 Some(conformal_quantile(&scores, self.config.alpha))
847 }
848 }
849 }
850
851 #[must_use]
853 pub fn coverage_rates(&self) -> BTreeMap<String, f64> {
854 self.coverage_trackers
855 .iter()
856 .map(|(name, tracker)| (name.clone(), tracker.rate()))
857 .collect()
858 }
859
860 #[must_use]
862 pub fn metric_counts(&self) -> BTreeMap<String, usize> {
863 self.metrics
864 .iter()
865 .map(|(name, cal)| (name.clone(), cal.n()))
866 .collect()
867 }
868
869 #[must_use]
871 pub fn check_all(&self, observations: &[(&str, f64)]) -> Vec<ThresholdCheck> {
872 observations
873 .iter()
874 .filter_map(|(metric, value)| self.check(metric, *value))
875 .collect()
876 }
877
878 #[must_use]
880 pub fn any_anomalous(&self, observations: &[(&str, f64)]) -> bool {
881 observations
882 .iter()
883 .filter_map(|(metric, value)| self.check(metric, *value))
884 .any(|r| !r.conforming)
885 }
886}
887
888impl std::fmt::Display for ThresholdCheck {
889 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
890 let status = if self.conforming { "OK" } else { "ANOMALOUS" };
891 write!(
892 f,
893 "{}: value={:.4} threshold={:.4} [{}] (n={})",
894 self.metric, self.value, self.threshold, status, self.calibration_n
895 )
896 }
897}
898
899#[cfg(test)]
900mod tests {
901 #![allow(
902 clippy::pedantic,
903 clippy::nursery,
904 clippy::expect_fun_call,
905 clippy::map_unwrap_or,
906 clippy::cast_possible_wrap,
907 clippy::future_not_send
908 )]
909 use super::*;
910 use crate::lab::OracleStats;
911
912 fn make_clean_report(entities: usize, events: usize) -> OracleReport {
913 OracleReport {
914 entries: vec![OracleEntryReport {
915 invariant: "test_oracle".to_string(),
916 passed: true,
917 violation: None,
918 stats: OracleStats {
919 entities_tracked: entities,
920 events_recorded: events,
921 },
922 }],
923 total: 1,
924 passed: 1,
925 failed: 0,
926 check_time_nanos: 0,
927 }
928 }
929
930 fn make_violated_report(entities: usize, events: usize) -> OracleReport {
931 OracleReport {
932 entries: vec![OracleEntryReport {
933 invariant: "test_oracle".to_string(),
934 passed: false,
935 violation: Some("test violation".to_string()),
936 stats: OracleStats {
937 entities_tracked: entities,
938 events_recorded: events,
939 },
940 }],
941 total: 1,
942 passed: 0,
943 failed: 1,
944 check_time_nanos: 0,
945 }
946 }
947
948 #[test]
949 fn conformal_quantile_empty() {
950 assert!(conformal_quantile(&[], 0.05).is_infinite());
951 }
952
953 #[test]
954 fn conformal_quantile_single() {
955 let scores = [0.5];
959 assert!(conformal_quantile(&scores, 0.05).is_infinite());
960
961 let q = conformal_quantile(&scores, 0.5);
964 assert!((q - 0.5).abs() < f64::EPSILON);
965 }
966
967 #[test]
968 fn conformal_quantile_sorted() {
969 let scores = [0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0];
970 assert!(conformal_quantile(&scores, 0.05).is_infinite());
973
974 let q80 = conformal_quantile(&scores, 0.20);
975 assert!((q80 - 0.9).abs() < f64::EPSILON);
977 }
978
979 #[test]
980 fn conformal_quantile_infinite_when_rank_exceeds_n() {
981 let twenty: Vec<f64> = (1..=20).map(f64::from).collect();
986 let q = conformal_quantile(&twenty, 0.05);
989 assert!(
990 (q - 20.0).abs() < f64::EPSILON,
991 "rank==n is finite, got {q}"
992 );
993
994 assert!(
996 conformal_quantile(&twenty, 0.04).is_infinite(),
997 "rank>n must be +inf to preserve >= 1-alpha coverage"
998 );
999 }
1000
1001 #[test]
1002 fn calibrator_starts_uncalibrated() {
1003 let cal = ConformalCalibrator::default_calibrator();
1004 assert!(!cal.is_calibrated());
1005 assert_eq!(cal.calibration_samples(), 0);
1006 }
1007
1008 #[test]
1009 fn calibrator_becomes_calibrated() {
1010 let config = ConformalConfig::new(0.10).min_samples(3);
1011 let mut cal = ConformalCalibrator::new(config);
1012
1013 for _ in 0..3 {
1014 cal.calibrate(&make_clean_report(10, 50));
1015 }
1016 assert!(cal.is_calibrated());
1017 assert_eq!(cal.calibration_samples(), 3);
1018 }
1019
1020 #[test]
1021 fn predict_returns_none_before_calibrated() {
1022 let config = ConformalConfig::new(0.10).min_samples(5);
1023 let mut cal = ConformalCalibrator::new(config);
1024
1025 for _ in 0..4 {
1027 assert!(cal.predict(&make_clean_report(10, 50)).is_none());
1028 }
1029 let report = cal.predict(&make_clean_report(10, 50));
1033 assert!(
1034 report.is_none(),
1035 "calibration-completing observation must be skipped"
1036 );
1037
1038 let report = cal.predict(&make_clean_report(10, 50));
1040 assert!(
1041 report.is_some(),
1042 "post-calibration observation should produce prediction"
1043 );
1044 }
1045
1046 #[test]
1047 fn clean_observations_are_conforming() {
1048 let config = ConformalConfig::new(0.10).min_samples(3);
1049 let mut cal = ConformalCalibrator::new(config);
1050
1051 for _ in 0..5 {
1053 cal.calibrate(&make_clean_report(10, 50));
1054 }
1055
1056 let report = cal.predict(&make_clean_report(10, 50)).unwrap();
1058 assert_eq!(report.prediction_sets.len(), 1);
1059 assert!(
1060 report.prediction_sets[0].conforming,
1061 "clean observation should be conforming"
1062 );
1063 }
1064
1065 #[test]
1066 fn sparse_calibration_uses_infinite_threshold_for_high_coverage() {
1067 let config = ConformalConfig::new(0.05).min_samples(3);
1068 let mut cal = ConformalCalibrator::new(config);
1069
1070 for _ in 0..3 {
1071 cal.calibrate(&make_clean_report(10, 50));
1072 }
1073
1074 let report = cal.predict(&make_violated_report(10_000, 50_000)).unwrap();
1075 assert_eq!(report.prediction_sets.len(), 1);
1076 let prediction = &report.prediction_sets[0];
1077 assert!(
1078 prediction.threshold.is_infinite(),
1079 "n=3 alpha=0.05 requires +inf threshold, got {}",
1080 prediction.threshold
1081 );
1082 assert!(
1083 prediction.conforming,
1084 "high-coverage sparse calibration must cover all scores"
1085 );
1086 }
1087
1088 #[test]
1089 fn violation_is_anomalous() {
1090 let config = ConformalConfig::new(0.10).min_samples(3);
1091 let mut cal = ConformalCalibrator::new(config);
1092
1093 for _ in 0..10 {
1095 cal.calibrate(&make_clean_report(10, 50));
1096 }
1097
1098 let report = cal.predict(&make_violated_report(10, 50)).unwrap();
1100 assert!(!report.prediction_sets[0].conforming);
1101 }
1102
1103 #[test]
1104 fn coverage_tracking() {
1105 let config = ConformalConfig::new(0.10).min_samples(3);
1106 let mut cal = ConformalCalibrator::new(config);
1107
1108 for _ in 0..5 {
1110 cal.calibrate(&make_clean_report(10, 50));
1111 }
1112
1113 for _ in 0..10 {
1115 let _ = cal.predict(&make_clean_report(10, 50));
1116 }
1117
1118 let rates = cal.coverage_rates();
1119 let rate = rates.get("test_oracle").copied().unwrap_or(0.0);
1120 assert!(
1121 rate >= 0.8,
1122 "coverage rate should be high for clean data, got {rate:.2}"
1123 );
1124 }
1125
1126 #[test]
1127 fn calibration_report_text_output() {
1128 let config = ConformalConfig::new(0.05).min_samples(3);
1129 let mut cal = ConformalCalibrator::new(config);
1130
1131 for _ in 0..5 {
1132 cal.calibrate(&make_clean_report(10, 50));
1133 }
1134 let report = cal.predict(&make_clean_report(10, 50)).unwrap();
1135 let text = report.to_text();
1136
1137 assert!(text.contains("CONFORMAL CALIBRATION REPORT"));
1138 assert!(text.contains("95.0%"));
1139 assert!(text.contains("alpha=0.050"));
1140 assert!(text.contains("test_oracle"));
1141 }
1142
1143 #[test]
1144 fn calibration_report_json_roundtrip() {
1145 let config = ConformalConfig::new(0.05).min_samples(3);
1146 let mut cal = ConformalCalibrator::new(config);
1147
1148 for _ in 0..5 {
1149 cal.calibrate(&make_clean_report(10, 50));
1150 }
1151 let report = cal.predict(&make_clean_report(10, 50)).unwrap();
1152 let json = report.to_json();
1153
1154 assert!(json.is_object());
1155 assert_eq!(json["alpha"], 0.05);
1156 assert!(json["well_calibrated"].as_bool().unwrap());
1157 assert!(json["prediction_sets"].is_array());
1158 }
1159
1160 #[test]
1161 fn well_calibrated_with_clean_data() {
1162 let config = ConformalConfig::new(0.10).min_samples(3);
1163 let mut cal = ConformalCalibrator::new(config);
1164
1165 for _ in 0..5 {
1166 cal.calibrate(&make_clean_report(10, 50));
1167 }
1168
1169 let mut last_report = None;
1170 for _ in 0..20 {
1171 last_report = cal.predict(&make_clean_report(10, 50));
1172 }
1173 let report = last_report.unwrap();
1174 assert!(report.is_well_calibrated());
1175 assert!(report.miscalibrated_invariants().is_empty());
1176 }
1177
1178 #[test]
1179 fn violation_rates_tracked() {
1180 let config = ConformalConfig::new(0.10).min_samples(2);
1181 let mut cal = ConformalCalibrator::new(config);
1182
1183 cal.calibrate(&make_clean_report(10, 50));
1184 cal.calibrate(&make_violated_report(10, 50));
1185 cal.calibrate(&make_clean_report(10, 50));
1186
1187 let rates = cal.violation_rates();
1188 let rate = rates.get("test_oracle").copied().unwrap_or(0.0);
1189 assert!(
1190 (rate - 1.0 / 3.0).abs() < 0.01,
1191 "expected ~0.33 violation rate, got {rate:.3}"
1192 );
1193 }
1194
1195 #[test]
1196 fn conformity_score_clean_is_low() {
1197 let cal = InvariantCalibration::default();
1198 let entry = OracleEntryReport {
1199 invariant: "test".to_string(),
1200 passed: true,
1201 violation: None,
1202 stats: OracleStats {
1203 entities_tracked: 10,
1204 events_recorded: 50,
1205 },
1206 };
1207 let score = conformity_score(&entry, &cal);
1208 assert!(score < 1.0, "clean score should be < 1.0, got {score}");
1209 }
1210
1211 #[test]
1212 fn conformity_score_violation_is_high() {
1213 let cal = InvariantCalibration::default();
1214 let entry = OracleEntryReport {
1215 invariant: "test".to_string(),
1216 passed: false,
1217 violation: Some("leak".to_string()),
1218 stats: OracleStats {
1219 entities_tracked: 10,
1220 events_recorded: 50,
1221 },
1222 };
1223 let score = conformity_score(&entry, &cal);
1224 assert!(
1225 score >= 1.0,
1226 "violation score should be >= 1.0, got {score}"
1227 );
1228 }
1229
1230 #[test]
1231 fn deterministic_calibration() {
1232 let run = || {
1233 let config = ConformalConfig::new(0.05).min_samples(3);
1234 let mut cal = ConformalCalibrator::new(config);
1235 for i in 0..5 {
1236 cal.calibrate(&make_clean_report(10 + i, 50 + i * 5));
1237 }
1238 cal.predict(&make_clean_report(10, 50))
1239 };
1240
1241 let r1 = run().unwrap();
1242 let r2 = run().unwrap();
1243 assert_eq!(r1.prediction_sets.len(), r2.prediction_sets.len());
1244 for (a, b) in r1.prediction_sets.iter().zip(r2.prediction_sets.iter()) {
1245 assert!((a.score - b.score).abs() < f64::EPSILON);
1246 assert_eq!(a.threshold, b.threshold);
1247 assert_eq!(a.conforming, b.conforming);
1248 }
1249 }
1250
1251 #[test]
1256 fn health_threshold_uncalibrated_returns_none() {
1257 let config = HealthThresholdConfig::new(0.05, ThresholdMode::Upper).min_samples(5);
1258 let cal = HealthThresholdCalibrator::new(config);
1259 assert!(cal.check("queue_depth", 10.0).is_none());
1260 assert!(!cal.is_metric_calibrated("queue_depth"));
1261 }
1262
1263 #[test]
1264 fn health_threshold_upper_normal_conforming() {
1265 let config = HealthThresholdConfig::new(0.05, ThresholdMode::Upper).min_samples(5);
1266 let mut cal = HealthThresholdCalibrator::new(config);
1267
1268 for i in 1..=10 {
1270 cal.calibrate("queue_depth", f64::from(i));
1271 }
1272 assert!(cal.is_metric_calibrated("queue_depth"));
1273
1274 let result = cal.check("queue_depth", 5.0).unwrap();
1276 assert!(result.conforming, "normal depth should be conforming");
1277 }
1278
1279 #[test]
1280 fn health_threshold_upper_extreme_anomalous() {
1281 let config = HealthThresholdConfig::new(0.05, ThresholdMode::Upper).min_samples(5);
1282 let mut cal = HealthThresholdCalibrator::new(config);
1283
1284 for i in 1..=20 {
1286 cal.calibrate("queue_depth", f64::from(i));
1287 }
1288
1289 let result = cal.check("queue_depth", 1000.0).unwrap();
1291 assert!(
1292 !result.conforming,
1293 "extreme depth should be anomalous, got threshold={:.2}",
1294 result.threshold
1295 );
1296 }
1297
1298 #[test]
1299 fn health_threshold_two_sided_normal_conforming() {
1300 let config = HealthThresholdConfig::new(0.05, ThresholdMode::TwoSided).min_samples(5);
1301 let mut cal = HealthThresholdCalibrator::new(config);
1302
1303 for v in [48.0, 50.0, 52.0, 49.0, 51.0, 50.0, 48.0, 52.0, 49.0, 51.0] {
1305 cal.calibrate("latency", v);
1306 }
1307
1308 let result = cal.check("latency", 50.0).unwrap();
1310 assert!(result.conforming, "near-median value should be conforming");
1311 }
1312
1313 #[test]
1314 fn health_threshold_two_sided_extreme_anomalous() {
1315 let config = HealthThresholdConfig::new(0.20, ThresholdMode::TwoSided).min_samples(5);
1316 let mut cal = HealthThresholdCalibrator::new(config);
1317
1318 for v in [48.0, 50.0, 52.0, 49.0, 51.0, 50.0, 48.0, 52.0, 49.0, 51.0] {
1320 cal.calibrate("latency", v);
1321 }
1322
1323 let result = cal.check("latency", 500.0).unwrap();
1325 assert!(
1326 !result.conforming,
1327 "far-from-median value should be anomalous"
1328 );
1329 }
1330
1331 #[test]
1332 fn health_threshold_adaptive_grows_with_data() {
1333 let config = HealthThresholdConfig::new(0.20, ThresholdMode::Upper).min_samples(5);
1334 let mut cal = HealthThresholdCalibrator::new(config);
1335
1336 for i in 1..=10 {
1338 cal.calibrate("metric", f64::from(i));
1339 }
1340 let t1 = cal.threshold("metric").unwrap();
1341
1342 for i in 11..=20 {
1344 cal.calibrate("metric", f64::from(i));
1345 }
1346 let t2 = cal.threshold("metric").unwrap();
1347
1348 assert!(
1349 t2 >= t1,
1350 "threshold should grow as calibration expands, t1={t1}, t2={t2}"
1351 );
1352 }
1353
1354 #[test]
1355 fn health_threshold_coverage_tracking() {
1356 let config = HealthThresholdConfig::new(0.10, ThresholdMode::Upper).min_samples(5);
1357 let mut cal = HealthThresholdCalibrator::new(config);
1358
1359 for i in 1..=20 {
1360 cal.calibrate("depth", f64::from(i));
1361 }
1362
1363 for i in 1..=10 {
1365 let _ = cal.check_and_track("depth", f64::from(i));
1366 }
1367
1368 let rates = cal.coverage_rates();
1369 let rate = rates.get("depth").copied().unwrap_or(0.0);
1370 assert!(
1371 rate >= 0.8,
1372 "coverage rate for normal data should be high, got {rate:.2}"
1373 );
1374 }
1375
1376 #[test]
1377 fn health_threshold_multiple_metrics() {
1378 let config = HealthThresholdConfig::new(0.05, ThresholdMode::Upper).min_samples(3);
1379 let mut cal = HealthThresholdCalibrator::new(config);
1380
1381 for i in 1..=10 {
1382 cal.calibrate("queue_depth", f64::from(i));
1383 cal.calibrate("restart_rate", f64::from(i) * 0.01);
1384 }
1385
1386 assert!(cal.is_metric_calibrated("queue_depth"));
1387 assert!(cal.is_metric_calibrated("restart_rate"));
1388
1389 let results = cal.check_all(&[("queue_depth", 5.0), ("restart_rate", 0.05)]);
1390 assert_eq!(results.len(), 2);
1391 assert!(results.iter().all(|r| r.conforming));
1392 }
1393
1394 #[test]
1395 fn health_threshold_any_anomalous() {
1396 let config = HealthThresholdConfig::new(0.20, ThresholdMode::Upper).min_samples(3);
1397 let mut cal = HealthThresholdCalibrator::new(config);
1398
1399 for i in 1..=10 {
1400 cal.calibrate("queue_depth", f64::from(i));
1401 }
1402
1403 assert!(!cal.any_anomalous(&[("queue_depth", 5.0)]));
1404 assert!(cal.any_anomalous(&[("queue_depth", 10000.0)]));
1405 }
1406
1407 #[test]
1408 fn health_threshold_display() {
1409 let config = HealthThresholdConfig::new(0.05, ThresholdMode::Upper).min_samples(3);
1410 let mut cal = HealthThresholdCalibrator::new(config);
1411
1412 for i in 1..=10 {
1413 cal.calibrate("queue_depth", f64::from(i));
1414 }
1415
1416 let result = cal.check("queue_depth", 5.0).unwrap();
1417 let display = format!("{result}");
1418 assert!(display.contains("queue_depth"));
1419 assert!(display.contains("OK") || display.contains("ANOMALOUS"));
1420 }
1421
1422 #[test]
1423 fn health_threshold_deterministic() {
1424 let run = || {
1425 let config = HealthThresholdConfig::new(0.05, ThresholdMode::Upper).min_samples(3);
1426 let mut cal = HealthThresholdCalibrator::new(config);
1427 for i in 1..=10 {
1428 cal.calibrate("m", f64::from(i));
1429 }
1430 cal.check("m", 7.5).unwrap()
1431 };
1432
1433 let r1 = run();
1434 let r2 = run();
1435 assert_eq!(r1.threshold, r2.threshold);
1436 assert!((r1.nonconformity_score - r2.nonconformity_score).abs() < f64::EPSILON);
1437 assert_eq!(r1.conforming, r2.conforming);
1438 }
1439
1440 #[test]
1441 fn health_threshold_ignores_non_finite_calibration_values() {
1442 let config = HealthThresholdConfig::new(0.20, ThresholdMode::Upper).min_samples(3);
1443 let mut cal = HealthThresholdCalibrator::new(config);
1444
1445 for i in 1..=10 {
1446 cal.calibrate("metric", f64::from(i));
1447 }
1448 cal.calibrate("metric", f64::NAN);
1449 cal.calibrate("metric", f64::INFINITY);
1450 cal.calibrate("metric", f64::NEG_INFINITY);
1451
1452 let counts = cal.metric_counts();
1453 assert_eq!(counts.get("metric"), Some(&10));
1454 let threshold = cal
1455 .threshold("metric")
1456 .expect("metric should be calibrated");
1457 assert!(threshold.is_finite());
1458 }
1459
1460 #[test]
1461 fn health_threshold_non_finite_check_is_anomalous() {
1462 let config = HealthThresholdConfig::new(0.20, ThresholdMode::Upper).min_samples(3);
1463 let mut cal = HealthThresholdCalibrator::new(config);
1464 for i in 1..=10 {
1465 cal.calibrate("metric", f64::from(i));
1466 }
1467
1468 let result = cal
1469 .check("metric", f64::NAN)
1470 .expect("metric should be calibrated");
1471 assert!(!result.conforming);
1472 assert!(result.nonconformity_score.is_infinite());
1473 assert!(result.threshold.is_finite());
1474 }
1475
1476 #[test]
1477 fn health_threshold_metric_counts() {
1478 let config = HealthThresholdConfig::new(0.05, ThresholdMode::Upper).min_samples(3);
1479 let mut cal = HealthThresholdCalibrator::new(config);
1480
1481 cal.calibrate("a", 1.0);
1482 cal.calibrate("a", 2.0);
1483 cal.calibrate("b", 10.0);
1484
1485 let counts = cal.metric_counts();
1486 assert_eq!(counts.get("a"), Some(&2));
1487 assert_eq!(counts.get("b"), Some(&1));
1488 }
1489
1490 #[test]
1495 fn obs_conformal_coverage_guarantee_holds() {
1496 let alpha = 0.10;
1499 let config = ConformalConfig::new(alpha).min_samples(10);
1500 let mut cal = ConformalCalibrator::new(config);
1501
1502 for i in 0..10 {
1504 cal.calibrate(&make_clean_report(10 + i, 50 + i * 3));
1505 }
1506
1507 let mut conforming_count = 0;
1509 let total = 100;
1510 for _ in 0..total {
1511 if let Some(report) = cal.predict(&make_clean_report(10, 50)) {
1512 if report.prediction_sets.iter().all(|ps| ps.conforming) {
1513 conforming_count += 1;
1514 }
1515 }
1516 }
1517
1518 let coverage = f64::from(conforming_count) / f64::from(total);
1519 assert!(
1520 coverage >= 1.0 - alpha - 0.05,
1521 "coverage {coverage:.2} should be ≥ {:.2}",
1522 1.0 - alpha - 0.05
1523 );
1524 }
1525
1526 #[test]
1527 fn obs_health_threshold_coverage_guarantee_holds() {
1528 let alpha = 0.10;
1529 let config = HealthThresholdConfig::new(alpha, ThresholdMode::Upper).min_samples(20);
1530 let mut cal = HealthThresholdCalibrator::new(config);
1531
1532 for i in 1..=20 {
1534 cal.calibrate("depth", f64::from(i));
1535 }
1536
1537 let mut conforming = 0;
1539 let total = 50;
1540 for i in 0..total {
1541 let value = f64::from((i % 20) + 1);
1542 if let Some(result) = cal.check("depth", value) {
1543 if result.conforming {
1544 conforming += 1;
1545 }
1546 }
1547 }
1548
1549 let coverage = f64::from(conforming) / f64::from(total);
1550 assert!(
1551 coverage >= 1.0 - alpha - 0.05,
1552 "health threshold coverage {coverage:.2} should be ≥ {:.2}",
1553 1.0 - alpha - 0.05
1554 );
1555 }
1556
1557 #[test]
1558 fn obs_conformal_anomaly_detection_deterministic() {
1559 let run = || {
1561 let config = ConformalConfig::new(0.05).min_samples(5);
1562 let mut cal = ConformalCalibrator::new(config);
1563
1564 for i in 0..8 {
1565 cal.calibrate(&make_clean_report(10 + i, 50 + i * 3));
1566 }
1567
1568 let clean = cal.predict(&make_clean_report(10, 50)).unwrap();
1569 let anomalous = cal.predict(&make_violated_report(10, 50)).unwrap();
1570 (clean, anomalous)
1571 };
1572
1573 let (c1, a1) = run();
1574 let (c2, a2) = run();
1575
1576 assert_eq!(c1.prediction_sets.len(), c2.prediction_sets.len());
1578 for (p1, p2) in c1.prediction_sets.iter().zip(c2.prediction_sets.iter()) {
1579 assert!((p1.score - p2.score).abs() < f64::EPSILON);
1580 assert_eq!(p1.threshold, p2.threshold);
1581 assert_eq!(p1.conforming, p2.conforming);
1582 }
1583
1584 assert_eq!(a1.prediction_sets.len(), a2.prediction_sets.len());
1586 for (p1, p2) in a1.prediction_sets.iter().zip(a2.prediction_sets.iter()) {
1587 assert!((p1.score - p2.score).abs() < f64::EPSILON);
1588 assert_eq!(p1.conforming, p2.conforming);
1589 }
1590 }
1591
1592 #[test]
1593 fn obs_conformal_report_well_calibrated_diagnostics() {
1594 let config = ConformalConfig::new(0.05).min_samples(5);
1595 let mut cal = ConformalCalibrator::new(config);
1596
1597 for i in 0..10 {
1599 cal.calibrate(&make_clean_report(10 + i, 50 + i * 2));
1600 }
1601
1602 let mut last_report = None;
1604 for _ in 0..30 {
1605 last_report = cal.predict(&make_clean_report(10, 50));
1606 }
1607
1608 let report = last_report.unwrap();
1609
1610 assert!(report.is_well_calibrated());
1612 assert!(report.miscalibrated_invariants().is_empty());
1613
1614 let text = report.to_text();
1616 assert!(text.contains("CONFORMAL CALIBRATION REPORT"));
1617 assert!(text.contains("WELL-CALIBRATED"));
1618
1619 let json = report.to_json();
1621 assert!(json["well_calibrated"].as_bool().unwrap());
1622 assert_eq!(json["alpha"], 0.05);
1623 }
1624
1625 #[test]
1626 fn conformal_config_debug_clone_default() {
1627 let c = ConformalConfig::default();
1628 let dbg = format!("{c:?}");
1629 assert!(dbg.contains("ConformalConfig"));
1630
1631 let c2 = c;
1632 assert!((c2.alpha - 0.05).abs() < f64::EPSILON);
1633 assert_eq!(c2.min_calibration_samples, 5);
1634 }
1635
1636 #[test]
1637 #[should_panic(expected = "alpha must be finite and in (0, 1)")]
1638 fn conformal_config_rejects_invalid_alpha() {
1639 let _ = ConformalConfig::new(1.0);
1640 }
1641
1642 #[test]
1643 #[should_panic(expected = "min_calibration_samples must be > 0")]
1644 fn conformal_calibrator_rejects_zero_min_samples() {
1645 let mut cfg = ConformalConfig::new(0.05);
1646 cfg.min_calibration_samples = 0;
1647 let _ = ConformalCalibrator::new(cfg);
1648 }
1649
1650 #[test]
1651 #[should_panic(expected = "min_calibration_samples must be > 0")]
1652 fn conformal_config_builder_rejects_zero_min_samples() {
1653 let _ = ConformalConfig::new(0.05).min_samples(0);
1654 }
1655
1656 #[test]
1657 #[should_panic(expected = "alpha must be finite and in (0, 1)")]
1658 fn health_threshold_config_rejects_invalid_alpha() {
1659 let _ = HealthThresholdConfig::new(0.0, ThresholdMode::Upper);
1660 }
1661
1662 #[test]
1663 #[should_panic(expected = "min_calibration_samples must be > 0")]
1664 fn health_threshold_calibrator_rejects_zero_min_samples() {
1665 let mut cfg = HealthThresholdConfig::new(0.05, ThresholdMode::Upper);
1666 cfg.min_calibration_samples = 0;
1667 let _ = HealthThresholdCalibrator::new(cfg);
1668 }
1669
1670 #[test]
1671 #[should_panic(expected = "min_calibration_samples must be > 0")]
1672 fn health_threshold_config_builder_rejects_zero_min_samples() {
1673 let _ = HealthThresholdConfig::new(0.05, ThresholdMode::Upper).min_samples(0);
1674 }
1675
1676 #[test]
1677 fn conformity_score_debug_clone_copy_eq() {
1678 let s = ConformityScore {
1679 value: 0.42,
1680 violated: false,
1681 };
1682 let dbg = format!("{s:?}");
1683 assert!(dbg.contains("ConformityScore"));
1684
1685 let s2 = s;
1686 assert_eq!(s, s2);
1687
1688 let s3 = s;
1690 assert_eq!(s, s3);
1691 }
1692
1693 #[test]
1694 fn threshold_mode_debug_clone_copy_eq() {
1695 let m = ThresholdMode::Upper;
1696 let dbg = format!("{m:?}");
1697 assert!(dbg.contains("Upper"));
1698
1699 let m2 = m;
1700 assert_eq!(m, m2);
1701
1702 let m3 = m;
1703 assert_eq!(m, m3);
1704
1705 assert_ne!(ThresholdMode::Upper, ThresholdMode::TwoSided);
1706 }
1707
1708 #[test]
1709 fn coverage_tracker_debug_clone() {
1710 let t = CoverageTracker {
1711 total: 10,
1712 covered: 9,
1713 };
1714 let dbg = format!("{t:?}");
1715 assert!(dbg.contains("CoverageTracker"));
1716
1717 let t2 = t;
1718 assert_eq!(t2.total, 10);
1719 assert_eq!(t2.covered, 9);
1720 }
1721
1722 fn report_with(alpha: f64, total: usize, covered: usize) -> CalibrationReport {
1728 CalibrationReport {
1729 prediction_sets: Vec::new(),
1730 coverage: BTreeMap::new(),
1731 overall_coverage: CoverageTracker { total, covered },
1732 alpha,
1733 calibration_samples: total,
1734 }
1735 }
1736
1737 #[test]
1738 fn _9u4ext_tolerance_is_alpha_derived() {
1739 let r = report_with(0.05, 1, 1);
1740 assert!((r.calibration_tolerance() - 0.01).abs() < 1e-12);
1742 let r = report_with(0.20, 1, 1);
1743 assert!((r.calibration_tolerance() - 0.04).abs() < 1e-12);
1745 }
1746
1747 #[test]
1748 fn _9u4ext_well_calibrated_strict_at_default_alpha() {
1749 let r = report_with(0.05, 100, 90);
1754 assert!(
1755 !r.is_well_calibrated(),
1756 "90% coverage at alpha=0.05 should now be flagged miscalibrated"
1757 );
1758 let r = report_with(0.05, 100, 94);
1760 assert!(r.is_well_calibrated(), "94% should sit on the new boundary");
1761 }
1762
1763 #[test]
1764 fn _9u4ext_well_calibrated_target_met() {
1765 let r = report_with(0.05, 1000, 950);
1767 assert!(r.is_well_calibrated());
1768 }
1769}