Skip to main content

cortiq_engine/
kv_cache.rs

1//! KV cache — per-layer, head-major storage.
2//!
3//! Layout: one contiguous `Vec<f32>` per KV head (`[pos × head_dim]`),
4//! so per-head attention reads a straight slice — no per-head gather
5//! copies per token. Dead GQA groups (all Q heads masked) store
6//! nothing at all: masked heads cost neither FLOPs nor memory.
7
8/// KV storage mode. `CMF_KV=q8` enables the q8_2f cache: an int8 row per
9/// (position, head) + an f32 scale per row + a per-channel scale field,
10/// frozen after WARMUP positions with retroactive requantization
11/// (D4: "KV-quant 2f"). Memory ×~3.7 smaller than f32.
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub enum KvMode {
14    F32,
15    /// Quantized components: (K, V) — sensitivity diagnostics.
16    Q8 {
17        k: bool,
18        v: bool,
19    },
20}
21
22impl KvMode {
23    pub fn from_env() -> Self {
24        match std::env::var("CMF_KV").as_deref() {
25            Ok("q8") | Ok("q8_2f") => KvMode::Q8 { k: true, v: true },
26            Ok("q8k") => KvMode::Q8 { k: true, v: false },
27            Ok("q8v") => KvMode::Q8 { k: false, v: true },
28            _ => KvMode::F32,
29        }
30    }
31
32    fn quant_k(self) -> bool {
33        matches!(self, KvMode::Q8 { k: true, .. })
34    }
35
36    fn quant_v(self) -> bool {
37        matches!(self, KvMode::Q8 { v: true, .. })
38    }
39}
40
41/// Positions before freezing the per-channel field (2f): before — col ≡ 1,
42/// after — col = RMS over channels of the stored rows, old rows are requantized.
43const KV_COL_WARMUP: usize = 64;
44
45/// K-rows are quantized in groups of 32 channels (scale per group):
46/// attention logits are sensitive to the dot-product error, per-group scales
47/// localize it along RoPE bands (35B: +4.6% PPL with a per-row scale
48/// → target <1% with a per-group one). V — per-row scale (measured +0.56%).
49const KV_K_GROUP: usize = 32;
50
51/// Per-layer O(1) Nyström attention state (runtime `attn_type`
52/// override — spec §7 presence-driven pattern, no format change).
53///
54/// Collecting: the prompt pass still runs EXACT cache attention (the
55/// prefill outputs feed the residual stream, so they cannot be
56/// deferred) while the per-position rotated queries are buffered;
57/// `o1_seal()` then freezes landmarks + M from the full prompt, replays
58/// it into per-KV-group streaming states, and DROPS the full KV.
59/// Sealed: decode replaces cache attention with
60/// `NystromState::step_group()`.
61#[derive(Debug, Clone)]
62pub enum O1State {
63    Collecting {
64        m: usize,
65        w: usize,
66        sink: usize,
67        rect: crate::nystrom::O1Rect,
68        /// Optional completed-row barrier. `None` means the caller will
69        /// request a full-prompt seal; `Some(B)` keeps a short prompt exact
70        /// until the first skeleton-safe boundary B.
71        seal_at: Option<usize>,
72        /// Rotated post-norm queries, `[pos × num_heads × head_dim]`.
73        q_buf: Vec<f32>,
74    },
75    /// One state per KV GROUP, each holding its group's Q heads. The
76    /// exact window / sinks / K̃ are stored once per group (every Q head
77    /// of the group reads the same k/v rows); only the far field, Q̃ and
78    /// M — the query-dependent pieces — stay per Q head. See
79    /// `NystromState` for which piece is which and why.
80    Sealed {
81        groups: Vec<crate::nystrom::NystromState>,
82    },
83}
84
85/// KV cache for a single layer, head-major.
86#[derive(Debug, Clone)]
87pub struct LayerKvCache {
88    pub mode: KvMode,
89    /// Per-KV-head keys: `k[h]` is `[seq_len × head_dim]` (empty if head is dead).
90    k: Vec<Vec<f32>>,
91    /// Per-KV-head values, same layout.
92    v: Vec<Vec<f32>>,
93    /// q8 storage (mode == Q8_2F): int8 rows + f32 scale per row.
94    kq: Vec<Vec<i8>>,
95    ks: Vec<Vec<f32>>,
96    vq: Vec<Vec<i8>>,
97    vs: Vec<Vec<f32>>,
98    /// Per-channel scale fields per head [head_dim]; empty until frozen.
99    kcol: Vec<Vec<f32>>,
100    vcol: Vec<Vec<f32>>,
101    /// Accumulated attention mass per stored position: importance of a
102    /// position is how much probability mass reads it.
103    imp: Vec<f32>,
104    /// Positions appended so far (grows once per token, dead heads included).
105    pub seq_len: usize,
106    pub num_kv_heads: usize,
107    pub head_dim: usize,
108    /// Linear-core recurrent state S (vmf_phase), f64; empty on full layers.
109    pub linear_state: Vec<f32>,
110    /// Tentative lane-2 state during speculative verify.
111    pub linear_scratch: Vec<f32>,
112    /// O(1) Nyström override (None = plain cache attention).
113    pub o1: Option<O1State>,
114    /// A deferred seal failure is terminal for the current request. The
115    /// attention functions return only a hidden vector, so the pipeline
116    /// consumes this side channel at its next forward boundary.
117    o1_error: Option<String>,
118    /// Set when a collecting state actually becomes sealed. Pipeline owns
119    /// the epoch bump and consumes this bit after a complete forward.
120    o1_transitioned: bool,
121}
122
123impl LayerKvCache {
124    pub fn new(num_kv_heads: usize, head_dim: usize) -> Self {
125        Self {
126            mode: KvMode::from_env(),
127            k: vec![Vec::new(); num_kv_heads],
128            v: vec![Vec::new(); num_kv_heads],
129            kq: vec![Vec::new(); num_kv_heads],
130            ks: vec![Vec::new(); num_kv_heads],
131            vq: vec![Vec::new(); num_kv_heads],
132            vs: vec![Vec::new(); num_kv_heads],
133            kcol: vec![Vec::new(); num_kv_heads],
134            vcol: vec![Vec::new(); num_kv_heads],
135            imp: Vec::new(),
136            seq_len: 0,
137            num_kv_heads,
138            head_dim,
139            linear_state: Vec::new(),
140            linear_scratch: Vec::new(),
141            o1: None,
142            o1_error: None,
143            o1_transitioned: false,
144        }
145    }
146
147    /// Per-KV-head stored keys `[seq_len × head_dim]` (GPU token graph sync).
148    pub fn k_heads(&self) -> &[Vec<f32>] {
149        &self.k
150    }
151    /// Per-KV-head stored values `[seq_len × head_dim]`.
152    pub fn v_heads(&self) -> &[Vec<f32>] {
153        &self.v
154    }
155
156    // ── O(1) Nyström override ──
157
158    /// Arm query collection for a fresh prompt pass (a cleared cache).
159    pub fn o1_begin(&mut self, m: usize, w: usize, sink: usize, rect: crate::nystrom::O1Rect) {
160        self.o1_begin_with_boundary(m, w, sink, rect, None);
161    }
162
163    /// Arm query collection with an optional completed-row seal barrier.
164    /// The barrier is deliberately part of the existing collecting state:
165    /// no second history or scheduler is introduced for short prompts.
166    pub(crate) fn o1_begin_with_boundary(
167        &mut self,
168        m: usize,
169        w: usize,
170        sink: usize,
171        rect: crate::nystrom::O1Rect,
172        seal_at: Option<usize>,
173    ) {
174        self.o1 = Some(O1State::Collecting {
175            m,
176            w,
177            sink,
178            rect,
179            seal_at,
180            q_buf: Vec::new(),
181        });
182        self.o1_error = None;
183        self.o1_transitioned = false;
184    }
185
186    /// Record one position's rotated queries (`[num_heads × head_dim]`)
187    /// during the exact prompt pass. No-op unless collecting — the hook
188    /// sits inside qwen_attention so every prefill flavor (sequential,
189    /// batched) feeds the same trace.
190    pub fn o1_push_q(&mut self, q_all: &[f32]) {
191        if let Some(O1State::Collecting { q_buf, .. }) = &mut self.o1 {
192            q_buf.extend_from_slice(q_all);
193        }
194    }
195
196    pub fn o1_sealed(&self) -> bool {
197        matches!(self.o1, Some(O1State::Sealed { .. }))
198    }
199
200    /// Pending completed-row barrier, if any. A plain full-prompt seal has
201    /// no barrier until the caller asks to seal.
202    pub(crate) fn o1_pending_boundary(&self) -> Option<usize> {
203        match &self.o1 {
204            Some(O1State::Collecting { seal_at, .. }) => *seal_at,
205            _ => None,
206        }
207    }
208
209    /// Whether a batch of `count` exact rows would cross the deferred
210    /// boundary. This lets batched/pair callers split before row B rather
211    /// than appending exact KV past the point where conversion is required.
212    pub(crate) fn o1_boundary_crossed_by(&self, count: usize) -> bool {
213        let Some(target) = self.o1_pending_boundary() else {
214            return false;
215        };
216        target <= self.seq_len
217            || self
218                .seq_len
219                .checked_add(count)
220                .map_or(true, |next| next >= target)
221    }
222
223    pub(crate) fn take_o1_transition(&mut self) -> bool {
224        std::mem::take(&mut self.o1_transitioned)
225    }
226
227    pub(crate) fn take_o1_error(&self) -> Option<String> {
228        // Error observation is deliberately non-consuming.  The error is the
229        // append guard for this request; removing it would let a caller that
230        // ignored the returned Err resume ordinary KV growth after the
231        // bounded transition dropped its overlay.  `clear()` is the explicit
232        // reset boundary that clears the latch.
233        self.o1_error.clone()
234    }
235
236    /// Abort a malformed deferred transition after attention has already
237    /// produced its current row. Clearing the exact storage and dropping
238    /// the overlay makes the state unrecoverable by continued decode; the
239    /// pipeline then routes through its normal graph/cancel cleanup.
240    pub(crate) fn o1_abort(&mut self, err: String) {
241        self.k.iter_mut().for_each(Vec::clear);
242        self.v.iter_mut().for_each(Vec::clear);
243        self.kq.iter_mut().for_each(Vec::clear);
244        self.ks.iter_mut().for_each(Vec::clear);
245        self.vq.iter_mut().for_each(Vec::clear);
246        self.vs.iter_mut().for_each(Vec::clear);
247        self.kcol.iter_mut().for_each(Vec::clear);
248        self.vcol.iter_mut().for_each(Vec::clear);
249        self.imp.clear();
250        self.o1 = None;
251        self.seq_len = 0;
252        self.o1_transitioned = false;
253        self.o1_error = Some(err);
254    }
255
256    /// Freeze the prompt into per-KV-group Nyström states and drop this
257    /// layer's full KV. Returns false while a short collecting layer is
258    /// below its deferred boundary; malformed prerequisites abort the
259    /// layer instead of silently resuming exact KV growth. The seal needs
260    /// f32 KV rows (`CMF_KV=q8` stores int8), every group densely stored, a
261    /// full q trace, and a GQA fan-out that actually divides.
262    pub fn o1_seal(&mut self, num_heads: usize) -> bool {
263        match self.o1_seal_checked(num_heads) {
264            Ok(sealed) => sealed,
265            Err(err) => {
266                tracing::error!("o1: seal aborted: {err}");
267                self.o1_abort(err);
268                false
269            }
270        }
271    }
272
273    /// Checked seal implementation. Validation happens while the collecting
274    /// state and full KV are still intact; only a valid completed boundary
275    /// is allowed to destructively convert them.
276    pub(crate) fn o1_seal_checked(&mut self, num_heads: usize) -> Result<bool, String> {
277        if let Some(err) = self.o1_error.clone() {
278            return Err(err);
279        }
280        // Idempotent: sealing a sealed (or plain) layer must not disturb its
281        // state, and a plain layer is not an O(1) participant.
282        if !matches!(self.o1, Some(O1State::Collecting { .. })) {
283            return Ok(self.o1_sealed());
284        }
285        let (m, w, sink, requested_boundary, q_len) = match &self.o1 {
286            Some(O1State::Collecting {
287                m,
288                w,
289                sink,
290                rect: _,
291                seal_at,
292                q_buf,
293            }) => (*m, *w, *sink, *seal_at, q_buf.len()),
294            _ => unreachable!("checked above"),
295        };
296        let floor = crate::nystrom::o1_deferred_boundary(w, sink)
297            .ok_or_else(|| "o1 seal: w + sink + slack + 1 overflow".to_string())?;
298        let target = requested_boundary.unwrap_or(floor).max(floor);
299        let t = self.seq_len;
300        if t < target {
301            if let Some(O1State::Collecting { seal_at, .. }) = &mut self.o1 {
302                if *seal_at != Some(target) {
303                    *seal_at = Some(target);
304                    tracing::info!(
305                        "o1 deferred seal: current rows={t}, boundary={target} (floor={floor})"
306                    );
307                }
308            }
309            return Ok(false);
310        }
311
312        let hd = self.head_dim;
313        if t == 0 {
314            return Err("o1 seal: cannot seal an empty layer".into());
315        }
316        if self.mode != KvMode::F32 {
317            return Err("o1 seal: requires dense F32 KV storage".into());
318        }
319        if self.num_kv_heads == 0 || num_heads == 0 || num_heads % self.num_kv_heads != 0 {
320            return Err(format!(
321                "o1 seal: invalid GQA geometry num_heads={num_heads} num_kv_heads={}",
322                self.num_kv_heads
323            ));
324        }
325        let hpk = num_heads / self.num_kv_heads;
326        let expected_k = t
327            .checked_mul(hd)
328            .ok_or_else(|| "o1 seal: KV row length overflow".to_string())?;
329        let expected_q = expected_k
330            .checked_mul(num_heads)
331            .ok_or_else(|| "o1 seal: query trace length overflow".to_string())?;
332        if q_len != expected_q {
333            return Err(format!(
334                "o1 seal: query trace has {q_len} values, expected {expected_q}"
335            ));
336        }
337        if (0..self.num_kv_heads)
338            .any(|g| self.k[g].len() != expected_k || self.v[g].len() != expected_k)
339        {
340            return Err("o1 seal: KV heads are not densely populated".into());
341        }
342        if m < 4 || w == 0 {
343            return Err(format!("o1 seal: invalid geometry m={m} w={w}"));
344        }
345
346        let Some(O1State::Collecting {
347            m,
348            w,
349            sink,
350            rect,
351            q_buf,
352            ..
353        }) = self.o1.take()
354        else {
355            unreachable!("collecting state disappeared after validation");
356        };
357        let mut groups = Vec::with_capacity(self.num_kv_heads);
358        // Query trace is position-major; the state wants each head's
359        // queries contiguous, so transpose one group at a time.
360        let mut qh = vec![0.0f32; hpk * t * hd];
361        for g in 0..self.num_kv_heads {
362            for hh in 0..hpk {
363                let h = g * hpk + hh;
364                for p in 0..t {
365                    let src = (p * num_heads + h) * hd;
366                    let dst = (hh * t + p) * hd;
367                    qh[dst..dst + hd].copy_from_slice(&q_buf[src..src + hd]);
368                }
369            }
370            let qs: Vec<&[f32]> = (0..hpk)
371                .map(|hh| &qh[hh * t * hd..(hh + 1) * t * hd])
372                .collect();
373            let mut st = crate::nystrom::NystromState::new_group(m, w, sink, hpk).with_rect(rect);
374            st.prefill_group(&qs, &self.k[g], &self.v[g], t, hd, hd);
375            groups.push(st);
376        }
377        // The states now carry everything decode needs — release the
378        // O(context) storage (this is the memory claim, not a cosmetic).
379        for h in 0..self.num_kv_heads {
380            self.k[h] = Vec::new();
381            self.v[h] = Vec::new();
382        }
383        self.imp = Vec::new();
384        self.o1 = Some(O1State::Sealed { groups });
385        self.o1_transitioned = true;
386        Ok(true)
387    }
388
389    /// One decode step on a sealed layer: per KV group, insert the
390    /// group's fresh (k, v) ONCE and read every Q head's attention
391    /// output. Returns `[num_heads × head_dim]`. Head h belongs to group
392    /// h/hpk, so a group's Q heads are contiguous in `q_all`/`out` —
393    /// same math as the shared KV row the exact path appends once.
394    /// Device views of the sealed o1 groups, or None when o1 is not
395    /// sealed on this layer (or any group is in the degenerate
396    /// exact-only mode the GPU path does not carry).
397    pub fn o1_views(&self) -> Option<Vec<crate::nystrom::O1DeviceView<'_>>> {
398        let Some(O1State::Sealed { groups }) = &self.o1 else {
399            return None;
400        };
401        let views: Vec<_> = groups.iter().map(|g| g.device_view()).collect();
402        if views.iter().any(|v| v.exact_only) {
403            return None;
404        }
405        Some(views)
406    }
407
408    pub fn o1_step(
409        &mut self,
410        q_all: &[f32],
411        k_new: &[f32],
412        v_new: &[f32],
413        num_heads: usize,
414    ) -> Vec<f32> {
415        let hd = self.head_dim;
416        let hpk = num_heads / self.num_kv_heads.max(1);
417        let mut out = vec![0.0f32; num_heads * hd];
418        let Some(O1State::Sealed { groups }) = &mut self.o1 else {
419            debug_assert!(false, "o1_step on an unsealed layer");
420            return out;
421        };
422        for (g, st) in groups.iter_mut().enumerate() {
423            let (lo, hi) = (g * hpk * hd, (g + 1) * hpk * hd);
424            st.step_group(
425                &q_all[lo..hi],
426                &k_new[g * hd..(g + 1) * hd],
427                &v_new[g * hd..(g + 1) * hd],
428                &mut out[lo..hi],
429            );
430        }
431        // Track the true context depth for the honest memory/seq report
432        // (nothing is stored per position — the state is O(1)).
433        self.seq_len += 1;
434        out
435    }
436
437    /// Bytes held by the O(1) override (query trace while collecting,
438    /// per-KV-group states once sealed).
439    pub fn o1_memory_bytes(&self) -> usize {
440        match &self.o1 {
441            Some(O1State::Collecting { q_buf, .. }) => q_buf.len() * std::mem::size_of::<f32>(),
442            Some(O1State::Sealed { groups }) => groups.iter().map(|s| s.memory_bytes()).sum(),
443            None => 0,
444        }
445    }
446
447    /// Quantize one row against the per-channel field (empty col = 1);
448    /// `group` — elements per scale (the whole row or KV_K_GROUP).
449    fn quant_row(row: &[f32], col: &[f32], q: &mut Vec<i8>, sc: &mut Vec<f32>, group: usize) {
450        let mut resid = vec![0.0f32; row.len()];
451        for (d, &x) in row.iter().enumerate() {
452            resid[d] = if col.is_empty() { x } else { x / col[d] };
453        }
454        for g0 in (0..row.len()).step_by(group) {
455            let g1 = (g0 + group).min(row.len());
456            let mut absmax = 0.0f32;
457            for &r in &resid[g0..g1] {
458                absmax = absmax.max(r.abs());
459            }
460            let s = (absmax / 127.0).max(1e-12);
461            sc.push(s);
462            for &r in &resid[g0..g1] {
463                q.push((r / s).round().clamp(-127.0, 127.0) as i8);
464            }
465        }
466    }
467
468    /// Freeze the 2f field: col = RMS of channels over stored rows, old
469    /// rows are requantized against the new field (once per conversation).
470    fn freeze_cols(&mut self) {
471        let hd = self.head_dim;
472        let ngk = hd.div_ceil(KV_K_GROUP);
473        for h in 0..self.num_kv_heads {
474            for (qv, sv, colv, group) in [
475                (
476                    &mut self.kq[h],
477                    &mut self.ks[h],
478                    &mut self.kcol[h],
479                    KV_K_GROUP,
480                ),
481                (&mut self.vq[h], &mut self.vs[h], &mut self.vcol[h], hd),
482            ] {
483                let spp = if group == hd { 1 } else { ngk }; // scales per position
484                let n = sv.len() / spp;
485                if n == 0 {
486                    continue;
487                }
488                // Dequantize to f32, RMS over channels, requantize.
489                let mut rows = vec![0.0f32; n * hd];
490                for p in 0..n {
491                    for d in 0..hd {
492                        rows[p * hd + d] = qv[p * hd + d] as f32 * sv[p * spp + d / group];
493                    }
494                }
495                let mut col = vec![0.0f32; hd];
496                for p in 0..n {
497                    for d in 0..hd {
498                        col[d] += rows[p * hd + d] * rows[p * hd + d];
499                    }
500                }
501                for c in col.iter_mut() {
502                    *c = (*c / n as f32).sqrt().max(1e-6);
503                }
504                qv.clear();
505                sv.clear();
506                for p in 0..n {
507                    Self::quant_row(&rows[p * hd..(p + 1) * hd], &col, qv, sv, group);
508                }
509                *colv = col;
510            }
511        }
512    }
513
514    /// Append K/V for one position. `k_new`/`v_new` are
515    /// `[num_kv_heads × head_dim]`; heads with `alive[h] == false` are
516    /// skipped (their slices stay empty).
517    pub fn append(&mut self, k_new: &[f32], v_new: &[f32], alive: &[bool]) {
518        // A failed bounded transition is terminal until the sequence is
519        // cleared. Do not let an ignored boolean/result resume plain KV
520        // growth after the O(1) collector has aborted.
521        if self.o1_error.is_some() {
522            return;
523        }
524        debug_assert_eq!(k_new.len(), self.num_kv_heads * self.head_dim);
525        debug_assert_eq!(v_new.len(), self.num_kv_heads * self.head_dim);
526        // Freeze the 2f field AT THE START of append: only rows that
527        // survived verify are visible (a rejected lane-2 draft does not
528        // pollute the field — found in review), and the threshold uses >=
529        // rather than strict equality (in small windows eviction may
530        // oscillate across 64).
531        if matches!(self.mode, KvMode::Q8 { .. })
532            && self.seq_len >= KV_COL_WARMUP
533            && self.kcol.iter().all(Vec::is_empty)
534            && self.vcol.iter().all(Vec::is_empty)
535        {
536            self.freeze_cols();
537        }
538        for h in 0..self.num_kv_heads {
539            if !alive.get(h).copied().unwrap_or(true) {
540                continue;
541            }
542            let s = h * self.head_dim;
543            if self.mode.quant_k() {
544                Self::quant_row(
545                    &k_new[s..s + self.head_dim],
546                    &self.kcol[h],
547                    &mut self.kq[h],
548                    &mut self.ks[h],
549                    KV_K_GROUP,
550                );
551            } else {
552                self.k[h].extend_from_slice(&k_new[s..s + self.head_dim]);
553            }
554            if self.mode.quant_v() {
555                Self::quant_row(
556                    &v_new[s..s + self.head_dim],
557                    &self.vcol[h],
558                    &mut self.vq[h],
559                    &mut self.vs[h],
560                    self.head_dim,
561                );
562            } else {
563                self.v[h].extend_from_slice(&v_new[s..s + self.head_dim]);
564            }
565        }
566        self.imp.push(0.0);
567        self.seq_len += 1;
568    }
569
570    /// Per-head attention over its own storage: the f32 branch is
571    /// bit-for-bit equal to attention_head() over slices; the q8 branch
572    /// computes score = s_k·⟨q⊙col_k, k_q⟩ and the weighted sum of V in i8
573    /// with f32 accumulation. Returns (output [head_dim], probs [stored]).
574    pub fn attend(&self, q: &[f32], kv_head: usize) -> (Vec<f32>, Vec<f32>) {
575        let hd = self.head_dim;
576        if self.mode == KvMode::F32 {
577            let stored = self.k[kv_head].len() / hd;
578            return crate::attention::attention_head(
579                q,
580                &self.k[kv_head],
581                &self.v[kv_head],
582                hd,
583                stored,
584            );
585        }
586        let stored = self.head_len(kv_head);
587        let scale = 1.0 / (hd as f32).sqrt();
588        let mut scores = vec![0.0f32; stored];
589        if self.mode.quant_k() {
590            let (kq, ks) = (&self.kq[kv_head], &self.ks[kv_head]);
591            // q ⊙ col_k — once per call.
592            let kcol = &self.kcol[kv_head];
593            let mut qc = vec![0.0f32; hd];
594            for d in 0..hd {
595                qc[d] = if kcol.is_empty() {
596                    q[d]
597                } else {
598                    q[d] * kcol[d]
599                };
600            }
601            let ng = hd.div_ceil(KV_K_GROUP);
602            for p in 0..stored {
603                let row = &kq[p * hd..(p + 1) * hd];
604                // SAFETY: i8 and u8 share layout; dot_i8_f32 reads the
605                // bytes back as i8.
606                let row_u8 =
607                    unsafe { std::slice::from_raw_parts(row.as_ptr() as *const u8, row.len()) };
608                let mut dot = 0.0f32;
609                for g in 0..ng {
610                    let g0 = g * KV_K_GROUP;
611                    let g1 = (g0 + KV_K_GROUP).min(hd);
612                    dot +=
613                        crate::qtensor::dot_i8_f32(&row_u8[g0..g1], &qc[g0..g1]) * ks[p * ng + g];
614                }
615                scores[p] = dot * scale;
616            }
617        } else {
618            let k = &self.k[kv_head];
619            for p in 0..stored {
620                let row = &k[p * hd..(p + 1) * hd];
621                scores[p] = crate::attention::dot_f32(q, row) * scale;
622            }
623        }
624        let max_score = scores.iter().cloned().fold(f32::NEG_INFINITY, f32::max);
625        let mut sum = 0.0f32;
626        for s in scores.iter_mut() {
627            *s = (*s - max_score).exp();
628            sum += *s;
629        }
630        if sum > 0.0 {
631            for s in scores.iter_mut() {
632                *s /= sum;
633            }
634        }
635        let mut acc = vec![0.0f32; hd];
636        if self.mode.quant_v() {
637            let (vq, vs) = (&self.vq[kv_head], &self.vs[kv_head]);
638            for p in 0..stored {
639                let w = scores[p] * vs[p];
640                if w.abs() < 1e-12 {
641                    continue;
642                }
643                crate::qtensor::axpy_i8_f32(&mut acc, &vq[p * hd..(p + 1) * hd], w);
644            }
645            let vcol = &self.vcol[kv_head];
646            if !vcol.is_empty() {
647                for d in 0..hd {
648                    acc[d] *= vcol[d];
649                }
650            }
651        } else {
652            let v = &self.v[kv_head];
653            for p in 0..stored {
654                let w = scores[p];
655                if w.abs() < 1e-12 {
656                    continue;
657                }
658                crate::attention::axpy_f32(&mut acc, &v[p * hd..(p + 1) * hd], w);
659            }
660        }
661        (acc, scores)
662    }
663
664    /// Grouped GQA attention: all Q-heads of one KV group in a single
665    /// pass over the stored K rows and a single pass over the V rows
666    /// (per-head `attend` re-read the shared group storage
667    /// heads_per_kv times — roadmap §3 P1). Per-head score order,
668    /// softmax and V accumulation are IDENTICAL to `attend`, so each
669    /// head's output is bit-for-bit the same.
670    ///
671    /// `q_group`: `[n_heads_in_group × head_dim]` (global head order);
672    /// `out`: same shape; `imp_acc[0..stored]` accumulates the probabilities
673    /// of every head (attention importance), matching the caller's former loop.
674    /// `scale` is the score scale (1/√hd unless the arch overrides);
675    /// `first` is the earliest visible position — sliding-window layers
676    /// pass `stored − window` so older rows get zero probability.
677    #[allow(clippy::too_many_arguments)]
678    pub fn attend_group(
679        &self,
680        q_group: &[f32],
681        kv_head: usize,
682        out: &mut [f32],
683        imp_acc: &mut [f32],
684        scale: f32,
685        first: usize,
686        softcap: f32,
687    ) {
688        self.attend_group_upto(q_group, kv_head, out, imp_acc, scale, first, softcap, usize::MAX)
689    }
690
691    /// `attend_group` over the first `upto` stored rows only — what the
692    /// same call saw when the cache held exactly `upto` rows. A prefill
693    /// chunk appends all its rows first and then attends every position
694    /// in parallel; position `i` passes `upto = s0 + i + 1`, which makes
695    /// its result bit-identical to the sequential append-then-attend.
696    #[allow(clippy::too_many_arguments)]
697    pub fn attend_group_upto(
698        &self,
699        q_group: &[f32],
700        kv_head: usize,
701        out: &mut [f32],
702        imp_acc: &mut [f32],
703        scale: f32,
704        first: usize,
705        softcap: f32,
706        upto: usize,
707    ) {
708        let hd = self.head_dim;
709        let nheads = q_group.len() / hd;
710        debug_assert_eq!(out.len(), nheads * hd);
711        let stored = if self.mode == KvMode::F32 {
712            self.k[kv_head].len() / hd
713        } else {
714            self.head_len(kv_head)
715        }
716        .min(upto);
717        if stored == 0 {
718            out.fill(0.0);
719            return;
720        }
721        let first = first.min(stored.saturating_sub(1));
722
723        thread_local! {
724            /// scores [nheads × stored] — reused across layers/tokens.
725            static GQA_SCORES: std::cell::RefCell<Vec<f32>> =
726                const { std::cell::RefCell::new(Vec::new()) };
727            /// q ⊙ col_k per head (q8 K mode).
728            static GQA_QC: std::cell::RefCell<Vec<f32>> =
729                const { std::cell::RefCell::new(Vec::new()) };
730        }
731
732        GQA_SCORES.with(|sc| {
733            let mut scores = sc.borrow_mut();
734            if first > 0 {
735                // Out-of-window rows stay at −inf → exp gives exactly 0,
736                // so softmax / V / importance need no special-casing.
737                scores.clear();
738                scores.resize(nheads * stored, f32::NEG_INFINITY);
739            } else {
740                scores.resize(nheads * stored, 0.0);
741            }
742
743            // ── score pass: each stored K row is read ONCE for all heads.
744            if self.mode.quant_k() {
745                let (kq, ks) = (&self.kq[kv_head], &self.ks[kv_head]);
746                let kcol = &self.kcol[kv_head];
747                let ng = hd.div_ceil(KV_K_GROUP);
748                GQA_QC.with(|qc| {
749                    let mut qcb = qc.borrow_mut();
750                    qcb.resize(nheads * hd, 0.0);
751                    for h in 0..nheads {
752                        for d in 0..hd {
753                            let qv = q_group[h * hd + d];
754                            qcb[h * hd + d] = if kcol.is_empty() { qv } else { qv * kcol[d] };
755                        }
756                    }
757                    for p in first..stored {
758                        let row = &kq[p * hd..(p + 1) * hd];
759                        // SAFETY: i8 and u8 share layout; dot_i8_f32 reads
760                        // the bytes back as i8.
761                        let row_u8 = unsafe {
762                            std::slice::from_raw_parts(row.as_ptr() as *const u8, row.len())
763                        };
764                        for h in 0..nheads {
765                            let qch = &qcb[h * hd..(h + 1) * hd];
766                            let mut dot = 0.0f32;
767                            for g in 0..ng {
768                                let g0 = g * KV_K_GROUP;
769                                let g1 = (g0 + KV_K_GROUP).min(hd);
770                                dot += crate::qtensor::dot_i8_f32(&row_u8[g0..g1], &qch[g0..g1])
771                                    * ks[p * ng + g];
772                            }
773                            scores[h * stored + p] = dot * scale;
774                        }
775                    }
776                });
777            } else {
778                let k = &self.k[kv_head];
779                for p in first..stored {
780                    let row = &k[p * hd..(p + 1) * hd];
781                    for h in 0..nheads {
782                        scores[h * stored + p] =
783                            crate::attention::dot_f32(&q_group[h * hd..(h + 1) * hd], row) * scale;
784                    }
785                }
786            }
787
788            // Gemma-2 attention-logit soft-capping: tanh-squash the
789            // COMPUTED scores before the softmax. Out-of-window rows sit
790            // at −inf and must stay there (tanh would resurrect them at
791            // −cap), hence the finiteness guard.
792            if softcap > 0.0 {
793                for v in scores.iter_mut() {
794                    if v.is_finite() {
795                        *v = softcap * (*v / softcap).tanh();
796                    }
797                }
798            }
799
800            // ── per-head softmax (identical to attend / attention_head).
801            for h in 0..nheads {
802                let s = &mut scores[h * stored..(h + 1) * stored];
803                let max_score = s.iter().cloned().fold(f32::NEG_INFINITY, f32::max);
804                let mut sum = 0.0f32;
805                for v in s.iter_mut() {
806                    *v = (*v - max_score).exp();
807                    sum += *v;
808                }
809                if sum > 0.0 {
810                    for v in s.iter_mut() {
811                        *v /= sum;
812                    }
813                }
814            }
815
816            // ── value pass: each stored V row is read ONCE for all heads.
817            out.fill(0.0);
818            if self.mode.quant_v() {
819                let (vq, vs) = (&self.vq[kv_head], &self.vs[kv_head]);
820                for p in first..stored {
821                    let row = &vq[p * hd..(p + 1) * hd];
822                    for h in 0..nheads {
823                        let w = scores[h * stored + p] * vs[p];
824                        if w.abs() < 1e-12 {
825                            continue;
826                        }
827                        crate::qtensor::axpy_i8_f32(&mut out[h * hd..(h + 1) * hd], row, w);
828                    }
829                }
830                let vcol = &self.vcol[kv_head];
831                if !vcol.is_empty() {
832                    for h in 0..nheads {
833                        for d in 0..hd {
834                            out[h * hd + d] *= vcol[d];
835                        }
836                    }
837                }
838            } else {
839                let v = &self.v[kv_head];
840                for p in first..stored {
841                    let row = &v[p * hd..(p + 1) * hd];
842                    for h in 0..nheads {
843                        let w = scores[h * stored + p];
844                        if w.abs() < 1e-12 {
845                            continue;
846                        }
847                        crate::attention::axpy_f32(&mut out[h * hd..(h + 1) * hd], row, w);
848                    }
849                }
850            }
851
852            // ── Attention-importance accumulation (Σ probs over heads), same
853            // head order as the caller's former per-head loop.
854            let n = imp_acc.len().min(stored);
855            for h in 0..nheads {
856                let s = &scores[h * stored..(h + 1) * stored];
857                for (dst, &p) in imp_acc[..n].iter_mut().zip(s) {
858                    *dst += p;
859                }
860            }
861        });
862    }
863
864    /// Batched causal attend for a prefill chunk (macOS/AArch64): the
865    /// cache already holds every chunk row (`s0` old + `b` new). Per
866    /// Q-head the scores GEMM `Q·Kᵀ` rides the AMX, the causal softmax
867    /// zeroes the not-yet-visible tail so the `P·V` GEMM needs no
868    /// mask, and attention importance takes the masked column sums. Same
869    /// math as the per-position attend; summation order differs
870    /// (tolerance-class, like the projection GEMMs).
871    #[cfg(target_arch = "aarch64")]
872    #[allow(clippy::too_many_arguments)]
873    pub fn attend_chunk(
874        &mut self,
875        q_all: &[f32],
876        b: usize,
877        s0: usize,
878        nh: usize,
879        heads_per_kv: usize,
880        hd: usize,
881        out: &mut [f32],
882        pool: Option<&crate::pool::Pool>,
883        scale: f32,
884        window: Option<usize>,
885    ) {
886        let n = s0 + b;
887        struct SendPtr(*mut f32);
888        unsafe impl Send for SendPtr {}
889        unsafe impl Sync for SendPtr {}
890        impl SendPtr {
891            fn at(&self, i: usize) -> *mut f32 {
892                // Method receiver keeps the closure capturing &SendPtr
893                // (2021 disjoint capture would grab the raw field).
894                unsafe { self.0.add(i) }
895            }
896        }
897        thread_local! {
898            static SCRATCH: std::cell::RefCell<(Vec<f32>, Vec<f32>, Vec<f32>, Vec<f32>)> =
899                const { std::cell::RefCell::new((Vec::new(), Vec::new(), Vec::new(), Vec::new())) };
900        }
901        // The portable NEON GEMM pays dearly for the gathered Bᵀ loads
902        // of the scores multiply — pack Kᵀ once per (group, chunk) and
903        // hand it the sequential-B fast path instead. Accelerate keeps
904        // the no-copy transposed call.
905        let neon_gemm = cfg!(not(target_os = "macos"))
906            || std::env::var("CMF_FORCE_NEON_GEMM")
907                .map(|v| v == "1")
908                .unwrap_or(false);
909        SCRATCH.with(|s| {
910            let mut s = s.borrow_mut();
911            let (qpanel, scores, aopanel, ktpack) = &mut *s;
912            // The whole KV-group attends in one GEMM pair: the group's
913            // Q-heads stack head-major into one tall panel [hpk·b, hd]
914            // (row hl·b + bi), so each layer costs 2 sgemm calls per
915            // group instead of 2 per head — fat M keeps the AMX fed.
916            let m = heads_per_kv * b;
917            qpanel.resize(m * hd, 0.0);
918            scores.resize(m * n, 0.0);
919            aopanel.resize(m * hd, 0.0);
920            for g in 0..self.num_kv_heads {
921                let kmat = &self.k[g];
922                let vmat = &self.v[g];
923                debug_assert_eq!(kmat.len(), n * hd);
924                for hl in 0..heads_per_kv {
925                    let hh = g * heads_per_kv + hl;
926                    for bi in 0..b {
927                        qpanel[(hl * b + bi) * hd..(hl * b + bi + 1) * hd]
928                            .copy_from_slice(&q_all[bi * nh * hd + hh * hd..][..hd]);
929                    }
930                }
931                if neon_gemm {
932                    ktpack.resize(hd * n, 0.0);
933                    for p in 0..n {
934                        let row = &kmat[p * hd..(p + 1) * hd];
935                        for (d, &v) in row.iter().enumerate() {
936                            ktpack[d * n + p] = v;
937                        }
938                    }
939                    // Accelerate threads its own GEMM; the NEON kernel
940                    // splits the m rows across the pool instead.
941                    let sp_q = SendPtr(qpanel.as_ptr() as *mut f32);
942                    let sp_s = SendPtr(scores.as_mut_ptr());
943                    let kt = &*ktpack;
944                    let run = |start: usize, end: usize| {
945                        if end > start {
946                            // SAFETY: workers write disjoint score rows.
947                            let a = unsafe {
948                                std::slice::from_raw_parts(sp_q.at(start * hd), (end - start) * hd)
949                            };
950                            let c = unsafe {
951                                std::slice::from_raw_parts_mut(
952                                    sp_s.at(start * n),
953                                    (end - start) * n,
954                                )
955                            };
956                            crate::qtensor::neon_gemm_rm(
957                                end - start,
958                                n,
959                                hd,
960                                scale,
961                                a,
962                                hd,
963                                kt,
964                                n,
965                                false,
966                                c,
967                                n,
968                            );
969                        }
970                    };
971                    match pool {
972                        Some(p) if m >= 64 => p.run_rows(m, &run),
973                        _ => run(0, m),
974                    }
975                } else {
976                    crate::qtensor::sgemm_rm(
977                        m, n, hd, scale, qpanel, hd, kmat, hd, true, scores, n,
978                    );
979                }
980                // Causal softmax, row-parallel (rows are disjoint).
981                let sp = SendPtr(scores.as_mut_ptr());
982                let run = |start: usize, end: usize| {
983                    for r in start..end {
984                        let allowed = s0 + (r % b) + 1;
985                        // Sliding-window layers see only the last W of
986                        // the causal range; the zeroed head contributes
987                        // nothing to P·V or attention importance.
988                        let lo = window.map(|w| allowed.saturating_sub(w)).unwrap_or(0);
989                        // SAFETY: workers cover disjoint row ranges.
990                        let row = unsafe { std::slice::from_raw_parts_mut(sp.at(r * n), n) };
991                        crate::attention::softmax_row(&mut row[lo..allowed]);
992                        row[..lo].fill(0.0);
993                        row[allowed..].fill(0.0);
994                    }
995                };
996                match pool {
997                    Some(p) if m >= 64 => p.run_rows(m, &run),
998                    _ => run(0, m),
999                }
1000                // Attention importance: masked column sums (probs of the
1001                // zeroed tail contribute nothing, same as the CPU
1002                // per-position accumulate).
1003                let ni = self.imp.len().min(n);
1004                for r in 0..m {
1005                    let al = (s0 + (r % b) + 1).min(ni);
1006                    for (dst, &p) in self.imp[..al].iter_mut().zip(&scores[r * n..r * n + al]) {
1007                        *dst += p;
1008                    }
1009                }
1010                if neon_gemm {
1011                    let sp_s = SendPtr(scores.as_mut_ptr());
1012                    let sp_o = SendPtr(aopanel.as_mut_ptr());
1013                    let run = |start: usize, end: usize| {
1014                        if end > start {
1015                            // SAFETY: workers write disjoint output rows.
1016                            let a = unsafe {
1017                                std::slice::from_raw_parts(sp_s.at(start * n), (end - start) * n)
1018                            };
1019                            let c = unsafe {
1020                                std::slice::from_raw_parts_mut(
1021                                    sp_o.at(start * hd),
1022                                    (end - start) * hd,
1023                                )
1024                            };
1025                            crate::qtensor::neon_gemm_rm(
1026                                end - start,
1027                                hd,
1028                                n,
1029                                1.0,
1030                                a,
1031                                n,
1032                                vmat,
1033                                hd,
1034                                false,
1035                                c,
1036                                hd,
1037                            );
1038                        }
1039                    };
1040                    match pool {
1041                        Some(p) if m >= 64 => p.run_rows(m, &run),
1042                        _ => run(0, m),
1043                    }
1044                } else {
1045                    crate::qtensor::sgemm_rm(
1046                        m, hd, n, 1.0, scores, n, vmat, hd, false, aopanel, hd,
1047                    );
1048                }
1049                for hl in 0..heads_per_kv {
1050                    let hh = g * heads_per_kv + hl;
1051                    for bi in 0..b {
1052                        out[bi * nh * hd + hh * hd..][..hd]
1053                            .copy_from_slice(&aopanel[(hl * b + bi) * hd..(hl * b + bi + 1) * hd]);
1054                    }
1055                }
1056            }
1057        });
1058    }
1059
1060    /// Roll back the last `n_drop` positions (speculative-decode reject).
1061    pub fn truncate_last(&mut self, n_drop: usize) {
1062        let d = n_drop.min(self.seq_len);
1063        for h in 0..self.num_kv_heads {
1064            let keep = self.k[h].len().saturating_sub(d * self.head_dim);
1065            self.k[h].truncate(keep);
1066            self.v[h].truncate(keep);
1067            let ngk = self.head_dim.div_ceil(KV_K_GROUP);
1068            let keep_q = self.kq[h].len().saturating_sub(d * self.head_dim);
1069            self.kq[h].truncate(keep_q);
1070            let keep_vq = self.vq[h].len().saturating_sub(d * self.head_dim);
1071            self.vq[h].truncate(keep_vq);
1072            let keep_ks = self.ks[h].len().saturating_sub(d * ngk);
1073            self.ks[h].truncate(keep_ks);
1074            let keep_vs = self.vs[h].len().saturating_sub(d);
1075            self.vs[h].truncate(keep_vs);
1076        }
1077        self.imp.truncate(self.imp.len().saturating_sub(d));
1078        self.seq_len -= d;
1079    }
1080
1081    /// Accumulate attention mass per stored position (summed over heads).
1082    pub fn accumulate_imp(&mut self, probs: &[f32]) {
1083        for (dst, &p) in self.imp.iter_mut().zip(probs) {
1084            *dst += p;
1085        }
1086    }
1087
1088    /// Contiguous keys of one head: `[stored_len × head_dim]`.
1089    pub fn head_keys(&self, kv_head: usize) -> &[f32] {
1090        &self.k[kv_head]
1091    }
1092
1093    pub fn head_values(&self, kv_head: usize) -> &[f32] {
1094        &self.v[kv_head]
1095    }
1096
1097    /// Number of positions actually stored for a head (0 for dead heads).
1098    pub fn head_len(&self, kv_head: usize) -> usize {
1099        let ng = self.head_dim.div_ceil(KV_K_GROUP);
1100        (self.k[kv_head].len() / self.head_dim)
1101            .max(self.ks[kv_head].len() / ng)
1102            .max(self.vs[kv_head].len())
1103    }
1104
1105    /// Clear cache (e.g. on new conversation or task switch).
1106    pub fn clear(&mut self) {
1107        for h in 0..self.num_kv_heads {
1108            self.k[h].clear();
1109            self.v[h].clear();
1110            self.kq[h].clear();
1111            self.ks[h].clear();
1112            self.vq[h].clear();
1113            self.vs[h].clear();
1114            self.kcol[h].clear();
1115            self.vcol[h].clear();
1116        }
1117        self.imp.clear();
1118        self.linear_state.clear();
1119        self.linear_scratch.clear();
1120        // Fresh conversation → the pipeline re-arms collection if the
1121        // layer is o1-flagged (landmarks are per-prompt, never reused).
1122        self.o1 = None;
1123        self.o1_error = None;
1124        self.o1_transitioned = false;
1125        self.seq_len = 0;
1126    }
1127
1128    /// Memory usage in bytes.
1129    /// Serialize this layer's state for the wire: `f16` halves it and is
1130    /// the caller's explicit choice, exactly like the hidden-state wire.
1131    ///
1132    /// REFUSES rather than travelling half-complete. A cache carrying
1133    /// frozen columns, accumulated importance, a Nyström overlay or q8
1134    /// storage holds state this format does not describe, and shipping
1135    /// the rest would land a plausible-looking cache that answers
1136    /// differently — the failure mode this whole format exists to avoid.
1137    pub fn export_wire(&self, f16: bool) -> Result<Vec<u8>, String> {
1138        if !matches!(self.mode, KvMode::F32) {
1139            return Err("kv export: only the F32 cache is described by this                         format (CMF_KV=q8 stores int8 rows and per-row scales)"
1140                .into());
1141        }
1142        if self.o1.is_some() {
1143            return Err("kv export: an O(1) Nyström overlay is not part of                         this format — the skeletons are irreversible and                         would have to travel with it"
1144                .into());
1145        }
1146        // Frozen columns only exist under q8 storage, which is refused
1147        // above. If one shows up under an F32 cache the format is lying
1148        // about something and the transfer must not proceed.
1149        if self.kcol.iter().any(|c| !c.is_empty()) || self.vcol.iter().any(|c| !c.is_empty()) {
1150            return Err(
1151                "kv export: frozen columns under an F32 cache — refusing to ship \
1152                        a state this format does not describe"
1153                    .into(),
1154            );
1155        }
1156        let mut out = Vec::with_capacity(self.memory_bytes() / if f16 { 2 } else { 1 } + 64);
1157        let u = |v: u32, o: &mut Vec<u8>| o.extend_from_slice(&v.to_le_bytes());
1158        u(u8::from(f16) as u32, &mut out);
1159        u(self.seq_len as u32, &mut out);
1160        u(self.num_kv_heads as u32, &mut out);
1161        u(self.head_dim as u32, &mut out);
1162        u(self.linear_state.len() as u32, &mut out);
1163        // Attention importance is ordinary state: every attention call
1164        // accumulates it and eviction reads it. Leaving it behind would
1165        // hand the far side a cache that forgets the RIGHT positions
1166        // later — a divergence that shows up only under pressure.
1167        u(self.imp.len() as u32, &mut out);
1168        let push = |xs: &[f32], o: &mut Vec<u8>| {
1169            if f16 {
1170                for &x in xs {
1171                    o.extend_from_slice(&cortiq_core::quant::f32_to_f16(x).to_le_bytes());
1172                }
1173            } else {
1174                for &x in xs {
1175                    o.extend_from_slice(&x.to_le_bytes());
1176                }
1177            }
1178        };
1179        // The recurrent state stays f32 whatever the wire dtype: it
1180        // is one small vector per layer and it is the ONLY state a linear
1181        // layer has — rounding it rounds the whole history.
1182        for &x in &self.linear_state {
1183            out.extend_from_slice(&x.to_le_bytes());
1184        }
1185        for &x in &self.imp {
1186            out.extend_from_slice(&x.to_le_bytes());
1187        }
1188        for h in 0..self.num_kv_heads {
1189            u(self.k[h].len() as u32, &mut out);
1190            push(&self.k[h], &mut out);
1191            u(self.v[h].len() as u32, &mut out);
1192            push(&self.v[h], &mut out);
1193        }
1194        Ok(out)
1195    }
1196
1197    /// Install a peer's state over this layer. The geometry must match the
1198    /// model both sides hold — it is checked, not assumed.
1199    pub fn import_wire(&mut self, buf: &[u8]) -> Result<(), String> {
1200        let mut o = 0usize;
1201        let u32_at = |o: &mut usize| -> Result<u32, String> {
1202            if *o + 4 > buf.len() {
1203                return Err("kv import: truncated header".into());
1204            }
1205            let v = u32::from_le_bytes(buf[*o..*o + 4].try_into().unwrap());
1206            *o += 4;
1207            Ok(v)
1208        };
1209        let f16 = u32_at(&mut o)? != 0;
1210        let seq_len = u32_at(&mut o)? as usize;
1211        let heads = u32_at(&mut o)? as usize;
1212        let hd = u32_at(&mut o)? as usize;
1213        let lin = u32_at(&mut o)? as usize;
1214        let nimp = u32_at(&mut o)? as usize;
1215        if heads != self.num_kv_heads || hd != self.head_dim {
1216            return Err(format!(
1217                "kv import: peer sent {heads}×{hd} per position, this layer is {}×{}",
1218                self.num_kv_heads, self.head_dim
1219            ));
1220        }
1221        let w = if f16 { 2 } else { 4 };
1222        let need = |n: usize, o: usize| -> Result<(), String> {
1223            if o + n > buf.len() {
1224                Err("kv import: truncated payload".into())
1225            } else {
1226                Ok(())
1227            }
1228        };
1229        need(lin * 4, o)?;
1230        self.linear_state = (0..lin)
1231            .map(|i| f32::from_le_bytes(buf[o + i * 4..o + i * 4 + 4].try_into().unwrap()))
1232            .collect();
1233        o += lin * 4;
1234        need(nimp * 4, o)?;
1235        let imp: Vec<f32> = (0..nimp)
1236            .map(|i| f32::from_le_bytes(buf[o + i * 4..o + i * 4 + 4].try_into().unwrap()))
1237            .collect();
1238        o += nimp * 4;
1239        let mut k: Vec<Vec<f32>> = Vec::with_capacity(heads);
1240        let mut v: Vec<Vec<f32>> = Vec::with_capacity(heads);
1241        for _ in 0..heads {
1242            for which in 0..2 {
1243                let n = u32_at(&mut o)? as usize;
1244                need(n * w, o)?;
1245                let xs: Vec<f32> = (0..n)
1246                    .map(|i| {
1247                        let at = o + i * w;
1248                        if f16 {
1249                            cortiq_core::quant::f16_to_f32(u16::from_le_bytes(
1250                                buf[at..at + 2].try_into().unwrap(),
1251                            ))
1252                        } else {
1253                            f32::from_le_bytes(buf[at..at + 4].try_into().unwrap())
1254                        }
1255                    })
1256                    .collect();
1257                o += n * w;
1258                if which == 0 { k.push(xs) } else { v.push(xs) }
1259            }
1260        }
1261        self.mode = KvMode::F32;
1262        self.k = k;
1263        self.v = v;
1264        self.kq = vec![Vec::new(); heads];
1265        self.ks = vec![Vec::new(); heads];
1266        self.vq = vec![Vec::new(); heads];
1267        self.vs = vec![Vec::new(); heads];
1268        self.kcol = vec![Vec::new(); heads];
1269        self.vcol = vec![Vec::new(); heads];
1270        self.imp = imp;
1271        self.o1 = None;
1272        self.o1_error = None;
1273        self.o1_transitioned = false;
1274        self.seq_len = seq_len;
1275        Ok(())
1276    }
1277
1278    pub fn memory_bytes(&self) -> usize {
1279        let floats: usize = self.k.iter().map(Vec::len).sum::<usize>()
1280            + self.v.iter().map(Vec::len).sum::<usize>()
1281            + self.ks.iter().map(Vec::len).sum::<usize>()
1282            + self.vs.iter().map(Vec::len).sum::<usize>()
1283            + self.kcol.iter().map(Vec::len).sum::<usize>()
1284            + self.vcol.iter().map(Vec::len).sum::<usize>();
1285        let bytes: usize = self.kq.iter().map(Vec::len).sum::<usize>()
1286            + self.vq.iter().map(Vec::len).sum::<usize>();
1287        floats * std::mem::size_of::<f32>()
1288            + bytes
1289            // O(1) recurrent state of linear-core layers (vmf_phase/GDN):
1290            // constant in context, but real memory — the honest "KV+state"
1291            // line must count it (a pure-linear model reported 0 before).
1292            + self.linear_state.len() * std::mem::size_of::<f32>()
1293            // O(1) Nyström state (window + sinks + skeleton) — same
1294            // discipline: constant in context, but real memory.
1295            + self.o1_memory_bytes()
1296    }
1297
1298    /// Drop oldest positions, keeping the last `keep_last`.
1299    fn evict(&mut self, keep_last: usize) {
1300        // A collecting o1 layer owns a still-needed exact prefix and query
1301        // trace.  Evicting it would lower the effective seal boundary while
1302        // leaving q_buf untouched, so conversion could never match its KV
1303        // rows.  A sealed layer stores nothing per position — the Nyström
1304        // state IS the eviction policy; resetting seq_len here would lie
1305        // about the context depth.  Both states therefore bypass ordinary
1306        // eviction until the transition or explicit reset completes.
1307        if self.o1.is_some() || self.seq_len <= keep_last {
1308            return;
1309        }
1310        let drop = self.seq_len - keep_last;
1311        for h in 0..self.num_kv_heads {
1312            // Dead heads store fewer positions; drop proportionally.
1313            let stored = self.head_len(h);
1314            let d = drop.min(stored);
1315            let hd = self.head_dim;
1316            fn drop_front<T>(v: &mut Vec<T>, n: usize) {
1317                let n = n.min(v.len());
1318                v.drain(..n);
1319            }
1320            drop_front(&mut self.k[h], d * hd);
1321            drop_front(&mut self.v[h], d * hd);
1322            drop_front(&mut self.kq[h], d * hd);
1323            drop_front(&mut self.vq[h], d * hd);
1324            drop_front(&mut self.ks[h], d * hd.div_ceil(KV_K_GROUP));
1325            drop_front(&mut self.vs[h], d);
1326        }
1327        let d = drop.min(self.imp.len());
1328        self.imp.drain(..d);
1329        self.seq_len = keep_last;
1330    }
1331
1332    /// Mass-based eviction: keep `sink` earliest positions (attention sinks),
1333    /// the `recent` latest, and fill the rest of the `keep_last` budget
1334    /// with the positions carrying the highest accumulated attention
1335    /// mass (vmfcore: PPL 8.342 vs 8.687 for recency-only, full 8.295).
1336    fn evict_born(&mut self, keep_last: usize, sink: usize, recent: usize) {
1337        if self.o1.is_some() {
1338            // See evict(): collecting must retain the exact prefix as well as
1339            // sealed O(1) state must retain its own bounded representation.
1340            return;
1341        }
1342        let stored = self.imp.len();
1343        if stored <= keep_last {
1344            return;
1345        }
1346        // Budget discipline: sinks first, recents next, both clamped so
1347        // the total never exceeds keep_last.
1348        let sink_n = sink.min(keep_last);
1349        let recent_n = recent.min(keep_last - sink_n);
1350        let mut keep = vec![false; stored];
1351        for k in keep.iter_mut().take(sink_n) {
1352            *k = true;
1353        }
1354        for k in keep.iter_mut().skip(stored.saturating_sub(recent_n)) {
1355            *k = true;
1356        }
1357        let mut budget = keep_last.saturating_sub(keep.iter().filter(|&&x| x).count());
1358        // Highest accumulated mass first among the middle positions.
1359        let mut order: Vec<usize> = (0..stored).filter(|&i| !keep[i]).collect();
1360        order.sort_by(|&a, &b| {
1361            self.imp[b]
1362                .partial_cmp(&self.imp[a])
1363                .unwrap_or(std::cmp::Ordering::Equal)
1364        });
1365        for i in order {
1366            if budget == 0 {
1367                break;
1368            }
1369            keep[i] = true;
1370            budget -= 1;
1371        }
1372
1373        let kept: Vec<usize> = (0..stored).filter(|&i| keep[i]).collect();
1374        let hd = self.head_dim;
1375        fn gather<T: Copy>(src: &[T], kept: &[usize], step: usize) -> Vec<T> {
1376            let mut out = Vec::with_capacity(kept.len() * step);
1377            for &i in kept {
1378                out.extend_from_slice(&src[i * step..(i + 1) * step]);
1379            }
1380            out
1381        }
1382        // Each storage is gathered INDEPENDENTLY: in mixed modes
1383        // (q8k/q8v) K and V live in different storages — the paired branch
1384        // panicked (q8v) or silently left V uncompressed (q8k);
1385        // found by adversarial review, closed by regression tests.
1386        for h in 0..self.num_kv_heads {
1387            if !self.k[h].is_empty() {
1388                self.k[h] = gather(&self.k[h], &kept, hd);
1389            }
1390            if !self.v[h].is_empty() {
1391                self.v[h] = gather(&self.v[h], &kept, hd);
1392            }
1393            if !self.kq[h].is_empty() {
1394                self.kq[h] = gather(&self.kq[h], &kept, hd);
1395                self.ks[h] = gather(&self.ks[h], &kept, hd.div_ceil(KV_K_GROUP));
1396            }
1397            if !self.vq[h].is_empty() {
1398                self.vq[h] = gather(&self.vq[h], &kept, hd);
1399                self.vs[h] = gather(&self.vs[h], &kept, 1);
1400            }
1401        }
1402        self.imp = kept.iter().map(|&i| self.imp[i]).collect();
1403        self.seq_len = kept.len();
1404    }
1405}
1406
1407/// Eviction policy for a bounded cache.
1408#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1409pub enum EvictionPolicy {
1410    /// Sliding window: keep only the most recent positions.
1411    Recent,
1412    /// Mass-based eviction: sinks + recents + top accumulated attention mass.
1413    Born { sink: usize },
1414}
1415
1416/// Full KV cache for all layers.
1417#[derive(Debug)]
1418pub struct KvCache {
1419    pub layers: Vec<LayerKvCache>,
1420    pub max_seq_len: usize,
1421    pub policy: EvictionPolicy,
1422}
1423
1424impl KvCache {
1425    pub fn new(
1426        num_layers: usize,
1427        num_kv_heads: usize,
1428        head_dim: usize,
1429        max_seq_len: usize,
1430    ) -> Self {
1431        let layers = (0..num_layers)
1432            .map(|_| LayerKvCache::new(num_kv_heads, head_dim))
1433            .collect();
1434        Self {
1435            layers,
1436            max_seq_len,
1437            policy: EvictionPolicy::Born { sink: 4 },
1438        }
1439    }
1440
1441    pub fn clear(&mut self) {
1442        for layer in &mut self.layers {
1443            layer.clear();
1444        }
1445    }
1446
1447    pub fn total_memory_bytes(&self) -> usize {
1448        self.layers.iter().map(|l| l.memory_bytes()).sum()
1449    }
1450
1451    /// Current sequence length (max across layers — dead layers may lag).
1452    pub fn seq_len(&self) -> usize {
1453        self.layers.iter().map(|l| l.seq_len).max().unwrap_or(0)
1454    }
1455
1456    pub fn needs_eviction(&self) -> bool {
1457        self.seq_len() >= self.max_seq_len
1458    }
1459
1460    /// Evict down to `keep_last` positions according to the policy.
1461    pub fn evict(&mut self, keep_last: usize) {
1462        match self.policy {
1463            EvictionPolicy::Recent => {
1464                for layer in &mut self.layers {
1465                    layer.evict(keep_last);
1466                }
1467            }
1468            EvictionPolicy::Born { sink } => {
1469                let recent = (keep_last / 2).max(1);
1470                for layer in &mut self.layers {
1471                    layer.evict_born(keep_last, sink, recent);
1472                }
1473            }
1474        }
1475    }
1476}
1477
1478#[cfg(test)]
1479mod tests {
1480    use super::*;
1481
1482    #[test]
1483    fn wire_round_trip_reproduces_attention() {
1484        // The state has to arrive as state, not as something that looks
1485        // like it: the oracle is what the layer ANSWERS, not what it
1486        // stores. Same query, same output, bit for bit.
1487        let (heads, hd) = (2usize, 4usize);
1488        let mut a = LayerKvCache::new(heads, hd);
1489        for p in 0..5 {
1490            let k: Vec<f32> = (0..heads * hd)
1491                .map(|i| (p * 10 + i) as f32 * 0.031)
1492                .collect();
1493            let v: Vec<f32> = (0..heads * hd)
1494                .map(|i| (p * 7 + i) as f32 * -0.017)
1495                .collect();
1496            a.append(&k, &v, &[true, true]);
1497        }
1498        a.linear_state = vec![0.5, -0.25, 1.0];
1499        let q: Vec<f32> = (0..hd).map(|i| 0.1 * (i as f32 + 1.0)).collect();
1500
1501        let bytes = a.export_wire(false).expect("f32 cache exports");
1502        let mut b = LayerKvCache::new(heads, hd);
1503        b.import_wire(&bytes).expect("import");
1504
1505        assert_eq!(b.seq_len, a.seq_len);
1506        assert_eq!(b.linear_state, a.linear_state);
1507        for h in 0..heads {
1508            let (oa, sa) = a.attend(&q, h);
1509            let (ob, sb) = b.attend(&q, h);
1510            assert_eq!(oa, ob, "head {h} attention output diverged");
1511            assert_eq!(sa, sb, "head {h} attention scores diverged");
1512        }
1513    }
1514
1515    #[test]
1516    fn wire_refuses_what_it_cannot_describe() {
1517        // A refusal is the feature: a cache whose extra state this format
1518        // does not carry must not travel looking complete.
1519        let mut c = LayerKvCache::new(1, 4);
1520        c.mode = KvMode::Q8 { k: true, v: true };
1521        let err = c.export_wire(false).unwrap_err();
1522        assert!(err.contains("F32"), "{err}");
1523    }
1524
1525    #[test]
1526    fn wire_import_checks_geometry() {
1527        let a = LayerKvCache::new(2, 4);
1528        let bytes = a.export_wire(false).unwrap();
1529        let mut wrong = LayerKvCache::new(2, 8);
1530        let err = wrong.import_wire(&bytes).unwrap_err();
1531        assert!(err.contains("2×4"), "{err}");
1532    }
1533
1534    #[test]
1535    fn append_tracks_seq_len_and_layout() {
1536        let mut cache = LayerKvCache::new(4, 8);
1537        cache.mode = KvMode::F32;
1538        assert_eq!(cache.seq_len, 0);
1539
1540        let k: Vec<f32> = (0..32).map(|i| i as f32).collect();
1541        let v = vec![2.0f32; 32];
1542        cache.append(&k, &v, &[true; 4]);
1543
1544        assert_eq!(cache.seq_len, 1);
1545        assert_eq!(cache.head_len(0), 1);
1546        // head 1 slice is contiguous and equals its part of k_new
1547        assert_eq!(cache.head_keys(1), &k[8..16]);
1548        assert_eq!(cache.memory_bytes(), 256);
1549    }
1550
1551    #[test]
1552    fn dead_head_stores_nothing() {
1553        let mut cache = LayerKvCache::new(2, 4);
1554        cache.mode = KvMode::F32;
1555        let k = vec![1.0f32; 8];
1556        let v = vec![2.0f32; 8];
1557        cache.append(&k, &v, &[true, false]);
1558        cache.append(&k, &v, &[true, false]);
1559
1560        assert_eq!(cache.seq_len, 2);
1561        assert_eq!(cache.head_len(0), 2);
1562        assert_eq!(cache.head_len(1), 0, "dead head must not store KV");
1563        assert_eq!(cache.memory_bytes(), 2 * 2 * 4 * 4);
1564    }
1565
1566    #[test]
1567    fn eviction_keeps_recent() {
1568        let mut cache = KvCache::new(2, 4, 8, 10);
1569        cache.policy = EvictionPolicy::Recent;
1570        for l in &mut cache.layers {
1571            l.mode = KvMode::F32;
1572        }
1573        let k = vec![1.0f32; 32];
1574        let v = vec![2.0f32; 32];
1575        for _ in 0..8 {
1576            for layer in &mut cache.layers {
1577                layer.append(&k, &v, &[true; 4]);
1578            }
1579        }
1580        assert_eq!(cache.seq_len(), 8);
1581        assert!(!cache.needs_eviction());
1582
1583        cache.evict(4);
1584        assert_eq!(cache.seq_len(), 4);
1585        assert_eq!(cache.layers[0].head_len(0), 4);
1586    }
1587
1588    #[test]
1589    fn collecting_o1_eviction_retains_exact_storage_until_boundary() {
1590        const B: usize = 19;
1591        let q = vec![0.1f32; 8];
1592        let k = vec![0.2f32; 4];
1593        let v = vec![0.3f32; 4];
1594
1595        for policy in [EvictionPolicy::Recent, EvictionPolicy::Born { sink: 2 }] {
1596            let mut cache = KvCache::new(1, 1, 4, 6);
1597            cache.policy = policy;
1598            cache.layers[0].mode = KvMode::F32;
1599            cache.layers[0].o1_begin_with_boundary(
1600                4,
1601                8,
1602                2,
1603                crate::nystrom::O1Rect::Aggregate,
1604                Some(B),
1605            );
1606
1607            for pos in 0..B {
1608                {
1609                    let layer = &mut cache.layers[0];
1610                    layer.o1_push_q(&q);
1611                    layer.append(&k, &v, &[]);
1612                }
1613                if pos + 1 < B {
1614                    cache.evict(3);
1615                }
1616            }
1617
1618            let layer = &cache.layers[0];
1619            let rows = B * layer.head_dim;
1620            assert_eq!(layer.seq_len, B, "policy {policy:?} retained depth");
1621            assert_eq!(layer.k[0].len(), rows, "policy {policy:?} K rows");
1622            assert_eq!(layer.v[0].len(), rows, "policy {policy:?} V rows");
1623            assert!(
1624                layer.k[0].capacity() >= rows,
1625                "policy {policy:?} K capacity"
1626            );
1627            assert!(
1628                layer.v[0].capacity() >= rows,
1629                "policy {policy:?} V capacity"
1630            );
1631            let q_capacity = match layer.o1.as_ref() {
1632                Some(O1State::Collecting { q_buf, .. }) => q_buf.capacity(),
1633                other => panic!("policy {policy:?} changed state early: {other:?}"),
1634            };
1635            assert!(
1636                q_capacity >= B * 8,
1637                "policy {policy:?} Q capacity must cover the exact prefix"
1638            );
1639
1640            assert!(cache.layers[0].o1_seal_checked(2).unwrap());
1641            assert_eq!(cache.layers[0].k[0].capacity(), 0, "K released after seal");
1642            assert_eq!(cache.layers[0].v[0].capacity(), 0, "V released after seal");
1643        }
1644    }
1645
1646    #[test]
1647    fn truncate_rolls_back_speculative_positions() {
1648        let mut cache = LayerKvCache::new(2, 4);
1649        cache.mode = KvMode::F32;
1650        for pos in 0..5 {
1651            let k = vec![pos as f32; 8];
1652            let v = vec![pos as f32; 8];
1653            cache.append(&k, &v, &[true; 2]);
1654        }
1655        cache.truncate_last(2);
1656        assert_eq!(cache.seq_len, 3);
1657        assert_eq!(cache.head_len(0), 3);
1658        assert_eq!(cache.head_keys(0)[2 * 4], 2.0, "position 2 survives");
1659    }
1660
1661    /// q8_2f-attend ≈ f32-attend: 100 positions (crosses the field freeze
1662    /// at the 64th), pseudo-random vectors, relative tolerance of the
1663    /// int8 grid. Plus rollback and mass-based eviction on the q8 storage.
1664    #[test]
1665    fn q8_attend_matches_f32_within_grid() {
1666        let (heads, hd) = (2, 32);
1667        let mut f = LayerKvCache::new(heads, hd);
1668        f.mode = KvMode::F32;
1669        let mut q8 = LayerKvCache::new(heads, hd);
1670        q8.mode = KvMode::Q8 { k: true, v: true };
1671
1672        let synth = |p: usize, salt: usize| -> Vec<f32> {
1673            (0..heads * hd)
1674                .map(|i| {
1675                    let x = ((i * 31 + p * 17 + salt * 7 + 3) % 97) as f32 / 97.0 - 0.5;
1676                    // channel structure: even channels ×4 (checks the 2f field)
1677                    if i % 2 == 0 { x * 4.0 } else { x * 0.25 }
1678                })
1679                .collect()
1680        };
1681        for p in 0..100 {
1682            let k = synth(p, 1);
1683            let v = synth(p, 2);
1684            f.append(&k, &v, &[true; 2]);
1685            q8.append(&k, &v, &[true; 2]);
1686        }
1687        let q: Vec<f32> = (0..hd)
1688            .map(|i| ((i * 13 + 5) % 89) as f32 / 89.0 - 0.5)
1689            .collect();
1690        for g in 0..heads {
1691            let (of, pf) = f.attend(&q, g);
1692            let (o8, p8) = q8.attend(&q, g);
1693            let scale = of.iter().fold(0f32, |m, x| m.max(x.abs())).max(1e-6);
1694            for d in 0..hd {
1695                assert!(
1696                    (of[d] - o8[d]).abs() <= scale * 0.03 + 1e-3,
1697                    "g{g} d{d}: f32 {} vs q8 {}",
1698                    of[d],
1699                    o8[d]
1700                );
1701            }
1702            for p in 0..100 {
1703                assert!((pf[p] - p8[p]).abs() < 0.02, "prob p{p}");
1704            }
1705        }
1706        // rollback + eviction live on the q8 storage
1707        q8.truncate_last(30);
1708        assert_eq!(q8.head_len(0), 70);
1709        let imp: Vec<f32> = (0..70).map(|i| i as f32).collect();
1710        q8.accumulate_imp(&imp);
1711        q8.evict_born(20, 2, 8);
1712        assert_eq!(q8.head_len(0), 20);
1713        let (o, _) = q8.attend(&q, 0);
1714        assert!(o.iter().all(|x| x.is_finite()));
1715        // memory: q8 ≈ 1 byte/element + scale per row (vs 4 for f32)
1716        assert!(q8.memory_bytes() * 3 < f.memory_bytes());
1717    }
1718
1719    /// Grouped GQA attend must be bit-identical to per-head attend in
1720    /// every KV mode (it is the same math with rows streamed once).
1721    #[test]
1722    fn attend_group_equals_per_head_attend_bitexact() {
1723        let (kv_heads, hd, hpk) = (2usize, 32usize, 3usize); // 6 Q-heads
1724        for mode in [KvMode::F32, KvMode::Q8 { k: true, v: true }] {
1725            let mut c = LayerKvCache::new(kv_heads, hd);
1726            c.mode = mode;
1727            for p in 0..70 {
1728                let k: Vec<f32> = (0..kv_heads * hd)
1729                    .map(|i| ((i * 31 + p * 17 + 3) % 97) as f32 / 97.0 - 0.5)
1730                    .collect();
1731                let v: Vec<f32> = (0..kv_heads * hd)
1732                    .map(|i| ((i * 13 + p * 29 + 7) % 89) as f32 / 89.0 - 0.5)
1733                    .collect();
1734                c.append(&k, &v, &[true; 2]);
1735            }
1736            let q: Vec<f32> = (0..kv_heads * hpk * hd)
1737                .map(|i| ((i * 11 + 5) % 83) as f32 / 83.0 - 0.5)
1738                .collect();
1739            for g in 0..kv_heads {
1740                let span = g * hpk * hd..(g + 1) * hpk * hd;
1741                let mut out = vec![0f32; hpk * hd];
1742                let mut imp = vec![0f32; 70];
1743                c.attend_group(
1744                    &q[span.clone()],
1745                    g,
1746                    &mut out,
1747                    &mut imp,
1748                    1.0 / (hd as f32).sqrt(),
1749                    0,
1750                    0.0,
1751                );
1752                let mut imp_ref = vec![0f32; 70];
1753                for h in 0..hpk {
1754                    let qh = &q[span.start + h * hd..span.start + (h + 1) * hd];
1755                    let (o, probs) = c.attend(qh, g);
1756                    assert_eq!(
1757                        &out[h * hd..(h + 1) * hd],
1758                        &o[..],
1759                        "mode {mode:?} g{g} h{h}: grouped attend must be bit-identical"
1760                    );
1761                    for (dst, &p) in imp_ref.iter_mut().zip(&probs) {
1762                        *dst += p;
1763                    }
1764                }
1765                assert_eq!(
1766                    imp, imp_ref,
1767                    "mode {mode:?} g{g}: attention mass must match"
1768                );
1769            }
1770        }
1771    }
1772
1773    /// Review regression: mass-based eviction in MIXED modes. q8v used to
1774    /// panic (gather over an empty v[h]), q8k silently left raw V
1775    /// uncompressed (stale rows under kept keys + memory leak).
1776    #[test]
1777    fn born_eviction_mixed_modes_stay_consistent() {
1778        for (mk, mv) in [(false, true), (true, false)] {
1779            let mut c = LayerKvCache::new(1, 4);
1780            c.mode = KvMode::Q8 { k: mk, v: mv };
1781            for p in 0..80 {
1782                let k = vec![p as f32 * 0.01; 4];
1783                let v = vec![p as f32; 4];
1784                c.append(&k, &v, &[true]);
1785            }
1786            let imp: Vec<f32> = (0..80).map(|i| i as f32).collect();
1787            c.accumulate_imp(&imp);
1788            let before = c.memory_bytes();
1789            c.evict_born(20, 4, 8); // q8v: used to panic here
1790            assert_eq!(c.head_len(0), 20, "k={mk} v={mv}");
1791            assert!(
1792                c.memory_bytes() < before / 2,
1793                "memory must shrink (k={mk} v={mv})"
1794            );
1795            // V rows match the kept set: the heaviest positions
1796            // (tail 60..79) must be present in the attend output.
1797            let (out, _) = c.attend(&[1.0, 1.0, 1.0, 1.0], 0);
1798            assert!(
1799                out[0] > 30.0,
1800                "V from the kept tail, not the stale head (k={mk} v={mv}, out {})",
1801                out[0]
1802            );
1803        }
1804    }
1805
1806    #[test]
1807    fn born_eviction_keeps_high_mass_position() {
1808        let mut cache = KvCache::new(1, 1, 2, 16);
1809        cache.policy = EvictionPolicy::Born { sink: 1 };
1810        for l in &mut cache.layers {
1811            l.mode = KvMode::F32;
1812        }
1813        let layer = &mut cache.layers[0];
1814        // 8 positions; keys carry the position index so we can verify
1815        // exactly which positions survive the gather.
1816        for pos in 0..8 {
1817            let k = vec![pos as f32; 2];
1818            let v = vec![pos as f32 + 100.0; 2];
1819            layer.append(&k, &v, &[true]);
1820        }
1821        // Position 3 carries the most attention mass.
1822        let mut imp = vec![0.05f32; 8];
1823        imp[3] = 5.0;
1824        layer.accumulate_imp(&imp);
1825
1826        cache.evict(4); // sink 1 + recent 2 + 1 top-mass slot
1827        let layer = &cache.layers[0];
1828        assert_eq!(layer.seq_len, 4);
1829        let kept_keys: Vec<f32> = (0..4).map(|i| layer.head_keys(0)[i * 2]).collect();
1830        assert_eq!(
1831            kept_keys,
1832            vec![0.0, 3.0, 6.0, 7.0],
1833            "kept = sink(0) + mass-top(3) + recent(6,7)"
1834        );
1835        // imp stays aligned with the gathered positions.
1836        assert_eq!(layer.head_len(0), 4);
1837    }
1838}