1use crate::bench::agreement::{aggregate_agreement, aggregate_cost};
42use crate::bench::bundle;
43use crate::bench::canonical::{canonical_json, canonical_json_pretty};
44use crate::bench::head_to_head;
45use crate::bench::stability::aggregate_stability;
46use serde_json::{json, Map, Value};
47use std::collections::{BTreeSet, HashMap, HashSet};
48use std::io::Write;
49use std::path::{Path, PathBuf};
50
51pub const MANIFEST: &str = "manifest.json";
52pub const OBSERVATIONS: &str = "observations.jsonl";
53pub const H2H_RECORDS: &str = "h2h.jsonl";
54
55const RESUME_CONFIG_EXCLUDES: [&str; 5] = [
62 "checkpoint_dir",
63 "output",
64 "resume",
65 "skip_agreement",
66 "workers",
67];
68
69#[derive(Debug, Clone)]
71pub struct CheckpointPaths {
72 pub root: PathBuf,
73 pub manifest: PathBuf,
74 pub observations: PathBuf,
75 pub h2h: PathBuf,
76}
77
78pub fn checkpoint_paths(root: &Path) -> CheckpointPaths {
80 CheckpointPaths {
81 root: root.to_path_buf(),
82 manifest: root.join(MANIFEST),
83 observations: root.join(OBSERVATIONS),
84 h2h: root.join(H2H_RECORDS),
85 }
86}
87
88fn manifest_path(path: &Path) -> PathBuf {
93 if path.is_dir() {
94 return path.join(MANIFEST);
95 }
96 let name_is_manifest = path.file_name().is_some_and(|n| n == MANIFEST);
97 let has_json_suffix = path.extension().is_some_and(|e| e == "json");
98 if !name_is_manifest && !has_json_suffix {
99 return path.join(MANIFEST);
100 }
101 path.to_path_buf()
102}
103
104pub fn append_jsonl(path: &Path, row: &Value) -> Result<(), String> {
107 if let Some(parent) = path.parent() {
108 if !parent.as_os_str().is_empty() {
109 std::fs::create_dir_all(parent).map_err(|e| format!("mkdir {parent:?}: {e}"))?;
110 }
111 }
112 let mut file = std::fs::OpenOptions::new()
113 .create(true)
114 .append(true)
115 .open(path)
116 .map_err(|e| format!("open {path:?}: {e}"))?;
117 writeln!(file, "{}", canonical_json(row)).map_err(|e| format!("write {path:?}: {e}"))?;
118 file.flush().map_err(|e| format!("flush {path:?}: {e}"))
119}
120
121pub fn load_jsonl(path: &Path) -> Result<Vec<Value>, String> {
126 if !path.exists() {
127 return Ok(Vec::new());
128 }
129 let text = std::fs::read_to_string(path).map_err(|e| format!("read {path:?}: {e}"))?;
130 let mut rows = Vec::new();
131 for (i, line) in text.lines().enumerate() {
132 let trimmed = line.trim();
133 if trimmed.is_empty() {
134 continue;
135 }
136 let value: Value = serde_json::from_str(trimmed)
137 .map_err(|e| format!("{}:{}: invalid checkpoint JSON: {e}", path.display(), i + 1))?;
138 rows.push(value);
139 }
140 Ok(rows)
141}
142
143pub fn key_set<K: Eq + std::hash::Hash>(
145 rows: &[Value],
146 key_func: impl Fn(&Value) -> K,
147) -> HashSet<K> {
148 rows.iter().map(key_func).collect()
149}
150
151pub fn write_manifest(path: &Path, manifest: &Value) -> Result<(), String> {
155 let target = manifest_path(path);
156 if let Some(parent) = target.parent() {
157 if !parent.as_os_str().is_empty() {
158 std::fs::create_dir_all(parent).map_err(|e| format!("mkdir {parent:?}: {e}"))?;
159 }
160 }
161 let file_name = target
162 .file_name()
163 .ok_or("manifest path has no file name")?
164 .to_string_lossy()
165 .to_string();
166 let tmp = target.with_file_name(format!("{file_name}.tmp"));
167 std::fs::write(&tmp, canonical_json_pretty(manifest))
168 .map_err(|e| format!("write {tmp:?}: {e}"))?;
169 std::fs::rename(&tmp, &target).map_err(|e| format!("rename {tmp:?} -> {target:?}: {e}"))
170}
171
172pub fn load_manifest(path: &Path) -> Result<Value, String> {
175 let target = manifest_path(path);
176 if !target.exists() {
177 return Ok(json!({}));
178 }
179 let text = std::fs::read_to_string(&target).map_err(|e| format!("read {target:?}: {e}"))?;
180 serde_json::from_str(&text).map_err(|e| format!("parse {target:?}: {e}"))
181}
182
183fn now_timestamp() -> String {
184 chrono::Local::now()
185 .format("%Y-%m-%dT%H:%M:%S%z")
186 .to_string()
187}
188
189pub fn update_manifest_counts(
192 path: &Path,
193 observations: u64,
194 h2h_records: u64,
195 status: Option<&str>,
196) -> Result<Value, String> {
197 let target = manifest_path(path);
198 let mut manifest = load_manifest(&target)?;
199 manifest["counts"] = json!({"observations": observations, "h2h_records": h2h_records});
200 if let Some(status) = status {
201 manifest["status"] = json!(status);
202 }
203 manifest["updated_at"] = json!(now_timestamp());
204 write_manifest(&target, &manifest)?;
205 Ok(manifest)
206}
207
208pub fn normalize_run_config(config: &Value) -> Value {
210 let mut map = config.as_object().cloned().unwrap_or_default();
211 for key in RESUME_CONFIG_EXCLUDES {
212 map.remove(key);
213 }
214 Value::Object(map)
215}
216
217fn config_signature(config: &Value, ignore_skip_h2h: bool) -> Value {
218 let mut signature = normalize_run_config(config);
219 if ignore_skip_h2h {
220 if let Some(map) = signature.as_object_mut() {
221 map.remove("skip_h2h");
222 }
223 }
224 signature
225}
226
227pub fn build_manifest(
234 config: &Value,
235 dataset_payload: &Value,
236 status: &str,
237 observations: u64,
238 h2h_records: u64,
239) -> Value {
240 let positions = dataset_payload["positions"]
241 .as_array()
242 .cloned()
243 .unwrap_or_default();
244 let mut phases: Map<String, Value> = Map::new();
245 for position in &positions {
246 let phase = position["phase"].as_str().unwrap_or_default().to_string();
247 let count = phases.get(&phase).and_then(Value::as_u64).unwrap_or(0);
248 phases.insert(phase, json!(count + 1));
249 }
250 json!({
251 "started_at": now_timestamp(),
252 "environment": bundle::collect_environment(),
253 "config": config,
254 "dataset": {
255 "checksum": dataset_payload.get("checksum").cloned().unwrap_or(Value::Null),
256 "generator": dataset_payload["generator"],
257 "seed": dataset_payload["seed"],
258 "schema_version": dataset_payload["schema_version"],
259 "positions": positions.len(),
260 "phases": phases,
261 },
262 "status": status,
263 "counts": {"observations": observations, "h2h_records": h2h_records},
264 })
265}
266
267pub fn validate_resume_manifest(
275 manifest: &Value,
276 dataset_checksum: Option<&str>,
277 config: &Value,
278 allow_skip_h2h_mismatch: bool,
279) -> Result<(), String> {
280 let manifest_dataset = manifest.get("dataset").cloned().unwrap_or(json!({}));
281 let actual_checksum = manifest_dataset.get("checksum").and_then(Value::as_str);
282 if actual_checksum != dataset_checksum {
283 return Err(format!(
284 "checkpoint dataset checksum mismatch: expected {dataset_checksum:?}, found {actual_checksum:?}"
285 ));
286 }
287
288 let expected_config = config_signature(config, allow_skip_h2h_mismatch);
289 let empty = Value::Null;
290 let actual_config = config_signature(
291 manifest.get("config").unwrap_or(&empty),
292 allow_skip_h2h_mismatch,
293 );
294 if actual_config != expected_config {
295 let expected_map = expected_config.as_object().cloned().unwrap_or_default();
296 let actual_map = actual_config.as_object().cloned().unwrap_or_default();
297 let mut keys: BTreeSet<String> = expected_map.keys().cloned().collect();
298 keys.extend(actual_map.keys().cloned());
299 let mut diffs = Vec::new();
300 for key in keys {
301 let expected_value = expected_map.get(&key).cloned().unwrap_or(Value::Null);
302 let actual_value = actual_map.get(&key).cloned().unwrap_or(Value::Null);
303 if expected_value != actual_value {
304 diffs.push(format!(
305 "{key}: expected {expected_value}, found {actual_value}"
306 ));
307 }
308 }
309 let detail = if diffs.is_empty() {
310 "unknown difference".to_string()
311 } else {
312 diffs.join("; ")
313 };
314 return Err(format!("checkpoint config mismatch: {detail}"));
315 }
316 Ok(())
317}
318
319fn dataset_summary(manifest: &Value) -> Result<Value, String> {
320 match manifest.get("dataset") {
321 Some(d) if !d.is_null() => Ok(d.clone()),
322 _ => Err("checkpoint manifest is missing dataset metadata".into()),
323 }
324}
325
326fn head_to_head_aggregates(records: &[Value]) -> Vec<Value> {
331 let mut order: Vec<(String, String)> = Vec::new();
332 let mut names: HashMap<(String, String), (String, String)> = HashMap::new();
333 let mut groups: HashMap<(String, String), Vec<Value>> = HashMap::new();
334
335 for record in records {
336 let mover = record["mover"].as_str().unwrap_or_default().to_string();
337 let responder = record["responder"].as_str().unwrap_or_default().to_string();
338 let mut sorted_pair = [mover.clone(), responder.clone()];
339 sorted_pair.sort();
340 let pair_key = (sorted_pair[0].clone(), sorted_pair[1].clone());
341
342 groups
343 .entry(pair_key.clone())
344 .or_insert_with(|| {
345 order.push(pair_key.clone());
346 names.insert(pair_key.clone(), (mover.clone(), responder.clone()));
347 Vec::new()
348 })
349 .push(record.clone());
350 }
351
352 order
353 .into_iter()
354 .map(|pair_key| {
355 let (name_a, name_b) = names[&pair_key].clone();
356 head_to_head::aggregate_head_to_head(&groups[&pair_key], &name_a, &name_b)
357 })
358 .collect()
359}
360
361pub fn bundle_from_checkpoint(root: &Path) -> Result<Value, String> {
367 let paths = checkpoint_paths(root);
368 let manifest = load_manifest(&paths.manifest)?;
369 let observations = load_jsonl(&paths.observations)?;
370 let records = load_jsonl(&paths.h2h)?;
371 let dataset = dataset_summary(&manifest)?;
372
373 let counts = manifest
374 .get("counts")
375 .cloned()
376 .unwrap_or(json!({"observations": 0, "h2h_records": 0}));
377 let mut checkpoint_info = json!({
378 "status": manifest.get("status").and_then(Value::as_str).unwrap_or("unknown"),
379 "counts": counts,
380 });
381 if let Some(pairs) = manifest.get("h2h_pairs") {
382 checkpoint_info["h2h_pairs"] = pairs.clone();
383 }
384
385 let started_at = manifest
386 .get("started_at")
387 .and_then(Value::as_str)
388 .map(str::to_string)
389 .unwrap_or_else(now_timestamp);
390 let environment = manifest
391 .get("environment")
392 .cloned()
393 .unwrap_or_else(bundle::collect_environment);
394 let config = manifest.get("config").cloned().unwrap_or(json!({}));
395
396 Ok(json!({
397 "schema_version": bundle::SCHEMA_VERSION,
398 "started_at": started_at,
399 "environment": environment,
400 "config": config,
401 "dataset": dataset,
402 "checkpoint": checkpoint_info,
403 "observations": observations,
404 "head_to_head": {
405 "records": records,
406 "aggregates": head_to_head_aggregates(&records),
407 },
408 "aggregates": {
409 "agreement": aggregate_agreement(&observations),
410 "cost": aggregate_cost(&observations),
411 "stability": aggregate_stability(&observations),
412 },
413 }))
414}
415
416#[cfg(test)]
417mod tests {
418 use super::*;
419 use crate::bench::adapters::{EngineAdapter, MinimaxAdapter, RandomAdapter};
420 use crate::bench::agreement::{observation_key, run_agreement, ObservationKey};
421 use crate::bench::head_to_head::h2h_key;
422 use crate::bench::report;
423
424 fn scratch_dir(tag: &str) -> PathBuf {
425 let id = std::time::SystemTime::now()
426 .duration_since(std::time::UNIX_EPOCH)
427 .unwrap()
428 .as_nanos();
429 let dir =
430 std::env::temp_dir().join(format!("quantik-ckpt-{}-{tag}-{id}", std::process::id()));
431 std::fs::create_dir_all(&dir).unwrap();
432 dir
433 }
434
435 #[test]
436 fn jsonl_append_and_load_roundtrip() {
437 let dir = scratch_dir("jsonl-roundtrip");
438 let path = dir.join("rows.jsonl");
439 append_jsonl(&path, &json!({"b": 2, "a": 1})).unwrap();
440 append_jsonl(&path, &json!({"a": 3, "b": 4})).unwrap();
441
442 assert_eq!(
443 load_jsonl(&path).unwrap(),
444 vec![json!({"a": 1, "b": 2}), json!({"a": 3, "b": 4})]
445 );
446 let text = std::fs::read_to_string(&path).unwrap();
447 let lines: Vec<&str> = text.lines().collect();
448 assert_eq!(lines, vec![r#"{"a":1,"b":2}"#, r#"{"a":3,"b":4}"#]);
449 std::fs::remove_dir_all(&dir).ok();
450 }
451
452 #[test]
453 fn load_jsonl_missing_file_returns_empty() {
454 let dir = scratch_dir("missing");
455 assert_eq!(
456 load_jsonl(&dir.join("missing.jsonl")).unwrap(),
457 Vec::<Value>::new()
458 );
459 std::fs::remove_dir_all(&dir).ok();
460 }
461
462 #[test]
463 fn load_jsonl_bad_json_includes_file_and_line() {
464 let dir = scratch_dir("bad-json");
465 let path = dir.join("rows.jsonl");
466 std::fs::write(&path, "{\"ok\":1}\n{\"bad\":\n").unwrap();
467 let err = load_jsonl(&path).unwrap_err();
468 assert!(err.contains("rows.jsonl:2"), "{err}");
469 std::fs::remove_dir_all(&dir).ok();
470 }
471
472 #[test]
473 fn load_jsonl_skips_blank_lines() {
474 let dir = scratch_dir("blank-lines");
475 let path = dir.join("rows.jsonl");
476 std::fs::write(&path, "{\"a\":1}\n\n \n{\"a\":2}\n").unwrap();
477 assert_eq!(
478 load_jsonl(&path).unwrap(),
479 vec![json!({"a": 1}), json!({"a": 2})]
480 );
481 std::fs::remove_dir_all(&dir).ok();
482 }
483
484 #[test]
485 fn observation_and_h2h_keys_are_stable() {
486 let observation = json!({
487 "position_id": "p0008",
488 "engine": "mcts",
489 "config_label": "mcts(it=5000,d=16,c=1.414)",
490 "seed": 7,
491 });
492 let h2h = json!({
493 "position_id": "p0008",
494 "mover": "beam",
495 "responder": "mcts",
496 "seed": 11,
497 });
498 assert_eq!(
499 observation_key(&observation),
500 (
501 "p0008".to_string(),
502 "mcts".to_string(),
503 "mcts(it=5000,d=16,c=1.414)".to_string(),
504 Some(7)
505 )
506 );
507 assert_eq!(
508 h2h_key(&h2h),
509 (
510 "p0008".to_string(),
511 "beam".to_string(),
512 "mcts".to_string(),
513 11
514 )
515 );
516 let set: HashSet<ObservationKey> =
517 key_set(std::slice::from_ref(&observation), observation_key);
518 assert!(set.contains(&(
519 "p0008".to_string(),
520 "mcts".to_string(),
521 "mcts(it=5000,d=16,c=1.414)".to_string(),
522 Some(7)
523 )));
524 }
525
526 #[test]
527 fn manifest_atomic_write_counts_and_status_transitions() {
528 let dir = scratch_dir("manifest-lifecycle");
529 let root = dir.join("checkpoint");
530 let config = json!({"family": "native", "engine_seeds": [0, 1]});
531 let dataset_payload = json!({
532 "checksum": "abc123",
533 "generator": "benchmarks.dataset.generate/v1",
534 "seed": 20260711,
535 "schema_version": 1,
536 "positions": [{"phase": "late_mid"}],
537 });
538 let manifest = build_manifest(&config, &dataset_payload, "running", 0, 0);
539 write_manifest(&root, &manifest).unwrap();
540 assert!(root.join(MANIFEST).exists());
541 assert!(!root.join("manifest.json.tmp").exists());
543
544 let loaded = load_manifest(&root).unwrap();
545 assert_eq!(loaded["status"], json!("running"));
546 assert_eq!(loaded["dataset"]["checksum"], json!("abc123"));
547 assert_eq!(loaded["dataset"]["phases"]["late_mid"], json!(1));
548
549 let observation = json!({
550 "engine": "minimax", "config_label": "minimax(d=16)",
551 "position_id": "p0000", "move": "1:3:5", "seed": 0, "hit": true,
552 "wall_time_s": 0.01, "nodes": 42, "peak_memory_bytes": Value::Null,
553 });
554 let h2h_record = json!({
555 "position_id": "p0000", "phase": "late_mid",
556 "mover": "minimax", "responder": "random",
557 "winner": "minimax", "plies": 1, "seed": 0,
558 });
559 append_jsonl(&root.join(OBSERVATIONS), &observation).unwrap();
560 append_jsonl(&root.join(H2H_RECORDS), &h2h_record).unwrap();
561
562 let updated = update_manifest_counts(&root, 1, 1, Some("complete")).unwrap();
563 assert_eq!(updated["status"], json!("complete"));
564 assert_eq!(
565 updated["counts"],
566 json!({"observations": 1, "h2h_records": 1})
567 );
568 assert!(updated["updated_at"].is_string());
569
570 let bundle_dict = bundle_from_checkpoint(&root).unwrap();
571 assert_eq!(bundle_dict["schema_version"], json!(bundle::SCHEMA_VERSION));
572 assert_eq!(bundle_dict["config"], config);
573 assert_eq!(bundle_dict["observations"], json!([observation]));
574 assert_eq!(bundle_dict["head_to_head"]["records"], json!([h2h_record]));
575 assert_eq!(bundle_dict["aggregates"]["agreement"][0]["n"], json!(1));
576 assert_eq!(
577 bundle_dict["aggregates"]["cost"][0]["median_nodes"],
578 json!(42.0)
579 );
580 assert_eq!(
581 bundle_dict["head_to_head"]["aggregates"][0]["games"],
582 json!(1)
583 );
584 assert_eq!(bundle_dict["checkpoint"]["status"], json!("complete"));
585 assert_eq!(
586 bundle_dict["checkpoint"]["counts"],
587 json!({"observations": 1, "h2h_records": 1})
588 );
589 assert!(report::render_markdown(&bundle_dict).contains("checkpoint status: complete"));
590
591 std::fs::remove_dir_all(&dir).ok();
592 }
593
594 #[test]
595 fn running_checkpoint_bundle_and_report_show_partial_status() {
596 let dir = scratch_dir("partial-status");
597 let root = dir.join("checkpoint");
598 let dataset_payload = json!({
599 "schema_version": 1,
600 "generator": "benchmarks.dataset.generate/v1",
601 "seed": 20260711,
602 "checksum": "abc123",
603 "positions": [
604 {"id": "p0000", "qfen": ".ba./..CC/DcbD/cA.A", "phase": "late_mid",
605 "pieces": 8, "side_to_move": 1, "legal_moves": 10, "reference": Value::Null},
606 ],
607 });
608 let manifest = build_manifest(
609 &json!({"family": "native", "engine_seeds": [0]}),
610 &dataset_payload,
611 "running",
612 0,
613 0,
614 );
615 write_manifest(&root, &manifest).unwrap();
616
617 let bundle_dict = bundle_from_checkpoint(&root).unwrap();
618 assert_eq!(bundle_dict["checkpoint"]["status"], json!("running"));
619 assert_eq!(
620 bundle_dict["checkpoint"]["counts"],
621 json!({"observations": 0, "h2h_records": 0})
622 );
623 assert_eq!(bundle_dict["observations"], json!([]));
624 assert!(report::render_markdown(&bundle_dict).contains("checkpoint status: running"));
625
626 std::fs::remove_dir_all(&dir).ok();
627 }
628
629 #[test]
630 fn bundle_from_checkpoint_requires_dataset_metadata() {
631 let dir = scratch_dir("no-dataset");
632 let root = dir.join("checkpoint");
633 write_manifest(&root, &json!({"status": "running"})).unwrap();
634 let err = bundle_from_checkpoint(&root).unwrap_err();
635 assert!(err.contains("dataset metadata"), "{err}");
636 std::fs::remove_dir_all(&dir).ok();
637 }
638
639 #[test]
640 fn resume_validation_checksum_mismatch() {
641 let manifest = json!({"dataset": {"checksum": "a"}, "config": {}});
642 let err = validate_resume_manifest(&manifest, Some("b"), &json!({}), false).unwrap_err();
643 assert!(err.contains("checksum mismatch"), "{err}");
644 }
645
646 #[test]
647 fn resume_validation_config_diff_message_names_the_key() {
648 let manifest = json!({
649 "dataset": {"checksum": "a"},
650 "config": {"family": "fixed", "seeds": 10},
651 });
652 let err = validate_resume_manifest(
653 &manifest,
654 Some("a"),
655 &json!({"family": "fixed", "seeds": 30}),
656 false,
657 )
658 .unwrap_err();
659 assert!(err.contains("seeds: expected 30, found 10"), "{err}");
660 }
661
662 #[test]
663 fn resume_validation_excludes_volatile_fields() {
664 let manifest = json!({
665 "dataset": {"checksum": "a"},
666 "config": {
667 "family": "fixed", "checkpoint_dir": "old/dir",
668 "output": "old.json", "resume": false, "workers": 1,
669 },
670 });
671 validate_resume_manifest(
674 &manifest,
675 Some("a"),
676 &json!({
677 "family": "fixed", "checkpoint_dir": "new/dir",
678 "output": "new.json", "resume": true, "workers": 4,
679 }),
680 false,
681 )
682 .unwrap();
683 }
684
685 #[test]
686 fn resume_validation_skip_h2h_allowed_only_when_ignored() {
687 let manifest = json!({
688 "dataset": {"checksum": "a"},
689 "config": {"family": "fixed", "skip_h2h": false},
690 });
691 let strict_config = json!({"family": "fixed", "skip_h2h": true});
692 assert!(validate_resume_manifest(&manifest, Some("a"), &strict_config, false).is_err());
693 validate_resume_manifest(&manifest, Some("a"), &strict_config, true).unwrap();
694 }
695
696 #[test]
697 fn head_to_head_aggregates_group_unordered_pairs_first_seen_order() {
698 let dir = scratch_dir("h2h-pairs");
699 let root = dir.join("checkpoint");
700 let dataset_payload = json!({
701 "checksum": "c", "generator": "g/v1", "seed": 1, "schema_version": 1,
702 "positions": [{"phase": "opening"}],
703 });
704 write_manifest(
705 &root,
706 &build_manifest(&json!({}), &dataset_payload, "running", 0, 0),
707 )
708 .unwrap();
709 append_jsonl(
713 &root.join(H2H_RECORDS),
714 &json!({"position_id": "p0", "phase": "opening", "mover": "beam",
715 "responder": "mcts", "winner": "beam", "plies": 3, "seed": 0}),
716 )
717 .unwrap();
718 append_jsonl(
719 &root.join(H2H_RECORDS),
720 &json!({"position_id": "p0", "phase": "opening", "mover": "minimax",
721 "responder": "random", "winner": "minimax", "plies": 1, "seed": 0}),
722 )
723 .unwrap();
724 append_jsonl(
725 &root.join(H2H_RECORDS),
726 &json!({"position_id": "p0", "phase": "opening", "mover": "mcts",
727 "responder": "beam", "winner": "mcts", "plies": 2, "seed": 1}),
728 )
729 .unwrap();
730
731 let bundle_dict = bundle_from_checkpoint(&root).unwrap();
732 let aggregates = bundle_dict["head_to_head"]["aggregates"]
733 .as_array()
734 .unwrap();
735 assert_eq!(aggregates.len(), 2, "two distinct unordered pairs");
736 assert_eq!(aggregates[0]["engine_a"], json!("beam"));
737 assert_eq!(aggregates[0]["engine_b"], json!("mcts"));
738 assert_eq!(
739 aggregates[0]["games"],
740 json!(2),
741 "beam/mcts pair merges both orientations"
742 );
743 assert_eq!(aggregates[1]["engine_a"], json!("minimax"));
744 assert_eq!(aggregates[1]["engine_b"], json!("random"));
745
746 std::fs::remove_dir_all(&dir).ok();
747 }
748
749 fn cheap_adapters() -> Vec<Box<dyn EngineAdapter>> {
750 vec![
751 Box::new(RandomAdapter),
752 Box::new(MinimaxAdapter {
753 max_depth: 2,
754 time_limit_s: Some(0.05),
755 }),
756 ]
757 }
758
759 fn two_position_payload() -> (Value, Value) {
760 use crate::bitboard::Bitboard;
761 use crate::state::State;
762 let p0 = State::new(Bitboard::EMPTY).to_qfen();
763 let p1 = State::new(Bitboard::EMPTY.with_move(0, 0, 0)).to_qfen();
764 let full = json!({
765 "positions": [
766 {"id": "p0", "qfen": p0, "phase": "opening", "reference": Value::Null},
767 {"id": "p1", "qfen": p1, "phase": "opening", "reference": Value::Null},
768 ]
769 });
770 let truncated = json!({"positions": [full["positions"][0].clone()]});
771 (full, truncated)
772 }
773
774 #[test]
780 fn resume_after_interrupt_matches_uninterrupted_run() {
781 let (full, truncated) = two_position_payload();
782 let adapters = cheap_adapters();
783 let seeds = [10u64, 11u64];
784 let dir = scratch_dir("resume-interrupt");
785 let root = dir.join("checkpoint");
786 let obs_path = root.join(OBSERVATIONS);
787
788 let interrupted_rows =
790 run_agreement(&adapters, &truncated, &seeds, &HashSet::new(), 1, |row| {
791 append_jsonl(&obs_path, row)
792 })
793 .unwrap();
794 assert_eq!(
795 interrupted_rows.len(),
796 3,
797 "1 position: random x2 + minimax x1"
798 );
799
800 let loaded_rows = load_jsonl(&obs_path).unwrap();
802 let skip: HashSet<ObservationKey> = key_set(&loaded_rows, observation_key);
803 let fresh_rows = run_agreement(&adapters, &full, &seeds, &skip, 1, |row| {
804 append_jsonl(&obs_path, row)
805 })
806 .unwrap();
807 let mut resumed_rows = loaded_rows;
808 resumed_rows.extend(fresh_rows);
809
810 let scratch_rows =
812 run_agreement(&adapters, &full, &seeds, &HashSet::new(), 1, |_| Ok(())).unwrap();
813
814 let fingerprint = |rows: &[Value]| -> BTreeSet<(ObservationKey, String)> {
815 rows.iter()
816 .map(|row| {
817 (
818 observation_key(row),
819 row["move"].as_str().unwrap_or_default().to_string(),
820 )
821 })
822 .collect()
823 };
824 assert_eq!(resumed_rows.len(), scratch_rows.len());
825 assert_eq!(fingerprint(&resumed_rows), fingerprint(&scratch_rows));
826
827 let reloaded = load_jsonl(&obs_path).unwrap();
829 assert_eq!(fingerprint(&reloaded), fingerprint(&scratch_rows));
830
831 std::fs::remove_dir_all(&dir).ok();
832 }
833}