Skip to content

GEPA eval-cache hits now carry output/trace (no more hollow reflective dataset) - #210

Open
OsherElhadad wants to merge 2 commits into
mainfrom
fix/issue-111-gepa-cache-traces
Open

GEPA eval-cache hits now carry output/trace (no more hollow reflective dataset)#210
OsherElhadad wants to merge 2 commits into
mainfrom
fix/issue-111-gepa-cache-traces

Conversation

@OsherElhadad

Copy link
Copy Markdown
Collaborator

Closes #111

Problem

EvalCache stored only {reward, feedback}, so on a cache hit _eval_minibatch built a Score with raw={"cached": True} and no output/trace. GEPA re-samples parents constantly, so the parent minibatch that feeds reflection frequently hit the cache — and _write_reflection then emitted - Agent output: (empty) for exactly those failing tasks.

Reflection quality is the whole point of GEPA (arXiv:2507.19457). On cached parents it degraded to a blind hill-climb, and the expensive optimizer call was spent on a blank dataset.

Fix

Issue #111 Option A, pointer flavour:

  • EvalCache.put(..., rollout_file=...) — each entry gains rollout_file, the name of the rollout json under rollouts/<split>/ that produced that score.
  • gepa._replay_cached(...) — on a hit, re-read that json for the real output/trace and rebuild the full Score; hardlink it under the current eval tag so rollouts/<split>/*__<tag>__t0.json is complete either way and the tag-pinned trajectories/ dir exists for a cached minibatch too.
  • A hit whose pointer does not resolve (a pre-GEPA eval cache drops output/trace → hollow reflective dataset #111 cache, or pruned rollouts) is treated as a MISS and re-run. Paying one rollout beats serving an empty Agent output: as if it were a trace — so a score-only entry can never hollow out a reflective dataset again.

Why a pointer, not the payload. The traces are already persisted in the run dir. Copying them into eval_cache.json would grow the cache by the trace size and leave reflection truncated (whatever bound you pick). A pointer costs ~42 bytes/entry, keeps reflection untruncated, and the hardlink makes a cache hit free on disk. Replayed output/trace still go through the same _short(n=1500) truncation a fresh eval uses, so prompt size stays bounded regardless of trace size.

Cache format + size impact

Measured on the pasted end-to-end run (3 GEPA iterations, toy_calc, 8 cache entries, 24 rollout files):

Before

"6a1c1a0e…::a3": {
  "reward": 0.0,
  "feedback": "expected '10' but agent produced 'I think 2 * 5 is roughly some number.'; …"
}

After

"6a1c1a0e…::a3": {
  "reward": 0.0,
  "feedback": "expected '10' but agent produced 'I think 2 * 5 is roughly some number.'; …",
  "rollout_file": "a3__mb_p_0000__t0.json"
}
before after
eval_cache.json 2110 B 2446 B (+336 B / 8 entries = +42 B per entry)
rollouts/ 48 KB 48 KB (hardlinks — a cache hit adds a name, not bytes)
total run dir 436 KB 436 KB

The per-entry cost is a fixed filename, independent of trace size — a 50 KB trace still costs 42 bytes of cache. test_trace_is_bounded_in_the_reflective_signal pins both halves (replayed trace truncated, cache stays <1 KB with a 50 KB trace).

Before / after: the reflective dataset on a cache-hit iteration

Same run, iteration 2, whose parent minibatch was 4/4 served from the cache ({"kind":"minibatch","tag":"mb_p_0001","fired":0,"cached":4}) and 0/4 passing — the exact scenario in the issue.

BEFOREwork/gepa_0002/REFLECTION.md

## 4 actionable failing task(s)
### task a6
- Agent output: 
- Feedback: expected '12' but agent produced 'I think 6 * 2 is roughly some number.'; …
### task a2
- Agent output: 
- Feedback: expected '4' but agent produced 'I think 10 - 6 is roughly some number.'; …

Every "Generated Outputs" blank, no Trajectory: line at all. Note the scorer feedback quotes the output the optimizer was not shown.

AFTER — same file, same iteration

## 4 actionable failing task(s)
### task a6
- Agent output: I think 6 * 2 is roughly some number.
- Trajectory: prompt_had_calc=False
- Feedback: expected '12' but agent produced 'I think 6 * 2 is roughly some number.'; …
### task a2
- Agent output: I think 10 - 6 is roughly some number.
- Trajectory: prompt_had_calc=False
- Feedback: expected '4' but agent produced 'I think 10 - 6 is roughly some number.'; …

Real per-task output and trajectory, from a 100% cache hit, at zero extra rollout cost.

Coordination with #199 / #197

#199 (issue #109) and this PR compose exactly as its author described: #199 made the absence honest; this makes the traces available, so the honest-absence path becomes a genuine edge case (stale/pruned cache) instead of the normal cached path.

I did not edit #199's text — that would conflict for no benefit. Instead this PR makes it self-correcting, because both #199 branches are already conditional on the dir existing:

  • gepa._gepa_block(..., has_trajectories=...) — the "This minibatch was served entirely from the eval cache, so NO rollout files were persisted for it" branch is now not taken on a cached minibatch (the rollouts ARE there), so the prompt makes the truthful "./trajectories/ holds the SAME minibatch rollouts VERBATIM and untruncated" claim instead.
  • harness._copy_step_trajectories — the optimizer_context_warning event with "no rollouts persisted for the pinned eval tag (fully-cached minibatch); trajectories/ OMITTED" now fires 0 times on the merged tree (was once per cached iteration).

Verified on a tree with all three PRs merged:

=== optimizer_context_warning events ===  → 0
=== iter2 trajectories/ (cached minibatch) ===
a2__mb_p_0001__t0.json  a3__mb_p_0001__t0.json
a5__mb_p_0001__t0.json  a6__mb_p_0001__t0.json
=== iter2 prompt claim ===
trajectories/` holds the SAME minibatch rollouts VERBATIM and untruncated — read them when REFLECTION…

Two stale comments for #199's author (theirs to change, one word each, no behavior): harness._copy_step_trajectories says "the eval cache stores only reward+feedback — see #111" and "A fully-cached minibatch writes no rollout files". Both are false after this PR; the surrounding fallback logic remains correct and still-needed for a pre-#111 / pruned cache.

#197 (issue #142): untouched. Both protect.verify guards in _eval_minibatch (pre and post) survive verbatim — I only changed the cache-hit branch inside the loop and the cache.put call.

Expected merge order: #199#197 → this PR (any order works; conflicts are import-line only and between #199 and #197, not with this PR — see Verification).

Verification

$ PYTHONPATH=core python -m pytest core/tests -q
184 passed in 60.70s

179 baseline + 5 new (core/tests/test_gepa_cache_traces.py), 0 failed.

Fail-before/pass-after, by stashing only the source change:

$ git stash push core/cap_evolve/gepa.py core/cap_evolve/cache.py
$ pytest core/tests/test_gepa_cache_traces.py -q
5 failed in 0.53s
$ git stash pop && pytest core/tests/test_gepa_cache_traces.py -q
5 passed in 0.07s

Merged with both sibling branches (#199 then #197), conflicts resolved (import lines only, all three between #199 and #197 — none in my hunks):

$ pytest core/tests -q
227 passed in 74.41s
$ python -m compileall -q core skills
COMPILE_OK

Full commands + untruncated output in the ## 🔬 Evidence comment below.

…e dataset)

The eval cache stored only {reward, feedback}, so `_eval_minibatch` rebuilt a
cache-hit `Score` with `raw={"cached": True}` and no output/trace. GEPA re-samples
parents constantly, so the parent minibatch that FEEDS reflection frequently hit
the cache — and `_write_reflection` then emitted `- Agent output:` (empty) for those
failing tasks. GEPA's headline advantage (rich reflection over failures) was being
degraded by GEPA's own cache: a blind hill-climb on cached parents.

Fix (issue #111 Option A, pointer flavour): each cache entry gains `rollout_file`,
the name of the rollout json under `rollouts/<split>/` that produced the score. On a
hit, `_replay_cached` re-reads that json for the real output/trace and hardlinks it
under the current eval tag, so `rollouts/<split>/*__<tag>__t0.json` is complete
either way and the tag-pinned `trajectories/` dir exists for a cached minibatch too.

A pointer flavour, not a payload flavour, because the traces are ALREADY persisted
in the run dir — duplicating them into eval_cache.json would grow the cache with the
trace size while the reflection stayed truncated. A pointer costs ~42 bytes/entry,
keeps reflection untruncated, and the hardlink makes a hit free on disk.

A hit whose pointer does not resolve (a pre-#111 cache, or pruned rollouts) is
treated as a MISS and re-run: paying one rollout beats serving an empty
"Agent output:" as if it were a trace. Replayed output/trace go through the same
`_short` truncation a fresh eval uses, so prompt size stays bounded.
Copilot AI review requested due to automatic review settings July 29, 2026 23:23

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

Copy link
Copy Markdown
Contributor

Automatic Labeling Failed

An error occurred while trying to automatically label this pull request. Please check the workflow logs for details and add labels manually.

@OsherElhadad

Copy link
Copy Markdown
Collaborator Author

🔬 Evidence

Every command + its literal output. Python /tmp/ce-venv/bin/python, worktree /tmp/wt-111 off origin/main @ 6fca097.

1. Fail-before (stash only the source change, keep the test)

$ git stash push core/cap_evolve/gepa.py core/cap_evolve/cache.py
Saved working directory and index state WIP on fix/issue-111-gepa-cache-traces: 6fca097 …

$ PYTHONPATH=core python -m pytest core/tests/test_gepa_cache_traces.py -q
        res = gepa._eval_minibatch(adapter, cand, ["t0"], run_dir=run_dir, cache=cache,
                                   tag="mb_p_0001", seed=0)
        trace = str((res.per_task[0]["raw"] or {}).get("trace"))
>       assert len(trace) < 2000 and trace.endswith("…[truncated]")
E       AssertionError: assert (4 < 2000 and False)
E        +  where 4 = len('None')
E        +  and   False = <built-in method endswith of str object at 0x108802250>('…[truncated]')
E        +    where <built-in method endswith of str object at 0x108802250> = 'None'.endswith

core/tests/test_gepa_cache_traces.py:164: AssertionError
=========================== short test summary info ============================
FAILED core/tests/test_gepa_cache_traces.py::test_cache_hit_reflective_dataset_has_output_and_trace
FAILED core/tests/test_gepa_cache_traces.py::test_cache_hit_rematerializes_rollouts_under_new_tag
FAILED core/tests/test_gepa_cache_traces.py::test_cache_entry_stores_rollout_pointer_not_payload
FAILED core/tests/test_gepa_cache_traces.py::test_pointerless_or_missing_rollout_is_treated_as_a_miss
FAILED core/tests/test_gepa_cache_traces.py::test_trace_is_bounded_in_the_reflective_signal
5 failed in 0.53s

2. Pass-after

$ git stash pop
$ PYTHONPATH=core python -m pytest core/tests/test_gepa_cache_traces.py -q
.....                                                                    [100%]
5 passed in 0.07s

3. Real end-to-end, zero API cost — examples/toy_calc, mock optimizer, gepa algorithm

Setup (identical for the before and after runs). noop_edit.json is a mock edit that appends a comment with no behavior change, so the local minibatch gate rejects every child and GEPA keeps re-sampling the still-failing seed as parent — precisely the issue's scenario (a re-sampled failing parent whose minibatch is served from cache).

export CAPEVOLVE_CORE=$PWD/core PYTHONPATH=$PWD/core CAPEVOLVE_SKILLS_DIR=$PWD/skills \
       CAPEVOLVE_TOY_DATA=$PWD/examples/toy_calc CAPEVOLVE_MOCK_SCRIPT=$D/noop_edit.json
mkdir -p $D/.capevolve/project/adapters
cp examples/toy_calc/adapter.py $D/.capevolve/project/adapters/
cp -R examples/toy_calc/capability $D/seed_capability
cat > $D/noop_edit.json <<'EOF'
{"edits": [{"file": "prompt.txt", "op": "ensure_contains", "text": "\n# note: iteration touched this file (no behavior change)"}]}
EOF
sed -e 's/^algorithm_skill: hill-climb/algorithm_skill: gepa/' \
    -e 's/^max_iterations: 10/max_iterations: 3/' -e 's/^stall: 2/stall: 99/' \
    templates/project/capevolve.yaml > $D/.capevolve/project/capevolve.yaml
python -m cap_evolve.cli run --spec $D/.capevolve/project/capevolve.yaml \
       --project $D/.capevolve/project --run-ts demo --dashboard off

Run result (identical before/after — the fix changes the reflective signal, not the score):

{
  "run_dir": ".capevolve/run_demo",
  "best_id": "seed",
  "baseline_val": 0.0,
  "test_reward": 0.0,
  "iterations": 3
}

The cache hits, from events.jsonl — iterations 2 and 3 fire 0 rollouts, 4 cached:

{"kind": "minibatch", "tag": "mb_p_0000", "ids": ["a3","a5","a2","a6"], "reward": 0.0, "fired": 4, "cached": 0}
{"kind": "minibatch", "tag": "mb_c_0000", "ids": ["a3","a5","a2","a6"], "reward": 0.0, "fired": 4, "cached": 0}
{"kind": "gepa_local_gate", "candidate": "gepa_0001", "parent": "seed", "child_sum": 0.0, "parent_sum": 0.0, "passed": false}
{"kind": "minibatch", "tag": "mb_p_0001", "ids": ["a6","a2","a3","a5"], "reward": 0.0, "fired": 0, "cached": 4}
{"kind": "minibatch", "tag": "mb_c_0001", "ids": ["a6","a2","a3","a5"], "reward": 0.0, "fired": 0, "cached": 4}
{"kind": "gepa_local_gate", "candidate": "gepa_0002", "parent": "seed", "child_sum": 0.0, "parent_sum": 0.0, "passed": false}
{"kind": "minibatch", "tag": "mb_p_0002", "ids": ["a6","a5","a3","a2"], "reward": 0.0, "fired": 0, "cached": 4}
{"kind": "minibatch", "tag": "mb_c_0002", "ids": ["a6","a5","a3","a2"], "reward": 0.0, "fired": 0, "cached": 4}
{"kind": "gepa_local_gate", "candidate": "gepa_0003", "parent": "seed", "child_sum": 0.0, "parent_sum": 0.0, "passed": false}

BEFORE — work/gepa_0002/REFLECTION.md (source change stashed), full file

# Reflective dataset (GEPA)

Parent minibatch reward: 0.000 (0/4 sampled tasks pass). Below are the FAILING tasks with the agent's actual output/trajectory and the scorer's feedback. Diagnose the COMMON root cause and edit the capability to fix the general pattern — not one task.

## 4 actionable failing task(s)
### task a6
- Agent output: 
- Feedback: expected '12' but agent produced 'I think 6 * 2 is roughly some number.'; the prompt likely lacks an explicit instruction to compute and output only the number

### task a2
- Agent output: 
- Feedback: expected '4' but agent produced 'I think 10 - 6 is roughly some number.'; the prompt likely lacks an explicit instruction to compute and output only the number

### task a3
- Agent output: 
- Feedback: expected '10' but agent produced 'I think 2 * 5 is roughly some number.'; the prompt likely lacks an explicit instruction to compute and output only the number

### task a5
- Agent output: 
- Feedback: expected '5' but agent produced 'I think 8 - 3 is roughly some number.'; the prompt likely lacks an explicit instruction to compute and output only the number

Four blank Agent output: lines, zero Trajectory: lines — hollow, exactly as #111 describes.

AFTER — same file, same iteration, same 4/4 cache hit, full file

# Reflective dataset (GEPA)

Parent minibatch reward: 0.000 (0/4 sampled tasks pass). Below are the FAILING tasks with the agent's actual output/trajectory and the scorer's feedback. Diagnose the COMMON root cause and edit the capability to fix the general pattern — not one task.

## 4 actionable failing task(s)
### task a6
- Agent output: I think 6 * 2 is roughly some number.
- Trajectory: prompt_had_calc=False
- Feedback: expected '12' but agent produced 'I think 6 * 2 is roughly some number.'; the prompt likely lacks an explicit instruction to compute and output only the number

### task a2
- Agent output: I think 10 - 6 is roughly some number.
- Trajectory: prompt_had_calc=False
- Feedback: expected '4' but agent produced 'I think 10 - 6 is roughly some number.'; the prompt likely lacks an explicit instruction to compute and output only the number

### task a3
- Agent output: I think 2 * 5 is roughly some number.
- Trajectory: prompt_had_calc=False
- Feedback: expected '10' but agent produced 'I think 2 * 5 is roughly some number.'; the prompt likely lacks an explicit instruction to compute and output only the number

### task a5
- Agent output: I think 8 - 3 is roughly some number.
- Trajectory: prompt_had_calc=False
- Feedback: expected '5' but agent produced 'I think 8 - 3 is roughly some number.'; the prompt likely lacks an explicit instruction to compute and output only the number

Genuine per-task output and trajectory — prompt_had_calc=False is the actual root cause the optimizer needs and previously never saw.

4. Cache format + size impact, same run

$ # BEFORE
$ python -c "import json,os; d=json.load(open('$B/eval_cache.json')); k=list(d)[0]; print(json.dumps({k:d[k]},indent=2)); print('bytes:',os.path.getsize('$B/eval_cache.json'),'entries:',len(d))"
{
  "6a1c1a0ed1679765499f01e0d1305e5b0af2f930d56cfd25a4e01630f3bf2667::a3": {
    "reward": 0.0,
    "feedback": "expected '10' but agent produced 'I think 2 * 5 is roughly some number.'; the prompt likely lacks an explicit instruction to compute and output only the number"
  }
}
bytes: 2110 entries: 8

$ # AFTER
{
  "6a1c1a0ed1679765499f01e0d1305e5b0af2f930d56cfd25a4e01630f3bf2667::a3": {
    "reward": 0.0,
    "feedback": "expected '10' but agent produced 'I think 2 * 5 is roughly some number.'; the prompt likely lacks an explicit instruction to compute and output only the number",
    "rollout_file": "a3__mb_p_0000__t0.json"
  }
}
bytes: 2446 entries: 8    bytes/entry: 305.8

+336 B over 8 entries = +42 B per entry, and that cost is a fixed filename — independent of trace size.

Disk (hardlinks make a cache hit free; du -sk):

$ du -sk $B/rollouts $A/rollouts
48	/tmp/e2e111_before/.capevolve/run_demo/rollouts
48	/tmp/e2e111c/.capevolve/run_demo/rollouts
$ du -sk $B $A
436	/tmp/e2e111_before/.capevolve/run_demo
436	/tmp/e2e111c/.capevolve/run_demo

24 rollout names after vs 8 before (mb_p_0001/mb_p_0002 re-materialized), 0 KB of new bytes.

The bounded-trace test proves the ceiling holds with a large trace:

huge = "X" * 50_000   # 50 KB trace
...
assert len(trace) < 2000 and trace.endswith("…[truncated]")   # replayed trace bounded
assert (run_dir.root / "eval_cache.json").stat().st_size < 1000   # cache did NOT grow by it

5. Full suite, this branch

$ PYTHONPATH=core python -m pytest core/tests -q
........................................................................ [ 39%]
........................................................................ [ 78%]
........................................                                 [100%]
184 passed in 60.70s (0:01:00)

179 baseline + 5 new, 0 failed. (test_dashboard_launch.py::test_maybe_launch_spawns_when_available — the #200 port-7878 flake — passed here.)

6. Merge compatibility with #199 and #197

$ git merge origin/fix/issue-109-optimizer-context      # PR #199
 18 files changed, 1064 insertions(+), 181 deletions(-)   ← CLEAN, no conflicts

$ git merge origin/feat/issue-142-protected-paths        # PR #197
CONFLICT (content): Merge conflict in core/cap_evolve/__init__.py
CONFLICT (content): Merge conflict in core/cap_evolve/gepa.py
CONFLICT (content): Merge conflict in core/cap_evolve/harness.py

$ grep -n "<<<<<<<\|=======\|>>>>>>>" core/cap_evolve/{__init__,harness,gepa}.py
core/cap_evolve/__init__.py:20:<<<<<<< HEAD
core/cap_evolve/__init__.py:22:=======
core/cap_evolve/__init__.py:24:>>>>>>> origin/feat/issue-142-protected-paths
core/cap_evolve/harness.py:27:<<<<<<< HEAD
core/cap_evolve/harness.py:31:=======
core/cap_evolve/harness.py:33:>>>>>>> origin/feat/issue-142-protected-paths
core/cap_evolve/gepa.py:55:<<<<<<< HEAD
core/cap_evolve/gepa.py:57:=======
core/cap_evolve/gepa.py:59:>>>>>>> origin/feat/issue-142-protected-paths

All three conflicts are import lines between #199 and #197 (from . import optimizer_context as oc vs from . import protect) — they exist independently of this PR and resolve by keeping both sides. Zero conflicts in this PR's hunks.

Full suite on the merged tree (main + #199 + #197 + this):

$ PYTHONPATH=core python -m pytest core/tests -q
........................................................................ [ 31%]
........................................................................ [ 63%]
........................................................................ [ 95%]
...........                                                              [100%]
227 passed in 74.41s (0:01:14)

7. #199's warning is now unnecessary — merged-tree e2e

Same no-op e2e run, on the tree with all three PRs:

$ grep -c optimizer_context_warning $D/.capevolve/run_demo/events.jsonl
0

$ ls $D/.capevolve/run_demo/work/gepa_0002/trajectories/
a2__mb_p_0001__t0.json
a3__mb_p_0001__t0.json
a5__mb_p_0001__t0.json
a6__mb_p_0001__t0.json

$ grep -o "trajectories/\` holds[^.]*\|served entirely from the eval cache" \
     $D/.capevolve/run_demo/work/gepa_0002/INSTRUCTIONS.md
trajectories/` holds the SAME minibatch rollouts VERBATIM and untruncated — read them when REFLECTION

On origin/fix/issue-109-optimizer-context alone this iteration produced an optimizer_context_warning with "no rollouts persisted for the pinned eval tag (fully-cached minibatch); trajectories/ OMITTED", no trajectories/ dir, and the "served entirely from the eval cache" prompt branch. After this PR: 0 warnings, the dir is present with this exact minibatch's four rollouts, and the prompt makes the truthful VERBATIM claim. #199's honest-absence path is preserved and still correct for a pre-#111 or pruned cache.

8. compileall

$ python -m compileall -q core skills
COMPILE_OK

Files touched

  • core/cap_evolve/cache.pyput(..., rollout_file=...), module + class docstrings
  • core/cap_evolve/gepa.py_replay_cached() (new), cache-hit branch of _eval_minibatch, cache.put call, import os, _eval_minibatch docstring
  • core/tests/test_gepa_cache_traces.py — new, 5 tests

@OsherElhadad

Copy link
Copy Markdown
Collaborator Author

🔍 Review — PR #210

Verdict: CHANGES REQUESTED

The diagnosis and the content of the fix are right: I independently confirmed the reflective dataset is now genuinely per-task correct (not merely non-empty), truncation is symmetric, and the cached REFLECTION.md is byte-identical to an uncached one. Every claim in the evidence comment reproduced. But the re-materialization mechanism has two real defects: an unvalidated rollout_file path (a tampered cache entry reads any JSON-shaped file on disk into the optimizer prompt — and hardlinks it into the run dir), and the hardlink's shared inode lets a later write silently rewrite an earlier iteration's archived rollout. Both are two-line fixes.

Blocking

1. gepa.py:216-219rollout_file is not validated or confined to the rollouts dir.
src = out_dir / rfile is a bare Path.__truediv__ on a value read from eval_cache.json, a plain on-disk JSON file. ../ traversal escapes; an absolute path replaces out_dir entirely (Path("/a/b") / "/etc/x" == Path("/etc/x")). This is precisely the threat #142 models — the guard hashes .capevolve/project, it does not cover eval_cache.json. Any JSON file with a rollout key is exfiltrated into REFLECTION.md, i.e. into the optimizer LLM's prompt:

$ /tmp/ce-venv/bin/python /tmp/probe_exfil.py
crafted rollout_file = ../../../../secrets.json
fired rollouts: 0
output in reflective signal: 'sk-SUPER-SECRET-KEY'
trace  in reflective signal: 'AWS_SECRET=xyz'
--- REFLECTION.md leaked? ---
True
ABSOLUTE path output: 'sk-SUPER-SECRET-KEY'      # rollout_file="/tmp/probe_exfil/secrets.json"

Worse, os.link at gepa.py:233 then hardlinks the out-of-tree inode into rollouts/train/, and a later write on that tag writes through it. Pointed at a protected gold.json:

crafted pointer: ../../../project/gold.json | gold nlink before: 1
hardlink created into rollouts: True | shares gold's inode: True | gold nlink now: 2
cap_evolve.protect.TamperError: ... modified gold.json

The #142 guard catches the write (good — the run aborts loudly), but the read/exfiltration is silent and the link is created regardless. Cost accounting also reads reward unchecked: _replay_cached({"reward": "not-a-number", ...})ValueError: could not convert string to float, an uncaught crash mid-loop.

Fix — confine the pointer, one guard at the top of _replay_cached:

rfile = str(cached.get("rollout_file") or "")
# rollout_file is a BARE FILENAME under out_dir. The cache is plain on-disk JSON, so a
# tampered/absolute/../ entry must not read outside rollouts/ (#142's threat model).
if not rfile or Path(rfile).name != rfile:
    return None

Path(rfile).name != rfile rejects /etc/x, ../y, and sub/z in one comparison, and every entry put() writes is already a bare f"{task.id}__{tag}__t0.json", so nothing legitimate is lost. Also wrap the float(cached.get("reward", 0.0)) in the existing except (or add TypeError, ValueError) so a malformed number degrades to MISS rather than aborting.

2. gepa.py:230-236 — the hardlink shares an inode, so a later write_text on a reused tag silently rewrites a PREVIOUS iteration's archived rollout.
_eval_minibatch's fresh-eval writer (gepa.py:182) is Path.write_textopen(..., "w"), which truncates the shared inode rather than replacing the directory entry. Reachable path: tags are f"mb_p_{n:04d}" / f"mb_c_{n:04d}" where n = step_offset + len(steps), and _save() only runs at the end of a step (gepa.py:728, gepa.py:781) — so a run torn mid-iteration and --resumed repeats the same tag. In the e2e run I did, a no-op child edit is byte-identical to the parent, so mb_c_0000 is a cache HIT hardlinked onto the seed's mb_p_0000 inode (verified: nlink=3 across all 8 inodes). On resume the optimizer proposes a real edit → new hash → MISS on that same tag → the truncate lands on iteration 0's record:

$ /tmp/ce-venv/bin/python /tmp/probe_corrupt3.py
iter-0 archived parent rollout   : out-of[PARENT-SEED]
mb_c_0000 shares the inode       : True nlink = 2
--- torn iteration; --resume repeats step n=0 with a REAL edit (cache MISS) ---
iter-0 archived parent rollout NOW: out-of[CHILD-REAL-EDIT]
RESULT: PAST ITERATION SILENTLY CORRUPTED

Consequence: the audit trail this PR exists to make trustworthy can be retroactively rewritten with another candidate's output — no event, no warning. harness._copy_step_trajectories also uses shutil.copyfile (harness.py:1020), which write-throughs a hardlinked destination the same way; and _reconstruct_gepa reads val results back off these files, so a corrupted rollout is silently believed.

Fix — make the fresh-eval write break the link instead of truncating it. rundir._atomic_write (already imported in harness, tmp+os.replace) does exactly this; the two-line version at gepa.py:181:

rfile = f"{task.id}__{tag}__t0.json"
(out_dir / rfile).unlink(missing_ok=True)   # never truncate a hardlinked inode: a cache
                                            # hit may have linked a PRIOR iteration here
(out_dir / rfile).write_text(...)

Add a test that pins it: link a rollout under a second tag, write a fresh rollout to that second tag, assert the first tag's bytes are unchanged.

Non-blocking

3. gepa.py:231 — the not dst.exists() guard makes a reused tag serve a stale trajectory, contradicting the "SAME minibatch VERBATIM" prompt claim. On a resumed run where tag mb_p_0000 already exists from parent A but the frontier now selects parent B, the replay skips materialization and trajectories/ keeps A's rollout while REFLECTION.md shows B's — under #199's unconditional "VERBATIM" assertion:

mb_p_0000 on disk: OUT-A
replayed Score (what REFLECTION.md gets): OUT-B
mb_p_0000 on disk (what trajectories/ gets): OUT-A
MISMATCH: True

Fix: overwrite when the existing record's rollout.task_id/content doesn't match, or drop the not dst.exists() short-circuit once finding 2's unlink is in (the unlink makes the overwrite safe).

4. gepa.py:238 — the copy fallback swallows OSError to pass. When both link and copy fail, the hit is still returned as a complete Score, so trajectories/ silently lacks this task while the prompt (on the merged tree, has_traj is True if any file landed) still claims VERBATIM completeness. The # ponytail: comment names the intent but there's no log_event. Fix: run_dir.log_event("rollout_rematerialize_failed", ...) — cheap, and keeps the honesty posture the rest of the module has. (Requires threading run_dir or returning a flag.)

5. Untested critical paths. The 5 new tests cover the happy path, the pointer format, dangling/legacy MISS, and truncation — genuinely content-asserting, not non-emptiness theatre (assert f"WRONG-ANSWER-for-{pt['task_id']}" in ... is real per-task correspondence). Missing: hardlink-unavailable / cross-fs EXDEV fallback, tag reuse (findings 2 and 3), and path traversal. The EXDEV fallback does work — I verified the copy is byte-identical to the link source by monkeypatching os.link to raise OSError(18) — but nothing in CI pins it.

Nits

6. cache.py:108-110rollout_file is written only when truthy, so entries are heterogeneous ({reward, feedback} vs {reward, feedback, rollout_file}). Harmless (the reader treats absence as MISS), but the docstring at cache.py:80 promises a 3-key shape. One word in the docstring.

7. gepa.py:263raw={"cached": True} is preserved (good) but has zero consumers. grep -rn '"cached"' core/ outside the new tests finds only this write site; dashboard and report don't read it. Nothing broken, just noting the marker is currently write-only, so no downstream behavior changed.

Hardlink audit

Scenario Behavior Acceptable?
Cross-filesystem (EXDEV) os.link raises → dst.write_text(json.dumps(rec)). Verified byte-identical to the link source.
Filesystem without hardlink support Same OSError branch → copy fallback.
Both link and copy fail pass — Score still returned complete, no event logged. ⚠️ (finding 4)
In-place mutation of the target write_text truncates the shared inode; a reused tag rewrites a previous iteration's archived rollout. nlink=3 observed in the real e2e run. (finding 2)
Deleted target Cache entry becomes a MISS → re-runs, cost accounted (usd delta: 0.5, fired: 1), score complete (n: 1), and the entry self-heals to the new rollout_file. No loop, no partial score.
Windows os.link works on NTFS; on FAT/exFAT/network shares it raises → copy fallback. Untested in CI but structurally covered.
Existing dst from a different parent (tag reuse) Skipped — stale trajectory served under a VERBATIM claim. ❌ (finding 3)
#197 tamper guard false-positive NO false positive. protect.build_manifest hashes .capevolve/project with exclude=run_dir.root (protect.py:362,407); rollouts/ is inside the run dir and never hashed. Real merged-tree gepa run: grep -c tamper_detected events.jsonl0, protected.json present.

Is the reflection CORRECT?

Yes — per-task correspondence holds, and I tested it adversarially rather than taking the non-emptiness evidence at face value.

Cache populated in order t0,t1,t2,t3, replayed in a different order t3,t0,t2,t1 (real minibatches are rng-sampled, so ordering must not be load-bearing). Every field cross-checked against its own task id:

fired (want 0): 0
  t3: OK  out='OUT-t3-IN-3' trace='TRACE-t3-only' fb='FB-t3'
  t0: OK  out='OUT-t0-IN-0' trace='TRACE-t0-only' fb='FB-t0'
  t2: OK  out='OUT-t2-IN-2' trace='TRACE-t2-only' fb='FB-t2'
  t1: OK  out='OUT-t1-IN-1' trace='TRACE-t1-only' fb='FB-t1'
PER-TASK CORRESPONDENCE: CORRECT

The re-materialized files carry the matching record, so filename ↔ task_idinput all agree (no pointer mix-up):

  t0__mb_p_0001__t0.json -> t0 OUT-t0-IN-0 | input field: IN-0
  t1__mb_p_0001__t0.json -> t1 OUT-t1-IN-1 | input field: IN-1
  t2__mb_p_0001__t0.json -> t2 OUT-t2-IN-2 | input field: IN-2
  t3__mb_p_0001__t0.json -> t3 OUT-t3-IN-3 | input field: IN-3

Same in the real merged-tree gepa run's fully-cached iteration (gepa_0002, 0 fired / 4 cached) — each task's arithmetic matches its own input:

  a2__mb_p_0001__t0.json | record task_id: a2 | input: 10 - 6 | output: I think 10 - 6 is roughly...
  a3__mb_p_0001__t0.json | record task_id: a3 | input: 2 * 5  | output: I think 2 * 5 is roughly...
  a5__mb_p_0001__t0.json | record task_id: a5 | input: 8 - 3  | output: I think 8 - 3 is roughly...
  a6__mb_p_0001__t0.json | record task_id: a6 | input: 6 * 2  | output: I think 6 * 2 is roughly...

Truncation is symmetric (5 KB output + a nested-dict trace, fresh vs cached):

  fresh  out len 1513 trace len 1513
  cached out len 1513 trace len 1513
  IDENTICAL output: True | IDENTICAL trace: True

Both go through the same _short(n=1500). It cuts mid-structure on a JSON-serialized dict trace ('YYY… …[truncated]' is not valid JSON) — but that is pre-existing _short behavior on the fresh path too, so cache state doesn't change it. Not a #210 finding.

Determinism: byte-identical. Cached vs uncached REFLECTION.md:

uncached sha: fc2f9e6a4884cc44
cached   sha: fc2f9e6a4884cc44
BYTE-IDENTICAL REFLECTION.md: True
raw keys fresh : ['errored', 'output', 'trace']
raw keys cached: ['cached', 'errored', 'output', 'trace']

The only divergence is the extra cached key in raw, which _write_reflection doesn't render. Published results reproduce across cache states.

Merged-tree result

My own merge, main @ 6fca097 + #199 + #197 + #210. Confirmed 3 conflicts, all between #199 and #197 only (__init__.py:20, harness.py:27, gepa.py:54 — every one a keep-both import pair: optimizer_context vs protect). #210 merged with zero conflicts.

$ PYTHONPATH=/tmp/rv-merge/core python -m pytest core/tests -q
227 passed in 75.64s (0:01:15)

Real zero-API gepa run on examples/toy_calc + mock optimizer, merged tree — every #199 interaction claim reproduces:

$ grep -c optimizer_context_warning events.jsonl   → 0
$ grep -c tamper_detected events.jsonl             → 0        (protected.json present)
{"kind":"minibatch","tag":"mb_p_0001","ids":["a6","a2","a3","a5"],"reward":0.0,"fired":0,"cached":4}
{"kind":"minibatch","tag":"mb_p_0002","ids":["a6","a5","a3","a2"],"reward":0.0,"fired":0,"cached":4}
$ work/gepa_0002/trajectories/ → a2__mb_p_0001__t0.json a3__… a5__… a6__…
$ grep -o "trajectories/\` holds the SAME[^.]*" work/*/INSTRUCTIONS.md
  → all 3 iterations make the VERBATIM claim

