evaluate — honest, multi-trial scoring
Turns a candidate into a score you can trust. A reward number is only as honest
as the variance around it and as the denominator under it: agents are stochastic,
and infrastructure fails. evaluate produces a point estimate, its uncertainty, and
the count of tasks that actually produced a measurement. The math lives in
cap_evolve.stats; this skill drives the adapter and aggregates.
What it produces
A SplitResult (core/cap_evolve/loop.py:59-115):
reward — the mean, over the tasks that were scored, of each task's mean
over its valid trials (harness.py:405-414, loop.py:127-131). Not the mean
over every task in the split — see the next section.
n_tasks / n_scored (and coverage = n_scored/n_tasks, loop.py:79-84) —
the honest denominator. Read these on every result, never reward alone.
stderr — the combined SE of that reported mean: between-task variance
(do different tasks agree?) folded with within-task trial variance (is the agent
consistent on a fixed task?), stats.combined_stderr. This is what the report
prints and what the gate's significant mode consumes — not what the default
gate reads; see "What the gate actually consumes".
pass_k — when trials > 1, the estimated probability that all k i.i.d.
trials pass (reliability). Also pass_at_k — at least one of k passes
(capability). Opposite questions; see references/concepts.md.
- per-task scores + feedback, and the rollout files
diagnose reads:
<run-dir>/rollouts/<split>/<task>__<tag>__t<k>.json (harness.py:334).
A crashed rollout is missing data, not a zero
The single largest honesty mechanism in the eval path. Two ways a trial produces
no measurement:
- the runner errored (
rollout.error set) — the target never ran;
- the rollout succeeded and the scorer could not grade it (crashed grading
harness, missing report file). There is no
rollout.error, so adapters must flag
it by setting Score.raw["errored"] (harness.py:308-323). An adapter that
doesn't is how a scorer outage becomes a real 0.0.
Such a trial is excluded from the mean (harness.py:324-331), and a task with zero
valid trials is dropped from every statistic (loop.py:118-127). Averaging its
0.0 in would state that the capability failed a task it was never given — which is
how a registry rate-limit storm produced val 0.000 and taught the optimizer to
"fix" content that was never at fault. The rollout file is still written, for
forensics.
What to check. raw.valid_trials == 0 on a per-task record means unmeasured,
not failed. A reward computed over a third of a split describes the
infrastructure, not the edit. Below coverage 0.6 the gate returns
indecisive=True and declines to judge rather than calling it a regression
(gate.py:137-146) — a run producing repeated indecisive steps has an
infrastructure fault, not a bad optimizer. Pinned by
core/tests/test_infra_errors_not_zeros.py (518 lines).
What the gate actually consumes
When per-task data is available the loop sets gate mode to paired
(harness.py:1524-1526), and paired mode recomputes the SE from the per-task
deltas against the same tasks (gate.py:156-160); SplitResult.stderr is never
read on that path. So what extra trials buy you under the default gate is a more
stable per-task mean, which shrinks the paired delta variance — not a smaller
stderr. stderr feeds the report and the significant fallback used when
paired data is unavailable (gate.py:184-207).
How to run
python scripts/run.py --run-dir .capevolve/run_XXXX --project .capevolve/project \
--candidate seed --split val --n-trials 3
--split accepts only train or val, enforced by argparse choices
(scripts/run.py:25) — a --split test invocation exits non-zero. The
enforcement lives in this CLI, not in harness.evaluate_candidate (issue #361),
so never "helpfully" widen those choices.
--n-trials defaults to 1. On a stochastic target that is the degenerate
case below; run.py prints a warning to stderr when it happens.
--ks picks the k values for pass^k; it defaults to 1..n_trials, so
--n-trials 3 reports pass^1..pass^3. Any k above a task's trial count is
omitted rather than reported as 0.0 (loop.py:134-147).
CAPEVOLVE_WORKERS=N generates rollouts through a thread pool
(harness.py:49-57); scoring stays serial so the numbers match a serial run.
Keep it at 1 if run_target is not thread-safe (shared scratch dir, one live
container, module-global client) — harness.py:225-227.
- A subset/triage eval (
ids=) is never gateable: its n_tasks is the subset,
so coverage reads 1.0 (harness.py:229-237).
How much measurement do you need
Two axes, and the trials axis is the one people get wrong.
- Trials. Deterministic scorer + greedy decode: 1 trial is honest. Any
sampling / temperature / tool nondeterminism: ≥3–4. Trials are only independent
draws if the adapter forwards the per-trial seed — trial
k runs with
seed = base_seed + k (harness.py:374, trials.py:10-13) and the adapter
contract requires passing it to a stochastic runner (adapter.py:52-54). An
adapter that drops it gives you n identical copies: per-task stderr is 0,
pass^k is exactly 0 or 1, and the whole apparatus looks healthy while measuring
nothing. cap-evolve check can prove it: with
CAPEVOLVE_N_TRIALS=3 CAPEVOLVE_CHECK_TRIAL_PROBE=1 it fires two real rollouts at
different seeds and warns if they are byte-identical
(core/cap_evolve/check.py:169-190). It is opt-in because the probe costs real
rollouts — run it once per adapter, and treat the warning as "every variance number
here is fiction". Trials cost budget linearly, so spend them where variance actually
threatens a decision — the val split the gate reads — not on every exploratory probe.
- Tasks.
stats.stderr returns 0.0 below 2 tasks and combined_stderr's
between-task term is 0 below 2 (stats.py:28-30, 47-50), so a 1-task val gives
stderr = 0, a bar of 0, and the gate degenerates to strict ("any Δ>0 wins") with
a logged warning (gate.py:40-60). Below roughly 5 val tasks the k·SE bar is
dominated by sample size and is optimistic — issue #113. An empty val presents as
coverage 1.0 with reward 0.0 (loop.py:79-84).
A one-task gain is not reliably bankable. Under the shipped default
(mode: paired, k_se: 1.0) a candidate that improves exactly one val task and
changes nothing else has Δ̄ == SE(Δ) algebraically, so the strict > at
gate.py:176 is settled by floating-point representation — rejected at n=4, 8, 50,
accepted at n=20, identical printed numbers. Issue #351, open; derivation in
references/concepts.md. Do not read a rejection of a single-task fix as evidence
the edit was bad — check how many tasks moved.
What good vs bad looks like
- Good:
n_trials ≥ 3 on a stochastic agent with the seed forwarded; stderr
non-zero; n_scored == n_tasks; pass^k inspected alongside the mean.
- Bad: a plausible low reward that is an infrastructure outage, not a capability
measurement (check
coverage first, always); single-trial scores feeding a
significance gate; identical trial rewards across seeds; trusting a high mean when
pass^k is low (the gain is fragile).
References
references/concepts.md (125 lines) — the variance decomposition and the
combined-SE formula, where these statistics break down on small samples (including
the #351 Δ̄ == SE derivation), pass^k vs pass@k with their unbiased estimators,
bootstrap CIs, where the test-split refusal is enforced, and sources. Load it when
you need the statistics themselves rather than how to run an evaluation.
1---2name: evaluate3description: Score a candidate on a split with honest, variance-aware evaluation. Use whenever you need a number for a candidate (the algorithm calls it internally; you can also call it directly to inspect). Runs the target via the adapter for each task, scores each rollout, aggregates mean + standard error, and reports pass^k when trials > 1. Never touches the test split (that is finalize's sealed job).4---56# evaluate — honest, multi-trial scoring78Turns a candidate into a score you can *trust*. A reward number is only as honest9as the variance around it and as the denominator under it: agents are stochastic,10and infrastructure fails. evaluate produces a point estimate, its uncertainty, and11the count of tasks that actually produced a measurement. The math lives in12`cap_evolve.stats`; this skill drives the adapter and aggregates.1314## What it produces15A `SplitResult` (`core/cap_evolve/loop.py:59-115`):16- **`reward`** — the mean, over the tasks that were **scored**, of each task's mean17 over its **valid** trials (`harness.py:405-414`, `loop.py:127-131`). Not the mean18 over every task in the split — see the next section.19- **`n_tasks` / `n_scored`** (and `coverage = n_scored/n_tasks`, `loop.py:79-84`) —20 the honest denominator. Read these on every result, never `reward` alone.21- **`stderr`** — the *combined* SE of that reported mean: between-task variance22 (do different tasks agree?) folded with within-task trial variance (is the agent23 consistent on a fixed task?), `stats.combined_stderr`. This is what the report24 prints and what the gate's `significant` mode consumes — **not** what the default25 gate reads; see "What the gate actually consumes".26- **`pass_k`** — when trials > 1, the estimated probability that **all** k i.i.d.27 trials pass (reliability). Also `pass_at_k` — at least one of k passes28 (capability). Opposite questions; see `references/concepts.md`.29- **per-task scores + feedback**, and the rollout files `diagnose` reads:30 `<run-dir>/rollouts/<split>/<task>__<tag>__t<k>.json` (`harness.py:334`).3132## A crashed rollout is missing data, not a zero33The single largest honesty mechanism in the eval path. Two ways a trial produces34no measurement:35- the runner errored (`rollout.error` set) — the target never ran;36- the rollout *succeeded* and the **scorer** could not grade it (crashed grading37 harness, missing report file). There is no `rollout.error`, so adapters must flag38 it by setting `Score.raw["errored"]` (`harness.py:308-323`). An adapter that39 doesn't is how a scorer outage becomes a real 0.0.4041Such a trial is excluded from the mean (`harness.py:324-331`), and a task with zero42valid trials is dropped from **every** statistic (`loop.py:118-127`). Averaging its430.0 in would state that the capability failed a task it was never given — which is44how a registry rate-limit storm produced `val 0.000` and taught the optimizer to45"fix" content that was never at fault. The rollout file is still written, for46forensics.4748**What to check.** `raw.valid_trials == 0` on a per-task record means unmeasured,49not failed. A `reward` computed over a third of a split describes the50infrastructure, not the edit. Below `coverage 0.6` the gate returns51`indecisive=True` and declines to judge rather than calling it a regression52(`gate.py:137-146`) — a run producing repeated indecisive steps has an53infrastructure fault, not a bad optimizer. Pinned by54`core/tests/test_infra_errors_not_zeros.py` (518 lines).5556## What the gate actually consumes57When per-task data is available the loop sets gate mode to `paired`58(`harness.py:1524-1526`), and paired mode **recomputes** the SE from the per-task59deltas against the same tasks (`gate.py:156-160`); `SplitResult.stderr` is never60read on that path. So what extra trials buy you under the default gate is a more61stable *per-task* mean, which shrinks the paired delta variance — not a smaller62`stderr`. `stderr` feeds the report and the `significant` fallback used when63paired data is unavailable (`gate.py:184-207`).6465## How to run66```67python scripts/run.py --run-dir .capevolve/run_XXXX --project .capevolve/project \68 --candidate seed --split val --n-trials 369```70- `--split` accepts only `train` or `val`, enforced by argparse choices71 (`scripts/run.py:25`) — a `--split test` invocation exits non-zero. The72 enforcement lives in *this CLI*, not in `harness.evaluate_candidate` (issue #361),73 so never "helpfully" widen those choices.74- `--n-trials` **defaults to 1**. On a stochastic target that is the degenerate75 case below; run.py prints a warning to stderr when it happens.76- `--ks` picks the k values for pass^k; it defaults to `1..n_trials`, so77 `--n-trials 3` reports pass^1..pass^3. Any k above a task's trial count is78 omitted rather than reported as 0.0 (`loop.py:134-147`).79- `CAPEVOLVE_WORKERS=N` generates rollouts through a thread pool80 (`harness.py:49-57`); scoring stays serial so the numbers match a serial run.81 Keep it at 1 if `run_target` is not thread-safe (shared scratch dir, one live82 container, module-global client) — `harness.py:225-227`.83- A subset/triage eval (`ids=`) is **never** gateable: its `n_tasks` is the subset,84 so `coverage` reads 1.0 (`harness.py:229-237`).8586## How much measurement do you need87Two axes, and the trials axis is the one people get wrong.8889- **Trials.** Deterministic scorer + greedy decode: 1 trial is honest. Any90 sampling / temperature / tool nondeterminism: ≥3–4. Trials are only independent91 draws if the adapter **forwards the per-trial seed** — trial `k` runs with92 `seed = base_seed + k` (`harness.py:374`, `trials.py:10-13`) and the adapter93 contract requires passing it to a stochastic runner (`adapter.py:52-54`). An94 adapter that drops it gives you n identical copies: per-task `stderr` is 0,95 `pass^k` is exactly 0 or 1, and the whole apparatus looks healthy while measuring96 nothing. `cap-evolve check` can prove it: with97 `CAPEVOLVE_N_TRIALS=3 CAPEVOLVE_CHECK_TRIAL_PROBE=1` it fires two real rollouts at98 different seeds and warns if they are byte-identical99 (`core/cap_evolve/check.py:169-190`). It is opt-in because the probe costs real100 rollouts — run it once per adapter, and treat the warning as "every variance number101 here is fiction". Trials cost budget linearly, so spend them where variance actually102 threatens a decision — the val split the gate reads — not on every exploratory probe.103- **Tasks.** `stats.stderr` returns 0.0 below 2 tasks and `combined_stderr`'s104 between-task term is 0 below 2 (`stats.py:28-30, 47-50`), so a 1-task val gives105 `stderr = 0`, a bar of 0, and the gate degenerates to strict ("any Δ>0 wins") with106 a logged warning (`gate.py:40-60`). Below roughly 5 val tasks the `k·SE` bar is107 dominated by sample size and is optimistic — issue #113. An empty val presents as108 `coverage 1.0` with `reward 0.0` (`loop.py:79-84`).109110**A one-task gain is not reliably bankable.** Under the shipped default111(`mode: paired`, `k_se: 1.0`) a candidate that improves exactly one val task and112changes nothing else has `Δ̄ == SE(Δ)` *algebraically*, so the strict `>` at113`gate.py:176` is settled by floating-point representation — rejected at n=4, 8, 50,114accepted at n=20, identical printed numbers. Issue #351, open; derivation in115`references/concepts.md`. Do not read a rejection of a single-task fix as evidence116the edit was bad — check how many tasks moved.117118## What good vs bad looks like119- **Good:** `n_trials ≥ 3` on a stochastic agent with the seed forwarded; `stderr`120 non-zero; `n_scored == n_tasks`; pass^k inspected alongside the mean.121- **Bad:** a plausible low reward that is an infrastructure outage, not a capability122 measurement (check `coverage` first, always); single-trial scores feeding a123 significance gate; identical trial rewards across seeds; trusting a high mean when124 pass^k is low (the gain is fragile).125126## References127- `references/concepts.md` (125 lines) — the variance decomposition and the128 combined-SE formula, where these statistics break down on small samples (including129 the #351 `Δ̄ == SE` derivation), pass^k vs pass@k with their unbiased estimators,130 bootstrap CIs, where the test-split refusal is enforced, and sources. Load it when131 you need the statistics themselves rather than how to run an evaluation.