nmbrs_runtime/checkpoint/identity.rs
1// Copyright 2024-2026 Jonathan Shook
2// SPDX-License-Identifier: Apache-2.0
3
4//! Per-phase identity for checkpoint match / mismatch decisions.
5//!
6//! Per SRD-44 §"Phase identity", every checkpoint entry is
7//! keyed by an identity tuple, not by display labels. The
8//! tuple is structural (yaml_path + coords) plus an optional
9//! phase-program hash for sufficiency.
10//!
11//! This module owns the type definitions; the pre-map walker
12//! ([`crate::executor::pre_map_recursive`]) is what populates
13//! `yaml_path` per scene-tree node, and the program-canonical-
14//! emit logic lives in [`crate::checkpoint::storage`] alongside
15//! the JSON serialization.
16//!
17//! ## Why per-phase, not workload-level
18//!
19//! Workload-level identity gates ("did the YAML byte-hash
20//! match?") are coarser than per-phase: a comment-only edit
21//! invalidates the entire run's saved progress, even though
22//! every phase's compiled program is identical. Per-phase hash
23//! lets the resume planner invalidate exactly the affected
24//! phases.
25//!
26//! ## What the hash covers
27//!
28//! Per SRD-44 §"Why hash the compiled program, not the YAML
29//! body", the hash is over the canonical re-emission of the
30//! phase's *compiled `PolydatProgram`* — incorporating substituted
31//! param values, transitively-referenced binding values, and
32//! all fold-able compile-time state. So a phase whose body
33//! references `{dataset}` correctly invalidates when the
34//! dataset param changes; one that doesn't reference `{dataset}`
35//! correctly survives.
36
37use serde::{Deserialize, Serialize};
38
39/// One step in a phase's structural location within the
40/// workload YAML. The full path is built by walking the
41/// scenario tree from the workload root down to the phase
42/// declaration. Order matters; comparison is element-wise.
43#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
44pub enum PathSegment {
45 /// Scenario block declaration — the named entry under the
46 /// workload's `scenarios:` map.
47 Scenario(String),
48 /// Sub-scenario inclusion via the `scenario:` directive
49 /// (a scenario referencing another by name from inside a
50 /// phase list).
51 ScenarioInclude(String),
52 /// Single-clause `for_each` iteration.
53 ForEach { var: String },
54 /// Multi-clause `for_combinations` iteration. Vars in
55 /// declaration order.
56 ForCombinations { vars: Vec<String> },
57 /// `do_while` loop. `counter` is the optional
58 /// loop-counter binding name.
59 DoWhile { counter: Option<String> },
60 /// `do_until` loop.
61 DoUntil { counter: Option<String> },
62 /// Terminal: the phase declaration itself, by name.
63 Phase(String),
64}
65
66/// Per-phase identity, used by the checkpoint writer to record
67/// "what phase is this" and by the resume planner to match a
68/// saved entry to a freshly-pre-mapped phase.
69///
70/// `(yaml_path, coords)` together are necessary for a saved
71/// entry to apply to a new pre-map; `phase_hash` (when
72/// present) is the sufficiency check. See SRD-44 §"Identity
73/// matching at resume" for the full contract.
74///
75/// `coords` is the canonical scope-coordinate-path string
76/// (the leaf-first striated form produced by
77/// [`polydat::kernel::format_scope_coordinate_path`]).
78/// Stored as a string rather than typed `Vec<ScopeCoord>`
79/// because:
80/// - the runtime already produces it for every phase via
81/// `parent_kernel.scope_coordinates()` + the formatter,
82/// - it serialises trivially to JSON,
83/// - identity comparison reduces to string equality, and
84/// - the formatting function is the canonical identity
85/// producer for both pre-map and runtime — they cannot
86/// disagree without the formatter itself drifting.
87#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
88pub struct PhaseIdentity {
89 pub yaml_path: Vec<PathSegment>,
90 pub coords: String,
91 /// SHA-256 of the canonical re-emission of the phase's
92 /// compiled `PolydatProgram`. `None` when `checkpoint.hashed
93 /// = false` was declared on the workload (operator
94 /// opt-out per SRD-44); resume falls back to
95 /// tuple-only match in that case.
96 #[serde(default, with = "hex_opt")]
97 pub phase_hash: Option<[u8; 32]>,
98}
99
100impl PhaseIdentity {
101 /// Tuple-only match — `(yaml_path, coords)` equal.
102 /// Necessary precondition for any further match check;
103 /// if this returns `false`, the saved entry simply
104 /// doesn't apply to the candidate phase.
105 pub fn matches_structural(&self, other: &PhaseIdentity) -> bool {
106 self.yaml_path == other.yaml_path && self.coords == other.coords
107 }
108
109 /// Full match — tuple-equal AND, when both sides carry a
110 /// hash, hashes equal too. The "both carry a hash" bit
111 /// covers the operator opt-out (`checkpoint.hashed =
112 /// false`): in that case the saved entry has `None` and
113 /// the candidate may also have `None`, in which case
114 /// tuple-only equivalence is what the operator asked for.
115 /// Mismatched-hash → invalidate this single phase per
116 /// SRD-44 §"Identity matching at resume" item 3.
117 pub fn matches_full(&self, other: &PhaseIdentity) -> bool {
118 if !self.matches_structural(other) {
119 return false;
120 }
121 match (&self.phase_hash, &other.phase_hash) {
122 (Some(a), Some(b)) => a == b,
123 (None, _) | (_, None) => true,
124 }
125 }
126}
127
128/// Hex-encoded `[u8; 32]` for human-readable JSON output.
129mod hex_opt {
130 use serde::{Deserialize, Deserializer, Serialize, Serializer};
131
132 pub fn serialize<S: Serializer>(v: &Option<[u8; 32]>, s: S) -> Result<S::Ok, S::Error> {
133 match v {
134 None => s.serialize_none(),
135 Some(bytes) => {
136 let mut hex = String::with_capacity(64);
137 for b in bytes {
138 hex.push_str(&format!("{b:02x}"));
139 }
140 hex.serialize(s)
141 }
142 }
143 }
144
145 pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<Option<[u8; 32]>, D::Error> {
146 let s: Option<String> = Option::deserialize(d)?;
147 match s {
148 None => Ok(None),
149 Some(hex) => {
150 if hex.len() != 64 {
151 return Err(serde::de::Error::custom(format!(
152 "phase_hash: expected 64 hex chars, got {}",
153 hex.len()
154 )));
155 }
156 let mut out = [0u8; 32];
157 for (i, byte) in out.iter_mut().enumerate() {
158 let pair = &hex[i * 2..i * 2 + 2];
159 *byte = u8::from_str_radix(pair, 16).map_err(|e| {
160 serde::de::Error::custom(format!(
161 "phase_hash: invalid hex at byte {i}: {e}"
162 ))
163 })?;
164 }
165 Ok(Some(out))
166 }
167 }
168 }
169}
170
171#[cfg(test)]
172mod tests {
173 use super::*;
174
175 fn id(path: Vec<PathSegment>, coords: &str, hash: Option<[u8; 32]>) -> PhaseIdentity {
176 PhaseIdentity {
177 yaml_path: path,
178 coords: coords.to_string(),
179 phase_hash: hash,
180 }
181 }
182
183 #[test]
184 fn structural_match_requires_path_and_coords() {
185 let a = id(vec![PathSegment::Phase("p".into())], "", None);
186 let b = id(vec![PathSegment::Phase("p".into())], "", None);
187 let c = id(vec![PathSegment::Phase("q".into())], "", None);
188 let d = id(vec![PathSegment::Phase("p".into())], "(k=1)", None);
189 assert!(a.matches_structural(&b));
190 assert!(!a.matches_structural(&c));
191 assert!(!a.matches_structural(&d));
192 }
193
194 #[test]
195 fn full_match_with_both_hashes_present() {
196 let h1 = [1u8; 32];
197 let h2 = [2u8; 32];
198 let a = id(vec![PathSegment::Phase("p".into())], "", Some(h1));
199 let b = id(vec![PathSegment::Phase("p".into())], "", Some(h1));
200 let c = id(vec![PathSegment::Phase("p".into())], "", Some(h2));
201 assert!(a.matches_full(&b));
202 assert!(!a.matches_full(&c));
203 }
204
205 #[test]
206 fn full_match_with_one_side_unhashed() {
207 // Operator opted out (`hashed: false`); structural
208 // match is sufficient.
209 let h = [1u8; 32];
210 let a = id(vec![PathSegment::Phase("p".into())], "", Some(h));
211 let b = id(vec![PathSegment::Phase("p".into())], "", None);
212 assert!(a.matches_full(&b));
213 assert!(b.matches_full(&a));
214 }
215
216 #[test]
217 fn json_round_trip_with_hash() {
218 let h = [0xab; 32];
219 let original = id(
220 vec![
221 PathSegment::Scenario("fulltest".into()),
222 PathSegment::ForEach {
223 var: "profile".into(),
224 },
225 PathSegment::Phase("rampup".into()),
226 ],
227 "(profile=label_03)",
228 Some(h),
229 );
230 let json = serde_json::to_string(&original).expect("serialize");
231 // The hash should appear as 64 hex chars.
232 assert!(
233 json.contains(&"ab".repeat(32)),
234 "expected hex hash in JSON: {json}"
235 );
236 let parsed: PhaseIdentity = serde_json::from_str(&json).expect("deserialize");
237 assert_eq!(parsed, original);
238 }
239
240 #[test]
241 fn json_round_trip_without_hash() {
242 let original = id(vec![PathSegment::Phase("p".into())], "", None);
243 let json = serde_json::to_string(&original).expect("serialize");
244 let parsed: PhaseIdentity = serde_json::from_str(&json).expect("deserialize");
245 assert_eq!(parsed, original);
246 }
247
248 #[test]
249 fn rejects_invalid_hex_length() {
250 let bad = r#"{"yaml_path":[],"coords":"","phase_hash":"ab12"}"#;
251 let err = serde_json::from_str::<PhaseIdentity>(bad).unwrap_err();
252 assert!(err.to_string().contains("64 hex"), "got: {err}");
253 }
254}