Skip to content

Benchmark zoo + cap-evolve benchmark add/verify + declarative manifest - #233

Open
OsherElhadad wants to merge 2 commits into
mainfrom
feat/issue-141-benchmark-zoo
Open

Benchmark zoo + cap-evolve benchmark add/verify + declarative manifest#233
OsherElhadad wants to merge 2 commits into
mainfrom
feat/issue-141-benchmark-zoo

Conversation

@OsherElhadad

Copy link
Copy Markdown
Collaborator

Closes #141

What this does

Onboarding a benchmark was a from-scratch, per-user effort: hand-write a CapabilityAdapter subclass, prune a ~100-line capevolve.yaml, author a bespoke optimizer/INSTRUCTIONS.md. This adds the three pieces #141 asks for — a curated verifier-gated library (benchmarks/), a cap-evolve benchmark add|verify|list subcommand, and a declarative manifest — plus the docs.

The boilerplate, measured first

I diffed the two generic bundled templates (templates/adapters/jsonl_litellm vs huggingface_litellm): 78 changed lines out of 127, and the changes are almost entirely the dataset-loading block.

Repeats verbatim across every benchmark Genuinely per-benchmark
module preamble + sys.path juggling how the target agent runs
dataset → Task loop a bespoke match predicate (sometimes)
if rollout.error: infra-noise branch of score
the exact/contains/regex match helper
Score(task_id=…, reward=…, feedback=…, trial_rewards=[…])
the whole capevolve.yaml except ~6 values

Measured reduction — same benchmark (toy_calc), hand-authored non-blank lines

Before (hand-written adapter) After (manifest)
Python 43 (adapter.py: 3 methods + apply) 18 (target.py: one run())
YAML 35 (capevolve.yaml from the template) 18 (benchmark.yaml)
adapters/adapter.py 0 — generated
capevolve.yaml 0 — generated
Total 78 36 (−54%)

For the documented generic LLM case (jsonl_litellm) the "before" is 101 (88 Python + 13 YAML) against the same 36.

Declarative vs code (and why)

benchmark.yaml declares: dataset file + field mapping, scoring mode, metric direction, capability path, split seed/ratios/pinned ids, trial count, protected paths. An unknown key is a hard error — a silently-ignored key in an honesty-critical config is exactly how "I declared it" and "it applied" drift apart.

target.py stays code: run(task, ctx, *, seed=0) — how the target agent runs. There is deliberately no config language for it. Running an agent is real logic; a DSL reimplementing Python would be worse than the Python it replaced, and the repo's norm is lazy-correct with zero runtime deps. Same reasoning for scoring: custom + score(task, rollout): the new json_extract entry does per-field partial credit over parsed JSON, which no config key should try to express. The five built-in modes (exact/contains/regex/numeric/custom) cover the rest.

ManifestAdapter then is the adapter, so adapters/adapter.py is a generated 3-line subclass and the contract is satisfied without the user seeing it.

Exactly what verify executes

