Skip to main content

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}