1use std::fs;
11use std::path::{Path, PathBuf};
12
13use serde::Serialize;
14use serde_json::json;
15
16use crate::core::{ConditionsRecord, Harness, run_git};
17use crate::pipeline::run_slots;
18use crate::workspace::teardown::PROMOTED_MARKER;
19use crate::workspace::{WorkspaceError, now_iso8601, write_json};
20
21pub struct PromoteOptions<'a> {
23 pub workspace_root: &'a Path,
24 pub skill_name: &'a str,
25 pub skill_subdir: &'a Path,
26 pub iteration: u32,
27 pub harness: Harness,
28 pub label: Option<&'a str>,
29 pub agent_model: Option<&'a str>,
32 pub judge_model: Option<&'a str>,
33 pub git_cwd: &'a Path,
35}
36
37#[derive(Debug)]
39pub struct PromoteResult {
40 pub baseline_dir: PathBuf,
41 pub gradings_copied: usize,
42 pub missing_gradings: usize,
46 pub notes: NotesStatus,
47}
48
49#[derive(Debug, Clone, Copy, PartialEq, Eq)]
55pub enum NotesStatus {
56 RetainedFromPrior,
58 StubWritten,
60}
61
62pub fn promote_baseline(opts: &PromoteOptions) -> Result<PromoteResult, WorkspaceError> {
66 let iteration_dir = opts
67 .workspace_root
68 .join(opts.skill_name)
69 .join(format!("iteration-{}", opts.iteration));
70 if !iteration_dir.exists() {
71 return Err(WorkspaceError::Message(format!(
72 "not found: {} (build/grade iteration-{} first)",
73 iteration_dir.display(),
74 opts.iteration
75 )));
76 }
77
78 let benchmark_src = iteration_dir.join("benchmark.json");
79 if !benchmark_src.exists() {
80 return Err(WorkspaceError::Message(format!(
81 "missing benchmark.json in iteration-{} — run 'eval-magic aggregate' before promoting",
82 opts.iteration
83 )));
84 }
85
86 let conditions_src = iteration_dir.join("conditions.json");
87 let conditions: Option<ConditionsRecord> = if conditions_src.exists() {
88 Some(serde_json::from_str(&fs::read_to_string(&conditions_src)?)?)
89 } else {
90 None
91 };
92
93 let baseline_dir = opts.skill_subdir.join("evals").join("baseline");
94 let grading_dir = baseline_dir.join("grading");
95 fs::create_dir_all(&grading_dir)?;
96
97 fs::copy(&benchmark_src, baseline_dir.join("benchmark.json"))?;
98
99 let (gradings_copied, missing_gradings) = copy_gradings(&iteration_dir, &grading_dir)?;
100
101 let head = git_head(opts.git_cwd);
102 fs::write(
103 baseline_dir.join("BASELINE.md"),
104 provenance(opts, conditions.as_ref(), &head),
105 )?;
106
107 let notes = write_or_retain_notes(&baseline_dir, opts)?;
108
109 write_json(
112 &iteration_dir.join(PROMOTED_MARKER),
113 &json!({
114 "promoted_at": now_iso8601(),
115 "baseline_dir": baseline_dir.to_string_lossy(),
116 "commit": head,
117 }),
118 )?;
119
120 Ok(PromoteResult {
121 baseline_dir,
122 gradings_copied,
123 missing_gradings,
124 notes,
125 })
126}
127
128fn write_or_retain_notes(
131 baseline_dir: &Path,
132 opts: &PromoteOptions,
133) -> Result<NotesStatus, WorkspaceError> {
134 let notes_path = baseline_dir.join("NOTES.md");
135 if notes_path.exists() {
136 return Ok(NotesStatus::RetainedFromPrior);
137 }
138 fs::write(
139 ¬es_path,
140 format!(
141 "# Notes — {}\n\nPromoted from iteration-{} at {}.\n\nRecord operator observations \
142 for this baseline here (judge quirks, flaky evals, context for the deltas).\n",
143 opts.skill_name,
144 opts.iteration,
145 now_iso8601(),
146 ),
147 )?;
148 Ok(NotesStatus::StubWritten)
149}
150
151fn copy_gradings(
158 iteration_dir: &Path,
159 grading_dir: &Path,
160) -> Result<(usize, usize), WorkspaceError> {
161 let mut copied = 0;
162 let mut missing = 0;
163 for eval_name in sorted_entry_names(iteration_dir) {
164 let Some(eval_id) = eval_name.strip_prefix("eval-") else {
165 continue;
166 };
167 let eval_dir = iteration_dir.join(&eval_name);
168 if !eval_dir.is_dir() {
169 continue;
170 }
171 for cond_name in sorted_entry_names(&eval_dir) {
172 let cond_dir = eval_dir.join(&cond_name);
173 if !cond_dir.is_dir() {
174 continue;
175 }
176 for slot in run_slots(&cond_dir) {
180 let grading_src = slot.dir.join("grading.json");
181 if !grading_src.exists() {
182 missing += 1;
183 continue;
184 }
185 let dest = match slot.run_index {
186 Some(k) => format!("{eval_id}__{cond_name}__r{k}.json"),
187 None => format!("{eval_id}__{cond_name}.json"),
188 };
189 fs::copy(&grading_src, grading_dir.join(dest))?;
190 copied += 1;
191 }
192 }
193 }
194 Ok((copied, missing))
195}
196
197fn sorted_entry_names(dir: &Path) -> Vec<String> {
199 let mut names: Vec<String> = match fs::read_dir(dir) {
200 Ok(rd) => rd
201 .filter_map(Result::ok)
202 .map(|e| e.file_name().to_string_lossy().into_owned())
203 .collect(),
204 Err(_) => Vec::new(),
205 };
206 names.sort();
207 names
208}
209
210fn git_head(cwd: &Path) -> String {
213 let res = run_git(&["rev-parse", "--short", "HEAD"], cwd);
214 if res.status == Some(0) {
215 String::from_utf8_lossy(&res.stdout).trim().to_string()
216 } else {
217 "unknown".to_string()
218 }
219}
220
221fn label(value: &impl Serialize) -> String {
224 serde_json::to_value(value)
225 .ok()
226 .and_then(|v| v.as_str().map(str::to_owned))
227 .unwrap_or_else(|| "unknown".to_string())
228}
229
230fn provenance(opts: &PromoteOptions, conditions: Option<&ConditionsRecord>, head: &str) -> String {
233 let mode = conditions
234 .map(|c| label(&c.mode))
235 .unwrap_or_else(|| "unknown".to_string());
236 let timestamp = conditions
237 .map(|c| c.timestamp.clone())
238 .unwrap_or_else(|| "unknown".to_string());
239 let condition_names: Vec<&str> = conditions
240 .map(|c| c.conditions.iter().map(|e| e.name.as_str()).collect())
241 .unwrap_or_default();
242 let conditions_cell = if condition_names.is_empty() {
243 "unknown".to_string()
244 } else {
245 condition_names.join(", ")
246 };
247 let harness = label(&opts.harness);
248
249 let agent_model = opts
252 .agent_model
253 .or_else(|| conditions.and_then(|c| c.agent_model.as_deref()))
254 .unwrap_or("unspecified");
255 let judge_model = opts
256 .judge_model
257 .or_else(|| conditions.and_then(|c| c.judge_model.as_deref()))
258 .unwrap_or("unspecified");
259 let run_label = opts
260 .label
261 .or_else(|| conditions.and_then(|c| c.label.as_deref()))
262 .unwrap_or("(none)");
263
264 let lines = [
265 format!("# Baseline — {}", opts.skill_name),
266 String::new(),
267 "Committed reference output from a canonical eval run. Regenerate with".to_string(),
268 format!(
269 "`eval-magic promote-baseline --iteration {}` after aggregating. The ephemeral workspace (run records, timing,",
270 opts.iteration
271 ),
272 "dispatch files, produced outputs) stays gitignored under `.eval-magic/`".to_string(),
273 "and is reclaimable by `eval-magic teardown` once promoted (this commit's marker)."
274 .to_string(),
275 String::new(),
276 "| Field | Value |".to_string(),
277 "|-------|-------|".to_string(),
278 format!("| Mode | {mode} |"),
279 format!("| Iteration | iteration-{} |", opts.iteration),
280 format!("| Harness | {harness} |"),
281 format!("| Agent model | {agent_model} |"),
282 format!("| Judge model | {judge_model} |"),
283 format!("| Conditions | {conditions_cell} |"),
284 format!("| Run timestamp | {timestamp} |"),
285 format!("| Label | {run_label} |"),
286 format!("| Promoted from commit | {head} |"),
287 String::new(),
288 "Files:".to_string(),
289 "- `benchmark.json` — aggregate pass-rate / duration / token deltas.".to_string(),
290 "- `grading/<eval-id>__<condition>.json` (multi-run cells add an `__r<k>` suffix per run) — assertion results and judge rationales."
291 .to_string(),
292 "- `NOTES.md` — operator-authored observations for this baseline (never overwritten by promote)."
293 .to_string(),
294 String::new(),
295 ];
296 format!("{}\n", lines.join("\n"))
297}
298
299#[cfg(test)]
300mod tests {
301 use super::*;
302 use tempfile::TempDir;
303
304 fn write(path: &Path, body: &str) {
306 fs::create_dir_all(path.parent().unwrap()).unwrap();
307 fs::write(path, body).unwrap();
308 }
309
310 struct Fixture {
311 _tmp: TempDir,
312 skill_subdir: PathBuf,
313 workspace_root: PathBuf,
314 iteration_dir: PathBuf,
315 }
316
317 fn fixture(iteration: u32) -> Fixture {
319 let tmp = TempDir::new().unwrap();
320 let skill_subdir = tmp.path().join("skill-dir").join("mr-review");
321 write(
322 &skill_subdir.join("SKILL.md"),
323 "---\nname: mr-review\ndescription: review MRs\n---\n\nbody\n",
324 );
325 let workspace_root = tmp.path().join("work").join(".eval-magic");
326 let iteration_dir = workspace_root
327 .join("mr-review")
328 .join(format!("iteration-{iteration}"));
329 fs::create_dir_all(&iteration_dir).unwrap();
330 Fixture {
331 _tmp: tmp,
332 skill_subdir,
333 workspace_root,
334 iteration_dir,
335 }
336 }
337
338 fn opts<'a>(f: &'a Fixture, iteration: u32) -> PromoteOptions<'a> {
339 PromoteOptions {
340 workspace_root: &f.workspace_root,
341 skill_name: "mr-review",
342 skill_subdir: &f.skill_subdir,
343 iteration,
344 harness: Harness::resolve("claude-code").unwrap(),
345 label: None,
346 agent_model: None,
347 judge_model: None,
348 git_cwd: &f.skill_subdir,
349 }
350 }
351
352 const CONDITIONS: &str = r#"{
353 "mode": "new-skill",
354 "conditions": [
355 { "name": "with_skill", "skill_path": "/x/SKILL.md" },
356 { "name": "without_skill", "skill_path": null }
357 ],
358 "timestamp": "2026-05-27T00:00:00.000Z",
359 "harness": "claude-code"
360 }"#;
361
362 #[test]
363 fn copies_benchmark_and_per_run_gradings_into_baseline() {
364 let f = fixture(2);
365 write(&f.iteration_dir.join("conditions.json"), CONDITIONS);
366 write(
367 &f.iteration_dir.join("benchmark.json"),
368 r#"{"delta":{"pass_rate":0.5}}"#,
369 );
370 write(
371 &f.iteration_dir.join("eval-e1/with_skill/grading.json"),
372 r#"{"summary":{"pass_rate":1}}"#,
373 );
374 write(
375 &f.iteration_dir.join("eval-e1/without_skill/grading.json"),
376 r#"{"summary":{"pass_rate":0}}"#,
377 );
378
379 let res = promote_baseline(&opts(&f, 2)).unwrap();
380 let baseline = &res.baseline_dir;
381
382 assert_eq!(res.gradings_copied, 2);
383 let benchmark = fs::read_to_string(baseline.join("benchmark.json")).unwrap();
384 assert!(benchmark.contains("\"pass_rate\":0.5"));
385 let with = fs::read_to_string(baseline.join("grading/e1__with_skill.json")).unwrap();
386 assert!(with.contains("\"pass_rate\":1"));
387 assert!(baseline.join("grading/e1__without_skill.json").exists());
388
389 let provenance = fs::read_to_string(baseline.join("BASELINE.md")).unwrap();
390 assert!(provenance.contains("new-skill"));
391 assert!(provenance.contains("iteration-2"));
392 assert!(provenance.contains("claude-code"));
393 assert!(provenance.contains("2026-05-27T00:00:00.000Z"));
394 assert!(provenance.contains("Agent model | unspecified"));
395 assert!(provenance.contains("Judge model | unspecified"));
396 }
397
398 #[test]
399 fn captures_per_run_gradings_for_multi_run_cells() {
400 let f = fixture(4);
401 write(
402 &f.iteration_dir.join("benchmark.json"),
403 r#"{"delta":{"pass_rate":0.5}}"#,
404 );
405 for cond in ["with_skill", "without_skill"] {
407 for k in 1..=3 {
408 write(
409 &f.iteration_dir
410 .join(format!("eval-e1/{cond}/run-{k}/grading.json")),
411 r#"{"summary":{"pass_rate":1}}"#,
412 );
413 }
414 }
415 write(
417 &f.iteration_dir.join("eval-e2/with_skill/grading.json"),
418 r#"{"summary":{"pass_rate":0}}"#,
419 );
420
421 let res = promote_baseline(&opts(&f, 4)).unwrap();
422 let baseline = &res.baseline_dir;
423
424 assert_eq!(res.gradings_copied, 7);
425 for k in 1..=3 {
427 assert!(
428 baseline
429 .join(format!("grading/e1__with_skill__r{k}.json"))
430 .exists()
431 );
432 assert!(
433 baseline
434 .join(format!("grading/e1__without_skill__r{k}.json"))
435 .exists()
436 );
437 }
438 assert!(baseline.join("grading/e2__with_skill.json").exists());
440 assert_eq!(res.missing_gradings, 0);
441 }
442
443 #[test]
444 fn reports_missing_gradings_for_incomplete_run_cells() {
445 let f = fixture(5);
446 write(
447 &f.iteration_dir.join("benchmark.json"),
448 r#"{"delta":{"pass_rate":0}}"#,
449 );
450 write(
452 &f.iteration_dir
453 .join("eval-e1/with_skill/run-1/grading.json"),
454 r#"{"summary":{"pass_rate":1}}"#,
455 );
456 fs::create_dir_all(f.iteration_dir.join("eval-e1/with_skill/run-2")).unwrap();
457
458 let res = promote_baseline(&opts(&f, 5)).unwrap();
459
460 assert_eq!(res.gradings_copied, 1);
461 assert_eq!(res.missing_gradings, 1);
462 }
463
464 #[test]
465 fn drops_promoted_marker_into_iteration_dir() {
466 let f = fixture(3);
467 write(
468 &f.iteration_dir.join("benchmark.json"),
469 r#"{"delta":{"pass_rate":0}}"#,
470 );
471
472 promote_baseline(&opts(&f, 3)).unwrap();
473
474 let marker_path = f.iteration_dir.join(PROMOTED_MARKER);
475 assert!(marker_path.exists());
476 let marker: serde_json::Value =
477 serde_json::from_str(&fs::read_to_string(&marker_path).unwrap()).unwrap();
478 assert!(
479 marker["promoted_at"]
480 .as_str()
481 .is_some_and(|s| !s.is_empty())
482 );
483 assert_eq!(
484 marker["baseline_dir"].as_str().unwrap(),
485 f.skill_subdir
486 .join("evals")
487 .join("baseline")
488 .to_string_lossy()
489 );
490 }
491
492 #[test]
493 fn records_agent_and_judge_models_when_provided() {
494 let f = fixture(1);
495 write(&f.iteration_dir.join("conditions.json"), CONDITIONS);
496 write(
497 &f.iteration_dir.join("benchmark.json"),
498 r#"{"delta":{"pass_rate":0}}"#,
499 );
500
501 let mut o = opts(&f, 1);
502 o.agent_model = Some("claude-haiku-4-5-20251001");
503 o.judge_model = Some("claude-opus-4-7");
504 promote_baseline(&o).unwrap();
505
506 let provenance =
507 fs::read_to_string(f.skill_subdir.join("evals/baseline/BASELINE.md")).unwrap();
508 assert!(provenance.contains("Agent model | claude-haiku-4-5-20251001"));
509 assert!(provenance.contains("Judge model | claude-opus-4-7"));
510 }
511
512 const CONDITIONS_WITH_PROVENANCE: &str = r#"{
513 "mode": "new-skill",
514 "conditions": [
515 { "name": "with_skill", "skill_path": "/x/SKILL.md" },
516 { "name": "without_skill", "skill_path": null }
517 ],
518 "timestamp": "2026-05-27T00:00:00.000Z",
519 "harness": "claude-code",
520 "agent_model": "claude-haiku-4-5-20251001",
521 "judge_model": "claude-opus-4-8",
522 "label": "canonical-run"
523 }"#;
524
525 #[test]
526 fn provenance_falls_back_to_manifest_models_and_label() {
527 let f = fixture(1);
528 write(
529 &f.iteration_dir.join("conditions.json"),
530 CONDITIONS_WITH_PROVENANCE,
531 );
532 write(
533 &f.iteration_dir.join("benchmark.json"),
534 r#"{"delta":{"pass_rate":0}}"#,
535 );
536
537 promote_baseline(&opts(&f, 1)).unwrap();
538
539 let provenance =
540 fs::read_to_string(f.skill_subdir.join("evals/baseline/BASELINE.md")).unwrap();
541 assert!(provenance.contains("Agent model | claude-haiku-4-5-20251001"));
542 assert!(provenance.contains("Judge model | claude-opus-4-8"));
543 assert!(provenance.contains("Label | canonical-run"));
544 }
545
546 #[test]
547 fn promote_flags_override_manifest_values() {
548 let f = fixture(1);
549 write(
550 &f.iteration_dir.join("conditions.json"),
551 CONDITIONS_WITH_PROVENANCE,
552 );
553 write(
554 &f.iteration_dir.join("benchmark.json"),
555 r#"{"delta":{"pass_rate":0}}"#,
556 );
557
558 let mut o = opts(&f, 1);
559 o.agent_model = Some("claude-fable-5");
560 o.label = Some("override-label");
561 promote_baseline(&o).unwrap();
562
563 let provenance =
564 fs::read_to_string(f.skill_subdir.join("evals/baseline/BASELINE.md")).unwrap();
565 assert!(provenance.contains("Agent model | claude-fable-5"));
566 assert!(provenance.contains("Judge model | claude-opus-4-8"));
568 assert!(provenance.contains("Label | override-label"));
569 }
570
571 #[test]
572 fn writes_notes_stub_when_absent() {
573 let f = fixture(2);
574 write(
575 &f.iteration_dir.join("benchmark.json"),
576 r#"{"delta":{"pass_rate":0}}"#,
577 );
578
579 let res = promote_baseline(&opts(&f, 2)).unwrap();
580
581 assert_eq!(res.notes, NotesStatus::StubWritten);
582 let notes = fs::read_to_string(res.baseline_dir.join("NOTES.md")).unwrap();
583 assert!(notes.contains("mr-review"));
584 assert!(notes.contains("iteration-2"));
585 }
586
587 #[test]
588 fn retains_existing_notes_untouched() {
589 let f = fixture(3);
590 write(
591 &f.iteration_dir.join("benchmark.json"),
592 r#"{"delta":{"pass_rate":0}}"#,
593 );
594 let notes_path = f.skill_subdir.join("evals/baseline/NOTES.md");
595 write(
596 ¬es_path,
597 "human-authored observations from iteration-2\n",
598 );
599
600 let res = promote_baseline(&opts(&f, 3)).unwrap();
601
602 assert_eq!(res.notes, NotesStatus::RetainedFromPrior);
603 assert_eq!(
604 fs::read_to_string(¬es_path).unwrap(),
605 "human-authored observations from iteration-2\n"
606 );
607 }
608
609 #[test]
610 fn fails_clearly_when_iteration_dir_is_missing() {
611 let f = fixture(1); let err = promote_baseline(&opts(&f, 9)).unwrap_err();
613 assert!(matches!(err, WorkspaceError::Message(_)));
614 assert!(err.to_string().contains("iteration-9"));
615 }
616}