Skip to content

vision-suite: MTP draft-depth as a campaign arm, a low start rung, and two silent-failure fixes - #209

Merged
glennneuber merged 5 commits into
mainfrom
feat/vision-suite-mtp-knobs
Aug 22, 2026
Merged

glennneuber merged 5 commits into
mainfrom
feat/vision-suite-mtp-knobs

Conversation

@glennneuber

Copy link
Copy Markdown

Five harness changes, split out of the v0.32.15 sync work (#208) because they are independent of it — disjoint files, and draft_num_predict has been in api/types.go since 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 — --prefix for summarize_contract_matrix.py

The only summarizer without it. It called resolve_tag(DIR, model, think) with the prefix argument omitted, so a TAG_PREFIX campaign found no score file and rendered every arm as — — indistinguishable from a model that answered nothing. resolve_tag already took prefix; this threads the flag through.

73d948a4 — CTX_START knob, and escalate on a context-overflow rejection

CTX_START / CTX_START_THINKON set the first rung each think mode runs at; CTX_LADDER still supplies the rungs escalation climbs to. Both default to 16384, so unset behaviour is byte-identical. They are separate because think-on derives num_predict as num_ctx - CTX_PROMPT_RESERVE — an 8192 start would give it num_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_predict alone. A context-overflow 400 carries 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 — reading as "this model cannot do this test" when the truth was "the harness asked in too small a window". At 8192 with num_predict 2200 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_PREDICT passthrough, and actually recording it

The 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: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, and 0 is the only way to state disabled rather than unspecified.

The second commit is a fix to the first, and worth flagging honestly: 7bef3253 stamped _draft_num_predict on the response dict, but vision_suite.py copies 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 read draft=[], carrying the arm only in their tag — the exact provenance gap ADR 0012 rule 1 exists to prevent. 704db6a8 records req_draft_num_predict in the block, following the req_image_*_tokens contract immediately above it, and tests not in (None, "") rather than truthiness because the IMAGE_* loop's if os.environ.get(env) idiom would silently drop the 0 off-arm.

3c5a9d28 — docs

All 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_predict is that rule applied to a new field, not a new rule.

Measured with it

MTP on qwen3.8:27b-q4_K_M (arch qwen35, nextn_predict_layers=1), full vision suite, clean single-build provenance:

arm gen tok/s req/h scene IoU eval_count
think=false, MTP off 70 390 0.977 544
think=false, MTP on (4) 140 667 0.977 544
think=on, MTP off 70 205 0.975 1117
think=on, MTP on (4) 135 335 1.000 1147

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.

glennneuber and others added 5 commits August 22, 2026 11:44
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>
@glennneuber

Copy link
Copy Markdown
Author

Reviewing as consolidator. All five are real, every failure mode named is genuinely silent, and the CTX_START half is safe for the reason you give rather than by luck.

CTX_START and the ladder guard

I wrote the exit-2 guard (SPEC H4a), so this is the interaction I checked first. It composes correctly: the guard refuses when CTX_MAX leaves no rung above the think-on start, and CTX_START_THINKON moves that start rather than removing it — so a low start makes the guard more likely to fire usefully, not less. Keeping the two knobs separate because think-on derives num_predict = num_ctx − CTX_PROMPT_RESERVE, and an 8192 start would yield num_predict = 0, is exactly the sort of thing that would otherwise be discovered as a mystifying empty cell.

The second half is what makes it safe, and it closes a hole the guard never covered. A context-overflow 400 carries no eval_count, so escalation keyed on eval_count >= num_predict broke out and recorded a failure at the low rung — "this model cannot do this test" when the truth was "asked in too small a window". That is the same class as #202's capped-cell-rendered-as-a-result: a harness limit published as a model property. My guard stopped a fixed ceiling; it did nothing about a rejection that never produced a count to compare.

Not escalating on unrelated failures is the right restraint — a 500 is not a window problem, and climbing burns KV to fail again.

The self-correction in 704db6a8 is the most useful paragraph here

7bef3253 stamped _draft_num_predict on the response dict, but vision_suite.py copies 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.

Recording that is worth more than the fix. It is the same shape as several failures this week — a value that exists somewhere real but not where the reader looks — and "I verified the wrong artefact" is the sentence that stops the next person doing it. The consequence being four score files reading draft=[] with the arm surviving only in the tag makes the ADR 0012 rule 1 point concrete rather than abstract.

Testing not in (None, "") rather than truthiness is the correct fix for exactly the reason given: the neighbouring IMAGE_* loop's if os.environ.get(env) idiom would drop the 0 off-arm, and 0 is the only way to say disabled rather than unspecified given server/routes.go:154. Worth a comment at the IMAGE_* loop too, since it now differs from its neighbour for a non-obvious reason and looks like an inconsistency.

Smaller notes

--prefix on summarize_contract_matrix.py rendering every arm as — is the worst possible failure shape — indistinguishable from a model that answered nothing — and it is the last summarizer without the flag, so this closes the class rather than an instance.

The README rung-table correction (five rungs stopping at 65536, when 131072 was added 2026-08-19 and a qwen3.6 think-on cell reached it on 2026-08-21) is small but load-bearing: a documented ladder that stops short of where cells actually land invites exactly the fixed-ceiling reasoning H4a exists to prevent.

@glennneuber
glennneuber merged commit f8bec1e into main Aug 22, 2026
1 check passed
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