The "trajectories/ OMITTED" branch fires 0 times, and the VERBATIM claim is truthful — the dir holds exactly this minibatch's four tasks with matching inputs (see above). #199's honest-absence path remains correct for a pre-#111 / pruned cache. The author's "self-correcting, no #199 edit needed" reasoning holds.

Recommended merge order: #197 → #199 → #110 → #210 → #114. #197 and #199 are the only pair that actually conflicts, so land them first and resolve the three import lines once (keep both sides). #110 merges clean into #210. #210 last of the gepa trio — its _eval_minibatch hunk sits next to but not on #197's protect.verify call. #114 conflicts with #210 in core/cap_evolve/cache.py docstring only (the #111 rollout_file paragraph vs #114's shortened header) — trivial, but rebase #114 rather than #210 since #114 is a refactor.

Verification I re-ran

$ cd /tmp/rv-210 && PYTHONPATH=core python -m pytest core/tests -q
184 passed in 58.38s                                  ← 179 baseline + 5, claim reproduces

$ git checkout origin/main -- core/cap_evolve/gepa.py core/cap_evolve/cache.py
$ PYTHONPATH=core python -m pytest core/tests/test_gepa_cache_traces.py -q
FAILED ...::test_cache_hit_reflective_dataset_has_output_and_trace
FAILED ...::test_cache_hit_rematerializes_rollouts_under_new_tag
FAILED ...::test_cache_entry_stores_rollout_pointer_not_payload
FAILED ...::test_pointerless_or_missing_rollout_is_treated_as_a_miss
FAILED ...::test_trace_is_bounded_in_the_reflective_signal
5 failed in 0.54s                                     ← fail-before reproduces exactly
$ # restored
5 passed in 0.04s

