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 atlas_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/// (`ATLAS_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 ATLAS_SNAP_EVICT_ALPHA).
46pub fn draft_conf_tau() -> f32 {
47    parse_draft_conf_tau(std::env::var("ATLAS_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 (`ATLAS_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("ATLAS_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/// `ATLAS_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("ATLAS_MTP_ACCEPT_DEBUG").is_ok())
109}
110
111/// Drafter catch-up feed on serial->speculative transitions
112/// (`ATLAS_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("ATLAS_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/// (`ATLAS_MTP_REFEED_ACCEPTED=1`, default OFF). Requires `ATLAS_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: `ATLAS_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 `ATLAS_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 (`ATLAS_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 `ATLAS_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, `ATLAS_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("ATLAS_MTP_REFEED_ACCEPTED").ok().as_deref() == Some("1") && !mtp_multi_seq_mode()
245}
246
247/// Deliberate off-by-N perturbation of the re-feed's ring LABEL
248/// (`ATLAS_MTP_REFEED_SHIFT`, default 0 = the derived mapping).
249///
250/// This is a MAPPING-VALIDATION hatch, not a tuning knob. The label
251/// convention (`label n holds hidden_{n-1}`, so pair key `k` reads label
252/// `k+1`) cannot be falsified by any self-consistent checksum: the only
253/// independently-exercised consumer of the verify hidden buffer is
254/// `save_hidden_for_mtp(num_accepted)`, which reads the SAME buffer at the
255/// SAME offset formula as the re-feed's `t = num_accepted` write, and the
256/// sequence positions strictly between two propose positions are never
257/// observed by any other code path. So the mapping is tested BEHAVIOURALLY
258/// instead: shift every re-fed label by ±1 and measure acceptance. A uniform
259/// shift keeps the ring contiguous (delivery is unchanged) but hands pair key
260/// `k` the hidden of position `k ± 1`. If the derived mapping is right,
261/// `shift = 0` must be the maximum of the three arms; if `+1` or `−1` wins,
262/// that arm IS the correct mapping. If all three are indistinguishable, the
263/// drafter's KV-history rows do not carry enough signal for this lever to
264/// work at all — which is itself the answer.
265pub fn mtp_refeed_shift() -> isize {
266    std::env::var("ATLAS_MTP_REFEED_SHIFT")
267        .ok()
268        .and_then(|v| v.parse::<isize>().ok())
269        .unwrap_or(0)
270        .clamp(-4, 4)
271}
272
273/// `ATLAS_MTP_REFEED_DEBUG=1`: fingerprint every hidden that enters and
274/// leaves the catch-up ring, so the pair-key -> hidden mapping can be read
275/// off the serve log instead of argued about. Costs a D2H of one hidden row
276/// (`h * 2` bytes) plus a stream sync per event — NEVER enable it in a timed
277/// leg. See `mtp_refeed_shift` for why the fingerprints alone cannot falsify
278/// the mapping, and what they DO establish (the ring's slot arithmetic and
279/// the pair-key bookkeeping round-trip).
280pub fn mtp_refeed_debug() -> bool {
281    std::env::var("ATLAS_MTP_REFEED_DEBUG").ok().as_deref() == Some("1")
282}
283
284/// FNV-1a over a BF16 GPU row, for `mtp_refeed_debug` fingerprints.
285pub fn hidden_fingerprint(gpu: &dyn GpuBackend, p: DevicePtr, h: usize) -> u64 {
286    let mut b = vec![0u8; h * 2];
287    if gpu.copy_d2h(p, &mut b).is_err() {
288        return 0;
289    }
290    let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
291    for byte in &b {
292        hash ^= *byte as u64;
293        hash = hash.wrapping_mul(0x1000_0000_01b3);
294    }
295    hash
296}
297
298/// EP worker command: run one MTP propose in lockstep with rank 0.
299/// Payload after the code: `last_token`, `position`, `num_drafts` (3 x u32).
300pub const EP_CMD_MTP_PROPOSE: u32 = 0xFFFF_FFF5;
301
302/// Run the drafter on EVERY rank with the communicator, instead of rank-0-only
303/// with `comm: None`. **DEFAULT ON since 2026-08-29**; kill switch
304/// `ATLAS_NO_MTP_EP_PROPOSE=1` restores the rank-0-only path.
305///
306/// Both halves move together and neither is safe alone:
307/// * the head broadcasts [`EP_CMD_MTP_PROPOSE`] before every propose, so the
308///   worker runs the SAME drafter forward and issues the SAME collectives in
309///   the same stream order;
310/// * [`DraftProposer::needs_comm`] then hands the block a comm.
311///
312/// Only a proposer that returns true from [`DraftProposer::needs_comm`] is
313/// affected, and today that is GLM-5.3 alone — the Qwen and DeepSeek-V4 MTP
314/// modules load every expert on every rank and must keep `comm: None`.
315///
316/// Measured on 2 x GB10 (t67, six-probe gate byte-identical on every arm):
317/// open512 17.53 -> 19.11 tok/s, p1 0.747 -> 0.875.
318///
319/// 🪤 `ATLAS_MTP_EP_PROPOSE=1` (the opt-in name it shipped behind for one day)
320/// still reads as ON, so a launch script carrying it keeps working.
321pub fn mtp_ep_propose_enabled() -> bool {
322    static ON: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
323    *ON.get_or_init(|| std::env::var("ATLAS_NO_MTP_EP_PROPOSE").ok().as_deref() != Some("1"))
324}
325
326pub trait DraftProposer: Send + Sync {
327    /// Allocate per-sequence proposer state.
328    fn alloc_state(&self, gpu: &dyn GpuBackend) -> Result<Box<dyn ProposerState>>;
329
330    /// [`Self::alloc_state`] with the sequence's KNOWN token budget
331    /// (`prompt_len + max_tokens`), so a proposer whose per-sequence state
332    /// scales with context can size to what this request can actually reach
333    /// instead of the global `--max-seq-len` ceiling. That distinction is what
334    /// OOMs a high-concurrency long-context serve: the ceiling is per-sequence
335    /// and paid n times, while a typical request needs a fraction of it.
336    ///
337    /// `usize::MAX` means "unknown, use the ceiling". Defaults to
338    /// `alloc_state`, so proposers with fixed-size state need not implement it.
339    fn alloc_state_for(
340        &self,
341        gpu: &dyn GpuBackend,
342        budget_tokens: usize,
343    ) -> Result<Box<dyn ProposerState>> {
344        let _ = budget_tokens;
345        self.alloc_state(gpu)
346    }
347
348    /// The proposer's trained block size γ, when it is a block-diffusion
349    /// drafter (DFlash/DFlash2). The serve layer derives num_drafts from
350    /// this — the head resolved it from the drafter checkpoint and is the
351    /// SSOT. `None` = not a block drafter.
352    fn block_gamma(&self) -> Option<usize> {
353        None
354    }
355
356    /// Chain confidence of the most recent `propose` (min top-1 softmax prob
357    /// across its drafts), when the proposer computes it (`draft_conf_tau` >
358    /// 0). `None` = not computed; callers must not gate on it then.
359    fn last_confidence(&self) -> Option<f32> {
360        None
361    }
362
363    /// Rows this proposer can actually consume from `mtp_prefill_hidden`, given
364    /// the served `--max-seq-len`.
365    ///
366    /// The model allocates that buffer as `[rows, hidden]` BF16 before it knows
367    /// anything about the proposer, so `max_seq_len` is the only bound it has —
368    /// 4.0 GiB at 524,288 and h=4096. A proposer whose own architecture caps the
369    /// position it can ever be asked for returns that cap instead, and the
370    /// difference stops being allocated. See ANOMALIES A59 and the note in
371    /// `Glm5NextMtpHead::new`.
372    ///
373    /// 🔴 Return a SMALLER number ONLY when the proposer can never be handed a
374    /// position past it. A cap below the reachable context does not corrupt
375    /// anything — the capture-coverage check at the propose site disables
376    /// drafter-prefill for a sequence whose rows are short — but it silently
377    /// costs acceptance on exactly the long prompts the feature exists for.
378    ///
379    /// Default: `max_seq_len`, i.e. the pre-A59 sizing, which is correct for any
380    /// proposer that can follow the target to the end of the served context.
381    fn prefill_hidden_rows(&self, max_seq_len: usize) -> usize {
382        max_seq_len
383    }
384
385    /// True when this proposer's block is SHARDED across ranks and its
386    /// forward therefore needs the communicator (a routed-MoE all-reduce and
387    /// a row-parallel `o_proj` reduce), like any target layer.
388    ///
389    /// Default false, which is correct for the Qwen and DeepSeek-V4 drafters:
390    /// their MTP modules load EVERY expert on EVERY rank, so the output is
391    /// already complete and passing a comm would DOUBLE it via SUM.
392    ///
393    /// 🔴 GLM-5.3 is the opposite and it is not a choice: `load_glm5next_mtp_module`
394    /// builds its MoE through the same `Glm5NextMlpConfig` the target layers use, so
395    /// `build_moe` walks `cfg.local_expert_range()` and loads 144 of 288 experts;
396    /// `DsaTpPlan::new(tp_rank, tp_world_size, ..)` splits the DSA heads the same way.
397    ///
398    /// 🪤 Returning true is NOT sufficient on its own — that is exactly what `t58` did and
399    /// it deadlocked at the first propose. The worker rank must ALSO execute the propose,
400    /// or rank 0's drafter collectives land against whatever the worker issues next. See
401    /// `EP_CMD_MTP_PROPOSE`.
402    fn needs_comm(&self) -> bool {
403        false
404    }
405
406    /// True when this proposer's context prefill uses the SHARED forward
407    /// scratch (`ctx.buffers`), so it must not run from the end-of-prefill
408    /// eager hook — only from the first `propose`, where the target owns
409    /// nothing.
410    ///
411    /// MEASURED 2026-08-29 (GLM-5.3, 2x GB10, t61): the eager call site with
412    /// the GLM drafter prefill engaged changed the TARGET's completion on 2 of
413    /// the 6 sealed probes and collapsed p1 from 0.625 to 0.045. The identical
414    /// prefill work moved to the first propose is byte-identical on all six and
415    /// takes p1 to 0.747. The call site is the only variable between the two
416    /// arms; the exact colliding buffer is UNVERIFIED (`norm_output` and
417    /// `moe_output` are the candidates the GLM block writes).
418    fn prefill_uses_shared_buffers(&self) -> bool {
419        false
420    }
421
422    /// Current drafter KV length (rows), for the catch-up append point.
423    /// 0 = unknown / not applicable (catch-up is skipped).
424    fn drafter_rows(&self, _state: &mut dyn ProposerState) -> usize {
425        0
426    }
427
428    /// Sequence-space pair key of the newest drafter row (`None` = untracked;
429    /// catch-up is skipped). The drafter row space is compacted, so `rows`
430    /// cannot locate the drafter in the sequence — this can.
431    fn last_pair_key(&self, _state: &mut dyn ProposerState) -> Option<usize> {
432        None
433    }
434
435    /// ATLAS_MTP_CARRY_DRAFTER: move this sequence's drafter KV blocks OUT of
436    /// its proposer state, so `free_state` releases nothing and the model can
437    /// hold them for the next turn. Returns `(blocks, rows, last_pair_key)`;
438    /// `None` = unsupported or nothing to carry. After this call the state
439    /// must behave as if freshly allocated.
440    fn take_drafter_kv(
441        &self,
442        _state: &mut dyn ProposerState,
443    ) -> Option<(Vec<u32>, usize, Option<usize>)> {
444        None
445    }
446
447    /// Inverse of [`Self::take_drafter_kv`]: install carried blocks into a fresh
448    /// proposer state. Returns false when unsupported (caller must then free
449    /// the blocks itself).
450    fn install_drafter_kv(
451        &self,
452        _state: &mut dyn ProposerState,
453        _blocks: Vec<u32>,
454        _rows: usize,
455        _last_pair_key: Option<usize>,
456    ) -> bool {
457        false
458    }
459
460    /// Release drafter KV blocks that no proposer state owns (a carried entry
461    /// being replaced or dropped).
462    fn free_drafter_kv(&self, _blocks: &[u32]) {}
463
464    /// Append drafter rows at KV slots `row_base ..` with RoPE positions
465    /// `pos_base ..` from `(tokens, hiddens)` pairs — the catch-up feed.
466    /// Returns rows written (0 = unsupported/no-op).
467    #[allow(clippy::too_many_arguments)]
468    fn catchup_drafter(
469        &self,
470        _tokens: &[u32],
471        _hiddens: DevicePtr,
472        _row_base: usize,
473        _pos_base: usize,
474        _state: &mut dyn ProposerState,
475        _ctx: &ForwardContext,
476        _stream: u64,
477    ) -> Result<usize> {
478        Ok(0)
479    }
480
481    /// Propose up to `num_drafts` tokens autoregressively.
482    ///
483    /// # Arguments
484    /// * `last_token` - The last verified token (target model output)
485    /// * `target_hidden` - Target model's hidden states after final norm [1, hidden_size] BF16
486    /// * `position` - Current sequence position (for RoPE)
487    /// * `num_drafts` - Maximum number of draft tokens to produce
488    /// * `state` - Per-sequence proposer state
489    /// * `ctx` - Shared forward context (buffers, gpu, config)
490    /// * `stream` - CUDA stream handle
491    /// * `grammar_bitmask` - Optional XGrammar bitmask (ceil(vocab_size/32) i32
492    ///   words). When `Some`, drafts are constrained to tokens the grammar
493    ///   accepts at the current matcher position; bit `tok` set ⇒ allowed.
494    ///   `None` preserves the unconstrained fast path.
495    /// * `target_hidden_stack` - Optional pointer to a contiguous buffer of
496    ///   `5 × target_hidden × bf16` containing the most-recently-decoded
497    ///   token's hidden states captured at the drafter's `target_layer_ids`
498    ///   (DFlash uses this; MTP ignores). Layout matches vLLM's
499    ///   `combine_hidden_states` input: shallow-to-deep concatenation along
500    ///   the feature axis.
501    fn propose(
502        &self,
503        last_token: u32,
504        target_hidden: DevicePtr,
505        position: usize,
506        num_drafts: usize,
507        state: &mut dyn ProposerState,
508        ctx: &ForwardContext,
509        stream: u64,
510        draft_embed_target: Option<DevicePtr>,
511        grammar_bitmask: Option<&[i32]>,
512        target_hidden_stack: Option<DevicePtr>,
513    ) -> Result<Vec<u32>>;
514
515    /// Batched cross-sequence propose: draft `num_drafts` tokens for each of
516    /// `n = last_tokens.len()` sequences, reading every drafter weight ONCE
517    /// per draft position instead of once per sequence (the measured C=4
518    /// serialization: 12 x ~5 ms per-seq drafter forwards per batched verify
519    /// step, ~62 ms of the ~180 ms step).
520    ///
521    /// Row i of every slice belongs to sequence i; `target_hiddens[i]` is
522    /// that sequence's accepted-position hidden ([1, hidden] BF16, may be
523    /// non-contiguous across i). Chains autoregressively per sequence like
524    /// `propose` — position j uses (draft_{j-1}, drafter's own hidden row i).
525    ///
526    /// Returns `Ok(None)` when unsupported (caller falls back to the per-seq
527    /// `propose` loop); `Ok(Some(drafts))` with `drafts[i].len() ==
528    /// num_drafts` on success. Grammar-constrained sequences must not reach
529    /// this path (callers gate on grammarless).
530    #[allow(clippy::too_many_arguments)]
531    fn propose_batch(
532        &self,
533        _last_tokens: &[u32],
534        _target_hiddens: &[DevicePtr],
535        _positions: &[usize],
536        _num_drafts: usize,
537        _states: &mut [&mut dyn ProposerState],
538        _ctx: &ForwardContext,
539        _stream: u64,
540        _out_conf: Option<&mut Vec<Vec<f32>>>,
541    ) -> Result<Option<Vec<Vec<u32>>>> {
542        Ok(None)
543    }
544
545    /// The widest batch [`Self::propose_batch`] can carry in ONE drafter
546    /// forward per draft position, derived from this proposer's resolved
547    /// kernels and the arena's row capacities. `1` = per-sequence only.
548    ///
549    /// Callers chunk by this instead of a hardcoded constant: a fixed cap of
550    /// 4 made a 16-sequence step run 4 drafter forwards per position, each
551    /// re-reading the whole drafter — the batched-propose lever's own cost
552    /// re-introduced by its caller.
553    fn propose_batch_max(&self, _buffers: &BufferArena, _config: &ModelConfig) -> usize {
554        1
555    }
556
557    /// Prefill the drafter's own context (KV cache) over the prompt, before
558    /// the first `propose()` of a sequence (ATLAS_MTP_DRAFTER_PREFILL).
559    ///
560    /// * `prompt_tokens` — the prompt token ids `t_0..t_{P-1}`.
561    /// * `hiddens` — device buffer `[P, hidden_size]` BF16; row `i` is the
562    ///   target's final-layer (pre-final-norm) hidden after processing `t_i`.
563    ///
564    /// Returns the number of drafter positions written (0 = unsupported /
565    /// already prefilled / nothing to do). Default: no-op.
566    fn prefill_drafter(
567        &self,
568        prompt_tokens: &[u32],
569        hiddens: DevicePtr,
570        state: &mut dyn ProposerState,
571        ctx: &ForwardContext,
572        stream: u64,
573    ) -> Result<usize> {
574        let _ = (prompt_tokens, hiddens, state, ctx, stream);
575        Ok(0)
576    }
577
578    /// Read the draft token ID stored on GPU by the last `propose()` call
579    /// that used `draft_embed_target = Some(...)`. Returns 0 if not supported.
580    fn read_deferred_draft_token(&self, gpu: &dyn GpuBackend) -> Result<u32> {
581        let _ = gpu;
582        Ok(0)
583    }
584
585    /// Called after target verification to trim proposer state.
586    ///
587    /// `num_accepted` indicates how many draft tokens were accepted.
588    /// The proposer should trim its KV cache / state to match.
589    fn after_verify(
590        &self,
591        num_accepted: usize,
592        state: &mut dyn ProposerState,
593        stream: u64,
594    ) -> Result<()>;
595
596    /// Free per-sequence proposer state (KV cache blocks, device buffers, etc.).
597    ///
598    /// Must be called when a sequence is finished to avoid resource leaks.
599    /// `gpu` is threaded in (symmetric with `alloc_state`) so implementations
600    /// can release raw device allocations stored on the state — `DevicePtr`
601    /// has no `Drop`, so anything `alloc_state` allocated leaks unless it is
602    /// explicitly freed here.
603    fn free_state(&self, gpu: &dyn GpuBackend, state: &mut dyn ProposerState) -> Result<()> {
604        let _ = (gpu, state);
605        Ok(())
606    }
607}