Skip to main content

asupersync/lab/oracle/
evidence.rs

1//! Evidence ledger for "galaxy-brain" oracle diagnostics.
2//!
3//! The evidence ledger explains invariant violations (or their absence) using
4//! Bayes factors and log-likelihood contributions. Each invariant gets a
5//! structured evidence entry with:
6//!
7//! - **Bayes factor** (BF): quantifies how much the observed data favours the
8//!   "invariant violated" hypothesis vs "invariant holds".
9//! - **Log-likelihood contributions**: breaks the evidence into structural,
10//!   temporal, and aggregate components.
11//! - **Evidence lines**: human-readable `equation + substitution + intuition`
12//!   triples that form the "galaxy-brain" explanation.
13//!
14//! # Bayes Factor Interpretation (Kass & Raftery 1995)
15//!
16//! | log₁₀(BF) | BF        | Strength      |
17//! |-----------|-----------|---------------|
18//! | < 0       | < 1       | Against       |
19//! | 0 – 0.5  | 1 – 3.2   | Negligible    |
20//! | 0.5 – 1.3| 3.2 – 20  | Positive      |
21//! | 1.3 – 2.2| 20 – 150  | Strong        |
22//! | > 2.2    | > 150     | Very strong   |
23
24use std::fmt::Write as _;
25
26use serde::{Deserialize, Serialize};
27
28use super::{OracleReport, OracleStats};
29
30// ---------------------------------------------------------------------------
31// Evidence strength classification
32// ---------------------------------------------------------------------------
33
34/// Strength of evidence for a hypothesis, following Kass & Raftery (1995).
35#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
36pub enum EvidenceStrength {
37    /// BF < 1 — evidence *against* the hypothesis.
38    Against,
39    /// 1 ≤ BF < 3.2 (log₁₀ BF < 0.5) — barely worth mentioning.
40    Negligible,
41    /// 3.2 ≤ BF < 20 (0.5 ≤ log₁₀ BF < 1.3) — positive evidence.
42    Positive,
43    /// 20 ≤ BF < 150 (1.3 ≤ log₁₀ BF < 2.2) — strong evidence.
44    Strong,
45    /// BF ≥ 150 (log₁₀ BF ≥ 2.2) — very strong evidence.
46    VeryStrong,
47}
48
49impl EvidenceStrength {
50    /// Classifies a log₁₀ Bayes factor into a strength category.
51    #[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    /// Returns a short label for the strength.
69    #[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// ---------------------------------------------------------------------------
88// Bayes factor
89// ---------------------------------------------------------------------------
90
91/// Bayes factor for an invariant hypothesis.
92///
93/// `BF = P(data | H_violation) / P(data | H_holds)`.
94///
95/// Stored as `log₁₀(BF)` for numerical stability.
96#[derive(Debug, Clone, Serialize, Deserialize)]
97pub struct BayesFactor {
98    /// `log₁₀(BF)`. Positive ⇒ evidence for violation, negative ⇒ evidence for clean.
99    pub log10_bf: f64,
100    /// The hypothesis being tested.
101    pub hypothesis: String,
102    /// Classified evidence strength.
103    pub strength: EvidenceStrength,
104}
105
106impl BayesFactor {
107    /// Computes a Bayes factor from explicit log-likelihoods.
108    ///
109    /// `log10_bf = log₁₀ P(data | H1) − log₁₀ P(data | H0)`
110    #[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    /// Returns the raw Bayes factor (10^log10_bf), clamped to avoid infinity.
125    #[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// ---------------------------------------------------------------------------
132// Log-likelihood contributions
133// ---------------------------------------------------------------------------
134
135/// Decomposed log-likelihood contributions from different evidence sources.
136#[derive(Debug, Clone, Serialize, Deserialize)]
137pub struct LogLikelihoodContributions {
138    /// Contribution from structural observations (entity counts, topology).
139    pub structural: f64,
140    /// Contribution from the detection signal (violation present/absent).
141    pub detection: f64,
142    /// Aggregate (sum of components).
143    pub total: f64,
144}
145
146// ---------------------------------------------------------------------------
147// Evidence line — the "galaxy-brain" unit
148// ---------------------------------------------------------------------------
149
150/// A single evidence line: equation + substituted values + one-line intuition.
151///
152/// # Example
153///
154/// ```text
155/// equation:     BF = P(violation_observed | leak) / P(violation_observed | clean)
156/// substitution: BF = 0.998 / 0.001 = 998.0
157/// intuition:    Very strong evidence of task leak in region R1 (3 tasks tracked)
158/// ```
159#[derive(Debug, Clone, Serialize, Deserialize)]
160pub struct EvidenceLine {
161    /// The general equation form.
162    pub equation: String,
163    /// The equation with concrete values substituted.
164    pub substitution: String,
165    /// One-line human-readable intuition.
166    pub intuition: String,
167}
168
169// ---------------------------------------------------------------------------
170// Per-invariant evidence entry
171// ---------------------------------------------------------------------------
172
173/// Evidence entry for a single invariant within the ledger.
174#[derive(Debug, Clone, Serialize, Deserialize)]
175pub struct EvidenceEntry {
176    /// Invariant name (e.g., "task_leak").
177    pub invariant: String,
178    /// Whether the invariant passed.
179    pub passed: bool,
180    /// Bayes factor for the violation hypothesis.
181    pub bayes_factor: BayesFactor,
182    /// Decomposed log-likelihood contributions.
183    pub log_likelihoods: LogLikelihoodContributions,
184    /// Evidence lines (equations + substitutions + intuitions).
185    pub evidence_lines: Vec<EvidenceLine>,
186}
187
188// ---------------------------------------------------------------------------
189// Evidence summary
190// ---------------------------------------------------------------------------
191
192/// Aggregate summary across all invariants.
193#[derive(Debug, Clone, Serialize, Deserialize)]
194pub struct EvidenceSummary {
195    /// Total invariants examined.
196    pub total_invariants: usize,
197    /// Number where a violation was detected.
198    pub violations_detected: usize,
199    /// Invariant with the strongest evidence of violation (if any).
200    pub strongest_violation: Option<String>,
201    /// Invariant with the strongest evidence of being clean (if any violations exist).
202    pub strongest_clean: Option<String>,
203    /// Sum of log₁₀ BF across all invariants with violations.
204    pub aggregate_log10_bf: f64,
205}
206
207// ---------------------------------------------------------------------------
208// Evidence ledger
209// ---------------------------------------------------------------------------
210
211/// The evidence ledger: structured Bayesian explanation of oracle results.
212///
213/// Constructed from an [`OracleReport`] via [`EvidenceLedger::from_report`].
214#[derive(Debug, Clone, Serialize, Deserialize)]
215pub struct EvidenceLedger {
216    /// Per-invariant evidence entries.
217    pub entries: Vec<EvidenceEntry>,
218    /// Aggregate summary.
219    pub summary: EvidenceSummary,
220    /// Check time in nanoseconds (inherited from the oracle report).
221    pub check_time_nanos: u64,
222}
223
224// ---------------------------------------------------------------------------
225// Bayes factor computation model
226// ---------------------------------------------------------------------------
227
228/// Detection model parameters for computing Bayes factors.
229///
230/// These encode assumptions about oracle sensitivity.
231#[derive(Debug, Clone)]
232pub struct DetectionModel {
233    /// Per-entity detection probability when the invariant is violated.
234    /// Default: 0.9 (oracle detects 90% of single-entity violations).
235    pub per_entity_detection_rate: f64,
236    /// False-positive rate: probability of observing a violation when the
237    /// invariant actually holds.  Default: 0.001.
238    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    /// Probability of observing a violation given that one exists,
252    /// as a function of the number of tracked entities.
253    ///
254    /// `P(violation_observed | H_violated) = 1 − (1 − p)^n`
255    ///
256    /// More entities → higher detection probability.
257    #[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    /// Probability of observing a pass given that the invariant holds.
264    ///
265    /// `P(pass | H_holds) = 1 − ε`
266    #[must_use]
267    pub fn p_pass_given_clean(&self) -> f64 {
268        1.0 - self.false_positive_rate
269    }
270
271    /// Probability of observing a pass given that a violation exists.
272    ///
273    /// `P(pass | H_violated) = (1 − p)^n`
274    #[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    /// Computes a [`BayesFactor`] and evidence lines for an invariant.
281    #[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            // Observed: pass.  BF for "holds" = P(pass|holds) / P(pass|violated)
292            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            // The BF for "violation" hypothesis is inverted: we want BF < 1 for clean.
298            let log10_bf_violation = log10_h1 - log10_h0;
299
300            // Structural bonus: more events recorded → slightly more confident.
301            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            // Observed: violation.  BF for "violated" = P(violation|violated) / P(violation|holds)
344            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
395/// Small structural evidence contribution from event counts.
396///
397/// More events ⇒ marginally more confident in whatever conclusion was reached.
398/// Uses `log₁₀(1 + events / 100)` as a mild bonus (< 0.1 for typical runs).
399fn 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
404// ---------------------------------------------------------------------------
405// EvidenceLedger construction
406// ---------------------------------------------------------------------------
407
408impl EvidenceLedger {
409    /// Constructs an evidence ledger from an oracle report using the default
410    /// detection model.
411    #[must_use]
412    pub fn from_report(report: &OracleReport) -> Self {
413        Self::from_report_with_model(report, &DetectionModel::default())
414    }
415
416    /// Constructs an evidence ledger from an oracle report using a custom
417    /// detection model.
418    #[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    /// Returns entries with violations, sorted by descending evidence strength.
486    #[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    /// Returns entries for clean invariants, sorted by ascending log₁₀ BF
499    /// (most confident first, i.e. most negative).
500    #[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    /// Renders the ledger as structured text (the "galaxy-brain" output).
513    #[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        // Violations first.
552        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        // Clean invariants.
564        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    /// Serializes the ledger to a JSON value.
579    #[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// ===========================================================================
611// Tests
612// ===========================================================================
613
614#[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    // -- EvidenceStrength classification --
687
688    #[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    // -- BayesFactor --
743
744    #[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        // log10_bf = 2.0, so value = 100.0
755        assert!((bf.value() - 100.0).abs() < 1e-6);
756    }
757
758    #[test]
759    fn bayes_factor_value_clamped() {
760        // Extreme value should not produce infinity.
761        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    // -- DetectionModel --
770
771    #[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        // With 2 entities: 1 - (1 - 0.9)^2 = 1 - 0.01 = 0.99
789        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        // (1 - 0.9)^1 = 0.1
813        assert!((m.p_pass_given_violation(1) - 0.1).abs() < 1e-10);
814        // (1 - 0.9)^2 = 0.01
815        assert!((m.p_pass_given_violation(2) - 0.01).abs() < 1e-10);
816    }
817
818    // -- structural_contribution --
819
820    #[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        // log10(1 + 0/100) = log10(1) = 0
828        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    // -- EvidenceLedger from clean report --
845
846    #[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            // BF for "violation" should be < 1 (log10 < 0) since oracle passed.
867            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            // Check first line references the equation.
888            assert!(
889                entry.evidence_lines[0]
890                    .equation
891                    .contains("P(pass | violated)")
892            );
893        }
894    }
895
896    // -- EvidenceLedger from violation report --
897
898    #[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    // -- violations_by_strength --
944
945    #[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        // Stronger evidence (more entities) should come first.
953        assert!(violations[0].bayes_factor.log10_bf >= violations[1].bayes_factor.log10_bf);
954    }
955
956    // -- clean_by_confidence --
957
958    #[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    // -- text output --
969
970    #[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    // -- JSON serialization --
1005
1006    #[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    // -- custom detection model --
1034
1035    #[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        // With lower detection rate, BF for violations should be smaller.
1045        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    // -- log-likelihood contributions --
1060
1061    #[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    // -- integration with OracleSuite --
1076
1077    #[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        // All should show evidence against violation.
1086        for entry in &ledger.entries {
1087            assert!(entry.passed);
1088            assert_eq!(entry.bayes_factor.strength, EvidenceStrength::Against);
1089        }
1090    }
1091
1092    // -- EvidenceEntry struct --
1093
1094    #[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    // -- EvidenceSummary --
1108
1109    #[test]
1110    fn evidence_summary_strongest_violation() {
1111        let report = make_violation_report();
1112        let ledger = EvidenceLedger::from_report(&report);
1113
1114        // task_leak has 5 entities, obligation_leak has 1 — task_leak should be strongest.
1115        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}