Skip to content

Add the cheap first-real-run on-ramp between toy_calc and the ~$148 tau2 run (closes #124) - #249

Open
OsherElhadad wants to merge 2 commits into
mainfrom
feat/issue-124-cheap-onramp
Open

Add the cheap first-real-run on-ramp between toy_calc and the ~$148 tau2 run (closes #124)#249
OsherElhadad wants to merge 2 commits into
mainfrom
feat/issue-124-cheap-onramp

Conversation

@OsherElhadad

Copy link
Copy Markdown
Collaborator

Closes #124

There was a cliff between rung 1 and rung 2 of onboarding: toy_calc is free but calls
no model, and the next advertised step was the τ² airline run — hours and ~$148. This
adds the missing rung: examples/cheap_real, a real run (real LLM, real paired
gate, real sealed test split) that finishes in minutes for $0 on a local model, or
cents with a hosted proposer.

The task is date normalization — "the 22nd of November, 1963"1963-11-22, scored
by exact match. The seed prompt is a generic "you are a helpful assistant", so a small
model answers in prose and scores 0; the fix is an output contract, which is exactly the
edit an optimizer is good at proposing. That makes the demonstration honest: the gain
comes from a general rule, not a memorized answer.

The three-rung ladder

All three are now visible in docs/GETTING_STARTED.md and site/getting-started.html,
each with its cost, runtime, and derivation.

Rung What is real Runtime Cost
1. freetoy_calc pipeline, gate, sealed test — no model seconds $0
2. cheap-realcheap_real a real LLM, real gate, real sealed test 36 s – 5.4 min MEASURED $0 local / $0.5427 MEASURED hosted proposer
3. full — τ²-bench airline a published benchmark hours ~$148

Derivation (rung 2)

20 tasks → train 10 / val 5 / test 5, num_trials: 1, max_iterations: 3:

runner calls   = val 5 x trials 1 x (1 baseline + 3 candidates)  = 20
               + test 5 x (best + baseline seed)                 = 10   → 30 calls
proposer calls = max_iterations                                  =  3
  • Free variant (ollama/llama3.2:3b runner + mock proposer) — MEASURED: 36 s,
    $0.00.
    $0 by construction: a local model is unmetered and mock makes no network
    call at all.
  • Cheap variant (same local runner, claude-code/Haiku proposer) — MEASURED
    from the run's own accounting (state.jsonspent): $0.5427 total, 321 s
    (5.4 min) wall clock, and exactly the 30 metric_calls the formula above predicts.

    Per-proposal spend was $0.159 / $0.199 / $0.185, so budget ~$0.15–0.35 per
    iteration
    . Proposal latency dominates: 293 s of the 321 s was the optimizer, only
    27 s the runner. All 30 runner calls cost $0 because the runner is local.
  • Hosted-runner variant (no local model) — 30 runner calls at the bundled table's
    Haiku rate and cap-evolve's assumed 3 000 in / 800 out tokens per rollout:
    30 x (3000 x $1.00 + 800 x $5.00) / 1e6$0.21 — an ESTIMATE from
    core/cap_evolve/pricing.py, explicitly labelled as such in the docs. I did not run
    this variant.

max_usd: 3.0 / max_optimizer_usd: 2.5 are hard stops, so rung 2 cannot quietly
become rung 3.

Standalone example, not a zoo entry — and why

#233's zoo was the natural home, and I checked it properly rather than assuming. It
cannot host this one: verify step 5 runs every val task twice and fails the
benchmark if any rollout fingerprint differs
(NON-DETERMINISTIC: … cannot produce a reproducible number). That guard is correct and
worth keeping, but no real LLM can satisfy it, even at temperature=0 with a fixed
seed. This example exists specifically to call a real model, so making it a zoo entry
would mean weakening the zoo's determinism guarantee to admit the one benchmark that
structurally cannot meet it. Standalone example; the zoo keeps its invariant. Reasoning
is recorded in the example's README so the next person does not re-litigate it.

Entry point for #133

bash examples/cheap_real/run.sh. Every knob is an env var with a default, and the last
object on stdout is the run's summary JSON:

Var Default
CHEAP_REAL_MODEL ollama/llama3.2:3b (free, local)
CHEAP_REAL_API_BASE http://localhost:11434
CHEAP_REAL_OPTIMIZER mock (zero-API)
CHEAP_REAL_OPT_MODEL ""
CHEAP_REAL_WORKDIR fresh mktemp dir
CHEAP_REAL_MAX_USD spec value (3.0)
CHEAP_REAL_PYTHON python3

So #133 can call it with CHEAP_REAL_WORKDIR pinned and parse the last JSON object; no
import, no new API surface.

How it composes with the sibling PRs

Expected merge order: none required — this is additive (one new example dir, one new
test file, three edited docs; zero core changes). It composes with all five siblings in
any order.

Three bugs found while proving this works

Worth recording, because each one produced a clean-looking run whose numbers were
meaningless — the failure mode this epic keeps catching.

  1. A missing litellm made a broken run look like an honest 0.0. The adapter
    correctly turns a failed model call into error: + reward 0.0 (infra noise must not
    be optimized against), but that means a stopped Ollama yields baseline_val 0.0 → test_reward 0.0 with no visible failure. run.sh now preflights the interpreter,
    the endpoint, and whether the model is actually pulled, before spending anything.
  2. optimizer_max_turns: 12 silently discarded every candidate. claude-code exits
    non-zero with Reached max turns (12), which cap-evolve correctly reports as a
    failed iteration — so the run read as "the optimizer proposed nothing" rather than
    "the cap was too tight". Now 40, with the measured reason in a comment.
  3. A relative optimizer_instructions_file silently falls back to the generic
    template.
    cli.py resolves it against its own cwd and then a cwd-relative
    .capevolve/project, neither of which is the run's project dir when the workdir is
    elsewhere. The optimizer consequently received tau2-flavored "edit the tool code"
    advice for a project with no tools, and proposed a prompt for the wrong task
    (it made the agent a date-trivia assistant). run.sh writes an absolute path.
    This is a latent main-branch sharp edge for any out-of-tree project dir; I worked
    around it here rather than widen this PR's scope into cli.py.

Verification

Every command and its full output is in the ## 🔬 Evidence comment below. Summary:

cap-evolve check on the preset — {"ok": true}:

{
  "ok": true,
  "stubs": [],
  "problems": [],
  "notes": [
    "tasks('val') -> 20 task(s)",
    "scorer deterministic (probe reward=0.0000)",
    "materialize() callable (dry-run into temp copy; host untouched)"
  ]
}

Zero-cost proof first — real local LLM runner, mock optimizer, through to a sealed
test number (36 s, $0):

{
  "run_dir": ".capevolve/run_cheap",
  "best_id": "cand_0001",
  "baseline_val": 0.0,
  "test_reward": 1.0,
  "test_baseline_reward": 0.0,
  "test_delta": 1.0,
  "iterations": 3
}

Then with a REAL agent proposing the edit (claude-code / Haiku) — completed end to
end to a sealed test number, MEASURED:

{
  "run_dir": ".capevolve/run_cheap",
  "best_id": "cand_0001",
  "baseline_val": 0.0,
  "test_reward": 1.0,
  "test_baseline_reward": 0.0,
  "test_delta": 1.0,
  "iterations": 3
}

Its own spend accounting — note metric_calls: 30, exactly what the derivation predicts:

{"iterations": 3, "metric_calls": 30, "usd": 0.0,
 "optimizer_usd": 0.5426701500000001,
 "runner_seconds": 26.98, "optimizer_seconds": 293.47}

The gate accepted on iteration 1 and correctly rejected the two that followed (val was
already 1.0, so there was nothing left to gain):

{"kind": "step", "candidate": "cand_0001", "accept": true,
 "reason": "paired \u0394\u0304=+1.0000 > 0 (SE=0 \u2192 STRICT fallback, warned; n=5)",
 "val": 1.0, "parent": "seed", "parent_val": 0.0, "opt_cost_usd": 0.159017}

The agent's actual proposed edit — a general rule, no memorized dates:

Convert the date to ISO 8601 format: YYYY-MM-DD.
Four-digit year, two-digit month, two-digit day.
Output only the date. No explanation, no greeting, no text.

#195's val floor:

MIN_VAL_TASKS=2  LOW_CONFIDENCE_VAL_TASKS=5
cheap_real realized split: train=10 val=5 test=5
check_val_size warning: NONE (clears both floors)
gate.decide(n=5 pairs): True | paired Δ̄=+0.8000 > 1.1416·SE=0.2283 (SE=0.2000, n=5, df=4, t-corrected from 1.0·SE)

#197's protected_paths rule — key omitted, all four grader files covered by the
layout defaults:

protected_paths key present in preset? False
resolved protected paths:
   adapters/adapter.py
   adapters/model_config.py
   capevolve.yaml
   tasks.jsonl

Full suite — 183 passed (179 baseline + 4 new), 0 failed:

183 passed in 63.41s (0:01:03)

compileall clean, bash -n run.sh clean, every relative link in the touched docs
resolves.

Honest limits, stated in the README

  • The gate reports SE=0 → STRICT fallback at num_trials: 1, so the accept decision
    is "Δ > 0", not a significance test. That is documented behavior, not a defect of the
    preset — but it is stated plainly rather than glossed.
  • A 3B local model is a weak reader. The point is watching the machinery work on a real
    model, not producing a publishable number.
  • I did not run the hosted-runner variant; its figure is labelled an estimate with its
    arithmetic shown.

…au2 run

There was a cliff in onboarding: `toy_calc` is free but calls no model, and the next
advertised step was the tau2 airline run — hours and ~$148. A newcomer who wanted to
see cap-evolve work on a real LLM had nothing between "no model at all" and "commit an
afternoon and $148".

`examples/cheap_real` is the missing rung: a REAL run (real LLM, real paired gate, real
sealed test split) in minutes, for $0 on a local model or cents with a hosted proposer.
The task is date normalization ("the 22nd of November, 1963" -> 1963-11-22) scored by
exact match, so the seed's generic "helpful assistant" prompt answers in prose and
scores 0, and the fix is an output contract — the edit an optimizer is actually good at
proposing. The gain therefore comes from a general rule, not a memorized answer.

MEASURED, both rungs to a sealed test number of 1.0 from a baseline of 0.0:
  * free   (ollama/llama3.2:3b + mock proposer)     36 s, $0.00
  * cheap  (same runner + claude-code/Haiku)       321 s, $0.5427
The cheap rung's spend is entirely its 3 proposals ($0.159/$0.199/$0.185 from the run's
own opt_cost_usd); all 30 runner calls are free because the runner is local. The run
reported exactly the 30 metric_calls the documented derivation predicts. The
hosted-runner figure in the docs (~$0.21) is labelled an ESTIMATE with its arithmetic,
because I did not run that variant.

docs/GETTING_STARTED.md and site/getting-started.html now show all three rungs with
cost, runtime and derivation, so free -> cheap-real -> full is one visible ladder.

A standalone example, not a benchmark-zoo entry (#233), and deliberately: `verify`
step 5 runs every val task twice and fails on any rollout-fingerprint drift. That guard
is right and worth keeping, but no real LLM can satisfy it even at temperature 0 with a
fixed seed — and this example exists to call a real model. Admitting it would mean
weakening the zoo's determinism invariant for the one benchmark that structurally
cannot meet it. The reasoning is in the example's README so it is not re-litigated.

No new adapter code: run.sh copies templates/adapters/jsonl_litellm/adapter.py and
model_config.py verbatim, which is exactly what that bundled generic template is for.
No core changes at all.

Composes with the sibling work rather than duplicating it:
  * #113 — the 20-task size is a FLOOR, not a preference. val 5 is the smallest split
    clearing both MIN_VAL_TASKS=2 (gate.decide refuses below it) and
    LOW_CONFIDENCE_VAL_TASKS=5 (below which decisions are branded low-confidence).
    Verified against that branch, and pinned by a test rather than a comment.
  * #142 — protected_paths is OMITTED, never []. run.sh puts the adapter,
    model_config.py and tasks.jsonl inside the project dir and the spec names
    dataset_source: tasks.jsonl, so the layout defaults cover all four grader files
    without declaring anything (a declared list replaces the defaults).
  * #132 — sets proposer_model AND optimizer_model to a cheap tier on purpose: the
    latter is what ships today, the former is the tier name and falls back to it, so
    both are correct before and after that merge. aux_model is left unset because
    every auxiliary step is still pure Python and a tier would route nothing.
  * #134 — nothing printed echoes an endpoint. The preflight reports an unreachable
    local endpoint WITHOUT the URL, per the rule that a non-default base_url is
    confidential.

Three bugs found while proving this works, each of which produced a clean-LOOKING run
whose numbers were meaningless:
  1. A missing litellm made a broken run indistinguishable from an honest 0.0 — the
     adapter correctly turns a failed call into reward 0.0, so a stopped Ollama yielded
     baseline_val 0.0 -> test_reward 0.0 with no visible failure. run.sh now preflights
     the interpreter, the endpoint AND whether the model is pulled, before spending.
  2. optimizer_max_turns: 12 silently discarded every candidate — claude-code exits
     non-zero on `Reached max turns`, correctly reported as a failed iteration, so the
     run read as "the optimizer proposed nothing". Now 40, with the measured reason.
  3. A relative optimizer_instructions_file silently falls back to the GENERIC
     template, because cli.py resolves it against its own cwd. The optimizer got
     tau2-flavored "edit the tool code" advice for a project with no tools and
     optimized the wrong task entirely. run.sh writes an absolute path; the underlying
     sharp edge in cli.py is noted rather than widened into this change.

Tests: 4 new preset invariants (split floors, protected_paths omission, dataset_source
names the real file, budget bounded) — the assertions a future "make it cheaper" edit
would break. Suite 179 -> 183, 0 failed. compileall clean; links resolve.
Copilot AI review requested due to automatic review settings July 30, 2026 23:20

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.


def test_split_clears_the_honest_gate_floors():
from cap_evolve.splits import make_splits
import cap_evolve.splits as splits
@OsherElhadad

Copy link
Copy Markdown
Collaborator Author

🔬 Evidence

Every command below was run in a clean worktree of this branch (/tmp/wt-124b) with
PYTHONPATH set, as the repo requires. Output is pasted literally.

1. Free rung — real local LLM runner, mock proposer, end to end to a sealed test

$ CHEAP_REAL_WORKDIR=/tmp/cr9 bash examples/cheap_real/run.sh
cheap_real preflight: OK
Working directory: /tmp/cr9
Runner model: ollama/llama3.2:3b   Optimizer: mock
{
  "run_dir": ".capevolve/run_cheap",
  "best_id": "cand_0001",
  "baseline_val": 0.0,
  "test_reward": 1.0,
  "test_baseline_reward": 0.0,
  "test_delta": 1.0,
  "test_pass_k": {
    "1": 1.0,
    "2": 0.0
  },
  "iterations": 3,
  "dashboard": ".capevolve/run_cheap/dashboard.html"
}

real  0m30.423s

MEASURED: 30 s, $0.00. Zero cost, and it reaches a real sealed test number (1.0) from
a real baseline (0.0) — the runner is a genuine LLM, only the proposer is deterministic.

2. cap-evolve check on the preset

$ cap-evolve check /tmp/cr9/.capevolve/project
{
  "ok": true,
  "stubs": [],
  "problems": [],
  "notes": [
    "tasks('val') -> 20 task(s)",
    "scorer deterministic (probe reward=0.0000)",
    "materialize() callable (dry-run into temp copy; host untouched)"
  ]
}

{"ok": true}.

3. Real-agent rung — claude-code / Haiku proposes the edit

Run via the gateway. Neither ANTHROPIC_BASE_URL nor ANTHROPIC_AUTH_TOKEN is echoed
anywhere in this PR, its commits, or the run artifacts.

$ CHEAP_REAL_OPTIMIZER=claude-code CHEAP_REAL_OPT_MODEL=claude-haiku-4-5 \
    CHEAP_REAL_WORKDIR=/tmp/cr8 bash examples/cheap_real/run.sh
cheap_real preflight: OK
Working directory: /tmp/cr8
Runner model: ollama/llama3.2:3b   Optimizer: claude-code
{
  "run_dir": ".capevolve/run_cheap",
  "best_id": "cand_0001",
  "baseline_val": 0.0,
  "test_reward": 1.0,
  "test_baseline_reward": 0.0,
  "test_delta": 1.0,
  "test_pass_k": {
    "1": 1.0,
    "2": 0.0
  },
  "iterations": 3,
  "dashboard": ".capevolve/run_cheap/dashboard.html"
}

Every step event, from the run itself:

{"kind": "splits", "train": 10, "val": 5, "test": 5, "seed": 0}
{"kind": "baseline", "val": 0.0, "stderr": 0.0}
{"kind": "step", "candidate": "cand_0001", "accept": true, "reason": "paired \u0394\u0304=+1.0000 > 0 (SE=0 \u2192 STRICT fallback, warned; n=5)", "val": 1.0, "parent": "seed", "parent_val": 0.0, "optimizer_seconds": 81.58, "runner_seconds": 3.57, "cost_usd": 0.0, "tokens": 476, "opt_cost_usd": 0.159017, "opt_tokens": 5761}
{"kind": "step", "candidate": "cand_0002", "accept": false, "reason": "paired \u0394\u0304=+0.0000 <= 0 (SE=0 \u2192 STRICT fallback, warned; n=5)", "val": 1.0, "parent": "cand_0001", "parent_val": 1.0, "optimizer_seconds": 106.69, "runner_seconds": 1.59, "cost_usd": 0.0, "tokens": 491, "opt_cost_usd": 0.198835, "opt_tokens": 6442}
{"kind": "step", "candidate": "cand_0003", "accept": false, "reason": "paired \u0394\u0304=+0.0000 <= 0 (SE=0 \u2192 STRICT fallback, warned; n=5)", "val": 1.0, "parent": "cand_0001", "parent_val": 1.0, "optimizer_seconds": 105.2, "runner_seconds": 1.64, "cost_usd": 0.0, "tokens": 496, "opt_cost_usd": 0.184818, "opt_tokens": 7060}
{"kind": "finalize", "test_reward": 1.0, "test_baseline_reward": 0.0, "test_delta": 1.0, "best_id": "cand_0001"}

Its own spend accounting (state.jsonspent) — note metric_calls: 30, exactly
what the derivation in the PR body predicts:

{
  "iterations": 3,
  "metric_calls": 30,
  "usd": 0.0,
  "stall": 2,
  "runner_tokens": 3658,
  "runner_seconds": 26.9793119430542,
  "optimizer_seconds": 293.4690363407135,
  "optimizer_usd": 0.5426701500000001,
  "optimizer_tokens": 19263,
  "intake_usd": 0.0,
  "intake_tokens": 0,
  "intake_seconds": 0.0
}

MEASURED: 321 s (5.4 min), $0.5427. Accepted on iteration 1 (val 0.0 → 1.0), then
correctly rejected two candidates that could not improve on a val of 1.0, and sealed
the test split at 1.0.

The agent's actual proposed edit — a general rule, no memorized dates:

Convert the date to ISO 8601 format: YYYY-MM-DD.
Four-digit year, two-digit month, two-digit day.
Output only the date. No explanation, no greeting, no text.
Only output the date in YYYY-MM-DD format.

4. #113/#195 — the val floor

Run against origin/fix/issue-113-small-samples, where the floors actually exist:

MIN_VAL_TASKS=2  LOW_CONFIDENCE_VAL_TASKS=5
cheap_real realized split: train=10 val=5 test=5
check_val_size warning: NONE (clears both floors)
gate.decide(n=5 pairs): True | paired Δ̄=+0.8000 > 1.1416·SE=0.2283 (SE=0.2000, n=5, df=4, t-corrected from 1.0·SE)

val=5 clears both bars with zero warnings, and the paired gate produces a real
t-corrected decision on 5 pairs.

The new test file asserts this, and passes on BOTH branches (on main the constants
do not exist yet, so it falls back to the documented values):

$ # against origin/fix/issue-113-small-samples
$ pytest core/tests/test_cheap_real_preset.py -q
....                                                                     [100%]
4 passed in 0.03s

5. #142/#197protected_paths

Run against origin/feat/issue-142-protected-paths, on the real project dir the free
rung produced:

protected_paths declared in the preset? False -> KEY OMITTED
resolved protected paths (from the layout defaults alone):
    adapters/adapter.py
    adapters/model_config.py
    capevolve.yaml
    tasks.jsonl

And proof that an empty list — what this preset deliberately avoids — is a hard error:
  TamperError: cap-evolve: `protected_paths` in /private/var/folders/zh/srgnbq_97qvb6002zsr40tgc0000gn/T/tmp1zz1f1o9/project/capevolve.yaml is an EMPTY list. That wo...

All four grader files — the adapter, its model wiring, the spec and the answer key —
are covered by #197's layout defaults, with nothing declared. That only works because
run.sh places them inside the project dir and the spec names
dataset_source: tasks.jsonl.

6. The preflight actually fires

This is the guard against the bug I hit: a broken setup that looks like an honest 0.0.

$ CHEAP_REAL_API_BASE=http://localhost:59999 bash examples/cheap_real/run.sh
cheap_real preflight: the local Ollama endpoint did not answer (URLError). Start it (`ollama serve`) or set CHEAP_REAL_MODEL to a hosted model. Endpoint withheld (see docs/TROUBLESHOOTING.md).

$ CHEAP_REAL_MODEL=ollama/nonexistent-model bash examples/cheap_real/run.sh
cheap_real preflight: Ollama is up but does not have 'nonexistent-model'. Run `ollama pull nonexistent-model` (~2 GB), or set CHEAP_REAL_MODEL.

Note the first message withholds the endpoint deliberately, per #134's rule that a
non-default base_url is confidential.

7. Full suite, compileall, shell syntax, links

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

$ python -m compileall -q core examples && echo OK
OK

$ bash -n examples/cheap_real/run.sh && echo OK
OK

183 passed (179 baseline + 4 new), 0 failed. test_dashboard_launch.py (flaky per
#200) passed on this run.

Relative links in every touched doc resolve:

broken relative links: NONE

What I did NOT verify

  • The hosted-runner variant (CHEAP_REAL_MODEL=claude-haiku-4-5). Its ~$0.21 is an
    estimate, derived in the PR body from core/cap_evolve/pricing.py, and labelled
    as an estimate in the docs. I did not spend money to measure it.
  • Rung 3 (~$148). That figure is the committed tau2 run's own reported spend, quoted,
    not re-measured.
  • The SE=0 → STRICT fallback shown in every step above is real and documented in
    the example's README: at num_trials: 1 the accept decision is "Δ > 0", not a
    significance test. I did not paper over it.

@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

🔍 Review — PR #249

CHANGES REQUESTED — one blocking defect, and it is the exact failure mode this PR
exists to eliminate. The three bugs the PR found are real and well-documented, the cost
figures hold up to the arithmetic, the zoo declination is correct, and the free rung
reproduced on my machine to the digit. But the third advertised variant — the hosted
runner, the one for users with no local model — produces a clean-looking run whose every
number is 0.0
, on the very first command the docs give them, and this PR's preflight
does not fire. That is bug (a) reintroduced through the door the preflight does not
cover.


Blocking

B1 — examples/cheap_real/run.sh:43-63 — the hosted-runner variant is broken, and the
preflight is blind to it: a clean run with test_reward: 0.0 from an unconditional
seed kwarg litellm rejects.

templates/adapters/jsonl_litellm/adapter.py:78 forwards seed=seed to every
litellm.completion call. Anthropic does not accept seed. The adapter (correctly)
turns the failure into error: + reward 0.0, and run.sh's preflight only runs its
endpoint/pull checks under if MODEL.startswith("ollama/") (run.sh:50), so the hosted
path gets preflight: OK and nothing else. I ran the command the docs advertise
verbatim:

$ CHEAP_REAL_MODEL=claude-haiku-4-5 bash examples/cheap_real/run.sh
cheap_real preflight: OK
Working directory: /tmp/cr-hosted
Runner model: claude-haiku-4-5   Optimizer: mock
{
  "run_dir": ".capevolve/run_cheap",
  "best_id": "seed",
  "baseline_val": 0.0,
  "test_reward": 0.0,
  "test_baseline_reward": 0.0,
  "test_delta": 0.0,
  "test_pass_k": {"1": 0.0, "2": 0.0},
  "iterations": 2
}

Exit 0. Empty stderr. Zero warnings. The reason is only visible if you go read a rollout
file by hand:

$ cat .capevolve/run_cheap/rollouts/val/d05__cand_0001__t0.json
"error": "LLM call failed: litellm.UnsupportedParamsError: anthropic does not support
 parameters: ['seed'], for model=claude-haiku-4-5. To drop these, set
 `litellm.drop_params=True` ..."

Consequence: this is precisely bug (a) from the PR body — "a broken run made
indistinguishable from an honest 0.0"
— shipped, in the variant the docs offer as the
fallback for the user with no Ollama. And it is worse than (a) was, because the preflight
now prints OK and so actively vouches for the run. This example's whole reason to exist
is being the rung a newcomer can trust; the newcomer without a 2 GB local model gets a
$0-but-silently-dead run and no way to tell.

Three separate places advertise this exact command as working: README.md:39,
docs/GETTING_STARTED.md:118, site/getting-started.html:130 — all as "one env var, no
code change"
.

Note the PR body says of the hosted variant "I did not run this variant" — that is
honestly stated, and the ~$0.21 figure is correctly labelled an ESTIMATE. But
"unmeasured" is being used to cover "unexercised", and running it costs a fraction of a
cent, not money worth withholding. It is also the one claim in this PR whose failure
mode is invisible.

Fix (cheapest that holds, in run.sh, no core change):

export LITELLM_DROP_PARAMS=1   # litellm honours this; unsupported kwargs are dropped

or, if you prefer to fail loudly rather than fix silently, extend the preflight past the
ollama/ branch to do one real 1-token completion through model_config.llm_kwargs()
and exit non-zero on error. Either way, run the hosted variant once before merge — it
costs under a cent and it is the only variant nobody has exercised. Please also check
whether run.sh:35's unconditional export API_BASE=http://localhost:11434 reaches a
hosted provider: model_config.py:95 reads the generic API_BASE first, before any
provider special-casing, so a hosted MODEL inherits a localhost api_base unless the
user also clears it — a second trap on the same path. That one deserves a line in the
docs at minimum.


Non-blocking

N1 — examples/cheap_real/capevolve.yaml:63 — the spec's own comment contradicts the
PR's (correct) derivation.
It says "3 iterations x 5 val tasks x 1 trial = 15 runner
calls"
. The real number is 30, as the PR body derives and as state.json reports; the
comment omits the baseline val eval (5) and both test evals (10). A future reader sizing
the budget from the file rather than the PR gets half the truth. Use the PR's own
derivation here.

N2 — examples/cheap_real/run.sh:14 — header says CHEAP_REAL_MAX_USD defaults to
"capevolve.yaml: 2.0"; capevolve.yaml:70 says max_usd: 3.0.
Stale after the cap was
raised. One-character class of error, but this is a budget number in a PR whose subject
is budget figures.

N3 — examples/cheap_real/README.md:17 and docs/GETTING_STARTED.md:15,30 cite "36 s
measured" for the free rung; the PR's own evidence comment shows real 0m30.423s and I
measured 33.9 s.
Neither is 36. Not a material claim — all three are "half a minute" —
but #238 got dinged in this very epic for presenting single-run timing noise as a
measurement, and quoting a third number that appears in neither run is the same class of
sloppiness. Either say "~30 s" or state the spread.

N4 — core/tests/test_cheap_real_preset.py docstring:11-13 and capevolve.yaml:4-7
both assert "a present protected_paths list REPLACES the layout defaults".
True, and
I verified it (protect.py:145-171 on #197's branch: a non-empty declared list returns
out alone and never reaches [*_DEFAULT_GLOBS, *extra]). But the reason given for
omitting the key is incomplete in a way that will mislead: what omission actually buys is
[*_DEFAULT_GLOBS, *spec['dataset_source']] — the dataset is covered because it is
omitted
, i.e. dataset_source is folded into the default set at protect.py:169, not
because tasks.jsonl matches any default glob (it matches none; _DEFAULT_GLOBS is
adapters, capevolve.yaml, and *gold* data suffixes). The current wording reads as
though the defaults cover the answer key on their own. Worth one clarifying clause,
because it is the load-bearing fact.

N5 — examples/cheap_real/capevolve.yaml:24,51proposer_model and dataset_source
are read by nothing in core/ today.
The spec's own comments say so for
dataset_source (line 48-50) and explain proposer_model as forward-compat with #132,
so this is documented, not hidden. Flagging only so it is a conscious call: two of the
preset's 20-odd keys are inert on main, and one of them (dataset_source) only becomes
load-bearing once #197 merges.

N6 — untested paths. The 4 new tests are all static file reads of the preset (see
"Test quality" below). Nothing tests run.sh itself: not the preflight branches, not the
YAML-rewrite SED heredoc at run.sh:84-104, not the absolute-path append. The
YAML-rewrite is the one I would want a test on — it is a line-prefix string rewriter over
a config file (line.startswith("optimizer_skill:") etc.), it is the fix for bug (c),
and it silently no-ops if a key is ever indented or renamed. A ~10-line test that runs
the heredoc over the committed preset and asserts the three resulting keys would pin it.


Nits

  • examples/cheap_real/README.md:24 — "all 30 runner calls were free because the runner
    is local" is right, but the free rung's state.json shows runner_tokens: 3863 and
    runner_seconds: 29.5, i.e. the runner cost is time, not $0 across the board. Fine
    as written; just noting the $0 is a metering artifact, not a physics claim.
  • test_pass_k: {"2": 0.0} at num_trials: 1 is pre-existing Report pass^k/pass@k as N/A (not 0.0) when k > num_trials #112 (pass^k should read
    N/A above num_trials), not this PR's. But this PR is putting that JSON in front of
    first-time users on three doc pages, so it is now a first-impression bug. Consider a
    one-line note in the README's "Honest limits" until Report pass^k/pass@k as N/A (not 0.0) when k > num_trials #112 lands.

Do the cost figures hold up?

Yes — every number reproduces, and the accounting model is the right one. This is the
most carefully-costed PR in the batch.
Six independent checks:

1. The 30-call derivation is arithmetically correct AND matches the spec's actual
values.
I re-derived it from the committed preset rather than from the PR's prose:

val 5 x trials 1 x (1 baseline + 3 candidates)  = 20
test 5 x (best FINAL + baseline FINAL_seed)     = 10
                                          total = 30

And I confirmed each of the six evals independently from my own run's events.jsonl,
because a formula that happens to total 30 is not the same as 30 calls actually happening
in that shape:

$ grep -o '"kind": "evaluate"[^}]*' state events
"split": "val",  "tag": "seed",       "reward": 0.0
"split": "val",  "tag": "cand_0001",  "reward": 1.0
"split": "val",  "tag": "cand_0002",  "reward": 1.0
"split": "val",  "tag": "cand_0003",  "reward": 1.0
"split": "test", "tag": "FINAL",      "reward": 1.0
"split": "test", "tag": "FINAL_seed", "reward": 0.0

Six evals × 5 tasks × 1 trial = 30. The "best + baseline seed" term the PR asserts for
the test split is real (FINAL + FINAL_seed), not a fudge to make the total land on
30. harness.py:307 credits len(tasks) * n_trials per eval, which is the same
arithmetic.

2. My own free-rung run reports metric_calls: 30 — independently, on a different
machine, three weeks later.

$ cat /tmp/cr-canary/.capevolve/run_cheap/state.json | jq .spent
{"iterations": 3, "metric_calls": 30, "usd": 0.0, "stall": 2,
 "runner_tokens": 3863, "runner_seconds": 29.5, "optimizer_usd": 0.0}

3. The three per-proposal costs sum to the total, to 1.5e-07.

$ python -c "print(sum([0.159017,0.198835,0.184818]))"
0.54267
$ # state.json optimizer_usd: 0.5426701500000001   → diff = 1.5e-07

Exact to float rounding. The three opt_cost_usd values in the step events are the
$0.5427, with nothing unaccounted.

4. Is $0.5427 the total or only the optimizer share? Both — and that is verifiable,
not a coincidence.
rundir.py:135-137: total_usd = usd + optimizer_usd + intake_usd.
The run reports usd: 0.0 (local runner, unmetered) and intake_usd: 0.0 (no intake
phase), so total_usd == optimizer_usd == 0.5427. The PR calls it "total" and the README
calls it "entirely the 3 proposals" — both are correct for this configuration, and the
PR says why (line: "all 30 runner calls cost $0 because the runner is local"). This is
the accounting gap #234's review found (a burn meter understating GEPA spend 3.9× by
reading one role's field as the total) and this PR does not have it: it names the role,
shows the other roles are zero, and the identity closes.

5. The $0.21 hosted-runner ESTIMATE reproduces from the stated inputs.

$ python -c "print(30*(3000*1.0+800*5.0)/1e6)"
0.21

Correctly labelled ESTIMATE in the PR body, in GETTING_STARTED.md:40-44, and excluded
from the "measured" columns. No estimate is presented as a measurement anywhere in this
PR — which, given this epic's history, is worth stating explicitly.

6. The one figure I could not check, and the one that is slightly off. Rung 3's ~$148
is quoted from RESULTS.md, not re-measured — appropriately, and the PR says so. The
"36 s" free-rung timing is the one number that reproduces neither in the PR's own
evidence (30.4 s) nor mine (33.9 s) — see N3. Non-material; the cost figures, which are
what this section is about, are clean.

Verdict on the figures: they hold. The derivation is independently correct, the
call count reproduces on a fresh machine, the per-proposal costs sum to the total, the
accounting model is the all-roles one, and estimates are labelled. I went looking for the
#234 accounting gap and the #238 single-run-noise problem and found neither in the money
numbers.


Is this a real optimization or a formatting fix?

It is a real optimization, and I'll defend that against the obvious objection — but the
PR is overselling it as an optimization demo when its actual value is as a pipeline
demo
, and one number in it is a red flag the PR does not flag.

The objection is fair on its face: the seed scores 0 only because it answers in prose,
the fix is an output contract, and mock_script.json proves a hard-coded one-line append
solves it. That is a formatting fix by any plain reading.

Why it is nonetheless real:

  • The gain is a general rule scored on held-out data. The accepted edit
    ("four-digit year, two-digit month, two-digit day. Output only the date.") is scored on
    a sealed test split of 5 dates the optimizer never saw, and the optimizer's
    INSTRUCTIONS.md:22-24 explicitly forbids memorizing a date with the tamper guard
    behind it. Baseline 0.0 → test 1.0 on unseen inputs is a real generalizing improvement,
    not a lookup.
  • "Formatting" is not a lesser category of prompt optimization — it is the modal
    finding.
    Output-contract violations are the single most common reason a capable model
    scores 0 on a benchmark it can actually do. Demonstrating the machinery on the most
    common real failure class is a feature.
  • The pipeline exercised is the full one, not a subset. Real LLM rollouts, real paired
    gate, real accept/reject, real sealed finalize, real tamper guard, real spend
    accounting. Nothing is stubbed but the proposer, in the free rung, and the PR labels
    that.

Where the PR oversells, and what it should have said:

The two rejected iterations prove nothing about the gate, and the PR presents them as
if they do.
It says the gate "correctly rejected the two that followed" — true but
vacuous: val was already 1.0, so there was nothing to reject between. The run
demonstrates one accept from a floor and two no-ops from a ceiling. It never once
demonstrates the thing the gate exists for: rejecting a plausible-looking candidate
that does not actually beat the parent.
A task that saturates at 1.0 on iteration 1
cannot show that, and this one does, twice over (both the free and the paid rung end at
1.0 after one step).

Compounding it: at num_trials: 1 the gate reports SE=0 → STRICT fallback, so the
decision is Δ > 0, not a significance test. The PR does disclose this plainly
(README:79-83, PR body "Honest limits") — real credit for that, most PRs would have
buried it. But stack the two facts and the honest description of rung 2 is: a real LLM,
a real sealed test, and a gate that was never asked a hard question.
"Real paired gate"
appears five times across the PR body, README and docs; it is true that the gate ran, and
misleading as a claim that the gate was demonstrated.

Verdict: a real optimization on a task too easy to exercise the interesting half of the
machinery.
As "the cheap rung that proves the pipeline runs end to end on a real model
for $0", which is what #124 asked for, it delivers. Two changes would close the gap
without adding cost: (1) drop "real paired gate" to "real accept/reject decision" and add
one sentence to Honest limits saying the run never exercises a rejection on merit
because val saturates at iteration 1; (2) consider whether a slightly harder task — a few
ambiguous or malformed dates that a 3B model gets wrong even with the contract — would
land baseline ~0.0 → best ~0.6 instead of 0.0 → 1.0, at identical cost and runtime, and
give the gate something to actually decide. (2) is a suggestion, not a request; (1) I
would want before merge.


Was declining the zoo right?

Yes, and the claim is accurate against #233's code — I verified it rather than taking
it.
core/cap_evolve/zoo.py:1111-1153 on origin/feat/issue-141-benchmark-zoo:

for attempt in (1, 2):
    ...
    got[t.id] = (_rollout_fingerprint(r), round(float(s.reward), 9))
    passes.append(got)
drift = sorted(k for k in passes[0] if passes[0][k] != passes[1][k])
if drift:
    rep.problems.append(f"NON-DETERMINISTIC: {len(drift)} task(s) produced a different
        rollout or reward on an identical re-run with seed=0 ...")

Every val task, twice, and _rollout_fingerprint (zoo.py:972-982) is a SHA-256 over
the whole rollout.to_dict() with only cost_usd/tokens popped. So the comparison is
byte-exact on the model's output text. A real LLM cannot satisfy that: temperature 0
is not a determinism guarantee across batching, kv-cache state or server version, and
_rollout_fingerprint has no tolerance knob — the exclusion list is two keys, hardcoded.
There is no allow_nondeterministic escape either; I grepped: the only opt-out flag in
the manifest schema is allow_saturated_baseline (zoo.py:120), which addresses a
different problem. So the PR's claim — unsatisfiable for a live model, and admitting
this benchmark would mean weakening the invariant
— is literally correct.

Could a tolerance have accommodated it? Not cheaply. The natural knob is
"reward-only, ignore the rollout hash", but the reward also drifts for a real model
(different phrasing → different exact-match score), so the tolerance would have to be a
numeric band on the mean reward — which is a different guarantee (statistical stability)
wearing determinism's name. Building that to admit one example is the wrong trade.

Where I'd push slightly: the PR frames this as binary ("the zoo keeps its invariant"),
but the durable answer is the one the task brief names — the zoo eventually needs an
explicit non-deterministic grade, because cheap_real is the first of a class, not a
one-off. Every future live-model benchmark hits this same wall. The right shape is a
declared determinism: exact | statistical | none in the manifest, where statistical
substitutes a variance check for the fingerprint check and the zoo reports the grade
rather than pretending everything in it is exact. That is #233's or a follow-up's work,
not this PR's — but the README's "the zoo keeps its determinism guarantee intact"
(README:75) reads as though the question is settled, and it is deferred. One sentence
pointing at the open shape would age better.

On consistency with #244: both declinations are on the same principle — don't weaken a
host's invariant to admit one guest — and both are argued from the host's actual code
rather than asserted. Consistent, and correctly so.


The relative-instructions-path bug

Mechanism confirmed on main. Workaround acceptable in this PR. Yes, it needs its own
issue — and it is worse than the PR describes.

core/cap_evolve/cli.py:393-398 (unchanged on main; this PR touches zero core files —
git diff origin/main...HEAD -- core/ returns only the new test file):

instr = spec.get("optimizer_instructions_file") or "optimizer/INSTRUCTIONS.md"
instr_p = Path(instr)
if not instr_p.is_absolute() and not instr_p.exists():
    instr_p = Path(project) / instr        # ← project is RELATIVE: ".capevolve/project"
if instr_p.exists():
    alg_cmd += ["--instructions-file", str(instr_p)]

project is deliberately relativized at cli.py:194-196 (proj_abs.relative_to(workdir)
→ the literal string ".capevolve/project"), and both probes — instr_p.exists() and
Path(project) / instr — resolve against the cwd of cap-evolve run, which is not
workdir. Reproduced:

cwd            = /private/tmp
workdir        = /private/tmp/bugc/proj
project (rel)  = .capevolve/project
instr_p abs?    False   exists from cwd? False
fallback       = .capevolve/project/MY_INSTRUCTIONS.md   exists? False
=> --instructions-file passed? False

Then the failure is silent by construction, at two layers:

  1. cli.py:397if instr_p.exists() — a missing path simply omits the flag. No
    warning, no log line.
  2. harness.py:1790tmpl_path = Path(instructions_file) if instructions_file else _DEFAULT_INSTRUCTIONS_TEMPLATE, and harness.py:1792-1795 wraps the read in a
    bare except Exception: tmpl = None — so even a present but unreadable file
    degrades quietly to the generic template.

And _DEFAULT_INSTRUCTIONS_TEMPLATE (templates/project/optimizer/INSTRUCTIONS.md) is
not a neutral fallback — it is tau2-shaped, and aggressively so: "EDIT BOTH ARTIFACTS,
AND PREFER CODE"
, "leaving tools.py logic untouched … is an under-used iteration".
So a project with no tools gets told its iteration is wasted unless it edits tool code
that does not exist. The PR's account of the consequence — the optimizer proposed a
date-trivia assistant, i.e. optimized the wrong task — is entirely plausible given that
text.

Severity beyond what the PR claims: skills/phases/implement-and-check/scripts/pipeline_selftest.py:73-77
resolves the same key differentlyinstr_path = project / instr_rel, project-dir-relative,
unconditionally — and problems on a missing file. So cap-evolve check passes a
relative optimizer_instructions_file that cap-evolve run then silently ignores. The
gate designed to catch this disagrees with the runtime about what the path means. That is
the part that makes it a latent sharp edge for every user, not just out-of-tree workdirs:
you can pass the hard gate and still optimize against the wrong instructions.

Workaround: acceptable here. run.sh:96-104 appends an absolute
optimizer_instructions_file and documents why in six lines of comment. I verified it
resolves (absolute: True exists: True => --instructions-file passed: True). Widening
this PR into cli.py would put a core behavior change inside a docs-and-example PR that
currently has the rare virtue of touching zero core files — right call.

File the issue. Suggested fix, one line: resolve against the absolute project dir
and warn when the declared file is missing rather than silently omitting it —
instr_p = (proj_abs / instr) when not absolute, plus else: print(..., file=sys.stderr)
on the miss. The except Exception → generic template at harness.py:1792 deserves the
same treatment: a declared-but-unreadable instructions file should be loud. Include the
pipeline_selftest.py disagreement in the issue — the two resolvers should share one
helper, or the gate is checking a different thing than the runtime does. I'd call it P1:
silent, produces confidently wrong optimization, and passes the gate meant to catch it.


Canary sweep

Mine, not the PR's. Six shapes across two secret-looking names, two innocent-looking
names, and URL userinfo:

ANTHROPIC_AUTH_TOKEN  = sk-ant-CANARY-AAAA1111
ANTHROPIC_BASE_URL    = https://user:pw-CANARY-BBBB2222@gw.canary-host.invalid/v1
MY_INNOCENT_SETTING   = CANARY-CCCC3333
DEPLOY_REGION         = CANARY-DDDD4444
OPENAI_API_KEY        = sk-CANARY-EEEE5555
CAPEVOLVE_HOME_NOTE   = CANARY-FFFF6666

Full free-rung run with all six exported, then swept every written file plus both
streams:

$ grep -rIl -E 'CANARY-(AAAA1111|BBBB2222|CCCC3333|DDDD4444|EEEE5555|FFFF6666)|canary-host|pw-CANARY' .
(no output)
$ grep -c -E 'CANARY|canary-host' /tmp/cr-stdout.txt /tmp/cr-stderr.txt
/tmp/cr-stdout.txt:0
/tmp/cr-stderr.txt:0
$ find . -type f | wc -l
     116

Zero leaks across 116 written files, stdout and stderr. The URL-userinfo shape
(user:pw@host) is the one that usually slips through a naive redactor and it did not
appear either. I also confirmed the preflight's deliberate withholding actually holds
under a failure, which is the case where an endpoint most wants to escape:

$ CHEAP_REAL_API_BASE=http://localhost:59999 bash examples/cheap_real/run.sh
cheap_real preflight: the local Ollama endpoint did not answer (URLError). Start it
(`ollama serve`) or set CHEAP_REAL_MODEL to a hosted model. Endpoint withheld
(see docs/TROUBLESHOOTING.md).

Type name only, no URL. Correct per #134.

I confirm the separate pre-existing committed-hostname finding the PR reports elsewhere in
the repo; per the review brief I am not naming the file or the host here, and it is not
this PR's to fix.


#195 / #197 compliance

Both reproduce on the sibling branches.

#195 — against origin/fix/issue-113-small-samples
(core/cap_evolve/splits.py:22,27), using the committed tasks.jsonl and the committed
ratios:

MIN_VAL_TASKS=2  LOW_CONFIDENCE_VAL_TASKS=5
cheap_real realized split: train=10 val=5 test=5
check_val_size warning: None

val=5, both floors cleared, zero warnings — exactly as claimed. And the control, so
"None" means something: a 12-task dataset at the same ratios lands val=3 and does warn
('val split (control) has only 3 tasks (< 5) — acceptance decisions are LOW CONFIDENCE...').

The test genuinely pins the floor — it is not a comment. I dropped tasks.jsonl to
12 lines and re-ran:

E  AssertionError: cheap_real val split is 3, below 5: every gate decision would be
   branded LOW CONFIDENCE. Add tasks rather than lowering this.
E  assert 3 >= 5
1 failed, 3 passed in 0.06s

Fails, on the right assertion, with a message that tells you what to do. This is the
correct way to pin a load-bearing constant and I'd like to see it copied.

One caveat: the test's getattr(splits, "MIN_VAL_TASKS", 2) fallback (line 51-52) means
that on main — where the constants do not exist — it asserts against hardcoded 2/5
rather than the real values. Deliberate and documented in the comment, and it makes the
test pass on both branches, which is the right trade pre-merge. Worth deleting the
fallback once #195 lands so the test tracks the real constants; otherwise it silently
stops testing the thing if #195 ever changes them.

#197 — against origin/feat/issue-142-protected-paths. protected_paths is absent
from the preset (confirmed: grep protected_paths examples/cheap_real/capevolve.yaml
no match), and protect.py:145-171 confirms all three claims:

  • empty list → TamperError (line 152-157) ✓
  • non-empty declared list → return out, defaults never reached (line 150-151) ✓
  • omitted → [*_DEFAULT_GLOBS, *extra] where extra pulls in dataset_source (line
    168-170) ✓

So the four grader files are covered: adapters/adapter.py and
adapters/model_config.py via the adapters default, capevolve.yaml via its own
default, and tasks.jsonl via the dataset_source fold-in. run.sh:73-78 is what makes
that true — all four are copied inside the project dir. Correct, and the reasoning in
run.sh:67-72 is accurate. See N4 for the one clarification I'd want in the wording.


The #248 entry point

The contract holds, including under #217. From my own run:

$ python -c "<scan stdout for JSON objects>"
objects on stdout: 1
last object keys: ['baseline_val','best_id','dashboard','iterations','run_dir',
                   'test_baseline_reward','test_delta','test_pass_k','test_reward']

Exactly one JSON object on stdout — #217's two-object bug does not fire here, because
run.sh:114 passes --dashboard "${CAPEVOLVE_DASHBOARD:-off}", and off skips the
maybe_launch print at cli.py:205-207 that #217 is about. Worth noting that this is
incidental immunity: a user who sets CAPEVOLVE_DASHBOARD=auto gets two objects and
their parse breaks. Since run.sh is being sold as the programmatic entry point, one line
of comment at run.sh:114 saying the off default is load-bearing for the
single-object contract (until #217 lands) would stop someone "improving" it later.

A naive whole-stdout json.load still fails, because three human-readable lines precede
the object (preflight: OK, Working directory:, Runner model:). That is consistent
with #116's convention only in spirit — those three lines are progress output and belong
on stderr; they are on stdout. The documented contract is "the last object on
stdout", which is honestly stated in run.sh:7 and README.md:42-43, and #248 can
implement it in three lines. But moving the three echos to >&2 would make the whole of
stdout parseable and cost nothing. Recommended, non-blocking.

Could #248 genuinely call it? Yes — every knob is an env var with a default
(run.sh:25,34-36,65,80), CHEAP_REAL_WORKDIR lets the caller pin the location, and exit
codes are meaningful (I got exit 1 with a single-line reason from three separate preflight
failures). I checked origin/feat/issue-133-quickstart and it does not reference
cheap_real yet — but core/cap_evolve/quickstart.py:67 already carries the comment
"#124's cheap-real-run preset is one more row", so the seam is planned from the other
side. Caveat: if #248 adds the hosted variant as a preset row, it inherits B1 — a
preset that silently returns all-zeros is much worse inside quickstart than in an
example dir.


Test quality

4 new tests in core/tests/test_cheap_real_preset.py, all asserting outcomes, not
that code ran:

  • test_split_clears_the_honest_gate_floors:39 — runs the real make_splits with the
    committed ratios and asserts val >= hard/soft, plus train/val disjointness and test
    isolation. Verified it fails on a shrunk dataset (above). The strongest of the four.
  • test_protected_paths_is_omitted_not_empty:64 — asserts absence. Coarse (a line-prefix
    scan, so an indented protected_paths: inside another block would false-positive),
    but it pins the right invariant.
  • test_dataset_source_names_the_real_file:73 — asserts the value is tasks.jsonl, which
    is what makes Protected-paths tamper guard: verify the optimizer never edited scoring/eval/task files #197's fold-in cover the answer key. Right assertion.
  • test_budget_is_bounded:79 — bounds max_usd, max_optimizer_usd <= max_usd, and
    max_iterations <= 5. Would catch someone raising the cap toward rung 3.

They are cheap (0.03 s, no model) and each one fails for a real reason. Untested: all of
run.sh — see N6. The gap that matters is the YAML-rewrite heredoc, which is the fix for
bug (c) and is unpinned.

On bug (b): capevolve.yaml:30 is now optimizer_max_turns: 40 with the measured reason
in a comment (lines 25-29), and optimizer_usd_per_iter: 1.0 is likewise set above the
observed spend for the same reason (lines 31-36). A user copying this example gets 40, not
12, so the trap is not reachable from the committed preset. Nothing pins it — a test
asserting optimizer_max_turns >= 40 would be one line in the existing file and would
stop a future "tighten the caps" edit from silently reintroducing a run where every
candidate is discarded. Non-blocking, but it is the same shape as the floor test that this
PR got right, so it is a cheap consistency win.


Merge-order note

Recommend: #195#197#233#249#248.


Verification I re-ran

$ gh pr view 249 --json headRefName
{"headRefName":"feat/issue-124-cheap-onramp"}   # 8f727e8e

$ PYTHONPATH=/tmp/rv-249/core python -m pytest core/tests -q
........................................................................ [ 39%]
........................................................................ [ 78%]
.......................................                                  [100%]
183 passed in 63.07s (0:01:03)

183 passed, 0 failed — matches the claim exactly (179 + 4). test_dashboard_launch.py
(flaky per #200) passed on this run.

$ python -m compileall -q core examples && echo COMPILEALL_OK
COMPILEALL_OK
$ bash -n examples/cheap_real/run.sh && echo BASH_N_OK
BASH_N_OK

Zero-cost path, end to end, on a real local model:

$ CHEAP_REAL_WORKDIR=/tmp/cr-canary CHEAP_REAL_PYTHON=/tmp/ce-venv/bin/python \
    time bash examples/cheap_real/run.sh
cheap_real preflight: OK
Working directory: /tmp/cr-canary
Runner model: ollama/llama3.2:3b   Optimizer: mock
{
  "run_dir": ".capevolve/run_cheap",
  "best_id": "cand_0001",
  "baseline_val": 0.0,
  "test_reward": 1.0,
  "test_baseline_reward": 0.0,
  "test_delta": 1.0,
  "test_pass_k": {"1": 1.0, "2": 0.0},
  "iterations": 3,
  "dashboard": ".capevolve/run_cheap/dashboard.html"
}
33.947 total

Reproduces the PR's free-rung result exactly (same best_id, same
baseline_val/test_reward, same iterations) in 33.9 s at $0. I spent no money.

All three preflight branches fire:

$ bash examples/cheap_real/run.sh          # system python3, no litellm
cheap_real preflight: litellm is not importable by this interpreter. `pip install
litellm`, or point CHEAP_REAL_PYTHON at one that has it.

$ CHEAP_REAL_MODEL=ollama/nonexistent-model bash examples/cheap_real/run.sh
cheap_real preflight: Ollama is up but does not have 'nonexistent-model'. Run
`ollama pull nonexistent-model` (~2 GB), or set CHEAP_REAL_MODEL.

$ CHEAP_REAL_API_BASE=http://localhost:59999 bash examples/cheap_real/run.sh
cheap_real preflight: the local Ollama endpoint did not answer (URLError). ...

All three exit non-zero before spending anything. To answer the brief's question
directly: yes, the preflight checks the model is actually pulled, not merely that
litellm imports
run.sh:53-61 hits /api/tags and set-compares the model name
(with a :latest fallback), and the "does not have" branch above is that check firing.
That is a genuinely better guard than "does the import work". Its scope limit is B1: the
whole block is inside if MODEL.startswith("ollama/"), so the hosted path gets none of
it.

Bug (c) mechanism, on main's cli.py:393-398 — reproduced above under "The
relative-instructions-path bug"; --instructions-file is silently omitted and
harness.py:1790 falls back to the tau2-flavored generic template.

#195 floors, #197 defaults, the shrunk-dataset control, the 30-call breakdown, the
per-proposal sum, and the canary sweep
— all pasted in their sections above.

Docs links:

broken relative links: NONE

B1 (blocking). `CHEAP_REAL_MODEL=claude-haiku-4-5` — the fallback offered in three
docs for users with no local model — produced `preflight: OK`, exit 0, empty stderr
and `test_reward: 0.0` on every task. Two causes, both fixed in run.sh:

* the bundled adapter forwards `seed=`, which Anthropic rejects
  (`UnsupportedParamsError`); the adapter correctly turns that into reward 0.0, so
  the whole rung scored zero behind a clean-looking run. `export LITELLM_DROP_PARAMS=1`.
* every preflight check sat inside `if MODEL.startswith("ollama/")`, so the one
  documented path nobody had exercised was the one path the preflight did not cover —
  and it printed OK, actively vouching for the dead run. The hosted branch now does one
  real 1-token completion through `model_config.llm_kwargs()` and exits non-zero on any
  failure, with the endpoint and every credential redacted from the message.

Secondary trap on the same path: `API_BASE=localhost:11434` was exported
unconditionally and `model_config.py:95` reads the generic `API_BASE` before any
provider special-casing, so a hosted MODEL inherited a localhost endpoint. Now exported
only for an `ollama/` MODEL.

MEASURED, hosted variant, run for real: 98 s, $0.011478, 30 metric_calls,
baseline_val 0.0 -> sealed test_reward 1.0. The six eval costs sum to the total
exactly. That retires the prior $0.21 ESTIMATE, which was 20x high because
pricing.py assumes a 3000-in/800-out rollout and a one-line date is far smaller.

Review findings also addressed:

* Dropped "real paired gate" (5x) for "real accept/reject decision", and added an
  Honest-limits entry stating plainly that this run never rejects on merit: val
  saturates at 1.0 on iteration 1, so the two later rejections are against a ceiling,
  and at num_trials 1 the decision is Δ > 0, not a significance test.
* Three progress lines moved to stderr (#116), and the single-object stdout contract no
  longer depends on `--dashboard off` defaulting: run.sh filters the CLI's stdout to the
  last object, so `json.loads(stdout)` works under `CAPEVOLVE_DASHBOARD=auto` too (#217).
* Corrected the #197 wording. `tasks.jsonl` matches NO default glob; the answer key is
  covered by the `dataset_source` fold-in at protect.py:169, which only happens when
  `protected_paths` is omitted. Declaring the key drops it — verified.
* Corrected the call-count comment in the preset (15 -> 30, with the derivation) and
  the stale CHEAP_REAL_MAX_USD default in run.sh's header (2.0 -> 3.0).
* Replaced the "36 s" figure, which appeared in neither measurement, with "~30 s" and
  the observed spread (30.4 / 33.9 / 34.2 s).
* Softened the zoo line: the determinism guarantee question is deferred, not settled —
  named the `determinism: exact|statistical|none` grade #233 will need.
* Noted the pass^k `{"2": 0.0}` artifact as pre-existing #112, and that the local
  rung's $0 is a metering artifact (the runner's cost there is time).
* Two new tests: the run.sh YAML-rewrite heredoc over the committed preset (it is a
  line-prefix rewriter, so a no-op means the paid rung runs `mock`), and the measured
  `optimizer_max_turns >= 40` floor. Both verified to fail on a broken invariant.

Declined, filed instead: the relative `optimizer_instructions_file` bug is #252 (the
absolute-path workaround stays, with the `check`-passes-what-`run`-ignores disagreement
named in the comment); the optimizer stderr discarded on success is #251 — the reason a
warning alone would not have surfaced this bug.
Copilot AI review requested due to automatic review settings July 31, 2026 01:11

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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

@OsherElhadad

Copy link
Copy Markdown
Collaborator Author

🔧 Review fixes

Commit 7a91227. Every claim below is pasted literal output. The deliverable first: the
hosted variant has now been run for real, and it reaches a non-zero sealed number.


B1 (BLOCKING) — fixed. The hosted variant, run once for real.

You were right on both counts, and on the part that stung: the preflight printing OK
made it worse than the bug it was built to prevent. Two causes, both in run.sh, no core
change:

  1. export LITELLM_DROP_PARAMS=1 — litellm honours it at litellm/__init__.py:227, so
    the seed= the bundled adapter forwards is dropped for providers that reject it.
  2. The preflight's else branch now covers the hosted path with one real 1-token
    completion through model_config.llm_kwargs() and the same seed= kwarg the adapter
    uses
    — the wiring, not a proxy for it.

The hosted run. CHEAP_REAL_MODEL=claude-haiku-4-5, credentials read from the
environment and never printed:

$ CHEAP_REAL_MODEL=claude-haiku-4-5 bash examples/cheap_real/run.sh
cheap_real preflight: OK                          # ← on stderr now
Working directory: /tmp/fx-hosted-wd              # ← on stderr now
Runner model: claude-haiku-4-5   Optimizer: mock   # ← on stderr now
{
  "run_dir": ".capevolve/run_cheap",
  "best_id": "cand_0001",
  "baseline_val": 0.0,
  "test_reward": 1.0,
  "test_baseline_reward": 0.0,
  "test_delta": 1.0,
  "test_pass_k": {"1": 1.0, "2": 0.0},
  "iterations": 3,
  "dashboard": ".capevolve/run_cheap/dashboard.html"
}

1:38.13 total

baseline_val 0.0 → sealed test_reward 1.0, not the all-zeros you got. Its own
accounting:

$ jq .spent .capevolve/run_cheap/state.json
{
  "iterations": 3, "metric_calls": 30, "usd": 0.011478000000000002, "stall": 2,
  "runner_tokens": 3910, "runner_seconds": 91.76, "optimizer_seconds": 0.55,
  "optimizer_usd": 0.0, "optimizer_tokens": 0, "intake_usd": 0.0, "intake_seconds": 0.0
}

MEASURED: 98 s, $0.011478, 30 metric_calls. The six eval costs sum to the total
exactly, so nothing is unaccounted:

$ grep -o '"kind": "evaluate"[^}]*' events.jsonl
"split": "val",  "tag": "seed",       "reward": 0.0, "cost_usd": 0.003466
"split": "val",  "tag": "cand_0001",  "reward": 1.0, "cost_usd": 0.000681
"split": "val",  "tag": "cand_0002",  "reward": 1.0, "cost_usd": 0.000681
"split": "val",  "tag": "cand_0003",  "reward": 1.0, "cost_usd": 0.000681
"split": "test", "tag": "FINAL",      "reward": 1.0, "cost_usd": 0.000687
"split": "test", "tag": "FINAL_seed", "reward": 0.0, "cost_usd": 0.005282

$ python -c "print(0.003466+0.000681*3+0.000687+0.005282)"
0.011478            # state.json usd: 0.011478000000000002

And a rollout, to show the call actually landed rather than erroring into a 0:

$ cat rollouts/test/d02__FINAL__t0.json
{"input": "the 5th of July 1999",
 "rollout": {"output": "1999-07-05", "cost_usd": 0.000138, "tokens": 98, "error": null,
             "metadata": {"model": "claude-haiku-4-5", "seed": 0}},
 "score": {"reward": 1.0, "feedback": "correct"}}

This retires the $0.21 ESTIMATE — and it was 20x high. pricing.py assumes a
3 000-in/800-out rollout; a one-line date is far smaller. The docs now carry $0.0115 as
measured, and the estimate is gone rather than kept alongside. Note the seed evals
cost 8x the optimized ones ($0.003466 and $0.005282 vs $0.000681) — the unoptimized
prompt answers in prose and burns output tokens, which is the same fact the task's
premise rests on, now visible in the money.

The preflight fails loudly on the hosted path. A bad model name:

$ CHEAP_REAL_MODEL=claude-haiku-4-5-NONEXISTENT bash examples/cheap_real/run.sh
cheap_real preflight: the hosted model 'claude-haiku-4-5-NONEXISTENT' did not answer a
1-token probe (BadRequestError). Every rollout would score 0.0. Check the model name and
its credential; endpoint/keys withheld (see docs/TROUBLESHOOTING.md). Detail:
litellm.BadRequestError: LLM Provider NOT provided. ...

A missing credential (run under env -i, so no key is present at all):

$ env -i ... CHEAP_REAL_MODEL=anthropic/claude-haiku-4-5 bash examples/cheap_real/run.sh
cheap_real preflight: the hosted model 'anthropic/claude-haiku-4-5' did not answer a
1-token probe (AuthenticationError). Every rollout would score 0.0. ...

All five preflight branches, exit code and stdout cleanliness:

exit=1 stdout_bytes=0   <- system python3 (no litellm)
exit=1 stdout_bytes=0   <- CHEAP_REAL_API_BASE=http://localhost:59999
exit=1 stdout_bytes=0   <- CHEAP_REAL_MODEL=ollama/nonexistent-model
exit=1 stdout_bytes=0   <- CHEAP_REAL_MODEL=claude-haiku-4-5-NOPE   (new)
exit=1 stdout_bytes=0   <- hosted, no credential                    (new)

Note stdout is 0 bytes on every failure. It was not before: litellm writes some of
its own banners (Provider List: ...) straight to stdout, which would have landed in a
caller's JSON stream. The whole preflight block is now { ... } >&2.

The API_BASE trap is fixed. Your read of model_config.py:95 was exact — the
generic API_BASE is read before any provider special-casing, so a hosted MODEL
inherited localhost:11434. Now conditional:

MODEL=claude-haiku-4-5      API_BASE=<UNSET>
MODEL=ollama/llama3.2:3b    API_BASE=http://localhost:11434

CHEAP_REAL_API_BASE still wins if the user sets it explicitly, for a self-hosted
OpenAI-compatible endpoint.


The nine other findings

N1 — fixed. capevolve.yaml now carries your derivation, not the 15:

# 30 runner calls, plus 3 proposer calls. The full derivation — the baseline val eval
# and both test evals are easy to forget:
#   val  5 x trials 1 x (1 baseline + 3 candidates)  = 20
#   test 5 x (best FINAL + baseline FINAL_seed)      = 10
#                                             total  = 30   (= state.json metric_calls)

N2 — fixed. run.sh:16 now says 3.0, matching capevolve.yaml.

N3 — fixed. "36 s" is gone from all three places. Replaced with "~30 s" plus the
spread, and stated as spread rather than as a figure: "30.4 s, 33.9 s and 34.2 s across
three runs on two machines — single-run timing is noise at this scale, so treat it as
'about half a minute', not as a figure."
You are right that this is the #238 shape.

N4 — fixed, and this was the most useful correction in the review. My wording implied
the defaults cover the answer key. They do not. Verified against #197's branch:

=== OMITTED key (what the preset ships)
globs   : ['adapters', 'capevolve.yaml', '*gold*.json', ..., 'tasks.jsonl']
resolved: ['adapters/adapter.py', 'adapters/model_config.py', 'capevolve.yaml', 'tasks.jsonl']

tasks.jsonl matches a _DEFAULT_GLOB on its own? False
    <- it is the dataset_source fold-in that covers the answer key

=== EMPTY list -> TamperError: `protected_paths` ... is an EMPTY list ...
=== DECLARED [adapters] resolved: ['adapters/adapter.py', 'adapters/model_config.py']
    <- tasks.jsonl DROPPED

That third line is the one that makes the point: declaring the key at all, even
correctly, silently drops the dataset. The README, the spec comment and the test
docstring now all say the fold-in at protect.py:168-170 is the mechanism.

N5 — acknowledged, no change. A conscious call, as you read it: dataset_source is
inert on main and load-bearing after #197, proposer_model is forward-compat with
#132. Both documented in the spec.

N6 — fixed, on the path you named. A test that runs the real heredoc (extracted
from run.sh by regex, so it cannot drift from a copy) over the committed preset:

$ # with optimizer_max_turns lowered to 12 and optimizer_skill indented one space
E  AssertionError: optimizer_max_turns below 40 was MEASURED to make every iteration
   fail with `Reached max turns`, so no candidate survives. Raise it back.
E  assert 12 >= 40
E  AssertionError: the optimizer_skill flip no-opped
2 failed, 4 passed in 0.04s

Both new tests fail on the real breakage, in the shape you praised on the floor test.
The heredoc test also asserts the instructions path is absolute, referencing #252. And
optimizer_max_turns >= 40 is the one-line consistency win you asked for.

Nit 1 — fixed. The README now says the local rung's 30 calls were "metered at $0
... the runner's cost there is time (29.5 s, 3 863 tokens), not zero across the board".

Nit 2 — fixed. New Honest-limits entry naming {"2": 0.0} as pre-existing #112 and
telling the reader to ignore the "2" entry until it lands.


The gate claim — you were right, and here is the corrected wording

I over-claimed and the two "correct rejections" I touted are vacuous, exactly as you
say: val was already 1.0, so there was nothing to reject between. "Real paired gate"
is gone from all five places, replaced with "a real accept/reject decision". And
Honest limits now says it outright rather than leaving it to be inferred:

This run never exercises a rejection on merit. Val saturates at 1.0 on iteration 1,
so the two candidates that follow are rejected against a ceiling — there was nothing
left to improve, not a plausible candidate found wanting. The gate ran and made real
decisions; it was never asked a hard question. A task the model still gets partly wrong
with the output contract would land baseline ~0.0 → best ~0.6 at the same cost and
give the gate something to decide. Do not read this example as a demonstration that
the significance machinery works — read examples/skillsbench (num_trials: 3) for
that.

Same line in GETTING_STARTED.md. On your suggestion (2) — a harder task — I agree it
would be strictly better and I am not doing it in this PR: it changes the committed
dataset, and therefore the measured numbers in three docs and the split test, on the last
turnaround of a PR whose blocking bug was in the variant nobody had run. It belongs in a
follow-up with its own measurement, not bundled here. Say the word and I will file it.

Zoo line softened. README:75 read as settled; it now names the shape you identified:

That is the answer for this PR, not the durable one: cheap_real is the first of a
class [...] The open shape is an explicit determinism: exact | statistical | none
grade in the manifest, where statistical substitutes a variance check for the
byte-exact fingerprint and the zoo reports the grade rather than implying everything
in it is exact. Deferred to #233 or a follow-up — not settled.


Filed, not fixed here


Re-verification

$ PYTHONPATH=$PWD/core python -m pytest core/tests -q
........................................................................ [ 38%]
........................................................................ [ 77%]
.........................................                                [100%]
185 passed in 64.06s (0:01:04)

$ python -m compileall -q core examples && echo OK
OK
$ bash -n examples/cheap_real/run.sh && echo OK
OK

185 passed (183 + 2 new), 0 failed.

Stdout is exactly one object with CAPEVOLVE_DASHBOARD=auto — your incidental-immunity
point is fixed properly, not by leaving off load-bearing. run.sh filters the CLI's
stdout to the last object, so it holds regardless of the dashboard mode:

$ CAPEVOLVE_DASHBOARD=auto bash examples/cheap_real/run.sh
objects on stdout: 1
whole-stdout json.loads: OK
keys: ['baseline_val','best_id','dashboard','dashboard_server','iterations','run_dir',
       'test_baseline_reward','test_delta','test_pass_k','test_reward']

Note json.loads(stdout) now succeeds outright, because the three progress lines moved
to stderr — the naive parse you noted was failing is the one that now works. The filter
carries a comment saying to delete it when #217 lands.

Free rung, unchanged (33.6 s this run, $0):

{"run_dir": ".capevolve/run_cheap", "best_id": "cand_0001", "baseline_val": 0.0,
 "test_reward": 1.0, "test_baseline_reward": 0.0, "test_delta": 1.0, "iterations": 3}
metric_calls: 30

#195 compliance — against origin/fix/issue-113-small-samples:

MIN_VAL_TASKS=2  LOW_CONFIDENCE_VAL_TASKS=5
cheap_real realized split: train=10 val=5 test=5
check_val_size warning: None
CONTROL 12 tasks: val=3  warning: val split has only 3 tasks (< 5) — acceptance
  decisions are LOW CONFIDENCE. ...

#197 compliance — pasted under N4 above, including the new
declared-list-drops-the-dataset control.

cap-evolve check on the hosted run's project dir:

{"ok": true, "stubs": [], "problems": [],
 "notes": ["tasks('val') -> 20 task(s)", "scorer deterministic (probe reward=0.0000)",
           "materialize() callable (dry-run into temp copy; host untouched)"]}

No canary leaks — including on the newly-added hosted path, which is where it now
matters most, since the preflight touches real credentials and embeds provider errors in
its message. Your six shapes on the free rung:

$ find . -type f | wc -l
     116
$ grep -rIl -E 'CANARY-(AAAA1111|...|GGGG7777)|canary-host|pw-CANARY' .
(no output)
$ grep -c -E 'CANARY|canary-host' stdout stderr
stdout:0   stderr:0

And two new shapes aimed at the new code, both forcing the preflight to fail so the
error message is the escape route:

$ ANTHROPIC_API_KEY=sk-ant-CANARY-HHHH8888 \
  ANTHROPIC_API_BASE='https://user:pw-CANARY-IIII9999@gw.canary-host.invalid/v1' ...
cheap_real preflight: the hosted model 'anthropic/claude-haiku-4-5' did not answer a
1-token probe (InternalServerError). ... Detail: litellm.InternalServerError:
AnthropicException - [Errno 8] nodename nor servname provided, or not known.
canary count: stdout:0  stderr:0

$ # reachable endpoint, so the 404 path — the one that usually echoes the URL
$ ANTHROPIC_API_BASE='http://user:pw-CANARY-LLLL5678@127.0.0.1:11434/CANARY-PATH-MMMM9012'
cheap_real preflight: ... (NotFoundError). ... Detail: litellm.NotFoundError:
AnthropicException - 404 page not found
canary count: stdout:0  stderr:0

Zero leaks, including the URL-userinfo shape, on the failure path. The preflight replaces
each of six credential/endpoint vars in the provider's own message before printing it.

Broken relative links: NONE.


Every finding is fixed or, in one case (N5), consciously kept with the reason. The one
suggestion I declined — the harder task — I declined for scope, not merit, and it should
be filed. Thanks for running the hosted variant; that finding was the whole value of this
review, and it is exactly the failure mode this example claims to prevent.

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.

Add a cheap first real run on-ramp between toy_calc and the multi-hour ~$148 tau2 run

3 participants