metrale_closure/lib.rs
1// SPDX-License-Identifier: MIT OR Apache-2.0
2
3//! 2026-09-26: One SHA-256 over everything a kernel target's device code is
4//! compiled from: the transitive quoted-`#include` closure of its sources, the
5//! manifests, the nvcc flags, the arch string and the compiler string.
6//!
7//! Owner: metrale-closure (shared by the kernels build script and the bench gate).
8//! Invariants:
9//! - A path under the canonicalised `root` enters the digest relative to it, so
10//! two checkouts of one commit agree; a path outside `root` enters as given.
11//! - Every file in the closure is read, and any read error returns `Err`; there
12//! is no partial digest.
13//! - A quoted include that names no file on disk is not an error: it is kept in
14//! [`Closure::unresolved`] and hashed by name, not by content.
15//!
16//! The closure, not the resolved file set, is hashed because a leaf file can
17//! `#include` the common file it shadows (the qwen3.6-27b nvfp4
18//! `attn_prefill_paged_indirect.cu` includes its `common/` namesake), and
19//! because headers are not in `layout::Layout::sources`, which lists one
20//! source per module.
21//!
22//! Not covered:
23//! - angle-bracket includes (only the compiler string stands for them);
24//! - includes found through a search path or in another staged layer: an
25//! include is resolved only against the including file's own directory;
26//! - preprocessor conditionals, which are not evaluated, so an include in a
27//! dead branch is still walked;
28//! - host code under `crates/`: the gate's `excuses` refuses any non-kernel
29//! path before it consults this hash;
30//! - anything not passed in [`ClosureInputs`].
31
32use std::collections::BTreeSet;
33use std::path::{Path, PathBuf};
34
35mod circuits;
36mod hex;
37pub mod layout;
38mod layout_manifest;
39mod layout_scan;
40
41pub use hex::hex_lower;
42
43use sha2::{Digest, Sha256};
44
45pub use circuits::{circuit_configs, circuit_dirs, fusions_chain};
46
47/// 2026-09-26: Version of the hash definition. It is the first value fed into
48/// the digest after the domain tag, so changing it changes every digest.
49/// Change it whenever what is fed into the digest changes (an input, an order,
50/// a separator), or old and new records compare equal while meaning different
51/// things.
52pub const CLOSURE_SCHEMA: u32 = 2;
53
54#[derive(Debug)]
55pub enum ClosureError {
56 Io {
57 path: PathBuf,
58 source: std::io::Error,
59 },
60}
61
62impl std::fmt::Display for ClosureError {
63 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
64 match self {
65 Self::Io { path, source } => write!(f, "reading {}: {source}", path.display()),
66 }
67 }
68}
69
70/// 2026-09-26: A computed closure: the digest, plus the includes that named no
71/// file.
72#[derive(Debug, Clone)]
73pub struct Closure {
74 /// 2026-09-26: Lower-case hex SHA-256.
75 pub digest: String,
76 /// 2026-09-26: Quoted includes naming a file that is not on disk, as
77 /// `including-file -> include`, the including file relative to `root`.
78 pub unresolved: BTreeSet<String>,
79}
80
81impl std::error::Error for ClosureError {}
82
83type Result<T> = std::result::Result<T, ClosureError>;
84
85/// 2026-09-26: The inputs of one target's hash.
86#[derive(Debug, Clone)]
87pub struct ClosureInputs {
88 /// 2026-09-26: Resolved sources, in any order: the closure is a sorted set.
89 pub sources: Vec<PathBuf>,
90 /// 2026-09-26: Files hashed by content but not walked for includes. The
91 /// kernels build passes HARDWARE.toml, the layers' KERNEL.tomls and
92 /// MODEL.toml.
93 pub configs: Vec<PathBuf>,
94 /// 2026-09-26: Compiler flags, hashed in the given order.
95 pub flags: Vec<String>,
96 /// 2026-09-26: `[hardware] arch` of HARDWARE.toml, e.g. `"sm_121f"`.
97 pub arch: String,
98 /// 2026-09-26: Compiler identification. The kernels build passes the last
99 /// non-empty line of `nvcc --version`.
100 pub compiler: String,
101}
102
103/// 2026-09-26: The digest of [`hash_with_report`], without the unresolved set.
104pub fn hash(root: &Path, inputs: &ClosureInputs) -> Result<String> {
105 hash_with_report(root, inputs).map(|c| c.digest)
106}
107
108/// 2026-09-26: Hash a target's closure and report the quoted includes that
109/// named no file on disk.
110///
111/// An unresolvable include is recorded, not fatal. Its name still reaches the
112/// digest twice: through the including file's bytes, and through the
113/// unresolved set, hashed under its own label. What stays uncovered is the
114/// content of a header the compiler finds some other way. The kernels build
115/// prints each unresolved entry as a `cargo:warning`.
116///
117/// # Errors
118/// [`ClosureError::Io`] when a source, an included file or a config cannot be
119/// read.
120pub fn hash_with_report(root: &Path, inputs: &ClosureInputs) -> Result<Closure> {
121 // 2026-09-26: `expand` canonicalises every source, so the root is
122 // canonicalised too; otherwise an alias such as macOS `/var` ->
123 // `/private/var` makes `strip_prefix` fail and puts absolute paths into the
124 // report and the digest.
125 let canonical_root = root.canonicalize().unwrap_or_else(|_| root.to_path_buf());
126 let mut closure: BTreeSet<PathBuf> = BTreeSet::new();
127 let mut raw: BTreeSet<(PathBuf, String)> = BTreeSet::new();
128 for src in &inputs.sources {
129 expand(src, &mut closure, &mut raw)?;
130 }
131 let unresolved: BTreeSet<String> = raw
132 .into_iter()
133 .map(|(from, include)| {
134 let rel = from.strip_prefix(&canonical_root).unwrap_or(&from);
135 format!("{} -> {include}", rel.display())
136 })
137 .collect();
138 for cfg in &inputs.configs {
139 closure.insert(cfg.canonicalize().unwrap_or_else(|_| cfg.clone()));
140 }
141
142 let mut digest = Sha256::new();
143 // 2026-09-26: Domain separation: a tag, the schema and a label before each
144 // field, so the same bytes in a different field give a different digest.
145 digest.update(b"metrale-closure\x00");
146 digest.update(CLOSURE_SCHEMA.to_le_bytes());
147 digest.update(b"\x00arch\x00");
148 digest.update(inputs.arch.as_bytes());
149 digest.update(b"\x00compiler\x00");
150 digest.update(inputs.compiler.as_bytes());
151 digest.update(b"\x00flags\x00");
152 for flag in &inputs.flags {
153 digest.update(flag.as_bytes());
154 digest.update(b"\x1f");
155 }
156
157 digest.update(b"\x00files\x00");
158 for path in &closure {
159 let rel = path.strip_prefix(&canonical_root).unwrap_or(path);
160 // 2026-09-26: The path is hashed as well as the bytes: the same content
161 // under another stem shadows a different module.
162 digest.update(rel.to_string_lossy().as_bytes());
163 digest.update(b"\x1f");
164 let bytes = std::fs::read(path).map_err(|source| ClosureError::Io {
165 path: path.clone(),
166 source,
167 })?;
168 digest.update(bytes.len().to_le_bytes());
169 digest.update(&bytes);
170 digest.update(b"\x1e");
171 }
172
173 // 2026-09-26: Under its own label, so an include that becomes resolvable
174 // or unresolvable moves the digest even when no file's bytes change.
175 digest.update(b"\x00unresolved\x00");
176 for entry in &unresolved {
177 digest.update(entry.as_bytes());
178 digest.update(b"\x1f");
179 }
180
181 Ok(Closure {
182 digest: hex_lower(&digest.finalize()),
183 unresolved,
184 })
185}
186
187/// 2026-09-26: Add `file` and everything it quoted-includes, transitively,
188/// resolving each include against the including file's directory.
189///
190/// `out` is also the cycle guard: a file already inserted is not walked again,
191/// so mutually including headers terminate.
192fn expand(
193 file: &Path,
194 out: &mut BTreeSet<PathBuf>,
195 unresolved: &mut BTreeSet<(PathBuf, String)>,
196) -> Result<()> {
197 let canonical = file.canonicalize().unwrap_or_else(|_| file.to_path_buf());
198 if !out.insert(canonical.clone()) {
199 return Ok(());
200 }
201 let text = std::fs::read_to_string(&canonical).map_err(|source| ClosureError::Io {
202 path: canonical.clone(),
203 source,
204 })?;
205 let dir = canonical.parent().unwrap_or(Path::new("."));
206 for include in quoted_includes(&text) {
207 let target = dir.join(&include);
208 if !target.exists() {
209 // 2026-09-26: Keyed by the including file, so two files naming the
210 // same missing header are two entries.
211 unresolved.insert((canonical.clone(), include));
212 continue;
213 }
214 expand(&target, out, unresolved)?;
215 }
216 Ok(())
217}
218
219/// 2026-09-26: Quoted include paths, in source order.
220///
221/// Angle-bracket includes are skipped. So is a line that starts with `//`,
222/// because it is not compiled. Block comments are not tracked: an include
223/// inside `/* … */` is still followed, which over-includes.
224fn quoted_includes(text: &str) -> Vec<String> {
225 let mut found = Vec::new();
226 for line in text.lines() {
227 let trimmed = line.trim_start();
228 if trimmed.starts_with("//") {
229 continue;
230 }
231 let Some(rest) = trimmed.strip_prefix("#include") else {
232 continue;
233 };
234 let rest = rest.trim_start();
235 let Some(rest) = rest.strip_prefix('"') else {
236 continue;
237 };
238 if let Some(end) = rest.find('"') {
239 found.push(rest[..end].to_string());
240 }
241 }
242 found
243}
244
245#[cfg(test)]
246#[path = "lib_tests.rs"]
247mod lib_tests;