metrale_circuit/
digest.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2
3//! 2026-09-28: Two digests. `plan_digest` is SHA-256 over a canonical serialization of what a
4//! plan runs: its groups (members, kernels, repetition, emitter, numerics) and every edge's
5//! stored format and state, under the plan's arch, mode and rows. `rules_digest` is SHA-256
6//! over the FUSIONS.toml text the rules were parsed from.
7//!
8//! Owner: metrale-circuit.
9//! Invariants:
10//! - The plan serialization is a fixed-order text with a tag and a schema number up front,
11//!   one record per line, every field labelled, so the same bytes in another field give
12//!   another digest.
13//! - A plan digest moves only when the plan does: editing, adding or reordering a rule that
14//!   the plan does not select leaves it unchanged. Rule ids and citations are names, not
15//!   content, and are not hashed; the kernels a group runs are.
16//! - The rule set is attested by `rules_digest` and by the closure hash, which covers
17//!   FUSIONS.toml as a config.
18
19use std::fmt::Write as _;
20
21use sha2::{Digest, Sha256};
22
23use crate::fuser::{EdgeState, FusionPlan};
24use crate::ir::Circuit;
25
26/// 2026-09-28: Bumped when the canonical form changes.
27pub const DIGEST_SCHEMA: u32 = 2;
28
29/// 2026-09-28: The canonical text the plan digest is taken over.
30pub fn canonical(circuit: &Circuit, plan: &FusionPlan) -> String {
31    let mut s = String::new();
32    let _ = writeln!(s, "metrale-circuit-plan schema={DIGEST_SCHEMA}");
33    let _ = writeln!(
34        s,
35        "plan arch={} mode={} rows={}",
36        plan.arch,
37        plan.mode.name(),
38        plan.rows
39    );
40    // 2026-09-30: A batched verify's row table, and each per-run group's launches below.
41    if let Some(t) = &plan.table {
42        let _ = writeln!(s, "table {t}");
43    }
44    for (i, g) in plan.groups.iter().enumerate() {
45        let kernels: Vec<String> = g.kernels.iter().map(|k| k.to_string()).collect();
46        let nodes: Vec<&str> = g
47            .nodes
48            .iter()
49            .map(|&n| circuit.nodes[n].id.as_str())
50            .collect();
51        let _ = writeln!(
52            s,
53            "group {i} kernels=[{}] repeat={}{} emitter={} numerics={:?} nodes=[{}]",
54            kernels.join(","),
55            g.repeat.name(),
56            g.copies
57                .map(|c| format!(" copies={}", c.name()))
58                .unwrap_or_default(),
59            g.emitter,
60            g.numerics,
61            nodes.join(",")
62        );
63        for r in &g.runs {
64            let launches: Vec<String> = r
65                .launches
66                .iter()
67                .map(|(k, t)| format!("{k}*{}", t.name()))
68                .collect();
69            let _ = writeln!(
70                s,
71                "  run k={} n={} contiguous={} launches=[{}] copies={}",
72                r.run.k,
73                r.run.n,
74                r.run.contiguous,
75                launches.join(","),
76                r.copies.map_or("none", |c| c.name())
77            );
78        }
79    }
80    for (e, edge) in circuit.edges.iter().enumerate() {
81        let Some(state) = plan.edge_states[e] else {
82            continue;
83        };
84        let state = match state {
85            EdgeState::Materialized => "M".to_string(),
86            EdgeState::Fused(g) => format!("F{g}"),
87        };
88        let _ = writeln!(
89            s,
90            "edge {} stored={} rows={} dim={}={} state={state}",
91            edge.id,
92            plan.edge_formats[e].name(),
93            edge.rows.text(),
94            edge.dim.text(),
95            edge.dim_value
96        );
97    }
98    s
99}
100
101fn hex(bytes: &[u8]) -> String {
102    bytes.iter().fold(String::with_capacity(64), |mut acc, b| {
103        let _ = write!(acc, "{b:02x}");
104        acc
105    })
106}
107
108/// 2026-09-28: Lower-case hex SHA-256 of [`canonical`].
109pub fn plan_digest(circuit: &Circuit, plan: &FusionPlan) -> String {
110    hex(&Sha256::digest(canonical(circuit, plan).as_bytes()))
111}
112
113/// 2026-09-28: Lower-case hex SHA-256 of the FUSIONS.toml text the rules came from.
114pub fn rules_digest(fusions_text: &str) -> String {
115    hex(&Sha256::digest(fusions_text.as_bytes()))
116}
117
118/// 2026-09-28: Lower-case hex SHA-256 over several plan digests, each followed by a newline, in
119/// the given order: one value for a set of plans compiled together.
120pub fn plans_digest<'a>(digests: impl IntoIterator<Item = &'a str>) -> String {
121    let mut h = Sha256::new();
122    for d in digests {
123        h.update(d.as_bytes());
124        h.update(b"\n");
125    }
126    hex(&h.finalize())
127}