feat(runner): ship the fresh-baseline measurement harness (#39) - #164
Conversation
Opus review — approvedReviewed the seam, the runner, the Anthropic client's accounting, the matrix wiring, and the definition doc. This is the strongest of today's four PRs and I want to be specific about the two places it did better than the brief asked. 1. The NDJSON separation is a hazard the issue never mentioned, and it is real. 2. The accounting convention is shared, not merely matched. Refuses rather than degrades. Honest about what it is not. File layout — three modules mirroring Out of scope, now filed as #165The flagged
Correct call to leave it untouched here. Fixing it in this PR would have mixed an unrelated severity-1 correctness fix into a harness PR. Verdict: approve, merge on green CI. |
Adds the harness half of #39 — the §9 kill line's denominator. Ships: FreshBaselineClient/StubFreshBaselineClient, AnthropicFreshBaselineClient (same SDK wiring and token-accounting convention as repair-anthropic.ts, including billedInputTokens reused verbatim), FreshBaselineRunner (emits through MetricsEmitter to its own file, never the matrix's), the `gate:baseline` entry point, `gate:matrix --cost-fresh` wiring into ReplayRunner's existing costFresh option, and docs/gate/fresh-baseline.md defining "fresh reasoning" with the protocol template. Does NOT ship a measured number: no live model call was made, cost_fresh stays zeros, and repair-cost-vs-fresh / amortized-tokens stay no_data. The live measurement (3+ runs, real ANTHROPIC_API_KEY, real spend) is separate follow-up work. Refs #39 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…uthorization #162 (SC-05 consent gate) merged after this branch was cut and changed EstablishSessionOptions.baseUrl to a required target: SessionAuthorization, gating non-local session establishment on recorded consent. This entry point is a third establishSession call site that didn't exist when #162 landed, so typecheck caught the gap the merge couldn't. Matches the other two call sites (src/recorder/cli.ts, live-run.ts): SessionAuthorization.authorize(baseUrl) with no consent argument, because the fresh-baseline runner only ever targets the local test-bed with fixture credentials the project owns. Documents in docs/gate/fresh-baseline.md that pointing this harness at a non-local target now requires a recorded consent acknowledgment, and that ConsentRequiredError is correct behaviour there, not a bug. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ccccdae to
ae8aad8
Compare
Opus re-review — approved, mergingThe integration break is fixed correctly and documented.
The doc note is the part I'd have accepted the PR without and am glad is there: Worth recording why this break happened, because it is the one class of failure a green local run cannot catch: #162 changed That is also the argument for #162's approach. A convention would have let this through; a required All checks green: |
What this is
The harness half of #39: everything needed to measure the §9 kill-line's denominator — "what it costs a model to do the gate task from scratch" — except the measurement itself. Refs #39 (issue stays open; the live run is the remaining half).
What landed
docs/gate/fresh-baseline.md— the written definition of "fresh reasoning" (page + goal only, no compiled trajectory, no cached locators, no step list), written before any number exists, per the issue's own checklist. States the token-accounting convention explicitly and the cost_fresh is a per-run field used as a one-time cost — #39 will flatten the amortization curve #123 correction (below). Ends with the protocol-record template (model id, effort, run count, date, testbed version, mean and spread) as[PENDING TRACK-1]placeholders.src/runner/fresh-baseline.ts—FreshBaselineClientinterface +StubFreshBaselineClient(mirrorsRepairModelClient/StubRepairModelClient: reports failure and zero tokens, never a no-op success).src/runner/fresh-baseline-anthropic.ts—AnthropicFreshBaselineClient, the real SDK wiring. Onemessages.createcall per model turn (structured output, not tool-use, not prose), looping until the model reports done orDEFAULT_MAX_TURNS(30) is hit. Drives the page via the existingexecuteAction/capturePageState("interactive")— the same privacy-safe capture the repair model already gets (ADR-0012), so a fresh agent never sees more of the page than a repair does.src/runner/fresh-baseline-runner.ts—FreshBaselineRunner, the client-agnostic orchestration layer: measures wall-clock around one attempt, sanitizes the reported numbers, emits aRunMetricrow throughMetricsEmitter.experiments/gate-v1/fresh-baseline.ts(npm run gate:baseline) — a separate entry point, not a mode ofReplayRunner/run-matrix.ts. Same--dry-run/live split and bring-up/seed/browser/teardown conventions asrun-matrix.ts/live-run.ts, reusing the same testbed/preamble code rather than re-implementing it. Writesout/fresh-baseline/metrics.ndjsonandout/fresh-baseline/baseline.json(the protocol record + mean/spread).gate:matrix --cost-fresh <path>— reads a measuredbaseline.jsonand attaches itsmean_cost_freshto every live run row'scost_freshviaReplayRunner's existingcostFreshoption (experiments/gate-v1/run-matrix.ts,live-run.ts). Refuses (exit 2) unless the baseline isusable: true— never silently falls back to a zero that would look identical to never having asked. Ignored under--dry-run.package.json:gate:baselinescript.Why fresh-baseline rows never reach
gate:matrix's own NDJSONFreshBaselineRunneremits through the sameMetricsEmitterclass, but toout/fresh-baseline/metrics.ndjson— neverout/metrics.ndjson. A fresh attempt'sRunMetricrow has no compiled steps and no repair loop, sosteps_total/cost_repair/etc. are honest zeros — butrepairCostVsFresh()andtaskSuccessLe2Repairs()pool every run row in the file they're given, unconditionally. Mixing the two files would silently dilute the real matrix's success rate and mean repair cost.gate:reporthard-codesout/metrics.ndjsonas its only input, so the two stay apart by construction. Full reasoning in the module doc-comment anddocs/gate/fresh-baseline.md.Why three files under
src/runner/(not one)Mirrors the existing
repair.ts/repair-anthropic.tssplit, applied a second time because both clients now share the same constraint (§9 wants them measured identically):fresh-baseline.ts— the seam (interface + stub). LetsFreshBaselineRunnerdepend on an abstraction, never the SDK.fresh-baseline-anthropic.ts— everything that touches@anthropic-ai/sdkand nothing else. A reviewer auditing "does this ever make a network call" has exactly one file to read.fresh-baseline-runner.ts— orchestration (measure, sanitize, emit), client-agnostic. Tested with a hand-written fake client and never touches the SDK — collapsing this into the Anthropic client would force every cost-mapping/zeros-on-failure test to carry SDK mock scaffolding it doesn't need, the same taxrepair-anthropic.test.tsalready avoids for the repair side.The alternative (one file) was considered;
fresh-baseline-anthropic.tsalone is ~350 lines of request-shape/turn-loop/error-path logic that runner-level tests have no reason to exercise through a mockedAnthropicclient. This isn't a new pattern invented for #39 — it's the existing one, applied because the constraint is now shared.Token-accounting convention — matched to
repair-anthropic.tsexactlybilledInputTokens()is imported fromrepair-anthropic.ts, not reimplemented — sumsinput_tokens + cache_read_input_tokens + cache_creation_input_tokens.cache_control).fallbacks, notemperature/top_p/top_kon either.ANTHROPIC_API_KEYthrows at construction on both (MissingAnthropicKeyError/MissingFreshBaselineKeyError) rather than degrading to a stub that would look measured.DEFAULT_FRESH_MODEL = DEFAULT_REPAIR_MODEL— so the ratio isn't secretly comparing two different reasoning systems.Full table in
docs/gate/fresh-baseline.md.The #123 / ADR-0010 correction
Issue #39's step 4, as filed, predates #123: it says to attach
cost_freshso thatrepairCostVsFresh()andamortizedTokensOverN()compute. That's no longer correct atHEAD— ADR-0010 split the field. This PR follows the code, not the issue's original wording:--cost-freshfeedsrepairCostVsFresh()only.amortizedTokensOverN()'s numerator iscost_program_build(a separate, unmeasured, one-time capital cost) and nothing here wires it or measures it. Stated explicitly indocs/gate/fresh-baseline.md.What did NOT land (by design)
ANTHROPIC_API_KEYwas set or used anywhere in this session.cost_freshstays zeros;gate:report'srepair cost vs freshandamortized tokens/taskstayno_data. Per CONTRIBUTING rule 3, there is no number to invent, and none is invented — every protocol-record field in the doc is[PENDING TRACK-1].done: true, success: trueself-report — flagged as an open question, not built. The model's own claim is what's measured today; nothing external checks it.Test output
npm run ci— green (secret-scan, validate:contracts, lint, lint:docs, typecheck, 500 unit tests across 35 files, 26 integration tests across 5 files). New coverage:tests/unit/fresh-baseline-anthropic.test.ts(19 tests, real headless browser + mocked SDK — token accounting across turns, no-retry, missing-page zeros, structured-output shape, privacy of what's sent),tests/unit/fresh-baseline-runner.test.ts(11 tests — cost mapping,model_idpropagation, and the case #39 calls out by name: a client that throws produces an all-zerocost_freshrow with an explicit note, never partial garbage),tests/unit/fresh-baseline-summary.test.ts(11 tests — mean/spread over measured runs only,usableflag), and aloadCostFreshBaselineblock added totests/unit/gate-matrix.test.ts(6 tests — refuses a missing/invalid/not-usable baseline by name rather than degrading silently).npm run test:canary— green (52 tests, 8 files). No network calls anywhere in either suite.Branch:
track1/b4-fresh-baseline, rebased ontoorigin/mainafter #160/#161 landed (both docs/README.md rows kept).🤖 Generated with Claude Code