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}