metrale_bench/benchmarks/kat_equality/
driver.rs1use std::future::Future;
11use std::time::{Duration, Instant};
12
13use anyhow::{Context, Result};
14use serde_json::json;
15
16use crate::benchmark::{Benchmark, BenchmarkDescriptor};
17use crate::benchmarks::bfcl::{MAX_NEW_TOKENS, dataset, draw::DrawSpec, provision};
18use crate::benchmarks::transcript::{RequestOutcome, Transcript};
19use crate::hardware::Sensitivity;
20use crate::http;
21use crate::metadata::PluginMetadata;
22use crate::params::{ParamKind, ParamSpec, ParamValue, ParamValues};
23use crate::plugin::{Plugin, PluginHandle};
24use crate::result::{BenchmarkResult, LogLine, RunStatus};
25
26use super::compare::{Observation, OrderRun, permutation, score, verdict};
27
28const SUMMARY: &str = "The same sample must answer the same, whatever ran before it";
29
30const DEFAULT_ORDERS: usize = 2;
33
34const DEFAULT_SAMPLE_CAP: usize = 0;
39
40pub const METADATA: PluginMetadata = PluginMetadata::metrale(SUMMARY);
41
42pub const DESCRIPTOR: BenchmarkDescriptor = BenchmarkDescriptor {
43 id: "kat-equality-gate",
44 name: "KAT Equality Gate",
45 summary: SUMMARY,
46 detail: "Issues one BFCL draw against ONE server in two or more request ORDERS \
47 (canonical, then reversed, then rotations) at temperature 0, and requires \
48 every sample_id's reply to be byte-identical across all of them — text, \
49 reasoning, tool calls with RAW arguments, finish reason, and completion \
50 token count. Any difference FAILS: at temperature 0 a reply must be a \
51 function of its own request, and a benchmark whose samples can see each \
52 other cannot be sharded, nor can a score taken under one order describe \
53 another. A failed request is UNMEASURED, never agreement, and a run whose \
54 replies were all empty fails as VACUOUS rather than certifying a dead \
55 endpoint as order-independent. Serve it with --hermetic to measure the \
56 regime a known-answer test actually requires.",
57 duration_hint: "~2x one BFCL leg",
58 expected_secs: 4200,
59 updated: "2026-09-09",
60 needs_confirmation: false,
61 intended_for: None,
64 threshold_params: &[],
65 sensitivity: Sensitivity::Correctness,
68 ctor: || Box::new(KatEquality::default()),
69};
70
71enum Phase {
72 Provision,
73 Generate,
74 Compare,
75 Done,
76}
77
78pub struct KatEquality {
79 handle: Option<PluginHandle>,
80 phase: Phase,
81 artifacts: Option<provision::Artifacts>,
82 samples: Vec<dataset::Sample>,
85 order_index: usize,
86 cursor: usize,
87 runs: Vec<OrderRun>,
88 current: Vec<Observation>,
89 orders: usize,
90 sample_cap: usize,
91 max_new_tokens: usize,
92 request_timeout: Duration,
93 started: Option<Instant>,
94}
95
96impl Default for KatEquality {
97 fn default() -> Self {
98 Self {
99 handle: None,
100 phase: Phase::Provision,
101 artifacts: None,
102 samples: Vec::new(),
103 order_index: 0,
104 cursor: 0,
105 runs: Vec::new(),
106 current: Vec::new(),
107 orders: DEFAULT_ORDERS,
108 sample_cap: DEFAULT_SAMPLE_CAP,
109 max_new_tokens: MAX_NEW_TOKENS,
110 request_timeout: Duration::from_secs(300),
111 started: None,
112 }
113 }
114}
115
116impl KatEquality {
117 fn handle(&self) -> Result<&PluginHandle> {
118 self.handle.as_ref().context("plugin was not loaded")
119 }
120
121 fn elapsed(&self) -> Duration {
122 self.started.map(|s| s.elapsed()).unwrap_or_default()
123 }
124
125 fn order_label(&self, index: usize) -> String {
126 match index {
127 0 => "canonical".to_string(),
128 1 => "reversed".to_string(),
129 k => format!("rotation-{k}"),
130 }
131 }
132
133 fn total_steps(&self) -> u64 {
134 (self.samples.len() * self.orders) as u64
135 }
136
137 fn done_steps(&self) -> u64 {
138 (self.order_index * self.samples.len() + self.cursor) as u64
139 }
140
141 async fn issue_one(&mut self, sample_index: usize) -> Result<()> {
149 let handle = self.handle()?.clone();
150 let sample = self.samples[sample_index].clone();
151 let target = handle.target();
152 let body = json!({
153 "model": target.model,
154 "stream": true,
155 "stream_options": {"include_usage": true},
156 "temperature": 0.0,
157 "max_tokens": self.max_new_tokens,
158 "messages": sample.messages,
159 "tools": sample.tools,
160 "tool_choice": sample.tool_choice,
161 });
162 let outcome = match http::chat_stream(target, &body, self.request_timeout).await {
163 Err(e) => RequestOutcome::Error(format!("{e:#}")),
167 Ok(o) => RequestOutcome::Ok(Box::new(Transcript::from(&o))),
168 };
169 self.current.push(Observation {
170 sample_id: sample.sample_id.clone(),
171 outcome,
172 });
173 Ok(())
174 }
175}
176
177impl Plugin for KatEquality {
178 fn metadata(&self) -> &'static PluginMetadata {
179 &METADATA
180 }
181
182 fn load(&mut self, handle: PluginHandle) -> impl Future<Output = Result<()>> + Send {
183 self.started = Some(Instant::now());
184 self.handle = Some(handle.clone());
185 async move {
186 let artifacts = provision::ensure(handle.artifacts(), &handle).await?;
187 self.artifacts = Some(artifacts);
188 Ok(())
189 }
190 }
191}
192
193impl Benchmark for KatEquality {
194 fn descriptor(&self) -> &'static BenchmarkDescriptor {
195 &DESCRIPTOR
196 }
197
198 fn parameters(&self) -> Vec<ParamSpec> {
199 vec![
200 ParamSpec::new(
201 "orders",
202 "Request orders",
203 "How many different orders to issue the draw in. 2 is canonical plus \
204 reversed, which is the strongest pair; further orders are rotations.",
205 ParamKind::Int { min: 2, max: 6 },
206 ParamValue::Int(DEFAULT_ORDERS as i64),
207 ),
208 ParamSpec::new(
209 "sample_cap",
210 "Sample cap",
211 "Truncate the draw to this many samples (0 = the whole draw). This is \
212 a PREFIX, not a sample: the draw concatenates subsets in sorted name \
213 order, so a cap decides which subsets are compared at all. Pick it from \
214 where the effect lives, and re-run the negative control AT the cap — one \
215 taken on the whole draw does not describe a capped run, because \
216 truncating changes what ran before every surviving sample.",
217 ParamKind::Int { min: 0, max: 5000 },
218 ParamValue::Int(DEFAULT_SAMPLE_CAP as i64),
219 ),
220 ParamSpec::new(
221 "max_new_tokens",
222 "Max new tokens",
223 "Generation cap per sample. Defaults to BFCL's own budget: this \
224 gate asks whether BFCL's conditions are order-independent, so a \
225 different budget would answer about a regime nobody measures.",
226 ParamKind::Int { min: 32, max: 4096 },
227 ParamValue::Int(MAX_NEW_TOKENS as i64),
228 ),
229 ParamSpec::new(
230 "request_timeout_s",
231 "Request timeout (s)",
232 "Per-request timeout. A timeout is UNMEASURED, which fails the gate — \
233 it is not evidence that the orders agreed.",
234 ParamKind::Int { min: 10, max: 3600 },
235 ParamValue::Int(300),
236 ),
237 ]
238 }
239
240 fn configure(&mut self, values: &ParamValues) -> Result<()> {
241 let specs = self.parameters();
242 values.validate_against(&specs)?;
243 self.orders = values.usize("orders")?;
244 self.sample_cap = values.usize("sample_cap")?;
245 self.max_new_tokens = values.usize("max_new_tokens")?;
246 self.request_timeout = Duration::from_secs(values.usize("request_timeout_s")? as u64);
247 self.phase = Phase::Provision;
250 self.order_index = 0;
251 self.cursor = 0;
252 self.runs.clear();
253 self.current.clear();
254 Ok(())
255 }
256
257 async fn next(&mut self) -> Result<BenchmarkResult> {
258 let handle = self.handle()?.clone();
259 handle.check_cancelled()?;
260 match self.phase {
261 Phase::Provision => {
262 http::probe(handle.target(), Duration::from_secs(10))
263 .await
264 .context("endpoint probe failed — check the target URL and port")?;
265 let artifacts = self
266 .artifacts
267 .clone()
268 .context("artifacts were not provisioned")?;
269 self.samples = dataset::load_shard(&artifacts.dataset, &DrawSpec::golden(), None)?;
270 if self.sample_cap > 0 {
271 self.samples.truncate(self.sample_cap);
272 }
273 self.phase = Phase::Generate;
274 let n = self.samples.len();
275 let mut frame = BenchmarkResult::running("draw", self.elapsed())
276 .with_progress(0, self.total_steps())
277 .log_line(LogLine::info(format!(
278 "{n} samples x {} orders = {} generations against one server",
279 self.orders,
280 n * self.orders
281 )));
282 if self.sample_cap > 0 {
283 frame = frame.log_line(LogLine::warn(format!(
284 "capped at {n} of the golden 995 — the cap TRUNCATES, and the draw \
285 concatenates in sorted subset order, so this selects which subsets \
286 are compared rather than a random subsample of them; say the cap \
287 when you report a green"
288 )));
289 }
290 Ok(frame)
291 }
292 Phase::Generate => {
293 let n = self.samples.len();
294 if self.cursor >= n {
295 let label = self.order_label(self.order_index);
298 self.runs.push(OrderRun {
299 label: label.clone(),
300 observations: std::mem::take(&mut self.current),
301 });
302 self.order_index += 1;
303 self.cursor = 0;
304 if self.order_index >= self.orders {
305 self.phase = Phase::Compare;
306 }
307 return Ok(BenchmarkResult::running("order done", self.elapsed())
308 .with_progress(self.done_steps(), self.total_steps())
309 .log_line(LogLine::info(format!("order `{label}` complete"))));
310 }
311 let index = permutation(self.order_index, n)[self.cursor];
312 self.issue_one(index).await?;
313 self.cursor += 1;
314 let label = self.order_label(self.order_index);
315 handle.progress(self.done_steps(), self.total_steps());
316 handle.status(format!("{label} · {}/{n}", self.cursor));
317 Ok(BenchmarkResult::running(label, self.elapsed())
318 .with_progress(self.done_steps(), self.total_steps()))
319 }
320 Phase::Compare => {
321 let s = score(&self.runs);
322 let v = verdict(&s);
323 self.phase = Phase::Done;
324 let total = self.total_steps();
325 Ok(BenchmarkResult {
326 status: RunStatus::Completed,
327 ..BenchmarkResult::running("done", self.elapsed())
328 }
329 .with_progress(total, total)
330 .with_summary(super::report::summary(&s))
331 .with_table(super::report::table(&s, &self.runs))
332 .with_metrics(super::report::metrics(&s))
333 .with_verdict(v))
334 }
335 Phase::Done => Ok(BenchmarkResult {
336 status: RunStatus::Completed,
337 ..BenchmarkResult::running("done", self.elapsed())
338 }),
339 }
340 }
341}