$ python -m compileall -q core skills
COMPILE_OK

$ # (9) pre-#111 old-format eval_cache.json, real file, score-only entry
loaded OK; fired: 1  reward now: 0.0  raw: {'errored': False, 'output': 'OUT', 'trace': 'TR'}
cost accounted: 0.5 tokens: 7                         ← degrades to MISS, re-runs, complete

$ # (4) dangling pointer
fired: 1  usd delta: 0.5  reward complete: 0.0  n: 1
cache entry AFTER the miss-rerun: t0__mb_p_0001__t0.json (self-heals -> True)
                                                      ← no loop, no partial score

$ # (1) EXDEV fallback (os.link monkeypatched to raise OSError(18))
fallback file exists: True | shares inode: False
byte-identical to hardlink source: True

$ # real hardlink state, merged-tree e2e run
inode 212538709: nlink=3  ['a2__mb_p_0000__t0.json','a2__mb_p_0001__t0.json','a2__mb_p_0002__t0.json']
… 8 distinct inodes / 24 files                        ← sharing is real, hence finding 2

Both blocking findings are contained in _replay_cached + one line in _eval_minibatch. The reflective-dataset work itself — the part that matters for #111 — is correct and I'd take it as-is.

…stop archived rollouts being rewritten

Review fixes for #210. The reflective-dataset content was correct; the
re-materialization *mechanism* had two exploitable defects.

