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}