Skip to content

feat(run-case): calibrate the regression gate against a null (bank + same-call, v2 measurement setup) - #34

Merged
leoluyi merged 6 commits into
mainfrom
fix/gate-null-calibrated
Aug 6, 2026
Merged

feat(run-case): calibrate the regression gate against a null (bank + same-call, v2 measurement setup)#34
leoluyi merged 6 commits into
mainfrom
fix/gate-null-calibrated

Conversation

@leoluyi

@leoluyi leoluyi commented Aug 6, 2026

Copy link
Copy Markdown
Owner

What

Replaces the run-case regression gate's variance-model thresholds with a null-calibrated table, then rebuilds that calibration cheaply (bank + same-call same-text pairing) after the original cross-round pool went unreproducible and stale.

  • CONFIRM_AT fixed-2 → majority (n//2+1); mean guardrails → row_margin (net-worse minus net-better rows).
  • Thresholds are counted from a null, not derived from a variance estimate (125 rounds would be needed for 80% power on a 1-row difference under the old approach).
  • tools/run_case/bank.py: baseline-arm outputs are generated once (--build-bank) and reused across rounds sharing the same baseline text, instead of regenerating every round.
  • --null-run A,B: pairs two bank rounds inside one grader call (zero runner dispatch) — this is the gate's real pairing structure, not an approximation of it, closing the "cross-round null is noisier than the real comparison" gap the old calibration carried.
  • evals/run-case.json chunks 6→3, codex runner effort xhighhigh — cuts the rule-blob resend volume ~75% per round.

Why

The shipped calibration.json named 21 source rounds that no longer exist on disk anywhere, all under a stale criteria_sha256--calibrate could not reproduce the shipped table. Recalibration was mandatory, not optional cleanup.

Decisions

  • Same-call over cross-round. The real gate judges both arms in one grader call; a null built the same way (bank vs bank, one call) measures the gate's actual false-alarm rate instead of a noisier proxy. Cross-round pairing stays as a documented fallback path when no bank exists.
  • 6-round bank / C(6,2)=15 pairs, sized to make n=6 (the only round count that can produce NO-SHIP) measurable rather than borrowed from a looser neighbour.
  • Detection-power table (deliberately-broken-row measurement) not remeasured this round. The full matrix (5 damage shapes × {3,6} rounds) is expensive and not ship-blocking — regression-protocol.md already discloses the existing numbers as old-setup/directional-only. Left as a follow-up.

Assumptions

  • The 3 old-setup probe rounds (results-2026-08-04-null-r1~r3, 6-chunk/xhigh) are kept committed as a reference range only, never pooled with new-setup data — enforced by aggregate.py's IDENTITY_FIELDS.

Verification

  • --dry-run: 3-chunk partition, runner-prompt-new-arm-only, bank-missing hard error — all correct.
  • Manifest tamper (flipped a chunk's chunk_prompt_sha256) → hard error on next use, confirmed then reverted.
  • --aggregate on --null-run output → hard-rejected ("measures the noise floor, not a change"), confirming the old/new mixing guard.
  • --calibrate regenerated calibration.json from the 15 null-run pairs (method: same-call).
  • 3-round --baseline HEAD ship-check (new arm live-dispatched, base arm from bank, same underlying blob): SHIP, zero breaches.
  • Token ledger: rule blob is ~112KB; old setup resent it 12×/round (6 chunk × 2 arms), new setup 3×/round (new arm only, base from bank) — a 75% cut in blob-resend volume, comfortably past the ≥50% target.

Known issue, not blocking

Recurring transient claude exited 1 (empty stderr) on grader dispatch — hit 11/15 times during --null-run pairing and twice during the ship-check rounds. Always cleared on retry; root cause not identified (not the earlier ANTHROPIC_API_KEY credit issue, confirmed via direct claude -p smoke test). Also observed: two apparent full stalls (all three parallel chunks frozen 40min+ with zero output growth), cleared by kill + retry.

Separately, chunk 6→3 cuts token cost but not wall-clock — a single 34-case chunk is one serial generation call, so round latency is bounded by the largest chunk rather than total case count. Ship-check rounds ran up to 4h+. Recorded as an open item in root backlog.md for whoever next touches chunk sizing or dispatch concurrency.

Test plan

  • tools/run-case humanizer-zh --dry-run
  • tools/run-case humanizer-zh --calibrate <15 null-run files>
  • tools/run-case --aggregate mixing-guard rejection on null-run input
  • 3-round --baseline HEAD ship-check → SHIP, zero breaches
  • Full detection-power table remeasurement (follow-up, not blocking)

leoluyi added 6 commits August 4, 2026 22:51
…ce model

The two mean guardrails compared point estimates whose round-to-round
spread was four to eight times the difference they acted on. Splitting an
archive of 21 same-baseline rounds against itself — a comparison whose
true answer is 'no difference' — they fired on 47% of draws.

Swapping the statistic does not help: a class mean times the round count
is exactly the net discordant-cell count, so the two are one quantity
differing only in how many observations it is collapsed onto. What
changes the answer is measuring the thresholds instead of deriving them.

Three rules, each with its null measurement:

- Only new-arm-only regressions count. Of eight rows the old gate once
  called confirmed false kills, seven were already red on main.
- Confirmation is a majority of rounds, not a fixed two. At a fixed two
  the null's 95th percentile climbed from 3 rows at three rounds to 11 at
  eight, so the gate loosened as evidence accumulated.
- A row margin replaces the class means, which are still printed but no
  longer decide. A single flaky row moves it by at most one, and the
  improving side subtracts the corpus's own flakiness back out.

Injecting known damage into the null measures what survives: two
protected rows broken consistently block 100% of the time at six rounds,
twelve rows broken thinly block 78%, and nothing broken blocks 5%. Three
rounds can clear a change but not condemn one, so a three-round breach
reports INCONCLUSIVE rather than NO-SHIP.

Also moves the aggregation policy out of the graded criteria section. It
was being fed to the grader, telling it how its own scores would be
pooled, and it made every gate edit invalidate criteria_sha256.
Operator-facing prose only. The 判分標準 section is extracted verbatim
and sent to the grader as its rubric, so it stays byte-identical —
criteria_sha256 is unchanged at 3876e6b1.

Two lines were stale rather than verbose and are corrected, not
shortened: the archiving section still named the retired 誤殺 0 /
no-baseline-regression targets, and the dual-run step still said to
repeat each configuration 2-3 times, which the null calibration since
showed can clear a change but not block one.
Reverts both edits this branch made to the template: the round-budget
paragraph citing humanizer-zh's null calibration, and the compression
pass that followed it. The template is back to main byte-for-byte.

The finding those edits carried is real but belongs somewhere a scaffold
does not reach for it: three rounds clear a change and cannot condemn
one. It stays recorded in skills/humanizer-zh/evals/regression-protocol.md
and in the root backlog.
…nd same-call null

Baseline arm answered the same prompt every round yet got regenerated every
time — the rule blob and all 85 case prompts resent in full for text nobody
was actually testing. Three changes, one measurement setup:

- evals/run-case.json: chunks 6 -> 3, halving how many times the blob gets
  resent per round (partition re-verified: 85 ids, no gap, no overlap).
- dispatch.py: codex runner reasoning effort xhigh -> high.
- tools/run_case/bank.py (new): --build-bank dispatches N independent
  baseline generations once and persists them under
  evals/baseline-bank/<blob-sha[:12]>/; any round comparing against that same
  blob reads one back instead of re-dispatching. Reuse is verified against
  runner family/model/effort and each chunk's prompt sha256 — any mismatch is
  a hard error with a rebuild instruction, never a silent live-dispatch
  fallback.

Same mechanism also closes a standing gap in the gate's calibration: the null
it was built from paired archived rounds *across* separate grader calls,
which is noisier than the real gate's single-call comparison, so its
thresholds ran loose (documented at calibration.py:20-25,
regression-protocol.md:90-92). --null-run scores two bank rounds against each
other inside one grader call, with zero runner dispatch; calibrate_same_call
pools those results directly instead of simulating the comparison by
splitting rounds. aggregate.py's IDENTITY_FIELDS gained runner_effort,
grader_effort and baseline_source so rounds measured under different setups
(old 6-chunk/xhigh/cross-round vs new 3-chunk/high/bank) hard-error rather
than silently pooling.

Estimated new-arm-only dispatch drops from ~500k runner input tokens/round to
~135k; full null-calibration moves from 108-216 runner+grader jobs (dispatch
--baseline HEAD per pool round) to 18 one-time runner jobs plus 45 grader-only
--null-run jobs for a 6-round/15-pair pool.

Supersedes the within-round-null plan this branch started from (same goal,
reached via the bank instead of repeated --baseline HEAD rounds); the old
plan file and project memory now carry a banner pointing here.
skills/humanizer-zh/evals/null-r-series-SUPERSEDED.md flags the same thing
in-repo, next to the r1-r3 probe rounds dispatched under the superseded
approach (left as-is, not part of this commit).

Calibration.json regeneration and the baseline bank itself still need a real
dispatch pass (--build-bank, then --null-run across the pool) before this
branch's gate numbers are trustworthy again.
Builds a 6-round baseline bank, runs all C(6,2)=15 --null-run pairings
(same-call blind pairs, zero runner dispatch), and regenerates
calibration.json from the pool (method: same-call, 3-round ceilings:
7 protection rows / 8 hit rows).

Also commits the 3-round --baseline HEAD ship-check that verifies the
new setup end to end (bank fetch + live new-arm dispatch + aggregate):
SHIP, zero breaches, both arms the same blob.

Keeps the three old-setup (6-chunk, xhigh, cross-round) probe rounds
results-2026-08-04-null-r1~r3 as a reference range only — never pooled
with the new-setup data; IDENTITY_FIELDS enforces the split.
design-notes.md: record the v2 measurement setup's completion — bank
built, 15 null-run pairs, calibration.json regenerated, 3-round SHIP
verified. Delete null-r-series-SUPERSEDED.md, its job (steering the
other in-flight session off the old r-series) is done.

backlog.md: open item — chunk 6→3 cuts token cost but not wall-clock;
a single 34-case chunk still runs as one serial call, so round latency
is bounded by the largest chunk, not the case count. Observed rounds
up to 4h+, including two apparent stalls (all three chunks frozen
40min+, cleared by kill and retry) sharing the same failure signature
as the recurring transient `claude exited 1` grader dispatch error.

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Sorry, we are unable to review this pull request

The GitHub API does not allow us to fetch diffs exceeding 20000 lines

@leoluyi
leoluyi merged commit 36a52e6 into main Aug 6, 2026
1 check passed
@leoluyi
leoluyi deleted the fix/gate-null-calibrated branch August 6, 2026 12:06
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.

1 participant