Repository navigation
fix(vision-suite): the token column is not the answer length under think-on - #195
Conversation
…ink-on
summarize_engine_compare.py rendered eval_count under the header "Answer tok"
in both think modes. eval_count is every token the model generated, so with
thinking on the column is reasoning + answer: the merged eighteen-model
campaign records 5588 for gemma4:12b-it-q4_K_M against an answer of a few
hundred tokens, and reading that as an answer length understates every model's
answer-per-second by whatever share went to reasoning.
That eval_count includes reasoning is not an inference. Spec R6 makes it
normative ("Reasoning tokens MUST appear in eval_count ... MUST be the total
generated across any internal passes"), TestChatThinkFormatMarkerStop asserts
109 = 100 thinking-pass + 9 content tokens, and on the MLX side pipeline.go
increments one counter per sampled token with no notion of a thinking block.
So the header now follows --think: "Answer tok" with thinking off, where the
runner asked for no reasoning and the label is exact and has been published
since 2026-08-08, and "Gen tok" with it on, which is what eval_count is in
either mode. An unrecognised mode lands on "Gen tok" -- that one is never
wrong, and the narrower label is wrong whenever reasoning ran. No number moves:
s/req and req/h were always derived from eval_count correctly, which is why
only the label was at fault.
The published table is corrected in place, header only, with a note saying so.
An audit of every vision-campaign-*.md found exactly one such table, and the
think-off tables keep "Answer tok".
The real split remains unavailable -- the API reports one count and the parser
that knows where reasoning ends takes text, not tokens (#189) -- so this
corrects what the column claims rather than measuring something new.
test_summarizers.py is new: it pins label-to-field pairing, which is what
nothing else checked, and sweeps every summarize_*.py with ast for a header
that names the answer without consulting the mode. Verified by mutation --
always-"Answer tok", always-"Gen tok", rendering prompt_eval_count instead, and
an ungated header each turn it red. The fixture caught one of my own errors
first: 5588 against a think-off cap reads as capped.
Both this and test_rescore.py sit outside preflight/, where nothing ran them,
so preflight-expectations.yaml now scopes to the whole vision suite and runs
all three. Together ~0.2 seconds, stdlib only.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Reviewing as consolidator. This is the right fix and it supersedes work I had in progress — I am dropping mine. I went the other way: stream the response and count chunks per phase, so the split would be measured rather than labelled around. That does not work, and the measurement is worth adding here because it closes off the workaround the next person will reach for.
One streamed chunk is not one token. 552 chunks carried 690 tokens — about 20% fewer chunks than tokens, and only a single chunk is unattributable, so "some tokens produce no delta" explains none of it. The server coalesces. A second run at a different length showed the same ~15–20% shortfall (1412 counted against 1659), so it is proportional rather than a fixed offset. So the phase split from streaming is a chunk count wearing a token label — the same class of error this PR fixes, arrived at by a different route. Your line
is exactly right, and the streaming route does not rescue it. That leaves character counts as the only exact per-phase quantity available. Worth a sentence in the document, because "just stream it and count" is an obvious idea that looks like it should work and takes a measurement to rule out. On the fix itselfMaking the header follow Correcting the published table in place with a note that only the header moved is right too — and auditing every
|
The rest of the auditFinding the one mislabelled published table meant sweeping every summarizer and every campaign doc for the same defect class — a right number under a wrong name. That turned up 13 more, each independently verified against the file by a second pass that was told to default to "not real". Recording them here rather than fixing them: several would change published numbers, and each deserves its own justification. Ordered by whether a reader would draw a wrong conclusion. Changes a published number
Overstates what was checked
Records the wrong thing / explains it wrongly
One layer down, outside this suite
I would not fix that one here. Rejected23 further candidates were rejected on verification — mostly labels that are terse rather than wrong ( |
…-18 audit (#204) Eight sites where a label described something other than the field under it. Recorded on #195 and left open; closed here. LABELS THAT OVERSTATE WHAT WAS CHECKED - "✅ all Qs + bbox" gates q1, q2 and q4-bbox. The multi-image prompt asks FOUR questions and q3 is never scored, so a cell that answered q3 wrongly still read as a clean sweep. Now "✅ q1 + q2 + q4-bbox". - "name_bbox hits" is a coarse pixel-band match, not an IoU hit; the column now says in-band. - "finetext fabricated" is total_found - sum(recall), which counts every entry that matched no gold code -- malformed and duplicate entries included, not only invented ones. Now "finetext unmatched", which is what the arithmetic supports. EXPLANATIONS THAT NAME THE WRONG LIMIT - summarize_contract_matrix's footnote said `cap` meant generation "never terminated at that window", attributing to num_ctx a stop that num_predict caused. They are different limits and the whole point of the marker is which. - docs/api.md defined eval_count as "number of tokens in the response". It is every generated token, thinking included, and with think+format the total across both passes -- normative here as spec R6, asserted by TestChatThinkFormatMarkerStop (109 = 100 thinking + 9 content), and the reason the vision suite's token column had to stop saying "Answer tok". FIELDS THAT WERE SIMPLY THE WRONG ONE - ctx_for read num_ctx where req_num_ctx is the requested window. Files written before the num_ctx fold (#153) recorded the SUITE default in num_ctx for the finetext block while req_num_ctx kept what was asked, so served-first reports those rows at a window their finetext never ran at. Two of the seven committed runs change, both from "16384" to "16384/32768 ⚠" -- revealing a mixed window that was hidden, not inventing one. Same rule summarize_reps' _window already uses, so the two agree. - summarize_head_to_head's "(?)" marker means "recorded before req_num_ctx existed", but it also fired on any cell sourced from the pre-fold ft_<tag>.json path, which never wrote that field. It now falls back to num_ctx, so the marker identifies what it claims to. - extbench recorded os.environ["THINK"] verbatim while gen() gates on `!= "on"`, so THINK=true wrote "true" into the summary of a run that sent think=False. Records both now: think_env and think_on. Four tests pin the corrected pairings. summarize_lowtemp's "mean IoU (scored cells)" -- the ninth finding, and the one that inflated a mean by dropping genuine 0.0 IoUs on a truthiness check -- needs no fix: #197 retired the file. Co-authored-by: Local Dev <local@localhost> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Last of the loose ends from the #189 write-up.
The bug
summarize_engine_compare.pyheaded itseval_countcolumn "Answer tok" in both think modes.eval_countis every token generated, so under think-on the column is reasoning + answer:gemma4:12b-it-q4_K_M(think-on, published)against an answer of a few hundred tokens. Read as an answer length it understates every model's answer-per-second by whatever share went to reasoning.
This is not an inference about the API. Three independent confirmations in-tree:
eval_count… MUST be the total generated across any internal passes" (docs/maxusai/spec/structured-output-with-thinking.md:62)TestChatThinkFormatMarkerStopassertseval_count == 109= 100 thinking-pass + 9 content tokens (server/routes_generate_test.go:3992)generated++once per sampled token, no notion of a thinking block (x/mlxrunner/pipeline.go:335,:390)The fix
The header follows
--think:Answer tok. The runner asked for no reasoning, the label is exact, and it has been published in that form since 2026-08-08.Gen tok. Whateval_countis in either mode, and whats/reqandreq/hare derived from — which is why those columns were always right and only the label was wrong.Gen tok. That label is never wrong; the narrower one is wrong whenever reasoning ran.No number moves. The split itself is still unavailable — the API reports one count and the parser that knows where reasoning ends takes text, not tokens (#189) — so this corrects what the column claims, not what it measures.
The published record
vision-campaign-2026-08-17-eighteen-model.mdcarries the think-on table verbatim, so its header is corrected in place with a note stating that only the header changed and no number moved. An audit of everyvision-campaign-*.mdfound exactly one such table; the think-off tables keepAnswer tok, where it is accurate.Verification
test_summarizers.pyis new. It pins label-to-field pairing — the thing nothing else checked — and sweeps everysummarize_*.pywithastfor a header that names the answer without consulting the mode. Mutation-tested:Answer tokGen toktest_think_off_says_answer+ rendered-table testprompt_eval_countinsteadtest_the_number_is_eval_count_in_both_modesastsweepThe fixture caught an error of mine before any of that: 5588 tokens against a think-off cap of 2200 renders as capped, so the think-on fixture needed the raised
num_predictthe runner actually uses.CI gap closed
test_summarizers.pyandtest_rescore.pyboth sit outsidepreflight/, which is the only pathpreflight-expectations.yamlscoped to — sotest_rescore.pyhas guarded the bbox scorer against silent renumbering since it was written with nothing running it. The workflow now scopes to the whole vision suite and runs all three: ~0.2s total, stdlib only, no new required-check name.Not fixed here
The audit that found the single mislabelled table also surfaced other confirmed label/field mismatches across the suite — including one in
docs/api.mdand one insummarize_reps.pythat I introduced earlier tonight. They are listed in the thread rather than fixed here; each deserves its own justification.