BLOCKING 1 — `rollout_file` was unvalidated. `eval_cache.json` is plain JSON in
the optimizer-writable run dir, so every field it carries is untrusted input —
exactly the threat #142 models (whose guard hashes `.capevolve/project` and
excludes the run dir, so it does not cover this file). A crafted
`../../../../secrets.json` or absolute pointer read any json-shaped file on disk
straight into REFLECTION.md — i.e. into the optimizer LLM's prompt — and
hardlinked the target's inode into the run dir. `_replay_cached` now requires a
bare filename (`Path(rfile).name != rfile` rejects absolute, `../` and `sub/` in
one comparison — an allowlist of the only legitimate shape, not a denylist of
bad ones) and confines the resolved path to the rollouts dir as a second layer.
A malformed `reward` now degrades to an honest MISS instead of raising
`ValueError` mid-loop.

BLOCKING 2 — a cache hit hardlinks a rollout under the new tag, and the
fresh-eval writer used `write_text`, which truncates the *shared inode*. On a
torn iteration + `--resume` (tags repeat, since `_save()` only runs at step end)
a fresh write silently rewrote a PREVIOUS iteration's archived rollout with
another candidate's output — no event, no warning, falsifying the evidence trail
a sealed number rests on. Both writers into `rollouts/` now use
`rundir._atomic_write` (tmp + `os.replace`), which replaces the directory entry
instead of truncating, so an archived record is immutable once written.
Hardlink sharing is retained, so a cache hit is still free on disk.

