metrale_bench/benchmarks/ssm_poison/
report.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2
3//! 2026-09-26: How a poisoning run is presented: pure functions of the
4//! [`super::score::Score`].
5//!
6//! Owner: bench, SSM poisoning gate.
7//! Invariants:
8//! - No I/O.
9
10use std::collections::BTreeMap;
11
12use super::compare::TurnDelta;
13use super::score::Score;
14use crate::result::{Cell, CellStyle, Column, ResultTable, Stat};
15
16fn delta_detail(turns: &[TurnDelta]) -> String {
17    turns
18        .iter()
19        .map(|t| {
20            format!(
21                "t{}: {}->{}tok fin {:?}->{:?}",
22                t.turn, t.ref_tokens, t.replay_tokens, t.ref_finish, t.replay_finish
23            )
24        })
25        .collect::<Vec<_>>()
26        .join(", ")
27}
28
29/// 2026-09-26: One row per replay round, in round order.
30pub(super) fn table(s: &Score) -> ResultTable {
31    let mut t = ResultTable::new(
32        "REPLAY ROUNDS",
33        vec![
34            Column::left("Round", 6),
35            Column::left("Result", 14),
36            Column::left("Detail", 56),
37        ],
38    );
39    let jitter_map: BTreeMap<usize, &Vec<TurnDelta>> =
40        s.jittered_rounds.iter().map(|(n, d)| (*n, d)).collect();
41    let collapse_map: BTreeMap<usize, &Vec<TurnDelta>> =
42        s.collapsed_rounds.iter().map(|(n, d)| (*n, d)).collect();
43    // 2026-09-26: Unmeasured rows come from the Score's per-round records.
44    let unmeasured_map: BTreeMap<usize, &str> = s
45        .unmeasured_rounds
46        .iter()
47        .map(|(n, r)| (*n, r.as_str()))
48        .collect();
49    for round in 1..=s.rounds {
50        let (what, style, detail) = if let Some(turns) = collapse_map.get(&round) {
51            ("COLLAPSED".to_string(), CellStyle::Bad, delta_detail(turns))
52        } else if let Some(turns) = jitter_map.get(&round) {
53            ("jittered".to_string(), CellStyle::Warn, delta_detail(turns))
54        } else if let Some(reason) = unmeasured_map.get(&round) {
55            (
56                "unmeasured".into(),
57                CellStyle::Bad,
58                format!("invariant not proven this round — {reason}"),
59            )
60        } else {
61            ("invariant".into(), CellStyle::Good, String::new())
62        };
63        t.push(vec![
64            Cell::new(format!("r{round}")),
65            Cell::styled(what, style),
66            Cell::new(detail),
67        ]);
68    }
69    t
70}
71
72/// 2026-09-26: The headline tiles. `Collapsed`, `Unmeasured` and the turn-1 cache
73/// tile are Bad when they would fail the verdict; `Jittered` is Warn when
74/// present, because the verdict passes it.
75pub(super) fn summary(s: &Score) -> Vec<Stat> {
76    vec![
77        Stat::new("Invariant", format!("{}/{}", s.invariant, s.rounds), "").with_style(
78            if s.rounds > 0 && s.invariant == s.rounds {
79                CellStyle::Good
80            } else {
81                CellStyle::Neutral
82            },
83        ),
84        Stat::new("Jittered", s.jittered.to_string(), "").with_style(if s.jittered == 0 {
85            CellStyle::Good
86        } else {
87            CellStyle::Warn
88        }),
89        Stat::new("Collapsed", s.collapsed.to_string(), "").with_style(if s.collapsed == 0 {
90            CellStyle::Good
91        } else {
92            CellStyle::Bad
93        }),
94        Stat::new("Unmeasured", s.unmeasured.to_string(), "").with_style(if s.unmeasured == 0 {
95            CellStyle::Good
96        } else {
97            CellStyle::Bad
98        }),
99        // 2026-09-26: The vacuity tile: zero, or no figure, is Bad.
100        Stat::new(
101            "Min t1 cache",
102            s.min_turn1_cached.unwrap_or(0).to_string(),
103            "tok",
104        )
105        .with_style(if s.min_turn1_cached.unwrap_or(0) > 0 {
106            CellStyle::Good
107        } else {
108            CellStyle::Bad
109        }),
110    ]
111}
112
113/// 2026-09-26: Raw gate numbers for the record. Every verdict class is a key even
114/// when zero.
115pub(super) fn metrics(s: &Score) -> BTreeMap<String, f64> {
116    let mut m: BTreeMap<String, f64> = [
117        ("rounds", s.rounds),
118        ("invariant", s.invariant),
119        ("jittered", s.jittered),
120        ("collapsed", s.collapsed),
121        ("unmeasured", s.unmeasured),
122    ]
123    .into_iter()
124    .map(|(k, v)| (k.to_string(), v as f64))
125    .collect();
126    // 2026-09-26: The vacuity metric; the gate's BENCH.toml entry floors it at
127    // `min = 900.0`. It is written only when a replay reported a figure: with
128    // no figure the key is absent, and the gate reports it missing from the
129    // record rather than as a zero. `concurrency.rs` omits the same key the
130    // same way.
131    if let Some(min) = s.min_turn1_cached {
132        m.insert("min_cached_prompt_tokens".to_string(), min as f64);
133    }
134    m
135}
136
137#[cfg(test)]
138#[path = "report_tests.rs"]
139mod report_tests;