1use std::collections::HashMap;
33
34use super::identity::PhaseIdentity;
35use super::storage::{Checkpoint, PhaseStatus};
36
37#[derive(Clone, Debug)]
41pub enum ResumeAction {
42 Skip,
48 CursorResume { cursor_state: serde_json::Value },
54 ReRun,
59 IdentityMismatch { reason: String },
65}
66
67#[derive(Clone, Debug, Default)]
71pub struct ResumePlan {
72 actions: HashMap<String, ResumeAction>,
73 pub is_resume: bool,
79}
80
81impl ResumePlan {
82 pub fn fresh() -> Self {
84 Self::default()
85 }
86
87 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 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 pub fn skip_count(&self) -> usize {
134 self.actions
135 .values()
136 .filter(|a| matches!(a, ResumeAction::Skip))
137 .count()
138 }
139
140 pub fn cursor_resume_count(&self) -> usize {
142 self.actions
143 .values()
144 .filter(|a| matches!(a, ResumeAction::CursorResume { .. }))
145 .count()
146 }
147
148 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 if !declared_idempotent {
171 return ResumeAction::ReRun;
172 }
173
174 match saved.status {
175 PhaseStatus::Completed => {
176 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 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 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 PhaseStatus::Pending | PhaseStatus::Failed => ResumeAction::ReRun,
225 }
226}
227
228fn 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
254fn 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 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 #[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)], ¶ms);
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 #[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)], ¶ms);
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}