Skip to content

fix(algorithm): one shared scratch-file list so GEPA snapshots are clean (#110) - #211

Open
OsherElhadad wants to merge 3 commits into
mainfrom
fix/issue-110-gepa-snapshot-ignore
Open

fix(algorithm): one shared scratch-file list so GEPA snapshots are clean (#110)#211
OsherElhadad wants to merge 3 commits into
mainfrom
fix/issue-110-gepa-snapshot-ignore

Conversation

@OsherElhadad

Copy link
Copy Markdown
Collaborator

Closes #110

GEPA's candidate snapshots were dirty. Both run_dir.snapshot() calls in gepa.py omitted ignore=, so every accepted candidate carried the optimizer's scratch alongside the capability — 8 files / 32 KB where hill-climb stored 3 files — and every dashboard candidate-vs-parent diff showed scratch churn instead of the real edit.

Before / after (real runs, examples/toy_calc + mock optimizer)

candidates/ after a 3-iteration run:

GEPA on origin/main GEPA after #199 GEPA after this PR hill-climb (all three)
files 8 (32 KB) 5 3 (12 KB) 3
contents FOCUS.md INSTRUCTIONS.md JOURNAL.md LEDGER.md PROCESS.md REFLECTION.md RUNMAP.md prompt.txt FOCUS.md INSTRUCTIONS.md PROCESS.md REFLECTION.md prompt.txt INSTRUCTIONS.md PROCESS.md prompt.txt INSTRUCTIONS.md PROCESS.md prompt.txt

GEPA now agrees with hill-climb exactly on what is excluded. 5 stray files per accepted iteration eliminated; 32 KB → 12 KB (−62%).

What #199 already fixed vs what this PR adds

#199 (issue #109) did most of the mechanical fix and I built on it rather than duplicating it: it added ignore=_SNAPSHOT_IGNORE to both gepa.py snapshot calls, made _SNAPSHOT_IGNORE derived from optimizer_context.INJECTED_DIRS/INJECTED_NAMES, and folded the same lists into cache.py and gepa._NON_COMPONENT. That eliminated LEDGER.md / JOURNAL.md / RUNMAP.md / prior_iterations/.

What genuinely remained: FOCUS.md and REFLECTION.md — GEPA's own per-iteration scratch — still landed in every snapshot. They were listed in cache._IGNORE_NAMES and gepa._NON_COMPONENT but never in _SNAPSHOT_IGNORE. Verified empirically: the new tests fail on top of origin/fix/issue-109-optimizer-context, not just on main (output pasted below).

Root cause and the shared-constant decision

The missing ignore= argument was the symptom. The cause is that the scratch-name literal was copy-pasted into four modules and desynced — exactly the failure mode that produced #109's kind == "step" filter and #189's counts. #199 correctly unified the injected read-context half (INJECTED_*), but the scratch-file half stayed as four independent literals, and skillopt._changed_components still had it inline twice.

So I made it one definition:

  • rundir.SCRATCH_NAMES — a single tuple, placed in rundir.py because that module is at the bottom of the import graph (stdlib + .splits only), so every consumer can import it eagerly with no cycle and no import-order dependence.
  • Four consumers now derive from it: harness._SNAPSHOT_IGNORE, cache._IGNORE_NAMES, gepa._NON_COMPONENT, skillopt._SCAFFOLDING (new named constant replacing two inline literals).

A newly-injected scratch file added to SCRATCH_NAMES now lands in all four automatically. I deliberately did not put it in optimizer_context next to INJECTED_*: those two lists mean different things (INJECTED_* is read-context the harness copies in; SCRATCH_NAMES is state the harness/algorithm writes) and optimizer_context sits higher in the import graph than cache/rundir need.

INSTRUCTIONS.md and PROCESS.md are deliberately excluded from SCRATCH_NAMES: PROCESS.md is the candidate's per-iteration explainability record and is meant to be snapshotted. Both are filtered at diff time only (dashboard._DIFF_SKIP / harness._CAP_DIFF_SKIP). A test pins that.

Expected merge order

#199#197this PR (any order works, but this is the order verified). My diff is written to apply after both. All merge conflicts are trivial adjacent-import unions; I resolved them locally and ran the combined suite: 225 passed, 0 failed with all three branches merged. #197's tamper guard is untouched (no overlapping lines).

Verification

Full suite on this branch:

$ PYTHONPATH=core python -m pytest core/tests -q
........................................................................ [ 39%]
........................................................................ [ 79%]
......................................                                   [100%]
182 passed in 65.15s (0:01:05)

179 baseline + 3 new, 0 failed.

New tests fail on origin/main (the defect):

$ python -m pytest core/tests/test_gepa.py -q -k "snapshots_are_clean or scratch_ignores"
E       AssertionError: gepa snapshots carry scratch: {'gepa_0001': ['JOURNAL.md', 'LEDGER.md', 'RUNMAP.md']}
FAILED core/tests/test_gepa.py::test_scratch_ignores_are_one_shared_definition
FAILED core/tests/test_gepa.py::test_candidate_snapshots_are_clean_for_every_algorithm[gepa]
2 failed, 1 passed, 6 deselected in 5.16s

…and still fail on top of #199 (what this PR adds):

E           AssertionError: gepa_0001: ['FOCUS.md', 'INSTRUCTIONS.md', 'PROCESS.md', 'REFLECTION.md', 'prompt.txt']
FAILED core/tests/test_gepa.py::test_scratch_ignores_are_one_shared_definition
FAILED core/tests/test_gepa.py::test_candidate_snapshots_are_clean_for_every_algorithm[gepa]
2 failed, 1 passed, 6 deselected in 4.53s

The hill-climb parametrization passes in all three states — it was already correct, and now the two algorithms are pinned to agree.

$ python -m compileall -q core skills && echo COMPILEALL_OK
COMPILEALL_OK

Zero new runtime deps. Tests in core/tests/ per CONTRIBUTING.md.

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

Files touched

  • core/cap_evolve/rundir.py — new shared SCRATCH_NAMES
  • core/cap_evolve/harness.py_SNAPSHOT_IGNORE derives from it
  • core/cap_evolve/gepa.pyignore=_SNAPSHOT_IGNORE on both snapshot calls; _NON_COMPONENT derives
  • core/cap_evolve/cache.py_IGNORE_NAMES derives
  • core/cap_evolve/skillopt.py — two inline literals → _SCAFFOLDING
  • core/tests/test_gepa.py — +3 regression tests

…ean (#110)

GEPA's candidate snapshots were dirty: both `run_dir.snapshot()` calls in gepa.py
omitted `ignore=`, so every accepted candidate carried FOCUS.md, REFLECTION.md,
LEDGER.md, JOURNAL.md and RUNMAP.md alongside the capability — 8 files / 32 KB where
hill-climb stored 3 files, and every dashboard candidate-vs-parent diff showed scratch
churn instead of the real edit.

Root cause is not the missing `ignore=` argument, it is that the scratch-name list was
copy-pasted into four modules and desynced. FOCUS.md/REFLECTION.md were already in
cache._IGNORE_NAMES and gepa._NON_COMPONENT but never in harness._SNAPSHOT_IGNORE, so
even after PR #199 made _SNAPSHOT_IGNORE derived from optimizer_context.INJECTED_*
(which fixed LEDGER/JOURNAL/RUNMAP), GEPA snapshots still leaked GEPA's own scratch.
Same class of bug as #109's `kind == "step"` filter and #189's counts.

Fixed once: rundir.SCRATCH_NAMES is the single definition, at the bottom of the import
graph, read by all four consumers — harness._SNAPSHOT_IGNORE, cache._IGNORE_NAMES,
gepa._NON_COMPONENT and skillopt._SCAFFOLDING (which still had the literal inline,
twice). A newly-injected scratch file now lands in all four automatically.
INSTRUCTIONS.md/PROCESS.md stay deliberately OUT of SCRATCH_NAMES: they are snapshotted
for explainability and filtered at diff time only.

Verified end to end on examples/toy_calc with the mock optimizer: a GEPA candidate went
from 8 files / 32 KB to 3 files / 12 KB and now matches hill-climb's snapshot contents
exactly (INSTRUCTIONS.md, PROCESS.md, prompt.txt) — 5 stray files per accepted
iteration eliminated.

Tests: +3 (the shared-constant invariant, plus an end-to-end snapshot-cleanliness check
parameterized over gepa and hill-climb so the two can't diverge again). Both fail on
origin/main AND on top of #199; hill-climb passes throughout. 182 passed, 0 failed.
compileall core skills clean. Zero new runtime deps.

Closes #110
Copilot AI review requested due to automatic review settings July 29, 2026 23:25

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

🔬 Evidence

Every command and its literal output. Python: a venv at /tmp/ce-venv. Worktrees: /tmp/wt-110 (this branch), /tmp/wt-110-scratch (checked out at other branches for the before-comparisons).

1. Prove the defect on unmodified origin/main

REPO=/tmp/wt-110   # at origin/main
export CAPEVOLVE_CORE="$REPO/core" PYTHONPATH="$REPO/core" CAPEVOLVE_SKILLS_DIR="$REPO/skills"
export CAPEVOLVE_TOY_DATA="$REPO/examples/toy_calc" CAPEVOLVE_MOCK_SCRIPT="$REPO/examples/toy_calc/mock_script.json"
for ALGO in gepa hill-climb; do
  D="/tmp/base-$ALGO"; rm -rf "$D"; mkdir -p "$D/.capevolve/project/adapters"
  cp "$REPO/examples/toy_calc/adapter.py" "$D/.capevolve/project/adapters/"
  cp -R "$REPO/examples/toy_calc/capability" "$D/seed_capability"
  sed "s/^algorithm_skill: .*/algorithm_skill: $ALGO/" \
      "$REPO/templates/project/capevolve.yaml" > "$D/.capevolve/project/capevolve.yaml"
  (cd "$D" && python -m cap_evolve.cli run \
     --spec "$D/.capevolve/project/capevolve.yaml" \
     --project "$D/.capevolve/project" --run-ts demo)
done

Both runs completed ("iterations": 3). Listing what landed in each snapshot:

for A in gepa hill-climb; do
  echo "########## $A candidates ##########"
  for c in /tmp/base-$A/.capevolve/run_demo/candidates/*/; do
    echo "--- $(basename $c)  ($(find "$c" -type f | wc -l) files, $(du -sk "$c" | cut -f1) KB) ---"
    (cd "$c" && find . -mindepth 1 | sed 's|^\./||' | sort)
  done
done
########## gepa candidates ##########
--- gepa_0001  (8 files, 32 KB) ---
FOCUS.md
INSTRUCTIONS.md
JOURNAL.md
LEDGER.md
PROCESS.md
REFLECTION.md
RUNMAP.md
prompt.txt
--- seed  (1 files, 4 KB) ---
prompt.txt
########## hill-climb candidates ##########
--- cand_0001  (3 files, 32 KB) ---
INSTRUCTIONS.md
PROCESS.md
prompt.txt
--- cand_0002  (3 files, 28 KB) ---
INSTRUCTIONS.md
PROCESS.md
prompt.txt
--- cand_0003  (3 files, 28 KB) ---
INSTRUCTIONS.md
PROCESS.md
prompt.txt
--- seed  (1 files, 4 KB) ---
prompt.txt

Defect confirmed. GEPA: 8 files. Hill-climb: 3. The 5 extras are FOCUS.md, JOURNAL.md, LEDGER.md, REFLECTION.md, RUNMAP.md. (No __pycache__/trajectories/ in this particular run — trajectories/guidance/prior_iterations were already in the untargeted snapshot() path's blast radius but the mock optimizer's workdir happened not to retain them here; the .md scratch is the whole delta.)

2. The same runs on top of PR #199 — what it already fixed

cd /tmp/wt-110-scratch && git checkout origin/fix/issue-109-optimizer-context
# …identical run loop, REPO=/tmp/wt-110-scratch…
########## gepa (after #199) ##########
--- gepa_0001 (5 files) ---
FOCUS.md
INSTRUCTIONS.md
PROCESS.md
REFLECTION.md
prompt.txt
--- seed (1 files) ---
prompt.txt
########## hill-climb (after #199) ##########
--- cand_0001 (3 files) ---
INSTRUCTIONS.md
PROCESS.md
prompt.txt
--- cand_0002 (3 files) ---
INSTRUCTIONS.md
PROCESS.md
prompt.txt
--- cand_0003 (3 files) ---
INSTRUCTIONS.md
PROCESS.md
prompt.txt
--- seed (1 files) ---
prompt.txt

#199 removed JOURNAL.md / LEDGER.md / RUNMAP.md. FOCUS.md and REFLECTION.md remain — that is what this PR fixes.

Why: they were in the other two lists but not the snapshot one.

$ grep -n 'FOCUS.md' core/cap_evolve/cache.py core/cap_evolve/gepa.py core/cap_evolve/harness.py
core/cap_evolve/cache.py:27:_IGNORE_NAMES = {"MEMORY.md", "STATE.md", "INSTRUCTIONS.md", "REJECTED.md", "FOCUS.md",
core/cap_evolve/gepa.py:74:    "FOCUS.md", "REFLECTION.md",
# harness.py: no hit — _SNAPSHOT_IGNORE never listed it

3. After this PR

REPO=/tmp/wt-110   # this branch
# …identical run loop…
########## gepa (AFTER FIX) ##########
--- gepa_0001 (3 files, 12 KB) ---
INSTRUCTIONS.md
PROCESS.md
prompt.txt
--- seed (1 files, 4 KB) ---
prompt.txt
########## hill-climb (AFTER FIX) ##########
--- cand_0001 (3 files, 32 KB) ---
INSTRUCTIONS.md
PROCESS.md
prompt.txt
--- cand_0002 (3 files, 28 KB) ---
INSTRUCTIONS.md
PROCESS.md
prompt.txt
--- cand_0003 (3 files, 28 KB) ---
INSTRUCTIONS.md
PROCESS.md
prompt.txt
--- seed (1 files, 4 KB) ---
prompt.txt

GEPA and hill-climb now list identical contents. 8 → 3 files, 32 KB → 12 KB (−62%), 5 stray files per accepted iteration eliminated.

4. The resulting shared constant

$ PYTHONPATH=core python -c "
import cap_evolve.cache, cap_evolve.gepa, cap_evolve.skillopt, cap_evolve.harness as h
print('SNAPSHOT_IGNORE:', h._SNAPSHOT_IGNORE)"
SNAPSHOT_IGNORE: ('trajectories', 'guidance', 'prior_iterations', '.claude', '.agents', '.gemini', '.opencode', '.bob', 'CLAUDE.md', 'AGENTS.md', 'GEMINI.md', 'LEDGER.md', 'JOURNAL.md', 'RUNMAP.md', 'FOCUS.md', 'REFLECTION.md', 'REJECTED.md', 'MEMORY.md', 'STATE.md')

(No import cycle: rundir imports only stdlib + .splits, so cache, harness, gepa and skillopt all import SCRATCH_NAMES eagerly.)

5. Regression tests fail before, pass after

On origin/main:

$ PYTHONPATH=core python -m pytest core/tests/test_gepa.py -q -k "snapshots_are_clean or scratch_ignores"
E       AssertionError: gepa snapshots carry scratch: {'gepa_0001': ['JOURNAL.md', 'LEDGER.md', 'RUNMAP.md']}
E       assert not True
core/tests/test_gepa.py:328: AssertionError
=========================== short test summary info ============================
FAILED core/tests/test_gepa.py::test_scratch_ignores_are_one_shared_definition
FAILED core/tests/test_gepa.py::test_candidate_snapshots_are_clean_for_every_algorithm[gepa]
2 failed, 1 passed, 6 deselected in 5.16s

On top of origin/fix/issue-109-optimizer-context (#199):

$ PYTHONPATH=core python -m pytest core/tests/test_gepa.py -q -k "snapshots_are_clean or scratch_ignores"
E           AssertionError: gepa_0001: ['FOCUS.md', 'INSTRUCTIONS.md', 'PROCESS.md', 'REFLECTION.md', 'prompt.txt']
core/tests/test_gepa.py:332: AssertionError
FAILED core/tests/test_gepa.py::test_scratch_ignores_are_one_shared_definition
FAILED core/tests/test_gepa.py::test_candidate_snapshots_are_clean_for_every_algorithm[gepa]
2 failed, 1 passed, 6 deselected in 4.53s

In both cases the [hill-climb] parametrization is the 1 passed — it was already correct.

On this branch:

$ PYTHONPATH=core python -m pytest core/tests/test_gepa.py -q
.........                                                                [100%]
9 passed in 30.45s

6. Full suite + compileall

$ PYTHONPATH=/tmp/wt-110/core python -m pytest core/tests -q
........................................................................ [ 39%]
........................................................................ [ 79%]
......................................                                   [100%]
182 passed in 65.15s (0:01:05)

179 baseline + 3 new = 182, 0 failed. (test_dashboard_launch.py::test_maybe_launch_spawns_when_available passed here; it is the known port-7878 flake, #200.)

$ python -m compileall -q core skills && echo COMPILEALL_OK
COMPILEALL_OK

7. Merge-order verification: this PR + #199 + #197 together

$ git merge origin/fix/issue-109-optimizer-context
CONFLICT (content): Merge conflict in core/cap_evolve/gepa.py
CONFLICT (content): Merge conflict in core/cap_evolve/harness.py
CONFLICT (content): Merge conflict in core/cap_evolve/skillopt.py
CONFLICT (content): Merge conflict in core/cap_evolve/cache.py

All four are adjacent-import / adjacent-constant unions, e.g.

<<<<<<< HEAD
from .rundir import SCRATCH_NAMES, RunDir, _atomic_write
=======
from .rundir import RunDir, _atomic_write, iteration_candidate
>>>>>>> origin/fix/issue-109-optimizer-context

resolves to from .rundir import SCRATCH_NAMES, RunDir, _atomic_write, iteration_candidate, and the constant itself composes cleanly:

_SNAPSHOT_IGNORE = _oc.INJECTED_DIRS + _oc.INJECTED_NAMES + SCRATCH_NAMES

Then merging #197 on top:

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

Both are from . import protect landing next to from . import optimizer_context#197 vs #199, not touching any line of mine. (core/cap_evolve/__init__.py likewise: from .protect import TamperError vs from .optimizer_context import OptimizerContext.) All resolved by keeping both imports.

Combined suite with all three branches merged:

$ PYTHONPATH=/tmp/wt-110-scratch/core python -m pytest core/tests -q
........................................................................ [ 32%]
........................................................................ [ 64%]
........................................................................ [ 96%]
.........                                                                [100%]
225 passed in 80.23s (0:01:20)

225 passed, 0 failed. #197's tamper guard and #199's context seam both intact.

@skillberry-bot skillberry-bot added algorithm Optimization algorithms: GEPA / SkillOpt / hill-climb bug Something isn't working tech-debt Dead code, duplication, refactors priority-p1 High impact labels Jul 29, 2026
@skillberry-bot

Copy link
Copy Markdown
Contributor

🏷️ Automatic Labeling

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

  • algorithm - bug - tech-debt - algorithm - bug - tech-debt - 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

🔍 Review — PR #211

Verdict: CHANGES REQUESTED — 1 blocking finding.

The core fix is right and the scoping is honest (I reproduced the fail-on-main and the fail-on-top-of-#199 states verbatim). The excluded set is correct, PROCESS.md really is written for GEPA candidates, a trimmed snapshot is still materializable and re-evaluable, old eval caches are byte-identically unaffected, and the four-way unification is sound. My own merged tree (#211 + #199 + #197 + #210) is 230 passed, 0 failed.

The one blocking issue is a side effect of the unification that the PR's own evidence can't see: _SNAPSHOT_IGNORE grew 5 names, and unlike the other three consumers, snapshot exclusion is destructive. Two of the new names (STATE.md, MEMORY.md) are dead framework names — nothing in core/ writes them any more (only test_w1_engine.py:458-459 does) — so their only remaining real-world referent is a capability file that happens to share the name. shutil.ignore_patterns matches by basename at every depth, so such a file is now silently deleted from the candidate on the way into the snapshot, and from every descendant iteration after it.


Blocking

1. core/cap_evolve/harness.py:1588 — snapshot exclusion is destructive, and 5 names were added to it that only the non-destructive consumers needed. A capability file named STATE.md / MEMORY.md / REFLECTION.md / FOCUS.md / REJECTED.md — at any depth — is now silently deleted from the candidate and from the whole downstream lineage.

SCRATCH_NAMES (rundir.py:43-51) is folded into all four consumers. For three of them the operation is a read-side filtercache._IGNORE_NAMES skips bytes when hashing, gepa._NON_COMPONENT skips a name when listing components, skillopt._SCAFFOLDING skips a name when counting edits. Nothing is lost; a false positive only costs precision. For _SNAPSHOT_IGNORE the operation is shutil.copytree(..., ignore=shutil.ignore_patterns(*ignore)) (rundir.py:409-410), which drops the file permanently, and shutil.ignore_patterns matches on basename at every directory level, not just the workdir root.

Net delta to _SNAPSHOT_IGNORE vs main (measured, not read):

snapshot added: ['FOCUS.md', 'MEMORY.md', 'REFLECTION.md', 'REJECTED.md', 'STATE.md']  removed: []

FOCUS.md/REFLECTION.md are the intended fix (GEPA writes them, gepa.py:247,271). But MEMORY.md/STATE.md/REJECTED.md are not written by any live code path — grep across core/ skills/ templates/ for a write of those names returns only core/tests/test_w1_engine.py:458-459. rundir.py:49's own comment concedes it: "the legacy MEMORY/STATE pair it replaced". REJECTED.md likewise — rejected-edit memory is run_dir.rejected_path (rejected.jsonl, harness.py:1381), not a workdir .md. So excluding them buys nothing, and the only remaining thing those names can refer to on disk is a capability file.

Reproduction — a capability with a nested src/prompts/STATE.md (a plausible prompt fragment), zero-API gepa on examples/toy_calc + the mock optimizer:

=== workdir (what the optimizer actually edited & what was EVALUATED) ===
./FOCUS.md ./INSTRUCTIONS.md ./JOURNAL.md ./LEDGER.md ./PROCESS.md ./REFLECTION.md ./RUNMAP.md
./prompt.txt
./src/prompts/STATE.md      <-- capability file, present
./src/prompts/keep.txt

=== candidate SNAPSHOT (this branch) ===
./INSTRUCTIONS.md ./PROCESS.md ./prompt.txt
./src/prompts/keep.txt      <-- STATE.md GONE

On main (hill-climb, same capability) it survives, so this is a regression, not a pre-existing wart:

=== ON MAIN, hill-climb cand_0001 snapshot ===
./INSTRUCTIONS.md ./PROCESS.md ./prompt.txt
./src/prompts/STATE.md      <-- survives
./src/prompts/keep.txt

Three compounding consequences:

  1. Lineage amputation. The next iteration's workdir is shutil.copytree(parent_dir, workdir) from the snapshot (harness.py:1249), so the deletion propagates:
    --- STATE.md survived into iter2? ---
    NO — silently deleted from the lineage
    
  2. A stale cache hit that should not match. Because cache._IGNORE_NAMES also ignores the name, deleting it does not bust the key:
    has STATE.md: True False
    hash a: b783c25fcc1a34af
    hash b: b783c25fcc1a34af
    SAME HASH despite a real capability file being deleted? True
    
    Iteration 2 therefore serves iteration 1's cached rewards for a candidate that is materially different. This is the exact "stale hit that shouldn't match" failure mode, and it lands only because snapshot-exclusion and cache-exclusion now share one list.
  3. The sealed number stops referring to the snapshot. For the first iteration the snapshot is still a faithful record (I checked: hash_candidate_dir(workdir) == hash_candidate_dir(snapshot), both b783c25f… — the ignored name cancels on both sides). But the capability that produced that number had a file the artifact does not, so materializing the snapshot into a fresh runner rebuilds a different capability.

Fix (small). Split the destructive consumer from the non-destructive ones. SCRATCH_NAMES should carry only names the framework/algorithms actually write — drop the three dead ones:

# rundir.py:43
SCRATCH_NAMES = (
    "LEDGER.md", "JOURNAL.md", "RUNMAP.md",
    "FOCUS.md", "REFLECTION.md",
)
LEGACY_SCRATCH_NAMES = ("REJECTED.md", "MEMORY.md", "STATE.md")  # no live writer; filter-only

then keep the legacy trio only in the three read-side consumers (cache.py:31, gepa.py:75, skillopt.py:389| set(SCRATCH_NAMES) | set(LEGACY_SCRATCH_NAMES)), and leave harness.py:1588's _SNAPSHOT_IGNORE on SCRATCH_NAMES alone. That preserves the whole #110 fix (FOCUS.md/REFLECTION.md still excluded; my run still yields the 3-file parity snapshot) while restoring main's behaviour for the three dead names.

Even better and equally cheap, since snapshot() is the only destructive site: make rundir.snapshot root-anchored so a nested capability file can never be caught by a basename rule, e.g. pass an ignore callable that only filters when src == src_dir. Worth doing regardless of the split — trajectories/guidance/prior_iterations/CLAUDE.md are all root-level injections too, so root-anchoring is strictly more correct for every entry in the list.


Non-blocking

2 findings.

2. core/cap_evolve/dashboard.py:589-590_DIFF_SKIP was left behind and is now the only surviving copy of the old list. The PR unified four sets and (correctly, per the stated reasoning) declined to unify the two *_DIFF_SKIP sets. But dashboard._DIFF_SKIP still hardcodes {"INSTRUCTIONS.md", "MEMORY.md", "STATE.md", "LEDGER.md", "JOURNAL.md", "PROCESS.md", "RUNMAP.md"} and is missing FOCUS.md/REFLECTION.md/REJECTED.md. Today that is masked because those files are no longer in the snapshot at all. Consequence: the desync the PR set out to eliminate now lives in exactly one place, and if a future change re-admits REFLECTION.md to the snapshot (a plausible ask — see the audit table), the dashboard diff goes noisy again with no test catching it. Fix: derive _DIFF_SKIP from SCRATCH_NAMES | {"INSTRUCTIONS.md", "PROCESS.md"} too — the "not a capability edit" question genuinely is the same question, unlike the snapshot/cache pair. Declining to unify _DIFF_SKIP because it is a fifth consumer is the wrong reason; unify it, or add a test pinning that it is a superset.

3. core/tests/test_gepa.py:326p.parts[len(d.parts)] will IndexError rather than fail cleanly. For any p yielded by d.rglob("*") the index is in range today, but the expression is fragile and, more importantly, the whole dirty dict is redundant: the assert got == [...] at line 332 already subsumes it (an exact-set assertion catches both over- and under-exclusion, which is the property that matters). Consequence: a future edit gets an opaque IndexError instead of an assertion message. Fix: delete lines 324-328 and keep the exact-set assertion, or use p.relative_to(d).parts[0].


Nits

4. core/cap_evolve/rundir.py:29-42 — the 14-line comment says "four consumers" and names skillopt._changed_files; the function is _changed_components (skillopt.py:394). Also dashboard._DIFF_SKIP is cited as the diff-time filter but is not actually derived from anything (see #2), so the comment overstates the invariant. One-word fix plus a hedge.

5. core/cap_evolve/rundir.py:49 — the comment "the legacy MEMORY/STATE pair it replaced" is the tell for finding #1. If a name is legacy with no writer, it does not belong in a list whose primary consumer deletes files. Worth stating explicitly next to whatever survives the split.


Excluded-set audit

Verdict on the 5 files this PR removes from GEPA snapshots (plus prior_iterations/, already excluded pre-PR). "Regenerable" = reconstructible from what the run dir still persists after the trim.

File Scratch or evidence? Regenerable from the run dir? Correct to exclude?
FOCUS.md (gepa.py:271) Scratch. Pure derivation: comps[comp_cursor % len(comps)]. Its content is also mirrored verbatim into the snapshot — candidates/gepa_0001/INSTRUCTIONS.md line 4 reads Component focus: prompt.txt. Yes — plus it's literally still in the snapshot, in INSTRUCTIONS.md.
REFLECTION.md (gepa.py:247) Borderline — a truncated view, not the source. It IS GEPA's learning signal, but it is a lossy rendering ([:800] per field, [:12] tasks) of rollouts/train/<task>__mb_p_NNNN__t0.json, which survives the trim untouched. INSTRUCTIONS.md in the snapshot preserves the join key (Minibatch task ids: a3, a5, a2, a6) and events.jsonl logs minibatch {tag, ids, reward}. So the reflection that produced the edit does travel with the edit — via a pointer to richer data than the file it replaced. Yes, and losslessly — the rollout JSONs are the superset. ✅ (with the caveat that this depends on INSTRUCTIONS.md staying snapshotted; if that ever changes, the join key is lost)
LEDGER.md Scratch. Framework-regenerated every iteration from history.jsonl + rejected.jsonl (harness.py:912_build_ledger). Snapshotting iteration N's copy stores a stale ledger. Yes (history.jsonl, rejected.jsonl, events.jsonl all persist).
JOURNAL.md Evidence, but run-level, not candidate-level. Append-only across the whole run and persisted at run_dir.root/JOURNAL.md (harness.py:785, _reconcile_journal at harness.py:1340). Per-candidate copies would each be a prefix of the same file. Yes — the authoritative copy is the run-level file. ⚠️ but see below. ✅ for this PR
RUNMAP.md Scratch. A manifest of prior workdirs, regenerated each iteration (harness.py:916). Yes.
prior_iterations/ Scratch (copies). Copies of other candidates' PROCESS.md + diffs, all still in candidates/. Yes. ✅ (pre-existing)

Nothing excluded is irreplaceable evidence. The REFLECTION.md question — the sharpest one — resolves in the author's favour: the reflective dataset is a view over rollouts/, which is untouched, and the snapshot retains the minibatch ids needed to reconstruct it. #210 makes this strictly stronger (it hardlinks the producing rollout into the tag dir so a cache hit no longer hollows out the reflection), so the two PRs reinforce rather than fight.

⚠️ One observation, not a finding for this PR: _reconcile_journal is only called from harness.py:1340 (hill-climb's run_step). GEPA never calls it, so the run-level JOURNAL.md is never written on a GEPA run — my run dir has no JOURNAL.md at the root, while the hill-climb one does (2922 bytes). Excluding JOURNAL.md from GEPA snapshots is still correct (the per-candidate copy was a stale prefix), but on GEPA the journal now has no durable home at all. That gap predates this PR and is orthogonal to #110 — worth its own issue, not a blocker here.

On the KEPT set: confirmed correct and confirmed non-nominal. PROCESS.md is produced for GEPA candidates — it comes from _augment_instructionsharness.py:914-915, which GEPA calls via the shared import (gepa.py:57), not from any hill-climb-specific path. My gepa run's candidates/gepa_0001/PROCESS.md contains the real _PROCESS_SEED template. So the "parity with hill-climb" claim is substantive, not nominal.

Is the four-way unification sound?

Sound for three of four; the fourth is where finding #1 lives. The four sets were already byte-identical in intent on main (I diffed them), so this is deduplication of genuinely one concept — "this file is not capability content" — not a coincidental overlap. Verified: cache._IGNORE_NAMES and gepa._NON_COMPONENT come out exactly equal to their main values (added: [] removed: []), i.e. the unification is a pure refactor for those two. Per consumer:

Consumer Same concept? Verdict
gepa._NON_COMPONENT (gepa.py:75) Yes — "not an editable component". Set is byte-identical to main. ✅ Sound. Pure refactor.
cache._IGNORE_NAMES (cache.py:31) Yes, despite answering a different question. It governs cache keying ("are these two candidates identical?") while _SNAPSHOT_IGNORE governs what is committed. Different questions — but both reduce to the same predicate, "is this byte capability content?", and both must answer it identically or you get a hash that keys on bytes the artifact doesn't contain. Byte-identical to main, so no invalidation. ✅ Sound as unified — but it is the amplifier for finding #1: sharing the list with the destructive consumer is exactly why a deletion doesn't bust the key.
skillopt._SCAFFOLDING (skillopt.py:389) Yes — "not an applied edit". Gains FOCUS.md, REFLECTION.md, REJECTED.md vs main, all correct (they were an under-exclusion bug: skillopt was miscounting GEPA-style scratch as applied edits). ✅ Sound, and a latent bug fixed as a side effect.
harness._SNAPSHOT_IGNORE (harness.py:1588) Concept yes, semantics no. The predicate matches; the operation does not. This is the only consumer where a false positive is destructive and unrecoverable. Over-unified. It needs a subset — names with a live writer — not the union. See finding #1.

So: not over-unified across the board — over-unified in exactly one direction, harness._SNAPSHOT_IGNORE. The author's own reasoning for declining to unify dashboard._DIFF_SKIP/harness._CAP_DIFF_SKIP ("different consumers, don't collapse what only coincidentally overlaps") applies here and was not applied — ironically it was applied to the one pair where it doesn't hold (_DIFF_SKIP really is the same question — see finding #2).

Cache invalidation

Old eval caches still behave correctly — no mass misses, no invalidation. cache._IGNORE_NAMES comes out set-equal to its main value, and _IGNORE_DIRS (cache.py:32) is untouched:

cache identical to main? True  added: []  removed: []
_IGNORE_DIRS = {".git", "__pycache__", "prior_iterations"}     # unchanged line in the diff

Since hash_candidate_dir (cache.py:35-62) folds only path + bytes of non-ignored files and the ignore sets are unchanged, every pre-existing <hash>::<task_id> key still resolves. Confirmed against a live cache: candidate hash in existing cache? True. No interaction with #210 either — #210 adds a rollout_file value field, not a key change, so the two are orthogonal.

The one stale-hit hazard is finding #1's, and it is introduced by the snapshot change, not the cache change: a capability file whose basename collides with a SCRATCH_NAMES entry is deleted by snapshot() yet ignored by hash_candidate_dir(), so the mutated candidate keeps the parent's key and serves the parent's rewards (SAME HASH despite a real capability file being deleted? True). Fixing #1 closes this.

Merged-tree result

I merged all four myself (#211 base → #199#197#210). The author's report of the #199 conflicts is accurate — 4 files, all adjacent-import / adjacent-constant unions, no semantic overlap. #197 was 3 files, all pure from . import protect next to from . import optimizer_context, exactly as described. #210 merged clean, zero conflicts (the author didn't test this combination).

$ git merge origin/fix/issue-109-optimizer-context     # 4 conflicts, all import/constant unions
$ git merge origin/feat/issue-142-protected-paths      # 3 conflicts, all import adjacency
$ git merge origin/fix/issue-111-gepa-cache-traces
Merge made by the 'ort' strategy.
 core/cap_evolve/cache.py             |  44 +++++++---
 core/cap_evolve/gepa.py              |  72 +++++++++++++--
 core/tests/test_gepa_cache_traces.py | 166 +++++++++++++++++++++++++++++++++++

$ python -m compileall -q core skills && echo COMPILEALL_OK
COMPILEALL_OK
$ PYTHONPATH=core python -m pytest core/tests -q
........................................................................ [ 31%]
........................................................................ [ 62%]
........................................................................ [ 93%]
..............                                                           [100%]
230 passed in 78.75s (0:01:18)

230 passed, 0 failed (the author's 225 was the 3-way; #210's test_gepa_cache_traces.py adds the other 5). The composed constants come out correct:

snap: ['.agents','.bob','.claude','.cursor','.gemini','.opencode','AGENTS.md','CLAUDE.md','FOCUS.md',
       'GEMINI.md','JOURNAL.md','LEDGER.md','MEMORY.md','REFLECTION.md','REJECTED.md','RUNMAP.md',
       'STATE.md','guidance','prior_iterations','trajectories']

#210 hardlinks do not land in a snapshot and do not trip the tamper guard. _score_from_cache writes to out_dir = run_dir.rollouts / "train" (gepa.py:159, os.link at gepa.py:248) — the run dir's rollouts/, never the optimizer workdir, so snapshot() can't see them. Verified on a real merged-tree gepa run:

=== hardlinks inside snapshot? ===
(none)
=== tamper events ===
   1 protected_paths_unmatched      # toy_calc has no scorer at the default globs; NOT a tamper

protect.build_manifest is scoped to project_dir with exclude=run_dir.root (protect.py:407), so rollouts/ is outside the protected surface by construction. Snapshot parity holds on the merged tree — gepa and hill-climb both emit INSTRUCTIONS.md, PROCESS.md, prompt.txt.

Security

Clean. Nothing new is committed — the diff only removes files from snapshots. The two kept files carry no secrets: INSTRUCTIONS.md is the rendered prompt (task ids + focus label + template prose) and PROCESS.md is the optimizer's template/notes. No absolute paths leak in (step["workdir"] holds one but lives in the in-memory step dict, not the snapshot). Nothing previously excluded-by-accident was load-bearing for #197's protected set: protect derives its manifest from project_dir globs, wholly independent of SCRATCH_NAMES, and grep SCRATCH_NAMES core/cap_evolve/protect.py is empty.

Merge order

origin/refactor/issue-114-drop-write-only-memory does not exist on the remote. Recommended:

  1. fix(algorithm): give GEPA & SkillOpt the same optimizer context as hill-climb, un-gate the CLI flags #199 (fix/issue-109-optimizer-context) — the hub. It owns optimizer_context.INJECTED_*, which the other three all touch. Landing it first turns three of fix(algorithm): one shared scratch-file list so GEPA snapshots are clean (#110) #211's four conflicts into no-ops.
  2. Protected-paths tamper guard: verify the optimizer never edited scoring/eval/task files #197 (feat/issue-142-protected-paths) — orthogonal; conflicts with everything only at import adjacency. Land early to get it out of the way.
  3. fix(algorithm): one shared scratch-file list so GEPA snapshots are clean (#110) #211 (this PR) — rebase on fix(algorithm): give GEPA & SkillOpt the same optimizer context as hill-climb, un-gate the CLI flags #199+Protected-paths tamper guard: verify the optimizer never edited scoring/eval/task files #197. The composed constant is _oc.INJECTED_DIRS + _oc.INJECTED_NAMES + SCRATCH_NAMES; verified working. Land after finding Executing: pip install ./core #1 is fixed.
  4. GEPA eval-cache hits now carry output/trace (no more hollow reflective dataset) #210 (fix/issue-111-gepa-cache-traces) — merged clean against all three. Last, since it benefits from fix(algorithm): one shared scratch-file list so GEPA snapshots are clean (#110) #211's trimmed snapshots and fix(algorithm): give GEPA & SkillOpt the same optimizer context as hill-climb, un-gate the CLI flags #199's context seam.

Note for the rebase: post-#199, dashboard._DIFF_SKIP (finding #2) is the only remaining hardcoded copy of the list in the tree. Worth folding in while the file is open.

Verification I re-ran

$ cd /tmp/rv-211 && PYTHONPATH=core pytest core/tests -q
182 passed in 62.56s (0:01:02)                     # matches the claim exactly (179 + 3)

$ python -m compileall -q core skills && echo COMPILEALL_OK
COMPILEALL_OK

Fail-before on main (new tests cherry-picked onto origin/main) — reproduces verbatim:

E       AssertionError: gepa snapshots carry scratch: {'gepa_0001': ['JOURNAL.md','LEDGER.md','RUNMAP.md']}
FAILED core/tests/test_gepa.py::test_scratch_ignores_are_one_shared_definition
FAILED core/tests/test_gepa.py::test_candidate_snapshots_are_clean_for_every_algorithm[gepa]
2 failed, 1 passed, 6 deselected in 5.39s

Fail-before on top of #199 — the honest-scoping claim — reproduces verbatim:

$ cd /tmp/rv-211-199   # origin/fix/issue-109-optimizer-context + only the new tests
E           AssertionError: gepa_0001: ['FOCUS.md','INSTRUCTIONS.md','PROCESS.md','REFLECTION.md','prompt.txt']
FAILED core/tests/test_gepa.py::test_scratch_ignores_are_one_shared_definition
FAILED core/tests/test_gepa.py::test_candidate_snapshots_are_clean_for_every_algorithm[gepa]
2 failed, 1 passed, 6 deselected in 5.09s

The tests genuinely fail on top of #199, not just on main#199 alone leaves FOCUS.md/REFLECTION.md. The scoping claim ("#199 did 8→5, this PR does 5→3") is accurate. In both cases [hill-climb] is the 1 passed, so the parametrization is a real parity guard, not decoration.

Real zero-API run, examples/toy_calc + mock optimizer, this branch:

########## gepa ##########
--- gepa_0001  (3 files) ---   INSTRUCTIONS.md  PROCESS.md  prompt.txt
--- seed (1 files) ---         prompt.txt
########## hill-climb ##########
--- cand_0001  (3 files) ---   INSTRUCTIONS.md  PROCESS.md  prompt.txt
--- cand_0002/0003 ---         (identical)
--- seed (1 files) ---         prompt.txt

8 → 3 confirmed, and gepa/hill-climb list identical contents.

Materialize-from-snapshot / replay check — the snapshot is a faithful record of what was scored, for a capability with no name collisions:

$ python -c "from cap_evolve.cache import hash_candidate_dir; ..."
workdir  hash: b783c25fcc1a34af303551ebaa05dfe14c4fef2b5c325278c7e33f66d675e716
snapshot hash: b783c25fcc1a34af303551ebaa05dfe14c4fef2b5c325278c7e33f66d675e716
EQUAL (snapshot faithfully represents what was evaluated)? True

Cache invalidation:

cache identical to main? True  added: []  removed: []
gepa NC identical? True  [] []
skillopt added: ['FOCUS.md','REFLECTION.md','REJECTED.md']
snapshot added: ['FOCUS.md','MEMORY.md','REFLECTION.md','REJECTED.md','STATE.md']  removed: []
candidate hash in existing cache? True

Import-graph / placement claim — verified, and no import-time set mutation. rundir.py's full import list is stdlib + .splits, and splits.py imports only random/dataclasses/typing. SCRATCH_NAMES is a module-level tuple literal, and all four consumers fold it with a plain constant expression ({...} | set(SCRATCH_NAMES)), so #199's review flag about cache.py's import-time |= mutation is not reintroduced — this PR is actually the pattern that review asked for.

$ python -c "import ast; ..."   # rundir.py imports
contextlib json os shutil time | dataclasses pathlib | .splits (level=1)

Live writers for the names added to the destructive list (basis for finding #1):

$ grep -rn 'MEMORY.md"|STATE.md"|REJECTED.md"' core/ skills/ templates/   # excluding the ignore-lists
core/tests/test_w1_engine.py:458:    (d / "MEMORY.md").write_text("notes")
core/tests/test_w1_engine.py:459:    (d / "STATE.md").write_text("plan")

…hot + cache hash

Review fix for #211. The four-way unification made harness._SNAPSHOT_IGNORE — the
one DESTRUCTIVE consumer — take the full union. Three of the added names
(MEMORY.md, STATE.md, REJECTED.md) have no live writer in core/, so their only
real-world referent is a capability file that shares the name, and snapshot()
silently deleted it from the candidate, from every descendant iteration, and
WITHOUT busting the eval-cache key (cache ignored the same name) — a stale hit on
a mutilated candidate. Regression vs main.

- rundir: SCRATCH_NAMES (live writers) vs LEGACY_SCRATCH_NAMES (no writer,
  filter-only) + NON_CAPABILITY_NAMES union. Destructive consumer takes the
  subset; every read-side filter takes the union.
- rundir.snapshot: root-anchored ignore callable. shutil.ignore_patterns matches
  by basename at EVERY depth; every entry is a root-level framework injection, so
  a nested src/prompts/STATE.md can no longer be caught.
- cache.hash_candidate_dir: root-anchored too, so deleting a nested colliding
  capability file DOES change the key (closes the stale-hit hazard at the root).
- dashboard._DIFF_SKIP + harness._CAP_DIFF_SKIP now derive from
  NON_CAPABILITY_NAMES — they are read-side filters asking the same question, and
  were the last hardcoded copies (review finding #2).
- test_gepa: pin the live/legacy split and that all five read-side filters equal
  the union; new test_snapshot_ignore_excludes_legacy_names_and_is_root_anchored;
  drop the IndexError-prone parts[] check subsumed by the exact-set assert.
Copilot AI review requested due to automatic review settings July 30, 2026 00:06

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 pushed a commit that referenced this pull request Jul 30, 2026
…mory->prompt framing

Review fixes for #212:

1. cache.py — restore main's intro paragraph verbatim (the sentence #211
   rewrites) and confine the correction to a separate "Scope: GEPA only"
   paragraph, so the #211 conflict is a single textual hunk whose wrong
   resolution can no longer restore the false maybe_cached_score line — that
   line's removal now auto-merges outside the conflict region.
2. test_w1_engine.py — new guard pinning that no doc under core/ or skills/
   cites maybe_cached_score, so a revert of the docstring fix fails a test.
3. skillopt SKILL.md / concepts.md / skillopt.py — the rejected/history jsonl
   are dashboard audit records, write-only, never prompt input.
4. MemoryPanel.tsx — drop the "do-not-re-propose" framing this PR disproved.
A filter may legitimately add its own read-context names (post-#199 the cache and
component lists fold in optimizer_context.INJECTED_NAMES); the invariant that
matters is that none of them DROPS a shared name.
Copilot AI review requested due to automatic review settings July 30, 2026 00:12

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 5 findings addressed. The blocking one was real and I missed it: I checked that the predicate was one concept and never checked that the operation was. Fixed at the root, plus the deeper root-anchoring you suggested. aad0f1b + 7d6ed65.

Merge order restated: #199#197#211 (this fix) → #210. #210 merges clean and its hardlinks go to run_dir.rollouts/train, never the workdir, so none can land in a snapshot.


1. BLOCKING — destructive snapshot exclusion deleting a capability file — FIXED

Confirmed exactly as described. SCRATCH_NAMES is now split into two tiers, and I also root-anchored the destructive site and the cache hash, because the split alone leaves the footgun armed for every future name.

The split (rundir.py):

SCRATCH_NAMES = (                      # live writers → safe for the DESTRUCTIVE consumer
    "LEDGER.md", "JOURNAL.md", "RUNMAP.md",
    "FOCUS.md", "REFLECTION.md",
)
LEGACY_SCRATCH_NAMES = ("REJECTED.md", "MEMORY.md", "STATE.md")   # no live writer; filter-only
NON_CAPABILITY_NAMES = frozenset(
    {"INSTRUCTIONS.md", "PROCESS.md"} | set(SCRATCH_NAMES) | set(LEGACY_SCRATCH_NAMES))
  • harness._SNAPSHOT_IGNORE (the one destructive consumer) → SCRATCH_NAMES only.
  • every read-side filter → NON_CAPABILITY_NAMES (the union), so caches and run dirs written before those names were retired keep behaving.

Root-anchoring: yes, done here, not flagged. You framed it as "even better and equally cheap" and I agree — but I'd go further: the split alone only fixes the three names we know about today. Basename-matching-at-any-depth stays a live footgun for every name added later, and the failure mode is silent data loss. The lazy fix is the root-cause fix: one guard in the shared function is smaller than remembering this rule forever at every call site. rundir.snapshot now filters only at src_dir itself:

names = set(ignore or ())
ig = (lambda d, cs: names if Path(d) == src_dir else set()) if names else None
shutil.copytree(src_dir, dst, ignore=ig)

I also root-anchored cache.hash_candidate_dir, which the split does not fix. This is the part worth flagging back: your prescribed fix closes the stale hit for MEMORY/STATE/REJECTED, but not for FOCUS.md/REFLECTION.md/LEDGER.md/JOURNAL.md/RUNMAP.md — those stay in both lists, so src/prompts/LEDGER.md was still hash-invisible. Same class of bug, one name-set away. So:

rel = p.relative_to(cdir)
if len(rel.parts) == 1 and p.name in _IGNORE_NAMES:
    continue

Every ignored name is a root-level framework injection, so anchoring is strictly more correct — and now snapshot and cache agree on where a name counts, which is what made the stale hit possible.

The two remaining basename-at-any-depth filters (gepa._NON_COMPONENT, skillopt._SCAFFOLDING) are left alone with a ponytail: note at each site — a false positive there costs one editable component or one uncounted edit, nothing is lost, and anchoring _NON_COMPONENT would change which files GEPA may edit. Out of scope for #110.

Data-loss probe — before / after

Your probe, reproduced: seed capability with nested src/prompts/{MEMORY,STATE,REJECTED}.md, real zero-API gepa run on examples/toy_calc + the mock optimizer.

=== SEED capability (what the user gave us) ===
  ./prompt.txt
  ./src/prompts/MEMORY.md
  ./src/prompts/REJECTED.md
  ./src/prompts/STATE.md
  ./src/prompts/keep.txt

BEFORE (659cb1a, this PR as reviewed) — reproduces your finding verbatim:

=== candidate SNAPSHOT gepa_0001 ===
  ./INSTRUCTIONS.md
  ./PROCESS.md
  ./prompt.txt
  ./src/prompts/keep.txt          <-- all three capability files GONE

--- collision files survived into EVERY descendant candidate? ---
  gepa_0001: []  -> NO

--- cache key busts when a colliding capability file is removed? ---
Traceback (most recent call last):
  File "/tmp/probe211_before.py", line 59, in <module>
    (tmp2 / "src" / "prompts" / "STATE.md").unlink()
FileNotFoundError: ... '/tmp/probe-before/mutated/src/prompts/STATE.md'

(The probe can't even reach the cache check on the old code — the file it wanted to delete was already deleted for it.)

AFTER (7d6ed65):

=== candidate SNAPSHOT gepa_0001 ===
  ./INSTRUCTIONS.md
  ./PROCESS.md
  ./prompt.txt
  ./src/prompts/MEMORY.md         <-- survives
  ./src/prompts/REJECTED.md       <-- survives
  ./src/prompts/STATE.md          <-- survives
  ./src/prompts/keep.txt

--- collision files survived into EVERY descendant candidate? ---
  gepa_0001: ['MEMORY.md', 'STATE.md', 'REJECTED.md']  -> YES

--- root-level scratch still excluded (the #110 fix)? ---
  snapshot names: ['INSTRUCTIONS.md', 'PROCESS.md', 'prompt.txt', 'src']

Cache-key evidence — your b783c25f… stale hit is no longer possible

Isolating the exact case (one nested colliding capability file, hash before/after its removal):

BEFORE (unfixed):  hash a: ba44ee29f771dec5  hash b: ba44ee29f771dec5  SAME? True
AFTER  (fixed):    hash a: b8155b9018242ff4  hash b: ba44ee29f771dec5  SAME? False

The pre-fix hash ba44ee29… is literally the hash of the mutilated candidate — i.e. before the fix, a candidate WITH the file and a candidate WITHOUT it were indistinguishable to the cache. After the fix they differ, and the with-file hash is new. On the full gepa candidate:

has src/prompts/STATE.md: True False
hash a: 741156df2c980e20
hash b: 0a650b8032e8d454
SAME HASH despite a real capability file being deleted? False

Constants vs main

main   snap : ['.agents','.bob','.claude','.gemini','.opencode','AGENTS.md','CLAUDE.md','GEMINI.md',
               'JOURNAL.md','LEDGER.md','RUNMAP.md','guidance','prior_iterations','trajectories']
BEFORE snap : + FOCUS.md MEMORY.md REFLECTION.md REJECTED.md STATE.md        (5 added — the bug)
AFTER  snap : + FOCUS.md REFLECTION.md                                       (2 added — the #110 fix only)

MEMORY/STATE/REJECTED are back to main's behaviour; FOCUS/REFLECTION — the actual #110 fix — still excluded.

Old caches still work. cache._IGNORE_NAMES is set-equal to main, _IGNORE_DIRS unchanged:

main   cache: ['FOCUS.md','INSTRUCTIONS.md','JOURNAL.md','LEDGER.md','MEMORY.md','PROCESS.md',
               'REFLECTION.md','REJECTED.md','RUNMAP.md','STATE.md']
AFTER  cache: ['FOCUS.md','INSTRUCTIONS.md','JOURNAL.md','LEDGER.md','MEMORY.md','PROCESS.md',
               'REFLECTION.md','REJECTED.md','RUNMAP.md','STATE.md']       # identical
main   cachedirs: ['.git','__pycache__','prior_iterations']   AFTER: identical

The root-anchoring is the one behavioural change to keying, and only for a nested file sharing an ignored basename — which previously produced a wrong hash. Any pre-existing cache entry for a candidate without such a collision resolves unchanged.


2. Non-blocking — dashboard._DIFF_SKIP left behind as the last hardcoded copy — FIXED by unifying

You were right, and right about why my reasoning was inconsistent. I applied "don't collapse coincidental overlap" to the one pair where it doesn't hold and withheld it from the one where it does. The distinguishing question is the operation, not the overlap — so both diff filters are now derived:

_DIFF_SKIP = set(NON_CAPABILITY_NAMES)        # dashboard.py
_CAP_DIFF_SKIP = set(NON_CAPABILITY_NAMES)    # harness.py

They gain FOCUS.md/REFLECTION.md/REJECTED.md, which closes the future-noise hole: if REFLECTION.md is ever re-admitted to the snapshot, the diff stays quiet without anyone remembering to update a second list.

Comment rationale at both sites (you asked for one even if I declined; deriving them makes the opposite warning the load-bearing one — the next person's temptation is now to "finish the job" by feeding this list to the snapshot):

Derived from rundir.NON_CAPABILITY_NAMES — a read-side FILTER like the cache and component lists, so it takes the whole union (live + legacy scratch + the two snapshotted explainability files). It must NOT be shared with harness._SNAPSHOT_IGNORE, which is DESTRUCTIVE and takes SCRATCH_NAMES only: feeding this list to the snapshot would DELETE PROCESS.md, the explainability record we deliberately keep. Same predicate, different operation.

And the tier note in rundir.py states the rule once, in the one place both tiers are defined.

3. Non-blocking — test_gepa.py:326 p.parts[len(d.parts)] IndexError-prone — FIXED

Deleted. You're right that the exact-set assert subsumes it (it catches over- and under-exclusion, which is the property that matters), so the whole dirty dict went rather than being rewritten:

# The exact-set assertion below subsumes any "is it dirty?" check — it catches
# over- AND under-exclusion, which is the property that matters.
for d in cands:
    got = sorted(str(p.relative_to(d)) for p in d.rglob("*") if p.is_file())
    assert got == ["INSTRUCTIONS.md", "PROCESS.md", "prompt.txt"], f"{d.name}: {got}"

4. Nit — comment says "four consumers", names _changed_files — FIXED

The whole note was rewritten around the two-tier split, so both errors are gone: the function is named skillopt._changed_components, the consumer count is no longer asserted (there are now six, and the note groups them by operation rather than counting them), and dashboard._DIFF_SKIP/harness._CAP_DIFF_SKIP are now genuinely derived, so citing them no longer overstates the invariant.

5. Nit — "the legacy MEMORY/STATE pair it replaced" is the tell — FIXED

Promoted from a throwaway aside to the documented reason the second tier exists:

LEGACY_SCRATCH_NAMES — retired names with NO live writer anywhere in core/ … They must NEVER reach the snapshot filter: with no live writer, the only thing such a name can refer to on disk is a CAPABILITY file that happens to share it, and deleting that is silent data loss the cache key cannot even see (the same name is ignored when hashing, so the mutilated candidate keeps its parent's key → stale hit).


⚠️ Your JOURNAL.md observation — agreed, filing separately

Confirmed: _reconcile_journal is only called from harness.py's hill-climb run_step; GEPA never calls it, so a GEPA run's run-level JOURNAL.md is never written. Excluding the per-candidate copy is still correct (it was a stale prefix), but on GEPA the journal now has no durable home at all. Predates this PR, orthogonal to #110 — filing as its own issue rather than widening this one.


Verification

8 → 3 trim and hill-climb parity still hold after narrowing the set (real zero-API runs, examples/toy_calc + mock):

########## gepa ##########            (this branch)
--- gepa_0001 (3 files, 2902 bytes) --- INSTRUCTIONS.md  PROCESS.md  prompt.txt
--- seed (1 files, 62 bytes) --- prompt.txt
########## hill-climb ##########
--- cand_0001 (3 files, 22448 bytes) --- INSTRUCTIONS.md  PROCESS.md  prompt.txt
--- cand_0002 (3 files, 21315 bytes) --- INSTRUCTIONS.md  PROCESS.md  prompt.txt
--- cand_0003 (3 files, 21315 bytes) --- INSTRUCTIONS.md  PROCESS.md  prompt.txt
--- seed (1 files, 62 bytes) --- prompt.txt

@@@@@@@@@@ BEFORE (main) @@@@@@@@@@
########## gepa ##########
--- gepa_0001 (8 files, 7900 bytes) --- FOCUS.md  INSTRUCTIONS.md  JOURNAL.md  LEDGER.md
                                        PROCESS.md  REFLECTION.md  RUNMAP.md  prompt.txt
########## hill-climb ##########
--- cand_0001 (3 files, 22448 bytes) --- INSTRUCTIONS.md  PROCESS.md  prompt.txt

8 → 3 confirmed; gepa and hill-climb list byte-identical contents; hill-climb unchanged from main (parity is a real guard, not decoration).

Full suite + compileall:

$ python3 -m compileall -q core skills && echo COMPILEALL_OK
COMPILEALL_OK

$ PYTHONPATH=core pytest core/tests -q
........................................................................ [ 39%]
........................................................................ [ 78%]
.......................................                                  [100%]
183 passed in 61.90s (0:01:01)

183 passed, 0 failed (182 + 1 new regression test).

Both fail-before states still reproduce (new tests cherry-picked onto each base; the new test spells the legacy names literally rather than importing them, so it fails behaviourally on any base rev rather than on ImportError):

### FAIL-BEFORE on main
FAILED core/tests/test_gepa.py::test_scratch_ignores_are_one_shared_definition
FAILED core/tests/test_gepa.py::test_snapshot_ignore_excludes_legacy_names_and_is_root_anchored
FAILED core/tests/test_gepa.py::test_candidate_snapshots_are_clean_for_every_algorithm[gepa]
3 failed, 1 passed, 6 deselected in 6.09s
  E  AssertionError: gepa_0001: ['FOCUS.md','INSTRUCTIONS.md','JOURNAL.md','LEDGER.md',
                                 'PROCESS.md','REFLECTION.md','RUNMAP.md','prompt.txt']

### FAIL-BEFORE on 199
FAILED core/tests/test_gepa.py::test_scratch_ignores_are_one_shared_definition
FAILED core/tests/test_gepa.py::test_snapshot_ignore_excludes_legacy_names_and_is_root_anchored
FAILED core/tests/test_gepa.py::test_candidate_snapshots_are_clean_for_every_algorithm[gepa]
3 failed, 1 passed, 6 deselected in 4.59s
  E  AssertionError: gepa_0001: ['FOCUS.md','INSTRUCTIONS.md','PROCESS.md','REFLECTION.md','prompt.txt']

[hill-climb] is the 1 passed in both. The 8→5→3 scoping claim is unchanged.

4-way merged tree — #211 (fixed) + #199 + #197 + #210:

$ git merge origin/fix/issue-109-optimizer-context     # 5 conflicts, all import/constant unions
$ git merge origin/feat/issue-142-protected-paths      # 3 conflicts, all import adjacency
$ git merge origin/fix/issue-111-gepa-cache-traces
Merge made by the 'ort' strategy.                      # clean, as you found

$ python3 -m compileall -q core skills && echo COMPILEALL_OK
COMPILEALL_OK
$ PYTHONPATH=core pytest core/tests -q
231 passed in 79.65s (0:01:19)

231 passed, 0 failed — your 230 plus the one new regression test. (My earlier 225 was the 3-way without #210, as you noted.) One extra conflict vs your 5-file count, in dashboard.py, from finding #2's new rundir import — trivially unioned with #199's.

Composed constants on the merged tree are correct — the legacy trio is absent from the destructive list and present in the read-side filter:

snap : ['.agents','.bob','.claude','.cursor','.gemini','.opencode','AGENTS.md','CLAUDE.md',
        'FOCUS.md','GEMINI.md','JOURNAL.md','LEDGER.md','REFLECTION.md','RUNMAP.md',
        'guidance','prior_iterations','trajectories']              # no MEMORY/STATE/REJECTED
cache: ['AGENTS.md','CLAUDE.md','FOCUS.md','GEMINI.md','INSTRUCTIONS.md','JOURNAL.md',
        'LEDGER.md','MEMORY.md','PROCESS.md','REFLECTION.md','REJECTED.md','RUNMAP.md','STATE.md']

And the data-loss probe passes on the merged tree too:

--- collision files survived into EVERY descendant candidate? ---
  gepa_0001: ['MEMORY.md', 'STATE.md', 'REJECTED.md']  -> YES
--- cache key busts when a colliding capability file is removed? ---
  hash a: 741156df2c980e20   hash b: 0a650b8032e8d454
  SAME HASH despite a real capability file being deleted? False

One test relaxation worth calling out: the new pin originally asserted every read-side filter is equal to the union, which fails on the merged tree because post-#199 the cache and component lists legitimately fold in optimizer_context.INJECTED_NAMES. Changed to superset (7d6ed65) — the invariant that matters is that none of them drops a shared name.

Files touched: core/cap_evolve/{rundir,cache,gepa,skillopt,harness,dashboard}.py, core/tests/test_gepa.py (165 insertions, 48 deletions).

OsherElhadad pushed a commit that referenced this pull request Jul 30, 2026
OsherElhadad pushed a commit that referenced this pull request Jul 30, 2026
… edit (#129 review)

Review of #222 found the constraint block was actively misdirecting on every REAL agent
optimizer: it told the optimizer "you already tried this, re-proposed 2x" about approaches
it had never proposed. Four blocking fixes, all reproduced fail-before/pass-after.

B1 — the signature was dominated by framework-injected read-context. A capability diff
compares a SNAPSHOT parent (INJECTED_* already stripped by _SNAPSHOT_IGNORE) against the
LIVE workdir (not stripped), so every injected CLAUDE.md / .claude/skills/<x>/SKILL.md read
as a capability ADDITION, sorted to the front, and truncated the real edit away entirely.
_CAP_DIFF_SKIP omitted INJECTED_NAMES and _capability_files filtered a hardcoded 3-of-9
subset of INJECTED_DIRS. Both sets are now derived, never enumerated — in harness,
dashboard and skillopt alike. Only `mock` (no registry skills_dir) hid this, which is why
13/13 tests passed over a broken path; the new test runs run_step with
optimizer_name="claude-code".

B2 — head-first truncation made the signature not a function of the edit: two different
edits sharing a long prefix (any realistic SKILL.md or system prompt) collapsed to one
signature. Overflow now closes with a sha256 digest of the whole normalized body, so it
stays stable under cosmetic variation AND distinct whenever the bytes differ.

B3 — eviction was by FIRST appearance, so the single most predictive row (a dead end the
optimizer JUST re-proposed) was dropped while newer one-offs were kept, contradicting the
block's own "12 most recent" wording. A repeat now requeues its row.

B4 — skillopt._changed_components was a fifth, pre-drift copy of the scratch list. It now
derives from the one shared definition like the other four.

SCRATCH_NAMES end state (rebased onto #211): rundir.SCRATCH_NAMES /
LEGACY_SCRATCH_NAMES / NON_CAPABILITY_NAMES is the sole definition, split by OPERATION so
the one destructive consumer never takes a retired name; optimizer_context.SCRATCH_NAMES is
deleted. The five read-side filters compose NON_CAPABILITY_NAMES with INJECTED_NAMES/DIRS.

Also: control + bidi-override chars stripped from the signature once (N3); the row shows
"(latest <cid>)" so a repeat count cannot be misread as the first candidate's (N2); a
frontend test for the new `approaches` field (N4); N5 noted with its upgrade path; the
function-body cap_instructions import, the <8 KB bound comment and the 70/30 tail-slice
docstring corrected (nits).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

algorithm Optimization algorithms: GEPA / SkillOpt / hill-climb bug Something isn't working priority-p1 High impact tech-debt Dead code, duplication, refactors

Projects

None yet

Development

Successfully merging this pull request may close these issues.

GEPA candidate snapshots are dirty: pass ignore=_SNAPSHOT_IGNORE

3 participants