metrale_bench/gate/
required.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2
3//! 2026-09-26: What a PR owes: the gates its changed paths invalidate
4//! (`by_path`) united with the gates its recorded intent classifications
5//! imply (`by_intent`).
6//!
7//! `pr_taxonomy::validate` refuses any `_benches` id outside
8//! [`super::coverage::REQUIRED`], so the intent half names only required
9//! gates. The intent half matters where paths invalidate nothing: paths off
10//! `PERF_PATHS` (`docker/`, `docs/`, `scripts/`, `bench/`), `BENCH.toml`
11//! files, and `crates/bench/src/gate` files outside `BOUNDARY_FILES`.
12//!
13//! [`super::check::check_gates`] checks every gate in `REQUIRED_GATES`
14//! whatever this module computes. `met benchmark --pull-request-gate-check`
15//! prints the intent half after the verdicts, and it does not change the
16//! exit code.
17//!
18//! Every classification recorded for the PR counts, and the result is their
19//! union, so re-running the classifier can only add gates.
20//!
21//! Owner: bench gate (intent).
22//! Invariants:
23//! - `by_path` does not depend on the classifications or the taxonomy.
24//! - [`required_for`] and [`report`] read no files.
25
26use std::collections::BTreeSet;
27
28use super::pr_taxonomy::{Node, benches_for};
29
30/// 2026-09-26: Both halves, kept apart so a report can say which half
31/// required a gate.
32#[derive(Debug, Clone, Default, PartialEq, Eq)]
33pub struct RequiredSet {
34    /// 2026-09-26: Gates the changed paths invalidate
35    /// (`coverage::invalidated_by`).
36    pub by_path: BTreeSet<String>,
37    /// 2026-09-26: Gates the classifications imply, unioned over all of them.
38    pub by_intent: BTreeSet<String>,
39}
40
41impl RequiredSet {
42    /// 2026-09-26: `by_path ∪ by_intent`.
43    pub fn union(&self) -> BTreeSet<String> {
44        self.by_path.union(&self.by_intent).cloned().collect()
45    }
46
47    /// 2026-09-26: `by_intent` minus `by_path`: what intent added.
48    pub fn intent_only(&self) -> BTreeSet<String> {
49        self.by_intent.difference(&self.by_path).cloned().collect()
50    }
51}
52
53/// 2026-09-26: Compute both halves. `categories` is every recorded
54/// classification path; an empty slice (not classified) or an empty `roots`
55/// yields an empty intent half.
56pub fn required_for(changed: &[String], categories: &[Vec<String>], roots: &[Node]) -> RequiredSet {
57    let by_path = super::coverage::invalidated_by(changed.iter().map(String::as_str))
58        .into_iter()
59        .map(str::to_string)
60        .collect();
61    let mut by_intent = BTreeSet::new();
62    for category in categories {
63        by_intent.extend(benches_for(roots, category));
64    }
65    RequiredSet { by_path, by_intent }
66}
67
68/// 2026-09-26: Parse `performance/decode` into `["performance", "decode"]`.
69/// Segments are trimmed and empty ones dropped: `benches_for` would stop at an
70/// empty segment and lose the benchmarks below it.
71pub fn parse_category(value: &str) -> Vec<String> {
72    value
73        .split('/')
74        .map(str::trim)
75        .filter(|s| !s.is_empty())
76        .map(str::to_string)
77        .collect()
78}
79
80#[cfg(test)]
81#[path = "required_tests.rs"]
82mod required_tests;
83
84/// 2026-09-26: Where the intent half came from. It keeps "not classified"
85/// apart from "the ledger could not be read", which both give an empty intent
86/// half.
87#[derive(Debug, Clone, PartialEq, Eq)]
88pub enum IntentSource {
89    /// 2026-09-26: No PR number was given; not evaluated.
90    NotRequested,
91    /// 2026-09-26: The ledger is missing or holds no non-empty `ok` or
92    /// `partial` classification for this PR.
93    NotRecorded { ledger: std::path::PathBuf },
94    /// 2026-09-26: The ledger could not be read.
95    Degraded { reason: String },
96    Recorded {
97        /// 2026-09-26: Every `ok` or `partial` path recorded for this PR, in
98        /// ledger order, without duplicates.
99        categories: Vec<Vec<String>>,
100        /// 2026-09-26: Rows with any other status (`abstain`, `error`):
101        /// counted, never used as intent.
102        skipped: usize,
103    },
104}
105
106/// 2026-09-26: Both halves plus where the intent half came from.
107#[derive(Debug, Clone, PartialEq, Eq)]
108pub struct RequiredReport {
109    pub set: RequiredSet,
110    pub source: IntentSource,
111}
112
113/// 2026-09-26: Read the classifications recorded for `pr` from its ledger,
114/// deduplicated. Every Category row counts, whatever its `head_sha`: the row
115/// for a head is committed after that head, so a head filter would find
116/// nothing. Unioning across older heads can only add gates.
117pub fn intent_source(root: &std::path::Path, pr: Option<u64>) -> IntentSource {
118    let Some(pr) = pr else {
119        return IntentSource::NotRequested;
120    };
121    let ledger = metrale_governance::ledger::path_for(root, pr);
122    if !ledger.exists() {
123        return IntentSource::NotRecorded { ledger };
124    }
125    let journey = match metrale_governance::ledger::read_all(&ledger) {
126        Ok(j) => j.deduplicated(),
127        // 2026-09-26: `read_all` fails on a malformed line; that is reported
128        // as `Degraded`, not as an error and not as "no intent".
129        Err(e) => {
130            return IntentSource::Degraded {
131                reason: format!("{}: {e:#}", ledger.display()),
132            };
133        }
134    };
135
136    let (mut categories, mut skipped) = (Vec::new(), 0usize);
137    for event in &journey.events {
138        let metrale_governance::event::EventKind::Category { value, status } = &event.kind else {
139            continue;
140        };
141        // 2026-09-26: `partial` counts: its matched prefix still implies the
142        // ancestors' `_benches`.
143        if status != "ok" && status != "partial" {
144            skipped += 1;
145            continue;
146        }
147        let segments = parse_category(value);
148        if !segments.is_empty() && !categories.contains(&segments) {
149            categories.push(segments);
150        }
151    }
152    if categories.is_empty() {
153        return IntentSource::NotRecorded { ledger };
154    }
155    IntentSource::Recorded {
156        categories,
157        skipped,
158    }
159}
160
161/// 2026-09-26: Assemble the report. Only a `Recorded` source contributes
162/// intent. Reads no files: the I/O is in [`intent_source`] and the caller's
163/// taxonomy load.
164pub fn report(changed: &[String], source: IntentSource, roots: &[Node]) -> RequiredReport {
165    let categories: &[Vec<String>] = match &source {
166        IntentSource::Recorded { categories, .. } => categories,
167        _ => &[],
168    };
169    RequiredReport {
170        set: required_for(changed, categories, roots),
171        source,
172    }
173}