Make per-turn token usage observable, and say what it still cannot price - #390
Merged
Conversation
TOKEN-LEDGER.md blocker B gave two reasons the drafting turn (W4) is missing from the write side. One was that it needs a model call. The other was sharper: the driver read a single session-total `usage` object, so the harness could not have attributed an answer to the turn that produced it even if a call were made. The second reason is now false. This is the instrument, not the measurement. The CLI was run rather than read about. `--output-format stream-json` emits an assistant event per content block, each carrying the same `message.usage` and the same `message.id`, so summing them double-counts every turn. Deduplicated, `input_tokens` and both cache fields are final and reconcile exactly with the session total — and `output_tokens` does not. It is the `message_start` snapshot: three turns reported 4, 1 and 1 against real outputs of 157, 193 and 36, a session total of 6 against 403. Nothing on the event marks it provisional while its neighbours are not. The term the write side is missing is precisely the one term this format reports wrongly, and the correct figure arrives only on `message_delta`, only under `--include-partial-messages`. Both raw captures are committed as fixtures, so that paragraph is checkable rather than asserted. Nothing an earlier run measured moves. `--per-turn-usage` is opt-in and off is the shape every row in bench/results/ was produced under. Both output formats end in the same `result` object, and `transcript`, `turns` and `tokens` are still read from it under the same field selection — verified against a live run: session total 26 + 649 + 752 = the 1,427 the row reported. The ledger is built from events the JSON format never printed and nothing already measured is re-derived from them. Every ledger carries its own audit rather than asking to be trusted: `turn_total`, `session_total`, and a `reconciled` boolean that is true only when the turns sum to the total the CLI states for itself, field for field. A parser that missed or duplicated a turn says so on the row. The schema gate was already failing before this touched it. `reproposal_matches` and the four `rejected_path_*` counts reached the rows without reaching `result.schema.json`, so `verify.mjs` rejected all 80 rows of the most recent matrix on `additionalProperties`. Adding a sixth field to that list without fixing it would have shipped a gate that gates nothing. Limit: `turn_usage` carries the provider's tokenizer; every figure in bench/TOKEN-LEDGER.md is in the product's own ceil(chars / 4) proxy. A future W4 must be reported beside the floor, never added into it, until both sides are on one tokenizer Limit: `content_blocks` says a turn called a tool, not which tool or on what — the tool_use block's name and input are discarded, so #141's rejected-path tool actions stay uncounted Ruled-out: turning per-turn capture on by default | it changes the CLI invocation, and a harness flag that quietly rewrote how a run is observed would make old and new rows two different things under one name Ruled-out: summing the `usage` on assistant events | they repeat once per content block and their output_tokens is a pre-generation snapshot; that reading is wrong by 397 of 403 on the one probe that checked it Ruled-out: writing `turn_usage` on every row with nulls when uninstrumented | an always-null field is worse than no field, and absent already reads as "not instrumented" everywhere else on this row Ruled-out: retaining the whole stream to parse at the end | partial messages emit an event per chunk, so a long run would hold tens of megabytes to produce a few hundred integers Warn: this closes the attribution half of blocker B only. W4 is still unmeasured, W3 is still unrecoverable for this corpus, and no figure in TOKEN-LEDGER.md section 9 changes Warn: section 5 of TOKEN-LEDGER.md is pre-registration and was appended to, not edited — the floor has to stay visibly declared in advance Verified: the raw event stream of two real invocations, kept and committed under test/fixtures/claude-stream/ with local paths redacted Verified: the per-turn ledger reconciles with the CLI's own session total on all four usage fields — 26 / 386 / 307 / 72,845 on the fixture, and again on a live run through the wired driver Verified: the same driver with the option off and on returns the same transcript, turn count and token field selection Verified: 12 new tests; bench/verify.mjs now validates 252 rows across three current-shape result files that it previously rejected Verified: npm run typecheck, tsc on bench, npm run build, and scripts/check-readme-numbers.mjs all clean Unverified: no drafting turn has been priced. No bench arm runs `capture` against its own transcript and diff, so the number blocker B is about still does not exist Unverified: subagent attribution is covered by a constructed stream, not an observed one — no probe here produced a nested agent Evidence: bench/TOKEN-LEDGER.md Evidence: bench/drivers/stream-json.ts Evidence: test/fixtures/claude-stream/partial-messages.jsonl Follows: r-ledgerresult Blast: module Undo: easy Certainty: firm Record-Id: r-4e7c19 Provenance: authored CommitLore-Version: 2.0.0
CommitLore — record lintTrailers: clean — 1 commit in Active constraints for the paths this PR touchesLimits (20)
Ruled out (39)
Warnings (15)
Trailer violations fail this check. Active constraints are informational — they are what the repository already decided, not a verdict on this PR. |
This was referenced Aug 1, 2026
MongLong0214
added a commit
that referenced
this pull request
Aug 1, 2026
…efault bench/verify.mjs had no npm script and no CI step: `grep -rn "verify.mjs" .github/workflows/ package.json` returned nothing. So the schema drifted five fields behind the runner and bench/results/m5-off-design-20-tasks.jsonl sat in the tree failing the gate for two days. Reproduced at 6d4e828^, the commit before #390 fixed the schema: 80 problem(s) across 80 rows, every one `must NOT have additional properties`. #390 fixed that drift. This is about the reason nobody saw it, and docs/RELEASE-GATE.md already names the shape -- "A gate nobody can check is a slogan." Running it needs an answer to which files, and bench/results/ is not one population. Seventeen .jsonl files there hold two row families that share a directory and nothing else. Nine are run records -- one row per (task, condition, seed), written by bench/runner.ts, which is what result.schema.json describes in its own words. Eight are metric rows written by bench/deterministic.ts and bench/external/run.ts: one row per measurement, never a run id, a task or a condition. Checking those against this schema is not a drift check, it is a category error, and it fails eight of seventeen files permanently. So the scope is the directory, with two exemptions decided per row. Nothing opts in: `npm run bench:verify` reads bench/results/*.jsonl and a new results file is gated the moment it is committed. A declared list of filenames was the obvious alternative and is the one thing that cannot work -- it is opt-in wearing a different hat, and a file left off it is silently ungated, which is the failure being fixed. The family split keys on `schema_version`, and on its absence rather than its presence. #392 suggested the field as the inclusion marker; it is the opposite. BaseRow (bench/deterministic/types.ts) declares `readonly schema_version: 1`, so every metric row carries it, and result.schema.json is additionalProperties:false with no such property, so no valid run record can. Keying inclusion on presence would have selected exactly the eight files this schema cannot describe. A file carrying it on some rows and not others is failed rather than classified: that is a corrupt file, and it is also the only accident by which a run-record file could leave the gate. Six run-record matrices predate 1073fa4, which made harness_commit and dist_digest required. They cannot be corrected -- bench/results/ holds committed measurements, and ADR-0018 is why a superseded or invalidated matrix stays in the tree rather than being deleted -- but they are not skipped and the schema is not weakened. The gate compiles a second validator from the committed schema with exactly those two `required` entries dropped, and applies it only to rows that identify themselves as old by their own started_at. Every other constraint still holds for all 380 of them. The cutoff is not a judgement call: the last row without provenance started at 2026-07-27T02:21:50.808Z, the first row with it at 2026-07-27T07:21:31.025Z, and 1073fa4 is dated 2026-07-27T02:56:12Z, inside that gap. It cannot creep forward, because started_at comes from the clock when the runner writes the row. Both exemptions are printed per file on every run, so the scope is checkable by running the gate rather than by trusting the comment that defines it -- which is the whole complaint this closes. Record-Id: r-benchscope Limit: nothing checks the shape of the eight metric-row files. This gate names them and steps over them, and bench/deterministic/types.ts is the only definition that family has -- there is no JSON schema for it, so drift on that side is still invisible Limit: the pre-provenance exemption reads started_at, which is data on the row rather than a fact about the file. A row that misreported it would be held to the shorter list of requirements; that is a deliberate falsification rather than the omission this fixes, and nothing here detects it Ruled-out: A declared list of result files, in the gate or in a manifest | opt-in under another name, and a file left off it is silently ungated -- which is precisely the failure #392 reports Ruled-out: Validating only the three files that pass today | a description of the present rather than a rule, and it goes stale the first time a matrix is added or the schema moves Ruled-out: A naming convention on results filenames | the runner's default output is an ISO stamp plus hex and the metric writers' is a family name plus an ISO stamp, but every run-record file in the tree was named by hand through --out (t702-m2, m5-off-design-20-tasks), so the convention is enforced nowhere and cannot carry a gate Ruled-out: A marker file dropped beside each gated result | still a step somebody has to remember, and a forgotten step is the defect being fixed Ruled-out: Widening result.schema.json to describe both row families | they share no required field, so the union would accept a run record with no task and a metric row with no metric, which is a schema that rejects nothing Ruled-out: Making harness_commit and dist_digest optional in the schema | weakens the gate for every future row in order to admit six historical files, and #390's drift was an additionalProperties failure that a laxer schema catches later rather than sooner Ruled-out: Skipping the six pre-provenance files whole | cheaper to write, but it stops checking types, patterns and the closed property set on 380 rows in order to excuse two absent fields Certainty: firm Blast: module Undo: easy Verified: the gap confirmed at 85ea006 -- grep for verify.mjs across .github/workflows/ and package.json exits 1; the drift reproduced at 6d4e828^ (e5d8eed), where verify.mjs reports 80 problem(s) across 80 rows of m5-off-design-20-tasks.jsonl, all additionalProperties; the survey run file by file before any change -- 17 .jsonl, 3 passing and 14 failing, the 14 splitting into 8 metric-row files and 6 run-record files missing only harness_commit and dist_digest; npm run bench:verify now exits 0 over 632 rows in 9 files, naming the 8 metric-row files as out of scope and 380 rows as pre-provenance; the gate bites -- a scratch bench/results/*.jsonl carrying one undeclared field made it exit 1 naming file and line, and deleting that file returned it to exit 0 with bench/results/ clean; the exemption is bounded -- the same row passes dated 2026-07-26 and fails dated 2026-08-01, and a pre-provenance row with an extra field still fails; npx vitest run reports Test Files 83 passed, Tests 2092 passed and 1 skipped, 11 of them new here; npm run typecheck, npx tsc -p bench/tsconfig.json --noEmit and npm run build clean with dist/ byte-unchanged; node scripts/check-readme-numbers.mjs exits 0 and bash spec/verify.sh reports OK on 26 fixtures; no file under bench/results/ was modified Unverified: the CI step has not run on a GitHub runner from this commit -- only the command it invokes has been run locally, on macOS and one Node version; whether the metric family keeps declaring schema_version, which holds because BaseRow requires it today and is enforced nowhere outside bench/deterministic/types.ts; whether any pre-provenance row misstates its own started_at, since the cutoff was derived from the rows in the tree rather than from an independent record of when each matrix ran
This was referenced Aug 2, 2026
Merged
This was referenced Aug 11, 2026
Merged
Merged
This was referenced Aug 19, 2026
This was referenced Aug 21, 2026
The record is usually the only place the rejected alternative is written down, so Stage 0 holds
#829
Merged
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
bench/TOKEN-LEDGER.md§5 blocker B gave two reasons the drafting turn (W4)is missing from the write side. One was that it needs a model call. The other
was sharper: the driver read a single session-total
usageobject, so theharness could not have attributed an answer to the turn that produced it even
if a call were made. This closes the second reason. It is the instrument, not
the measurement — no drafting turn has been priced, and no figure in §9 moves.
What the CLI actually emits
Established by running it, not by designing against an expectation. Both raw
captures are committed under
test/fixtures/claude-stream/with local pathsredacted, so every claim below is checkable.
--output-format stream-json--verboseunder--printassistanteventsmessage.usageandmessage.id— summing them double-counts every turninput_tokensand both cache fields on those eventsoutput_tokenson those eventsmessage_startsnapshotstream_event→message_deltaoutput_tokens_details.thinking_tokens— emitted only under--include-partial-messagesThe gap is not small. On one probe the assistant events reported 4, 1 and 1
output tokens for three turns whose real outputs were 157, 193 and 36 — a
session total of 6 against 403. Nothing on the event marks that field
provisional while its neighbours are not.
So the one term the write side is missing is exactly the one term
stream-jsonalone reports wrongly, which is why the driver passes--include-partial-messagesand the parser keys offmessage_delta.What is now measurable
--per-turn-usagewrites aturn_usageobject: one entry per assistant APIcall, with its four usage fields, thinking tokens, stop reason, model, and the
kinds of its content blocks. Subagent turns are recorded with their
parent_tool_use_idrather than merged away.Every ledger carries its own audit instead of asking to be trusted:
turn_total,session_total, and areconciledboolean that is true onlywhen the turns sum to the total the CLI states for itself, field for field. A
parser that missed or duplicated a turn says so on the row.
How earlier runs are kept intact
bench/results/was produced under,and no committed result file is touched.
resultobject.transcript,turnsand
tokensare still read from it, under the same field selection(
cache_read_input_tokensstill excluded). Checked on a live run: sessiontotal 26 + 649 + 752 = the 1,427 the row reported.
already measured is re-derived from them.
A pre-existing schema failure, fixed on the way past
reproposal_matchesand the fourrejected_path_*counts had reached the rowswithout reaching
result.schema.json, sobench/verify.mjsrejected all 80rows of the most recent matrix on
additionalProperties. Adding a sixth fieldto that list without fixing it would have shipped a gate that gates nothing.
The gate now validates 252 rows across three current-shape result files.
What remains blocked
captureagainst a run's owntranscript and diff. The instrument exists; the measurement has not been
made.
corpus — a usage ledger cannot recover a transcript nobody kept.
counts, but a count is not a billing rate.
turn_usagecarries the provider's; everything inTOKEN-LEDGER.md is in the product's own
ceil(chars / 4)proxy. A future W4must be reported beside the floor, never added into it.
content_blockssays a turn called a tool, not which tool or on what.reproposedcounts correct behaviour as failure — measure rejected work, not rejected mentions #141's rejected-path tool actions stay uncounted.bench/TOKEN-LEDGER.md§5 was appended to, not edited — it ispre-registration, and it is the reason §9's figures can be read as a floor. The
new §5.1 records what changed and what did not. The row constant in
bench/deterministic/ledger.tswas narrowed so future rows say the same thing;the committed row keeps the text it was written with.
Verification
npx vitest run— 12 new tests intest/bench-turn-usage.test.ts; fullsuite green
npm run typecheck,npx tsc -p bench/tsconfig.json --noEmit,npm run buildnode scripts/check-readme-numbers.mjs— exit 0, BENCH block byte-identicalnode dist/commitlore.mjs validate -c HEAD— shape ok, references okto inspect the stream format.
README.mdand the translations are untouched.