Not a manifest parse. Every guard in this epic that measured the wrong artifact passed its own test vacuously (#189 counts, #202 chart DOM, #207 npm test, #208 install.sh, #213 frontmatter), so verify runs the benchmark:

  1. manifest parse + field validation;
  2. dataset load through the real adapter — missing file / duplicate ids / empty all fail here;
  3. cap_evolve.check.run_check on the generated project (stubs, task stability, scorer determinism, pure materialize);
  4. seeded split + the honest-gate floor: val >= MIN_VAL_TASKS (read from splits when Guard tiny/empty val splits and add a small-sample correction to the paired gate #113 lands, same literal otherwise) and a non-empty sealed test split — a 3-task dataset fails here, not mid-run inside gate.decide;
  5. a real zero-API smoke eval: every val task through live()run_target()score(), twice, comparing rollout fingerprints and rewards. This is the step that catches a non-deterministic run_target()check never runs the target at all;
  6. protected-paths resolution — the grader, dataset and manifest must all be covered.

verified.json records the measured val reward, split sizes, dataset SHA-256 and the ordered step list. benchmark list reads that stamp from disk: a committed verified: flag is a claim, the stamp is evidence (there's a test that a flag flipped to true with no stamp still reads unverified).

Note on reusing check: the review that found a raising materialize() still yields ok: true is why steps 4–6 exist independently rather than trusting check alone — and why verify also asserts on the smoke outcome, not just ok.

Protected paths / #142

A zoo entry keeps the manifest, scorer and dataset inside project/ (which is the cap-evolve project dir). That is not cosmetic: my first pass parked them at the benchmark root, and #197's resolve_protected() hashed only adapters/adapter.py — a declared-but-unprotected grader, caught by actually running the guard rather than reading it. With the current layout the guard resolves all four declared paths and a tampered target.py raises TamperError. Evidence in the comment below.

CLI

cap-evolve --help now generates the subcommand listing from COMMANDS + each handler's first docstring line (a test asserts the old literal version|splits|check|run|estimate|dashboard string is gone). This is the same shape as #137/#214, so adding benchmark needed no manual edit to a string five branches conflict on. Each handler owns its own --help; stdout stays exactly one JSON object on every path, including errors ({"ok": false, "error": …}), and exit is 1 on any problem.

Expected merge order

  1. Protected-paths tamper guard: verify the optimizer never edited scoring/eval/task files #142 / PR Protected-paths tamper guard: verify the optimizer never edited scoring/eval/task files #197 (protected-paths guard) — this PR's manifests declare protected_paths, and verify step 6 imports protect when present (graceful ImportError note otherwise, so it merges in either order).
  2. Guard tiny/empty val splits and add a small-sample correction to the paired gate #113 / fix/issue-113-small-samplesMIN_VAL_TASKS is read from splits when present, so after that merge the verify floor and gate.decide share one constant automatically. No conflict either way.
  3. CLI ergonomics overhaul: structured flags, did-you-mean, per-subcommand help, UTF-8/exit codes #137 / feat/issue-137-cli-ergonomics — both generate the listing from COMMANDS; expect a small textual conflict in main()/the module docstring, resolvable by keeping CLI ergonomics overhaul: structured flags, did-you-mean, per-subcommand help, UTF-8/exit codes #137's _parser() helper and this PR's COMMANDS row.

No overlap with #199/#191/#193/#190/#205 beyond the COMMANDS dict row.

Verification

Full output in the 🔬 Evidence comment. Summary:

  • Suite: 203 passed in 60.94s (baseline 179 + 24 new), 0 failed.
  • compileall on core + benchmarks: clean (exit 0). No skills/ changes, so no manifest rebuild.
  • Real e2e through the new flow, toy_calc: baseline_val 0.0 → test_reward 1.0, test_delta 1.0, gate-accepted, test sealed.
  • Second entry json_extract (custom scoring): baseline_val 0.0 → test_reward 1.0, and a graded signal — prose 0.0, [JSON] 1/3, [JSON]+[FIELDS] 1.0.
  • From-scratch scaffold: benchmark add demoverify demook: true with no hand-editing.
  • All four breakage cases fail with an actionable message and exit 1: stubbed score(), non-deterministic run_target(), 3-task dataset (tiny val + empty test), missing dataset file. Plus duplicate task ids and under-declared protected_paths.
  • Protected-paths tamper guard: verify the optimizer never edited scoring/eval/task files #197 guard: protected manifest = ['adapters/adapter.py', 'benchmark.yaml', 'target.py', 'tasks.jsonl']; clean verify []; tampering target.pyTamperError: … modified target.py.
  • cap-evolve --help lists benchmark from the generated listing; benchmark --help renders its own subparsers.

…ifest

Onboarding a benchmark was a from-scratch effort: a hand-written CapabilityAdapter
subclass plus a ~100-line capevolve.yaml. Diffing the two generic bundled templates
(jsonl_litellm vs huggingface_litellm) shows what actually repeats — module
preamble, the dataset->Task loop, the infra-error branch of score, the match
helper, the Score(...) construction, and nearly the whole spec. What does not
repeat is one thing: how the target agent runs.

So benchmark.yaml declares the repeating half and target.py keeps run(task, ctx,
*, seed=0) as code. There is deliberately no config language for run() — running
an agent is real logic, and a DSL reimplementing Python would be worse than the
Python it replaced. `scoring: custom` keeps a bespoke predicate as code too.

Measured on the same benchmark (toy_calc): 78 -> 36 hand-authored non-blank lines
(-54%); adapters/adapter.py and capevolve.yaml are generated (0 hand-authored).

`cap-evolve benchmark verify` EXECUTES the benchmark rather than parsing its
manifest: the real check gate, a dataset load through the real adapter, the seeded
split plus the honest-gate floor (val >= MIN_VAL_TASKS and a non-empty sealed test
split, so a 3-task dataset fails at verify rather than mid-run inside
gate.decide), and a real zero-API smoke eval — every val task through live() ->
run_target() -> score(), twice, comparing rollout fingerprints and rewards, which
is what catches a non-deterministic run_target() (check never runs the target).
verified.json records the measured reward, split sizes and dataset SHA-256;
`benchmark list` reads that stamp from disk, not the manifest's `verified:` flag.

A zoo entry keeps the manifest, scorer and dataset INSIDE the project dir so
#142's tamper guard covers them by construction — a first pass parked them at the
benchmark root and resolve_protected() hashed only adapters/adapter.py, i.e. a
declared-but-unprotected grader.

`cap-evolve --help` now generates the subcommand listing from COMMANDS plus each
handler's docstring, so adding a subcommand no longer edits a literal usage string
five branches all conflict on.
Copilot AI review requested due to automatic review settings July 30, 2026 10:42

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@skillberry-bot skillberry-bot added enhancement New feature or request documentation Improvements or additions to documentation dx Developer/onboarding experience priority-p1 High impact labels Jul 30, 2026
@skillberry-bot

Copy link
Copy Markdown
Contributor

🏷️ Automatic Labeling

I've analyzed this pull request and added the following labels:

  • enhancement - documentation - dx - enhancement - documentation - dx - priority-p1

These labels were selected based on the PR title, description, and changed files. If you believe any labels are incorrect or missing, feel free to adjust them manually.

@OsherElhadad

Copy link
Copy Markdown
Collaborator Author

🔬 Evidence

All commands run in /tmp/wt-141 (worktree off origin/main @ 031dc72f), venv /tmp/ce-venv/bin/python.

1. Baseline suite (before any change)

$ PYTHONPATH=/tmp/wt-141/core python -m pytest core/tests -q
........................................................................ [ 40%]
........................................................................ [ 80%]
...................................                                      [100%]
179 passed in 64.61s (0:01:04)

2. The boilerplate, measured

$ diff templates/adapters/jsonl_litellm/adapter.py templates/adapters/huggingface_litellm/adapter.py | grep -c "^[<>]"
78
$ python measure.py    # tokenize-based: excludes blanks, comments and docstrings
SAME BENCHMARK (toy_calc), hand-authored lines a user must write:
  BEFORE  examples/toy_calc/adapter.py           43 python
          templates/project/capevolve.yaml       35 yaml
          TOTAL                                  78
  AFTER   project/target.py                      18 python
          project/benchmark.yaml                 18 yaml
          project/adapters/adapter.py             0 GENERATED (3 lines)
          project/capevolve.yaml                  0 GENERATED (21 lines)
          TOTAL                                  36

GENERIC LLM BENCHMARK (jsonl_litellm, the documented common case):
  BEFORE  adapter.py 88 python + capevolve.yaml 13 yaml = 101

3. The whole generated adapter

$ cat benchmarks/toy_calc/project/adapters/adapter.py
from cap_evolve.zoo import ManifestAdapter


class Adapter(ManifestAdapter):
    """toy_calc — everything is declared in ../benchmark.yaml."""

    manifest_path = __file__

4. Scaffold from scratch → verify (no hand-editing)

$ cap-evolve benchmark add demo --description "does the agent echo the question"
{
  "name": "demo",
  "dir": "/private/tmp/ev-demo/demo",
  "project": "/private/tmp/ev-demo/demo/project",
  "manifest": "/private/tmp/ev-demo/demo/project/benchmark.yaml",
  "files": [
    "README.md",
    "project/adapters/adapter.py",
    "project/benchmark.yaml",
    "project/capevolve.yaml",
    "project/seed_capability/prompt.txt",
    "project/target.py",
    "project/tasks.jsonl"
  ],
  "next": "cap-evolve benchmark verify /private/tmp/ev-demo/demo"
}

$ cap-evolve benchmark verify demo; echo "exit=$?"
{
  "name": "demo",
  "ok": true,
  "steps": [
    "manifest parsed + validated",
    "dataset loaded through the adapter: 8 task(s)",
    "cap-evolve check executed on the generated project",
    "splits computed: {'train': 4, 'val': 2, 'test': 2}",
    "REAL smoke eval: 2 val task(s) x 2 passes through live() -> run_target() -> score()",
    "protected paths declared + resolved: ['adapters', 'benchmark.yaml', 'target.py', 'tasks.jsonl']"
  ],
  "problems": [],
  "notes": [
    "check: tasks('val') -> 8 task(s)",
    "check: scorer deterministic (probe reward=0.0000)",
    "check: materialize() callable (dry-run into temp copy; host untouched)",
    "smoke val reward (seed capability) = 0.0",
    "protect module absent (pre-#142); manifest declaration checked only"
  ],
  "val_reward": 0.0,
  "n_tasks": 8,
  "splits": {
    "train": 4,
    "val": 2,
    "test": 2
  },
  "protected": [],
  "stamp": "demo/verified.json"
}
exit=0

5. cap-evolve benchmark list — the curated zoo

$ cap-evolve benchmark list
{
  "zoo": "/private/tmp/wt-141/benchmarks",
  "benchmarks": [
    {
      "name": "json_extract",
      "dir": "/private/tmp/wt-141/benchmarks/json_extract",
      "description": "Structured-JSON extraction accuracy (per-field partial credit) for a deterministic zero-API extractor whose prompt is optimized.",
      "scoring": "custom",
      "metric_direction": "higher",
      "verified": true,
      "verified_at": "2026-07-30T10:29:22+00:00",
      "smoke_val_reward": 0.0,
      "n_tasks": 12
    },
    {
      "name": "toy_calc",
      "dir": "/private/tmp/wt-141/benchmarks/toy_calc",
      "description": "Arithmetic accuracy of a deterministic zero-API stand-in agent whose system prompt is optimized.",
      "scoring": "exact",
      "metric_direction": "higher",
      "verified": true,
      "verified_at": "2026-07-30T10:19:01+00:00",
      "smoke_val_reward": 0.0,
      "n_tasks": 8
    }
  ]
}

6. Real zero-API cap-evolve run through the new flow → sealed test number

toy_calc (declarative scoring: exact):

$ cap-evolve benchmark verify /tmp/ev-run/toy_calc
{
  "name": "toy_calc",
  "ok": true,
  "steps": [
    "manifest parsed + validated",
    "dataset loaded through the adapter: 8 task(s)",
    "cap-evolve check executed on the generated project",
    "splits computed: {'train': 4, 'val': 2, 'test': 2}",
    "REAL smoke eval: 2 val task(s) x 2 passes through live() -> run_target() -> score()",
    "protected paths declared + resolved: ['adapters', 'benchmark.yaml', 'target.py', 'tasks.jsonl']"
  ],
  "problems": [],
  "notes": [
    "check: tasks('val') -> 8 task(s)",
    "check: scorer deterministic (probe reward=0.0000)",
    "check: materialize() callable (dry-run into temp copy; host untouched)",
    "smoke val reward (seed capability) = 0.0",
    "protect module absent (pre-#142); manifest declaration checked only"
  ],
  "val_reward": 0.0,
  "n_tasks": 8,
  "splits": {
    "train": 4,
    "val": 2,
    "test": 2
  },
  "protected": [],
  "stamp": "/tmp/ev-run/toy_calc/verified.json"
}

$ cap-evolve run --spec project/capevolve.yaml --project project --run-ts demo --dashboard off
{
  "run_dir": "toy_calc/run_demo",
  "best_id": "cand_0001",
  "baseline_val": 0.0,
  "test_reward": 1.0,
  "test_baseline_reward": 0.0,
  "test_delta": 1.0,
  "test_pass_k": {
    "1": 1.0,
    "2": 0.0
  },
  "iterations": 3,
  "dashboard": "toy_calc/run_demo/dashboard.html"
}

json_extract (scoring: custom, per-field partial credit):

$ cap-evolve run --spec project/capevolve.yaml --project project --run-ts demo --dashboard off
{
  "run_dir": "json_extract/run_demo",
  "best_id": "cand_0001",
  "baseline_val": 0.0,
  "test_reward": 1.0,
  "test_baseline_reward": 0.0,
  "test_delta": 1.0,
  "test_pass_k": {
    "1": 1.0,
    "2": 0.0
  },
  "iterations": 3,
  "dashboard": "json_extract/run_demo/dashboard.html"
}

Graded (non-binary) signal, asserted in test_custom_scoring_entry_gives_graded_partial_credit:

seed prompt (prose)          val = 0.0000
+ [JSON]                     val = 0.3333
+ [JSON] + [FIELDS]          val = 1.0000

7. verify catches real breakage (all four cases, each exits 1)

Case: stubbed score()

$ cap-evolve benchmark verify /tmp/ev-brk/stub --no-stamp
{
  "name": "toy_calc",
  "ok": false,
  "steps": [
    "manifest parsed + validated",
    "dataset loaded through the adapter: 8 task(s)",
    "cap-evolve check executed on the generated project",
    "splits computed: {'train': 4, 'val': 2, 'test': 2}"
  ],
  "problems": [
    "cap-evolve check: unimplemented adapter methods: score \u2014 implement them in adapters/adapter.py"
  ],
  "notes": [],
  "val_reward": null,
  "n_tasks": 8,
  "splits": {
    "train": 4,
    "val": 2,
    "test": 2
  },
  "protected": []
}
exit=1

Case: non-deterministic run_target()

$ cap-evolve benchmark verify /tmp/ev-brk/nondet --no-stamp
{
  "name": "toy_calc",
  "ok": false,
  "steps": [
    "manifest parsed + validated",
    "dataset loaded through the adapter: 8 task(s)",
    "cap-evolve check executed on the generated project",
    "splits computed: {'train': 4, 'val': 2, 'test': 2}",
    "REAL smoke eval: 2 val task(s) x 2 passes through live() -> run_target() -> score()",
    "protected paths declared + resolved: ['adapters', 'benchmark.yaml', 'target.py', 'tasks.jsonl']"
  ],
  "problems": [
    "NON-DETERMINISTIC: 2 task(s) produced a different rollout or reward on an identical re-run with seed=0 \u2014 e.g. ['a1', 'a4']: [('888e2e667464f455', 0.0), ('47bd007ffa5fae80', 0.0)] vs [('f6c1413c2f8b8512', 0.0), ('497e5a99d3c74325', 0.0)]. A benchmark whose rollouts drift at a fixed seed cannot produce a reproducible number. Make target.py's run() a function of (task, candidate, seed) only, and forward `seed` to any sampler."
  ],
  "notes": [
    "check: tasks('val') -> 8 task(s)",
    "check: scorer deterministic (probe reward=0.0000)",
    "check: materialize() callable (dry-run into temp copy; host untouched)",
    "smoke val reward (seed capability) = 0.0",
    "protect module absent (pre-#142); manifest declaration checked only"
  ],
  "val_reward": 0.0,
  "n_tasks": 8,
  "splits": {
    "train": 4,
    "val": 2,
    "test": 2
  },
  "protected": []
}
exit=1

Case: 3-task dataset (tiny val)

$ cap-evolve benchmark verify /tmp/ev-brk/tiny --no-stamp
{
  "name": "toy_calc",
  "ok": false,
  "steps": [
    "manifest parsed + validated",
    "dataset loaded through the adapter: 3 task(s)",
    "cap-evolve check executed on the generated project",
    "splits computed: {'train': 2, 'val': 1, 'test': 0}"
  ],
  "problems": [
    "val split has 1 task(s), below the honest-gate minimum of 2: SE(\u0394) would have 0 degrees of freedom, so every accept/reject is meaningless (the gate itself refuses this mid-run). This benchmark has 3 task(s) total \u2014 add more tasks, or raise split_val above 0.25 in /tmp/ev-brk/tiny/project/benchmark.yaml.",
    "test split is EMPTY \u2014 there is no sealed held-out set, so this benchmark cannot produce an honest headline number. Add tasks or raise split_test above 0.25."
  ],
  "notes": [
    "check: tasks('val') -> 3 task(s)",
    "check: scorer deterministic (probe reward=0.0000)",
    "check: materialize() callable (dry-run into temp copy; host untouched)"
  ],
  "val_reward": null,
  "n_tasks": 3,
  "splits": {
    "train": 2,
    "val": 1,
    "test": 0
  },
  "protected": []
}
exit=1

Case: missing dataset file

$ cap-evolve benchmark verify /tmp/ev-brk/missing --no-stamp
{
  "name": "toy_calc",
  "ok": false,
  "steps": [
    "manifest parsed + validated"
  ],
  "problems": [
    "dataset/adapter load failed: dataset file missing: /private/tmp/ev-brk/missing/project/tasks.jsonl (declared as tasks_file='tasks.jsonl' in /private/tmp/ev-brk/missing/project/benchmark.yaml). Create it \u2014 one JSON object per line, e.g. {\"id\": \"t1\", \"input\": \"...\", \"target\": \"...\"} \u2014 or point tasks_file at the real dataset."
  ],
  "notes": [],
  "val_reward": null,
  "n_tasks": null,
  "splits": {},
  "protected": []
}
exit=1

8. Protected-paths declaration works with #197 (origin/feat/issue-142-protected-paths @ 887349ca)

Setup: git worktree add /tmp/wt-197 --detach origin/feat/issue-142-protected-paths, then zoo.py + _cmd_benchmark + benchmarks/ grafted on top of it.

verify step 6 resolves the declared paths through #197's own protect.resolve_protected():

$ cap-evolve benchmark verify . --no-stamp   # under PR #197
{
  "name": "toy_calc",
  "ok": true,
  "steps": [
    "manifest parsed + validated",
    "dataset loaded through the adapter: 8 task(s)",
    "cap-evolve check executed on the generated project",
    "splits computed: {'train': 4, 'val': 2, 'test': 2}",
    "REAL smoke eval: 2 val task(s) x 2 passes through live() -> run_target() -> score()",
    "protected paths declared + resolved: ['adapters', 'benchmark.yaml', 'target.py', 'tasks.jsonl']"
  ],
  "problems": [],
  "notes": [
    "check: tasks('val') -> 8 task(s)",
    "check: scorer deterministic (probe reward=0.0000)",
    "check: materialize() callable (dry-run into temp copy; host untouched)",
    "smoke val reward (seed capability) = 0.0"
  ],
  "val_reward": 0.0,
  "n_tasks": 8,
  "splits": {
    "train": 4,
    "val": 2,
    "test": 2
  },
  "protected": [
    "adapters/adapter.py",
    "benchmark.yaml",
    "target.py",
    "tasks.jsonl"
  ]
}

And the guard actually fires when the optimizer edits the grader:

$ python tamper.py
protected manifest (hashed at baseline):
   adapters/adapter.py      241fb2f82fbcdd62…
   benchmark.yaml           87e751daa3278e2f…
   target.py                25cfd9f142539b85…
   tasks.jsonl              2ba70d56bce14825…
clean verify after an optimizer step -> []

-- optimizer 'improves' by rewriting target.py's scorer --

TamperError: cap-evolve TAMPER DETECTED during after optimizer step: 1 protected file(s) changed under /private/tmp/ev-tamper/toy_calc/project — modified target.py. Protected paths are the scorer / eval harness / task data; a candidate that edits them is reward hacking, so this run is aborted (the score is discarded and the test split is NOT sealed). Optimize the capability, not the grader. Recorded hashes: .capevolve/run_tamper/protected.json. If a path is legitimately editable, remove it from `protected_paths` in capevolve.yaml and start a new run.

9. Generated CLI listing + benchmark --help

$ cap-evolve --help
usage: cap-evolve {version|splits|check|run|estimate|dashboard|benchmark} [args]

  version    Print the installed cap-evolve version as JSON.
  splits     Compute the seeded train/val/test split for a set of task ids.
  check      Verify a project's adapter is fully implemented and deterministic.
  run        Sequence a whole optimization run: baseline -> algorithm -> sealed test -> report.
  estimate   Pre-run cost estimate without spending anything.
  dashboard  Launch (or focus) the live dashboard server over a base dir of runs.
  benchmark  Manage the benchmark zoo: list | add | verify a declarative benchmark.

run `cap-evolve <command> --help` for a command's own options.
$ cap-evolve benchmark --help
usage: cap-evolve benchmark [-h] {list,add,verify} ...

Manage the benchmark zoo: list | add | verify a declarative benchmark.

positional arguments:
  {list,add,verify}
    list             list the zoo with each entry's verified status
    add              scaffold a draft benchmark (manifest + one code file)
    verify           check gate + REAL smoke eval, then stamp verified.json

options:
  -h, --help         show this help message and exit

examples:
  cap-evolve benchmark list
  cap-evolve benchmark add my_bench --description 'what it measures'
  cap-evolve benchmark add my_bench --from-zoo toy_calc
  cap-evolve benchmark add my_bench --refresh   # regen project from manifest
  cap-evolve benchmark verify my_bench

The literal usage string is gone (asserted by test_cli_help_lists_benchmark_from_the_generated_listing):

$ grep -c "version|splits|check|run|estimate|dashboard" core/cap_evolve/cli.py
0
0

stdout stays exactly one JSON object even on the error path:

$ cd /tmp && cap-evolve benchmark verify nope; echo "exit=$?"
{
  "ok": false,
  "error": "no benchmark 'nope': no project/benchmark.yaml under /private/tmp/nope or /private/tmp/wt-141/benchmarks/nope. Zoo entries: ['json_extract', 'toy_calc']"
}
exit=1

10. Full suite + compileall

$ PYTHONPATH=/tmp/wt-141/core python -m pytest core/tests -q
........................................................................ [ 35%]
........................................................................ [ 70%]
...........................................................              [100%]
203 passed in 65.43s (0:01:05)

New tests only (24):

$ PYTHONPATH=/tmp/wt-141/core python -m pytest core/tests/test_benchmark_zoo.py -v
============================== 24 passed in 1.19s ==============================
$ python -m compileall -q core benchmarks; echo "exit=$?"
exit=0

$ python -m cap_evolve.zoo        # ponytail self-check
zoo predicate self-check: OK

$ git status --short skills/       # untouched, so no manifest rebuild needed
(empty)

11. Files touched

 CHANGELOG.md                                       |  28 +
 benchmarks/README.md                               |  76 ++
 benchmarks/json_extract/README.md                  |  17 +
 benchmarks/json_extract/mock_script.json           |   5 +
 .../json_extract/project/adapters/adapter.py       |   7 +
 benchmarks/json_extract/project/benchmark.yaml     |  31 +
 benchmarks/json_extract/project/capevolve.yaml     |  25 +
 .../project/seed_capability/prompt.txt             |   1 +
 benchmarks/json_extract/project/target.py          |  68 ++
 benchmarks/json_extract/project/tasks.jsonl        |  12 +
 benchmarks/json_extract/verified.json              |  22 +
 benchmarks/toy_calc/README.md                      |  21 +
 benchmarks/toy_calc/mock_script.json               |   5 +
 benchmarks/toy_calc/project/adapters/adapter.py    |   7 +
 benchmarks/toy_calc/project/benchmark.yaml         |  32 +
 benchmarks/toy_calc/project/capevolve.yaml         |  25 +
 .../toy_calc/project/seed_capability/prompt.txt    |   1 +
 benchmarks/toy_calc/project/target.py              |  35 +
 benchmarks/toy_calc/project/tasks.jsonl            |   8 +
 benchmarks/toy_calc/verified.json                  |  22 +
 core/cap_evolve/__init__.py                        |   3 +
 core/cap_evolve/cli.py                             |  84 ++-
 core/cap_evolve/zoo.py                             | 824 +++++++++++++++++++++
 core/tests/test_benchmark_zoo.py                   | 340 +++++++++
 docs/ADAPTER_CONTRACT.md                           |   7 +-
 docs/BENCHMARK_ZOO.md                              | 156 ++++
 templates/adapters/README.md                       |   8 +
 27 files changed, 1862 insertions(+), 8 deletions(-)

@OsherElhadad

Copy link
Copy Markdown
Collaborator Author

🔍 Review — PR #233

CHANGES REQUESTED.

The scaffolder, the manifest, the CLI wiring and the two zoo entries are solid, and the four claimed breakage cases all reproduce. But the headline claim — "verify is not a manifest parser, it runs the benchmark" — is only half true. It runs the benchmark mechanically (live()run_target()score(), twice, for real). It does not check that what ran measures anything. Nine of my twelve attacks made verify return ok: true on a benchmark that is worthless, including the two most obvious forms of reward hacking (a score() hard-wired to 1.0, and a run() that returns task.target). And benchmark list's "reads the stamp from disk" is a weaker guarantee than the PR says: the stamp is unauthenticated JSON in the benchmark dir, so a hand-written verified.json forges a verified badge with a text editor.


Blocking

B1. verify cannot tell a working benchmark from a self-answering one — zoo.py:754-757, zoo.py:727

The smoke eval measures val_reward and then does nothing with it except a note. The only case it comments on is val_reward == 1.0, and it emits a note, not a problem:

if rewards and all(r == rewards[0] for r in rewards) and rewards[0] == 1.0:
    rep.notes.append("the seed capability already scores 1.0 on every smoke task — ...")

So the two canonical reward hacks both verify clean:

# attack 1: appended to target.py; manifest still says `scoring: exact`
def score(task, rollout):
    return Score(task_id=task.id, reward=1.0, feedback="perfect", trial_rewards=[1.0])
ATTACK 1 always-1.0 score (manifest says scoring: exact): PASSED VERIFY []
  val_reward: 1.0
  index says: {... 'verified': True, 'smoke_val_reward': 1.0 ...}
# attack 2: run() returns the gold answer
def run(task, ctx, *, seed=0):
    return {"output": str(task.target), "trace": "I just read the gold answer"}
ATTACK 2 run() echoes task.target: PASSED VERIFY []
  val_reward: 1.0

Consequence: the zoo's central promise ("verifier-gated library") does not hold. A verified: true badge on a third-party contribution means "the code ran twice without crashing", not "this benchmark measures a capability". That is exactly the class of vacuous guard this epic filed #189/#202/#213 about — the artifact measured (does it execute) is not the artifact claimed (does it discriminate).

Fix — cheap and sufficient: verify already has the signal, it just discards it. A benchmark whose seed capability scores a perfect val_reward has zero headroom and cannot be optimized; that is a problems.append, not a note. Additionally add a degenerate-scorer probe: run the smoke loop a second time against a deliberately wrong output (Rollout(task_id=t.id, output="__CAPEVOLVE_WRONG__")) and require score() to return < 1.0 for at least one task. Two calls per benchmark, catches attack 1 outright and any scorer that ignores its input. Attack 2 is then caught by the perfect-score rule.

B2. Scoring custom code silently overrides the declared scoring mode — zoo.py:302

if callable(custom):  # a custom scorer always wins over the declared mode
    return custom(task, rollout)

A manifest that declares scoring: exact is honoured only if target.py happens not to define score. Consequence: the manifest — the thing #233 makes a hard error on unknown keys precisely so declaration and behaviour cannot drift — lies about how the benchmark is graded, and benchmark list reports 'scoring': 'exact' for a benchmark scored by arbitrary code (see the ATTACK 1 output above, which lists scoring: exact next to verified: True). Fix: if scoring != "custom" and target defines score, raise BenchmarkError ("define scoring: custom or delete score()"). This is the same reasoning as the unknown-key hard error, applied one level deeper.

B3. A hand-written verified.json forges a verified badge; a stale stamp survives a dataset change — zoo.py:555-567

index() reads st.get("ok") and nothing else. The stamp records dataset_sha256 but nobody ever compares it to the file on disk (grep dataset_sha256 zoo.py → one hit, the write at zoo.py:792).

ATTACK 6 hand-written verified.json: FORGED BADGE
  {'verified': True, 'verified_at': '2099-01-01T00:00:00+00:00', 'smoke_val_reward': 0.99, 'n_tasks': 999}
ATTACK 7 dataset mutated after stamping: STALE STAMP STILL VERIFIED
  stamp sha: 2ba70d56bce14825  actual now: c7f4556667d114ee

The stamp file I wrote by hand does not even contain steps or problems. Consequence: "a committed flag is a claim, the stamp is evidence" is not true as implemented — the stamp is a differently-located claim, and a contributor who edits tasks.jsonl after verifying ships a badge that certifies a dataset that no longer exists. Fix: index() recomputes _file_sha(tasks_file) and treats a mismatch (or an absent dataset_sha256/steps) as unverified, with a "stale": true marker so the reason is visible. Also stamp a SHA of target.py and benchmark.yaml — the grader changing invalidates the evidence just as much as the data changing.

B4. tasks_file / target_module escape the benchmark dir; target_module is arbitrary code execution outside the project — zoo.py:178, zoo.py:248

Both are root / str(value) with no containment check.

ATTACK 4b tasks_file: ../../evil.jsonl (declared in protected_paths): PASSED VERIFY []
   n_tasks: 8  -> dataset really came from /tmp/atk/evil.jsonl
ATTACK 5  target_module: ../../pwned.py: PASSED VERIFY []
   /tmp/atk/PWNED exists (code ran outside project dir): True

pwned.py wrote a file at import time, from a path outside the benchmark directory, during cap-evolve benchmark verify. Consequence: benchmark add --from-zoo / a PR to benchmarks/ / any third-party benchmark dir is an arbitrary-code-execution vector via a one-line manifest edit, and the grader/dataset can live somewhere #142's guard structurally cannot hash (resolve_protected only covers paths under the project dir — the very trap the PR body says the layout closes). Note this also bypasses the protected-paths check in the escaped case, because a declared ../../evil.jsonl satisfies the string-prefix test at zoo.py:768.

Fix: after resolving, require Path(...).resolve().is_relative_to(root.resolve()) for tasks_file, target_module, capability_path and split_ids_file, and reject with a message naming the escape. Three lines, and it is the only structural guarantee that makes the "everything inside project/" layout mean anything.

B5. verify's protected-paths step checks the manifest, not what the guard will hash — zoo.py:760-776

declared = m["protected_paths"] is read from benchmark.yaml, but protect.resolve_protected() reads capevolve.yaml — the generated file. The two can diverge (the generated spec is a build artifact; nothing re-derives or re-checks it at verify time). On the #197 merge:

verify ok (manifest still declares all 4): True
rep.protected (what protect ACTUALLY hashes): ['adapters/adapter.py']
=> verify reports OK while target.py + tasks.jsonl are unprotected

(I weakened only capevolve.yaml's protected_paths, left the manifest intact.) Consequence: verify reports the claim while rep.protected sitting right next to it shows the reality, and nothing compares them — the same wrong-artifact bug as #189. Fix: after resolve_protected, assert every path in must appears in rep.protected (not in declared), and fail otherwise. That single change also subsumes the current check and makes B4's escaped-path bypass visible.

B6. A zero-holdout / degenerate split passes the honesty floor — zoo.py:683-704

The floor checks only len(sp.val) >= 2 and sp.test != []. It never checks overlap, and for split_ids_file it hand-rolls an anonymous type("S", (), ...) object at zoo.py:684 instead of going through Splits, so it also skips the test overlaps train/val warning harness.ensure_splits emits.

ATTACK 8 train==val==test (zero holdout, via split_ids_file): PASSED VERIFY []
  splits: {'train': 8, 'val': 8, 'test': 8}   notes: []   <- no overlap warning at all
ATTACK 9 train=0.0 val=0.9 test=0.1: PASSED VERIFY []  {'train': 0, 'val': 7, 'test': 1}

Consequence: the PR's headline is "a 3-task dataset fails at verify, not mid-run", but the worse failure — a test split that is literally the training set, so the sealed number is a fit metric — passes silently. A train: 0 benchmark also verifies, which no optimizer can use. Fix: problems.append on set(test) & (set(train) | set(val)) and on an empty train split; construct Splits(...) from split_ids_file so the existing overlap logic applies instead of a throwaway class.

B7. --description with a newline rewrites the manifest — zoo.py:487

_manifest_text interpolates the description unquoted into YAML:

$ cap-evolve benchmark add b --description $'oops\nscoring: contains\ntasks_file: /etc/hosts'
# resulting benchmark.yaml:
description: oops
scoring: contains
tasks_file: /etc/hosts

Here the injected keys land above the real ones so the later definitions win, but that is an accident of key ordering in the template — an injected verified: true or split_test: 0.0 placed after its real counterpart takes effect, and either way the manifest the author reads back is not the one they asked for. Consequence: silent config corruption from ordinary CLI input, in the file whose whole point is that declaration cannot drift from behaviour. Fix: json.dumps(description) (valid YAML for a scalar) or strip newlines and reject them with a message.


Non-blocking

(7 items.)

N1. tasks(split) ignores split and hands out sealed test ids to any caller — zoo.py:244-245

tasks(val)  ids: ['a1'..'a8']
tasks(test) ids: ['a1'..'a8']

Today's callers are safe (harness.ensure_splits/_tasks_for use tasks("all") and filter by frozen ids; check.py:115 only needs stability), so this is not a live leak. But the base contract says "Return the tasks for split" (adapter.py, tasks() docstring), ManifestAdapter is the adapter every zoo benchmark inherits, and a future caller or a skill that trusts the signature gets the test split back. Either honour split (the manifest already declares seed+ratios, so it can) or raise on anything but "all".

N2. --refresh silently clobbers a hand-edited adapters/adapter.pyzoo.py:470-475

The PR asks "what happens when a zoo benchmark needs to override one generated method?" I tried it: adding a trajectories() override to the shim verifies fine, then benchmark add --refresh deletes it with no warning and no backup. Since --refresh is the documented way to re-derive capevolve.yaml after a manifest edit, an author who overrode a hook loses it the next time they touch the manifest. Fix: skip the shim rewrite when its content differs from the generated text, or write the override-carrying shim only if absent and note it in the JSON output.

N3. Unanchored regex mode — zoo.py:209

re.search(tgt, out) with no anchors: match("17", "7", "regex")True, match("42", "4", "regex")True. A benchmark author writing target: "7" under scoring: regex gets a scorer that credits 17, 71, 0.7. Given exact/contains already exist, regex should either anchor (re.fullmatch) or the docs must state loudly that the target is a pattern and must be anchored by the author. Also: an invalid pattern raises re.PatternError out of score(), which verify reports as a smoke-eval crash rather than a manifest validation error — validate re.compile on every target at load time when scoring: regex.

N4. numeric uses a fixed absolute tolerance and the first number in the output — zoo.py:198-212

abs(a-b) < 1e-9 is absolute, so 1000000000.0 vs 1000000000.0000001 compares unequal (float spacing at 1e9 is ~2e-7) while 1e-10 vs 0 compares equal. And _num takes re.search's first match, so "in 2024 the answer is 7" vs target 7False. Neither is wrong-by-design, but there is no tolerance: manifest key and no doc note, so the first author to score floats will silently get 0.0 rows. Add rel_tol via math.isclose, or document the exact semantics. _num also doesn't accept scientific notation (1e3 vs 1000False).

N5. contains with an empty target always scores 1.0 — zoo.py:206-207

match("anything", "", "contains")True. A dataset row missing its target field (or with target: "") becomes a free point rather than an error. verify won't catch it — it would just raise val_reward. One guard in tasks(): reject rows whose target field is absent/empty unless scoring: custom.

N6. Non-determinism at coarser-than-one-second granularity escapes the twice-run fingerprint — zoo.py:719-742

The two passes run back-to-back in one process, so a run() keyed on int(time.time()) % 2 verifies clean:

nondeterminism at 1s granularity (time-based): ok= True -> two passes in the same second look identical

Real LLM-backed runners drift on much longer scales (cache state, rate-limit retries), so the guard is genuinely useful but should not be described as the determinism proof. The cost_usd/tokens exclusion at zoo.py:615 is well-reasoned and correctly documented — no change asked there.

N7. docs/ADAPTER_CONTRACT.md loses its trailing newline — the diff shows \ No newline at end of file on the last line. Trivial, but it makes the next edit to that file show a spurious change.


Nits

  • zoo.py:69 — a mid-module from . import splits as _splits after 68 lines of prose, with # noqa: E402. splits is already imported at zoo.py:50 (from .splits import make_splits); MIN_VAL_TASKS = getattr(_splits, "MIN_VAL_TASKS", 2) can move up next to it and drop the noqa.
  • core/cap_evolve/__init__.py now imports zoo, which imports check-adjacent modules lazily but pulls specfile/splits/types eagerly, for two names (BenchmarkError, ManifestAdapter) that only the CLI and generated shims use. import cap_evolve is on the hot path of every skill script; consider leaving zoo out of the package __init__.
  • zoo.py:684type("S", (), {...})() to fake a Splits. Use Splits(...) (already imported transitively); see B6.
  • cli.py:104if not args.name.strip("./").count("/") is an obscure way to ask "is this a bare name". "/" not in args.name reads the same and doesn't depend on strip chars.
  • benchmark add ../../../../tmp/atk/escaped happily creates a benchmark four levels above cwd and reports success. Not a security issue (the user typed the path), but add is the one place a relative-path guard would be cheap.

Can I make verify pass on a broken benchmark?

YES — 9 of 12. All run against a working copy of the bundled toy_calc, on this branch, with PYTHONPATH=/tmp/rv-233/core.

# Attack Result
1 score() appended to target.py returning reward=1.0 unconditionally (manifest still says scoring: exact) PASSED VERIFY, val_reward: 1.0, badge verified: True
2 run() returns str(task.target) — echoes the gold answer PASSED VERIFY, val_reward: 1.0
3 run() reads tasks.jsonl off disk and returns the matching target PASSED VERIFY, val_reward: 1.0
4 Every val task is a content-duplicate of a train task (new ids, identical input/target) PASSED VERIFY, 16 tasks, splits {train:8,val:4,test:4}
5 tasks_file: ../../evil.jsonl (dataset outside the benchmark dir), undeclared blocked — but only by the protected_paths string check, not by any containment rule
6 Same, with ../../evil.jsonl added to protected_paths PASSED VERIFY — dataset genuinely loaded from /tmp/atk/evil.jsonl
7 target_module: ../../pwned.py (writes a file at import), declared PASSED VERIFY/tmp/atk/PWNED created; code executed outside the project dir
8 Hand-written verified.json with {"ok": true, "val_reward": 0.99, "n_tasks": 999}, no steps, bogus sha FORGED BADGEbenchmark list reports verified: True
9 Verify + stamp, then edit tasks.jsonl STALE STAMP STILL VERIFIED (2ba70d56… vs actual c7f45566…)
10 split_ids_file with train == val == test == all ids (zero holdout) PASSED VERIFY, {train:8,val:8,test:8}, no overlap note
11 Ratios train 0.0 / val 0.9 / test 0.1 PASSED VERIFY, {train:0,val:7,test:1}
12 Weaken protected_paths in the generated capevolve.yaml only (manifest intact), on the #197 merge PASSED VERIFY while rep.protected == ['adapters/adapter.py']

Blocked as claimed: stubbed score(), non-deterministic run() (planted random.random() — caught, message names seed=0 and forward \seed`), 3-task dataset, missing dataset file, duplicate task ids, under-declared protected_paths` in the manifest. Those six all reproduce exactly as the PR describes. What none of them cover is whether the benchmark measures anything.


Protected-paths generalization

No — a third author's new file is silently unprotected. The #197 fix is real for the two committed examples: on the merged tree resolve_protected(project) returns exactly ['adapters/adapter.py', 'benchmark.yaml', 'target.py', 'tasks.jsonl'], a clean protect.verify returns [], and touching target.py raises TamperError: … modified target.py. Verified literally (output below).

But protected_paths is a static list in the manifest, and _FIELDS defaults it to []default_protected() → the same four hardcoded names. A third author who adds anything else gets nothing:

third-author protected set: ['adapters/adapter.py', 'benchmark.yaml', 'target.py', 'tasks.jsonl']
verify third-author layout ok= True problems= []
=> NEW FILES SILENTLY UNPROTECTED (tamper undetected)

I added helpers.py (a module target.py could import), scorer2.py (a second grader) and answers_gold.json (an answer key with a non-gold*-prefixed name — #197's default glob is *gold* on data suffixes, and answers_gold.json does match *gold*, yet it is not protected here because the manifest's explicit protected_paths replaces the defaults wholesale). verify reported ok: true, then all three were modified and protect.verify stayed silent.

So the PR's claim that under-declaration is detected is true only for the four names verify hardcodes in must (zoo.py:767). Anything a third author adds is neither protected nor flagged — it is the same trap the author already fell into once, just moved one file over. The layout convention works for the two committed examples and no further. A generalizing fix: protect project/ minus capability_path minus the run dir by default (deny-list rather than allow-list) — the seed capability is the only thing that should be writable, and that is exactly what the manifest already names.


Boilerplate claim

Independently reproduced, and it holds — using an AST-based count (non-blank, non-comment, docstrings excluded, which is stricter than "non-blank" and therefore fairer to the "before" side):

  18  benchmarks/toy_calc/project/target.py        <- hand-authored
  18  benchmarks/toy_calc/project/benchmark.yaml   <- hand-authored
   3  benchmarks/toy_calc/project/adapters/adapter.py   (generated)
  21  benchmarks/toy_calc/project/capevolve.yaml        (generated)
  43  benchmarks/json_extract/project/target.py    <- hand-authored (custom scorer)
  88  templates/adapters/jsonl_litellm/adapter.py
  13  templates/adapters/jsonl_litellm/capevolve.yaml
  98  templates/adapters/huggingface_litellm/adapter.py
 562  core/cap_evolve/zoo.py

36 hand-authored (18 + 18) — exactly the PR's number. Against jsonl_litellm as the "before" I get 88 + 13 = 101, matching the PR's alternate figure, so 36 → −64% there and the −54% claim against a 78-line baseline is if anything conservative. Excluding generated files is the right call; I'd note that capevolve.yaml at 21 lines is larger than the 18-line manifest it's generated from, so the win is that it's derived, not that it's shorter.

The complexity is not free, though: it moved into zoo.py's 562 lines that every zoo author now depends on and none of them owns. That is a good trade for two benchmarks and a fine trade for twenty — but the concrete cost surfaced in N2: an author who needs to override one generated hook edits the generated shim and --refresh deletes it without a word. The abstraction has no supported override path, which is the usual way generated-code wins turn into 3am debugging.


Merge-order note

I trial-merged all three. Recommend #197#195#137, i.e. the author's order, with one correction to their expectation:

CLI integration verdict: benchmark appears in the generated listing with no manual edit, on both this branch and the merge — COMMANDS is the single source and --help renders it. No conflict with #214's test. #214 owns core/tests/test_documented_cli.py; this PR's assertion lives in its own test_cli_help_lists_benchmark_from_the_generated_listing (test_benchmark_zoo.py:233-235) and only greps cli.py for the dead literal version|splits|check|run|estimate|dashboard, which #137 also removes — the two agree rather than compete. stdout is one JSON object on every path I could reach, including verify nope (exit 1, {"ok": false, ...}) and a broken benchmark (exit 1). Note main()'s help output goes to stderr, so it doesn't pollute the JSON contract.

ManifestAdapter / contract: satisfies all three required methods plus the defaulted hooks; cap-evolve check on the generated project returns ok: true, stubs: []. Per the standing caveat I did not infer anything from that — a raising materialize() still yields ok: true, which is precisely why B1 matters: verify inherits check's blind spot for outcome and adds a mechanical execution check on top, not a semantic one.

Seal: the smoke eval does not consume the seal. verify runs on val only, creates no run dir, and the sealed test ids appear in zero artifacts:

splits: {'train': ['a5','a2','a6','a3'], 'val': ['a1','a4'], 'test': ['a8','a7']}
stamp ok: True | test ids a7/a8 present in verified.json (sha excluded): False False
run dirs created under bench: []      seal artifacts: []
splits deterministic across re-derivation: True

The TestSealError-on-second-finalize path also passes in the e2e test. Splits are seeded once and reproducible. (B6 is about a manifest that declares a degenerate split, not about verify leaking one.)


Verification I re-ran

Suite — 203 passed, as claimed:

$ cd /tmp/rv-233 && PYTHONPATH=/tmp/rv-233/core python -m pytest core/tests -q
........................................................................ [ 35%]
........................................................................ [ 70%]
...........................................................              [100%]
203 passed in 63.38s (0:01:03)

compileall on core + benchmarks: exit 0, silent.

Real verify on the bundled toy_calc (exit 0) — all six steps present:

"steps": [
  "manifest parsed + validated",
  "dataset loaded through the adapter: 8 task(s)",
  "cap-evolve check executed on the generated project",
  "splits computed: {'train': 4, 'val': 2, 'test': 2}",
  "REAL smoke eval: 2 val task(s) x 2 passes through live() -> run_target() -> score()",
  "protected paths declared + resolved: ['adapters', 'benchmark.yaml', 'target.py', 'tasks.jsonl']"
],
"problems": [], "val_reward": 0.0, "n_tasks": 8, "splits": {"train": 4, "val": 2, "test": 2}

The smoke eval is real: I confirmed by planting random.random() in run() and watching it fail with the fingerprint diff quoted in the table.

cap-evolve check on the generated project:

{"ok": true, "stubs": [], "notes": ["tasks('val') -> 8 task(s)", "scorer deterministic (probe reward=0.0000)", "materialize() callable (dry-run into temp copy; host untouched)"]}

json_extract graded signal — 0.0 / ⅓ / 1.0 reproduced exactly:

prose: 0.0 | [JSON]: 0.3333 | [JSON]+[FIELDS]: 1.0

E2E to a sealed test number: test_zoo_benchmark_runs_end_to_end_to_a_sealed_test_number passes — baseline 0.0, gate-accepted, test.reward == 1.0, second finalize raises TestSealError.

Four claimed breakage cases (all reproduce, exit 1, actionable message): stubbed score()"unimplemented adapter methods … score"; non-deterministic run()"NON-DETERMINISTIC: 2 task(s) … seed=0 … forward \seed`"; 3-task dataset → "val split has 1 task(s), below the honest-gate minimum of 2"**and**"test split is EMPTY"; missing dataset → "dataset file missing … tasks.jsonl". Plus duplicate ids and manifest-level under-declared protected_paths`.

#197 merge:

$ git merge origin/feat/issue-142-protected-paths     # CHANGELOG.md only
resolve_protected: ['adapters/adapter.py', 'benchmark.yaml', 'target.py', 'tasks.jsonl']
clean verify: []
TamperError: cap-evolve TAMPER DETECTED: 1 protected file(s) changed under …/project — modified target.py.
zoo.verify ok: True | rep.protected: ['adapters/adapter.py','benchmark.yaml','target.py','tasks.jsonl']

…and the generalization probe, same tree:

third-author protected set: ['adapters/adapter.py','benchmark.yaml','target.py','tasks.jsonl']
verify third-author layout ok= True problems= []
=> NEW FILES SILENTLY UNPROTECTED (tamper undetected)

#195 merge: clean; zoo.MIN_VAL_TASKS = 2 | splits.MIN_VAL_TASKS = 2.

#137 merge: CONFLICT (content): core/cap_evolve/cli.py, 5 conflict hunks (lines 8, 72, 88, 193, 818).

--help:

$ python -m cap_evolve.cli --help          # stderr
usage: cap-evolve {version|splits|check|run|estimate|dashboard|benchmark} [args]
  benchmark  Manage the benchmark zoo: list | add | verify a declarative benchmark.

benchmark --help renders its own subparsers with the documented examples.


Summary: the mechanical half of verify is genuinely real and I could not fake my way past the six guards the PR names. The semantic half doesn't exist yet — B1 (no headroom/degenerate-scorer assertion), B3 (unauthenticated stamp), B4 (path containment) and B5 (check rep.protected, not declared) are the four that turn "it ran" into "it measures". B1 and B5 are each a handful of lines and reuse signal the code already computes.

The review made `verify` pass on a broken benchmark in 9 of 12 attacks. Its
mechanical claims were all true — it really does run `live()` -> `run_target()` ->
`score()` twice for every val task — but it drew no conclusion from the results.
This fixes the seven blocking findings plus the third-author protection gap.

verify now concludes:

- HEADROOM is a hard failure, not a note. A seed capability already scoring 1.0
  has nothing to optimize, which is the one thing `baseline` exists to confirm and
  the signature of the two commonest reward hacks (a score() wired to a constant,
  a run() returning task.target or reading the answer key off disk). A genuinely
  saturated reference fixture opts out loudly with allow_saturated_baseline: true.
- A DEGENERATE-SCORER PROBE scores a synthetically correct rollout against a
  deliberately wrong one and requires the rewards to differ. Correct-vs-wrong is a
  property of the scorer alone, so it catches a score() that ignores its input at
  any baseline — which the headroom rule cannot see.
- SPLITS must be genuinely disjoint with a non-empty train, asserted on the
  REALIZED split. train == val == test passed the old floor; #99 found the repo's
  own headline tau^2 number came from exactly that. Built as a real Splits rather
  than a throwaway type("S", (), ...).
- CONTAINMENT is an allowlist, the shape PR #210 used at gepa.py: every path key
  must be a plain relative path whose resolved parent is inside the project dir,
  checked once in load_manifest so no use site can bypass it. `target_module:
  ../../pwned.py` previously EXECUTED code outside the project dir during verify,
  from a location #142's guard structurally cannot hash. Denylists have failed six
  times in this batch; this is not a seventh.
- PROTECTED PATHS are asserted on what protect.resolve_protected() actually
  resolves from the GENERATED capevolve.yaml — the artifact the runtime guard
  reads — not on what benchmark.yaml claims. Weakening only the spec left verify
  reporting OK with rep.protected == ['adapters/adapter.py'], the same
  wrong-artifact bug as #189.
- protected_paths is now ADDITIVE: unioned with the layout defaults and #197's
  globs, never substituted. #197's own list replaces its defaults wholesale, so
  declaring four paths silently switched off the *gold* answer-key globs. Union is
  the only default that fails safe. Plus an UNDER-DECLARATION SWEEP flagging any
  .py or answer-key-ish file under project/ (outside capability_path/) the guard
  would not hash: a third author's helpers.py / scorer2.py were silently
  unprotected and tampering all three went undetected.
- The STAMP is evidence, not a differently-located claim. verified.json now
  records the grader and manifest hashes alongside the dataset, and `benchmark
  list` RE-CHECKS every one: a hand-written stamp (no steps, no hashes) and a
  stale one both read verified: false with the reason in stale_reason. The
  dataset_sha256 was written and never compared.
- A score() without `scoring: custom` is a hard error. It silently overrode the
  declared mode, so both the manifest and `benchmark list` reported a grading mode
  that was not in effect — the unknown-key hard error, one level deeper.
- --description is emitted as a quoted YAML scalar. A newline redefined manifest
  keys.

Also: content-duplicate task rows are refused (fresh ids on identical rows split
cleanly, so val became a copy of train); tasks(split) honours its argument instead
of handing the sealed test split to any caller; an empty or uncompilable regex
target is a dataset error rather than a free 1.0 / a mid-eval crash; numeric uses
math.isclose(rel_tol=...) and accepts scientific notation; and --refresh KEEPS a
hand-edited adapters/adapter.py instead of clobbering it, so overriding one
generated hook is a supported edit rather than work the next manifest change
deletes.

All 12 attacks now fail. 220 passed (was 203, + 17 new tests), 0 failed apart from
the known #200 dashboard port flake; compileall clean.
Copilot AI review requested due to automatic review settings July 30, 2026 14:30

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@OsherElhadad

Copy link
Copy Markdown
Collaborator Author

🔧 Review fixes

Thanks — the summary landed: "the mechanical claims are all true; the semantic ones are not." verify really did run everything it claimed and then conclude nothing from the results. All 7 blocking findings, the third-author protection gap, the 7 non-blocking items and the 5 nits are addressed below, with the literal output.

New commit 321257f1 on top of a03e6a44 (not an amend, so the fix reads as a diff).

⚠️ Landed-state audit — a previous fix attempt died mid-flight

A prior pass amended the branch and posted nothing. I re-checked every finding against a03e6a44 before touching anything, and none of them had landed:

# Finding State at a03e6a44 Evidence
B1 headroom is only a note not fixed zoo.py:754-757 verbatim rep.notes.append("…no headroom to optimize…")
B2 stray score() overrides declared mode not fixed zoo.py:302 verbatim if callable(custom): # a custom scorer always wins
B3 stamp never re-checked not fixed grep -n dataset_sha256 zoo.py → 1 hit, the write at :792
B4 no path containment not fixed grep -c 'resolve().parent|is_relative_to' zoo.py0
B5 protected step reads the manifest not fixed zoo.py:760 verbatim declared = m["protected_paths"] or …
B6 zero-holdout passes not fixed zoo.py:684 verbatim type("S", (), {...})(), no overlap check
B7 --description unquoted into YAML not fixed zoo.py:487 verbatim description: {description or …}

So everything below is new work, verified by me rather than assumed.

Attacks closed

All 12 re-run against a working copy of bundled toy_calc on the pushed commit. 12/12 now fail (was 3/12).

# Attack Result Refused because
1 score() hard-wired to 1.0 FAILS NO HEADROOM: seed capability scores 1.0 on all 2 smoke val tasks
2 run() returns task.target FAILS NO HEADROOM: seed capability scores 1.0 on all 2 smoke val tasks
3 run() reads the answer key off disk FAILS NO HEADROOM: seed capability scores 1.0 on all 2 smoke val tasks
4 val == train content duplicates (new ids) FAILS dataset/adapter load failed: tasks.jsonl:9 task 'a1_dup' is a CONTENT duplicate of 'a1'
5 tasks_file: ../../evil.jsonl (declared in protected_paths) FAILS tasks_file='../../evil.jsonl' must be a plain relative path inside the project dir
6 target_module: ../../pwned.py (code execution) FAILS target_module='../../pwned.py' must be a plain relative path inside the project dir — side-effect file created: False
7 forged verified.json FAILS verified.json is missing ['steps', 'splits', 'dataset_sha256', 'target_sha256', 'manifest_sha256'] — hand-written, not measured
8 stale stamp (dataset edited after verify) FAILS STALE STAMP: dataset_sha256 changed since verification (2ba70d56bce11991103bc2fd)
9 train == val == test (zero holdout) FAILS test split OVERLAPS train/val on 8 task id(s) (and train ∩ val on 8)
10 ratios train 0.0 / val 0.9 / test 0.1 FAILS train split is EMPTY ({'train': 0, 'val': 7, 'test': 1})
11 generated spec weakened, manifest intact (wrong-artifact skew) FAILS the runtime guard will NOT hash ['benchmark.yaml', 'target.py', 'tasks.jsonl'] (it resolves only ['adapters/adapter.py'])
12 stray score() overriding scoring: exact FAILS dataset/adapter load failed: target.py defines score() but the manifest declares scoring: 'exact'
Full literal attack output (all 12)

### ATTACK 1: score() hard-wired to 1.0
verify ok = False
  problem: NO HEADROOM: the seed capability already scores 1.0 on all 2 smoke val task(s). A benchmark that is perfect at baseline cannot demonstrate an improvement, so optimizing it is meaningless — and this is the signature of the two commonest reward hacks: a score() hard-wired to a constant, and a run() that returns task.target (or reads the answer key off disk). Make the tasks harder, weaken the seed capability, or — for a genuinely saturated reference fixture — declare `allow_saturated_baseline: true` in /var/folders/zh/srgnbq_97qvb6002zsr40tgc0000gn/T/tmpljivcltz/toy_calc/project/benchmark.yaml.
  problem: SCORER DOES NOT DISCRIMINATE: a correct output and a deliberately wrong one ('__CAPEVOLVE_DELIBERATELY_WRONG__') received the SAME reward on every one of 2 smoke task(s) — e.g. {'a1': {'correct': 1.0, 'wrong': 1.0}, 'a4': {'correct': 1.0, 'wrong': 1.0}}. score() is therefore not a function of the agent's output: it grades a constant, so every number this benchmark produces is fiction and no optimizer can learn from it. Fix score() in target.py (or the 'custom' mode's target field).

### ATTACK 2: run() returns task.target
verify ok = False
  problem: NO HEADROOM: the seed capability already scores 1.0 on all 2 smoke val task(s). A benchmark that is perfect at baseline cannot demonstrate an improvement, so optimizing it is meaningless — and this is the signature of the two commonest reward hacks: a score() hard-wired to a constant, and a run() that returns task.target (or reads the answer key off disk). Make the tasks harder, weaken the seed capability, or — for a genuinely saturated reference fixture — declare `allow_saturated_baseline: true` in /var/folders/zh/srgnbq_97qvb6002zsr40tgc0000gn/T/tmpmv694v94/toy_calc/project/benchmark.yaml.

### ATTACK 3: run() reads the answer key off disk
verify ok = False
  problem: NO HEADROOM: the seed capability already scores 1.0 on all 2 smoke val task(s). A benchmark that is perfect at baseline cannot demonstrate an improvement, so optimizing it is meaningless — and this is the signature of the two commonest reward hacks: a score() hard-wired to a constant, and a run() that returns task.target (or reads the answer key off disk). Make the tasks harder, weaken the seed capability, or — for a genuinely saturated reference fixture — declare `allow_saturated_baseline: true` in /var/folders/zh/srgnbq_97qvb6002zsr40tgc0000gn/T/tmp4hmrryje/toy_calc/project/benchmark.yaml.

### ATTACK 4: val == train content duplicates (new ids)
verify ok = False
  problem: dataset/adapter load failed: /private/var/folders/zh/srgnbq_97qvb6002zsr40tgc0000gn/T/tmpecqtuzw8/toy_calc/project/tasks.jsonl:9 task 'a1_dup' is a CONTENT duplicate of 'a1': identical input and target under a different id. Distinct ids make it split cleanly, so the same task can land in train and val — the gate then rewards memorization, and the sealed test number measures recall of a seen row. De-duplicate the dataset.

### ATTACK 5: tasks_file: ../../evil.jsonl (declared in protected_paths)
verify ok = False
  problem: tasks_file='../../evil.jsonl' must be a plain relative path inside the benchmark's project dir (/private/var/folders/zh/srgnbq_97qvb6002zsr40tgc0000gn/T/tmps5x3kwl6/toy_calc/project). Absolute paths, `~` and `..` are refused: tasks_file is read (and for target_module, IMPORTED) by verify, so a value escaping the project dir would execute code and load data that #142's tamper guard structurally cannot hash — the guard only covers paths under the project dir.

### ATTACK 6: target_module: ../../pwned.py (CODE EXECUTION)
verify ok = False
  problem: target_module='../../pwned.py' must be a plain relative path inside the benchmark's project dir (/private/var/folders/zh/srgnbq_97qvb6002zsr40tgc0000gn/T/tmp91_oqafw/toy_calc/project). Absolute paths, `~` and `..` are refused: target_module is read (and for target_module, IMPORTED) by verify, so a value escaping the project dir would execute code and load data that #142's tamper guard structurally cannot hash — the guard only covers paths under the project dir.
  >>> code executed outside project dir? False

### ATTACK 7: hand-written verified.json
  index() -> verified=False stale=True
  reason: verified.json is missing ['steps', 'splits', 'dataset_sha256', 'target_sha256', 'manifest_sha256'] — `cap-evolve benchmark verify` always writes these, so this stamp was hand-written, not measured. Re-run verify.

  (control) a REAL stamp reads verified=True

### ATTACK 8: dataset mutated after stamping
  index() -> verified=False stale=True
  reason: STALE STAMP: ['dataset_sha256'] changed since verification ({'dataset_sha256': ('2ba70d56bce1', '1991103bc2fd')}). The badge certifies a dataset/grader/manifest that no longer exists on disk. Re-run `cap-evolve benchmark verify`.

### ATTACK 9: train == val == test (zero holdout)
verify ok = False
  problem: test split OVERLAPS train/val on 8 task id(s) (e.g. ['a1', 'a2', 'a3', 'a4', 'a5']) — the 'sealed' number would be measured on data the optimizer trained against, making it a fit metric, not a held-out result. train/val/test must be disjoint.
  problem: train and val OVERLAP on 8 task id(s) (e.g. ['a1', 'a2', 'a3', 'a4', 'a5']) — the acceptance gate would score candidates on the very tasks reflection read, so every accept is measuring memorization.

### ATTACK 10: ratios train 0.0 / val 0.9 / test 0.1
verify ok = False
  problem: train split is EMPTY — there is nothing for the optimizer to reflect over, so no candidate can be proposed from evidence. Raise split_train above 0.0 in /var/folders/zh/srgnbq_97qvb6002zsr40tgc0000gn/T/tmp20x8ihwt/toy_calc/project/benchmark.yaml.

  (manifest still declares all four; only the generated spec was weakened)

### ATTACK 11: generated spec weakened, manifest intact (wrong-artifact skew)
verify ok = False
  problem: the runtime tamper guard will NOT hash ['benchmark.yaml', 'target.py', 'tasks.jsonl']. What it actually resolves from /var/folders/zh/srgnbq_97qvb6002zsr40tgc0000gn/T/tmp6z1ta6k9/toy_calc/project/capevolve.yaml is ['adapters/adapter.py'] — so the grader / dataset / manifest is optimizer-writable and a candidate could 'improve' by rewriting it. This is checked against the generated spec (what the guard reads), not benchmark.yaml (what it was derived from); if they have diverged, re-run `cap-evolve benchmark add /var/folders/zh/srgnbq_97qvb6002zsr40tgc0000gn/T/tmp6z1ta6k9/toy_calc --refresh`.
  problem: UNDER-DECLARED: 1 file(s) inside /var/folders/zh/srgnbq_97qvb6002zsr40tgc0000gn/T/tmp6z1ta6k9/toy_calc/project are code or answer-key data that the tamper guard will not hash: ['target.py']. A helper the grader imports is as much the grader as adapter.py, and an answer key the optimizer can rewrite is a free 1.0. Add them to protected_paths in /var/folders/zh/srgnbq_97qvb6002zsr40tgc0000gn/T/tmp6z1ta6k9/toy_calc/project/benchmark.yaml and re-run `cap-evolve benchmark add /var/folders/zh/srgnbq_97qvb6002zsr40tgc0000gn/T/tmp6z1ta6k9/toy_calc --refresh` — or move them under seed_capability/ if they are genuinely part of the artifact being optimized.

### ATTACK 12: stray score() overriding scoring: exact
verify ok = False
  problem: dataset/adapter load failed: /private/var/folders/zh/srgnbq_97qvb6002zsr40tgc0000gn/T/tmpau3ozsxk/toy_calc/project/target.py defines score(task, rollout) but benchmark.yaml declares scoring: 'exact'. A code scorer silently overriding the declared mode makes the manifest — and `benchmark list` — report a grading mode that is not the one in effect. Either set `scoring: custom` to declare it, or delete score() and let the declared mode grade.


Notes on two of them:

  • Feat/cap evolve dashboard #6 is the code-execution case. pwned.py writes a marker at import. The run reports side-effect file created: False — the containment check fires in load_manifest, before _load_target_module imports anything. There is a dedicated test for exactly this ordering (test_target_module_escape_never_imports), because "rejected after execution" would be no fix at all.
  • fix: repair quickstart command — add toy_calc run.sh (closes #3) #4 was still passing after my first pass and I only caught it by re-running the matrix rather than trusting the fixes. Content-duplicate rows (identical input+target under fresh ids) split cleanly and passed every honesty check. Fixed at the existing id-duplicate guard in tasks(), which is where all callers already route.

Third-author files are protected automatically

Root cause was as you diagnosed: protected_paths replaced #197's derived defaults wholesale, and verify only hardcoded four names in must. Two changes:

  1. Protection is additive. effective_protected() unions the manifest's list with the layout defaults and Protected-paths tamper guard: verify the optimizer never edited scoring/eval/task files #197's _DEFAULT_GLOBS, and the generated capevolve.yaml carries the union. Declaring one more path can now never unprotect something — which is the only default that fails safe. (Note this also means answers_gold.json is protected with no declaration at all: the *gold* globs are alive again.)
  2. Under-declaration is detected. A sweep over project/ flags every .py and answer-key-ish data file (gold|answer|label|solution|truth|key|expected × data suffixes) that the guard would not hash, excluding capability_path/ — the one thing that must stay writable — plus run dirs and caches.

Run on the #197 merge (protect.py + harness.py from feat/issue-142-protected-paths), using the real protect.verify() with a real RunDir:

=== 1. a third author adds three files to the zoo entry
  added project/helpers.py
  added project/scorer2.py
  added project/answers_gold.json

=== 2. verify now DETECTS the under-declaration (it used to say ok: true)
  ok = False
  problem: UNDER-DECLARED: 2 file(s) inside /var/folders/zh/srgnbq_97qvb6002zsr40tgc0000gn/T/tmpd4182zzu/toy_calc/project are code or answer-key data that the tamper guard will not hash: ['helpers.py', 'scorer2.py']. A helper the grader imports is as much the grader as adapter.py, and an answer key the optimizer can rewrite is a free 1.0. Add them to protected_paths in /var/folders/zh/srgnbq_97qvb6002zsr40tgc0000gn/T/tmpd4182zzu/toy_calc/project/benchmark.yaml and re-run `cap-evolve benchmark add /var/folders/zh/srgnbq_97qvb6002zsr40tgc0000gn/T/tmpd4182zzu/toy_calc --refresh` — or move them under seed_capability/ if they are genuinely part of the artifact being optimized.
  answers_gold.json already covered by the UNIONED *gold* globs: True

=== 3. declare the two modules, regenerate the spec, verify
  verify ok = True  problems = []
  protect.resolve_protected -> ['adapters/__pycache__/adapter.cpython-314.pyc', 'adapters/adapter.py', 'answers_gold.json', 'benchmark.yaml', 'capevolve.yaml', 'helpers.py', 'scorer2.py', 'target.py', 'tasks.jsonl']
  project/helpers.py IS guard-hashed: True
  project/scorer2.py IS guard-hashed: True
  project/answers_gold.json IS guard-hashed: True

=== 4. tamper with each file; the REAL #197 guard must raise TamperError

  --- project/helpers.py
  TamperError raised: True
  message: cap-evolve TAMPER DETECTED during tampering with helpers.py: 1 protected file(s) changed under /private/var/folders/zh/srgnbq_97qvb6002zsr40tgc0000gn/T/tmpd4182zzu/toy_calc/project — modified helpers.py. Protected paths are the sc

  --- project/scorer2.py
  TamperError raised: True
  message: cap-evolve TAMPER DETECTED during tampering with scorer2.py: 1 protected file(s) changed under /private/var/folders/zh/srgnbq_97qvb6002zsr40tgc0000gn/T/tmpd4182zzu/toy_calc/project — modified scorer2.py. Protected paths are the sc

  --- project/answers_gold.json
  TamperError raised: True
  message: cap-evolve TAMPER DETECTED during tampering with answers_gold.json: 1 protected file(s) changed under /private/var/folders/zh/srgnbq_97qvb6002zsr40tgc0000gn/T/tmpd4182zzu/toy_calc/project — modified answers_gold.json. Protected pa

=== 5. control: the seed capability stays WRITABLE (it is the target)
  editing seed_capability/prompt.txt -> verify returns []

ALL THREE THIRD-AUTHOR FILES ARE PROTECTED, AND TAMPERING EACH RAISES TamperError.

So: the two modules are flagged (they need declaring — detection, not silent protection, because an author who genuinely wants a file writable should say so), the answer key is already covered, and once declared all three raise TamperError naming the file. The seed capability stays writable, as it must.

Numbered response to all 19 findings

Blocking

  1. B1 — verify cannot tell a working benchmark from a self-answering one. Fixed, both halves you suggested. min(rewards) >= 1.0 is now problems.append, not a note; and a degenerate-scorer probe runs score() on a synthetically correct rollout (output=str(task.target)) versus a deliberately wrong one and requires the rewards to differ on ≥1 task. One deviation from your sketch worth flagging: I first compared wrong-vs-the-seed-rollout as written, and it false-positived on the good bundled toy_calc — the seed capability is already wrong, so both scored 0.0 and a healthy benchmark failed. Correct-vs-wrong is a property of the scorer alone, so it holds at any baseline. Opt-out for a genuinely saturated reference fixture: allow_saturated_baseline: true, which downgrades to a loud note naming the cost ("NO headroom… only usable as a regression fixture"). Closes attacks 1, 2, 3.
  2. B2 — score() silently overrides the declared mode. Fixed as you proposed: an undeclared score() is a hard error at module load, naming both remedies. The if callable(custom) fallthrough in ManifestAdapter.score is gone, so reaching the declared-mode branch now proves the declared mode is the effective one — which is what makes benchmark list's scoring column honest. Closes attack 12.
  3. B3 — forged / stale stamp. Fixed. stamp_state() requires the fields verify actually writes (a hand-typed {"ok": true} has no steps) and re-hashes; index() reports verified: false with stale: true and a stale_reason. Per your suggestion I also stamp target_sha256 and manifest_sha256 — the grader changing invalidates the evidence as much as the data. Closes attacks 7, 8.
  4. B4 — path escape / arbitrary code execution. Fixed with an allowlist, no denylist. _contained() requires (a) a plain relative path — not absolute, no drive, no ~, no .. component — and (b) the resolved parent inside the resolved root, which is what catches a symlinked subdirectory that a string check cannot. Called once from load_manifest, the single choke point every caller routes through, for all four path keys. Same shape as GEPA eval-cache hits now carry output/trace (no more hollow reflective dataset) #210's gepa.py:232,237. Closes attacks 5, 6.
  5. B5 — wrong artifact. Fixed exactly as you specified: the assertion is on the set protect.resolve_protected() resolves from the generated capevolve.yaml, and the manifest is only the source. As you predicted, this also subsumes the old string check. There is a fallback resolver for a pre-Protected-paths tamper guard: verify the optimizer never edited scoring/eval/task files #142 checkout (the branches merge in either order) so the step is never silently skipped — a guard that quietly does nothing being the exact failure mode this review found. Closes attack 11.
  6. B6 — zero holdout. Fixed on the realized split: test & (train|val), train & val, and an empty train are all problems.append, and split_ids_file builds a real Splits(...) instead of type("S", (), ...). Closes attacks 9, 10.
  7. B7 — YAML injection. Fixed via json.dumps (YAML's double-quoted style is a superset of JSON string syntax, so this is a valid scalar and needs no dumper dependency). name additionally rejects newlines, since it is interpolated into generated Python, where a broken value emits broken code rather than broken config. Proof below.

Non-blocking

  1. N1 — tasks(split) ignores split. Fixed by honouring it, not raising: I tried raising first and it broke check.py:115, which calls tasks("val") — a good reminder that the sibling caller matters. _splits() derives the manifest's declared partition; harness still asks for "all" and filters by frozen ids, so nothing re-splits mid-run. Test asserts tasks("test") is neither the full dataset nor overlapping train/val.
  2. N2 — --refresh clobbers a hand-edited shim. Fixed, and this is my answer to the structural criticism (below). A shim whose bytes differ from the generated text is treated as an authored override and left alone; capevolve.yaml is still re-derived, since it holds no logic to override. add reports kept_hand_edited so a stale override is visible rather than silent.
  3. N3 — unanchored regex. Declining the anchor change, documenting insteadre.fullmatch would silently change the meaning of every existing regex target, and exact already covers equality. The match() docstring and docs/BENCHMARK_ZOO.md now state the target is an unanchored pattern with the 7/17 example. I did take the other half: every target is re.compile-validated in tasks(), so a bad pattern is a dataset error naming the line, not a smoke-eval crash.
  4. N4 — fixed absolute tolerance + first-number. Partially fixed: math.isclose(rel_tol=1e-9, abs_tol=1e-12) (relative, with a small floor so near-zero still compares) and _num now accepts scientific notation. Declining a tolerance: manifest key — no benchmark needs it yet, and an unused config knob in an honesty-critical file is a liability. The first-number semantics and the "in 2024 the answer is 7" case are documented exactly.
  5. N5 — empty target scores 1.0. Fixed in tasks(): an absent/empty target under a non-custom mode is a BenchmarkError naming the line and why ("scores 1.0 for free under contains/regex").
  6. N6 — 1-second determinism granularity. Fixed as a claim, not a mechanism: the module docstring and the docs now say the two back-to-back passes catch an unseeded sampler and are "a necessary condition, not a proof of reproducibility". Making it a real multi-scale check would mean sleeping in verify, which is a bad trade. Thanks for confirming the cost_usd/tokens exclusion.
  7. N7 — missing trailing newline. Fixed.

Nits

  1. zoo.py:69 mid-module from . import splits as _splits + noqa: E402 — fixed, moved to the import block next to make_splits, noqa gone.
  2. zoo in __init__ on the hot path — declining. stamp() does __import__("cap_evolve").__version__ and the generated shim imports cap_evolve.zoo, so removing it risks a circular-import regression for a saving I have not measured. Worth its own change with a measurement, not a drive-by here.
  3. type("S", (), {...})() → real Splits(...) — fixed (part of B6).
  4. cli.py:104 strip("./").count("/") → fixed, now "/" not in args.name.
  5. benchmark add ../../../../tmp/x — fixed, a .. component is refused with a message; stdout stays one JSON object and exit is 1.

The structural criticism: 562 lines with no override path

Taken, and it is the fairest point in the review — a generated-code win with no supported override is how these turn into 3am debugging. Concretely fixed at N2: --refresh now byte-compares adapters/adapter.py against the generated shim and keeps an authored override, reporting kept_hand_edited in the JSON. So "I need to override one CapabilityAdapter hook" has an answer that survives the next manifest edit, documented under a new Overriding a generated file heading.

I did not add a plugin/hook system. The shim is a real Python subclass — overriding a method there is already the language's own extension mechanism, and the only thing broken was the tool deleting it. Removing the clobber is the whole fix; an override registry would be the abstraction the review is rightly warning about.

Correction to my prediction

You are right and I was wrong: #137 is 5 conflict hunks in cli.py, not the "small textual conflict in main()" I wrote. Re-measured on the pushed commit:

$ git merge --no-commit --no-ff origin/feat/issue-137-cli-ergonomics
Auto-merging core/cap_evolve/cli.py
CONFLICT (content): Merge conflict in core/cap_evolve/cli.py
Automatic merge failed; fix conflicts and then commit the result.

$ grep -c '^<<<<<<<' core/cap_evolve/cli.py
5

Adopting your resolution: take #137's side everywhere, keep the COMMANDS row, and convert _cmd_benchmark to _parser(). Merge order unchanged (#197#195#137).

YAML injection is inert

$ cap-evolve benchmark add b --description $'oops
scoring: contains
tasks_file: /etc/hosts
verified: true
split_test: 0.0'

# resulting benchmark.yaml (head):
# cap-evolve benchmark manifest — the DECLARATIVE half of a benchmark.
# The only code is target.py's run(task, ctx, *, seed=0). Flat keys only, so the
# zero-dependency spec reader can parse it.
name: "b"
description: "oops\nscoring: contains\ntasks_file: /etc/hosts\nverified: true\nsplit_test: 0.0"

# parsed manifest — every injected key is INERT:
{
  "scoring": "exact",
  "tasks_file": "tasks.jsonl",
  "verified": false,
  "split_test": 0.25,
  "description": "oops\nscoring: contains\ntasks_file: /etc/hosts\nverified: true\nsplit_test: 0.0"
}

The description itself survives intact — quoting, not stripping.

Re-proved what already worked

Both real e2e runs, the four original breakage cases, non-determinism detection and the seal are all still green, as suite tests rather than pasted transcripts (they were already tests):

core/tests/test_benchmark_zoo.py::test_manifest_rejects_unknown_key PASSED [  2%]
core/tests/test_benchmark_zoo.py::test_manifest_rejects_bad_scoring_and_direction PASSED [  4%]
core/tests/test_benchmark_zoo.py::test_builtin_predicates PASSED         [  7%]
core/tests/test_benchmark_zoo.py::test_add_scaffolds_a_benchmark_that_verifies PASSED [  9%]
core/tests/test_benchmark_zoo.py::test_add_refuses_to_clobber_and_refresh_regenerates PASSED [ 11%]
core/tests/test_benchmark_zoo.py::test_generated_spec_declares_the_protected_paths PASSED [ 14%]
core/tests/test_benchmark_zoo.py::test_zoo_index_reads_the_stamp_from_disk_not_the_manifest_flag PASSED [ 16%]
core/tests/test_benchmark_zoo.py::test_bundled_zoo_entry_verifies PASSED [ 19%]
core/tests/test_benchmark_zoo.py::test_verify_runs_the_real_thing_not_just_the_manifest PASSED [ 21%]
core/tests/test_benchmark_zoo.py::test_verify_catches_a_stubbed_score PASSED [ 23%]
core/tests/test_benchmark_zoo.py::test_verify_catches_a_nondeterministic_run_target PASSED [ 26%]
core/tests/test_benchmark_zoo.py::test_verify_refuses_a_tiny_dataset_before_the_gate_can_surprise_anyone PASSED [ 28%]
core/tests/test_benchmark_zoo.py::test_verify_catches_a_missing_dataset_file PASSED [ 30%]
core/tests/test_benchmark_zoo.py::test_verify_catches_duplicate_task_ids PASSED [ 33%]
core/tests/test_benchmark_zoo.py::test_verify_checks_the_generated_spec_not_the_manifest PASSED [ 35%]
core/tests/test_benchmark_zoo.py::test_protection_is_additive_not_replacing PASSED [ 38%]
core/tests/test_benchmark_zoo.py::test_verify_flags_a_third_authors_new_files PASSED [ 40%]
core/tests/test_benchmark_zoo.py::test_verify_fails_a_saturated_baseline_and_allows_a_declared_opt_out PASSED [ 42%]
core/tests/test_benchmark_zoo.py::test_verify_catches_a_constant_scorer PASSED [ 45%]
core/tests/test_benchmark_zoo.py::test_undeclared_custom_scorer_is_a_hard_error PASSED [ 47%]
core/tests/test_benchmark_zoo.py::test_path_fields_are_contained_in_the_project_dir[target_module-../../pwned.py] PASSED [ 50%]
core/tests/test_benchmark_zoo.py::test_path_fields_are_contained_in_the_project_dir[tasks_file-../../evil.jsonl] PASSED [ 52%]
core/tests/test_benchmark_zoo.py::test_path_fields_are_contained_in_the_project_dir[capability_path-../../elsewhere] PASSED [ 54%]
core/tests/test_benchmark_zoo.py::test_path_fields_are_contained_in_the_project_dir[split_ids_file-/etc/passwd] PASSED [ 57%]
core/tests/test_benchmark_zoo.py::test_target_module_escape_never_imports PASSED [ 59%]
core/tests/test_benchmark_zoo.py::test_zero_holdout_and_empty_train_are_refused PASSED [ 61%]
core/tests/test_benchmark_zoo.py::test_content_duplicate_tasks_are_refused PASSED [ 64%]
core/tests/test_benchmark_zoo.py::test_forged_and_stale_stamps_read_as_unverified PASSED [ 66%]
core/tests/test_benchmark_zoo.py::test_description_newline_cannot_redefine_manifest_keys PASSED [ 69%]
core/tests/test_benchmark_zoo.py::test_refresh_keeps_a_hand_edited_adapter_shim PASSED [ 71%]
core/tests/test_benchmark_zoo.py::test_tasks_honours_the_split_argument PASSED [ 73%]
core/tests/test_benchmark_zoo.py::test_empty_target_is_not_a_free_point PASSED [ 76%]
core/tests/test_benchmark_zoo.py::test_bad_regex_target_fails_at_load PASSED [ 78%]
core/tests/test_benchmark_zoo.py::test_stamp_records_measured_evidence PASSED [ 80%]
core/tests/test_benchmark_zoo.py::test_cli_help_lists_benchmark_from_the_generated_listing PASSED [ 83%]
core/tests/test_benchmark_zoo.py::test_cli_benchmark_add_verify_roundtrip_stdout_is_one_json_object PASSED [ 85%]
core/tests/test_benchmark_zoo.py::test_cli_benchmark_verify_exits_nonzero_on_a_broken_benchmark PASSED [ 88%]
core/tests/test_benchmark_zoo.py::test_cli_benchmark_list_shows_the_bundled_zoo PASSED [ 90%]
core/tests/test_benchmark_zoo.py::test_cli_benchmark_error_path_still_prints_one_json_object PASSED [ 92%]
core/tests/test_benchmark_zoo.py::test_zoo_benchmark_runs_end_to_end_to_a_sealed_test_number PASSED [ 95%]
core/tests/test_benchmark_zoo.py::test_custom_scoring_entry_gives_graded_partial_credit PASSED [ 97%]
core/tests/test_benchmark_zoo.py::test_every_bundled_zoo_entry_verifies_and_is_stamped PASSED [100%]
============================== 42 passed in 1.88s ==============================
  • test_zoo_benchmark_runs_end_to_end_to_a_sealed_test_numbertoy_calc: baseline val 0.0 → gate-accepted → sealed test.reward == 1.0, and a second finalize raises TestSealError.
  • test_custom_scoring_entry_gives_graded_partial_creditjson_extract: prose 0.0, [JSON] ⅓, [JSON]+[FIELDS] 1.0.
  • The four original breakage cases: test_verify_catches_a_stubbed_score, test_verify_catches_a_nondeterministic_run_target (planted random, message still names seed=0 and forward \seed`), test_verify_refuses_a_tiny_dataset_before_the_gate_can_surprise_anyone, test_verify_catches_a_missing_dataset_file`. Plus duplicate ids.
  • Seal untouched: verify runs on val only, creates no run dir, writes no test ids.

Both bundled entries still verify clean and are re-stamped (their capevolve.yaml gained the unioned globs and their stamps gained the two new hashes):

cap-evolve benchmark verify toy_calc — the full executed step list
{
  "name": "toy_calc",
  "ok": true,
  "steps": [
    "manifest parsed + validated",
    "dataset loaded through the adapter: 8 task(s)",
    "cap-evolve check executed on the generated project",
    "splits computed: {'train': 4, 'val': 2, 'test': 2}",
    "REAL smoke eval: 2 val task(s) x 2 passes through live() -> run_target() -> score()",
    "degenerate-scorer probe: 2 task(s) scored with a correct vs a deliberately-wrong output",
    "under-declaration sweep: every .py and answer-key-ish file under project/ (outside seed_capability/) is guard-hashed",
    "protected paths resolved from the generated spec: ['adapters/adapter.py', 'benchmark.yaml', 'capevolve.yaml', 'target.py', 'tasks.jsonl']"
  ],
  "problems": [],
  "notes": [
    "check: tasks('val') -> 2 task(s)",
    "check: scorer deterministic (probe reward=0.0000)",
    "check: materialize() callable (dry-run into temp copy; host untouched)",
    "smoke val reward (seed capability) = 0.0",
    "scorer discriminates on 2/2 smoke task(s) (a correct output outscores a deliberately wrong one)"
  ],
  "val_reward": 0.0,
  "n_tasks": 8,
  "splits": {
    "train": 4,
    "val": 2,
    "test": 2
  },
  "protected": [
    "adapters/adapter.py",
    "benchmark.yaml",
    "capevolve.yaml",
    "target.py",
    "tasks.jsonl"
  ]
}
cap-evolve benchmark list
{
  "zoo": "/private/tmp/fx-233b/benchmarks",
  "benchmarks": [
    {
      "name": "json_extract",
      "dir": "/private/tmp/fx-233b/benchmarks/json_extract",
      "description": "Structured-JSON extraction accuracy (per-field partial credit) for a deterministic zero-API extractor whose prompt is optimized.",
      "scoring": "custom",
      "metric_direction": "higher",
      "verified": true,
      "verified_at": "2026-07-30T14:08:15+00:00",
      "smoke_val_reward": 0.0,
      "n_tasks": 12
    },
    {
      "name": "toy_calc",
      "dir": "/private/tmp/fx-233b/benchmarks/toy_calc",
      "description": "Arithmetic accuracy of a deterministic zero-API stand-in agent whose system prompt is optimized.",
      "scoring": "exact",
      "metric_direction": "higher",
      "verified": true,
      "verified_at": "2026-07-30T14:08:15+00:00",
      "smoke_val_reward": 0.0,
      "n_tasks": 8
    }
  ]
}

Suite + compileall

E         ?                    +

core/tests/test_dashboard_launch.py:56: AssertionError
=========================== short test summary info ============================
FAILED core/tests/test_dashboard_launch.py::test_maybe_launch_spawns_when_available
1 failed, 220 passed in 65.96s (0:01:05)

$ compileall
exit=0

$ python -m cap_evolve.zoo
zoo predicate self-check: OK

220 passed (baseline 203 + 17 new), 0 failed other than test_dashboard_launch.py::test_maybe_launch_spawns_when_available, which is the known #200 flake — port 7878 was occupied on this box (lsof -ti:787840333), so maybe_launch picked 7882. Confirmed pre-existing by stashing every change on this commit and re-running that file alone: same single failure. Excluding just that file: 214 passed.

compileall on core + benchmarks: exit 0, silent. skills/ untouched, so no manifest rebuild.

Files touched


 CHANGELOG.md                                   |  33 ++
 benchmarks/json_extract/project/capevolve.yaml |   2 +-
 benchmarks/json_extract/verified.json          |  10 +-
 benchmarks/toy_calc/project/capevolve.yaml     |   2 +-
 benchmarks/toy_calc/verified.json              |  10 +-
 core/cap_evolve/cli.py                         |   7 +-
 core/cap_evolve/zoo.py                         | 719 ++++++++++++++++++++++---
 core/tests/test_benchmark_zoo.py               | 290 +++++++++-
 docs/ADAPTER_CONTRACT.md                       |   2 +-
 docs/BENCHMARK_ZOO.md                          | 100 +++-
 10 files changed, 1078 insertions(+), 97 deletions(-)

OsherElhadad pushed a commit that referenced this pull request Jul 30, 2026
…s-ref

The anti-rot assertion was `"Honest limits" in project_md`, which matched an
incidental cross-reference earlier in PROJECT.md — so deleting the whole section
(all 5 caveats) or renaming the heading both left the test green. It now anchors
on `## Honest limits`, slices the section body, asserts a distinctive phrase per
caveat, and pins the count at 5.

Also:
- PROJECT.md: qualify "check does not run run_target" — check.py:178-182 does,
  behind CAPEVOLVE_CHECK_TRIAL_PROBE=1 and CAPEVOLVE_N_TRIALS>1 (warn-only).
- PROJECT.md: name LOW_CONFIDENCE_VAL_TASKS = 5 instead of "the threshold".
- PROJECT.md / capevolve.yaml: quote the stable prefix of the gate transcript,
  since #195 appends a suffix after `n=2`.
- README.md: forward link to benchmarks/toy_calc/ (the declarative form), making
  the #233 cross-reference bidirectional.
OsherElhadad pushed a commit that referenced this pull request Jul 31, 2026
B1 (blocking). `CHEAP_REAL_MODEL=claude-haiku-4-5` — the fallback offered in three
docs for users with no local model — produced `preflight: OK`, exit 0, empty stderr
and `test_reward: 0.0` on every task. Two causes, both fixed in run.sh:

* the bundled adapter forwards `seed=`, which Anthropic rejects
  (`UnsupportedParamsError`); the adapter correctly turns that into reward 0.0, so
  the whole rung scored zero behind a clean-looking run. `export LITELLM_DROP_PARAMS=1`.
* every preflight check sat inside `if MODEL.startswith("ollama/")`, so the one
  documented path nobody had exercised was the one path the preflight did not cover —
  and it printed OK, actively vouching for the dead run. The hosted branch now does one
  real 1-token completion through `model_config.llm_kwargs()` and exits non-zero on any
  failure, with the endpoint and every credential redacted from the message.

Secondary trap on the same path: `API_BASE=localhost:11434` was exported
unconditionally and `model_config.py:95` reads the generic `API_BASE` before any
provider special-casing, so a hosted MODEL inherited a localhost endpoint. Now exported
only for an `ollama/` MODEL.

MEASURED, hosted variant, run for real: 98 s, $0.011478, 30 metric_calls,
baseline_val 0.0 -> sealed test_reward 1.0. The six eval costs sum to the total
exactly. That retires the prior $0.21 ESTIMATE, which was 20x high because
pricing.py assumes a 3000-in/800-out rollout and a one-line date is far smaller.

Review findings also addressed:

* Dropped "real paired gate" (5x) for "real accept/reject decision", and added an
  Honest-limits entry stating plainly that this run never rejects on merit: val
  saturates at 1.0 on iteration 1, so the two later rejections are against a ceiling,
  and at num_trials 1 the decision is Δ > 0, not a significance test.
* Three progress lines moved to stderr (#116), and the single-object stdout contract no
  longer depends on `--dashboard off` defaulting: run.sh filters the CLI's stdout to the
  last object, so `json.loads(stdout)` works under `CAPEVOLVE_DASHBOARD=auto` too (#217).
* Corrected the #197 wording. `tasks.jsonl` matches NO default glob; the answer key is
  covered by the `dataset_source` fold-in at protect.py:169, which only happens when
  `protected_paths` is omitted. Declaring the key drops it — verified.
* Corrected the call-count comment in the preset (15 -> 30, with the derivation) and
  the stale CHEAP_REAL_MAX_USD default in run.sh's header (2.0 -> 3.0).
* Replaced the "36 s" figure, which appeared in neither measurement, with "~30 s" and
  the observed spread (30.4 / 33.9 / 34.2 s).
* Softened the zoo line: the determinism guarantee question is deferred, not settled —
  named the `determinism: exact|statistical|none` grade #233 will need.
* Noted the pass^k `{"2": 0.0}` artifact as pre-existing #112, and that the local
  rung's $0 is a metering artifact (the runner's cost there is time).
* Two new tests: the run.sh YAML-rewrite heredoc over the committed preset (it is a
  line-prefix rewriter, so a no-op means the paid rung runs `mock`), and the measured
  `optimizer_max_turns >= 40` floor. Both verified to fail on a broken invariant.

Declined, filed instead: the relative `optimizer_instructions_file` bug is #252 (the
absolute-path workaround stays, with the `check`-passes-what-`run`-ignores disagreement
named in the comment); the optimizer stderr discarded on success is #251 — the reason a
warning alone would not have surfaced this bug.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation dx Developer/onboarding experience enhancement New feature or request priority-p1 High impact

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Benchmark library ("zoo") + cap-evolve benchmark add/verify + declarative manifest

3 participants