metrale_bench/benchmarks/contamination/
driver.rs1use crate::hardware::Sensitivity;
23use std::future::Future;
24use std::time::{Duration, Instant};
25
26use anyhow::{Context, Result, bail};
27use serde_json::{Value, json};
28
29use crate::benchmark::{Benchmark, BenchmarkDescriptor};
30use crate::benchmarks::one_line;
31use crate::http;
32use crate::metadata::PluginMetadata;
33use crate::params::{ParamKind, ParamSpec, ParamValue, ParamValues};
34use crate::plugin::{Plugin, PluginHandle};
35use crate::result::{BenchmarkResult, LogLine, RunStatus, Verdict};
36
37use super::prompts::{self, Probe};
38use super::report;
39use super::score::{Legs, Score, score, verdict};
40use super::transcript::{RequestOutcome, Transcript};
41
42const SUMMARY: &str = "Concurrent requests must not change each other's output";
43pub const METADATA: PluginMetadata = PluginMetadata::metrale(SUMMARY);
44
45pub const DESCRIPTOR: BenchmarkDescriptor = BenchmarkDescriptor {
46 id: "cross-contamination",
47 name: "Cross-Contamination Detector",
48 summary: SUMMARY,
49 detail: "Four legs at temperature 0: prime every probe cache-warm, record each probe's solo \
50 output twice, re-run the probes inside concurrent batches at several concurrency \
51 rungs, then re-run them solo afterwards. Each probe carries a lexical canary; a \
52 foreign canary in a reply is leakage on its own evidence, any other token-level \
53 difference from the solo reference is a divergence, and a divergence in the closing \
54 solo leg means corrupted state SURVIVED the batch. Zero tolerance — token identity \
55 has no noise term to allow for.",
56 duration_hint: "~2–5 min",
57 expected_secs: 240,
58 updated: "2026-08-09",
59 needs_confirmation: false,
60 intended_for: None,
63 threshold_params: &[],
64 sensitivity: Sensitivity::Correctness,
67 ctor: || Box::new(CrossContamination::default()),
68};
69
70#[derive(Default, Clone, Copy, PartialEq, Eq, Debug)]
71enum Phase {
72 #[default]
73 Prime,
74 RefA,
75 RefB,
76 Rung,
77 Post,
78 Score,
79 Done,
80}
81
82#[derive(Default)]
83pub struct CrossContamination {
84 handle: Option<PluginHandle>,
85 phase: Phase,
86 concurrencies: Vec<usize>,
87 rung_cursor: usize,
88 ref_a: Vec<RequestOutcome>,
89 ref_b: Vec<RequestOutcome>,
90 rungs: Vec<(String, Vec<RequestOutcome>)>,
91 post: Vec<RequestOutcome>,
92 max_tokens: usize,
93 min_completion_tokens: usize,
94 timeout: Duration,
95 started: Option<Instant>,
96 probed: bool,
97}
98
99pub(super) fn request_body(model: &str, prompt: &str, max_tokens: usize) -> Value {
102 json!({
103 "model": model,
104 "stream": true,
105 "temperature": 0.0,
106 "max_tokens": max_tokens,
107 "messages": [{"role": "user", "content": prompt}],
108 })
109}
110
111pub(super) fn rung_slots(n_probes: usize, conc: usize) -> Vec<usize> {
117 (0..conc.max(n_probes)).map(|s| s % n_probes).collect()
118}
119
120pub(super) fn to_outcome(r: Result<http::ChatOutcome>) -> RequestOutcome {
123 match r {
124 Ok(o) => RequestOutcome::Ok(Box::new(Transcript::from(&o))),
125 Err(e) => RequestOutcome::Error(one_line(format!("{e:#}"))),
126 }
127}
128
129impl CrossContamination {
130 fn handle(&self) -> Result<&PluginHandle> {
131 self.handle.as_ref().context("benchmark was not loaded")
132 }
133
134 fn elapsed(&self) -> Duration {
135 self.started.map(|s| s.elapsed()).unwrap_or_default()
136 }
137
138 fn total_steps(&self) -> u64 {
140 self.concurrencies.len() as u64 + 5
141 }
142
143 fn steps_done(&self) -> u64 {
144 match self.phase {
145 Phase::Prime => 0,
146 Phase::RefA => 1,
147 Phase::RefB => 2,
148 Phase::Rung => 3 + self.rung_cursor as u64,
149 Phase::Post => 3 + self.concurrencies.len() as u64,
150 Phase::Score => 4 + self.concurrencies.len() as u64,
151 Phase::Done => self.total_steps(),
152 }
153 }
154
155 async fn one(&self, probe: &Probe) -> RequestOutcome {
156 let Ok(handle) = self.handle() else {
157 return RequestOutcome::Error("benchmark was not loaded".into());
158 };
159 let target = handle.target();
160 let body = request_body(&target.model, &probe.prompt(), self.max_tokens);
161 to_outcome(http::chat_stream(target, &body, self.timeout).await)
162 }
163
164 async fn solo_leg(&self, label: &str) -> Result<Vec<RequestOutcome>> {
166 let handle = self.handle()?.clone();
167 let mut out = Vec::with_capacity(prompts::PROBES.len());
168 for probe in &prompts::PROBES {
169 handle.check_cancelled()?;
170 handle.status(format!("{label} · probe {} solo", probe.name));
171 out.push(self.one(probe).await);
172 }
173 Ok(out)
174 }
175
176 async fn run_rung(&self, conc: usize) -> Vec<RequestOutcome> {
179 let slots = rung_slots(prompts::PROBES.len(), conc);
180 let futures: Vec<_> = slots
181 .iter()
182 .map(|&p| self.one(&prompts::PROBES[p]))
183 .collect();
184 let mut outcomes = futures::future::join_all(futures).await;
185 outcomes.truncate(prompts::PROBES.len());
186 outcomes
187 }
188
189 pub(super) fn scored(&self) -> (Score, Verdict) {
192 let canaries = prompts::canaries();
193 let legs = Legs {
194 ref_a: &self.ref_a,
195 ref_b: &self.ref_b,
196 rungs: &self.rungs,
197 post: &self.post,
198 canaries: &canaries,
199 min_completion_tokens: self.min_completion_tokens,
200 };
201 let s = score(&legs);
202 let v = verdict(&s);
203 (s, v)
204 }
205
206 fn frame(&self, phase: &str, line: Option<LogLine>) -> BenchmarkResult {
207 let mut f = BenchmarkResult::running(phase, self.elapsed())
208 .with_progress(self.steps_done(), self.total_steps());
209 if let Some(line) = line {
210 f = f.log_line(line);
211 }
212 f
213 }
214}
215
216impl Plugin for CrossContamination {
217 fn metadata(&self) -> &'static PluginMetadata {
218 &METADATA
219 }
220
221 fn load(&mut self, handle: PluginHandle) -> impl Future<Output = Result<()>> + Send {
222 self.handle = Some(handle);
223 self.started = Some(Instant::now());
224 async { Ok(()) }
225 }
226}
227
228impl Benchmark for CrossContamination {
229 fn descriptor(&self) -> &'static BenchmarkDescriptor {
230 &DESCRIPTOR
231 }
232
233 fn parameters(&self) -> Vec<ParamSpec> {
234 vec![
235 ParamSpec::new(
236 "concurrencies",
237 "Concurrency rungs",
238 "Batch sizes for the concurrent leg; the probes ride inside each batch.",
239 ParamKind::IntList { min: 2, max: 128 },
240 ParamValue::IntList(vec![2, 4, 8]),
241 ),
242 ParamSpec::new(
243 "max_tokens",
244 "Max tokens",
245 "Output budget per request. The probes answer in well under this.",
246 ParamKind::Int { min: 32, max: 4096 },
247 ParamValue::Int(256),
248 ),
249 ParamSpec::new(
250 "min_completion_tokens",
251 "Completion-token floor",
252 "Replies shorter than this are Unmeasured: two empty replies are equal and prove nothing.",
253 ParamKind::Int { min: 1, max: 4096 },
254 ParamValue::Int(16),
255 ),
256 ParamSpec::new(
257 "request_timeout_s",
258 "Request timeout",
259 "Seconds before a single request is abandoned and scored as Unmeasured.",
260 ParamKind::Int { min: 10, max: 3600 },
261 ParamValue::Int(300),
262 ),
263 ]
264 }
265
266 fn configure(&mut self, values: &ParamValues) -> Result<()> {
267 let specs = self.parameters();
268 values.validate_against(&specs)?;
269 let floor = values.usize("min_completion_tokens")?;
270 let max_tokens = values.usize("max_tokens")?;
271 if floor >= max_tokens {
274 bail!(
275 "Completion-token floor: {floor} must be below the max_tokens budget \
276 ({max_tokens}), or every reply is Unmeasured by construction"
277 );
278 }
279 self.concurrencies = values
280 .int_list("concurrencies")?
281 .iter()
282 .map(|c| *c as usize)
283 .collect();
284 self.max_tokens = max_tokens;
285 self.min_completion_tokens = floor;
286 self.timeout = Duration::from_secs(values.usize("request_timeout_s")? as u64);
287 self.phase = Phase::Prime;
288 self.probed = false;
289 self.rung_cursor = 0;
290 self.ref_a.clear();
291 self.ref_b.clear();
292 self.rungs.clear();
293 self.post.clear();
294 Ok(())
295 }
296
297 async fn next(&mut self) -> Result<BenchmarkResult> {
298 let handle = self.handle()?.clone();
299 handle.check_cancelled()?;
300
301 if !self.probed {
302 self.probed = true;
303 http::probe(handle.target(), Duration::from_secs(10))
304 .await
305 .context("endpoint probe failed — check the target URL and port")?;
306 if self.concurrencies.is_empty() {
307 bail!("no concurrency rungs configured");
308 }
309 return Ok(self.frame(
310 "probe",
311 Some(LogLine::info(format!(
312 "{} · model {} · {} probes × {} rungs",
313 handle.target().base_url,
314 handle.target().model,
315 prompts::PROBES.len(),
316 self.concurrencies.len()
317 ))),
318 ));
319 }
320
321 match self.phase {
322 Phase::Prime => {
323 let primes = self.solo_leg("prime").await?;
324 let failed = primes
325 .iter()
326 .filter(|o| matches!(o, RequestOutcome::Error(_)))
327 .count();
328 self.phase = Phase::RefA;
329 let line = if failed > 0 {
330 LogLine::warn(format!(
331 "{failed} prime request(s) failed — later legs may run cache-cold"
332 ))
333 } else {
334 LogLine::info("primed — all measured legs run cache-warm")
335 };
336 Ok(self.frame("prime", Some(line)))
337 }
338 Phase::RefA => {
339 self.ref_a = self.solo_leg("reference 1/2").await?;
340 self.phase = Phase::RefB;
341 Ok(self.frame("reference 1/2", None))
342 }
343 Phase::RefB => {
344 self.ref_b = self.solo_leg("reference 2/2").await?;
345 self.phase = Phase::Rung;
346 Ok(self.frame("reference 2/2", None))
347 }
348 Phase::Rung => {
349 let conc = self.concurrencies[self.rung_cursor];
350 handle.status(format!("concurrency {conc} · batch in flight"));
351 let measured = self.run_rung(conc).await;
352 self.rungs.push((format!("c{conc}"), measured));
353 self.rung_cursor += 1;
354 if self.rung_cursor >= self.concurrencies.len() {
355 self.phase = Phase::Post;
356 }
357 handle.progress(self.steps_done(), self.total_steps());
358 Ok(self.frame(format!("concurrency {conc}").as_str(), None))
359 }
360 Phase::Post => {
361 self.post = self.solo_leg("post-check").await?;
362 self.phase = Phase::Score;
363 Ok(self.frame("post-check", None))
364 }
365 Phase::Score => {
366 let (s, v) = self.scored();
367 self.phase = Phase::Done;
368 let line = LogLine::info(one_line(format!(
369 "{} comparisons: {} identical · {} diverged · {} contaminated · \
370 {} persistent · {} unmeasured",
371 s.compared, s.identical, s.diverged, s.contaminated, s.persistent, s.unmeasured
372 )));
373 Ok(BenchmarkResult {
374 status: RunStatus::Completed,
375 ..BenchmarkResult::running("done", self.elapsed())
376 }
377 .with_progress(self.total_steps(), self.total_steps())
378 .with_summary(report::summary(&s))
379 .with_table(report::table(&s))
380 .with_metrics(report::metrics(&s))
381 .with_verdict(v)
382 .log_line(line))
383 }
384 Phase::Done => bail!("next() was called after the run finished"),
385 }
386 }
387}
388
389#[cfg(test)]
390#[path = "driver_tests.rs"]
391mod driver_tests;