Skip to content

Commit 9fef4b4

Browse files
eval: make generation provenance explicit and resume-safe (#135)
* eval-generation-contract: extract shared generation semantics * eval-generation-capture: enforce generation-safe resume
1 parent 77e87f5 commit 9fef4b4

7 files changed

Lines changed: 759 additions & 225 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ venv/
1414
.coverage
1515
htmlcov/
1616
.pytest_cache/
17+
.ticket-run/
1718

1819
# IDE
1920
.vscode/

docs/EVAL_GUIDE.md

Lines changed: 30 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,7 @@ Run large-scale model comparisons across all backends. Results append to JSONL w
121121
|------|--------|---------|-------------|
122122
| `--config` | `all`, `ollama`, `llamaserver`, `llamafile`, `llamaserver-native`, `llamaserver-prompt`, `anthropic`, `anthropic-any`, `haiku`, `sonnet`, `opus`, `haiku-any`, `sonnet-any`, `opus-any` | `all` | Config set to run |
123123
| `--runs` | int | `50` | Runs per scenario |
124+
| `--generation` | non-negative int | `0` | Comparability epoch written to every row; released runs select a nonzero generation |
124125
| `--output` | path | `eval_results.jsonl` | JSONL output path |
125126
| `--scenario` | name(s) | all | Run specific scenario(s) |
126127
| `--tags` | tag(s) | all | Filter scenarios by tag |
@@ -153,7 +154,8 @@ python -m tests.eval.batch_eval --config llamaserver --model 8b-reasoning --runs
153154
python -m tests.eval.batch_eval --config ollama --runs 50 --scenario basic_2step sequential_reasoning
154155
```
155156

156-
Resume is automatic: re-run the same command and it skips completed scenarios.
157+
Resume is automatic: re-run the same command with the same generation and
158+
policy and it skips completed scenarios.
157159

158160
---
159161

@@ -173,11 +175,33 @@ python -m tests.eval.report \
173175

174176
Generating from a single file is fine for a quick look at one release in isolation, but it drops every model not present in that file — including the carried-forward older generations — so do not commit a single-file render as the shipped dashboard. `batch_eval` writes to `eval_results.jsonl` by default; rename to a versioned filename before committing to the repo.
175177

176-
### Eval generations and post-release addenda
177-
178-
The `gen` field (an integer injected per-row, legend in `report.py:GEN_INFO`) is a **comparability epoch, not a release version**. It is bumped only when a change is judged eval-material; many releases can share one gen, and a single gen can span several eval waves merged across files (`dedup_latest_gen` keeps the newest gen per config). This decouples "did we add models / re-sweep" from "did we cut a release" — adding models does not require a version bump.
179-
180-
To fold new models into an existing dataset, stamp them with that dataset's `gen` and append the rows. Because they are net-new configs, no existing number is recomputed and they slot into the leaderboard as same-gen peers (no carry-forward badge).
178+
### Eval generations and collection
179+
180+
The `gen` field is a **comparability epoch, not a release version**. It is
181+
bumped only when a change is judged eval-material; many releases can share one
182+
generation, and one generation can span several eval waves merged across files
183+
(`dedup_latest_gen` keeps the newest generation per config). Generation 0 is
184+
reserved for scratch runs and legacy rows that predate the field. Every new row
185+
records an explicit generation; invoke a released collection with, for example,
186+
`--generation 4` rather than adding the field after collection.
187+
188+
One output file carries only one effective generation. Before dry-run or live
189+
collection starts a client/server or appends a row, `batch_eval` streams the
190+
existing file and rejects generation mismatches, mixed generations, malformed
191+
generation values, malformed JSON, and ambiguous resume rows. A legacy file
192+
whose rows all omit `gen` is generation 0 and can only resume with generation
193+
0. For resume identity, historical rows without `reasoning_replay` mean `full`
194+
(the behavior they actually ran); they do not collide with an explicit modern
195+
`none` arm. Same-generation, same-policy resume remains count-based.
196+
197+
To fold new models into an existing dataset, run the collector with that
198+
dataset's generation and append the rows. Because they are net-new configs, no
199+
existing number is recomputed and they slot into the leaderboard as
200+
same-generation peers (no carry-forward badge).
201+
202+
An output is single-process owned while collection is active. Concurrent
203+
writers and file locking are unsupported; schedule separate output files and
204+
merge them only after validating their generation.
181205

182206
Addenda to date:
183207

tests/eval/batch_eval.py

Lines changed: 114 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727

2828
from tests.eval.ablation import ABLATION_PRESETS, AblationConfig
2929
from tests.eval.eval_runner import EvalConfig, RunResult, run_scenario
30+
from tests.eval.generation import effective_generation, effective_reasoning_replay
3031
from tests.eval.metrics import analyze_history, compute_metrics, count_wire_reasoning
3132
from tests.eval.scenarios import ALL_SCENARIOS, EvalScenario
3233

@@ -350,55 +351,132 @@ def _run_key(
350351
)
351352

352353

353-
def _count_completed_runs(
354+
def _validate_generation(generation: Any, *, context: str = "generation") -> int:
355+
"""Return a valid eval generation or fail closed."""
356+
if type(generation) is not int or generation < 0:
357+
raise ValueError(
358+
f"{context} must be a non-negative integer (bool is not allowed), "
359+
f"got {generation!r}"
360+
)
361+
return generation
362+
363+
364+
def _parse_generation(value: str) -> int:
365+
"""Argparse type for non-negative eval generations."""
366+
try:
367+
generation = int(value)
368+
except ValueError as exc:
369+
raise ValueError("generation must be a non-negative integer") from exc
370+
return _validate_generation(generation)
371+
372+
373+
def _preflight_completed_runs(
354374
jsonl_path: Path,
375+
requested_generation: int,
355376
ablation_name: str = "reforged",
356377
) -> dict[str, int]:
357-
"""Scan JSONL and count completed runs per resume key (see ``_run_key``).
378+
"""Validate append compatibility and count runs in one streaming pass.
358379
359-
Returns dict mapping the canonical run key → count. Records without an
360-
ablation field are treated as "reforged", without tool_choice as "auto",
361-
and without reasoning_replay as the default policy (none) — so
362-
pre-knob dumps resume cleanly under the default and are re-run under a
363-
different policy.
380+
Every stored row participates in the single-generation check, while only
381+
rows for ``ablation_name`` contribute to the resume map. Historical rows
382+
without ``gen`` are generation 0 and rows without ``reasoning_replay`` used
383+
the historical ``full`` behavior.
364384
"""
385+
requested_generation = _validate_generation(requested_generation)
365386
counts: dict[str, int] = {}
366387
if not jsonl_path.exists():
367388
return counts
368-
with jsonl_path.open() as f:
369-
for line in f:
389+
390+
file_generation: int | None = None
391+
with jsonl_path.open("rb") as f:
392+
for line_number, raw_line in enumerate(f, 1):
393+
try:
394+
line = raw_line.decode("utf-8")
395+
except UnicodeDecodeError as exc:
396+
raise ValueError(
397+
f"{jsonl_path}:{line_number}: invalid UTF-8 JSONL row"
398+
) from exc
370399
line = line.strip()
371400
if not line:
372401
continue
373402
try:
374403
row = json.loads(line)
375-
except json.JSONDecodeError:
376-
continue
404+
except json.JSONDecodeError as exc:
405+
raise ValueError(
406+
f"{jsonl_path}:{line_number}: malformed JSON: {exc.msg}"
407+
) from exc
408+
if not isinstance(row, dict):
409+
raise ValueError(
410+
f"{jsonl_path}:{line_number}: JSONL row must be an object"
411+
)
412+
try:
413+
row_generation = _validate_generation(
414+
effective_generation(row), context="gen"
415+
)
416+
except ValueError as exc:
417+
raise ValueError(f"{jsonl_path}:{line_number}: {exc}") from exc
418+
if file_generation is None:
419+
file_generation = row_generation
420+
elif row_generation != file_generation:
421+
raise ValueError(
422+
f"{jsonl_path}:{line_number}: mixed effective generations "
423+
f"{file_generation} and {row_generation}"
424+
)
425+
377426
row_ablation = row.get("ablation", "reforged")
378427
if row_ablation != ablation_name:
379428
continue
380-
row_tc = row.get("tool_choice", "auto")
381-
row_rr = row.get("reasoning_replay", DEFAULT_REASONING_REPLAY)
382-
row_rl = row.get("reasoning_level", "default")
383-
key = _run_key(
384-
row["model"], row["backend"], row["mode"],
385-
row_ablation, row_tc, row_rr, row_rl, row["scenario"],
386-
)
429+
try:
430+
key = _run_key(
431+
row["model"], row["backend"], row["mode"],
432+
row_ablation, row.get("tool_choice", "auto"),
433+
effective_reasoning_replay(row),
434+
row.get("reasoning_level", "default"), row["scenario"],
435+
)
436+
except (KeyError, TypeError, ValueError) as exc:
437+
raise ValueError(
438+
f"{jsonl_path}:{line_number}: cannot build resume key: {exc}"
439+
) from exc
387440
counts[key] = counts.get(key, 0) + 1
441+
442+
if file_generation is not None and file_generation != requested_generation:
443+
raise ValueError(
444+
f"{jsonl_path}: existing effective generation {file_generation} "
445+
f"does not match requested generation {requested_generation}"
446+
)
388447
return counts
389448

390449

450+
def _append_jsonl_row(jsonl_path: Path, row: dict[str, Any]) -> None:
451+
"""Append one UTF-8 row, separating an unterminated existing final row."""
452+
payload = (json.dumps(row) + "\n").encode("utf-8")
453+
with jsonl_path.open("ab+") as f:
454+
f.seek(0, os.SEEK_END)
455+
size = f.tell()
456+
separator = b""
457+
if size:
458+
f.seek(-1, os.SEEK_END)
459+
if f.read(1) != b"\n":
460+
separator = b"\n"
461+
f.seek(0, os.SEEK_END)
462+
f.write(separator + payload)
463+
464+
391465
def _run_result_to_row(
392466
result: RunResult,
393467
config: BatchConfig,
394468
scenario: EvalScenario,
395469
run_idx: int,
470+
*,
471+
generation: int,
396472
budget_tokens: int | None = None,
397473
ablation_name: str = "reforged",
398474
reasoning_replay: str = DEFAULT_REASONING_REPLAY,
399475
) -> dict[str, Any]:
400476
"""Convert a RunResult into a flat dict for JSONL output."""
477+
generation = _validate_generation(generation)
401478
row: dict[str, Any] = {
479+
"gen": generation,
402480
"model": config.model,
403481
"backend": config.backend,
404482
"mode": config.mode,
@@ -798,6 +876,7 @@ async def run_batch(
798876
scenario_names: list[str] | None = None,
799877
ablation: AblationConfig | None = None,
800878
reasoning_replay: ReasoningReplay = DEFAULT_REASONING_REPLAY,
879+
generation: int = 0,
801880
) -> None:
802881
"""Run all configs × scenarios, appending each result to JSONL.
803882
@@ -808,6 +887,8 @@ async def run_batch(
808887
from forge.context.strategies import TieredCompact
809888
from tests.eval.eval_runner import _COMPACTION_SCENARIOS
810889

890+
generation = _validate_generation(generation)
891+
811892
if scenario_names:
812893
name_set = set(scenario_names)
813894
scenarios = [s for s in ALL_SCENARIOS if s.name in name_set]
@@ -822,7 +903,9 @@ async def run_batch(
822903
scenarios = ALL_SCENARIOS
823904

824905
ablation_name = ablation.name if ablation is not None else "reforged"
825-
completed_counts = _count_completed_runs(output_path, ablation_name=ablation_name)
906+
completed_counts = _preflight_completed_runs(
907+
output_path, generation, ablation_name=ablation_name
908+
)
826909

827910
# Precompute total expected runs (excluding skips and unavailable models)
828911
total_expected = 0
@@ -954,12 +1037,12 @@ async def run_batch(
9541037

9551038
row = _run_result_to_row(
9561039
result, config, scenario, run_idx + 1,
1040+
generation=generation,
9571041
budget_tokens=scenario_budget,
9581042
ablation_name=ablation_name,
9591043
reasoning_replay=reasoning_replay,
9601044
)
961-
with output_path.open("a") as f:
962-
f.write(json.dumps(row) + "\n")
1045+
_append_jsonl_row(output_path, row)
9631046

9641047
completed_counts[key] = completed_counts.get(key, 0) + 1
9651048
continue
@@ -1141,12 +1224,12 @@ async def run_batch(
11411224

11421225
row = _run_result_to_row(
11431226
result, config, scenario, run_idx + 1,
1227+
generation=generation,
11441228
budget_tokens=scenario_budget,
11451229
ablation_name=ablation_name,
11461230
reasoning_replay=reasoning_replay,
11471231
)
1148-
with output_path.open("a") as f:
1149-
f.write(json.dumps(row) + "\n")
1232+
_append_jsonl_row(output_path, row)
11501233

11511234
# Update in-memory count for resume correctness
11521235
completed_counts[key] = completed_counts.get(key, 0) + 1
@@ -1179,6 +1262,12 @@ async def main() -> None:
11791262
budget_choices = [m.value for m in BudgetMode]
11801263
parser = argparse.ArgumentParser(description="Forge batch eval runner")
11811264
parser.add_argument("--runs", type=int, default=50, help="Runs per scenario")
1265+
parser.add_argument(
1266+
"--generation",
1267+
type=_parse_generation,
1268+
default=0,
1269+
help="Non-negative eval comparability generation (default: 0)",
1270+
)
11821271
parser.add_argument(
11831272
"--output", type=str, default=None, help="JSONL output path"
11841273
)
@@ -1262,6 +1351,7 @@ async def main() -> None:
12621351
print(f" Budget mode: {budget_mode.value}")
12631352
print(f" Ablation: {ablation.name}")
12641353
print(f" Reasoning replay: {args.reasoning_replay}")
1354+
print(f" Generation: {args.generation}")
12651355
if args.scenario:
12661356
print(f" Scenarios: {', '.join(args.scenario)}")
12671357
elif args.tags:
@@ -1287,6 +1377,7 @@ async def main() -> None:
12871377
scenario_names=args.scenario,
12881378
ablation=ablation,
12891379
reasoning_replay=args.reasoning_replay,
1380+
generation=args.generation,
12901381
)
12911382

12921383

tests/eval/generation.py

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
"""Shared eval-generation and historical replay semantics.
2+
3+
This module is deliberately independent of Forge runtime imports so reporting
4+
and collection can use one small contract for interpreting stored eval rows.
5+
"""
6+
7+
from __future__ import annotations
8+
9+
from typing import Any
10+
11+
12+
BaseConfigurationIdentity = tuple[Any, Any, Any, Any, Any, Any]
13+
ExplicitPolicyIdentity = tuple[Any, Any, Any, Any, Any, Any, Any]
14+
15+
16+
def effective_generation(row: dict[str, Any]) -> int:
17+
"""Return a row's generation, treating a missing field as generation 0."""
18+
return row.get("gen", 0)
19+
20+
21+
def effective_reasoning_replay(row: dict[str, Any]) -> str:
22+
"""Return replay policy, treating a legacy missing field as ``full``."""
23+
return row.get("reasoning_replay", "full")
24+
25+
26+
def base_configuration_identity(row: dict[str, Any]) -> BaseConfigurationIdentity:
27+
"""Return the whole-configuration identity used for legacy supersession."""
28+
return (
29+
row["model"],
30+
row["backend"],
31+
row["mode"],
32+
row.get("ablation", "reforged"),
33+
row.get("tool_choice", "auto"),
34+
row.get("reasoning_level", "default"),
35+
)
36+
37+
38+
def explicit_policy_identity(row: dict[str, Any]) -> ExplicitPolicyIdentity:
39+
"""Return base identity plus the row's stored replay-policy value."""
40+
return base_configuration_identity(row) + (row.get("reasoning_replay"),)
41+
42+
43+
def select_latest_generation(rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
44+
"""Select report-compatible newest-generation rows in input order.
45+
46+
Legacy rows without an explicit replay field are superseded by the newest
47+
generation of their base configuration. Rows with an explicit replay field
48+
are superseded only by newer rows of the same explicit policy. Every row at
49+
the relevant maximum generation is retained.
50+
51+
Selection is whole-configuration rather than per-scenario. The returned
52+
list contains the original row objects and preserves their input order.
53+
"""
54+
base_max: dict[BaseConfigurationIdentity, int] = {}
55+
policy_max: dict[ExplicitPolicyIdentity, int] = {}
56+
for row in rows:
57+
generation = effective_generation(row)
58+
base_identity = base_configuration_identity(row)
59+
policy_identity = explicit_policy_identity(row)
60+
if generation > base_max.get(base_identity, -1):
61+
base_max[base_identity] = generation
62+
if generation > policy_max.get(policy_identity, -1):
63+
policy_max[policy_identity] = generation
64+
65+
selected: list[dict[str, Any]] = []
66+
for row in rows:
67+
generation = effective_generation(row)
68+
if "reasoning_replay" in row:
69+
if generation == policy_max[explicit_policy_identity(row)]:
70+
selected.append(row)
71+
elif generation == base_max[base_configuration_identity(row)]:
72+
selected.append(row)
73+
return selected

0 commit comments

Comments
 (0)