Skip to main content

fuel_tensor/
inference_context.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2//! Persistent inference state for the pipelined executor (Phase 7.6
3//! step 9c, sub-phase E.3.0).
4//!
5//! ## What this module owns
6//!
7//! - **[`InferenceContext`]**: a host-side per-session struct holding
8//!   long-lived `Arc<RwLock<Storage>>` references that survive across
9//!   realize calls. Replaces the legacy executor's `const_pool`
10//!   for the pipelined dispatch world. Today's backing is a simple
11//!   in-memory `HashMap<NodeId, Arc<...>>`; future work may swap this
12//!   for an mmap-backed coherent store per
13//!   `project_unified_durable_tensor_store.md`.
14//!
15//! - **[`KvCache`]**, **[`KvLayer`]**, **[`KvLayerId`]**, **[`KvSlot`]**,
16//!   **[`AuthorityState`]**: the backend-erased KV cache primitive that
17//!   replaced `lazy::KVCache<B>` and `lazy::LlamaKVCache` (both
18//!   retired — E.3.3.D and Unification Session 4 E.3.4). Single
19//!   concrete type with no generic-over-B parameter; each layer holds
20//!   `Arc<RwLock<Storage>>` for K and V plus side-table metadata
21//!   (layout, monotonic version, coherence authority).
22//!
23//! ## What this module deliberately does NOT do (yet)
24//!
25//! - **Pre-allocated buffers** (added Phase E.3.3.A, 2026-05-20):
26//!   [`KvCache::with_capacity`] eagerly allocates `[1, n_kv_heads,
27//!   max_seq_len, head_dim]` zero-initialized K + V buffers on a
28//!   target device. Subsequent forward steps write into these via
29//!   `Op::WriteSlice` rather than growing-by-replacement. The legacy
30//!   [`KvCache::with_dims`] constructor stays available for callers
31//!   not yet on the pre-allocated path (grow-by-replace works without
32//!   changes).
33//! - **Multi-device coherence protocol**: the `authority` and
34//!   `version` fields exist as placeholders. No protocol consults
35//!   them yet — every layer starts and stays `AuthorityState::Host`
36//!   for the lifetime of the session in single-device usage.
37//!   Phase J (multi-GPU) activates the protocol.
38//! - **Weight persistence**: weights stay in the graph's storage_map
39//!   per the design discussion; the persistent map handles KV layers
40//!   and transient cross-step state only.
41
42use std::collections::HashMap;
43use std::sync::atomic::{AtomicUsize, Ordering};
44use std::sync::{Arc, RwLock};
45
46use fuel_dispatch::{
47    optimize::OptimizedGraph,
48    pipelined::{PipelinedExecutor, StorageCache},
49};
50use fuel_graph::{Graph, Node, NodeId, Op};
51use fuel_ir::{DType, DeviceLocation, Error, Layout, Result, SType, Shape, SymEnv, SymId};
52use fuel_memory::{BackendStorage, Storage};
53
54use crate::Device;
55use crate::decode_shape::KvAllocId;
56
57// ===========================================================================
58// KV cache primitive types
59// ===========================================================================
60
61/// Identifies a single key-or-value tensor within a [`KvCache`].
62/// Stable across realize calls — the graph-level NodeId may churn
63/// per step (each `Op::WriteSlice` produces a fresh NodeId pointing
64/// at the same Storage Arc) but the logical KV slot identity stays.
65#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
66pub struct KvLayerId {
67    pub layer_idx: usize,
68    pub slot: KvSlot,
69}
70
71/// Which half of a KV pair a [`KvLayerId`] addresses.
72#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
73pub enum KvSlot {
74    K,
75    V,
76}
77
78/// Coherence authority for one KV slot's storage. Placeholder for the
79/// multi-device protocol; today every slot starts and stays
80/// [`AuthorityState::Host`] for the lifetime of the session unless
81/// future code explicitly transitions it.
82///
83/// The intended protocol (deferred until multi-GPU lands):
84/// - **Host**: the host has the most recent version. Devices may
85///   hold stale copies. The default at session start.
86/// - **Device(loc)**: a device owns the most recent version. The
87///   host's copy may be stale until an explicit flush.
88#[derive(Debug, Clone)]
89pub enum AuthorityState {
90    Host,
91    Device(DeviceLocation),
92}
93
94/// One layer's K + V slots in a backend-erased KV cache.
95///
96/// Storage Arcs are dispatch-erased: each Arc points at a
97/// [`fuel_memory::BackendStorage`] enum variant (`Cpu`, `Cuda`,
98/// `Vulkan`, `Metal`). The graph references these Arcs via the
99/// persistent map in [`InferenceContext`]; readers see the bytes
100/// through whichever Layout the graph carries for the consuming
101/// NodeId.
102pub struct KvLayer {
103    pub k: Arc<RwLock<Storage>>,
104    pub v: Arc<RwLock<Storage>>,
105    /// View layout into `k` describing the live extent. For the grow-
106    /// by-replacement pattern (today) the layout matches the storage's
107    /// full shape. For pre-allocated buffers (Phase E.3.2) the layout
108    /// describes the leading `[..., cached_len, ...]` slice.
109    pub k_layout: Layout,
110    pub v_layout: Layout,
111    /// Monotonic write version for K. Bumps on every successful
112    /// `Op::WriteSlice` targeting this slot. Used by the future
113    /// multi-device coherence protocol to detect staleness.
114    pub k_version: u64,
115    pub v_version: u64,
116    /// Coherence authority per slot. K and V can in principle diverge
117    /// (a future optimization that decouples their update cadence
118    /// could exploit this), though standard transformer decoding
119    /// updates both together every step.
120    pub k_authority: AuthorityState,
121    pub v_authority: AuthorityState,
122}
123
124/// Backend-erased KV cache. Replaces `lazy::KVCache<B>` and
125/// `lazy::LlamaKVCache`. Indexed by global layer index; `layers[i]`
126/// is `None` if layer `i` hasn't been populated yet.
127///
128/// Pipeline-parallel friendly: when a model is split across devices,
129/// each device's cache holds entries only for its layer range; other
130/// indices are `None`. The model code queries `cache.layer(global_i)`
131/// regardless of which device owns it; the storage Arc inside
132/// [`KvLayer`] carries the device identity via its
133/// [`fuel_memory::BackendStorage`] variant.
134pub struct KvCache {
135    pub layers: Vec<Option<KvLayer>>,
136    pub cached_len: usize,
137    pub n_kv_heads: usize,
138    pub head_dim: usize,
139    /// Identity of the *allocation* backing this cache — the thing a held
140    /// [`DecodeSession`] is welded to. Private with an [`Self::alloc_id`]
141    /// reader so it cannot be assigned by hand: every path that replaces
142    /// storage ([`Self::clear`], [`Self::set_layer`]) re-mints it, and
143    /// [`Self::truncate_to`] deliberately does not. See [`KvAllocId`].
144    alloc_id: KvAllocId,
145    /// Pre-allocated capacity along the sequence axis. `Some(n)` when
146    /// constructed via [`KvCache::with_capacity`] — every layer's K + V
147    /// storage holds `[1, n_kv_heads, n, head_dim]` of zeros and the
148    /// forward path writes into it via `Op::WriteSlice`. `None` for the
149    /// legacy [`KvCache::with_dims`] grow-by-replacement constructor.
150    pub max_seq_len: Option<usize>,
151    /// Dtype the pre-allocated buffers were allocated with. `Some(dt)`
152    /// in the [`KvCache::with_capacity`] path, `None` in the legacy
153    /// `with_dims` path (which leaves dtype to whatever the first
154    /// inserted layer specifies).
155    pub dtype: Option<DType>,
156    /// Where this cache's storage lives — `Some(loc)` only while every
157    /// buffer is one [`Self::with_capacity`] allocated on `loc`, `None`
158    /// the moment that stops being provable.
159    ///
160    /// Exists for [`DecodeSession::rebind_kv`], which splices these `Arc`s
161    /// into a graph whose placement stamps and residency `Op::Copy`s were
162    /// baked against the *previous* cache. Shape it can prove from the
163    /// storage itself; residency it cannot — `fuel_memory::Storage` exposes
164    /// a backend *variant*, and `VulkanStorage` records no device ordinal at
165    /// all, so a multi-GPU swap is invisible from the bytes. The cache is
166    /// therefore the honest carrier: it is handed a `&Device` at the one
167    /// moment residency is known for certain.
168    ///
169    /// `None` means "cannot prove", not "CPU" — and every consumer must
170    /// treat it as a refusal. [`Self::with_dims`] never had a device;
171    /// [`Self::set_layer`] installs caller-supplied storage from anywhere.
172    /// Both therefore forfeit re-bind and fall back to a plan rebuild,
173    /// which is what shipped in GAP-014 and is safe by construction.
174    device_location: Option<DeviceLocation>,
175}
176
177impl KvCache {
178    /// Legacy grow-by-replacement constructor. Layers start `None`; the
179    /// caller calls [`Self::set_layer`] each forward step with a freshly
180    /// allocated `KvLayer` whose storage is sized to the current cached
181    /// length. Subsequent steps replace the layer entirely with a
182    /// larger buffer.
183    ///
184    /// Replaced by [`Self::with_capacity`] when the caller knows the
185    /// generation's max sequence length up-front (the common case for
186    /// autoregressive decoding).
187    pub fn with_dims(n_layers: usize, n_kv_heads: usize, head_dim: usize) -> Self {
188        Self {
189            layers: (0..n_layers).map(|_| None).collect(),
190            cached_len: 0,
191            n_kv_heads,
192            head_dim,
193            max_seq_len: None,
194            dtype: None,
195            alloc_id: KvAllocId::next(),
196            // No device was named, and this constructor's whole shape is
197            // grow-by-replacement — the storage a plan baked is replaced
198            // every step anyway. Unprovable residency, and nothing lost.
199            device_location: None,
200        }
201    }
202
203    /// Pre-allocated KV cache. Every layer's K + V storage is allocated
204    /// up-front as a `[1, n_kv_heads, max_seq_len, head_dim]` zero
205    /// buffer on `device` with `dtype`. Subsequent forward steps write
206    /// fresh K/V slabs into these buffers via `Op::WriteSlice` rather
207    /// than growing-by-replacement.
208    ///
209    /// Memory footprint: `n_layers * 2 * n_kv_heads * max_seq_len *
210    /// head_dim * dtype_size` bytes. For Llama-7B at max_seq_len=4096,
211    /// bf16: 32 * 2 * 8 * 4096 * 128 * 2 ≈ 512 MiB.
212    ///
213    /// Returns `Err` if any per-layer allocation fails (e.g. CUDA OOM)
214    /// or the requested device hasn't been wired up in
215    /// [`crate::pipelined_bridge`] (Vulkan / Metal — D2H/H2D for those still
216    /// goes through the legacy executor).
217    pub fn with_capacity(
218        n_layers: usize,
219        n_kv_heads: usize,
220        head_dim: usize,
221        max_seq_len: usize,
222        dtype: DType,
223        device: &Device,
224    ) -> Result<Self> {
225        // Bridge-retirement Phase 3a (post-9c): allocate the 2N K/V
226        // buffers via `Op::Alloc` graph emission, not direct backend
227        // calls. The executor's `WorkItemKind::Alloc` arm dispatches
228        // per-backend; the per-`DeviceLocation` match that used to
229        // live in `alloc_zeroed_on` is now in the executor (the
230        // architectural dispatch layer).
231        let shape = Shape::from_dims(&[1, n_kv_heads, max_seq_len, head_dim]);
232        let layout = Layout::contiguous(shape.clone());
233        let target_loc = device.location();
234
235        // Build the transient graph. For non-CPU targets the first
236        // node is an `Op::Const` placeholder whose StorageCache entry
237        // carries a small "device anchor" storage — the executor's
238        // Alloc arm searches the cache for any storage on the target
239        // backend to derive the device handle, so this entry makes
240        // the first Op::Alloc's device lookup succeed. The anchor's
241        // NodeId must exist in the graph so `realize_many`'s
242        // `layout_cache` seeding (which calls `g.layout(id)` on every
243        // cache key) doesn't panic.
244        let graph = Arc::new(RwLock::new(Graph::new()));
245        let mut cache = StorageCache::new();
246        if let Some(seed) = crate::pipelined_bridge::device_seed_storage(device)? {
247            let anchor_id = {
248                let mut g = graph.write().map_err(|_| {
249                    Error::Msg("graph lock poisoned during KvCache build".into()).bt()
250                })?;
251                g.push(Node {
252                    op: Op::Const,
253                    inputs: vec![],
254                    shape: Shape::from_dims(&[4]),
255                    dtype: DType::U8,
256                })
257            };
258            cache.insert(anchor_id, Arc::new(RwLock::new(seed)));
259        }
260        // Emit pairs of (Op::Alloc, Op::ZeroFill) per K/V slot. The
261        // ZeroFill consumes the Alloc destructively (in-place fill,
262        // output adopts the Alloc's Storage Arc). Realizing the
263        // ZeroFill IDs produces zero-initialized device storages.
264        //
265        // Phase 3a follow-up architecture: Op::Alloc gives uninit
266        // memory; Op::ZeroFill explicitly fills with zero. On Vulkan
267        // this replaces the host-staged-zeros path with device-side
268        // `vkCmdFillBuffer` (~2× the bandwidth saved). On CUDA the
269        // pair becomes uninit `cuMemAlloc` + async `cuMemsetD8Async`
270        // — same total cost as the old `alloc_zeros` but exposes the
271        // ZeroFill as a first-class graph node the optimizer can
272        // see (and skip if a downstream op covers the full buffer).
273        let zero_fill_ids: Vec<NodeId> = {
274            let mut g = graph
275                .write()
276                .map_err(|_| Error::Msg("graph lock poisoned during KvCache build".into()).bt())?;
277            (0..(2 * n_layers))
278                .map(|_| {
279                    let alloc_id = g.push(Node {
280                        op: Op::Alloc { target: target_loc },
281                        inputs: vec![],
282                        shape: shape.clone(),
283                        dtype,
284                    });
285                    g.push(Node {
286                        op: Op::ZeroFill,
287                        inputs: vec![alloc_id],
288                        shape: shape.clone(),
289                        dtype,
290                    })
291                })
292                .collect()
293        };
294
295        // Realize all 2*n_layers Op::ZeroFill targets in one pass —
296        // PipelinedExecutor::realize_many shares the compile/execute
297        // pipeline so device-handle reuse is automatic, and the
298        // executor's destructive_input cleanup evicts the Op::Alloc
299        // intermediate NodeIds while the ZeroFill targets adopt the
300        // same Arcs (post-fill bytes).
301        let realized = PipelinedExecutor::realize_many(Arc::clone(&graph), &zero_fill_ids, cache)?;
302        if realized.len() != 2 * n_layers {
303            return Err(Error::Msg(format!(
304                "KvCache::with_capacity: realize_many returned {} storages \
305                 for {} Op::ZeroFill targets — internal bug",
306                realized.len(),
307                2 * n_layers,
308            ))
309            .bt());
310        }
311
312        // Never-panic: the length is checked above, but the production path
313        // must not `.expect()` — surface a typed `Err` if the iterator runs
314        // short. (Same pattern as `alloc_batched_kv` below.)
315        let mut realized_iter = realized.into_iter();
316        let short = |what: &str, l: usize| {
317            Error::Msg(format!(
318                "alloc_kv: realize_many yielded fewer storages than expected \
319                 (ran out at layer {l} fetching {what})"
320            ))
321            .bt()
322        };
323        let mut layers: Vec<Option<KvLayer>> = Vec::with_capacity(n_layers);
324        for l in 0..n_layers {
325            let (k_arc, _) = realized_iter.next().ok_or_else(|| short("K", l))?;
326            let (v_arc, _) = realized_iter.next().ok_or_else(|| short("V", l))?;
327            layers.push(Some(KvLayer {
328                k: k_arc,
329                v: v_arc,
330                k_layout: layout.clone(),
331                v_layout: layout.clone(),
332                k_version: 0,
333                v_version: 0,
334                k_authority: AuthorityState::Host,
335                v_authority: AuthorityState::Host,
336            }));
337        }
338
339        // Silence the unused-bind lint for elem_count when this
340        // path no longer needs the legacy alloc helper.
341        let _ = (n_kv_heads, max_seq_len, head_dim);
342
343        Ok(Self {
344            layers,
345            cached_len: 0,
346            n_kv_heads,
347            head_dim,
348            max_seq_len: Some(max_seq_len),
349            dtype: Some(dtype),
350            alloc_id: KvAllocId::next(),
351            // Every buffer above was `Op::Alloc { target: target_loc }`, so
352            // this is measured at the allocation, not inferred later.
353            device_location: Some(target_loc),
354        })
355    }
356
357    /// Borrow the K-or-V storage Arc for a given layer + slot. Used by
358    /// the forward path to bind cache storage to per-step Const nodes
359    /// via [`InferenceContext::insert`]. Returns `None` if the layer
360    /// isn't populated (with_dims path before first
361    /// [`Self::set_layer`]) or `layer_idx` is out of range.
362    pub fn slot_storage(&self, layer_idx: usize, slot: KvSlot) -> Option<Arc<RwLock<Storage>>> {
363        let layer = self.layer(layer_idx)?;
364        Some(match slot {
365            KvSlot::K => Arc::clone(&layer.k),
366            KvSlot::V => Arc::clone(&layer.v),
367        })
368    }
369
370    /// Bump a slot's monotonic version counter. Called after every
371    /// successful `Op::WriteSlice` targeting the slot — the forward
372    /// path invokes this once per (layer, slot) per step.
373    ///
374    /// The version is consumed by the future multi-device coherence
375    /// protocol (placeholder today — see [`AuthorityState`]).
376    pub fn bump_version(&mut self, layer_idx: usize, slot: KvSlot) {
377        if let Some(layer) = self.layer_mut(layer_idx) {
378            match slot {
379                KvSlot::K => layer.k_version += 1,
380                KvSlot::V => layer.v_version += 1,
381            }
382        }
383    }
384
385    pub fn n_layers(&self) -> usize {
386        self.layers.len()
387    }
388
389    pub fn layer(&self, li: usize) -> Option<&KvLayer> {
390        self.layers.get(li).and_then(|o| o.as_ref())
391    }
392
393    pub fn layer_mut(&mut self, li: usize) -> Option<&mut KvLayer> {
394        self.layers.get_mut(li).and_then(|o| o.as_mut())
395    }
396
397    /// Identity of the storage this cache currently owns — the value a held
398    /// [`DecodeSession`]'s validity key carries so a cache **swap** cannot be
399    /// mistaken for a continuation. See [`KvAllocId`] for why re-allocation
400    /// re-mints it and rewinding does not.
401    pub fn alloc_id(&self) -> KvAllocId {
402        self.alloc_id
403    }
404
405    /// Where this cache's storage lives, or `None` when that is not provable.
406    /// See the field's doc — `None` is a refusal, never a CPU default.
407    pub fn device_location(&self) -> Option<DeviceLocation> {
408        self.device_location
409    }
410
411    /// Install a layer's storage, replacing whatever was there.
412    ///
413    /// This **re-mints [`Self::alloc_id`]**: the storage `Arc` a held plan
414    /// baked into its `base_cache` for this layer is no longer the storage
415    /// this cache reads, so any plan welded to the old one must be rebuilt.
416    /// It also clears [`Self::device_location`]: the storage arrives from the
417    /// caller, so where it lives is no longer something this cache measured.
418    /// That forfeits [`DecodeSession::rebind_kv`] for the rest of this cache's
419    /// life — deliberately, because an unprovable residency spliced into a
420    /// baked plan is the GAP-014 failure one layer down.
421    pub fn set_layer(&mut self, li: usize, layer: KvLayer) {
422        self.layers[li] = Some(layer);
423        self.alloc_id = KvAllocId::next();
424        self.device_location = None;
425    }
426
427    /// Drop every layer; reset `cached_len` to zero. Use between
428    /// independent generations (the cache's K/V shapes are tied to
429    /// a specific prompt prefix).
430    ///
431    /// **Re-mints [`Self::alloc_id`]** — every storage `Arc` is released, so
432    /// this is an allocation change, not a rewind. Contrast
433    /// [`Self::truncate_to`], which moves only `cached_len` and therefore must
434    /// preserve the id.
435    pub fn clear(&mut self) {
436        for layer in &mut self.layers {
437            *layer = None;
438        }
439        self.cached_len = 0;
440        self.alloc_id = KvAllocId::next();
441        // There is no storage left to have a residency. Repopulating goes
442        // through `set_layer`, which cannot prove one either.
443        self.device_location = None;
444    }
445
446    /// Shrink the cache to `new_len` cached positions. Speculative-
447    /// decoding's reject path calls this after a draft batch is
448    /// rejected by the target model.
449    ///
450    /// **Phase E.3.0 limitation**: this only updates `cached_len`;
451    /// the underlying storages and their layouts are *not* shrunk.
452    /// The grow-by-replacement pattern (today) means the next forward
453    /// pass would still see the old layout's full extent. Phase E.3.2
454    /// pairs this with pre-allocated buffers + `Op::WriteSlice` so
455    /// truncate becomes a pure metadata update — the trailing rows
456    /// of the pre-allocated buffer simply stop being read.
457    ///
458    /// **Deliberately does NOT touch [`Self::alloc_id`].** Rewinding the
459    /// conversation is not re-allocating the buffers: a held plan is still
460    /// welded to exactly the right storage, so invalidating here would forfeit
461    /// plan reuse on every rejected draft batch and buy no correctness. The
462    /// id names the allocation, never the conversation — see [`KvAllocId`].
463    pub fn truncate_to(&mut self, new_len: usize) {
464        if new_len < self.cached_len {
465            self.cached_len = new_len;
466        }
467    }
468
469    /// Write host-supplied K/V bytes into layer `layer_idx`'s `slot` buffer at
470    /// position range `[start, start+n)` — the `KvCache`-side sibling of
471    /// [`DeviceKvPool::write_block_bytes`](crate::kv_block_pool_device::DeviceKvPool::write_block_bytes).
472    ///
473    /// Builds a throwaway `Op::Const`(bytes) → `Op::WriteSlice` graph and
474    /// realizes it ONCE into the SAME buffer `Arc` this cache already holds —
475    /// it does **not** replace the Arc, so unlike [`Self::set_layer`] this
476    /// does **not** touch [`Self::alloc_id`] or [`Self::device_location`]: a
477    /// [`DecodeSession`] already welded to this cache's buffers (via
478    /// `base_cache`) sees the write transparently on its next token, with no
479    /// rebind and no rebuild — the write lands in the exact buffer the held
480    /// plan's `Op::WriteSlice` nodes already target.
481    ///
482    /// `bytes` is `n * n_kv_heads * head_dim * dtype.size_in_bytes()` raw
483    /// little-endian element bytes, row-major `[1, n_kv_heads, n, head_dim]`
484    /// — **HEAD-MAJOR** (position is axis 2). This is NOT the same layout as
485    /// [`DeviceKvPool`](crate::kv_block_pool_device::DeviceKvPool)'s
486    /// **position-major** `[block_size, Hkv, D]` block layout: a caller
487    /// moving bytes from the paged pool into a `KvCache` must transpose, a
488    /// plain memcpy silently produces wrong attention.
489    ///
490    /// `Err` (never a panic): this cache has no fixed dtype/capacity (built
491    /// via [`Self::with_dims`] rather than [`Self::with_capacity`] — this
492    /// primitive is `with_capacity`-only, since the legacy grow-by-replacement
493    /// path has no stable buffer to write into); `layer_idx` is out of range
494    /// or unpopulated; `start + n` exceeds the allocated `max_seq_len`; or
495    /// `bytes.len()` doesn't match the expected size for `n` positions.
496    pub fn write_bytes(
497        &self,
498        layer_idx: usize,
499        slot: KvSlot,
500        start: usize,
501        n: usize,
502        bytes: &[u8],
503        device: &Device,
504    ) -> Result<()> {
505        let dtype = self.dtype.ok_or_else(|| {
506            Error::Msg(
507                "KvCache::write_bytes: cache has no fixed dtype — not built via with_capacity"
508                    .into(),
509            )
510            .bt()
511        })?;
512        let max_seq_len = self.max_seq_len.ok_or_else(|| {
513            Error::Msg(
514                "KvCache::write_bytes: cache has no fixed capacity — not built via with_capacity"
515                    .into(),
516            )
517            .bt()
518        })?;
519        if start + n > max_seq_len {
520            return Err(Error::Msg(format!(
521                "KvCache::write_bytes: range [{start}, {}) exceeds max_seq_len {max_seq_len}",
522                start + n,
523            ))
524            .bt());
525        }
526        let elem_bytes = kv_cache_dtype_elem_bytes(dtype)?;
527        let want = n * self.n_kv_heads * self.head_dim * elem_bytes;
528        if bytes.len() != want {
529            return Err(Error::Msg(format!(
530                "KvCache::write_bytes: {} bytes != expected {want} \
531                 ([1, n_kv_heads {}, n {n}, head_dim {}] for {dtype:?})",
532                bytes.len(),
533                self.n_kv_heads,
534                self.head_dim,
535            ))
536            .bt());
537        }
538        let layer = self.layer(layer_idx).ok_or_else(|| {
539            Error::Msg(format!(
540                "KvCache::write_bytes: layer {layer_idx} out of range or unpopulated"
541            ))
542            .bt()
543        })?;
544        let buf = match slot {
545            KvSlot::K => Arc::clone(&layer.k),
546            KvSlot::V => Arc::clone(&layer.v),
547        };
548        let full_shape = Shape::from_dims(&[1, self.n_kv_heads, max_seq_len, self.head_dim]);
549        let src = kv_cache_const_from_bytes(
550            dtype,
551            bytes,
552            Shape::from_dims(&[1, self.n_kv_heads, n, self.head_dim]),
553            device,
554        )?;
555        let dest = src.const_placeholder_like(full_shape, dtype);
556        let ranges = vec![
557            (0, 1),
558            (0, self.n_kv_heads),
559            (start, start + n),
560            (0, self.head_dim),
561        ];
562        let post = dest.write_slice(&src, ranges)?;
563        let mut cache = StorageCache::new();
564        cache.insert(dest.node_id(), buf);
565        let _ = crate::pipelined_bridge::realize_one_bytes_with_initial(
566            post.graph_handle(),
567            post.node_id(),
568            device,
569            cache,
570        )?;
571        Ok(())
572    }
573}
574
575/// Byte width of one `KvCache` element for byte-level movement — the
576/// `KvCache`-side sibling of
577/// [`DeviceKvPool::dtype_elem_bytes`](crate::kv_block_pool_device::DeviceKvPool).
578/// F32/BF16 only, matching [`KvCache::write_bytes`]'s supported dtypes.
579fn kv_cache_dtype_elem_bytes(dtype: DType) -> Result<usize> {
580    match dtype {
581        DType::F32 => Ok(4),
582        DType::BF16 => Ok(2),
583        other => Err(Error::Msg(format!(
584            "KvCache: byte movement unsupported for dtype {other:?} (F32/BF16)",
585        ))
586        .bt()),
587    }
588}
589
590/// Build a fresh-graph `Op::Const` of `dtype` from raw little-endian element
591/// bytes — the `KvCache`-side sibling of
592/// [`DeviceKvPool::const_from_bytes`](crate::kv_block_pool_device::DeviceKvPool).
593/// Dispatches `dtype` to the matching typed graph constructor via
594/// alignment-safe `try_cast_slice` (never panics).
595fn kv_cache_const_from_bytes(
596    dtype: DType,
597    bytes: &[u8],
598    shape: Shape,
599    device: &Device,
600) -> Result<crate::lazy::Tensor> {
601    match dtype {
602        DType::F32 => {
603            let v: &[f32] = bytemuck::try_cast_slice(bytes).map_err(|e| {
604                Error::Msg(format!("kv_cache_const_from_bytes: f32 cast: {e:?}")).bt()
605            })?;
606            crate::lazy::Tensor::from_f32(v.to_vec(), shape, device)
607        }
608        DType::BF16 => {
609            let v: &[half::bf16] = bytemuck::try_cast_slice(bytes).map_err(|e| {
610                Error::Msg(format!("kv_cache_const_from_bytes: bf16 cast: {e:?}")).bt()
611            })?;
612            let anchor =
613                crate::lazy::Tensor::from_f32(vec![0.0f32], Shape::from_dims(&[1]), device)?;
614            crate::lazy::Tensor::from_bf16_on(anchor.graph(), v.to_vec(), shape, device)
615        }
616        other => Err(Error::Msg(format!(
617            "kv_cache_const_from_bytes: unsupported dtype {other:?} (F32/BF16)",
618        ))
619        .bt()),
620    }
621}
622
623/// One layer's `(K, V)` cache storages. A KV cache is a `Vec` of these, one
624/// per layer, and the two halves are always allocated and dropped together.
625type KvStoragePair = (Arc<RwLock<Storage>>, Arc<RwLock<Storage>>);
626
627/// Allocate `n_layers` pairs of zero-initialized `[k, n_kv_heads,
628/// max_seq_len, head_dim]` K/V buffers on `device` with `dtype` — the shared
629/// batch-slot KV buffer for the multi-session batched decode arm (serving
630/// Increment 1, C3). Same `Op::Alloc`→`Op::ZeroFill` emission +
631/// `PipelinedExecutor::realize_many` pattern as [`KvCache::with_capacity`],
632/// with the leading dim `k` (the batch slot) instead of `1`. Returns the
633/// per-layer `(K_arc, V_arc)` pairs; the caller binds them into a batched
634/// decode graph.
635///
636/// Returns `Err` if any allocation fails (fail-on-OOM, spec #6).
637pub fn alloc_batched_kv(
638    k: usize,
639    n_layers: usize,
640    n_kv_heads: usize,
641    head_dim: usize,
642    max_seq_len: usize,
643    dtype: DType,
644    device: &Device,
645) -> Result<Vec<KvStoragePair>> {
646    let shape = Shape::from_dims(&[k, n_kv_heads, max_seq_len, head_dim]);
647    let target_loc = device.location();
648
649    let graph = Arc::new(RwLock::new(Graph::new()));
650    let mut cache = StorageCache::new();
651    if let Some(seed) = crate::pipelined_bridge::device_seed_storage(device)? {
652        let anchor_id = {
653            let mut g = graph.write().map_err(|_| {
654                Error::Msg("graph lock poisoned during alloc_batched_kv".into()).bt()
655            })?;
656            g.push(Node {
657                op: Op::Const,
658                inputs: vec![],
659                shape: Shape::from_dims(&[4]),
660                dtype: DType::U8,
661            })
662        };
663        cache.insert(anchor_id, Arc::new(RwLock::new(seed)));
664    }
665
666    let zero_fill_ids: Vec<NodeId> = {
667        let mut g = graph
668            .write()
669            .map_err(|_| Error::Msg("graph lock poisoned during alloc_batched_kv".into()).bt())?;
670        (0..(2 * n_layers))
671            .map(|_| {
672                let alloc_id = g.push(Node {
673                    op: Op::Alloc { target: target_loc },
674                    inputs: vec![],
675                    shape: shape.clone(),
676                    dtype,
677                });
678                g.push(Node {
679                    op: Op::ZeroFill,
680                    inputs: vec![alloc_id],
681                    shape: shape.clone(),
682                    dtype,
683                })
684            })
685            .collect()
686    };
687
688    let realized = PipelinedExecutor::realize_many(Arc::clone(&graph), &zero_fill_ids, cache)?;
689    if realized.len() != 2 * n_layers {
690        return Err(Error::Msg(format!(
691            "alloc_batched_kv: realize_many returned {} storages for {} Op::ZeroFill targets",
692            realized.len(),
693            2 * n_layers,
694        ))
695        .bt());
696    }
697
698    // Never-panic: the length is checked above, but the production path must
699    // not `.expect()` — surface a typed `Err` if the iterator is short.
700    let mut it = realized.into_iter();
701    let mut pairs = Vec::with_capacity(n_layers);
702    let short = || {
703        Error::Msg(
704            "alloc_batched_kv: realize_many yielded fewer storages than expected".to_string(),
705        )
706        .bt()
707    };
708    for _ in 0..n_layers {
709        let (k_arc, _) = it.next().ok_or_else(short)?;
710        let (v_arc, _) = it.next().ok_or_else(short)?;
711        pairs.push((k_arc, v_arc));
712    }
713    Ok(pairs)
714}
715
716// ===========================================================================
717// LatentKvCache — N-slot generalization of KvCache (MLA increment 4)
718// ===========================================================================
719
720/// One slot's persistent latent buffer within a [`LatentKvCache`] layer.
721/// The per-slot analogue of [`KvLayer`]'s `k`/`v` pair, generalized to an
722/// arbitrary slot count and independent per-slot trailing shape.
723pub struct LatentSlot {
724    pub storage: Arc<RwLock<Storage>>,
725    /// View layout into `storage`. For the pre-allocated (capacity)
726    /// buffers [`LatentKvCache::with_capacity`] allocates, the layout
727    /// matches the storage's full `[max_seq_len, ...trailing]` shape.
728    pub layout: Layout,
729    /// Monotonic write version. Bumps on every successful
730    /// `Op::WriteSlice` targeting this slot. Placeholder for the future
731    /// multi-device coherence protocol — mirrors [`KvLayer::k_version`] /
732    /// `v_version`.
733    pub version: u64,
734    /// Coherence authority. Placeholder — see [`AuthorityState`].
735    pub authority: AuthorityState,
736}
737
738/// Cross-graph, cross-forward-pass persistent **N-slot** decode cache —
739/// the [`KvCache`] generalization for latent-caching attention
740/// architectures.
741///
742/// [`KvCache`] hardwires a symmetric K/V pair (two buffers per layer,
743/// same shape). That's wrong for compression architectures:
744///
745///   - **Multi-head Latent Attention (DeepSeek-V2 MLA)**: per layer, a
746///     low-rank compressed latent `[kv_lora_rank]` (slot 0) **and** a
747///     single-head RoPE key `[qk_rope_head_dim]` (slot 1) — two slots of
748///     *different* trailing shape, neither a `[n_kv_heads, head_dim]` K/V.
749///   - **Two-projection attention / QKV pruning**: a single retained
750///     projection per layer — one slot.
751///
752/// `LatentKvCache` is this module's counterpart to
753/// [`crate::lazy_latent_cache::LatentCache`] — that type makes the
754/// exact same **per-layer, ordered list of latent buffers with
755/// independent trailing shapes** generalization for the **per-forward-
756/// pass** (single [`fuel_graph::Graph`]-anchored, functional
757/// append-and-thread) lifecycle. `LatentKvCache` is the **persistent**
758/// sibling: device-resident `Arc<RwLock<Storage>>` buffers that survive
759/// across graphs/forward calls and are mutated in place via
760/// `Op::WriteSlice`, exactly the relationship [`KvCache`] has to a plain
761/// per-pass K/V cache. Read both modules' docs together for the two
762/// lifecycles side by side.
763///
764/// # Shape contract
765///
766/// Slot `s` of every layer is a buffer `[max_seq_len, ...slot_trailing[s]]`
767/// — the capacity axis is dim **0**. This differs from [`KvCache`]'s `[1,
768/// n_kv_heads, max_seq_len, head_dim]` convention (capacity axis 2):
769/// a latent is a per-token *vector*, not a per-head *plane*, so there is
770/// no head axis to lead with. Matches [`crate::lazy_latent_cache::
771/// LatentCache`]'s dim-0 convention exactly — this type's per-slot
772/// buffer is byte-for-byte what that type's `slot_buffer_full` realizes,
773/// modulo device residency.
774pub struct LatentKvCache {
775    /// `layers[l]` is `None` until populated; `Some(slots)` holds one
776    /// [`LatentSlot`] per slot index (pipeline-parallel friendly — mirrors
777    /// [`KvCache::layers`]).
778    pub layers: Vec<Option<Vec<LatentSlot>>>,
779    pub cached_len: usize,
780    /// Per-slot trailing shape (past the leading capacity axis); its
781    /// length is the per-layer slot count.
782    pub slot_trailing: Vec<Vec<usize>>,
783    pub max_seq_len: usize,
784    pub dtype: DType,
785    /// Identity of the allocation backing this cache. Same role and same
786    /// re-mint rules as [`KvCache::alloc_id`] — MLA reuses [`DecodeSession`]
787    /// verbatim (its 2 latent slots ride `kv_nodes` as `(latent, kpe)` pairs),
788    /// so it inherits the same welding and needs the same guard.
789    alloc_id: KvAllocId,
790    /// Where the slot buffers live. Unconditional here, unlike
791    /// [`KvCache::device_location`]: this type has exactly one constructor
792    /// and it is handed a `&Device`, so residency is never unprovable.
793    device_location: DeviceLocation,
794}
795
796impl LatentKvCache {
797    /// Pre-allocated N-slot latent cache. Every layer's slot buffers are
798    /// allocated up-front as zero buffers on `device` with `dtype`,
799    /// shaped `[max_seq_len, ...slot_trailing[s]]`. Mirrors [`KvCache::
800    /// with_capacity`]'s `Op::Alloc` → `Op::ZeroFill` graph-emission
801    /// pattern exactly, generalized from a fixed 2-buffer (K, V) layer to
802    /// `slot_trailing.len()` buffers of independent shape.
803    ///
804    /// Returns `Err` for degenerate geometry (`n_layers == 0`,
805    /// `max_seq_len == 0`, or no slots) or if any per-layer allocation
806    /// fails (e.g. CUDA OOM / an unwired device).
807    pub fn with_capacity(
808        n_layers: usize,
809        max_seq_len: usize,
810        slot_trailing: Vec<Vec<usize>>,
811        dtype: DType,
812        device: &Device,
813    ) -> Result<Self> {
814        if n_layers == 0 {
815            return Err(
816                Error::Msg("LatentKvCache::with_capacity: n_layers must be >= 1".into()).bt(),
817            );
818        }
819        if max_seq_len == 0 {
820            return Err(Error::Msg(
821                "LatentKvCache::with_capacity: max_seq_len must be >= 1".into(),
822            )
823            .bt());
824        }
825        if slot_trailing.is_empty() {
826            return Err(Error::Msg(
827                "LatentKvCache::with_capacity: need at least one slot (slot_trailing empty)".into(),
828            )
829            .bt());
830        }
831        let n_slots = slot_trailing.len();
832        let shapes: Vec<Shape> = slot_trailing
833            .iter()
834            .map(|trailing| {
835                let mut dims = Vec::with_capacity(1 + trailing.len());
836                dims.push(max_seq_len);
837                dims.extend_from_slice(trailing);
838                Shape::from_dims(&dims)
839            })
840            .collect();
841        let layouts: Vec<Layout> = shapes
842            .iter()
843            .map(|s| Layout::contiguous(s.clone()))
844            .collect();
845        let target_loc = device.location();
846
847        // Transient graph, same device-anchor trick + Alloc/ZeroFill
848        // emission pattern as KvCache::with_capacity — see that
849        // constructor's doc for the full rationale.
850        let graph = Arc::new(RwLock::new(Graph::new()));
851        let mut cache = StorageCache::new();
852        if let Some(seed) = crate::pipelined_bridge::device_seed_storage(device)? {
853            let anchor_id = {
854                let mut g = graph.write().map_err(|_| {
855                    Error::Msg("graph lock poisoned during LatentKvCache build".into()).bt()
856                })?;
857                g.push(Node {
858                    op: Op::Const,
859                    inputs: vec![],
860                    shape: Shape::from_dims(&[4]),
861                    dtype: DType::U8,
862                })
863            };
864            cache.insert(anchor_id, Arc::new(RwLock::new(seed)));
865        }
866
867        // Emit (Op::Alloc, Op::ZeroFill) pairs for every (layer, slot),
868        // each shaped per its own slot's shape.
869        let mut zero_fill_ids: Vec<NodeId> = Vec::with_capacity(n_layers * n_slots);
870        {
871            let mut g = graph.write().map_err(|_| {
872                Error::Msg("graph lock poisoned during LatentKvCache build".into()).bt()
873            })?;
874            for _ in 0..n_layers {
875                for shape in &shapes {
876                    let alloc_id = g.push(Node {
877                        op: Op::Alloc { target: target_loc },
878                        inputs: vec![],
879                        shape: shape.clone(),
880                        dtype,
881                    });
882                    let zf_id = g.push(Node {
883                        op: Op::ZeroFill,
884                        inputs: vec![alloc_id],
885                        shape: shape.clone(),
886                        dtype,
887                    });
888                    zero_fill_ids.push(zf_id);
889                }
890            }
891        }
892
893        // Realize all n_layers * n_slots Op::ZeroFill targets in one pass
894        // — see KvCache::with_capacity's doc for why a single realize_many
895        // call is used instead of one realize per buffer.
896        let realized = PipelinedExecutor::realize_many(Arc::clone(&graph), &zero_fill_ids, cache)?;
897        if realized.len() != n_layers * n_slots {
898            return Err(Error::Msg(format!(
899                "LatentKvCache::with_capacity: realize_many returned {} storages \
900                 for {} Op::ZeroFill targets — internal bug",
901                realized.len(),
902                n_layers * n_slots,
903            ))
904            .bt());
905        }
906
907        // Never-panic: length checked above; still surface a typed `Err`
908        // rather than `.expect()` on a production path.
909        let mut realized_iter = realized.into_iter();
910        let short = |l: usize| {
911            Error::Msg(format!(
912                "alloc_latent: realize_many yielded fewer storages than \
913                 expected (ran out at layer {l})"
914            ))
915            .bt()
916        };
917        let mut layers: Vec<Option<Vec<LatentSlot>>> = Vec::with_capacity(n_layers);
918        for l in 0..n_layers {
919            let mut slots = Vec::with_capacity(n_slots);
920            for layout in &layouts {
921                let (arc, _) = realized_iter.next().ok_or_else(|| short(l))?;
922                slots.push(LatentSlot {
923                    storage: arc,
924                    layout: layout.clone(),
925                    version: 0,
926                    authority: AuthorityState::Host,
927                });
928            }
929            layers.push(Some(slots));
930        }
931
932        Ok(Self {
933            layers,
934            cached_len: 0,
935            slot_trailing,
936            max_seq_len,
937            dtype,
938            alloc_id: KvAllocId::next(),
939            device_location: target_loc,
940        })
941    }
942
943    /// Borrow the slot `s` storage Arc for layer `layer_idx`. `None` if
944    /// the layer is unpopulated or either index is out of range. Used by
945    /// the forward path to bind cache storage to per-step Const nodes via
946    /// [`InferenceContext::insert`]. Mirrors [`KvCache::slot_storage`].
947    pub fn slot_storage(&self, layer_idx: usize, slot: usize) -> Option<Arc<RwLock<Storage>>> {
948        let layer = self.layers.get(layer_idx)?.as_ref()?;
949        layer.get(slot).map(|s| Arc::clone(&s.storage))
950    }
951
952    /// Bump slot `s`'s monotonic version counter for `layer_idx`. No-op
953    /// (never panics) if either index is out of range. Mirrors
954    /// [`KvCache::bump_version`].
955    pub fn bump_version(&mut self, layer_idx: usize, slot: usize) {
956        if let Some(Some(layer)) = self.layers.get_mut(layer_idx)
957            && let Some(s) = layer.get_mut(slot)
958        {
959            s.version += 1;
960        }
961    }
962
963    pub fn n_layers(&self) -> usize {
964        self.layers.len()
965    }
966
967    /// Number of slots per layer.
968    pub fn n_slots(&self) -> usize {
969        self.slot_trailing.len()
970    }
971
972    /// Trailing shape of slot `s` (past the leading capacity axis).
973    pub fn slot_trailing(&self, s: usize) -> &[usize] {
974        &self.slot_trailing[s]
975    }
976
977    /// Identity of the storage this cache currently owns. Mirrors
978    /// [`KvCache::alloc_id`]; see [`KvAllocId`].
979    pub fn alloc_id(&self) -> KvAllocId {
980        self.alloc_id
981    }
982
983    /// Drop every layer; reset `cached_len` to zero. Mirrors [`KvCache::clear`],
984    /// including the [`Self::alloc_id`] re-mint — every storage `Arc` is
985    /// released, so a plan welded to them must be rebuilt.
986    pub fn clear(&mut self) {
987        for layer in &mut self.layers {
988            *layer = None;
989        }
990        self.cached_len = 0;
991        self.alloc_id = KvAllocId::next();
992    }
993
994    /// Shrink the cache to `new_len` cached positions — metadata-only,
995    /// same Phase E.3.0 limitation and rationale as [`KvCache::
996    /// truncate_to`]: the underlying storages are not shrunk; the
997    /// pre-allocated buffers' trailing rows simply stop being read until
998    /// overwritten by a later `Op::WriteSlice` at the same positions.
999    ///
1000    /// Deliberately preserves [`Self::alloc_id`] — a rewind is not a
1001    /// re-allocation. Same rule as [`KvCache::truncate_to`].
1002    pub fn truncate_to(&mut self, new_len: usize) {
1003        if new_len < self.cached_len {
1004            self.cached_len = new_len;
1005        }
1006    }
1007}
1008
1009// ===========================================================================
1010// Guarded KV re-bind (GAP-028)
1011// ===========================================================================
1012
1013/// A KV allocation a held [`DecodeSession`] can be re-bound onto — the one
1014/// abstraction over [`KvCache`] (K/V pairs) and [`LatentKvCache`] (MLA's
1015/// `(latent, kpe)` pairs), which already share `DecodeSession` verbatim and
1016/// therefore share its welding.
1017///
1018/// Deliberately narrow: identity, residency, and the storage behind a baked
1019/// `kv_nodes` slot. Geometry is **not** here, because the guard does not ask
1020/// this trait for it — it compares the incoming storage against the storage
1021/// already baked into the plan, which is ground truth and cannot drift from
1022/// whatever a cache believes about itself.
1023pub trait KvRebindSource {
1024    /// Identity of this allocation. See [`KvAllocId`].
1025    fn alloc_id(&self) -> KvAllocId;
1026    /// Where the storage lives, or `None` when that is not provable — which
1027    /// [`DecodeSession::rebind_kv`] treats as a refusal, never as a default.
1028    fn rebind_location(&self) -> Option<DeviceLocation>;
1029    /// Storage for slot `which` (0 or 1) of `layer` — the two halves of the
1030    /// plan's `kv_nodes[layer]` pair, in the order the builder bound them.
1031    fn rebind_slot(&self, layer: usize, which: usize) -> Option<Arc<RwLock<Storage>>>;
1032}
1033
1034impl KvRebindSource for KvCache {
1035    fn alloc_id(&self) -> KvAllocId {
1036        KvCache::alloc_id(self)
1037    }
1038    fn rebind_location(&self) -> Option<DeviceLocation> {
1039        self.device_location
1040    }
1041    fn rebind_slot(&self, layer: usize, which: usize) -> Option<Arc<RwLock<Storage>>> {
1042        // The builder pushes `(k_id, v_id)` — this order is the contract.
1043        let slot = match which {
1044            0 => KvSlot::K,
1045            1 => KvSlot::V,
1046            _ => return None,
1047        };
1048        self.slot_storage(layer, slot)
1049    }
1050}
1051
1052impl KvRebindSource for LatentKvCache {
1053    fn alloc_id(&self) -> KvAllocId {
1054        LatentKvCache::alloc_id(self)
1055    }
1056    fn rebind_location(&self) -> Option<DeviceLocation> {
1057        Some(self.device_location)
1058    }
1059    fn rebind_slot(&self, layer: usize, which: usize) -> Option<Arc<RwLock<Storage>>> {
1060        // MLA binds `(latent, kpe)` as slots 0 and 1 — same positional
1061        // contract as K/V above, which is why one `DecodeSession` serves both.
1062        self.slot_storage(layer, which)
1063    }
1064}
1065
1066/// Why a held plan could not be re-bound onto a replacement KV allocation.
1067///
1068/// A refusal is a normal outcome, not an error: the caller falls back to the
1069/// GAP-014 rebuild, which is correct by construction. The variants exist so
1070/// the guard's reasons are *nameable in a test* — the point of GAP-028 is that
1071/// this guard is load-bearing for correctness, so "it refused" is not enough
1072/// to know it refused for the right reason.
1073#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1074pub enum RebindRefusal {
1075    /// The replacement cannot say where its storage lives, so residency
1076    /// cannot be proven equal to the plan's. See [`KvCache::device_location`].
1077    ResidencyUnknown,
1078    /// The plan's placement stamps and residency copies were baked for a
1079    /// different device.
1080    ResidencyDiffers {
1081        baked: DeviceLocation,
1082        incoming: DeviceLocation,
1083    },
1084    /// The replacement has no storage for a slot the plan writes.
1085    MissingSlot { layer: usize, which: usize },
1086    /// A baked KV `Const` has no `base_cache` entry — an internal
1087    /// inconsistency in the held plan, not a property of the replacement.
1088    UnbakedSlot { layer: usize, which: usize },
1089    /// The replacement storage is not byte-interchangeable with the baked
1090    /// storage: different dtype, encoding, backend variant, or extent.
1091    StorageDiffers { layer: usize, which: usize },
1092    /// A storage lock was poisoned while comparing. Refuse rather than guess.
1093    LockPoisoned,
1094}
1095
1096/// Whether a held plan may serve a step, and if not, why not — **one** set of
1097/// comparisons, so the "is it valid" and "is only the allocation different"
1098/// questions cannot drift apart. [`DecodeSession::is_valid_for`] is a thin
1099/// view over this.
1100#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1101pub enum PlanValidity {
1102    /// Reuse the plan as-is.
1103    Valid,
1104    /// Everything the plan bakes still matches **except which KV allocation
1105    /// it is welded to.** A guarded [`DecodeSession::rebind_kv`] may rescue
1106    /// it; without one, the plan MUST NOT be reused — that is GAP-014.
1107    AllocationChanged,
1108    /// Something structural differs (shape, layer count, dtype, or the model
1109    /// itself). Rebuild.
1110    Stale,
1111}
1112
1113/// Everything about a storage that a baked graph depends on and that is
1114/// readable from the storage itself. Deliberately excludes residency, which
1115/// is NOT readable — see [`DecodeSession::rebind_kv`].
1116#[derive(PartialEq, Eq)]
1117struct StorageFingerprint {
1118    dtype: DType,
1119    stype: SType,
1120    bundled: bool,
1121    backend: std::mem::Discriminant<BackendStorage>,
1122    len_bytes: usize,
1123}
1124
1125fn fingerprint(s: &Arc<RwLock<Storage>>) -> Option<StorageFingerprint> {
1126    let g = s.read().ok()?;
1127    Some(StorageFingerprint {
1128        dtype: g.dtype,
1129        stype: g.stype.clone(),
1130        bundled: g.bundle.is_some(),
1131        backend: std::mem::discriminant(&g.inner),
1132        len_bytes: g.inner.len_bytes(),
1133    })
1134}
1135
1136/// May `incoming` stand in for `baked` inside an already-optimized graph?
1137/// `None` means a lock was poisoned and the question could not be answered —
1138/// which callers must treat as "no", not as "probably fine".
1139///
1140/// The two locks are taken one at a time and released before the next, so this
1141/// cannot deadlock even if some future caller passes overlapping storage.
1142fn storage_is_interchangeable(
1143    baked: &Arc<RwLock<Storage>>,
1144    incoming: &Arc<RwLock<Storage>>,
1145) -> Option<bool> {
1146    if Arc::ptr_eq(baked, incoming) {
1147        return Some(true);
1148    }
1149    let a = fingerprint(baked)?;
1150    let b = fingerprint(incoming)?;
1151    Some(a == b)
1152}
1153
1154// ===========================================================================
1155// DecodeSession (Phase D · D2b — plan-once persistent decode)
1156// ===========================================================================
1157
1158/// Per-token re-bound data-Const Arcs for a persistent decode step
1159/// (Phase D · D2b). Each is a device-resident `fuel_memory::Storage`
1160/// built from freshly recomputed host bytes (token-ids = the new token,
1161/// RoPE cos+sin at `position = cached_len`, mask with the shifted `-inf`
1162/// boundary). [`DecodeSession::realize_token`] overwrites the held
1163/// `base_cache`'s stable data-Const entries with these for the token.
1164pub struct DecodeTokenData {
1165    pub token_ids: Arc<RwLock<Storage>>,
1166    pub rope_cos: Arc<RwLock<Storage>>,
1167    pub rope_sin: Arc<RwLock<Storage>>,
1168    pub mask: Arc<RwLock<Storage>>,
1169    /// The device-resident KV-write offset (`cached_len` as a rank-0
1170    /// `I64`), present ONLY on the device-offset decode path (CUDA/CPU
1171    /// targets, where `Op::WriteSliceDoff` reads the start from this
1172    /// buffer at kernel launch — capture-ready). `None` on the SymEnv
1173    /// path (Vulkan), where the KV write is `Op::WriteSlice` with a
1174    /// `DynScalar::Sym(cached_len)` offset resolved host-side per token.
1175    pub offset: Option<Arc<RwLock<Storage>>>,
1176}
1177
1178/// Plan-once persistent decode state for one `LlamaModel` + one
1179/// [`KvCache`] capacity/dtype. Built on the first `seq == 1` decode token;
1180/// reused (graph + optimize view HELD, only the per-token data Consts +
1181/// `SymEnv` re-bound) for every subsequent token.
1182///
1183/// The ~1.8×/token win lives here: the decode-step graph is built +
1184/// `prepare`d (D2H `Op::Copy` spliced at the root) + `optimize_graph`'d
1185/// (placement stamps + residency `Op::Copy` + layout `Op::Contiguize`
1186/// baked in place) ONCE, then re-realized via the D2a prebuilt seam
1187/// ([`InferenceContext::realize_prebuilt_as_with_env`]) which SKIPS both
1188/// `prepare` and `optimize_graph`.
1189///
1190/// Held by the generate-loop owner (the caller of
1191/// `forward_with_kv_context_persistent`), NOT by the immutable
1192/// `LlamaModel` (the model is read-only weights; a session is per-
1193/// generation state). Constructed lazily as `Option<DecodeSession>` and
1194/// dropped-then-rebuilt on any validity-key mismatch / `TopologyChanged`
1195/// / a non-`seq==1` step (see `forward_with_kv_context_persistent`).
1196///
1197/// ## Why the graph is HELD here
1198///
1199/// D1 rebuilds a fresh `Tensor` graph every token and drops it after
1200/// realize. D2 keeps the `Arc<RwLock<Graph>>` alive on the session so the
1201/// already-optimized structure (the stamps + inserted copies) survives.
1202/// The cached [`OptimizedGraph`] holds only `{roots, generation}` — it
1203/// bakes NO Const data / storage / `SymEnv`, so reusing it is sound
1204/// **iff the graph structure + topology generation are unchanged** (D1's
1205/// input-independent-graph guarantee).
1206pub struct DecodeSession {
1207    /// The held decode-step graph, ALREADY optimized in place (stamps +
1208    /// residency/layout copies + D2H root splice baked in). Structure is
1209    /// stable across tokens (D1 guarantee).
1210    graph: Arc<RwLock<Graph>>,
1211    /// The cached optimize view from the first realize. Holds only
1212    /// `{roots, generation}`; valid while `graph` structure + topology
1213    /// generation are unchanged.
1214    optimized: OptimizedGraph,
1215    /// The realize root the executor was asked for — the D2H `Op::Copy`
1216    /// NodeId `prepare` spliced (NOT the logits node itself). Stable
1217    /// across tokens.
1218    effective_target: NodeId,
1219    /// The logits node (pre-D2H-splice) — `effective_target`'s input.
1220    /// Retained for D3 attribution / debugging; the executor is asked
1221    /// for `effective_target`. Also the CapturedRun replay TARGET
1222    /// (`CapturedDecodeSession::capture` must be given the pre-splice
1223    /// node — see [`Self::logits_node`]'s doc for why).
1224    logits_node: NodeId,
1225    /// Stable re-bindable token-ids Const (`[seq]` U32).
1226    token_ids_node: NodeId,
1227    /// Stable re-bindable RoPE cos table Const (`[seq, head_dim]` F32).
1228    rope_cos_node: NodeId,
1229    /// Stable re-bindable RoPE sin table Const (`[seq, head_dim]` F32).
1230    rope_sin_node: NodeId,
1231    /// Stable re-bindable causal mask Const (`[1, 1, seq, max_seq_len]`
1232    /// F32) — hoisted to ONE shared Const (was per-layer in D1).
1233    mask_node: NodeId,
1234    /// Per-layer `(k_const, v_const)` stable KV placeholder NodeIds. The
1235    /// KV Arcs are re-bound once at build time and mutated in place by
1236    /// `Op::WriteSlice`/`Op::WriteSliceDoff` each token (never re-inserted
1237    /// per token).
1238    kv_nodes: Vec<(NodeId, NodeId)>,
1239    /// The stable rank-0 `I64` KV-write offset Const, present ONLY on the
1240    /// **device-offset** decode path (CUDA/CPU — `Op::WriteSliceDoff`
1241    /// reads `cached_len` from this buffer device-side at launch, making
1242    /// the decode step CUDA-graph-capturable). `None` on the SymEnv path
1243    /// (Vulkan — the offset is host-resolved via `cached_len_sym`). When
1244    /// `Some`, `realize_token` re-binds the per-token offset Arc into the
1245    /// base-cache clone alongside token-ids/RoPE/mask.
1246    offset_node: Option<NodeId>,
1247    /// The symbol the per-pass `SymEnv` binds to `cached_len` each token.
1248    cached_len_sym: SymId,
1249    /// The symbol the per-pass `SymEnv` binds to the live **attended
1250    /// prefix length** (`cached_len + seq`) each token. This is the
1251    /// `k_len` the optimizer-emitted CUDA flash-decode arm resolves
1252    /// against (`decode_flash::DecodeFlashSpec::k_len`); distinct from
1253    /// `cached_len_sym` because the flash kernel attends `[0, k_len)`
1254    /// (the whole prefix including this token), while the KV-write lands
1255    /// at `[cached_len, cached_len + seq)`. Unreferenced on today's f32
1256    /// decode graph (no flash arm offered) — a harmless extra binding.
1257    attended_len_sym: SymId,
1258    /// The full realized [`StorageCache`] from the first realize — every
1259    /// reachable `Op::Const` (weights + the KV Arcs + the initial data
1260    /// Consts) that `build_const_cache` uploaded. Held because the
1261    /// prebuilt-realize path SKIPS the const-cache walk: each token we
1262    /// clone this and overwrite ONLY the per-token data-Const entries
1263    /// (token-ids / RoPE / mask). The weight Arcs + KV Arcs are stable
1264    /// (KV mutates in place via `Op::WriteSlice`).
1265    base_cache: StorageCache,
1266    /// The `seq` the held graph is shape-keyed to (always 1 for D2 decode).
1267    seq: usize,
1268    // ---- validity keys — rebuild if any change vs. the live cache/model ----
1269    max_seq_len: usize,
1270    n_layers: usize,
1271    cache_dtype: DType,
1272    /// The [`crate::decode_shape`] key this plan was baked against — model
1273    /// structure + weight identity. See [`Self::is_valid_for`].
1274    shape_key: u64,
1275    /// The KV **allocation** whose storage `Arc`s are baked into `base_cache`.
1276    /// `rebind_and_realize_prebuilt` never re-binds `kv_nodes`, so this plan
1277    /// reads and writes THESE buffers regardless of which cache the caller
1278    /// hands in — unless [`Self::rebind_kv`] re-points them — making the id,
1279    /// not the geometry, the thing that decides reuse. See [`KvAllocId`] and
1280    /// [`Self::is_valid_for`].
1281    alloc_id: KvAllocId,
1282    /// Where the KV `Arc`s baked into `base_cache` lived when this graph was
1283    /// optimized. The placement stamps and residency `Op::Copy`s in `graph`
1284    /// were chosen against THIS location, so [`Self::rebind_kv`] must refuse a
1285    /// replacement that lives anywhere else. `None` when the source could not
1286    /// prove it, which is likewise a refusal.
1287    kv_location: Option<DeviceLocation>,
1288    /// `order_for` fix track (`docs/design/incremental-consumer-index.md`
1289    /// §5): the memoized dispatch order for this session's
1290    /// `(graph, effective_target, optimized)` triple, computed once on the
1291    /// first `realize_token` call and reused on every later one — measured
1292    /// as the single largest named per-token sub-cost prior to this cache
1293    /// (`extract_runs_multi` + `non_chosen_arm_nodes`, ~56ms/step on an RTX
1294    /// 4070). Empty at construction; `realize_token` never resets it, since
1295    /// a `DecodeSession` holds one shape-keyed graph for its whole lifetime
1296    /// (see `is_valid_for` — any mismatch rebuilds a fresh session with a
1297    /// fresh, empty cache, never reuses this one against a different graph).
1298    order_cache: std::sync::OnceLock<Vec<NodeId>>,
1299}
1300
1301impl DecodeSession {
1302    /// Assemble a held session from the artifacts a first-realize
1303    /// prebuild produced. `graph` is the (already prepared + optimized)
1304    /// held decode graph; `effective_target` / `optimized` come from
1305    /// [`InferenceContext::prebuild_optimized_as_with_env`]; the node ids
1306    /// are the STABLE data-Const NodeIds the builder minted.
1307    pub fn new(
1308        graph: Arc<RwLock<Graph>>,
1309        optimized: OptimizedGraph,
1310        effective_target: NodeId,
1311        logits_node: NodeId,
1312        token_ids_node: NodeId,
1313        rope_cos_node: NodeId,
1314        rope_sin_node: NodeId,
1315        mask_node: NodeId,
1316        kv_nodes: Vec<(NodeId, NodeId)>,
1317        offset_node: Option<NodeId>,
1318        cached_len_sym: SymId,
1319        attended_len_sym: SymId,
1320        base_cache: StorageCache,
1321        seq: usize,
1322        max_seq_len: usize,
1323        n_layers: usize,
1324        cache_dtype: DType,
1325        shape_key: u64,
1326        kv: &dyn KvRebindSource,
1327    ) -> Self {
1328        Self {
1329            shape_key,
1330            // Identity and residency are read from the SAME source in the same
1331            // breath, so they cannot describe different allocations — which a
1332            // pair of separate `alloc_id` / `location` parameters could.
1333            alloc_id: kv.alloc_id(),
1334            kv_location: kv.rebind_location(),
1335            graph,
1336            optimized,
1337            effective_target,
1338            logits_node,
1339            token_ids_node,
1340            rope_cos_node,
1341            rope_sin_node,
1342            mask_node,
1343            kv_nodes,
1344            offset_node,
1345            cached_len_sym,
1346            attended_len_sym,
1347            base_cache,
1348            seq,
1349            max_seq_len,
1350            n_layers,
1351            cache_dtype,
1352            order_cache: std::sync::OnceLock::new(),
1353        }
1354    }
1355
1356    /// Realize the held graph for one decode token via the D2a prebuilt
1357    /// seam (SKIP `prepare` + `optimize_graph`). `data` supplies the
1358    /// per-token re-bound data-Const Arcs (token-ids / RoPE cos+sin /
1359    /// mask); they overwrite the held `base_cache`'s entries for this
1360    /// token (the weight + KV Arcs stay from `base_cache`). `sym_env`
1361    /// binds `cached_len`. `TopologyChanged` surfaces typed (caller
1362    /// invalidates the session).
1363    pub fn realize_token(
1364        &self,
1365        device: &Device,
1366        data: DecodeTokenData,
1367        sym_env: &SymEnv,
1368    ) -> Result<Vec<f32>> {
1369        let _t = fuel_dispatch::compiled::op_profile::PhaseTimer::start("decode.realize_token");
1370        let mut cache = self.base_cache.clone();
1371        cache.insert(self.token_ids_node, data.token_ids);
1372        cache.insert(self.rope_cos_node, data.rope_cos);
1373        cache.insert(self.rope_sin_node, data.rope_sin);
1374        cache.insert(self.mask_node, data.mask);
1375        // Device-offset path (CUDA/CPU): re-bind the per-token KV-write
1376        // offset (`cached_len` as I64) into the base-cache clone so the
1377        // held `Op::WriteSliceDoff` nodes read the live position from a
1378        // fixed device buffer. On the SymEnv path both are `None` (the
1379        // offset rides `cached_len_sym` in `sym_env` instead).
1380        if let (Some(offset_node), Some(offset)) = (self.offset_node, data.offset) {
1381            cache.insert(offset_node, offset);
1382        }
1383        crate::pipelined_bridge::realize_one_prebuilt_cached_env::<f32>(
1384            &self.graph,
1385            self.effective_target,
1386            &self.optimized,
1387            &self.order_cache,
1388            device,
1389            cache,
1390            sym_env,
1391        )
1392    }
1393
1394    /// Whether this held session is valid for the given decode step.
1395    /// Rebuild (drop + build fresh) on any mismatch. `seq` must be 1
1396    /// (the held graph is the seq==1 decode graph); a change in
1397    /// `max_seq_len` / `n_layers` / `cache_dtype` means a different
1398    /// model/cache → the held graph's shapes are stale.
1399    /// May this held plan be reused for a step with these parameters?
1400    ///
1401    /// `shape_key` is the caller's [`crate::decode_shape`] key. Geometry alone
1402    /// is NOT sufficient and was the whole reason that module exists: a held
1403    /// graph bakes its op structure and its weight `Const`s, so two models at
1404    /// identical dims — a different architecture, or the same architecture with
1405    /// different weights — would otherwise be judged interchangeable and one
1406    /// would silently execute the other's baked plan.
1407    ///
1408    /// `alloc_id` is the live cache's [`KvCache::alloc_id`]. It is a SEPARATE
1409    /// question from `shape_key` and neither subsumes the other: `shape_key`
1410    /// names the model whose weights are baked in, `alloc_id` names the KV
1411    /// buffers the plan writes into. One model serving many concurrent
1412    /// requests has one `shape_key` and one allocation per request, and it is
1413    /// exactly that case — the slot-pooled server — where geometry-only keying
1414    /// hands request B a plan welded to request A's KV. Nothing errors: B just
1415    /// decodes over A's context at full speed.
1416    ///
1417    /// Both directions matter. Returning `true` too readily is a wrong answer
1418    /// at full speed; returning `false` too readily forfeits plan reuse
1419    /// (measured 223× on CUDA) while every correctness test stays green. That
1420    /// second direction is why `alloc_id` tracks the ALLOCATION and not the
1421    /// conversation — see [`KvCache::truncate_to`], which must not invalidate.
1422    pub fn is_valid_for(
1423        &self,
1424        seq: usize,
1425        max_seq_len: usize,
1426        n_layers: usize,
1427        cache_dtype: DType,
1428        shape_key: u64,
1429        alloc_id: KvAllocId,
1430    ) -> bool {
1431        matches!(
1432            self.validity_for(seq, max_seq_len, n_layers, cache_dtype, shape_key, alloc_id),
1433            PlanValidity::Valid,
1434        )
1435    }
1436
1437    /// The same comparisons as [`Self::is_valid_for`], reporting *which kind*
1438    /// of mismatch occurred rather than collapsing to a bool.
1439    ///
1440    /// The split exists so GAP-028 does not introduce a second predicate. A
1441    /// caller that wants to try [`Self::rebind_kv`] needs to know "everything
1442    /// matches except the allocation", and answering that with an
1443    /// `is_valid_ignoring_alloc` twin would leave two comparison lists to keep
1444    /// in sync — the exact drift that made
1445    /// `lazy::invalidate_decode_pair_if_stale` a shared free function instead
1446    /// of a per-model copy. There is one list, here.
1447    ///
1448    /// Note the asymmetry between the two non-`Valid` answers.
1449    /// `AllocationChanged` is a *conditional* verdict: it says a re-bind is
1450    /// worth attempting, NOT that one is safe. Everything it reports on is a
1451    /// property of the held plan; whether the incoming storage is actually
1452    /// interchangeable is [`Self::rebind_kv`]'s question, and it is answered
1453    /// against the storage itself.
1454    pub fn validity_for(
1455        &self,
1456        seq: usize,
1457        max_seq_len: usize,
1458        n_layers: usize,
1459        cache_dtype: DType,
1460        shape_key: u64,
1461        alloc_id: KvAllocId,
1462    ) -> PlanValidity {
1463        let structural = self.seq == seq
1464            && self.max_seq_len == max_seq_len
1465            && self.n_layers == n_layers
1466            && self.cache_dtype == cache_dtype
1467            && self.shape_key == shape_key;
1468        if !structural {
1469            return PlanValidity::Stale;
1470        }
1471        if self.alloc_id == alloc_id {
1472            PlanValidity::Valid
1473        } else {
1474            PlanValidity::AllocationChanged
1475        }
1476    }
1477
1478    /// Re-point the held plan's baked KV `Const`s at a replacement allocation,
1479    /// or refuse — the GAP-028 alternative to throwing the plan away.
1480    ///
1481    /// ## What this trades
1482    ///
1483    /// GAP-014 shipped invalidate-and-rebuild, which is safe **by
1484    /// construction**: a plan welded to storage it no longer serves is simply
1485    /// destroyed, and no guard can be wrong because there is no guard. This
1486    /// method replaces that with a guarantee that holds **by proof**. If the
1487    /// checks below ever accept an incompatible allocation, the failure mode is
1488    /// GAP-014 verbatim — a plan executing one request's tokens over another's
1489    /// KV, at full speed, with a plausible distribution and nothing to report.
1490    /// Every check here is therefore load-bearing for correctness, not for
1491    /// performance, and the cost of refusing wrongly is only a rebuild.
1492    ///
1493    /// ## What is checked, and against what
1494    ///
1495    /// Not against the cache's own description of itself — against the storage
1496    /// **already baked into `base_cache`**, which is ground truth and cannot
1497    /// disagree with the graph the way a `KvCache` field can. Per slot: dtype,
1498    /// encoding, backend variant, bundling, and byte extent. Byte extent is the
1499    /// load-bearing one: it collapses `batch × n_kv_heads × max_seq_len ×
1500    /// head_dim` into a single comparison that needs no reconstruction of the
1501    /// baked shape.
1502    ///
1503    /// Residency is the exception and is why [`KvRebindSource::rebind_location`]
1504    /// exists. `fuel_memory::Storage` exposes a backend *variant* but no device
1505    /// ordinal — `VulkanStorage` records none at all — so a same-geometry cache
1506    /// on a *different GPU of the same backend* is invisible from the bytes. It
1507    /// would pass every check above and be spliced into a graph whose placement
1508    /// stamps and residency `Op::Copy`s were baked for the old device. An
1509    /// unprovable residency is therefore a refusal, never a default.
1510    ///
1511    /// ## What the caller still owes
1512    ///
1513    /// **A re-bind invalidates any capture.** A `CapturedDecodeSession` records
1514    /// a CUDA graph over FIXED device addresses drawn from `base_cache`; after
1515    /// this returns `Ok`, some of those addresses name the old allocation.
1516    /// Callers must clear the capture alongside, on the same
1517    /// reader-before-owner rule that governs
1518    /// `lazy::invalidate_decode_pair_if_stale`.
1519    ///
1520    /// Structural validity is NOT rechecked here — call this only when
1521    /// [`Self::validity_for`] answered [`PlanValidity::AllocationChanged`].
1522    pub fn rebind_kv(
1523        &mut self,
1524        kv: &dyn KvRebindSource,
1525    ) -> core::result::Result<(), RebindRefusal> {
1526        let incoming_loc = kv
1527            .rebind_location()
1528            .ok_or(RebindRefusal::ResidencyUnknown)?;
1529        let baked_loc = self.kv_location.ok_or(RebindRefusal::ResidencyUnknown)?;
1530        if baked_loc != incoming_loc {
1531            return Err(RebindRefusal::ResidencyDiffers {
1532                baked: baked_loc,
1533                incoming: incoming_loc,
1534            });
1535        }
1536
1537        // Collect first, mutate second. A partial re-bind — some slots on the
1538        // new allocation, the rest still on the old — is the one state strictly
1539        // worse than either endpoint, and the only way to avoid it is to refuse
1540        // before touching `base_cache`.
1541        let mut staged: Vec<(NodeId, Arc<RwLock<Storage>>)> =
1542            Vec::with_capacity(self.kv_nodes.len() * 2);
1543        for (layer, &(k_node, v_node)) in self.kv_nodes.iter().enumerate() {
1544            for (which, node) in [(0usize, k_node), (1usize, v_node)] {
1545                let incoming = kv
1546                    .rebind_slot(layer, which)
1547                    .ok_or(RebindRefusal::MissingSlot { layer, which })?;
1548                let baked = self
1549                    .base_cache
1550                    .get(&node)
1551                    .ok_or(RebindRefusal::UnbakedSlot { layer, which })?;
1552                if !storage_is_interchangeable(baked, &incoming)
1553                    .ok_or(RebindRefusal::LockPoisoned)?
1554                {
1555                    return Err(RebindRefusal::StorageDiffers { layer, which });
1556                }
1557                staged.push((node, incoming));
1558            }
1559        }
1560
1561        for (node, arc) in staged {
1562            self.base_cache.insert(node, arc);
1563        }
1564        self.alloc_id = kv.alloc_id();
1565        Ok(())
1566    }
1567
1568    /// The held graph handle (the caller re-binds data Consts + realizes
1569    /// through it).
1570    pub fn graph(&self) -> &Arc<RwLock<Graph>> {
1571        &self.graph
1572    }
1573
1574    /// The cached optimize view (fed to the prebuilt-realize seam).
1575    pub fn optimized(&self) -> &OptimizedGraph {
1576        &self.optimized
1577    }
1578
1579    /// The D2H `Op::Copy` root the executor is asked for.
1580    pub fn effective_target(&self) -> NodeId {
1581        self.effective_target
1582    }
1583
1584    /// The logits node (pre-D2H-splice) — `capture_decode` hard-rejects
1585    /// any `Op::Copy`/`Move` in the captured graph (cross-device, not
1586    /// single-device-CUDA-capturable), so [`fuel_dispatch::pipelined::
1587    /// CapturedDecodeSession::capture`] must target THIS node, not
1588    /// [`Self::effective_target`] (the spliced D2H `Op::Copy` root).
1589    /// The D2H happens OUTSIDE the capture, after `replay_token` returns
1590    /// the device-resident output Arc.
1591    pub fn logits_node(&self) -> NodeId {
1592        self.logits_node
1593    }
1594    pub fn token_ids_node(&self) -> NodeId {
1595        self.token_ids_node
1596    }
1597    pub fn rope_cos_node(&self) -> NodeId {
1598        self.rope_cos_node
1599    }
1600    pub fn rope_sin_node(&self) -> NodeId {
1601        self.rope_sin_node
1602    }
1603    pub fn mask_node(&self) -> NodeId {
1604        self.mask_node
1605    }
1606    pub fn kv_nodes(&self) -> &[(NodeId, NodeId)] {
1607        &self.kv_nodes
1608    }
1609    /// The device-resident KV-write offset Const NodeId, `Some` only on
1610    /// the device-offset decode path (CUDA/CPU); `None` on Vulkan's
1611    /// SymEnv path. Used by the CapturedRun replay wiring (Phase 3) and
1612    /// the per-token offset re-bind.
1613    pub fn offset_node(&self) -> Option<NodeId> {
1614        self.offset_node
1615    }
1616    pub fn cached_len_sym(&self) -> SymId {
1617        self.cached_len_sym
1618    }
1619    pub fn attended_len_sym(&self) -> SymId {
1620        self.attended_len_sym
1621    }
1622    pub fn max_seq_len(&self) -> usize {
1623        self.max_seq_len
1624    }
1625
1626    /// Build the per-token [`SymEnv`] for one decode step: bind
1627    /// `cached_len_sym = cached_len` (the KV-write offset) AND
1628    /// `attended_len_sym = cached_len + seq` (the flash-arm `k_len` — the
1629    /// live attended prefix including this token). Both are bound each
1630    /// token; the attended-length binding is unreferenced on today's f32
1631    /// decode graph (no flash arm) and becomes load-bearing the moment a
1632    /// bf16/f16 CUDA decode offers the arm. Write-once per pass (a
1633    /// conflicting rebind surfaces a typed error, never a panic).
1634    /// # DO NOT DELETE THE `cached_len_sym` BINDING BECAUSE IT LOOKS DEAD
1635    ///
1636    /// **`LlamaModel` and `PhiModel` take DIFFERENT decode paths on CPU/F32,
1637    /// measured 2026-08-12 (GAP-029 2b):**
1638    ///
1639    /// ```text
1640    /// llama: offset_node.is_some() == true   → device-offset path; SymEnv INERT
1641    /// phi:   offset_node.is_some() == false  → SymEnv path;        SymEnv LIVE
1642    /// ```
1643    ///
1644    /// On the device-offset path the KV write offset rides a device-resident
1645    /// buffer (`Op::WriteSliceDoff` reads the start from
1646    /// [`DecodeTokenData::offset`] at launch), so **no symbol here drives
1647    /// Llama's write** and both bindings are inert for it. On the SymEnv path
1648    /// the offset rides `cached_len_sym`, and it is **load-bearing for Phi** —
1649    /// proved by negative control: binding it wrong moves Phi's logits
1650    /// (`phi_attended_len_sym_is_unreferenced_negative_control`).
1651    ///
1652    /// The trap this comment exists to prevent: someone profiling or tracing
1653    /// **the Llama path** sees a binding nothing consumes and removes it as dead
1654    /// code. **Their evidence would be about Llama; the requirement comes from
1655    /// Phi**, and the breakage would be silent — wrong KV write offsets, not a
1656    /// compile error. Whichever model you were looking at, you were looking at
1657    /// half the callers.
1658    ///
1659    /// `attended_len_sym` is separately inert on **both** paths today (F32, CPU,
1660    /// no flash arm) and is bound anyway: it becomes load-bearing the moment a
1661    /// bf16/f16 CUDA decode offers the flash arm, and binding it unconditionally
1662    /// is what makes that switch a no-op rather than a hunt. That claim is
1663    /// scoped — re-run the controls above before relying on it under any other
1664    /// dtype/graph.
1665    pub fn per_token_sym_env(&self, cached_len: usize) -> Result<SymEnv> {
1666        let mut env = SymEnv::new();
1667        env.bind(self.cached_len_sym, cached_len)?;
1668        env.bind(self.attended_len_sym, cached_len + self.seq)?;
1669        Ok(env)
1670    }
1671
1672    /// The held graph's node count. The D2b born-red test asserts this
1673    /// is stable from token 2 onward (no per-token node growth — the
1674    /// guard against an accidental re-splice / re-insert / a builder
1675    /// sneaking a `cached_len`-dependent shape back in).
1676    pub fn graph_node_count(&self) -> usize {
1677        self.graph.read().map(|g| g.len()).unwrap_or(0)
1678    }
1679
1680    /// The full realized [`StorageCache`] from the first realize (every
1681    /// weight Const + the KV Arcs + the initial per-token data Consts).
1682    /// The CapturedRun capture-building step clones this cheaply
1683    /// (Arc-clones only) and overwrites the per-token entries with fresh
1684    /// FIXED-address Arcs before handing the merged cache to
1685    /// [`fuel_dispatch::pipelined::CapturedDecodeSession::capture`] as
1686    /// `inputs`.
1687    pub fn base_cache(&self) -> &StorageCache {
1688        &self.base_cache
1689    }
1690}
1691
1692/// Runtime policy for the paged persistent decode path
1693/// (`LlamaModel::forward_paged_step_persistent`):
1694/// whether to build the plan
1695/// ONCE and reuse it across tokens, or re-plan every token (the pre-plan-once
1696/// behavior). The paged driver holds this per config and passes it each step;
1697/// **the driver default is [`PlanOnce`](Self::PlanOnce)** (set by
1698/// `PagedSessionScheduler::new`), with [`Replan`](Self::Replan) available as an
1699/// explicit opt-out.
1700///
1701/// The A/B rollout this flag was built for is DONE, and the default moved with
1702/// the result. Measured (nsys, Lightbulb, 2026-08-01; 16 tokens, same binary and
1703/// model, only the plan mode differing): `Replan` → `PlanOnce` is **29.7×** on
1704/// ms/token (8,192.0 → 275.8) and cuts host-ward DtoH 61% (101,691 → 40,088 MB)
1705/// at IDENTICAL kernel-launch count (17,802 in both arms — same compute, less
1706/// data movement). Shipping `Replan` as the default meant every consumer who did
1707/// not know to opt in paid that 29.7×.
1708///
1709/// It remains a real runtime flag — not a test hook — because toggling it in ONE
1710/// process is what lets the correctness gate run both arms off a single setup,
1711/// and because `Replan` is the parity reference the plan-once path is checked
1712/// against. On [`Replan`](Self::Replan) the persistent forward drops any held
1713/// session (so nothing stale lingers) and delegates to the re-planning
1714/// `LlamaModel::forward_paged_step`;
1715/// on [`PlanOnce`](Self::PlanOnce) it
1716/// builds-once / rebinds.
1717#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1718pub enum PagedDecodePlan {
1719    /// Re-plan (build + optimize the graph) every token — the pre-plan-once
1720    /// path. Drops any held [`PagedDecodeSession`] and routes to
1721    /// `LlamaModel::forward_paged_step`.
1722    Replan,
1723    /// Build + optimize the graph ONCE (into a [`PagedDecodeSession`]) and reuse
1724    /// it (rebinding only the per-token data) for every subsequent token.
1725    PlanOnce,
1726}
1727
1728/// Per-token re-bound data Consts for one PAGED decode step — the paged twin
1729/// of [`DecodeTokenData`]. The shapes are stable across tokens (that is the
1730/// whole point of the plan-once path); only the bytes change, so each is an
1731/// `Arc<RwLock<Storage>>` overwriting the held [`PagedDecodeSession`]'s
1732/// base-cache entry for its stable NodeId.
1733pub struct PagedDecodeTokenData {
1734    /// The single decode token id (`[1]` U32).
1735    pub token_ids: Arc<RwLock<Storage>>,
1736    /// RoPE cos table at THIS token's absolute position (`[1, head_dim]` F32).
1737    pub rope_cos: Arc<RwLock<Storage>>,
1738    /// RoPE sin table at THIS token's absolute position (`[1, head_dim]` F32).
1739    pub rope_sin: Arc<RwLock<Storage>>,
1740    /// Capacity-padded block table (`[1, max_blocks_cap]` U32) — the shape is
1741    /// FIXED by `max_blocks_cap`; the content grows a physical block whenever
1742    /// this token crosses a block boundary (padded tail stays 0, masked out).
1743    pub block_table: Arc<RwLock<Storage>>,
1744    /// Attended prefix length for this row (`[1]` U32) — the `Op::PagedAttn`
1745    /// mask cutoff (`Ge(pos, context_len) → −inf`) that neutralizes both the
1746    /// future positions AND the padded block_table tail.
1747    pub context_lens: Arc<RwLock<Storage>>,
1748    /// The flattened KV-write offset (`linear = phys·block_size + slot` as a
1749    /// rank-0 I64), present ONLY on the device-offset path (CPU/CUDA, where
1750    /// `Op::WriteSliceDoff` reads the start device-side). `None` on the SymEnv
1751    /// path (Vulkan), where the offset rides `write_sym` in the per-pass env.
1752    pub offset: Option<Arc<RwLock<Storage>>>,
1753}
1754
1755/// Plan-once persistent decode state for one PAGED (block-pool) decode
1756/// stream — the paged twin of [`DecodeSession`]. Built on the first
1757/// `seq == 1` paged decode token; reused (graph + optimize view HELD, only
1758/// the per-token data Consts + `SymEnv` re-bound) for every subsequent token.
1759///
1760/// Paged decode's structural variabilities that a re-plan would otherwise pay
1761/// EVERY token are removed here: (1) the fresh `Tensor` graph root becomes
1762/// a held graph with stable re-bindable Const placeholders; (2) the L-varying
1763/// `block_table` shape is pinned to `[1, max_blocks_cap]` (Task 1 padded
1764/// materialize); (3) the per-step KV-write range becomes ONE flattened
1765/// dynamic offset (Task 2 `build_decode_attn_off`). What remains per token is
1766/// shape-stable data re-bound into a clone of `base_cache` + one bound scalar.
1767///
1768/// The pool K/V buffer Arcs are bound ONCE into `base_cache` and mutate IN
1769/// PLACE via `Op::WriteSliceDoff`/`Op::WriteSliceDyn` each token (never
1770/// re-inserted) — exactly the contiguous KV relationship, so the write this
1771/// step lands in the pool and is visible next step.
1772///
1773/// Held by the paged generate-loop owner alongside the `DeviceKvPool` +
1774/// `SessionHandle`; the caller must NOT swap the pool/model out from under a
1775/// live session (the held `base_cache` references THIS pool's buffers). A
1776/// validity-key mismatch (`is_valid_for`) or `TopologyChanged` drops + rebuilds
1777/// it (see `LlamaModel::forward_paged_step_persistent`). Single-session (B = 1)
1778/// today; the ragged batched persistent path is a follow-on.
1779pub struct PagedDecodeSession {
1780    /// The held paged decode-step graph, ALREADY optimized in place. Structure
1781    /// is stable across tokens (variabilities #1–#3 removed).
1782    graph: Arc<RwLock<Graph>>,
1783    /// The cached optimize view from the first realize. Holds only
1784    /// `{roots, generation}`; valid while `graph` structure + topology
1785    /// generation are unchanged.
1786    optimized: OptimizedGraph,
1787    /// The realize root the executor is asked for (the D2H `Op::Copy` splice).
1788    effective_target: NodeId,
1789    /// The logits node (pre-D2H-splice) — retained for debugging/attribution.
1790    logits_node: NodeId,
1791    /// Stable re-bindable single-token-id Const (`[1]` U32).
1792    token_ids_node: NodeId,
1793    /// Stable re-bindable RoPE cos table Const (`[1, head_dim]` F32).
1794    rope_cos_node: NodeId,
1795    /// Stable re-bindable RoPE sin table Const (`[1, head_dim]` F32).
1796    rope_sin_node: NodeId,
1797    /// Stable re-bindable padded block_table Const (`[1, max_blocks_cap]` U32).
1798    block_table_node: NodeId,
1799    /// Stable re-bindable context_lens Const (`[1]` U32).
1800    context_lens_node: NodeId,
1801    /// Per-layer `(k_pool_ph, v_pool_ph)` stable KV placeholder NodeIds, bound
1802    /// ONCE to the pool buffers (viewed at `pool_shape_flat`) and mutated in
1803    /// place by the flattened-offset write each token.
1804    kv_nodes: Vec<(NodeId, NodeId)>,
1805    /// The stable rank-0 `I64` KV-write offset Const, present ONLY on the
1806    /// device-offset path (CPU/CUDA — `Op::WriteSliceDoff`). `None` on the
1807    /// SymEnv path (Vulkan — the offset rides `write_sym`).
1808    offset_node: Option<NodeId>,
1809    /// The symbol the per-pass `SymEnv` binds to the flattened write offset
1810    /// (`linear`) each token. Load-bearing on the SymEnv path; a harmless
1811    /// (unreferenced) extra binding on the device-offset path.
1812    write_sym: SymId,
1813    /// The full realized [`StorageCache`] from the first realize (weights +
1814    /// the pool KV Arcs + the initial per-token data Consts). Each token we
1815    /// clone this and overwrite ONLY the per-token data-Const entries; the
1816    /// weight + pool Arcs are stable (the pool mutates in place).
1817    base_cache: StorageCache,
1818    // ---- validity keys — rebuild if any change vs. the live pool ----
1819    max_blocks_cap: usize,
1820    n_layers: usize,
1821    block_size: usize,
1822    cache_dtype: DType,
1823    /// The model's `decode_shape_key()` at build time — family + structure-
1824    /// affecting config + WEIGHT IDENTITY (a never-recycled `ModelInstanceId`).
1825    ///
1826    /// Geometry alone cannot separate two models: `PagedDecodeSession` bakes the
1827    /// weight `Const`s, so two same-shaped models produce identical
1828    /// `(max_blocks_cap, n_layers, block_size, cache_dtype)` and a plan built for
1829    /// one would be judged valid for the other — silently computing the wrong
1830    /// architecture, or the right architecture with the wrong weights, at full
1831    /// speed. The contiguous twin closed this first; this is the paged half of
1832    /// the same hole.
1833    shape_key: u64,
1834    /// The [`DeviceKvPool`](crate::kv_block_pool_device::DeviceKvPool) whose
1835    /// per-layer block buffers are baked into `base_cache`. The paged half of
1836    /// the same welding: `rebind_and_realize_paged_prebuilt` re-binds the
1837    /// per-token data (token id, RoPE, block_table, context_lens, offset) and
1838    /// never the pool, so this plan reads THESE buffers whichever pool the
1839    /// caller passes. Two same-geometry pools are otherwise indistinguishable
1840    /// to the key. See [`KvAllocId`].
1841    alloc_id: KvAllocId,
1842    /// Count of REBIND realizes this session has served (one per
1843    /// [`Self::realize_token`]). The build itself realizes through the prebuild
1844    /// seam, NOT `realize_token`, so it stays 0 right after construction and
1845    /// increments only on each plan-reuse token. A DEVICE-INDEPENDENT plan-HIT
1846    /// signal (the `optimize_calls_thread_local` counter is CPU-scoped — GPU
1847    /// per-token uploads bump it), so the correctness gate can assert "this
1848    /// token reused the plan" on any backend. Interior-mutable (`realize_token`
1849    /// takes `&self`); `Relaxed` — it is telemetry, ordered by the caller.
1850    realize_count: AtomicUsize,
1851    /// `order_for` fix track (`docs/design/incremental-consumer-index.md`
1852    /// §5) — see [`DecodeSession::order_cache`]'s doc for the full
1853    /// argument; identical role here for the paged path.
1854    order_cache: std::sync::OnceLock<Vec<NodeId>>,
1855}
1856
1857impl PagedDecodeSession {
1858    /// Assemble a held paged session from the artifacts a first-realize
1859    /// prebuild produced. The node ids are the STABLE data-Const NodeIds the
1860    /// paged builder minted.
1861    pub fn new(
1862        graph: Arc<RwLock<Graph>>,
1863        optimized: OptimizedGraph,
1864        effective_target: NodeId,
1865        logits_node: NodeId,
1866        token_ids_node: NodeId,
1867        rope_cos_node: NodeId,
1868        rope_sin_node: NodeId,
1869        block_table_node: NodeId,
1870        context_lens_node: NodeId,
1871        kv_nodes: Vec<(NodeId, NodeId)>,
1872        offset_node: Option<NodeId>,
1873        write_sym: SymId,
1874        base_cache: StorageCache,
1875        max_blocks_cap: usize,
1876        n_layers: usize,
1877        block_size: usize,
1878        cache_dtype: DType,
1879        shape_key: u64,
1880        alloc_id: KvAllocId,
1881    ) -> Self {
1882        Self {
1883            alloc_id,
1884            graph,
1885            optimized,
1886            effective_target,
1887            logits_node,
1888            token_ids_node,
1889            rope_cos_node,
1890            rope_sin_node,
1891            block_table_node,
1892            context_lens_node,
1893            kv_nodes,
1894            offset_node,
1895            write_sym,
1896            base_cache,
1897            max_blocks_cap,
1898            n_layers,
1899            block_size,
1900            cache_dtype,
1901            shape_key,
1902            realize_count: AtomicUsize::new(0),
1903            order_cache: std::sync::OnceLock::new(),
1904        }
1905    }
1906
1907    /// Realize the held paged graph for one decode token via the prebuilt seam
1908    /// (SKIP `prepare` + `optimize_graph`). `data` supplies the per-token
1909    /// re-bound data-Const Arcs; they overwrite the held `base_cache`'s entries
1910    /// for this token (the weight + pool KV Arcs stay from `base_cache`).
1911    /// `sym_env` binds `write_sym` (load-bearing on the SymEnv path, harmless
1912    /// on the device-offset path). `TopologyChanged` surfaces typed (caller
1913    /// invalidates the session).
1914    pub fn realize_token(
1915        &self,
1916        device: &Device,
1917        data: PagedDecodeTokenData,
1918        sym_env: &SymEnv,
1919    ) -> Result<Vec<f32>> {
1920        let mut cache = self.base_cache.clone();
1921        cache.insert(self.token_ids_node, data.token_ids);
1922        cache.insert(self.rope_cos_node, data.rope_cos);
1923        cache.insert(self.rope_sin_node, data.rope_sin);
1924        cache.insert(self.block_table_node, data.block_table);
1925        cache.insert(self.context_lens_node, data.context_lens);
1926        if let (Some(offset_node), Some(offset)) = (self.offset_node, data.offset) {
1927            cache.insert(offset_node, offset);
1928        }
1929        // Device-independent plan-HIT tick: this token reuses the held plan
1930        // (the prebuild seam below SKIPs optimize). Bumped BEFORE the realize
1931        // so a realize error still records the attempted rebind.
1932        self.realize_count.fetch_add(1, Ordering::Relaxed);
1933        crate::pipelined_bridge::realize_one_prebuilt_cached_env::<f32>(
1934            &self.graph,
1935            self.effective_target,
1936            &self.optimized,
1937            &self.order_cache,
1938            device,
1939            cache,
1940            sym_env,
1941        )
1942    }
1943
1944    /// Whether this held session is valid for the given paged decode step.
1945    /// Rebuild (drop + build fresh) on any mismatch: a change in
1946    /// `max_blocks_cap` (the block_table shape key), `n_layers`, `block_size`,
1947    /// or `cache_dtype` means the held graph's shapes / offsets are stale.
1948    ///
1949    /// `alloc_id` is the live pool's
1950    /// [`DeviceKvPool::alloc_id`](crate::kv_block_pool_device::DeviceKvPool::alloc_id)
1951    /// — which POOL's buffers, as distinct from `shape_key`'s which MODEL's
1952    /// weights. Note it is deliberately not sensitive to block allocation:
1953    /// which physical blocks a session owns rides the per-token `block_table`
1954    /// rebind, so a generation that grows into new blocks keeps its plan.
1955    pub fn is_valid_for(
1956        &self,
1957        max_blocks_cap: usize,
1958        n_layers: usize,
1959        block_size: usize,
1960        cache_dtype: DType,
1961        shape_key: u64,
1962        alloc_id: KvAllocId,
1963    ) -> bool {
1964        self.max_blocks_cap == max_blocks_cap
1965            && self.n_layers == n_layers
1966            && self.block_size == block_size
1967            && self.cache_dtype == cache_dtype
1968            && self.shape_key == shape_key
1969            && self.alloc_id == alloc_id
1970    }
1971
1972    /// Build the per-token [`SymEnv`] for one paged decode step: bind
1973    /// `write_sym = linear` (the flattened KV-write offset). Load-bearing on
1974    /// the SymEnv path; a harmless unreferenced binding on the device-offset
1975    /// path (a conflicting rebind surfaces a typed error, never a panic).
1976    pub fn per_token_sym_env(&self, linear: usize) -> Result<SymEnv> {
1977        let mut env = SymEnv::new();
1978        env.bind(self.write_sym, linear)?;
1979        Ok(env)
1980    }
1981
1982    /// The held graph handle.
1983    pub fn graph(&self) -> &Arc<RwLock<Graph>> {
1984        &self.graph
1985    }
1986    /// The cached optimize view (fed to the prebuilt-realize seam).
1987    pub fn optimized(&self) -> &OptimizedGraph {
1988        &self.optimized
1989    }
1990    pub fn effective_target(&self) -> NodeId {
1991        self.effective_target
1992    }
1993    pub fn logits_node(&self) -> NodeId {
1994        self.logits_node
1995    }
1996    pub fn token_ids_node(&self) -> NodeId {
1997        self.token_ids_node
1998    }
1999    pub fn rope_cos_node(&self) -> NodeId {
2000        self.rope_cos_node
2001    }
2002    pub fn rope_sin_node(&self) -> NodeId {
2003        self.rope_sin_node
2004    }
2005    pub fn block_table_node(&self) -> NodeId {
2006        self.block_table_node
2007    }
2008    pub fn context_lens_node(&self) -> NodeId {
2009        self.context_lens_node
2010    }
2011    pub fn kv_nodes(&self) -> &[(NodeId, NodeId)] {
2012        &self.kv_nodes
2013    }
2014    pub fn offset_node(&self) -> Option<NodeId> {
2015        self.offset_node
2016    }
2017    pub fn write_sym(&self) -> SymId {
2018        self.write_sym
2019    }
2020    pub fn max_blocks_cap(&self) -> usize {
2021        self.max_blocks_cap
2022    }
2023
2024    /// How many REBIND (plan-reuse) tokens this session has served — a
2025    /// device-independent plan-HIT signal (see [`Self::realize_count`]). Zero
2026    /// right after the build; `n` after `n` reuse tokens. The correctness gate
2027    /// asserts this advances by exactly one per plan-once decode step.
2028    pub fn realize_count(&self) -> usize {
2029        self.realize_count.load(Ordering::Relaxed)
2030    }
2031
2032    /// The held graph's node count. The Task 3 born-red test asserts this is
2033    /// stable from token 2 onward (no per-token node growth — the guard
2034    /// against an accidental re-splice / re-insert).
2035    pub fn graph_node_count(&self) -> usize {
2036        self.graph.read().map(|g| g.len()).unwrap_or(0)
2037    }
2038
2039    /// The full realized [`StorageCache`] from the first realize.
2040    pub fn base_cache(&self) -> &StorageCache {
2041        &self.base_cache
2042    }
2043}
2044
2045// `alloc_zeroed_on` retired 2026-05-22 (bridge-retirement Phase 3a).
2046// Zero-init device allocation now flows through `Op::Alloc` graph
2047// emission + the executor's `WorkItemKind::Alloc` arm. The per-
2048// `DeviceLocation` match this function used to carry lives in
2049// `fuel-storage::pipelined::execute_work_item`'s Alloc arm; the
2050// residual "0-byte device anchor" helper lives in
2051// `crate::pipelined_bridge::device_seed_storage`.
2052
2053// ===========================================================================
2054// InferenceContext
2055// ===========================================================================
2056
2057/// Per-session host-side context holding long-lived storage Arcs
2058/// that survive across realize calls.
2059///
2060/// The persistent map is the seam the unified-storage / mmap-coherence
2061/// backplane work later replaces (see
2062/// `project_unified_durable_tensor_store.md`); today it's a simple
2063/// in-memory `HashMap`. Each realize call clones the Arcs into the
2064/// executor's input cache; persistent entries reuse those Arcs
2065/// across calls instead of re-uploading.
2066///
2067/// ## Lifecycle
2068///
2069/// 1. Construct with a target device: `InferenceContext::new(device)`.
2070/// 2. Insert long-lived storages (KV layer storages, anything else
2071///    that should survive across realize calls):
2072///    `ctx.insert(node_id, arc)`.
2073/// 3. Realize: `ctx.realize_one_as::<f32>(&graph, target_node)`. The
2074///    persistent map is seeded into the executor's input cache; new
2075///    `Op::Const` nodes the graph references that aren't in the
2076///    persistent map get uploaded fresh from the graph's storage map.
2077/// 4. Subsequent realize calls reuse the persistent Arcs — no
2078///    re-upload, no D2H/H2D round-trip.
2079pub struct InferenceContext {
2080    device: Device,
2081    persistent: HashMap<NodeId, Arc<RwLock<Storage>>>,
2082    /// Held plan-once decode plan for `LlamaModel::forward_decode_step`
2083    /// — the carrier that lets a caller-driven decode loop get plan reuse
2084    /// WITHOUT threading an `Option<DecodeSession>` of its own.
2085    ///
2086    /// This exists because the ergonomic call shape and the fast call shape had
2087    /// drifted apart. `forward_with_kv_context(tokens, cache, ctx)` is the raw
2088    /// rebuild primitive; its persistent sibling takes a fourth argument, so a
2089    /// consumer hand-rolling a decode loop reaches the slow one and never learns
2090    /// the fast one exists. Not hypothetical: that gap cost a measured consumer
2091    /// 5,901 → 26.47 ms/token before anyone noticed (nsys, 2026-08-01).
2092    ///
2093    /// **One context per model.** Already this codebase's invariant — speculative
2094    /// decoding builds a separate `InferenceContext` for the draft and the target
2095    /// — and the held plan self-heals regardless: `DecodeSession::is_valid_for`
2096    /// drops it on any cache-geometry or dtype change.
2097    decode_session: Option<DecodeSession>,
2098}
2099
2100impl InferenceContext {
2101    pub fn new(device: Device) -> Self {
2102        Self {
2103            device,
2104            persistent: HashMap::new(),
2105            decode_session: None,
2106        }
2107    }
2108
2109    pub fn device(&self) -> &Device {
2110        &self.device
2111    }
2112
2113    /// Take the held decode plan out of the context, leaving `None`.
2114    ///
2115    /// Paired with [`Self::put_decode_session`]. The take/put dance exists for
2116    /// the borrow checker: the persistent forward needs `&mut InferenceContext`
2117    /// AND `&mut Option<DecodeSession>` at once, which it cannot have if the
2118    /// session lives inside the context. Callers outside
2119    /// `forward_decode_step` should not need either method.
2120    pub fn take_decode_session(&mut self) -> Option<DecodeSession> {
2121        self.decode_session.take()
2122    }
2123
2124    /// Put a decode plan back into the context. See [`Self::take_decode_session`].
2125    pub fn put_decode_session(&mut self, session: Option<DecodeSession>) {
2126        self.decode_session = session;
2127    }
2128
2129    /// Whether a decode plan is currently held. Observability hook — a caller
2130    /// that has decoded at least one token through
2131    /// `LlamaModel::forward_decode_step` holds one.
2132    pub fn has_decode_session(&self) -> bool {
2133        self.decode_session.is_some()
2134    }
2135
2136    /// Insert a long-lived storage at `node_id`. The Arc survives
2137    /// across subsequent realize calls and is treated as pre-realized
2138    /// for any graph that references `node_id` as a Const-shaped leaf.
2139    ///
2140    /// Replaces any prior entry at `node_id`.
2141    pub fn insert(&mut self, node_id: NodeId, storage: Arc<RwLock<Storage>>) {
2142        self.persistent.insert(node_id, storage);
2143    }
2144
2145    /// Remove the persistent entry at `node_id`. Returns the Arc if
2146    /// one was present. After this, the next realize that touches
2147    /// `node_id` will re-fetch from the graph's storage_map (and fail
2148    /// if the slot isn't populated there either).
2149    pub fn remove(&mut self, node_id: NodeId) -> Option<Arc<RwLock<Storage>>> {
2150        self.persistent.remove(&node_id)
2151    }
2152
2153    /// Whether the persistent map has an entry at `node_id`.
2154    pub fn contains(&self, node_id: NodeId) -> bool {
2155        self.persistent.contains_key(&node_id)
2156    }
2157
2158    /// Borrow the persistent entry at `node_id` if present.
2159    pub fn get(&self, node_id: NodeId) -> Option<&Arc<RwLock<Storage>>> {
2160        self.persistent.get(&node_id)
2161    }
2162
2163    /// Number of persistent slots.
2164    pub fn len(&self) -> usize {
2165        self.persistent.len()
2166    }
2167
2168    pub fn is_empty(&self) -> bool {
2169        self.persistent.is_empty()
2170    }
2171
2172    /// Drop every persistent entry. The Arcs are released; storages
2173    /// whose only remaining reference was the context's are freed
2174    /// (host memory for CPU, deferred-on-stream for async backends).
2175    pub fn clear_persistent(&mut self) {
2176        self.persistent.clear();
2177    }
2178
2179    /// Realize a single target. The persistent map is seeded into
2180    /// the executor's input cache via `Arc::clone` (the persistent
2181    /// entries survive the call). Op::Const NodeIds in the graph
2182    /// that aren't already in the persistent map get uploaded fresh
2183    /// from `graph.storage_for(id)` per the existing pipelined-bridge
2184    /// pattern.
2185    pub fn realize_one_as<T: bytemuck::Pod + fuel_ir::WithDType>(
2186        &self,
2187        graph: &Arc<RwLock<Graph>>,
2188        target: NodeId,
2189    ) -> Result<Vec<T>> {
2190        self.realize_one_as_with_env::<T>(graph, target, &SymEnv::default())
2191    }
2192
2193    /// Env-carrying counterpart of [`Self::realize_one_as`]: supplies a
2194    /// per-pass [`SymEnv`] binding the runtime values of any `DynScalar`
2195    /// op params (Phase D symbolic extents — e.g. the decode KV-cache
2196    /// write offset `cached_len`). The env is **per-pass** (re-supplied
2197    /// every forward step) while the persistent map is **per-session**;
2198    /// an empty env is byte-identical to [`Self::realize_one_as`].
2199    pub fn realize_one_as_with_env<T: bytemuck::Pod + fuel_ir::WithDType>(
2200        &self,
2201        graph: &Arc<RwLock<Graph>>,
2202        target: NodeId,
2203        env: &SymEnv,
2204    ) -> Result<Vec<T>> {
2205        crate::pipelined_bridge::realize_one_as_with_initial_env::<T>(
2206            graph,
2207            target,
2208            &self.device,
2209            self.cloned_persistent(),
2210            env,
2211        )
2212    }
2213
2214    /// Phase D · D2a — first-realize that ALSO captures the reusable
2215    /// optimize artifacts for a later prebuilt (plan-once) re-realize.
2216    ///
2217    /// Runs the full `prepare` + `optimize_graph` + dispatch path ONCE (like
2218    /// [`Self::realize_one_as_with_env`]) and returns
2219    /// `(effective_target, OptimizedGraph, result)`. The caller (D2b's
2220    /// `DecodeSession`) holds the `effective_target` (the spliced D2H
2221    /// `Op::Copy` root) + the cached `OptimizedGraph` and feeds them to
2222    /// [`Self::realize_prebuilt_as_with_env`] on later tokens to SKIP the
2223    /// re-plan. See [`crate::pipelined_bridge::prebuild_optimized_env`].
2224    pub fn prebuild_optimized_as_with_env<T: bytemuck::Pod + fuel_ir::WithDType>(
2225        &self,
2226        graph: &Arc<RwLock<Graph>>,
2227        target: NodeId,
2228        env: &SymEnv,
2229    ) -> Result<(NodeId, fuel_dispatch::optimize::OptimizedGraph, Vec<T>)> {
2230        crate::pipelined_bridge::prebuild_optimized_env::<T>(
2231            graph,
2232            target,
2233            &self.device,
2234            self.cloned_persistent(),
2235            env,
2236        )
2237    }
2238
2239    /// Phase D · D2b — first-realize that captures the reusable optimize
2240    /// artifacts AND the full realized [`StorageCache`] (all weight
2241    /// Consts uploaded by `build_const_cache`, merged over this context's
2242    /// persistent Arcs). The [`DecodeSession`] holds the cache so later
2243    /// prebuilt realizes (which SKIP the const-cache walk) still resolve
2244    /// every weight Const. Returns
2245    /// `(effective_target, OptimizedGraph, full_cache, result)`.
2246    pub fn prebuild_optimized_capturing_as_with_env<T: bytemuck::Pod + fuel_ir::WithDType>(
2247        &self,
2248        graph: &Arc<RwLock<Graph>>,
2249        target: NodeId,
2250        env: &SymEnv,
2251    ) -> Result<(
2252        NodeId,
2253        fuel_dispatch::optimize::OptimizedGraph,
2254        StorageCache,
2255        Vec<T>,
2256    )> {
2257        crate::pipelined_bridge::prebuild_optimized_env_capturing_cache::<T>(
2258            graph,
2259            target,
2260            &self.device,
2261            self.cloned_persistent(),
2262            env,
2263        )
2264    }
2265
2266    /// Phase D · D2a — plan-once re-realize over a graph already prepared +
2267    /// optimized by a prior [`Self::prebuild_optimized_as_with_env`]. Skips
2268    /// BOTH `prepare` (no D2H re-splice / const-cache walk) AND
2269    /// `optimize_graph` (no re-plan / double-insert). Re-binds only the
2270    /// per-call `StorageCache` (this context's persistent Arcs, incl. the
2271    /// re-bound per-token data Consts) + `env`, then dispatches straight to
2272    /// the executor. `effective_target` + `optimized` come from the prebuild.
2273    ///
2274    /// A `TopologyChanged` error surfaces to the caller (typed, not
2275    /// retried) — the cached view is stale; invalidate + rebuild the session.
2276    pub fn realize_prebuilt_as_with_env<T: bytemuck::Pod + fuel_ir::WithDType>(
2277        &self,
2278        graph: &Arc<RwLock<Graph>>,
2279        effective_target: NodeId,
2280        optimized: &fuel_dispatch::optimize::OptimizedGraph,
2281        env: &SymEnv,
2282    ) -> Result<Vec<T>> {
2283        crate::pipelined_bridge::realize_one_prebuilt_env::<T>(
2284            graph,
2285            effective_target,
2286            optimized,
2287            &self.device,
2288            self.cloned_persistent(),
2289            env,
2290        )
2291    }
2292
2293    /// Multi-target counterpart of [`Self::realize_one_as`].
2294    pub fn realize_many_as<T: bytemuck::Pod + fuel_ir::WithDType>(
2295        &self,
2296        graph: &Arc<RwLock<Graph>>,
2297        targets: &[NodeId],
2298    ) -> Result<Vec<Vec<T>>> {
2299        self.realize_many_as_with_env::<T>(graph, targets, &SymEnv::default())
2300    }
2301
2302    /// Env-carrying counterpart of [`Self::realize_many_as`] (Phase D
2303    /// symbolic extents). An empty env is byte-identical to
2304    /// [`Self::realize_many_as`].
2305    pub fn realize_many_as_with_env<T: bytemuck::Pod + fuel_ir::WithDType>(
2306        &self,
2307        graph: &Arc<RwLock<Graph>>,
2308        targets: &[NodeId],
2309        env: &SymEnv,
2310    ) -> Result<Vec<Vec<T>>> {
2311        crate::pipelined_bridge::realize_many_as_with_initial_env::<T>(
2312            graph,
2313            targets,
2314            &self.device,
2315            self.cloned_persistent(),
2316            env,
2317        )
2318    }
2319
2320    /// Realize-split counterpart of [`Self::realize_many_as`]: the
2321    /// first `n_host` targets are downloaded to host `Vec<T>`s, the
2322    /// rest come back as device-resident `(storage, layout)` pairs —
2323    /// no D2H for results that feed the next step's graph. See
2324    /// [`crate::pipelined_bridge::realize_split_as_with_initial`].
2325    pub fn realize_split_as<T: bytemuck::Pod + fuel_ir::WithDType>(
2326        &self,
2327        graph: &Arc<RwLock<Graph>>,
2328        targets: &[NodeId],
2329        n_host: usize,
2330    ) -> Result<crate::pipelined_bridge::SplitRealizeOutput<T>> {
2331        crate::pipelined_bridge::realize_split_as_with_initial::<T>(
2332            graph,
2333            targets,
2334            n_host,
2335            &self.device,
2336            self.cloned_persistent(),
2337        )
2338    }
2339
2340    /// Build a [`StorageCache`] containing Arc-clones of every
2341    /// persistent entry. The clone is cheap (Arc refcount bumps); the
2342    /// returned `StorageCache` is consumed by the realize call but
2343    /// the original Arcs in `self.persistent` survive.
2344    fn cloned_persistent(&self) -> StorageCache {
2345        let mut out = StorageCache::with_capacity(self.persistent.len());
2346        for (id, arc) in &self.persistent {
2347            out.insert(*id, Arc::clone(arc));
2348        }
2349        out
2350    }
2351}
2352
2353// ===========================================================================
2354// Tests
2355// ===========================================================================
2356
2357#[cfg(test)]
2358mod tests {
2359    use super::*;
2360    use fuel_graph::{Node, Op};
2361    use fuel_ir::{DType, Shape};
2362
2363    /// `InferenceContext::insert` + immediate retrieval round-trips.
2364    #[test]
2365    fn context_insert_retrieve() {
2366        let mut ctx = InferenceContext::new(Device::cpu());
2367        let storage = fuel_memory::from_slice_cpu(&[1.0_f32, 2.0, 3.0]);
2368        let arc = Arc::new(RwLock::new(storage));
2369        let id = NodeId(42);
2370
2371        assert!(!ctx.contains(id));
2372        assert_eq!(ctx.len(), 0);
2373
2374        ctx.insert(id, Arc::clone(&arc));
2375
2376        assert!(ctx.contains(id));
2377        assert_eq!(ctx.len(), 1);
2378        // The returned Arc is the same as what we inserted.
2379        let retrieved = ctx.get(id).expect("just inserted");
2380        assert!(Arc::ptr_eq(retrieved, &arc));
2381    }
2382
2383    /// Inserted Arcs survive across realize calls. Build a minimal
2384    /// 3-node graph (Const + Const + Add). Both Const slots come
2385    /// from the context's persistent map (no graph.storage_map
2386    /// involvement). Realize twice; verify the persistent Arcs are
2387    /// still held by the context after each call.
2388    #[test]
2389    fn context_persistent_arc_survives_realize() {
2390        let lhs_arc = Arc::new(RwLock::new(fuel_memory::from_slice_cpu(&[
2391            1.0_f32, 2.0, 3.0,
2392        ])));
2393        let rhs_arc = Arc::new(RwLock::new(fuel_memory::from_slice_cpu(&[
2394            10.0_f32, 20.0, 30.0,
2395        ])));
2396
2397        let graph = Arc::new(RwLock::new(Graph::new()));
2398        let (lhs_id, rhs_id, add_id) = {
2399            let mut g = graph.write().unwrap();
2400            let lhs = g.push(Node {
2401                op: Op::Const,
2402                inputs: vec![],
2403                shape: Shape::from_dims(&[3]),
2404                dtype: DType::F32,
2405            });
2406            let rhs = g.push(Node {
2407                op: Op::Const,
2408                inputs: vec![],
2409                shape: Shape::from_dims(&[3]),
2410                dtype: DType::F32,
2411            });
2412            let add = g.push(Node {
2413                op: Op::Add,
2414                inputs: vec![lhs, rhs],
2415                shape: Shape::from_dims(&[3]),
2416                dtype: DType::F32,
2417            });
2418            (lhs, rhs, add)
2419        };
2420
2421        let mut ctx = InferenceContext::new(Device::cpu());
2422        ctx.insert(lhs_id, Arc::clone(&lhs_arc));
2423        ctx.insert(rhs_id, Arc::clone(&rhs_arc));
2424
2425        // Before realize: each Arc held by the test (1) + context (1) = 2.
2426        assert_eq!(Arc::strong_count(&lhs_arc), 2);
2427        assert_eq!(Arc::strong_count(&rhs_arc), 2);
2428
2429        let out1 = ctx
2430            .realize_one_as::<f32>(&graph, add_id)
2431            .expect("first realize");
2432        assert_eq!(out1, vec![11.0, 22.0, 33.0]);
2433
2434        // After first realize: same count. The realize call's
2435        // cloned_persistent() bumped the counts to 3 during the call
2436        // and dropped them back to 2 when the call returned.
2437        assert_eq!(Arc::strong_count(&lhs_arc), 2);
2438        assert_eq!(Arc::strong_count(&rhs_arc), 2);
2439
2440        // Realize again — same Arcs get reused without re-upload.
2441        // This is the autoregressive-decoding pattern: weights stay
2442        // resident across forward passes.
2443        let out2 = ctx
2444            .realize_one_as::<f32>(&graph, add_id)
2445            .expect("second realize");
2446        assert_eq!(out2, vec![11.0, 22.0, 33.0]);
2447        assert_eq!(Arc::strong_count(&lhs_arc), 2);
2448        assert_eq!(Arc::strong_count(&rhs_arc), 2);
2449    }
2450
2451    /// Read back layer `layer_idx`'s `slot` buffer in full, as a flat
2452    /// row-major `[n_kv_heads * max_seq_len * head_dim]` f32 vec — test-only
2453    /// readback counterpart to [`KvCache::write_bytes`], deliberately NOT
2454    /// sliced, so a wrong write RANGE shows up as corruption anywhere in the
2455    /// buffer rather than being hidden by only reading the written window.
2456    fn read_kv_layer_f32(
2457        cache: &KvCache,
2458        layer_idx: usize,
2459        slot: KvSlot,
2460        device: &Device,
2461    ) -> Vec<f32> {
2462        let max_seq_len = cache.max_seq_len.expect("with_capacity cache");
2463        let full_shape = Shape::from_dims(&[1, cache.n_kv_heads, max_seq_len, cache.head_dim]);
2464        let buf = match slot {
2465            KvSlot::K => Arc::clone(&cache.layer(layer_idx).unwrap().k),
2466            KvSlot::V => Arc::clone(&cache.layer(layer_idx).unwrap().v),
2467        };
2468        let anchor =
2469            crate::lazy::Tensor::from_f32(vec![0.0f32], Shape::from_dims(&[1]), device).unwrap();
2470        let dest = anchor.const_placeholder_like(full_shape, DType::F32);
2471        let mut sc = StorageCache::new();
2472        sc.insert(dest.node_id(), buf);
2473        let bytes = crate::pipelined_bridge::realize_one_bytes_with_initial(
2474            dest.graph_handle(),
2475            dest.node_id(),
2476            device,
2477            sc,
2478        )
2479        .expect("readback realize");
2480        bytemuck::try_cast_slice::<u8, f32>(&bytes)
2481            .expect("f32 cast")
2482            .to_vec()
2483    }
2484
2485    /// Board item 86 (OverMind self-fork / Lightbulb KV-cache handoff design):
2486    /// [`KvCache::write_bytes`] must write EXACTLY the `[start, start+n)`
2487    /// position range on the right axis (2, head-major) and leave every other
2488    /// position untouched — a wrong range (e.g. accidentally position-major,
2489    /// axis 0) would either corrupt unrelated positions or silently land on
2490    /// the wrong head/position entirely, and reading back the FULL buffer
2491    /// (not just the written window) is what catches either failure.
2492    #[test]
2493    fn write_bytes_lands_at_the_correct_position_and_nowhere_else() {
2494        let device = Device::cpu();
2495        let (n_layers, n_kv_heads, head_dim, max_seq_len) = (1, 2, 3, 5);
2496        let cache = KvCache::with_capacity(
2497            n_layers,
2498            n_kv_heads,
2499            head_dim,
2500            max_seq_len,
2501            DType::F32,
2502            &device,
2503        )
2504        .expect("with_capacity");
2505
2506        let alloc_before = cache.alloc_id();
2507        let loc_before = cache.device_location();
2508
2509        // Write positions [1, 3) (n=2) with a distinctive, non-zero pattern —
2510        // zero would be indistinguishable from the buffer's own zero-init.
2511        let (start, n) = (1usize, 2usize);
2512        let payload: Vec<f32> = (0..(n * n_kv_heads * head_dim))
2513            .map(|i| 1000.0 + i as f32)
2514            .collect();
2515        cache
2516            .write_bytes(
2517                0,
2518                KvSlot::K,
2519                start,
2520                n,
2521                bytemuck::cast_slice(&payload),
2522                &device,
2523            )
2524            .expect("write_bytes");
2525
2526        // THE CORE CLAIM: no Arc replacement happened, so neither identity
2527        // marker moved — this is what makes a held DecodeSession safe to
2528        // keep using without a rebind or rebuild.
2529        assert_eq!(
2530            cache.alloc_id(),
2531            alloc_before,
2532            "write_bytes must not re-mint alloc_id (that would mean the Arc was replaced)",
2533        );
2534        assert_eq!(
2535            cache.device_location(),
2536            loc_before,
2537            "write_bytes must not clear device_location (that would mean residency became unprovable)",
2538        );
2539
2540        let full = read_kv_layer_f32(&cache, 0, KvSlot::K, &device);
2541        for h in 0..n_kv_heads {
2542            for p in 0..max_seq_len {
2543                for d in 0..head_dim {
2544                    let got = full[h * max_seq_len * head_dim + p * head_dim + d];
2545                    if (start..start + n).contains(&p) {
2546                        let local_p = p - start;
2547                        let want = 1000.0 + (h * n * head_dim + local_p * head_dim + d) as f32;
2548                        assert_eq!(
2549                            got, want,
2550                            "head {h} position {p} dim {d}: written value must land exactly here",
2551                        );
2552                    } else {
2553                        assert_eq!(
2554                            got,
2555                            0.0,
2556                            "head {h} position {p} dim {d}: outside [{start},{}) must stay \
2557                             untouched (zero-init) — a wrong write range would corrupt this",
2558                            start + n,
2559                        );
2560                    }
2561                }
2562            }
2563        }
2564    }
2565
2566    /// `write_bytes` is `with_capacity`-only (needs a fixed dtype + a stable
2567    /// buffer to write into) — a `with_dims` (grow-by-replacement) cache has
2568    /// neither, and must refuse with a typed error, never panic.
2569    #[test]
2570    fn write_bytes_refuses_a_with_dims_cache() {
2571        let device = Device::cpu();
2572        let cache = KvCache::with_dims(1, 2, 3);
2573        let err = cache
2574            .write_bytes(0, KvSlot::K, 0, 1, &[0u8; 24], &device)
2575            .expect_err("with_dims cache has no fixed dtype/capacity");
2576        assert!(
2577            err.to_string().contains("with_capacity"),
2578            "refusal must name the real constraint, got: {err}",
2579        );
2580    }
2581
2582    /// A range exceeding `max_seq_len` must refuse before touching anything —
2583    /// never silently clamp (which would write at the wrong position) and
2584    /// never panic.
2585    #[test]
2586    fn write_bytes_refuses_an_out_of_range_position() {
2587        let device = Device::cpu();
2588        let cache = KvCache::with_capacity(1, 2, 3, 5, DType::F32, &device).unwrap();
2589        let payload = vec![0.0f32; 2 * 2 * 3]; // n=2 positions, valid byte length
2590        let err = cache
2591            .write_bytes(
2592                0,
2593                KvSlot::K,
2594                4, // start=4, n=2 -> [4,6) exceeds max_seq_len=5
2595                2,
2596                bytemuck::cast_slice(&payload),
2597                &device,
2598            )
2599            .expect_err("range must be refused, not clamped");
2600        assert!(
2601            err.to_string().contains("max_seq_len"),
2602            "refusal must name the real constraint, got: {err}",
2603        );
2604    }
2605
2606    /// A `bytes` length that doesn't match `n * n_kv_heads * head_dim *
2607    /// dtype_size` must refuse — a mismatched length is the shape of exactly
2608    /// the bug this check exists to catch (a caller passing position-major
2609    /// pool bytes straight through without transposing to head-major).
2610    #[test]
2611    fn write_bytes_refuses_a_mismatched_byte_length() {
2612        let device = Device::cpu();
2613        let cache = KvCache::with_capacity(1, 2, 3, 5, DType::F32, &device).unwrap();
2614        let wrong_payload = vec![0.0f32; 5]; // not n_kv_heads*n*head_dim for any valid n
2615        let err = cache
2616            .write_bytes(
2617                0,
2618                KvSlot::K,
2619                0,
2620                1,
2621                bytemuck::cast_slice(&wrong_payload),
2622                &device,
2623            )
2624            .expect_err("mismatched byte length must be refused");
2625        assert!(
2626            err.to_string().contains("bytes"),
2627            "refusal must name the byte-length mismatch, got: {err}",
2628        );
2629    }
2630
2631    /// Phase D symbolic extents — the decode-shaped path: a persistent
2632    /// fixed-capacity buffer + an `Op::WriteSlice` whose start offset is
2633    /// a per-pass `SymEnv` binding (the KV-cache append at `cached_len`).
2634    /// `realize_one_as_with_env` threads the env from the session through
2635    /// the bridge to the executor; the width-2 slab lands at the bound
2636    /// offset (3), not the static placeholder (0). Re-realizing the SAME
2637    /// graph with a different binding lands the slab at the new offset —
2638    /// the input-independent-graph property persistent decode relies on.
2639    #[test]
2640    fn realize_one_as_with_env_resolves_write_slice_offset() {
2641        use fuel_ir::{DynScalar, SymId};
2642        let dest_arc = Arc::new(RwLock::new(fuel_memory::from_slice_cpu(&[0.0_f32; 6])));
2643        let src_arc = Arc::new(RwLock::new(fuel_memory::from_slice_cpu(&[7.0_f32, 8.0])));
2644        let graph = Arc::new(RwLock::new(Graph::new()));
2645        let sym = SymId(0);
2646        let (dest_id, src_id, ws_id) = {
2647            let mut g = graph.write().unwrap();
2648            let dest = g.push(Node {
2649                op: Op::Const,
2650                inputs: vec![],
2651                shape: Shape::from_dims(&[6]),
2652                dtype: DType::F32,
2653            });
2654            let src = g.push(Node {
2655                op: Op::Const,
2656                inputs: vec![],
2657                shape: Shape::from_dims(&[2]),
2658                dtype: DType::F32,
2659            });
2660            let ws = g.push(Node {
2661                op: Op::WriteSlice {
2662                    ranges: vec![(0, 2)],
2663                    dyn_offset: Some((0, DynScalar::Sym(sym))),
2664                },
2665                inputs: vec![dest, src],
2666                shape: Shape::from_dims(&[6]),
2667                dtype: DType::F32,
2668            });
2669            (dest, src, ws)
2670        };
2671        let mut ctx = InferenceContext::new(Device::cpu());
2672        ctx.insert(dest_id, Arc::clone(&dest_arc));
2673        ctx.insert(src_id, Arc::clone(&src_arc));
2674
2675        // Bind cached_len = 3: the slab must append at indices [3, 4].
2676        let mut env = SymEnv::new();
2677        env.bind(sym, 3).unwrap();
2678        let out = ctx
2679            .realize_one_as_with_env::<f32>(&graph, ws_id, &env)
2680            .expect("realize_one_as_with_env");
2681        assert_eq!(
2682            out,
2683            vec![0.0, 0.0, 0.0, 7.0, 8.0, 0.0],
2684            "KV slab must land at the SymEnv-bound cached_len=3, not the placeholder 0",
2685        );
2686
2687        // An empty env on a graph with an unbound dyn_offset surfaces a
2688        // typed error (never a panic) — the write-once contract's
2689        // "presence ⇒ produced" read at realize.
2690        let err = ctx.realize_one_as_with_env::<f32>(&graph, ws_id, &SymEnv::new());
2691        assert!(
2692            err.is_err(),
2693            "unbound cached_len must surface a typed error"
2694        );
2695    }
2696
2697    /// `KvCache::with_dims` produces a fresh cache of the right
2698    /// shape with all layers `None` and `cached_len = 0`.
2699    #[test]
2700    fn kv_cache_with_dims_constructs_fresh() {
2701        let cache = KvCache::with_dims(4, 8, 64);
2702        assert_eq!(cache.n_layers(), 4);
2703        assert_eq!(cache.cached_len, 0);
2704        assert_eq!(cache.n_kv_heads, 8);
2705        assert_eq!(cache.head_dim, 64);
2706        for li in 0..4 {
2707            assert!(cache.layer(li).is_none());
2708        }
2709    }
2710
2711    /// `KvCache::set_layer` + `layer` round-trip.
2712    #[test]
2713    fn kv_cache_set_and_get_layer() {
2714        let mut cache = KvCache::with_dims(2, 4, 8);
2715        let k_arc = Arc::new(RwLock::new(fuel_memory::from_slice_cpu(&[0.0_f32; 32])));
2716        let v_arc = Arc::new(RwLock::new(fuel_memory::from_slice_cpu(&[0.0_f32; 32])));
2717        let layout = Layout::contiguous(Shape::from_dims(&[1, 4, 1, 8]));
2718        cache.set_layer(
2719            0,
2720            KvLayer {
2721                k: Arc::clone(&k_arc),
2722                v: Arc::clone(&v_arc),
2723                k_layout: layout.clone(),
2724                v_layout: layout.clone(),
2725                k_version: 0,
2726                v_version: 0,
2727                k_authority: AuthorityState::Host,
2728                v_authority: AuthorityState::Host,
2729            },
2730        );
2731        let layer = cache.layer(0).expect("just set");
2732        assert!(Arc::ptr_eq(&layer.k, &k_arc));
2733        assert!(Arc::ptr_eq(&layer.v, &v_arc));
2734        assert_eq!(layer.k_version, 0);
2735        assert!(matches!(layer.k_authority, AuthorityState::Host));
2736        assert!(cache.layer(1).is_none());
2737    }
2738
2739    /// `KvCache::clear` drops all layers and resets `cached_len`.
2740    #[test]
2741    fn kv_cache_clear_drops_layers() {
2742        let mut cache = KvCache::with_dims(2, 4, 8);
2743        let k = Arc::new(RwLock::new(fuel_memory::from_slice_cpu(&[0.0_f32; 32])));
2744        let v = Arc::new(RwLock::new(fuel_memory::from_slice_cpu(&[0.0_f32; 32])));
2745        let layout = Layout::contiguous(Shape::from_dims(&[1, 4, 1, 8]));
2746        cache.set_layer(
2747            0,
2748            KvLayer {
2749                k: Arc::clone(&k),
2750                v: Arc::clone(&v),
2751                k_layout: layout.clone(),
2752                v_layout: layout,
2753                k_version: 1,
2754                v_version: 1,
2755                k_authority: AuthorityState::Host,
2756                v_authority: AuthorityState::Host,
2757            },
2758        );
2759        cache.cached_len = 7;
2760        // Before clear: cache holds an Arc to k → strong_count 2.
2761        assert_eq!(Arc::strong_count(&k), 2);
2762
2763        cache.clear();
2764
2765        assert!(cache.layer(0).is_none());
2766        assert_eq!(cache.cached_len, 0);
2767        // After clear: cache's Arc dropped → strong_count 1.
2768        assert_eq!(Arc::strong_count(&k), 1);
2769    }
2770
2771    /// `KvCache::truncate_to` updates `cached_len` only (Phase E.3.0
2772    /// limitation; pre-allocated buffer + WriteSlice handles the
2773    /// in-place truncate in Phase E.3.2).
2774    #[test]
2775    fn kv_cache_truncate_updates_cached_len() {
2776        let mut cache = KvCache::with_dims(2, 4, 8);
2777        cache.cached_len = 16;
2778        cache.truncate_to(10);
2779        assert_eq!(cache.cached_len, 10);
2780        // Truncating to a value >= current is a no-op.
2781        cache.truncate_to(15);
2782        assert_eq!(cache.cached_len, 10);
2783        // Truncating to zero clears the live extent.
2784        cache.truncate_to(0);
2785        assert_eq!(cache.cached_len, 0);
2786    }
2787
2788    /// Authority + version fields are placeholders today; just
2789    /// confirm they default sensibly via `KvLayer` construction.
2790    #[test]
2791    fn kv_layer_default_authority_is_host() {
2792        let arc = Arc::new(RwLock::new(fuel_memory::from_slice_cpu(&[0.0_f32; 4])));
2793        let layout = Layout::contiguous(Shape::from_dims(&[4]));
2794        let layer = KvLayer {
2795            k: Arc::clone(&arc),
2796            v: Arc::clone(&arc),
2797            k_layout: layout.clone(),
2798            v_layout: layout,
2799            k_version: 0,
2800            v_version: 0,
2801            k_authority: AuthorityState::Host,
2802            v_authority: AuthorityState::Host,
2803        };
2804        assert!(matches!(layer.k_authority, AuthorityState::Host));
2805        assert!(matches!(layer.v_authority, AuthorityState::Host));
2806        assert_eq!(layer.k_version, 0);
2807        assert_eq!(layer.v_version, 0);
2808    }
2809
2810    // ---- KvCache::with_capacity + accessors (Phase E.3.3.A) -----------------
2811
2812    /// `with_capacity` allocates n_layers fresh K + V buffers on the
2813    /// CPU device. Each buffer is `n_kv_heads * max_seq_len * head_dim`
2814    /// elements of the requested dtype, zero-initialized, with the
2815    /// `[1, n_kv_heads, max_seq_len, head_dim]` layout pre-populated.
2816    #[test]
2817    // `BackendStorage::{Cuda,Vulkan,Metal}` are cfg-gated, so at default
2818    // features `Cpu` is the only variant and this `if let` is irrefutable;
2819    // under --features cuda/vulkan/metal the pattern is refutable, so a plain
2820    // `let` would be an E0005 there. Allow the default-features lint.
2821    #[allow(irrefutable_let_patterns)]
2822    fn kv_cache_with_capacity_allocates_all_layers_on_cpu() {
2823        let device = Device::cpu();
2824        let cache = KvCache::with_capacity(
2825            /* n_layers     */ 3,
2826            /* n_kv_heads   */ 4,
2827            /* head_dim     */ 16,
2828            /* max_seq_len  */ 32,
2829            DType::F32,
2830            &device,
2831        )
2832        .expect("with_capacity");
2833
2834        assert_eq!(cache.n_layers(), 3);
2835        assert_eq!(cache.cached_len, 0);
2836        assert_eq!(cache.n_kv_heads, 4);
2837        assert_eq!(cache.head_dim, 16);
2838        assert_eq!(cache.max_seq_len, Some(32));
2839        assert_eq!(cache.dtype, Some(DType::F32));
2840
2841        // Every layer is populated and has the expected layout.
2842        for li in 0..3 {
2843            let layer = cache.layer(li).expect("layer populated");
2844            assert_eq!(layer.k_layout.shape().dims(), &[1, 4, 32, 16]);
2845            assert_eq!(layer.v_layout.shape().dims(), &[1, 4, 32, 16]);
2846            // Bytes are zero-initialized: 4 (n_kv) * 32 (seq) * 16 (head)
2847            // * 4 (f32) = 8192 bytes per slot.
2848            let k_guard = layer.k.read().unwrap();
2849            assert_eq!(k_guard.inner.len_bytes(), 8192);
2850            assert_eq!(k_guard.dtype, DType::F32);
2851            // Spot-check zero init.
2852            if let BackendStorage::Cpu(c) = &k_guard.inner {
2853                let typed: &[f32] = c.as_slice().unwrap();
2854                assert!(typed.iter().all(|&x| x == 0.0));
2855            } else {
2856                panic!("expected CPU storage");
2857            }
2858        }
2859    }
2860
2861    /// `slot_storage(li, K)` returns the same Arc as `layer(li).k`.
2862    /// Used by the forward path to bind cache storage to per-step
2863    /// Const nodes.
2864    #[test]
2865    fn kv_cache_slot_storage_returns_layer_arc() {
2866        let device = Device::cpu();
2867        let cache =
2868            KvCache::with_capacity(2, 4, 8, 16, DType::F32, &device).expect("with_capacity");
2869
2870        let k_via_layer = Arc::clone(&cache.layer(0).unwrap().k);
2871        let k_via_slot = cache.slot_storage(0, KvSlot::K).expect("layer 0 K");
2872        assert!(
2873            Arc::ptr_eq(&k_via_layer, &k_via_slot),
2874            "slot_storage should return the same Arc as layer().k",
2875        );
2876
2877        let v_via_layer = Arc::clone(&cache.layer(1).unwrap().v);
2878        let v_via_slot = cache.slot_storage(1, KvSlot::V).expect("layer 1 V");
2879        assert!(Arc::ptr_eq(&v_via_layer, &v_via_slot));
2880    }
2881
2882    /// `slot_storage` returns `None` for an unpopulated layer (the
2883    /// with_dims path before the first set_layer) and for an out-of-
2884    /// range layer index.
2885    #[test]
2886    fn kv_cache_slot_storage_returns_none_for_unpopulated() {
2887        let cache = KvCache::with_dims(2, 4, 8);
2888        assert!(cache.slot_storage(0, KvSlot::K).is_none());
2889        assert!(cache.slot_storage(1, KvSlot::V).is_none());
2890        // Out of range.
2891        assert!(cache.slot_storage(5, KvSlot::K).is_none());
2892    }
2893
2894    /// `bump_version` advances the per-slot version counter
2895    /// independently for K and V.
2896    #[test]
2897    fn kv_cache_bump_version_advances_per_slot() {
2898        let device = Device::cpu();
2899        let mut cache =
2900            KvCache::with_capacity(1, 4, 8, 16, DType::F32, &device).expect("with_capacity");
2901        assert_eq!(cache.layer(0).unwrap().k_version, 0);
2902        assert_eq!(cache.layer(0).unwrap().v_version, 0);
2903
2904        cache.bump_version(0, KvSlot::K);
2905        cache.bump_version(0, KvSlot::K);
2906        cache.bump_version(0, KvSlot::V);
2907
2908        assert_eq!(cache.layer(0).unwrap().k_version, 2);
2909        assert_eq!(cache.layer(0).unwrap().v_version, 1);
2910    }
2911
2912    /// Bumping a version on an unpopulated layer is a no-op (not a
2913    /// panic). The `with_dims` path leaves layers `None`; the
2914    /// forward code shouldn't blow up if it calls bump_version
2915    /// before the layer is wired.
2916    #[test]
2917    fn kv_cache_bump_version_unpopulated_is_noop() {
2918        let mut cache = KvCache::with_dims(2, 4, 8);
2919        cache.bump_version(0, KvSlot::K);
2920        cache.bump_version(99, KvSlot::V); // out of range, also no-op
2921        assert!(cache.layer(0).is_none());
2922    }
2923
2924    /// Sanity: bf16 capacity allocation produces 2-byte elements.
2925    #[test]
2926    fn kv_cache_with_capacity_bf16_byte_count() {
2927        let device = Device::cpu();
2928        let cache =
2929            KvCache::with_capacity(1, 2, 4, 8, DType::BF16, &device).expect("with_capacity bf16");
2930        let layer = cache.layer(0).unwrap();
2931        // 2 (n_kv) * 8 (seq) * 4 (head) * 2 (bf16) = 128 bytes per slot.
2932        assert_eq!(layer.k.read().unwrap().inner.len_bytes(), 128);
2933        assert_eq!(cache.dtype, Some(DType::BF16));
2934    }
2935
2936    // ---- LatentKvCache (MLA increment 4 — persistent N-slot decode cache) --
2937
2938    /// `LatentKvCache::with_capacity` allocates `n_layers * n_slots` fresh
2939    /// zero-initialized buffers on the CPU device, one per (layer, slot),
2940    /// shaped `[max_seq_len, ...slot_trailing[s]]`.
2941    #[test]
2942    // Irrefutable only at default features: `BackendStorage::{Cuda,Vulkan,
2943    // Metal}` are cfg-gated, so under --features cuda/vulkan/metal this pattern
2944    // is refutable and a plain `let` would be an E0005. Allow the default lint.
2945    #[allow(irrefutable_let_patterns)]
2946    fn latent_kv_cache_with_capacity_allocates_all_layers_on_cpu() {
2947        let device = Device::cpu();
2948        let cache = LatentKvCache::with_capacity(
2949            /* n_layers    */ 3,
2950            /* max_seq_len */ 8,
2951            // MLA-shaped: slot 0 latent trailing [5], slot 1 k_pe trailing [2].
2952            vec![vec![5], vec![2]],
2953            DType::F32,
2954            &device,
2955        )
2956        .expect("with_capacity");
2957
2958        assert_eq!(cache.n_layers(), 3);
2959        assert_eq!(cache.n_slots(), 2);
2960        assert_eq!(cache.cached_len, 0);
2961        assert_eq!(cache.max_seq_len, 8);
2962        assert_eq!(cache.dtype, DType::F32);
2963        assert_eq!(cache.slot_trailing(0), &[5]);
2964        assert_eq!(cache.slot_trailing(1), &[2]);
2965
2966        for li in 0..3 {
2967            let latent = cache.slot_storage(li, 0).expect("latent slot populated");
2968            let kpe = cache.slot_storage(li, 1).expect("k_pe slot populated");
2969            // 8 (max_seq) * 5 (trailing) * 4 (f32) = 160 bytes.
2970            assert_eq!(latent.read().unwrap().inner.len_bytes(), 160);
2971            // 8 (max_seq) * 2 (trailing) * 4 (f32) = 64 bytes.
2972            assert_eq!(kpe.read().unwrap().inner.len_bytes(), 64);
2973            let guard = latent.read().unwrap();
2974            if let BackendStorage::Cpu(c) = &guard.inner {
2975                let typed: &[f32] = c.as_slice().unwrap();
2976                assert!(typed.iter().all(|&x| x == 0.0));
2977            } else {
2978                panic!("expected CPU storage");
2979            }
2980        }
2981    }
2982
2983    /// `slot_storage` returns `None` for an out-of-range layer or slot
2984    /// index (never panics).
2985    #[test]
2986    fn latent_kv_cache_slot_storage_returns_none_for_oob() {
2987        let device = Device::cpu();
2988        let cache = LatentKvCache::with_capacity(2, 4, vec![vec![3]], DType::F32, &device)
2989            .expect("with_capacity");
2990        assert!(cache.slot_storage(5, 0).is_none()); // layer OOB
2991        assert!(cache.slot_storage(0, 5).is_none()); // slot OOB
2992    }
2993
2994    /// `bump_version` advances the per-slot version counter independently
2995    /// per slot; out-of-range (layer, slot) is a no-op, never a panic.
2996    #[test]
2997    fn latent_kv_cache_bump_version_advances_per_slot_and_oob_is_noop() {
2998        let device = Device::cpu();
2999        let mut cache =
3000            LatentKvCache::with_capacity(1, 4, vec![vec![2], vec![1]], DType::F32, &device)
3001                .expect("with_capacity");
3002
3003        cache.bump_version(0, 0);
3004        cache.bump_version(0, 0);
3005        cache.bump_version(0, 1);
3006        cache.bump_version(99, 0); // layer OOB, no-op
3007        cache.bump_version(0, 99); // slot OOB, no-op
3008
3009        let layer0 = cache.layers[0].as_ref().expect("layer 0 populated");
3010        assert_eq!(layer0[0].version, 2);
3011        assert_eq!(layer0[1].version, 1);
3012    }
3013
3014    /// Degenerate geometry (`n_layers == 0`, `max_seq_len == 0`, or no
3015    /// slots) surfaces as a typed `Err`, never a panic.
3016    #[test]
3017    fn latent_kv_cache_with_capacity_rejects_bad_geometry() {
3018        let device = Device::cpu();
3019        assert!(LatentKvCache::with_capacity(0, 4, vec![vec![2]], DType::F32, &device).is_err());
3020        assert!(LatentKvCache::with_capacity(1, 0, vec![vec![2]], DType::F32, &device).is_err());
3021        assert!(LatentKvCache::with_capacity(1, 4, vec![], DType::F32, &device).is_err());
3022    }
3023}