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 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 { k: bool, v: bool },
17}
18
19impl KvMode {
20    pub fn from_env() -> Self {
21        match std::env::var("CMF_KV").as_deref() {
22            Ok("q8") | Ok("q8_2f") => KvMode::Q8 { k: true, v: true },
23            Ok("q8k") => KvMode::Q8 { k: true, v: false },
24            Ok("q8v") => KvMode::Q8 { k: false, v: true },
25            _ => KvMode::F32,
26        }
27    }
28
29    fn quant_k(self) -> bool {
30        matches!(self, KvMode::Q8 { k: true, .. })
31    }
32
33    fn quant_v(self) -> bool {
34        matches!(self, KvMode::Q8 { v: true, .. })
35    }
36}
37
38/// Positions before freezing the per-channel field (2f): before — col ≡ 1,
39/// after — col = RMS over channels of the stored rows, old rows are requantized.
40const KV_COL_WARMUP: usize = 64;
41
42/// K-rows are quantized in groups of 32 channels (scale per group):
43/// attention logits are sensitive to the dot-product error, per-group scales
44/// localize it along RoPE bands (35B: +4.6% PPL with a per-row scale
45/// → target <1% with a per-group one). V — per-row scale (measured +0.56%).
46const KV_K_GROUP: usize = 32;
47
48/// Per-layer O(1) Nyström attention state (runtime `attn_type`
49/// override — spec §7 presence-driven pattern, no format change).
50///
51/// Collecting: the prompt pass still runs EXACT cache attention (the
52/// prefill outputs feed the residual stream, so they cannot be
53/// deferred) while the per-position rotated queries are buffered;
54/// `o1_seal()` then freezes landmarks + M from the full prompt, replays
55/// it into per-KV-group streaming states, and DROPS the full KV.
56/// Sealed: decode replaces cache attention with
57/// `NystromState::step_group()`.
58#[derive(Debug, Clone)]
59pub enum O1State {
60    Collecting {
61        m: usize,
62        w: usize,
63        sink: usize,
64        rect: crate::nystrom::O1Rect,
65        /// Rotated post-norm queries, `[pos × num_heads × head_dim]`.
66        q_buf: Vec<f32>,
67    },
68    /// One state per KV GROUP, each holding its group's Q heads. The
69    /// exact window / sinks / K̃ are stored once per group (every Q head
70    /// of the group reads the same k/v rows); only the far field, Q̃ and
71    /// M — the query-dependent pieces — stay per Q head. See
72    /// `NystromState` for which piece is which and why.
73    Sealed { groups: Vec<crate::nystrom::NystromState> },
74}
75
76/// KV cache for a single layer, head-major.
77#[derive(Debug, Clone)]
78pub struct LayerKvCache {
79    pub mode: KvMode,
80    /// Per-KV-head keys: `k[h]` is `[seq_len × head_dim]` (empty if head is dead).
81    k: Vec<Vec<f32>>,
82    /// Per-KV-head values, same layout.
83    v: Vec<Vec<f32>>,
84    /// q8 storage (mode == Q8_2F): int8 rows + f32 scale per row.
85    kq: Vec<Vec<i8>>,
86    ks: Vec<Vec<f32>>,
87    vq: Vec<Vec<i8>>,
88    vs: Vec<Vec<f32>>,
89    /// Per-channel fields 𝒲×θ per head [head_dim]; empty until frozen.
90    kcol: Vec<Vec<f32>>,
91    vcol: Vec<Vec<f32>>,
92    /// Accumulated attention mass per stored position (Born rule:
93    /// importance of a position = how much probability mass reads it).
94    imp: Vec<f32>,
95    /// Positions appended so far (grows once per token, dead heads included).
96    pub seq_len: usize,
97    pub num_kv_heads: usize,
98    pub head_dim: usize,
99    /// Linear-core condensate S (vmf_phase), f64; empty on full layers.
100    pub linear_state: Vec<f32>,
101    /// Tentative lane-2 state during speculative verify.
102    pub linear_scratch: Vec<f32>,
103    /// O(1) Nyström override (None = plain cache attention).
104    pub o1: Option<O1State>,
105}
106
107impl LayerKvCache {
108    pub fn new(num_kv_heads: usize, head_dim: usize) -> Self {
109        Self {
110            mode: KvMode::from_env(),
111            k: vec![Vec::new(); num_kv_heads],
112            v: vec![Vec::new(); num_kv_heads],
113            kq: vec![Vec::new(); num_kv_heads],
114            ks: vec![Vec::new(); num_kv_heads],
115            vq: vec![Vec::new(); num_kv_heads],
116            vs: vec![Vec::new(); num_kv_heads],
117            kcol: vec![Vec::new(); num_kv_heads],
118            vcol: vec![Vec::new(); num_kv_heads],
119            imp: Vec::new(),
120            seq_len: 0,
121            num_kv_heads,
122            head_dim,
123            linear_state: Vec::new(),
124            linear_scratch: Vec::new(),
125            o1: None,
126        }
127    }
128
129    /// Per-KV-head stored keys `[seq_len × head_dim]` (GPU token graph sync).
130    pub fn k_heads(&self) -> &[Vec<f32>] {
131        &self.k
132    }
133    /// Per-KV-head stored values `[seq_len × head_dim]`.
134    pub fn v_heads(&self) -> &[Vec<f32>] {
135        &self.v
136    }
137
138    // ── O(1) Nyström override ──
139
140    /// Arm query collection for a fresh prompt pass (a cleared cache).
141    pub fn o1_begin(&mut self, m: usize, w: usize, sink: usize, rect: crate::nystrom::O1Rect) {
142        self.o1 = Some(O1State::Collecting { m, w, sink, rect, q_buf: Vec::new() });
143    }
144
145    /// Record one position's rotated queries (`[num_heads × head_dim]`)
146    /// during the exact prompt pass. No-op unless collecting — the hook
147    /// sits inside qwen_attention so every prefill flavor (sequential,
148    /// batched) feeds the same trace.
149    pub fn o1_push_q(&mut self, q_all: &[f32]) {
150        if let Some(O1State::Collecting { q_buf, .. }) = &mut self.o1 {
151            q_buf.extend_from_slice(q_all);
152        }
153    }
154
155    pub fn o1_sealed(&self) -> bool {
156        matches!(self.o1, Some(O1State::Sealed { .. }))
157    }
158
159    /// Freeze the prompt into per-KV-group Nyström states and drop this
160    /// layer's full KV. Returns false (layer stays exact, KV kept) when
161    /// the preconditions fail: the seal needs f32 KV rows (`CMF_KV=q8`
162    /// stores int8), every group densely stored, a full q trace, and a
163    /// GQA fan-out that actually divides.
164    pub fn o1_seal(&mut self, num_heads: usize) -> bool {
165        // Idempotent: sealing a sealed (or plain) layer must not
166        // disturb its state — check before take().
167        if !matches!(self.o1, Some(O1State::Collecting { .. })) {
168            return self.o1_sealed();
169        }
170        let Some(O1State::Collecting { m, w, sink, rect, q_buf }) = self.o1.take() else {
171            unreachable!("checked above");
172        };
173        let (hd, t) = (self.head_dim, self.seq_len);
174        let nkv = self.num_kv_heads.max(1);
175        let hpk = num_heads / nkv;
176        let ok = t > 0
177            && self.mode == KvMode::F32
178            && q_buf.len() == t * num_heads * hd
179            && hpk * nkv == num_heads
180            && (0..self.num_kv_heads).all(|g| self.head_len(g) == t);
181        if !ok {
182            tracing::warn!(
183                "o1: cannot seal (needs f32 KV mode, dense heads, full query \
184                 trace, num_heads divisible by num_kv_heads) — layer keeps \
185                 exact attention"
186            );
187            return false;
188        }
189        let mut groups = Vec::with_capacity(self.num_kv_heads);
190        // Query trace is position-major; the state wants each head's
191        // queries contiguous, so transpose one group at a time.
192        let mut qh = vec![0.0f32; hpk * t * hd];
193        for g in 0..self.num_kv_heads {
194            for hh in 0..hpk {
195                let h = g * hpk + hh;
196                for p in 0..t {
197                    let src = (p * num_heads + h) * hd;
198                    let dst = (hh * t + p) * hd;
199                    qh[dst..dst + hd].copy_from_slice(&q_buf[src..src + hd]);
200                }
201            }
202            let qs: Vec<&[f32]> = (0..hpk).map(|hh| &qh[hh * t * hd..(hh + 1) * t * hd]).collect();
203            let mut st = crate::nystrom::NystromState::new_group(m, w, sink, hpk).with_rect(rect);
204            st.prefill_group(&qs, &self.k[g], &self.v[g], t, hd, hd);
205            groups.push(st);
206        }
207        // The states now carry everything decode needs — release the
208        // O(context) storage (this is the memory claim, not a cosmetic).
209        for h in 0..self.num_kv_heads {
210            self.k[h] = Vec::new();
211            self.v[h] = Vec::new();
212        }
213        self.imp = Vec::new();
214        self.o1 = Some(O1State::Sealed { groups });
215        true
216    }
217
218    /// One decode step on a sealed layer: per KV group, insert the
219    /// group's fresh (k, v) ONCE and read every Q head's attention
220    /// output. Returns `[num_heads × head_dim]`. Head h belongs to group
221    /// h/hpk, so a group's Q heads are contiguous in `q_all`/`out` —
222    /// same math as the shared KV row the exact path appends once.
223    pub fn o1_step(
224        &mut self,
225        q_all: &[f32],
226        k_new: &[f32],
227        v_new: &[f32],
228        num_heads: usize,
229    ) -> Vec<f32> {
230        let hd = self.head_dim;
231        let hpk = num_heads / self.num_kv_heads.max(1);
232        let mut out = vec![0.0f32; num_heads * hd];
233        let Some(O1State::Sealed { groups }) = &mut self.o1 else {
234            debug_assert!(false, "o1_step on an unsealed layer");
235            return out;
236        };
237        for (g, st) in groups.iter_mut().enumerate() {
238            let (lo, hi) = (g * hpk * hd, (g + 1) * hpk * hd);
239            st.step_group(
240                &q_all[lo..hi],
241                &k_new[g * hd..(g + 1) * hd],
242                &v_new[g * hd..(g + 1) * hd],
243                &mut out[lo..hi],
244            );
245        }
246        // Track the true context depth for the honest memory/seq report
247        // (nothing is stored per position — the state is O(1)).
248        self.seq_len += 1;
249        out
250    }
251
252    /// Bytes held by the O(1) override (query trace while collecting,
253    /// per-KV-group states once sealed).
254    pub fn o1_memory_bytes(&self) -> usize {
255        match &self.o1 {
256            Some(O1State::Collecting { q_buf, .. }) => {
257                q_buf.len() * std::mem::size_of::<f32>()
258            }
259            Some(O1State::Sealed { groups }) => {
260                groups.iter().map(|s| s.memory_bytes()).sum()
261            }
262            None => 0,
263        }
264    }
265
266    /// Quantize one row against the per-channel field (empty col = 1);
267    /// `group` — elements per scale (the whole row or KV_K_GROUP).
268    fn quant_row(row: &[f32], col: &[f32], q: &mut Vec<i8>, sc: &mut Vec<f32>,
269                 group: usize) {
270        let mut resid = vec![0.0f32; row.len()];
271        for (d, &x) in row.iter().enumerate() {
272            resid[d] = if col.is_empty() { x } else { x / col[d] };
273        }
274        for g0 in (0..row.len()).step_by(group) {
275            let g1 = (g0 + group).min(row.len());
276            let mut absmax = 0.0f32;
277            for &r in &resid[g0..g1] {
278                absmax = absmax.max(r.abs());
279            }
280            let s = (absmax / 127.0).max(1e-12);
281            sc.push(s);
282            for &r in &resid[g0..g1] {
283                q.push((r / s).round().clamp(-127.0, 127.0) as i8);
284            }
285        }
286    }
287
288    /// Freeze the 2f field: col = RMS of channels over stored rows, old
289    /// rows are requantized against the new field (once per conversation).
290    fn freeze_cols(&mut self) {
291        let hd = self.head_dim;
292        let ngk = hd.div_ceil(KV_K_GROUP);
293        for h in 0..self.num_kv_heads {
294            for (qv, sv, colv, group) in [
295                (&mut self.kq[h], &mut self.ks[h], &mut self.kcol[h], KV_K_GROUP),
296                (&mut self.vq[h], &mut self.vs[h], &mut self.vcol[h], hd),
297            ] {
298                let spp = if group == hd { 1 } else { ngk }; // scales per position
299                let n = sv.len() / spp;
300                if n == 0 {
301                    continue;
302                }
303                // Dequantize to f32, RMS over channels, requantize.
304                let mut rows = vec![0.0f32; n * hd];
305                for p in 0..n {
306                    for d in 0..hd {
307                        rows[p * hd + d] =
308                            qv[p * hd + d] as f32 * sv[p * spp + d / group];
309                    }
310                }
311                let mut col = vec![0.0f32; hd];
312                for p in 0..n {
313                    for d in 0..hd {
314                        col[d] += rows[p * hd + d] * rows[p * hd + d];
315                    }
316                }
317                for c in col.iter_mut() {
318                    *c = (*c / n as f32).sqrt().max(1e-6);
319                }
320                qv.clear();
321                sv.clear();
322                for p in 0..n {
323                    Self::quant_row(&rows[p * hd..(p + 1) * hd], &col, qv, sv, group);
324                }
325                *colv = col;
326            }
327        }
328    }
329
330    /// Append K/V for one position. `k_new`/`v_new` are
331    /// `[num_kv_heads × head_dim]`; heads with `alive[h] == false` are
332    /// skipped (their slices stay empty).
333    pub fn append(&mut self, k_new: &[f32], v_new: &[f32], alive: &[bool]) {
334        debug_assert_eq!(k_new.len(), self.num_kv_heads * self.head_dim);
335        debug_assert_eq!(v_new.len(), self.num_kv_heads * self.head_dim);
336        // Freeze the 2f field AT THE START of append: only rows that
337        // survived verify are visible (a rejected lane-2 draft does not
338        // pollute the field — found in review), and the threshold uses >=
339        // rather than strict equality (in small windows eviction may
340        // oscillate across 64).
341        if matches!(self.mode, KvMode::Q8 { .. })
342            && self.seq_len >= KV_COL_WARMUP
343            && self.kcol.iter().all(Vec::is_empty)
344            && self.vcol.iter().all(Vec::is_empty)
345        {
346            self.freeze_cols();
347        }
348        for h in 0..self.num_kv_heads {
349            if !alive.get(h).copied().unwrap_or(true) {
350                continue;
351            }
352            let s = h * self.head_dim;
353            if self.mode.quant_k() {
354                Self::quant_row(&k_new[s..s + self.head_dim],
355                                &self.kcol[h], &mut self.kq[h], &mut self.ks[h],
356                                KV_K_GROUP);
357            } else {
358                self.k[h].extend_from_slice(&k_new[s..s + self.head_dim]);
359            }
360            if self.mode.quant_v() {
361                Self::quant_row(&v_new[s..s + self.head_dim],
362                                &self.vcol[h], &mut self.vq[h], &mut self.vs[h],
363                                self.head_dim);
364            } else {
365                self.v[h].extend_from_slice(&v_new[s..s + self.head_dim]);
366            }
367        }
368        self.imp.push(0.0);
369        self.seq_len += 1;
370    }
371
372    /// Per-head attention over its own storage: the f32 branch is
373    /// bit-for-bit equal to attention_head() over slices; the q8 branch
374    /// computes score = s_k·⟨q⊙col_k, k_q⟩ and the weighted sum of V in i8
375    /// with f32 accumulation. Returns (output [head_dim], probs [stored]).
376    pub fn attend(&self, q: &[f32], kv_head: usize) -> (Vec<f32>, Vec<f32>) {
377        let hd = self.head_dim;
378        if self.mode == KvMode::F32 {
379            let stored = self.k[kv_head].len() / hd;
380            return crate::attention::attention_head(
381                q, &self.k[kv_head], &self.v[kv_head], hd, stored);
382        }
383        let stored = self.head_len(kv_head);
384        let scale = 1.0 / (hd as f32).sqrt();
385        let mut scores = vec![0.0f32; stored];
386        if self.mode.quant_k() {
387            let (kq, ks) = (&self.kq[kv_head], &self.ks[kv_head]);
388            // q ⊙ col_k — once per call.
389            let kcol = &self.kcol[kv_head];
390            let mut qc = vec![0.0f32; hd];
391            for d in 0..hd {
392                qc[d] = if kcol.is_empty() { q[d] } else { q[d] * kcol[d] };
393            }
394            let ng = hd.div_ceil(KV_K_GROUP);
395            for p in 0..stored {
396                let row = &kq[p * hd..(p + 1) * hd];
397                // SAFETY: i8 and u8 share layout; dot_i8_f32 reads the
398                // bytes back as i8.
399                let row_u8 = unsafe {
400                    std::slice::from_raw_parts(row.as_ptr() as *const u8, row.len())
401                };
402                let mut dot = 0.0f32;
403                for g in 0..ng {
404                    let g0 = g * KV_K_GROUP;
405                    let g1 = (g0 + KV_K_GROUP).min(hd);
406                    dot += crate::qtensor::dot_i8_f32(&row_u8[g0..g1], &qc[g0..g1])
407                        * ks[p * ng + g];
408                }
409                scores[p] = dot * scale;
410            }
411        } else {
412            let k = &self.k[kv_head];
413            for p in 0..stored {
414                let row = &k[p * hd..(p + 1) * hd];
415                scores[p] = crate::attention::dot_f32(q, row) * scale;
416            }
417        }
418        let max_score = scores.iter().cloned().fold(f32::NEG_INFINITY, f32::max);
419        let mut sum = 0.0f32;
420        for s in scores.iter_mut() {
421            *s = (*s - max_score).exp();
422            sum += *s;
423        }
424        if sum > 0.0 {
425            for s in scores.iter_mut() {
426                *s /= sum;
427            }
428        }
429        let mut acc = vec![0.0f32; hd];
430        if self.mode.quant_v() {
431            let (vq, vs) = (&self.vq[kv_head], &self.vs[kv_head]);
432            for p in 0..stored {
433                let w = scores[p] * vs[p];
434                if w.abs() < 1e-12 {
435                    continue;
436                }
437                crate::qtensor::axpy_i8_f32(&mut acc, &vq[p * hd..(p + 1) * hd], w);
438            }
439            let vcol = &self.vcol[kv_head];
440            if !vcol.is_empty() {
441                for d in 0..hd {
442                    acc[d] *= vcol[d];
443                }
444            }
445        } else {
446            let v = &self.v[kv_head];
447            for p in 0..stored {
448                let w = scores[p];
449                if w.abs() < 1e-12 {
450                    continue;
451                }
452                crate::attention::axpy_f32(&mut acc, &v[p * hd..(p + 1) * hd], w);
453            }
454        }
455        (acc, scores)
456    }
457
458    /// Grouped GQA attention: all Q-heads of one KV group in a single
459    /// pass over the stored K rows and a single pass over the V rows
460    /// (per-head `attend` re-read the shared group storage
461    /// heads_per_kv times — roadmap §3 P1). Per-head score order,
462    /// softmax and V accumulation are IDENTICAL to `attend`, so each
463    /// head's output is bit-for-bit the same.
464    ///
465    /// `q_group`: `[n_heads_in_group × head_dim]` (global head order);
466    /// `out`: same shape; `imp_acc[0..stored]` accumulates the probs of
467    /// every head (Born importance), matching the caller's former loop.
468    /// `scale` is the score scale (1/√hd unless the arch overrides);
469    /// `first` is the earliest visible position — sliding-window layers
470    /// pass `stored − window` so older rows get zero probability.
471    #[allow(clippy::too_many_arguments)]
472    pub fn attend_group(
473        &self,
474        q_group: &[f32],
475        kv_head: usize,
476        out: &mut [f32],
477        imp_acc: &mut [f32],
478        scale: f32,
479        first: usize,
480    ) {
481        let hd = self.head_dim;
482        let nheads = q_group.len() / hd;
483        debug_assert_eq!(out.len(), nheads * hd);
484        let stored = if self.mode == KvMode::F32 {
485            self.k[kv_head].len() / hd
486        } else {
487            self.head_len(kv_head)
488        };
489        if stored == 0 {
490            out.fill(0.0);
491            return;
492        }
493        let first = first.min(stored.saturating_sub(1));
494
495        thread_local! {
496            /// scores [nheads × stored] — reused across layers/tokens.
497            static GQA_SCORES: std::cell::RefCell<Vec<f32>> =
498                const { std::cell::RefCell::new(Vec::new()) };
499            /// q ⊙ col_k per head (q8 K mode).
500            static GQA_QC: std::cell::RefCell<Vec<f32>> =
501                const { std::cell::RefCell::new(Vec::new()) };
502        }
503
504        GQA_SCORES.with(|sc| {
505            let mut scores = sc.borrow_mut();
506            if first > 0 {
507                // Out-of-window rows stay at −inf → exp gives exactly 0,
508                // so softmax / V / importance need no special-casing.
509                scores.clear();
510                scores.resize(nheads * stored, f32::NEG_INFINITY);
511            } else {
512                scores.resize(nheads * stored, 0.0);
513            }
514
515            // ── score pass: each stored K row is read ONCE for all heads.
516            if self.mode.quant_k() {
517                let (kq, ks) = (&self.kq[kv_head], &self.ks[kv_head]);
518                let kcol = &self.kcol[kv_head];
519                let ng = hd.div_ceil(KV_K_GROUP);
520                GQA_QC.with(|qc| {
521                    let mut qcb = qc.borrow_mut();
522                    qcb.resize(nheads * hd, 0.0);
523                    for h in 0..nheads {
524                        for d in 0..hd {
525                            let qv = q_group[h * hd + d];
526                            qcb[h * hd + d] = if kcol.is_empty() { qv } else { qv * kcol[d] };
527                        }
528                    }
529                    for p in first..stored {
530                        let row = &kq[p * hd..(p + 1) * hd];
531                        // SAFETY: i8 and u8 share layout; dot_i8_f32 reads
532                        // the bytes back as i8.
533                        let row_u8 = unsafe {
534                            std::slice::from_raw_parts(row.as_ptr() as *const u8, row.len())
535                        };
536                        for h in 0..nheads {
537                            let qch = &qcb[h * hd..(h + 1) * hd];
538                            let mut dot = 0.0f32;
539                            for g in 0..ng {
540                                let g0 = g * KV_K_GROUP;
541                                let g1 = (g0 + KV_K_GROUP).min(hd);
542                                dot += crate::qtensor::dot_i8_f32(&row_u8[g0..g1], &qch[g0..g1])
543                                    * ks[p * ng + g];
544                            }
545                            scores[h * stored + p] = dot * scale;
546                        }
547                    }
548                });
549            } else {
550                let k = &self.k[kv_head];
551                for p in first..stored {
552                    let row = &k[p * hd..(p + 1) * hd];
553                    for h in 0..nheads {
554                        scores[h * stored + p] =
555                            crate::attention::dot_f32(&q_group[h * hd..(h + 1) * hd], row)
556                                * scale;
557                    }
558                }
559            }
560
561            // ── per-head softmax (identical to attend / attention_head).
562            for h in 0..nheads {
563                let s = &mut scores[h * stored..(h + 1) * stored];
564                let max_score = s.iter().cloned().fold(f32::NEG_INFINITY, f32::max);
565                let mut sum = 0.0f32;
566                for v in s.iter_mut() {
567                    *v = (*v - max_score).exp();
568                    sum += *v;
569                }
570                if sum > 0.0 {
571                    for v in s.iter_mut() {
572                        *v /= sum;
573                    }
574                }
575            }
576
577            // ── value pass: each stored V row is read ONCE for all heads.
578            out.fill(0.0);
579            if self.mode.quant_v() {
580                let (vq, vs) = (&self.vq[kv_head], &self.vs[kv_head]);
581                for p in first..stored {
582                    let row = &vq[p * hd..(p + 1) * hd];
583                    for h in 0..nheads {
584                        let w = scores[h * stored + p] * vs[p];
585                        if w.abs() < 1e-12 {
586                            continue;
587                        }
588                        crate::qtensor::axpy_i8_f32(&mut out[h * hd..(h + 1) * hd], row, w);
589                    }
590                }
591                let vcol = &self.vcol[kv_head];
592                if !vcol.is_empty() {
593                    for h in 0..nheads {
594                        for d in 0..hd {
595                            out[h * hd + d] *= vcol[d];
596                        }
597                    }
598                }
599            } else {
600                let v = &self.v[kv_head];
601                for p in first..stored {
602                    let row = &v[p * hd..(p + 1) * hd];
603                    for h in 0..nheads {
604                        let w = scores[h * stored + p];
605                        if w.abs() < 1e-12 {
606                            continue;
607                        }
608                        crate::attention::axpy_f32(&mut out[h * hd..(h + 1) * hd], row, w);
609                    }
610                }
611            }
612
613            // ── Born-importance accumulation (Σ probs over heads), same
614            // head order as the caller's former per-head loop.
615            let n = imp_acc.len().min(stored);
616            for h in 0..nheads {
617                let s = &scores[h * stored..(h + 1) * stored];
618                for (dst, &p) in imp_acc[..n].iter_mut().zip(s) {
619                    *dst += p;
620                }
621            }
622        });
623    }
624
625    /// Batched causal attend for a prefill chunk (macOS/AArch64): the
626    /// cache already holds every chunk row (`s0` old + `b` new). Per
627    /// Q-head the scores GEMM `Q·Kᵀ` rides the AMX, the causal softmax
628    /// zeroes the not-yet-visible tail so the `P·V` GEMM needs no
629    /// mask, and Born importance takes the masked column sums. Same
630    /// math as the per-position attend; summation order differs
631    /// (tolerance-class, like the projection GEMMs).
632    #[cfg(target_arch = "aarch64")]
633    #[allow(clippy::too_many_arguments)]
634    pub fn attend_chunk(
635        &mut self,
636        q_all: &[f32],
637        b: usize,
638        s0: usize,
639        nh: usize,
640        heads_per_kv: usize,
641        hd: usize,
642        out: &mut [f32],
643        pool: Option<&crate::pool::Pool>,
644        scale: f32,
645        window: Option<usize>,
646    ) {
647        let n = s0 + b;
648        struct SendPtr(*mut f32);
649        unsafe impl Send for SendPtr {}
650        unsafe impl Sync for SendPtr {}
651        impl SendPtr {
652            fn at(&self, i: usize) -> *mut f32 {
653                // Method receiver keeps the closure capturing &SendPtr
654                // (2021 disjoint capture would grab the raw field).
655                unsafe { self.0.add(i) }
656            }
657        }
658        thread_local! {
659            static SCRATCH: std::cell::RefCell<(Vec<f32>, Vec<f32>, Vec<f32>, Vec<f32>)> =
660                const { std::cell::RefCell::new((Vec::new(), Vec::new(), Vec::new(), Vec::new())) };
661        }
662        // The portable NEON GEMM pays dearly for the gathered Bᵀ loads
663        // of the scores multiply — pack Kᵀ once per (group, chunk) and
664        // hand it the sequential-B fast path instead. Accelerate keeps
665        // the no-copy transposed call.
666        let neon_gemm = cfg!(not(target_os = "macos"))
667            || std::env::var("CMF_FORCE_NEON_GEMM").map(|v| v == "1").unwrap_or(false);
668        SCRATCH.with(|s| {
669            let mut s = s.borrow_mut();
670            let (qpanel, scores, aopanel, ktpack) = &mut *s;
671            // The whole KV-group attends in one GEMM pair: the group's
672            // Q-heads stack head-major into one tall panel [hpk·b, hd]
673            // (row hl·b + bi), so each layer costs 2 sgemm calls per
674            // group instead of 2 per head — fat M keeps the AMX fed.
675            let m = heads_per_kv * b;
676            qpanel.resize(m * hd, 0.0);
677            scores.resize(m * n, 0.0);
678            aopanel.resize(m * hd, 0.0);
679            for g in 0..self.num_kv_heads {
680                let kmat = &self.k[g];
681                let vmat = &self.v[g];
682                debug_assert_eq!(kmat.len(), n * hd);
683                for hl in 0..heads_per_kv {
684                    let hh = g * heads_per_kv + hl;
685                    for bi in 0..b {
686                        qpanel[(hl * b + bi) * hd..(hl * b + bi + 1) * hd]
687                            .copy_from_slice(&q_all[bi * nh * hd + hh * hd..][..hd]);
688                    }
689                }
690                if neon_gemm {
691                    ktpack.resize(hd * n, 0.0);
692                    for p in 0..n {
693                        let row = &kmat[p * hd..(p + 1) * hd];
694                        for (d, &v) in row.iter().enumerate() {
695                            ktpack[d * n + p] = v;
696                        }
697                    }
698                    // Accelerate threads its own GEMM; the NEON kernel
699                    // splits the m rows across the pool instead.
700                    let sp_q = SendPtr(qpanel.as_ptr() as *mut f32);
701                    let sp_s = SendPtr(scores.as_mut_ptr());
702                    let kt = &*ktpack;
703                    let run = |start: usize, end: usize| {
704                        if end > start {
705                            // SAFETY: workers write disjoint score rows.
706                            let a = unsafe {
707                                std::slice::from_raw_parts(sp_q.at(start * hd), (end - start) * hd)
708                            };
709                            let c = unsafe {
710                                std::slice::from_raw_parts_mut(sp_s.at(start * n), (end - start) * n)
711                            };
712                            crate::qtensor::neon_gemm_rm(
713                                end - start, n, hd, scale, a, hd, kt, n, false, c, n,
714                            );
715                        }
716                    };
717                    match pool {
718                        Some(p) if m >= 64 => p.run_rows(m, &run),
719                        _ => run(0, m),
720                    }
721                } else {
722                    crate::qtensor::sgemm_rm(m, n, hd, scale, qpanel, hd, kmat, hd, true, scores, n);
723                }
724                // Causal softmax, row-parallel (rows are disjoint).
725                let sp = SendPtr(scores.as_mut_ptr());
726                let run = |start: usize, end: usize| {
727                    for r in start..end {
728                        let allowed = s0 + (r % b) + 1;
729                        // Sliding-window layers see only the last W of
730                        // the causal range; the zeroed head contributes
731                        // nothing to P·V or Born importance.
732                        let lo = window.map(|w| allowed.saturating_sub(w)).unwrap_or(0);
733                        // SAFETY: workers cover disjoint row ranges.
734                        let row = unsafe { std::slice::from_raw_parts_mut(sp.at(r * n), n) };
735                        crate::attention::softmax_row(&mut row[lo..allowed]);
736                        row[..lo].fill(0.0);
737                        row[allowed..].fill(0.0);
738                    }
739                };
740                match pool {
741                    Some(p) if m >= 64 => p.run_rows(m, &run),
742                    _ => run(0, m),
743                }
744                // Born importance: masked column sums (probs of the
745                // zeroed tail contribute nothing, same as the CPU
746                // per-position accumulate).
747                let ni = self.imp.len().min(n);
748                for r in 0..m {
749                    let al = (s0 + (r % b) + 1).min(ni);
750                    for (dst, &p) in self.imp[..al].iter_mut().zip(&scores[r * n..r * n + al]) {
751                        *dst += p;
752                    }
753                }
754                if neon_gemm {
755                    let sp_s = SendPtr(scores.as_mut_ptr());
756                    let sp_o = SendPtr(aopanel.as_mut_ptr());
757                    let run = |start: usize, end: usize| {
758                        if end > start {
759                            // SAFETY: workers write disjoint output rows.
760                            let a = unsafe {
761                                std::slice::from_raw_parts(sp_s.at(start * n), (end - start) * n)
762                            };
763                            let c = unsafe {
764                                std::slice::from_raw_parts_mut(
765                                    sp_o.at(start * hd),
766                                    (end - start) * hd,
767                                )
768                            };
769                            crate::qtensor::neon_gemm_rm(
770                                end - start, hd, n, 1.0, a, n, vmat, hd, false, c, hd,
771                            );
772                        }
773                    };
774                    match pool {
775                        Some(p) if m >= 64 => p.run_rows(m, &run),
776                        _ => run(0, m),
777                    }
778                } else {
779                    crate::qtensor::sgemm_rm(m, hd, n, 1.0, scores, n, vmat, hd, false, aopanel, hd);
780                }
781                for hl in 0..heads_per_kv {
782                    let hh = g * heads_per_kv + hl;
783                    for bi in 0..b {
784                        out[bi * nh * hd + hh * hd..][..hd]
785                            .copy_from_slice(&aopanel[(hl * b + bi) * hd..(hl * b + bi + 1) * hd]);
786                    }
787                }
788            }
789        });
790    }
791
792    /// Roll back the last `n_drop` positions (speculative-decode reject).
793    pub fn truncate_last(&mut self, n_drop: usize) {
794        let d = n_drop.min(self.seq_len);
795        for h in 0..self.num_kv_heads {
796            let keep = self.k[h].len().saturating_sub(d * self.head_dim);
797            self.k[h].truncate(keep);
798            self.v[h].truncate(keep);
799            let ngk = self.head_dim.div_ceil(KV_K_GROUP);
800            let keep_q = self.kq[h].len().saturating_sub(d * self.head_dim);
801            self.kq[h].truncate(keep_q);
802            let keep_vq = self.vq[h].len().saturating_sub(d * self.head_dim);
803            self.vq[h].truncate(keep_vq);
804            let keep_ks = self.ks[h].len().saturating_sub(d * ngk);
805            self.ks[h].truncate(keep_ks);
806            let keep_vs = self.vs[h].len().saturating_sub(d);
807            self.vs[h].truncate(keep_vs);
808        }
809        self.imp.truncate(self.imp.len().saturating_sub(d));
810        self.seq_len -= d;
811    }
812
813    /// Accumulate attention mass per stored position (summed over heads).
814    pub fn accumulate_imp(&mut self, probs: &[f32]) {
815        for (dst, &p) in self.imp.iter_mut().zip(probs) {
816            *dst += p;
817        }
818    }
819
820    /// Contiguous keys of one head: `[stored_len × head_dim]`.
821    pub fn head_keys(&self, kv_head: usize) -> &[f32] {
822        &self.k[kv_head]
823    }
824
825    pub fn head_values(&self, kv_head: usize) -> &[f32] {
826        &self.v[kv_head]
827    }
828
829    /// Number of positions actually stored for a head (0 for dead heads).
830    pub fn head_len(&self, kv_head: usize) -> usize {
831        let ng = self.head_dim.div_ceil(KV_K_GROUP);
832        (self.k[kv_head].len() / self.head_dim)
833            .max(self.ks[kv_head].len() / ng)
834            .max(self.vs[kv_head].len())
835    }
836
837    /// Clear cache (e.g. on new conversation or task switch).
838    pub fn clear(&mut self) {
839        for h in 0..self.num_kv_heads {
840            self.k[h].clear();
841            self.v[h].clear();
842            self.kq[h].clear();
843            self.ks[h].clear();
844            self.vq[h].clear();
845            self.vs[h].clear();
846            self.kcol[h].clear();
847            self.vcol[h].clear();
848        }
849        self.imp.clear();
850        self.linear_state.clear();
851        self.linear_scratch.clear();
852        // Fresh conversation → the pipeline re-arms collection if the
853        // layer is o1-flagged (landmarks are per-prompt, never reused).
854        self.o1 = None;
855        self.seq_len = 0;
856    }
857
858    /// Memory usage in bytes.
859    pub fn memory_bytes(&self) -> usize {
860        let floats: usize = self.k.iter().map(Vec::len).sum::<usize>()
861            + self.v.iter().map(Vec::len).sum::<usize>()
862            + self.ks.iter().map(Vec::len).sum::<usize>()
863            + self.vs.iter().map(Vec::len).sum::<usize>()
864            + self.kcol.iter().map(Vec::len).sum::<usize>()
865            + self.vcol.iter().map(Vec::len).sum::<usize>();
866        let bytes: usize = self.kq.iter().map(Vec::len).sum::<usize>()
867            + self.vq.iter().map(Vec::len).sum::<usize>();
868        floats * std::mem::size_of::<f32>()
869            + bytes
870            // O(1) recurrent state of linear-core layers (vmf_phase/GDN):
871            // constant in context, but real memory — the honest "KV+state"
872            // line must count it (a pure-linear model reported 0 before).
873            + self.linear_state.len() * std::mem::size_of::<f32>()
874            // O(1) Nyström state (window + sinks + skeleton) — same
875            // discipline: constant in context, but real memory.
876            + self.o1_memory_bytes()
877    }
878
879    /// Drop oldest positions, keeping the last `keep_last`.
880    fn evict(&mut self, keep_last: usize) {
881        // A sealed o1 layer stores nothing per position — the Nyström
882        // state IS the eviction policy; resetting seq_len here would lie
883        // about the context depth.
884        if self.o1_sealed() || self.seq_len <= keep_last {
885            return;
886        }
887        let drop = self.seq_len - keep_last;
888        for h in 0..self.num_kv_heads {
889            // Dead heads store fewer positions; drop proportionally.
890            let stored = self.head_len(h);
891            let d = drop.min(stored);
892            let hd = self.head_dim;
893            fn drop_front<T>(v: &mut Vec<T>, n: usize) {
894                let n = n.min(v.len());
895                v.drain(..n);
896            }
897            drop_front(&mut self.k[h], d * hd);
898            drop_front(&mut self.v[h], d * hd);
899            drop_front(&mut self.kq[h], d * hd);
900            drop_front(&mut self.vq[h], d * hd);
901            drop_front(&mut self.ks[h], d * hd.div_ceil(KV_K_GROUP));
902            drop_front(&mut self.vs[h], d);
903        }
904        let d = drop.min(self.imp.len());
905        self.imp.drain(..d);
906        self.seq_len = keep_last;
907    }
908
909    /// Born eviction: keep `sink` earliest positions (attention sinks),
910    /// the `recent` latest, and fill the rest of the `keep_last` budget
911    /// with the positions carrying the highest accumulated attention
912    /// mass (vmfcore: PPL 8.342 vs 8.687 for recency-only, full 8.295).
913    fn evict_born(&mut self, keep_last: usize, sink: usize, recent: usize) {
914        if self.o1_sealed() {
915            return; // see evict(): the o1 state is its own eviction
916        }
917        let stored = self.imp.len();
918        if stored <= keep_last {
919            return;
920        }
921        // Budget discipline: sinks first, recents next, both clamped so
922        // the total never exceeds keep_last.
923        let sink_n = sink.min(keep_last);
924        let recent_n = recent.min(keep_last - sink_n);
925        let mut keep = vec![false; stored];
926        for k in keep.iter_mut().take(sink_n) {
927            *k = true;
928        }
929        for k in keep.iter_mut().skip(stored.saturating_sub(recent_n)) {
930            *k = true;
931        }
932        let mut budget = keep_last.saturating_sub(keep.iter().filter(|&&x| x).count());
933        // Highest accumulated mass first among the middle positions.
934        let mut order: Vec<usize> = (0..stored).filter(|&i| !keep[i]).collect();
935        order.sort_by(|&a, &b| {
936            self.imp[b].partial_cmp(&self.imp[a]).unwrap_or(std::cmp::Ordering::Equal)
937        });
938        for i in order {
939            if budget == 0 {
940                break;
941            }
942            keep[i] = true;
943            budget -= 1;
944        }
945
946        let kept: Vec<usize> = (0..stored).filter(|&i| keep[i]).collect();
947        let hd = self.head_dim;
948        fn gather<T: Copy>(src: &[T], kept: &[usize], step: usize) -> Vec<T> {
949            let mut out = Vec::with_capacity(kept.len() * step);
950            for &i in kept {
951                out.extend_from_slice(&src[i * step..(i + 1) * step]);
952            }
953            out
954        }
955        // Each storage is gathered INDEPENDENTLY: in mixed modes
956        // (q8k/q8v) K and V live in different storages — the paired branch
957        // panicked (q8v) or silently left V uncompressed (q8k);
958        // found by adversarial review, closed by regression tests.
959        for h in 0..self.num_kv_heads {
960            if !self.k[h].is_empty() {
961                self.k[h] = gather(&self.k[h], &kept, hd);
962            }
963            if !self.v[h].is_empty() {
964                self.v[h] = gather(&self.v[h], &kept, hd);
965            }
966            if !self.kq[h].is_empty() {
967                self.kq[h] = gather(&self.kq[h], &kept, hd);
968                self.ks[h] = gather(&self.ks[h], &kept, hd.div_ceil(KV_K_GROUP));
969            }
970            if !self.vq[h].is_empty() {
971                self.vq[h] = gather(&self.vq[h], &kept, hd);
972                self.vs[h] = gather(&self.vs[h], &kept, 1);
973            }
974        }
975        self.imp = kept.iter().map(|&i| self.imp[i]).collect();
976        self.seq_len = kept.len();
977    }
978}
979
980/// Eviction policy for a bounded cache.
981#[derive(Debug, Clone, Copy, PartialEq, Eq)]
982pub enum EvictionPolicy {
983    /// Sliding window: keep only the most recent positions.
984    Recent,
985    /// Born rule: sinks + recents + top accumulated attention mass.
986    Born { sink: usize },
987}
988
989/// Full KV cache for all layers.
990#[derive(Debug)]
991pub struct KvCache {
992    pub layers: Vec<LayerKvCache>,
993    pub max_seq_len: usize,
994    pub policy: EvictionPolicy,
995}
996
997impl KvCache {
998    pub fn new(num_layers: usize, num_kv_heads: usize, head_dim: usize, max_seq_len: usize) -> Self {
999        let layers = (0..num_layers)
1000            .map(|_| LayerKvCache::new(num_kv_heads, head_dim))
1001            .collect();
1002        Self {
1003            layers,
1004            max_seq_len,
1005            policy: EvictionPolicy::Born { sink: 4 },
1006        }
1007    }
1008
1009    pub fn clear(&mut self) {
1010        for layer in &mut self.layers {
1011            layer.clear();
1012        }
1013    }
1014
1015    pub fn total_memory_bytes(&self) -> usize {
1016        self.layers.iter().map(|l| l.memory_bytes()).sum()
1017    }
1018
1019    /// Current sequence length (max across layers — dead layers may lag).
1020    pub fn seq_len(&self) -> usize {
1021        self.layers.iter().map(|l| l.seq_len).max().unwrap_or(0)
1022    }
1023
1024    pub fn needs_eviction(&self) -> bool {
1025        self.seq_len() >= self.max_seq_len
1026    }
1027
1028    /// Evict down to `keep_last` positions according to the policy.
1029    pub fn evict(&mut self, keep_last: usize) {
1030        match self.policy {
1031            EvictionPolicy::Recent => {
1032                for layer in &mut self.layers {
1033                    layer.evict(keep_last);
1034                }
1035            }
1036            EvictionPolicy::Born { sink } => {
1037                let recent = (keep_last / 2).max(1);
1038                for layer in &mut self.layers {
1039                    layer.evict_born(keep_last, sink, recent);
1040                }
1041            }
1042        }
1043    }
1044}
1045
1046#[cfg(test)]
1047mod tests {
1048    use super::*;
1049
1050    #[test]
1051    fn append_tracks_seq_len_and_layout() {
1052        let mut cache = LayerKvCache::new(4, 8);
1053        cache.mode = KvMode::F32;
1054        assert_eq!(cache.seq_len, 0);
1055
1056        let k: Vec<f32> = (0..32).map(|i| i as f32).collect();
1057        let v = vec![2.0f32; 32];
1058        cache.append(&k, &v, &[true; 4]);
1059
1060        assert_eq!(cache.seq_len, 1);
1061        assert_eq!(cache.head_len(0), 1);
1062        // head 1 slice is contiguous and equals its part of k_new
1063        assert_eq!(cache.head_keys(1), &k[8..16]);
1064        assert_eq!(cache.memory_bytes(), 256);
1065    }
1066
1067    #[test]
1068    fn dead_head_stores_nothing() {
1069        let mut cache = LayerKvCache::new(2, 4);
1070        cache.mode = KvMode::F32;
1071        let k = vec![1.0f32; 8];
1072        let v = vec![2.0f32; 8];
1073        cache.append(&k, &v, &[true, false]);
1074        cache.append(&k, &v, &[true, false]);
1075
1076        assert_eq!(cache.seq_len, 2);
1077        assert_eq!(cache.head_len(0), 2);
1078        assert_eq!(cache.head_len(1), 0, "dead head must not store KV");
1079        assert_eq!(cache.memory_bytes(), 2 * 2 * 4 * 4);
1080    }
1081
1082    #[test]
1083    fn eviction_keeps_recent() {
1084        let mut cache = KvCache::new(2, 4, 8, 10);
1085        cache.policy = EvictionPolicy::Recent;
1086        for l in &mut cache.layers { l.mode = KvMode::F32; }
1087        let k = vec![1.0f32; 32];
1088        let v = vec![2.0f32; 32];
1089        for _ in 0..8 {
1090            for layer in &mut cache.layers {
1091                layer.append(&k, &v, &[true; 4]);
1092            }
1093        }
1094        assert_eq!(cache.seq_len(), 8);
1095        assert!(!cache.needs_eviction());
1096
1097        cache.evict(4);
1098        assert_eq!(cache.seq_len(), 4);
1099        assert_eq!(cache.layers[0].head_len(0), 4);
1100    }
1101
1102    #[test]
1103    fn truncate_rolls_back_speculative_positions() {
1104        let mut cache = LayerKvCache::new(2, 4);
1105        cache.mode = KvMode::F32;
1106        for pos in 0..5 {
1107            let k = vec![pos as f32; 8];
1108            let v = vec![pos as f32; 8];
1109            cache.append(&k, &v, &[true; 2]);
1110        }
1111        cache.truncate_last(2);
1112        assert_eq!(cache.seq_len, 3);
1113        assert_eq!(cache.head_len(0), 3);
1114        assert_eq!(cache.head_keys(0)[2 * 4], 2.0, "position 2 survives");
1115    }
1116
1117    /// q8_2f-attend ≈ f32-attend: 100 positions (crosses the field freeze
1118    /// at the 64th), pseudo-random vectors, relative tolerance of the
1119    /// int8 grid. Plus rollback and Born eviction on the q8 storage.
1120    #[test]
1121    fn q8_attend_matches_f32_within_grid() {
1122        let (heads, hd) = (2, 32);
1123        let mut f = LayerKvCache::new(heads, hd);
1124        f.mode = KvMode::F32;
1125        let mut q8 = LayerKvCache::new(heads, hd);
1126        q8.mode = KvMode::Q8 { k: true, v: true };
1127
1128        let synth = |p: usize, salt: usize| -> Vec<f32> {
1129            (0..heads * hd)
1130                .map(|i| {
1131                    let x = ((i * 31 + p * 17 + salt * 7 + 3) % 97) as f32 / 97.0 - 0.5;
1132                    // channel structure: even channels ×4 (checks the 2f field)
1133                    if i % 2 == 0 { x * 4.0 } else { x * 0.25 }
1134                })
1135                .collect()
1136        };
1137        for p in 0..100 {
1138            let k = synth(p, 1);
1139            let v = synth(p, 2);
1140            f.append(&k, &v, &[true; 2]);
1141            q8.append(&k, &v, &[true; 2]);
1142        }
1143        let q: Vec<f32> = (0..hd).map(|i| ((i * 13 + 5) % 89) as f32 / 89.0 - 0.5).collect();
1144        for g in 0..heads {
1145            let (of, pf) = f.attend(&q, g);
1146            let (o8, p8) = q8.attend(&q, g);
1147            let scale = of.iter().fold(0f32, |m, x| m.max(x.abs())).max(1e-6);
1148            for d in 0..hd {
1149                assert!(
1150                    (of[d] - o8[d]).abs() <= scale * 0.03 + 1e-3,
1151                    "g{g} d{d}: f32 {} vs q8 {}", of[d], o8[d]
1152                );
1153            }
1154            for p in 0..100 {
1155                assert!((pf[p] - p8[p]).abs() < 0.02, "prob p{p}");
1156            }
1157        }
1158        // rollback + eviction live on the q8 storage
1159        q8.truncate_last(30);
1160        assert_eq!(q8.head_len(0), 70);
1161        let imp: Vec<f32> = (0..70).map(|i| i as f32).collect();
1162        q8.accumulate_imp(&imp);
1163        q8.evict_born(20, 2, 8);
1164        assert_eq!(q8.head_len(0), 20);
1165        let (o, _) = q8.attend(&q, 0);
1166        assert!(o.iter().all(|x| x.is_finite()));
1167        // memory: q8 ≈ 1 byte/element + scale per row (vs 4 for f32)
1168        assert!(q8.memory_bytes() * 3 < f.memory_bytes());
1169    }
1170
1171    /// Grouped GQA attend must be bit-identical to per-head attend in
1172    /// every KV mode (it is the same math with rows streamed once).
1173    #[test]
1174    fn attend_group_equals_per_head_attend_bitexact() {
1175        let (kv_heads, hd, hpk) = (2usize, 32usize, 3usize); // 6 Q-heads
1176        for mode in [KvMode::F32, KvMode::Q8 { k: true, v: true }] {
1177            let mut c = LayerKvCache::new(kv_heads, hd);
1178            c.mode = mode;
1179            for p in 0..70 {
1180                let k: Vec<f32> = (0..kv_heads * hd)
1181                    .map(|i| ((i * 31 + p * 17 + 3) % 97) as f32 / 97.0 - 0.5)
1182                    .collect();
1183                let v: Vec<f32> = (0..kv_heads * hd)
1184                    .map(|i| ((i * 13 + p * 29 + 7) % 89) as f32 / 89.0 - 0.5)
1185                    .collect();
1186                c.append(&k, &v, &[true; 2]);
1187            }
1188            let q: Vec<f32> = (0..kv_heads * hpk * hd)
1189                .map(|i| ((i * 11 + 5) % 83) as f32 / 83.0 - 0.5)
1190                .collect();
1191            for g in 0..kv_heads {
1192                let span = g * hpk * hd..(g + 1) * hpk * hd;
1193                let mut out = vec![0f32; hpk * hd];
1194                let mut imp = vec![0f32; 70];
1195                c.attend_group(
1196                    &q[span.clone()],
1197                    g,
1198                    &mut out,
1199                    &mut imp,
1200                    1.0 / (hd as f32).sqrt(),
1201                    0,
1202                );
1203                let mut imp_ref = vec![0f32; 70];
1204                for h in 0..hpk {
1205                    let qh = &q[span.start + h * hd..span.start + (h + 1) * hd];
1206                    let (o, probs) = c.attend(qh, g);
1207                    assert_eq!(
1208                        &out[h * hd..(h + 1) * hd],
1209                        &o[..],
1210                        "mode {mode:?} g{g} h{h}: grouped attend must be bit-identical"
1211                    );
1212                    for (dst, &p) in imp_ref.iter_mut().zip(&probs) {
1213                        *dst += p;
1214                    }
1215                }
1216                assert_eq!(imp, imp_ref, "mode {mode:?} g{g}: Born mass must match");
1217            }
1218        }
1219    }
1220
1221    /// Review regression: Born eviction in MIXED modes. q8v used to
1222    /// panic (gather over an empty v[h]), q8k silently left raw V
1223    /// uncompressed (stale rows under kept keys + memory leak).
1224    #[test]
1225    fn born_eviction_mixed_modes_stay_consistent() {
1226        for (mk, mv) in [(false, true), (true, false)] {
1227            let mut c = LayerKvCache::new(1, 4);
1228            c.mode = KvMode::Q8 { k: mk, v: mv };
1229            for p in 0..80 {
1230                let k = vec![p as f32 * 0.01; 4];
1231                let v = vec![p as f32; 4];
1232                c.append(&k, &v, &[true]);
1233            }
1234            let imp: Vec<f32> = (0..80).map(|i| i as f32).collect();
1235            c.accumulate_imp(&imp);
1236            let before = c.memory_bytes();
1237            c.evict_born(20, 4, 8); // q8v: used to panic here
1238            assert_eq!(c.head_len(0), 20, "k={mk} v={mv}");
1239            assert!(c.memory_bytes() < before / 2,
1240                    "memory must shrink (k={mk} v={mv})");
1241            // V rows match the kept set: the heaviest positions
1242            // (tail 60..79) must be present in the attend output.
1243            let (out, _) = c.attend(&[1.0, 1.0, 1.0, 1.0], 0);
1244            assert!(out[0] > 30.0,
1245                    "V from the kept tail, not the stale head (k={mk} v={mv}, out {})",
1246                    out[0]);
1247        }
1248    }
1249
1250    #[test]
1251    fn born_eviction_keeps_high_mass_position() {
1252        let mut cache = KvCache::new(1, 1, 2, 16);
1253        cache.policy = EvictionPolicy::Born { sink: 1 };
1254        for l in &mut cache.layers { l.mode = KvMode::F32; }
1255        let layer = &mut cache.layers[0];
1256        // 8 positions; keys carry the position index so we can verify
1257        // exactly which positions survive the gather.
1258        for pos in 0..8 {
1259            let k = vec![pos as f32; 2];
1260            let v = vec![pos as f32 + 100.0; 2];
1261            layer.append(&k, &v, &[true]);
1262        }
1263        // Position 3 carries the most attention mass (Born importance).
1264        let mut imp = vec![0.05f32; 8];
1265        imp[3] = 5.0;
1266        layer.accumulate_imp(&imp);
1267
1268        cache.evict(4); // sink 1 + recent 2 + 1 top-mass slot
1269        let layer = &cache.layers[0];
1270        assert_eq!(layer.seq_len, 4);
1271        let kept_keys: Vec<f32> = (0..4).map(|i| layer.head_keys(0)[i * 2]).collect();
1272        assert_eq!(
1273            kept_keys,
1274            vec![0.0, 3.0, 6.0, 7.0],
1275            "kept = sink(0) + Born-top(3) + recent(6,7)"
1276        );
1277        // imp stays aligned with the gathered positions.
1278        assert_eq!(layer.head_len(0), 4);
1279    }
1280}