Also: drop the stale `not dst.exists()` short-circuit, which let `trajectories/`
serve a PRIOR parent's rollout while REFLECTION.md showed the current one, under
#199's unconditional "SAME minibatch VERBATIM" claim; log
`rollout_rematerialize_failed` instead of a silent `pass` when both link and copy
fail; and correct the EvalCache docstring, which promised a 3-key entry shape
while `rollout_file` is optional.

9 regression tests: path traversal (3 shapes), malformed reward, hardlink
write-through, tag reuse, EXDEV copy fallback, loud re-materialization failure,
and the `_copy_step_trajectories` no-write-through invariant.
Copilot AI review requested due to automatic review settings July 30, 2026 00:09

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

All 7 findings addressed. Both blocking exploits re-run against the fixed code and closed. Nothing in the validated reflective-dataset work was churned — re-proved below.

Exploits closed

Attack Previously Now Test pinning it
rollout_file = "../../../../secrets.json" fired=0, sk-SUPER-SECRET-KEY + AWS_SECRET=xyz into REFLECTION.md (canary count 1), secrets inode hardlinked into rollouts/ (nlink=2) fired=1 (honest MISS + real re-run), canary count 0, secrets nlink=1 test_crafted_rollout_pointer_cannot_read_outside_the_rollouts_dir[../../../../secrets.json]
rollout_file = "/abs/path/secrets.json" same exfiltration, nlink=3 canary count 0, nlink=1 …[/etc/passwd] (absolute)
rollout_file = "sub/secrets.json" escaped out_dir MISS …[sub/secrets.json]
reward = "not-a-number" uncaught ValueError mid-loop degrades to MISS, no crash test_malformed_cached_reward_degrades_to_a_miss_instead_of_crashing
Torn iteration + --resume tag reuse iteration-0 archived rollout silently rewritten out-of[PARENT-SEED]out-of[CHILD-REAL-EDIT] archived rollout UNCHANGED test_fresh_write_never_rewrites_a_hardlinked_archived_rollout
Tag reuse across parents trajectories/ served OUT-A while REFLECTION.md showed OUT-B under the VERBATIM claim both OUT-B, MISMATCH: False test_tag_reuse_replaces_a_prior_parents_trajectory

