Skip to main content

fuel_tensor/
persistent_decode.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2//! The shared persistent-decode rebind driver (GAP-029 increment 2b).
3//!
4//! # Why this module exists
5//!
6//! `rebind_and_realize_prebuilt` — the per-token hot path that reuses a held,
7//! already-optimized decode graph — was **hand-copied per model**. `LlamaModel`
8//! and `PhiModel` each carried a 48-line copy, structurally identical, and
9//! `DeepSeek2Model` carries a third that has *diverged* (`rebind_..._mla`).
10//!
11//! **Two hand-maintained copies of a hot path are a reproduction mechanism, not
12//! two implementations.** GAP-029 adds up to eight more model families; porting
13//! them against the copied shape would have produced ten copies and kept the
14//! generator that produces copy eleven. So the driver is collapsed here *before*
15//! any new family is added.
16//!
17//! # Why the two existing impls were ported first
18//!
19//! This refactor is behaviour-preserving, which means **it has no born-red
20//! state**: "the tests still pass" is also exactly what a no-op produces. The
21//! only thing that makes it verifiable is an oracle that predates it —
22//! `LlamaModel` and `PhiModel` already work, so correctness *is* "their
23//! behaviour is unchanged". A newly added family cannot serve that role: it has
24//! no prior behaviour to be unchanged from, so its test can only assert what the
25//! new code already does.
26//!
27//! Measured oracle (not assumed): 8 Phi decode tests + the parallel Llama set,
28//! including `phi_generate_loop_persistent_byte_exact_and_plans_once`, which
29//! asserts byte-exact logits *and* plans-once and therefore already walks the
30//! multi-token **rebind** path this driver implements.
31//!
32//! # The path divergence this driver must PRESERVE, not unify
33//!
34//! Measured 2026-08-12 on CPU/F32 — and stated nowhere else in the tree:
35//!
36//! ```text
37//! llama: offset_node.is_some() == true   → device-offset path (Op::WriteSliceDoff)
38//! phi:   offset_node.is_some() == false  → SymEnv path
39//! ```
40//!
41//! The KV-write offset rides a **device buffer** for one and a **symbol** for the
42//! other. Forcing either model onto the other's path would be a behaviour change
43//! wearing a refactor's clothes, so the divergence lives behind
44//! [`PersistentDecodeModel::build_decode_token_data`]: each model keeps building
45//! its own token data exactly as before, and this driver never inspects which
46//! path it got.
47
48use crate::inference_context::{DecodeSession, DecodeTokenData, InferenceContext, KvCache, KvSlot};
49use crate::lazy::Tensor;
50use crate::{Device, Result};
51use fuel_ir::{DType, Shape};
52use std::sync::Arc;
53
54/// The per-model variation points of one persistent-decode rebind step.
55///
56/// Deliberately **one** data hook plus a layer count, rather than a hook per
57/// difference. The three ways the Llama and Phi copies differed were: an extra
58/// `rope_inv_freq` argument, an extra `cache_dtype` + device-offset argument
59/// pair, and the `SymEnv` construction. The first two are inputs to token-data
60/// construction and collapse into the hook; the third **dissolved** — see
61/// [`crate::inference_context::DecodeSession::per_token_sym_env`], whose doc
62/// carries the measurement and the do-not-delete warning.
63pub trait PersistentDecodeModel {
64    /// Transformer layers whose K/V versions are bumped after a step.
65    fn decode_n_layers(&self) -> usize;
66
67    /// Recompute this token's device-resident data Consts (token ids, RoPE
68    /// cos/sin, mask, and — only on the device-offset path — the KV-write
69    /// offset).
70    ///
71    /// **This is where the per-model path divergence lives and stays.** An impl
72    /// reads `session.offset_node().is_some()` if it needs to; the driver does
73    /// not, so it cannot accidentally normalise the two models onto one path.
74    fn build_decode_token_data(
75        &self,
76        device: &Device,
77        cached_len: usize,
78        tokens: &[u32],
79        session: &DecodeSession,
80        cache: &KvCache,
81        rope_inv_freq: Option<&[f64]>,
82    ) -> Result<DecodeTokenData>;
83}
84
85/// One persistent-decode step on an already-built session: recompute the
86/// per-token data, rebind, realize the held graph, advance cache state.
87///
88/// `ctx` is **not** mutated on this path — the data lands in a clone of the
89/// session's held `base_cache`, not in `ctx.persistent`.
90///
91/// # A panic preserved on purpose
92///
93/// The `expect` below is carried over verbatim from *both* original copies,
94/// where the caller guarantees `Some`. It is a known violation of the
95/// never-panic rule and converting it to a typed error would be an improvement —
96/// but this increment is behaviour-preserving, and mixing a semantic fix into it
97/// would blunt the only oracle available (the pre-existing tests, which can only
98/// certify "unchanged"). Tracked as a follow-up rather than smuggled in here.
99pub fn rebind_and_realize_prebuilt<M: PersistentDecodeModel + ?Sized>(
100    model: &M,
101    tokens: &[u32],
102    cache: &mut KvCache,
103    ctx: &InferenceContext,
104    session: &Option<DecodeSession>,
105    rope_inv_freq: Option<&[f64]>,
106) -> Result<Vec<f32>> {
107    let seq = tokens.len();
108    let cached_len = cache.cached_len;
109    let device = ctx.device().clone();
110
111    // Session guaranteed Some + valid by the caller.
112    let s = session.as_ref().expect("session is Some");
113
114    let data = {
115        let _t = fuel_dispatch::compiled::op_profile::PhaseTimer::start("decode.build_token_data");
116        model.build_decode_token_data(&device, cached_len, tokens, s, cache, rope_inv_freq)?
117    };
118
119    // Bind BOTH per-token symbols. Inert for a device-offset session, and
120    // load-bearing for a SymEnv one — see `per_token_sym_env`'s doc before
121    // concluding from one model that this is dead.
122    let sym_env = s.per_token_sym_env(cached_len)?;
123    let logits_vec = s.realize_token(&device, data, &sym_env)?;
124
125    // Bump cache state (identical to the D1 path).
126    cache.cached_len += seq;
127    for li in 0..model.decode_n_layers() {
128        cache.bump_version(li, KvSlot::K);
129        cache.bump_version(li, KvSlot::V);
130    }
131    Ok(logits_vec)
132}
133
134// ===========================================================================
135// SABOTAGE RECORD (2026-08-12)
136//
137// This extraction is behaviour-preserving, so it has NO born-red state: a
138// passing suite is also exactly what a no-op produces, and "I moved the code
139// and the tests are green" is indistinguishable from "I moved nothing".
140//
141// The specific risk worth discriminating is not "is the driver correct" — the
142// pre-existing suites cover that — but "do BOTH models actually route through
143// it, or does one still have a live private copy the compiler happily kept?"
144// A sabotage that reddens only one model would prove the other never reached
145// this code.
146//
147// Sabotage applied: drop `cache.cached_len += seq`, so the cache never advances.
148// Run carries `Compiling fuel-core`, so the binary was rebuilt, not cached:
149//
150//   llama : forward_with_kv_context_persistent_plan_once_matches_d1  FAILED
151//           generate_loop_persistent_byte_exact_and_plans_once       FAILED
152//   llama3: scaled_persistent_decode_matches_full_prefix_forward     FAILED
153//   phi   : phi_persistent_plan_once_matches_d1                      FAILED
154//           phi_generate_loop_persistent_byte_exact_and_plans_once   FAILED
155//   test result: FAILED. 27 passed; 5 failed
156//
157// Both families went red — plus `Llama3Model`, which reaches this through
158// `LlamaModel` — so all three carriers demonstrably execute this driver. The 27
159// that stayed green are what keep the 5 meaningful: the suite is not simply
160// broken.
161//
162// The assertions fired at **"persistent decode token 2"**, i.e. the REBIND
163// path rather than the first-token BUILD path — which is the step this driver
164// actually implements, and the one a 1-token test would have been blind to.
165// ===========================================================================
166
167// ===========================================================================
168// THE SHARED DECODE **BUILD** PATH (GAP-029 increment 3)
169// ===========================================================================
170
171/// # ⚠️ THIS IS SEVEN FAMILIES' BUILD PATH. A CHANGE HERE IS A SEVEN-FAMILY CHANGE.
172///
173/// [`build_decode_graph`] and its two tails were, until GAP-029 increment 3,
174/// two inherent methods on `LlamaModel`: `forward_with_kv_context_impl` (the D1
175/// rebuild-per-step path) and `build_and_realize_first_decode_token` (the D2
176/// plan-once path). They live here now, and the relocation is the point.
177///
178/// ## Why relocated rather than doc-commented in place
179///
180/// The architect's ruling on increment 3 was that the six LLaMA-shaped families
181/// (Qwen2, Qwen3, Qwen3Moe, SmolLm3, Glm4, Phi3) **reuse Llama's build path**
182/// rather than each getting a copy — increment 3 copies it *zero* times. That
183/// creates a hazard one level up from the `per_token_sym_env` trap: an inherent
184/// method on `LlamaModel` that six other types call is **a shared implementation
185/// wearing a private name**, so the next person to optimise "`LlamaModel`'s
186/// decode path" edits seven families' decode path with nothing telling them.
187/// Their evidence would be about Llama; the requirement comes from the others.
188///
189/// A doc comment asks the reader to already be careful. Moving the body out of
190/// `LlamaModel` makes it *structurally impossible* to edit it while believing it
191/// is Llama's — the same reason increment 2b put the offset-path divergence
192/// behind a hook instead of behind a warning.
193///
194/// ## D1 and D2 are ONE body, and that was a build-order requirement
195///
196/// The two paths differ only in how the per-token data Consts are minted
197/// (concrete vs re-bindable) and in what happens after the graph is built
198/// (realize-and-discard vs optimize-and-hold). Everything between — embed,
199/// RoPE tables, mask, the per-layer KV-write loop, final norm, logits — is
200/// identical. [`DataConsts`] is that one difference, and it is a parameter of
201/// the body **from its first line**: built for D2 alone, D1 would have become a
202/// second per-family copy, six times over.
203///
204/// ## Phi is deliberately NOT on this path
205///
206/// `PhiModel` keeps its own build body. It is the outlier, not the template
207/// (parallel attention block, LayerNorm+bias), and folding it in was declined on
208/// its own merits. "Phi adopts the shared path" is a recorded follow-on, not
209/// part of this increment.
210mod shared_build_path_docs {}
211
212/// Which flavour of per-token data Consts [`build_decode_graph`] mints.
213///
214/// This is the *entire* D1/D2 difference inside the graph-building half.
215#[derive(Clone, Copy, PartialEq, Eq, Debug)]
216pub(crate) enum DataConsts {
217    /// **D1** — the rebuild-per-step path. Token ids, RoPE tables and the mask
218    /// are baked into the graph as concrete Consts; the graph is realized once
219    /// and dropped. The KV-write offset always rides the backend-generic
220    /// `SymEnv` here (no capture to make device-resident).
221    Baked,
222    /// **D2** — the plan-once persistent path. Every per-token datum is a
223    /// re-bindable placeholder Const whose Arc is bound through the
224    /// `InferenceContext`, and the optimized graph is held on a
225    /// [`DecodeSession`].
226    Rebindable,
227}
228
229impl DataConsts {
230    /// The public entry point this mode serves, for error messages.
231    fn entry(self) -> &'static str {
232        match self {
233            Self::Baked => "forward_with_kv_context",
234            Self::Rebindable => "forward_with_kv_context_persistent",
235        }
236    }
237}
238
239/// The model-scalar geometry the shared build path needs.
240///
241/// Deliberately **not** a description of the KV state — that is
242/// `fuel_inference::decode_state_spec`'s job (moved there in the fuel-core
243/// dissolution; not an intra-doc link, since fuel-core cannot depend on
244/// fuel-inference to resolve one), and conflating the two is the GAP-166
245/// mistake. These are the dimensions of the *graph* this path builds.
246#[derive(Clone, Copy, Debug, PartialEq)]
247pub struct DecodeDims {
248    pub n_layers: usize,
249    pub n_kv_heads: usize,
250    pub head_dim: usize,
251    /// Model width (`dim` / `hidden_size`).
252    pub hidden: usize,
253    pub vocab: usize,
254    /// Width of the RoPE cos/sin tables. Equal to `head_dim` for a full-rotary
255    /// family (Llama, Qwen2); a partial-rotary family (Glm4) passes its
256    /// `rotary_dim`. Carried as a value rather than assumed, because that one
257    /// `usize` is the whole of Glm4's divergence from this path.
258    pub rope_width: usize,
259    /// Multiplier applied to the token embeddings before the layer loop.
260    /// `None` = no scaling, and `None` **emits no graph node at all** — a
261    /// `Some(1.0)` would still cost a `mul_scalar`, which is why this is an
262    /// `Option` rather than a plain `f64` defaulting to 1.
263    ///
264    /// Gemma-family models pass `Some(sqrt(hidden_size))`; everyone else `None`.
265    ///
266    /// ⚠️ **It is the SEAM that applies this, deliberately, and the ordering is
267    /// the reason.** The scale must land BEFORE the activation dtype cast:
268    /// prefill scales in f32, so scaling after a cast would round in bf16 on a
269    /// bf16 cache and diverge — **invisible on an f32 gate**. A hook would let
270    /// each family put it wherever, making that hazard per-family and silent;
271    /// a value applied at one fixed point makes the ordering unforgettable.
272    ///
273    /// It is a scalar rather than a dimension, which the struct's name does not
274    /// quite cover — stated here rather than left to overreach quietly.
275    pub embed_scale: Option<f64>,
276}
277
278/// **Per-layer attention-mask variation** — the axis GAP-029 measured across
279/// increment 3's families and the earlier six-axis check could not see, every
280/// one of its axes being model-scalar.
281///
282/// Note the axis is named for the **behaviour that varies**, not the first
283/// mechanism found: three families vary by sliding window, SmolLm3 varies by
284/// skipping RoPE entirely, and a checklist keyed on `sliding_window` would have
285/// cleared SmolLm3 outright.
286///
287/// # Shape in the graph
288///
289/// The mask Const is `[n_variants, 1, seq, max_seq_len]`; the build path hoists
290/// `n_variants` width-1 slices **once**, before the layer loop, and layer `i`
291/// takes `variant_for_layer(i)`. `DecodeTokenData`, `DecodeSession` and the
292/// rebind driver are untouched — still one mask Arc, one `mask_node`.
293///
294/// **`n_variants == 1` mints `[1, 1, seq, max_seq_len]` and emits no slice node
295/// at all**, so a uniform family's graph is byte-identical to the pre-GAP-029
296/// one. That is asserted, not claimed: see `mask_variants_are_byte_identical_
297/// to_the_dense_builder_when_uniform`.
298#[derive(Clone, Debug, PartialEq, Eq)]
299pub struct MaskPlan {
300    /// One entry per variant. `None` is dense causal; `Some(w)` attends only to
301    /// the most recent `w` positions.
302    windows: Vec<Option<usize>>,
303    /// Variant index per layer. `len() == n_layers`.
304    per_layer: Vec<usize>,
305}
306
307impl MaskPlan {
308    /// The uniform dense plan — every layer attends causally to the whole
309    /// prefix. What `LlamaModel` and every other non-varying family use, and
310    /// byte-identical to the pre-GAP-029 single-mask decode path.
311    pub fn dense(n_layers: usize) -> Self {
312        Self {
313            windows: vec![None],
314            per_layer: vec![0; n_layers],
315        }
316    }
317
318    /// Validated at construction, per the build-time-validation rule: an
319    /// out-of-range variant index would otherwise surface as a panic deep in
320    /// the layer loop, or — worse — as a silently mis-masked layer.
321    pub fn new(windows: Vec<Option<usize>>, per_layer: Vec<usize>) -> Result<Self> {
322        if windows.is_empty() {
323            return Err(
324                fuel_ir::Error::Msg("MaskPlan: needs at least one variant".to_string()).bt(),
325            );
326        }
327        if let Some((li, v)) = per_layer
328            .iter()
329            .enumerate()
330            .find(|(_, v)| **v >= windows.len())
331        {
332            return Err(fuel_ir::Error::Msg(format!(
333                "MaskPlan: layer {li} selects mask variant {v} but only {} \
334                 variant(s) are defined",
335                windows.len(),
336            ))
337            .bt());
338        }
339        Ok(Self { windows, per_layer })
340    }
341
342    /// The **`layer_idx < split`** plan: the first `split` layers attend
343    /// through a `window`, the rest densely. This is exactly the predicate
344    /// Qwen2, Qwen3 and Qwen3Moe share (`use_sliding_window && layer_idx <
345    /// max_window_layers`), so all three express their variation by calling
346    /// this rather than hand-rolling a variant table.
347    ///
348    /// **Total by construction — no `Result`, no panic.** Both degenerate
349    /// splits collapse to a single variant, so a model that is entirely dense
350    /// *or* entirely windowed emits no slice node and pays nothing:
351    /// `split == 0` is [`Self::dense`], `split >= n_layers` is one windowed
352    /// variant.
353    pub fn split_window(n_layers: usize, split: usize, window: usize) -> Self {
354        let split = split.min(n_layers);
355        if split == 0 {
356            return Self::dense(n_layers);
357        }
358        if split == n_layers {
359            return Self {
360                windows: vec![Some(window)],
361                per_layer: vec![0; n_layers],
362            };
363        }
364        Self {
365            windows: vec![Some(window), None],
366            per_layer: (0..n_layers).map(|i| usize::from(i >= split)).collect(),
367        }
368    }
369
370    /// **Arbitrary per-layer window selection, TOTAL by construction.**
371    ///
372    /// [`Self::split_window`] covers the *prefix* predicate Qwen2/Qwen3/Qwen3Moe
373    /// share. Gemma3's is **modular** (`(i + 1) % sliding_window_pattern > 0`),
374    /// so it needs a free selector — and it cannot use [`Self::new`], because
375    /// `decode_mask_plan` is infallible and `expect`-ing a `Result` on a
376    /// production path is exactly the never-panic violation this project bans.
377    /// A closure returning `bool` over exactly two defined variants cannot
378    /// produce an out-of-range index, so this needs no `Result` at all.
379    ///
380    /// Collapses to a single variant when the selector is constant, so a config
381    /// that happens to window every layer (or none) still emits no slice.
382    pub fn per_layer_window(
383        n_layers: usize,
384        window: usize,
385        uses_window: impl Fn(usize) -> bool,
386    ) -> Self {
387        let per_layer: Vec<usize> = (0..n_layers)
388            .map(|i| usize::from(!uses_window(i)))
389            .collect();
390        match (per_layer.contains(&0), per_layer.contains(&1)) {
391            (true, false) => Self {
392                windows: vec![Some(window)],
393                per_layer: vec![0; n_layers],
394            },
395            (false, true) => Self::dense(n_layers),
396            // Mixed (or zero layers): keep both variants, windowed first.
397            _ => Self {
398                windows: vec![Some(window), None],
399                per_layer,
400            },
401        }
402    }
403
404    pub fn n_variants(&self) -> usize {
405        self.windows.len()
406    }
407
408    pub fn n_layers(&self) -> usize {
409        self.per_layer.len()
410    }
411
412    pub fn variant_for_layer(&self, layer_idx: usize) -> usize {
413        self.per_layer[layer_idx]
414    }
415
416    /// This layer's window width, or `None` if it attends densely.
417    ///
418    /// **The single source shared with the mask bytes** — the CUDA flash arm's
419    /// `window_size_*` and the mask Const come from the *same* plan entry, so
420    /// they cannot disagree. A family deriving the window separately for the arm
421    /// would be free to drift from the mask it actually applies, and *"arm says
422    /// dense, mask says windowed"* is precisely the silent defect GAP-194 was
423    /// filed about.
424    pub fn window_for_layer(&self, layer_idx: usize) -> Option<usize> {
425        self.windows[self.per_layer[layer_idx]]
426    }
427
428    /// Mix the plan's **structural** content into a decode shape key.
429    ///
430    /// The variant count and the per-layer assignment change the graph's shape
431    /// and wiring, so a session built under one plan must not be reused under
432    /// another. The window *widths* are deliberately excluded: they are data,
433    /// rebound per token like the RoPE tables, and baking them would forfeit
434    /// plan reuse across a change that is already handled correctly — the same
435    /// argument `decode_shape_key`'s own doc makes for `rope_base`.
436    pub fn mix_into(&self, h: &mut crate::decode_shape::ShapeKeyHasher) {
437        h.mix_u64(self.windows.len() as u64);
438        for v in &self.per_layer {
439            h.mix_u64(*v as u64);
440        }
441    }
442}
443
444/// **Per-layer RoPE-BASE variation** — the sibling axis to [`MaskPlan`], and
445/// the one Gemma3 forced.
446///
447/// A family with two RoPE bases (Gemma3: `rope_theta` for global layers,
448/// `rope_local_base_freq` for sliding ones) produces **different table bytes per
449/// variant**, so — exactly like the mask — the Const must physically carry N of
450/// them and the layer loop must select.
451///
452/// This is the criterion that separates it from SmolLm3's per-layer RoPE
453/// *gating*, which needs nothing: **variation in DATA needs a variant axis;
454/// variation in WHETHER-TO-APPLY does not.** A skipping layer simply does not
455/// consume the shared tables; a differently-based layer needs different bytes.
456///
457/// # Shape, and an honest asymmetry with `MaskPlan`
458///
459/// `n_variants == 1` mints `[seq, rope_width]` — **exactly today's shape, with
460/// no slice and no reshape** — so every single-base family's graph is unchanged
461/// (pinned by the `*_held_decode_graph_has_not_grown` node-count tests).
462///
463/// `n_variants > 1` mints `[n, seq, rope_width]` and hoists the per-variant
464/// views once before the layer loop. Unlike the mask — whose Const is already
465/// rank-4, so a width-1 slice is directly usable — RoPE tables are rank-2, so
466/// each variant costs a slice **and** a reshape. That cost is real and is named
467/// here rather than presented as symmetric.
468#[derive(Clone, Debug, PartialEq)]
469pub struct RopePlan {
470    bases: Vec<f64>,
471    per_layer: Vec<usize>,
472}
473
474impl RopePlan {
475    /// The single-base plan — what every family except Gemma3 uses.
476    pub fn single(base: f64, n_layers: usize) -> Self {
477        Self {
478            bases: vec![base],
479            per_layer: vec![0; n_layers],
480        }
481    }
482
483    /// Validated at construction, like [`MaskPlan::new`]: an out-of-range
484    /// variant index would otherwise be a panic in the layer loop or, worse, a
485    /// layer silently roped at the wrong frequency.
486    pub fn new(bases: Vec<f64>, per_layer: Vec<usize>) -> Result<Self> {
487        if bases.is_empty() {
488            return Err(fuel_ir::Error::Msg("RopePlan: needs at least one base".to_string()).bt());
489        }
490        if let Some((li, v)) = per_layer
491            .iter()
492            .enumerate()
493            .find(|(_, v)| **v >= bases.len())
494        {
495            return Err(fuel_ir::Error::Msg(format!(
496                "RopePlan: layer {li} selects RoPE variant {v} but only {} base(s) \
497                 are defined",
498                bases.len(),
499            ))
500            .bt());
501        }
502        Ok(Self { bases, per_layer })
503    }
504
505    /// **Two-base per-layer selection, TOTAL by construction** — the sibling of
506    /// [`MaskPlan::per_layer_window`], and what Gemma3 uses: `base_when_true` is
507    /// its `rope_local_base_freq` (sliding layers), `base_when_false` its
508    /// `rope_theta` (full-causal layers).
509    ///
510    /// Infallible for the same reason: a `bool` selector over exactly two
511    /// defined bases cannot index out of range, so `decode_rope_plan` needs no
512    /// `Result` and no `expect`.
513    ///
514    /// ⚠️ **Collapses to a single base when the two are EQUAL** — which is not a
515    /// micro-optimisation but a correctness-of-measurement matter: Gemma3's
516    /// shipped fixtures set `rope_local_base_freq == rope_theta`, and a plan
517    /// that still reported 2 variants there would make the graph *look*
518    /// dual-base while every layer read identical bytes. Collapsing makes the
519    /// degeneracy visible in `n_variants()`, which is what the non-vacuity
520    /// assertions in Gemma3's tests key on.
521    pub fn per_layer_base(
522        n_layers: usize,
523        base_when_true: f64,
524        base_when_false: f64,
525        uses_first: impl Fn(usize) -> bool,
526    ) -> Self {
527        if base_when_true == base_when_false {
528            return Self::single(base_when_true, n_layers);
529        }
530        let per_layer: Vec<usize> = (0..n_layers).map(|i| usize::from(!uses_first(i))).collect();
531        match (per_layer.contains(&0), per_layer.contains(&1)) {
532            (true, false) => Self::single(base_when_true, n_layers),
533            (false, true) => Self::single(base_when_false, n_layers),
534            _ => Self {
535                bases: vec![base_when_true, base_when_false],
536                per_layer,
537            },
538        }
539    }
540
541    pub fn n_variants(&self) -> usize {
542        self.bases.len()
543    }
544
545    pub fn n_layers(&self) -> usize {
546        self.per_layer.len()
547    }
548
549    pub fn variant_for_layer(&self, layer_idx: usize) -> usize {
550        self.per_layer[layer_idx]
551    }
552
553    /// Mix the plan's **structural** content into a decode shape key: the
554    /// variant count and per-layer assignment wire the graph. The base *values*
555    /// are excluded for the same reason `rope_base` always was — they are data,
556    /// recomputed and rebound every token, so baking them would forfeit plan
557    /// reuse across a change already handled correctly.
558    pub fn mix_into(&self, h: &mut crate::decode_shape::ShapeKeyHasher) {
559        h.mix_u64(self.bases.len() as u64);
560        for v in &self.per_layer {
561            h.mix_u64(*v as u64);
562        }
563    }
564}
565
566/// Build the stacked RoPE cos/sin tables, variants concatenated along a leading
567/// axis. Single source of table bytes for both the build path and every rebind.
568fn build_rope_variants(
569    plan: &RopePlan,
570    width: usize,
571    cached_len: usize,
572    seq: usize,
573    rope_inv_freq: Option<&[f64]>,
574) -> (Vec<f32>, Vec<f32>) {
575    // A caller-supplied inverse-frequency vector (scaled RoPE) replaces the
576    // base entirely, so it applies to every variant — a scaled multi-base model
577    // would need per-variant inv_freq, and no such model exists yet. Single-base
578    // is the only shape that reaches here with `Some`.
579    if let Some(inv) = rope_inv_freq {
580        return fuel_graph::build_rope_tables_with_inv_freq(inv, cached_len, seq, width);
581    }
582    let mut cos = Vec::with_capacity(plan.n_variants() * seq * width);
583    let mut sin = Vec::with_capacity(plan.n_variants() * seq * width);
584    for base in &plan.bases {
585        let (c, s) = fuel_graph::build_rope_tables(*base, cached_len, seq, width);
586        cos.extend_from_slice(&c);
587        sin.extend_from_slice(&s);
588    }
589    (cos, sin)
590}
591
592/// Build the `[n_variants, 1, seq, max_seq_len]` decode mask, variants
593/// concatenated along the leading axis.
594///
595/// This is the single source of mask bytes for **both** halves of decode: the
596/// build path bakes/binds it here, and each family's per-token rebind hook
597/// recomputes it through this same function. Two mask formulas — one for the
598/// held graph and one for the rebind — is precisely the divergence that would
599/// go unnoticed until a windowed family decoded its second token.
600pub fn build_decode_mask_variants(
601    plan: &MaskPlan,
602    cached_len: usize,
603    seq: usize,
604    max_seq_len: usize,
605) -> Vec<f32> {
606    let mut out = Vec::with_capacity(plan.n_variants() * seq * max_seq_len);
607    for window in &plan.windows {
608        match window {
609            None => out.extend_from_slice(&crate::lazy::build_decode_causal_mask(
610                cached_len,
611                seq,
612                max_seq_len,
613            )),
614            Some(window) => out.extend_from_slice(&crate::lazy::build_decode_causal_mask_windowed(
615                cached_len,
616                seq,
617                max_seq_len,
618                *window,
619            )),
620        }
621    }
622    out
623}
624
625/// Everything the shared build path needs from one model family.
626///
627/// Four of increment 3's seven per-family items are served through this trait
628/// rather than copied; the two that are genuinely per-family — the attention
629/// block and the token-data hook — are the two methods that take real work.
630pub trait DecodeBackbone: PersistentDecodeModel {
631    /// Family name, threaded into every error this shared path raises.
632    ///
633    /// **Not cosmetic.** The 23 hunks that made collapsing two build bodies look
634    /// hard turned out to be mostly error-message prefixes, so sharing the body
635    /// makes diagnostics lie by default: without this, a Qwen3Moe failure
636    /// reports `"forward_with_kv_context: …"` and reads as a Llama bug.
637    fn decode_family(&self) -> &'static str;
638
639    fn decode_dims(&self) -> DecodeDims;
640
641    /// Identity of the held decode plan — family + the config values that change
642    /// graph structure + this weight set. Must fold in
643    /// [`MaskPlan::mix_into`].
644    fn decode_shape_key(&self) -> u64;
645
646    fn decode_mask_plan(&self) -> MaskPlan;
647
648    /// Per-layer RoPE-base assignment. **Required, not defaulted, on purpose:**
649    /// a default would be silently correct for the single-base families and
650    /// silently WRONG for a multi-base one that forgot to override it. Every
651    /// family stating it is the compiler enumerating them.
652    fn decode_rope_plan(&self) -> RopePlan;
653
654    /// The `[vocab, hidden]` embedding table the token lookup reads.
655    fn decode_token_embedding(&self) -> Arc<[f32]>;
656
657    /// One transformer layer: attention (writing this step's K/V into the
658    /// cache buffers) plus the FFN. **This is the per-family architecture** —
659    /// partial rotary, GQA replication, norm placement, bias presence, MoE
660    /// routing all live here and nowhere else on this path.
661    ///
662    /// `layer_idx` is passed because per-layer variation is real (mask variant
663    /// selection is resolved by the caller, but Gemma3's RoPE base and LFM2's
664    /// state kind are not).
665    fn decode_apply_layer(
666        &self,
667        layer_idx: usize,
668        inputs: &DecodeLayerInputs<'_>,
669    ) -> Result<Tensor>;
670
671    /// Final norm + LM head → `[batch, seq, vocab]`.
672    fn decode_final_norm_and_head(&self, h: &Tensor) -> Result<Tensor>;
673}
674
675/// The per-layer arguments of [`DecodeBackbone::decode_apply_layer`], bundled
676/// so the signature stays readable as families are added.
677pub struct DecodeLayerInputs<'a> {
678    pub x: &'a Tensor,
679    pub k_cache: &'a Tensor,
680    pub v_cache: &'a Tensor,
681    pub cached_len_sym: fuel_ir::SymId,
682    pub attended_len_sym: fuel_ir::SymId,
683    /// `Some` on the device-offset (`Op::WriteSliceDoff`) path, `None` on the
684    /// backend-generic `SymEnv` path. The layer must honour both — see the
685    /// divergence note at the top of this module.
686    pub offset: Option<&'a Tensor>,
687    pub rope_cos: &'a Tensor,
688    pub rope_sin: &'a Tensor,
689    /// **This layer's** mask variant, already sliced out of the stacked Const.
690    pub mask: &'a Tensor,
691    /// **This layer's** window width, or `None` if it attends densely — the
692    /// same plan entry `mask` was built from ([`MaskPlan::window_for_layer`]).
693    ///
694    /// Carried so a layer offering the CUDA flash-decode arm can state the
695    /// truth about its own key range instead of asserting `None`. The arm's
696    /// `flash_decoding` kernel does not implement local attention, so a
697    /// windowed layer's offer is *declined* — which is correct and is the
698    /// point. Asserting `None` there would make the arm attend the whole
699    /// prefix and silently drop the window (GAP-194).
700    pub attn_window: Option<usize>,
701}
702
703/// Per-token host data, before it becomes either a baked Const or an upload.
704pub struct DecodeTokenHost {
705    pub token_ids: Vec<u32>,
706    pub rope_cos: Vec<f32>,
707    pub rope_sin: Vec<f32>,
708    /// `[n_variants, 1, seq, max_seq_len]` as f32; converted to the cache dtype
709    /// at the Const/upload boundary.
710    pub mask: Vec<f32>,
711}
712
713/// Compute one decode step's host-side data — the RoPE tables and the stacked
714/// mask. Pure host math, no graph and no upload, shared by the build path (which
715/// bakes or uploads it) and every family's rebind hook (which uploads it).
716pub fn compute_decode_token_host<M: DecodeBackbone + ?Sized>(
717    model: &M,
718    cached_len: usize,
719    tokens: &[u32],
720    max_seq_len: usize,
721    rope_inv_freq: Option<&[f64]>,
722) -> DecodeTokenHost {
723    let _t = fuel_dispatch::compiled::op_profile::PhaseTimer::start("decode.token_host_compute");
724    let dims = model.decode_dims();
725    let seq = tokens.len();
726    let (rope_cos, rope_sin) = build_rope_variants(
727        &model.decode_rope_plan(),
728        dims.rope_width,
729        cached_len,
730        seq,
731        rope_inv_freq,
732    );
733    DecodeTokenHost {
734        token_ids: tokens.to_vec(),
735        rope_cos,
736        rope_sin,
737        mask: build_decode_mask_variants(&model.decode_mask_plan(), cached_len, seq, max_seq_len),
738    }
739}
740
741/// The graph [`build_decode_graph`] produced, plus the handles each tail needs.
742pub(crate) struct BuiltDecodeGraph {
743    logits_root: Tensor,
744    kv_nodes: Vec<(fuel_graph::NodeId, fuel_graph::NodeId)>,
745    /// `Some` under [`DataConsts::Rebindable`] only — D1 has no re-bindable
746    /// node to hold.
747    rebindable: Option<RebindableNodes>,
748    cached_len_sym: fuel_ir::SymId,
749    attended_len_sym: fuel_ir::SymId,
750}
751
752/// The stable NodeIds + first-token Arcs a [`DecodeSession`] is built from.
753pub(crate) struct RebindableNodes {
754    token_ids_node: fuel_graph::NodeId,
755    rope_cos_node: fuel_graph::NodeId,
756    rope_sin_node: fuel_graph::NodeId,
757    mask_node: fuel_graph::NodeId,
758    offset_node: Option<fuel_graph::NodeId>,
759    first_token: DecodeTokenData,
760}
761
762/// Build one decode-step graph: embed → RoPE tables → stacked mask → per-layer
763/// KV-write attention → final norm → logits.
764///
765/// The only thing `consts` changes inside this body is how the four per-token
766/// data Consts are minted. Everything else — including the KV placeholder
767/// binding, which both paths do identically — is shared.
768fn build_decode_graph<M: DecodeBackbone + ?Sized>(
769    model: &M,
770    consts: DataConsts,
771    tokens: &[u32],
772    cache: &KvCache,
773    ctx: &mut InferenceContext,
774    return_all_positions: bool,
775    rope_inv_freq: Option<&[f64]>,
776) -> Result<BuiltDecodeGraph> {
777    let dims = model.decode_dims();
778    let plan = model.decode_mask_plan();
779    let family = model.decode_family();
780    let entry = consts.entry();
781    let seq = tokens.len();
782    let batch = 1;
783    let cached_len = cache.cached_len;
784
785    if seq == 0 {
786        return Err(fuel_ir::Error::Msg(format!("{family}::{entry}: zero tokens",)).bt());
787    }
788    if cache.n_layers() != dims.n_layers {
789        return Err(fuel_ir::Error::Msg(format!(
790            "{family}::{entry}: cache n_layers {} != model n_layers {}",
791            cache.n_layers(),
792            dims.n_layers,
793        ))
794        .bt());
795    }
796    if plan.n_layers() != dims.n_layers {
797        return Err(fuel_ir::Error::Msg(format!(
798            "{family}::{entry}: mask plan covers {} layer(s) but the model has {}",
799            plan.n_layers(),
800            dims.n_layers,
801        ))
802        .bt());
803    }
804    let max_seq_len = cache.max_seq_len.ok_or_else(|| {
805        fuel_ir::Error::Msg(format!(
806            "{family}::{entry}: cache was constructed via with_dims (no \
807             pre-allocated buffers); call KvCache::with_capacity(...) for the \
808             WriteSlice path",
809        ))
810        .bt()
811    })?;
812    if cached_len + seq > max_seq_len {
813        return Err(fuel_ir::Error::Msg(format!(
814            "{family}::{entry}: cached_len ({cached_len}) + seq ({seq}) > \
815             max_seq_len ({max_seq_len})",
816        ))
817        .bt());
818    }
819    let cache_dtype = cache.dtype.unwrap_or(DType::F32);
820    // Behaviour preserved deliberately: the pre-GAP-029 D2 path did NOT check
821    // cache-vs-config KV geometry and D1 did. Making D2 stricter here would be
822    // a semantic change smuggled into a behaviour-preserving refactor, which
823    // blunts the only oracle this refactor has (the pre-existing suites plus
824    // the captured golden). Tracked as a follow-up, not fixed in passing.
825    if consts == DataConsts::Baked
826        && (cache.n_kv_heads != dims.n_kv_heads || cache.head_dim != dims.head_dim)
827    {
828        return Err(fuel_ir::Error::Msg(format!(
829            "{family}::{entry}: cache shape (n_kv_heads={}, head_dim={}) \
830             disagrees with model config (n_kv_heads={}, head_dim={})",
831            cache.n_kv_heads, cache.head_dim, dims.n_kv_heads, dims.head_dim,
832        ))
833        .bt());
834    }
835
836    let host = compute_decode_token_host(model, cached_len, tokens, max_seq_len, rope_inv_freq);
837
838    // ---- Embed lookup + reshape to [batch, seq, hidden] ----
839    let embed = Tensor::from_f32(
840        model.decode_token_embedding(),
841        Shape::from_dims(&[dims.vocab, dims.hidden]),
842        &Device::cpu(),
843    )?;
844    let token_ids = match consts {
845        DataConsts::Baked => {
846            embed.const_u32_like(host.token_ids.clone(), Shape::from_dims(&[seq]))?
847        }
848        DataConsts::Rebindable => {
849            embed.const_placeholder_like(Shape::from_dims(&[seq]), DType::U32)
850        }
851    };
852    let token_ids_node = token_ids.inner.id();
853    let mut h = embed
854        .index_select(0, &token_ids)?
855        .reshape(Shape::from_dims(&[batch, seq, dims.hidden]))?;
856    // Embedding scale (Gemma family: sqrt(hidden_size)) — applied HERE, before
857    // the dtype cast, because prefill scales in f32 and scaling after the cast
858    // would round in bf16 on a bf16 cache. `None` emits no node at all.
859    if let Some(scale) = dims.embed_scale {
860        h = h.mul_scalar(scale);
861    }
862    // BF16-throughout decode (Phase D increment A): the embedding table stays
863    // f32 (CUDA IndexSelect has no BF16 key), but every activation downstream
864    // tracks the cache dtype. No-op for f32 caches.
865    h = h.to_dtype(cache_dtype)?;
866
867    // ---- RoPE cos/sin tables ----
868    // Single-variant families mint `[seq, rope_width]` exactly as before — no
869    // stack, no slice, no reshape — so their graphs are unchanged.
870    let rope_plan = model.decode_rope_plan();
871    if rope_plan.n_layers() != dims.n_layers {
872        return Err(fuel_ir::Error::Msg(format!(
873            "{family}::{entry}: RoPE plan covers {} layer(s) but the model has {}",
874            rope_plan.n_layers(),
875            dims.n_layers,
876        ))
877        .bt());
878    }
879    // A caller-supplied inv_freq replaces the base for every variant, so a
880    // multi-base family cannot express per-variant scaling. Refused rather than
881    // silently applying one variant's frequencies to all of them.
882    if rope_inv_freq.is_some() && rope_plan.n_variants() > 1 {
883        return Err(fuel_ir::Error::Msg(format!(
884            "{family}::{entry}: rope_inv_freq override is single-base only, but this \
885             model declares {} RoPE variants; per-variant scaled frequencies are not \
886             expressible yet",
887            rope_plan.n_variants(),
888        ))
889        .bt());
890    }
891    let n_rope = rope_plan.n_variants();
892    let rope_shape = if n_rope == 1 {
893        Shape::from_dims(&[seq, dims.rope_width])
894    } else {
895        Shape::from_dims(&[n_rope, seq, dims.rope_width])
896    };
897    let (rope_cos, rope_sin) = match consts {
898        DataConsts::Baked => (
899            h.const_f32_like(host.rope_cos.clone(), rope_shape.clone())?,
900            h.const_f32_like(host.rope_sin.clone(), rope_shape)?,
901        ),
902        DataConsts::Rebindable => (
903            h.const_placeholder_like(rope_shape.clone(), DType::F32),
904            h.const_placeholder_like(rope_shape, DType::F32),
905        ),
906    };
907    let rope_cos_node = rope_cos.inner.id();
908    let rope_sin_node = rope_sin.inner.id();
909    // Hoist the per-variant views ONCE, before the layer loop. The rank-2 table
910    // needs a reshape after the slice; the uniform case takes the Const itself
911    // and emits neither.
912    let rope_2d = Shape::from_dims(&[seq, dims.rope_width]);
913    let (rope_cos_v, rope_sin_v): (Vec<Tensor>, Vec<Tensor>) = if n_rope == 1 {
914        (vec![rope_cos], vec![rope_sin])
915    } else {
916        let mut cs = Vec::with_capacity(n_rope);
917        let mut ss = Vec::with_capacity(n_rope);
918        for v in 0..n_rope {
919            cs.push(rope_cos.slice(0, v, 1)?.reshape(rope_2d.clone())?);
920            ss.push(rope_sin.slice(0, v, 1)?.reshape(rope_2d.clone())?);
921        }
922        (cs, ss)
923    };
924
925    // ---- Stacked causal mask: [n_variants, 1, seq, max_seq_len] ----
926    // Dtype tracks the activation dtype it is broadcast-added onto.
927    let n_variants = plan.n_variants();
928    let mask_shape = Shape::from_dims(&[n_variants, 1, seq, max_seq_len]);
929    let mask = match consts {
930        DataConsts::Baked => h.const_like_dtype(&host.mask, mask_shape, cache_dtype)?,
931        DataConsts::Rebindable => h.const_placeholder_like(mask_shape, cache_dtype),
932    };
933    let mask_node = mask.inner.id();
934    // Hoist the per-variant width-1 slices ONCE, before the layer loop. The
935    // uniform case takes the Const itself: no slice node is emitted at all, so
936    // a non-varying family's graph is exactly what it was pre-GAP-029.
937    let masks: Vec<Tensor> = if n_variants == 1 {
938        vec![mask]
939    } else {
940        (0..n_variants)
941            .map(|v| mask.slice(0, v, 1))
942            .collect::<std::result::Result<Vec<_>, _>>()?
943    };
944
945    // ---- KV-write offset carrier ----
946    // D1 keeps the backend-generic SymEnv `Op::WriteSlice` offset (no capture to
947    // serve). D2 uses the device-resident `Op::WriteSliceDoff` offset where the
948    // binding exists (CPU/CUDA) so the step is CUDA-graph-capturable, and falls
949    // back to SymEnv on Vulkan. The two produce bit-identical KV writes.
950    let use_device_offset =
951        consts == DataConsts::Rebindable && (ctx.device().is_cpu() || ctx.device().is_cuda());
952    let offset_tensor = if use_device_offset {
953        Some(h.const_placeholder_like(Shape::from_dims(&[]), DType::I64))
954    } else {
955        None
956    };
957    let offset_node = offset_tensor.as_ref().map(|t| t.inner.id());
958
959    let cached_len_sym = fuel_ir::SymId(0);
960    // The live attended-prefix length (`cached_len + seq`) — the CUDA flash
961    // decode arm's `k_len`. Bound alongside `cached_len_sym` every pass; inert
962    // on an f32 decode graph, load-bearing where the flash arm is offered.
963    let attended_len_sym = fuel_ir::SymId(1);
964
965    // ---- Per-layer: bind the cache K + V Arcs, run the family's block ----
966    let cache_shape = Shape::from_dims(&[batch, dims.n_kv_heads, max_seq_len, dims.head_dim]);
967    let mut kv_nodes: Vec<(fuel_graph::NodeId, fuel_graph::NodeId)> =
968        Vec::with_capacity(dims.n_layers);
969    for li in 0..dims.n_layers {
970        let k_arc = cache.slot_storage(li, KvSlot::K).ok_or_else(|| {
971            fuel_ir::Error::Msg(format!(
972                "{family}::{entry}: cache layer {li} has no K slot \
973                 (with_capacity should have populated all layers)",
974            ))
975            .bt()
976        })?;
977        let v_arc = cache.slot_storage(li, KvSlot::V).ok_or_else(|| {
978            fuel_ir::Error::Msg(format!("{family}::{entry}: cache layer {li} has no V slot",)).bt()
979        })?;
980        let k_cache_node = h.const_placeholder_like(cache_shape.clone(), cache_dtype);
981        let v_cache_node = h.const_placeholder_like(cache_shape.clone(), cache_dtype);
982        let k_id = k_cache_node.inner.id();
983        let v_id = v_cache_node.inner.id();
984        ctx.insert(k_id, k_arc);
985        ctx.insert(v_id, v_arc);
986        kv_nodes.push((k_id, v_id));
987
988        h = model.decode_apply_layer(
989            li,
990            &DecodeLayerInputs {
991                x: &h,
992                k_cache: &k_cache_node,
993                v_cache: &v_cache_node,
994                cached_len_sym,
995                attended_len_sym,
996                offset: offset_tensor.as_ref(),
997                rope_cos: &rope_cos_v[rope_plan.variant_for_layer(li)],
998                rope_sin: &rope_sin_v[rope_plan.variant_for_layer(li)],
999                mask: &masks[plan.variant_for_layer(li)],
1000                attn_window: plan.window_for_layer(li),
1001            },
1002        )?;
1003    }
1004
1005    // ---- Final norm + LM head ----
1006    let logits = model.decode_final_norm_and_head(&h)?;
1007    let logits_root = if return_all_positions {
1008        logits.reshape(Shape::from_dims(&[seq * dims.vocab]))?
1009    } else {
1010        let last_pos = seq - 1;
1011        logits
1012            .slice(1, last_pos, 1)?
1013            .reshape(Shape::from_dims(&[dims.vocab]))?
1014    };
1015    // `realize_one_as_with_env::<f32>` reinterprets the root's raw bytes as
1016    // `[f32]`; a BF16 root would be UB (half the byte width, silently wrong
1017    // data). No-op under f32 caches.
1018    let logits_root = logits_root.to_dtype(DType::F32)?;
1019
1020    let rebindable = match consts {
1021        DataConsts::Baked => None,
1022        DataConsts::Rebindable => Some(RebindableNodes {
1023            token_ids_node,
1024            rope_cos_node,
1025            rope_sin_node,
1026            mask_node,
1027            offset_node,
1028            first_token: upload_decode_token_data(
1029                ctx.device(),
1030                &host,
1031                cache_dtype,
1032                use_device_offset.then_some(cached_len),
1033            )?,
1034        }),
1035    };
1036
1037    Ok(BuiltDecodeGraph {
1038        logits_root,
1039        kv_nodes,
1040        rebindable,
1041        cached_len_sym,
1042        attended_len_sym,
1043    })
1044}
1045
1046/// Upload one step's host data to device-resident Arcs — the same upload path
1047/// `KvCache::with_capacity` uses. On CPU the Storage wraps the host bytes; on
1048/// GPU it performs the (tiny) H2D copy.
1049pub fn upload_decode_token_data(
1050    device: &Device,
1051    host: &DecodeTokenHost,
1052    cache_dtype: DType,
1053    device_offset: Option<usize>,
1054) -> Result<DecodeTokenData> {
1055    let _t = fuel_dispatch::compiled::op_profile::PhaseTimer::start("decode.token_upload");
1056    let upload = crate::pipelined_bridge::upload_host_buffer_to_device;
1057    let mask = match cache_dtype {
1058        DType::F32 => fuel_ir::HostBuffer::F32(host.mask.clone()),
1059        DType::BF16 => {
1060            fuel_ir::HostBuffer::BF16(host.mask.iter().map(|&v| half::bf16::from_f32(v)).collect())
1061        }
1062        other => {
1063            return Err(fuel_ir::Error::Msg(format!(
1064                "decode token data: unsupported cache dtype {other:?} (expected F32 or BF16)",
1065            ))
1066            .bt());
1067        }
1068    };
1069    Ok(DecodeTokenData {
1070        token_ids: upload(device, fuel_ir::HostBuffer::U32(host.token_ids.clone()))?,
1071        rope_cos: upload(device, fuel_ir::HostBuffer::F32(host.rope_cos.clone()))?,
1072        rope_sin: upload(device, fuel_ir::HostBuffer::F32(host.rope_sin.clone()))?,
1073        mask: upload(device, mask)?,
1074        offset: device_offset
1075            .map(|o| upload(device, fuel_ir::HostBuffer::I64(vec![o as i64])))
1076            .transpose()?,
1077    })
1078}
1079
1080/// **D1** — build the decode graph fresh, realize it, discard it.
1081///
1082/// This is the primitive the persistent path itself falls back to (`seq != 1`,
1083/// invalidation, `TopologyChanged`), so its rebuild contract is deliberate.
1084pub fn forward_with_kv_context<M: DecodeBackbone + ?Sized>(
1085    model: &M,
1086    tokens: &[u32],
1087    cache: &mut KvCache,
1088    ctx: &mut InferenceContext,
1089    return_all_positions: bool,
1090    rope_inv_freq: Option<&[f64]>,
1091) -> Result<Vec<f32>> {
1092    let seq = tokens.len();
1093    let built = build_decode_graph(
1094        model,
1095        DataConsts::Baked,
1096        tokens,
1097        &*cache,
1098        ctx,
1099        return_all_positions,
1100        rope_inv_freq,
1101    )?;
1102    let cached_len = cache.cached_len;
1103
1104    // Planner Stage 4a: populate the plan store for this graph before realizing,
1105    // so realize's planning half HITs the store. Advisory by design — a warm
1106    // failure is discarded because the realize below runs the identical planning
1107    // path and surfaces any genuine error with full realize context.
1108    let _ = crate::planner::Planner::warm(
1109        built.logits_root.inner.graph(),
1110        &[built.logits_root.inner.id()],
1111        ctx.device(),
1112    );
1113
1114    let mut sym_env = fuel_ir::SymEnv::new();
1115    sym_env.bind(built.cached_len_sym, cached_len)?;
1116    sym_env.bind(built.attended_len_sym, cached_len + seq)?;
1117    let logits_vec = ctx.realize_one_as_with_env::<f32>(
1118        built.logits_root.inner.graph(),
1119        built.logits_root.inner.id(),
1120        &sym_env,
1121    )?;
1122
1123    // Per-step bindings reference a graph that dies with this call; leaving them
1124    // in ctx.persistent would leak across decode steps.
1125    for (k, v) in &built.kv_nodes {
1126        ctx.remove(*k);
1127        ctx.remove(*v);
1128    }
1129
1130    advance_cache(cache, seq, model.decode_n_layers());
1131    Ok(logits_vec)
1132}
1133
1134/// **D2 build** — mint the held graph with re-bindable data Consts, optimize it
1135/// ONCE, populate `session`, and return the first token's logits.
1136pub fn build_and_realize_first_decode_token<M: DecodeBackbone + ?Sized>(
1137    model: &M,
1138    tokens: &[u32],
1139    cache: &mut KvCache,
1140    ctx: &mut InferenceContext,
1141    session: &mut Option<DecodeSession>,
1142    rope_inv_freq: Option<&[f64]>,
1143) -> Result<Vec<f32>> {
1144    let seq = tokens.len();
1145    let cached_len = cache.cached_len;
1146    let cache_dtype = cache.dtype.unwrap_or(DType::F32);
1147    let dims = model.decode_dims();
1148    let built = build_decode_graph(
1149        model,
1150        DataConsts::Rebindable,
1151        tokens,
1152        &*cache,
1153        ctx,
1154        false,
1155        rope_inv_freq,
1156    )?;
1157    let max_seq_len = cache.max_seq_len.expect("checked in build_decode_graph");
1158    let nodes = built
1159        .rebindable
1160        .expect("Rebindable mode yields rebindable nodes");
1161    let logits_node = built.logits_root.inner.id();
1162    let graph = built.logits_root.inner.graph().clone();
1163
1164    // Bind the per-token DATA into ctx so the FIRST realize's const-cache walk
1165    // resolves them (they are placeholders, absent from graph.storage_map). The
1166    // KV Arcs went in during the build.
1167    ctx.insert(
1168        nodes.token_ids_node,
1169        Arc::clone(&nodes.first_token.token_ids),
1170    );
1171    ctx.insert(nodes.rope_cos_node, Arc::clone(&nodes.first_token.rope_cos));
1172    ctx.insert(nodes.rope_sin_node, Arc::clone(&nodes.first_token.rope_sin));
1173    ctx.insert(nodes.mask_node, Arc::clone(&nodes.first_token.mask));
1174    if let (Some(off_node), Some(off_arc)) = (nodes.offset_node, nodes.first_token.offset.as_ref())
1175    {
1176        ctx.insert(off_node, Arc::clone(off_arc));
1177    }
1178
1179    let mut sym_env = fuel_ir::SymEnv::new();
1180    sym_env.bind(built.cached_len_sym, cached_len)?;
1181    sym_env.bind(built.attended_len_sym, cached_len + seq)?;
1182
1183    let (effective_target, optimized, base_cache, logits_vec) =
1184        ctx.prebuild_optimized_capturing_as_with_env::<f32>(&graph, logits_node, &sym_env)?;
1185
1186    // The held session owns the graph + base_cache now; drop the transient ctx
1187    // bindings (they live in base_cache from here on, re-bound per token into a
1188    // clone of it, not into ctx).
1189    ctx.remove(nodes.token_ids_node);
1190    ctx.remove(nodes.rope_cos_node);
1191    ctx.remove(nodes.rope_sin_node);
1192    ctx.remove(nodes.mask_node);
1193    if let Some(off_node) = nodes.offset_node {
1194        ctx.remove(off_node);
1195    }
1196    for (k, v) in &built.kv_nodes {
1197        ctx.remove(*k);
1198        ctx.remove(*v);
1199    }
1200
1201    *session = Some(DecodeSession::new(
1202        graph,
1203        optimized,
1204        effective_target,
1205        logits_node,
1206        nodes.token_ids_node,
1207        nodes.rope_cos_node,
1208        nodes.rope_sin_node,
1209        nodes.mask_node,
1210        built.kv_nodes,
1211        nodes.offset_node,
1212        built.cached_len_sym,
1213        built.attended_len_sym,
1214        base_cache,
1215        seq,
1216        max_seq_len,
1217        dims.n_layers,
1218        cache_dtype,
1219        model.decode_shape_key(),
1220        // Which ALLOCATION's KV Arcs are baked into `base_cache`, and where they
1221        // live — both read from the one source (GAP-028).
1222        cache,
1223    ));
1224
1225    advance_cache(cache, seq, dims.n_layers);
1226    Ok(logits_vec)
1227}
1228
1229/// Drop a held session, releasing its stable node bindings from `ctx`.
1230pub fn drop_decode_session(session: &mut Option<DecodeSession>, ctx: &mut InferenceContext) {
1231    if let Some(s) = session.take() {
1232        ctx.remove(s.token_ids_node());
1233        ctx.remove(s.rope_cos_node());
1234        ctx.remove(s.rope_sin_node());
1235        ctx.remove(s.mask_node());
1236        for (k, v) in s.kv_nodes() {
1237            ctx.remove(*k);
1238            ctx.remove(*v);
1239        }
1240    }
1241}
1242
1243/// **The persistent decode entry point**, shared by every family on this path.
1244///
1245/// Three arms, exactly as the pre-GAP-029 `LlamaModel` sibling had them:
1246/// `seq != 1` falls back to D1 (prefill / spec-decode verification), a missing
1247/// or stale session takes the D2 build, and a live session takes the rebind
1248/// driver — with a `TopologyChanged` dropping the session and serving this one
1249/// token through D1.
1250pub fn forward_with_kv_context_persistent<M: DecodeBackbone + ?Sized>(
1251    model: &M,
1252    tokens: &[u32],
1253    cache: &mut KvCache,
1254    ctx: &mut InferenceContext,
1255    session: &mut Option<DecodeSession>,
1256    rope_inv_freq: Option<&[f64]>,
1257) -> Result<Vec<f32>> {
1258    let seq = tokens.len();
1259    let max_seq_len = cache.max_seq_len;
1260    let cache_dtype = cache.dtype.unwrap_or(DType::F32);
1261    let n_layers = model.decode_dims().n_layers;
1262
1263    // A non-`seq == 1` step is shape-distinct from the held decode graph — drop
1264    // any session and rebuild it on the next decode token.
1265    if seq != 1 {
1266        drop_decode_session(session, ctx);
1267        return forward_with_kv_context(model, tokens, cache, ctx, false, rope_inv_freq);
1268    }
1269
1270    crate::lazy::refresh_decode_session(
1271        session,
1272        ctx,
1273        seq,
1274        max_seq_len,
1275        cache_dtype,
1276        n_layers,
1277        model.decode_shape_key(),
1278        cache,
1279        || {},
1280        drop_decode_session,
1281    );
1282
1283    match session.as_ref() {
1284        None => {
1285            build_and_realize_first_decode_token(model, tokens, cache, ctx, session, rope_inv_freq)
1286        }
1287        Some(_) => {
1288            match rebind_and_realize_prebuilt(model, tokens, cache, &*ctx, &*session, rope_inv_freq)
1289            {
1290                Ok(logits) => Ok(logits),
1291                Err(crate::Error::TopologyChanged { .. }) => {
1292                    // Stale cached generation — drop the session and serve this
1293                    // token through D1; the session rebuilds on the next one.
1294                    drop_decode_session(session, ctx);
1295                    forward_with_kv_context(model, tokens, cache, ctx, false, rope_inv_freq)
1296                }
1297                Err(e) => Err(e),
1298            }
1299        }
1300    }
1301}
1302
1303/// Bump `cached_len` and every layer's K/V version after a successful step.
1304fn advance_cache(cache: &mut KvCache, seq: usize, n_layers: usize) {
1305    cache.cached_len += seq;
1306    for li in 0..n_layers {
1307        cache.bump_version(li, KvSlot::K);
1308        cache.bump_version(li, KvSlot::V);
1309    }
1310}
1311
1312// ===========================================================================
1313// SABOTAGE RECORD — THE SHARED BUILD PATH (GAP-029 increment 3, 2026-08-13)
1314//
1315// Two sabotages, because the two risks are different questions and one
1316// sabotage cannot answer both. Both runs carried `Compiling fuel-core`.
1317//
1318// ---------------------------------------------------------------------------
1319// A. SHARED: swap `rope_cos`/`rope_sin` in `build_decode_graph`'s layer loop.
1320//    Question: do BOTH families actually route through this body, or does one
1321//    still reach a private copy the compiler happily kept?
1322//
1323//      llama : forward_with_kv_context_prefill_matches_non_cached_forward FAILED
1324//              llama_decode_logits_unchanged_by_the_shared_build_path      FAILED
1325//      llama3: scaled_persistent_decode_matches_full_prefix_forward        FAILED
1326//      qwen2 : qwen2_windowed_decode_matches_per_layer_gated_forward       FAILED
1327//              qwen2_decode_matches_forward_when_no_layer_is_windowed      FAILED
1328//              qwen2_windowed_multi_token_prefill_..._matches_forward      FAILED
1329//      test result: FAILED. 41 passed; 6 failed
1330//
1331//    Three carriers red — Llama, Llama3 through its delegation, and Qwen2 — so
1332//    all three demonstrably execute this code. The 41 that stayed green are what
1333//    make the 6 mean anything.
1334//
1335//    ⚠️ AND THE PASSES ARE THE MORE INTERESTING HALF. Three things this run
1336//    established that a pass/fail count would have hidden:
1337//
1338//    (1) `phi_*` and `lazy_deepseek2::*` ALL STAYED GREEN. That is the designed
1339//        outcome, not luck: Phi keeps its own build body (it is the outlier, not
1340//        the template) and DeepSeek2 has its own MLA path. Sabotage A doubles as
1341//        the negative control for "increment 3 did not quietly conscript Phi".
1342//
1343//    (2) `forward_with_kv_context_persistent_plan_once_matches_d1` PASSED under
1344//        a sabotage that corrupts every logit it looks at — because it compares
1345//        D2 against D1, and both were sabotaged identically. A relative oracle is
1346//        STRUCTURALLY BLIND to a defect in shared code. It is a good test of the
1347//        thing it tests and it cannot certify this refactor.
1348//
1349//    (3) `forward_with_kv_context_decode_matches_non_cached_forward` PASSED too,
1350//        and this one is an ABSOLUTE oracle that should have caught it. Its
1351//        tolerance is `diff < 5e-3 || rel < 1e-2` — loose enough that swapping
1352//        RoPE's cos and sin does not move a tiny model past it. That is the same
1353//        weakness GAP-029 recorded from the other side (a real 7.9e-3 masking
1354//        divergence sitting under the same bound), demonstrated here on a defect
1355//        nobody could call marginal.
1356//
1357//    (2) and (3) together are why the 1e-6 golden was captured BEFORE the
1358//    refactor rather than trusting the existing suite: of the decode tests that
1359//    look like they cover this body, the ones that failed did so at prefill
1360//    width, and the decode-shaped ones did not fail at all.
1361//
1362// ---------------------------------------------------------------------------
1363// B. PER-FAMILY: drop Qwen2's Q bias in `Qwen2Model::apply_layer_with_kv_writes`.
1364//    Question: does Qwen2's test suite test QWEN2, or is it testing the shared
1365//    path under a family-shaped name? With seven families riding one body, a
1366//    family's test passing "because the driver works" is the live hazard.
1367//
1368//      qwen2 : all three decode tests                                     FAILED
1369//      test result: FAILED. 44 passed; 3 failed
1370//
1371//    EXACTLY ONE family red; Llama, Llama3, Phi and DeepSeek2 untouched. A
1372//    sabotage of a family's own code that reddened everything would have been
1373//    worth much less.
1374// ===========================================================================
1375
1376#[cfg(test)]
1377mod mask_plan_tests {
1378    use super::*;
1379
1380    /// **The "uniform families pay literally nothing" claim, asserted rather
1381    /// than stated.** Every non-varying family — Llama included — routes its
1382    /// mask through the stacked builder now. If that were not byte-identical to
1383    /// the dense builder it replaced, every existing decode carrier would have
1384    /// silently changed numerics under a refactor advertised as
1385    /// behaviour-preserving.
1386    #[test]
1387    fn mask_variants_match_the_dense_builder_when_uniform() {
1388        for (cached_len, seq, max_seq_len, n_layers) in
1389            [(0, 1, 8, 2), (3, 1, 8, 4), (0, 5, 8, 1), (2, 3, 16, 3)]
1390        {
1391            let plan = MaskPlan::dense(n_layers);
1392            assert_eq!(
1393                plan.n_variants(),
1394                1,
1395                "the dense plan must be single-variant"
1396            );
1397            assert_eq!(
1398                build_decode_mask_variants(&plan, cached_len, seq, max_seq_len),
1399                crate::lazy::build_decode_causal_mask(cached_len, seq, max_seq_len),
1400                "uniform plan must be byte-identical to the dense builder \
1401                 (cached_len={cached_len}, seq={seq}, max_seq_len={max_seq_len})",
1402            );
1403        }
1404    }
1405
1406    /// The stacked layout is what the build path slices, so its size and the
1407    /// ordering of variants are load-bearing, not incidental.
1408    #[test]
1409    fn variants_stack_on_the_leading_axis_in_plan_order() {
1410        let plan = MaskPlan::new(vec![None, Some(2)], vec![0, 1, 1]).expect("valid plan");
1411        let (cached_len, seq, max_seq_len) = (3, 1, 8);
1412        let stacked = build_decode_mask_variants(&plan, cached_len, seq, max_seq_len);
1413        assert_eq!(stacked.len(), 2 * seq * max_seq_len);
1414        assert_eq!(
1415            &stacked[..seq * max_seq_len],
1416            &crate::lazy::build_decode_causal_mask(cached_len, seq, max_seq_len)[..],
1417            "variant 0 is the dense mask and must come first",
1418        );
1419    }
1420
1421    /// **Both degenerate splits must collapse to ONE variant**, or a family that
1422    /// windows every layer (or none) silently pays for a slice node and an
1423    /// N-wide mask Const it cannot use.
1424    ///
1425    /// The `split >= n_layers` arm is the one with no family on it yet and is
1426    /// tested for that reason: SmolLm3's mask is uniform-windowed
1427    /// (`sliding_window` with no `max_window_layers` gate), so it will land here
1428    /// rather than on the two-variant path.
1429    #[test]
1430    fn both_degenerate_splits_collapse_to_a_single_variant() {
1431        let all_dense = MaskPlan::split_window(4, 0, 8);
1432        assert_eq!(all_dense.n_variants(), 1, "split 0 must be the dense plan");
1433        assert_eq!(all_dense, MaskPlan::dense(4), "and identical to it");
1434
1435        for split in [4, 9] {
1436            let all_windowed = MaskPlan::split_window(4, split, 8);
1437            assert_eq!(
1438                all_windowed.n_variants(),
1439                1,
1440                "split {split} covers every layer — one windowed variant, no slice",
1441            );
1442            for li in 0..4 {
1443                assert_eq!(all_windowed.variant_for_layer(li), 0);
1444            }
1445            // ...and it must be the WINDOWED variant, not silently the dense one.
1446            //
1447            // `cached_len` must exceed `window` or the two masks coincide
1448            // legitimately and this assertion is vacuous — the same trap
1449            // `window_wider_than_capacity_is_byte_identical_to_the_dense_mask`
1450            // records. (Written first with `cached_len = 5` against a window of
1451            // 8, which is exactly that mistake; the assertion caught it.)
1452            let (cached_len, seq, max_seq_len, window) = (10, 1, 12, 8);
1453            assert!(
1454                cached_len > window,
1455                "non-vacuity: the window must bite here"
1456            );
1457            assert_ne!(
1458                build_decode_mask_variants(
1459                    &MaskPlan::split_window(4, split, window),
1460                    cached_len,
1461                    seq,
1462                    max_seq_len,
1463                ),
1464                crate::lazy::build_decode_causal_mask(cached_len, seq, max_seq_len),
1465                "collapsing to one variant must not collapse to the DENSE one",
1466            );
1467        }
1468
1469        // A genuinely mixed split keeps both.
1470        assert_eq!(MaskPlan::split_window(4, 2, 8).n_variants(), 2);
1471    }
1472
1473    /// Build-time validation, per the no-`try_*`/validate-early rule: an
1474    /// out-of-range variant index would otherwise land as a panic deep inside
1475    /// the layer loop — or, worse, as a silently mis-masked layer.
1476    #[test]
1477    fn a_layer_selecting_an_undefined_variant_is_refused_at_construction() {
1478        let err = MaskPlan::new(vec![None], vec![0, 1]).expect_err("must refuse");
1479        let msg = err.to_string();
1480        assert!(
1481            msg.contains("layer 1") && msg.contains("variant 1"),
1482            "the refusal must name the offending layer and variant, got: {msg}",
1483        );
1484        assert!(
1485            MaskPlan::new(vec![], vec![]).is_err(),
1486            "a plan needs a variant"
1487        );
1488    }
1489}