metrale_bench/gate/bench.rs
1// SPDX-License-Identifier: MIT OR Apache-2.0
2
3//! 2026-09-26: `kernels/<hw>/<model>/BENCH.toml`: a model's benchmark entries
4//! and thresholds, beside its `MODEL.toml`, and [`baseline_for`], which
5//! assembles one gate's [`GateBaseline`] from every such file.
6//!
7//! One file per model, one `[[benchmarks]]` entry per (quant, checkpoint,
8//! gate). Hardware and model come from the file's path.
9//!
10//! [`super::taxon::configs`] does not list `BENCH.toml`, so editing a
11//! threshold does not change any target's closure hash.
12//!
13//! Owner: bench gate.
14//! Invariants:
15//! - [`load_all`] refuses an entry whose `status` is neither `measured` nor
16//! `unmeasured`, an `unmeasured` entry with metrics, a `measured` entry
17//! without them, a `port` serve override, an incomplete hermetic pin set,
18//! an undeclared `serve_env` lever, and invalid `noise`.
19//! - [`baseline_for`] contains only `measured` entries, and exactly one
20//! default checkpoint per hardware.
21
22use std::collections::BTreeMap;
23use std::path::Path;
24
25use anyhow::{Context, Result, bail};
26use serde::{Deserialize, Serialize};
27
28use super::record::{GateBaseline, HardwareBaseline, ModelBaseline};
29use super::taxon;
30
31/// 2026-09-26: One `[[benchmarks]]` entry.
32#[derive(Debug, Clone, Serialize, Deserialize)]
33pub struct BenchEntry {
34 /// 2026-09-26: The quant, used as the `quant` of the entry's
35 /// `taxon::Target`.
36 pub quant: String,
37 /// 2026-09-26: The served checkpoint; thresholds are keyed by it, not by
38 /// the model directory.
39 pub checkpoint: String,
40 /// 2026-09-26: Benchmark id, e.g. `bfcl-subset`.
41 pub gate: String,
42 /// 2026-09-26: The recipe id `<family>/<stem>` that serves this entry,
43 /// when there is one.
44 #[serde(default, skip_serializing_if = "Option::is_none")]
45 pub recipe: Option<String>,
46 /// 2026-09-26: Human name for this variant in the TUI's variant list;
47 /// when empty the list shows the checkpoint id (`tui::bench_variants`).
48 #[serde(default, skip_serializing_if = "String::is_empty")]
49 pub label: String,
50 /// 2026-09-26: Whether this is the checkpoint the gate runs when none is
51 /// named.
52 #[serde(default)]
53 pub default: bool,
54 /// 2026-09-26: `measured` or `unmeasured`; [`load_all`] refuses anything
55 /// else.
56 pub status: String,
57 #[serde(default, skip_serializing_if = "String::is_empty")]
58 pub note: String,
59 /// 2026-09-26: Recipe keys self-start applies on every
60 /// `--pull-request-gate` run, as strings matching
61 /// `--serve-override KEY=VALUE` (e.g. `ssm_cache_slots = "256"` on the
62 /// qwen3.6-35b-a3b `bfcl-subset-echolp` entry). Empty and omitted is the
63 /// normal case.
64 /// `check_record` requires the record's serve overrides to equal this map
65 /// exactly. `port` is refused: self-start binds a free port itself.
66 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
67 pub serve_overrides: BTreeMap<String, String>,
68 /// 2026-09-26: Benchmark parameter pins the gate applies on every
69 /// `--pull-request-gate` run, for thresholds measured with non-default
70 /// parameters. Empty and omitted is the normal case.
71 ///
72 /// Values are strings parsed by each parameter's own `ParamKind::parse`,
73 /// like a typed `--param KEY=VALUE`, and an explicit `--param` wins
74 /// (`bench_resolve::apply_param_overrides`). A key naming a
75 /// `threshold_params`-coupled parameter, or no parameter at all, is
76 /// refused there. `check_record` requires each pin on the record's
77 /// `params`, compared ignoring spaces around commas.
78 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
79 pub param_overrides: BTreeMap<String, String>,
80 /// 2026-09-26: `METRALE_*` serve levers the gate pins for this entry,
81 /// applied to the serve on top of the recipe's own `env:` block, for
82 /// levers that are not recipe keys (e.g. `METRALE_FP8_ROWWISE` on the
83 /// qwen3.8-27b `concurrency-sweep` entry). Empty and omitted is the normal
84 /// case. Validated at parse by `serve_env::declared`; disclosed on the
85 /// record as `GateRecord::serve_env`, never demanded by `check_record`.
86 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
87 pub serve_env: BTreeMap<String, String>,
88 /// 2026-09-26: Thresholds. Absent exactly when `status` is `unmeasured`.
89 #[serde(default, skip_serializing_if = "Option::is_none")]
90 pub metrics: Option<BTreeMap<String, super::record::Bound>>,
91}
92
93#[derive(Debug, Default, Deserialize)]
94struct BenchFile {
95 #[serde(default)]
96 benchmarks: Vec<BenchEntry>,
97}
98
99/// 2026-09-26: Every benchmark entry in the tree, validated, with the target
100/// each belongs to.
101///
102/// Files are found by `bench_files`, not [`taxon::walk`]: the walk needs a
103/// `MODEL.toml` and a quant directory, and a BENCH.toml may exist before its
104/// model's kernels do.
105pub fn load_all(root: &Path) -> Result<Vec<(taxon::Target, BenchEntry)>> {
106 let mut out = Vec::new();
107 for (hardware, model, path) in bench_files(root) {
108 let text = std::fs::read_to_string(&path)
109 .with_context(|| format!("reading {}", path.display()))?;
110 let parsed: BenchFile =
111 toml::from_str(&text).with_context(|| format!("parsing {}", path.display()))?;
112 for entry in parsed.benchmarks {
113 if entry.status != "measured" && entry.status != "unmeasured" {
114 bail!(
115 "{}: status must be \"measured\" or \"unmeasured\", got {:?}",
116 path.display(),
117 entry.status
118 );
119 }
120 if entry.status == "unmeasured" && entry.metrics.is_some() {
121 bail!(
122 "{}: {} / {} is unmeasured but carries thresholds. A guessed \
123 number a run can clear is worse than no number — it reports \
124 PASS for something nobody measured.",
125 path.display(),
126 entry.gate,
127 entry.checkpoint
128 );
129 }
130 if entry.status == "measured" && entry.metrics.as_ref().is_none_or(BTreeMap::is_empty) {
131 bail!(
132 "{}: {} / {} claims to be measured but declares no metrics",
133 path.display(),
134 entry.gate,
135 entry.checkpoint
136 );
137 }
138 if entry.serve_overrides.contains_key("port") {
139 bail!(
140 "{}: {} / {} serve_overrides cannot set `port`: self-start binds \
141 a free port itself, so a pin here would name a listener that is not there",
142 path.display(),
143 entry.gate,
144 entry.checkpoint
145 );
146 }
147 // 2026-09-26: A hermetic entry must also pin every key hermetic
148 // closes (`hermetic::CLOSED_KEYS`) at its value. The record carries
149 // those keys, and `scoring::check_record` compares serve overrides
150 // in both directions, so a partial pin set could never pass;
151 // refusing it here costs a parse, not a campaign.
152 if super::hermetic::is_requested(&entry.serve_overrides) {
153 let missing = super::hermetic::missing_pins(&entry.serve_overrides);
154 if !missing.is_empty() {
155 bail!(
156 "{}: {} / {} pins hermetic=true but not {}: --hermetic expands into those keys, so the record will carry them and `check_record` compares the two sets in both directions. Pin them at these values or drop hermetic.",
157 path.display(),
158 entry.gate,
159 entry.checkpoint,
160 missing
161 .iter()
162 .map(|(k, v)| format!("{k}={v}"))
163 .collect::<Vec<_>>()
164 .join(", ")
165 );
166 }
167 }
168 // 2026-09-26: Lever pins are validated at parse for the same
169 // reason, rather than by the serve after the first model load.
170 crate::serve_env::declared(
171 &format!(
172 "{} / {} [benchmarks.serve_env]",
173 entry.gate, entry.checkpoint
174 ),
175 &entry.serve_env,
176 )
177 .with_context(|| path.display().to_string())?;
178 validate_noise(&path, &entry)?;
179 out.push((
180 taxon::Target {
181 hardware: hardware.clone(),
182 model: model.clone(),
183 quant: entry.quant.clone(),
184 },
185 entry,
186 ));
187 }
188 }
189 Ok(out)
190}
191
192/// 2026-09-26: Every `kernels/<hw>/<model>/BENCH.toml`, with the hardware and
193/// model directory names, sorted. A hardware directory is one holding
194/// `HARDWARE.toml`; a model directory needs only the BENCH.toml.
195fn bench_files(root: &Path) -> Vec<(String, String, std::path::PathBuf)> {
196 let subdirs = |dir: &Path| -> Vec<String> {
197 let Ok(entries) = std::fs::read_dir(dir) else {
198 return Vec::new();
199 };
200 let mut names: Vec<String> = entries
201 .flatten()
202 .filter(|e| e.path().is_dir())
203 .filter_map(|e| e.file_name().to_str().map(str::to_string))
204 .collect();
205 names.sort();
206 names
207 };
208 let kernels = root.join("kernels");
209 let mut out = Vec::new();
210 for hardware in subdirs(&kernels) {
211 let hw_dir = kernels.join(&hardware);
212 if !hw_dir.join("HARDWARE.toml").exists() {
213 continue;
214 }
215 for model in subdirs(&hw_dir) {
216 let path = hw_dir.join(&model).join("BENCH.toml");
217 if path.exists() {
218 out.push((hardware.clone(), model, path));
219 }
220 }
221 }
222 out
223}
224
225/// 2026-09-26: Assemble the baseline for one gate from every `BENCH.toml` in
226/// the tree.
227///
228/// `unmeasured` entries are left out, so `GateBaseline::resolve` on one fails
229/// rather than returning an entry with no bounds: with "no baseline for
230/// hardware …" when no `measured` entry is left on that hardware, and "no
231/// baseline for model …" otherwise. Errors on two defaults on one hardware, a
232/// checkpoint declared twice on one hardware, or a hardware with entries but no
233/// default.
234pub fn baseline_for(root: &Path, benchmark_id: &str) -> Result<GateBaseline> {
235 let mut hardware: BTreeMap<String, HardwareBaseline> = BTreeMap::new();
236 let mut defaults: BTreeMap<String, (String, String)> = BTreeMap::new();
237
238 for (target, entry) in load_all(root)? {
239 if entry.gate != benchmark_id || entry.status != "measured" {
240 continue;
241 }
242 let Some(metrics) = entry.metrics.clone() else {
243 continue;
244 };
245 if entry.default {
246 if let Some((prior, prior_model)) = defaults.get(&target.hardware) {
247 bail!(
248 "{benchmark_id}: both {prior} (in {prior_model}) and {} (in {}) \
249 claim to be the default on {}",
250 entry.checkpoint,
251 target.model,
252 target.hardware
253 );
254 }
255 defaults.insert(
256 target.hardware.clone(),
257 (entry.checkpoint.clone(), target.model.clone()),
258 );
259 }
260 let hw = hardware
261 .entry(target.hardware.clone())
262 .or_insert_with(|| HardwareBaseline {
263 default: String::new(),
264 models: BTreeMap::new(),
265 });
266 if hw
267 .models
268 .insert(
269 entry.checkpoint.clone(),
270 ModelBaseline {
271 recipe: entry.recipe.clone(),
272 label: entry.label.clone(),
273 note: entry.note.clone(),
274 metrics,
275 serve_overrides: entry.serve_overrides.clone(),
276 param_overrides: entry.param_overrides.clone(),
277 serve_env: entry.serve_env.clone(),
278 },
279 )
280 .is_some()
281 {
282 bail!(
283 "{benchmark_id}: {} is declared twice on {}",
284 entry.checkpoint,
285 target.hardware
286 );
287 }
288 }
289
290 for (hw_name, hw) in &mut hardware {
291 match defaults.get(hw_name) {
292 Some((checkpoint, _)) => hw.default = checkpoint.clone(),
293 // 2026-09-26: No implicit default, even for a single checkpoint.
294 None => bail!(
295 "{benchmark_id}: no checkpoint on {hw_name} sets `default = true`; \
296 one must, or the gate has no defined subject"
297 ),
298 }
299 }
300 Ok(GateBaseline {
301 schema: 2,
302 hardware,
303 })
304}
305
306#[cfg(test)]
307#[path = "bench_tests.rs"]
308mod bench_tests;
309
310#[cfg(test)]
311#[path = "bench_serve_pin_tests.rs"]
312mod bench_serve_pin_tests;
313
314/// 2026-09-26: The largest `noise` an entry may declare, as a fraction of the
315/// bound's magnitude. `noise` widens a threshold at compare time
316/// (`scoring::compare`), so without a cap a large value would pass any record
317/// while reading like a measurement annotation.
318const MAX_NOISE_FRACTION: f64 = 0.05;
319
320/// 2026-09-26: `noise` must be finite, non-negative, at most
321/// [`MAX_NOISE_FRACTION`] of a non-zero bound, and absent on an exact pin.
322///
323/// `min == max` is how the BFCL draw size is pinned (`samples` = 995 in
324/// qwen3.6-27b, 1004 in qwen3.6-35b-a3b), to catch a changed draw.
325/// `scoring::compare` applies `noise` to the two-sided arm as well, so noise
326/// on a pin would widen it into a range.
327fn validate_noise(path: &std::path::Path, entry: &BenchEntry) -> Result<()> {
328 let Some(metrics) = entry.metrics.as_ref() else {
329 return Ok(());
330 };
331 for (name, bound) in metrics {
332 let Some(noise) = bound.noise else { continue };
333 if !noise.is_finite() || noise < 0.0 {
334 bail!(
335 "{}: {} / {} metric {name}: noise must be finite and non-negative, got {noise}",
336 path.display(),
337 entry.gate,
338 entry.checkpoint,
339 );
340 }
341 if bound.min.is_some() && bound.min == bound.max {
342 bail!(
343 "{}: {} / {} metric {name} is an EXACT pin (min == max == {:?}) and carries \
344 noise {noise}. Noise on a pin disables it — and a pin is used for things like \
345 the BFCL draw size, where a changed draw is undetectable after the fact.",
346 path.display(),
347 entry.gate,
348 entry.checkpoint,
349 bound.min,
350 );
351 }
352 let magnitude = bound.min.or(bound.max).unwrap_or(0.0).abs();
353 let cap = magnitude * MAX_NOISE_FRACTION;
354 if magnitude > 0.0 && noise > cap {
355 bail!(
356 "{}: {} / {} metric {name}: noise {noise} exceeds {:.0}% of the bound \
357 ({magnitude}) — that is a threshold change wearing a measurement-noise \
358 label. Move the bound instead, so the ratchet is visible in review.",
359 path.display(),
360 entry.gate,
361 entry.checkpoint,
362 MAX_NOISE_FRACTION * 100.0,
363 );
364 }
365 }
366 Ok(())
367}