Exploit 1 — before → after (same probe, same canary):

BEFORE
--- rel: rollout_file = ../../../../secrets.json
    fired rollouts (0 == exploit served from 'cache'): 0
    output in reflective signal : 'sk-SUPER-SECRET-KEY'
    trace  in reflective signal : 'AWS_SECRET=xyz'
    canary count in REFLECTION.md: 1
    hardlinked secrets inode into rollouts/: True  (secrets nlink=2)
--- abs: rollout_file = /var/folders/.../secrets.json
    canary count in REFLECTION.md: 1
    hardlinked secrets inode into rollouts/: True  (secrets nlink=3)
--- malformed reward ---
    malformed reward: CRASH ValueError: could not convert string to float: 'not-a-number'
EXFILTRATED (rel=1, abs=1) -> exploit OPEN

AFTER
--- rel: rollout_file = ../../../../secrets.json
    fired rollouts (0 == exploit served from 'cache'): 1
    output in reflective signal : 'WRONG-ANSWER-for-t0'
    trace  in reflective signal : 'STEP1 read in-0; STEP2 guessed'
    canary count in REFLECTION.md: 0
    hardlinked secrets inode into rollouts/: False  (secrets nlink=1)
--- abs: rollout_file = /var/folders/.../secrets.json
    canary count in REFLECTION.md: 0
    hardlinked secrets inode into rollouts/: False  (secrets nlink=1)
