Repository navigation
vision-suite: MTP draft-depth as a campaign arm, a low start rung, and two silent-failure fixes - #209
Conversation
summarize_engine_compare.py and summarize_head_to_head.py both take run_engine_compare.sh's TAG_PREFIX campaign namespace; the contract matrix did not, and called resolve_tag(DIR, model, think) with the prefix argument omitted. The failure mode is silent and misreads badly: a prefixed campaign finds no score file, so every arm renders "—" and the row looks like a model that answered nothing, when the scores are simply filed under another tag. Hit on the sync15_ campaign, where all eight contract arms rendered empty. resolve_tag already accepted `prefix`; this only threads the flag through. Unprefixed invocations are unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ction Two changes, the second of which is what makes the first safe. CTX_START / CTX_START_THINKON set the FIRST rung each think mode runs at; CTX_LADDER keeps supplying the rungs escalation climbs to. Both default to 16384, so unset behaviour is byte-identical to before. They are separate because the modes have different floors: think-on derives num_predict as (nc - CTX_PROMPT_RESERVE), so a start rung at or below the 8192 reserve yields num_predict <= 0 and cannot run at all. Starting think-off lower is worth real wall clock. A 16384 window allocates KV for tokens most think-off cells never reach, and a larger window decodes slower, so a low-start cell is both cheaper and a fairer throughput reading. But a low start is only safe where prompt + num_predict fits the rung. The server enforces that BEFORE generating and returns 400 -- at 8192 with the default num_predict 2200 that is fine for gemma4 (3765 + 2200) and not for nemotron3, whose multi_3img prompt is 6203 and needs 8403. So escalation now fires on a context-overflow rejection as well as on a cap. Previously it keyed on eval_count >= num_predict alone; an overflow has no eval_count at all, so the cell broke out of the loop, was recorded as a failure at the low rung, and was never retried -- which reads as "the model cannot do this test" when the truth is "the harness asked for it in too small a window". client.py already translates that 400 into a message naming the numbers; this matches on it. Unrelated failures are deliberately NOT escalated: a 500 is not a window problem and climbing the ladder would only burn KV to fail again. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The suite honoured twelve request-shaping env vars and draft depth was not one of them, so MTP speculative decoding could not be varied as a campaign arm -- the only way to measure it was a bespoke loop outside the harness, which is what run_engine_compare.sh's own header warns against. Sent whenever DRAFT_NUM_PREDICT is set, INCLUDING "0". That is deliberate: server/routes.go:154 zeroes draft_num_predict unless the model carries a DraftPath or the option was EXPLICITLY set, so the api/types.go default of 4 never reaches an embedded-MTP model. "0" is therefore the only way to state "MTP off" rather than "unspecified", and without it the off-arm could not be distinguished from an arm that simply forgot the option. Also stamped as _draft_num_predict next to _num_predict / _num_ctx (ADR 0012 rule 1): two scores measured with MTP on and off are otherwise identical in the file, and the arm would exist only in the tag -- exactly the provenance gap SPEC H11 closed for host and build. Inert on the MLX runner, which never reads draft_num_predict (no reference in x/ at all) and chooses depth adaptively via speculate_depth.go instead. Verified end to end on qwen3.8:27b-q4_K_M (arch qwen35, nextn_predict_layers=1): the request stamps _draft_num_predict=4 and the runner argv carries `--spec-type draft-mtp --spec-draft-n-max 4`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The DRAFT_NUM_PREDICT passthrough (79e67c99) stamped _draft_num_predict on the response dict, but vision_suite.py copies only selected fields into the score block and that was not one of them -- so the stamp landed one layer short of the file. Measured it directly: an MTP on/off A/B on qwen3.8:27b-q4_K_M produced four score files that all read draft=[], carrying the arm only in their tag. That is the exact provenance gap ADR 0012 rule 1 exists to prevent, and the reason the fields next to this one exist at all. Follows the req_image_*_tokens contract immediately above: absent means build default, present means this cell was an arm. Tested with `not in (None, "")` rather than truthiness on purpose. "0" is a MEANINGFUL value here -- server/routes.go:154 zeroes draft_num_predict unless explicitly set, so "0" is how an off-arm states MTP was disabled rather than unspecified. `if os.environ.get(env)`, which is what the IMAGE_* loop above uses, would drop precisely that cell and leave the control indistinguishable from a run that never set the option. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…w escalation The four harness changes ahead of this added two env knobs and one behavioural change to the runner, none of which were discoverable from the README -- and an undocumented knob is one nobody uses and everybody re-invents as a bespoke loop, which is the failure mode run_engine_compare.sh's own header was written to stop. DRAFT_NUM_PREDICT goes beside IMAGE_MIN/MAX_TOKENS since it shares their contract (absent = build default, present = this cell was an arm, recorded as req_draft_num_predict). The note to set "0" explicitly for an off-arm is the part worth reading: server/routes.go zeroes the option unless explicitly set, so "unset" and "disabled" produce the same request and only one of them is self-describing afterwards. CTX_START / CTX_START_THINKON and the overflow escalation are documented on the runner entry, including why the two modes need separate floors (think-on derives num_predict from the rung) and why a low start is now safe (a 400 carries no eval_count, so escalation had to learn to fire on rejection as well as on cap). Also corrects the context-ladder table, which still listed five rungs and stopped at 65536: 131072 was added 2026-08-19 and a qwen3.6 think-on cell reached it on 2026-08-21. No ADR change: these apply existing decisions rather than making new ones -- ADR 0012 rule 1 already requires provenance to come from the score files, and req_draft_num_predict is that rule applied to a new field, not a new rule. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Reviewing as consolidator. All five are real, every failure mode named is genuinely silent, and the
|
Five harness changes, split out of the v0.32.15 sync work (#208) because they are independent of it — disjoint files, and
draft_num_predicthas been inapi/types.gosince upstream v0.32.7, so none of this depends on the merge.Each was found by hitting it during a real campaign, and each failure mode was silent.
ae07fe1d—--prefixforsummarize_contract_matrix.pyThe only summarizer without it. It called
resolve_tag(DIR, model, think)with the prefix argument omitted, so aTAG_PREFIXcampaign found no score file and rendered every arm as—— indistinguishable from a model that answered nothing.resolve_tagalready tookprefix; this threads the flag through.73d948a4—CTX_STARTknob, and escalate on a context-overflow rejectionCTX_START/CTX_START_THINKONset the first rung each think mode runs at;CTX_LADDERstill supplies the rungs escalation climbs to. Both default to 16384, so unset behaviour is byte-identical. They are separate because think-on derivesnum_predictasnum_ctx - CTX_PROMPT_RESERVE— an 8192 start would give itnum_predict = 0.A low think-off start is worth real wall clock: a 16384 window allocates KV most think-off cells never reach, and a larger window decodes slower.
What makes it safe is the second half. Escalation previously keyed on
eval_count >= num_predictalone. A context-overflow 400 carries noeval_countat all, so the cell broke out of the loop, was recorded as a failure at the low rung, and was never retried — reading as "this model cannot do this test" when the truth was "the harness asked in too small a window". At 8192 withnum_predict2200 that is the difference between gemma4 (3765 + 2200, fits) and nemotron3 (6203 + 2200, rejected).Unrelated failures deliberately do not escalate — a 500 is not a window problem, and climbing would burn KV to fail again. Unit-tested against capped / both overflow message forms / an unrelated 500 / a non-dict block before use.
7bef3253+704db6a8—DRAFT_NUM_PREDICTpassthrough, and actually recording itThe suite honoured twelve request-shaping env vars and draft depth was not one, so MTP could not be a campaign arm at all — the only way to vary it was a bespoke loop outside the harness, which
run_engine_compare.sh's own header exists to prevent.Sent whenever set, including
0:server/routes.go:154zeroesdraft_num_predictunless the model carries aDraftPathor the option was explicitly set, so theapi/types.godefault of 4 never reaches an embedded-MTP model, and0is the only way to state disabled rather than unspecified.The second commit is a fix to the first, and worth flagging honestly:
7bef3253stamped_draft_num_predicton the response dict, butvision_suite.pycopies only selected fields into the score block, so the stamp landed one layer short of the file. I verified the wrong artefact and called it proven. An MTP on/off A/B then produced four score files that all readdraft=[], carrying the arm only in their tag — the exact provenance gap ADR 0012 rule 1 exists to prevent.704db6a8recordsreq_draft_num_predictin the block, following thereq_image_*_tokenscontract immediately above it, and testsnot in (None, "")rather than truthiness because theIMAGE_*loop'sif os.environ.get(env)idiom would silently drop the0off-arm.3c5a9d28— docsAll of the above in the README, plus a correction: the context-ladder table still listed five rungs stopping at 65536. 131072 was added 2026-08-19 and a qwen3.6 think-on cell reached it on 2026-08-21.
No ADR change. These apply existing decisions rather than making new ones — ADR 0012 rule 1 already requires provenance to come from score files, and
req_draft_num_predictis that rule applied to a new field, not a new rule.Measured with it
MTP on
qwen3.8:27b-q4_K_M(archqwen35,nextn_predict_layers=1), full vision suite, clean single-build provenance:Decode roughly doubles, prefill is untouched, and think=false quality is bit-identical at the same
eval_count— same output, half the time. A depth sweep (0/2/4/6/8) put the knee at 4, which is already the built-in default.