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