metrale_bench/benchmarks/
transcript.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2
3//! 2026-09-26: What a request emitted, reduced to the parts the equality gates
4//! compare (contamination, scheduler equivalence, KAT equality, SSM poisoning).
5//!
6//! Owner: bench.
7//! Invariants:
8//! - `canonical` holds reasoning, text, tool calls and finish reason, and no
9//!   token count.
10
11use crate::http::ChatOutcome;
12
13/// 2026-09-26: The comparable part of one reply.
14#[derive(Debug, Clone, PartialEq, Eq, Default)]
15pub struct Transcript {
16    /// 2026-09-26: Kept apart from `text`, as `ChatOutcome` does, so a change of
17    /// reasoning is not hidden by an identical answer.
18    pub reasoning: String,
19    pub text: String,
20    /// 2026-09-26: `(name, raw arguments)`. Arguments stay raw: re-serialising
21    /// the JSON would normalise key order and whitespace.
22    pub tool_calls: Vec<(String, String)>,
23    pub finish_reason: Option<String>,
24    /// 2026-09-26: `ChatOutcome::completion_tokens`: the server's
25    /// `usage.completion_tokens` when the stream carries it, else the streamed
26    /// delta count.
27    pub completion_tokens: usize,
28    /// 2026-09-26: Diagnostic: not in `canonical`, so no divergence check here
29    /// compares it. The SSM poisoning gate reads it as its turn-1 cache count.
30    pub cached_prompt_tokens: usize,
31}
32
33impl From<&ChatOutcome> for Transcript {
34    fn from(o: &ChatOutcome) -> Self {
35        Self {
36            reasoning: o.reasoning.clone(),
37            text: o.text.clone(),
38            tool_calls: o
39                .tool_calls
40                .iter()
41                .map(|t| (t.name.clone(), t.arguments.clone()))
42                .collect(),
43            finish_reason: o.finish_reason.clone(),
44            completion_tokens: o.completion_tokens,
45            cached_prompt_tokens: o.cached_prompt_tokens,
46        }
47    }
48}
49
50impl Transcript {
51    /// 2026-09-26: The text a divergence check compares, concatenated in a fixed
52    /// order with control-character separators; callers also use it for
53    /// longest-common-prefix localisation. The token counts are left out:
54    /// callers compare `completion_tokens` separately.
55    pub fn canonical(&self) -> String {
56        let mut s = String::with_capacity(self.reasoning.len() + self.text.len() + 64);
57        s.push_str(&self.reasoning);
58        s.push('\u{1}');
59        s.push_str(&self.text);
60        for (name, args) in &self.tool_calls {
61            s.push('\u{2}');
62            s.push_str(name);
63            s.push('\u{3}');
64            s.push_str(args);
65        }
66        s.push('\u{4}');
67        s.push_str(self.finish_reason.as_deref().unwrap_or(""));
68        s
69    }
70
71    /// 2026-09-26: The first canary in `all`, other than `own`, that appears in
72    /// this reply's canonical text. Unlike a diff, it needs no reference.
73    pub fn carries_foreign_canary<'a>(&self, own: &str, all: &[&'a str]) -> Option<&'a str> {
74        let hay = self.canonical();
75        all.iter().find(|c| **c != own && hay.contains(*c)).copied()
76    }
77}
78
79/// 2026-09-26: One request's result. An error is its own variant, so callers
80/// score it apart from a transcript.
81#[derive(Debug, Clone, PartialEq, Eq)]
82pub enum RequestOutcome {
83    Ok(Box<Transcript>),
84    Error(String),
85}
86
87impl RequestOutcome {
88    pub fn transcript(&self) -> Option<&Transcript> {
89        match self {
90            Self::Ok(t) => Some(t),
91            Self::Error(_) => None,
92        }
93    }
94}