Skip to content

Commit 645ff6a

Browse files
authored
feat(eval): freeze cell identity and resume semantics before stable cut (#436)
* feat(eval): freeze cell identity payload and resume semantics - Document identity fields, per-state resume rules, and stale-cell keep-and-report policy in docs/phase-eval-cell-identity.md - Snapshot-lock the identity payload keys and cell_id digest in tests - Normalize CRLF/CR prompt line endings before identity hashing - Record manifest_digest in every cell.json (running and final writes) - Number attempts as max(existing) + 1, tolerating gaps and foreign dirs - Print stale-cell count to stderr on execute --resume - Cover manifest-edit-under-resume and kill-mid-run resume in tests * fix(eval): persist attempt high-water mark against reuse Review feedback on #436: max(attempt dirs) + 1 reuses a number when the highest attempt directory is deleted. Take the next attempt as max over both the attempt-NNN directories and the attempt value recorded in cell.json, and document the mechanism. * test(eval): cover running marker with no attempt directory CodeRabbit review on #436: pin the crash window where cell.json records a running attempt but no attempt-NNN directory exists yet, so resume allocates the next number instead of reusing it. * fix(eval): ignore non-positive recorded attempt numbers A corrupt-but-valid-JSON cell.json marker (attempt -1 or 0) would lower the high-water mark and produce attempt-000, violating the 1-based numbering contract. Only positive integers count.
1 parent e097aef commit 645ff6a

3 files changed

Lines changed: 290 additions & 4 deletions

File tree

docs/phase-eval-cell-identity.md

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
# Eval cell identity and resume semantics
2+
3+
This document is the contract for `brigade.eval_cell.v1` receipts produced by
4+
`src/brigade/model_trials.py`. It freezes the rules that become compatibility
5+
surfaces at the stable cut: how `cell_id` is derived, what `execute --resume`
6+
does for every recorded state, and how stale cells are handled.
7+
8+
## Cell identity
9+
10+
`cell_id` is the sha256 hex digest of a canonical JSON identity payload
11+
(`json.dumps` with sorted keys, `(",", ":")` separators, UTF-8) built in
12+
`expand_cells`. Exactly these fields participate, and nothing else:
13+
14+
- `schema` — the `CELL_SCHEMA` tag (`brigade.eval_cell.v1`).
15+
- `case.id` — the case identifier from the manifest.
16+
- `case.prompt` — the inlined prompt text, after line-ending normalization
17+
(see below).
18+
- `seat.seat` — the seat name.
19+
- `seat.cli` — the agent's CLI adapter.
20+
- `seat.model` — the pinned model.
21+
- `seat.reasoning` — the reasoning setting.
22+
- `seat.transport` — the transport, if any.
23+
- `seat.transport_version` — the transport version, if any.
24+
- `seat.env` — the agent's environment map, if any.
25+
- `seat.codex_transport` — the roster codex transport; set for codex seats
26+
only, otherwise `null`.
27+
- `trial` — the 1-based trial number.
28+
- `graders` — the grader list for the case.
29+
- `execution_mode``read-only` or `writable-worktree`.
30+
31+
The `coordinate` (`case:seat:trial`) is the human-stable axis and is **not**
32+
part of the identity payload; it is how staleness is detected (below).
33+
34+
### Line-ending normalization
35+
36+
Before prompt text (inline or from `prompt_file`) enters the identity payload,
37+
`\r\n` and bare `\r` are normalized to `\n`, so the same logical manifest
38+
hashes identically across checkouts with different line-ending conventions.
39+
This changes `cell_id` only for manifests that contained CRLF or CR line
40+
endings; that breakage is accepted because it lands before the stable cut.
41+
42+
### Changing the identity payload
43+
44+
Any change to the field set above — adding, removing, or reinterpreting a
45+
field — requires bumping `CELL_SCHEMA` and writing a migration note in this
46+
document. The identity lock test in `tests/test_model_trials.py` snapshots the
47+
exact payload keys and the resulting digest, so an accidental change fails CI.
48+
49+
## Resume semantics
50+
51+
`execute --resume` rebuilds the plan from the current manifest and decides per
52+
cell from the recorded `cell.json`:
53+
54+
- `accepted`, `rejected`, `unscored`, `execution_error`, `adapter_error`,
55+
`grader_error` (the terminal states): the cell is **skipped**; the existing
56+
receipt stands.
57+
- `running`: the cell **re-runs as a new attempt**. `running` means the
58+
previous process died mid-run (or, without a lock, is still executing in
59+
another process). Resume treats it as a crash and starts the next attempt,
60+
preserving the original `started_at`.
61+
- Missing, unreadable, or corrupt `cell.json`: the cell runs as a new attempt.
62+
- Any other state value: the cell re-runs (only exact terminal-state
63+
membership causes a skip).
64+
65+
Attempt numbers are `max(existing attempt numbers) + 1` over two sources —
66+
the `attempt-NNN` directories under `attempts/` and the `attempt` value
67+
recorded in `cell.json` — tolerating gaps and non-`attempt-NNN` directories.
68+
Because `cell.json` persists the last attempt number, deleting even the
69+
highest attempt directory never causes a number to be reused.
70+
71+
Every `cell.json` — both the `running` marker written before the run and the
72+
final receipt — records `manifest_digest`, the canonical digest of the
73+
manifest that produced the plan, so each cell stays attributable to its
74+
generation even after later manifest edits.
75+
76+
## Stale cells
77+
78+
A cell is stale when its `coordinate` exists in the previous `plan.json` with
79+
a different `cell_id` (for example after a manifest edit). Staleness is
80+
computed against the immediately previous `plan.json` only.
81+
82+
The policy is **keep and report, no pruning**:
83+
84+
- Stale cell directories are left on disk untouched.
85+
- The new `plan.json` lists them under `stale_cells` with the previous and
86+
current ids.
87+
- `summarize` excludes them from the headline counts and reports them under
88+
`stale_counts`.
89+
- On `execute --resume` with stale cells present, a one-line stale count is
90+
printed to stderr.
91+
92+
## Concurrency
93+
94+
Two concurrent `execute` processes on one output directory are not guarded:
95+
both see a `running` cell and interleave writes. A lockfile-based guard is
96+
tracked separately and deliberately not part of this freeze; until it lands,
97+
do not run concurrent executes against the same output directory.

src/brigade/model_trials.py

Lines changed: 30 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -73,17 +73,21 @@ def load_manifest(path: Path) -> dict[str, Any]:
7373
return data
7474

7575

76+
def _normalize_prompt(text: str) -> str:
77+
return text.replace("\r\n", "\n").replace("\r", "\n")
78+
79+
7680
def _case_prompt(case: dict[str, Any], base_dir: Path) -> str:
7781
prompt = case.get("prompt")
7882
prompt_file = case.get("prompt_file")
7983
if isinstance(prompt, str) and prompt:
80-
return prompt
84+
return _normalize_prompt(prompt)
8185
if isinstance(prompt_file, str) and prompt_file:
8286
candidate = (base_dir / prompt_file).resolve()
8387
if base_dir.resolve() not in candidate.parents and candidate != base_dir.resolve():
8488
raise ValueError(f"case {case.get('id')!r} prompt_file escapes the manifest directory")
8589
try:
86-
return candidate.read_text()
90+
return _normalize_prompt(candidate.read_text())
8791
except OSError as exc:
8892
raise ValueError(f"case {case.get('id')!r} prompt_file unreadable: {exc}") from exc
8993
raise ValueError(f"case {case.get('id')!r} needs prompt or prompt_file")
@@ -322,10 +326,25 @@ def _trial_worktree_path(
322326
)
323327

324328

329+
_ATTEMPT_DIR = re.compile(r"attempt-(\d+)")
330+
331+
325332
def _attempt_number(cell_dir: Path) -> int:
326333
attempts = cell_dir / "attempts"
327-
existing = [p for p in attempts.iterdir() if p.is_dir()] if attempts.is_dir() else []
328-
return len(existing) + 1
334+
numbers = (
335+
[
336+
int(match.group(1))
337+
for entry in attempts.iterdir()
338+
if entry.is_dir() and (match := _ATTEMPT_DIR.fullmatch(entry.name)) is not None
339+
]
340+
if attempts.is_dir()
341+
else []
342+
)
343+
recorded = _load_json(cell_dir / "cell.json") or {}
344+
prior = recorded.get("attempt")
345+
if isinstance(prior, int) and not isinstance(prior, bool) and prior > 0:
346+
numbers.append(prior)
347+
return max(numbers, default=0) + 1
329348

330349

331350
def _load_json(path: Path) -> dict[str, Any] | None:
@@ -385,6 +404,11 @@ def execute(
385404
print("error: writable-worktree trials require a git worktree target", file=sys.stderr)
386405
return 2
387406
localio.write_json(output_dir / "plan.json", plan)
407+
if resume and plan["stale_cells"]:
408+
print(
409+
f"note: {len(plan['stale_cells'])} stale cell(s) from the previous plan kept and counted in summary",
410+
file=sys.stderr,
411+
)
388412
failures = 0
389413
for cell in cells:
390414
cell_dir = output_dir / "cells" / cell.cell_id
@@ -406,6 +430,7 @@ def execute(
406430
"state": "running",
407431
"attempt": attempt,
408432
"started_at": started_at,
433+
"manifest_digest": plan["manifest_digest"],
409434
},
410435
)
411436
run_dir = cell_dir / "attempts" / f"attempt-{attempt:03d}" / "run"
@@ -456,6 +481,7 @@ def execute(
456481
"state": state,
457482
"attempt": attempt,
458483
"started_at": started_at,
484+
"manifest_digest": plan["manifest_digest"],
459485
"exit_code": rc,
460486
"duration_seconds": run_meta.get("duration_seconds"),
461487
"run_dir": str(run_dir),

tests/test_model_trials.py

Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,83 @@ def _roster() -> Roster:
2828
)
2929

3030

31+
def test_cell_identity_payload_is_locked():
32+
# Snapshot of the frozen identity contract (docs/phase-eval-cell-identity.md).
33+
# Any change to the field set below must come with a CELL_SCHEMA bump.
34+
manifest = {
35+
"schema": "brigade.eval_manifest.v1",
36+
"name": "identity-lock",
37+
"trials": 1,
38+
"seats": ["cursor"],
39+
"cases": [{"id": "hello", "prompt": "Say hello"}],
40+
"graders": [{"type": "exact_output", "expected": "hello"}],
41+
}
42+
cell = model_trials.expand_cells(manifest, _roster())[0]
43+
expected_identity = {
44+
"schema": "brigade.eval_cell.v1",
45+
"case": {"id": "hello", "prompt": "Say hello"},
46+
"seat": {
47+
"seat": "cursor",
48+
"cli": "cursor",
49+
"model": "composer-2.5",
50+
"reasoning": None,
51+
"transport": "direct",
52+
"transport_version": None,
53+
"env": None,
54+
"codex_transport": None,
55+
},
56+
"trial": 1,
57+
"graders": [{"type": "exact_output", "expected": "hello"}],
58+
"execution_mode": "read-only",
59+
}
60+
assert model_trials._canonical_digest(expected_identity) == cell.cell_id
61+
assert cell.cell_id == "55c07e87f401b5aa49f956b2cec1bfee87986088702bc0b1762cc722ff2638c1"
62+
63+
64+
def test_prompt_line_endings_do_not_change_identity():
65+
crlf = _manifest()
66+
crlf["cases"][0]["prompt"] = "Say hello\r\nagain\rnow"
67+
unix = _manifest()
68+
unix["cases"][0]["prompt"] = "Say hello\nagain\nnow"
69+
crlf_cells = model_trials.expand_cells(crlf, _roster())
70+
unix_cells = model_trials.expand_cells(unix, _roster())
71+
assert [cell.cell_id for cell in crlf_cells] == [cell.cell_id for cell in unix_cells]
72+
assert crlf_cells[0].prompt == "Say hello\nagain\nnow"
73+
74+
75+
def test_attempt_number_uses_max_plus_one_and_tolerates_gaps(tmp_path):
76+
assert model_trials._attempt_number(tmp_path) == 1
77+
attempts = tmp_path / "attempts"
78+
attempts.mkdir()
79+
(attempts / "attempt-001").mkdir()
80+
(attempts / "attempt-003").mkdir()
81+
(attempts / "scratch").mkdir()
82+
(attempts / "attempt-002-partial").mkdir()
83+
assert model_trials._attempt_number(tmp_path) == 4
84+
85+
86+
def test_attempt_number_does_not_reuse_deleted_highest_attempt(tmp_path):
87+
attempts = tmp_path / "attempts"
88+
attempts.mkdir()
89+
(attempts / "attempt-001").mkdir()
90+
(tmp_path / "cell.json").write_text(json.dumps({"attempt": 3}))
91+
assert model_trials._attempt_number(tmp_path) == 4
92+
93+
94+
def test_attempt_number_counts_running_marker_without_attempt_dir(tmp_path):
95+
# Crash window: cell.json was written as running before attempt-001 existed.
96+
(tmp_path / "cell.json").write_text(json.dumps({"state": "running", "attempt": 1}))
97+
assert model_trials._attempt_number(tmp_path) == 2
98+
99+
100+
def test_attempt_number_ignores_nonpositive_recorded_attempt(tmp_path):
101+
# Corrupt-but-valid-JSON markers must not produce attempt-000.
102+
(tmp_path / "cell.json").write_text(json.dumps({"state": "running", "attempt": -1}))
103+
assert model_trials._attempt_number(tmp_path) == 1
104+
(tmp_path / "cell.json").write_text(json.dumps({"state": "running", "attempt": 0}))
105+
assert model_trials._attempt_number(tmp_path) == 1
106+
107+
31108
def test_expand_cells_is_stable_and_conditions_change_identity():
32109
first = model_trials.expand_cells(_manifest(), _roster())
33110
second = model_trials.expand_cells(_manifest(), _roster())
@@ -228,6 +305,92 @@ def fake_run(task, roster, **kwargs):
228305
assert summary["stale_counts"] == {"accepted": 2}
229306

230307

308+
def test_resume_after_manifest_edit_reruns_only_changed_cells(tmp_path, monkeypatch, capsys):
309+
manifest = _manifest()
310+
manifest["trials"] = 1
311+
manifest["cases"] = [
312+
{"id": "alpha", "prompt": "Say alpha"},
313+
{"id": "beta", "prompt": "Say beta"},
314+
]
315+
manifest_path = tmp_path / "eval.json"
316+
manifest_path.write_text(json.dumps(manifest))
317+
tasks: list[str] = []
318+
319+
def fake_run(task, roster, **kwargs):
320+
tasks.append(task)
321+
out = kwargs["output_dir"]
322+
out.mkdir(parents=True, exist_ok=True)
323+
(out / "final.txt").write_text("hello\n")
324+
(out / "run.json").write_text(json.dumps({"status": "ok", "duration_seconds": 0.5}))
325+
return 0
326+
327+
monkeypatch.setattr(model_trials.aboyeur, "run", fake_run)
328+
root = tmp_path / "results"
329+
assert model_trials.execute(manifest_path, _roster(), workspace=tmp_path, output_dir=root, resume=False) == 0
330+
assert sorted(tasks) == ["Say alpha", "Say beta"]
331+
332+
original_ids = {cell.case_id: cell.cell_id for cell in model_trials.expand_cells(manifest, _roster())}
333+
alpha_path = root / "cells" / original_ids["alpha"] / "cell.json"
334+
335+
edited = json.loads(manifest_path.read_text())
336+
edited["cases"][1]["prompt"] = "Say beta differently"
337+
manifest_path.write_text(json.dumps(edited))
338+
tasks.clear()
339+
capsys.readouterr()
340+
assert model_trials.execute(manifest_path, _roster(), workspace=tmp_path, output_dir=root, resume=True) == 0
341+
342+
# The unchanged cell is skipped; the edited cell re-runs under a new cell_id.
343+
assert tasks == ["Say beta differently"]
344+
alpha = json.loads(alpha_path.read_text())
345+
assert alpha["state"] == "accepted"
346+
assert alpha["attempt"] == 1
347+
edited_ids = {cell.case_id: cell.cell_id for cell in model_trials.expand_cells(edited, _roster())}
348+
assert edited_ids["alpha"] == original_ids["alpha"]
349+
assert edited_ids["beta"] != original_ids["beta"]
350+
new_beta = json.loads((root / "cells" / edited_ids["beta"] / "cell.json").read_text())
351+
assert new_beta["state"] == "accepted"
352+
plan = json.loads((root / "plan.json").read_text())
353+
assert new_beta["manifest_digest"] == plan["manifest_digest"]
354+
355+
# The old cell is kept and reported, not pruned; resume warns on stderr.
356+
assert (root / "cells" / original_ids["beta"] / "cell.json").is_file()
357+
summary = json.loads((root / "summary.json").read_text())
358+
assert summary["counts"] == {"accepted": 2}
359+
assert summary["stale_counts"] == {"accepted": 1}
360+
assert "1 stale cell(s)" in capsys.readouterr().err
361+
362+
363+
def test_resume_reruns_killed_running_cell_as_new_attempt(tmp_path, monkeypatch):
364+
manifest = _manifest()
365+
manifest["trials"] = 1
366+
manifest_path = tmp_path / "eval.json"
367+
manifest_path.write_text(json.dumps(manifest))
368+
369+
def fake_run(task, roster, **kwargs):
370+
out = kwargs["output_dir"]
371+
out.mkdir(parents=True, exist_ok=True)
372+
(out / "final.txt").write_text("hello\n")
373+
(out / "run.json").write_text(json.dumps({"status": "ok", "duration_seconds": 0.5}))
374+
return 0
375+
376+
monkeypatch.setattr(model_trials.aboyeur, "run", fake_run)
377+
root = tmp_path / "results"
378+
assert model_trials.execute(manifest_path, _roster(), workspace=tmp_path, output_dir=root, resume=False) == 0
379+
cell_path = next((root / "cells").glob("*/cell.json"))
380+
381+
# Simulate a kill mid-run: the last durable state is "running".
382+
killed = json.loads(cell_path.read_text())
383+
killed["state"] = "running"
384+
cell_path.write_text(json.dumps(killed))
385+
386+
assert model_trials.execute(manifest_path, _roster(), workspace=tmp_path, output_dir=root, resume=True) == 0
387+
final = json.loads(cell_path.read_text())
388+
assert final["state"] == "accepted"
389+
assert final["attempt"] == 2
390+
attempts = sorted(p.name for p in (cell_path.parent / "attempts").iterdir())
391+
assert attempts == ["attempt-001", "attempt-002"]
392+
393+
231394
def test_grader_envelope_links_digested_output(tmp_path, monkeypatch):
232395
manifest_path = tmp_path / "eval.json"
233396
manifest_path.write_text(json.dumps(_manifest()))

0 commit comments

Comments
 (0)