metrale_bench/benchmarks/contamination/
report.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2
3//! 2026-09-26: How a contamination run is presented: the probe-by-leg table,
4//! the summary tiles and the gate metrics, each a pure function of a
5//! [`Score`].
6//!
7//! Owner: bench (contamination).
8//! Invariants: none beyond the types.
9
10use std::collections::BTreeMap;
11
12use super::prompts;
13use super::score::{Class, Score};
14use crate::result::{Cell, CellStyle, Column, ResultTable, Stat};
15
16/// 2026-09-26: `(result, style, detail)` for one cell; the detail column holds
17/// what does not fit the fixed-width result cell.
18fn render_class(class: &Class) -> (String, CellStyle, String) {
19    match class {
20        Class::Identical => ("identical".into(), CellStyle::Good, String::new()),
21        Class::Diverged { at, detail } => (
22            format!("DIVERGED @ char {at}"),
23            CellStyle::Bad,
24            detail.clone(),
25        ),
26        Class::Contaminated { foreign } => (
27            "CONTAMINATED".into(),
28            CellStyle::Bad,
29            format!("carries foreign canary {foreign}"),
30        ),
31        Class::Persistent { at } => (
32            format!("PERSISTENT @ char {at}"),
33            CellStyle::Bad,
34            "state survived into solo execution".into(),
35        ),
36        Class::AloneUnstable => (
37            "alone-unstable".into(),
38            CellStyle::Warn,
39            "two solo runs disagree (#435) — contamination unattributable".into(),
40        ),
41        Class::Unmeasured { why } => ("unmeasured".into(), CellStyle::Warn, why.clone()),
42    }
43}
44
45/// 2026-09-26: One row per `(probe, leg)` cell, in the order of `Score::cells`
46/// (a `BTreeMap`).
47pub(super) fn table(s: &Score) -> ResultTable {
48    let mut t = ResultTable::new(
49        "PROBE × LEG",
50        vec![
51            Column::left("Probe", 6),
52            Column::left("Leg", 6),
53            Column::left("Result", 20),
54            Column::left("Detail", 44),
55        ],
56    );
57    for ((probe_idx, leg), class) in &s.cells {
58        let name = prompts::PROBES
59            .get(*probe_idx)
60            .map(|p| p.name)
61            .unwrap_or("?");
62        let (what, style, detail) = render_class(class);
63        t.push(vec![
64            Cell::new(name),
65            Cell::styled(leg.clone(), CellStyle::Dim),
66            Cell::styled(what, style),
67            Cell::styled(detail, CellStyle::Dim),
68        ]);
69    }
70    t
71}
72
73fn count_stat(label: &str, n: usize) -> Stat {
74    Stat::new(label, n.to_string(), "").with_style(if n == 0 {
75        CellStyle::Good
76    } else {
77        CellStyle::Bad
78    })
79}
80
81pub(super) fn summary(s: &Score) -> Vec<Stat> {
82    vec![
83        Stat::new("Identical", format!("{}/{}", s.identical, s.compared), "").with_style(
84            if s.compared > 0 && s.identical == s.compared {
85                CellStyle::Good
86            } else {
87                CellStyle::Bad
88            },
89        ),
90        count_stat("Contaminated", s.contaminated),
91        count_stat("Persistent", s.persistent),
92        count_stat("Diverged", s.diverged),
93        Stat::new("Tokens compared", s.tokens_compared.to_string(), "")
94            .with_style(CellStyle::Accent),
95    ]
96}
97
98/// 2026-09-26: Every class is a key even when zero, so a zero cannot be
99/// mistaken for an absent key.
100pub(super) fn metrics(s: &Score) -> BTreeMap<String, f64> {
101    [
102        ("compared", s.compared),
103        ("identical", s.identical),
104        ("diverged", s.diverged),
105        ("contaminated", s.contaminated),
106        ("persistent", s.persistent),
107        ("alone_unstable", s.alone_unstable),
108        ("unmeasured", s.unmeasured),
109        ("tokens_compared", s.tokens_compared),
110    ]
111    .into_iter()
112    .map(|(k, v)| (k.to_string(), v as f64))
113    .collect()
114}
115
116#[cfg(test)]
117mod tests {
118    use super::super::score::{Class, Score};
119    use super::*;
120    use crate::result::CellStyle;
121
122    fn score_with(cells: Vec<((usize, &str), Class)>) -> Score {
123        let mut s = Score::default();
124        for ((i, leg), c) in cells {
125            match &c {
126                Class::Identical => s.identical += 1,
127                Class::Diverged { .. } => s.diverged += 1,
128                Class::Contaminated { .. } => s.contaminated += 1,
129                Class::Persistent { .. } => s.persistent += 1,
130                Class::AloneUnstable => s.alone_unstable += 1,
131                Class::Unmeasured { .. } => s.unmeasured += 1,
132            }
133            s.compared += 1;
134            s.cells.insert((i, leg.to_string()), c);
135        }
136        s
137    }
138
139    #[test]
140    fn every_class_renders_its_evidence_and_severity() {
141        for (class, expected) in [
142            (Class::Identical, ("identical", CellStyle::Good, "")),
143            (
144                Class::Diverged {
145                    at: 4,
146                    detail: "streams differ".into(),
147                },
148                ("DIVERGED @ char 4", CellStyle::Bad, "streams differ"),
149            ),
150            (
151                Class::Contaminated {
152                    foreign: "FOREIGN".into(),
153                },
154                (
155                    "CONTAMINATED",
156                    CellStyle::Bad,
157                    "carries foreign canary FOREIGN",
158                ),
159            ),
160            (
161                Class::Persistent { at: 7 },
162                (
163                    "PERSISTENT @ char 7",
164                    CellStyle::Bad,
165                    "state survived into solo execution",
166                ),
167            ),
168            (
169                Class::AloneUnstable,
170                (
171                    "alone-unstable",
172                    CellStyle::Warn,
173                    "two solo runs disagree (#435) — contamination unattributable",
174                ),
175            ),
176            (
177                Class::Unmeasured {
178                    why: "timeout".into(),
179                },
180                ("unmeasured", CellStyle::Warn, "timeout"),
181            ),
182        ] {
183            let rendered = render_class(&class);
184            assert_eq!(
185                (rendered.0.as_str(), rendered.1, rendered.2.as_str()),
186                expected
187            );
188        }
189
190        let s = score_with(vec![
191            ((0, "c2"), Class::Identical),
192            (
193                (0, "c4"),
194                Class::Contaminated {
195                    foreign: "QJ-CRIMSON-OTTER-77".into(),
196                },
197            ),
198        ]);
199        let t = table(&s);
200        assert_eq!(t.rows.len(), 2, "one row per cell");
201        let bad: Vec<_> = t
202            .rows
203            .iter()
204            .filter(|r| r[2].style == CellStyle::Bad)
205            .collect();
206        assert_eq!(bad.len(), 1, "exactly the contaminated cell is Bad");
207        assert!(bad[0][2].text.contains("CONTAMINATED"));
208        assert!(
209            bad[0][3].text.contains("QJ-CRIMSON-OTTER-77"),
210            "the operator's first question is WHOSE state leaked; the row must \
211             answer it: {:?}",
212            bad[0][3].text
213        );
214        let clean = t
215            .rows
216            .iter()
217            .find(|r| r[1].text == "c2")
218            .expect("the c2 row");
219        assert_eq!(clean[2].text, "identical");
220        assert_eq!(clean[2].style, CellStyle::Good);
221    }
222
223    #[test]
224    fn probe_rows_carry_the_probe_name_not_an_index() {
225        let s = score_with(vec![((1, "post"), Class::Persistent { at: 7 })]);
226        let t = table(&s);
227        assert_eq!(t.rows[0][0].text, prompts::PROBES[1].name);
228        assert!(t.rows[0][2].text.contains("PERSISTENT @ char 7"));
229    }
230
231    #[test]
232    fn summary_headline_tracks_the_verdict_direction() {
233        let clean = score_with(vec![((0, "c2"), Class::Identical)]);
234        assert_eq!(summary(&clean)[0].style, CellStyle::Good);
235
236        let dirty = score_with(vec![
237            ((0, "c2"), Class::Identical),
238            (
239                (1, "c2"),
240                Class::Diverged {
241                    at: 3,
242                    detail: "streams differ".into(),
243                },
244            ),
245            (
246                (0, "c4"),
247                Class::Contaminated {
248                    foreign: "foreign".into(),
249                },
250            ),
251            ((1, "post"), Class::Persistent { at: 9 }),
252        ]);
253        let mut dirty = dirty;
254        dirty.tokens_compared = 42;
255        let tiles: Vec<_> = summary(&dirty)
256            .into_iter()
257            .map(|tile| (tile.label, tile.value, tile.style))
258            .collect();
259        assert_eq!(
260            tiles,
261            vec![
262                ("Identical".into(), "1/4".into(), CellStyle::Bad),
263                ("Contaminated".into(), "1".into(), CellStyle::Bad),
264                ("Persistent".into(), "1".into(), CellStyle::Bad),
265                ("Diverged".into(), "1".into(), CellStyle::Bad),
266                ("Tokens compared".into(), "42".into(), CellStyle::Accent),
267            ]
268        );
269    }
270
271    #[test]
272    fn metrics_carry_every_class_even_when_zero() {
273        let s = score_with(vec![((0, "c2"), Class::Identical)]);
274        let m = metrics(&s);
275        for key in [
276            "compared",
277            "identical",
278            "diverged",
279            "contaminated",
280            "persistent",
281            "alone_unstable",
282            "unmeasured",
283            "tokens_compared",
284        ] {
285            assert!(m.contains_key(key), "missing metric {key}");
286        }
287        assert_eq!(m["identical"], 1.0);
288        assert_eq!(m["contaminated"], 0.0, "zero, not absent");
289
290        // 2026-09-26: Distinct nonzero values, so a metric frozen at 0 or
291        // wired to the wrong field fails.
292        let dirty = Score {
293            compared: 8,
294            identical: 7,
295            diverged: 6,
296            contaminated: 5,
297            persistent: 4,
298            alone_unstable: 3,
299            unmeasured: 2,
300            tokens_compared: 1,
301            ..Default::default()
302        };
303        assert_eq!(
304            metrics(&dirty),
305            [
306                ("alone_unstable".to_string(), 3.0),
307                ("compared".to_string(), 8.0),
308                ("contaminated".to_string(), 5.0),
309                ("diverged".to_string(), 6.0),
310                ("identical".to_string(), 7.0),
311                ("persistent".to_string(), 4.0),
312                ("tokens_compared".to_string(), 1.0),
313                ("unmeasured".to_string(), 2.0),
314            ]
315            .into_iter()
316            .collect()
317        );
318    }
319}