Skip to content

feat(algorithm): re-inject rejected approaches as optimizer constraints (#129) - #222

Open
OsherElhadad wants to merge 11 commits into
mainfrom
feat/issue-129-failure-memory
Open

feat(algorithm): re-inject rejected approaches as optimizer constraints (#129)#222
OsherElhadad wants to merge 11 commits into
mainfrom
feat/issue-129-failure-memory

Conversation

@OsherElhadad

Copy link
Copy Markdown
Collaborator

Closes #129

The honesty gap first: was "never re-proposed" true?

No — it was false on both halves. RUN.md claimed:

Rejected approaches are remembered and never re-proposed.

Before this PR:

  1. Nothing re-injected them. rejected.jsonl was written but its only reader was the dashboard. PR refactor(core): drop dead optimizer-memory API + unused params; fix misleading cache docstring #212 (issue Remove write-only optimizer-memory (memory.py) + unused params; fix misleading cache.py docstring #114) deleted the write-only memory whose render() never reached a prompt and left an explicit note: _augment_instructions is the only function whose output reaches the optimizer. Nothing routed rejections there.
  2. Nothing could prevent a re-proposal even in principle — the optimizer is a black-box agent CLI.

The e2e transcript below shows the failure concretely: with a mock optimizer proposing the same bad edit, hill-climb re-proposed the identical rejected edit 5 times and the prompt never mentioned it once. That's 4 wasted full-val evals per run.

RUN.md and docs/COMPARISON.md are corrected to state what's actually true.

What changed

harness.approach_signature(parent_dir, cand_dir) — a stable, compact signature of what an edit changed, from the capability diff (_diff_capabilities, the same source the dashboard/RUNMAP use, so it never picks up injected read-context or algorithm scratch): per touched file, whitespace-collapsed added/removed lines. Cosmetic variants of one idea collapse to one signature. A no-op edit (optimizer errored → workdir is a verbatim parent copy) yields "" and is not injected.

harness.dead_end_constraints(run_dir) — the ## ALREADY TRIED & REJECTED block: deduped signature + gate reason + repeat count, plus the actual constraint ("do not re-propose; if you revisit one, state in PROCESS.md what is materially different and which lesson it counters").

Wired into _augment_instructions — the one function whose output reaches the prompt (#114) and which all three algorithms route through, so hill-climb / GEPA / SkillOpt get it with zero per-algorithm plumbing. Every rejection site now records approach: run_step, GEPA's local minibatch gate, GEPA's val gate, and GEPA's two merge gates.

Per the task brief I used RunDir.iteration_events() semantics throughout and hand-filtered no event kindsdead_end_constraints reads rejected.jsonl, which is kind-agnostic by construction, so #216's class of bug cannot recur here.

How constraints are bounded

Three independent budgets, zero LLM calls (pure Python, per PR #205 — no aux_model needed; a verbatim diff signature is more actionable than a paraphrase anyway):

Budget Value Where
distinct approaches injected 12 most recent _MAX_DEAD_ENDS
per-signature chars 300 _MAX_APPROACH_CHARS, enforced on write and on read
per-reason chars 200 dead_end_constraints

Eviction policy is recency, not relevance: the newest rejections are the ones the current lineage is closest to re-proposing, and recency needs no scoring model. Repeats are counted, not stored twice — 50 re-proposals of one idea is one row saying "re-proposed 50x".

Also: render_instructions capped its own output, but _augment_instructions appends after it — so the cross-iteration blocks were previously outside the ceiling. cap_instructions is extracted so the final assembled prompt is held under MAX_INSTRUCTIONS_CHARS (60k).

Enforcement: advisory at the prompt, hard at the gate

Stated precisely because it matters: cap-evolve cannot forbid a black-box agent CLI from re-emitting an edit. The constraint is prompt text. What is hard is the val gate — a re-proposed dead end is still rejected, and the repeat is counted and shown back ("re-proposed 4x"), which is a strictly stronger signal each time. RUN.md now says exactly this rather than implying a guarantee.

Drive-by root-cause fix: one scratch list, four consumers

Building the signature surfaced a real bug. 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" reported its reflective scratch as a real edit everywhere that diff is shown (dashboard, RUNMAP, prior_iterations/). First GEPA e2e run made this visible: the signature was 300 chars of FOCUS.md/REFLECTION.md boilerplate with the real edit truncated off. Unified as optimizer_context.SCRATCH_NAMES. Fixed once, where all four consumers route through.

Scope

Rejected-approach constraints only. Not #128 (synthesized insight/priors) and not #130 (plateau detection).

Dashboard

Insights "What not to try" now shows the rejected edit signature under each reason — same data, one new optional field, degrades cleanly on pre-#129 runs.

Expected merge order

origin/main#199 (issue #109) → #212 (issue #114) → this. One trivial conflict resolving #199+#212 in gepa.py (keep #199's render_instructions, #212's 3-arg _augment_instructions); already resolved in the merge commit on this branch.

Verification

Merged-tree baseline (main + #199 + #212): 200 passed. This branch: 213 passed, 0 failed (+13 new tests in core/tests/test_failure_memory.py). compileall core skills clean.

$ PYTHONPATH=/tmp/wt-129/core python -m pytest core/tests -q -p no:randomly
213 passed in 84.70s (0:01:24)
=== compileall ===
compileall clean

Fail-before, proven by stashing the implementation and keeping only the test file:

$ git stash; pytest core/tests/test_failure_memory.py -q
13 failed in 3.06s

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

examples/toy_calc via cap-evolve run, mock optimizer, 5 iterations, stall: 99 so rejections accumulate. Mock script proposes a deliberately harmful edit, so every candidate is genuinely rejected and the mock re-proposes the same dead end.

hill-climb, iteration 5 prompt:

## ALREADY TRIED & REJECTED — do not re-propose these (framework, read-only)

The gate has rejected 1 distinct approach(es) on this run. Each row is the EXACT capability edit that failed and why:

- **cand_0001**, re-proposed 4x — `prompt.txt: +[BAD-IDEA] Always reply with a short poem instead of the answer.`
  - rejected because: paired Δ̄=+0.0000 <= 0 (SE=0 → STRICT fallback, warned; n=2)

**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.

GEPA — specifically proven, being the algorithm whose cross-iteration channel was silently empty before #199:

- **gepa_0001**, re-proposed 4x — `prompt.txt: +[BAD-IDEA] Always reply with a short poem instead of the answer.`
  - rejected because: local minibatch gate: sum(child) <= sum(parent)

SkillOpt:

- **so_e01s01**, re-proposed 3x — `prompt.txt: +[BAD-IDEA] Always reply with a short poem instead of the answer.`
  - rejected because: paired Δ̄=+0.0000 <= 0 (SE=0 → STRICT fallback, warned; n=2)

Prompt stays capped; no sealed-test/val leak

=== hill-climb ===
test=['a8', 'a7'] val=['a1', 'a4']  -> test/val ids in constraint block: 0
max INSTRUCTIONS.md: 21587 chars (cap 60000)
sealed-test ids anywhere in workdir *.md: 0
=== gepa ===
test=['a8', 'a7'] val=['a1', 'a4']  -> test/val ids in constraint block: 0
max INSTRUCTIONS.md: 23096 chars (cap 60000)
sealed-test ids anywhere in workdir *.md: 0
=== skillopt ===
test=['a8', 'a7'] val=['a1', 'a4']  -> test/val ids in constraint block: 0
max INSTRUCTIONS.md: 22999 chars (cap 60000)
sealed-test ids anywhere in workdir *.md: 0

test_constraints_bounded_on_a_long_run additionally proves 50 rejections with 5 KB signatures and 5 KB reasons produce a block < 8 KB, not 50 verbatim constraints.

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

Osher Elhadad added 9 commits July 30, 2026 00:49
…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.
…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.
…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
Copilot AI review requested due to automatic review settings July 30, 2026 01:11

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.

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 +973 to +978
"**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.",
@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

All commands run in /tmp/wt-129 (worktree from origin/main + origin/fix/issue-109-optimizer-context + origin/refactor/issue-114-drop-write-only-memory). Python /tmp/ce-venv/bin/python.

1. Merge base

da9ad44 feat(algorithm): re-inject rejected approaches as optimizer constraints (#129)
871567d merge: #199 (#109 optimizer context) + #212 (#114 drop write-only memory)

2. Fail-before (stash the implementation, keep only the new test file)

$ git stash push -- <impl files>
$ PYTHONPATH=/tmp/wt-129/core python -m pytest core/tests/test_failure_memory.py -q -p no:randomly
FAILED core/tests/test_failure_memory.py::test_signature_captures_the_edit_and_ignores_injected_context
FAILED core/tests/test_failure_memory.py::test_signature_is_stable_across_cosmetic_variation
FAILED core/tests/test_failure_memory.py::test_signature_empty_for_a_noop_edit
FAILED core/tests/test_failure_memory.py::test_constraints_block_names_the_edit_and_the_reason
FAILED core/tests/test_failure_memory.py::test_constraints_empty_without_rejections
FAILED core/tests/test_failure_memory.py::test_constraints_dedupe_and_count_repeats
FAILED core/tests/test_failure_memory.py::test_pre_129_records_without_approach_are_skipped
FAILED core/tests/test_failure_memory.py::test_constraints_bounded_on_a_long_run
FAILED core/tests/test_failure_memory.py::test_hill_climb_prompt_carries_the_constraints
FAILED core/tests/test_failure_memory.py::test_gepa_prompt_carries_the_constraints
FAILED core/tests/test_failure_memory.py::test_skillopt_prompt_carries_the_constraints
FAILED core/tests/test_failure_memory.py::test_a_real_rejection_records_its_approach_signature
FAILED core/tests/test_failure_memory.py::test_no_val_or_test_ground_truth_in_the_constraint_block
13 failed in 3.06s

3. Pass-after + merged-tree baseline

$ cd /tmp/wt-129   # main + #199 + #212 + THIS
$ PYTHONPATH=/tmp/wt-129/core python -m pytest core/tests -q -p no:randomly
........................................................................ [ 33%]
........................................................................ [ 67%]
.....................................................................    [100%]
213 passed in 84.22s (0:01:24)

$ cd /tmp/wt-base  # main + #199 + #212 ONLY (merged-tree baseline)
$ PYTHONPATH=/tmp/wt-base/core python -m pytest core/tests -q -p no:randomly
200 passed in 75.87s (0:01:15)

$ python -m compileall -q core skills
(clean, no output)

4. Only-new-tests differ from the merged baseline

$ diff <(baseline collected) <(branch collected)
> tests/test_failure_memory.py::test_a_real_rejection_records_its_approach_signature
> tests/test_failure_memory.py::test_constraints_block_names_the_edit_and_the_reason
> tests/test_failure_memory.py::test_constraints_bounded_on_a_long_run
> tests/test_failure_memory.py::test_constraints_dedupe_and_count_repeats
> tests/test_failure_memory.py::test_constraints_empty_without_rejections
> tests/test_failure_memory.py::test_gepa_prompt_carries_the_constraints
> tests/test_failure_memory.py::test_hill_climb_prompt_carries_the_constraints
> tests/test_failure_memory.py::test_no_val_or_test_ground_truth_in_the_constraint_block
> tests/test_failure_memory.py::test_pre_129_records_without_approach_are_skipped
> tests/test_failure_memory.py::test_signature_captures_the_edit_and_ignores_injected_context
> tests/test_failure_memory.py::test_signature_empty_for_a_noop_edit
> tests/test_failure_memory.py::test_signature_is_stable_across_cosmetic_variation
> tests/test_failure_memory.py::test_skillopt_prompt_carries_the_constraints

5. Real end-to-end runs (cap-evolve run, mock optimizer, zero API cost)

Runner script (5 iterations, stall: 99, harmful mock edit so every candidate is genuinely rejected):

set -uo pipefail
REPO=/tmp/wt-129
export CAPEVOLVE_CORE="$REPO/core" PYTHONPATH="$REPO/core"
export CAPEVOLVE_SKILLS_DIR="$REPO/skills" CAPEVOLVE_TOY_DATA="$REPO/examples/toy_calc"
export CAPEVOLVE_MOCK_SCRIPT="/tmp/e2e/bad_script.json"
ALGO="$1"; D="/tmp/e2e/$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 -e "s/^algorithm_skill: .*/algorithm_skill: $ALGO/" \
    -e "s/^max_iterations: .*/max_iterations: 5/" \
    -e "s/^stall: .*/stall: 99/" \
    "$REPO/templates/project/capevolve.yaml" > "$D/.capevolve/project/capevolve.yaml"
/tmp/ce-venv/bin/python -m cap_evolve.cli run \
  --spec "$D/.capevolve/project/capevolve.yaml" \
  --project "$D/.capevolve/project" --run-ts "e2e" 2>&1 | tail -8

Mock edit script:

{
  "edits": [
    {"file": "prompt.txt", "op": "ensure_contains", "text": "\n[BAD-IDEA] Always reply with a short poem instead of the answer."}
  ]
}

hill-climb

rejected.jsonl (all 5 iterations — note the identical approach signature, i.e. the optimizer really did re-propose the dead end):

{"candidate_id": "cand_0001", "summary": "candidate cand_0001 (val 0.000, \u0394 +0.000)", "reason": "paired \u0394\u0304=+0.0000 <= 0 (SE=0 \u2192 STRICT fallback, warned; n=2)", "val": 0.0, "approach": "prompt.txt: +[BAD-IDEA] Always reply with a short poem instead of the answer."}
{"candidate_id": "cand_0002", "summary": "candidate cand_0002 (val 0.000, \u0394 +0.000)", "reason": "paired \u0394\u0304=+0.0000 <= 0 (SE=0 \u2192 STRICT fallback, warned; n=2)", "val": 0.0, "approach": "prompt.txt: +[BAD-IDEA] Always reply with a short poem instead of the answer."}
{"candidate_id": "cand_0003", "summary": "candidate cand_0003 (val 0.000, \u0394 +0.000)", "reason": "paired \u0394\u0304=+0.0000 <= 0 (SE=0 \u2192 STRICT fallback, warned; n=2)", "val": 0.0, "approach": "prompt.txt: +[BAD-IDEA] Always reply with a short poem instead of the answer."}
{"candidate_id": "cand_0004", "summary": "candidate cand_0004 (val 0.000, \u0394 +0.000)", "reason": "paired \u0394\u0304=+0.0000 <= 0 (SE=0 \u2192 STRICT fallback, warned; n=2)", "val": 0.0, "approach": "prompt.txt: +[BAD-IDEA] Always reply with a short poem instead of the answer."}
{"candidate_id": "cand_0005", "summary": "candidate cand_0005 (val 0.000, \u0394 +0.000)", "reason": "paired \u0394\u0304=+0.0000 <= 0 (SE=0 \u2192 STRICT fallback, warned; n=2)", "val": 0.0, "approach": "prompt.txt: +[BAD-IDEA] Always reply with a short poem instead of the answer."}

Constraint block in the LAST iteration's prompt (cand_0005/INSTRUCTIONS.md):

## ALREADY TRIED & REJECTED — do not re-propose these (framework, read-only)

The gate has rejected 1 distinct approach(es) on this run. Each row is the EXACT capability edit that failed and why:

- **cand_0001**, re-proposed 4x — `prompt.txt: +[BAD-IDEA] Always reply with a short poem instead of the answer.`
  - rejected because: paired Δ̄=+0.0000 <= 0 (SE=0 → STRICT fallback, warned; n=2)

**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.

gepa

rejected.jsonl (all 5 iterations — note the identical approach signature, i.e. the optimizer really did re-propose the dead end):

{"candidate_id": "gepa_0001", "summary": "candidate gepa_0001 (mb 0.000 vs parent 0.000)", "reason": "local minibatch gate: sum(child) <= sum(parent)", "val": 0.0, "approach": "prompt.txt: +[BAD-IDEA] Always reply with a short poem instead of the answer."}
{"candidate_id": "gepa_0002", "summary": "candidate gepa_0002 (mb 0.000 vs parent 0.000)", "reason": "local minibatch gate: sum(child) <= sum(parent)", "val": 0.0, "approach": "prompt.txt: +[BAD-IDEA] Always reply with a short poem instead of the answer."}
{"candidate_id": "gepa_0003", "summary": "candidate gepa_0003 (mb 0.000 vs parent 0.000)", "reason": "local minibatch gate: sum(child) <= sum(parent)", "val": 0.0, "approach": "prompt.txt: +[BAD-IDEA] Always reply with a short poem instead of the answer."}
{"candidate_id": "gepa_0004", "summary": "candidate gepa_0004 (mb 0.000 vs parent 0.000)", "reason": "local minibatch gate: sum(child) <= sum(parent)", "val": 0.0, "approach": "prompt.txt: +[BAD-IDEA] Always reply with a short poem instead of the answer."}
{"candidate_id": "gepa_0005", "summary": "candidate gepa_0005 (mb 0.000 vs parent 0.000)", "reason": "local minibatch gate: sum(child) <= sum(parent)", "val": 0.0, "approach": "prompt.txt: +[BAD-IDEA] Always reply with a short poem instead of the answer."}

Constraint block in the LAST iteration's prompt (gepa_0005/INSTRUCTIONS.md):

## ALREADY TRIED & REJECTED — do not re-propose these (framework, read-only)

The gate has rejected 1 distinct approach(es) on this run. Each row is the EXACT capability edit that failed and why:

- **gepa_0001**, re-proposed 4x — `prompt.txt: +[BAD-IDEA] Always reply with a short poem instead of the answer.`
  - rejected because: local minibatch gate: sum(child) <= sum(parent)

**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.

skillopt

rejected.jsonl (all 5 iterations — note the identical approach signature, i.e. the optimizer really did re-propose the dead end):

{"candidate_id": "so_e01s01", "summary": "candidate so_e01s01 (val 0.000, \u0394 +0.000)", "reason": "paired \u0394\u0304=+0.0000 <= 0 (SE=0 \u2192 STRICT fallback, warned; n=2)", "val": 0.0, "approach": "prompt.txt: +[BAD-IDEA] Always reply with a short poem instead of the answer."}
{"candidate_id": "so_e02s01", "summary": "candidate so_e02s01 (val 0.000, \u0394 +0.000)", "reason": "paired \u0394\u0304=+0.0000 <= 0 (SE=0 \u2192 STRICT fallback, warned; n=2)", "val": 0.0, "approach": "prompt.txt: +[BAD-IDEA] Always reply with a short poem instead of the answer."}
{"candidate_id": "so_e02_slow", "summary": "candidate so_e02_slow (val 0.000, \u0394 +0.000)", "reason": "paired \u0394\u0304=+0.0000 <= 0 (SE=0 \u2192 STRICT fallback, warned; n=2)", "val": 0.0, "approach": "prompt.txt: +[BAD-IDEA] Always reply with a short poem instead of the answer."}
{"candidate_id": "so_e03s01", "summary": "candidate so_e03s01 (val 0.000, \u0394 +0.000)", "reason": "paired \u0394\u0304=+0.0000 <= 0 (SE=0 \u2192 STRICT fallback, warned; n=2)", "val": 0.0, "approach": "prompt.txt: +[BAD-IDEA] Always reply with a short poem instead of the answer."}
{"candidate_id": "so_e03_slow", "summary": "candidate so_e03_slow (val 0.000, \u0394 +0.000)", "reason": "paired \u0394\u0304=+0.0000 <= 0 (SE=0 \u2192 STRICT fallback, warned; n=2)", "val": 0.0, "approach": "prompt.txt: +[BAD-IDEA] Always reply with a short poem instead of the answer."}

Constraint block in the LAST iteration's prompt (so_e03s01/INSTRUCTIONS.md):

## ALREADY TRIED & REJECTED — do not re-propose these (framework, read-only)

The gate has rejected 1 distinct approach(es) on this run. Each row is the EXACT capability edit that failed and why:

- **so_e01s01**, re-proposed 3x — `prompt.txt: +[BAD-IDEA] Always reply with a short poem instead of the answer.`
  - rejected because: paired Δ̄=+0.0000 <= 0 (SE=0 → STRICT fallback, warned; n=2)

**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.

6. No sealed-test / val leak, and prompt under the cap

Scan every assembled INSTRUCTIONS.md for split ids inside the constraint block, plus the sealed-test ids anywhere in the workdir markdown:

=== hill-climb ===
test=['a8', 'a7'] val=['a1', 'a4']  -> test/val ids in constraint block: 0
max INSTRUCTIONS.md: 21587 chars (cap 60000)
sealed-test ids anywhere in workdir *.md: 0
=== gepa ===
test=['a8', 'a7'] val=['a1', 'a4']  -> test/val ids in constraint block: 0
max INSTRUCTIONS.md: 23096 chars (cap 60000)
sealed-test ids anywhere in workdir *.md: 0
=== skillopt ===
test=['a8', 'a7'] val=['a1', 'a4']  -> test/val ids in constraint block: 0
max INSTRUCTIONS.md: 22999 chars (cap 60000)
sealed-test ids anywhere in workdir *.md: 0

7. Bound proven at 50 rejections

$ pytest core/tests/test_failure_memory.py::test_constraints_bounded_on_a_long_run -q -p no:randomly
.                                                                        [100%]
1 passed in 0.62s

# 50 rejections, each with a 5 KB signature and a 5 KB reason. Asserts:
#   exactly 12 rows injected, "showing the 12 most recent", newest kept (c049 in, c000 out),
#   total block < 8000 chars, and < MAX_INSTRUCTIONS_CHARS.

8. Drive-by root-cause fix: the four scratch lists now agree

The first GEPA e2e run produced this polluted signature — the real edit truncated off by FOCUS.md/REFLECTION.md boilerplate, because harness._CAP_DIFF_SKIP did not know about GEPA scratch:

"approach": "FOCUS.md: +# Component focus | FOCUS.md: +Edit ONLY the component(s) below this iteration (other files exist but are out of scope right now): | FOCUS.md: +- prompt.txt | FOCUS.md: +## All components in this capability | FOCUS.md: +- prompt.txt | REFLECTION.md: +# Reflective dataset (GEPA) | REFLE..."

After unifying on optimizer_context.SCRATCH_NAMES:

$ python -c "from cap_evolve import harness, gepa, cache, dashboard; ..."
harness._CAP_DIFF_SKIP   ['FOCUS.md', 'INSTRUCTIONS.md', 'JOURNAL.md', 'LEDGER.md', 'MEMORY.md', 'PROCESS.md', 'REFLECTION.md', 'REJECTED.md', 'RUNMAP.md', 'STATE.md']
gepa._NON_COMPONENT      ['AGENTS.md', 'CLAUDE.md', 'FOCUS.md', 'GEMINI.md', 'INSTRUCTIONS.md', 'JOURNAL.md', 'LEDGER.md', 'MEMORY.md', 'PROCESS.md', 'REFLECTION.md', 'REJECTED.md', 'RUNMAP.md', 'STATE.md']
cache._IGNORE_NAMES      ['AGENTS.md', 'CLAUDE.md', 'FOCUS.md', 'GEMINI.md', 'INSTRUCTIONS.md', 'JOURNAL.md', 'LEDGER.md', 'MEMORY.md', 'PROCESS.md', 'REFLECTION.md', 'REJECTED.md', 'RUNMAP.md', 'STATE.md']
dashboard._DIFF_SKIP     ['FOCUS.md', 'INSTRUCTIONS.md', 'JOURNAL.md', 'LEDGER.md', 'MEMORY.md', 'PROCESS.md', 'REFLECTION.md', 'REJECTED.md', 'RUNMAP.md', 'STATE.md']

(gepa/cache additionally carry the injected agent-instruction files CLAUDE.md/AGENTS.md/GEMINI.md, which is correct — those must not be components or bust the eval-cache hash.)

9. Commit author

Osher Elhadad <Osher.Elhadad@ibm.com>
feat(algorithm): re-inject rejected approaches as optimizer constraints (#129)

@OsherElhadad

Copy link
Copy Markdown
Collaborator Author

🔍 Review — PR #222

Verdict: CHANGES REQUESTED

The honesty correction is right and the split it names (advisory at the prompt / hard at the gate) is the correct framing — that half I'd merge today. But the injected content is wrong on the paths that matter most: for every real agent optimizer (claude-code, codex, gemini-cli, opencode, ibm-bob, cursor) the signature is filled with the framework's own injected skill files, the real edit is truncated off, and two genuinely different edits collapse to the same signature — so the block tells the optimizer "you already tried this, re-proposed 2x" about an approach it never tried. That is exactly the "false you already tried this" failure the issue is trying to avoid, and it is worse than shipping no constraint. Reproduced end-to-end below.

Blocking

B1 — _CAP_DIFF_SKIP omits INJECTED_NAMES/INJECTED_DIRS, so the signature is dominated by framework-injected read-context; distinct edits collapse.
core/cap_evolve/harness.py:588 sets _CAP_DIFF_SKIP = set(_oc.SCRATCH_NAMES), and harness.py:604 filters top in ("trajectories", "guidance", "prior_iterations") — a hardcoded 3-dir subset of the 9-entry INJECTED_DIRS, and it never filters INJECTED_NAMES at all. harness._inject_native_skills (harness.py:1224) writes CLAUDE.md + .claude/skills/<cap>/SKILL.md etc. into the workdir for every registry row with skills_dir/instructions_file. The parent is a snapshot (_SNAPSHOT_IGNORE does strip those), the child is the live workdir (it doesn't) — so _diff_capabilities(parent_dir, workdir) reports every injected skill file as an addition. Sorted-path order puts .claude/… first, and the 300-char cap then truncates the real edit away entirely.

Real run_step, zero-API mock edit, only difference from the PR's own e2e evidence being optimizer_name="claude-code" instead of mock (which has no skills_dir, which is why the PR's evidence looks clean):

workdir top-level: ['.claude', 'CLAUDE.md', 'INSTRUCTIONS.md', 'JOURNAL.md', 'LEDGER.md',
                    'PROCESS.md', 'RUNMAP.md', 'guidance', 'prior_iterations',
                    'prompt.txt', 'trajectories']

cand_0001 -> .claude/skills/diagnose/SKILL.md: +--- | .claude/skills/diagnose/SKILL.md: +name: diagnose | .claude/skills/diagnose/SKILL.md: +description: Extract the learning signal from execution traces — the textual analogue of a gradient. Use between evaluation and proposing edits. Reads a
   names the real [CALC] edit? False
cand_0002 -> (identical)
   names the real [CALC] edit? False

--- constraint block the NEXT prompt would carry ---
- **cand_0001**, re-proposed 2x — `.claude/skills/diagnose/SKILL.md: +--- | .claude/skills/diagnose/SKILL.md: +name: diagnose | ...`
  - rejected because: Δ=+1.0000 <= 1000000000.0000

And the collapse, with a realistic snapshot-vs-workdir pair and two different prompt edits:

parent snapshot contents: ['prompt.txt']
workdir contents        : ['.claude', 'CLAUDE.md', 'prompt.txt']
edit A signature (300 chars): .claude/skills/system-prompt/SKILL.md: +--- | ... real edit present? False
edit B signature (300 chars): .claude/skills/system-prompt/SKILL.md: +--- | ... real edit present? False
DO TWO DIFFERENT EDITS COLLAPSE TO THE SAME SIGNATURE? True

Consequence: on every non-mock optimizer the memory is not just useless, it is actively misdirecting — it dedupes unrelated approaches into one row and tells the optimizer it re-proposed something it never proposed. This also poisons RUNMAP.md/prior_iterations diffs (harness.py:836 uses the same _diff_capabilities) and the dashboard diff panel.
Fix: derive both skip sets from one place and cover all of it:

_CAP_DIFF_SKIP = set(_oc.SCRATCH_NAMES) | set(_oc.INJECTED_NAMES)
_CAP_DIFF_SKIP_DIRS = set(_oc.INJECTED_DIRS)

and in _capability_files (harness.py:604) test top in _CAP_DIFF_SKIP_DIRS instead of the 3-dir literal. Then add a test that runs run_step with optimizer_name="claude-code" and asserts the recorded approach names the real edited file — the current suite only exercises mock, which is precisely why 13/13 pass over a broken path.

B2 — signature truncation is head-first, so any edit to a large capability yields a signature that does not contain the edit.
harness.py:882-909: sig[:max_chars - 3] + "..." with _MAX_APPROACH_CHARS = 300 (harness.py:879). Independent of B1: sorted-path order plus a 300-char budget means the first touched file's first few added lines are all that survive.

### PROBE C - DISTINCTNESS: different edits sharing a long prefix
len sig1 300 len sig2 300 cap 300
COLLAPSED (identical despite different edits)? True
sig1 tail: ' budget | prompt.txt: +RULE 3: fairly long boilerplate rule line he...'
sig2 tail: ' budget | prompt.txt: +RULE 3: fairly long boilerplate rule line he...'

Consequence: the dedupe key is not a function of the edit for realistic capabilities (a SKILL.md, a system prompt of any length). Same false-positive as B1.
Fix: make the signature order-and-size stable rather than prefix-truncated. Minimal version: keep the per-file summary (path: +N/-M lines) plus a short hash of the full normalized diff body, and only inline verbatim text when the whole thing fits. That gives you both properties the block needs — stable under cosmetic variation, and distinct whenever the bytes differ:

body = " | ".join(parts)
if len(body) <= max_chars:
    return body
digest = hashlib.sha256(body.encode()).hexdigest()[:12]
return body[:max_chars - 20].rsplit(" | ", 1)[0] + f" … [{digest}]"

B3 — eviction is by first appearance, not recency, so a just-re-proposed dead end can be the one that gets evicted.
harness.py:957: rows = list(seen.values())[-limit:]. seen is a plain dict keyed by approach, so a repeat updates count in place (harness.py:947-949) and keeps its original insertion position. The docstring at harness.py:925-935 explicitly promises "only the limit MOST RECENT distinct approaches are injected."

last rejection in the file was OLD IDEA (just re-proposed).
Is OLD IDEA in the injected block? False
rows: 12
['- **c003** — `prompt.txt: +IDEA 3`', ... '- **c014** — `prompt.txt: +IDEA 14`']
counted repeat for OLD IDEA? False

Consequence: the single most predictive row — the approach the optimizer just re-emitted — is the one dropped, on exactly the runs (>12 distinct dead ends) where the block matters. Also silently invalidates the "12 most recent" wording the block prints to the optimizer.
Fix: move a repeated key to the end. One line inside the if approach in seen: branch:

seen[approach]["count"] += 1
seen[approach] = seen.pop(approach)   # re-proposed => most recent
continue

test_constraints_bounded_on_a_long_run uses 50 distinct approaches so it cannot catch this; add a repeat case.

B4 — the "one definition, four consumers" claim is false: a fifth copy is untouched, and harness._SNAPSHOT_IGNORE is not derived from it.
optimizer_context.py:53-62 says "cache._IGNORE_NAMES, gepa._NON_COMPONENT, harness._CAP_DIFF_SKIP and dashboard._DIFF_SKIP each had their OWN copy … One definition, four consumers." But skillopt._changed_components (core/cap_evolve/skillopt.py:412-415) still hardcodes the pre-drift seven-name list and is not updated — it is missing FOCUS.md/REFLECTION.md, the exact drift this PR says it fixed:

if rel.name in ("INSTRUCTIONS.md", "MEMORY.md", "STATE.md",
                "LEDGER.md", "JOURNAL.md", "PROCESS.md", "RUNMAP.md"):

Consequence: SkillOpt's applied-edit-budget count still inflates by GEPA-style scratch when present, and the header comment now misdescribes the code — which is the failure mode this whole change exists to correct.
Fix: _changed_components should use set(oc.SCRATCH_NAMES) | set(oc.INJECTED_NAMES) and skip INJECTED_DIRS. Either that, or delete the "four consumers" sentence — but the drift is real, so fix it.

Non-blocking

N1 — harness.py:588 / optimizer_context.py:61 — the unification does not fix #110, which is what #211 is for. SCRATCH_NAMES here is only ever read by non-destructive filters; the destructive _SNAPSHOT_IGNORE (harness.py:1708) is left as INJECTED_* + ("LEDGER.md","JOURNAL.md","RUNMAP.md"), so GEPA snapshots still carry FOCUS.md/REFLECTION.md:

211 SCRATCH <= 222 _SNAPSHOT_IGNORE? False missing= ['FOCUS.md', 'REFLECTION.md']
/tmp/rv222-good/.../candidates/gepa_0001/: FOCUS.md INSTRUCTIONS.md PROCESS.md REFLECTION.md prompt.txt

Not this PR's job, but the comment reads as if the drift is fully resolved. See the SCRATCH_NAMES section.

N2 — harness.py:940-946: the reason kept is the FIRST rejection's, but the cid shown is also the first, while the count reflects the latest. Documented ("keeping the FIRST rejection reason per approach") and defensible, but the row reads **cand_0001**, re-proposed 5x — a reader may take cand_0001 as the latest repeat. Consider cand_0001 (latest cand_0006).

N3 — control characters survive into approach. Confirmed:

### PROBE E - unicode / control chars / ANSI in the edit
'prompt.txt: +\x1b[31mRED\x07 ‮RTL emoji 😀'
ESC present in signature? True | BEL: True | RTL-override: True

Not exploitable today on this branch: approach reaches no terminal writer (grep -c approach core/cap_evolve/dashboard.py0, no eventstream.py on this base) and the SPA renders it as a React text child in Insights.tsx:74-78 (auto-escaped, no dangerouslySetInnerHTML), passing through dashboard.redact() via dashboard/backend/capevolve_dashboard/memory.py:30. It becomes live the moment #191's format_event or #220's replay touches rejected.jsonl. Cheapest durable fix: " ".join(line[1:].split()) at harness.py:904 already collapses whitespace — add .translate({c: None for c in range(32)}) there and it's closed for every consumer forever.

N4 — no frontend test for the new approaches field. dashboard/frontend/src/lib/insights.ts:38-43 adds dedupe + a ≤3 cap inside deadEnds, and src/test/insights.test.ts (which already tests deadEnds) is not extended. CI never runs vitest (#207), so this is untested in both directions. Three lines in the existing describe('normalizeReason + deadEnds').

N5 — approach_signature reads the whole capability twice per rejection via _diff_capabilities_capability_filesrglob("*") + read_text, and run_step already computed nothing reusable. Fine at toy_calc scale; on a real repo-sized capability it's a second full tree read per rejected iteration. Note it, don't fix it.

Nits

  • harness.py:1023: from .optimizer_context import cap_instructions is a function-body import while harness.py already has from . import optimizer_context as _oc at module level (and _oc.SCRATCH_NAMES is used at import time at line 588, so the module is definitively loaded). Use _oc.cap_instructions(...).
  • harness.py:879: the comment -> block <~ 5 KB is right but understates the real bound; measured 7123 chars at 200 rejections. Say <8 KB.
  • optimizer_context.py:257: cap_instructions' docstring says "keeping the head and tail" — worth adding that the tail slice is 30% and the constraint block lives in it, which is why it survives (see the composed-prompt section).

Is the new honesty wording precise?

Old (RUN.md:77, one line):

  • Rejected approaches are remembered and never re-proposed.

New (RUN.md:77-82):

  • Rejected approaches are remembered (rejected.jsonl) and re-injected into every later proposal prompt as an explicit "already tried & rejected — do not re-propose" constraint block, carrying the exact edit signature and why the gate killed it. Enforcement is advisory at the prompt: the optimizer is a black-box agent CLI, so cap-evolve cannot forbid it from re-emitting an edit. What is hard is the gate — a re-proposed dead end is still rejected on val, and the repeat is counted in the constraint block.

Verdict: precise, and the strongest part of this PR. It makes exactly the claim the system can support and no more. Specifically:

  • It names the mechanism (rejected.jsonl → constraint block) rather than an outcome, so it is checkable.
  • It says "cannot forbid it from re-emitting an edit" — literally true; verified below that the mock re-emits an identical edit 5× with the block present and nothing stops it.
  • It splits advisory-vs-hard correctly. The val gate genuinely does reject the repeat (all 6 rows in every e2e rejected.jsonl below), and the repeat genuinely is counted (re-proposed 5x).
  • It does not say "and the optimizer therefore stops" — the one overclaim available here, correctly avoided.

Two things stop it from being airtight, both mechanical rather than rhetorical:

  1. "every later proposal prompt" is true only when the edit produced a signature. harness.py:952 skips empty approach, and (per B1/B2) a real agent optimizer's signature often does not contain the edit at all — so what is re-injected is not "the exact edit signature". Fixing B1/B2 makes the sentence true as written; until then the wording is honest but the code doesn't meet it.
  2. "the repeat is counted in the constraint block" is true only for rows that survive eviction; per B3 a re-proposed approach can be evicted while newer one-offs are kept. Fixing B3 closes it.

docs/COMPARISON.md:38-40 compresses the same split to "(advisory at the prompt, hard at the val gate)" with a link to RUN.md — correct compression, no overclaim.

The four claims #212 added are all corrected, and correctly. A scan of the merged baseline (main + #199 + #212) finds five stale statements; the branch clears all of them:

$ cd /tmp/rv-base && grep -rn "write-only|never reach a prompt|NOT fed back|audit/UI records|only consumer" core/ skills/ docs/ RUN.md README.md | grep -v ^core/tests
core/cap_evolve/memory.py:3:   These are **audit/UI records, not optimizer input.**
core/cap_evolve/memory.py:10:  Their only consumer is the dashboard
core/cap_evolve/memory.py:15:  They are NOT fed back into any proposal prompt
core/cap_evolve/skillopt.py:37: the write-only rejected/history audit jsonl
skills/algorithms/skillopt/SKILL.md:29: write-only, they never reach a prompt
skills/algorithms/skillopt/references/concepts.md:39: (write-only; the only reject signal

$ cd /tmp/rv-222 && grep -rn "write-only|never reach a prompt|NOT fed back|audit/UI records|never re-proposed|nothing re-reads" core/ skills/ docs/ RUN.md README.md dashboard/frontend/src | grep -v ^core/tests
(no output)

And crucially the new memory.py:8-11 text does not contradict #212's correct finding that rejected.jsonl has a live dashboard reader — it says "The dashboard reads both … rejected.jsonl additionally feeds …", which is the accurate superset. memory.py:19-20 keeps #212's real invariant ("_augment_instructions is the only function whose output reaches the optimizer") intact.

Signature correctness

Stable: yes. Distinct: no. The PR's stability claim holds; the distinctness property it silently depends on does not.

Stability (all pass):

### PROBE A - stability across cosmetic variation
distinct sigs: 1
   'prompt.txt: +BE TERSE'
### PROBE B - same text, DIFFERENT PLACEMENT in the file
s1: 'prompt.txt: +BE TERSE'   s2: 'prompt.txt: +BE TERSE'   SAME? True
### PROBE D - different FILE, same text
'a.txt: +BE TERSE' / 'b.txt: +BE TERSE'  SAME? False      <- correct
### PROBE F - same basename, different dir
'src/prompt.txt: +BE TERSE' / 'prompt.txt: +BE TERSE'  SAME? False   <- correct

Distinctness fails two independent ways — B2 (300-char head truncation, PROBE C above) and B1 (injected read-context dominates sorted order, so all edits on a real agent optimizer share one signature). Both produce the same user-visible defect: the block asserts re-proposed Nx about approaches that were never proposed. Given the issue's own framing ("permanent search pruning"), a false pin is strictly worse than an empty block, so these are blocking rather than nits.

Bound holds comfortably, and beats the author's claim:

MAX_INSTRUCTIONS_CHARS = 60000
200 rejections, 12KB sigs, 9KB reasons -> block chars: 7123
rows: 12
author claim (<8000)? True
under MAX? True
newest kept? True | oldest dropped? True

Repeat count and gate reason are accurate against the run record. GEPA e2e: 6 gepa_local_gate events, 6 rows in rejected.jsonl, 1 distinct approach, and the block prints re-proposed 5x (i.e. 6 records − 1 first occurrence — the natural reading, and consistent across all three algorithms). Reasons are the verbatim gate strings (local minibatch gate: sum(child) <= sum(parent), paired Δ̄=+0.0000 <= 0 (SE=0 → STRICT fallback, warned; n=2)).

All five rejection sites record approach — verified by grep, not by trust:

core/cap_evolve/harness.py:1473  run_step val gate            approach_signature(parent_dir, workdir)
core/cap_evolve/gepa.py:647      GEPA local minibatch gate    approach_signature(parent_dir, workdir)
core/cap_evolve/gepa.py:688      GEPA full-val gate           approach_signature(parent_dir, workdir)
core/cap_evolve/gepa.py:820      GEPA merge local gate        approach_signature(anc_dir, workdir)
core/cap_evolve/gepa.py:847      GEPA merge val gate          approach_signature(anc_dir, workdir)

No missed site. The merge gates correctly diff against the common ancestor (gepa.py:795) rather than a parent, which is the right baseline for a recombination — the recorded signature is the merged delta, which is what you'd want to constrain against.

Three competing SCRATCH_NAMES unifications

They do not conflict semantically but they do conflict textually, and #222's is the weakest of the three.

location tiering destructive consumer
#211 rundir.SCRATCH_NAMES / LEGACY_SCRATCH_NAMES / NON_CAPABILITY_NAMES yes — split by operation _SNAPSHOT_IGNORE = live-writer subset only, root-anchored
#219 cache.py only, folds in INJECTED_* no untouched
#222 optimizer_context.SCRATCH_NAMES (single flat 10-tuple) no untouched

#211 is correct and is the one to keep. It is at the bottom of the import graph (rundir.py, which optimizer_context already imports), it splits by operation, and it root-anchors RunDir.snapshot so src/prompts/STATE.md can no longer be silently deleted from a candidate and every descendant while the cache key stays stable — a real data-loss + stale-hit pair a prior review caught.

#222 does NOT reintroduce #211's data-loss hazard. Verified directly:

222 leaks LEGACY into destructive snapshot? NO
harness._SNAPSHOT_IGNORE = ['.agents','.bob','.claude','.cursor','.gemini','.opencode',
                            'AGENTS.md','CLAUDE.md','GEMINI.md','JOURNAL.md','LEDGER.md',
                            'RUNMAP.md','guidance','prior_iterations','trajectories']

SCRATCH_NAMES is read by four read-side filters only (_CAP_DIFF_SKIP, _DIFF_SKIP, _NON_COMPONENT, _IGNORE_NAMES); the destructive filter is untouched. Safe — but only by not doing the thing #211 does. And #222's flat tuple would become dangerous the instant someone "completes the unification" by wiring it into _SNAPSHOT_IGNORE, because it contains all three retired LEGACY names with no marking. #211's two-tier structure exists precisely to make that mistake impossible; #222 removes the guardrail while adding a same-named symbol in a different module.

#211's own invariant tests already pass against #222's sets (they assert superset, not equality):

cache            211-union satisfied? True  missing=[]
gepa             211-union satisfied? True  missing=[]
dashboard        211-union satisfied? True  missing=[]
harness_capdiff  211-union satisfied? True  missing=[]

Single recommended end state:

  1. Land fix(algorithm): one shared scratch-file list so GEPA snapshots are clean (#110) #211 as the sole definition: rundir.SCRATCH_NAMES (live-writer) / LEGACY_SCRATCH_NAMES (retired, read-side only) / NON_CAPABILITY_NAMES (union), plus its root-anchored snapshot.
  2. Delete optimizer_context.SCRATCH_NAMES from feat(algorithm): re-inject rejected approaches as optimizer constraints (#129) #222. Point harness._CAP_DIFF_SKIP and dashboard._DIFF_SKIP at rundir.NON_CAPABILITY_NAMES | set(INJECTED_NAMES) (the INJECTED_NAMES half is B1's fix and belongs in the union for every read-side filter).
  3. Drop Durable synthesized priors (INSIGHTS.md) fed to every proposal, all three algorithms #219's cache.py hunkrefactor(core): drop dead optimizer-memory API + unused params; fix misleading cache docstring #212 already ships the identical change, so Durable synthesized priors (INSIGHTS.md) fed to every proposal, all three algorithms #219's copy is a pure conflict with no content.
  4. Add skillopt._changed_components (B4) as the fifth read-side consumer and cover it in fix(algorithm): one shared scratch-file list so GEPA snapshots are clean (#110) #211's superset test.
  5. Keep INJECTED_DIRS/INJECTED_NAMES in optimizer_context (correct home — inject() writes them) and have rundir stay unaware of them; the read-side filters compose the two.

That's one definition per operation, five consumers, one destructive filter, no third symbol.

Composed prompt with #219

I built the composed tree by hand (#199 + #212 + #222 + #219); it does not merge cleanly — 4 conflicts across harness.py (2) and dashboard.py (1), plus a signature mismatch:

Capped: yes, comfortably. With both blocks live, real e2e prompts are nowhere near the ceiling and cap_instructions fires end-to-end when forced:

composed hill-climb, last iter: workdir=cand_0006  chars=22345   (cap 60000)
branch-only: hill-climb 21587 / gepa 23096 / skillopt 22111

Truncation behaviour when the base prompt alone overflows — the constraint block sits in the tail slice, so it survives whole rather than being half-cut:

composed len: 59905 <= MAX? True
constraint HEADER survived? True
constraint FOOTER survived? True
rows surviving: 12 of 12
elision notice present? True
'HHHH…\n\n... [7225 chars elided to keep this prompt under 60000 chars — the full record is in the run dir] ...\n\nHHHH…'

The 70/30 head/tail split in cap_instructions (optimizer_context.py:262-267) means the block is protected as long as it is under 30% of the cap (18 KB) — it measures 7 KB at 200 rejections, so there is real headroom. #219's INSIGHTS.md is a file in the workdir, not appended text (only its pointer paragraph lands in the prompt), so the two do not compete for the same 18 KB tail. Good design; worth an assertion.

Coherent: mostly, with one genuine redundancy. The two blocks read as complementary rather than duplicative — #219's ## What HURT (gate-rejected, largest movers first) lists candidate ids + val Δ + which tasks broke, and #222's block lists the edit signature + gate reason + repeat count. Different axes; neither is derivable from the other. But on the composed run they narrate the same six rejections twice with no cross-reference:

--- INSIGHTS.md What HURT section ---
- iter 5 `cand_0005` val Δ +0.000 (paired Δ̄=+0.0000 <= 0 (SE=0 → STRICT fallback, warned; n=2))
- iter 4 `cand_0004` val Δ +0.000 (…)   [× 5 rows, one per rejection]

--- #222 block ---
- **cand_0001**, re-proposed 5x — `…`
  - rejected because: paired Δ̄=+0.0000 <= 0 (SE=0 → STRICT fallback, warned; n=2)

The gate reason string is verbatim-duplicated per row, and #219 lists each repeat separately where #222 correctly collapses them — so the optimizer sees "5 separate things hurt" next to "1 distinct approach, re-proposed 5x". Whichever merges second should add a one-line pointer (INSIGHTS.md's HURT section → "see the ALREADY TRIED & REJECTED block for the exact edits"), and #219's HURT rows should dedupe on approach once it exists.

And the composed tree reproduces B1 in a second form, which is independent evidence that the skip list is the root cause: INSIGHTS.md is written into the workdir but is absent from #222's SCRATCH_NAMES, so it becomes the signature:

$ pytest core/tests/test_failure_memory.py::test_a_real_rejection_records_its_approach_signature
E  AssertionError: signature does not name the edited file: INSIGHTS.md: +# INSIGHTS — durable
   priors carried across iterations (framework-synthesized) | INSIGHTS.md: +A compact, …
2 failed, 218 passed

The composed e2e block shows the same thing reaching the prompt verbatim. Fixing B1 properly (derive from one union, add each new framework-written file there) makes this a non-event; resolving the conflict by adding "INSIGHTS.md" to a hardcoded literal just re-creates the drift.

Merge-order note

  1. fix(algorithm): give GEPA & SkillOpt the same optimizer context as hill-climb, un-gate the CLI flags #199 (hub) → refactor(core): drop dead optimizer-memory API + unused params; fix misleading cache docstring #212 (drops the dead API; supplies the _augment_instructions signature everything else must target).
  2. fix(algorithm): one shared scratch-file list so GEPA snapshots are clean (#110) #211 — before feat(algorithm): re-inject rejected approaches as optimizer constraints (#129) #222. It owns the name-set end state and the destructive/read-side split; letting feat(algorithm): re-inject rejected approaches as optimizer constraints (#129) #222 land its own SCRATCH_NAMES first means fix(algorithm): one shared scratch-file list so GEPA snapshots are clean (#110) #211 arrives into a conflicting third symbol and someone resolves it by deleting the tier structure.
  3. feat(algorithm): re-inject rejected approaches as optimizer constraints (#129) #222 — rebased on fix(algorithm): one shared scratch-file list so GEPA snapshots are clean (#110) #211, with optimizer_context.SCRATCH_NAMES deleted in favour of rundir.NON_CAPABILITY_NAMES | INJECTED_NAMES, and B1–B4 fixed.
  4. Durable synthesized priors (INSIGHTS.md) fed to every proposal, all three algorithms #219 last, rebased on refactor(core): drop dead optimizer-memory API + unused params; fix misleading cache docstring #212 (restore the dropped params) + feat(algorithm): re-inject rejected approaches as optimizer constraints (#129) #222 (INSIGHTS.md added to the shared union, not to a literal; HURT rows deduped; cross-pointer added).

#204/#218 are frontend-only and don't touch insights.ts/Insights.tsx/MemoryPanel.tsx/types.ts, so no conflict there. No dist/ in this PR (git diff --name-only 871567d..da9ad44 | grep dist → empty; the 7 tracked dist/ files predate it on main).

Verification I re-ran

Suite, branch — matches the claimed 213:

$ cd /tmp/rv-222 && PYTHONPATH=/tmp/rv-222/core pytest core/tests -q -p no:randomly
213 passed in 79.80s (0:01:19)

Merged-tree baseline at 871567d (main + #199 + #212) — matches the claimed 200:

$ cd /tmp/rv-base && PYTHONPATH=/tmp/rv-base/core pytest core/tests -q -p no:randomly
200 passed in 78.71s (0:01:18)

compileall:

$ /tmp/ce-venv/bin/python -m compileall -q core skills
compileall exit=0

Fail-before, by reverting only the implementation files to the merge base and keeping the new test file:

$ for f in harness memory optimizer_context gepa cache dashboard skillopt; do git checkout 871567d -- core/cap_evolve/$f.py; done
$ PYTHONPATH=core pytest core/tests/test_failure_memory.py -q -p no:randomly
13 failed in 2.36s
E  AttributeError: module 'cap_evolve.harness' has no attribute 'dead_end_constraints'

13/13 reproduced. (Note git stash push -- <impl> reported No local changes to save on a clean worktree and the tests then passed — the PR's stash-based recipe as written doesn't reproduce; the checkout form does.)

Orchestration-mode flake claim: VERIFIED, and the diagnosis is exactly right. It is an editable-install artifact, present on both sides, and it disappears with PYTHONPATH:

$ /tmp/ce-venv/bin/python -c "import cap_evolve; print(cap_evolve.__file__)"
/Users/osherelhadad/Documents/capevo/cap-evolve/core/cap_evolve/__init__.py   <- the MAIN checkout

$ cd /tmp/rv-222 && pytest core/tests/test_orchestration_mode.py -q          # no PYTHONPATH
FAILED test_deterministic_mode_still_runs_full_pipeline
  ModuleNotFoundError: No module named 'cap_evolve.optimizer_context'
$ cd /tmp/rv-base && pytest core/tests/test_orchestration_mode.py -q          # no PYTHONPATH
FAILED test_deterministic_mode_still_runs_full_pipeline   <- SAME on the baseline
$ cd /tmp/rv-222 && PYTHONPATH=/tmp/rv-222/core pytest core/tests/test_orchestration_mode.py core/tests/test_dashboard_launch.py -q
10 passed in 3.76s

Not random-ordering-dependent (pytest-randomly isn't installed in /tmp/ce-venv, so -p no:randomly is a no-op here) — it's purely whether the subprocess inherits PYTHONPATH. Unrelated to this PR. test_dashboard_launch.py (#200) passed 7/7 for me.

E2E, real cap-evolve run, mock optimizer, zero API, 6 iterations, harmful edit — all three algorithms produce 6 rejections with one distinct signature and the block reaches the last prompt:

hill-climb  cand_0001..0006  approach="prompt.txt: +[BAD-IDEA] Always reply with a short poem…"  block: re-proposed 5x   21587 chars
gepa        gepa_0001..0006  reason="local minibatch gate: sum(child) <= sum(parent)"            block: re-proposed 5x   23096 chars
skillopt    so_e01s01…       (incl. so_e02_slow, so_e03_slow)                                    block: re-proposed 5x   22111 chars

Baseline reproduction of the #1 claim — the same run on 871567d: 6 identical rejections, approach field absent, and no prompt mentions it:

$ cut -c1-160 rejected.jsonl
{"candidate_id": "cand_0001", …, "val": 0.0}      <- no "approach"
… × 6
$ grep -c "BAD-IDEA" work/*/INSTRUCTIONS.md
work/cand_0001/INSTRUCTIONS.md:0 … work/cand_0006/INSTRUCTIONS.md:0   (all six)
$ grep -l "ALREADY TRIED" work/*/INSTRUCTIONS.md
(none)

Both halves of the old RUN.md:77 claim were false, exactly as stated.

GEPA capability-diff bug: real, and fixed for the read-side filters. On the baseline, a GEPA candidate's capability diff leads with its own reflective scratch:

$ baseline harness._CAP_DIFF_SKIP: ['INSTRUCTIONS.md','JOURNAL.md','LEDGER.md','MEMORY.md','PROCESS.md','RUNMAP.md','STATE.md']
--- diff (first 700 chars) ---
--- a/FOCUS.md
+++ b/FOCUS.md
+# Component focus
+Edit ONLY the component(s) below this iteration …
--- a/REFLECTION.md
+++ b/REFLECTION.md
+# Reflective dataset (GEPA)
+Parent minibatch reward: 0.000 (0/4 sampled tasks pass) …

On the branch all four read-side filters agree and the GEPA e2e signature is clean (prompt.txt: +[BAD-IDEA] …). Fixed in harness._CAP_DIFF_SKIP and dashboard._DIFF_SKIP, i.e. both places the diff is displayed. Not fixed in skillopt._changed_components (B4) or the destructive snapshot (N1).

Frontend — npm ci, tsc -b, eslint, vitest all clean:

$ npx tsc -b            → tsc rc=0
$ npx eslint src/components/MemoryPanel.tsx src/components/Insights.tsx src/lib/insights.ts src/lib/types.ts
                        → eslint rc=0 (no output)
$ npm test              → Test Files 13 passed (13) | Tests 45 passed (45)
$ git diff --name-only 871567d..da9ad44 | grep dist
                        → NO dist/ in this PR's diff

No new frontend test for the approaches field (N4).

Leak scan — no split id reaches the block; .claude/CLAUDE.md/AGENTS.md do (B1):

### PROBE G - is INJECTED read-context excluded from the signature?
  leaks CLAUDE.md? True   leaks AGENTS.md? True   leaks .claude? True

test_no_val_or_test_ground_truth_in_the_constraint_block passes and I confirmed it independently on all three e2e runs — the split-id half is genuinely clean.

Commit authorship is correct: Osher Elhadad <Osher.Elhadad@ibm.com>, single author across the range.

… 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).
OsherElhadad pushed a commit that referenced this pull request Jul 30, 2026
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.
Copilot AI review requested due to automatic review settings July 30, 2026 02:33

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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

@OsherElhadad

Copy link
Copy Markdown
Collaborator Author

🔧 Review fixes

All 4 blocking, 5 non-blocking and 3 nits addressed. Rebased onto #211 and its SCRATCH_NAMES end state adopted verbatim; optimizer_context.SCRATCH_NAMES is deleted. Every blocker reproduces fail-before / passes-after in isolation, and the two signature probes are re-run with a non-mock optimizer as requested.

Branch: b8dbed3 (merge #211 into the #199+#212 base) → 6d6cc52 (the fixes).


The verdict's core claim, confirmed and fixed

The reviewer was right that the block was actively misdirecting on every real optimizer, and right about the root cause. The asymmetry that makes B1 bite is worth stating plainly because it explains B1 and B4 at once: a capability diff compares a SNAPSHOT parent (already stripped by _SNAPSHOT_IGNORE) against the LIVE workdir (not stripped). So any injected file missing from the read-side filter reads as a capability addition on every single iteration — and sorted-path order puts .claude/… first, so the 300-char budget then truncated the real edit away entirely.

B1 evidence — real run_step, optimizer_name="claude-code", zero API

### PROBE B1 — real run_step, optimizer_name='claude-code' (2 iterations)
workdir top-level: ['.claude', 'CLAUDE.md', 'INSTRUCTIONS.md', 'JOURNAL.md', 'LEDGER.md', 'PROCESS.md', 'RUNMAP.md', 'guidance', 'prompt.txt', 'trajectories']

cand_0001 -> prompt.txt: +[CALC] Compute the arithmetic expression exactly and output ONLY the resulting number.
   names the real [CALC] edit? True | names prompt.txt? True
   leaks CLAUDE.md? False  leaks .claude? False  leaks SKILL.md? False  leaks AGENTS.md? False

cand_0002 -> prompt.txt: +[CALC] Compute the arithmetic expression exactly and output ONLY the resulting number.
   names the real [CALC] edit? True | names prompt.txt? True
   leaks CLAUDE.md? False  leaks .claude? False  leaks SKILL.md? False  leaks AGENTS.md? False

--- constraint block the NEXT prompt would carry ---
## ALREADY TRIED & REJECTED — do not re-propose these (framework, read-only)

The gate has rejected 1 distinct approach(es) on this run. Each row is the EXACT capability edit that failed and why:

- **cand_0001**, re-proposed 2x (latest cand_0002) — `prompt.txt: +[CALC] Compute the arithmetic expression exactly and output ONLY the resulting number.`
  - rejected because: Δ=+1.0000 <= 1000000000.0000

**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.

split ids (test+val) leaked into block: NONE
max INSTRUCTIONS.md: 21763 chars (cap 60000)

Compare against the reviewer's reproduction on the old code, which produced .claude/skills/diagnose/SKILL.md: +--- | … and names the real [CALC] edit? False.

And the reviewer's PROBE G (snapshot-vs-workdir pair, the exact shape that broke):

### PROBE G — snapshot-vs-workdir pair, is injected read-context excluded?
snapshot: ['prompt.txt'] -> live: ['.claude', 'CLAUDE.md', 'prompt.txt']
signature: 'prompt.txt: +BE TERSE'
leaks CLAUDE.md? False  leaks .claude? False  leaks AGENTS.md? False  real edit present? True

Fix: both sets derived, never enumerated, in all five read-side consumers:

_CAP_DIFF_SKIP      = set(NON_CAPABILITY_NAMES) | set(_oc.INJECTED_NAMES)
_CAP_DIFF_SKIP_DIRS = set(_oc.INJECTED_DIRS)          # was a hardcoded 3-of-9 literal

The hardcoded ("trajectories", "guidance", "prior_iterations") literal in _capability_files is gone; same in dashboard._read_dir_files.

B2 evidence — reviewer's PROBE C now prints COLLAPSED? False

### PROBE C — DISTINCTNESS: two different edits sharing a long prefix
len sig1 300 len sig2 300 cap 300
COLLAPSED? False
sig1 tail: 'te rule line here | prompt.txt: +RULE 4 … [+975 chars, sha 03a1a44b042c]'
sig2 tail: 'te rule line here | prompt.txt: +RULE 4 … [+975 chars, sha 815c4e0c8ea1]'

Fix: on overflow, keep the readable head and close it with a sha256 digest of the whole normalized body — so the key stays stable under cosmetic variation (PROBE A/B/D/F unchanged) and is distinct whenever the bytes differ. _diff_capabilities is also called with a 200 KB budget from here, since its display default (8 KB) would otherwise silently cap what the digest covers.

B3 evidence — the just-re-proposed dead end is kept

$ pytest core/tests/test_failure_memory.py::test_a_re_proposed_dead_end_is_never_the_one_evicted -q
1 passed
# 1 old idea + 14 distinct one-offs + the old idea re-proposed as c099. Asserts:
#   "THE OLD IDEA" IS in the block, "re-proposed 2x", "(latest c099)", exactly 12 rows,
#   and it displaced the OLDEST surviving one-off (IDEA 1), not a newer one.

Fix: seen.pop(approach) then reinsert — a repeat requeues its row, so recency means last-seen. The docstring now states that explicitly, and the block prints (latest <cid>) (also closes N2).

B4 evidence — one definition, five consumers, proven not asserted

rundir.SCRATCH_NAMES         ['FOCUS.md', 'JOURNAL.md', 'LEDGER.md', 'REFLECTION.md', 'RUNMAP.md']
rundir.LEGACY_SCRATCH_NAMES  ['MEMORY.md', 'REJECTED.md', 'STATE.md']
rundir.NON_CAPABILITY_NAMES  ['FOCUS.md', 'INSTRUCTIONS.md', 'JOURNAL.md', 'LEDGER.md', 'MEMORY.md', 'PROCESS.md', 'REFLECTION.md', 'REJECTED.md', 'RUNMAP.md', 'STATE.md']
optimizer_context has SCRATCH_NAMES?  False      <- deleted

cache._IGNORE_NAMES         superset of union? True  dirs superset? True  missing=[] []
gepa._NON_COMPONENT         superset of union? True  dirs superset? True  missing=[] []
dashboard._DIFF_SKIP        superset of union? True  dirs superset? True  missing=[] []
harness._CAP_DIFF_SKIP      superset of union? True  dirs superset? True  missing=[] []
skillopt._SCAFFOLDING       superset of union? True  dirs superset? True  missing=[] []

harness._SNAPSHOT_IGNORE (DESTRUCTIVE): ['.agents', '.bob', '.claude', '.cursor', '.gemini', '.opencode', 'AGENTS.md', 'CLAUDE.md', 'FOCUS.md', 'GEMINI.md', 'JOURNAL.md', 'LEDGER.md', 'REFLECTION.md', 'RUNMAP.md', 'guidance', 'prior_iterations', 'trajectories']
  leaks any LEGACY name? NO

skillopt._changed_components also compares snapshot-vs-workdir, so it got the INJECTED_* half too — otherwise every injected CLAUDE.md/.claude/skills file counts as an applied edit and the requested-vs-applied budget log is nonsense.

Fail-before, each blocker reverted in isolation

### FAIL-BEFORE B1 (drop INJECTED_NAMES, restore the hardcoded 3-of-9 dir subset):
FAILED test_failure_memory.py::test_signature_names_the_real_edit_under_a_native_skills_optimizer
FAILED test_gepa.py::test_scratch_ignores_are_one_shared_definition
2 failed, 25 passed in 39.51s
E  AssertionError: signature does not carry the real edit: .claude/skills/diagnose/SKILL.md: +--- |
   .claude/skills/diagnose/SKILL.md: +name: diagnose | … [+64089 chars, sha 86660cc851a6]

### FAIL-BEFORE B2 (restore head-first truncation):
FAILED test_failure_memory.py::test_signature_is_distinct_for_edits_sharing_a_long_prefix
1 failed, 26 passed in 39.33s
E  AssertionError: different edits collapsed to one signature

### FAIL-BEFORE B3 (restore first-appearance eviction):
FAILED test_failure_memory.py::test_a_re_proposed_dead_end_is_never_the_one_evicted
1 failed, 26 passed in 39.44s
E  AssertionError: the just-re-proposed dead end was evicted

### FAIL-BEFORE B4 (restore skillopt's pre-drift 5th copy):
FAILED test_gepa.py::test_scratch_ignores_are_one_shared_definition
E  'REJECTED.md'   (missing from skillopt._SCAFFOLDING)

### restored, both files:
27 passed in 39.73s

SCRATCH_NAMES — the agreed single end state, implemented

Adopting the reviewer's ruling exactly. #211 wins; #222's symbol is deleted. The two-tier split by operation is the guardrail — my flat 10-tuple carried all three retired LEGACY names unmarked, and while it never reached the destructive filter, that was incidental. Removing a guardrail while adding a same-named symbol in a second module is the wrong trade, and I'd rather not be the reason someone later "completes the unification" into _SNAPSHOT_IGNORE and silently deletes a user's MEMORY.md.

before after
sole definition 3 competing symbols rundir.SCRATCH_NAMES / LEGACY_SCRATCH_NAMES / NON_CAPABILITY_NAMES (#211)
optimizer_context.SCRATCH_NAMES 10-tuple, flat deleted
read-side filters 4, one pre-drift 5th 5, all NON_CAPABILITY_NAMES | INJECTED_NAMES + INJECTED_DIRS
destructive filter INJECTED_* + 3 names INJECTED_* + SCRATCH_NAMES (live writers only), root-anchored
INJECTED_DIRS/NAMES home optimizer_context unchanged — inject() writes them, rundir stays unaware

#211's own superset test is extended to pin the INJECTED_* half as well, since that is B1's root cause and a hardcoded subset is exactly how the previous four copies drifted:

for name, names, dirs in (("cache", …), ("gepa", …), ("skillopt", …),
                          ("dashboard", …), ("harness_capdiff", …)):
    assert set(INJECTED_NAMES) <= set(names)
    assert set(INJECTED_DIRS)  <= set(dirs)

#219 must rebase on both #211 (the name sets moved to rundir; add INSIGHTS.md to the shared INJECTED_NAMES, not to a literal) and #212 (whose _augment_instructions signature is the right resolution — restoring the dropped rejected, history params is what produced the reviewer's 40 failed).


Coherence with #219 — proposed division of labour

Yours = what helped/hurt numerically. Mine = what was tried and rejected, and how often. Neither is derivable from the other, and the composed prompt confirms both fire without competing for the same budget. Concretely:

#219 INSIGHTS.md #222 constraint block
axis val Δ per candidate, which tasks broke, what's still open the exact edit signature, the gate reason, the repeat count
unit one row per rejection one row per distinct approach
delivery a file in the workdir + a pointer paragraph appended text in the prompt tail
lives in its own file (no prompt-budget competition) the protected 30% tail slice

Three asks, all yours to land since #219 merges last:

  1. Dedupe INSIGHTS.md's HURT rows on approach. The composed run shows the honest problem the reviewer named — 5 separate HURT rows next to my 1 row saying re-proposed 5x (latest cand_0005):
    ## What was REJECTED by the gate (largest movers first …)
    - iter 5 `cand_0005` val Δ +1.000 (Δ=+1.0000 <= 1000000000.0000) — while fixing {a1, a4}
    - iter 4 `cand_0004` val Δ +1.000 (Δ=+1.0000 <= 1000000000.0000) — while fixing {a1, a4}
    - iter 3 `cand_0003` … - iter 2 `cand_0002` … - iter 1 `cand_0001` …
    
    Same six events, one collapsed and one not. That reads as a contradiction to the optimizer.
  2. Add the cross-pointer on the HURT heading: "see the ALREADY TRIED & REJECTED block for the exact edits" — and drop the verbatim gate-reason string per row, since my block already quotes it once per distinct approach.
  3. feat(algorithm): plateau/convergence detection with escalation + per-lineage exhaustion #221's bare rejected-id line should go in favour of this block, per the earlier suggestion.

Nothing to remove on my side: the duplication is #219's rows, not mine, and my rows are already the collapsed form.

Composed prompt, capped comfortably (#199+#212+#211+#222+#219, optimizer_name="claude-code", 6 iterations):

composed hill-climb, last iter: workdir=cand_0006  chars=22261   (cap 60000)
both blocks live?  #222 block: True | #219 INSIGHTS pointer: True
INSIGHTS.md is a FILE not appended text: True | its size: 1803

Also documented in cap_instructions' docstring (nit 3): the kept tail is 30% of the budget (18 KB at the 60 KB ceiling), the appended blocks live in it, my block measures ~7 KB at 200 rejections — so it survives an overflow whole rather than cut mid-list. Anything appended after that point must stay well under 30%.


Numbered response to all 12 findings

# Finding Response
B1 _CAP_DIFF_SKIP omits INJECTED_*; hardcoded 3-of-9 dirs Fixed. Both sets derived, in all 5 consumers. New test runs run_step with optimizer_name="claude-code" and asserts the real edit is present and no injected file leaks.
B2 head-first truncation → not injective Fixed. sha256 of the whole normalized body on overflow. PROBE C now COLLAPSED? False.
B3 eviction by first appearance Fixed. A repeat requeues its row. Test with a repeat case (the 50-distinct bound test structurally cannot catch it).
B4 5th scratch copy in skillopt Fixed + given the INJECTED_* half too, since it also walks snapshot-vs-workdir. Covered by #211's superset test.
N1 unification doesn't fix #110; _SNAPSHOT_IGNORE untouched Resolved by the #211 rebase, not by a comment change: _SNAPSHOT_IGNORE = INJECTED_DIRS + INJECTED_NAMES + SCRATCH_NAMES, so FOCUS.md/REFLECTION.md are now stripped and no LEGACY name reaches it (leaks any LEGACY name? NO above). The comment that "read as if the drift is fully resolved" is now accurate.
N2 cid is the first but the count is the latest Fixed. Rows now read **cand_0001**, re-proposed 5x (latest cand_0005). Keeping the first cid is deliberate (its reason is what's quoted); the ambiguity is gone.
N3 control chars survive Fixed at harness.py:904 as suggested, and widened: your PROBE E showed the U+202E RTL override surviving my first pass, which is the actual spoofing vector. Stripped: C0 + DEL + bidi embedding (U+202A–U+202E) + bidi isolates (U+2066–U+2069). Emoji and non-latin text survive — pinned by a test. 'prompt.txt: +[31mRED ‮RTL 😀'ESC? False | BEL? False | any C0/DEL? False.
N4 no frontend test for approaches Fixed. Two cases in the existing describe('normalizeReason + deadEnds'): dedupe + ≤3 cap, and [] on pre-#129 records. vitest 45 → 47 passed.
N5 reads the capability twice per rejection Noted, declining, with a ponytail: comment naming the ceiling and the upgrade path (accept the already-computed _diff_capabilities text as an optional arg). Negligible next to the rollouts that just ran, and free on an accept — threading a cache through 5 call sites buys nothing at current scale.
nit 1 function-body cap_instructions import Fixed_oc.cap_instructions(...).
nit 2 -> block <~ 5 KB understates Fixed<8 KB (your measurement: 7123 chars at 200 rejections).
nit 3 cap_instructions docstring vague on the tail Fixed — 30% tail, the block lives in it, ~7 KB at 200 rejections, and the constraint on anything appended later.

Reproduction recipe corrected, thank you — git stash push -- <impl> reports No local changes to save on a clean worktree, exactly as you found:

### PR's original recipe (git stash push) on a clean worktree ->
No local changes to save

The isolated per-fix reverts above are what I used instead; they're tighter than a whole-file git checkout <base> -- on this tree, which now also reverts the #211 rebase and produces 26 unrelated ImportError failures rather than a clean signal.


Verification

$ cd /tmp/fx-222 && /tmp/ce-venv/bin/python -m compileall -q core skills
compileall clean (exit 0)

$ PYTHONPATH=/tmp/fx-222/core /tmp/ce-venv/bin/python -m pytest core/tests -q
221 passed in 90.44s (0:01:30)
tree tests
main + #199 + #212 (previous baseline) 200 passed
+ #211 (new rebase base, b8dbed3) 204 passed
+ #222 (this branch, 6d6cc52) 221 passed (+17: 13 original + 4 new)
+ #219 (composed, all five) 234 passed, 0 failed

The composed number is the one worth calling out: the reviewer measured 40 failed on the naive composition. Resolved in the order #199#212#211#222#219, taking #212's _augment_instructions signature and letting INSIGHTS.md arrive through the shared INJECTED_NAMES, it is 234 passed / 0 failed. INSIGHTS.md no longer becomes the signature — the failure you saw (signature does not name the edited file: INSIGHTS.md: +# INSIGHTS — durable priors…) was B1 in a second form, and fixing the union properly makes it a non-event, as you predicted.

Re-proved, unchanged:

5 rejection sites still record `approach`:
  core/cap_evolve/harness.py:1518  run_step val gate           approach_signature(parent_dir, workdir)
  core/cap_evolve/gepa.py:655      GEPA local minibatch gate   approach_signature(parent_dir, workdir)
  core/cap_evolve/gepa.py:694      GEPA full-val gate          approach_signature(parent_dir, workdir)
  core/cap_evolve/gepa.py:826      GEPA merge local gate       approach_signature(anc_dir, workdir)
  core/cap_evolve/gepa.py:853      GEPA merge val gate         approach_signature(anc_dir, workdir)

### bound — 200 rejections
block chars: 7123 | rows: 12 | <8000? True | under MAX? True | newest kept? True | oldest dropped? True

split ids (test+val) leaked into block: NONE
max INSTRUCTIONS.md: 21763 chars (cap 60000)     # branch only
composed (#222 + #219): 22261 chars (cap 60000)

Frontend:

$ npx tsc -b        → tsc rc=0
$ npm test          → Test Files 13 passed (13) | Tests 47 passed (47)     (was 45)

RUN.md — the two sentences are now true of the code

Wording unchanged (it was the right wording; the code just hadn't met it):

  1. "re-injected into every later proposal prompt … carrying the exact edit signature" — true now that B1/B2 are fixed. Before, a real agent optimizer's signature usually didn't contain the edit at all; PROBE B1/G above show it does, and PROBE C shows it's the exact one rather than a prefix shared with three other edits.
  2. "the repeat is counted in the constraint block" — true now that B3 is fixed. Before, a re-proposed approach could be evicted while newer one-offs were kept.

Files touched

core/cap_evolve/harness.py · dashboard.py · skillopt.py · optimizer_context.py · gepa.py · cache.py · rundir.py (via the #211 merge) · core/tests/test_failure_memory.py · core/tests/test_gepa.py · dashboard/frontend/src/test/insights.test.ts

Commits authored Osher Elhadad <Osher.Elhadad@ibm.com>, no Co-Authored-By.

OsherElhadad pushed a commit that referenced this pull request Jul 30, 2026
Review of #246 (APPROVE WITH NITS) found the declaration parser inverted on the
most likely agent behaviour, a documented self-check that could not run, and a
false-rejection probe that only covered hill-climb.

1. `_field` used `re.search`, which takes the FIRST match — so an agent that
   appends its filled declaration BELOW the seed's `<...>` placeholders recorded
   as fully UNDECLARED. Advisory-only, so no run outcome changed, but it inverted
   the exact signal this feature exists to observe, on a very common shape. Now
   `finditer` scans every occurrence and skips placeholders, so the filled
   declaration wins wherever it sits. (Second first-match-only defect in this
   epic, after #189's guard.)

2. The `scripts/run.py` self-check documented in SKILL.md could not run: not from
   the optimizer's workdir (no `skills/` tree) and not from the injected copy
   (the bootstrap's upward walk never finds `core/`). `scripts/` was kept in the
   injected copy only to serve that dead command, so both are gone — one word in
   the existing `ignore_patterns`, matching the capability/diagnose copies. Also
   shrinks the injected read-context now four blocks share the prompt budget.

3. The advisory guarantee was pinned only on `run_step`. An enforcement injected
   into GEPA's LOCAL gate passed all 19 tests and surfaced only as five confusing
   test_gepa.py failures. Added per-algorithm probes for GEPA (on its own
   `gepa_local_gate` event) and SkillOpt, so the guarantee is pinned per
   algorithm rather than per code path.

Nits: `_PLACEHOLDER_RE`'s empty case is now its own alternative instead of a `*`
quantifier that happened to also match ""; a bare `Observable:` now parses as the
same field as `Expected observable:` (requiring the adjective recorded real
declarations as missing). The one-character-value floor is left as-is — the
declaration is presence-only by design, and any length bar would be arbitrary.

Honesty: the "historically wasted the iteration" claim is reworded in both
SKILL.md and PROMPT_BLOCK as the unvalidated hypothesis it is — nothing in this
repo measures knob-versus-mechanism edit outcomes, and the `proposal_quality`
event this PR adds is the instrument that would test it (zero rows so far).

244 tests pass (base #222 is 221, not the 214 the PR body stated; 221 + 23 = 244).
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.

Active failure-memory: re-inject rejected approaches as optimizer constraints

3 participants