Skip to main content

jig/
checkpoint.rs

1//! Trial checkpointing.
2//!
3//! A baseline run can be 50+ trials and take 1+ hours. Without
4//! checkpointing, any process death (laptop sleep into shutdown,
5//! harness exit, OOM) costs the full run. Instead every completed
6//! `(trial, verdict)` pair is appended as one JSON line to a
7//! checkpoint file; on restart we load the file and skip any cell
8//! that already has its expected number of entries.
9//!
10//! Keying on `(section, task_id, model, trial_index)`: the
11//! trial_index distinguishes the nth run within a (task, model)
12//! cell. The checkpoint file is append-only; resume is idempotent.
13
14use crate::judge::JudgeResult;
15use crate::report::Section;
16use crate::runner::TrialResult;
17use anyhow::{Context, Result};
18use serde::{Deserialize, Serialize};
19use std::fs::OpenOptions;
20use std::io::{BufRead, BufReader, Write};
21use std::path::Path;
22
23/// One line in the checkpoint JSONL.
24#[derive(Debug, Clone, Serialize, Deserialize)]
25pub struct CheckpointEntry {
26    pub section: Section,
27    pub task_id: String,
28    pub model: String,
29    pub trial_index: u32,
30    pub trial: TrialResult,
31    pub verdict: JudgeResult,
32}
33
34/// Load every entry from a checkpoint file. Non-existent files return
35/// an empty vec (first run). Malformed lines are skipped with a
36/// warning to stderr.
37pub fn load(path: &Path) -> Result<Vec<CheckpointEntry>> {
38    if !path.exists() {
39        return Ok(Vec::new());
40    }
41    let file =
42        std::fs::File::open(path).with_context(|| format!("open checkpoint {}", path.display()))?;
43    let reader = BufReader::new(file);
44    let mut out = Vec::new();
45    for (i, line) in reader.lines().enumerate() {
46        let line = line.with_context(|| format!("read line {} of checkpoint", i + 1))?;
47        if line.trim().is_empty() {
48            continue;
49        }
50        match serde_json::from_str::<CheckpointEntry>(&line) {
51            Ok(e) => out.push(e),
52            Err(e) => eprintln!("[jig] skipping malformed checkpoint line {}: {e}", i + 1),
53        }
54    }
55    Ok(out)
56}
57
58/// Append one entry to the checkpoint file. Each write is a single
59/// JSON line followed by `\n` so partial writes are easy to detect on
60/// resume (the malformed-line branch in `load` drops them).
61pub fn append(path: &Path, entry: &CheckpointEntry) -> Result<()> {
62    let mut file = OpenOptions::new()
63        .create(true)
64        .append(true)
65        .open(path)
66        .with_context(|| format!("open checkpoint for append: {}", path.display()))?;
67    let line = serde_json::to_string(entry).context("serialize checkpoint entry")?;
68    writeln!(file, "{line}").context("write checkpoint line")?;
69    file.sync_data().context("fsync checkpoint")?;
70    Ok(())
71}
72
73/// Is the (section, task, model, trial_index) cell already done?
74pub fn has_entry<'a>(
75    entries: &'a [CheckpointEntry],
76    section: Section,
77    task_id: &str,
78    model: &str,
79    trial_index: u32,
80) -> Option<&'a CheckpointEntry> {
81    entries.iter().find(|e| {
82        e.section == section
83            && e.task_id == task_id
84            && e.model == model
85            && e.trial_index == trial_index
86    })
87}
88
89#[cfg(test)]
90mod tests {
91    use super::*;
92    use crate::judge::JudgeScore;
93
94    fn entry(section: Section, task: &str, model: &str, idx: u32) -> CheckpointEntry {
95        CheckpointEntry {
96            section,
97            task_id: task.into(),
98            model: model.into(),
99            trial_index: idx,
100            trial: TrialResult {
101                task_id: task.into(),
102                model: model.into(),
103                bash_commands: vec![],
104                assistant_texts: vec![],
105                num_turns: 1,
106                input_tokens: 10,
107                output_tokens: 5,
108                cost_usd: 0.001,
109                duration_ms: 100,
110                terminal_reason: "completed".into(),
111                is_error: false,
112                completed_under_turn_cap: true,
113                final_text: String::new(),
114                setup_failed: false,
115                timed_out: false,
116            },
117            verdict: JudgeResult {
118                task_id: task.into(),
119                model_under_test: model.into(),
120                judge_model: "haiku".into(),
121                first: JudgeScore {
122                    score: 1.0,
123                    first_command: Some("x".into()),
124                    first_command_existed: true,
125                    completed: true,
126                    invented_commands: vec![],
127                    fallback_to_sql: false,
128                    reasoning: "r".into(),
129                },
130                second: None,
131                irr_delta: None,
132            },
133        }
134    }
135
136    #[test]
137    fn roundtrip_append_load() {
138        let tmp = tempfile_path();
139        let e1 = entry(Section::Tuning, "t1", "m1", 0);
140        let e2 = entry(Section::Tuning, "t1", "m1", 1);
141        append(&tmp, &e1).unwrap();
142        append(&tmp, &e2).unwrap();
143        let loaded = load(&tmp).unwrap();
144        assert_eq!(loaded.len(), 2);
145        assert_eq!(loaded[0].trial_index, 0);
146        assert_eq!(loaded[1].trial_index, 1);
147        let _ = std::fs::remove_file(&tmp);
148    }
149
150    #[test]
151    fn load_missing_file_is_empty() {
152        let p = Path::new("/tmp/jig-definitely-does-not-exist-xyz-42.jsonl");
153        let loaded = load(p).unwrap();
154        assert!(loaded.is_empty());
155    }
156
157    #[test]
158    fn has_entry_matches_on_all_keys() {
159        let es = vec![
160            entry(Section::Tuning, "t1", "m1", 0),
161            entry(Section::Tuning, "t1", "m1", 1),
162            entry(Section::Tuning, "t1", "m2", 0),
163            entry(Section::Holdout, "t1", "m1", 0),
164        ];
165        assert!(has_entry(&es, Section::Tuning, "t1", "m1", 0).is_some());
166        assert!(has_entry(&es, Section::Tuning, "t1", "m1", 2).is_none());
167        assert!(has_entry(&es, Section::Tuning, "t2", "m1", 0).is_none());
168        assert!(has_entry(&es, Section::Holdout, "t1", "m1", 0).is_some());
169    }
170
171    #[test]
172    fn load_skips_malformed_lines() {
173        let tmp = tempfile_path();
174        std::fs::write(
175            &tmp,
176            format!(
177                "{}\nnot json\n\n{}\n",
178                serde_json::to_string(&entry(Section::Tuning, "t1", "m1", 0)).unwrap(),
179                serde_json::to_string(&entry(Section::Tuning, "t1", "m1", 1)).unwrap(),
180            ),
181        )
182        .unwrap();
183        let loaded = load(&tmp).unwrap();
184        assert_eq!(loaded.len(), 2);
185        let _ = std::fs::remove_file(&tmp);
186    }
187
188    fn tempfile_path() -> std::path::PathBuf {
189        let nanos = std::time::SystemTime::now()
190            .duration_since(std::time::UNIX_EPOCH)
191            .unwrap()
192            .as_nanos();
193        std::env::temp_dir().join(format!("jig-ckpt-test-{nanos}.jsonl"))
194    }
195}