metrale_bench/gate/
pr_taxonomy.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2
3//! 2026-09-26: The PR intent taxonomy (`.github/pr-taxonomy.json`): a tree of
4//! what a change is for (`performance/decode`, `correctness/kv-cache`), and
5//! the benchmarks each path implies. [`crate::gate::taxon`] is the separate
6//! hardware taxonomy derived from `kernels/` paths.
7//!
8//! Owner: bench gate (intent).
9//! Invariants:
10//! - `benches_for` is the union of `_benches` along the matched prefix, so
11//!   descending further never removes a benchmark.
12//! - `load` returns only a tree that passed `validate`: at least two roots,
13//!   kebab-case keys, no single-child node, and every `_benches` id in
14//!   `coverage::REQUIRED`.
15
16use std::collections::BTreeSet;
17use std::path::Path;
18
19use anyhow::{Context, Result, bail};
20
21/// 2026-09-26: Keys in the JSON that are metadata rather than child nodes.
22const RESERVED: [&str; 2] = ["_doc", "_benches"];
23
24/// 2026-09-26: One node of the parsed tree. Children keep the JSON file's key
25/// order (the workspace enables serde_json's `preserve_order`).
26#[derive(Debug, Clone, PartialEq, Eq)]
27pub struct Node {
28    pub name: String,
29    pub benches: Vec<String>,
30    pub children: Vec<Node>,
31}
32
33impl Node {
34    pub fn is_leaf(&self) -> bool {
35        self.children.is_empty()
36    }
37}
38
39/// 2026-09-26: Load and validate `.github/pr-taxonomy.json` from a repo root.
40pub fn load(root: &Path) -> Result<Vec<Node>> {
41    let path = root.join(".github/pr-taxonomy.json");
42    let text =
43        std::fs::read_to_string(&path).with_context(|| format!("reading {}", path.display()))?;
44    let json: serde_json::Value =
45        serde_json::from_str(&text).with_context(|| format!("parsing {}", path.display()))?;
46    let roots = parse_children(&json)?;
47    validate(&roots)?;
48    Ok(roots)
49}
50
51fn parse_children(value: &serde_json::Value) -> Result<Vec<Node>> {
52    let Some(obj) = value.as_object() else {
53        bail!("every taxonomy node must be a JSON object");
54    };
55    let mut out = Vec::new();
56    for (key, child) in obj {
57        if RESERVED.contains(&key.as_str()) {
58            continue;
59        }
60        // 2026-09-26: `_benches` must be an array of strings. Any other shape
61        // is an error rather than an empty list, which would drop benchmarks.
62        let benches = match child.get("_benches") {
63            None => Vec::new(),
64            Some(serde_json::Value::Array(items)) => {
65                let mut v = Vec::with_capacity(items.len());
66                for item in items {
67                    match item.as_str() {
68                        Some(s) => v.push(s.to_string()),
69                        None => bail!(
70                            "{key}: _benches contains a non-string entry ({item}). \
71                             A silently-dropped entry removes a benchmark."
72                        ),
73                    }
74                }
75                v
76            }
77            Some(other) => bail!(
78                "{key}: _benches must be an ARRAY of benchmark ids, got {other}. \
79                 A bare string parses as empty here while jq reads it, so the two \
80                 halves would disagree — in the removing direction."
81            ),
82        };
83        out.push(Node {
84            name: key.clone(),
85            benches,
86            children: parse_children(child)?,
87        });
88    }
89    Ok(out)
90}
91
92/// 2026-09-26: The shape rules the JSON's `_doc` states: at least two roots,
93/// lowercase kebab-case keys, no node with exactly one child, and every
94/// `_benches` id in `coverage::REQUIRED`.
95fn validate(roots: &[Node]) -> Result<()> {
96    if roots.len() < 2 {
97        bail!("the taxonomy needs at least two roots; one root is not a choice");
98    }
99    let known: BTreeSet<&str> = super::coverage::REQUIRED.iter().map(|g| g.id).collect();
100    fn walk(nodes: &[Node], trail: &str, known: &BTreeSet<&str>) -> Result<()> {
101        for n in nodes {
102            let here = if trail.is_empty() {
103                n.name.clone()
104            } else {
105                format!("{trail}/{}", n.name)
106            };
107            if !n
108                .name
109                .chars()
110                .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
111                || n.name.is_empty()
112            {
113                bail!("{here}: keys must be lowercase kebab-case so a path is a safe label");
114            }
115            for b in &n.benches {
116                if !known.contains(b.as_str()) {
117                    bail!(
118                        "{here}: _benches names {b:?}, which is not a required benchmark. \
119                         A path that selects a benchmark nobody runs is a silent no-op."
120                    );
121                }
122            }
123            // 2026-09-26: A single child is not a choice for the classifier.
124            if n.children.len() == 1 {
125                bail!(
126                    "{here} has exactly one child ({}). Either give it a sibling or \
127                     make {here} a leaf.",
128                    n.children[0].name
129                );
130            }
131            walk(&n.children, &here, known)?;
132        }
133        Ok(())
134    }
135    walk(roots, "", &known)
136}
137
138/// 2026-09-26: The children of the node at `path`, when there are at least
139/// two. `None` for a leaf, a lone child (which [`resolve`] follows) or a path
140/// that does not match.
141pub fn options_at(roots: &[Node], path: &[String]) -> Option<Vec<String>> {
142    let node = walk_to(roots, path)?;
143    let kids: Vec<String> = node.iter().map(|n| n.name.clone()).collect();
144    (kids.len() > 1).then_some(kids)
145}
146
147fn walk_to<'a>(roots: &'a [Node], path: &[String]) -> Option<&'a [Node]> {
148    let mut level = roots;
149    for step in path {
150        level = &level.iter().find(|n| &n.name == step)?.children;
151    }
152    Some(level)
153}
154
155/// 2026-09-26: Every benchmark a path implies: the union of `_benches` along
156/// it, so an ancestor's benchmarks apply to every descendant. The walk stops
157/// at the first unknown segment and returns what matched so far.
158pub fn benches_for(roots: &[Node], path: &[String]) -> BTreeSet<String> {
159    benches_for_matched(roots, path).0
160}
161
162/// 2026-09-26: [`benches_for`], plus how many leading segments matched, so a
163/// caller can report a stale segment (`performance/decodes` implies the same
164/// set as `performance`).
165pub fn benches_for_matched(roots: &[Node], path: &[String]) -> (BTreeSet<String>, usize) {
166    let mut out = BTreeSet::new();
167    let mut level = roots;
168    let mut matched = 0usize;
169    for step in path {
170        let Some(node) = level.iter().find(|n| &n.name == step) else {
171            break;
172        };
173        out.extend(node.benches.iter().cloned());
174        level = &node.children;
175        matched += 1;
176    }
177    (out, matched)
178}
179
180/// 2026-09-26: `path` extended through every single-child step below it.
181/// `validate` refuses single-child nodes, so on a loaded tree this returns
182/// `path` unchanged.
183pub fn resolve(roots: &[Node], path: &[String]) -> Vec<String> {
184    let mut out = path.to_vec();
185    loop {
186        let Some(level) = walk_to(roots, &out) else {
187            return out;
188        };
189        if level.len() == 1 {
190            out.push(level[0].name.clone());
191        } else {
192            return out;
193        }
194    }
195}
196
197/// 2026-09-26: Whether `path` matches and ends at a leaf.
198pub fn is_complete(roots: &[Node], path: &[String]) -> bool {
199    walk_to(roots, path).is_some_and(<[Node]>::is_empty)
200}
201
202#[cfg(test)]
203#[path = "pr_taxonomy_tests.rs"]
204mod pr_taxonomy_tests;