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}