Skip to content

refactor(core): split the 1,928-line harness.py god module into nine focused modules - #235

Open
OsherElhadad wants to merge 32 commits into
mainfrom
refactor/issue-115-split-harness
Open

refactor(core): split the 1,928-line harness.py god module into nine focused modules#235
OsherElhadad wants to merge 32 commits into
mainfrom
refactor/issue-115-split-harness

Conversation

@OsherElhadad

@OsherElhadad OsherElhadad commented Jul 30, 2026

Copy link
Copy Markdown
Collaborator

Closes #115

core/cap_evolve/harness.py was a god module mixing eight unrelated concerns behind one
import path. It is the module every algorithm change has to touch, which is exactly how
the under-wiring bugs (#109, #110, #114) hid in it: the shared machinery was never cleanly
separated from the hill-climb-specific loop.

This is a pure, behaviour-preserving refactor. No signature changed, no side effect
reordered, no "while I'm here" fix.

Module map — what moved where, and why that seam

Module Lines Responsibility Why this is a real seam
optimizer_proc.py 110 Build an OptimizerFn from a shell command; parse the CLI's self-reported cost; render a non-zero exit into an actionable message The ONE seam between the framework and an external agent CLI. Knows nothing about splits, candidates or gates — nothing else in the engine should have to care how an agent CLI is invoked.
evaluate.py 323 The per-task rollout loop, multi-trial aggregation, rollout persistence, protected-path verify on both sides, split_result_from_rollouts, _paired_deltas The ONE path by which a candidate becomes a number. Honesty-critical; now readable without prompt/handover noise in view.
capdiff.py 136 Capability snapshot reads, diffs, per-task impact (broke/fixed) Exactly three consumers need this and nothing else (LEDGER/RUNMAP, #129's approach signature, the dashboard diff). Grouping them is what stops the skip-list drifting into a fourth copy — the bug rundir.NON_CAPABILITY_NAMES exists to prevent.
insights.py 224 INSIGHTS.md, the durable synthesized priors (#128) Single caller (handover._augment_instructions), and its bounds (MAX_INSIGHT_CHARS, the +N more markers) are now auditable in one place.
handover.py 520 LEDGER / JOURNAL / PROCESS / RUNMAP + prior_iterations/, marker-guarded journal reconciliation, #129 dead-end constraints, and _augment_instructions The single largest concern tangled into the old file (~480 lines) and the one the issue called out by name. _augment_instructions is the ONE function whose output reaches the optimizer prompt, and all three algorithms route through it.
context_inject.py 316 The FILE side of the optimizer-context seam: per-tag trajectory copies, guidance + sources, bench repo, NATIVE per-agent skill dirs (.claude/skills/, CLAUDE.md, …) Pairs with optimizer_context.render_instructions (the PROMPT side). Separating the two sides makes #109's "same context for every algorithm" invariant checkable rather than aspirational.
instructions.py 458 Prompt templating: failure index, passing-set block, capability edit-space brief, algorithm brief, parallel-subagents note, empty-seed note, and the failure classification they are built from Pure text assembly — writes no file, runs no eval, touches no gate. Its bounds can be audited without the loop in view.
step.py 254 run_step — ONE propose→gate step The boundary the issue was filed about. run_step is SHARED: hill-climb, GEPA and SkillOpt all drive it, so a change here changes all three. _SNAPSHOT_IGNORE lives here because run_step is its only caller and it is the engine's single DESTRUCTIVE name filter.
harness.py 482 Run LIFECYCLE — the three points where a run touches its own boundaries (ensure_splits, baseline/reuse_baseline, finalize) + hill_climb_loop + the re-export facade

optimizer_context.py (#109) and plateau.py (#221) already existed; this extends that
direction rather than inventing a new architecture.

Why hill_climb_loop stayed in harness.py

It is hill-climb-ONLY, so step.py (shared) is the wrong home — but it also could not move
to a new hillclimb.py: core/tests/test_plateau.py does
monkeypatch.setattr(harness, "run_step", fake_run_step) and then calls
harness.hill_climb_loop(...). That patch only reaches the loop if the loop resolves
run_step from harness's own namespace. Keeping the loop in harness.py (next to the
other lifecycle entry points) preserves that exactly; a re-export would have silently made
those two tests exercise the real run_step. Same reason import subprocess is retained in
harness.py: test_budget_cost.py patches harness.subprocess.run, and it must stay the
same module object optimizer_proc uses.

Before / after wc -l

BEFORE (composed base)          AFTER
2526  harness.py         ->      520  handover.py
                                 482  harness.py
                                 458  instructions.py
                                 323  evaluate.py
                                 316  context_inject.py
                                 254  step.py
                                 224  insights.py
                                 136  capdiff.py
                                 110  optimizer_proc.py
                                ----
2526                            2823 total (9 files; +297 = new module docstrings
                                 + the re-export facade)

No file is left absurdly large; the largest (handover.py, 520) is under the issue's
<600-line soft guideline, and harness.py went 2,526 → 482.

Exact composed base + resolutions used

Per epic #127 this lands LAST in the harness.py cluster, so it is based on the composed
tree, not main. Base: origin/main @ e47a8e15, then merged in the agreed order:

# Branch Merge commit
#199 fix/issue-109-optimizer-context @ 729a79ad 49c539a3
#197 feat/issue-142-protected-paths @ 887349ca 3943a9e3
#195 fix/issue-113-small-samples @ 53f1a1f2 8d26978c
#212 refactor/issue-114-drop-write-only-memory @ 5e31cf40 9f3dd09d
#211 fix/issue-110-gepa-snapshot-ignore @ 7d6ed65c 32bb9980
#222 feat/issue-129-failure-memory @ 6d6cc52d 9fddd881
#219 feat/issue-128-persist-insight @ a814e6bb a5af3f84
#221 feat/issue-130-plateau-detection @ 5d530019 fad048d8

Composed baseline = 69483ee3 (tagged locally compose-base-115). The split is the single
commit d67639b9 on top of it.

Conflict resolutions:

  1. fix(algorithm): give GEPA & SkillOpt the same optimizer context as hill-climb, un-gate the CLI flags #199Protected-paths tamper guard: verify the optimizer never edited scoring/eval/task files #197 adjacent imports (__init__.py, gepa.py, harness.py) — kept both
    sides, as the reviews advised.
  2. The fix(algorithm): give GEPA & SkillOpt the same optimizer context as hill-climb, un-gate the CLI flags #199 prompt-assembly trap in gepa.py — the obvious "keep both" compiles clean
    with feat(algorithm): plateau/convergence detection with escalation + per-lineage exhaustion #221's plateau feature silently dead. Resolved by keeping
    render_instructions(...) (fix(algorithm): give GEPA & SkillOpt the same optimizer context as hill-climb, un-gate the CLI flags #199) and not reintroducing _instructions(...) (dead
    after refactor(core): drop dead optimizer-memory API + unused params; fix misleading cache docstring #212), and keeping feat(algorithm): plateau/convergence detection with escalation + per-lineage exhaustion #221's extra=plateau.prompt_block(pstate) so the plateau
    block still reaches the prompt. Verified live: test_plateau.py asserts
    "MATERIALLY DIFFERENT" reaches the assembled instructions and both tests pass.
  3. refactor(core): drop dead optimizer-memory API + unused params; fix misleading cache docstring #212 narrowed signatures_augment_instructions / _build_ledger lost their
    rejected, history params; Durable synthesized priors (INSIGHTS.md) fed to every proposal, all three algorithms #219 and feat(algorithm): re-inject rejected approaches as optimizer constraints (#129) #222 both use the narrowed form (they read the run
    dir instead). feat(algorithm): plateau/convergence detection with escalation + per-lineage exhaustion #221's extra was appended to the narrowed signature and kept
    positional ((instructions, workdir, run_dir, extra="")) — making it keyword-only
    broke test_insights.py's signature-agnostic call, which is a test failure git does not
    flag as a conflict.
  4. feat(algorithm): re-inject rejected approaches as optimizer constraints (#129) #222's _CAP_DIFF_SKIP — derived as NON_CAPABILITY_NAMES | INJECTED_NAMES, never
    hardcoding a single name; the same derived form was applied to the sibling filters
    (dashboard._DIFF_SKIP, skillopt._SCAFFOLDING, cache._IGNORE_NAMES,
    gepa._NON_COMPONENT) plus their *_DIRS companions, which is what fix(algorithm): one shared scratch-file list so GEPA snapshots are clean (#110) #211's
    superset assertion requires.
  5. feat(algorithm): plateau/convergence detection with escalation + per-lineage exhaustion #221 vs fix(algorithm): give GEPA & SkillOpt the same optimizer context as hill-climb, un-gate the CLI flags #199 in the three skills/algorithms/*/scripts/run.pyfeat(algorithm): plateau/convergence detection with escalation + per-lineage exhaustion #221 (based on
    main) re-added the raw --capabilities/--instructions-file/... flags that fix(algorithm): give GEPA & SkillOpt the same optimizer context as hill-climb, un-gate the CLI flags #199 had
    replaced with OptimizerContext.add_arguments(p). A naive keep-both registers each flag
    twice and argparse raises at runtime. Kept add_arguments + feat(algorithm): plateau/convergence detection with escalation + per-lineage exhaustion #221's four --plateau-*
    flags only; all three run.py --help verified.
  6. Protected-paths tamper guard: verify the optimizer never edited scoring/eval/task files #197Guard tiny/empty val splits and add a Student-t small-sample correction to the paired gate #195 dashboard banner — both banners kept, as sections 0 (tamper) and 0b
    (honesty), preserving the tamper block's trailing blank line.

Findings (reported, deliberately left)

  • protect.manifest_digest hashes project_dir (core/cap_evolve/protect.py:266), so
    the protected_manifest event digest is not reproducible across two runs of the same
    tree at different paths. Not wrong for its stated purpose (it makes forging
    protected.json require forging the event too) but it does mean the digest is not a pure
    function of the protected content. Out of scope for a pure refactor — left as-is, and
    it is why the first before/after comparison showed a digest diff until the runs were
    re-done at identical paths.
  • test_dashboard_launch.py::test_maybe_launch_spawns_when_available is the known test_maybe_launch_spawns_when_available asserts a hard-coded port and fails whenever 7878 is in use #200
    environmental flake (it fails when any other process holds port 7878). It failed once on
    the composed base while a sibling agent held the port and passed in the same tree once
    the port freed — no relation to this change.

Verification

Identical test count — the bar for a pure refactor

$ # BEFORE (composed base 69483ee3)
$ PYTHONPATH=$PWD/core python -m pytest core/tests -q
357 passed in 97.51s (0:01:37)

$ # AFTER (split, d67639b9)
$ PYTHONPATH=$PWD/core python -m pytest core/tests -q
357 passed in 97.69s (0:01:37)

357 → 357. No test added, removed, skipped or newly xfailed.

Moved bodies are byte-identical

An AST pass over the pre-split file vs. the nine post-split files:

pre-split top-level symbols: 68
  byte-identical after move: 68
  changed:                    0
  missing:                    0

(The raw scan reported one "changed" for OptimizerFn — a false positive: it matched
gepa.py's own pre-existing OptimizerFn alias first. optimizer_proc.OptimizerFn is
byte-identical to the original.)

compileall clean

$ python -m compileall -q core skills
$ echo $?
0

Public API intact

cap_evolve.harness: 97 pre-split names, all importable: True
cap_evolve       : 55 pre-split names, all importable: True
__all__ intact: True
RESULT: PUBLIC API 100% INTACT (0 broken imports)

Every from .harness import / from cap_evolve.harness import / from cap_evolve import harness site across core/, skills/ and dashboard/ was enumerated and re-resolved
(49 sites, including gepa.py's 8-name list and
skills/phases/implement-and-check/scripts/pipeline_selftest.py's _focus_instructions).
Not one caller file changed.

No import cycle

module-level import cycles: NONE
rundir module-level deps: ['splits'] (must be {'splits'} or empty)

rundir.py is untouched and still at the bottom of the graph (stdlib + .splits only).
Each of the 18 modules also imports cleanly as the FIRST import in a cold interpreter.

Layering (module-level . imports only):

optimizer_proc   -> (nothing)
instructions     -> loop
context_inject   -> rundir
evaluate         -> loop protect rundir types
capdiff          -> evaluate optimizer_context rundir
insights         -> capdiff rundir
handover         -> capdiff insights optimizer_context rundir
step             -> evaluate gate handover optimizer_context optimizer_proc protect rundir
harness          -> (all of the above) + plateau splits

Real end-to-end, zero API cost — all three algorithms

examples/toy_calc via cap-evolve run with the mock optimizer (deterministic, no
model calls), before and after the split. Because protected.json records the run's own
project_dir (and the manifest digest hashes it), the comparison was re-done with both
trees writing to identical paths so the only variable is the code:

hill-climb  files=284==284:True  byte-identical=279 time-normalized-equal=3 REAL-DIFF=2
            test_reward 1.0 -> 1.0 | baseline 0.0 -> 0.0 | delta 1.0 -> 1.0 | best cand_0001 -> cand_0001
gepa        files=229==229:True  byte-identical=224 time-normalized-equal=4 REAL-DIFF=1
            test_reward 1.0 -> 1.0 | baseline 0.0 -> 0.0 | delta 1.0 -> 1.0 | best gepa_0001 -> gepa_0001
skillopt    files=194==194:True  byte-identical=189 time-normalized-equal=3 REAL-DIFF=2
            test_reward 1.0 -> 1.0 | baseline 0.0 -> 0.0 | delta 1.0 -> 1.0 | best so_e01s01 -> so_e01s01

splits.json, report.md, INSIGHTS.md, rejected.jsonl and history.jsonl are
byte-identical in all three; final.json / baseline.json differ only in seconds.

Same file set, same headline numbers, same winning candidate id. The residual "REAL-DIFF"
entries were then diffed structurally and are wall-clock only — every single field is a
seconds float, a plateau.t epoch timestamp, or a git short-SHA (timestamp-derived):

### hill-climb   ... 36 field diffs total: 36 are seconds/plateau.t/git-SHA, NON-TIME/NON-SHA diffs = 0
### gepa         ... 19 field diffs total: 19 are seconds/plateau.t/git-SHA, NON-TIME/NON-SHA diffs = 0
### skillopt     ... 25 field diffs total: 25 are seconds/plateau.t/git-SHA, NON-TIME/NON-SHA diffs = 0
   non-island HTML identical: True   (all three)

e.g. hill-climb:
   .graph.nodes[1].optimizer_seconds: 0.16 -> 0.15
   .summary.plateau.t: 1785409382.332066 -> 1785409472.295084
   .summary.git_log[0].hash: '13921c7' -> '2e44cf5'

skillopt's events.jsonl is 44/44 lines with ['optimizer_seconds', 't'] the only
fields that differ anywhere in the file.

All rollouts/ and all candidates/ are byte-identical.

All the feature blocks the cluster added are still produced after the split:

INSIGHTS.md      before=True after=True     (#128)
rejected.jsonl   before=True after=True     (#129)
report.md        before=True after=True
dashboard.html   before=True after=True
plateau events after: 2  (hill-climb) / 1 (gepa) / 1 (skillopt)     (#130)
event kinds (hill-climb): baseline evaluate finalize gate_warning lineage_exhausted
  plateau protected_manifest protected_paths_unmatched split_warning splits step

protected_manifest / protected_paths_unmatched (#142) and lineage_exhausted (#130)
both still fire, and GEPA still emits gepa_local_gate / gepa_select / gepa_val_gate.

dashboard/frontend/dist/ untouched (#188): git status --short -- dashboard/frontend/dist
is empty.

Osher Elhadad added 30 commits July 30, 2026 00:40
Two small-sample hazards made the acceptance gate dishonest on small
benchmarks:

1. `make_splits` silently produced an empty (n=0) or single-task (n=1) val
   split for tiny task lists. The gate then had nothing to decide on, or
   0 degrees of freedom so SE(Δ) collapsed and it degenerated to "any Δ>0
   wins" — unannounced. `check_val_size` now REFUSES both with an
   actionable `TinyValSplitError`, at split-freeze time AND again at
   `baseline` (so a hand-written/resumed splits.json can't sneak past).
   2 <= n < 5 proceeds but logs a `split_warning` and flags each gate
   decision LOW CONFIDENCE. `CAPEVOLVE_ALLOW_TINY_VAL=1` opts out.

2. The paired gate's bar was `k_se * SE`, i.e. a *z* multiplier — valid
   only when SE is known. SE(Δ) is estimated from the same n deltas, so
   the standardized mean difference is t-distributed with df = n-1
   (fatter tails). Using z at small n set the bar too LOW and accepted
   noise. The bar now uses the t multiplier at the same one-sided
   significance level. t >= z always, so this can only make the gate
   STRICTER: 1.32x wider at n=3, 1.14x at n=5, 1.06x at n=10, 1.02x at
   n=30, converging to the old z bar as n grows.

`stats` gains a stdlib-only Student-t CDF (regularized incomplete beta,
A&S 1964 §26.7.1 + Numerical Recipes 2e §6.4 `betacf`) and `t_critical`
by bisection; cross-checked against published A&S Table 26.10 values.
Zero new runtime deps.

test_empty_seed's toy adapter goes 4 -> 8 tasks: its 4-task set produced
exactly the val=1 split the new guard rightly refuses.
…ader

cap-evolve enforces honesty on the evaluation side (seeded splits, sealed test,
val-only significance gate). But when the capability is tool code or a skill
package, the optimizer is a coding agent with write tools and nothing structural
stopped it from "improving" by editing the scorer / eval harness / task data
instead of the target. A candidate that rewrites score() is reward hacking and
every number after it is fiction.

New core/cap_evolve/protect.py records a SHA-256 manifest of the protected paths
at baseline (protected.json) and re-verifies it inside evaluate_candidate — the
chokepoint EVERY evaluation goes through (baseline, each iteration's val gate,
finalize) — plus GEPA's minibatch path, which bypasses it. Any modification,
deletion, or newly-added protected file logs a tamper_detected event and raises
TamperError naming the file, before the candidate can be scored, snapshotted,
become best, or seal the test split.

Content hash, not mtime: os.utime is a one-liner, and a size check misses
same-length edits. hashlib is stdlib, so zero new deps.

Defaults derive from the project layout (adapters/, capevolve.yaml, the spec's
dataset_source / split_ids_file, any *gold* file); protected_paths in
capevolve.yaml overrides them. The capability dir is never protected — it is the
target. __pycache__/*.pyc is excluded because load_adapter execs the adapter
during a normal run, and a guard that blocks normal runs is worse than none.

The existing PreToolUse honesty hook also denies writes to a protected path, so
the model gets actionable feedback instead of burning an iteration; core remains
the enforcement.

Tests: adversarial (an optimizer editing adapters/adapter.py fails the run, the
event is logged, best_id is unchanged, test stays unsealed) and negative (the
real mock optimizer's legitimate capability edit runs clean to a sealed test with
zero tamper events), plus mtime-spoof, pycache, override, add/delete, dashboard,
hook, and no-project no-op cases.
…ll-climb

The two flagship algorithms ran blind. `_inject_optimizer_context` was called only
from `run_step` (the hill-climb path): GEPA bypasses `run_step` entirely, so its
optimizer got no `./trajectories/`, no `./guidance/<cap>/`, no native-skill
injection and a hand-rolled 1.4 KB prompt with no capability brief. `skillopt_loop`
had no capability/optimizer parameters and called `_focus_instructions` bare, so
CAP_BRIEF was empty and the PARALLEL note always claimed a sequential optimizer.
On the CLI side `--capabilities --instructions-file --bench-repo --optimizer-name
--capability-sources --target-model --target-profile-file` were gated behind
`algorithm_name == "hill-climb"` and silently dropped for the other two.

Adds `core/cap_evolve/optimizer_context.py` — the one seam every algorithm routes
through: `OptimizerContext` (the per-run bundle + its argparse flag set), `inject`
(the file side) and `render_instructions` (the prompt side). hill-climb, gepa and
skillopt now all take `ctx=` and produce identical context; the CLI passes the
flags unconditionally to all three (agent-driven evograph/agent-optimize excluded
explicitly, not per-flag).

GEPA's `./trajectories/` is scoped to the parent's minibatch tag, so it holds the
verbatim untruncated rollouts REFLECTION.md only excerpts. `INJECTED_DIRS/NAMES`
gives the snapshot ignore-list, GEPA's component list and the eval-cache hash one
shared definition, so the newly injected read-context cannot become an editable
component or bust the cache.

Closes #109
…joint failure index

Review fixes for PR #199 (issue #109). Three blocking findings.

1. GEPA's cross-iteration history channel was permanently EMPTY.
   _parent_map / _build_ledger / _build_runmap filtered `kind == "step"`, but GEPA
   bypasses run_step and emits `gepa_val_gate` — so its LEDGER.md, RUNMAP.md and
   prior_iterations/ stayed empty while the prompt instructed the optimizer to read
   them, and it was told its best candidate was `seed` even after an accept.
   dashboard.py already special-cased all three kinds, so the correct set was known
   in-repo. Factored into ONE definition, rundir.ITERATION_EVENT_KINDS +
   RunDir.iteration_events(), now read by all four consumers (dashboard, LEDGER,
   RUNMAP, prior_iterations) so a fourth kind cannot desync a fifth consumer.
   gepa_loop also publishes the running best on each accept, not only at loop exit.
   #128 (persist insight), #129 (failure memory) and #130 (plateau detection) all
   read exactly this channel — it works for GEPA now.

2. Wrong trajectories on cache hits (regression introduced by this PR).
   The new `tag=` pin fell through to the best/seed/whole-dir chain when a cached
   minibatch persisted no rollout files, handing the optimizer a previous iteration's
   traces (including mb_c_* child rollouts) while the prompt claimed they were "the
   SAME minibatch VERBATIM". The pin is now honoured or omitted LOUDLY: the stale dir
   is removed, an optimizer_context_warning is logged, and GEPA's prompt block states
   the minibatch came from the cache with no traces rather than claiming a dir that
   holds someone else's data. Adjacent to #111 (GEPA eval cache drops output/trace);
   this fix does not depend on it — #111 makes trajectories available on a cache hit,
   this makes the absence honest either way.

3. Failure index was always "of 0 tasks" for SkillOpt.
   render_instructions received a val SplitResult narrowed by train ids — disjoint by
   construction — so the index was always empty, and this PR newly routed the
   slow/meta update through the same shape. Fixed at the root in
   _focus_instructions: zero overlap between focus_ids and the scored result no longer
   filters, and the prompt says why. The seam parameter is renamed `current` ->
   `scored_result` so the invariant is visible at every call site instead of only in a
   docstring.

Non-blocking: cache.py no longer mutates module-level sets at import time (plain
constant expression, no import-order dependence; same for harness._SNAPSHOT_IGNORE);
a global MAX_INSTRUCTIONS_CHARS cap bounds the assembled prompt (every block was
bounded, the sum was not); ARCHITECTURE.md names the seam and the shared event-kind
set, and all three algorithm SKILL.mds document the context they receive; the
render_instructions docstring records that `extra=` is for algorithm-specific tails
and shared blocks belong in the body (#128/#129 need no signature change).

Tests: +7 (three blocking regressions, the prompt cap, and a real
--target-profile-file fixture exercised end to end on all three algorithms).
199 passed, 0 failed.
Review of #197 demonstrated four working bypasses of the guard end to end. Each
is now closed with a regression test that pins it.

1. Bytecode cache. A PEP 552 UNCHECKED_HASH pyc planted in the adapter's cache
   slot executed a hacked score() while adapter.py's SHA-256 stayed identical to
   the manifest — a sealed test_reward 1.0 against an honest 0.0 with
   tamper_detected: 0. Unchecked-hash pycs skip mtime, size AND hash validation,
   so cache invalidation is not a defence. Fixed by stopping the write rather
   than excluding the file: load_adapter clears the adapter's __pycache__ and
   sets sys.dont_write_bytecode, so a legitimate run writes no bytecode and the
   .pyc/__pycache__ exclusion is deleted entirely. A pyc appearing mid-run now
   reads as an added protected file and aborts the run.

2. Manifest rewrite. protected.json lives in the run dir the optimizer can
   write, and a torn manifest was silently re-recorded from the current
   (tampered) tree. Its digest is now logged with the protected_manifest event;
   a manifest that is missing, unparseable, or no longer matches that digest is
   a hard TamperError. Re-recording is legitimate only at first creation. The
   PreToolUse hook additionally denies writes to protected.json, events.jsonl,
   state.json and best.txt.

3. TOCTOU race. Verification was pre-scoring only, so a detached grandchild of
   the optimizer subprocess could rewrite ground truth during scoring
   undetected. Added the post-scoring re-verify issue #142 step 3 asked for, in
   evaluate_candidate (before aggregate_scores), in GEPA's _eval_minibatch, and
   once more in finalize before commit_test burns the seal. The residual window
   is the gap between the scorer's last read and the post-check hash, stated as
   such in HONEST_EVAL.md.

4. Symlink replacement. A protected path replaced by a symlink de-protected
   itself, because .resolve() followed the link out of the project dir. Relative
   paths are now computed from the un-resolved name and a symlink is hashed as a
   sentinel, so becoming (or ceasing to be) a symlink is a change. Fixed in both
   resolve_protected and is_protected.

Also addressed from the review:
- HONEST_EVAL.md guarantee 5 is "tamper-evident", not "tamper-proof", and now
  states exactly what is guaranteed (detection of byte-level changes to declared
  protected files between baseline and finalize, before the affected score is
  recorded, made best, or sealed) and the residual gaps: the scoring-window race,
  out-of-project ground truth, adapter-less projects, per-run scope, and the
  hook being advisory rather than enforcement.
- A malformed or empty protected_paths is a hard error instead of a silent
  fallback to the defaults, and the fallback YAML parser now understands block
  sequences (the idiomatic form previously parsed as {} for every list key).
- tamper_events are rendered: a red banner at the top of the dashboard, the ANSI
  summary, and report.md.
- *gold* narrowed to data suffixes so docs/golden-rules.md is not swept in.
- A project with no adapters/adapter.py logs protected_manifest_skipped, and a
  declared glob matching nothing inside the project logs
  protected_paths_unmatched, so "no protection" is never indistinguishable from
  "clean".
- reuse_baseline refuses a prior run that logged a tamper and inherits its
  manifest instead of re-recording from the current tree.
- is_protected compares case-folded, so a case-varied spelling of a protected
  path cannot slip past the hook on APFS/NTFS.
- The hook's protected-path check prints to stderr on internal error instead of
  failing open silently.

Refs #142
…orrect-or-loud t

Review of PR #195 (issue #113) found 5 blocking issues. All five are fixed, plus
the 10 non-blocking findings and 4 nits.

BLOCKING 1+2 — three unguarded production paths to a gate decision with n<2.
The guard sat at ensure_splits + baseline only. reuse_baseline copied a prior
splits.json and returned before baseline() (so an escape-hatch run's split became
a reusable seed for later runs that nothing marked dishonest), and the baseline
--resume / --reuse-baseline fast-paths returned before any check. Fixed at the
real chokepoint: gate.decide itself now refuses fewer than 2 matched pairs, which
no caller can route around. The three creation paths get check_val_size too
(defense in depth, and friendly pre-budget failures). This also closes finding 7:
a HEALTHY split whose realized pair count collapsed (candidate errored on most val
tasks; _paired_deltas intersects ids) previously got ACCEPTED via the SE=0 strict
fallback.

BLOCKING 3 — escape-hatch runs were indistinguishable from honest runs. They now
carry a durable tiny_val_bypass marker in state.json; final.json gains
honest_gate: false plus a warnings array; report.md leads with a NOT AN HONEST
GATE banner and retracts its "held-out tasks the optimizer never saw" claim; the
dashboard renders a red banner above every number (dashboard.py had no
split_warning branch at all). The warning text no longer claims a "Student-t
correction (df=0)" was applied at n<2, where none is. Keeping the env var: it is
out-of-band, an optimizer editing capevolve.yaml cannot set it, and CI smoke tests
need it — the objection was the silent output, which is now loud.

BLOCKING 4 — t_critical was SILENTLY wrong for alpha below ~1e-12. It bisected on
1.0 - alpha, exactly 1.0 in float64 below alpha ~1e-16: -5.9% at k_se=8, -29% at
8.3, constant for 10..38, and above 38.5 alpha underflowed so max() silently
reverted to the raw z bar — the original bug. Now bisects on a new
cancellation-free survival function t_sf, verifies the solved value reproduces
alpha before returning, and raises instead of returning sentinels. Verified
against an independent closed form at df=2 to 3.8e-13 across k_se 1..26.5; A&S
Table 26.10 values unchanged. The max(k_se, t) clamp is gone (bound in 0 of ~30k
valid cases; its only live effect was hiding this). betainc now raises on
non-convergence.

BLOCKING 5 — remediation option 2 did not work. `split_val: 0.4` yields
(0.5, 0.4, 0.25), still val=1 at n=3 and n=4. Replaced with the full verified
triple 0.25/0.5/0.25 (correct for every n >= 4), a note that option 3 needs
test >= 1, and the fact that at exactly 3 tasks no ratio can work.

Also: LOW CONFIDENCE now reaches report.md (finding 8); the SE=0 path's exemption
from the correction is documented (6); k_se's reinterpretation as a z quantile is
documented where users set it — capevolve.yaml template and OPTIMIZE_YOUR_OWN.md
(11); CHANGELOG entries for both breaking changes (10); the function-local stats
import moved to module level (13); "1 tasks" grammar (14); skillcheck.temp_run_dir
default 4 -> 8 ids so skill check.py files don't trip the guard.

The committed SkillsBench artifact (n=7, k_se 0.2) re-decides identically — zero
flips — so published results still reproduce.
…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
…ache docstring

The issue's "memory.py is write-only" premise is only PARTLY right. The
render/entries API and the note/impact fields ARE dead, but the jsonl files
themselves have a live reader the issue told us to verify: the dashboard's
GET /api/runs/{id}/memory (dashboard/backend/capevolve_dashboard/memory.py),
which feeds the Memory panel and the Insights "dead ends" grouping. So the
WRITES stay; only the genuinely-unread surface goes.

Removed (zero readers, proven by grep):
  - RejectedMemory.render / .entries, History.render / .entries, _render_impact,
    _store_impact — the prompt-facing API. LEDGER/JOURNAL/RUNMAP replaced it.
  - the `note=` and `impact=` kwargs and the `note`/`broke`/`fixed` record fields.
  - harness._latest_journal_note — its only caller was that dead `note=`.
  - the per-iteration _candidate_task_impact call in run_step, which existed only
    to populate those dead fields (re-read rollouts from disk every iteration).
    The LEDGER and _reconcile_journal paths keep their own computations.
  - the unused `rejected` / `history` params on _augment_instructions and
    _build_ledger.

_init_memory_store is untouched, so PR #204's algorithm-label stamp and the
dashboard badge are unaffected.

cache.py's docstring claimed wiring into evaluate_candidate was "OFF by default
and gated behind a flag (see maybe_cached_score)" — no such wiring, flag, or
function exists. Replaced with what the cache actually does: GEPA-only, consumed
solely by gepa._eval_minibatch; evaluate_candidate always pays full price.

Test change: test_rejected_memory_roundtrip_and_render tested the removed
render(); replaced by test_memory_jsonl_record_shape_matches_dashboard_contract,
which pins the exact keys the dashboard reads — the contract that actually
matters. Net test count unchanged.

Closes #114
…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.
…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.
…y proposal

Closes #128.

A compact, bounded, framework-synthesized "what we've learned so far" block that
survives across iterations independent of the transcript, and reaches the optimizer
prompt for ALL THREE deterministic algorithms.

What the insight is (three sections, all derived from the run's own numbers):
  * What HELPED — gate-ACCEPTED iterations, largest val |Δ| first, with the exact tasks
    each one fixed (and any it broke anyway).
  * What HURT — gate-REJECTED iterations, largest |Δ| first, with the reject reason and
    the exact tasks broken.
  * Still OPEN — the val tasks the current best does NOT pass.

ZERO LLM calls. The synthesis is pure Python over events.jsonl + the persisted rollouts,
like every other auxiliary step in core (#132/#205 established that profile). It reads
RunDir.iteration_events() and _candidate_task_impact() — the same reads LEDGER.md uses,
so the two artifacts can never disagree — and #205's aux_model tier is unused: the signal
is already fully determined by the run's numbers, so a per-iteration model call on every
run would buy prose, not information.

Built on the #199 seam exactly as its reviewer predicted: the whole wiring is two lines
in harness._augment_instructions, the ONE function whose output reaches the prompt and
which all three algorithms route through (the note #212 left in memory.py).

Honesty. Every line is labelled a CANDIDATE PRIOR, a hypothesis worth re-testing;
nothing bypasses the val gate, and the prompt says so. Only val rewards and val task ids
are read — never the sealed test split, never a gold answer.

Growth/eviction. The block is RE-DERIVED every iteration, so it does not grow
monotonically — but its input does. Eviction keeps the 6 largest |Δ| movers per section
(ties → newer iteration) plus 10 open task ids, because a +0.25 accept and a -0.20
regression are the priors worth re-testing while a -0.001 reject carries no signal.
MAX_INSIGHT_CHARS = 4,000 is the backstop. Measured: 1,953 chars after 200 iterations
with long task ids — 3.3% of #199's MAX_INSTRUCTIONS_CHARS.

INSIGHTS.md is framework read-context, not capability, so it is excluded from the
candidate snapshot, GEPA's editable components, the eval-cache content hash (or every
iteration would miss the cache, since the block changes as the run progresses) and
SkillOpt's applied-edit count. Added to optimizer_context.INJECTED_NAMES — the one list
#199 created for exactly this — plus the two diff-skip lists, and SkillOpt's two
copy-pasted scaffold tuples were folded into one _SCAFFOLD frozenset (they are how this
would have silently registered as an applied edit every iteration).

Also fixes a #199 defect this surfaced: SkillOpt logs BOTH "step" (via run_step) and
its own "skillopt_step" for the SAME candidate, so RunDir.iteration_events returned two
rows per SkillOpt iteration — LEDGER.md, RUNMAP.md, the dashboard lineage and the new
priors all double-counted it, the second copy missing parent/parent_val and so showing a
blank Δ. Deduplicated by candidate id at the root (first occurrence wins, later records
merged in for fields it lacks), so all four consumers are fixed at once rather than the
one this PR happens to add.
…lineage exhaustion

Closes #130

The engine stopped on budget (iterations/metric-calls/USD) and on `stall` (N
consecutive non-accepts). Neither answers "is the search still making progress,
or grinding a dead region?" — `stall` counts rejections, and a rejection is not
the same thing as a lack of progress.

New `core/cap_evolve/plateau.py` adds a third, orthogonal stop condition that
escalates warn -> diversify -> stop, plus per-lineage exhaustion.

The criterion counts DEAD iterations, not rejections: an iteration is dead when
it neither raised the global best nor moved the score in the right direction. A
near-miss (rejected but delta>0 — sub-significant, and made strictly more common
by #113's Student-t small-sample correction) RESETS the streak, as does a new
best. A ratchet caps escalation at `diversify` whenever the streak contains any
accept, so a GEPA run still widening its Pareto frontier is nudged, never killed.

Reads the per-iteration history through `RunDir.iteration_events()` (#109/PR199),
never by hand-filtering `kind == "step"` — GEPA emits `gepa_val_gate` and would
otherwise be invisible. Locally-killed GEPA children (`gepa_local_gate`,
passed=false) are counted too: they are spent iterations that never reach the val
gate.

Wired into all three deterministic loops; `plateau` / `lineage_exhausted` events
surface in the dashboard (summary + terminal + SPA) and in `--follow`. Config via
four `capevolve.yaml` keys threaded to every algorithm through one CLI loop.

Zero new runtime deps. 28 new tests in core/tests/test_plateau.py.
…ts (#129)

Rejected candidates were persisted to rejected.jsonl (audit + the dashboard's
"what not to try" panel) but nothing put them in a proposal prompt, so the
optimizer could — and demonstrably did — re-propose an approach the gate had
already killed, burning a full-val eval on a known dead end each time.
RUN.md nonetheless claimed "rejected approaches are remembered and never
re-proposed": false on both halves. This closes the feature gap and the
honesty gap together.

What changed
- harness.approach_signature(parent_dir, cand_dir) — a stable, compact
  signature of WHAT an edit changed, built from the capability diff
  (whitespace-collapsed added/removed lines per touched file). Cosmetic
  variants of the same idea collapse to one signature; a no-op edit yields "".
- harness.dead_end_constraints(run_dir) — the "ALREADY TRIED & REJECTED"
  block: deduped signature + gate reason + a repeat count, with an explicit
  "do not re-propose, and if you revisit one, state in PROCESS.md what is
  materially different" instruction.
- Wired into _augment_instructions, the ONE function whose output reaches the
  optimizer prompt (#114) and which all three algorithms route through — so
  hill-climb, GEPA and SkillOpt get it with no per-algorithm plumbing.
- RejectedMemory.add gains an optional `approach` field; every rejection site
  (run_step, GEPA's local + val gates, GEPA's two merge gates) now records it.

Bounding (zero LLM calls — pure Python, per PR #205)
- <= 12 most-recent DISTINCT approaches, signature <= 300 chars (capped on
  write AND on read), reason <= 200 chars -> block stays ~1 KB and is
  provably < 8 KB even with 50 long rejections.
- optimizer_context.cap_instructions is extracted from render_instructions so
  the FINAL assembled prompt — not just the rendered half — is held under
  MAX_INSTRUCTIONS_CHARS (60k). Previously these cross-iteration blocks were
  appended after the cap had already been applied.

Enforcement is ADVISORY, stated as such
The optimizer is a black-box agent CLI, so cap-evolve cannot forbid it from
re-emitting an edit. What is HARD is the val gate — a re-proposed dead end is
still rejected, and the repeat is counted in the block ("re-proposed 4x").
RUN.md and docs/COMPARISON.md now say exactly this instead of implying a
guarantee.

Drive-by root-cause fix: one scratch-file list, four consumers
Building the signature exposed that cache._IGNORE_NAMES, gepa._NON_COMPONENT,
harness._CAP_DIFF_SKIP and dashboard._DIFF_SKIP each kept their OWN copy of
"framework scratch, not capability", and they had drifted — only GEPA's knew
about FOCUS.md/REFLECTION.md. So a GEPA candidate's "capability diff" (shown
in the dashboard, RUNMAP and prior_iterations) reported its reflective scratch
as a real edit. Unified as optimizer_context.SCRATCH_NAMES.

Dashboard: the Insights dead-ends panel now shows the rejected edit signature
under each reason (same data, one new field).

Closes #129
… no dedup)

SkillOpt routes through harness.run_step, which already logs "step" for the same
candidate carrying parent/Δ/reason; "skillopt_step" only adds epoch/edit_budget/
applied_changes. Counting both double-counted every SkillOpt iteration in
LEDGER.md / RUNMAP.md / INSIGHTS.md, the second copy missing parent/parent_val
(blank Δ) and poisoning _parent_map.

The id-keyed dedup this branch first shipped is REVERTED: SkillOpt mints ids from
epoch/step counters that reset on --resume (the algorithm's --resume restores only
current_val), so a resumed run re-emits so_e01s01 for a different candidate and
first-wins dedup DISCARDED it — regression included. Measured on one live resumed
run: 9 rows on the #199 base (double-counted but present), 4 with the dedup (a
resumed reject silently gone), 5 correct after this change.

iteration_events() is now documented as one row per logged event in log order with
no dedup; consumers must key history by position, not candidate id. The dashboard
folds skillopt_step's epoch onto the lineage node in a separate pass so the audit
metadata is not lost.

Also in the durable priors block (#128):
- the char cap now RESERVES the truncation notice's length before cutting, so the
  output is bounded inclusive of the notice (it previously overshot by 98 chars on
  a long reject reason or long unicode ids), and the pinning test now actually
  crosses the bound;
- broke/fixed task sets render an honest "+N more" count instead of a silent cut
  at 8, so INSIGHTS no longer under-reports a 20-task regression as 8 while
  LEDGER shows 20 (LEDGER's own [:20] cut is marked the same way);
- gate reasons are flattened and markdown-escaped, so a reason cannot forge a
  "## What HELPED" section inside a framework-authored block, and are bounded to
  200 chars so one reason cannot eat the block's budget;
- "Still OPEN" leads with a count (N of M val tasks) and carries an explicit
  anti-overfit instruction: the names are a diagnostic, not a target list, and a
  task-specific special case passes the val gate and fails the sealed test;
- rejects now render what they FIXED as well as what they broke, and the heading
  is "What was REJECTED ... a reject is not necessarily a regression" rather than
  "What HURT", which mislabelled positive-Δ significance rejects;
- accepted rows evict by RECENCY (every row already cleared the gate, so |Δ| just
  froze a top-6 and permanently evicted small-but-real effects); rejected rows
  still evict by |Δ|, which is the damage signal;
- an absent-rollouts "Still OPEN" is reported as UNKNOWN rather than rendering
  identically to a perfect candidate;
- a missing Δ renders "Δ ?" instead of a fake "+0.000";
- the workdir copy is written atomically like the durable one, and the false
  docstring claim that the dashboard reads INSIGHTS.md is removed.
#212 narrows the signature (drops rejected/history). Passing them positionally made
this a THIRD mechanical merge site — and unlike the two in harness.py it surfaced as a
test failure git does not flag as a conflict. Fill trailing params reflectively so the
test passes on both signatures.
Fourth mechanical merge site for #212's narrowed signatures (two in harness.py, two
here) — all in tests, so git flags none of them as conflicts. Also refresh the module
docstring for the renamed sections and the new pinned properties.
… 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).
Review fixes for PR #221. Two of the four blocking findings made the feature kill
or degrade productive runs, which is the failure direction this module exists to
prevent.

1. SkillOpt iterations were counted TWICE. `harness.run_step` logs `step` for a
   candidate and `skillopt.py` then logs `skillopt_step` for the SAME candidate;
   both were in the iteration-kind list, so a window=6 ladder reached `stop` at
   iteration 5 of a productive run, and a genuine near-miss was recorded once
   alive and once dead (the duplicate carries no parent_val). Dropped
   `skillopt_step` from the pre-#199 `_FALLBACK_KINDS` literal. PR #219 owns the
   root fix in `rundir.ITERATION_EVENT_KINDS`; no second dedup layer here, and
   deliberately NOT id-keyed dedup, which drops resumed SkillOpt iterations.

2. `NameError: cannot access local variable 'why'` in `hill_climb_loop` when the
   loop body never runs (max_iterations=0, or a resume with no budget left).
   `why` is now initialised next to `steps`.

3. The plateau block escaped the prompt cap and silently lost data. It now
   travels as `extra=` through `_augment_instructions` / `run_step`, so it is
   inside #222's MAX_INSTRUCTIONS_CHARS (measured 64941 -> 2625) and lands in
   the TAIL that truncation preserves — it is the only behavioural block of the
   three, so it must not be the first casualty. Removed the bare rejected-ids
   line entirely: it called `RejectedMemory.entries()` (removed by #199) behind
   a bare `except` that swallowed the AttributeError, and duplicated #222's
   richer signature channel. `prompt_block` no longer accepts `rejected=`, so a
   caller passing one now fails loudly. Bounded `exhausted_lineages` to 6 ids,
   60 chars each.

4. GEPA was steered away from its BEST lineage. `exhausted_lineages` reused
   `_dead()`, which counts accepted-but-not-global-best as dead, so a lineage
   whose children were ALL accepted was dropped from the Pareto sampling pool —
   with no ratchet on that path and no stop event. Per-lineage exhaustion now
   uses its own narrower `_lineage_dead()`: an accept is never dead ground for a
   lineage, because widening the per-instance frontier is GEPA's mechanism. The
   accepted-but-not-best clause stays at the GLOBAL level, where the ratchet
   makes it safe.

Also, from the non-blocking findings:

- The ladder was unreachable at the shipped defaults: `stall: 2` stopped every
  run at 3 iterations, before `plateau_window: 6`. `stall` now defaults to 0
  (off) with the reasoning in the template, so the shipped product actually runs
  the ladder. All evidence is re-run at the shipped default.
- The ratchet now caps escalation at `warn` rather than `diversify`. A run still
  clearing the honest val gate every iteration gets a warning and no behavioural
  intervention, so the diversify block can never tell an optimizer a lineage
  failed when it was accepting.
- The reason string no longer claims "no near-miss in that streak" when the
  streak is all accepts.
- `series` carries the real signed delta for `gepa_local_gate` rows instead of
  hardcoding 0.0, so a tie is distinguishable from a regression. Deadness is
  unchanged (gepa's pass condition is a strict `>`).
- An explicit `plateau_window: 0` now means off instead of silently reverting
  to 6.
- Plateau state reaches the React dashboard: `plateau_level` on the hub row,
  `plateau` / `exhausted_lineages` typed on the detail summary, and a KPI tile.
  It is a separate field from `status` on purpose — that one is liveness, this
  one is progress.
- Documented the honest cost of the delta<=0 rule (N regressions then a
  breakthrough is stopped at N) and that `--resume` deliberately carries the
  streak.

Tests: 215 core (was 207) + 44 dashboard backend. 8 new regression tests, one
per blocking finding plus resume, the bounded block, and the local-gate delta.
`test_plateau_block_reaches_the_prompt_inside_the_cap` asserts the block reaches
the prompt, so the #199 prompt-assembly merge trap fails loudly instead of
compiling clean with the feature dead.
…into compose-115

# Conflicts:
#	core/cap_evolve/__init__.py
#	core/cap_evolve/gepa.py
#	core/cap_evolve/harness.py
…o compose-115

# Conflicts:
#	core/cap_evolve/dashboard.py
#	core/cap_evolve/harness.py
…ly-memory' into compose-115

# Conflicts:
#	core/cap_evolve/gepa.py
…re' into compose-115

# Conflicts:
#	core/cap_evolve/cache.py
#	core/cap_evolve/dashboard.py
#	core/cap_evolve/gepa.py
#	core/cap_evolve/harness.py
#	core/cap_evolve/skillopt.py
…nto compose-115

# Conflicts:
#	core/cap_evolve/cache.py
#	core/cap_evolve/dashboard.py
#	core/cap_evolve/harness.py
#	core/cap_evolve/skillopt.py
…into compose-115

# Conflicts:
#	core/cap_evolve/dashboard.py
#	core/cap_evolve/harness.py
#	core/cap_evolve/skillopt.py
…' into compose-115

# Conflicts:
#	core/cap_evolve/__init__.py
#	core/cap_evolve/dashboard.py
#	core/cap_evolve/gepa.py
#	core/cap_evolve/harness.py
#	core/cap_evolve/skillopt.py
#	skills/algorithms/gepa/scripts/run.py
#	skills/algorithms/hill-climb/scripts/run.py
#	skills/algorithms/skillopt/scripts/run.py
`harness.py` had accreted eight unrelated responsibilities behind one import
path. On the composed base (six merged-pending PRs) it was 2,526 lines, and it is
the module every algorithm change has to touch — which is exactly how the
under-wiring bugs #109/#110/#114 hid in it.

Split along the seams the module already had, extending the direction #199
(optimizer_context.py) and #221 (plateau.py) started:

  optimizer_proc.py  (110) optimizer subprocess plumbing + cost parsing
  evaluate.py        (323) per-task rollout loop, aggregation, paired deltas
  capdiff.py         (136) capability snapshot reads/diffs + per-task impact
  insights.py        (224) INSIGHTS.md durable synthesized priors (#128)
  handover.py        (520) LEDGER/JOURNAL/PROCESS/RUNMAP + dead-end constraints
  context_inject.py  (316) FILE side of the optimizer-context seam
  instructions.py    (458) prompt templating: failure index, briefs, notes
  step.py            (254) run_step — the SHARED propose->gate step
  harness.py         (482) run lifecycle: splits, baseline, hill-climb loop, finalize

The shared/hill-climb-specific boundary the issue asked for is now explicit:
`run_step` (all three algorithms drive it) lives in `step.py`; `hill_climb_loop`
is hill-climb-only and stays in `harness.py` — deliberately in the same namespace
as the `run_step` it calls, because `test_plateau` monkeypatches
`harness.run_step` and must still reach the loop.

Behaviour-preserving: all 68 pre-split top-level symbols were moved with
byte-identical source. No signature changed. Every one of the 97 names previously
importable from `cap_evolve.harness` and 55 from `cap_evolve` still imports
(re-exported), so no caller in core/, skills/ or dashboard/ changed.

Verified: identical test count (357 before, 357 after), compileall clean, no
module-level import cycles, `rundir.py` still stdlib + `.splits` only, and
toy_calc run end-to-end with the mock optimizer on all three algorithms — every
non-timestamp artifact byte-identical.
Copilot AI review requested due to automatic review settings July 30, 2026 11:15

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.

assert "MATERIALLY DIFFERENT" in blk
assert "rejected candidates" not in blk
with pytest.raises(TypeError):
plateau.prompt_block(st, rejected=object()) # type: ignore[call-arg]
if "optimizer_context_warning" in json_line and "mb_p_0007" in json_line]
assert warn, "unmet trajectory pin failed SILENTLY (no warning event)"
assert "OMITTED" in warn[0]
assert kinds == kinds # no-op; keeps the reader exercised without asserting count
Comment on lines +444 to +449
"**Constraint:** do NOT propose any of the above again, and do not propose a "
"cosmetic variation of one (same text, different wording/placement) — it shares "
"the same hidden assumption and will fail the same way. If you believe a rejected "
"direction is still right, you MUST state in `PROCESS.md` what is materially "
"different this time and which specific lesson above it counters. Otherwise pick "
"a genuinely different hypothesis.",
Comment on lines +163 to +165
"A compact, continually-updated summary of what this run has LEARNED SO FAR, "
"re-derived from the objective record every iteration so it survives even when "
"the transcript does not. Read it BEFORE proposing.",
Comment on lines +167 to +169
"**These are CANDIDATE PRIORS, not truth.** Each one is a hypothesis worth "
"re-testing, and every edit you make is still judged by the val significance "
"gate — a prior can be wrong, and acting on one earns no exemption.",
Comment on lines +183 to +184
lines += ["", "## What was REJECTED by the gate (largest movers first — a reject is "
"not necessarily a regression; read the reason)"]
Comment on lines +110 to +111
"The capability under optimization is composed of these editable artifact(s). "
"Use the FULL edit space below — do not limit yourself to trivial wording tweaks."]
Comment on lines +195 to +199
"**These names are a DIAGNOSTIC of where the capability is weak, not a "
"target list.** Fix the general defect they expose; your edit must generalize "
"beyond them. The gate runs on val, so a task-specific special case for these "
"ids will pass the gate and FAIL the sealed test — that is a val overfit, not "
"progress.", ""]
Comment on lines +228 to +229
"(A task that merely had one errored trial but still mostly PASSES is NOT listed "
"here — it is solid/flaky and must be protected, not ignored.)",
Comment on lines +427 to +428
"# Optimize the capability — analyze this step's trajectories in ./trajectories/, "
"then fix MANY root causes in this ONE candidate and STOP.",
@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 and its full literal output. Run in a worktree at the composed base
(69483ee3) and at the split commit (d67639b9).

1. The composed base

$ git log --oneline --first-parent -10
d67639b9 refactor(core): split the 1,928-line harness.py god module (#115)
69483ee3 compose: positional extra= on _augment_instructions (#221 contract)
fad048d8 Merge remote-tracking branch 'origin/feat/issue-130-plateau-detection' into compose-115
a5af3f84 Merge remote-tracking branch 'origin/feat/issue-128-persist-insight' into compose-115
9fddd881 Merge remote-tracking branch 'origin/feat/issue-129-failure-memory' into compose-115
32bb9980 Merge remote-tracking branch 'origin/fix/issue-110-gepa-snapshot-ignore' into compose-115
9f3dd09d Merge remote-tracking branch 'origin/refactor/issue-114-drop-write-only-memory' into compose-115
8d26978c Merge remote-tracking branch 'origin/fix/issue-113-small-samples' into compose-115
3943a9e3 Merge remote-tracking branch 'origin/feat/issue-142-protected-paths' into compose-115
49c539a3 Merge remote-tracking branch 'origin/fix/issue-109-optimizer-context' into compose-115

$ for each merged branch: tip SHA
fix/issue-109-optimizer-context               729a79ad
feat/issue-142-protected-paths                887349ca
fix/issue-113-small-samples                   53f1a1f2
refactor/issue-114-drop-write-only-memory     5e31cf40
fix/issue-110-gepa-snapshot-ignore            7d6ed65c
feat/issue-129-failure-memory                 6d6cc52d
feat/issue-128-persist-insight                a814e6bb
feat/issue-130-plateau-detection              5d530019

2. Baseline test count BEFORE the split

$ git stash list; git checkout 69483ee3 -- core skills   # composed base
$ PYTHONPATH=$PWD/core python -m pytest core/tests -q
........................................................................ [ 20%]
........................................................................ [ 40%]
........................................................................ [ 60%]
........................................................................ [ 80%]
.....................................................................    [100%]
357 passed in 97.51s (0:01:37)

The one earlier failure on this base was the known #200 flake, confirmed environmental:

$ PYTHONPATH=$PWD/core python -m pytest core/tests -q   # while a sibling agent held :7878
E         + http://127.0.0.1:7879
core/tests/test_dashboard_launch.py:56: AssertionError
FAILED core/tests/test_dashboard_launch.py::test_maybe_launch_spawns_when_available
1 failed, 356 passed in 96.73s (0:01:36)

$ lsof -nP -iTCP:7878 -sTCP:LISTEN
COMMAND  PID         USER   FD   TYPE             DEVICE SIZE/OFF NODE NAME
Python  9094 osherelhadad    6u  IPv4 0x1798102cf906e591      0t0  TCP 127.0.0.1:7878 (LISTEN)

3. Test count AFTER the split — identical

$ PYTHONPATH=$PWD/core python -m pytest core/tests -q
........................................................................ [ 20%]
........................................................................ [ 40%]
........................................................................ [ 60%]
........................................................................ [ 80%]
.....................................................................    [100%]
357 passed in 100.49s (0:01:40)

4. compileall

$ python -m compileall -q core skills; echo "exit=$?"
exit=0

5. wc -l for the new module set

$ wc -l core/cap_evolve/{harness,step,handover,instructions,evaluate,context_inject,insights,capdiff,optimizer_proc}.py | sort -rn
    2823 total
     520 core/cap_evolve/handover.py
     482 core/cap_evolve/harness.py
     458 core/cap_evolve/instructions.py
     323 core/cap_evolve/evaluate.py
     316 core/cap_evolve/context_inject.py
     254 core/cap_evolve/step.py
     224 core/cap_evolve/insights.py
     136 core/cap_evolve/capdiff.py
     110 core/cap_evolve/optimizer_proc.py

$ git show 69483ee3:core/cap_evolve/harness.py | wc -l   # BEFORE
    2526

6. Public API intact

$ PYTHONPATH=$PWD/core python /tmp/api_probe.py
cap_evolve.harness: 97 pre-split names, all importable: True
cap_evolve       : 55 pre-split names, all importable: True
__all__ intact: True
RESULT: PUBLIC API 100% INTACT (0 broken imports)

The probe script:

"""Prove #115 broke no public import: every name importable from cap_evolve.harness
and cap_evolve before the split still imports after it."""
import importlib, json, sys
before_h = set(json.load(open("/tmp/api_harness_before.json")))
before_p = set(json.load(open("/tmp/api_pkg_before.json")))
h = importlib.import_module("cap_evolve.harness")
p = importlib.import_module("cap_evolve")
fail = []
for n in sorted(before_h):
    try: getattr(h, n)
    except AttributeError: fail.append(f"cap_evolve.harness.{n}")
for n in sorted(before_p):
    try: getattr(p, n)
    except AttributeError: fail.append(f"cap_evolve.{n}")
# and prove `from ... import X` really works (not just getattr)
ns = {}
exec("from cap_evolve.harness import " + ", ".join(sorted(before_h)), ns)
exec("from cap_evolve import " + ", ".join(sorted(before_p)), ns)
print(f"cap_evolve.harness: {len(before_h)} pre-split names, all importable: {not fail}")
print(f"cap_evolve       : {len(before_p)} pre-split names, all importable: {not fail}")
print("__all__ intact:", sorted(p.__all__) == sorted(n for n in p.__all__))
if fail: print("BROKEN:", fail); sys.exit(1)
print("RESULT: PUBLIC API 100% INTACT (0 broken imports)")

7. Every harness import site across core/, skills/, dashboard/

$ grep -rn "from \.harness import\|from cap_evolve\.harness import\|from \. import harness\|from cap_evolve import harness" core skills dashboard --include="*.py"
core/cap_evolve/optimizer_context.py:199:    from . import harness
core/cap_evolve/optimizer_context.py:245:    from . import harness
core/cap_evolve/gepa.py:59:from .harness import (
core/cap_evolve/harness.py:140:# them THROUGH harness (verified by grep), but `from cap_evolve.harness import X` used to
core/cap_evolve/dashboard.py:167:        from . import harness
core/cap_evolve/skillopt.py:52:from . import harness
core/tests/test_native_skills.py:90:    from cap_evolve import harness
core/tests/test_gepa.py:337:    from cap_evolve import harness
core/tests/test_optimizer_context_parity.py:114:    from cap_evolve import harness
core/tests/test_optimizer_context_parity.py:189:    from cap_evolve import harness, skillopt
core/tests/test_optimizer_context_parity.py:324:    from cap_evolve import harness
core/tests/test_metrics_results.py:5:from cap_evolve.harness import _aggregate_metrics, split_result_from_rollouts
core/tests/test_target_reader_wiring.py:2:from cap_evolve import harness
core/tests/test_protected_paths.py:68:    from cap_evolve import harness
core/tests/test_protected_paths.py:436:    from cap_evolve.harness import _live
core/tests/test_optimizer_context.py:36:    from cap_evolve import harness
core/tests/test_optimizer_context.py:99:    from cap_evolve import harness
core/tests/test_target_reader_render.py:1:from cap_evolve import harness
core/tests/test_failure_memory.py:64:    from cap_evolve import harness
core/tests/test_failure_memory.py:78:    from cap_evolve.harness import approach_signature
core/tests/test_failure_memory.py:96:    from cap_evolve.harness import approach_signature
core/tests/test_failure_memory.py:112:    from cap_evolve.harness import approach_signature
core/tests/test_failure_memory.py:125:    from cap_evolve.harness import dead_end_constraints
core/tests/test_failure_memory.py:145:    from cap_evolve.harness import dead_end_constraints
core/tests/test_failure_memory.py:153:    from cap_evolve.harness import dead_end_constraints
core/tests/test_failure_memory.py:169:    from cap_evolve.harness import dead_end_constraints
core/tests/test_failure_memory.py:182:    from cap_evolve.harness import _MAX_DEAD_ENDS, dead_end_constraints
core/tests/test_failure_memory.py:209:    from cap_evolve.harness import _MAX_DEAD_ENDS, dead_end_constraints
core/tests/test_failure_memory.py:234:    from cap_evolve.harness import _MAX_APPROACH_CHARS, approach_signature
core/tests/test_failure_memory.py:253:    from cap_evolve.harness import approach_signature
core/tests/test_failure_memory.py:282:    from cap_evolve import harness
core/tests/test_failure_memory.py:324:    from cap_evolve import harness
core/tests/test_failure_memory.py:356:    from cap_evolve import harness
core/tests/test_failure_memory.py:382:    from cap_evolve import harness
core/tests/test_per_task_impact.py:74:    from cap_evolve import harness
core/tests/test_plateau.py:406:    from cap_evolve import harness
core/tests/test_plateau.py:450:    from cap_evolve import harness
core/tests/test_plateau.py:565:    from cap_evolve import harness
core/tests/test_plateau.py:596:    from cap_evolve import harness
core/tests/test_optimizer_error_detail.py:14:from cap_evolve.harness import _optimizer_failure_detail  # noqa: E402
core/tests/test_budget_cost.py:8:from cap_evolve.harness import _parse_optimizer_cost, optimizer_from_command
core/tests/test_insights.py:77:    from cap_evolve import harness
core/tests/test_insights.py:112:    from cap_evolve import harness
core/tests/test_insights.py:296:    from cap_evolve import harness
core/tests/test_insights.py:327:    from cap_evolve import harness, skillopt
core/tests/test_resume.py:53:    from cap_evolve import harness
skills/algorithms/hill-climb/scripts/check.py:19:    from cap_evolve import harness
skills/phases/implement-and-check/scripts/pipeline_selftest.py:40:from cap_evolve.harness import _focus_instructions

And each resolved explicitly:

$ python -c "<import gepa's 8-name list, pipeline_selftest's _focus_instructions, and 17 harness.* attrs>"
all documented harness.* consumer attributes resolve

8. No import cycle + rundir.py still at the bottom

$ python -c "import cap_evolve"
OK

$ grep -nE "^\s*(import|from)\s" core/cap_evolve/rundir.py    # stdlib + .splits ONLY
17:from __future__ import annotations
19:import contextlib
20:import json
21:import os
22:import shutil
23:import time
24:from dataclasses import dataclass, field
25:from pathlib import Path
27:from .splits import Splits

$ <AST cycle detector over module-level relative imports>
module-level import cycles: NONE
rundir module-level deps: ['splits']

layering:
  optimizer_proc   -> (nothing)
  instructions     -> loop
  context_inject   -> rundir
  evaluate         -> loop protect rundir types
  capdiff          -> evaluate optimizer_context rundir
  insights         -> capdiff rundir
  handover         -> capdiff insights optimizer_context rundir
  step             -> evaluate gate handover optimizer_context optimizer_proc protect rundir
  harness          -> capdiff context_inject evaluate gate handover insights instructions loop optimizer_context optimizer_proc plateau protect rundir splits step types

$ import-order probe: each module FIRST in a cold interpreter
  cap_evolve.harness            FIRST-import OK
  cap_evolve.step               FIRST-import OK
  cap_evolve.evaluate           FIRST-import OK
  cap_evolve.capdiff            FIRST-import OK
  cap_evolve.insights           FIRST-import OK
  cap_evolve.handover           FIRST-import OK
  cap_evolve.context_inject     FIRST-import OK
  cap_evolve.instructions       FIRST-import OK
  cap_evolve.optimizer_proc     FIRST-import OK
  cap_evolve.optimizer_context  FIRST-import OK
  cap_evolve.gepa               FIRST-import OK
  cap_evolve.skillopt           FIRST-import OK
  cap_evolve.dashboard          FIRST-import OK
  cap_evolve.plateau            FIRST-import OK
  cap_evolve.rundir             FIRST-import OK
  cap_evolve.cli                FIRST-import OK
  cap_evolve.protect            FIRST-import OK
  cap_evolve.splits             FIRST-import OK

9. All 68 moved symbols are byte-identical

$ <AST diff: pre-split harness.py bodies vs the nine post-split files>
pre-split top-level symbols: 68
  byte-identical after move: 68
  changed:                   0
  missing:                   0

10. The #199 prompt-assembly trap — plateau is NOT silently dead

$ grep -n "render_instructions\|_instructions(\|_augment_instructions\|plateau.prompt_block" core/cap_evolve/gepa.py
61:    _augment_instructions,
72:from .optimizer_context import render_instructions
304:    This is the ``extra`` passed to ``optimizer_context.render_instructions`` — the
651:        instructions = render_instructions(
655:        # The plateau block goes THROUGH _augment_instructions (extra=) so it lands inside
660:        instructions = _augment_instructions(instructions, workdir, run_dir,
661:                                             extra=plateau.prompt_block(pstate))

$ # _instructions() (dead after #212) is NOT reintroduced anywhere:
$ grep -rn 'def _instructions' core/cap_evolve/ || echo 'ABSENT (correct)'
ABSENT (correct)

$ # all three algorithms route the plateau block through extra_instructions:
$ grep -rn "extra_instructions=plateau.prompt_block\|extra=plateau.prompt_block" core/cap_evolve/
core/cap_evolve/harness.py:401:            extra_instructions=plateau.prompt_block(pstate),
core/cap_evolve/gepa.py:661:                                             extra=plateau.prompt_block(pstate))
core/cap_evolve/skillopt.py:331:                extra_instructions=plateau.prompt_block(pstate),

$ PYTHONPATH=$PWD/core python -m pytest core/tests/test_plateau.py -q
....................................                                     [100%]
36 passed in 3.04s

11. #222 resolution: _CAP_DIFF_SKIP is DERIVED, never hardcoded

$ grep -rn "_CAP_DIFF_SKIP =\|_DIFF_SKIP =\|_SCAFFOLDING =\|_IGNORE_NAMES =\|_NON_COMPONENT =\|_SNAPSHOT_IGNORE =" core/cap_evolve/
core/cap_evolve/gepa.py:83:_NON_COMPONENT = set(NON_CAPABILITY_NAMES) | set(oc.INJECTED_NAMES)
core/cap_evolve/capdiff.py:39:_CAP_DIFF_SKIP = set(NON_CAPABILITY_NAMES) | set(_oc.INJECTED_NAMES)
core/cap_evolve/step.py:49:_SNAPSHOT_IGNORE = _oc.INJECTED_DIRS + _oc.INJECTED_NAMES + SCRATCH_NAMES
core/cap_evolve/cache.py:47:_IGNORE_NAMES = set(NON_CAPABILITY_NAMES) | set(INJECTED_NAMES)
core/cap_evolve/dashboard.py:644:_DIFF_SKIP = set(NON_CAPABILITY_NAMES) | set(INJECTED_NAMES)
core/cap_evolve/skillopt.py:431:_SCAFFOLDING = set(NON_CAPABILITY_NAMES) | set(INJECTED_NAMES)

$ PYTHONPATH=$PWD/core python -m pytest core/tests/test_gepa.py -q -k shared
.                                                                        [100%]
1 passed, 9 deselected in 0.02s

12. All three run.py --help parse (no duplicate argparse flags)

$ python skills/algorithms/hill-climb/scripts/run.py --help | grep -E 'plateau|capabilities|target-model'
  --capabilities CAPABILITIES
  --target-model TARGET_MODEL
  --plateau-window PLATEAU_WINDOW
  --plateau-escalate-every PLATEAU_ESCALATE_EVERY
  --plateau-lineage-window PLATEAU_LINEAGE_WINDOW
  --no-plateau-stop     warn + diversify only; never stop the run on plateau

$ python skills/algorithms/gepa/scripts/run.py --help | grep -E 'plateau|capabilities|target-model'
  --capabilities CAPABILITIES
  --target-model TARGET_MODEL
  --plateau-window PLATEAU_WINDOW
  --plateau-escalate-every PLATEAU_ESCALATE_EVERY
  --plateau-lineage-window PLATEAU_LINEAGE_WINDOW
  --no-plateau-stop     warn + diversify only; never stop the run on plateau

$ python skills/algorithms/skillopt/scripts/run.py --help | grep -E 'plateau|capabilities|target-model'
  --capabilities CAPABILITIES
  --target-model TARGET_MODEL
  --plateau-window PLATEAU_WINDOW
  --plateau-escalate-every PLATEAU_ESCALATE_EVERY
  --plateau-lineage-window PLATEAU_LINEAGE_WINDOW
  --no-plateau-stop     warn + diversify only; never stop the run on plateau

13. Real end-to-end, zero API cost — all three algorithms

The runner script (mock optimizer, no model calls):

#!/usr/bin/env bash
# Run $1's code into the SAME directory tree /tmp/e2e-ctl uses, so paths are identical.
set -euo pipefail
REPO="$1"; DEST="$2"
export CAPEVOLVE_CORE="$REPO/core" PYTHONPATH="$REPO/core"
export CAPEVOLVE_SKILLS_DIR="$REPO/skills"
export CAPEVOLVE_TOY_DATA="$REPO/examples/toy_calc"
export CAPEVOLVE_MOCK_SCRIPT="$REPO/examples/toy_calc/mock_script.json"
for ALGO in hill-climb gepa skillopt; do
  D="$DEST/$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" && /tmp/ce-venv/bin/python -m cap_evolve.cli run --spec "$D/.capevolve/project/capevolve.yaml" \
      --project "$D/.capevolve/project" --run-ts demo --dashboard off ) >/dev/null 2>&1 || echo "  $ALGO EXIT=$?"
done

First pass (before vs after at different paths) showed only seconds drift in
final.json, plus protected.json/digest diffs traced to project_dir:

$ diff <(jq -S . before/protected.json) <(jq -S . after/protected.json)
23c23
<  "project_dir": "/private/tmp/e2e-before/hill-climb/.capevolve/project"
---
>  "project_dir": "/private/tmp/e2e-after/hill-climb/.capevolve/project"

$ grep -n "def manifest_digest" -A11 core/cap_evolve/protect.py | tail -4
266-    canon = json.dumps({k: payload.get(k) for k in ("project_dir", "globs", "files")},
267-                       sort_keys=True, separators=(",", ":"))
268-    return hashlib.sha256(canon.encode("utf-8")).hexdigest()
269-

So the runs were redone with pre-split and post-split code writing to identical
paths, isolating code as the only variable:

$ /tmp/e2e_same_path.sh <pre-split-tree>  /tmp/e2e-presplit
$ /tmp/e2e_same_path.sh <post-split-tree> /tmp/e2e-ctl
$ python /tmp/cmp_same_path.py
hill-climb  files=284==284:True  byte-identical=279 time-normalized-equal=3 REAL-DIFF=2
            residual: ['dashboard.html', 'events.jsonl']
            test_reward 1.0 -> 1.0 | baseline 0.0 -> 0.0 | delta 1.0 -> 1.0 | best cand_0001 -> cand_0001
            final.json       present-both
            baseline.json    present-both
            splits.json      byte-identical
            report.md        byte-identical
            INSIGHTS.md      byte-identical
            rejected.jsonl   byte-identical
            history.jsonl    byte-identical
            plateau events=2  kinds=['baseline', 'evaluate', 'finalize', 'gate_warning', 'lineage_exhausted', 'plateau', 'protected_manifest', 'protected_paths_unmatched', 'split_warning', 'splits', 'step']

gepa        files=229==229:True  byte-identical=224 time-normalized-equal=4 REAL-DIFF=1
            residual: ['dashboard.html']
            test_reward 1.0 -> 1.0 | baseline 0.0 -> 0.0 | delta 1.0 -> 1.0 | best gepa_0001 -> gepa_0001
            final.json       present-both
            baseline.json    present-both
            splits.json      byte-identical
            report.md        byte-identical
            INSIGHTS.md      byte-identical
            rejected.jsonl   byte-identical
            history.jsonl    byte-identical
            plateau events=1  kinds=['baseline', 'evaluate', 'finalize', 'gate_warning', 'gepa_local_gate', 'gepa_select', 'gepa_start', 'gepa_val_gate', 'lineage_exhausted', 'minibatch', 'optimizer_context_warning', 'plateau', 'protected_manifest', 'protected_paths_unmatched', 'split_warning', 'splits']

skillopt    files=194==194:True  byte-identical=189 time-normalized-equal=3 REAL-DIFF=2
            residual: ['dashboard.html', 'events.jsonl']
            test_reward 1.0 -> 1.0 | baseline 0.0 -> 0.0 | delta 1.0 -> 1.0 | best so_e01s01 -> so_e01s01
            final.json       present-both
            baseline.json    present-both
            splits.json      byte-identical
            report.md        byte-identical
            INSIGHTS.md      byte-identical
            rejected.jsonl   byte-identical
            history.jsonl    byte-identical
            plateau events=1  kinds=['baseline', 'evaluate', 'finalize', 'gate_warning', 'lineage_exhausted', 'plateau', 'protected_manifest', 'protected_paths_unmatched', 'skillopt_slow_eval', 'skillopt_slow_update', 'skillopt_start', 'skillopt_step', 'split_warning', 'splits', 'step']

The residual dashboard.html (and skillopt events.jsonl) diffs, structurally:

$ python /tmp/cmp_dashboard.py     # walks the run-data JSON island field by field
### hill-climb
    .graph.nodes[0].runner_seconds: 0.006575822830200195 -> 0.0058939456939697266
    .graph.nodes[0].seconds: 0.006575822830200195 -> 0.0058939456939697266
    .graph.nodes[1].optimizer_seconds: 0.16 -> 0.15
    .graph.nodes[1].seconds: 0.17 -> 0.16
    .graph.nodes[2].optimizer_seconds: 0.16 -> 0.17
    .graph.nodes[2].seconds: 0.17 -> 0.18000000000000002
    ... 36 field diffs total: 36 are seconds/plateau.t/git-SHA, NON-TIME/NON-SHA diffs = 0
    non-island HTML identical: True
### gepa
    .graph.nodes[0].runner_seconds: 0.006356000900268555 -> 0.005908012390136719
    .graph.nodes[0].seconds: 0.006356000900268555 -> 0.005908012390136719
    .summary.evaluations[0].seconds: 0.006356000900268555 -> 0.005908012390136719
    .summary.evaluations[3].seconds: 0.006422996520996094 -> 0.0060882568359375
    .summary.git_log[0].hash: '038da9a' -> '69f5bf9'
    .summary.git_log[1].hash: '3674526' -> '0b048f7'
    ... 19 field diffs total: 19 are seconds/plateau.t/git-SHA, NON-TIME/NON-SHA diffs = 0
    non-island HTML identical: True
### skillopt
    .graph.nodes[0].runner_seconds: 0.006557941436767578 -> 0.005666971206665039
    .graph.nodes[0].seconds: 0.006557941436767578 -> 0.005666971206665039
    .graph.nodes[5].optimizer_seconds: 0.16 -> 0.18
    .graph.nodes[5].seconds: 0.17 -> 0.19
    .graph.nodes[6].optimizer_seconds: 0.16 -> 0.17
    .graph.nodes[6].seconds: 0.17 -> 0.18000000000000002
    ... 25 field diffs total: 25 are seconds/plateau.t/git-SHA, NON-TIME/NON-SHA diffs = 0
    non-island HTML identical: True

skillopt's events.jsonl, all 44 lines:

lines: 44 vs 44
fields that differ across the whole file: ['optimizer_seconds', 't']
=> t (epoch timestamp) and optimizer_seconds ONLY; no semantic field differs.

14. dashboard/frontend/dist/ untouched (#188)

$ git status --short -- dashboard/frontend/dist
(empty output = untouched)

$ git show --stat HEAD | tail -12
    non-timestamp artifact byte-identical.

 core/cap_evolve/capdiff.py        |  136 +++
 core/cap_evolve/context_inject.py |  316 +++++
 core/cap_evolve/evaluate.py       |  323 ++++++
 core/cap_evolve/handover.py       |  520 +++++++++
 core/cap_evolve/harness.py        | 2310 +++----------------------------------
 core/cap_evolve/insights.py       |  224 ++++
 core/cap_evolve/instructions.py   |  458 ++++++++
 core/cap_evolve/optimizer_proc.py |  110 ++
 core/cap_evolve/step.py           |  254 ++++
 9 files changed, 2474 insertions(+), 2177 deletions(-)

15. Authorship

$ git log -1 --format="author=%an <%ae>%ncommitter=%cn <%ce>"
author=Osher Elhadad <Osher.Elhadad@ibm.com>
committer=Osher Elhadad <Osher.Elhadad@ibm.com>

$ git log -1 --format=%B | grep -icE "co-authored-by|generated with|claude"
0
(0 = no Claude trailer, no "Generated with" line)

@OsherElhadad

Copy link
Copy Markdown
Collaborator Author

🔍 Review — PR #235

Verdict: APPROVE WITH NITS

The "zero behaviour change" claim holds up under mechanical verification. I rebuilt the composed base independently, extracted and compared all 68 moved symbols myself, probed every new module standalone, and ran all three algorithms pre/post with artifact comparison. One real regression found — a dropped import that from __future__ import annotations hides from both the compiler and the test suite. It is a one-line fix, not a redesign.


Blocking

(none)


Non-blocking

1. core/cap_evolve/step.py:59SplitResult annotation is unresolvable; the import was dropped in the move.

run_step's current_val: SplitResult parameter references a name that step.py never imports. On the base, harness.py:34 had from .loop import SplitResult, aggregate_scores, so the annotation resolved. step.py's import block (lines 18–28) carries gate, optimizer_context, protect, evaluate, handover, optimizer_proc, rundir — but not loop.

from __future__ import annotations (line 16) is why nothing caught this: annotations are stored as strings and never evaluated, so compileall passes, the module imports fine, run_step is callable, and all 357 tests pass. The breakage is only visible to anything that resolves the annotations:

$ PYTHONPATH=/tmp/rv-235/core python -c \
    "import typing, cap_evolve.harness as h; typing.get_type_hints(h.run_step)"
NameError: name 'SplitResult' is not defined

# same probe on the base:
BASE run_step hints: RESOLVE OK

I swept every function and class in all nine modules for this class of defect; this is the only occurrence:

cap_evolve.step:
   UNRESOLVABLE ANNOTATION NAME 'SplitResult'  used by ['run_step@L59']

Consequence: strictly a regression against the base — typing.get_type_hints, inspect.signature(..., eval_str=True), Sphinx autodoc_typehints, pydantic-style validators, and any runtime type checker now raise on the engine's single most important shared entry point. Nothing in core/ calls get_type_hints today, which is why it is non-blocking rather than blocking — but it is a latent trip-wire planted in exactly the function all three algorithms route through, and "the refactor was byte-identical" is what a future reader will (reasonably) assume.

Fix — one line in step.py, restoring what harness.py:34 had:

from .loop import SplitResult
from .rundir import SCRATCH_NAMES, RunDir

Verified:

FIX VERIFIED: get_type_hints(run_step) resolves
facade still ok: True
350 passed in 108.23s      # full suite minus the #200 dashboard flake

Worth also considering a cheap guard so the next split cannot repeat this — a test that walks cap_evolve.* and calls get_type_hints on every public callable would have caught it at zero cost, and catches every future recurrence rather than this one instance.

2. core/cap_evolve/handover.py:454 — the positional-extra coupling is real but undocumented at the point that depends on it.

The author's claim checks out. extra is POSITIONAL_OR_KEYWORD:

sig: (instructions: 'str', workdir: 'Path', run_dir: 'RunDir', extra: 'str' = '') -> 'str'
  extra: kind=POSITIONAL_OR_KEYWORD default=''

and core/tests/test_insights.py:84-85 fills trailing params positionally by arity:

extra = len(inspect.signature(harness._augment_instructions).parameters) - 3
out = harness._augment_instructions("BASE", wd, rd, *([None] * max(0, extra)))

Making extra keyword-only is a clean-looking edit that turns green to red with no merge conflict — precisely the trap class this epic keeps hitting. The test explains its own reasoning (#212 dropping params), but _augment_instructions' docstring never mentions that a test depends on extra staying positional, so the constraint is invisible from the side someone would edit.

Consequence: latent trap for the next refactor. Fix: one line in the handover.py:454 docstring — e.g. "extra must stay POSITIONAL (not kw-only): test_insights.py fills trailing params by arity." Cheaper than the debugging session it prevents.


Nits

3. core/cap_evolve/harness.py:23, capdiff.py:26, dashboard.py:629,633,639, optimizer_context.py:6,46,48,65,232,267, memory.py:15, plateau.py:109 — doc references still point at harness.X for symbols that moved.

Cosmetic and pre-existing in the untouched files, but capdiff.py:26 is new text in this PR referring to harness._SNAPSHOT_IGNORE, which now lives in step.py:49. The comment is a careful explanation of the destructive/read-side split, so it is worth keeping accurate. Not worth a repo-wide sweep in this PR.


Is it byte-identical?

Yes — 68/68, verified by my own AST extraction, not by trusting the claim. I parsed the base harness.py and all nine post-split modules, extracted each top-level symbol's exact source span (decorators included), and compared strings:

base top-level symbols: 68

IDENTICAL: 68
CHANGED:   0
MISSING:   0
DUPLICATED across modules: 1
  ! OptimizerFn  ['gepa', 'optimizer_proc']

No symbol was edited during the move. The one duplicate is pre-existing, not introduced: gepa.py:76 defines its own narrower OptimizerFn = Callable[[Path, str], None] on the base too, alongside harness.py:44's Callable[[Path, str], "dict | None"]. The split moved the harness one to optimizer_proc.py:22 unchanged, so the shadowing is identical before and after. Worth its own cleanup issue eventually — two different types under one name is a reader trap — but out of scope here.

I also checked the derived import-time sets, since #199's review found an import-time set mutation in exactly this class of code. _SNAPSHOT_IGNORE (step.py:49), _CAP_DIFF_SKIP / _CAP_DIFF_SKIP_DIRS (capdiff.py:39,42) and NON_CAPABILITY_NAMES all construct at import time from rundir/optimizer_context tuples. Values compared across trees:

DERIVED SETS IDENTICAL

All module-level work in the new modules is pure constant construction (frozensets, seed strings, _CTRL_STRIP, one PosixPath) — no os.environ reads, no mutable module-level defaults, no mutation of another module's state, nothing that runs twice. Confirmed no module is executed more than once:

unique module objects: 26 of 26
run_step identity across facade+direct: True

Import surface grows 18 → 26 cap_evolve modules on import cap_evolve.harness, which is the expected and intended cost of the split.


Import-order probes

Every new module imported first, in a fresh interpreter, with nothing else pre-loaded. No cycle manifests under any import order:

OK   cap_evolve.harness
OK   cap_evolve.step
OK   cap_evolve.evaluate
OK   cap_evolve.handover
OK   cap_evolve.instructions
OK   cap_evolve.insights
OK   cap_evolve.context_inject
OK   cap_evolve.optimizer_proc
OK   cap_evolve.capdiff
OK   cap_evolve.rundir
OK   cap_evolve.plateau
OK   cap_evolve.protect
OK   cap_evolve.gepa
OK   cap_evolve.skillopt
OK   cap_evolve.cli

The import DAG among the new modules is genuinely acyclic and shallow — step → {evaluate, handover, optimizer_proc}, handover → {capdiff, insights}, insights → capdiff, capdiff → evaluate. Nothing reaches back into harness, so the modules are independently importable rather than nominally split:

$ grep -n "harness\._\|from \.harness import" step.py evaluate.py handover.py \
      instructions.py insights.py context_inject.py optimizer_proc.py capdiff.py
capdiff.py:26:# ``harness._SNAPSHOT_IGNORE``, which is DESTRUCTIVE ...   # comment only

The one harness._private reach still in the tree is optimizer_context.py:253 (harness._focus_instructions, plus :201 and :251), already behind function-local from . import harness imports at lines 199 and 245. Byte-identical to the base and optimizer_context.py is not in this PR's diff, so the split neither introduced nor worsened it. It does mean optimizer_contextinstructions is a real cycle deferred by a local import rather than eliminated — the natural follow-up now that _focus_instructions lives in instructions.py and no longer needs to come through harness at all.

Re-export facade: verified clean. All 97 names still resolve from cap_evolve.harness and all 55+ from cap_evolve, with nothing lost and nothing new leaking:

== harness_dir: base=97 new=97
   LOST(0): []
   ADDED(0): []
== ce_dir: base=56 new=64
   LOST(0): []
   ADDED(8): ['capdiff','context_inject','evaluate','handover','insights',
              'instructions','optimizer_proc','step']
harness __all__ base: None -> new: None
cap_evolve __all__ base: None -> new: None

The 8 additions to cap_evolve are the new submodules themselves (unavoidable and harmless). Neither module defines __all__ before or after, so from cap_evolve.harness import * exports the same non-underscore set — unchanged. I also compared every re-exported name's identity (type, defining module, qualname) to confirm no name is now bound to a wrapper instead of the original: all 53 rebinds are the expected relocations (function|cap_evolve.harness|Xfunction|cap_evolve.step|X), plus worktree-path and set-repr-ordering artifacts. No name is bound to a different object; no wrappers were introduced.


Did the traps stay avoided?

Yes — verified on running code, not by reading. render_instructions + extra=plateau.prompt_block(...) is intact and _instructions was not reintroduced (grep -rn "def _instructions" → no matches).

The trap's signature is that it compiles clean with the feature silently dead, so I captured every prompt actually handed to the optimizer and grepped for #221's literal banner. With an aggressive PlateauConfig(window=2, escalate_every=1, lineage_window=2), 12-iteration runs:

=== hill_climb ===          === skillopt ===
  banner in PROMPTS: 8        banner in PROMPTS: 3
  MATERIALLY DIFFERENT: 8     MATERIALLY DIFFERENT: 3

Identical counts on the base (8 / 3), so the split changed nothing.

GEPA never organically reached diversify on the toy benchmark (the mock optimizer accepts too early), so absence there was no evidence either way — I forced it instead, pinning plateau.prompt_block to the real diversify text and driving gepa_loop directly:

GEPA prompts captured: 3
  plateau banner present in: 3/3
  'MATERIALLY DIFFERENT' present in: 3/3
  block is in PRESERVED TAIL (last 2000 chars) in: 3/3
GEPA PLATEAU->PROMPT: LIVE

All three algorithms carry the block, and it lands in the preserved tail — which is the property gepa.py:655-661 and handover.py:458-464 argue for (a behavioural instruction placed mid-string is what cap_instructions elides). The trap stayed avoided on all three paths.


Seam quality

handover.py (520) is the weakest seam and the next god-module. It currently holds three separable concerns:

  1. the cross-iteration handover artifacts — _journal_tail, _build_ledger, _seed_journal, _reconcile_journal, _build_runmap (lines 107–293);
  2. Active failure-memory: re-inject rejected approaches as optimizer constraints #129's dead-end constraints — approach_signature, dead_end_constraints (294–453, already fenced off with its own # ---- rejected-approach constraints (#129) banner and its own constants _MAX_DEAD_ENDS, _MAX_APPROACH_CHARS, _CTRL_STRIP);
  3. _augment_instructions (454–520), the single prompt chokepoint.

That is not one responsibility. (2) is already visually a separate module — it shares no state with (1), and its natural home is next to memory.py, which memory.py:15 already documents as the pairing. The tell is the module docstring having to enumerate five owners to explain what the file is.

The instructions.py / handover.py boundary is defensible but not clean. instructions.py (458) is genuinely cohesive — every function is a pure block renderer feeding _focus_instructions. The awkwardness is that prompt assembly is now split across three files: instructions.py renders the blocks, optimizer_context.render_instructions assembles them, and handover._augment_instructions appends the tail and applies the cap. Following one prompt end-to-end means three hops. Defensible, because the chokepoint genuinely belongs with the handover artifacts it appends — but "the prompt-assembly boundary is coherent" overstates it.

hill_climb_loop staying in harness.py while run_step moved to step.py is the right call, and it is the part of the split that best answers what the issue actually asked for. run_step is shared by all three algorithms (gepa.py:660, skillopt.py:328, harness.py:401 all route through it), so a change there changes all three — that is exactly the "shared" tier. hill_climb_loop is hill-climb-only and belongs beside baseline/finalize. What is left in harness.py (482) is coherent: ensure_splits, baseline, reuse_baseline, hill_climb_loop, finalize — the run lifecycle plus one algorithm. The only quibble is that the one algorithm sitting in the lifecycle module is asymmetric with gepa.py/skillopt.py; a hill_climb.py would make the tiering fully explicit. Not worth churning this PR for.

Recommended follow-up issue: extract #129's constraint machinery out of handover.py into its own module (or into memory.py). ~160 lines, mechanical, and it stops the file that owns the prompt chokepoint from being the place every new cross-iteration feature gets bolted on.


The left-behind manifest_digest bug

Confirmedprotect.py:266 includes project_dir in the canonical JSON, so the digest is not a pure function of protected content:

same content, different path:
  e15c73a3f18c74e8da60b23661d66bf788c4ad2a8602a6c049584d595be471fb
  d31c986d9c590086d599afc42d631ce53805793a9e67c036ee18ef45de2e4d81
DIGESTS DIFFER: True

Identical files map and globs, different project_dir → different digest.

Severity: real but low, and it does not belong in this PR. protect.py is not in the diff (git diff --name-only 69483ee3 d67639b9 lists nine files, none of them protect.py), so this is inherited, not left behind by #235.

On whether it weakens #142's tamper evidence: mostly no, and the reasoning matters. The per-file SHAs in files are the actual tamper evidence, and manifest_digest only protects the manifest record — so content tampering is still caught by the file hashes regardless of path. The genuine weakness is the one flagged: a digest mismatch becomes ambiguous. protect.py:353 (if want and manifest_digest(payload) != want) cannot distinguish "someone rewrote protected.json" from "the run directory moved", which hands an attacker a plausible cover story and trains operators to wave off mismatches. Ambiguous alarms decay into ignored alarms.

Recommendation: does not block #235; give it its own issue. The fix is to drop project_dir from the canonicalization (keeping globs + files) so the digest is content-only and a mismatch means exactly one thing. That is a behaviour change to tamper evidence and needs its own test — precisely what must not ride along in a zero-behaviour-change refactor. Correctly scoped out here.


Merge-order note

Confirmed: this must land LAST in the harness.py cluster, and nothing in it obstructs the eight PRs merging first.

PR #235 touches nine files, all under core/cap_evolve/, and every one of the eight pending PRs modifies harness.py:

#199: 1  #197: 1  #195: 1  #212: 1  #211: 1  #222: 1  #219: 1  #221: 1   harness.py file(s)

Since #235 deletes 2,044 of harness.py's 2,526 lines, merging it before any of those turns each of them into a hand-resolved conflict against code that no longer exists in that file. Merging it last means each lands in the pre-split layout it was written against, then #235 replays mechanically.

Rebase cost if any of the eight changes: proportionate and predictable. Because all 68 symbols move byte-identically, a rebase is "re-apply the same move to the updated text" — and the byte-identity extraction in this review is a re-runnable check that the replay stayed faithful. I'd suggest re-running it as a gate on any rebase.

The composition claim checks out. All nine SHAs are ancestors of the local compose-base-115 (69483ee):

e47a8e15: ANCESTOR   729a79ad: ANCESTOR   887349ca: ANCESTOR
53f1a1f2: ANCESTOR   5e31cf40: ANCESTOR   7d6ed65c: ANCESTOR
6d6cc52d: ANCESTOR   a814e6bb: ANCESTOR   5d530019: ANCESTOR

The stated redundancy is also real — #222 (6d6cc52) already contains #199, #212 and #211, and #219 (a814e6b) already contains #199:

729a79ad in 6d6cc52d: YES   5e31cf40 in 6d6cc52d: YES   7d6ed65c in 6d6cc52d: YES
#219 contains #199: YES

Harmless (git dedupes; the merge commits are no-ops), and the composition is what was claimed.


Verification I re-ran

Baseline on my independently-checked-out composed base — reproduces 357 exactly:

$ cd /tmp/rv235-base && PYTHONPATH=/tmp/rv235-base/core python -m pytest core/tests -q
357 passed in 94.58s (0:01:34)

Post-split:

$ cd /tmp/rv-235 && PYTHONPATH=/tmp/rv-235/core python -m pytest core/tests -q
FAILED core/tests/test_dashboard_launch.py::test_maybe_launch_spawns_when_available
1 failed, 356 passed in 91.74s (0:01:31)

That failure is #200, not this PR — a stray process holds port 7878 on this machine, and the identical test fails identically on the base:

$ cd /tmp/rv235-base && ... pytest core/tests/test_dashboard_launch.py -q
1 failed, 6 passed in 0.03s          # same failure on the BASE
$ lsof -nP -iTCP:7878
Python  40333 ... TCP 127.0.0.1:7878 (LISTEN)

356 + 1 known-flaky = 357. Test count is identical; no test was silently dropped or added.

compileall + repo hygiene (#188):

$ python -m compileall -q core/cap_evolve   # exit=0, no output
$ git status --porcelain                    # empty
$ git ls-files | grep -c "^dist/"           # 0

Line counts — harness.py 2,526 → 482, nine modules, none a new god-module:

 520 handover.py     482 harness.py     458 instructions.py
 323 evaluate.py     316 context_inject.py
 254 step.py         224 insights.py    136 capdiff.py     110 optimizer_proc.py

Equivalence, all three deterministic algorithms. Pre/post into parallel paths with a fixed seed, PYTHONHASHSEED=0, and an aggressive plateau config; identical iteration counts and outcomes:

base:  hill_climb: iterations=4 best=1.0    new:  hill_climb: iterations=4 best=1.0
       gepa:       iterations=6 best=1.0          gepa:       iterations=6 best=1.0
       skillopt:   iterations=3 best=1.0          skillopt:   iterations=3 best=1.0

Full artifact tree comparison (512 files each side) with an exclusion list I wrote and printed rather than inherited:

=== EXCLUSION LIST APPLIED ===
  /tmp/eq-(base|new)                    -> <RUNROOT>     # run root path
  /(private/)?tmp/rv(235-base|-235)     -> <REPO>        # repo worktree path
  "seconds"\s*:\s*[0-9.eE+-]+           -> "seconds":<T>
  "(elapsed|duration|started_at|finished_at|ts|timestamp|at)": ... -> <T>
  \b[0-9a-f]{40}\b                      -> <SHA40>       # git sha
  \b[0-9a-f]{64}\b                      -> <SHA64>       # content digest
  "t"\s*:\s*[0-9.eE+-]+                 -> "t":<T>       # plateau.t
  /(var|private)/folders/[^"\s,)\]]+    -> <TMPDIR>      # mkdtemp
  \bpid"?\s*[:=]\s*[0-9]+               -> pid:<PID>
  \b17[0-9]{8}(\.[0-9]+)?\b             -> <EPOCH>       # bare unix epoch
  "?\w*_seconds"?\s*[:=]\s*[0-9.eE+-]+  -> _seconds:<T>

files base=512 new=512
RAW differing files: 43
NON-TIME/NON-SHA/NON-PATH differing files: 0

On whether the filter is honest. It excludes exactly four things, and I checked each covers only nondeterminism, not behaviour:

  • seconds / *_seconds / epochs / ts — wall-clock. plateau.t is an event timestamp, not the plateau level: the decision fields (level, run_length, velocity, accepts, near_misses, exhausted_lineages) are all unexcluded and compared, and they matched. A changed plateau decision would still have surfaced.
  • <SHA40> — git commit SHAs, which embed the commit timestamp. I did not take these on trust: I additionally reconstructed every commit's full tree content and compared it, so the content under those SHAs is compared even though the SHAs are masked.
  • <SHA64> — I confirmed these are eval-cache keys that hash the capability content; identical content produced identical keys on both sides (the eval_cache.json line matched byte-for-byte in the tree dump), so this exclusion is not load-bearing.
  • paths — unavoidable, the two trees are at different worktrees. This is also what makes the manifest_digest path-dependence above visible as a design smell rather than a diff artifact.

Before adding the *_seconds and bare-epoch patterns the diff reported 12 files; inspecting each showed all 12 were timing fields (runner_seconds, optimizer_seconds) and unix epochs in .git/logs/HEAD — i.e. the filter was tightened by inspecting real diffs, not by broadening until the number hit zero.

Plateau-in-prompt, all three algorithms — counts identical base vs split (8 / forced-live / 3), GEPA proven live by forcing the diversify state (3/3 prompts carry the banner, 3/3 in the preserved tail).

The proposed one-line fix, verified end to end:

FIX VERIFIED: get_type_hints(run_step) resolves
facade still ok: True
350 passed in 108.23s (0:01:48)     # full suite minus the #200 flake

Reproduced? Yes — every claim in the PR description held: 357 baseline, 68/68 byte-identical, 97/55 importable, no caller changed, no circular imports, compileall clean, no dist/ churn, and NON-TIME/NON-SHA diffs = 0 on all three algorithms. The only thing the PR's own verification missed is the SplitResult import, which is invisible to every check it ran (compileall, imports, and the test suite all pass) and requires annotation resolution to surface.

…lution (#115 review)

Review fixes for PR #235. All four changes are non-behavioural.

1. step.py — restore `from .loop import SplitResult`, dropped when `run_step`
   moved out of harness.py (base had it at harness.py:34). `from __future__
   import annotations` hid the break from compileall, from imports, and from all
   357 tests: it only surfaces on annotation *resolution*
   (`typing.get_type_hints(run_step)` -> NameError).

2. test_core.py — new `test_every_public_annotation_resolves`: walks every module
   in `cap_evolve` and calls `get_type_hints` on every function, class, and
   method defined there. Verified to FAIL on the unfixed tree and pass after, so
   it is a real trip-wire, not a tautology. This is the one defect class a
   byte-identical refactor can introduce that no existing check catches.

3. handover.py — docstring line on `_augment_instructions` naming why `extra`
   must stay POSITIONAL (test_insights.py fills trailing params by arity), so the
   constraint is visible from the side someone would edit. Docstring only; AST
   identical ignoring docstrings.

4. capdiff.py — two stale doc refs to the moved `_SNAPSHOT_IGNORE`
   (`hillclimb.`/`harness.` -> `step.`). Comment only.

Verified: annotation sweep 0 broken across all nine new modules; 68/68 symbols
still byte-identical (the one flagged delta is the intended handover docstring);
97/97 facade names resolve; all nine modules import standalone with no reach-back
into harness; #221's plateau block reaches the prompt LIVE on hill-climb (1/5) and
GEPA (6/6 forced), landing in the preserved tail; compileall clean; no dist/ churn.
357 passed (358 collected; the one failure is the known #200 port-7878 flake).

Known follow-ups, deliberately NOT in this PR: protect.manifest_digest's
path-dependence (protect.py is not in this diff) and extracting #129's ~160-line
constraint machinery out of handover.py.

Refs #115
Copilot AI review requested due to automatic review settings July 30, 2026 13:53

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

Commit 1146bb76 (added on top of d67639b9, not amended, so the fix is reviewable as a diff). Four files, 47 insertions, 2 deletions. Thank you for the sweep — the SplitResult finding is exactly right and every check this PR ran was blind to it.

$ git diff --stat d67639b9..1146bb76
 core/cap_evolve/capdiff.py  |  4 ++--
 core/cap_evolve/handover.py |  5 +++++
 core/cap_evolve/step.py     |  1 +
 core/tests/test_core.py     | 39 +++++++++++++++++++++++++++++++++++++++
 4 files changed, 47 insertions(+), 2 deletions(-)

1. step.py:59 — dropped SplitResult import — FIXED

Confirmed and fixed. Your diagnosis is exact: from __future__ import annotations stores annotations as strings, so the break was invisible to compileall, to imports, and to all 357 tests, and surfaces only on resolution.

 from .handover import _augment_instructions, _reconcile_journal, approach_signature
+from .loop import SplitResult
 from .optimizer_proc import OptimizerFn

The sweep you asked for, across all nine new modules. Script and full literal output:

"""Resolve every annotation on every public callable/class in the nine new modules."""
import importlib, inspect, sys, typing

MODS = ["harness","step","evaluate","handover","instructions","insights",
        "context_inject","optimizer_proc","capdiff"]
bad = 0
for name in MODS:
    m = importlib.import_module(f"cap_evolve.{name}")
    n = 0
    for attr, obj in vars(m).items():
        if getattr(obj, "__module__", None) != m.__name__:
            continue
        if not (inspect.isfunction(obj) or inspect.isclass(obj)):
            continue
        n += 1
        try:
            typing.get_type_hints(obj)
        except Exception as e:
            bad += 1
            print(f"  BROKEN cap_evolve.{name}.{attr}: {type(e).__name__}: {e}")
        if inspect.isclass(obj):
            for mn, mo in vars(obj).items():
                if inspect.isfunction(mo):
                    try:
                        typing.get_type_hints(mo)
                    except Exception as e:
                        bad += 1
                        print(f"  BROKEN cap_evolve.{name}.{attr}.{mn}: {type(e).__name__}: {e}")
    print(f"cap_evolve.{name}: {n} functions/classes swept")
print(f"\nTOTAL BROKEN ANNOTATIONS: {bad}")
sys.exit(1 if bad else 0)

BEFORE the fix — reproduces your single finding, and only that one:

$ PYTHONPATH=$PWD/core python /tmp/sweep235.py
cap_evolve.harness: 5 functions/classes swept
  BROKEN cap_evolve.step.run_step: NameError: name 'SplitResult' is not defined
cap_evolve.step: 2 functions/classes swept
cap_evolve.evaluate: 6 functions/classes swept
cap_evolve.handover: 8 functions/classes swept
cap_evolve.instructions: 13 functions/classes swept
cap_evolve.insights: 3 functions/classes swept
cap_evolve.context_inject: 4 functions/classes swept
cap_evolve.optimizer_proc: 3 functions/classes swept
cap_evolve.capdiff: 5 functions/classes swept

TOTAL BROKEN ANNOTATIONS: 1
exit=1

AFTER the fix:

$ PYTHONPATH=$PWD/core python /tmp/sweep235.py
cap_evolve.harness: 5 functions/classes swept
cap_evolve.step: 2 functions/classes swept
cap_evolve.evaluate: 6 functions/classes swept
cap_evolve.handover: 8 functions/classes swept
cap_evolve.instructions: 13 functions/classes swept
cap_evolve.insights: 3 functions/classes swept
cap_evolve.context_inject: 4 functions/classes swept
cap_evolve.optimizer_proc: 3 functions/classes swept
cap_evolve.capdiff: 5 functions/classes swept

TOTAL BROKEN ANNOTATIONS: 0
exit=0

No other broken annotation exists. 49 functions/classes swept across the nine modules (plus their methods); run_step was the only one, matching your independent result.

The guard test — added, per your suggestion. core/tests/test_core.py::test_every_public_annotation_resolves. It is deliberately wider than the nine modules: it walks every module in cap_evolve (all 38) via pkgutil.iter_modules and resolves hints on every function, class, and method defined there (skipping re-exports, which get checked at their definition site), so it guards the next move regardless of which file it touches.

Proof it is a real trip-wire, not a tautology — I deleted the fix line and re-ran only the guard:

$ sed -i '' '/^from \.loop import SplitResult$/d' core/cap_evolve/step.py
$ PYTHONPATH=$PWD/core python -m pytest core/tests/test_core.py::test_every_public_annotation_resolves -q
>       assert not broken, "unresolvable annotations (missing import after a move?):\n" + "\n".join(broken)
E       AssertionError: unresolvable annotations (missing import after a move?):
E         cap_evolve.step.run_step: NameError: name 'SplitResult' is not defined
E       assert not ["cap_evolve.step.run_step: NameError: name 'SplitResult' is not defined"]

core/tests/test_core.py:196: AssertionError
FAILED core/tests/test_core.py::test_every_public_annotation_resolves
1 failed in 0.05s

$ # restore the fix
$ PYTHONPATH=$PWD/core python -m pytest core/tests/test_core.py::test_every_public_annotation_resolves -q
.                                                                        [100%]
1 passed in 0.03s

Runtime cost: 0.03s. It fails on the exact defect it exists to catch.

2. handover.py:454 — positional-extra coupling undocumented — FIXED

Documented on the side someone would edit, as requested:

     Used by ``cap_evolve.plateau.prompt_block``.
 
+    ``extra`` MUST stay POSITIONAL (never keyword-only): ``test_insights.py`` calls this
+    signature-agnostically, filling trailing params by arity
+    (``*[None] * (len(signature.parameters) - 3)``) so #212-style param drops don't break
+    it. Making it kw-only turns that test red with no merge conflict to warn you.
+
     Clean ownership (see the file-header comment near ``_JOURNAL_SEED``):

Docstring only — proven, not asserted:

handover.py: AST identical ignoring docstrings/comments = True

3. NIT — stale harness.X doc references — FIXED for the new text, declined for the rest

You singled out capdiff.py:26 as new text in this PR pointing at harness._SNAPSHOT_IGNORE, which now lives at step.py:50. Fixed — and while there I found a second, worse one in the same file's module docstring at capdiff.py:10, naming hillclimb._SNAPSHOT_IGNORE, a module that does not exist in the tree at all:

-single DESTRUCTIVE consumer (``hillclimb._SNAPSHOT_IGNORE``).
+single DESTRUCTIVE consumer (``step._SNAPSHOT_IGNORE``).
...
-# ``harness._SNAPSHOT_IGNORE``, which is DESTRUCTIVE and takes ``SCRATCH_NAMES`` only:
+# ``step._SNAPSHOT_IGNORE``, which is DESTRUCTIVE and takes ``SCRATCH_NAMES`` only:

Comment/docstring only:

capdiff.py: AST identical ignoring docstrings/comments = True

Declining the rest, agreeing with your own framing. dashboard.py:629,633,639, optimizer_context.py:6,46,48,65,232,267, memory.py:15, plateau.py:109, rundir.py:39, and the test_gepa.py/test_insights.py/test_failure_memory.py references are in files this PR does not touch. Adding them would grow a zero-behaviour-change refactor's diff into files with eight PRs still pending against them, and each one is a merge conflict for someone else. harness.py:23's reference is correct as written — that line is the split's own map, describing where things went. Worth a separate one-commit doc sweep after the cluster lands.


Two recorded follow-ups — NOT fixed here

protect.manifest_digest (protect.py:266) needs its own issue. Thank you for confirming it, and for the sharper severity reasoning — per-file SHAs in files remain the actual tamper evidence, so content tampering is still caught regardless of path; the real harm is that a digest mismatch becomes ambiguous at protect.py:353 ("someone rewrote protected.json" vs "the run directory moved"), which hands an attacker a cover story and trains operators to wave off mismatches. Ambiguous alarms decay into ignored alarms. Severity low, does not block, and I did not touch protect.py:

$ git diff --name-only d67639b9..1146bb76
core/cap_evolve/capdiff.py
core/cap_evolve/handover.py
core/cap_evolve/step.py
core/tests/test_core.py

Recommended fix for the follow-up issue: drop project_dir from the canonicalization (keep globs + files) so the digest is content-only and a mismatch means exactly one thing. That is a behaviour change to tamper evidence and needs its own test — precisely what must not ride along in a zero-behaviour-change refactor.

handover.py (520 lines) is the next god-module — acknowledged, not re-split now. Your read is correct and I'm withdrawing the "coherent boundary" claim: prompt assembly now spans three files (instructions.py renders the blocks, optimizer_context.render_instructions assembles, handover._augment_instructions appends the tail and applies the cap), so following one prompt end-to-end is three hops. Defensible, but "coherent" overstated it. handover.py genuinely holds three concerns, with #129's constraint machinery (approach_signature, dead_end_constraints, ~160 lines at 294-453, already fenced with its own banner and constants) the natural extraction — next to memory.py, which memory.py:15 already documents as the pairing. Not re-splitting in this PR: it is already the largest structural change in the batch and lands last against eight pending PRs; a second extraction now multiplies the rebase surface for no behavioural gain. Filed as known follow-up rather than claiming the seams are final. I also agree a hill_climb.py would make the tiering fully explicit and is not worth churning this PR for.


Verification — full literal output

Annotation sweep across all nine modules: 0 broken (above).

Test suite — 358 collected (357 + the new guard), 357 pass; the single failure is the known #200 port-7878 flake:

$ PYTHONPATH=$PWD/core python -m pytest core/tests --collect-only -q | tail -2
358 tests collected in 0.07s

$ PYTHONPATH=$PWD/core python -m pytest core/tests -q
E         + http://127.0.0.1:7882
core/tests/test_dashboard_launch.py:56: AssertionError
FAILED core/tests/test_dashboard_launch.py::test_maybe_launch_spawns_when_available
1 failed, 357 passed in 92.19s (0:01:32)

$ lsof -nP -iTCP:7878 -sTCP:LISTEN
COMMAND   PID         USER   FD   TYPE             DEVICE SIZE/OFF NODE NAME
Python  40333 osherelhadad    6u  IPv4 0x4f33179a251c2abd      0t0  TCP 127.0.0.1:7878 (LISTEN)

A stray process still holds 7878 on this machine, same as in your run — #200, environmental, identical on the base.

Byte-identity, re-extracted from 69483ee3 by AST and compared against every module in the tree:

$ PYTHONPATH=$PWD/core python /tmp/byteid235.py
base symbols: 68
BYTE-IDENTICAL: 67/68
MISSING (0): []
CHANGED (1): [('_augment_instructions', ['handover.py'])]

The one delta is finding 2's docstring — intentional, and the only symbol I changed. Proven docstring-only by comparing the AST with all docstrings stripped:

handover.py: AST identical ignoring docstrings/comments = True
capdiff.py: AST identical ignoring docstrings/comments = True

So: 68/68 byte-identical apart from the one docstring this review asked for.

Facade — 97/97, no __all__ change:

$ PYTHONPATH=$PWD/core python /tmp/facade235.py
harness facade: 97 base top-level names, LOST=0 []
harness public dir(): 40
harness __all__: None | cap_evolve __all__: ['CapabilityAdapter', 'stub_methods', 'EvalCache', ...]
star-import surface all resolvable: True
exit=0

All nine modules import standalone, none reaching back into harness:

OK cap_evolve.harness      OK cap_evolve.step        OK cap_evolve.evaluate
OK cap_evolve.handover     OK cap_evolve.instructions OK cap_evolve.insights
OK cap_evolve.context_inject OK cap_evolve.optimizer_proc OK cap_evolve.capdiff

$ grep -n "harness\._\|from \.harness import" core/cap_evolve/{step,evaluate,handover,instructions,insights,context_inject,optimizer_proc,capdiff}.py
    (no output — zero reach-backs)

#221's plateau block still reaches the prompt — LIVE, on running code. Real hill_climb_loop end-to-end with the real mock optimizer subprocess and an aggressive PlateauConfig(window=2, escalate_every=1, lineage_window=2), capturing every prompt at the chokepoint; then GEPA with the diversify state forced, since it does not organically reach it on the toy bench:

=== hill_climb ===
  prompts captured: 5
  plateau banner present in: 1/5
  'MATERIALLY DIFFERENT' present in: 1/5
  in PRESERVED TAIL (last 2000 chars): 1/5
  hill_climb PLATEAU->PROMPT: LIVE
=== gepa (diversify state FORCED) ===
  prompts captured: 6
  plateau banner present in: 6/6
  'MATERIALLY DIFFERENT' present in: 6/6
  in PRESERVED TAIL (last 2000 chars): 6/6
  gepa (diversify state FORCED) PLATEAU->PROMPT: LIVE

And at the chokepoint directly, confirming extra= survives into the capped tail:

diversify block, first line: ## PLATEAU — CHANGE APPROACH (escalation: diversify)

chokepoint _augment_instructions(..., extra=plateau block):
  banner present in prompt: True
  'MATERIALLY DIFFERENT' present: True
  block is in PRESERVED TAIL (last 2000 chars): True
  prompt length: 2142

=== call sites that feed the plateau block in (verbatim) ===
harness.py:401: extra_instructions=plateau.prompt_block(pstate),
gepa.py:660: instructions = _augment_instructions(instructions, workdir, run_dir,
skillopt.py:331: extra_instructions=plateau.prompt_block(pstate),

SkillOpt routes through step.run_step(extra_instructions=) -> the same chokepoint (step.py:111-112), so all three algorithms are covered by one path plus GEPA's own direct call.

compileall + hygiene (#188):

$ python -m compileall -q core skills; echo "exit=$?"
exit=0

$ git status --porcelain          # clean after commit
$ git status --porcelain | grep -c "dist/"
0

Authorship:

$ git log --format='%h %an <%ae>%n%s' -1
1146bb76 Osher Elhadad <Osher.Elhadad@ibm.com>
fix(core): restore dropped SplitResult import + guard annotation resolution (#115 review)

All 3 findings addressed; nothing else touched. The two recorded items need their own issues.

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.

Split the 1,928-line harness.py god module

3 participants