metrale_bench/benchmarks/ssm_poison/compare.rs
1// SPDX-License-Identifier: MIT OR Apache-2.0
2
3//! 2026-09-26: Round comparison: every replay round against the reference round.
4//!
5//! Owner: bench, SSM poisoning gate.
6//! Invariants:
7//! - A round is `Invariant` only when every turn's canonical transcript and
8//! token count equal the reference turn's.
9//! - A differing turn is collapsed when its finish reason differs or its token
10//! ratio leaves [`COLLAPSE_RATIO_FLOOR`]..=[`COLLAPSE_RATIO_CEIL`], and
11//! jittered otherwise.
12//! - A round's verdict is `Collapsed` if any turn collapsed, else `Unmeasured`
13//! if any turn was, else `Jittered` if any turn jittered.
14
15use crate::benchmarks::transcript::Transcript;
16
17/// 2026-09-26: A replay turn shorter than this fraction of the reference turn,
18/// in completion tokens, is a collapse.
19pub const COLLAPSE_RATIO_FLOOR: f64 = 0.5;
20/// 2026-09-26: A replay turn longer than this multiple of the reference turn is a
21/// collapse too.
22pub const COLLAPSE_RATIO_CEIL: f64 = 2.0;
23
24/// 2026-09-26: One differing turn's shape.
25#[derive(Debug, Clone, PartialEq, Eq)]
26pub struct TurnDelta {
27 pub turn: usize,
28 pub ref_tokens: usize,
29 pub replay_tokens: usize,
30 pub ref_finish: Option<String>,
31 pub replay_finish: Option<String>,
32}
33
34impl TurnDelta {
35 /// 2026-09-26: A different finish reason, or a token ratio outside the
36 /// collapse window.
37 pub fn is_collapse(&self) -> bool {
38 if self.ref_finish != self.replay_finish {
39 return true;
40 }
41 if self.ref_tokens == 0 {
42 // 2026-09-26: A nonzero replay against a zero-token reference is an
43 // infinite ratio, so a collapse. The both-zero pair is not;
44 // `compare_round` scores that turn Unmeasured before asking.
45 return self.replay_tokens != 0;
46 }
47 let ratio = self.replay_tokens as f64 / self.ref_tokens as f64;
48 !(COLLAPSE_RATIO_FLOOR..=COLLAPSE_RATIO_CEIL).contains(&ratio)
49 }
50}
51
52/// 2026-09-26: The outcome of comparing one replay round to the reference round.
53#[derive(Debug, Clone, PartialEq, Eq)]
54pub enum RoundVerdict {
55 /// 2026-09-26: Every turn equal: canonical transcript and token count.
56 Invariant,
57 /// 2026-09-26: At least one turn differs, and every differing turn has the
58 /// same finish reason and a token ratio inside the window. Recorded; the
59 /// verdict passes it.
60 Jittered { turns: Vec<TurnDelta> },
61 /// 2026-09-26: At least one turn collapsed (`TurnDelta::is_collapse`).
62 Collapsed { turns: Vec<TurnDelta> },
63 /// 2026-09-26: The round proves nothing: the replay errored (set by the
64 /// driver), its turn count differs from the reference's, or a turn
65 /// returned no tokens in both rounds.
66 Unmeasured { reason: String },
67}
68
69/// 2026-09-26: Compare a reference turn list against a replay turn list. A replay
70/// whose turn count differs from the reference's is Unmeasured, never
71/// Invariant.
72pub fn compare_round(reference: &[Transcript], replay: &[Transcript]) -> RoundVerdict {
73 if replay.len() != reference.len() {
74 return RoundVerdict::Unmeasured {
75 reason: format!(
76 "replay produced {} turn(s), reference has {}",
77 replay.len(),
78 reference.len()
79 ),
80 };
81 }
82 if reference.is_empty() {
83 return RoundVerdict::Unmeasured {
84 reason: "reference round has no turns".into(),
85 };
86 }
87 let mut jittered = Vec::new();
88 let mut collapsed = Vec::new();
89 let mut unmeasured: Option<String> = None;
90 for (i, (r, p)) in reference.iter().zip(replay).enumerate() {
91 if r.completion_tokens == 0 && p.completion_tokens == 0 {
92 // 2026-09-26: Two empty replies are equal and prove nothing.
93 unmeasured = Some(format!("turn {} returned no tokens", i + 1));
94 continue;
95 }
96 if r.canonical() == p.canonical() && r.completion_tokens == p.completion_tokens {
97 continue;
98 }
99 let delta = TurnDelta {
100 turn: i + 1,
101 ref_tokens: r.completion_tokens,
102 replay_tokens: p.completion_tokens,
103 ref_finish: r.finish_reason.clone(),
104 replay_finish: p.finish_reason.clone(),
105 };
106 if delta.is_collapse() {
107 collapsed.push(delta);
108 } else {
109 jittered.push(delta);
110 }
111 }
112 if !collapsed.is_empty() {
113 return RoundVerdict::Collapsed { turns: collapsed };
114 }
115 // 2026-09-26: Unmeasured outranks Jittered, because the verdict fails an
116 // unmeasured round and passes a jittered one. Collapsed, checked above,
117 // is the more specific failure.
118 if let Some(reason) = unmeasured {
119 return RoundVerdict::Unmeasured { reason };
120 }
121 if !jittered.is_empty() {
122 return RoundVerdict::Jittered { turns: jittered };
123 }
124 RoundVerdict::Invariant
125}
126
127#[cfg(test)]
128#[path = "compare_tests.rs"]
129mod compare_tests;