spark_model/
speculative.rs

1// SPDX-License-Identifier: AGPL-3.0-only
2
3//! Speculative decoding abstraction (SDD).
4//!
5//! Defines the [`DraftProposer`] trait for speculative decoding strategies.
6//! MTP implements this first; EAGLE-3 can implement later without engine changes.
7
8pub mod ladder;
9pub mod tree_shape;
10pub mod verify_key;
11
12pub use ladder::{mtp_ladder_disabled, mtp_ladder_drafts, mtp_max_seqs};
13
14use std::any::Any;
15
16use anyhow::Result;
17use avarok_core::config::ModelConfig;
18use spark_runtime::buffers::BufferArena;
19use spark_runtime::gpu::{DevicePtr, GpuBackend};
20
21use crate::layer::ForwardContext;
22
23/// Per-sequence state owned by a [`DraftProposer`].
24///
25/// Stores KV cache, hidden states, or whatever the proposer needs
26/// across decode steps. Follows the same downcasting pattern as `LayerState`.
27pub trait ProposerState: Send + Sync {
28    fn as_any(&self) -> &dyn Any;
29    fn as_any_mut(&mut self) -> &mut dyn Any;
30}
31
32/// A draft token proposer for speculative decoding.
33///
34/// The engine calls `propose()` after each target decode to get draft tokens,
35/// then verifies them with the target model. `after_verify()` lets the
36/// proposer trim state (e.g., KV cache) based on how many drafts were accepted.
37/// Confidence floor for submitting drafts to verification
38/// (`AVAROK_MTP_DRAFT_CONF`, default 0.0 = disabled). When the drafter's
39/// chain confidence (min top-1 softmax prob across the drafts of one
40/// propose) is below this, the drafts are discarded and the next step
41/// decodes serially — skipping a verify that would most likely reject.
42/// Economics at K=1 on the 35B MoE: verify ≈ 35 ms for 1+acc tokens vs
43/// decode+propose ≈ 21 ms for 1, so a draft is only worth verifying when
44/// p(accept) ≳ 0.66 — the threshold to calibrate around. Staged OFF until
45/// its measured A/B (same discipline as AVAROK_SNAP_EVICT_ALPHA).
46pub fn draft_conf_tau() -> f32 {
47    parse_draft_conf_tau(std::env::var("AVAROK_MTP_DRAFT_CONF").ok().as_deref())
48}
49
50/// The rule itself, pure over the raw value.
51///
52/// Split from the reader so a test can exercise the CLAMP without mutating
53/// the process environment — `set_var` is global and `cargo test` runs this
54/// binary's tests in parallel, so an env-mutating test races every other one.
55/// The clamp is the load-bearing part: an unclamped `5.0` would put the floor
56/// above any achievable confidence and discard EVERY draft, turning
57/// speculation off with nothing logged.
58pub fn parse_draft_conf_tau(value: Option<&str>) -> f32 {
59    value
60        .and_then(|v| v.parse::<f32>().ok())
61        .map(|t| t.clamp(0.0, 0.99))
62        .unwrap_or(0.0)
63}
64
65/// Shadow top-k draft instrumentation (`AVAROK_MTP_SHADOW_TOPK=k`, default
66/// 0 = off, clamp k ≤ 8). Observational only — token selection untouched.
67/// Each drafter `forward_one` D2H's its logits (same ~200 µs the conf path
68/// pays) and logs the top-k candidate ids + softmax probs per position;
69/// the verify steps log the target argmaxes under the same gate. Joining
70/// the two offline yields the per-depth conditional top-k coverage that
71/// gates the tree-speculation build (Phase 0 of the tree-spec plan).
72/// Value-parsed, not presence-checked (`=0` really is off).
73///
74/// The SSOT parse. Both `ModelLevers::shadow_topk` (spark-model) and
75/// `SchedLevers::shadow_topk` (spark-server) resolve through it once per run
76/// rather than caching the answer in a `OnceLock` that a swap would pin.
77pub fn shadow_topk() -> usize {
78    std::env::var("AVAROK_MTP_SHADOW_TOPK")
79        .ok()
80        .and_then(|v| v.parse::<usize>().ok())
81        .unwrap_or(0)
82        .min(8)
83}
84
85/// True when the MTP cap is raised above one sequence. The catchup ring
86/// (`types.rs` `mtp_catchup_ring` + meta), the refeed label convention, and
87/// the carry slot (`mtp_carry.rs`) are SINGLE-SEQUENCE structures — one ring,
88/// one label range, one slot. Running them with n concurrently-verifying
89/// sequences interleaves unrelated hiddens under one label space and breaks
90/// `after_verify`'s env-keyed trim contract (`mtp_rows_to_trim`). Disabling
91/// them process-wide when the cap > 1 is the only consistent option; a
92/// slot-keyed ring is recorded follow-up work (acceptance debt at n>1).
93pub fn mtp_multi_seq_mode() -> bool {
94    mtp_max_seqs() > 1
95}
96
97/// `AVAROK_MTP_ACCEPT_DEBUG` (PRESENCE): per-BATCH-WIDTH acceptance telemetry.
98///
99/// The shipped K ladder gives `k_drafts == 2` at n in [5, 8], and the existing
100/// positional counters (`k4_record_positional`) are gated on `k_drafts == 3`,
101/// so at the C=8 operating point NOTHING reported p1 — only the na histogram.
102/// This gate turns on a per-n line reporting p1, mean accepted and the derived
103/// tokens/step, which is the quantity the C=8 arithmetic is written in.
104/// Counters only (no D2H, no sync), but it logs per period, so keep it off in
105/// timed legs unless the leg IS the accept measurement.
106pub fn mtp_accept_debug() -> bool {
107    static ON: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
108    *ON.get_or_init(|| std::env::var("AVAROK_MTP_ACCEPT_DEBUG").is_ok())
109}
110
111/// Drafter catch-up feed on serial->speculative transitions
112/// (`AVAROK_MTP_CATCHUP=1`, staged off). During serial-decode stretches the
113/// scheduler rings the per-step final hiddens; on the next propose the gap
114/// rows are batch-fed into the drafter KV so it never runs stale. Wrong
115/// feeds cannot corrupt output (verification rejects bad drafts) — the
116/// stake is acceptance only, which is the flip gate's metric.
117/// Force-off in multi-seq MTP mode (single-sequence ring; see
118/// [`mtp_multi_seq_mode`]).
119pub fn mtp_catchup_enabled() -> bool {
120    std::env::var("AVAROK_MTP_CATCHUP").ok().as_deref() == Some("1") && !mtp_multi_seq_mode()
121}
122
123/// Re-feed ACCEPTED draft rows with the target's TRUE hidden state
124/// (`AVAROK_MTP_REFEED_ACCEPTED=1`, default OFF). Requires `AVAROK_MTP_CATCHUP=1`.
125///
126/// WHY. The MTP head is one module run autoregressively. Draft 1 consumes the
127/// TARGET's verified hidden (`mtp_hidden_save`); every later draft consumes the
128/// drafter's OWN single-block residual (`mtp_head.rs`, `current_hidden =
129/// ctx.buffers.hidden_states()`). The drafter KV row written for draft d >= 2
130/// therefore pairs the right token with the WRONG hidden — and on ACCEPT that
131/// row is kept forever: `after_verify` trims only REJECTED rows. So every
132/// accepted draft permanently contaminates the drafter's own context.
133///
134/// Measured on dgx2 (W4A4 27B, gate disarmed, seq_len ~10k, n=700/config):
135/// unconditional per-position acceptance 0.660 -> 0.485 -> 0.407, i.e. the
136/// FIRST autoregressive step costs x0.735 while the second costs only x0.838 —
137/// the loss is concentrated exactly at the hidden-state handoff. Neither
138/// existing lever touches it: `AVAROK_MTP_CATCHUP=1` alone is bit-identical
139/// (its ring is only written on SERIAL decode steps, and with the throughput
140/// gate disarmed there are none), and dropping `AVAROK_MTP_DRAFTER_PREFILL`
141/// costs only 0.017/0.030 (~1 sd).
142///
143/// WHAT THIS DOES. After a verify, the target's true hidden for every accepted
144/// position is sitting in the verify hidden buffer. Ring those hiddens under
145/// the same label convention the serial path uses, and have `after_verify`
146/// additionally drop the `num_accepted - 1` accepted rows that were written
147/// with a drafter hidden. The next propose's catch-up feed then rebuilds
148/// exactly those rows from the ring, with the TARGET's hidden, through the
149/// already-exercised `catchup_drafter` batch path. No new kernel, no new
150/// state machine — it reuses the gap-fill machinery for a gap that was never
151/// being detected.
152///
153/// SAFETY. A wrong feed cannot corrupt output: verification rejects bad
154/// drafts. The stake is acceptance only.
155///
156/// ## STATUS 2026-07-21 (SUPERSEDES the earlier "refuted" note). STAGED OFF.
157///
158/// The earlier note claimed the pair-key -> hidden mapping was wrong, inferred
159/// from a sign reversal between a 67%-delivery and a 99%-delivery arm at
160/// n=700 (+0.021 -> −0.023 on p2_uncond). **That inference is withdrawn.** The
161/// two arms differed by only ~1.7 sd, neither was more than 1 sd from the
162/// baseline, and they are not paired samples (each arm emits different text).
163///
164/// The mapping has since been VERIFIED DIRECTLY, with dumped hidden
165/// fingerprints (`AVAROK_MTP_REFEED_DEBUG=1`, FNV-1a over each BF16 row), on
166/// dgx2 / W4A4 27B / nd=2 / gate disarmed:
167///
168/// | check | result |
169/// |---|---|
170/// | ring D2D landed (`fp_src == fp_dst`) | 658 / 658 |
171/// | fed hidden == live ring content at that label | 422 / 422 |
172/// | `label == key + 1` and `RoPE == key + 1` on every feed | always |
173/// | `fp(ring[position]) == fp(mtp_hidden_save)` at each propose | 302 / 304 (the 2 are a run's first propose) |
174/// | **feed(key k) == `mtp_hidden_save` at the propose whose position was k+1** | **93 / 93** |
175///
176/// The last row is the non-tautological one: it compares the hidden this
177/// feature feeds for pair key `k` against the hidden the drafter's own
178/// `forward_one` consumed as `target_hidden` when it wrote pair key `k` —
179/// two different code paths, bit-identical on every checkable case. So the
180/// convention "ring label n holds hidden_{n−1}, hence pair key k reads label
181/// k+1" is confirmed against an independently-exercised consumer.
182///
183/// What the earlier session DID find is real and is now fixed: the exclusive
184/// `0..num_accepted` bound left one label unwritten per step, collapsing the
185/// ring's contiguous window (458 fed / 231 missed = 67%). The bound is now
186/// `0..=num_accepted` on both K=3 and K=4 (K=4 matters because
187/// `mtp_rows_to_trim`'s extra trim is K-agnostic — without a K=4 ring write,
188/// nd=3 would drop accepted drafter rows with nothing rebuilding them).
189///
190/// ## POWERED A/B (2026-07-21, dgx2): the pre-registered threshold is MET.
191///
192/// nd=2, gate disarmed, 16 documents x 8 turns, ~10k verify steps per arm
193/// (with `AVAROK_MTP_GATE_FORCE=1` the engine is bit-reproducible, so n rises
194/// only with NEW CONTENT, never with repetitions).
195///
196/// | arm | n | p1 | p2_uncond | tokens/verify step |
197/// |---|---|---|---|---|
198/// | OFF | 10,400 | 0.6100 | 0.4182 | 1.882 |
199/// | ON  | 10,100 | 0.6262 | **0.4452** | 1.926 |
200/// | delta | | +0.016 (2.4 sd) | **+0.027 (3.9 sd)** | **+2.3%** |
201///
202/// Criterion, pre-registered before the run: `p2_uncond` up by ≥ 0.015 at
203/// ≥ 3 sd. Met. At n=700 — the sample that produced the earlier "refuted"
204/// verdict — this same effect is ~1.0 sd, i.e. invisible. That verdict was a
205/// power problem, not a mapping problem.
206///
207/// Caveat kept deliberately: the arms emit different text, so the binomial sd
208/// understates the true variance. Content is matched (identical documents and
209/// questions in both arms) but this is one measurement, not a replication.
210/// STAYS DEFAULT OFF pending the standard gates (C2 smoke, A 35B
211/// webserver_ok, B/D ST-995).
212///
213/// ## SIZE IT AGAINST THE REAL PRIZE BEFORE SPENDING ANY MORE TIME HERE
214///
215/// This lever is small BY CONSTRUCTION: it repairs at most
216/// `num_accepted − 1` drafter KV **history** rows per step, while the measured
217/// p1->p2 cliff happens WITHIN a single `propose`, where a history repair
218/// cannot act at all. Two larger effects were measured the same night:
219///
220/// 1. **The drafter's own INPUT hidden at draft position >= 2** (dgx1's
221///    teacher-forced oracle probe, `AVAROK_MTP_ORACLE_P2`): feeding draft 2 the
222///    TARGET's true hidden instead of the MTP head's own takes p2_cond
223///    0.5265 -> 0.7196, McNemar z = +18.4, recovering 1.40x the p1−p2 gap.
224///    "Exposure bias" is refuted — the drafter is not mis-calibrated, it is
225///    fed the wrong vector. That is **+0.193**, about **7x** this flag's
226///    +0.027.
227/// 2. **Drafter context blindness on WARM turns** (dgx2): the drafter holds
228///    only **142 KV rows at sequence position 10,098**, because
229///    `try_mtp_prefill_capture` no-ops whenever a prefill starts at a
230///    reused-prefix boundary and the drafter prompt-prefill is then skipped.
231///    Prefilling it on every turn measured **+0.086 p1 / +0.101 p2_uncond /
232///    +10.2% accepted tokens per verify step** at n ~ 10k per arm, of which a
233///    de-confounding pair (drafter coverage held at zero, prefix caching the
234///    only variable) attributes **+0.079 p1 / +0.089 p2_uncond — 92% / 88% —
235///    to drafter coverage** and the small remainder to warm restore.
236///
237/// Both dwarf this flag, and (2) also changes what this flag is worth: a
238/// drafter that can actually see the prompt is a different drafter. **Build
239/// (2) first, then re-measure this.**
240///
241/// Force-off in multi-seq MTP mode: the refeed label space is single-sequence
242/// (see [`mtp_multi_seq_mode`]).
243pub fn mtp_refeed_accepted_enabled() -> bool {
244    std::env::var("AVAROK_MTP_REFEED_ACCEPTED").ok().as_deref() == Some("1")
245        && !mtp_multi_seq_mode()
246}
247
248/// Deliberate off-by-N perturbation of the re-feed's ring LABEL
249/// (`AVAROK_MTP_REFEED_SHIFT`, default 0 = the derived mapping).
250///
251/// This is a MAPPING-VALIDATION hatch, not a tuning knob. The label
252/// convention (`label n holds hidden_{n-1}`, so pair key `k` reads label
253/// `k+1`) cannot be falsified by any self-consistent checksum: the only
254/// independently-exercised consumer of the verify hidden buffer is
255/// `save_hidden_for_mtp(num_accepted)`, which reads the SAME buffer at the
256/// SAME offset formula as the re-feed's `t = num_accepted` write, and the
257/// sequence positions strictly between two propose positions are never
258/// observed by any other code path. So the mapping is tested BEHAVIOURALLY
259/// instead: shift every re-fed label by ±1 and measure acceptance. A uniform
260/// shift keeps the ring contiguous (delivery is unchanged) but hands pair key
261/// `k` the hidden of position `k ± 1`. If the derived mapping is right,
262/// `shift = 0` must be the maximum of the three arms; if `+1` or `−1` wins,
263/// that arm IS the correct mapping. If all three are indistinguishable, the
264/// drafter's KV-history rows do not carry enough signal for this lever to
265/// work at all — which is itself the answer.
266pub fn mtp_refeed_shift() -> isize {
267    std::env::var("AVAROK_MTP_REFEED_SHIFT")
268        .ok()
269        .and_then(|v| v.parse::<isize>().ok())
270        .unwrap_or(0)
271        .clamp(-4, 4)
272}
273
274/// `AVAROK_MTP_REFEED_DEBUG=1`: fingerprint every hidden that enters and
275/// leaves the catch-up ring, so the pair-key -> hidden mapping can be read
276/// off the serve log instead of argued about. Costs a D2H of one hidden row
277/// (`h * 2` bytes) plus a stream sync per event — NEVER enable it in a timed
278/// leg. See `mtp_refeed_shift` for why the fingerprints alone cannot falsify
279/// the mapping, and what they DO establish (the ring's slot arithmetic and
280/// the pair-key bookkeeping round-trip).
281pub fn mtp_refeed_debug() -> bool {
282    std::env::var("AVAROK_MTP_REFEED_DEBUG").ok().as_deref() == Some("1")
283}
284
285/// FNV-1a over a BF16 GPU row, for `mtp_refeed_debug` fingerprints.
286pub fn hidden_fingerprint(gpu: &dyn GpuBackend, p: DevicePtr, h: usize) -> u64 {
287    let mut b = vec![0u8; h * 2];
288    if gpu.copy_d2h(p, &mut b).is_err() {
289        return 0;
290    }
291    let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
292    for byte in &b {
293        hash ^= *byte as u64;
294        hash = hash.wrapping_mul(0x1000_0000_01b3);
295    }
296    hash
297}
298
299/// EP worker command: run one MTP propose in lockstep with rank 0.
300/// Payload after the code: `last_token`, `position`, `num_drafts` (3 x u32).
301pub const EP_CMD_MTP_PROPOSE: u32 = 0xFFFF_FFF5;
302
303/// Run the drafter on EVERY rank with the communicator, instead of rank-0-only
304/// with `comm: None`. **DEFAULT ON since 2026-08-29**; kill switch
305/// `AVAROK_NO_MTP_EP_PROPOSE=1` restores the rank-0-only path.
306///
307/// Both halves move together and neither is safe alone:
308/// * the head broadcasts [`EP_CMD_MTP_PROPOSE`] before every propose, so the
309///   worker runs the SAME drafter forward and issues the SAME collectives in
310///   the same stream order;
311/// * [`DraftProposer::needs_comm`] then hands the block a comm.
312///
313/// Only a proposer that returns true from [`DraftProposer::needs_comm`] is
314/// affected, and today that is GLM-5.3 alone — the Qwen and DeepSeek-V4 MTP
315/// modules load every expert on every rank and must keep `comm: None`.
316///
317/// Measured on 2 x GB10 (t67, six-probe gate byte-identical on every arm):
318/// open512 17.53 -> 19.11 tok/s, p1 0.747 -> 0.875.
319///
320/// 🪤 `AVAROK_MTP_EP_PROPOSE=1` (the opt-in name it shipped behind for one day)
321/// still reads as ON, so a launch script carrying it keeps working.
322pub fn mtp_ep_propose_enabled() -> bool {
323    static ON: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
324    *ON.get_or_init(|| std::env::var("AVAROK_NO_MTP_EP_PROPOSE").ok().as_deref() != Some("1"))
325}
326
327pub trait DraftProposer: Send + Sync {
328    /// Allocate per-sequence proposer state.
329    fn alloc_state(&self, gpu: &dyn GpuBackend) -> Result<Box<dyn ProposerState>>;
330
331    /// [`Self::alloc_state`] with the sequence's KNOWN token budget
332    /// (`prompt_len + max_tokens`), so a proposer whose per-sequence state
333    /// scales with context can size to what this request can actually reach
334    /// instead of the global `--max-seq-len` ceiling. That distinction is what
335    /// OOMs a high-concurrency long-context serve: the ceiling is per-sequence
336    /// and paid n times, while a typical request needs a fraction of it.
337    ///
338    /// `usize::MAX` means "unknown, use the ceiling". Defaults to
339    /// `alloc_state`, so proposers with fixed-size state need not implement it.
340    fn alloc_state_for(
341        &self,
342        gpu: &dyn GpuBackend,
343        budget_tokens: usize,
344    ) -> Result<Box<dyn ProposerState>> {
345        let _ = budget_tokens;
346        self.alloc_state(gpu)
347    }
348
349    /// The proposer's trained block size γ, when it is a block-diffusion
350    /// drafter (DFlash/DFlash2). The serve layer derives num_drafts from
351    /// this — the head resolved it from the drafter checkpoint and is the
352    /// SSOT. `None` = not a block drafter.
353    fn block_gamma(&self) -> Option<usize> {
354        None
355    }
356
357    /// Chain confidence of the most recent `propose` (min top-1 softmax prob
358    /// across its drafts), when the proposer computes it (`draft_conf_tau` >
359    /// 0). `None` = not computed; callers must not gate on it then.
360    fn last_confidence(&self) -> Option<f32> {
361        None
362    }
363
364    /// Rows this proposer can actually consume from `mtp_prefill_hidden`, given
365    /// the served `--max-seq-len`.
366    ///
367    /// The model allocates that buffer as `[rows, hidden]` BF16 before it knows
368    /// anything about the proposer, so `max_seq_len` is the only bound it has —
369    /// 4.0 GiB at 524,288 and h=4096. A proposer whose own architecture caps the
370    /// position it can ever be asked for returns that cap instead, and the
371    /// difference stops being allocated. See ANOMALIES A59 and the note in
372    /// `Glm5NextMtpHead::new`.
373    ///
374    /// 🔴 Return a SMALLER number ONLY when the proposer can never be handed a
375    /// position past it. A cap below the reachable context does not corrupt
376    /// anything — the capture-coverage check at the propose site disables
377    /// drafter-prefill for a sequence whose rows are short — but it silently
378    /// costs acceptance on exactly the long prompts the feature exists for.
379    ///
380    /// Default: `max_seq_len`, i.e. the pre-A59 sizing, which is correct for any
381    /// proposer that can follow the target to the end of the served context.
382    fn prefill_hidden_rows(&self, max_seq_len: usize) -> usize {
383        max_seq_len
384    }
385
386    /// True when this proposer's block is SHARDED across ranks and its
387    /// forward therefore needs the communicator (a routed-MoE all-reduce and
388    /// a row-parallel `o_proj` reduce), like any target layer.
389    ///
390    /// Default false, which is correct for the Qwen and DeepSeek-V4 drafters:
391    /// their MTP modules load EVERY expert on EVERY rank, so the output is
392    /// already complete and passing a comm would DOUBLE it via SUM.
393    ///
394    /// 🔴 GLM-5.3 is the opposite and it is not a choice: `load_glm5next_mtp_module`
395    /// builds its MoE through the same `Glm5NextMlpConfig` the target layers use, so
396    /// `build_moe` walks `cfg.local_expert_range()` and loads 144 of 288 experts;
397    /// `DsaTpPlan::new(tp_rank, tp_world_size, ..)` splits the DSA heads the same way.
398    ///
399    /// 🪤 Returning true is NOT sufficient on its own — that is exactly what `t58` did and
400    /// it deadlocked at the first propose. The worker rank must ALSO execute the propose,
401    /// or rank 0's drafter collectives land against whatever the worker issues next. See
402    /// `EP_CMD_MTP_PROPOSE`.
403    fn needs_comm(&self) -> bool {
404        false
405    }
406
407    /// True when this proposer's context prefill uses the SHARED forward
408    /// scratch (`ctx.buffers`), so it must not run from the end-of-prefill
409    /// eager hook — only from the first `propose`, where the target owns
410    /// nothing.
411    ///
412    /// MEASURED 2026-08-29 (GLM-5.3, 2x GB10, t61): the eager call site with
413    /// the GLM drafter prefill engaged changed the TARGET's completion on 2 of
414    /// the 6 sealed probes and collapsed p1 from 0.625 to 0.045. The identical
415    /// prefill work moved to the first propose is byte-identical on all six and
416    /// takes p1 to 0.747. The call site is the only variable between the two
417    /// arms; the exact colliding buffer is UNVERIFIED (`norm_output` and
418    /// `moe_output` are the candidates the GLM block writes).
419    fn prefill_uses_shared_buffers(&self) -> bool {
420        false
421    }
422
423    /// Current drafter KV length (rows), for the catch-up append point.
424    /// 0 = unknown / not applicable (catch-up is skipped).
425    fn drafter_rows(&self, _state: &mut dyn ProposerState) -> usize {
426        0
427    }
428
429    /// Sequence-space pair key of the newest drafter row (`None` = untracked;
430    /// catch-up is skipped). The drafter row space is compacted, so `rows`
431    /// cannot locate the drafter in the sequence — this can.
432    fn last_pair_key(&self, _state: &mut dyn ProposerState) -> Option<usize> {
433        None
434    }
435
436    /// AVAROK_MTP_CARRY_DRAFTER: move this sequence's drafter KV blocks OUT of
437    /// its proposer state, so `free_state` releases nothing and the model can
438    /// hold them for the next turn. Returns `(blocks, rows, last_pair_key)`;
439    /// `None` = unsupported or nothing to carry. After this call the state
440    /// must behave as if freshly allocated.
441    fn take_drafter_kv(
442        &self,
443        _state: &mut dyn ProposerState,
444    ) -> Option<(Vec<u32>, usize, Option<usize>)> {
445        None
446    }
447
448    /// Inverse of [`Self::take_drafter_kv`]: install carried blocks into a fresh
449    /// proposer state. Returns false when unsupported (caller must then free
450    /// the blocks itself).
451    fn install_drafter_kv(
452        &self,
453        _state: &mut dyn ProposerState,
454        _blocks: Vec<u32>,
455        _rows: usize,
456        _last_pair_key: Option<usize>,
457    ) -> bool {
458        false
459    }
460
461    /// Release drafter KV blocks that no proposer state owns (a carried entry
462    /// being replaced or dropped).
463    fn free_drafter_kv(&self, _blocks: &[u32]) {}
464
465    /// Append drafter rows at KV slots `row_base ..` with RoPE positions
466    /// `pos_base ..` from `(tokens, hiddens)` pairs — the catch-up feed.
467    /// Returns rows written (0 = unsupported/no-op).
468    #[allow(clippy::too_many_arguments)]
469    fn catchup_drafter(
470        &self,
471        _tokens: &[u32],
472        _hiddens: DevicePtr,
473        _row_base: usize,
474        _pos_base: usize,
475        _state: &mut dyn ProposerState,
476        _ctx: &ForwardContext,
477        _stream: u64,
478    ) -> Result<usize> {
479        Ok(0)
480    }
481
482    /// Propose up to `num_drafts` tokens autoregressively.
483    ///
484    /// # Arguments
485    /// * `last_token` - The last verified token (target model output)
486    /// * `target_hidden` - Target model's hidden states after final norm [1, hidden_size] BF16
487    /// * `position` - Current sequence position (for RoPE)
488    /// * `num_drafts` - Maximum number of draft tokens to produce
489    /// * `state` - Per-sequence proposer state
490    /// * `ctx` - Shared forward context (buffers, gpu, config)
491    /// * `stream` - CUDA stream handle
492    /// * `grammar_bitmask` - Optional XGrammar bitmask (ceil(vocab_size/32) i32
493    ///   words). When `Some`, drafts are constrained to tokens the grammar
494    ///   accepts at the current matcher position; bit `tok` set ⇒ allowed.
495    ///   `None` preserves the unconstrained fast path.
496    /// * `target_hidden_stack` - Optional pointer to a contiguous buffer of
497    ///   `5 × target_hidden × bf16` containing the most-recently-decoded
498    ///   token's hidden states captured at the drafter's `target_layer_ids`
499    ///   (DFlash uses this; MTP ignores). Layout matches vLLM's
500    ///   `combine_hidden_states` input: shallow-to-deep concatenation along
501    ///   the feature axis.
502    fn propose(
503        &self,
504        last_token: u32,
505        target_hidden: DevicePtr,
506        position: usize,
507        num_drafts: usize,
508        state: &mut dyn ProposerState,
509        ctx: &ForwardContext,
510        stream: u64,
511        draft_embed_target: Option<DevicePtr>,
512        grammar_bitmask: Option<&[i32]>,
513        target_hidden_stack: Option<DevicePtr>,
514    ) -> Result<Vec<u32>>;
515
516    /// Batched cross-sequence propose: draft `num_drafts` tokens for each of
517    /// `n = last_tokens.len()` sequences, reading every drafter weight ONCE
518    /// per draft position instead of once per sequence (the measured C=4
519    /// serialization: 12 x ~5 ms per-seq drafter forwards per batched verify
520    /// step, ~62 ms of the ~180 ms step).
521    ///
522    /// Row i of every slice belongs to sequence i; `target_hiddens[i]` is
523    /// that sequence's accepted-position hidden ([1, hidden] BF16, may be
524    /// non-contiguous across i). Chains autoregressively per sequence like
525    /// `propose` — position j uses (draft_{j-1}, drafter's own hidden row i).
526    ///
527    /// Returns `Ok(None)` when unsupported (caller falls back to the per-seq
528    /// `propose` loop); `Ok(Some(drafts))` with `drafts[i].len() ==
529    /// num_drafts` on success. Grammar-constrained sequences must not reach
530    /// this path (callers gate on grammarless).
531    #[allow(clippy::too_many_arguments)]
532    fn propose_batch(
533        &self,
534        _last_tokens: &[u32],
535        _target_hiddens: &[DevicePtr],
536        _positions: &[usize],
537        _num_drafts: usize,
538        _states: &mut [&mut dyn ProposerState],
539        _ctx: &ForwardContext,
540        _stream: u64,
541        _out_conf: Option<&mut Vec<Vec<f32>>>,
542    ) -> Result<Option<Vec<Vec<u32>>>> {
543        Ok(None)
544    }
545
546    /// The widest batch [`Self::propose_batch`] can carry in ONE drafter
547    /// forward per draft position, derived from this proposer's resolved
548    /// kernels and the arena's row capacities. `1` = per-sequence only.
549    ///
550    /// Callers chunk by this instead of a hardcoded constant: a fixed cap of
551    /// 4 made a 16-sequence step run 4 drafter forwards per position, each
552    /// re-reading the whole drafter — the batched-propose lever's own cost
553    /// re-introduced by its caller.
554    fn propose_batch_max(&self, _buffers: &BufferArena, _config: &ModelConfig) -> usize {
555        1
556    }
557
558    /// Prefill the drafter's own context (KV cache) over the prompt, before
559    /// the first `propose()` of a sequence (AVAROK_MTP_DRAFTER_PREFILL).
560    ///
561    /// * `prompt_tokens` — the prompt token ids `t_0..t_{P-1}`.
562    /// * `hiddens` — device buffer `[P, hidden_size]` BF16; row `i` is the
563    ///   target's final-layer (pre-final-norm) hidden after processing `t_i`.
564    ///
565    /// Returns the number of drafter positions written (0 = unsupported /
566    /// already prefilled / nothing to do). Default: no-op.
567    fn prefill_drafter(
568        &self,
569        prompt_tokens: &[u32],
570        hiddens: DevicePtr,
571        state: &mut dyn ProposerState,
572        ctx: &ForwardContext,
573        stream: u64,
574    ) -> Result<usize> {
575        let _ = (prompt_tokens, hiddens, state, ctx, stream);
576        Ok(0)
577    }
578
579    /// Read the draft token ID stored on GPU by the last `propose()` call
580    /// that used `draft_embed_target = Some(...)`. Returns 0 if not supported.
581    fn read_deferred_draft_token(&self, gpu: &dyn GpuBackend) -> Result<u32> {
582        let _ = gpu;
583        Ok(0)
584    }
585
586    /// Called after target verification to trim proposer state.
587    ///
588    /// `num_accepted` indicates how many draft tokens were accepted.
589    /// The proposer should trim its KV cache / state to match.
590    fn after_verify(
591        &self,
592        num_accepted: usize,
593        state: &mut dyn ProposerState,
594        stream: u64,
595    ) -> Result<()>;
596
597    /// Free per-sequence proposer state (KV cache blocks, device buffers, etc.).
598    ///
599    /// Must be called when a sequence is finished to avoid resource leaks.
600    /// `gpu` is threaded in (symmetric with `alloc_state`) so implementations
601    /// can release raw device allocations stored on the state — `DevicePtr`
602    /// has no `Drop`, so anything `alloc_state` allocated leaks unless it is
603    /// explicitly freed here.
604    fn free_state(&self, gpu: &dyn GpuBackend, state: &mut dyn ProposerState) -> Result<()> {
605        let _ = (gpu, state);
606        Ok(())
607    }
608}