metrale_bench/gate/closure.rs
1// SPDX-License-Identifier: MIT OR Apache-2.0
2
3//! 2026-09-26: Excuse a kernel-only diff when every affected target still hashes to the closure
4//! its record attests.
5//!
6//! A record stores, per target, the closure hash and the arch, compiler and nvcc flags it was
7//! computed under. The check recomputes with the record's own stored arch, compiler and
8//! flags, so only a change to the target's sources, the headers they include or its config
9//! files (`taxon::configs`) moves the hash, not the toolchain of the machine running CI.
10//!
11//! Owner: bench gate.
12//! Invariants:
13//! - [`excuses`] only narrows the path boundary in [`super::coverage`]: it returns `false`
14//! unless every path is under `kernels/`.
15//! - It returns `false` for an empty path list or attestation, a tree the resolver cannot
16//! enumerate, paths that reach no target, and any target that is not attested, whose
17//! sources do not resolve ([`super::taxon::sources`] is `None`), whose hash cannot be
18//! computed, or whose hash changed.
19
20use std::collections::BTreeMap;
21use std::path::Path;
22
23use serde::{Deserialize, Serialize};
24
25use super::taxon::{self, Target};
26
27/// 2026-09-26: What one target compiled to, and under what. The flags are stored per target
28/// because a model's `KERNEL.toml` can add `extra_nvcc_flags`.
29#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
30pub struct TargetClosure {
31 /// 2026-09-26: Hex sha256 from [`metrale_closure::hash`].
32 pub hash: String,
33 pub arch: String,
34 pub compiler: String,
35 #[serde(default, skip_serializing_if = "Vec::is_empty")]
36 pub flags: Vec<String>,
37}
38
39impl TargetClosure {
40 fn inputs(&self, root: &Path, target: &Target) -> Option<metrale_closure::ClosureInputs> {
41 Some(metrale_closure::ClosureInputs {
42 sources: taxon::sources(root, target)?,
43 configs: taxon::configs(root, target).ok()?,
44 flags: self.flags.clone(),
45 arch: self.arch.clone(),
46 compiler: self.compiler.clone(),
47 })
48 }
49}
50
51/// 2026-09-26: Per-target attestations carried by a record, keyed by `hw/model/quant`.
52pub type Attestation = BTreeMap<String, TargetClosure>;
53
54/// 2026-09-26: Compute an attestation for every target from the working tree.
55///
56/// A record carries instead the attestation baked into the measuring binary
57/// (`metrale_kernels::TARGET_CLOSURES`, attached by `GateRecord::with_closure`), because the
58/// tree and the binary can differ. This tree-side version builds test fixtures and is what
59/// `crates/server/tests/closure_attestation.rs` compares the baked values against. A target
60/// whose sources do not resolve, or whose hash fails, is omitted.
61pub fn attest(
62 root: &Path,
63 arch: &str,
64 compiler: &str,
65 flags: &BTreeMap<String, Vec<String>>,
66) -> Attestation {
67 let mut out = BTreeMap::new();
68 for target in taxon::walk(root) {
69 let key = target.to_string();
70 let entry = TargetClosure {
71 hash: String::new(),
72 arch: arch.to_string(),
73 compiler: compiler.to_string(),
74 flags: flags.get(&key).cloned().unwrap_or_default(),
75 };
76 let Some(inputs) = entry.inputs(root, &target) else {
77 continue;
78 };
79 let Ok(hash) = metrale_closure::hash(root, &inputs) else {
80 continue;
81 };
82 out.insert(key, TargetClosure { hash, ..entry });
83 }
84 out
85}
86
87/// 2026-09-26: Whether an unchanged closure excuses every one of `paths`, the paths that
88/// survived the path boundary. `true` only when all are under `kernels/` and every target
89/// they affect still hashes to what the record attests.
90pub fn excuses(root: &Path, paths: &[String], attestation: &Attestation) -> bool {
91 if paths.is_empty() || attestation.is_empty() {
92 return false;
93 }
94 // 2026-09-26: A device-code hash says nothing about a path outside `kernels/`.
95 if !paths.iter().all(|p| taxon::hardware_of(p).is_some()) {
96 return false;
97 }
98 if !taxon::resolves(root) {
99 return false;
100 }
101 let affected = taxon::affected(root, paths);
102 if affected.is_empty() {
103 return false;
104 }
105 affected.iter().all(|target| {
106 attestation
107 .get(&target.to_string())
108 .and_then(|recorded| {
109 let inputs = recorded.inputs(root, target)?;
110 let current = metrale_closure::hash(root, &inputs).ok()?;
111 Some(current == recorded.hash)
112 })
113 .unwrap_or(false)
114 })
115}
116
117/// 2026-09-26: The targets `paths` affects whose closure changed, for reporting. A target that
118/// is not attested, or whose hash cannot be recomputed, is listed as changed.
119pub fn changed_targets(root: &Path, paths: &[String], attestation: &Attestation) -> Vec<String> {
120 taxon::affected(root, paths)
121 .into_iter()
122 .filter(|target| {
123 !attestation
124 .get(&target.to_string())
125 .and_then(|recorded| {
126 let inputs = recorded.inputs(root, target)?;
127 let current = metrale_closure::hash(root, &inputs).ok()?;
128 Some(current == recorded.hash)
129 })
130 .unwrap_or(false)
131 })
132 .map(|t| t.to_string())
133 .collect()
134}
135
136#[cfg(test)]
137#[path = "closure_tests.rs"]
138mod closure_tests;