--- malformed reward ---
    malformed reward: handled (no crash)
EXFILTRATED (rel=0, abs=0) -> exploit CLOSED

Exploit 2 + finding 3 — before → after:

BEFORE                                          AFTER
iter-0 archived parent rollout   : out-of[PARENT-SEED]      out-of[PARENT-SEED]
mb_c_0000 shares the inode       : True nlink = 2           True nlink = 2
--- torn iteration; --resume repeats step n=0 with a REAL edit (cache MISS) ---
iter-0 archived parent rollout NOW: out-of[CHILD-REAL-EDIT] out-of[PARENT-SEED]
RESULT: PAST ITERATION SILENTLY CORRUPTED       archived rollout UNCHANGED (safe)

--- finding 3: stale dst.exists() under the VERBATIM claim ---
replayed Score (REFLECTION.md) : out-of[OUT-B]  out-of[OUT-B]
mb_p_0000 on disk (trajectories/): out-of[OUT-A]  out-of[OUT-B]
MISMATCH: True                                  MISMATCH: False

Threat-model note

eval_cache.json lives in the optimizer-writable run dir, so every field it carries is untrusted input — exactly the threat #142 models. #197's guard hashes .capevolve/project with exclude=run_dir.root, so it deliberately does not cover this file (correctly — that's why rollouts/ produces no false positive). The trust boundary is therefore _replay_cached itself, and validation has to live there. Following the lesson this epic has now learned three times (#192's 5-host CDN list, #209's single-sequence HTML escape, #197's .pyc skip), the guard is an allowlist of the one legitimate shape, not a denylist of bad ones: Path(rfile).name != rfile rejects absolute, ../ and sub/ in a single comparison, and every pointer put() writes is already a bare f"{task.id}__{tag}__t0.json", so nothing legitimate is lost. src.resolve().parent != out_dir.resolve() is the second layer, closing symlink indirection.

nlink evidence

Sharing is retained (a cache hit is still free on disk) — what changed is that a fresh write replaces the directory entry instead of truncating the shared inode. Same 3-tag replay, before and after:

BEFORE                                                          AFTER
inode …094: nlink=3 [t0__mb_p_0000, t0__mb_p_0001, t0__mb_p_0002]   inode …586: nlink=3 [t0__mb_p_0000, t0__mb_p_0001, t0__mb_p_0002]
inode …096: nlink=3 [t1__…]                                          inode …588: nlink=3 [t1__…]
inode …098: nlink=3 [t2__…]                                          inode …590: nlink=3 [t2__…]
inode …100: nlink=3 [t3__…]                                          inode …592: nlink=3 [t3__…]
4 distinct inodes / 12 files                                         4 distinct inodes / 12 files

Real zero-API gepa e2e (examples/toy_calc + mock, 3 iterations), merged tree behavior unchanged:

--- cache hits (fired 0 / cached N) ---
  tag=mb_p_0000 fired=4 cached=0
  tag=mb_c_0000 fired=4 cached=0
  tag=mb_p_0001 fired=0 cached=4
  tag=mb_c_0001 fired=0 cached=4
  tag=mb_p_0002 fired=0 cached=4
  tag=mb_c_0002 fired=0 cached=4
--- honesty events (all must be 0) ---
  rollout_rematerialize_failed: 0
  optimizer_context_warning: 0
  tamper_detected: 0
--- nlink census in rollouts/train ---
  inode …428: nlink=3 ['a2__mb_c_0000__t0.json', 'a2__mb_c_0001__t0.json', 'a2__mb_c_0002__t0.json']
  inode …370: nlink=3 ['a2__mb_p_0000__t0.json', 'a2__mb_p_0001__t0.json', 'a2__mb_p_0002__t0.json']
  inode …424: nlink=3 ['a3__mb_c_0000__t0.json', 'a3__mb_c_0001__t0.json', 'a3__mb_c_0002__t0.json']
  8 distinct inodes / 24 files
  trajectories/ entries sharing a rollouts/ inode (must be 0): 0
--- archived iteration-0 rollouts still readable + self-consistent ---
  iteration-0 rollouts: 4 | task_id mismatches (must be 0): 0

