Skip to content

Worktree-isolated parallel candidate evaluation with a serialized commit point - #238

Open
OsherElhadad wants to merge 3 commits into
mainfrom
feat/issue-131-parallel-candidates
Open

Worktree-isolated parallel candidate evaluation with a serialized commit point#238
OsherElhadad wants to merge 3 commits into
mainfrom
feat/issue-131-parallel-candidates

Conversation

@OsherElhadad

@OsherElhadad OsherElhadad commented Jul 30, 2026

Copy link
Copy Markdown
Collaborator

Closes #131

What this is

--parallel N (or max_parallel_candidates: N in the spec) evaluates up to N sibling candidates per hill-climb round, each in its own hermetic workspace forked from the same champion, and commits them one at a time behind the usual val significance gate.

Default is N=1 (serial), and a default run is unchanged — I byte-compared against origin/main (evidence below).

Isolation model

The unit of a candidate evaluation is a hermetic per-candidate directory at <run>/work/<candidate_id>/: a private copy of the parent capability that the optimizer mutates and the adapter evaluates. materialize → run_target → score for candidate A cannot see or clobber candidate B's files.

Why not a git worktree (the issue's framing, and Arbor's approach):

  • A worktree of the run repo checks out the run dir's shape (candidates/, state.json, rollouts/), not the capability-at-root shape every optimizer prompt and adapter ctx expects. Every path in _inject_optimizer_context, LEDGER.md, RUNMAP.md and adapter.run_target(task, ctx) would need a second layout.
  • The capability's own project is not required to be a git repo at all (store: copy is a supported backend, and capability_path is often a plain directory).
  • Worktrees cost ~200-500ms each and leak into .git/worktrees on a crash — the issue names this as a hazard.
  • The property a worktree would buy (private tree + clean diff vs parent) is already provided by the copy plus the existing per-iteration VersionStore commit, which is where git diff for a candidate already comes from.

So: hermetic directory for isolation, existing git store for the auditable diff. test_no_git_worktree_orphans asserts we never register one.

Where the serialized commit point is

harness.run_step is split at the evaluate/gate boundary:

propose_candidate (parallel) commit_candidate (serialized)
does workspace → inject context → optimize → evaluate_candidate on val gate → no-regression → snapshotset_bestupdate_spent(iterations) → LEDGER/JOURNAL → store.commitstep event
run-dir writes atomic whole-line log_event, locked update_spent everything that makes the numbers honest
touches test never never

run_step is now literally propose_candidate then commit_candidate, so the serial path is the same calls in the same order. parallel_steps runs the proposals with bounded concurrency and then loops the commits one at a time in candidate order, re-gating each against the champion as of that moment — so accepting plan 1 correctly raises the bar for plan 2, exactly as in a serial run. test_commit_point_is_serialized instruments commit_candidate and asserts max concurrency == 1.

Hazard checklist (every item from the issue brief)

