Draft-model fallback for N-gram proposer misses (ngram_fallback = "draft") - #1410
Draft-model fallback for N-gram proposer misses (ngram_fallback = "draft")#1410danielwinterw wants to merge 6 commits into
Conversation
|
Important Draft PR not reviewedDraft PRs are not automatically reviewed by default.
To automatically review draft PRs, update your CodeRabbit configuration: reviews:
auto_review:
drafts: trueThanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
|
This pull request is currently a draft. Reviews will not take place until the PR is marked as ready for review. |
b246828 to
c012ea8
Compare
c012ea8 to
7e88288
Compare
7832eb8 to
c151cba
Compare
c151cba to
8833352
Compare
8833352 to
70125ee
Compare
70125ee to
cd64739
Compare
cd64739 to
463d54e
Compare
463d54e to
c407639
Compare
i386
left a comment
There was a problem hiding this comment.
The config plumbing and gating look solid (validated enum, resolver bails when draft is set without an N-gram strategy, default false keeps all existing paths identical, and the classic serial draft loop correctly stays authoritative at depth 1).
One blocking ask: the new incremental-sync logic in DraftRunner is subtle and entirely untested. synced bookkeeping is now load-bearing for KV correctness across fallback proposals (prefix-extension vs full reset on divergence, plus propose pushing each accepted current), and a mistake here silently corrupts draft proposals at runtime. Please add unit coverage for at least:
sync_to_contextextends incrementally when the target has the synced tokens as a prefix (assert no reset/prefill of the whole context),sync_to_contextfalls back to a full reset on divergence,- one end-to-end path exercising
ngram_fallback_draft = truewith an N-gram miss (assertingfallback_draft_proposals > 0and that output still matches the non-fallback run), or — if a model-backed test isn't feasible in CI — a test at thedraft_runnerlevel proving propose-after-sync produces the same tokens as propose-after-reset for the same context.
Minor, non-blocking: in the first fallback site the budget is ... .min(native_mtp_remaining) .max(2) — the .max(2) can exceed a remaining budget of 1; worth a comment or clamp if a downstream invariant depends on it.
| .sync_to_context(&context_tokens) | ||
| .map_err(openai_backend_error)?; | ||
| let budget = native_mtp_options | ||
| .ngram_max_proposal_tokens |
There was a problem hiding this comment.
The .max(2) here can raise the budget above native_mtp_remaining when only 1 token of run-ahead budget is left — the floor wins over the cap. If a downstream invariant assumes the proposal fits the remaining window this overshoots by one; either clamp after the min (budget.min(remaining).max(1) semantics) or document that exceeding the remaining budget by one is benign here.
Also flagging inline: the two sync_to_context call sites (here and the refill path extending with optimistic_suffix()) are the new untested KV-bookkeeping path from my review — please add the DraftRunner prefix/divergence unit tests before merge.
There was a problem hiding this comment.
Both fixed in 16c2de0 (now c6038f2 after rebase).
The floor: it now applies before the remaining-window cap (.max(2).min(native_mtp_remaining)), and the fallback is skipped entirely when fewer than two tokens remain, so it falls through to the serial path instead of overshooting by one. I went with the clamp rather than documenting the overshoot as benign — the downstream window arithmetic assumes the proposal fits, and a one-token slack there is not worth the reasoning.
The sync bookkeeping: extracted DraftSyncState/DraftSyncPlan so the load-bearing decision is separable from the session I/O and testable without a model. Eight unit tests cover incremental extension when the target has the synced tokens as a prefix, the exact-sync no-op, divergence resetting (both a differing token and a synced run longer than the target, which is the rejected-proposal case), propose's accepted tokens joining the prefix so the next sync extends rather than resets, a rejected proposal step forcing a reset, record_reset replacing the prefix, and the Extend range indexing the caller's context slice correctly.
c6038f2 to
1a227fb
Compare
1a227fb to
2da5803
Compare
|
Reviewed at
Three things. 1. The seed path trusts an invariant the refill path is careful aboutThe refill site does this correctly: let mut sequence = context_tokens.clone();
sequence.extend_from_slice(pipeline.optimistic_suffix());
if let Some(&last) = sequence.last() {
draft.sync_to_context(&sequence)?;
let draft_tokens = draft.propose(last, budget)?;
The seed site doesn't: draft.sync_to_context(&context_tokens)?;
...
let draft_tokens = draft.propose(current, budget)?;This is only correct if
Cheapest fix is to just not depend on it — propose from 2.
|
…= "draft") When the suffix/cache proposer misses, the pipelined verify-window path now proposes from the configured draft model instead of degrading to one token per round trip: the draft session syncs incrementally to the committed context plus optimistic suffix (full re-prefill only on divergence), and its greedy rollout feeds the same candidate pipeline. Config: speculative.ngram_fallback = "draft" alongside a draft_model; the classic serial draft loop stays disabled while fallback drives the pipeline, and depth-1 setups keep classic behavior. Falls back opt-in, pipelined only. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The exported reference is sorted by canonical path; insert the new speculative entries in order. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…dget inside the window - DraftSyncState/DraftSyncPlan split the load-bearing synced-prefix decision out of the session I/O so it can be tested without a model: prefix extension, exact-sync no-op, divergence reset, proposal steps joining the prefix, and a rejected step forcing a reset. - The fallback budget applies its floor before the remaining-window cap, and the fallback is skipped when fewer than two tokens remain, so a proposal can no longer overshoot the window by a token. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…onfigs, record steps after they land - The seed path proposes from context_tokens.last() — the token sync_to_context deliberately leaves unmaterialized — instead of from the loop's `current`. The two agree today, but only by an invariant maintained across the whole decode loop, and a slip would be silent KV corruption rather than an error. A debug_assert keeps it visible. - ngram_fallback = "draft" now requires speculative.draft_model and a pipeline depth above 1. Either missing meant the stage started cleanly and never took the fallback path, leaving the operator a zero counter indistinguishable from a proposer that never missed. - propose records a step after decode_step succeeds, so a failed decode cannot leave the sync state claiming a token the session lacks. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2da5803 to
2d47d28
Compare
|
All three in 2d47d28. 1. Seed path invariant. Took the cheap fix rather than depending on it — the seed path now proposes from 2. Silent no-op. Both cases now bail in the resolver: 3. propose ordering. Fixed as suggested — records Bench still owed before this leaves draft; that's the freeform-workload run, not an E2E smoke. |
Draft: fall back to a draft model when the N-gram proposer misses
speculative.ngram_fallback = "draft"(alongside a configured draft model):when the suffix/cache proposer has no candidates, the pipelined verify-window
path proposes from the draft model instead of degrading to one token per
round trip — the failure mode that dominates freeform text on high-RTT links.
suffix (
DraftRunner::sync_to_context); a full re-prefill only happens ondivergence. Accepted fallback proposals extend the sync prefix, so
consecutive fallback refills are cheap.
points; the classic serial draft loop stays disabled while fallback drives
the pipeline, and depth-1 setups keep classic behavior.
speculative_fallback_draft_{proposals,tokens,ms}.Draft because: unit-tested and suite-green, but not yet exercised
end-to-end with a real draft model — needs a freeform-workload bench run
(the N-gram-friendly re-emit workload never misses, so the fallback path
stays cold there).