spark_model/traits/
model.rs

1// SPDX-License-Identifier: AGPL-3.0-only
2
3//! `Model` trait — the interface the scheduler talks to.
4//!
5//! ## Dispatch contract
6//!
7//! Per request, the scheduler invokes:
8//!
9//! 1. [`Model::prefill`] (or [`Model::prefill_chunk`] for chunked prefill)
10//!    once per sequence. Returns logits at the last prompt position;
11//!    populates the sequence's KV cache and SSM state.
12//! 2. [`Model::decode`] once per emitted token. Returns next-token logits;
13//!    extends KV/SSM state by one position. May be replaced by
14//!    [`Model::decode_batch`] when multiple sequences are co-scheduled.
15//! 3. Optional speculative-decode verify path: [`Model::decode_verify_graphed`]
16//!    (K=2), [`Model::decode_verify_graphed_k3`] (K=3),
17//!    [`Model::decode_verify_graphed_k4`] (K=4), or
18//!    [`Model::decode_verify_graphed_kgamma`] (DFlash γ-token).
19//!    These take [last_token, draft0, ..] and return per-position logits;
20//!    the scheduler picks accept/reject and rolls back state on reject.
21//! 4. [`Model::mixed_forward`] fuses one decode step + one prefill chunk
22//!    through a single weight load; used by the scheduler to amortize
23//!    weight-streaming cost when both phases are pending.
24//!
25//! Implementors live under `crates/spark-model/src/model/trait_impl/`,
26//! split per phase (prefill_a/b/c/d, decode_a/b, verify_a/b/c/d) per
27//! ADR-0006's multi-file module idiom.
28//!
29//! ## Concurrency
30//!
31//! `Model: Send + Sync` — a single instance handles all sequences
32//! concurrently. Per-sequence state lives in [`SequenceState`].
33
34use anyhow::{Result, bail};
35use spark_runtime::gpu::DevicePtr;
36
37use super::{MixedBatchResult, MixedForwardResult, PrefillSlice, SequenceState};
38
39/// One beam-search request for a translation model (NLLB). Carries the resolved
40/// per-request parameters the scheduler stamps onto the sequence; the model runs
41/// the whole beam search to completion and returns the winning hypothesis.
42/// Per-call options for [`Model::decode_verify_batched`].
43#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
44pub struct VerifyBatchedOpts {
45    /// Ask the GDN layers for the write-on-accept K=4 verify. The caller
46    /// commits to running [`Model::gdn_fold_accepted`] with every verdict
47    /// before its `commit_accepted_prefix` calls. Off by default so a
48    /// caller that commits through another path (the MTP batched K-row
49    /// verify) never reaches a state the layer wrote nothing for.
50    pub write_on_accept: bool,
51}
52
53#[derive(Debug, Clone)]
54pub struct BeamReq {
55    /// Raw source subword ids (the model adds `[src_lang] … </s>` itself).
56    pub prompt_tokens: Vec<u32>,
57    /// Per-request source/target language token ids (`0` = deployment default).
58    pub src_lang_id: u32,
59    pub tgt_lang_id: u32,
60    /// Per-request LoRA slot (`>=0` apply, `-1` base).
61    pub adapter_slot: i32,
62    pub num_beams: usize,
63    pub max_new: usize,
64    pub length_penalty: f32,
65    pub early_stopping: bool,
66}
67
68/// The multi-sequence batch padding ladder — the SSOT for `padded_n`.
69///
70/// Batched decode pads the live sequence count up to a small set of captured
71/// sizes so that (a) CUDA graphs (`AVAROK_DECODE_GRAPHS_MULTISEQ`) are keyed by
72/// a handful of stable shapes instead of one per exact n, and (b) the batched
73/// kernels see a bounded set of widths. Padding rows point at the dummy SSM
74/// slot / dummy KV block and cost one wasted lane each.
75///
76/// This expression used to be duplicated at FOUR call sites
77/// (`decode_a2.rs:168`, `decode_b.rs:52` and `:110`,
78/// `phase_continue_prefills.rs:142` — the last one in a different crate), which
79/// is exactly how the ladder would have drifted when a step was added. All four
80/// now call here.
81///
82/// `12` and `16` were added for the `C=[1,2,4,8,16]` concurrency work
83/// (2026-07-25): previously any n ≥ 9 fell through to `padded_n = n`, so at
84/// C=16 every distinct batch composition minted its OWN CUDA graph (n=9, 10,
85/// ... 16 each a separate capture) and the buffer-fit guards were computed on
86/// exact n.
87///
88/// `24` and `32` were added for native bs=32 (2026-07-30): n=17..32 now pads
89/// to two stable graph shapes instead of minting one graph per exact n.
90///
91/// `48`, `64`, `96` and `128` were added for native bs=64+ (2026-07-31,
92/// wave-14a): the decode-metadata layout is now DERIVED from the serve
93/// `max_batch_size` (`spark_runtime::buffers::DecodeMetaLayout`, rows =
94/// max(32, bs), ceiling `DECODE_META_MAX_ROWS`), and
95/// `upload_batch_metadata_fixed` ensures `padded_n <= rows`. Rungs above the
96/// boot's `max_batch_size` are unreachable (the scheduler admits at most
97/// `max_batch_size` active sequences), so every bs<=32 boot never pads past
98/// 32 — byte-identical by construction. Rungs <=32 unchanged.
99/// Above 128 the fall-through behaviour is unchanged (guarded downstream).
100#[inline]
101pub fn padded_batch_n(n: usize) -> usize {
102    [2usize, 4, 8, 12, 16, 24, 32, 48, 64, 96, 128]
103        .iter()
104        .copied()
105        .find(|&s| s >= n)
106        .unwrap_or(n)
107}
108
109pub trait Model: Send + Sync {
110    /// Release the device memory this model owns, in reverse construction
111    /// order.
112    ///
113    /// Called by the host when the model is being replaced, **after** the
114    /// scheduler has drained and the stream is synchronised — the only point at
115    /// which a device free is safe on GB10, where a free interleaved with other
116    /// allocation traffic corrupts neighbouring allocations. See
117    /// `avarok_core::scope` for why this is not `Drop`: `Drop` can express
118    /// neither the ordering nor the failure.
119    ///
120    /// Default: a no-op returning `Ok`, which is honest for the mock and
121    /// translation models that own no pooled device memory. A model that DOES
122    /// own pools and leaves this unimplemented leaks them — loudly, as the next
123    /// load failing to fit, never as wrong output.
124    fn teardown(&mut self) -> Result<()> {
125        Ok(())
126    }
127
128    /// Poll TQ+ InnerQ calibration for this model. Called once per prefill
129    /// chunk. Default: a no-op, which is every model without a driver — the
130    /// scheduler used to reach a process-wide `OnceLock` for this, which meant
131    /// the driver could outlive the model whose device symbols it writes.
132    fn poll_innerq(&self) {}
133
134    /// True when this model implements run-to-completion beam search
135    /// ([`Self::generate_beam_batch`]). Default `false` — only encoder-decoder
136    /// translation models (NLLB) override it.
137    fn supports_beam(&self) -> bool {
138        false
139    }
140
141    /// Run beam search to completion for each request, returning each one's
142    /// winning hypothesis token ids (EOS-terminated). Called from the prefill
143    /// path for `num_beams > 1` requests, bypassing the token-by-token decode
144    /// loop. Default: unsupported.
145    fn generate_beam_batch(&self, _reqs: &[BeamReq]) -> Result<Vec<Vec<u32>>> {
146        bail!("this model does not support beam search")
147    }
148
149    /// Run prefill: process all prompt tokens through the model.
150    ///
151    /// Returns logits DevicePtr for the last token position.
152    /// Updates KV cache and SSM states for the sequence.
153    fn prefill(&self, tokens: &[u32], seq: &mut SequenceState, stream: u64) -> Result<DevicePtr>;
154
155    /// Process `chunk_len` tokens starting at `chunk_start` in the prompt.
156    /// `is_last_chunk` runs final norm + LM head; intermediate chunks return
157    /// `DevicePtr::NULL`. KV blocks alloc incrementally; SSM state carries
158    /// across chunks; attention uses FA on chunk 0, paged decode after.
159    fn prefill_chunk(
160        &self,
161        tokens: &[u32],
162        seq: &mut SequenceState,
163        chunk_start: usize,
164        chunk_len: usize,
165        is_last_chunk: bool,
166        stream: u64,
167    ) -> Result<DevicePtr>;
168
169    /// Run one decode step: process a single new token.
170    ///
171    /// Returns logits DevicePtr for the new token.
172    /// Updates KV cache and SSM states.
173    fn decode(&self, token: u32, seq: &mut SequenceState, stream: u64) -> Result<DevicePtr>;
174
175    /// Run batched decode: process one token per sequence.
176    ///
177    /// Returns logits DevicePtr for [batch_size, vocab_size].
178    fn decode_batch(
179        &self,
180        tokens: &[u32],
181        seqs: &mut [&mut SequenceState],
182        stream: u64,
183    ) -> Result<DevicePtr>;
184
185    /// Process N decode tokens + an M-token prefill chunk in one pass through
186    /// the same weight loads. Returns decode logits `[N, vocab]` and prefill
187    /// logits `[1, vocab]` (when `is_last`). Default: serial decode + prefill.
188    fn mixed_forward(
189        &self,
190        decode_tokens: &[u32],
191        decode_seqs: &mut [&mut SequenceState],
192        prefill_tokens: &[u32],
193        prefill_seq: &mut SequenceState,
194        prefill_chunk_start: usize,
195        prefill_chunk_len: usize,
196        prefill_is_last: bool,
197        stream: u64,
198    ) -> Result<MixedForwardResult> {
199        // Default: serial execution (no weight sharing)
200        let decode_logits = if !decode_tokens.is_empty() {
201            self.decode_batch(decode_tokens, decode_seqs, stream)?
202        } else {
203            spark_runtime::gpu::DevicePtr::NULL
204        };
205        let prefill_logits = self.prefill_chunk(
206            prefill_tokens,
207            prefill_seq,
208            prefill_chunk_start,
209            prefill_chunk_len,
210            prefill_is_last,
211            stream,
212        )?;
213        Ok(MixedForwardResult {
214            decode_logits,
215            prefill_logits,
216        })
217    }
218
219    /// Process N concurrent prefill chunks in one forward pass (same weight
220    /// load amortised across N streams). The default implementation falls
221    /// back to a per-stream loop calling `prefill_chunk` — implementors that
222    /// support kernel-level batched prefill should override this.
223    ///
224    /// Returns a `Vec<DevicePtr>` parallel to `streams`: each entry is the
225    /// last-token logits pointer for that stream when its chunk is
226    /// `is_last_chunk`, or `DevicePtr::NULL` otherwise.
227    ///
228    /// Tracks issue Q12 in
229    /// `/workspace/avarok-internal/qwen-refactor/notes.md`.
230    fn prefill_batch_chunk(
231        &self,
232        streams: &mut [PrefillSlice<'_>],
233        stream: u64,
234    ) -> Result<Vec<DevicePtr>> {
235        // Default: serialized per-stream prefill_chunk. This preserves
236        // current behavior for any model that doesn't override; only the
237        // weight-streaming amortisation is lost vs a true batched path.
238        let mut out = Vec::with_capacity(streams.len());
239        for slice in streams.iter_mut() {
240            let logits = self.prefill_chunk(
241                slice.prompt_tokens,
242                slice.seq,
243                slice.chunk_start,
244                slice.chunk_len,
245                slice.is_last_chunk,
246                stream,
247            )?;
248            out.push(logits);
249        }
250        Ok(out)
251    }
252
253    /// Like `prefill_batch_chunk`, but each finishing stream's first-token
254    /// logits land in row `row_base + stream_idx` of the shared logits arena
255    /// instead of row `stream_idx`.
256    ///
257    /// CROSS-REQUEST CORRUPTION (the reason this exists). `decode_batch`
258    /// writes lane `i`'s logits to row `i` of `buffers.logits()`, and a
259    /// finishing prefill stream writes row `stream_idx` of the SAME arena —
260    /// byte-identical addresses. In a mixed step decode runs first and the
261    /// caller samples the decode rows AFTER the prefill sub-pass, so every
262    /// active decode lane whose index collides with a finishing prefill
263    /// stream samples THAT REQUEST'S first-token distribution instead of its
264    /// own. Symptoms are exactly what a foreign distribution looks like: a
265    /// stray `<tool_call>` opener at the head of a reply (the foreign stream
266    /// was tool-enabled), or a reply that veers onto another user's topic.
267    /// It cannot happen sequentially — a mixed step needs >=2 prefills and
268    /// >=1 active decode in the same tick.
269    ///
270    /// Giving prefill a disjoint row window is enough to fix it. The arena
271    /// holds `min(max_batch_tokens, 32)` rows against `n_decode + n_prefill
272    /// <= max_num_seqs`, so the shifted window fits with room to spare;
273    /// implementations MUST bounds-check and fall back to `row_base = 0`
274    /// rather than write past the arena.
275    ///
276    /// Default ignores `row_base` (models with no batched prefill of their
277    /// own can't alias, since the serial path returns one row).
278    fn prefill_batch_chunk_rows(
279        &self,
280        streams: &mut [PrefillSlice<'_>],
281        stream: u64,
282        _row_base: usize,
283    ) -> Result<Vec<DevicePtr>> {
284        self.prefill_batch_chunk(streams, stream)
285    }
286
287    /// Generalised mixed forward: M decode tokens + N concurrent prefill
288    /// chunks fused into one forward pass. Default: delegates to
289    /// `decode_batch` + `prefill_batch_chunk` serially. Models that
290    /// implement true mixed batching should override.
291    fn mixed_forward_batch(
292        &self,
293        decode_tokens: &[u32],
294        decode_seqs: &mut [&mut SequenceState],
295        prefill_streams: &mut [PrefillSlice<'_>],
296        stream: u64,
297    ) -> Result<MixedBatchResult> {
298        // Default: serial execution.
299        let decode_logits = if !decode_tokens.is_empty() {
300            let lg = self.decode_batch(decode_tokens, decode_seqs, stream)?;
301            // #110: decode_batch runs its whole forward on the DEFAULT stream
302            // — both `decode_batch_compute_main` (n>=2) and the n==1 graph path
303            // ignore the `stream` arg and hardcode `gpu.default_stream()`. The
304            // batched prefill below reuses the SAME shared arena buffers
305            // (hidden_states/residual/scratch/gdn) but submits on `stream`
306            // (prefill_stream). With no barrier the two sub-passes execute
307            // concurrently on two different streams and race over those
308            // buffers — corrupting the batched prefill's slot table into wild
309            // KV-cache indices and faulting with a CUDA illegal access
310            // (status 700). Synchronize the decode stream so its buffer use is
311            // fully retired before prefill overwrites them. This runs once per
312            // mixed step (active+prefilling), never in the hot decode loop.
313            self.synchronize(self.default_stream())?;
314            lg
315        } else {
316            spark_runtime::gpu::DevicePtr::NULL
317        };
318        // Prefill rows start ABOVE the decode lanes: decode owns rows
319        // 0..decode_tokens.len(), so a finishing prefill stream can no longer
320        // overwrite a lane whose logits the caller has not sampled yet. See
321        // `prefill_batch_chunk_rows`.
322        let prefill_logits =
323            self.prefill_batch_chunk_rows(prefill_streams, stream, decode_tokens.len())?;
324        Ok(MixedBatchResult {
325            decode_logits,
326            prefill_logits,
327        })
328    }
329
330    /// Normalize SSM h_state norms to prevent catastrophic state explosion
331    /// during long chunked prefill. Called between chunks by the scheduler.
332    /// Default: no-op (models without SSM layers don't need normalization).
333    fn normalize_ssm_states(&self, _seq: &SequenceState, _stream: u64) -> Result<()> {
334        Ok(())
335    }
336
337    /// Per-layer chunked prefill: SSM layers use three phases (proj →
338    /// single-launch GDN → post) so the recurrence sees the full sequence
339    /// in one launch; attention layers use standard chunked prefill.
340    /// Returns last-token logits. Default: single-chunk prefill (no SSM).
341    fn prefill_twophase(
342        &self,
343        tokens: &[u32],
344        seq: &mut SequenceState,
345        _chunk_size: usize,
346        stream: u64,
347    ) -> Result<DevicePtr> {
348        // Default: single-chunk prefill (no two-phase benefit without SSM)
349        self.prefill_chunk(tokens, seq, 0, tokens.len(), true, stream)
350    }
351
352    /// Vocab size (for sampler allocation).
353    fn vocab_size(&self) -> usize;
354
355    /// Runtime LoRA adapter rotation: select the resident adapter named `name`
356    /// as active (re-points the delta pool pointers). MUST be called at a
357    /// scheduler quiescent point (no in-flight decode). Graph-safety is via the
358    /// eager-on-rotate gate. Default: unsupported (non-LoRA or non-rotatable).
359    fn set_active_lora(&mut self, _name: &str) -> Result<()> {
360        bail!("this model does not support LoRA adapter rotation")
361    }
362
363    /// Task #24: stable adapter_id (KV/prefix-cache identity) for a per-request
364    /// pool-slot selector. `slot` follows `SequenceState.adapter_slot`: `>= 0`
365    /// picks that resident slot, `-1` defers to the installed active adapter.
366    /// The default (no LoRA) returns the base sentinel `0`, keeping the prefix
367    /// cache byte-identical to the pre-LoRA path.
368    fn adapter_id_for(&self, _slot: i32) -> u64 {
369        0
370    }
371
372    /// Task #25: acquire a per-slot ref when a sequence begins using its adapter
373    /// (at prefill), resolving `-1 -> active` like [`Self::adapter_id_for`].
374    /// Returns the RESOLVED pool index the ref was taken on (store it, release
375    /// EXACTLY that index at terminal free — immune to a rotate changing active).
376    /// Default (no LoRA) returns `-1` "nothing acquired" so the release guard
377    /// skips and the base path is byte-identical.
378    fn acquire_adapter_slot(&self, _slot: i32) -> i32 {
379        -1
380    }
381
382    /// Task #25: release a per-slot ref acquired by [`Self::acquire_adapter_slot`],
383    /// by the RESOLVED index it returned. `-1` is a no-op. Default: no-op.
384    fn release_adapter_slot(&self, _resolved: i32) {}
385
386    /// Runtime LoRA adapter dynamic-load: load the adapter at `dir` INTO pool
387    /// `slot` and make it resident there (pool-size-1 per-request weight change).
388    /// MUST be called at a scheduler quiescent point; needs rotation armed.
389    /// Default: unsupported (non-LoRA or non-rotatable).
390    fn swap_lora_from_disk(
391        &mut self,
392        _dir: &std::path::Path,
393        _name: &str,
394        _slot: usize,
395    ) -> Result<()> {
396        bail!("this model does not support LoRA disk swap")
397    }
398
399    /// Task #27 (demand-driven promotion): RDMA-promote the adapter `name`
400    /// (staged on `peer_addr` at `adapter_id`) from the peer into a cache pool
401    /// slot and make it active, returning `(slot, evicted_name)`. Runs at a
402    /// scheduler quiescent point. `peft` supplies the r/alpha/scaling the peer
403    /// manifest does not carry. Default: unsupported (non-LoRA / non-cuda).
404    fn promote_lora_from_peer(
405        &mut self,
406        _peer_addr: &str,
407        _adapter_id: &str,
408        _name: &str,
409        _peft: avarok_core::config::PeftAdapterConfig,
410    ) -> Result<(usize, Option<String>)> {
411        bail!("this model does not support LoRA peer promotion")
412    }
413
414    /// Demand-driven DISK promotion (no RDMA/peer): load the adapter `name` from
415    /// `adapter_dir` into a cache pool slot (LRU victim) and make it active,
416    /// returning `(slot, evicted_name)`. Local-disk sibling of
417    /// [`Self::promote_lora_from_peer`]; the swap re-parses the dir's
418    /// `adapter_config.json`, so no `peft` arg. Runs at a scheduler quiescent
419    /// point; needs rotation armed. Default: unsupported.
420    fn promote_lora_from_disk(
421        &mut self,
422        _adapter_dir: &std::path::Path,
423        _name: &str,
424    ) -> Result<(usize, Option<String>)> {
425        bail!("this model does not support LoRA disk promotion")
426    }
427
428    /// Dims for the `--high-speed-swap` orchestrator (installed thread-local
429    /// after `bind_gpu_to_thread`). `None` for legacy/non-attention models.
430    fn high_speed_swap_dims(&self) -> Option<spark_storage::ModelDims> {
431        None
432    }
433
434    /// Bind the GPU context to the current thread.
435    /// Must be called from any thread other than the one that created the model.
436    fn bind_gpu_to_thread(&self) -> Result<()>;
437
438    /// Allocate a new SequenceState with SSM states.
439    fn alloc_sequence(&self) -> Result<SequenceState>;
440
441    /// [`Self::alloc_sequence`] told what this request can actually reach
442    /// (`prompt_len + max_tokens`). Proposer state that scales with context is
443    /// sized to THAT instead of `--max-seq-len`; see
444    /// `DraftProposer::alloc_state_for`. Defaults to the unsized form.
445    fn alloc_sequence_for(&self, budget_tokens: usize) -> Result<SequenceState> {
446        let _ = budget_tokens;
447        self.alloc_sequence()
448    }
449
450    /// Copy logits from device to host buffer (for CPU-side sampling).
451    ///
452    /// `logits_ptr` points to `[vocab_size]` BF16 values on device.
453    /// `dst` must be at least `vocab_size * 2` bytes.
454    fn copy_logits_to_host(&self, logits_ptr: DevicePtr, dst: &mut [u8]) -> Result<()>;
455
456    /// FP32 logits flag (host buffer needs `vocab*4` bytes, reinterpret `&[f32]`).
457    /// True only for Gemma-4 dense single-token decode `lm_head`; default false.
458    fn logits_ptr_is_fp32(&self, _logits_ptr: DevicePtr) -> bool {
459        false
460    }
461
462    /// Base pointer of the on-device logits buffer (`[k, vocab]` BF16 after
463    /// `decode_verify_graphed`). Lets the scheduler read logits for temp
464    /// sampling even though graphs bake in argmax.
465    fn logits_buffer_ptr(&self) -> DevicePtr;
466
467    /// GPU argmax: 4-byte D2H copy vs 304KB BF16 D2H + CPU argmax.
468    fn argmax_on_device(&self, logits_ptr: DevicePtr, stream: u64) -> Result<u32>;
469
470    /// GPU batched argmax over `[N, vocab]` BF16; returns N token IDs.
471    fn argmax_batch(&self, logits_ptr: DevicePtr, n: usize, stream: u64) -> Result<Vec<u32>>;
472
473    /// Return the hidden state after final norm from the last decode step.
474    ///
475    /// Used by MTP speculative decoding: the MTP head takes the target model's
476    /// post-norm hidden states as input alongside the token embedding.
477    fn hidden_after_norm(&self) -> DevicePtr;
478
479    /// L2-resident multi-token verification: per-position argmax token IDs;
480    /// each token advances KV/SSM state. All tokens go through each layer
481    /// before moving on so weights stay in L2.
482    fn decode_verify(
483        &self,
484        tokens: &[u32],
485        seq: &mut SequenceState,
486        stream: u64,
487    ) -> Result<Vec<u32>>;
488
489    /// Checkpoint SSM states before speculative verification.
490    fn checkpoint_ssm_states(&self, seq: &mut SequenceState) -> Result<()>;
491
492    /// Rollback SSM states after partial acceptance.
493    fn rollback_ssm_states(&self, seq: &mut SequenceState, num_accepted: usize) -> Result<()>;
494
495    /// True when this model has recurrent SSM / Mamba layers whose
496    /// `h_state` + `conv_state` are advanced in-place every decoded
497    /// token.
498    ///
499    /// Pure-attention models return `false` (the default): their only
500    /// per-token state is the paged KV cache, which the Phase-C
501    /// boundary rollback rewinds by lowering `seq_len`. Hybrid models
502    /// (Qwen3.6-A3B, MiniMax, Nemotron-nano) return `true` — for those
503    /// the scheduler MUST also restore the SSM state from a decode-time
504    /// snapshot, because the recurrent state cannot be undone by
505    /// lowering a cursor.
506    fn has_ssm_layers(&self) -> bool {
507        false
508    }
509
510    /// True when some layer of this model keeps per-sequence state that a
511    /// lowered KV cursor does not rewind and no snapshot ring restores
512    /// (`TransformerLayer::decode_rollback_unsupported`). The Phase-C
513    /// boundary rollback must decline for such a model: re-steering on
514    /// un-rewound state regenerates from a corrupted context. Default
515    /// `false`: paged-KV attention rewinds by cursor.
516    fn decode_rollback_unsupported(&self) -> bool {
517        false
518    }
519
520    /// Verify DRAFT capacity of the MTP state pools for a sequence
521    /// occupying SSM pool slot `slot_idx` — the deepest `num_drafts` a
522    /// speculative step may dispatch to it without overflowing its slot's
523    /// per-token H-intermediate allocation (tiered since 2026-08-16; SSOT
524    /// `ssm_reserve::verify_slot_h_intermediates`). The scheduler clamps
525    /// every spec step's draft count to the MINIMUM capacity across the
526    /// active slots. Default `usize::MAX`: no SSM verify pools to
527    /// constrain (pure-attention models, spec off).
528    fn mtp_slot_draft_capacity(&self, _slot_idx: usize) -> usize {
529        usize::MAX
530    }
531
532    /// Number of decode-rollback SSM snapshot slots reserved **per
533    /// active sequence** (Phase-C). The scheduler's per-sequence
534    /// snapshot ring is sized from this. `0` (the default) means the
535    /// model keeps no decode-rollback snapshots — appropriate for
536    /// pure-attention models and for SSM models when the snapshot pool
537    /// has no capacity reserved. SSM models with a populated pool
538    /// override to the depth `ssm_reserve::decode_rollback_ring_slots`
539    /// decided — 8 by default, or whatever `--ssm-decode-ring-slots` /
540    /// preflight's free-memory fit published (#915).
541    fn decode_rollback_ring_slots(&self) -> usize {
542        0
543    }
544
545    /// Save `seq`'s live SSM `h_state` + `conv_state` (all SSM layers)
546    /// into the decode-rollback snapshot slot `ring_slot`.
547    ///
548    /// `ring_slot` is a per-sequence ring index in
549    /// `[0, decode_rollback_ring_slots())`; the model maps it to a
550    /// concrete snapshot-pool slot keyed by `seq.slot_idx`. Reuses the
551    /// same `SsmSnapshotPool` D2D copy primitive as Marconi prefix
552    /// caching and MTP verify (SSOT — one snapshot mechanism).
553    ///
554    /// Default: no-op `Ok(())` for pure-attention models, which have no
555    /// SSM state to snapshot.
556    fn save_decode_ssm_snapshot(&self, _seq: &SequenceState, _ring_slot: usize) -> Result<()> {
557        Ok(())
558    }
559
560    /// Restore `seq`'s SSM `h_state` + `conv_state` (all SSM layers)
561    /// from the decode-rollback snapshot slot `ring_slot` previously
562    /// written by [`Self::save_decode_ssm_snapshot`].
563    ///
564    /// Default: no-op `Ok(())` for pure-attention models.
565    fn restore_decode_ssm_snapshot(&self, _seq: &SequenceState, _ring_slot: usize) -> Result<()> {
566        Ok(())
567    }
568
569    /// Speculative decoding via the model's internal MTP proposer; falls
570    /// back to regular decode when no proposer is wired up.
571    fn generate_speculative(
572        &self,
573        prompt_tokens: &[u32],
574        params: &spark_runtime::sampler::SamplingParams,
575        num_drafts: usize,
576    ) -> Result<crate::engine::GenerateResult>;
577
578    /// Check if speculative decoding is available (MTP or self-speculative).
579    fn has_proposer(&self) -> bool;
580    /// The installed DFlash drafter's block size γ, when one is installed.
581    /// The serve layer derives `num_drafts = γ - 1` from THIS (the head is
582    /// the SSOT — it resolved the drafter config's trained block size),
583    /// never from a CLI default that may not match the checkpoint.
584    fn dflash_gamma(&self) -> Option<usize> {
585        None
586    }
587
588    /// Check if self-speculative decoding is enabled.
589    fn has_self_speculative(&self) -> bool;
590
591    /// Eager decode skipping SSM layers. Used by self-speculative drafting.
592    /// Returns logits pointer for argmax. Advances seq_len by 1.
593    fn decode_draft(&self, token: u32, seq: &mut SequenceState, stream: u64) -> Result<DevicePtr>;
594
595    /// Insert the full token sequence (prompt + generated) into the prefix
596    /// cache. Call BEFORE `free_sequence()` (block indices must still be
597    /// valid). Benefits multi-turn agentic sessions that resend full history.
598    fn cache_sequence(&self, seq: &SequenceState);
599
600    /// #155 iter3: during decode, save a block-aligned Marconi SSM snapshot
601    /// at checkpoint-interval boundaries so the NEXT turn's warm prefix-cache
602    /// hit restores from decode-produced state near the conversation's end —
603    /// instead of replaying decode-produced tokens through the prefill kernel
604    /// (the warm-hit drift ratchet, issue #155). Called from the scheduler
605    /// after each decode step's live SSM state is canonical (post-commit on
606    /// the MTP path). Default no-op (non-hybrid models / caching disabled).
607    fn decode_marconi_checkpoint(&self, _seq: &mut SequenceState) {}
608
609    /// Free all GPU resources associated with a sequence.
610    ///
611    /// Releases KV cache blocks and returns SSM state pool slot.
612    /// Must be called when a sequence is no longer needed.
613    fn free_sequence(&self, seq: &mut SequenceState) -> Result<()>;
614
615    /// Move a sequence's SSM states to a different pool slot.
616    ///
617    /// Copies h_state and conv_state across all SSM layers from the current
618    /// slot to `new_slot`. Used by the scheduler for slot compaction after
619    /// swap_remove to keep active sequences at contiguous slots [0..N).
620    fn compact_sequence(&self, seq: &mut SequenceState, new_slot: usize) -> Result<()>;
621
622    /// Disown a retired sequence's SSM pool slot after `compact_sequence`
623    /// migrated it to a surviving sequence.
624    ///
625    /// Sets the `slot_idx` reuse sentinel AND neutralizes the sequence's
626    /// internal slot-release guard so the migrated slot is NOT released when
627    /// this sequence is later freed or dropped (the surviving sequence now owns
628    /// it). The scheduler MUST call this — instead of mutating `slot_idx`
629    /// directly — immediately after a `compact_sequence` that reuses this
630    /// sequence's slot, so a subsequent early-return/drop cannot double-release.
631    fn detach_slot_for_reuse(&self, seq: &mut SequenceState);
632
633    /// CUDA-graphed K=2 verify: 2 tokens, capture-then-replay. Returns
634    /// `[verified_0, verified_1]` argmax IDs. SSM intermediates saved for
635    /// partial rollback via `rollback_ssm_states`.
636    fn decode_verify_graphed(
637        &self,
638        tokens: &[u32; 2],
639        seq: &mut SequenceState,
640        stream: u64,
641    ) -> Result<[u32; 2]>;
642
643    /// CUDA-graphed K=3 verify (1 verified + 2 drafts). Returns 3 argmax IDs.
644    /// SSM intermediates `[0]` and `[1]` are saved for partial rollback.
645    fn decode_verify_graphed_k3(
646        &self,
647        tokens: &[u32; 3],
648        seq: &mut SequenceState,
649        stream: u64,
650    ) -> Result<[u32; 3]>;
651
652    /// CUDA-graphed K=4 verify (1 verified + 3 drafts). Returns 4 argmax IDs.
653    /// SSM intermediates [0..3] saved for partial rollback.
654    fn decode_verify_graphed_k4(
655        &self,
656        tokens: &[u32; 4],
657        seq: &mut SequenceState,
658        stream: u64,
659    ) -> Result<[u32; 4]>;
660
661    /// Whether [`Self::decode_verify_batched`] can run for `ks.len()`
662    /// sequences at `ks[i]` verify rows each (one more than that sequence's
663    /// draft count; the K-vs-batch ladder passes 2..=4, and D-Cut makes the
664    /// vector RAGGED — uniform is just the special case).
665    ///
666    /// Default `false`: the scheduler MUST fall back to the per-sequence
667    /// `decode_verify_graphed_k{2,3,4}` loop. There is deliberately NO
668    /// default loop impl of the batched form — a loop over the per-seq
669    /// verify would leave the shared logits buffer holding only the LAST
670    /// sequence's rows and silently poison row-based pipeline picks.
671    fn can_batch_verify(&self, _ks: &[usize]) -> bool {
672        false
673    }
674
675    /// Batched K-row verify: `ks.len()` sequences × `ks[i]` rows in ONE eager
676    /// forward (flat seq-major rows, `tokens.len() == Σ ks`). Weight matrices
677    /// are read once for all `Σ ks` rows. Sequence i occupies rows
678    /// `[off_i, off_i + ks[i])` where `off_i = Σ_{t<i} ks[t]`, holding
679    /// `[last_verified, d0, .., d_{ks[i]-2}]`. Returns the `Σ ks` argmax IDs
680    /// in the same flat order. On success each sequence's `tokens`/`seq_len`
681    /// advance by its own `ks[i]` (rewind is the caller's verdict arithmetic,
682    /// same as the per-seq path). On Err NO sequence state has been advanced.
683    ///
684    /// Callers must gate on [`Self::can_batch_verify`].
685    ///
686    /// `opts.write_on_accept` asks the GDN layers for the write-on-accept
687    /// K=4 verify: the caller then MUST run [`Self::gdn_fold_accepted`] with
688    /// every sequence's verdict before any `commit_accepted_prefix`. A caller
689    /// that commits through another path passes `VerifyBatchedOpts::default()`.
690    fn decode_verify_batched(
691        &self,
692        tokens: &[u32],
693        ks: &[usize],
694        seqs: &mut [&mut SequenceState],
695        stream: u64,
696        opts: VerifyBatchedOpts,
697    ) -> Result<Vec<u32>> {
698        let _ = (tokens, ks, seqs, stream, opts);
699        bail!("decode_verify_batched: unsupported by this model")
700    }
701
702    /// Copy raw-hidden rows `rows[i]` of the just-run batched verify forward
703    /// into stash slot `i` (`verify_hidden_stash`), BEFORE any propose
704    /// clobbers the shared `hidden_states` buffer. Companion of
705    /// [`Self::decode_verify_batched`].
706    fn stash_verify_hidden_rows(&self, rows: &[usize], stream: u64) -> Result<()> {
707        let _ = (rows, stream);
708        bail!("stash_verify_hidden_rows: unsupported by this model")
709    }
710
711    /// Stashed-row variant of [`Self::save_hidden_for_mtp`]: copy stash slot
712    /// `idx` (written by [`Self::stash_verify_hidden_rows`]) into the MTP
713    /// input buffer. Used by the batched-verify verdict path, whose propose
714    /// calls have already overwritten the live verify rows.
715    fn save_hidden_for_mtp_from_stash(&self, idx: usize, stream: u64) -> Result<()> {
716        let _ = (idx, stream);
717        bail!("save_hidden_for_mtp_from_stash: unsupported by this model")
718    }
719
720    /// Batched cross-sequence MTP propose for the batched K=4 verify path:
721    /// `num_drafts` drafts for each of `tokens.len()` sequences, reading
722    /// every drafter weight once per draft position instead of once per
723    /// sequence. `stash_idx[i]` names the verify-stash slot holding sequence
724    /// i's accepted-position hidden (written by
725    /// [`Self::stash_verify_hidden_rows`]); `positions[i]` is the propose
726    /// position (post-rewind `seq_len`), matching the per-seq
727    /// [`Self::run_mtp_propose_multi`] contract. Grammarless sequences only.
728    ///
729    /// `out_conf`, when `Some`, receives each draft's top-1 LOG-probability
730    /// (`ln p`, same shape as the returned drafts) — the D-Cut ranking key.
731    /// It is filled with zeros (certainty) when the drafter cannot measure
732    /// confidence, so a caller ranking by prefix product never prunes on a
733    /// value nobody produced.
734    ///
735    /// `Ok(None)` = unsupported (caller falls back to the per-seq propose
736    /// loop, re-saving each stash slot first). Default: unsupported.
737    #[allow(clippy::too_many_arguments)]
738    fn run_mtp_propose_batched(
739        &self,
740        tokens: &[u32],
741        positions: &[usize],
742        stash_idx: &[usize],
743        num_drafts: usize,
744        seqs: &mut [&mut SequenceState],
745        stream: u64,
746        out_conf: Option<&mut Vec<Vec<f32>>>,
747    ) -> Result<Option<Vec<Vec<u32>>>> {
748        let _ = (
749            tokens, positions, stash_idx, num_drafts, seqs, stream, out_conf,
750        );
751        Ok(None)
752    }
753
754    /// Widest batch [`Self::run_mtp_propose_batched`] can carry in ONE
755    /// drafter forward per draft position. `1` = per-sequence only.
756    /// Schedulers chunk their propose groups by this — never by a constant.
757    fn mtp_propose_batch_max(&self) -> usize {
758        1
759    }
760
761    /// DFlash K=γ graphed verify (γ+1 tokens). Specialization of the K=2/3/4
762    /// pattern for arbitrary K. Default impl falls back to eager
763    /// `decode_verify`. Models can override for CUDA-graph speedup keyed by
764    /// `(slot_idx, K)`.
765    fn decode_verify_graphed_kgamma(
766        &self,
767        tokens: &[u32],
768        seq: &mut SequenceState,
769        stream: u64,
770    ) -> Result<Vec<u32>> {
771        self.decode_verify(tokens, seq, stream)
772    }
773
774    /// DFlash γ-token verification: 1 verified + γ drafts → per-position
775    /// argmax. Variable-length γ (vs fixed K=2/3/4) because it's a drafter
776    /// config field. CUDA-graph capture keyed by `(slot_idx, tokens.len())`.
777    /// Default routes to `decode_verify_graphed_kgamma`.
778    fn decode_verify_dflash(
779        &self,
780        tokens: &[u32],
781        seq: &mut SequenceState,
782        stream: u64,
783    ) -> Result<Vec<u32>> {
784        // Phase 2.5e: route to the K=γ graphed path. Models that don't
785        // override `decode_verify_graphed_kgamma` get the eager fallback
786        // for free (the trait default does that).
787        self.decode_verify_graphed_kgamma(tokens, seq, stream)
788    }
789
790    /// DFlash fused decode+verify: one M=(1+k) forward replacing separate
791    /// M=1 decode + M=k verify on the DFlash path.
792    ///
793    /// `tokens[0]` = accepted/decode token; `tokens[1..]` = draft block.
794    /// `try_dflash_capture` fires at row 0 so the DFlash drafter conditions
795    /// on the confirmed-accepted token's per-layer hidden, never on a
796    /// potentially-rejected draft's hidden.
797    ///
798    /// CUDA-graph cache keyed by `(slot_idx, tokens.len())`. Default falls
799    /// back to `decode_verify_graphed_kgamma` (which itself falls back to
800    /// eager `decode_verify`) for models that don't override.
801    fn decode_and_verify_fused(
802        &self,
803        tokens: &[u32],
804        seq: &mut SequenceState,
805        stream: u64,
806    ) -> Result<Vec<u32>> {
807        self.decode_verify_graphed_kgamma(tokens, seq, stream)
808    }
809
810    /// Save the post-norm hidden state at `token_idx` (0 or 1) to a
811    /// dedicated MTP input buffer. Must precede `run_mtp_propose` — MTP
812    /// overwrites shared buffers including `norm_output`.
813    fn save_hidden_for_mtp(&self, token_idx: usize, stream: u64) -> Result<()>;
814
815    /// AVAROK_MTP_CATCHUP: ring-capture a serially decoded token's final
816    /// hidden at `pos` for the drafter catch-up feed. Default no-op.
817    fn save_hidden_for_catchup(&self, _token_idx: usize, _pos: usize) -> Result<()> {
818        Ok(())
819    }
820
821    /// Capture `hidden_states[token_idx]` from every DFlash capture layer
822    /// into `dflash_hidden_save`. Called after gamma verify Phase 3 D2H
823    /// sync (bonus position known). No-op when DFlash is disabled.
824    fn save_dflash_hidden_for_propose(&self, _token_idx: usize, _stream: u64) -> Result<()> {
825        Ok(())
826    }
827
828    /// Append the accepted draft's hidden state (row 1 of dflash_hidden_save)
829    /// into the proposer context. Base primitive for both legacy and Eagle paths.
830    /// Default no-op for models without a DFlash drafter.
831    fn dflash_accept_append(&self, _seq: &mut SequenceState) -> Result<()> {
832        Ok(())
833    }
834
835    /// EAGLE-fix (K=2 accept): append row 0 @ N then row 1 @ N+1 BEFORE propose
836    /// so forward_block conditions on row 1 (the hidden that generated bonus).
837    /// Default no-op for models without a DFlash drafter.
838    fn dflash_eagle_accept_append(&self, _seq: &mut SequenceState) -> Result<()> {
839        Ok(())
840    }
841
842    /// EAGLE-fix (K=gamma): append rows 0..=num_accepted at positions
843    /// base_pos..=base_pos+num_accepted. Row num_accepted is appended LAST ->
844    /// freshest ctx slot = the hidden that generated the bonus (EAGLE).
845    /// Default no-op for models without a DFlash drafter.
846    fn dflash_eagle_kgamma_append(
847        &self,
848        _seq: &mut SequenceState,
849        _num_accepted: usize,
850        _base_pos: usize,
851    ) -> Result<()> {
852        Ok(())
853    }
854
855    /// Ctx-holes fix (serial decode): append the just-decoded token's
856    /// captured per-layer hidden (`dflash_hidden_save` row 0, filled by
857    /// `try_dflash_capture` inside the decode layer loop) into the seq's
858    /// DFlash ctx accumulator, stamped at its true position
859    /// (`seq.seq_len - 1`, matching propose.rs's decode-append convention).
860    ///
861    /// Called from the scheduler's serial bootstrap path when adaptive
862    /// speculation has SUSPENDED this seq — propose() never runs there, so
863    /// without this hook every serially-decoded token's target hidden is
864    /// overwritten (single-slot model capture) and permanently lost,
865    /// leaving holes in the drafter's ctx at spec re-entry (measured
866    /// -0.42 accepted/step on think-gated vs spec-through-think content).
867    ///
868    /// Sets `skip_next_decode_append` so a propose() firing later (re-probe)
869    /// does not double-append the same capture. Graceful no-op when DFlash
870    /// is disabled or the seq has a non-DFlash proposer state.
871    fn dflash_serial_ctx_append(&self, _seq: &mut SequenceState) -> Result<()> {
872        Ok(())
873    }
874
875    /// Unified DFlash ctx commit (AVAROK_DFLASH_UNIFIED_CTX=1). Copies
876    /// `num_committed` scratch rows (`dflash_hidden_save` rows
877    /// `scratch_row..scratch_row+num_committed`) into `ctx_hidden_acc` at the
878    /// CURRENT TAIL (`ctx_len`), stamping RoPE positions
879    /// `base_pos..base_pos+num_committed`, folding the watermark slide in
880    /// first. `base_pos` is the RoPE position, NOT the acc row index (they
881    /// diverge after a watermark slide — DDD §4.1 landmine). `scratch_row` is
882    /// 0 on every single-sequence path; batched decode (n>1) captures ALL
883    /// batch rows, so seq i commits from scratch row i. The single structural
884    /// replacement for the ~5 fragmented appends. Default no-op for models
885    /// without a DFlash drafter.
886    fn commit_ctx(
887        &self,
888        _seq: &mut SequenceState,
889        _num_committed: usize,
890        _base_pos: usize,
891        _scratch_row: usize,
892    ) -> Result<()> {
893        Ok(())
894    }
895
896    /// Rows per per-sequence capture BAND in the DFlash hidden scratch (γ+1).
897    /// Sequence `i` of a batched K=γ verify captures into band `i`, so its
898    /// `commit_ctx` `scratch_row` is `i * dflash_capture_band()`. Returning
899    /// the model's own stride keeps the capture and the commit from ever
900    /// disagreeing. `0` when there is no DFlash drafter.
901    fn dflash_capture_band(&self) -> usize {
902        0
903    }
904
905    /// Run the MTP proposer for one draft token off the saved hidden state.
906    /// `None` when no proposer is wired.
907    fn run_mtp_propose(
908        &self,
909        token: u32,
910        position: usize,
911        seq: &mut SequenceState,
912        stream: u64,
913    ) -> Result<Option<u32>>;
914
915    /// Run the MTP proposer to generate multiple draft tokens.
916    ///
917    /// Uses the hidden state previously saved via `save_hidden_for_mtp`.
918    /// Returns empty vec if no MTP proposer is available.
919    ///
920    /// `grammar_bitmask`: when `Some`, drafts are constrained to the allowed
921    /// token set of an XGrammar matcher at its current position. Format is
922    /// `ceil(vocab_size / 32)` i32 words; bit `tok` set ⇒ allowed. `None`
923    /// preserves the unconstrained GPU-argmax fast path.
924    fn run_mtp_propose_multi(
925        &self,
926        token: u32,
927        position: usize,
928        num_drafts: usize,
929        seq: &mut SequenceState,
930        stream: u64,
931        grammar_bitmask: Option<&[i32]>,
932    ) -> Result<Vec<u32>>;
933
934    /// Read the draft token ID stored on GPU by the last `run_mtp_propose_multi`
935    /// call (which used `embed_from_argmax` to write the draft embedding and
936    /// token ID directly on GPU). Returns 0 if no proposer is available.
937    fn read_deferred_draft_token(&self) -> Result<u32> {
938        Ok(0)
939    }
940
941    /// Encode images through the vision encoder and store embeddings for the next prefill.
942    ///
943    /// Each tuple is `(pixels: Vec<f32>, grid_h: usize, grid_w: usize)`.
944    /// Pixels are laid out [P, C×T×Hp×Wp] matching `vision_preprocess::preprocess_image`.
945    /// Must be called before `prefill_chunk` when the prompt contains `<|image_pad|>` tokens.
946    ///
947    /// Default: no-op (text-only models).
948    fn prepare_vision_embed(&self, _images: &[crate::VisionItem]) -> Result<()> {
949        Ok(())
950    }
951
952    /// Hand every rank the SAME merged vision rows for this prompt.
953    ///
954    /// Called by the head immediately after the prompt-token broadcast and by
955    /// the worker at the matching point of the `0xFFFFFFF0` handler, so the
956    /// collective pairs by construction. Without it only rank 0 splices the
957    /// encoder's output, every other rank keeps the raw pad-token embedding,
958    /// and the per-layer all-reduce mixes the two — the model stays fluent and
959    /// describes a picture it was never shown (rsafier, PR #1066).
960    ///
961    /// Default: no-op (single-GPU, and every text-only model).
962    fn ep_sync_vision_embeds(&self, _tokens: &[u32]) -> Result<()> {
963        Ok(())
964    }
965
966    /// Batched vision encode across N requests' images in ONE `forward_batched`
967    /// call (block GEMM weights read once over Σpatches). `per_request[i]` is
968    /// request i's images. Returns one `(patch_row_offset, grid_index_offset,
969    /// num_images, patch_row_count)` per request, in request order, locating
970    /// its slice of the shared packed `buf_out`. Default: no-op (text models).
971    fn prepare_vision_embed_batched(
972        &self,
973        _per_request: &[Vec<crate::VisionItem>],
974    ) -> Result<Vec<(usize, usize, usize, usize)>> {
975        Ok(Vec::new())
976    }
977
978    /// Set the co-dispatched batched-ViT slice base for the NEXT prefill_chunk
979    /// (row offset into buf_out, grid index offset, image count owned). Pass
980    /// (0,0,0) to reset to the legacy single-request behaviour. Default: no-op.
981    fn set_vision_slice_base(&self, _row_base: usize, _grid_base: usize, _owned_images: usize) {}
982
983    /// EP worker step: receive a (seq_id, cmd) preamble from rank 0 and
984    /// execute the command in the addressed slot.
985    ///
986    /// 🔴 An `Err` carrying [`EpCommandFailed`] means the command EXECUTED and failed —
987    /// a per-request fault the head raises identically and answers the client with. The
988    /// worker must STAY UP. Any other `Err` came from receiving the command, i.e. the link
989    /// to the head is gone, and the worker must exit. See [`EpCommandFailed`].
990    ///
991    /// Returns false when the worker should shut down.
992    /// Only valid on rank > 0 with EP enabled.
993    ///
994    /// `slots` must be sized to `args.max_batch_size` (same as the head's
995    /// scheduler `active` capacity); commands with `seq_id >= slots.len()`
996    /// fail loudly rather than corrupt unrelated state.
997    fn ep_worker_step(&self, _slots: &mut [Option<SequenceState>]) -> Result<bool> {
998        Ok(true) // no-op for non-EP models
999    }
1000
1001    /// Check whether expert parallelism (EP) is enabled (multi-GPU MoE).
1002    ///
1003    /// When true, the scheduler must use separate decode + prefill commands
1004    /// with explicit EP broadcasts rather than mixed_forward (which has no
1005    /// EP broadcast protocol defined).
1006    fn is_ep(&self) -> bool {
1007        false
1008    }
1009
1010    /// True when single-token decode `lm_head` writes FP32 logits to a
1011    /// dedicated FP32 scratch buffer (rather than the shared BF16 logits
1012    /// buffer). Callers that consume those logits must read from
1013    /// [`Self::decode_logits_ptr`] using 4 bytes/element. Defaults false;
1014    /// only Gemma-4 dense overrides today (gated by
1015    /// `AVAROK_GEMMA4_FP32_LMHEAD=1`).
1016    fn decode_logits_fp32(&self) -> bool {
1017        false
1018    }
1019
1020    /// Buffer pointer the single-token decode `lm_head` last wrote to. The
1021    /// returned dtype is FP32 when [`Self::decode_logits_fp32`] is true,
1022    /// BF16 otherwise. The default impl returns the shared BF16 logits
1023    /// buffer used by every existing model. Override on models that route
1024    /// the lm_head output through an FP32 scratch (Gemma-4 + softcap).
1025    fn decode_logits_ptr(&self) -> DevicePtr {
1026        // Default: shared BF16 logits buffer. Models with FP32 lm_head
1027        // override.
1028        // NOTE: this default panics when the trait method is invoked on
1029        // models that don't implement either accessor. TransformerModel
1030        // overrides both. If a future model needs only one, it must
1031        // override both for consistency.
1032        unreachable!(
1033            "Model::decode_logits_ptr() must be overridden alongside \
1034             decode_logits_fp32() — default cannot return a valid pointer."
1035        )
1036    }
1037
1038    /// Multi-head Latent Attention guard. When true, chunked prefill MUST run
1039    /// as a single chunk — Atlas has no paged-MLA prefill kernel and
1040    /// multi-chunk MLA silently corrupts attention output (see Mistral-Small-4
1041    /// 2026-05-01 sweep: 8K collapses to "The\nThe…").
1042    fn is_mla(&self) -> bool {
1043        false
1044    }
1045
1046    /// mHC hyper-connection stream count (0 = no highway). Non-zero means
1047    /// the batched GDN decode paths are UNWIRED for this model (they carry
1048    /// their own residual, which the highway replaces — see
1049    /// `qwen3_ssm::hc::refuse_batched_under_hc`); the scheduler must clamp
1050    /// concurrency to 1 until the batched highway lands (Atlas #753 item B).
1051    fn hc_mult(&self) -> usize {
1052        0
1053    }
1054
1055    /// Tokens per paged-KV block, or `None` when the model has no paged KV.
1056    /// The scheduler uses this to land a prefill chunk boundary exactly on the
1057    /// block boundary a warm turn will match at (see
1058    /// `spark_runtime::ssm_tail_boundary`).
1059    fn kv_block_size(&self) -> Option<usize> {
1060        None
1061    }
1062
1063    /// EP broadcast: send a command (u32) to all worker ranks.
1064    ///
1065    /// Called by rank 0 before each model operation to synchronize workers.
1066    /// Only valid when EP is enabled.
1067    fn ep_broadcast_cmd(&self, _cmd: u32) -> Result<()> {
1068        Ok(()) // no-op for non-EP models
1069    }
1070
1071    /// EP broadcast: send a `(seq_id, cmd)` pair to all worker ranks.
1072    ///
1073    /// Use this at the *first* broadcast of a logical command sequence
1074    /// (e.g. the K=2 verify marker, prefill start, decode token, etc.).
1075    /// Follow-up broadcasts within the same command (chunk metadata, more
1076    /// tokens, accept/reject result) keep using [`Self::ep_broadcast_cmd`]
1077    /// — the worker consumes the preamble once per command and routes
1078    /// subsequent reads through the slot it identified.
1079    ///
1080    /// When [`Self::ep_protocol_v2`] returns false (the default), the
1081    /// `seq_id` is ignored on the wire and behaviour matches the legacy
1082    /// single-sequence broadcast.
1083    fn ep_broadcast_cmd_for_seq(&self, _seq_id: u32, _cmd: u32) -> Result<()> {
1084        Ok(()) // no-op for non-EP models
1085    }
1086
1087    /// Returns true if this model's EP comm path is using the v2 protocol
1088    /// (slot-aware seq_id preamble). Default false — pre-PR behaviour.
1089    fn ep_protocol_v2(&self) -> bool {
1090        false
1091    }
1092
1093    /// EP bulk broadcast: send an array of u32 tokens to all worker ranks.
1094    /// Uses a single NCCL broadcast instead of per-token broadcasts.
1095    fn ep_broadcast_tokens(&self, _tokens: &[u32]) -> Result<Vec<u32>> {
1096        Ok(Vec::new()) // no-op for non-EP models
1097    }
1098
1099    /// Trim the MTP proposer's KV cache after verification.
1100    ///
1101    /// Called on rejection to discard the rejected draft's MTP KV entry.
1102    fn trim_proposer_state(
1103        &self,
1104        seq: &mut SequenceState,
1105        num_accepted: usize,
1106        stream: u64,
1107    ) -> Result<()>;
1108
1109    /// Launch SSM state checkpoint D2D copies on a secondary CUDA stream.
1110    ///
1111    /// Non-blocking: returns immediately. The copies can overlap with MTP
1112    /// propose on the default stream since they access disjoint memory.
1113    /// Call `sync_secondary` before the next verify to ensure completion.
1114    fn start_checkpoint_async(&self, seq: &mut SequenceState) -> Result<()> {
1115        // Default: fall back to synchronous checkpoint.
1116        self.checkpoint_ssm_states(seq)
1117    }
1118
1119    /// Launch SSM state rollback + checkpoint on the secondary stream.
1120    ///
1121    /// Used on the reject path: rollback to `intermediate[0]`, then checkpoint
1122    /// the rolled-back state for the next verify iteration.
1123    fn start_rollback_and_checkpoint_async(
1124        &self,
1125        seq: &mut SequenceState,
1126        num_accepted: usize,
1127    ) -> Result<()> {
1128        // Default: fall back to synchronous operations.
1129        self.rollback_ssm_states(seq, num_accepted)?;
1130        self.checkpoint_ssm_states(seq)
1131    }
1132
1133    /// Wait for all work on the secondary stream to complete.
1134    fn sync_secondary(&self) -> Result<()> {
1135        Ok(()) // No-op if no secondary stream.
1136    }
1137
1138    /// Item #2 (STree-style in-place verify commit): commit the surviving
1139    /// prefix of a verify pass directly onto the canonical `h_state` /
1140    /// `conv_state`. Full accept (`num_accepted == k`) is a no-op (the
1141    /// kernel's final state is already live); partial accept is a single
1142    /// index-select of `h_state_intermediates[num_accepted-1]`. No-op
1143    /// default for backends without the dual-buffer SSM state.
1144    /// Runs on `secondary_stream`; pair with `sync_secondary`.
1145    fn commit_accepted_prefix(
1146        &self,
1147        _seq: &mut SequenceState,
1148        _num_accepted: usize,
1149        _k: usize,
1150    ) -> Result<()> {
1151        Ok(())
1152    }
1153
1154    /// Write-on-accept fold after a batched K=4 verify that was requested
1155    /// with `VerifyBatchedOpts { write_on_accept: true }`: `slots[i]` /
1156    /// `accepted_rows[i]` (verify-width rows incl. the anchor, 1..=k) in the
1157    /// verify's batch order. Returns `Ok(true)` when the GDN h states were
1158    /// committed here, in which case `commit_accepted_prefix` for those
1159    /// slots restores conv state only. `Ok(false)` when the last batched
1160    /// verify did not request write-on-accept, or ran without its WY
1161    /// pointer tables (per-sequence loop): the host restore then runs as
1162    /// before. Default: nothing to fold.
1163    fn gdn_fold_accepted(
1164        &self,
1165        _slots: &[usize],
1166        _accepted_rows: &[u32],
1167        _k_rows: usize,
1168    ) -> Result<bool> {
1169        Ok(false)
1170    }
1171
1172    /// Save KV blocks + SSM state to writer. Does NOT free resources.
1173    ///
1174    /// Format: `[KV layers × blocks × (K + V)]` then `[SSM layers × (h + conv)]`.
1175    /// The model owns the serialization format.
1176    fn save_sequence_state(
1177        &self,
1178        _seq: &SequenceState,
1179        _writer: &mut dyn std::io::Write,
1180    ) -> Result<()> {
1181        bail!("swap not supported by this model")
1182    }
1183
1184    /// Restore KV blocks + SSM state from reader into an allocated sequence.
1185    ///
1186    /// Allocates `num_blocks` new KV blocks, fills from reader, restores SSM.
1187    fn restore_sequence_state(
1188        &self,
1189        _seq: &mut SequenceState,
1190        _num_blocks: usize,
1191        _reader: &mut dyn std::io::Read,
1192    ) -> Result<()> {
1193        bail!("swap not supported by this model")
1194    }
1195
1196    /// Whether `tokens` contains a vision pad token for this model — i.e.
1197    /// the KV at those positions came from image/video EMBEDDINGS that a
1198    /// plain token re-prefill cannot reproduce. Decode-time preemption uses
1199    /// this to exclude vision sequences from the requeue-with-re-prefill
1200    /// path (the spill path, which saves KV verbatim, stays eligible).
1201    /// Default false: pure-text models are always re-prefillable.
1202    fn tokens_contain_vision_pad(&self, _tokens: &[u32]) -> bool {
1203        false
1204    }
1205
1206    /// Number of free KV cache blocks available for allocation.
1207    fn num_free_blocks(&self) -> usize {
1208        0
1209    }
1210
1211    /// Total KV blocks in the paged cache (denominator for occupancy
1212    /// gauges). Default 0 for backends without a paged cache.
1213    fn num_total_blocks(&self) -> usize {
1214        0
1215    }
1216
1217    /// Marconi SSM snapshot-pool occupancy `(used, total)`. `None` for
1218    /// models without a snapshot pool (non-SSM, CPU backends) — so metrics
1219    /// consumers can fall back instead of rendering a fake 0/0. The TUI's
1220    /// SSM gauge read the orphaned `SessionSsmManager` (whose
1221    /// `save_snapshot` was never called anywhere) and showed 0/0 forever;
1222    /// this accessor is the truth it now reads.
1223    fn ssm_snapshot_occupancy(&self) -> Option<(u32, u32)> {
1224        None
1225    }
1226
1227    /// Reclaim up to `num_blocks` blocks from the prefix cache, returning how
1228    /// many actually became free.
1229    ///
1230    /// The prefill/decode allocators reclaim implicitly (`try_alloc` → evict →
1231    /// retry), but swap-in cannot: it gates on `num_free_blocks()` BEFORE
1232    /// attempting a restore, so cached-but-evictable capacity is invisible to
1233    /// it and a swapped-out sequence waits for blocks that are never
1234    /// volunteered. Cached blocks are legitimately held (the cache owns one ref
1235    /// per radix node), so nothing frees them on its own — the swap-in path has
1236    /// to ask. Returns 0 when nothing is evictable, which the caller must treat
1237    /// as "no progress possible" rather than retrying forever.
1238    fn reclaim_prefix_blocks(&self, _num_blocks: usize) -> usize {
1239        0
1240    }
1241
1242    /// Return the default CUDA stream handle.
1243    fn default_stream(&self) -> u64 {
1244        0
1245    }
1246
1247    /// Create a new CUDA stream (for overlapping prefill with decode).
1248    fn create_stream(&self) -> Result<u64> {
1249        Ok(0)
1250    }
1251
1252    /// Create a CUDA event (for inter-stream synchronization).
1253    fn create_event(&self) -> Result<u64> {
1254        Ok(0)
1255    }
1256
1257    /// Record an event on a stream (marks a point in the stream's work).
1258    fn record_event(&self, _event: u64, _stream: u64) -> Result<()> {
1259        Ok(())
1260    }
1261
1262    /// Make a stream wait for an event (GPU-side sync, CPU does not block).
1263    fn stream_wait_event(&self, _stream: u64, _event: u64) -> Result<()> {
1264        Ok(())
1265    }
1266
1267    /// Block the host until all work submitted to `stream` has completed.
1268    /// Used by `mixed_forward_batch` to retire the decode pass (which runs on
1269    /// the default stream) before the batched prefill reuses the shared arena
1270    /// buffers on another stream (#110). Default no-op for non-CUDA mocks.
1271    fn synchronize(&self, _stream: u64) -> Result<()> {
1272        Ok(())
1273    }
1274}
1275
1276#[cfg(test)]
1277mod padded_batch_n_tests {
1278    use super::padded_batch_n;
1279
1280    /// Rungs <= 32 must be UNCHANGED by the wave-14a widening (byte-identity
1281    /// for every bs <= 32 boot), and the new rungs must cover n=33..128.
1282    #[test]
1283    fn ladder_rungs() {
1284        // Legacy rungs (aacd29cb and earlier) — must not move.
1285        for (n, want) in [
1286            (1usize, 2usize),
1287            (2, 2),
1288            (3, 4),
1289            (5, 8),
1290            (9, 12),
1291            (13, 16),
1292            (16, 16),
1293            (17, 24),
1294            (25, 32),
1295            (32, 32),
1296        ] {
1297            assert_eq!(padded_batch_n(n), want, "n={n}");
1298        }
1299        // Wave-14a rungs (only reachable when the boot's max_batch_size
1300        // admits that many active sequences).
1301        for (n, want) in [
1302            (33usize, 48usize),
1303            (48, 48),
1304            (49, 64),
1305            (64, 64),
1306            (65, 96),
1307            (96, 96),
1308            (97, 128),
1309            (128, 128),
1310        ] {
1311            assert_eq!(padded_batch_n(n), want, "n={n}");
1312        }
1313        // Above the ladder: fall-through unchanged.
1314        assert_eq!(padded_batch_n(129), 129);
1315    }
1316}
1317
1318/// A worker command that was received and then FAILED TO EXECUTE.
1319///
1320/// 🔴 Why this distinction is load-bearing. The EP worker loop used to `break` on any
1321/// error, so a per-request fault — a prefill chunk the model legitimately refuses — killed
1322/// the worker, which then exited with status **0** while the head stayed up. The head's very
1323/// next request issued a collective against a peer that no longer existed and spun in NCCL
1324/// forever at 100 % CPU, with `/v1/models`, `/health` and `/health/live` all still answering
1325/// 200. Measured 2026-08-30: rank 1 logged this exact refusal and stopped 4 s later; rank 0
1326/// accepted a 13-token request 10 minutes on and never produced a single further log line.
1327/// ANOMALIES A60 (the wedge) and A62 (the refusal that triggered it).
1328///
1329/// The head raises the SAME error for the SAME command and turns it into an HTTP 500, so the
1330/// two ranks disagreeing about whether it is fatal is the defect. A receive failure stays
1331/// fatal: the link is gone, and the next iteration's receive would fail again anyway.
1332#[derive(Debug)]
1333pub struct EpCommandFailed(pub anyhow::Error);
1334
1335impl std::fmt::Display for EpCommandFailed {
1336    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1337        write!(f, "{:#}", self.0)
1338    }
1339}
1340
1341impl std::error::Error for EpCommandFailed {}
1342
1343#[cfg(test)]
1344mod ep_command_failed_tests {
1345    use super::EpCommandFailed;
1346
1347    /// The worker loop classifies by downcast, so the tag must survive being boxed into an
1348    /// `anyhow::Error` — and the original message must survive with it, or the operator
1349    /// loses the only line that says WHY the command failed.
1350    #[test]
1351    fn the_tag_and_its_message_survive_anyhow() {
1352        let inner = anyhow::anyhow!("Prefill chunk layer 3 failed: DSA indexer cache: 16385");
1353        let tagged = anyhow::Error::new(EpCommandFailed(inner));
1354        assert!(
1355            tagged.downcast_ref::<EpCommandFailed>().is_some(),
1356            "the worker loop cannot tell a command failure from a dead link without this"
1357        );
1358        assert!(format!("{tagged:#}").contains("DSA indexer cache: 16385"));
1359    }
1360
1361    /// A receive failure must NOT be mistaken for a command failure: the link is gone and
1362    /// the worker has to exit rather than spin re-reading a dead socket.
1363    #[test]
1364    fn an_untagged_error_stays_fatal() {
1365        let recv = anyhow::anyhow!("ep_recv_seq_and_cmd: peer closed");
1366        assert!(recv.downcast_ref::<EpCommandFailed>().is_none());
1367    }
1368}