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