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 &params {
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}