Skip to main content

contextgraph_trace/
report.rs

1//! The typed oracle report — the same pass/fail/skip + evidence vocabulary as
2//! `contextgraph-conformance`'s report, applied to a journal instead of a live
3//! provider. Kept as this crate's own small types rather than a dependency:
4//! the conformance crate pulls in the host runtime (tokio, transports), and a
5//! journal oracle must stay runnable anywhere the journal can be read.
6
7use serde::{Deserialize, Serialize};
8
9/// The verdict for a single oracle check.
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
11#[serde(rename_all = "snake_case")]
12pub enum CheckStatus {
13    Pass,
14    Fail,
15    /// Not exercised by this journal (e.g. `resume-integrity` on a run that
16    /// never crashed) — declared honestly rather than counted as a pass.
17    Skipped,
18}
19
20/// One check's outcome: which check, its verdict, and human-readable evidence
21/// naming the exact `seq` numbers involved.
22#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
23pub struct CheckResult {
24    pub name: String,
25    pub status: CheckStatus,
26    pub evidence: String,
27}
28
29impl CheckResult {
30    pub fn pass(name: impl Into<String>, evidence: impl Into<String>) -> Self {
31        Self {
32            name: name.into(),
33            status: CheckStatus::Pass,
34            evidence: evidence.into(),
35        }
36    }
37
38    pub fn fail(name: impl Into<String>, evidence: impl Into<String>) -> Self {
39        Self {
40            name: name.into(),
41            status: CheckStatus::Fail,
42            evidence: evidence.into(),
43        }
44    }
45
46    pub fn skip(name: impl Into<String>, evidence: impl Into<String>) -> Self {
47        Self {
48            name: name.into(),
49            status: CheckStatus::Skipped,
50            evidence: evidence.into(),
51        }
52    }
53
54    /// Pass when `violations` is empty, otherwise fail with the violations
55    /// joined — the common shape of every journal oracle.
56    pub fn from_violations(
57        name: impl Into<String>,
58        violations: Vec<String>,
59        pass_evidence: impl Into<String>,
60    ) -> Self {
61        if violations.is_empty() {
62            Self::pass(name, pass_evidence)
63        } else {
64            Self::fail(name, violations.join("; "))
65        }
66    }
67}
68
69/// The result of running the oracles over one journal.
70#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
71pub struct TraceReport {
72    /// Human description of the journal under judgement
73    /// ([`crate::Journal::describe`]).
74    pub target: String,
75    pub checks: Vec<CheckResult>,
76}
77
78impl TraceReport {
79    /// True when no check failed (skips don't fail a run) — the "this journal
80    /// upholds the loop invariants" verdict.
81    pub fn passed(&self) -> bool {
82        !self
83            .checks
84            .iter()
85            .any(|check| check.status == CheckStatus::Fail)
86    }
87
88    /// The checks that failed, in order.
89    pub fn failures(&self) -> impl Iterator<Item = &CheckResult> {
90        self.checks
91            .iter()
92            .filter(|check| check.status == CheckStatus::Fail)
93    }
94
95    /// `(passed, failed, skipped)` tallies.
96    pub fn tally(&self) -> (usize, usize, usize) {
97        let mut passed = 0;
98        let mut failed = 0;
99        let mut skipped = 0;
100        for check in &self.checks {
101            match check.status {
102                CheckStatus::Pass => passed += 1,
103                CheckStatus::Fail => failed += 1,
104                CheckStatus::Skipped => skipped += 1,
105            }
106        }
107        (passed, failed, skipped)
108    }
109}
110
111#[cfg(test)]
112mod tests {
113    use super::*;
114
115    #[test]
116    fn a_report_passes_only_when_nothing_failed() {
117        let clean = TraceReport {
118            target: "session sess_1".into(),
119            checks: vec![
120                CheckResult::pass("sequence-integrity", "16 events, dense"),
121                CheckResult::skip("resume-integrity", "no resume recorded"),
122            ],
123        };
124        assert!(clean.passed());
125        assert_eq!(clean.tally(), (1, 0, 1));
126
127        let broken = TraceReport {
128            target: "session sess_1".into(),
129            checks: vec![CheckResult::from_violations(
130                "effect-exactly-once",
131                vec!["effect `write:x#1` replayed at seq 12".into()],
132                "",
133            )],
134        };
135        assert!(!broken.passed());
136        assert_eq!(broken.failures().count(), 1);
137    }
138
139    #[test]
140    fn from_violations_passes_on_empty_and_joins_on_failure() {
141        let pass = CheckResult::from_violations("citation-at-use", vec![], "3 frame(s) labelled");
142        assert_eq!(pass.status, CheckStatus::Pass);
143        assert_eq!(pass.evidence, "3 frame(s) labelled");
144
145        let fail =
146            CheckResult::from_violations("citation-at-use", vec!["a".into(), "b".into()], "unused");
147        assert_eq!(fail.status, CheckStatus::Fail);
148        assert_eq!(fail.evidence, "a; b");
149    }
150
151    #[test]
152    fn report_is_serde_roundtrippable_for_json_output() {
153        let report = TraceReport {
154            target: "session sess_1".into(),
155            checks: vec![CheckResult::pass("turn-loop-pairing", "2 call(s) paired")],
156        };
157        let json = serde_json::to_string(&report).unwrap();
158        let back: TraceReport = serde_json::from_str(&json).unwrap();
159        assert_eq!(back, report);
160    }
161}