1use 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#[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
34pub 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
58pub 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
73pub 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}