1use std::fmt::Write as _;
25
26use serde::{Deserialize, Serialize};
27
28use super::{OracleReport, OracleStats};
29
30#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
36pub enum EvidenceStrength {
37 Against,
39 Negligible,
41 Positive,
43 Strong,
45 VeryStrong,
47}
48
49impl EvidenceStrength {
50 #[must_use]
52 pub fn from_log10_bf(log10_bf: f64) -> Self {
53 if log10_bf.is_nan() {
54 Self::Negligible
55 } else if log10_bf < 0.0 {
56 Self::Against
57 } else if log10_bf < 0.5 {
58 Self::Negligible
59 } else if log10_bf < 1.3 {
60 Self::Positive
61 } else if log10_bf < 2.2 {
62 Self::Strong
63 } else {
64 Self::VeryStrong
65 }
66 }
67
68 #[must_use]
70 pub fn label(self) -> &'static str {
71 match self {
72 Self::Against => "against",
73 Self::Negligible => "negligible",
74 Self::Positive => "positive",
75 Self::Strong => "strong",
76 Self::VeryStrong => "very strong",
77 }
78 }
79}
80
81impl std::fmt::Display for EvidenceStrength {
82 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
83 f.write_str(self.label())
84 }
85}
86
87#[derive(Debug, Clone, Serialize, Deserialize)]
97pub struct BayesFactor {
98 pub log10_bf: f64,
100 pub hypothesis: String,
102 pub strength: EvidenceStrength,
104}
105
106impl BayesFactor {
107 #[must_use]
111 pub fn from_log_likelihoods(
112 log10_likelihood_h1: f64,
113 log10_likelihood_h0: f64,
114 hypothesis: String,
115 ) -> Self {
116 let log10_bf = log10_likelihood_h1 - log10_likelihood_h0;
117 Self {
118 log10_bf,
119 hypothesis,
120 strength: EvidenceStrength::from_log10_bf(log10_bf),
121 }
122 }
123
124 #[must_use]
126 pub fn value(&self) -> f64 {
127 10.0_f64.powf(self.log10_bf.clamp(-300.0, 300.0))
128 }
129}
130
131#[derive(Debug, Clone, Serialize, Deserialize)]
137pub struct LogLikelihoodContributions {
138 pub structural: f64,
140 pub detection: f64,
142 pub total: f64,
144}
145
146#[derive(Debug, Clone, Serialize, Deserialize)]
160pub struct EvidenceLine {
161 pub equation: String,
163 pub substitution: String,
165 pub intuition: String,
167}
168
169#[derive(Debug, Clone, Serialize, Deserialize)]
175pub struct EvidenceEntry {
176 pub invariant: String,
178 pub passed: bool,
180 pub bayes_factor: BayesFactor,
182 pub log_likelihoods: LogLikelihoodContributions,
184 pub evidence_lines: Vec<EvidenceLine>,
186}
187
188#[derive(Debug, Clone, Serialize, Deserialize)]
194pub struct EvidenceSummary {
195 pub total_invariants: usize,
197 pub violations_detected: usize,
199 pub strongest_violation: Option<String>,
201 pub strongest_clean: Option<String>,
203 pub aggregate_log10_bf: f64,
205}
206
207#[derive(Debug, Clone, Serialize, Deserialize)]
215pub struct EvidenceLedger {
216 pub entries: Vec<EvidenceEntry>,
218 pub summary: EvidenceSummary,
220 pub check_time_nanos: u64,
222}
223
224#[derive(Debug, Clone)]
232pub struct DetectionModel {
233 pub per_entity_detection_rate: f64,
236 pub false_positive_rate: f64,
239}
240
241impl Default for DetectionModel {
242 fn default() -> Self {
243 Self {
244 per_entity_detection_rate: 0.9,
245 false_positive_rate: 0.001,
246 }
247 }
248}
249
250impl DetectionModel {
251 #[must_use]
258 pub fn p_detection_given_violation(&self, entities_tracked: usize) -> f64 {
259 let n = f64::from(entities_tracked.max(1).min(u32::MAX as usize) as u32);
260 1.0 - (1.0 - self.per_entity_detection_rate).powf(n)
261 }
262
263 #[must_use]
267 pub fn p_pass_given_clean(&self) -> f64 {
268 1.0 - self.false_positive_rate
269 }
270
271 #[must_use]
275 pub fn p_pass_given_violation(&self, entities_tracked: usize) -> f64 {
276 let n = f64::from(entities_tracked.max(1).min(u32::MAX as usize) as u32);
277 (1.0 - self.per_entity_detection_rate).powf(n)
278 }
279
280 #[must_use]
282 pub fn compute_evidence(
283 &self,
284 invariant: &str,
285 passed: bool,
286 stats: &OracleStats,
287 ) -> (BayesFactor, LogLikelihoodContributions, Vec<EvidenceLine>) {
288 let n = stats.entities_tracked;
289
290 if passed {
291 let p_h0 = self.p_pass_given_clean();
293 let p_h1 = self.p_pass_given_violation(n);
294 let log10_h0 = p_h0.log10();
295 let log10_h1 = p_h1.log10();
296
297 let log10_bf_violation = log10_h1 - log10_h0;
299
300 let structural = structural_contribution(stats);
302
303 let detection = log10_bf_violation;
304 let total = structural + detection;
305
306 let bf_val = 10.0_f64.powf(total.clamp(-300.0, 300.0));
307
308 let bf = BayesFactor {
309 log10_bf: total,
310 hypothesis: format!("{invariant} violated"),
311 strength: EvidenceStrength::from_log10_bf(total),
312 };
313
314 let lines = vec![
315 EvidenceLine {
316 equation: "BF_violation = P(pass | violated) / P(pass | holds)".into(),
317 substitution: format!("BF = {p_h1:.6} / {p_h0:.6} = {bf_val:.4}"),
318 intuition: format!(
319 "{} evidence that '{invariant}' is violated ({n} entities tracked, oracle saw pass)",
320 bf.strength.label().to_uppercase(),
321 ),
322 },
323 EvidenceLine {
324 equation: "P(pass | violated) = (1 − p)^n".into(),
325 substitution: format!(
326 "P(pass | violated) = (1 − {:.2})^{n} = {:.6}",
327 self.per_entity_detection_rate, p_h1,
328 ),
329 intuition: format!(
330 "With {n} entities, a real violation would be missed with probability {p_h1:.6}",
331 ),
332 },
333 ];
334
335 let ll = LogLikelihoodContributions {
336 structural,
337 detection,
338 total,
339 };
340
341 (bf, ll, lines)
342 } else {
343 let p_h1 = self.p_detection_given_violation(n);
345 let p_h0 = self.false_positive_rate;
346 let log10_h1 = p_h1.log10();
347 let log10_h0 = p_h0.log10();
348
349 let structural = structural_contribution(stats);
350 let detection = log10_h1 - log10_h0;
351 let total = structural + detection;
352
353 let bf_val = 10.0_f64.powf(total.clamp(-300.0, 300.0));
354
355 let bf = BayesFactor {
356 log10_bf: total,
357 hypothesis: format!("{invariant} violated"),
358 strength: EvidenceStrength::from_log10_bf(total),
359 };
360
361 let lines = vec![
362 EvidenceLine {
363 equation:
364 "BF_violation = P(violation_observed | violated) / P(violation_observed | holds)"
365 .into(),
366 substitution: format!("BF = {p_h1:.6} / {p_h0:.6} = {bf_val:.1}"),
367 intuition: format!(
368 "{} evidence that '{invariant}' is violated ({n} entities tracked, violation observed)",
369 bf.strength.label().to_uppercase(),
370 ),
371 },
372 EvidenceLine {
373 equation: "P(violation_observed | violated) = 1 − (1 − p)^n".into(),
374 substitution: format!(
375 "P(detected | violated) = 1 − (1 − {:.2})^{n} = {:.6}",
376 self.per_entity_detection_rate, p_h1,
377 ),
378 intuition: format!(
379 "With {n} entities, a real violation would be detected with probability {p_h1:.6}",
380 ),
381 },
382 ];
383
384 let ll = LogLikelihoodContributions {
385 structural,
386 detection,
387 total,
388 };
389
390 (bf, ll, lines)
391 }
392 }
393}
394
395fn structural_contribution(stats: &OracleStats) -> f64 {
400 let events = stats.events_recorded.min(u32::MAX as usize) as u32;
401 (1.0 + f64::from(events) / 100.0).log10()
402}
403
404impl EvidenceLedger {
409 #[must_use]
412 pub fn from_report(report: &OracleReport) -> Self {
413 Self::from_report_with_model(report, &DetectionModel::default())
414 }
415
416 #[must_use]
419 pub fn from_report_with_model(report: &OracleReport, model: &DetectionModel) -> Self {
420 let entries: Vec<EvidenceEntry> = report
421 .entries
422 .iter()
423 .map(|entry| {
424 let (bf, ll, lines) =
425 model.compute_evidence(&entry.invariant, entry.passed, &entry.stats);
426 EvidenceEntry {
427 invariant: entry.invariant.clone(),
428 passed: entry.passed,
429 bayes_factor: bf,
430 log_likelihoods: ll,
431 evidence_lines: lines,
432 }
433 })
434 .collect();
435
436 let summary = Self::compute_summary(&entries);
437 Self {
438 entries,
439 summary,
440 check_time_nanos: report.check_time_nanos,
441 }
442 }
443
444 fn compute_summary(entries: &[EvidenceEntry]) -> EvidenceSummary {
445 let total_invariants = entries.len();
446 let violations_detected = entries.iter().filter(|e| !e.passed).count();
447
448 let strongest_violation = entries
449 .iter()
450 .filter(|e| !e.passed)
451 .max_by(|a, b| {
452 a.bayes_factor
453 .log10_bf
454 .partial_cmp(&b.bayes_factor.log10_bf)
455 .unwrap_or(std::cmp::Ordering::Equal)
456 })
457 .map(|e| e.invariant.clone());
458
459 let strongest_clean = entries
460 .iter()
461 .filter(|e| e.passed)
462 .min_by(|a, b| {
463 a.bayes_factor
464 .log10_bf
465 .partial_cmp(&b.bayes_factor.log10_bf)
466 .unwrap_or(std::cmp::Ordering::Equal)
467 })
468 .map(|e| e.invariant.clone());
469
470 let aggregate_log10_bf: f64 = entries
471 .iter()
472 .filter(|e| !e.passed)
473 .map(|e| e.bayes_factor.log10_bf)
474 .sum();
475
476 EvidenceSummary {
477 total_invariants,
478 violations_detected,
479 strongest_violation,
480 strongest_clean,
481 aggregate_log10_bf,
482 }
483 }
484
485 #[must_use]
487 pub fn violations_by_strength(&self) -> Vec<&EvidenceEntry> {
488 let mut v: Vec<_> = self.entries.iter().filter(|e| !e.passed).collect();
489 v.sort_by(|a, b| {
490 b.bayes_factor
491 .log10_bf
492 .partial_cmp(&a.bayes_factor.log10_bf)
493 .unwrap_or(std::cmp::Ordering::Equal)
494 });
495 v
496 }
497
498 #[must_use]
501 pub fn clean_by_confidence(&self) -> Vec<&EvidenceEntry> {
502 let mut v: Vec<_> = self.entries.iter().filter(|e| e.passed).collect();
503 v.sort_by(|a, b| {
504 a.bayes_factor
505 .log10_bf
506 .partial_cmp(&b.bayes_factor.log10_bf)
507 .unwrap_or(std::cmp::Ordering::Equal)
508 });
509 v
510 }
511
512 #[must_use]
514 pub fn to_text(&self) -> String {
515 let mut out = String::new();
516
517 let _ = writeln!(
518 &mut out,
519 "╔══════════════════════════════════════════════════╗"
520 );
521 let _ = writeln!(
522 &mut out,
523 "║ EVIDENCE LEDGER — ORACLE DIAGNOSTICS ║"
524 );
525 let _ = writeln!(
526 &mut out,
527 "╚══════════════════════════════════════════════════╝"
528 );
529 let _ = writeln!(&mut out);
530 let _ = writeln!(
531 &mut out,
532 " Invariants examined: {}",
533 self.summary.total_invariants
534 );
535 let _ = writeln!(
536 &mut out,
537 " Violations detected: {}",
538 self.summary.violations_detected
539 );
540 if let Some(ref s) = self.summary.strongest_violation {
541 let _ = writeln!(&mut out, " Strongest violation: {s}");
542 }
543 let _ = writeln!(
544 &mut out,
545 " Aggregate log₁₀(BF): {:.3}",
546 self.summary.aggregate_log10_bf
547 );
548 let _ = writeln!(&mut out, " Check time: {}ns", self.check_time_nanos);
549 let _ = writeln!(&mut out);
550
551 let violations = self.violations_by_strength();
553 if !violations.is_empty() {
554 let _ = writeln!(
555 &mut out,
556 "── VIOLATIONS ──────────────────────────────────────"
557 );
558 for entry in violations {
559 write_entry(&mut out, entry);
560 }
561 }
562
563 let clean = self.clean_by_confidence();
565 if !clean.is_empty() {
566 let _ = writeln!(
567 &mut out,
568 "── CLEAN INVARIANTS ────────────────────────────────"
569 );
570 for entry in clean {
571 write_entry(&mut out, entry);
572 }
573 }
574
575 out
576 }
577
578 #[must_use]
580 pub fn to_json(&self) -> serde_json::Value {
581 serde_json::to_value(self).unwrap_or_default()
582 }
583}
584
585fn write_entry(out: &mut String, entry: &EvidenceEntry) {
586 let status = if entry.passed { "PASS" } else { "FAIL" };
587 let _ = writeln!(out);
588 let _ = writeln!(
589 out,
590 " [{status}] {inv} (BF = {bf:.2}, strength = {strength})",
591 inv = entry.invariant,
592 bf = entry.bayes_factor.value(),
593 strength = entry.bayes_factor.strength,
594 );
595 let _ = writeln!(
596 out,
597 " log₁₀(BF) = {:.4} [structural={:.4}, detection={:.4}]",
598 entry.log_likelihoods.total,
599 entry.log_likelihoods.structural,
600 entry.log_likelihoods.detection,
601 );
602
603 for (i, line) in entry.evidence_lines.iter().enumerate() {
604 let _ = writeln!(out, " ({}) {}", i + 1, line.equation);
605 let _ = writeln!(out, " → {}", line.substitution);
606 let _ = writeln!(out, " ⇒ {}", line.intuition);
607 }
608}
609
610#[cfg(test)]
615mod tests {
616 use super::*;
617 use crate::lab::oracle::{OracleEntryReport, OracleReport};
618
619 fn make_clean_report() -> OracleReport {
620 OracleReport {
621 entries: vec![
622 OracleEntryReport {
623 invariant: "task_leak".into(),
624 passed: true,
625 violation: None,
626 stats: OracleStats {
627 entities_tracked: 5,
628 events_recorded: 10,
629 },
630 },
631 OracleEntryReport {
632 invariant: "obligation_leak".into(),
633 passed: true,
634 violation: None,
635 stats: OracleStats {
636 entities_tracked: 3,
637 events_recorded: 6,
638 },
639 },
640 ],
641 total: 2,
642 passed: 2,
643 failed: 0,
644 check_time_nanos: 42,
645 }
646 }
647
648 fn make_violation_report() -> OracleReport {
649 OracleReport {
650 entries: vec![
651 OracleEntryReport {
652 invariant: "task_leak".into(),
653 passed: false,
654 violation: Some("leaked 2 tasks".into()),
655 stats: OracleStats {
656 entities_tracked: 5,
657 events_recorded: 10,
658 },
659 },
660 OracleEntryReport {
661 invariant: "quiescence".into(),
662 passed: true,
663 violation: None,
664 stats: OracleStats {
665 entities_tracked: 2,
666 events_recorded: 4,
667 },
668 },
669 OracleEntryReport {
670 invariant: "obligation_leak".into(),
671 passed: false,
672 violation: Some("leaked 1 obligation".into()),
673 stats: OracleStats {
674 entities_tracked: 1,
675 events_recorded: 2,
676 },
677 },
678 ],
679 total: 3,
680 passed: 1,
681 failed: 2,
682 check_time_nanos: 100,
683 }
684 }
685
686 #[test]
689 fn strength_from_log10_bf() {
690 assert_eq!(
691 EvidenceStrength::from_log10_bf(-1.0),
692 EvidenceStrength::Against
693 );
694 assert_eq!(
695 EvidenceStrength::from_log10_bf(0.0),
696 EvidenceStrength::Negligible
697 );
698 assert_eq!(
699 EvidenceStrength::from_log10_bf(0.49),
700 EvidenceStrength::Negligible
701 );
702 assert_eq!(
703 EvidenceStrength::from_log10_bf(0.5),
704 EvidenceStrength::Positive
705 );
706 assert_eq!(
707 EvidenceStrength::from_log10_bf(1.29),
708 EvidenceStrength::Positive
709 );
710 assert_eq!(
711 EvidenceStrength::from_log10_bf(1.3),
712 EvidenceStrength::Strong
713 );
714 assert_eq!(
715 EvidenceStrength::from_log10_bf(2.19),
716 EvidenceStrength::Strong
717 );
718 assert_eq!(
719 EvidenceStrength::from_log10_bf(2.2),
720 EvidenceStrength::VeryStrong
721 );
722 assert_eq!(
723 EvidenceStrength::from_log10_bf(5.0),
724 EvidenceStrength::VeryStrong
725 );
726 }
727
728 #[test]
729 fn strength_labels() {
730 assert_eq!(EvidenceStrength::Against.label(), "against");
731 assert_eq!(EvidenceStrength::Negligible.label(), "negligible");
732 assert_eq!(EvidenceStrength::Positive.label(), "positive");
733 assert_eq!(EvidenceStrength::Strong.label(), "strong");
734 assert_eq!(EvidenceStrength::VeryStrong.label(), "very strong");
735 }
736
737 #[test]
738 fn strength_display() {
739 assert_eq!(format!("{}", EvidenceStrength::Strong), "strong");
740 }
741
742 #[test]
745 fn bayes_factor_from_log_likelihoods() {
746 let bf = BayesFactor::from_log_likelihoods(-0.5, -3.0, "test".into());
747 assert!((bf.log10_bf - 2.5).abs() < 1e-10);
748 assert_eq!(bf.strength, EvidenceStrength::VeryStrong);
749 }
750
751 #[test]
752 fn bayes_factor_value() {
753 let bf = BayesFactor::from_log_likelihoods(0.0, -2.0, "test".into());
754 assert!((bf.value() - 100.0).abs() < 1e-6);
756 }
757
758 #[test]
759 fn bayes_factor_value_clamped() {
760 let bf = BayesFactor {
762 log10_bf: 1000.0,
763 hypothesis: "extreme".into(),
764 strength: EvidenceStrength::VeryStrong,
765 };
766 assert!(bf.value().is_finite());
767 }
768
769 #[test]
772 fn detection_model_default() {
773 let m = DetectionModel::default();
774 assert!((m.per_entity_detection_rate - 0.9).abs() < 1e-10);
775 assert!((m.false_positive_rate - 0.001).abs() < 1e-10);
776 }
777
778 #[test]
779 fn detection_model_p_detection_single_entity() {
780 let m = DetectionModel::default();
781 let p = m.p_detection_given_violation(1);
782 assert!((p - 0.9).abs() < 1e-10);
783 }
784
785 #[test]
786 fn detection_model_p_detection_multiple_entities() {
787 let m = DetectionModel::default();
788 let p = m.p_detection_given_violation(2);
790 assert!((p - 0.99).abs() < 1e-10);
791 }
792
793 #[test]
794 fn detection_model_p_detection_zero_entities_uses_one() {
795 let m = DetectionModel::default();
796 let p = m.p_detection_given_violation(0);
797 assert!(
798 (p - 0.9).abs() < 1e-10,
799 "zero entities should be treated as 1"
800 );
801 }
802
803 #[test]
804 fn detection_model_p_pass_given_clean() {
805 let m = DetectionModel::default();
806 assert!((m.p_pass_given_clean() - 0.999).abs() < 1e-10);
807 }
808
809 #[test]
810 fn detection_model_p_pass_given_violation() {
811 let m = DetectionModel::default();
812 assert!((m.p_pass_given_violation(1) - 0.1).abs() < 1e-10);
814 assert!((m.p_pass_given_violation(2) - 0.01).abs() < 1e-10);
816 }
817
818 #[test]
821 fn structural_contribution_zero_events() {
822 let s = structural_contribution(&OracleStats {
823 entities_tracked: 0,
824 events_recorded: 0,
825 });
826 assert!((s - 0.0_f64.log10()).abs() < 1e-10 || (s - (1.0_f64).log10()).abs() < 1e-10);
827 assert!(s.abs() < 1e-10);
829 }
830
831 #[test]
832 fn structural_contribution_increases_with_events() {
833 let s1 = structural_contribution(&OracleStats {
834 entities_tracked: 0,
835 events_recorded: 10,
836 });
837 let s2 = structural_contribution(&OracleStats {
838 entities_tracked: 0,
839 events_recorded: 100,
840 });
841 assert!(s2 > s1);
842 }
843
844 #[test]
847 fn ledger_from_clean_report() {
848 let report = make_clean_report();
849 let ledger = EvidenceLedger::from_report(&report);
850
851 assert_eq!(ledger.entries.len(), 2);
852 assert_eq!(ledger.summary.total_invariants, 2);
853 assert_eq!(ledger.summary.violations_detected, 0);
854 assert!(ledger.summary.strongest_violation.is_none());
855 assert!((ledger.summary.aggregate_log10_bf).abs() < 1e-10);
856 assert_eq!(ledger.check_time_nanos, 42);
857 }
858
859 #[test]
860 fn ledger_clean_entries_have_negative_bf() {
861 let report = make_clean_report();
862 let ledger = EvidenceLedger::from_report(&report);
863
864 for entry in &ledger.entries {
865 assert!(entry.passed);
866 assert!(
868 entry.bayes_factor.log10_bf < 0.0,
869 "clean entry '{inv}' should have BF < 1, got log10_bf={bf}",
870 inv = entry.invariant,
871 bf = entry.bayes_factor.log10_bf,
872 );
873 assert_eq!(entry.bayes_factor.strength, EvidenceStrength::Against);
874 }
875 }
876
877 #[test]
878 fn ledger_clean_evidence_lines() {
879 let report = make_clean_report();
880 let ledger = EvidenceLedger::from_report(&report);
881
882 for entry in &ledger.entries {
883 assert!(
884 !entry.evidence_lines.is_empty(),
885 "entry should have evidence lines"
886 );
887 assert!(
889 entry.evidence_lines[0]
890 .equation
891 .contains("P(pass | violated)")
892 );
893 }
894 }
895
896 #[test]
899 fn ledger_from_violation_report() {
900 let report = make_violation_report();
901 let ledger = EvidenceLedger::from_report(&report);
902
903 assert_eq!(ledger.entries.len(), 3);
904 assert_eq!(ledger.summary.total_invariants, 3);
905 assert_eq!(ledger.summary.violations_detected, 2);
906 assert!(ledger.summary.strongest_violation.is_some());
907 assert!(ledger.summary.aggregate_log10_bf > 0.0);
908 }
909
910 #[test]
911 fn ledger_violation_entries_have_positive_bf() {
912 let report = make_violation_report();
913 let ledger = EvidenceLedger::from_report(&report);
914
915 for entry in ledger.entries.iter().filter(|e| !e.passed) {
916 assert!(
917 entry.bayes_factor.log10_bf > 0.0,
918 "violation entry '{inv}' should have BF > 1, got log10_bf={bf}",
919 inv = entry.invariant,
920 bf = entry.bayes_factor.log10_bf,
921 );
922 }
923 }
924
925 #[test]
926 fn ledger_violation_evidence_lines() {
927 let report = make_violation_report();
928 let ledger = EvidenceLedger::from_report(&report);
929
930 let task_entry = ledger
931 .entries
932 .iter()
933 .find(|e| e.invariant == "task_leak")
934 .unwrap();
935 assert!(!task_entry.passed);
936 assert!(
937 task_entry.evidence_lines[0]
938 .equation
939 .contains("P(violation_observed | violated)")
940 );
941 }
942
943 #[test]
946 fn violations_by_strength_ordering() {
947 let report = make_violation_report();
948 let ledger = EvidenceLedger::from_report(&report);
949 let violations = ledger.violations_by_strength();
950
951 assert_eq!(violations.len(), 2);
952 assert!(violations[0].bayes_factor.log10_bf >= violations[1].bayes_factor.log10_bf);
954 }
955
956 #[test]
959 fn clean_by_confidence_ordering() {
960 let report = make_violation_report();
961 let ledger = EvidenceLedger::from_report(&report);
962 let clean = ledger.clean_by_confidence();
963
964 assert_eq!(clean.len(), 1);
965 assert_eq!(clean[0].invariant, "quiescence");
966 }
967
968 #[test]
971 fn ledger_to_text_contains_header() {
972 let report = make_clean_report();
973 let ledger = EvidenceLedger::from_report(&report);
974 let text = ledger.to_text();
975
976 assert!(text.contains("EVIDENCE LEDGER"));
977 assert!(text.contains("Invariants examined: 2"));
978 assert!(text.contains("Violations detected: 0"));
979 assert!(text.contains("CLEAN INVARIANTS"));
980 }
981
982 #[test]
983 fn ledger_to_text_violations_section() {
984 let report = make_violation_report();
985 let ledger = EvidenceLedger::from_report(&report);
986 let text = ledger.to_text();
987
988 assert!(text.contains("VIOLATIONS"));
989 assert!(text.contains("[FAIL] task_leak"));
990 assert!(text.contains("[FAIL] obligation_leak"));
991 assert!(text.contains("[PASS] quiescence"));
992 }
993
994 #[test]
995 fn ledger_to_text_evidence_lines() {
996 let report = make_violation_report();
997 let ledger = EvidenceLedger::from_report(&report);
998 let text = ledger.to_text();
999
1000 assert!(text.contains("BF ="));
1001 assert!(text.contains("log₁₀(BF)"));
1002 }
1003
1004 #[test]
1007 fn ledger_json_roundtrip() {
1008 let report = make_violation_report();
1009 let ledger = EvidenceLedger::from_report(&report);
1010 let json = serde_json::to_string(&ledger).unwrap();
1011 let deserialized: EvidenceLedger = serde_json::from_str(&json).unwrap();
1012
1013 assert_eq!(deserialized.entries.len(), ledger.entries.len());
1014 assert_eq!(
1015 deserialized.summary.violations_detected,
1016 ledger.summary.violations_detected
1017 );
1018 assert_eq!(deserialized.check_time_nanos, ledger.check_time_nanos);
1019 }
1020
1021 #[test]
1022 fn ledger_to_json_structure() {
1023 let report = make_clean_report();
1024 let ledger = EvidenceLedger::from_report(&report);
1025 let json = ledger.to_json();
1026
1027 assert!(json["entries"].is_array());
1028 assert!(json["summary"].is_object());
1029 assert_eq!(json["summary"]["total_invariants"], 2);
1030 assert_eq!(json["check_time_nanos"], 42);
1031 }
1032
1033 #[test]
1036 fn custom_detection_model() {
1037 let model = DetectionModel {
1038 per_entity_detection_rate: 0.5,
1039 false_positive_rate: 0.01,
1040 };
1041 let report = make_violation_report();
1042 let ledger = EvidenceLedger::from_report_with_model(&report, &model);
1043
1044 let default_ledger = EvidenceLedger::from_report(&report);
1046 for (custom, default) in ledger
1047 .entries
1048 .iter()
1049 .zip(default_ledger.entries.iter())
1050 .filter(|(_, d)| !d.passed)
1051 {
1052 assert!(
1053 custom.bayes_factor.log10_bf < default.bayes_factor.log10_bf,
1054 "lower detection rate should produce weaker evidence"
1055 );
1056 }
1057 }
1058
1059 #[test]
1062 fn log_likelihood_components_sum_to_total() {
1063 let report = make_violation_report();
1064 let ledger = EvidenceLedger::from_report(&report);
1065
1066 for entry in &ledger.entries {
1067 let expected_total = entry.log_likelihoods.structural + entry.log_likelihoods.detection;
1068 assert!(
1069 (entry.log_likelihoods.total - expected_total).abs() < 1e-10,
1070 "total should equal structural + detection"
1071 );
1072 }
1073 }
1074
1075 #[test]
1078 fn ledger_from_oracle_suite() {
1079 let suite = super::super::OracleSuite::new();
1080 let report = suite.report(crate::types::Time::ZERO);
1081 let ledger = EvidenceLedger::from_report(&report);
1082
1083 assert_eq!(ledger.entries.len(), 17);
1084 assert_eq!(ledger.summary.violations_detected, 0);
1085 for entry in &ledger.entries {
1087 assert!(entry.passed);
1088 assert_eq!(entry.bayes_factor.strength, EvidenceStrength::Against);
1089 }
1090 }
1091
1092 #[test]
1095 fn evidence_entry_fields() {
1096 let report = make_violation_report();
1097 let ledger = EvidenceLedger::from_report(&report);
1098
1099 let task_entry = &ledger.entries[0];
1100 assert_eq!(task_entry.invariant, "task_leak");
1101 assert!(!task_entry.passed);
1102 assert!(!task_entry.evidence_lines.is_empty());
1103 assert!(task_entry.bayes_factor.log10_bf.is_finite());
1104 assert!(task_entry.log_likelihoods.total.is_finite());
1105 }
1106
1107 #[test]
1110 fn evidence_summary_strongest_violation() {
1111 let report = make_violation_report();
1112 let ledger = EvidenceLedger::from_report(&report);
1113
1114 assert_eq!(
1116 ledger.summary.strongest_violation.as_deref(),
1117 Some("task_leak")
1118 );
1119 }
1120
1121 #[test]
1122 fn evidence_summary_strongest_clean() {
1123 let report = make_violation_report();
1124 let ledger = EvidenceLedger::from_report(&report);
1125
1126 assert_eq!(
1127 ledger.summary.strongest_clean.as_deref(),
1128 Some("quiescence")
1129 );
1130 }
1131}