metrale_bench/benchmarks/ssm_poison/
score.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2
3//! 2026-09-26: The decision logic: collected round records → a [`Score`] → the
4//! replay verdict.
5//!
6//! Owner: bench, SSM poisoning gate.
7//! Invariants:
8//! - No I/O.
9//! - The verdict fails a run with fewer replays than configured, or any
10//!   collapsed, unmeasured or zero-cache replay; jittered replays pass.
11
12use crate::result::Verdict;
13
14use super::compare::{RoundVerdict, TurnDelta};
15
16/// 2026-09-26: One replay round as the driver collected it: the comparison
17/// verdict plus the cached prompt tokens the server reported for turn 1.
18#[derive(Debug, Clone)]
19pub struct RoundRecord {
20    /// 2026-09-26: 1-based round number over the replays.
21    pub round: usize,
22    pub verdict: RoundVerdict,
23    /// 2026-09-26: `usage.prompt_tokens_details.cached_tokens` of the round's
24    /// first turn. `None` when the replay errored (the round is then
25    /// Unmeasured). `Some(0)` means turn 1 restored nothing from the cache.
26    pub turn1_cached: Option<usize>,
27}
28
29/// 2026-09-26: Everything the report and the gate record read.
30#[derive(Debug, Clone)]
31pub struct Score {
32    pub rounds: usize,
33    pub invariant: usize,
34    pub jittered: usize,
35    pub collapsed: usize,
36    pub unmeasured: usize,
37    /// 2026-09-26: Which rounds jittered, with their differing turns.
38    pub jittered_rounds: Vec<(usize, Vec<TurnDelta>)>,
39    /// 2026-09-26: Which rounds collapsed, with their differing turns.
40    pub collapsed_rounds: Vec<(usize, Vec<TurnDelta>)>,
41    /// 2026-09-26: Which rounds were unmeasured, with the reason, so the report
42    /// attributes each to its own round.
43    pub unmeasured_rounds: Vec<(usize, String)>,
44    /// 2026-09-26: Rounds whose first turn reported zero cached prompt tokens.
45    pub vacuous_rounds: Vec<usize>,
46    /// 2026-09-26: Minimum turn-1 cached-token count across the replays that
47    /// have one. `None` when none has.
48    pub min_turn1_cached: Option<usize>,
49}
50
51/// 2026-09-26: Reduce the collected replay records to a [`Score`].
52pub(super) fn score(replays: &[RoundRecord]) -> Score {
53    let count = |f: fn(&RoundVerdict) -> bool| replays.iter().filter(|r| f(&r.verdict)).count();
54    let jittered_rounds = replays
55        .iter()
56        .filter_map(|r| {
57            if let RoundVerdict::Jittered { turns } = &r.verdict {
58                Some((r.round, turns.clone()))
59            } else {
60                None
61            }
62        })
63        .collect();
64    let collapsed_rounds = replays
65        .iter()
66        .filter_map(|r| {
67            if let RoundVerdict::Collapsed { turns } = &r.verdict {
68                Some((r.round, turns.clone()))
69            } else {
70                None
71            }
72        })
73        .collect();
74    let unmeasured_rounds = replays
75        .iter()
76        .filter_map(|r| {
77            if let RoundVerdict::Unmeasured { reason } = &r.verdict {
78                Some((r.round, reason.clone()))
79            } else {
80                None
81            }
82        })
83        .collect();
84    let vacuous_rounds = replays
85        .iter()
86        .filter(|r| r.turn1_cached == Some(0))
87        .map(|r| r.round)
88        .collect();
89    let min_turn1_cached = replays.iter().filter_map(|r| r.turn1_cached).min();
90    Score {
91        rounds: replays.len(),
92        invariant: count(|v| matches!(v, RoundVerdict::Invariant)),
93        jittered: count(|v| matches!(v, RoundVerdict::Jittered { .. })),
94        collapsed: count(|v| matches!(v, RoundVerdict::Collapsed { .. })),
95        unmeasured: count(|v| matches!(v, RoundVerdict::Unmeasured { .. })),
96        jittered_rounds,
97        collapsed_rounds,
98        unmeasured_rounds,
99        vacuous_rounds,
100        min_turn1_cached,
101    }
102}
103
104fn turn_summary(turns: &[TurnDelta]) -> String {
105    turns
106        .iter()
107        .map(|t| {
108            format!(
109                "turn {} ({} -> {} tokens, finish {:?} -> {:?})",
110                t.turn, t.ref_tokens, t.replay_tokens, t.ref_finish, t.replay_finish
111            )
112        })
113        .collect::<Vec<_>>()
114        .join("; ")
115}
116
117/// 2026-09-26: The replay verdict, first failing rule wins:
118/// * fewer replays than the configured `rounds` fails;
119/// * any collapsed round fails;
120/// * any unmeasured round fails;
121/// * any replay whose turn 1 reported zero cached prompt tokens fails, since
122///   it never exercised the prefix restore;
123/// * otherwise it passes, and names the jittered rounds' count if any.
124pub(super) fn verdict(s: &Score, rounds: usize) -> Verdict {
125    if s.rounds != rounds {
126        return Verdict::fail(format!(
127            "{} of {} replay rounds completed",
128            s.rounds, rounds
129        ));
130    }
131    if s.collapsed > 0 {
132        let detail = s
133            .collapsed_rounds
134            .iter()
135            .map(|(n, turns)| format!("round {n}: {}", turn_summary(turns)))
136            .collect::<Vec<_>>()
137            .join(" | ");
138        return Verdict::fail(format!(
139            "{} of {} replays COLLAPSED against the reference: {detail} — a restored prefix \
140             produced degenerate output (early-EOS or runaway), the SSM state poisoning \
141             signature",
142            s.collapsed, rounds
143        ));
144    }
145    if s.unmeasured > 0 {
146        let detail = s
147            .unmeasured_rounds
148            .iter()
149            .map(|(round, reason)| format!("round {round}: {reason}"))
150            .collect::<Vec<_>>()
151            .join(" | ");
152        return Verdict::fail(format!(
153            "{} of {} replays were unmeasurable (transport errors): {detail} — the replay \
154             invariant is unproven for those rounds",
155            s.unmeasured, rounds,
156        ));
157    }
158    if !s.vacuous_rounds.is_empty() {
159        return Verdict::fail(format!(
160            "replay round(s) {:?} attested 0 cached prompt tokens on turn 1 — the prefix \
161             restore path this gate polices was never exercised, so their transcripts prove \
162             nothing about the poisoning class (is prefix caching enabled on the served \
163             recipe?)",
164            s.vacuous_rounds
165        ));
166    }
167    if s.jittered > 0 {
168        return Verdict::pass(format!(
169            "{} of {} replays byte-identical, {} jittered within bounds (restore anchor \
170             selection varies between rounds on a healthy engine), 0 collapsed",
171            s.invariant, s.rounds, s.jittered
172        ));
173    }
174    Verdict::pass(format!(
175        "{} of {} replays byte-identical to the reference",
176        s.invariant, rounds
177    ))
178}
179
180#[cfg(test)]
181#[path = "score_tests.rs"]
182mod score_tests;