Skip to main content

nmbrs_runtime/checkpoint/
resume.rs

1// Copyright 2024-2026 Jonathan Shook
2// SPDX-License-Identifier: Apache-2.0
3
4//! Resume planner — classify each freshly-pre-mapped phase
5//! against the saved checkpoint document and emit a
6//! `ResumePlan` the executor consults before dispatch.
7//!
8//! Per SRD-44 §"Resume protocol", the planner walks the
9//! pre-map phase list (already in DFS-of-the-scenario-tree
10//! order) and for each phase asks:
11//!
12//! 1. Is there a structural match (same `yaml_path` and
13//!    `coords`) in the saved doc? If not → `ReRun` (this is a
14//!    new phase that wasn't in the last invocation).
15//! 2. If matched and the saved status is `Completed`:
16//!    - phase declared `checkpoint: idempotent` (or long form
17//!      with `idempotent: true`) → `Skip` when hashes match,
18//!      `IdentityMismatch` (and re-run) when they don't.
19//!    - phase declared `checkpoint: none` (or absent) → `ReRun`
20//!      regardless of saved status (operator opted out).
21//! 3. If matched and the saved status is `Running`:
22//!    - the phase has cursor state recorded → `CursorResume`
23//!      with that opaque snapshot (Tier 2).
24//!    - no cursor state → `ReRun` (Tier 1 inflight crash).
25//! 4. If matched and the saved status is `Failed` →
26//!    `ReRun` (the errors cascade decides whether to surface
27//!    the failure again — see SRD-44 §"Error handling is
28//!    invocation-agnostic"). The planner doesn't pre-decide.
29//! 5. If matched and the saved status is `Pending` → `ReRun`
30//!    (the previous invocation never started this phase).
31
32use std::collections::HashMap;
33
34use super::identity::PhaseIdentity;
35use super::storage::{Checkpoint, PhaseStatus};
36
37/// What the executor should do with one pre-mapped phase. The
38/// planner emits one of these per phase keyed by identity-key
39/// (see [`super::writer`] for the key shape).
40#[derive(Clone, Debug)]
41pub enum ResumeAction {
42    /// Phase already completed successfully on a prior
43    /// invocation, identity matches, and the operator declared
44    /// it idempotent. Executor emits the post-run summary line
45    /// for this phase as `[skipped]` and moves on; the writer
46    /// keeps the saved entry intact.
47    Skip,
48    /// Phase was in-flight (`Running`) and recorded a cursor
49    /// snapshot the source factory understands. Executor
50    /// reconstructs the source via the factory, calls
51    /// `restore_cursor(snapshot)` on it, and runs the remaining
52    /// cycles per SRD-44 §"Tier 2".
53    CursorResume { cursor_state: serde_json::Value },
54    /// Default — the phase runs from scratch this invocation.
55    /// Triggered by: no saved entry, declared `checkpoint:
56    /// none`, saved status `Pending`/`Failed`, or `Running`
57    /// without cursor state.
58    ReRun,
59    /// Saved entry exists, structural match holds, but the
60    /// program hash differs (or some other identity mismatch).
61    /// Executor re-runs the phase and the writer overwrites
62    /// the stale entry. Carries a human-readable reason for
63    /// the resume diagnostic banner.
64    IdentityMismatch { reason: String },
65}
66
67/// The resume plan for one pre-mapped scenario tree. Maps each
68/// phase's identity-key to its [`ResumeAction`]. A "fresh" plan
69/// (no saved checkpoint) maps every phase to `ReRun`.
70#[derive(Clone, Debug, Default)]
71pub struct ResumePlan {
72    actions: HashMap<String, ResumeAction>,
73    /// `true` when at least one phase was loaded from a
74    /// previously-flushed checkpoint document — i.e. this is a
75    /// resume run, not a fresh start. Drives the resume banner
76    /// in the runner header and the `--- RESUMED ---` separator
77    /// in `session.log`.
78    pub is_resume: bool,
79}
80
81impl ResumePlan {
82    /// A plan with no saved state — every phase re-runs.
83    pub fn fresh() -> Self {
84        Self::default()
85    }
86
87    /// Build a plan from a saved checkpoint and a list of
88    /// freshly-pre-mapped phase candidates. `candidates` is
89    /// `(identity, declared_idempotent)`; the second entry
90    /// reflects the workload's `checkpoint:` declaration for
91    /// that phase (`true` for idempotent, `false` for
92    /// none/absent).
93    pub fn from_checkpoint(
94        saved: &Checkpoint,
95        candidates: &[(PhaseIdentity, bool)],
96        current_params: &HashMap<String, String>,
97    ) -> Self {
98        let saved_index: HashMap<String, &super::storage::PhaseEntry> = saved
99            .phases
100            .iter()
101            .map(|e| (identity_key(&e.identity), e))
102            .collect();
103
104        let mut actions = HashMap::with_capacity(candidates.len());
105        for (cand, declared_idempotent) in candidates {
106            let key = identity_key(cand);
107            let action = match saved_index.get(&key) {
108                None => ResumeAction::ReRun,
109                Some(saved_entry) => {
110                    classify(cand, saved_entry, *declared_idempotent, current_params)
111                }
112            };
113            actions.insert(key, action);
114        }
115        Self {
116            actions,
117            is_resume: true,
118        }
119    }
120
121    /// Look up the action for a given phase identity. Returns
122    /// `ReRun` for unknown phases — the conservative default so
123    /// pre-map drift can never cause a phase to silently skip.
124    pub fn action_for(&self, identity: &PhaseIdentity) -> ResumeAction {
125        let key = identity_key(identity);
126        self.actions
127            .get(&key)
128            .cloned()
129            .unwrap_or(ResumeAction::ReRun)
130    }
131
132    /// Number of phases the plan classifies as `Skip`.
133    pub fn skip_count(&self) -> usize {
134        self.actions
135            .values()
136            .filter(|a| matches!(a, ResumeAction::Skip))
137            .count()
138    }
139
140    /// Number of phases the plan classifies as `CursorResume`.
141    pub fn cursor_resume_count(&self) -> usize {
142        self.actions
143            .values()
144            .filter(|a| matches!(a, ResumeAction::CursorResume { .. }))
145            .count()
146    }
147
148    /// Number of phases the plan classifies as
149    /// `IdentityMismatch`. Surfaced in the resume banner so
150    /// operators can tell at a glance whether their YAML edit
151    /// invalidated phases they thought were stable.
152    pub fn mismatch_count(&self) -> usize {
153        self.actions
154            .values()
155            .filter(|a| matches!(a, ResumeAction::IdentityMismatch { .. }))
156            .count()
157    }
158}
159
160fn classify(
161    candidate: &PhaseIdentity,
162    saved: &super::storage::PhaseEntry,
163    declared_idempotent: bool,
164    current_params: &HashMap<String, String>,
165) -> ResumeAction {
166    // The saved entry might have been written before the
167    // operator changed `checkpoint:` for this phase. Honour the
168    // *current* declaration: if the workload now says `none`,
169    // re-run regardless of saved status.
170    if !declared_idempotent {
171        return ResumeAction::ReRun;
172    }
173
174    match saved.status {
175        PhaseStatus::Completed => {
176            // Structural match has been established by the
177            // identity_key lookup; check sufficiency (base hash)
178            // only when both sides carry one.
179            if !candidate.matches_full(&saved.identity) {
180                return ResumeAction::IdentityMismatch {
181                    reason: format!(
182                        "phase '{}': scope or phase config changed since \
183                         this phase last ran (base hash differs)",
184                        phase_label(&candidate.yaml_path),
185                    ),
186                };
187            }
188            // SRD-107 — the per-param leg: every param the saved
189            // run consumed must digest to the same value NOW. The
190            // blocker names the param. A base-matching entry
191            // without the stored map is incomparable (mid-upgrade
192            // oddity) → conservative re-run.
193            if let Err(mismatch) =
194                saved_params_still_valid(saved.params_consumed.as_deref(), current_params)
195            {
196                return ResumeAction::IdentityMismatch {
197                    reason: format!(
198                        "phase '{}': {mismatch} since this phase last ran",
199                        phase_label(&candidate.yaml_path),
200                    ),
201                };
202            }
203            if !saved.skip_eligible {
204                // Saved entry was written when the phase was
205                // declared `none`; even though the current
206                // declaration is `idempotent`, the saved
207                // execution may not have been idempotent. Be
208                // conservative and re-run.
209                return ResumeAction::ReRun;
210            }
211            ResumeAction::Skip
212        }
213        PhaseStatus::Running => match &saved.cursor_state {
214            Some(cs) => ResumeAction::CursorResume {
215                cursor_state: cs.clone(),
216            },
217            None => ResumeAction::ReRun,
218        },
219        // Pending: never started in the prior invocation.
220        // Failed: errors cascade decides whether to retry; from
221        //   the planner's perspective, a Failed phase always
222        //   re-runs (the cascade may then mark it Failed again
223        //   or surface a different outcome).
224        PhaseStatus::Pending | PhaseStatus::Failed => ResumeAction::ReRun,
225    }
226}
227
228/// SRD-107 — evaluate a saved consumed-params map against the
229/// CURRENT param values. `Ok(())` when every stored name digests
230/// identically now; `Err(description)` naming the first changed
231/// or missing param, or the comparability gap.
232fn saved_params_still_valid(
233    stored_json: Option<&str>,
234    current_params: &HashMap<String, String>,
235) -> Result<(), String> {
236    let Some(json) = stored_json else {
237        return Err("saved entry carries no consumed-params record".into());
238    };
239    let Ok(stored) = serde_json::from_str::<std::collections::BTreeMap<String, String>>(json)
240    else {
241        return Err("saved consumed-params record is unreadable".into());
242    };
243    for (name, stored_digest) in stored {
244        let current = current_params
245            .get(&name)
246            .map(|v| crate::checkpoint::params_scope::value_digest(v));
247        if current.as_deref() != Some(stored_digest.as_str()) {
248            return Err(format!("param '{name}' changed"));
249        }
250    }
251    Ok(())
252}
253
254/// Reuse the writer's identity-key shape so both sides agree on
255/// equality semantics.
256fn identity_key(identity: &PhaseIdentity) -> String {
257    let path_json = serde_json::to_string(&identity.yaml_path).unwrap_or_else(|_| String::new());
258    format!("{path_json}\x1f{}", identity.coords)
259}
260
261fn phase_label(yaml_path: &[super::identity::PathSegment]) -> String {
262    use super::identity::PathSegment;
263    yaml_path
264        .iter()
265        .filter_map(|seg| match seg {
266            PathSegment::Phase(n) => Some(n.clone()),
267            _ => None,
268        })
269        .next_back()
270        .unwrap_or_else(|| "<unknown>".to_string())
271}
272
273#[cfg(test)]
274mod tests {
275    use super::*;
276    use crate::checkpoint::{
277        PathSegment,
278        storage::{OpCounts, PhaseEntry},
279    };
280
281    fn ident_with_hash(name: &str, coords: &str, hash: Option<[u8; 32]>) -> PhaseIdentity {
282        PhaseIdentity {
283            yaml_path: vec![
284                PathSegment::Scenario("s".into()),
285                PathSegment::Phase(name.into()),
286            ],
287            coords: coords.into(),
288            phase_hash: hash,
289        }
290    }
291
292    fn entry(identity: PhaseIdentity, status: PhaseStatus, skip_eligible: bool) -> PhaseEntry {
293        PhaseEntry {
294            identity,
295            skip_eligible,
296            // Comparable empty consumed-set: these fixtures pin the
297            // status/hash legs; the params leg has its own tests.
298            params_consumed: Some("{}".into()),
299            status,
300            duration_secs: Some(1.0),
301            op_counts: Some(OpCounts::default()),
302            cursor_state: None,
303            error: None,
304        }
305    }
306
307    fn checkpoint_with(phases: Vec<PhaseEntry>) -> Checkpoint {
308        Checkpoint {
309            version: 1,
310            session: "s".into(),
311            started_at: "t".into(),
312            checkpoint_at: "t".into(),
313            invocation: 1,
314            phases,
315        }
316    }
317
318    #[test]
319    fn fresh_plan_rerun_for_everything() {
320        let plan = ResumePlan::fresh();
321        assert!(matches!(
322            plan.action_for(&ident_with_hash("p", "", None)),
323            ResumeAction::ReRun
324        ));
325        assert!(!plan.is_resume);
326    }
327
328    /// SRD-107 — the per-param leg: same base hash, but a param
329    /// the saved run consumed now digests differently → the
330    /// mismatch names the param.
331    #[test]
332    fn consumed_param_change_invalidates_and_names_the_param() {
333        let h = [0xab; 32];
334        let id = ident_with_hash("load", "", Some(h));
335        let mut e = entry(id.clone(), PhaseStatus::Completed, true);
336        e.params_consumed = Some(format!(
337            r#"{{"dataset":"{}"}}"#,
338            crate::checkpoint::params_scope::value_digest("example"),
339        ));
340        let saved = checkpoint_with(vec![e]);
341
342        let mut params = HashMap::new();
343        params.insert("dataset".to_string(), "sift10m".to_string());
344        let plan = ResumePlan::from_checkpoint(&saved, &[(id.clone(), true)], &params);
345        match plan.action_for(&id) {
346            ResumeAction::IdentityMismatch { reason } => {
347                assert!(
348                    reason.contains("param 'dataset' changed"),
349                    "reason must name the param: {reason}"
350                );
351            }
352            other => panic!("expected IdentityMismatch, got {other:?}"),
353        }
354    }
355
356    /// SRD-107 — unrelated params may change freely: only the
357    /// STORED names are checked, so a phase that consumed nothing
358    /// (or different params) still skips.
359    #[test]
360    fn unrelated_param_change_still_skips() {
361        let h = [0xab; 32];
362        let id = ident_with_hash("load", "", Some(h));
363        let mut e = entry(id.clone(), PhaseStatus::Completed, true);
364        e.params_consumed = Some(format!(
365            r#"{{"dataset":"{}"}}"#,
366            crate::checkpoint::params_scope::value_digest("example"),
367        ));
368        let saved = checkpoint_with(vec![e]);
369
370        let mut params = HashMap::new();
371        params.insert("dataset".to_string(), "example".to_string());
372        params.insert("suite_k".to_string(), "100".to_string());
373        let plan = ResumePlan::from_checkpoint(&saved, &[(id.clone(), true)], &params);
374        assert!(
375            matches!(plan.action_for(&id), ResumeAction::Skip),
376            "an unconsumed param's change must not invalidate"
377        );
378    }
379
380    #[test]
381    fn completed_idempotent_with_matching_hash_skips() {
382        let h = [0xab; 32];
383        let id = ident_with_hash("schema", "", Some(h));
384        let saved = checkpoint_with(vec![entry(id.clone(), PhaseStatus::Completed, true)]);
385        let plan = ResumePlan::from_checkpoint(&saved, &[(id.clone(), true)], &HashMap::new());
386        assert!(matches!(plan.action_for(&id), ResumeAction::Skip));
387        assert_eq!(plan.skip_count(), 1);
388    }
389
390    #[test]
391    fn completed_with_hash_mismatch_invalidates() {
392        let h_old = [0x01; 32];
393        let h_new = [0x02; 32];
394        let id_saved = ident_with_hash("schema", "", Some(h_old));
395        let id_now = ident_with_hash("schema", "", Some(h_new));
396        let saved = checkpoint_with(vec![entry(id_saved.clone(), PhaseStatus::Completed, true)]);
397        let plan = ResumePlan::from_checkpoint(&saved, &[(id_now.clone(), true)], &HashMap::new());
398        match plan.action_for(&id_now) {
399            ResumeAction::IdentityMismatch { reason } => {
400                assert!(reason.contains("schema"), "reason: {reason}");
401            }
402            other => panic!("expected IdentityMismatch, got {other:?}"),
403        }
404        assert_eq!(plan.mismatch_count(), 1);
405    }
406
407    #[test]
408    fn declared_none_always_reruns_even_if_completed() {
409        let id = ident_with_hash("schema", "", None);
410        let saved = checkpoint_with(vec![entry(id.clone(), PhaseStatus::Completed, false)]);
411        let plan = ResumePlan::from_checkpoint(&saved, &[(id.clone(), false)], &HashMap::new());
412        assert!(matches!(plan.action_for(&id), ResumeAction::ReRun));
413    }
414
415    #[test]
416    fn running_with_cursor_state_yields_cursor_resume() {
417        let id = ident_with_hash("rampup", "", None);
418        let mut e = entry(id.clone(), PhaseStatus::Running, true);
419        e.cursor_state = Some(serde_json::json!({"next_cycle": 12345}));
420        let saved = checkpoint_with(vec![e]);
421        let plan = ResumePlan::from_checkpoint(&saved, &[(id.clone(), true)], &HashMap::new());
422        match plan.action_for(&id) {
423            ResumeAction::CursorResume { cursor_state } => {
424                assert_eq!(cursor_state["next_cycle"], 12345);
425            }
426            other => panic!("expected CursorResume, got {other:?}"),
427        }
428        assert_eq!(plan.cursor_resume_count(), 1);
429    }
430
431    #[test]
432    fn running_without_cursor_state_reruns() {
433        let id = ident_with_hash("rampup", "", None);
434        let saved = checkpoint_with(vec![entry(id.clone(), PhaseStatus::Running, true)]);
435        let plan = ResumePlan::from_checkpoint(&saved, &[(id.clone(), true)], &HashMap::new());
436        assert!(matches!(plan.action_for(&id), ResumeAction::ReRun));
437    }
438
439    #[test]
440    fn unknown_candidate_reruns() {
441        let saved = checkpoint_with(vec![]);
442        let id = ident_with_hash("brand_new", "", None);
443        let plan = ResumePlan::from_checkpoint(&saved, &[(id.clone(), true)], &HashMap::new());
444        assert!(matches!(plan.action_for(&id), ResumeAction::ReRun));
445    }
446
447    #[test]
448    fn failed_phase_reruns() {
449        let id = ident_with_hash("flaky", "", None);
450        let mut e = entry(id.clone(), PhaseStatus::Failed, true);
451        e.error = Some("boom".into());
452        let saved = checkpoint_with(vec![e]);
453        let plan = ResumePlan::from_checkpoint(&saved, &[(id.clone(), true)], &HashMap::new());
454        assert!(matches!(plan.action_for(&id), ResumeAction::ReRun));
455    }
456}