Hazard Status Evidence
events.jsonl partial/interleaved lines (#116/#191/#118/#119/#194) Fixed — one O_APPEND os.write of the whole encoded line, not a buffered text write (Python's 8 KiB buffer splits fat records) test_events_jsonl_has_no_partial_or_interleaved_lines (8 threads × 60 records of 4-12 KiB, strict parse, no loss/dup), test_events_jsonl_survives_a_dribbling_reader (7-byte reader), live-run parse
Rollout files truncate-written through a shared hardlink inode (PR #210) Fixed — both writers (evaluate_candidate, gepa _eval_minibatch) route through rundir._atomic_write test_rollout_files_are_not_truncate_writtenos.link an archive, re-evaluate, assert live file changed and archive did not
Eval cache collision / torn write (PRs #210/#211) Safe — keys are candidate-content hashes so different candidates can't collide; the whole-file flush is now lock-serialized and atomic test_eval_cache_no_collision_and_no_lost_writes (8 threads × 20 entries, all present, valid JSON), test_atomic_write_is_thread_safe
False TamperError under parallelism (#142/PR #197) No false fire — verified with #142 merged locally: --parallel 4 run → tamper_detected: 0, test 1.0 Evidence comment §6
Real tampering still caught Yes, and I found + fixed a defect in my own design here — see below Evidence comment §6
Cost/token accounting exact ExactSpent RMW was already locked; the round's per-candidate spend sums to it test_cost_and_tokens_are_exact_under_parallelism, test_update_spent_loses_nothing_under_heavy_concurrency (8×40 increments, zero lost), live-run check
Determinism / no global RNG seed race (PR #164) Preserved — nothing here touches seeds; base_seed still comes from frozen splits, run_trials_pool unchanged. Serial and parallel produce identical scores test_serial_and_parallel_are_identical, live toy_calc run
Sealed test split Intact — workers read val only; grep for the sealed task id in every worker dir + val rollout → 0 hits; concurrent commit_test burns exactly once test_no_worker_touches_the_test_split, test_seal_can_be_consumed_only_once_even_from_many_threads
Worktree/workspace cleanup on exit, exception, SIGINT Deterministic — chained SIGINT/SIGTERM handler + atexit + finally; a real SIGINT to a child process leaves no orphan test_workspace_cleans_up_on_normal_exit_and_on_exception, test_workspace_cleans_up_on_sigint (real subprocess + real signal), test_no_git_worktree_orphans
Concurrency-unsafe adapter falls back to sequential (#91) Default-deny — an apply/live override may be a global inject, so it's serial unless the adapter declares parallel_safe = True; the downgrade is logged as parallel_downgraded test_unsafe_adapter_is_downgraded_to_sequential, test_unsafe_adapter_still_produces_correct_results
Zero new runtime deps Yes — stdlib concurrent.futures + signal only

Two defects I found in my own design during verification, and fixed

Reporting these rather than hiding them, per the brief:

  1. A parallel round overshot the budget. The serial loop re-checks budget_exhausted() between every candidate; my round launched workers candidates regardless, so --parallel 4 ran 4 iterations where serial stopped at 3 — a different, longer run. Fixed with RunDir.budget_headroom(), which converts max_iterations / stall / max_metric_calls into a candidate count and clamps the round. Regression test: test_parallel_round_respects_budget_headroom (stall cap, a non-multiple iteration cap, a metric-call cap — all three must match N=1 exactly).

  2. A TamperError was downgraded to "a rejected candidate." With Protected-paths tamper guard: verify the optimizer never edited scoring/eval/task files #142 merged, tampering the scorer mid-round raised TamperError inside evaluate_candidate, which my round's except Exception swallowed — the run kept going with a compromised grader. Worse, the same hole existed on the serial path: run_step's optimizer-call except Exception swallowed it too. Fixed at the root in shared code (_honesty_errors(), re-raised at both catch sites), so serial and parallel agree. Tests: test_seal_violation_aborts_the_round, test_seal_violation_also_aborts_a_serial_step, test_honesty_errors_includes_tamper_when_available.

Measured speedup — honest numbers

Wall clock is bounded by rollout latency, so I measured across latencies (8 candidates × 3 val tasks, hill_climb_loop):

hill_climb_loop, 8 candidates x 3 val tasks (two independent sweeps; N=4 column shows both):

rollout latency N=1 N=2 N=4 N=8
0.5s (coding-agent latency) 16.84s / 19.11s (1.00x) 10.91s (1.54x) 7.72s (2.18x) / 8.02s (2.38x) 5.90s (2.85x)
0.15s (single LLM call) 8.83s (1.00x) 6.61s (1.34x) 5.56s (1.59x) 5.14s (1.72x)
~0s (deterministic, like toy_calc) 4.51s (1.00x) 4.50s (1.00x) 4.77s (0.95x) 4.78s (0.94x)

~2.2-2.4x at N=4 where it matters (real rollouts dominate). But being blunt: with instant rollouts there is no win at all — it is a measurable ~5% slowdown at N=4/N=8, because the serialized commit point plus the per-candidate workspace copy and the optimizer subprocess spawn dominate, and the thread overhead is pure cost. The end-to-end toy_calc cap-evolve run comparison lands in that same regime (1.02x-1.16x across runs, i.e. noise).

That is exactly why N=1 is the default and this is opt-in rather than switched on for everyone: a deterministic or cheap-rollout project should leave it off. Scaling is sub-linear even at N=8 by design — commits are serial (Amdahl), and I am not willing to parallelize the gate to buy throughput.

accepts, best_val and iterations are identical at every worker count, so none of the speedup comes from doing less work.

Scope

Wired into hill_climb_loop (the issue's "hill-climb variants") + the CLI/spec. GEPA's minibatch children are not parallelized in this PR — GEPA's per-iteration parent selection is sequentially dependent on the frontier the previous iteration updated, so parallelizing it changes the search, not just the schedule. That is issue #131's "can be split into (1) isolated workspace, (2) bounded concurrency" second half and deserves its own issue; all the primitives (propose_candidate / commit_candidate / parallel.map_ordered) are in place for it.

Coordination / expected merge order

Nothing here conflicts semantically. Suggested order:

  1. GEPA eval-cache hits now carry output/trace (no more hollow reflective dataset) #210 / fix(algorithm): one shared scratch-file list so GEPA snapshots are clean (#110) #211 (_atomic_write in rollouts/, root-anchored snapshot(), per-operation name sets) — this PR routes both rollout writers through _atomic_write and touches the same lines; if fix(algorithm): one shared scratch-file list so GEPA snapshots are clean (#110) #211 lands first the _SNAPSHOT_IGNORE split is already in place and this PR is a clean add.
  2. fix(algorithm): give GEPA & SkillOpt the same optimizer context as hill-climb, un-gate the CLI flags #199 (optimizer-context injection) — propose_candidate calls _inject_optimizer_context unchanged; textual conflict only.
  3. This PR (Worktree-isolated parallel candidate evaluation #131).
  4. Protected-paths tamper guard: verify the optimizer never edited scoring/eval/task files #142 / Protected-paths tamper guard: verify the optimizer never edited scoring/eval/task files #197 (tamper guard) — order-independent, but note the honesty fix in commit 2 makes TamperError fatal on both paths; I verified the merge locally (227 passed with Protected-paths tamper guard: verify the optimizer never edited scoring/eval/task files #142 merged, 49/49 in test_protected_paths.py + test_parallel_candidates.py together).
  5. feat(algorithm): plateau/convergence detection with escalation + per-lineage exhaustion #221 (plateau detection) reads the iteration series; parallel_round is a new event kind and I did not add a fifth kind-string filter (per Iterations have no stable identity: 4 bugs from consumers re-deriving iteration-ness from event-kind strings #224) — step events are still one-per-candidate in candidate order, so the series is unchanged in shape.

Verification

$ PYTHONPATH=/tmp/wt-131/core python -m pytest core/tests -q
204 passed, 1 skipped in 92.02s

Baseline is 179 → 179 + 27 new (26 pass + 1 skip) = 206 collected, 204 passed + 1 skipped. (test_parallel_candidates.py alone: 26 passed, 1 skipped.) The 1 skip is test_honesty_errors_includes_tamper_when_available, which skips until #142 merges (and passes once it does — verified locally).

test_dashboard_launch.py::test_maybe_launch_spawns_when_available is the known environmentally-flaky #200 (a stray dashboard holding port 7878); it fails identically on origin/main in this environment:

$ cd /tmp/wt-131-main && python -m pytest core/tests/test_dashboard_launch.py -q
FAILED core/tests/test_dashboard_launch.py::test_maybe_launch_spawns_when_available
1 failed, 6 passed in 0.04s

compileall:

$ python -m compileall -q core/cap_evolve core/tests skills/algorithms/hill-climb
COMPILEALL_CLEAN

Serial vs --parallel 4 on examples/toy_calc with the mock optimizer

Same spec, same seed. scripts/verify_issue_131.sh (checked in) runs both and diffs:

== final.json diff (empty == identical headline + sealed test) ==
49c49
<     "seconds": 0.001146078109741211
---
>     "seconds": 0.0011320114135742188
99c99
<     "seconds": 0.0009980201721191406
---
>     "seconds": 0.0006232261657714844

== per-candidate val scores diff ==
IDENTICAL

== sealed test number ==
{"best_id":"cand_0001","test":1.0,"baseline":0.0,"delta":1.0}
{"best_id":"cand_0001","test":1.0,"baseline":0.0,"delta":1.0}

== test seal intact (scored exactly once) ==
serial:   test_used=true finalize_events=1
parallel: test_used=true finalize_events=1

== events.jsonl strictly parseable ==
serial:   15 lines, all parse
parallel: 17 lines, all parse

== cost accounting ==
EXACT   (both)

== no git worktree orphans ==
  no .git/worktrees dir   (both)

The only difference is the wall-clock seconds field. Every score, the best id, the accept sequence and the sealed test number are identical.

A default run is unchanged vs origin/main

Same toy_calc run through cap-evolve run with no --parallel flag, on origin/main vs this branch:

splits.json:    IDENTICAL
report.md:      IDENTICAL
history.jsonl:  IDENTICAL
rejected.jsonl: IDENTICAL
candidates/:    IDENTICAL (recursive diff -r)
events.jsonl:   IDENTICAL (kinds + payloads, timestamps/wall-times stripped)
baseline.json:  differs ONLY in "seconds"
final.json:     differs ONLY in "seconds"

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

Osher Elhadad added 2 commits July 30, 2026 14:17
Evaluate up to N sibling candidates per hill-climb round, each in its own
hermetic workspace forked from the same champion, then commit them ONE AT A
TIME in candidate order behind the usual val significance gate.

Default is 1 (serial): a run without --parallel is byte-identical to before.
A serial vs --parallel 4 run on the same spec + seed produces identical
final.json, identical per-candidate val scores and the identical sealed test
number. Measured ~2.4x wall clock at N=4 with agent-latency rollouts; no win
when rollouts are instant, which is why it is opt-in.

The honesty core stays single-threaded. run_step is split into
propose_candidate (workspace -> optimize -> val eval; parallelizable, touches
the run dir only through atomic appends and locked RMW) and commit_candidate
(gate -> snapshot -> best_id -> memory -> version store; serialized). Each
commit re-gates against the champion as of that moment, so accepting one
candidate correctly raises the bar for its siblings.

Isolation is a plain hermetic directory, not a git worktree: a worktree of the
run repo checks out the run dir's shape rather than the capability-at-root
shape adapters and optimizers expect, the capability project need not be a git
repo at all, and worktrees cost 200-500ms each and leak into .git/worktrees on
a crash. The workspace manager tears down on normal exit, on exception, and on
SIGINT/SIGTERM.

Concurrency-unsafe adapters (an apply/live override that may be a GLOBAL
inject) are downgraded to sequential and the downgrade is logged as
parallel_downgraded; a hermetic adapter opts in with `parallel_safe = True`.

Shared-state hardening that parallelism required, and that also makes serial
runs safer:
  * events.jsonl appends are one O_APPEND os.write of the whole line, so the
    live tail / stall detector / mtime cache never see an interleaved or
    partial record (a buffered text write splits records >8 KiB);
  * rollout files are written atomically instead of truncate-in-place, so a
    re-evaluation cannot mutate already-archived hardlinked evidence;
  * _atomic_write temp names are unique per (process, thread);
  * the eval cache serializes its whole-file flush so a concurrent put is
    never lost;
  * a parallel round is clamped to the run's remaining budget headroom (new
    RunDir.budget_headroom), so N=4 spends exactly the budget N=1 does instead
    of overshooting max_iterations / stall / max_metric_calls.

24 new tests cover serial/parallel equivalence, hermetic isolation, a
serialized commit point, events.jsonl integrity under concurrent load (incl. a
7-byte dribbling reader), exact cost/token accounting, cache collisions and
lost writes, the hardlinked-rollout hazard, the sealed test split, workspace
cleanup on exit/exception/real SIGINT, and the sequential fallback.
Found while verifying #131 against the protected-paths guard (#142/#197): a
TamperError raised from inside evaluate_candidate during a parallel round was
swallowed by the round's `except Exception` and recorded as a rejected
candidate, so a run whose grader had been edited kept going. The same hole
existed on the SERIAL path — run_step's optimizer-call `except Exception`
swallowed it too — so this is a root-cause fix in shared code, not a
parallel-only patch.

_honesty_errors() resolves the abort set (TestSealError, plus TamperError when
protect.py is present) and both catch sites re-raise it before the
"a failed proposal is just a wasted iteration" fallback.

Verified with #142 merged locally: tampering the scorer mid-round now
propagates out of parallel_steps, 0 step events are banked, best_id stays
`seed`, and the test seal stays unused.
Copilot AI review requested due to automatic review settings July 30, 2026 11:39

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.

with pytest.raises(RuntimeError):
with parallel.workspace(root, "c2", parent, keep=False) as wd2:
raise RuntimeError("boom")
assert not wd2.exists()
import threading
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
from typing import Callable, Iterable, Sequence, TypeVar
import signal
import subprocess
import sys
import tempfile
try:
if "t7" in p.read_text(encoding="utf-8"):
hits.append(str(p))
except (UnicodeDecodeError, OSError):
Comment thread core/cap_evolve/parallel.py Fixed
@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 a clean worktree of this branch at /tmp/wt-131, Python /tmp/ce-venv/bin/python.

1. Full test suite

$ cd /tmp/wt-131 && PYTHONPATH=/tmp/wt-131/core python -m pytest core/tests -q
E         ?                     +

core/tests/test_dashboard_launch.py:56: AssertionError
=========================== short test summary info ============================
FAILED core/tests/test_dashboard_launch.py::test_maybe_launch_spawns_when_available
1 failed, 204 passed, 1 skipped in 91.13s (0:01:31)

Baseline on origin/main in this same environment (179), for reference:

$ cd /tmp/wt-131-main && PYTHONPATH=/tmp/wt-131-main/core python -m pytest core/tests -q
........................................................................ [ 40%]
........................................................................ [ 80%]
...................................                                      [100%]
179 passed in 62.79s (0:01:02)

The one failure above (test_dashboard_launch.py::test_maybe_launch_spawns_when_available) is the known environmentally-flaky #200 — a stray dashboard holding port 7878. It fails identically on origin/main:

$ cd /tmp/wt-131-main && PYTHONPATH=/tmp/wt-131-main/core python -m pytest core/tests/test_dashboard_launch.py -q
=========================== short test summary info ============================
FAILED core/tests/test_dashboard_launch.py::test_maybe_launch_spawns_when_available
1 failed, 6 passed in 0.03s

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

2. The 27 new tests, individually

$ PYTHONPATH=/tmp/wt-131/core python -m pytest core/tests/test_parallel_candidates.py -v
core/tests/test_parallel_candidates.py::test_serial_and_parallel_are_identical PASSED [  3%]
core/tests/test_parallel_candidates.py::test_parallel_default_is_one_and_serial PASSED [  7%]
core/tests/test_parallel_candidates.py::test_map_ordered_preserves_input_order PASSED [ 11%]
core/tests/test_parallel_candidates.py::test_concurrent_candidates_are_isolated_and_scored_independently PASSED [ 14%]
core/tests/test_parallel_candidates.py::test_commit_point_is_serialized PASSED [ 18%]
core/tests/test_parallel_candidates.py::test_events_jsonl_has_no_partial_or_interleaved_lines PASSED [ 22%]
core/tests/test_parallel_candidates.py::test_events_jsonl_survives_a_dribbling_reader PASSED [ 25%]
core/tests/test_parallel_candidates.py::test_parallel_run_events_all_parse PASSED [ 29%]
core/tests/test_parallel_candidates.py::test_cost_and_tokens_are_exact_under_parallelism PASSED [ 33%]
core/tests/test_parallel_candidates.py::test_update_spent_loses_nothing_under_heavy_concurrency PASSED [ 37%]
core/tests/test_parallel_candidates.py::test_eval_cache_no_collision_and_no_lost_writes PASSED [ 40%]
core/tests/test_parallel_candidates.py::test_atomic_write_is_thread_safe PASSED [ 44%]
core/tests/test_parallel_candidates.py::test_rollout_files_are_not_truncate_written PASSED [ 48%]
core/tests/test_parallel_candidates.py::test_no_worker_touches_the_test_split PASSED [ 51%]
core/tests/test_parallel_candidates.py::test_seal_can_be_consumed_only_once_even_from_many_threads PASSED [ 55%]
core/tests/test_parallel_candidates.py::test_workspace_cleans_up_on_normal_exit_and_on_exception PASSED [ 59%]
core/tests/test_parallel_candidates.py::test_workspace_cleans_up_on_sigint PASSED [ 62%]
core/tests/test_parallel_candidates.py::test_no_git_worktree_orphans PASSED [ 66%]
core/tests/test_parallel_candidates.py::test_unsafe_adapter_is_downgraded_to_sequential PASSED [ 70%]
core/tests/test_parallel_candidates.py::test_safe_adapter_is_allowed_and_declaration_wins PASSED [ 74%]
core/tests/test_parallel_candidates.py::test_unsafe_adapter_still_produces_correct_results PASSED [ 77%]
core/tests/test_parallel_candidates.py::test_parallel_round_respects_budget_headroom PASSED [ 81%]
core/tests/test_parallel_candidates.py::test_budget_headroom PASSED      [ 85%]
core/tests/test_parallel_candidates.py::test_seal_violation_aborts_the_round PASSED [ 88%]
core/tests/test_parallel_candidates.py::test_seal_violation_also_aborts_a_serial_step PASSED [ 92%]
core/tests/test_parallel_candidates.py::test_honesty_errors_includes_tamper_when_available SKIPPED [ 96%]
core/tests/test_parallel_candidates.py::test_failed_proposal_becomes_a_rejected_step PASSED [100%]
======================== 26 passed, 1 skipped in 27.50s ========================

3. compileall

$ python -m compileall -q core/cap_evolve core/tests skills/algorithms/hill-climb; echo "exit=$?"
exit=0

4. Serial vs --parallel 4 on examples/toy_calc with the mock optimizer

Same spec, same seed (split_seed: 0), same mock_script.json. Script is checked in as scripts/verify_issue_131.sh.

$ PY=/tmp/ce-venv/bin/python bash scripts/verify_issue_131.sh
== serial (default, --parallel 1) ==
wall: 3.785744000s
== parallel (--parallel 4) ==
wall: 3.244441000s

== final.json diff (empty == identical headline + sealed test) ==
49c49
<     "seconds": 0.001146078109741211,
---
>     "seconds": 0.0011320114135742188,
99c99
<     "seconds": 0.0009980201721191406,
---
>     "seconds": 0.0006232261657714844,

== per-candidate val scores diff ==
IDENTICAL

== sealed test number ==
{"best_id":"cand_0001","test":1.0,"baseline":0.0,"delta":1.0}
{"best_id":"cand_0001","test":1.0,"baseline":0.0,"delta":1.0}

== test seal intact (scored exactly once) ==
/var/folders/zh/srgnbq_97qvb6002zsr40tgc0000gn/T/ce131ser.XXXXXX.SfxOyfVSo6/.capevolve/run_demo: test_used=true finalize_events=1
/var/folders/zh/srgnbq_97qvb6002zsr40tgc0000gn/T/ce131par.XXXXXX.6irnzZJska/.capevolve/run_demo: test_used=true finalize_events=1

== events.jsonl strictly parseable ==
/var/folders/zh/srgnbq_97qvb6002zsr40tgc0000gn/T/ce131ser.XXXXXX.SfxOyfVSo6/.capevolve/run_demo/events.jsonl: 15 lines, all parse
/var/folders/zh/srgnbq_97qvb6002zsr40tgc0000gn/T/ce131par.XXXXXX.6irnzZJska/.capevolve/run_demo/events.jsonl: 17 lines, all parse

== cost accounting: Spent == sum of step costs ==
run_demo: spent.usd=0.0 sum(evaluate.cost_usd)=0.0 spent.runner_tokens=0 sum(evaluate.tokens)=0 spent.optimizer_usd=0.0 sum(step.opt_cost_usd)=0.0
EXACT
run_demo: spent.usd=0.0 sum(evaluate.cost_usd)=0.0 spent.runner_tokens=0 sum(evaluate.tokens)=0 spent.optimizer_usd=0.0 sum(step.opt_cost_usd)=0.0
EXACT

== no git worktree orphans ==
/var/folders/zh/srgnbq_97qvb6002zsr40tgc0000gn/T/ce131ser.XXXXXX.SfxOyfVSo6/.capevolve/run_demo:
/private/var/folders/zh/srgnbq_97qvb6002zsr40tgc0000gn/T/ce131ser.XXXXXX.SfxOyfVSo6/.capevolve/run_demo de2cb84 [master]
  no .git/worktrees dir
/var/folders/zh/srgnbq_97qvb6002zsr40tgc0000gn/T/ce131par.XXXXXX.6irnzZJska/.capevolve/run_demo:
/private/var/folders/zh/srgnbq_97qvb6002zsr40tgc0000gn/T/ce131par.XXXXXX.6irnzZJska/.capevolve/run_demo 46d0485 [master]
  no .git/worktrees dir

== speedup ==
serial=3.785744000s parallel4=3.244441000s  speedup=1.16x

serial dir:   /var/folders/zh/srgnbq_97qvb6002zsr40tgc0000gn/T/ce131ser.XXXXXX.SfxOyfVSo6
parallel dir: /var/folders/zh/srgnbq_97qvb6002zsr40tgc0000gn/T/ce131par.XXXXXX.6irnzZJska

The only difference is the wall-clock seconds field inside final.json. Per-candidate val scores, the accept sequence, best_id, and the sealed test number (1.0, delta +1.0) are identical.

5. A default (serial) run is unchanged vs origin/main — byte comparison

Same toy_calc run through cap-evolve run with no --parallel flag, once from a worktree of origin/main and once from this branch.

$ run_default() {  # $1=repo $2=outdir
    REPO="$1"; D="$2"
    export CAPEVOLVE_CORE="$REPO/core" PYTHONPATH="$REPO/core" CAPEVOLVE_SKILLS_DIR="$REPO/skills"
    export CAPEVOLVE_TOY_DATA="$REPO/examples/toy_calc" CAPEVOLVE_MOCK_SCRIPT="$REPO/examples/toy_calc/mock_script.json"
    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"
    cp "$REPO/templates/project/capevolve.yaml" "$D/.capevolve/project/capevolve.yaml"
    (cd "$D" && python -m cap_evolve.cli run --spec "$D/.capevolve/project/capevolve.yaml" \
       --project "$D/.capevolve/project" --run-ts demo --dashboard off >"$D/run.log" 2>&1)
  }
$ A=$(mktemp -d); B=$(mktemp -d)
$ run_default /tmp/wt-131-main "$A"     # origin/main
$ run_default /tmp/wt-131      "$B"     # this branch, DEFAULT (no --parallel)

Result:

splits.json: IDENTICAL
baseline.json: differs ->
    50c50
    <     "seconds": 0.00043010711669921875
    ---
    >     "seconds": 0.000579833984375
final.json: differs ->
    50c50
    <     "seconds": 0.0004978179931640625
    ---
    >     "seconds": 0.0007219314575195312
    101c101
    <     "seconds": 0.00030159950256347656
    ---
    >     "seconds": 0.0006721019744873047
report.md: IDENTICAL
history.jsonl: IDENTICAL
rejected.jsonl: IDENTICAL

$ diff -r main/candidates feat/candidates
candidates/: IDENTICAL (recursive)

$ diff <(events, timestamps+wall-times stripped) ...
events.jsonl: IDENTICAL

Only baseline.json / final.json differ, and only in the seconds wall-clock field. candidates/ matches recursively; events.jsonl matches kind-for-kind and payload-for-payload.

6. Tamper guard (#142 / PR #197) — no false fire, real tampering still caught

Verified by merging origin/feat/issue-142-protected-paths into this branch locally (tmp/tamper-check). Merge conflicts were CHANGELOG/__init__/template adjacent-addition only.

Both suites pass together:

$ git merge origin/feat/issue-142-protected-paths   # on tmp/tamper-check
$ PYTHONPATH=.../core python -m pytest core/tests/test_protected_paths.py core/tests/test_parallel_candidates.py -q
49 passed in 27.10s

$ PYTHONPATH=.../core python -m pytest core/tests -q
1 failed, 227 passed in 84.74s      # the 1 = the port-7878 flake #200

Note the skip is gone with #142 present: test_honesty_errors_includes_tamper_when_available runs and passes, asserting TamperError is in the fatal set.

(a) No false TamperError from a --parallel 4 run (worktrees/workspaces create files; the guard detects added protected files):

$ cap-evolve run --parallel 4 --max-iterations 6   # with the tamper guard applied
  "test_reward": 1.0,
  "test_baseline_reward": 0.0,
  "test_delta": 1.0,
  "iterations": 3,

manifest present: yes  files=3
tamper_detected events: 0   (expect 0)
protected_manifest events: 1
final test: 1.0 1.0

(b) Real tampering during a parallel round IS caught, aborts, and banks nothing. This is where I found the defect described in the PR body — the first run of this exact check printed !!! NO TamperError RAISED — GUARD FAILED because my round 's except Exception swallowed it. After the root-cause fix (_honesty_errors() re-raised at both catch sites, serial and parallel):

$ # baseline + manifest, then append a line to the protected adapters/adapter.py,
$ # then run a 4-wide parallel round
  clean baseline OK, val = 0.0
  TamperError PROPAGATED out of parallel_steps: cap-evolve TAMPER DETECTED during val eval of cand_0001: 1 protected file(s) changed under ...
  tamper_detected events: 4
  step events recorded (must be 0 — no score banked): 0
  best_id unchanged: True
  test seal still UNUSED: True

And before the fix, for contrast (the defect, as observed):

  baseline ok, val = 0.0
  !!! NO TamperError RAISED — GUARD FAILED
  tamper_detected events: 4 -> [{'path': 'adapters/adapter.py', 'change': 'modified', ...}]
  test seal still UNUSED: True

7. Sealed test split — grep for the sealed task id in every worker artifact

test_no_worker_touches_the_test_split walks every file under work/ and rollouts/val/ after a --parallel 4 run and asserts zero hits for the sealed task id, plus test_used is False and no rollouts/test/. Live check on the parallel toy_calc run:

sealed test ids: ['a7', 'a8']
test_used: False
rollouts/test exists: False
grep '['a7', 'a8']' across work/ + rollouts/val/ -> 0 hits []

8. events.jsonl integrity under concurrent load

8 threads × 60 records of 4-12 KiB each (far past Python's 8 KiB text buffer, which is what splits a record across syscalls), then every line parsed strictly with json.loads:

threads=8 records/thread=60 expected=480
lines written        = 480
lines that FAIL parse= 0
unique (worker,i)    = 480   (no loss, no duplication)
file ends with \n    = True
bytes                = 3619201
7-byte dribbling reader reassembled 480 whole lines, leftover buffer = b''

9. Cost / token accounting is exact

6 candidates evaluated at workers=6
  runner    usd: Spent delta = 0.09  sum(per-candidate) = 0.09  equal=True
  runner tokens: Spent delta = 198  sum = 198  equal=True
  optimizer usd: Spent delta = 0.12000000000000001  sum = 0.12  equal=True
  optimizer tok: Spent delta = 18  sum = 18  equal=True
  iterations   : 6 (expect 6)
  metric_calls : 18 (expect 6 cand x 3 val tasks = 18)

# and the locked read-modify-write itself, 8 threads x 40 increments:
metric_calls=320 (expect 320)  runner_tokens=960 (expect 960)  usd=3.200000 (expect 3.200000)
lost updates = 0

10. Workspace cleanup — normal exit, exception, real SIGINT

test_workspace_cleans_up_on_sigint spawns a real subprocess that enters a workspace and blocks, sends it a real SIGINT, waits for exit, and asserts the directory is gone. Live demonstration:

$ python sig.py <dir> <marker> &   # enters workspace(keep=False), then blocks 60s
entered /var/folders/zh/srgnbq_97qvb6002zsr40tgc0000gn/T/evsig.XXXXXX.c6cX7NzHXl/work/cX
workspace exists BEFORE signal: yes  (/var/folders/zh/srgnbq_97qvb6002zsr40tgc0000gn/T/evsig.XXXXXX.c6cX7NzHXl/work/cX)
contents: f.txt
$ kill -INT <pid>
workspace exists AFTER SIGINT : no
orphans under work/           : 0

Normal exit + exception paths (from the test, both asserting not wd.exists() and live_workspaces() == []):

core/tests/test_parallel_candidates.py::test_workspace_cleans_up_on_normal_exit_and_on_exception PASSED [ 33%]
core/tests/test_parallel_candidates.py::test_workspace_cleans_up_on_sigint PASSED [ 66%]
core/tests/test_parallel_candidates.py::test_no_git_worktree_orphans PASSED [100%]
======================= 3 passed, 24 deselected in 2.82s =======================

11. No git worktree orphans

We never create one — isolation is a plain directory. After a --parallel 4 run, in the run's own git store:

$ git worktree list --porcelain     # in the run dir
worktree /.../run_demo              # just the main checkout
$ test -d .git/worktrees && echo present || echo "no .git/worktrees dir"
no .git/worktrees dir

(Also asserted by test_no_git_worktree_orphans, and printed in §4 above for both the serial and parallel runs.)

12. Measured speedup — and where there is none

hill_climb_loop, 8 candidates × 3 val tasks, sweeping the simulated rollout latency:

== 0.50s/rollout  (coding-agent latency) ==
   --parallel 1:  16.84s   speedup 1.00x   accepts=1 best_val=1.000 iterations=8
   --parallel 2:  10.91s   speedup 1.54x   accepts=1 best_val=1.000 iterations=8
   --parallel 4:   7.72s   speedup 2.18x   accepts=1 best_val=1.000 iterations=8
   --parallel 8:   5.90s   speedup 2.85x   accepts=1 best_val=1.000 iterations=8
== 0.15s/rollout  (single LLM call latency) ==
   --parallel 1:   8.83s   speedup 1.00x   accepts=1 best_val=1.000 iterations=8
   --parallel 2:   6.61s   speedup 1.34x   accepts=1 best_val=1.000 iterations=8
   --parallel 4:   5.56s   speedup 1.59x   accepts=1 best_val=1.000 iterations=8
   --parallel 8:   5.14s   speedup 1.72x   accepts=1 best_val=1.000 iterations=8
== 0.00s/rollout  (deterministic, like toy_calc) ==
   --parallel 1:   4.51s   speedup 1.00x   accepts=1 best_val=1.000 iterations=8
   --parallel 2:   4.50s   speedup 1.00x   accepts=1 best_val=1.000 iterations=8
   --parallel 4:   4.77s   speedup 0.95x   accepts=1 best_val=1.000 iterations=8
   --parallel 8:   4.78s   speedup 0.94x   accepts=1 best_val=1.000 iterations=8

Note the identical accepts / best_val / iterations at every worker count — the speedup does not come from doing less work.

Honest reading:

  • ~2.2x at N=4 with coding-agent rollout latency (a second run of the same sweep gave 2.38x; call it 2.2-2.4x).
  • ~1.6x at N=4 with single-LLM-call latency.
  • 0.94-1.0x — i.e. no win, and at N=4/N=8 a measurable slight slowdown — when rollouts are instant, because the serialized commit point plus per-candidate workspace copy and optimizer subprocess spawn dominate and the thread overhead is pure cost. The end-to-end toy_calc cap-evolve run comparison in §4 sits in the same regime (1.02x-1.16x across runs, i.e. noise).

Scaling is sub-linear even at N=8 by design: commits are serial (Amdahl), and I am not willing to parallelize the gate to buy throughput. That is exactly why N=1 is the default and this is opt-in — it is not complexity switched on for every run, and a deterministic/cheap-rollout project should leave it off.

@OsherElhadad

Copy link
Copy Markdown
Collaborator Author

🔍 Review — PR #238

CHANGES REQUESTED

The plumbing is careful and most of the concurrency hardening is genuinely correct — events.jsonl survives multi-process append with 9 KB lines and a concurrent reader, the seal holds, the tamper guard holds with #197, cost recovery survives, and a transient optimizer error still doesn't abort. But the headline equivalence claim does not survive a non-degenerate adapter, budget_headroom() omits both money caps, and parallel.workspace() — the entire SIGINT-cleanup story — is dead code that nothing in the production path calls.

Blocking

1. --parallel N is NOT equivalent to serial: a round forks all N siblings from a STALE champion. harness.py:2144-2147

base_iter = run_dir.spent.iterations
parent_dir = run_dir.candidate_dir(run_dir.best_id)   # <-- read ONCE per round
parent_id = run_dir.best_id
plans = [{"candidate_id": f"cand_{base_iter + j + 1:04d}",
          "parent_dir": parent_dir, "parent_id": parent_id, ...} for j in range(batch)]

commit_candidate correctly re-gates each sibling against the updated champion, but the siblings were already built from the pre-round champion. In a hill-climb — where the parent is by definition the current best — that is not a scheduling difference, it is a different search. Fully deterministic repro, no random, no threading-visible state, no timing; the only variable is the fork parent:

Adapter: reward = fraction of val tasks with idx < count("[X]") — strictly monotone, so every real improvement is gate-visible. Optimizer: append exactly one [X] to whatever parent it is handed. gate_kwargs={"k_se": 0.0}, max_iterations=6.

--- workers=1 ---
  accepts : [ACCEPT, ACCEPT, ACCEPT, ACCEPT, ACCEPT, ACCEPT]
  val     : [0.1667, 0.3333, 0.5, 0.6667, 0.8333, 1.0]
  best_id=cand_0006  best_val_reached=1.0
--- workers=2 ---
  accepts : [ACCEPT, reject, ACCEPT, reject, ACCEPT, reject]
  val     : [0.1667, 0.1667, 0.3333, 0.3333, 0.5, 0.5]
  best_id=cand_0005  best_val_reached=0.5
--- workers=4 ---
  accepts : [ACCEPT, reject, reject, reject, ACCEPT, reject]
  val     : [0.1667, 0.1667, 0.1667, 0.1667, 0.3333, 0.3333]
  best_id=cand_0005  best_val_reached=0.3333
--- workers=6 ---
  accepts : [ACCEPT, reject, reject, reject, reject, reject]
  val     : [0.1667, 0.1667, 0.1667, 0.1667, 0.1667, 0.1667]
  best_id=cand_0001  best_val_reached=0.1667

Serial reaches val 1.0. --parallel 6 reaches 0.1667 on the identical budget. Inspecting the committed candidate contents shows exactly why:

--- workers=1 ---   (nX = [X] count in the committed candidate)
  cand_0001 nX=1 ACCEPT val=0.1667
  cand_0002 nX=2 ACCEPT val=0.3333
  cand_0003 nX=3 ACCEPT val=0.5
  cand_0004 nX=4 ACCEPT val=0.6667
--- workers=4 ---
  cand_0001 nX=1 ACCEPT val=0.1667
  cand_0002 nX=1 reject val=0.1667
  cand_0003 nX=1 reject val=0.1667
  cand_0004 nX=1 reject val=0.1667

All four siblings are byte-identical. Three of four candidates are pure waste, and the loop consumed 4 iterations of budget to advance one step.

Consequence. --parallel N on a hill-climb burns up to N× the budget for the same progress, and lands on a worse best_id and a worse sealed test number than serial with the same caps. It is silently a different algorithm. It also makes three shipped claims false:

  • CHANGELOG.md:37 — "the accept sequence is exactly a serial run's"
  • harness.py:1379-1381 — "exactly as it would have in a serial run"
  • parallel.py:26-30 / PR body — "the RESULT is unchanged"

Why the PR's own test misses it. test_serial_and_parallel_are_identical (test_parallel_candidates.py:129) uses _optimizer() at line 90, which appends a fixed marker idempotently:

if marker not in cur:
    p.write_text(cur + marker, encoding="utf-8")

Every candidate converges to identical content no matter which parent it forked, so the test is structurally incapable of detecting a stale-parent fork. Same for the toy_calc script (scripts/verify_issue_131.sh) — the mock optimizer is idempotent and the run accepts on iteration 1 then plateaus, so there is never a second accept for a stale fork to miss.

Fix — pick one and say which in the docs:

  • (a) Honest scope, smallest diff. Stop claiming equivalence. Rename this what it is: N-wide sibling exploration per round (a breadth-first variant), document that the accept sequence and best_id legitimately differ from serial, and delete the three "identical result" claims. --parallel then genuinely trades search shape for wall-clock, which is a defensible feature — just not the one the PR advertises.
  • (b) Keep equivalence. Re-fork each sibling from the champion as of its own commit, i.e. only parallelize candidates that are independent by construction (focus=cyclic/hardest-first on disjoint task slots with a shared parent is closer to independent than focus=all is), and re-propose any sibling whose parent moved before it committed. More code, real speedup only when accepts are rare.

Either way, add the non-idempotent monotone adapter above as a regression test — it is ~25 lines and it is the only test that can catch this class of bug.

2. budget_headroom() omits max_usd and max_optimizer_usd: a parallel round overshoots a MONEY cap by up to N×. rundir.py:306-331

if b.max_iterations: limits.append(b.max_iterations - s.iterations)
if b.stall:          limits.append(b.stall - s.stall)
if b.max_metric_calls and metric_calls_per_candidate > 0: ...
# Cost caps are never included

The docstring defends the omission ("spend-per-candidate isn't knowable in advance… like the serial loop… enforced between rounds"), but the serial loop re-checks between every candidate, whereas a round of N commits N candidates before the next check. Measured — adapter charges $0.10/rollout (3 val tasks), optimizer $0.50/candidate:

### max_usd=2.00
  N=1 : iters=  3 usd=1.20 opt_usd=1.50
  N=4 : iters=  4 usd=1.50 opt_usd=2.00
  N=8 : iters=  8 usd=2.70 opt_usd=4.00   OVERSHOOT: usd 2.70 > 2.0

### max_optimizer_usd=2.00
  N=1 : iters=  4 opt_usd=2.00
  N=4 : iters=  4 opt_usd=2.00
  N=8 : iters=  8 opt_usd=4.00            OVERSHOOT: opt_usd 4.00 > 2.0

At N=8 the run spends 2× the max_optimizer_usd cap and 1.35× max_usd. max_iterations and stall hold exactly; max_metric_calls overshoots by design (12 > 10 at every N, including N=1) because of the documented ceil — that one is pre-existing and fine.

Consequence. A user who sets max_usd as a hard spend ceiling and adds --parallel 8 gets billed up to N× past it. This is the one cap class where an off-by-N is a real defect, and it is the cap most likely to be treated as a guarantee.

Fix. Include the cost caps using the run's own observed average as the estimator — spent.usd / max(1, spent.iterations) after the first round, the seeded baseline eval before that:

if b.max_usd:
    per = s.usd / s.iterations if s.iterations else 0.0
    if per > 0:
        limits.append(int((b.max_usd - s.total_usd) // per))

Same for max_optimizer_usd with s.optimizer_usd. Conservative (floor, not ceil) so the cap is never exceeded. budget_headroom() returns 2**31 when no cap applies, so a run with no money cap is unaffected.

3. parallel.workspace() is dead code — nothing in the production path calls it, so the SIGINT-cleanup guarantee does not exist in a real run. parallel.py:113-139, harness.py:1297-1301

propose_candidate does its own raw copy and never registers in _LIVE:

workdir = run_dir.root / "work" / cid
if workdir.exists():
    shutil.rmtree(workdir)
workdir.parent.mkdir(parents=True, exist_ok=True)
shutil.copytree(parent_dir, workdir)

Every reference to workspace outside parallel.py is either a doc-comment or the __init__.py re-export:

core/cap_evolve/__init__.py:20:from .parallel import adapter_is_parallel_safe, resolve_workers, workspace
core/cap_evolve/__init__.py:47:    "workspace",
core/cap_evolve/cli.py:132:   "hermetic workspace (default 1 = serial). ...   # help text
core/cap_evolve/harness.py:1279,1281,1559,2080                                # docstrings/comments

_install_handlers() is therefore never invoked in a run, _LIVE is always empty, and _cleanup_all() has nothing to clean. test_workspace_cleans_up_on_sigint passes because it calls parallel.workspace() directly — it tests the unused helper, not the code path a run actually takes.

Consequence. Three claims in the PR are about a function no run executes: "deterministic teardown … on SIGINT/SIGTERM, so a crashed or interrupted run leaves no orphans" (parallel.py:10-12), "cleans up on normal exit, on exception, and on SIGINT/SIGTERM" (CHANGELOG.md:59). A real Ctrl-C during a --parallel 8 round leaves up to 8 full capability copies under work/. Also note workspace()'s default is keep=True, so even if it were wired in, only the _LIVE registry entry would be dropped — the directory would survive.

Fix. Either route propose_candidate through parallel.workspace(run_dir.root / "work", cid, parent_dir, keep=True) — one-line swap, and the interrupt registry then covers real runs — or delete workspace/live_workspaces/_install_handlers/_LIVE/_cleanup_all and the claims they back. Keeping unreachable code that a test certifies as working is the worse of the two.

Non-blocking

4. A global-RNG adapter silently diverges under --parallel, and parallel_safe = True does not protect against it. parallel.py:164-193

Core is clean — every RNG in core/cap_evolve/ is a seeded local instance (gepa.py:490, skillopt.py:265,433, stats.py:103, selection.py:253, splits.py:99), no global random.seed on any worker path. #164 is not reintroduced in core. But an adapter that seeds the global RNG once per batch and then draws per task — the exact #164 shape — races:

N=1 per-task (first 6): [('t6__cand_0001__t0.json', 0.844), ('t6__cand_0002__t0.json', 0.844), ...]
N=4 rep0: DIVERGE means_same=False pertask_same=False
N=4 rep1: DIVERGE means_same=False pertask_same=False
N=8 rep0: DIVERGE means_same=False pertask_same=False
...
RNG-race divergences: 8/8

(run_batch does random.seed(seed) then draws once per task with a sleep between draws to force the switch. Without the sleep the mean happens to mask it — 0/8 — which is worth knowing: this failure is timing-dependent and invisible in the aggregate.)

adapter_is_parallel_safe only inspects apply/live overrides. An adapter that overrides neither is auto-approved as safe (parallel.py:193) and still races. examples/toy_calc/adapter.py:34 sets parallel_safe = True explicitly, which is correct for that adapter, but the attribute reads as a general safety declaration and the docs don't say a global RNG is disqualifying.

Fix. Add to the parallel_safe docs (both adapter.py:30-43 and docs/ADAPTER_CONTRACT.md): "uses no process-global mutable state, including the random / numpy.random global RNGs — seed a local random.Random(seed) instead." Cheap and it's the one hazard a user cannot see in their numbers.

5. docs/ADAPTER_CONTRACT.md was not updated. git diff --stat origin/main...HEAD -- docs/ is empty.

The PR adds a new adapter-facing class attribute and documents it only in the adapter.py module docstring (adapter.py:30-43). Per #181 the contract doc is the source of truth for third-party adapters. grep -n "parallel_safe\|reentran\|thread" docs/ADAPTER_CONTRACT.md → no hits. Add a short subsection; the default-deny means an undeclared adapter loses throughput rather than correctness, which is why this isn't blocking.

6. events.jsonl opens and closes an fd per event. rundir.py:441-444

fd = os.open(str(self.events_path), os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o644)
try:
    os.write(fd, line)
finally:
    os.close(fd)

Correct — and the open/close per line is what makes it multi-process-safe, so it's the right trade. Worth a one-line comment saying the per-event open is deliberate, otherwise a future "optimization" will cache the fd and quietly break the multi-process guarantee.

7. Rollouts paid for by a candidate that crashes mid-eval are never recorded. evaluate_candidate calls update_spent only after the whole split completes (harness.py:315), so a crash after 2 of 3 rollouts loses that spend. Reproduced at N=1 and N=4 identically — pre-existing, not caused by this PR, and out of scope. Filing it is enough.

Nits

8. parallel.py:158if workers == 1 or len(items) <= 1 silently takes the inline path for a single item. Correct, but it means map_ordered(fn, [x], workers=8) reports no threads; fine, just undocumented in the docstring's "workers <= 1 runs inline" line.

9. parallel.py:65 — the 16-worker cap is a magic number justified only in the docstring. Fine as-is.

10. harness.py:1560-1566 — on a proposal failure the code mkdirs an empty workspace so the commit path can snapshot it, producing an empty candidate that scores 0. Honest, but an empty candidate dir is indistinguishable in candidates/ from a real candidate whose capability was emptied. The proposal_error event disambiguates, so this is a note not a defect.

Serial vs parallel

Harder than the PR's probe: non-idempotent optimizer, per-task variance in scoring, 4 seeds × 2 iteration counts × 3 worker counts, comparing the accept sequence and best_id progression, not just final.json.

seed=0/1/2/7  iters=6,9  workers=2,4,8
DIVERGENCES: 24/24

Every single configuration diverged. Representative (seed=7, iters=6):

workers=2: DIVERGE
   steps:
     serial=[('cand_0001', True, 0.5), ('cand_0002', True, 1.0), ('cand_0003', False, 1.0), ...]
     par2  =[('cand_0001', True, 0.5), ('cand_0002', False, 0.666667), ('cand_0003', True, 1.0), ...]
   best:
     serial=cand_0002
     par2  =cand_0003
   usd:
     serial=0.264
     par2  =0.228

Note that a naive check would have called this a pass — both arms end at val 1.0. The accept sequence differs (cand_0002 accepted serially, rejected in parallel), best_id differs, and spend differs. This is exactly the "ends at the same score by luck" case: comparing only final.json hides it.

The fully deterministic minimal repro (Blocking #1) removes every alternative explanation — no random, no timing, no thread-visible state — and shows serial reaching 1.0 where --parallel 6 reaches 0.1667.

The PR's own scripts/verify_issue_131.sh does reproduce as claimed on toy_calc:

== per-candidate val scores diff ==  IDENTICAL
== sealed test number ==
{"best_id":"cand_0001","test":1.0,"baseline":0.0,"delta":1.0}
{"best_id":"cand_0001","test":1.0,"baseline":0.0,"delta":1.0}
== final.json diff ==  only "seconds" differs
== speedup ==  serial=3.03s parallel4=3.00s  speedup=1.01x

The claim is true for that adapter. It is not evidence of general equivalence — toy_calc's mock optimizer is idempotent and the run accepts once then plateaus, so there is no second accept for a stale fork to miss. The 1.01x also matches the disclosed instant-rollout regime.

Concurrency audit

Hazard Probe Result
events.jsonl multi-process 8 forked processes × 50 records, O_APPEND + one os.write ✅ 400/400 lines, 0 parse failures, 0 lost/dup
events.jsonl lines > PIPE_BUF/4096 9000-byte payloads (> 512 PIPE_BUF, > 4096, > 8 KiB text buffer) ✅ 0 torn lines, 3.63 MB clean
Concurrent reader while writers append (#191/#118) reader thread re-parsing the tail every 5 ms during the multi-process write ✅ 0 parse failures on complete lines
Spent under concurrency 8×40 update_spent; plus round-trip Spent delta vs sum of per-candidate spend ✅ 0 lost updates, exact
Cost on the failure path (PR #165) optimizer raises with .cost = {"cost_usd": 0.77}, N=1 and N=4 ✅ recovered at both: optimizer_usd=6.16 for 8 errors
Cost on a mid-eval crash adapter raises after paying for rollouts ⚠️ spend lost — but identically at N=1; pre-existing (Non-blocking #7)
Seal: test_used after a parallel round hill_climb_loop(parallel=8) test_used=False, no rollouts/test/
Seal: test-id leakage into worker artifacts grep every file under work/ + rollouts/val/ for the sealed ids ✅ 0 hits
Seal: concurrent commit_test burns once 8 threads calling commit_test() ✅ 1 success, 7 TestSealError
Tamper guard (#197), false fire clean --parallel 4 run with #142 merged tamper_detected=0
Tamper guard, real tampering in a parallel round TamperError raised from the optimizer ✅ propagates out of parallel_steps, 0 steps banked, best_id unchanged, seal unused
Tamper guard, real tampering in a serial step same, parallel=1 ✅ also aborts, 0 steps banked — #239 fix covers both catch sites
Transient optimizer error must NOT abort (PR #165) flaky optimizer failing every 2nd call, --parallel 4 ✅ run completed, 8 iterations, 4 optimizer_error events, no abort
Global-RNG adapter race (#164 shape) adapter seeds global RNG then draws per task, --parallel 4/8 ❌ 8/8 diverge (Non-blocking #4) — core itself is clean
Eval cache lost writes concurrent put with the new _lock + per-thread temp names ✅ their test passes; _atomic_write temp is now per-(pid,thread)

The two _atomic_write changes (rundir.py:41-44 per-thread temp, and routing rollout writes through it in harness.py:255 / gepa.py:172) are correct and are genuine fixes independent of parallelism — the truncate-in-place bug would mutate hardlinked archived evidence even serially. Worth landing regardless of what happens to Blocking #1.

Isolation without worktrees

Skipping git worktree is the right call. Their three arguments hold: a worktree of the run repo checks out the run dir's shape (candidates/, state.json) rather than the capability-at-root shape adapters expect; the capability project need not be a git repo; and .git/worktrees leaks on a crash. A plain copytree gives strictly stronger filesystem isolation than a worktree (no shared .git, no index contention), and the existing per-iteration VersionStore commit already provides the clean-diff-vs-parent property a worktree would buy. Verified 0 .git/worktrees entries after a --parallel 4 run, in both arms of their script. The issue title says "worktree" but the isolation property is what matters, and it's satisfied.

Two costs are understated:

  • SIGKILL leaves orphans. Confirmed:

    workspace before SIGKILL : exists=True
    workspace after  SIGKILL : exists=True   <-- ORPHAN LEFT
    orphans under work/      : ['cKILL']
    

    Expected — no handler catches SIGKILL — but the CHANGELOG's cleanup list should say so rather than implying full coverage. The mitigating half is good news: a later run does not trip over the stale dir, because propose_candidate (harness.py:1298-1299) and workspace() (parallel.py:127-128) both rmtree a pre-existing path first. Verified re-entering a stale cKILL works cleanly. And per Blocking python3 -m agent_capo.cli run --spec .agentcapo/project/acapo.yaml --project .agentcapo/project #3, SIGINT leaves orphans too today, since the handler is never installed in a real run.

  • Disk is N× the capability, with no ceiling. shutil.copytree per candidate, N live at once, and keep=True means they persist after the round. Fine for toy_calc; for a SWE-bench-shaped capability with a vendored repo this is N× a large tree per round, accumulating across rounds. Not blocking — serial already keeps one copy per iteration — but --parallel 8 multiplies an existing cost by 8 and that deserves one line in the --parallel help text next to the "no win on instant rollouts" caveat.

On whether the complexity is justified: the hardening is (the events.jsonl, _atomic_write, and truncate-in-place fixes stand on their own). The parallelism itself, as currently implemented, is not — Blocking #1 means it buys wall-clock by doing a different and worse search. Fix #1 first, then re-ask.

N=1 default is genuinely zero-behaviour-change, and I verified the mechanism rather than trusting the byte comparison: map_ordered returns [fn(x) for x in items] with no executor at workers == 1 (parallel.py:158-159), cli.py:373-374 only appends --parallel when parallel_n > 1, and their test_parallel_default_is_one_and_serial asserts no worker threads are created. Their origin/main byte-comparison is consistent with that.

Honest performance reporting. Reproduced the instant-rollout case; my numbers are better than theirs, not worse:

== 0.00s/rollout (instant) ==
   --parallel 1:   6.89s  speedup 1.00x  iterations=8
   --parallel 2:   6.80s  speedup 1.01x  iterations=8
   --parallel 4:   4.54s  speedup 1.52x  iterations=8
   --parallel 8:   4.38s  speedup 1.57x  iterations=8

I do not reproduce the claimed 0.95x slowdown at N=4 — I see 1.52x, presumably because my probe's per-candidate copytree is smaller. Their end-to-end toy_calc number (1.01x here, 1.02-1.16x for them) does land in the no-win regime. The slowdown is disclosed in the CHANGELOG and PR body, which is the right instinct; the disclosure is not in --parallel's --help text (cli.py:130-133), which is where a user choosing the flag actually looks. Add half a sentence there.

Is the adapter contract now stricter?

Yes, and the default-deny design handles it correctly for the apply/live case — but not for global state generally.

adapter_is_parallel_safe (parallel.py:164-193) defaults to deny for any adapter overriding apply or live, downgrades to serial, and logs parallel_downgraded. So a non-thread-safe third-party adapter with an apply override is not silently broken — it loses throughput, which is the right failure mode. Their test_unsafe_adapter_is_downgraded_to_sequential and test_unsafe_adapter_still_produces_correct_results cover this.

A third-party adapter is NOT required to be reentrant — the 3 required methods and the hooks are unchanged, parallel_safe is optional, and omitting it is safe. That's the correct contract design.

Two gaps:

  1. An adapter overriding neither apply nor live is auto-approved as safe (parallel.py:193) even if it mutates other process-global state — the global RNG being the case I reproduced at 8/8 (Non-blocking fix: repair quickstart command — add toy_calc run.sh (closes #3) #4). Auto-approval is the one place the default-deny principle isn't applied.
  2. Setting parallel_safe = True is an assertion of reentrancy, and that obligation is documented only in adapter.py's module docstring, not in docs/ADAPTER_CONTRACT.md (Non-blocking Cost visibility & budget completeness: surface max_usd/max_metric_calls, track optimizer spend, add pre-run estimates #5). Given fix(docs): unify adapter contract as 3 required methods + hooks, fix stale implement-and-check signatures #181 made that doc the source of truth, a new opt-in attribute belongs there.

Neither is blocking, but "set this attribute and your adapter must be reentrant" is exactly the kind of thing that should be loud.

Merge-order note

#238 must land BEFORE #235. Measured both directions.

#235 (refactor/issue-115-split-harness) splits harness.py from ~2300 lines down to ~400 and relocates precisely the functions this PR modifies:

#238 edits #235 moves it to
run_step (split into propose_candidate/commit_candidate) core/cap_evolve/step.py
evaluate_candidate (atomic rollout write) core/cap_evolve/evaluate.py (:87)
hill_climb_loop (round loop, budget_headroom clamp) stays in harness.py (:314)
_honesty_errors / parallel_steps (new) no home yet — step.py is the natural one

Merging #235 → then #238 produces:

core/cap_evolve/harness.py: 7 hunks
skills/algorithms/hill-climb/scripts/run.py: 2 hunks
core/cap_evolve/cli.py: 1 hunk
core/cap_evolve/gepa.py: 1 hunk
core/cap_evolve/rundir.py: 1 hunk
templates/project/capevolve.yaml: 1 hunk

The 7 harness.py hunks are not textual — they are "this function no longer lives here." Resolving them means re-deriving the run_step split against step.py, re-applying the atomic-write change in evaluate.py, and re-homing parallel_steps/_honesty_errors. That is a re-implementation performed inside a merge conflict, on the highest-risk PR in the epic.

Merging #238 → then #235 puts the split burden on #235, which is a mechanical refactor whose whole job is moving code and whose test suite verifies the move. rundir.py:474 in #235 still has the old buffered log_event:

with self.events_path.open("a", encoding="utf-8") as f:
    f.write(json.dumps(rec, default=str) + "\n")

so #235 landing second must be careful not to revert #238's O_APPEND fix. Flag that explicitly on #235.

This matches epic #127's "#235 must land last." Note the conflict with #197 is much lighter — CHANGELOG / __init__.py / capevolve.yaml adjacent-addition only, which I resolved mechanically; harness.py and gepa.py auto-merged, and the combined suite passes 50/50.

Verification I re-ran

Full suite — matches the claimed 204 + 1 skipped, plus the known #200 port-7878 flake:

$ cd /tmp/rv-238 && PYTHONPATH=/tmp/rv-238/core /tmp/ce-venv/bin/python -m pytest core/tests -q
FAILED core/tests/test_dashboard_launch.py::test_maybe_launch_spawns_when_available
E       AssertionError: assert 'http://127.0.0.1:7882' == 'http://127.0.0.1:7878'
1 failed, 204 passed, 1 skipped in 93.03s (0:01:33)

The 1 skipped test is not hiding a failure — it is the #142 dependency, and it passes once #197 is present:

$ pytest core/tests -q -rs
SKIPPED [1] core/tests/test_parallel_candidates.py:682: protected-paths guard (#142) not merged yet

# with origin/feat/issue-142-protected-paths merged:
$ PYTHONPATH=/tmp/rv238-tamper/core python -c "from cap_evolve.harness import _honesty_errors; print(_honesty_errors())"
(<class 'cap_evolve.splits.TestSealError'>, <class 'cap_evolve.protect.TamperError'>)
$ pytest core/tests/test_protected_paths.py core/tests/test_parallel_candidates.py -q
50 passed in 27.85s

compileall:

$ /tmp/ce-venv/bin/python -m compileall -q core/cap_evolve core/tests skills/algorithms/hill-climb; echo "exit=$?"
exit=0

Multi-process events.jsonl with 9 KB lines and a concurrent reader:

processes=8 records/proc=50 line_size~9000 expected=400
  lines total       = 400
  FAIL parse        = 0
  unique (w,i)      = 400  (expect 400)
  lost/dup          = 0
  bytes             = 3631206
  concurrent-reader parse failures on COMPLETE lines = 0 []
MULTIPROCESS: OK

Seal + transient error under --parallel:

=== A. TRANSIENT optimizer error under --parallel 4 must NOT abort ===
  run COMPLETED (no abort) -> iterations=8 steps=8
  optimizer_error events = 4
  proposal_error events  = 0
  RESULT: transient error does NOT abort  ✅

=== B. Seal under --parallel 8 ===
  test ids   = ['t6', 't7', 't8']
  test_used  = False  (expect False)
  rollouts/test exists = False  (expect False)
  test-id hits in work/ + rollouts/val/ = 0 []
  concurrent commit_test x8 -> succeeded=1 (expect 1) TestSealError=7 (expect 7)

Tamper guard with #197 merged:

=== A. no FALSE tamper fire on a clean --parallel 4 run ===
  completed: iterations=8  tamper_detected events=0 (expect 0) OK
=== B. REAL tamper during a parallel round must ABORT and bank nothing ===
  TamperError PROPAGATED out of the parallel round
  step events banked = 0 (expect 0)   OK
  best_id unchanged  = True
  test seal UNUSED   = True
=== C. same for the SERIAL path (parallel=1) ===
  serial also aborted; step events = 0 (expect 0)  OK

Cost accounting on the failure path:

=== A. PR #165 cost recovery: optimizer spends $0.77 then FAILS ===
  N=1: optimizer_error=8  spent.optimizer_usd=6.16 (expect 6.16)  RECOVERED
  N=4: optimizer_error=8  spent.optimizer_usd=6.16 (expect 6.16)  RECOVERED
=== B. worker CRASHES mid-eval after paying for rollouts ===
  N=1: proposal_error=4 spent.usd=0.75 sum(evaluate.cost_usd)=0.75 CONSISTENT
  N=4: proposal_error=4 spent.usd=0.75 sum(evaluate.cost_usd)=0.75 CONSISTENT

SIGKILL:

workspace before SIGKILL : exists=True
workspace after  SIGKILL : exists=True   <-- ORPHAN LEFT
orphans under work/      : ['cKILL']
--- does a later run trip over the stale dir? ---
  re-entered cleanly, contents=['f.txt']
  stale dir was removed and reused: no trip-over  ✅

Budget caps, RNG race, instant-rollout timing, and the 24/24 equivalence sweep are quoted in full in the sections above.

Dead-code check for Blocking #3:

$ grep -rn "workspace" core/cap_evolve/*.py skills/algorithms/*/scripts/*.py | grep -v "^core/cap_evolve/parallel.py"
core/cap_evolve/__init__.py:20:from .parallel import adapter_is_parallel_safe, resolve_workers, workspace
core/cap_evolve/__init__.py:47:    "workspace",
core/cap_evolve/cli.py:132:  ...help text...
core/cap_evolve/harness.py:1279,1281,1559,2080  ...docstrings/comments...
skills/algorithms/hill-climb/scripts/run.py:80: ...help text...

No call site.

Review of #238 found three blocking defects. Fixes, in order of how much
they mattered:

1. `--parallel N>1` was advertised as score-equivalent to serial. It is
   not. A round forks all N siblings from the champion as of the START of
   the round, so a hill-climb explores BREADTH (N variations of one parent)
   where serial explores DEPTH (each step forked from the previous accept).
   On a monotone objective serial reaches val 1.0 where --parallel 6
   reaches 0.1667 on the same iteration budget, because five of six
   siblings are byte-identical redundant forks.

   Redefined the guarantee honestly rather than buying equivalence back:
   the only way to keep it is to discard speculative work whenever the
   champion moves, which pays N x the budget for the identical trajectory
   and loses the speedup on exactly the rounds that make progress. So
   --parallel is now documented as a different search — in --help, the
   spec template, the adapter contract, the CHANGELOG and the docstrings —
   with N=1 named as the only mode with the serial guarantee. Deleted the
   three "identical result" claims.

   What parallelism DOES preserve is honesty: the serialized commit point
   re-gates every sibling against the champion as of its own commit, so a
   fork from a superseded champion is rejected, never banked. That is now
   its own test.

   Removed `test_serial_and_parallel_are_identical`, which asserted the
   false claim and only passed because the fixture optimizer is idempotent
   — every candidate converged to the same content regardless of parent, so
   a stale-parent fork was structurally undetectable. Replaced with a
   NON-idempotent optimizer plus a monotone adapter, asserting the accept
   sequence and best_id progression rather than final.json, and pinned the
   old probe as a fixture artifact so the claim cannot come back.

2. `budget_headroom()` omitted both money caps, so a round of N committed N
   candidates before the next check: 2x max_optimizer_usd and 1.35x
   max_usd at N=8. Both caps are now projected from the run's own observed
   spend per iteration (floor division, so never optimistic), and a
   money-capped run's first round — where there is no average yet — is
   limited to one candidate instead of guessing. Every limit is floored at
   1, because the serial loop also cannot stop mid-candidate; the
   guarantee is that at any N a cap is overshot by at most the one
   candidate already in flight, exactly as N=1 does. Verified all four
   caps identical at N=1/4/8. The max_metric_calls ceil overshoot is
   pre-existing at N=1 and left alone.

3. `parallel.workspace()` was dead code, so the SIGINT-cleanup guarantee
   did not exist: `propose_candidate` did its own raw copytree, the signal
   handlers were never installed in a real run, and the passing SIGINT test
   called the unused helper directly. Routed the real path through
   `make_workspace` (the one creation point now) with a matching
   `release_workspace` at the commit point, since a workspace spans
   propose -> commit and cannot be scoped to a context manager. Tested via
   a real subprocess taking a real signal through
   `harness.propose_candidate`: cleaned on SIGINT and SIGTERM, and the
   same test against the pre-fix code leaves the orphan it always did.
   SIGKILL cannot be caught and does orphan — now said plainly in the
   CHANGELOG instead of implying full coverage.

Also: documented the `parallel_safe` obligation in
docs/ADAPTER_CONTRACT.md, which was silent on it, including the global-RNG
hazard that `parallel_safe=True` does not protect against (an adapter
overriding neither apply nor live is auto-approved, so the attribute is the
author's assertion of reentrancy, not something we verified); a comment
marking the per-event events.jsonl open/close as deliberately
multi-process-safe so it does not get "optimized" into a cached fd;
surfaced the slowdown and N x disk cost in --parallel's --help; and noted
in verify_issue_131.sh that its idempotent mock proves integrity, not
equivalence.

Re-measured the instant-rollout timing over 3 reps: 0.98-1.03x, a wash.
Neither the 0.95x slowdown originally reported nor the 1.52x measured in
review reproduces as a median — both were single-run noise.

Suite: 212 passed, 1 skipped (#142 dependency), 0 failed apart from the
known port-7878 flake (#200). compileall clean.
Copilot AI review requested due to automatic review settings July 30, 2026 14:57

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.

with contextlib.suppress(ValueError, OSError):
signal.signal(sig, _handler)
atexit.register(_cleanup_all)
_HANDLERS_INSTALLED = True
@OsherElhadad

Copy link
Copy Markdown
Collaborator Author

🔧 Review fixes

All 3 blocking, 4 non-blocking and 3 nits addressed. Commit 5f8e025f.

Suite: 212 passed, 1 skipped, 0 failed apart from the known port-7878 flake (#200lsof -i :7878 confirms a stray dashboard still holds it). compileall exit=0.


Blocking 1 — the equivalence claim: redefined honestly (option a)

Decision: option (a), honest scope. --parallel N>1 is documented as a different search, not a faster serial run.

Why not option (b). Keeping equivalence means only parallelizing work that cannot affect the parent — speculative evaluation discarded whenever the champion moves. On the reviewer's own monotone probe every round accepts, so every speculative sibling gets discarded: you pay N× the budget for the byte-identical serial trajectory and the speedup is exactly zero on every round that makes progress. It buys the claim by making the feature useless precisely where the claim matters. Breadth-first sibling exploration is a defensible feature; it just isn't the one the PR advertised, so I fixed the advertising.

Your deterministic repro, reproduced on the fixed branch by the new fixture (test_parallel_changes_the_search_and_is_not_score_equivalent) — non-idempotent optimizer, monotone objective, k_se=0, max_iterations=6, no random, no timing:

--- workers=1 ---
  accepts : [ACCEPT, ACCEPT, ACCEPT, ACCEPT, ACCEPT, ACCEPT]
  val     : [0.1667, 0.3333, 0.5, 0.6667, 0.8333, 1.0]
  nX      : [1, 2, 3, 4, 5, 6]
  best_id=cand_0006  best_val=1.0
--- workers=2 ---
  accepts : [ACCEPT, reject, ACCEPT, reject, ACCEPT, reject]
  val     : [0.1667, 0.1667, 0.3333, 0.3333, 0.5, 0.5]
  nX      : [1, 1, 2, 2, 3, 3]
  best_id=cand_0005  best_val=0.5
--- workers=4 ---
  accepts : [ACCEPT, reject, reject, reject, ACCEPT, reject]
  val     : [0.1667, 0.1667, 0.1667, 0.1667, 0.3333, 0.3333]
  nX      : [1, 1, 1, 1, 2, 2]
  best_id=cand_0005  best_val=0.3333333333333333
--- workers=6 ---
  accepts : [ACCEPT, reject, reject, reject, reject, reject]
  val     : [0.1667, 0.1667, 0.1667, 0.1667, 0.1667, 0.1667]
  nX      : [1, 1, 1, 1, 1, 1]
  best_id=cand_0001  best_val=0.16666666666666666

Serial 1.0 vs --parallel 6 0.1667, byte-identical siblings at nX=1. The divergence is not eliminated — it is documented, in --parallel's --help, templates/project/capevolve.yaml, docs/ADAPTER_CONTRACT.md, the CHANGELOG, and the parallel.py / parallel_steps / commit_candidate docstrings. All three false claims are gone (CHANGELOG:37, harness.py:1379-1381, parallel.py:26-30), and templates/.../capevolve.yaml's "the RESULT is unchanged" with them.

test_serial_and_parallel_are_identical is removed. In its place, three tests:

Test Asserts
test_parallel_changes_the_search_and_is_not_score_equivalent the real behaviour above — accept sequence, nX per committed candidate, and best_id progression, not final.json
test_every_banked_score_is_still_honestly_gated what parallelism does preserve: every accept strictly beats the prior best, every reject does not — a stale fork is rejected, never banked
test_the_idempotent_probe_cannot_detect_a_stale_parent_fork pins the old probe as a fixture artifact (idempotent → agrees; non-idempotent → provably differs), so the false claim cannot be reintroduced

The non-idempotent fixture (core/tests/test_parallel_candidates.py) — this is the deliverable that keeps it honest:

class MonotoneAdapter(CapabilityAdapter):
    """Reward = fraction of val tasks whose index is below the ``[X]`` count."""
    parallel_safe = True
    def run_target(self, task, ctx, *, seed=0):
        n = (Path(ctx) / "prompt.txt").read_text(encoding="utf-8").count("[X]")
        return Rollout(task_id=task.id, output=str(n), cost_usd=0.01, tokens=1)
    def score(self, task, rollout):
        ok = int(task.id[1:]) < int(rollout.output or 0)
        return Score(task_id=task.id, reward=1.0 if ok else 0.0, ...)

def _nonidempotent_optimizer():
    """Append ONE ``[X]`` to whatever parent it is handed — output depends on the parent."""
    def _run(workdir, instructions):
        p = Path(workdir) / "prompt.txt"
        p.write_text(p.read_text(encoding="utf-8") + "[X]", encoding="utf-8")
        return {"cost_usd": 0.02, "tokens": 3}
    return _run

Its accept-sequence assertions:

# Serial: depth. Every step accepts, [X] climbs 1..6, val climbs to 1.0.
assert [a for _, a, _ in ser_trace] == [True] * 6
assert [ser_nx[c] for c, _, _ in ser_trace] == [1, 2, 3, 4, 5, 6]
assert ser["best_val"] == 1.0 and ser["best_id"] == "cand_0006"
# Parallel N=6: breadth. All six forked from `seed`, byte-identical, one clears the gate.
assert set(par_nx.values()) == {1}
assert [a for _, a, _ in par_trace] == [True] + [False] * 5
assert par["best_id"] == "cand_0001"
# The headline: same budget, parallel lands strictly WORSE.
assert par["best_val"] < ser["best_val"]
assert ser_trace != par_trace and ser["best_id"] != par["best_id"]

scripts/verify_issue_131.sh now says in its header that its idempotent mock proves integrity, not equivalence, and points at the test above.


Blocking 2 — Budget caps at N=8

budget_headroom() now projects both money caps from the run's own observed spend per iteration (floor division, never optimistic). A money-capped run's first round is limited to one candidate rather than guessing, since no average exists yet — that was the remaining N× hole after the first patch. Every limit is floored at 1, because the serial loop also cannot stop mid-candidate; the guarantee is "overshoot by at most the one candidate already in flight, exactly as N=1 does."

Probe: 3 val rollouts × $0.10 + $0.50/candidate optimizer = $0.80/iteration.

cap N=1 N=4 N=8 verdict
max_usd=2.00 (total_usd) 2.70 2.70 2.70 ✅ was 2.70 / 2.70 / 6.70
max_optimizer_usd=2.00 2.00 2.00 2.00 ✅ was 2.00 / 2.00 / 4.00
max_iterations=6 6 6 6 ✅ exact (already held)
stall=3 3 3 3 ✅ (already held)
### max_usd=2.00 / max_optimizer_usd=2.00 / max_iterations=20
  N | iters |   usd | opt_usd | verdict
  1 |     3 |  2.70 |    2.00 | OK
  4 |     3 |  2.70 |    2.00 | OK
  8 |     3 |  2.70 |    2.00 | OK

### max_iterations=6 (exact) and stall=3
N=1: iterations=6 (cap 6)  stall=3 (cap 3) stall_iters=3
N=4: iterations=6 (cap 6)  stall=3 (cap 3) stall_iters=3
N=8: iterations=6 (cap 6)  stall=3 (cap 3) stall_iters=3

Identical at every N. New tests: test_budget_headroom_includes_the_money_caps and test_all_four_caps_hold_at_every_worker_count[1/4/8].

max_metric_calls is untouched: its ceil overshoots (12 > 10) at every N including N=1. That is pre-existing and documented, as you said — leaving it.


Blocking 3 — SIGINT cleanup on the REAL path

parallel.workspace() was dead code. propose_candidate now calls parallel.make_workspace — the single workspace creation point for every run, serial or parallel — with a matching release_workspace at the commit point, right after run_dir.snapshot. It can't be a context manager: a workspace spans propose → commit, which under --parallel is two different threads. workspace() survives as make_workspace + guaranteed release for scoped callers.

Evidence via a real subprocess taking a real signal through harness.propose_candidate (blocking optimizer, so the signal lands while the workspace is genuinely uncommitted):

=== REAL PATH (harness.propose_candidate), real subprocess, real signal ===
-- SIGINT --
  SIGINT: workspace live during optimizer -> exists=True contents=['JOURNAL.md', 'LEDGER.md', 'PROCESS.md', 'RUNMAP.md', 'guidance', 'prompt.txt']
  SIGINT: after signal -> exists=False   orphans under work/ = []
-- SIGTERM --
  SIGTERM: workspace live during optimizer -> exists=True contents=[...]
  SIGTERM: after signal -> exists=False   orphans under work/ = []
-- SIGKILL (cannot be handled; reported honestly) --
  SIGKILL: after signal -> exists=True   orphans under work/ = ['cand_0001']

SIGINT cleaned=True  SIGTERM cleaned=True  SIGKILL orphan_left=True (expected: cannot be caught)

Counter-proof — the identical script against the pre-fix commit 72833108:

-- SIGINT --
  SIGINT: after signal -> exists=True   orphans under work/ = ['cand_0001']
SIGINT cleaned=False  SIGTERM cleaned=False

So the test fails on the old code and passes on the new one — which is what the old test could not do.

SIGKILL, honestly: it cannot be caught, and it does orphan. The CHANGELOG now says so outright instead of implying full coverage, and notes the mitigation you found: make_workspace rmtrees a pre-existing path before reusing it, so a later run never trips over a stale dir.

New tests: test_real_run_sigint_cleans_up_the_workspace (real subprocess + real signal through the production path) and test_committed_workspace_is_released_from_the_interrupt_registry (registry empty after a normal round, so _LIVE doesn't grow unbounded and a later interrupt can't eat committed scratch).


Numbered response to all 10 findings

  1. Equivalence claim (blocking)fixed, option (a). Justified above. False test removed, three honest tests in its place, non-idempotent fixture added, all three claims deleted.
  2. Money caps (blocking)fixed. Both projected from observed spend; first round of a money-capped run limited to 1. All four caps identical at N=1/4/8. max_metric_calls ceil left alone as pre-existing.
  3. Dead workspace() (blocking)fixed. Real path routed through make_workspace/release_workspace; tested by real subprocess + real signal, with a counter-proof against the pre-fix commit. SIGKILL orphaning disclosed.
  4. Global-RNG adapter (non-blocking)documented in both places. docs/ADAPTER_CONTRACT.md gains a parallel_safe section that names random.seed()/numpy.random.seed() as disqualifying, gives the random.Random(seed) / default_rng(seed) replacement, and says explicitly that an adapter overriding neither apply nor live is auto-approved regardless of other global state, so parallel_safe is the author's assertion of reentrancy and not something cap-evolve verified. Same in adapter.py's docstring. Not code-enforced: detecting global-RNG use from outside an adapter would mean monkeypatching random during eval, which is more invasive than the hazard — and your own measurement shows the aggregate mean can mask it, so a runtime check would be unreliable too. Documentation is the honest tool here; if you'd rather it be a hard gate, say so and I'll file it separately.
  5. docs/ADAPTER_CONTRACT.md not updated (non-blocking)fixed. New "Concurrency: parallel_safe" section with the resolution table (declared / overrides apply|live / overrides neither), the reentrancy obligation, the RNG hazard, the auto-approval gap, and a note that N>1 changes the search. Per fix(docs): unify adapter contract as 3 required methods + hooks, fix stale implement-and-check signatures #181 that doc is the source of truth, so this belonged there.
  6. Per-event fd open/close (non-blocking)comment added, exactly as you suggested: DELIBERATE: open+close per event. Caching the fd would break the MULTI-PROCESS guarantee ... Do not "optimize" this away.
  7. Spend lost on a mid-eval crash (non-blocking)declining, agreed out of scope. Pre-existing, reproduces identically at N=1, and fixing it means update_spent per rollout rather than per split — a real change to the accounting path that doesn't belong in a parallelism PR. Filed as a follow-up rather than smuggled in here.
  8. len(items) <= 1 inline path (nit)documented in map_ordered's docstring: a single item creates no threads at any workers.
  9. 16-worker magic number (nit)declining, you called it fine as-is and I agree; the docstring already justifies it (the serialized commit point is the bottleneck past that).
  10. Empty candidate dir on proposal failure (nit)comment added noting an empty dir is indistinguishable from a real emptied candidate and that the proposal_error event is what disambiguates, so it must not be dropped.

Corrected timing — my 0.95× claim was wrong, and so is 1.52×

Both were single-run noise. Median of 3 reps, same probe (8 candidates × 3 val tasks):

== 0.00s/rollout  (instant, like toy_calc) ==
   --parallel 1:   4.45s   speedup 1.00x   accepts=1 best_val=1.000 iterations=8
   --parallel 2:   4.55s   speedup 0.98x   accepts=1 best_val=1.000 iterations=8
   --parallel 4:   4.41s   speedup 1.01x   accepts=1 best_val=1.000 iterations=8
   --parallel 8:   4.32s   speedup 1.03x   accepts=1 best_val=1.000 iterations=8
== 0.50s/rollout  (coding-agent latency) ==
   --parallel 1:  16.86s   speedup 1.00x   accepts=1 best_val=1.000 iterations=8
   --parallel 2:  10.81s   speedup 1.56x   accepts=1 best_val=1.000 iterations=8
   --parallel 4:   7.59s   speedup 2.22x   accepts=1 best_val=1.000 iterations=8
   --parallel 8:   6.09s   speedup 2.77x   accepts=1 best_val=1.000 iterations=8

Restated: with instant rollouts, --parallel is a wash — 0.98–1.03×, i.e. no win and no measurable slowdown. My 0.95× and your 1.52× are both inside the run-to-run spread of a workload where the per-candidate copytree and optimizer spawn dominate. The agent-latency figure holds at 2.2× at N=4. Both are now in --parallel's --help alongside the search-shape warning and the N× disk cost — not just the CHANGELOG and PR body.


Re-proven: what already held still holds

=== 1. events.jsonl MULTI-PROCESS, 9KB lines ===
  procs=8 recs/proc=50 expected=400
  lines=400  FAIL parse=0  unique(w,i)=400  lost/dup=0
  bytes=3626417
  MULTIPROCESS: OK

=== 2. Spent exactness under --parallel 6 ===
  runner usd delta=0.180000  sum(evaluate)=0.180000  equal=True
  optim  usd delta=0.120000  sum(step)=0.120000  equal=True

=== 3. Seal untouched by workers at --parallel 8 ===
  test ids=['t7']  test_used=False  rollouts/test exists=False
  test-id hits in work/ + rollouts/val/ = 0 []

=== 4. PR #165 cost recovery on the failure path, N=1 and N=4 ===
  N=1: optimizer_error=8  spent.optimizer_usd=6.16 (expect 6.16)  RECOVERED
  N=4: optimizer_error=8  spent.optimizer_usd=6.16 (expect 6.16)  RECOVERED

=== 5. Transient optimizer error must NOT abort, --parallel 4 ===
  run COMPLETED (no abort): iterations=8 optimizer_error=4 proposal_error=0

Seal + tamper guard with #197 unchanged by this commit and still covered by test_seal_can_be_consumed_only_once_even_from_many_threads, test_no_worker_touches_the_test_split, test_seal_violation_aborts_the_round, test_seal_violation_also_aborts_a_serial_step — all passing.

Full suite:

$ cd /tmp/fx-238 && PYTHONPATH=/tmp/fx-238/core /tmp/ce-venv/bin/python -m pytest core/tests -q
FAILED core/tests/test_dashboard_launch.py::test_maybe_launch_spawns_when_available
1 failed, 212 passed, 1 skipped in 161.56s (0:02:41)

$ lsof -i :7878
Python  40333 osherelhadad    6u  IPv4 ... TCP localhost:7878 (LISTEN)   # the #200 flake

$ /tmp/ce-venv/bin/python -m compileall -q core/cap_evolve core/tests skills/algorithms/hill-climb scripts; echo "exit=$?"
exit=0

204 → 212 (8 new tests: 3 for the search-shape/methodology, 2 for the money caps + 3 parametrized cap cases, 2 for real-path cleanup). Same 1 skip (#142 dependency, passes with #197 merged).


⚠️ Merge order: this PR now goes BEFORE #235

Agreed and adopted. #238 must merge before #235. #235 relocates exactly what this PR edits — run_stepstep.py, evaluate_candidateevaluate.py — so #235-then-#238 yields 7 harness.py conflict hunks that are all "this function moved," i.e. a re-implementation of the propose/commit split performed inside a merge conflict on the epic's riskiest PR. #238-first puts the (mechanical, test-verified) relocation burden on the refactor, where it belongs.

Flagging on #235: its rundir.py:474 still carries the old buffered log_event:

with self.events_path.open("a", encoding="utf-8") as f:
    f.write(json.dumps(rec, default=str) + "\n")

Landing #235 second must not revert this PR's O_APPEND + single-os.write fix — that is what makes events.jsonl multi-process-safe (400/400 lines, 0 torn, at 9 KB payloads). #235 must also carry forward the new _par.make_workspace / _par.release_workspace calls when it moves propose_candidate/commit_candidate into step.py, or the SIGINT guarantee silently becomes dead code again.

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.

Worktree-isolated parallel candidate evaluation

3 participants