Skip to main content

quantik_core/bench/
checkpoint.rs

1//! Directory-based resumable checkpoint storage for long benchmark runs.
2//!
3//! Port of `benchmarks/checkpoint.py`. A checkpoint is a directory:
4//!
5//! ```text
6//! <checkpoint-dir>/
7//!   manifest.json      pretty (indent 2), sorted-key JSON + trailing "\n",
8//!                       written atomically (tmp file + rename)
9//!   observations.jsonl one compact, sorted-key JSON row per completed
10//!                       agreement observation, appended + flushed per line
11//!   h2h.jsonl           one compact, sorted-key JSON row per completed
12//!                       head-to-head game, appended + flushed per line
13//! ```
14//!
15//! Both JSONL files use [`crate::bench::canonical::canonical_json`], the
16//! same byte-exact encoder used for dataset/bundle artifacts, so a
17//! checkpoint directory written by this crate loads and rehydrates
18//! (`bundle_from_checkpoint`) correctly in Python's `benchmarks.checkpoint`
19//! module and vice versa. `manifest.json` uses
20//! [`crate::bench::canonical::canonical_json_pretty`], matching Python's
21//! `json.dumps(manifest, indent=2, sort_keys=True) + "\n"`.
22//!
23//! **Resume validation is intra-language only.** `validate_resume_manifest`
24//! compares the run's config dict against the manifest's stored config
25//! dict, but the two languages' CLI argument dicts have different key sets
26//! and shapes (e.g. `track_memory`/`skip_agreement` exist only in the
27//! Python CLI) — a checkpoint directory started by one language's `run` is
28//! not expected to `--resume` cleanly in the other. Loading and reporting
29//! (`bundle_from_checkpoint`, `report --input <dir>`) work identically in
30//! both languages regardless of which one wrote the directory.
31//!
32//! Unlike the single-file `.ckpt` format this module replaces (PR #10),
33//! an invalid JSONL line is a hard error (file path + 1-based line
34//! number) — there is no truncated-tail tolerance here, because
35//! `manifest.json`'s atomic tmp+rename write is the integrity anchor: a
36//! crash mid-write of an observation/h2h line can only ever leave a
37//! trailing partial line (never a torn manifest), and callers that always
38//! flush after `append_jsonl` should not see one in practice, but Python
39//! chose to raise rather than silently drop, so this port matches that.
40
41use 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
55/// Config fields dropped from the resume signature: they legitimately
56/// differ between the interrupted run and the resuming one (or, for
57/// `checkpoint_dir`/`workers`, don't describe the *engine* configuration
58/// at all). Matches Python's `checkpoint._RESUME_CONFIG_EXCLUDES` exactly
59/// (five keys — the plan text lists four, but the Python source, which is
60/// authoritative, has five).
61const RESUME_CONFIG_EXCLUDES: [&str; 5] = [
62    "checkpoint_dir",
63    "output",
64    "resume",
65    "skip_agreement",
66    "workers",
67];
68
69/// The paths that make up one checkpoint directory.
70#[derive(Debug, Clone)]
71pub struct CheckpointPaths {
72    pub root: PathBuf,
73    pub manifest: PathBuf,
74    pub observations: PathBuf,
75    pub h2h: PathBuf,
76}
77
78/// Resolve the manifest/observations/h2h file paths under `root`.
79pub 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
88/// Resolve a manifest target from either a checkpoint root directory or a
89/// direct path to (or already ending in) `manifest.json` — mirrors
90/// Python's `_manifest_path` so `write_manifest`/`load_manifest` accept
91/// either form.
92fn 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
104/// Append one JSON object as one compact, sorted-key JSONL row, flushing to
105/// the OS immediately.
106pub 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
121/// Load a JSONL file; a missing file behaves like an empty stream. Blank
122/// lines are skipped. An invalid line is a hard error naming the file and
123/// its 1-based line number (no truncated-tail tolerance — see the module
124/// doc).
125pub 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
143/// Build the set of stable resume keys already present in checkpoint rows.
144pub 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
151/// Atomically write the checkpoint manifest (tmp file + rename), pretty
152/// (indent 2), sorted-key JSON + trailing newline, matching Python's
153/// `json.dumps(manifest, indent=2, sort_keys=True) + "\n"`.
154pub 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
172/// Load the checkpoint manifest, or an empty object if it doesn't exist
173/// yet (mirrors Python's `load_manifest` returning `{}`).
174pub 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
189/// Update checkpoint progress counters in place (`counts`, optionally
190/// `status`, always `updated_at`) and return the resulting manifest.
191pub 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
208/// Drop volatile fields that must not participate in resume validation.
209pub 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
227/// Build a fresh checkpoint manifest (mirrors Python CLI's
228/// `_checkpoint_manifest` in `examples/cross_engine_benchmark.py`, not the
229/// library-level `_build_manifest` in `benchmarks/checkpoint.py` — the CLI
230/// manifest intentionally omits `schema_version`; that key only lives on
231/// the bundle produced by `bundle_from_checkpoint`, from the
232/// `bundle::SCHEMA_VERSION` constant).
233pub 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
267/// Raise a clear error when a resume checkpoint does not match this run:
268/// dataset checksum must be equal, and the normalized config signature
269/// must be equal (per-key diff in the error message otherwise).
270/// `allow_skip_h2h_mismatch` additionally drops `skip_h2h` from the
271/// signature — the CLI only sets this when the checkpoint's existing h2h
272/// records already equal the expected count, so a differing `--skip-h2h`
273/// this run genuinely doesn't matter.
274pub 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
326/// Group head-to-head records by unordered engine pair, in first-seen
327/// order, with each aggregate's `(engine_a, engine_b)` naming taken from
328/// that pair's first record's `(mover, responder)` — mirrors Python's
329/// `_head_to_head_aggregates`.
330fn 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
361/// Rehydrate a checkpoint directory into the standard benchmark bundle,
362/// including a partial state — an in-progress or preflight-failed
363/// checkpoint rehydrates fine, with whatever rows/records/aggregates are
364/// available so far. The bundle gains a `"checkpoint": {status, counts}`
365/// block (and `h2h_pairs` when the manifest carries one).
366pub 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        // manifest.json.tmp must never be left behind after a successful write.
542        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        // Only checkpoint_dir/output/resume/workers differ: excluded fields
672        // must not trip the mismatch.
673        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        // beam-vs-mcts first, then minimax-vs-random; a later beam/mcts
710        // record must still land in the FIRST group (name order from its
711        // first record), not create a duplicate.
712        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    /// Adapted from the PR #10 resume test: interrupting a run after only
775    /// the first position, then resuming over the full position list, must
776    /// reproduce the same row multiset as an uninterrupted run (order need
777    /// not match across the interrupted/resumed split, since resumed rows
778    /// are the concatenation of loaded + freshly streamed).
779    #[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        // "Interrupted" run: only the first position is processed.
789        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        // Resume: reload, seed the skip set, run over the full list.
801        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        // From-scratch, uninterrupted run over the same full payload/seeds.
811        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        // Reloaded from disk, it must still match.
828        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}