Skip to main content

eval_magic/workspace/
promote.rs

1//! Baseline promotion.
2//!
3//! Copy the durable, reference-worthy
4//! subset of a workspace iteration (`benchmark.json`, per-run `grading.json`, a
5//! `BASELINE.md` provenance file) into the skill's version-controlled
6//! `evals/baseline/`, and drop a `.promoted.json` marker so `teardown` can
7//! reclaim the iteration. Ephemeral scaffolding (dispatch/timing/run records,
8//! produced outputs, transcripts) is intentionally left behind.
9
10use 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
21/// Inputs for [`promote_baseline`]. Borrowed for the duration of the call.
22pub 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    /// Operator-declared models for provenance. The runner never dispatches the
30    /// agent/judge itself, so it cannot observe these — record what was used.
31    pub agent_model: Option<&'a str>,
32    pub judge_model: Option<&'a str>,
33    /// Directory used to resolve the committing repo's git HEAD for provenance.
34    pub git_cwd: &'a Path,
35}
36
37/// What [`promote_baseline`] wrote.
38#[derive(Debug)]
39pub struct PromoteResult {
40    pub baseline_dir: PathBuf,
41    pub gradings_copied: usize,
42    /// Run slots whose `grading.json` was absent and therefore not copied — a
43    /// sign the iteration was promoted before grading finished. Surfaced as a
44    /// warning so the gap isn't silent.
45    pub missing_gradings: usize,
46    pub notes: NotesStatus,
47}
48
49/// How `NOTES.md` in the baseline dir was handled during promotion.
50///
51/// Promotion never overwrites operator-authored notes, but a baseline whose
52/// notes describe a *previous* iteration is easy to ship by accident — the
53/// caller should surface `RetainedFromPrior` as a warning.
54#[derive(Debug, Clone, Copy, PartialEq, Eq)]
55pub enum NotesStatus {
56    /// A `NOTES.md` already existed and was left untouched.
57    RetainedFromPrior,
58    /// No `NOTES.md` existed; a stub was written for the operator to fill in.
59    StubWritten,
60}
61
62/// Copy the durable subset of `iteration-<n>` into `<skill>/evals/baseline/` and
63/// mark the iteration promoted. Errors if the iteration or its `benchmark.json`
64/// is missing.
65pub 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    // Mark the iteration as committed so `teardown` can safely reclaim its
110    // workspace — without this marker teardown preserves it as uncommitted.
111    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
128/// Leave an existing `NOTES.md` untouched (operator-authored), or write a stub
129/// naming the promoted iteration so the convention is visible from the start.
130fn 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        &notes_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
151/// Copy each run's `grading.json` from every `eval-<id>/<condition>` cell into
152/// `<grading_dir>/`, returning `(copied, missing)`. A flat `runs: 1` cell lands
153/// at `<id>__<condition>.json`; a multi-run cell emits one
154/// `<id>__<condition>__r<k>.json` per `run-<k>/`. `missing` counts run slots
155/// whose `grading.json` is absent (an incomplete iteration). Entries are sorted
156/// so the copy is deterministic.
157fn 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            // Walk every run slot so multi-run cells (`run-<k>/grading.json`)
177            // are captured alongside flat `runs: 1` cells, just as `aggregate`
178            // reads them.
179            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
197/// Directory entry names, sorted. Missing/unreadable dirs yield `[]`.
198fn 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
210/// `git rev-parse --short HEAD` in `cwd`, or `"unknown"` when git is
211/// unavailable / `cwd` isn't a repo — provenance stays useful without it.
212fn 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
221/// Serialize an enum that renders to a string (`Harness`, `Mode`) to its
222/// kebab-case label via serde, so we never hardcode variant spellings.
223fn 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
230/// Build the `BASELINE.md` provenance document — byte-for-byte the layout of
231/// `promote-baseline.ts`.
232fn 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    // Provenance precedence: explicit promote-baseline flag → value recorded in
250    // the iteration's conditions.json (set via `run`) → placeholder.
251    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    /// Write `body` to `path`, creating parent dirs.
305    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    /// Build a skill dir (with SKILL.md) and a workspace iteration dir.
318    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        // eval-e1: runs=3 → gradings nested under run-<k>/.
406        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        // eval-e2: runs=1 → flat legacy layout.
416        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        // Nested cells carry an __r<k> suffix per run.
426        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        // The flat runs=1 cell keeps the unsuffixed name.
439        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        // run-1 graded; run-2 dispatched but never graded (incomplete iteration).
451        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        // Judge model not overridden — manifest value still wins over "unspecified".
567        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            &notes_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(&notes_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); // creates iteration-1, but we promote iteration-9
612        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}