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;