metrale_bench/gate/
check.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2
3//! 2026-09-26: `--pull-request-gate-check`: does this commit have a passing record for every
4//! required gate? Reads `.benchmarks/`, the kernel tree and `git diff`; no endpoint, no GPU.
5//!
6//! Owner: bench gate.
7//! Invariants:
8//! - [`exit_code`] depends only on the verdicts it is given.
9//! - A gate is [`GateStatus::Pass`] only when a readable record (for a group, a complete
10//!   shard partition) stands at the commit and passes every check; otherwise it is
11//!   [`GateStatus::Missing`] or [`GateStatus::Fail`].
12
13use std::collections::BTreeMap;
14use std::path::{Path, PathBuf};
15
16use super::check_paths::invalidating_paths;
17use super::record::{GateBaseline, GateRecord, read_baseline, read_record};
18use super::{REQUIRED_GATES, gate_dir};
19
20pub use super::scoring::{Comparison, check_record, compare};
21
22/// 2026-09-26: One required gate's standing in the committed tree.
23#[derive(Debug)]
24pub enum GateStatus {
25    /// 2026-09-26: The newest covering record passes every check.
26    Pass,
27    /// 2026-09-26: The newest covering record fails: one entry per problem (run failed, dirty
28    /// tree, verdict not PASS, baseline breach, signature).
29    Fail(Vec<String>),
30    /// 2026-09-26: No record covers this commit; the text says why.
31    Missing(String),
32}
33
34/// 2026-09-26: The `.json` record files in one benchmark's directory, newest first by each
35/// record's own `recorded_at`; `BASELINE.json` is not a record.
36///
37/// The file name is not a clock: two records from one UTC day would sort by their sha. An
38/// unreadable record sorts last but stays in the list, so a directory of corrupt records
39/// reports "unreadable" rather than "no records committed".
40pub fn records_newest_first(root: &Path, benchmark_id: &str) -> Vec<PathBuf> {
41    let dir = gate_dir(root, benchmark_id);
42    let mut candidates: Vec<PathBuf> = std::fs::read_dir(&dir)
43        .map(|entries| entries.flatten().map(|e| e.path()).collect())
44        .unwrap_or_default();
45    candidates.retain(|p| {
46        p.extension().is_some_and(|e| e == "json")
47            && p.file_name()
48                .is_some_and(|n| n.to_string_lossy() != "BASELINE.json")
49    });
50    let mut keyed: Vec<(u64, PathBuf)> = candidates
51        .into_iter()
52        .map(|p| (read_record(&p).map(|r| r.recorded_at).unwrap_or(0), p))
53        .collect();
54    // 2026-09-26: The file name breaks a `recorded_at` tie, so the order does not depend on
55    // readdir.
56    keyed.sort_by(|a, b| b.0.cmp(&a.0).then_with(|| b.1.cmp(&a.1)));
57    keyed.into_iter().map(|(_, p)| p).collect()
58}
59
60/// 2026-09-26: Whether a record measured at `record_sha` still stands for `head` on the path
61/// boundary alone: true when `git diff` between the two commits touches no path that
62/// invalidates `gate`, false when it does or git cannot diff them. Ancestry is not required,
63/// and the closure hash is not consulted (see [`record_standing`]).
64pub fn record_covers(
65    root: &Path,
66    head: &str,
67    record_sha: &str,
68    gate: &super::coverage::GateCoverage,
69) -> bool {
70    invalidating_paths(root, head, record_sha, gate).is_some_and(|p| p.is_empty())
71}
72
73/// 2026-09-26: Whether `record` still stands for `sha` ([`record_standing`] is `Stands`).
74pub(super) fn record_still_stands(
75    root: &Path,
76    sha: &str,
77    record: &GateRecord,
78    gate: &super::coverage::GateCoverage,
79) -> bool {
80    matches!(record_standing(root, sha, record, gate), Standing::Stands)
81}
82
83/// 2026-09-26: Where a record measured at its own commit stands relative to `sha`. The gate
84/// verdict (`record_still_stands`) and record agreement (`agreement::standing_at`) both read
85/// this, so they cannot disagree about a record.
86///
87/// A diff whose invalidating paths are all excused by the record's closure attestation
88/// ([`super::closure::excuses`]) still stands; the closure can only narrow the path boundary.
89/// A record also needs the recipe it served to be what its gate serves at `sha`
90/// ([`super::recipe_closure::recipe_standing`]).
91pub fn record_standing(
92    root: &Path,
93    sha: &str,
94    record: &GateRecord,
95    gate: &super::coverage::GateCoverage,
96) -> Standing {
97    let by_paths = match invalidating_paths(root, sha, &record.git_sha, gate) {
98        None => Standing::Unknown,
99        Some(paths) if paths.is_empty() => Standing::Stands,
100        Some(paths) => {
101            if super::closure::excuses(root, &paths, &record.closure) {
102                Standing::Stands
103            } else {
104                Standing::Invalidated(paths)
105            }
106        }
107    };
108    // 2026-10-02: A record that stands on its paths still falls when the recipe its gate serves
109    // is not the one it served (`recipe_closure`): recipes are outside `PERF_PATHS`.
110    match (
111        by_paths,
112        super::recipe_closure::recipe_standing(root, record),
113    ) {
114        (Standing::Stands, super::recipe_closure::RecipeStanding::Changed(path)) => {
115            Standing::Invalidated(vec![path])
116        }
117        (standing, _) => standing,
118    }
119}
120
121/// 2026-09-26: A record's standing at a head, from its own commit.
122#[derive(Debug, Clone, PartialEq, Eq)]
123pub enum Standing {
124    /// 2026-09-26: The diff from the record's commit to the head touches nothing that
125    /// invalidates this gate, or the record's closure excuses what it touches.
126    Stands,
127    /// 2026-09-26: git could not diff the record's commit against the head: a commit this
128    /// clone does not have, or git failed.
129    Unknown,
130    /// 2026-09-26: The diff touches these invalidating paths for the record's gate.
131    Invalidated(Vec<String>),
132}
133
134/// 2026-09-26: The verdict for `sha` of every gate in `REQUIRED_GATES`, keyed by gate id.
135pub fn check_gates(root: &Path, sha: &str) -> BTreeMap<String, GateStatus> {
136    let mut out = BTreeMap::new();
137    for id in REQUIRED_GATES {
138        out.insert((*id).to_string(), check_one(root, id, sha));
139    }
140    out
141}
142
143/// 2026-09-26: Whether a record's own `benchmark_id` is the gate whose directory it sits in.
144/// `ttft-warm-gate` and `ttft-cold-gate` share metric names (`median_ms`, `p90_ms`), so a
145/// record copied between their directories would otherwise satisfy the wrong gate.
146///
147/// A mismatch is logged and skipped, not failed, so a stray file leaves the gate reading
148/// "no covering record" rather than failing on another gate's run.
149pub(super) fn record_is_for(record: &GateRecord, benchmark_id: &str, path: &Path) -> bool {
150    if record.benchmark_id == benchmark_id {
151        return true;
152    }
153    tracing::warn!(
154        "ignoring {}: it is a `{}` record sitting in the `{benchmark_id}` directory",
155        path.display(),
156        record.benchmark_id,
157    );
158    false
159}
160
161/// 2026-09-26: Whether this record measured the gate's required subject: the checkpoint the
162/// baseline marks `default = true` for the record's box class. A record of another variant
163/// in the same directory is logged and skipped, so its pass is not evidence for the gate.
164///
165/// A record from a box class the baseline does not know returns true; `check_record` then
166/// fails it as having no baseline for that hardware.
167pub(super) fn record_is_required_subject(
168    baseline: &GateBaseline,
169    record: &GateRecord,
170    benchmark_id: &str,
171    path: &Path,
172) -> bool {
173    let hardware = record.hardware.gate_key();
174    let Some(hw) = baseline.hardware.get(&hardware) else {
175        return true;
176    };
177    if hw.default == record.target_model {
178        return true;
179    }
180    tracing::warn!(
181        "ignoring {} for the required {benchmark_id} gate: it measured the {} variant, \
182         and the gate's declared subject on {hardware} is {}",
183        path.display(),
184        record.target_model,
185        hw.default,
186    );
187    false
188}
189
190pub(super) fn check_one(root: &Path, benchmark_id: &str, sha: &str) -> GateStatus {
191    // 2026-09-26: A group is judged only by its shard records (`check_group`); a whole-draw
192    // record under the group's id does not satisfy it.
193    if let Some(group) = super::group::find(benchmark_id) {
194        return super::check_group::check_group(root, group, sha);
195    }
196    let Some(gate) = super::coverage::find(benchmark_id) else {
197        // 2026-09-26: Unreachable through `check_gates`, whose ids come from
198        // `coverage::REQUIRED`. Refused rather than judged with no exclusions.
199        return GateStatus::Missing(format!("{benchmark_id} has no coverage entry"));
200    };
201    let paths = records_newest_first(root, benchmark_id);
202    if paths.is_empty() {
203        return GateStatus::Missing("no gate records committed".into());
204    }
205    let baseline = match read_baseline(root, benchmark_id) {
206        Ok(b) => b,
207        Err(e) => return GateStatus::Missing(format!("baseline unreadable: {e:#}")),
208    };
209    // 2026-09-26: The newest record that is for this gate and subject and still stands for
210    // `sha`; others are skipped, not failed. Its path is kept because the signature sidecar
211    // is found beside the file.
212    let mut covered: Option<(GateRecord, std::path::PathBuf)> = None;
213    for path in &paths {
214        if let Ok(record) = read_record(path)
215            && record_is_for(&record, benchmark_id, path)
216            && record_is_required_subject(&baseline, &record, benchmark_id, path)
217            && record_still_stands(root, sha, &record, gate)
218        {
219            covered = Some((record, path.clone()));
220            break;
221        }
222    }
223    let Some((record, record_path)) = covered else {
224        let newest = read_record(&paths[0]).ok();
225        return GateStatus::Missing(match newest {
226            // 2026-09-26: Say why the newest record does not count: another gate's record,
227            // another variant, a commit git cannot diff, or the paths that invalidated it.
228            Some(newest_record) => {
229                if newest_record.benchmark_id != benchmark_id {
230                    return GateStatus::Missing(format!(
231                        "latest record belongs to {}, not {benchmark_id} ({})",
232                        newest_record.benchmark_id,
233                        paths[0].file_name().unwrap_or_default().to_string_lossy()
234                    ));
235                }
236                if !record_is_required_subject(&baseline, &newest_record, benchmark_id, &paths[0]) {
237                    let hardware = newest_record.hardware.gate_key();
238                    let subject = baseline
239                        .hardware
240                        .get(&hardware)
241                        .map(|hw| hw.default.clone())
242                        .unwrap_or_default();
243                    return GateStatus::Missing(format!(
244                        "latest record measured the {} variant; the required subject on \
245                         {hardware} is {subject}, which has no covering record",
246                        newest_record.target_model
247                    ));
248                }
249                let newest = newest_record.git_sha.clone();
250                let Some(why) = invalidating_paths(root, sha, &newest, gate) else {
251                    return GateStatus::Missing(format!(
252                        "latest record is for {newest} ({}) — git cannot diff that commit \
253                         against this one; is it in this clone? (the gate job needs \
254                         `fetch-depth: 0`)",
255                        paths[0].file_name().unwrap_or_default().to_string_lossy()
256                    ));
257                };
258                let because = if why.is_empty() {
259                    // 2026-09-26: No invalidating path, yet the record did not stand.
260                    "its recorded build inputs do not match this commit".to_string()
261                } else {
262                    // 2026-09-26: Also name the kernel targets whose device code changed.
263                    let targets =
264                        super::closure::changed_targets(root, &why, &newest_record.closure);
265                    match targets.len() {
266                        0 => format!("invalidated by {}", super::check_fmt::summarize_paths(&why)),
267                        n => format!(
268                            "invalidated by {} — device code changed for {n} target(s): {}",
269                            super::check_fmt::summarize_paths(&why),
270                            super::check_fmt::summarize_paths(&targets)
271                        ),
272                    }
273                };
274                format!(
275                    "latest record is for {newest} ({}) — {because}",
276                    paths[0].file_name().unwrap_or_default().to_string_lossy()
277                )
278            }
279            None => "latest record is unreadable".to_string(),
280        });
281    };
282    if record.frame_status_failed() {
283        return GateStatus::Fail(vec![format!(
284            "the run itself failed: {}",
285            record.verdict_reason
286        )]);
287    }
288    let mut problems = Vec::new();
289    // 2026-09-26: A record measured with uncommitted invalidation-set changes describes no
290    // commit, so it fails; the diff check above cannot see uncommitted edits. A record
291    // without the field deserializes it as empty.
292    if !record.dirty_paths.is_empty() {
293        problems.push(format!(
294            "measured from a dirty tree — {} uncommitted invalidation-set \
295             file(s) when the run started ({}), so the binary was not {}",
296            record.dirty_paths.len(),
297            record.dirty_paths.join(", "),
298            record.git_sha
299        ));
300    }
301    if !record.verdict_passes() {
302        problems.push(format!(
303            "run verdict is not PASS: {}",
304            record.verdict_reason
305        ));
306    }
307    if let Some(breaches) = check_record(&record, &baseline) {
308        problems.extend(breaches);
309    }
310    // 2026-09-26: A signature `verify_record` rejects fails the gate.
311    if let Err(why) =
312        super::signing::verify_record(root, &record_path, &record.git_sha, record.recorded_at)
313    {
314        problems.push(format!("{why}"));
315    }
316    if problems.is_empty() {
317        GateStatus::Pass
318    } else {
319        GateStatus::Fail(problems)
320    }
321}
322
323/// 2026-09-26: The exit code: 0 when every verdict is `Pass`, else 1.
324///
325/// It takes only the verdicts, so advisory data reported beside them
326/// ([`super::required::RequiredReport`], the governance ledger) cannot change it without a
327/// visible change to this signature.
328pub fn exit_code(statuses: &BTreeMap<String, GateStatus>) -> i32 {
329    let open = statuses
330        .values()
331        .filter(|s| !matches!(s, GateStatus::Pass))
332        .count();
333    i32::from(open > 0)
334}
335
336#[cfg(test)]
337#[path = "check_tests.rs"]
338mod check_tests;