Worktree-isolated parallel candidate evaluation with a serialized commit point - #238
Worktree-isolated parallel candidate evaluation with a serialized commit point#238OsherElhadad wants to merge 3 commits into
Conversation
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.
| 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): |
|
❌ 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. |
🔬 EvidenceAll commands run in a clean worktree of this branch at 1. Full test suiteBaseline on The one failure above ( 2. The 27 new tests, individually3. compileall4. Serial vs
|
🔍 Review — PR #238CHANGES REQUESTED The plumbing is careful and most of the concurrency hardening is genuinely correct — Blocking1. 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)]
Adapter: Serial reaches val 1.0. 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.
Why the PR's own test misses it. 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 ( Fix — pick one and say which in the docs:
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. 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 includedThe 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: At N=8 the run spends 2× the Consequence. A user who sets Fix. Include the cost caps using the run's own observed average as the estimator — 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 3.
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
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" ( Fix. Either route Non-blocking4. A global-RNG adapter silently diverges under Core is clean — every RNG in (
Fix. Add to the 5. The PR adds a new adapter-facing class attribute and documents it only in the 6. 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 7. Rollouts paid for by a candidate that crashes mid-eval are never recorded. Nits8. 9. 10. Serial vs parallelHarder 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 Every single configuration diverged. Representative (seed=7, iters=6): Note that a naive check would have called this a pass — both arms end at val The fully deterministic minimal repro (Blocking #1) removes every alternative explanation — no The PR's own 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
The two Isolation without worktreesSkipping Two costs are understated:
On whether the complexity is justified: the hardening is (the
Honest performance reporting. Reproduced the instant-rollout case; my numbers are better than theirs, not worse: I do not reproduce the claimed 0.95x slowdown at N=4 — I see 1.52x, presumably because my probe's per-candidate Is the adapter contract now stricter?Yes, and the default-deny design handles it correctly for the
A third-party adapter is NOT required to be reentrant — the 3 required methods and the hooks are unchanged, Two gaps:
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.
Merging #235 → then #238 produces: The 7 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. 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 This matches epic #127's "#235 must land last." Note the conflict with #197 is much lighter — CHANGELOG / Verification I re-ranFull suite — matches the claimed 204 + 1 skipped, plus the known #200 port-7878 flake: The 1 skipped test is not hiding a failure — it is the #142 dependency, and it passes once #197 is present:
Multi-process Seal + transient error under Tamper guard with #197 merged: Cost accounting on the failure path: SIGKILL: 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: 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.
| with contextlib.suppress(ValueError, OSError): | ||
| signal.signal(sig, _handler) | ||
| atexit.register(_cleanup_all) | ||
| _HANDLERS_INSTALLED = True |
🔧 Review fixesAll 3 blocking, 4 non-blocking and 3 nits addressed. Commit Suite: 212 passed, 1 skipped, 0 failed apart from the known port-7878 flake (#200 — Blocking 1 — the equivalence claim: redefined honestly (option a)Decision: option (a), honest scope. 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 ( Serial 1.0 vs
The non-idempotent fixture ( 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 _runIts 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"]
Blocking 2 — Budget caps at N=8
Probe: 3 val rollouts × $0.10 + $0.50/candidate optimizer = $0.80/iteration.
Identical at every N. New tests:
Blocking 3 — SIGINT cleanup on the REAL path
Evidence via a real subprocess taking a real signal through Counter-proof — the identical script against the pre-fix commit 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: New tests: Numbered response to all 10 findings
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): Restated: with instant rollouts, Re-proven: what already held still holdsSeal + tamper guard with #197 unchanged by this commit and still covered by Full suite: 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).
|
Closes #131
What this is
--parallel N(ormax_parallel_candidates: Nin 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 againstorigin/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 → scorefor candidate A cannot see or clobber candidate B's files.Why not a
git worktree(the issue's framing, and Arbor's approach):candidates/,state.json,rollouts/), not the capability-at-root shape every optimizer prompt and adapterctxexpects. Every path in_inject_optimizer_context,LEDGER.md,RUNMAP.mdandadapter.run_target(task, ctx)would need a second layout.store: copyis a supported backend, andcapability_pathis often a plain directory)..git/worktreeson a crash — the issue names this as a hazard.VersionStorecommit, which is wheregit difffor a candidate already comes from.So: hermetic directory for isolation, existing git store for the auditable diff.
test_no_git_worktree_orphansasserts we never register one.Where the serialized commit point is
harness.run_stepis split at the evaluate/gate boundary:propose_candidate(parallel)commit_candidate(serialized)evaluate_candidateon valsnapshot→set_best→update_spent(iterations)→ LEDGER/JOURNAL →store.commit→stepeventlog_event, lockedupdate_spentrun_stepis now literallypropose_candidatethencommit_candidate, so the serial path is the same calls in the same order.parallel_stepsruns 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_serializedinstrumentscommit_candidateand asserts max concurrency == 1.Hazard checklist (every item from the issue brief)
events.jsonlpartial/interleaved lines (#116/#191/#118/#119/#194)O_APPENDos.writeof 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 parseevaluate_candidate, gepa_eval_minibatch) route throughrundir._atomic_writetest_rollout_files_are_not_truncate_written—os.linkan archive, re-evaluate, assert live file changed and archive did nottest_eval_cache_no_collision_and_no_lost_writes(8 threads × 20 entries, all present, valid JSON),test_atomic_write_is_thread_safeTamperErrorunder parallelism (#142/PR #197)--parallel 4run →tamper_detected: 0, test 1.0SpentRMW was already locked; the round's per-candidate spend sums to ittest_cost_and_tokens_are_exact_under_parallelism,test_update_spent_loses_nothing_under_heavy_concurrency(8×40 increments, zero lost), live-run checkbase_seedstill comes from frozen splits,run_trials_poolunchanged. Serial and parallel produce identical scorestest_serial_and_parallel_are_identical, live toy_calc rungrepfor the sealed task id in every worker dir + val rollout → 0 hits; concurrentcommit_testburns exactly oncetest_no_worker_touches_the_test_split,test_seal_can_be_consumed_only_once_even_from_many_threadsatexit+finally; a real SIGINT to a child process leaves no orphantest_workspace_cleans_up_on_normal_exit_and_on_exception,test_workspace_cleans_up_on_sigint(real subprocess + real signal),test_no_git_worktree_orphansapply/liveoverride may be a global inject, so it's serial unless the adapter declaresparallel_safe = True; the downgrade is logged asparallel_downgradedtest_unsafe_adapter_is_downgraded_to_sequential,test_unsafe_adapter_still_produces_correct_resultsconcurrent.futures+signalonlyTwo defects I found in my own design during verification, and fixed
Reporting these rather than hiding them, per the brief:
A parallel round overshot the budget. The serial loop re-checks
budget_exhausted()between every candidate; my round launchedworkerscandidates regardless, so--parallel 4ran 4 iterations where serial stopped at 3 — a different, longer run. Fixed withRunDir.budget_headroom(), which convertsmax_iterations/stall/max_metric_callsinto 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).A
TamperErrorwas 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 raisedTamperErrorinsideevaluate_candidate, which my round'sexcept Exceptionswallowed — the run kept going with a compromised grader. Worse, the same hole existed on the serial path:run_step's optimizer-callexcept Exceptionswallowed 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):~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 runcomparison lands in that same regime (1.02x-1.16x across runs, i.e. noise).That is exactly why
N=1is 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_valanditerationsare 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:
_atomic_writeinrollouts/, root-anchoredsnapshot(), per-operation name sets) — this PR routes both rollout writers through_atomic_writeand touches the same lines; if fix(algorithm): one shared scratch-file list so GEPA snapshots are clean (#110) #211 lands first the_SNAPSHOT_IGNOREsplit is already in place and this PR is a clean add.propose_candidatecalls_inject_optimizer_contextunchanged; textual conflict only.TamperErrorfatal 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 intest_protected_paths.py+test_parallel_candidates.pytogether).parallel_roundis 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) —stepevents are still one-per-candidate in candidate order, so the series is unchanged in shape.Verification
Baseline is 179 → 179 + 27 new (26 pass + 1 skip) = 206 collected, 204 passed + 1 skipped. (
test_parallel_candidates.pyalone: 26 passed, 1 skipped.) The 1 skip istest_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_availableis the known environmentally-flaky #200 (a stray dashboard holding port 7878); it fails identically onorigin/mainin this environment:compileall:Serial vs
--parallel 4onexamples/toy_calcwith themockoptimizerSame spec, same seed.
scripts/verify_issue_131.sh(checked in) runs both and diffs:The only difference is the wall-clock
secondsfield. Every score, the best id, the accept sequence and the sealed test number are identical.A default run is unchanged vs
origin/mainSame toy_calc run through
cap-evolve runwith no--parallelflag, onorigin/mainvs this branch:Full commands and untruncated output in the
## 🔬 Evidencecomment below.