-
Notifications
You must be signed in to change notification settings - Fork 5
feat(eval): freeze cell identity and resume semantics before stable cut #436
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 1 commit
aaba5d1
683d6cd
a7f4b84
0246cae
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,95 @@ | ||
| # Eval cell identity and resume semantics | ||
|
|
||
| This document is the contract for `brigade.eval_cell.v1` receipts produced by | ||
| `src/brigade/model_trials.py`. It freezes the rules that become compatibility | ||
| surfaces at the stable cut: how `cell_id` is derived, what `execute --resume` | ||
| does for every recorded state, and how stale cells are handled. | ||
|
|
||
| ## Cell identity | ||
|
|
||
| `cell_id` is the sha256 hex digest of a canonical JSON identity payload | ||
| (`json.dumps` with sorted keys, `(",", ":")` separators, UTF-8) built in | ||
| `expand_cells`. Exactly these fields participate, and nothing else: | ||
|
|
||
| - `schema` — the `CELL_SCHEMA` tag (`brigade.eval_cell.v1`). | ||
| - `case.id` — the case identifier from the manifest. | ||
| - `case.prompt` — the inlined prompt text, after line-ending normalization | ||
| (see below). | ||
| - `seat.seat` — the seat name. | ||
| - `seat.cli` — the agent's CLI adapter. | ||
| - `seat.model` — the pinned model. | ||
| - `seat.reasoning` — the reasoning setting. | ||
| - `seat.transport` — the transport, if any. | ||
| - `seat.transport_version` — the transport version, if any. | ||
| - `seat.env` — the agent's environment map, if any. | ||
| - `seat.codex_transport` — the roster codex transport; set for codex seats | ||
| only, otherwise `null`. | ||
| - `trial` — the 1-based trial number. | ||
| - `graders` — the grader list for the case. | ||
| - `execution_mode` — `read-only` or `writable-worktree`. | ||
|
|
||
| The `coordinate` (`case:seat:trial`) is the human-stable axis and is **not** | ||
| part of the identity payload; it is how staleness is detected (below). | ||
|
|
||
| ### Line-ending normalization | ||
|
|
||
| Before prompt text (inline or from `prompt_file`) enters the identity payload, | ||
| `\r\n` and bare `\r` are normalized to `\n`, so the same logical manifest | ||
| hashes identically across checkouts with different line-ending conventions. | ||
| This changes `cell_id` only for manifests that contained CRLF or CR line | ||
| endings; that breakage is accepted because it lands before the stable cut. | ||
|
|
||
| ### Changing the identity payload | ||
|
|
||
| Any change to the field set above — adding, removing, or reinterpreting a | ||
| field — requires bumping `CELL_SCHEMA` and writing a migration note in this | ||
| document. The identity lock test in `tests/test_model_trials.py` snapshots the | ||
| exact payload keys and the resulting digest, so an accidental change fails CI. | ||
|
|
||
| ## Resume semantics | ||
|
|
||
| `execute --resume` rebuilds the plan from the current manifest and decides per | ||
| cell from the recorded `cell.json`: | ||
|
|
||
| - `accepted`, `rejected`, `unscored`, `execution_error`, `adapter_error`, | ||
| `grader_error` (the terminal states): the cell is **skipped**; the existing | ||
| receipt stands. | ||
| - `running`: the cell **re-runs as a new attempt**. `running` means the | ||
| previous process died mid-run (or, without a lock, is still executing in | ||
| another process). Resume treats it as a crash and starts the next attempt, | ||
| preserving the original `started_at`. | ||
| - Missing, unreadable, or corrupt `cell.json`: the cell runs as a new attempt. | ||
| - Any other state value: the cell re-runs (only exact terminal-state | ||
| membership causes a skip). | ||
|
|
||
| Attempt numbers are `max(existing attempt numbers under attempts/) + 1`, | ||
| tolerating gaps and non-`attempt-NNN` directories; a deleted attempt directory | ||
| never causes a number to be reused. | ||
|
|
||
| Every `cell.json` — both the `running` marker written before the run and the | ||
| final receipt — records `manifest_digest`, the canonical digest of the | ||
| manifest that produced the plan, so each cell stays attributable to its | ||
| generation even after later manifest edits. | ||
|
|
||
| ## Stale cells | ||
|
|
||
| A cell is stale when its `coordinate` exists in the previous `plan.json` with | ||
| a different `cell_id` (for example after a manifest edit). Staleness is | ||
| computed against the immediately previous `plan.json` only. | ||
|
|
||
| The policy is **keep and report, no pruning**: | ||
|
|
||
| - Stale cell directories are left on disk untouched. | ||
| - The new `plan.json` lists them under `stale_cells` with the previous and | ||
| current ids. | ||
| - `summarize` excludes them from the headline counts and reports them under | ||
| `stale_counts`. | ||
| - On `execute --resume` with stale cells present, a one-line stale count is | ||
| printed to stderr. | ||
|
|
||
| ## Concurrency | ||
|
|
||
| Two concurrent `execute` processes on one output directory are not guarded: | ||
| both see a `running` cell and interleave writes. A lockfile-based guard is | ||
| tracked separately and deliberately not part of this freeze; until it lands, | ||
| do not run concurrent executes against the same output directory. |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -73,17 +73,21 @@ def load_manifest(path: Path) -> dict[str, Any]: | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return data | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| def _normalize_prompt(text: str) -> str: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return text.replace("\r\n", "\n").replace("\r", "\n") | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| def _case_prompt(case: dict[str, Any], base_dir: Path) -> str: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| prompt = case.get("prompt") | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| prompt_file = case.get("prompt_file") | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if isinstance(prompt, str) and prompt: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return prompt | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return _normalize_prompt(prompt) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if isinstance(prompt_file, str) and prompt_file: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| candidate = (base_dir / prompt_file).resolve() | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if base_dir.resolve() not in candidate.parents and candidate != base_dir.resolve(): | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| raise ValueError(f"case {case.get('id')!r} prompt_file escapes the manifest directory") | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| try: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return candidate.read_text() | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return _normalize_prompt(candidate.read_text()) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| except OSError as exc: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| raise ValueError(f"case {case.get('id')!r} prompt_file unreadable: {exc}") from exc | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| raise ValueError(f"case {case.get('id')!r} needs prompt or prompt_file") | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -322,10 +326,19 @@ def _trial_worktree_path( | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| _ATTEMPT_DIR = re.compile(r"attempt-(\d+)") | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| def _attempt_number(cell_dir: Path) -> int: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| attempts = cell_dir / "attempts" | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| existing = [p for p in attempts.iterdir() if p.is_dir()] if attempts.is_dir() else [] | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return len(existing) + 1 | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if not attempts.is_dir(): | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return 1 | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| numbers = [ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| int(match.group(1)) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| for entry in attempts.iterdir() | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if entry.is_dir() and (match := _ATTEMPT_DIR.fullmatch(entry.name)) is not None | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ] | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return max(numbers, default=0) + 1 | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+329
to
+347
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win Account for a durable running marker with no attempt directory.
Proposed fix-def _attempt_number(cell_dir: Path) -> int:
+def _attempt_number(cell_dir: Path, *, prior_attempt: int | None = None) -> int:
attempts = cell_dir / "attempts"
- if not attempts.is_dir():
- return 1
numbers = [
int(match.group(1))
for entry in attempts.iterdir()
- if entry.is_dir() and (match := _ATTEMPT_DIR.fullmatch(entry.name)) is not None
- ]
+ if attempts.is_dir()
+ and entry.is_dir()
+ and (match := _ATTEMPT_DIR.fullmatch(entry.name)) is not None
+ ] if attempts.is_dir() else []
+ if isinstance(prior_attempt, int) and not isinstance(prior_attempt, bool) and prior_attempt > 0:
+ numbers.append(prior_attempt)
return max(numbers, default=0) + 1📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Already addressed, plus the requested regression test. The fix landed in 683d6cd: |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| def _load_json(path: Path) -> dict[str, Any] | None: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -385,6 +398,11 @@ def execute( | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| print("error: writable-worktree trials require a git worktree target", file=sys.stderr) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return 2 | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| localio.write_json(output_dir / "plan.json", plan) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if resume and plan["stale_cells"]: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| print( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| f"note: {len(plan['stale_cells'])} stale cell(s) from the previous plan kept and counted in summary", | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| file=sys.stderr, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| failures = 0 | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| for cell in cells: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| cell_dir = output_dir / "cells" / cell.cell_id | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -406,6 +424,7 @@ def execute( | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| "state": "running", | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| "attempt": attempt, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| "started_at": started_at, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| "manifest_digest": plan["manifest_digest"], | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| run_dir = cell_dir / "attempts" / f"attempt-{attempt:03d}" / "run" | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -456,6 +475,7 @@ def execute( | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| "state": state, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| "attempt": attempt, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| "started_at": started_at, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| "manifest_digest": plan["manifest_digest"], | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| "exit_code": rc, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| "duration_seconds": run_meta.get("duration_seconds"), | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| "run_dir": str(run_dir), | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
Uh oh!
There was an error while loading. Please reload this page.