metrale_bench/gate/record.rs
1// SPDX-License-Identifier: MIT OR Apache-2.0
2
3//! 2026-09-26: The committed shape of one gate run, the baseline shapes it is
4//! judged against, and reading both.
5//!
6//! Owner: bench gate (records).
7//! Invariants:
8//! - `GateRecord::from_run` never builds a record with an empty `git_sha` or
9//! from a `Running` frame.
10
11use std::collections::BTreeMap;
12use std::path::Path;
13
14use anyhow::{Context, Result, bail};
15use serde::{Deserialize, Serialize};
16
17use crate::hardware::Hardware;
18use crate::history::RunRecord;
19use crate::result::{RunStatus, VerdictKind};
20
21pub use super::record_env::resolve_perf_env;
22pub use super::record_path::{date_of, record_path, record_path_for, variant_slug};
23pub use super::record_summary::now_secs;
24use super::record_summary::summarize;
25pub use super::record_write::write_record;
26
27/// 2026-09-26: One run record, as committed under `.benchmarks/<id>/`.
28#[derive(Clone, Debug, Serialize, Deserialize)]
29pub struct GateRecord {
30 pub schema: u32,
31 pub benchmark_id: String,
32 pub benchmark_name: String,
33 /// 2026-09-26: The commit checked out when the run started. `from_run`
34 /// refuses an empty or whitespace-only sha.
35 pub git_sha: String,
36 /// 2026-09-26: The uncommitted `PERF_PATHS` files present when the run
37 /// started ([`super::dirty_perf_paths`]). Empty, and absent from the JSON,
38 /// for a clean tree; `check_one` fails a record whose list is not empty.
39 #[serde(default, skip_serializing_if = "Vec::is_empty")]
40 pub dirty_paths: Vec<String>,
41 pub recorded_at: u64,
42 pub target_model: String,
43 /// 2026-09-26: The run record's `params`, rendered into `command` as
44 /// `--param KEY=VALUE`.
45 pub params: BTreeMap<String, String>,
46 /// 2026-09-26: The `met benchmark run` invocation `from_run` rebuilds from
47 /// the recorded inputs, ending in `--pull-request-gate`.
48 pub command: Vec<String>,
49 /// 2026-09-26: The recipe (`<family>/<stem>`) that served this run when the
50 /// gate started its own server; `None` for an endpoint the operator ran.
51 /// When set, `command` carries no `--url` or `--model`, because that URL
52 /// named a port chosen for the run.
53 #[serde(default, skip_serializing_if = "Option::is_none")]
54 pub served_by: Option<String>,
55 /// 2026-09-26: Recipe keys changed for this run: the baseline entry's
56 /// `serve_overrides` merged with the operator's `--serve-override`, copied
57 /// from the run record. Empty, and absent from the JSON, means the recipe
58 /// served as written; otherwise `served_by` alone does not describe the
59 /// server's config.
60 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
61 pub serve_overrides: BTreeMap<String, String>,
62
63 pub metrale_version: String,
64 /// 2026-09-26: The fingerprint the serving endpoint's `/hardware` returned.
65 pub hardware: Hardware,
66 /// 2026-09-26: The box's state before and after the run, with the delta
67 /// and the pre- and post-check verdicts, carried from the terminal frame.
68 /// Absent when the frame captured none: absent means unmeasured, not
69 /// healthy.
70 #[serde(default, skip_serializing_if = "Option::is_none")]
71 pub hardware_state: Option<crate::hardware::HardwareStateReport>,
72 /// 2026-09-26: The terminal frame's metrics, by name.
73 pub metrics: BTreeMap<String, f64>,
74 /// 2026-09-26: Content identity of the dataset the run scored against,
75 /// from the terminal frame (the MLPerf agentic leg writes
76 /// `file-sha256:…;draw-sha256:…`). Absent when the frame carries none.
77 #[serde(default, skip_serializing_if = "Option::is_none")]
78 pub dataset_fingerprint: Option<String>,
79 /// 2026-09-26: The run's terminal status. `check_one` fails a record whose
80 /// status is `Failed`, whatever its metrics.
81 pub frame_status: RunStatus,
82 /// 2026-09-26: `PASS`, `FAIL` or `info` from the frame's verdict kind;
83 /// `None` when the frame has no verdict. Its reason is `verdict_reason`.
84 pub verdict: Option<String>,
85 pub verdict_reason: String,
86 /// 2026-09-26: The one line `record_summary::summarize` builds: model,
87 /// metrics, verdict, and the first warning or error logged.
88 pub summary: String,
89 /// 2026-09-26: The performance controls in `record_env`'s `PERF_CONTROLS`,
90 /// resolved: an unset or empty variable is recorded as its default.
91 /// `from_run` reads this process's environment, which an in-process serve
92 /// shares; `with_serve_env` re-resolves it through the levers applied to
93 /// the server. Disclosure only: `check_record` does not read it.
94 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
95 pub perf_env: BTreeMap<String, String>,
96 /// 2026-09-26: The serve settings the gate resolved from its recipe; the
97 /// keys and rules are in [`super::record_serve`]. Empty, and absent from
98 /// the JSON, for a run against an operator's own endpoint. Disclosure
99 /// only: `check_record` does not read it.
100 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
101 pub serve_resolved: BTreeMap<String, String>,
102 /// 2026-09-26: The `METRALE_*` levers the gate applied to the server it
103 /// measured: the recipe's `env:` block with the entry's
104 /// `[benchmarks.serve_env]` on top (`serve_env::Reconciled::env`). Empty,
105 /// and absent from the JSON, for an operator's own endpoint or a recipe
106 /// that declares none. Disclosure only: `check_record` does not read it.
107 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
108 pub serve_env: BTreeMap<String, String>,
109 /// 2026-10-02: Canonical content hash of the recipe the gate served, from the tree under
110 /// test with the run's serve overrides (`recipe_closure::content_sha256`). A later commit
111 /// keeps this record only while its recipe hashes the same (`recipe_closure`). Absent for an
112 /// operator's own endpoint and on records written before gates served in-tree recipes.
113 #[serde(default, skip_serializing_if = "Option::is_none")]
114 pub served_recipe_sha256: Option<String>,
115 /// 2026-09-26: What each kernel target compiled to when this was measured.
116 /// Lets a later `kernels/`-only diff keep this record for the targets whose
117 /// device code did not change; see [`super::closure`]. Empty, and absent
118 /// from the JSON, excuses no diff.
119 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
120 pub closure: super::closure::Attestation,
121}
122
123/// 2026-09-26: One metric's threshold: `min` alone is a floor, `max` alone a
124/// ceiling, both a range, and equal values an exact pin (`scoring::compare`).
125#[derive(Clone, Debug, Default, Serialize, Deserialize)]
126pub struct Bound {
127 #[serde(default, skip_serializing_if = "Option::is_none")]
128 pub min: Option<f64>,
129 #[serde(default, skip_serializing_if = "Option::is_none")]
130 pub max: Option<f64>,
131 /// 2026-09-26: Slack allowed beyond the bound, in the metric's units; 0
132 /// when absent.
133 #[serde(default, skip_serializing_if = "Option::is_none")]
134 pub noise: Option<f64>,
135}
136
137/// 2026-09-26: One (hardware, model) pair's thresholds, and the recipe that
138/// serves the model.
139#[derive(Clone, Debug, Default, Serialize, Deserialize)]
140pub struct ModelBaseline {
141 /// 2026-09-26: The recipe that serves this model, as `<family>/<stem>`
142 /// (e.g. `qwen3.6/qwen3.6-27b-nvfp4-unsloth`). `None` means the gate cannot
143 /// start its own server and must be given a live endpoint.
144 #[serde(default, skip_serializing_if = "Option::is_none")]
145 pub recipe: Option<String>,
146 /// 2026-09-26: Display name for this variant in the TUI's variant list;
147 /// empty shows the checkpoint id.
148 #[serde(default, skip_serializing_if = "String::is_empty")]
149 pub label: String,
150 /// 2026-09-26: The BENCH.toml entry's free-text note.
151 #[serde(default)]
152 pub note: String,
153 #[serde(default)]
154 pub metrics: BTreeMap<String, Bound>,
155 /// 2026-09-26: Recipe keys self-start applies for this gate, as
156 /// `--serve-override KEY=VALUE` strings. Empty, and omitted, means the
157 /// recipe serves as written. `port` is refused at parse; see
158 /// `bench::BenchEntry`.
159 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
160 pub serve_overrides: BTreeMap<String, String>,
161 /// 2026-09-26: Benchmark parameter values the gate pins for this entry.
162 /// Empty, and omitted, means the schema defaults. Each value is parsed by
163 /// its parameter's `ParamKind::parse`; an explicit `--param` wins, and
164 /// `check_record` requires every pin on the record.
165 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
166 pub param_overrides: BTreeMap<String, String>,
167 /// 2026-09-26: `METRALE_*` serve levers the gate pins for this entry,
168 /// applied on top of the recipe's own `env:` block. Validated by
169 /// `serve_env::declared` at parse and disclosed on the record as
170 /// `GateRecord::serve_env`; `check_record` does not read them.
171 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
172 pub serve_env: BTreeMap<String, String>,
173}
174
175/// 2026-09-26: Baseline pins first; the operator's `--serve-override` wins on
176/// a clash.
177pub fn merge_serve_overrides(
178 baseline: BTreeMap<String, String>,
179 requested: BTreeMap<String, String>,
180) -> BTreeMap<String, String> {
181 let mut out = baseline;
182 out.extend(requested);
183 out
184}
185
186/// 2026-09-26: Every measured model on one box class, and which one to serve
187/// by default.
188#[derive(Clone, Debug, Default, Serialize, Deserialize)]
189pub struct HardwareBaseline {
190 /// 2026-09-26: The model to use when the caller does not name one: the
191 /// BENCH.toml entry marked `default = true`. Two defaults on one box class
192 /// are refused when the baseline is assembled.
193 pub default: String,
194 #[serde(default)]
195 pub models: BTreeMap<String, ModelBaseline>,
196}
197
198/// 2026-09-26: The thresholds a benchmark's gate records must meet, assembled
199/// by [`super::bench`] from the measured entries in every
200/// `kernels/<hw>/<model>/BENCH.toml`. Keyed hardware, then model, and
201/// `check_record` scores a record only against its own pair. The records
202/// themselves live under `.benchmarks/<id>/`.
203#[derive(Clone, Debug, Default, Serialize, Deserialize)]
204pub struct GateBaseline {
205 /// 2026-09-26: Schema version; an assembled baseline is 2.
206 #[serde(default)]
207 pub schema: u32,
208 pub hardware: BTreeMap<String, HardwareBaseline>,
209}
210
211impl GateBaseline {
212 /// 2026-09-26: Resolve one (hardware, model) entry. `model: None` takes the
213 /// hardware's declared default. An unknown hardware or model is an error
214 /// naming what was asked for and what exists.
215 pub fn resolve(&self, hardware: &str, model: Option<&str>) -> Result<(String, &ModelBaseline)> {
216 let hw = self.hardware.get(hardware).ok_or_else(|| {
217 anyhow::anyhow!(
218 "no baseline for hardware {hardware:?}; this benchmark has entries for [{}]",
219 self.hardware.keys().cloned().collect::<Vec<_>>().join(", ")
220 )
221 })?;
222 let want = model.unwrap_or(&hw.default);
223 let entry = hw.models.get(want).ok_or_else(|| {
224 anyhow::anyhow!(
225 "no baseline for model {want:?} on {hardware:?}; it has [{}]",
226 hw.models.keys().cloned().collect::<Vec<_>>().join(", ")
227 )
228 })?;
229 Ok((want.to_string(), entry))
230 }
231}
232
233/// 2026-09-26: Read and parse one record file.
234pub fn read_record(path: &Path) -> Result<GateRecord> {
235 let text =
236 std::fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
237 serde_json::from_str(&text).with_context(|| format!("parsing {}", path.display()))
238}
239
240/// 2026-09-26: The benchmark's baseline, assembled from the BENCH.toml files.
241pub fn read_baseline(root: &Path, benchmark_id: &str) -> Result<GateBaseline> {
242 super::bench::baseline_for(root, benchmark_id)
243}
244
245impl GateRecord {
246 /// 2026-09-26: `(index, count)` from the `shard.index` / `shard.count`
247 /// metrics, or `None` for a whole-draw run or for values that are not
248 /// integers with `1 <= count` and `index < count`. `record_path_for`
249 /// copies it into the file name.
250 pub fn shard(&self) -> Option<(usize, usize)> {
251 let index = *self.metrics.get("shard.index")?;
252 let count = *self.metrics.get("shard.count")?;
253 if index.fract() != 0.0 || count.fract() != 0.0 || count < 1.0 || index >= count {
254 return None;
255 }
256 Some((index as usize, count as usize))
257 }
258
259 /// 2026-09-26: Build a gate record from a finished run. `hardware` is the
260 /// serving endpoint's `/hardware` fingerprint. With `served_by` set,
261 /// `command` omits `--url` and `--model`: replaying asks for the benchmark
262 /// again and the recipe supplies the endpoint. `dirty_paths` is the
263 /// [`super::dirty_perf_paths`] result from when the run started.
264 ///
265 /// Errors on an empty `git_sha` or a `Running` frame.
266 pub fn from_run(
267 record: &RunRecord,
268 hardware: Hardware,
269 git_sha: String,
270 dirty_paths: Vec<String>,
271 served_by: Option<String>,
272 ) -> Result<Self> {
273 // 2026-09-26: Read off the run record, so the run record and the gate
274 // record cannot disagree about the serve overrides.
275 let serve_overrides = record.serve_overrides.clone();
276 if git_sha.trim().is_empty() {
277 bail!("a gate record needs the commit sha it was measured from");
278 }
279 let frame = &record.frame;
280 if frame.status == RunStatus::Running {
281 bail!("the run never reached a terminal frame — nothing to gate");
282 }
283 let mut params = Vec::new();
284 if served_by.is_none() {
285 if !record.target_url.is_empty() {
286 params.push(("--url".to_string(), record.target_url.clone()));
287 }
288 if !record.target_model.is_empty() {
289 params.push(("--model".to_string(), record.target_model.clone()));
290 }
291 }
292 for (k, v) in &record.params {
293 params.push(("--param".to_string(), format!("{k}={v}")));
294 }
295 // 2026-09-26: The overrides go into `command` too, so replaying it
296 // serves the same config.
297 for (k, v) in &serve_overrides {
298 params.push(("--serve-override".to_string(), format!("{k}={v}")));
299 }
300 if record.benchmark_id == "agentic-webserver" {
301 params.push(("--yes".to_string(), String::new()));
302 }
303 let mut command: Vec<String> = vec![
304 "met".into(),
305 "benchmark".into(),
306 "run".into(),
307 record.benchmark_id.clone(),
308 ];
309 for (flag, value) in ¶ms {
310 command.push(flag.clone());
311 if !value.is_empty() {
312 command.push(value.clone());
313 }
314 }
315 command.push("--pull-request-gate".into());
316
317 let verdict = frame.verdict.as_ref().map(|v| match v.kind {
318 VerdictKind::Pass => "PASS".to_string(),
319 VerdictKind::Fail => "FAIL".to_string(),
320 VerdictKind::Info => "info".to_string(),
321 });
322 let verdict_reason = frame
323 .verdict
324 .as_ref()
325 .map(|v| v.reason.clone())
326 .unwrap_or_default();
327 Ok(Self {
328 schema: 1,
329 // 2026-09-26: Attached afterwards by `with_closure`. Left empty it
330 // excuses no later diff (`closure::excuses`), so a caller that
331 // skips it costs re-runs, not soundness.
332 closure: Default::default(),
333 benchmark_id: record.benchmark_id.clone(),
334 benchmark_name: record.benchmark_name.clone(),
335 git_sha,
336 dirty_paths,
337 recorded_at: record.recorded_at,
338 target_model: record.target_model.clone(),
339 params: record.params.clone(),
340 command,
341 served_by,
342 serve_overrides,
343 metrale_version: record.metrale_version.clone(),
344 hardware,
345 // 2026-09-26: Taken from the terminal frame rather than probed
346 // here: this runs after the benchmark finished.
347 hardware_state: frame.hardware_state.clone(),
348 metrics: frame.metrics.clone(),
349 dataset_fingerprint: frame.dataset_fingerprint.clone(),
350 frame_status: frame.status,
351 verdict,
352 verdict_reason,
353 summary: summarize(record),
354 // 2026-09-26: This process's environment; `with_serve_env`
355 // re-resolves it through the levers applied to the server.
356 perf_env: resolve_perf_env(|k| std::env::var(k).ok()),
357 // 2026-09-26: Attached afterwards by `with_serve_resolved` and
358 // `with_serve_env`.
359 serve_resolved: BTreeMap::new(),
360 serve_env: BTreeMap::new(),
361 // 2026-10-02: Attached afterwards by `with_served_recipe`.
362 served_recipe_sha256: None,
363 })
364 }
365
366 /// 2026-09-26: Attach what each kernel target in the measuring binary
367 /// compiled from. `baked` is `metrale_kernels::TARGET_CLOSURES`, which the
368 /// kernels build script emits, so it describes the built sources rather
369 /// than the working tree. Unparseable input attaches an empty attestation,
370 /// which excuses no later diff.
371 #[must_use]
372 pub fn with_closure(mut self, baked: &str) -> Self {
373 self.closure = serde_json::from_str(baked).unwrap_or_default();
374 self
375 }
376
377 /// 2026-09-26: True only when `verdict` is `PASS`; FAIL, info and no
378 /// verdict are false.
379 pub fn verdict_passes(&self) -> bool {
380 self.verdict.as_deref() == Some("PASS")
381 }
382
383 /// 2026-09-26: True when the frame status is `Failed`.
384 pub fn frame_status_failed(&self) -> bool {
385 self.frame_status == RunStatus::Failed
386 }
387}