metrale_bench/benchmarks/ssm_poison/
probe.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2
3//! 2026-09-26: The probe: a fixed conversation script that every round replays
4//! from scratch against the same server, and the anchors the reference round
5//! must meet.
6//!
7//! Owner: bench, SSM poisoning gate.
8//! Invariants:
9//! - The script is constant text, with no run id or `unique_prefix_tag`, so
10//!   every round's turn 1 sends the same prompt prefix (`probe_tests` pins
11//!   its SHA-256).
12
13use serde_json::{Value, json};
14
15use crate::benchmarks::transcript::Transcript;
16
17/// 2026-09-26: The document that opens turn 1. Every round sends it unchanged,
18/// so a replay's turn-1 prefill can be restored from the prefix cache.
19pub const LONG_PREFIX: &str = "\
20SYSTEM DOCUMENT 7741-C, revision 9, frozen text — quote from it, never extend it.
21
22Section 1, Invariants. The ledger defines three invariants that every batch
23must preserve end to end. First, monotonic sequence: each record carries a
24sequence number exactly one greater than its predecessor, and gaps are
25treated as corruption, never as reordering. Second, bounded drift: the
26clock offset between any two nodes in the batch must stay under forty
27milliseconds for the duration of the batch, and an offset beyond that bound
28invalidates every record written after the breach. Third, closed membership:
29a node joins the batch through a single signed admission record and leaves
30through a single signed departure record, and no record may reference a node
31outside its admission and departure interval.
32
33Section 2, Batch lifecycle. A batch opens when the coordinator writes the
34genesis record, assigning the batch id and the initial sequence window.
35Nodes acknowledge the genesis record, after which the coordinator admits
36writes. The batch runs until either the coordinator writes the seal record
37or the drift invariant is breached. When a batch seals, every node flushes
38its local ledger segment to the archive tier and reports the segment digest.
39The digest is the hash of the concatenated record envelopes in sequence
40order, excluding payloads. Two nodes that sealed the same batch must report
41the same digest; a mismatch is escalated as a split-brain event and the
42batch is quarantined.
43
44Section 3, Recovery. Recovery replays the archived segments in sequence
45order against an empty ledger until the digest matches the sealed value.
46Replay is deterministic by construction: the envelope fixes the record
47bytes, and the sequence order fixes their application order. Recovery never
48consults a live node; a replay that cannot reproduce the sealed digest
49marks the segment as untrusted and routes it to manual review. The recovery
50window is bounded at ninety minutes per segment, and a segment that exceeds
51the window is split at the nearest checkpoint boundary and retried in
52halves.
53
54Section 4, Checksum rule. The envelope checksum is computed over the header
55fields in their serialized order: batch id, sequence number, node id,
56timestamp, then payload length. The checksum excludes the payload itself,
57which is carried separately and verified against the declared length only.
58When a record is archived, the archive tier recomputes the checksum from the
59stored header and refuses any record whose recomputed value differs from
60the recorded one; such a record is quarantined with its recomputed value
61attached. The checksum is sixteen bytes and is never truncated in transit.
62
63Section 5, Escalation ladder. A drift breach escalates to the coordinator
64within one heartbeat interval. A digest mismatch escalates immediately and
65pauses admission. A split-brain event escalates to the on-call engineer and
66freezes the archive tier until the quarantined batch is resolved. Escalation
67records carry the breached invariant by name, the observed values, and the
68bounds from this document, in that order.
69
70Section 6, Admission and departure. Admission is a two-phase exchange. The
71candidate node first presents a signed intent record naming the batch and its
72own node id; the coordinator validates the signature against the roster key
73and, if the batch is open, returns an admission record carrying the assigned
74sequence window. The candidate becomes a member only after it acknowledges
75the admission record. Departure is symmetric: a member submits a departure
76intent, the coordinator drains the member's outstanding writes, then issues
77the departure record. A node that leaves before its writes are drained is
78marked delinquent, and its unacknowledged records are re-assigned to the
79coordinator for replay. Membership changes are appended to the batch journal
80in order and are themselves covered by the envelope checksum, so a roster
81tamper is detected at the next archive recomputation.
82
83Section 7, Archival layout. The archive tier stores segments in fixed-size
84blocks aligned on checksum boundaries; a segment smaller than one block is
85padded with a terminal envelope carrying a zero payload length. Block
86headers repeat the batch id and the first and last sequence numbers of the
87contained records, which lets a recovery scan locate a segment by sequence
88number alone without reading payloads. The tier keeps two copies of every
89block on distinct media and compares them on read; a copy disagreement
90triggers a re-fetch from the member that sealed the batch, and if that
91member is gone the block is marked degraded and excluded from digest
92recomputation until restored. Degraded blocks are reported in the daily
93integrity summary with their batch id, block offset, and the media that
94disagreed.";
95
96/// 2026-09-26: The user turns of the script, in order. Turn 1 is prefixed with
97/// [`LONG_PREFIX`]; each later turn is sent after the earlier turns and the
98/// model's replies to them.
99pub const TURNS: [&str; 4] = [
100    // 2026-09-26: Turn 1: acknowledge the document and count its sections.
101    "Reply with exactly one line: ACK 7741-C and the number of sections \
102     listed in the document.",
103    // 2026-09-26: Turn 2: Section 1's invariants as three numbered lines.
104    "List the three invariants from Section 1 of the document, numbered 1 \
105     to 3, one per line, each in at most ten words.",
106    // 2026-09-26: Turn 3: rewrite Section 4 as two sentences.
107    "Rewrite Section 4 of the document as exactly two sentences, preserving \
108     every rule it states.",
109    // 2026-09-26: Turn 4: three questions answered from Section 4.
110    "Answer from the document: (a) what does the envelope checksum cover, in \
111     field order; (b) what is excluded from it; (c) what happens when the \
112     archive tier recomputes a mismatching checksum. One short paragraph.",
113];
114
115/// 2026-09-26: The request every turn sends: greedy, seed 0, fixed `max_tokens`.
116/// `stream_options.include_usage` asks for usage on the stream; without it
117/// `ChatOutcome` counts deltas for `completion_tokens` and reads 0 cached
118/// tokens. There is no `chat_template_kwargs`: the gate's BENCH.toml entry
119/// turns thinking off with the `disable_thinking` serve override.
120pub(super) fn request_body(model: &str, messages: &[Value], max_tokens: usize) -> Value {
121    json!({
122        "model": model,
123        "stream": true,
124        "stream_options": {"include_usage": true},
125        "temperature": 0.0,
126        "seed": 0,
127        "max_tokens": max_tokens,
128        "messages": messages,
129    })
130}
131
132/// 2026-09-26: The full user message of turn 1.
133pub(super) fn first_turn() -> String {
134    format!("{LONG_PREFIX}\n\n{}", TURNS[0])
135}
136
137/// 2026-09-26: The number of sections in [`LONG_PREFIX`], counted from its
138/// `Section <n>,` headings; turn 1's anchor checks the reply states it.
139fn section_count() -> usize {
140    (1..)
141        .take_while(|n| LONG_PREFIX.contains(&format!("Section {n},")))
142        .count()
143}
144
145fn contains_token(text: &str, expected: &str) -> bool {
146    text.split(|c: char| !c.is_alphanumeric())
147        .any(|token| token.eq_ignore_ascii_case(expected))
148}
149
150fn has_number_prefix(line: &str, number: usize) -> bool {
151    line.strip_prefix(&number.to_string())
152        .and_then(|rest| rest.chars().next())
153        .is_some_and(|c| c.is_whitespace() || matches!(c, '.' | ')' | ':'))
154}
155
156fn contains_in_order(text: &str, phrases: &[&str]) -> bool {
157    let text = text.to_lowercase();
158    let mut cursor = 0;
159    phrases.iter().all(|phrase| {
160        let Some(found) = text[cursor..].find(phrase) else {
161            return false;
162        };
163        cursor += found + phrase.len();
164        true
165    })
166}
167
168/// 2026-09-26: Anchors on the reference round; every violation found, or empty.
169/// `compare.rs` holds replays to round 0 only, so these hold round 0 to the
170/// script: every turn ends with `stop`, turn 1 has the ACK and the section
171/// count, turn 2 is three numbered lines, and turns 3 and 4 name the checksum
172/// fields in order, the payload exclusion, recompute and quarantine (turn 3
173/// also in exactly two sentences). A turn that hit the token budget is a
174/// violation because its length cannot anchor the collapse ratios.
175pub(super) fn validate_reference(reference: &[Transcript]) -> Vec<String> {
176    let mut violations = Vec::new();
177    if reference.len() != TURNS.len() {
178        violations.push(format!(
179            "reference has {} turn(s), script has {}",
180            reference.len(),
181            TURNS.len()
182        ));
183        return violations;
184    }
185    for (i, t) in reference.iter().enumerate() {
186        match t.finish_reason.as_deref() {
187            Some("stop") => {}
188            Some("length") => violations.push(format!(
189                "turn {}: reference hit the token budget (finish_reason=length) — a \
190                 truncated reference cannot anchor the collapse ratios",
191                i + 1
192            )),
193            other => violations.push(format!(
194                "turn {}: reference did not finish normally (finish_reason={other:?})",
195                i + 1
196            )),
197        }
198    }
199    let t1 = &reference[0].text;
200    if !t1.contains("ACK 7741-C") {
201        violations.push("turn 1: missing the demanded 'ACK 7741-C' acknowledgement".into());
202    }
203    // 2026-09-26: The count is searched outside the document id, because
204    // "7741-C" contains a 7 of its own. A digit or the spelled-out word counts.
205    let sections = section_count();
206    let words = [
207        "zero", "one", "two", "three", "four", "five", "six", "seven", "eight", "nine",
208    ];
209    let stripped = t1.replace("7741-C", "");
210    let has_digit = contains_token(&stripped, &sections.to_string());
211    let has_word = words
212        .get(sections)
213        .is_some_and(|word| contains_token(&stripped, word));
214    if !has_digit && !has_word {
215        violations.push(format!(
216            "turn 1: does not state the document's section count ({sections})"
217        ));
218    }
219    let lines: Vec<&str> = reference[1]
220        .text
221        .lines()
222        .map(str::trim)
223        .filter(|l| !l.is_empty())
224        .collect();
225    if lines.len() != 3 {
226        violations.push(format!(
227            "turn 2: expected exactly 3 numbered lines, got {}",
228            lines.len()
229        ));
230    } else {
231        for (i, line) in lines.iter().enumerate() {
232            if !has_number_prefix(line, i + 1) {
233                violations.push(format!(
234                    "turn 2: line {} does not start with its number: {:?}",
235                    i + 1,
236                    line
237                ));
238            }
239        }
240    }
241    let t3 = reference[2].text.to_lowercase();
242    let t3_sentences = t3
243        .split_terminator(['.', '!', '?'])
244        .filter(|sentence| !sentence.trim().is_empty())
245        .count();
246    // 2026-09-26: Each anchor is named once and used both to decide and to
247    // report, so the message says which one failed.
248    let t3_fields = contains_in_order(
249        &t3,
250        &[
251            "batch id",
252            "sequence number",
253            "node id",
254            "timestamp",
255            "payload length",
256        ],
257    );
258    // 2026-09-26: Stem and noun are checked apart: Section 4 says "The checksum
259    // excludes the payload itself", with "the" between the two words.
260    let t3_exclusion = t3.contains("exclud") && t3.contains("payload");
261    let t3_recompute = t3.contains("recompute");
262    let t3_quarantine = t3.contains("quarantin");
263    if t3_sentences != 2 || !t3_fields || !t3_exclusion || !t3_recompute || !t3_quarantine {
264        violations.push(format!(
265            "turn 3: Section 4 rewrite failed an anchor (sentences={t3_sentences}, \
266             fields_in_order={t3_fields}, exclusion={t3_exclusion}, \
267             recompute={t3_recompute}, quarantine={t3_quarantine})"
268        ));
269    }
270    let t4 = reference[3].text.to_lowercase();
271    if !contains_in_order(
272        &t4,
273        &[
274            "batch id",
275            "sequence number",
276            "node id",
277            "timestamp",
278            "payload length",
279        ],
280        // 2026-09-26: Stemmed as in turn 3; `payload` is required on its own.
281    ) || !t4.contains("exclud")
282        || !t4.contains("payload")
283        || !t4.contains("recompute")
284        || !t4.contains("quarantin")
285    {
286        violations.push("turn 4: does not answer every checksum question".into());
287    }
288    violations
289}
290
291#[cfg(test)]
292#[path = "probe_tests.rs"]
293mod probe_tests;