Response to all 7 findings

  1. BLOCKING — unvalidated rollout_file. Fixed with your suggested shape plus a second layer. _replay_cached now starts if not rfile or Path(rfile).name != rfile: return None, and inside the try requires src.resolve().parent == out_dir.resolve(). float(cached.get("reward", 0.0)) moved inside the try with TypeError, ValueError added, so a malformed reward degrades to an honest MISS instead of aborting the loop. Exfiltration count 0, nlink=1 on the target, no crash — table above.

  2. BLOCKING — truncate-in-place through the shared inode. Root-caused rather than patched at the one call site: the bug is any write_text into rollouts/, since a replay may have hardlinked a prior iteration's record to that name. Both writers now use rundir._atomic_write (tmp + os.replace, already the module's answer for exactly this hazard) — gepa.py _eval_minibatch and harness.py:252's _persist_trial, which had the identical bug and is the writer for every non-GEPA eval. Replacing the directory entry breaks the link, so an archived record is immutable once written. Reused _atomic_write instead of adding an unlink + write_text pair: same effect, one existing helper, and it's crash-safe too.

  3. Non-blocking — stale dst.exists() serving a prior parent under the VERBATIM claim. Fixed, and treated as blocking-adjacent as you asked: this is fix(algorithm): give GEPA & SkillOpt the same optimizer context as hill-climb, un-gate the CLI flags #199's dishonesty class. The short-circuit is gonedst.unlink(missing_ok=True) then os.link, unconditionally. Safe precisely because finding 2's fix means a fresh write no longer writes through. MISMATCH: False above.

  4. Non-blocking — silent pass on double failure. Now run_dir.log_event("rollout_rematerialize_failed", task_id=…, tag=…, file=…, error=…). run_dir threaded into _replay_cached as an optional last arg. Pinned by test_rematerialize_failure_is_logged_not_swallowed, which fails both os.link and the copy and asserts the event is emitted while the Score stays complete.

  5. Non-blocking — untested critical paths. All three added, plus more: traversal (3 shapes, parametrized), tag reuse (findings 2 and 3, separate tests), EXDEV fallback (test_cross_filesystem_link_failure_falls_back_to_a_byte_identical_copy — monkeypatches os.link to OSError(EXDEV) and asserts the copy is a distinct inode with equal content), malformed reward, and the loud-failure path. Also test_copy_step_trajectories_never_writes_through_a_hardlink: shutil.copyfile does write through a pre-existing hardlinked destination (verified: a target went 'ARCHIVED''NEW'), so _copy_step_trajectories is safe only because _copy_tag rmtrees dst first — that invariant is now pinned rather than left implicit. 9 new tests.

  6. Nit — heterogeneous cache entries vs the docstring. Docstring corrected: {"reward", "feedback"} plus an optional "rollout_file" (omitted when unknown, e.g. a pre-GEPA eval cache drops output/trace → hollow reflective dataset #111 entry; readers treat its absence as a miss). Kept the shape conditional rather than writing "" — the reader already treats absence as a MISS, and an always-present empty string would be a second thing to mean "no pointer".

  7. Nit — raw={"cached": True} write-only. Confirmed and left as-is: it is consumed, by test_cache_hit_reflective_dataset_has_output_and_trace's assert raw.get("cached") is True, which is what distinguishes "served from cache" from "re-run" in the regression suite. No production consumer, no behavior change — noting agreement, not adding one speculatively.

Verification

Both exploits re-run and closed (literal output above). Re-proved everything already validated, so none of it regressed:

=== (A) per-task correspondence under SHUFFLED replay ===
fired (want 0): 0
  t3: OK  out='OUT-t3-IN-3' trace='TRACE-t3-only' fb='FB-t3'
  t0: OK  out='OUT-t0-IN-0' trace='TRACE-t0-only' fb='FB-t0'
  t2: OK  out='OUT-t2-IN-2' trace='TRACE-t2-only' fb='FB-t2'
  t1: OK  out='OUT-t1-IN-1' trace='TRACE-t1-only' fb='FB-t1'
PER-TASK CORRESPONDENCE HOLDS: True

=== (B) symmetric truncation (50KB trace) ===
  fresh  out len 11 trace len 1513
  cached out len 11 trace len 1513
  IDENTICAL output: True | IDENTICAL trace: True
  cache size (bytes, must stay small): 152

=== (C) byte-identical cached vs uncached REFLECTION.md ===
  uncached sha: a276fc9d05a9715b
  cached   sha: a276fc9d05a9715b
  BYTE-IDENTICAL: True
  raw keys fresh : ['errored', 'output', 'trace']
  raw keys cached: ['cached', 'errored', 'output', 'trace']

=== (D) old-format (pre-#111, score-only) cache degrades to MISS ===
  fired: 1  reward now: 0.0  raw: {'errored': 'False', 'output': 'OUT-t0-IN-0', 'trace': 'TRACE-t0-onl'}
  entry self-healed to a pointer: True

=== (E) dangling pointer self-heals, cost accounted ===
  fired: 1  reward complete: 0.0  n: 1
  cache entry AFTER the miss-rerun: t0__mb_p_0002__t0.json

=== (F) EXDEV fallback byte-identical ===
  fallback file exists: True | shares inode: False
  byte-identical to hardlink source: True

Suite + compileall:

$ PYTHONPATH=core python -m pytest core/tests -q
193 passed in 60.39s (0:01:00)          ← 184 + 9 new, 0 failed

$ PYTHONPATH=core python -m pytest core/tests/test_gepa_cache_traces.py -q
14 passed in 0.11s                      ← 5 original + 9 new

$ python -m compileall -q core skills
COMPILE_OK

Merged tree (main + #197 + #199 + #210):

$ PYTHONPATH=core python -m pytest core/tests -q
236 passed in 79.11s (0:01:19)          ← 227 + 9 new, 0 failed

Both exploits also verified closed on the merged tree (EXFILTRATED (rel=0, abs=0) -> exploit CLOSED, archived rollout UNCHANGED (safe), MISMATCH: False).

Conflicts: still 3, still all #199#197 import lines, keep-both (__init__.py:20, harness.py:27, gepa.py:54). #210 merges with zero conflicts — my _atomic_write import is deliberately on its own line rather than appended to the existing from .rundir import RunDir, so it does not create a 4th conflict with #199's optimizer_context imports.

Merge order

#197 → #199 → #110 → #210 → #114, per your recommendation. #197 and #199 are the only genuinely conflicting pair — land them first and resolve the three import lines once (keep both sides). #110 merges clean into #210. #210 last of the gepa trio. #114 conflicts with #210 in a cache.py docstring only — rebase #114, since #114 is the refactor.

Files touched by these fixes

  • core/cap_evolve/gepa.py — pointer confinement + malformed-reward MISS in _replay_cached, _atomic_write for the fresh-eval write, unconditional re-materialization, rollout_rematerialize_failed event, run_dir threaded through
  • core/cap_evolve/harness.py_persist_trial switched from write_text to _atomic_write (same truncate-in-place bug)
  • core/cap_evolve/cache.py — docstring: rollout_file documented as optional
  • core/tests/test_gepa_cache_traces.py — 9 new tests (5 original unchanged)

OsherElhadad pushed a commit that referenced this pull request Jul 30, 2026
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

GEPA eval cache drops output/trace → hollow reflective dataset

3 participants