1use std::collections::{BTreeSet, HashMap};
8use std::fs;
9use std::path::{Path, PathBuf};
10
11use serde::{Deserialize, Serialize};
12use similar::{Algorithm, DiffTag, capture_diff_slices};
13use walkdir::{DirEntry, WalkDir};
14
15use crate::pipeline::error::PipelineError;
16use crate::pipeline::io::write_json;
17
18const BASELINE_DIR: &str = "diff-scope-baseline";
19const BASELINE_MANIFEST: &str = "manifest.json";
20const BASELINE_FILES: &str = "files";
21const RESULT_FILE: &str = "diff-scope.json";
22
23#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
24pub struct DiffScopeMetrics {
25 pub files_touched: u64,
26 pub lines_added: u64,
27 pub lines_removed: u64,
28 pub hunks: u64,
29}
30
31impl DiffScopeMetrics {
32 pub fn lines_changed(self) -> u64 {
33 self.lines_added.saturating_add(self.lines_removed)
34 }
35}
36
37#[derive(Debug, Serialize, Deserialize)]
38struct BaselineManifest {
39 preexisting_files: Vec<String>,
40}
41
42#[derive(Debug, Deserialize)]
43struct DispatchFile {
44 #[serde(default)]
45 tasks: Vec<DispatchTask>,
46}
47
48#[derive(Debug, Deserialize)]
49struct DispatchTask {
50 eval_id: String,
51 condition: String,
52 #[serde(default)]
53 run_index: Option<u32>,
54 eval_root: Option<String>,
55 run_record_path: String,
56}
57
58#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
59pub struct DiffScopeSummary {
60 pub measured: usize,
61 pub reused: usize,
62 pub missing_baseline: usize,
63 pub shared_environment: usize,
64}
65
66#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
67struct FileDiff {
68 lines_added: u64,
69 lines_removed: u64,
70 hunks: u64,
71}
72
73fn diff_bytes(old: &[u8], new: &[u8]) -> FileDiff {
74 let old_lines: Vec<&[u8]> = old.split_inclusive(|byte| *byte == b'\n').collect();
75 let new_lines: Vec<&[u8]> = new.split_inclusive(|byte| *byte == b'\n').collect();
76 let mut result = FileDiff::default();
77 let mut in_hunk = false;
78
79 for operation in capture_diff_slices(Algorithm::Myers, &old_lines, &new_lines) {
80 let (tag, old_range, new_range) = operation.as_tag_tuple();
81 match tag {
82 DiffTag::Equal => in_hunk = false,
83 DiffTag::Delete => {
84 if !in_hunk {
85 result.hunks += 1;
86 in_hunk = true;
87 }
88 result.lines_removed += old_range.len() as u64;
89 }
90 DiffTag::Insert => {
91 if !in_hunk {
92 result.hunks += 1;
93 in_hunk = true;
94 }
95 result.lines_added += new_range.len() as u64;
96 }
97 DiffTag::Replace => {
98 if !in_hunk {
99 result.hunks += 1;
100 in_hunk = true;
101 }
102 result.lines_removed += old_range.len() as u64;
103 result.lines_added += new_range.len() as u64;
104 }
105 }
106 }
107 result
108}
109
110pub fn capture_iteration_baselines(iteration_dir: &Path) -> Result<(), PipelineError> {
111 let dispatch_path = iteration_dir.join("dispatch.json");
112 let dispatch: DispatchFile = serde_json::from_str(&fs::read_to_string(&dispatch_path)?)?;
113 for task in dispatch.tasks {
114 let eval_root = task.eval_root.ok_or_else(|| {
115 PipelineError::Message(format!(
116 "dispatch task in {} has no eval_root for diff-scope capture",
117 dispatch_path.display()
118 ))
119 })?;
120 let run_dir = Path::new(&task.run_record_path).parent().ok_or_else(|| {
121 PipelineError::Message(format!(
122 "diff-scope task has no run directory in run_record_path: {}",
123 task.run_record_path
124 ))
125 })?;
126 fs::create_dir_all(run_dir)?;
127 capture_task_baseline(Path::new(&eval_root), run_dir)?;
128 }
129 Ok(())
130}
131
132pub fn measure_iteration_diff_scopes(
133 iteration_dir: &Path,
134) -> Result<DiffScopeSummary, PipelineError> {
135 let dispatch_path = iteration_dir.join("dispatch.json");
136 if !dispatch_path.exists() {
140 return Ok(DiffScopeSummary::default());
141 }
142 let dispatch: DispatchFile = serde_json::from_str(&fs::read_to_string(&dispatch_path)?)?;
143 let root_counts: HashMap<String, usize> = dispatch
144 .tasks
145 .iter()
146 .filter_map(|task| task.eval_root.as_deref())
147 .fold(HashMap::new(), |mut counts, root| {
148 *counts.entry(root.to_string()).or_default() += 1;
149 counts
150 });
151 let mut summary = DiffScopeSummary::default();
152
153 for task in dispatch.tasks {
154 let run_record_path = Path::new(&task.run_record_path);
155 let run_dir = run_record_path.parent().ok_or_else(|| {
156 PipelineError::Message(format!(
157 "diff-scope task has no run directory in run_record_path: {}",
158 task.run_record_path
159 ))
160 })?;
161 if !run_record_path.exists() {
162 continue;
163 }
164 let result_path = run_dir.join(RESULT_FILE);
165 if result_path.exists() {
166 let value = serde_json::from_str(&fs::read_to_string(&result_path)?)?;
167 crate::validation::validate_against_schema::<DiffScopeMetrics>(
168 crate::validation::SchemaName::DiffScope,
169 &value,
170 &result_path.to_string_lossy(),
171 )?;
172 summary.reused += 1;
173 continue;
174 }
175 let run_label = task
176 .run_index
177 .map(|run| format!("/run-{run}"))
178 .unwrap_or_default();
179 let Some(eval_root) = task.eval_root.as_deref() else {
180 eprintln!(
181 "warn: {}/{}{run_label} has no eval_root — diff-scope unavailable; rebuild the iteration to capture metrics",
182 task.eval_id, task.condition
183 );
184 summary.missing_baseline += 1;
185 continue;
186 };
187 if root_counts.get(eval_root).copied().unwrap_or_default() != 1 {
188 eprintln!(
189 "warn: {}/{}{run_label} shares eval_root with another task — diff-scope unavailable; rebuild the iteration for task-scoped environments",
190 task.eval_id, task.condition
191 );
192 summary.shared_environment += 1;
193 continue;
194 }
195 if !run_dir.join(BASELINE_DIR).join(BASELINE_MANIFEST).exists() {
196 eprintln!(
197 "warn: {}/{}{run_label} has no pre-dispatch baseline — diff-scope unavailable; rebuild the iteration to capture metrics",
198 task.eval_id, task.condition
199 );
200 summary.missing_baseline += 1;
201 continue;
202 }
203 if run_dir.join("command-checks").exists() {
204 return Err(PipelineError::Message(format!(
205 "cannot capture diff scope for {}/{}{run_label} after command checks have run; rebuild the iteration",
206 task.eval_id, task.condition
207 )));
208 }
209
210 let metrics = measure_task_diff(Path::new(eval_root), run_dir)?;
211 crate::validation::validate_against_schema::<DiffScopeMetrics>(
212 crate::validation::SchemaName::DiffScope,
213 &serde_json::to_value(metrics)?,
214 &result_path.to_string_lossy(),
215 )?;
216 write_json(&result_path, &metrics)?;
217 summary.measured += 1;
218 }
219 Ok(summary)
220}
221
222fn capture_task_baseline(eval_root: &Path, run_dir: &Path) -> Result<(), PipelineError> {
223 let baseline_dir = run_dir.join(BASELINE_DIR);
224 if baseline_dir.exists() {
225 fs::remove_dir_all(&baseline_dir)?;
226 }
227 let file_snapshot = baseline_dir.join(BASELINE_FILES);
228 fs::create_dir_all(&file_snapshot)?;
229
230 let framework_outputs = eval_root.join(".eval-magic-outputs");
231 let mut preexisting_paths = walk_files(eval_root, Some(&framework_outputs))?;
232 preexisting_paths.sort();
233 let preexisting_files = preexisting_paths
234 .iter()
235 .map(|path| relative_key(eval_root, path))
236 .collect::<Result<Vec<_>, _>>()?;
237 write_json(
238 &baseline_dir.join(BASELINE_MANIFEST),
239 &BaselineManifest { preexisting_files },
240 )?;
241
242 for source in preexisting_paths {
243 let relative = source.strip_prefix(eval_root).map_err(|_| {
244 PipelineError::Message(format!(
245 "diff-scope baseline path {} is outside {}",
246 source.display(),
247 eval_root.display()
248 ))
249 })?;
250 copy_entry(&source, &file_snapshot.join(relative))?;
251 }
252 Ok(())
253}
254
255fn measure_task_diff(eval_root: &Path, run_dir: &Path) -> Result<DiffScopeMetrics, PipelineError> {
256 let baseline_dir = run_dir.join(BASELINE_DIR);
257 let manifest: BaselineManifest =
258 serde_json::from_str(&fs::read_to_string(baseline_dir.join(BASELINE_MANIFEST))?)?;
259 let file_snapshot = baseline_dir.join(BASELINE_FILES);
260 let mut candidates = BTreeSet::new();
261
262 for relative in manifest.preexisting_files {
263 if fs::symlink_metadata(file_snapshot.join(&relative)).is_err() {
264 return Err(PipelineError::Message(format!(
265 "diff-scope baseline is incomplete: missing snapshot for {relative}"
266 )));
267 }
268 candidates.insert(relative);
269 }
270 let framework_outputs = eval_root.join(".eval-magic-outputs");
271 for path in walk_files(eval_root, Some(&framework_outputs))? {
272 candidates.insert(relative_key(eval_root, &path)?);
273 }
274
275 let mut metrics = DiffScopeMetrics::default();
276 for relative in candidates {
277 let before = file_snapshot.join(&relative);
278 let after = eval_root.join(&relative);
279 let old = file_content(&before)?;
280 let new = file_content(&after)?;
281 if old == new {
282 continue;
283 }
284 metrics.files_touched += 1;
285 let diff = match (old, new) {
286 (FileContent::Regular(old), FileContent::Regular(new)) => Some(diff_bytes(&old, &new)),
287 (FileContent::Missing, FileContent::Regular(new)) => Some(diff_bytes(&[], &new)),
288 (FileContent::Regular(old), FileContent::Missing) => Some(diff_bytes(&old, &[])),
289 _ => None,
290 };
291 if let Some(diff) = diff {
292 metrics.lines_added += diff.lines_added;
293 metrics.lines_removed += diff.lines_removed;
294 metrics.hunks += diff.hunks;
295 }
296 }
297 Ok(metrics)
298}
299
300#[derive(Debug, PartialEq, Eq)]
301enum FileContent {
302 Missing,
303 Regular(Vec<u8>),
304 Symlink(PathBuf),
305}
306
307fn file_content(path: &Path) -> Result<FileContent, PipelineError> {
308 let metadata = match fs::symlink_metadata(path) {
309 Ok(metadata) => metadata,
310 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
311 return Ok(FileContent::Missing);
312 }
313 Err(error) => return Err(error.into()),
314 };
315 if metadata.file_type().is_symlink() {
316 return Ok(FileContent::Symlink(fs::read_link(path)?));
317 }
318 if metadata.is_file() {
319 return Ok(FileContent::Regular(fs::read(path)?));
320 }
321 Ok(FileContent::Missing)
322}
323
324fn walk_files(root: &Path, excluded_root: Option<&Path>) -> Result<Vec<PathBuf>, PipelineError> {
325 if !root.exists() {
326 return Ok(Vec::new());
327 }
328 WalkDir::new(root)
329 .follow_links(false)
330 .into_iter()
331 .filter_entry(|entry| !is_excluded(entry, excluded_root))
332 .filter_map(|entry| match entry {
333 Ok(entry) if entry.file_type().is_file() || entry.file_type().is_symlink() => {
334 Some(Ok(entry.into_path()))
335 }
336 Ok(_) => None,
337 Err(error) => Some(Err(PipelineError::Message(format!(
338 "could not walk diff-scope path under {}: {error}",
339 root.display()
340 )))),
341 })
342 .collect()
343}
344
345fn is_excluded(entry: &DirEntry, excluded_root: Option<&Path>) -> bool {
346 excluded_root.is_some_and(|excluded| entry.path().starts_with(excluded))
347}
348
349fn relative_key(root: &Path, path: &Path) -> Result<String, PipelineError> {
350 let relative = path.strip_prefix(root).map_err(|_| {
351 PipelineError::Message(format!(
352 "diff-scope path {} is outside {}",
353 path.display(),
354 root.display()
355 ))
356 })?;
357 Ok(relative
358 .components()
359 .map(|component| component.as_os_str().to_string_lossy())
360 .collect::<Vec<_>>()
361 .join("/"))
362}
363
364fn copy_entry(source: &Path, destination: &Path) -> Result<(), PipelineError> {
365 let metadata = fs::symlink_metadata(source)?;
366 if metadata.file_type().is_symlink() {
367 if let Some(parent) = destination.parent() {
368 fs::create_dir_all(parent)?;
369 }
370 let target = fs::read_link(source)?;
371 #[cfg(unix)]
372 std::os::unix::fs::symlink(target, destination)?;
373 #[cfg(windows)]
374 if source.metadata().is_ok_and(|metadata| metadata.is_dir()) {
375 std::os::windows::fs::symlink_dir(target, destination)?;
376 } else {
377 std::os::windows::fs::symlink_file(target, destination)?;
378 }
379 } else if metadata.is_dir() {
380 fs::create_dir_all(destination)?;
381 for entry in fs::read_dir(source)? {
382 let entry = entry?;
383 copy_entry(&entry.path(), &destination.join(entry.file_name()))?;
384 }
385 } else {
386 if let Some(parent) = destination.parent() {
387 fs::create_dir_all(parent)?;
388 }
389 fs::copy(source, destination)?;
390 }
391 Ok(())
392}
393
394#[cfg(test)]
395mod tests {
396 use super::*;
397 use std::fs;
398
399 #[test]
400 fn byte_line_diff_counts_changes_and_zero_context_hunks() {
401 let diff = diff_bytes(b"old\nsame\nbefore\n", b"new\nsame\nafter\n");
402 assert_eq!(diff.lines_added, 2);
403 assert_eq!(diff.lines_removed, 2);
404 assert_eq!(diff.hunks, 2);
405 }
406
407 #[test]
408 fn byte_line_diff_handles_empty_trailing_newline_and_non_utf8_inputs() {
409 assert_eq!(diff_bytes(b"", b""), FileDiff::default());
410
411 let trailing = diff_bytes(b"value", b"value\n");
412 assert_eq!(trailing.lines_added, 1);
413 assert_eq!(trailing.lines_removed, 1);
414 assert_eq!(trailing.hunks, 1);
415
416 let binary = diff_bytes(&[0xff, b'\n'], &[0xfe, b'\n']);
417 assert_eq!(binary.lines_added, 1);
418 assert_eq!(binary.lines_removed, 1);
419 assert_eq!(binary.hunks, 1);
420 }
421
422 #[test]
423 fn lines_changed_saturates_untrusted_artifact_totals() {
424 let metrics = DiffScopeMetrics {
425 lines_added: u64::MAX,
426 lines_removed: 1,
427 ..DiffScopeMetrics::default()
428 };
429 assert_eq!(metrics.lines_changed(), u64::MAX);
430 }
431
432 #[test]
433 fn baseline_measurement_counts_all_task_changes_except_framework_outputs() {
434 let temp = tempfile::TempDir::new().unwrap();
435 let eval_root = temp.path().join("env");
436 let run_dir = temp.path().join("run");
437 let outputs_dir = eval_root.join(".eval-magic-outputs/eval-e1/with_skill");
438 fs::create_dir_all(eval_root.join("src")).unwrap();
439 fs::create_dir_all(&outputs_dir).unwrap();
440 fs::write(eval_root.join("src/changed.txt"), "old\nsame\n").unwrap();
441 fs::write(eval_root.join("src/deleted.txt"), "gone\n").unwrap();
442 fs::write(eval_root.join("framework.txt"), "before\n").unwrap();
443
444 capture_task_baseline(&eval_root, &run_dir).unwrap();
445
446 fs::write(eval_root.join("src/changed.txt"), "new\nsame\n").unwrap();
447 fs::remove_file(eval_root.join("src/deleted.txt")).unwrap();
448 fs::write(eval_root.join("framework.txt"), "after\n").unwrap();
449 fs::write(eval_root.join("notes.txt"), "one\ntwo\n").unwrap();
450 fs::write(outputs_dir.join("final-message.md"), "ignored\n").unwrap();
451 fs::write(
452 eval_root.join(".eval-magic-outputs/agent-created.txt"),
453 "also ignored\n",
454 )
455 .unwrap();
456
457 let metrics = measure_task_diff(&eval_root, &run_dir).unwrap();
458 assert_eq!(
459 metrics,
460 DiffScopeMetrics {
461 files_touched: 4,
462 lines_added: 4,
463 lines_removed: 3,
464 hunks: 4,
465 }
466 );
467 }
468}