metrale_bench/benchmarks/contamination/
score.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2
3//! 2026-09-26: Scoring for the contamination benchmark: classify every prompt at
4//! every rung and in the post-check against its own solo reference. The module
5//! reads no handle, network or clock; `score_tests.rs` tests it with
6//! in-memory outcomes.
7//!
8//! Why more than "one solo reference, then diff each rung against it":
9//!
10//! 1. A single reference cannot tell a concurrency defect from a prompt that
11//!    is not reproducible alone. The reference runs twice; a prompt whose two
12//!    solo runs disagree is `AloneUnstable`, is excluded from the per-rung
13//!    comparison, and still fails the verdict under its own name.
14//! 2. State that survives the concurrent episode shows only in later solo
15//!    work, so a solo post-check leg after the rungs classifies a divergence
16//!    there as `Persistent`.
17//! 3. The driver primes every prompt once before any measured leg, so the
18//!    measured legs start cache-warm unless a prime request failed (the driver
19//!    then logs a warning). `cached_prompt_tokens` is not part of the
20//!    comparison.
21//!
22//! Owner: bench (contamination).
23//! Invariants:
24//! - Each cell inserted into `Score::cells` increments exactly one of the
25//!   class counters.
26//! - `verdict` passes only when at least one cell was compared and every
27//!   compared cell is `Identical`.
28
29use std::collections::BTreeMap;
30
31use super::transcript::{RequestOutcome, Transcript};
32
33/// 2026-09-26: What happened to one prompt in one leg: a rung, the
34/// post-check, or (as `"ref"`) its reference.
35#[derive(Debug, Clone, PartialEq, Eq)]
36pub enum Class {
37    Identical,
38    /// 2026-09-26: Streams differ, or the streams match and the completion-token
39    /// counts differ. `at` is the longest common prefix in characters.
40    Diverged {
41        at: usize,
42        detail: String,
43    },
44    /// 2026-09-26: Another request's canary appeared in this reply: leakage on
45    /// its own evidence, no reference required.
46    Contaminated {
47        foreign: String,
48    },
49    /// 2026-09-26: Diverged in the solo post-check leg that runs after the
50    /// rungs.
51    Persistent {
52        at: usize,
53    },
54    /// 2026-09-26: The prompt's two solo reference runs disagreed, so
55    /// contamination cannot be attributed for it.
56    AloneUnstable,
57    /// 2026-09-26: The request failed, a solo reference failed, or the reply or a
58    /// reference has fewer completion tokens than the floor.
59    Unmeasured {
60        why: String,
61    },
62}
63
64#[derive(Debug, Clone, Default, PartialEq, Eq)]
65pub struct Score {
66    pub prompts: usize,
67    pub rungs: usize,
68    pub compared: usize,
69    pub identical: usize,
70    pub diverged: usize,
71    pub contaminated: usize,
72    pub persistent: usize,
73    pub alone_unstable: usize,
74    pub unmeasured: usize,
75    pub foreign_canaries: usize,
76    pub tokens_compared: usize,
77    pub earliest_divergence: Option<usize>,
78    /// 2026-09-26: `(prompt_idx, leg_label) -> Class`, for the report table. The
79    /// leg label is `"ref"` when the prompt's reference could not be used.
80    pub cells: BTreeMap<(usize, String), Class>,
81}
82
83/// 2026-09-26: Longest common prefix, in characters.
84fn lcp(a: &str, b: &str) -> usize {
85    a.chars().zip(b.chars()).take_while(|(x, y)| x == y).count()
86}
87
88/// 2026-09-26: Classify one measured outcome against its own solo reference.
89fn classify(
90    reference: &Transcript,
91    got: &RequestOutcome,
92    own_canary: &str,
93    all_canaries: &[&str],
94    min_completion_tokens: usize,
95    persistent_leg: bool,
96) -> Class {
97    let t = match got {
98        RequestOutcome::Error(e) => {
99            return Class::Unmeasured {
100                why: format!("request failed: {e}"),
101            };
102        }
103        RequestOutcome::Ok(t) => t,
104    };
105    // 2026-09-26: Liveness before equality: two replies that both stopped after
106    // three tokens are equal and prove nothing.
107    if t.completion_tokens < min_completion_tokens {
108        return Class::Unmeasured {
109            why: format!(
110                "only {} completion tokens (floor {min_completion_tokens})",
111                t.completion_tokens
112            ),
113        };
114    }
115    // 2026-09-26: The canary check runs before the comparison: leakage is a
116    // stronger statement than "differs from its reference".
117    if let Some(foreign) = t.carries_foreign_canary(own_canary, all_canaries) {
118        return Class::Contaminated {
119            foreign: foreign.to_string(),
120        };
121    }
122    let (a, b) = (reference.canonical(), t.canonical());
123    if a != b {
124        let at = lcp(&a, &b);
125        return if persistent_leg {
126            Class::Persistent { at }
127        } else {
128            Class::Diverged {
129                at,
130                detail: "stream differs from its solo reference".into(),
131            }
132        };
133    }
134    // 2026-09-26: Equal streams with a different server-side count is still a
135    // divergence: the server accounted for the same text differently.
136    if reference.completion_tokens != t.completion_tokens {
137        return Class::Diverged {
138            at: a.chars().count(),
139            detail: format!(
140                "identical stream but completion_tokens {} vs {}",
141                reference.completion_tokens, t.completion_tokens
142            ),
143        };
144    }
145    Class::Identical
146}
147
148/// 2026-09-26: Inputs for one scoring pass. Every field is recorded data.
149pub struct Legs<'a> {
150    /// 2026-09-26: Solo reference, run twice. Indexed by prompt.
151    pub ref_a: &'a [RequestOutcome],
152    pub ref_b: &'a [RequestOutcome],
153    /// 2026-09-26: `(label, outcomes)` per concurrency rung.
154    pub rungs: &'a [(String, Vec<RequestOutcome>)],
155    /// 2026-09-26: Solo again, after the rungs.
156    pub post: &'a [RequestOutcome],
157    pub canaries: &'a [String],
158    pub min_completion_tokens: usize,
159}
160
161pub fn score(legs: &Legs) -> Score {
162    let all: Vec<&str> = legs.canaries.iter().map(String::as_str).collect();
163    let mut s = Score {
164        prompts: legs.ref_a.len(),
165        rungs: legs.rungs.len(),
166        ..Default::default()
167    };
168
169    for i in 0..legs.ref_a.len() {
170        let own = legs.canaries.get(i).map(String::as_str).unwrap_or("");
171        // 2026-09-26: The reference itself must be measurable and reproducible,
172        // or this prompt cannot speak to contamination at all.
173        let (a, b) = (
174            legs.ref_a[i].transcript(),
175            legs.ref_b.get(i).and_then(RequestOutcome::transcript),
176        );
177        let reference = match (a, b) {
178            (Some(a), Some(b))
179                if a.completion_tokens < legs.min_completion_tokens
180                    || b.completion_tokens < legs.min_completion_tokens =>
181            {
182                s.unmeasured += 1;
183                s.cells.insert(
184                    (i, "ref".into()),
185                    Class::Unmeasured {
186                        why: format!(
187                            "a solo reference was below the {} completion-token floor",
188                            legs.min_completion_tokens
189                        ),
190                    },
191                );
192                continue;
193            }
194            (Some(a), Some(b))
195                if a.canonical() == b.canonical() && a.completion_tokens == b.completion_tokens =>
196            {
197                a
198            }
199            (Some(_), Some(_)) => {
200                s.alone_unstable += 1;
201                s.cells.insert((i, "ref".into()), Class::AloneUnstable);
202                continue;
203            }
204            _ => {
205                s.unmeasured += 1;
206                s.cells.insert(
207                    (i, "ref".into()),
208                    Class::Unmeasured {
209                        why: "a solo reference leg failed".into(),
210                    },
211                );
212                continue;
213            }
214        };
215
216        let mut legs_for_prompt: Vec<(String, &RequestOutcome, bool)> = legs
217            .rungs
218            .iter()
219            .filter_map(|(label, outs)| outs.get(i).map(|o| (label.clone(), o, false)))
220            .collect();
221        if let Some(p) = legs.post.get(i) {
222            legs_for_prompt.push(("post".into(), p, true));
223        }
224
225        for (label, got, persistent) in legs_for_prompt {
226            let c = classify(
227                reference,
228                got,
229                own,
230                &all,
231                legs.min_completion_tokens,
232                persistent,
233            );
234            s.compared += 1;
235            match &c {
236                Class::Identical => {
237                    s.identical += 1;
238                    s.tokens_compared += reference.completion_tokens;
239                }
240                Class::Diverged { at, .. } => {
241                    s.diverged += 1;
242                    s.earliest_divergence =
243                        Some(s.earliest_divergence.map_or(*at, |e: usize| e.min(*at)));
244                }
245                Class::Persistent { at } => {
246                    s.persistent += 1;
247                    s.earliest_divergence =
248                        Some(s.earliest_divergence.map_or(*at, |e: usize| e.min(*at)));
249                }
250                Class::Contaminated { .. } => {
251                    s.contaminated += 1;
252                    s.foreign_canaries += 1;
253                }
254                Class::Unmeasured { .. } => s.unmeasured += 1,
255                Class::AloneUnstable => s.alone_unstable += 1,
256            }
257            s.cells.insert((i, label), c);
258        }
259    }
260    s
261}
262
263/// 2026-09-26: Zero tolerance: any unmeasured, alone-unstable, contaminated,
264/// persistent or diverged cell fails, as does a score with nothing compared.
265///
266/// The comparison is of stream text and token counts, not a timing statistic,
267/// so there is no noise term. Batch-width numerics can flip a near-tie argmax
268/// with no cross-request leak; that case is classified `Diverged`, not
269/// `Contaminated`, and still fails, because no principled bound says how many
270/// flipped tokens make a leak.
271pub fn verdict(s: &Score) -> crate::result::Verdict {
272    use crate::result::Verdict;
273    if s.compared == 0 {
274        return Verdict::fail("nothing measured: 0 comparisons (every leg failed?)");
275    }
276    let mut bad = Vec::new();
277    if s.unmeasured > 0 {
278        bad.push(format!("{} unmeasured", s.unmeasured));
279    }
280    if s.alone_unstable > 0 {
281        bad.push(format!(
282            "{} not reproducible ALONE at temp 0 (#435 class — contamination \
283             unattributable for them)",
284            s.alone_unstable
285        ));
286    }
287    if s.contaminated > 0 {
288        bad.push(format!(
289            "{} CONTAMINATED ({} foreign canaries)",
290            s.contaminated, s.foreign_canaries
291        ));
292    }
293    if s.persistent > 0 {
294        bad.push(format!("{} PERSISTENT (survived into solo)", s.persistent));
295    }
296    if s.diverged > 0 {
297        bad.push(format!("{} diverged", s.diverged));
298    }
299    if bad.is_empty() {
300        return Verdict::pass(format!(
301            "{} prompts x {} rungs + post-check: all {} streams identical to \
302             their solo reference ({} tokens compared)",
303            s.prompts, s.rungs, s.compared, s.tokens_compared
304        ));
305    }
306    let where_ = s
307        .earliest_divergence
308        .map(|c| format!("; earliest divergence at char {c}"))
309        .unwrap_or_default();
310    Verdict::fail(format!("{}{where_}", bad.join(" · ")))
311}
312
313#[cfg(test)]
314#[path = "score_tests.rs"]
315mod score_tests;