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    ) {
516        let hd = self.head_dim;
517        let nheads = q_group.len() / hd;
518        debug_assert_eq!(out.len(), nheads * hd);
519        let stored = if self.mode == KvMode::F32 {
520            self.k[kv_head].len() / hd
521        } else {
522            self.head_len(kv_head)
523        };
524        if stored == 0 {
525            out.fill(0.0);
526            return;
527        }
528        let first = first.min(stored.saturating_sub(1));
529
530        thread_local! {
531            /// scores [nheads × stored] — reused across layers/tokens.
532            static GQA_SCORES: std::cell::RefCell<Vec<f32>> =
533                const { std::cell::RefCell::new(Vec::new()) };
534            /// q ⊙ col_k per head (q8 K mode).
535            static GQA_QC: std::cell::RefCell<Vec<f32>> =
536                const { std::cell::RefCell::new(Vec::new()) };
537        }
538
539        GQA_SCORES.with(|sc| {
540            let mut scores = sc.borrow_mut();
541            if first > 0 {
542                // Out-of-window rows stay at −inf → exp gives exactly 0,
543                // so softmax / V / importance need no special-casing.
544                scores.clear();
545                scores.resize(nheads * stored, f32::NEG_INFINITY);
546            } else {
547                scores.resize(nheads * stored, 0.0);
548            }
549
550            // ── score pass: each stored K row is read ONCE for all heads.
551            if self.mode.quant_k() {
552                let (kq, ks) = (&self.kq[kv_head], &self.ks[kv_head]);
553                let kcol = &self.kcol[kv_head];
554                let ng = hd.div_ceil(KV_K_GROUP);
555                GQA_QC.with(|qc| {
556                    let mut qcb = qc.borrow_mut();
557                    qcb.resize(nheads * hd, 0.0);
558                    for h in 0..nheads {
559                        for d in 0..hd {
560                            let qv = q_group[h * hd + d];
561                            qcb[h * hd + d] = if kcol.is_empty() { qv } else { qv * kcol[d] };
562                        }
563                    }
564                    for p in first..stored {
565                        let row = &kq[p * hd..(p + 1) * hd];
566                        // SAFETY: i8 and u8 share layout; dot_i8_f32 reads
567                        // the bytes back as i8.
568                        let row_u8 = unsafe {
569                            std::slice::from_raw_parts(row.as_ptr() as *const u8, row.len())
570                        };
571                        for h in 0..nheads {
572                            let qch = &qcb[h * hd..(h + 1) * hd];
573                            let mut dot = 0.0f32;
574                            for g in 0..ng {
575                                let g0 = g * KV_K_GROUP;
576                                let g1 = (g0 + KV_K_GROUP).min(hd);
577                                dot += crate::qtensor::dot_i8_f32(&row_u8[g0..g1], &qch[g0..g1])
578                                    * ks[p * ng + g];
579                            }
580                            scores[h * stored + p] = dot * scale;
581                        }
582                    }
583                });
584            } else {
585                let k = &self.k[kv_head];
586                for p in first..stored {
587                    let row = &k[p * hd..(p + 1) * hd];
588                    for h in 0..nheads {
589                        scores[h * stored + p] =
590                            crate::attention::dot_f32(&q_group[h * hd..(h + 1) * hd], row) * scale;
591                    }
592                }
593            }
594
595            // ── per-head softmax (identical to attend / attention_head).
596            for h in 0..nheads {
597                let s = &mut scores[h * stored..(h + 1) * stored];
598                let max_score = s.iter().cloned().fold(f32::NEG_INFINITY, f32::max);
599                let mut sum = 0.0f32;
600                for v in s.iter_mut() {
601                    *v = (*v - max_score).exp();
602                    sum += *v;
603                }
604                if sum > 0.0 {
605                    for v in s.iter_mut() {
606                        *v /= sum;
607                    }
608                }
609            }
610
611            // ── value pass: each stored V row is read ONCE for all heads.
612            out.fill(0.0);
613            if self.mode.quant_v() {
614                let (vq, vs) = (&self.vq[kv_head], &self.vs[kv_head]);
615                for p in first..stored {
616                    let row = &vq[p * hd..(p + 1) * hd];
617                    for h in 0..nheads {
618                        let w = scores[h * stored + p] * vs[p];
619                        if w.abs() < 1e-12 {
620                            continue;
621                        }
622                        crate::qtensor::axpy_i8_f32(&mut out[h * hd..(h + 1) * hd], row, w);
623                    }
624                }
625                let vcol = &self.vcol[kv_head];
626                if !vcol.is_empty() {
627                    for h in 0..nheads {
628                        for d in 0..hd {
629                            out[h * hd + d] *= vcol[d];
630                        }
631                    }
632                }
633            } else {
634                let v = &self.v[kv_head];
635                for p in first..stored {
636                    let row = &v[p * hd..(p + 1) * hd];
637                    for h in 0..nheads {
638                        let w = scores[h * stored + p];
639                        if w.abs() < 1e-12 {
640                            continue;
641                        }
642                        crate::attention::axpy_f32(&mut out[h * hd..(h + 1) * hd], row, w);
643                    }
644                }
645            }
646
647            // ── Born-importance accumulation (Σ probs over heads), same
648            // head order as the caller's former per-head loop.
649            let n = imp_acc.len().min(stored);
650            for h in 0..nheads {
651                let s = &scores[h * stored..(h + 1) * stored];
652                for (dst, &p) in imp_acc[..n].iter_mut().zip(s) {
653                    *dst += p;
654                }
655            }
656        });
657    }
658
659    /// Batched causal attend for a prefill chunk (macOS/AArch64): the
660    /// cache already holds every chunk row (`s0` old + `b` new). Per
661    /// Q-head the scores GEMM `Q·Kᵀ` rides the AMX, the causal softmax
662    /// zeroes the not-yet-visible tail so the `P·V` GEMM needs no
663    /// mask, and Born importance takes the masked column sums. Same
664    /// math as the per-position attend; summation order differs
665    /// (tolerance-class, like the projection GEMMs).
666    #[cfg(target_arch = "aarch64")]
667    #[allow(clippy::too_many_arguments)]
668    pub fn attend_chunk(
669        &mut self,
670        q_all: &[f32],
671        b: usize,
672        s0: usize,
673        nh: usize,
674        heads_per_kv: usize,
675        hd: usize,
676        out: &mut [f32],
677        pool: Option<&crate::pool::Pool>,
678        scale: f32,
679        window: Option<usize>,
680    ) {
681        let n = s0 + b;
682        struct SendPtr(*mut f32);
683        unsafe impl Send for SendPtr {}
684        unsafe impl Sync for SendPtr {}
685        impl SendPtr {
686            fn at(&self, i: usize) -> *mut f32 {
687                // Method receiver keeps the closure capturing &SendPtr
688                // (2021 disjoint capture would grab the raw field).
689                unsafe { self.0.add(i) }
690            }
691        }
692        thread_local! {
693            static SCRATCH: std::cell::RefCell<(Vec<f32>, Vec<f32>, Vec<f32>, Vec<f32>)> =
694                const { std::cell::RefCell::new((Vec::new(), Vec::new(), Vec::new(), Vec::new())) };
695        }
696        // The portable NEON GEMM pays dearly for the gathered Bᵀ loads
697        // of the scores multiply — pack Kᵀ once per (group, chunk) and
698        // hand it the sequential-B fast path instead. Accelerate keeps
699        // the no-copy transposed call.
700        let neon_gemm = cfg!(not(target_os = "macos"))
701            || std::env::var("CMF_FORCE_NEON_GEMM")
702                .map(|v| v == "1")
703                .unwrap_or(false);
704        SCRATCH.with(|s| {
705            let mut s = s.borrow_mut();
706            let (qpanel, scores, aopanel, ktpack) = &mut *s;
707            // The whole KV-group attends in one GEMM pair: the group's
708            // Q-heads stack head-major into one tall panel [hpk·b, hd]
709            // (row hl·b + bi), so each layer costs 2 sgemm calls per
710            // group instead of 2 per head — fat M keeps the AMX fed.
711            let m = heads_per_kv * b;
712            qpanel.resize(m * hd, 0.0);
713            scores.resize(m * n, 0.0);
714            aopanel.resize(m * hd, 0.0);
715            for g in 0..self.num_kv_heads {
716                let kmat = &self.k[g];
717                let vmat = &self.v[g];
718                debug_assert_eq!(kmat.len(), n * hd);
719                for hl in 0..heads_per_kv {
720                    let hh = g * heads_per_kv + hl;
721                    for bi in 0..b {
722                        qpanel[(hl * b + bi) * hd..(hl * b + bi + 1) * hd]
723                            .copy_from_slice(&q_all[bi * nh * hd + hh * hd..][..hd]);
724                    }
725                }
726                if neon_gemm {
727                    ktpack.resize(hd * n, 0.0);
728                    for p in 0..n {
729                        let row = &kmat[p * hd..(p + 1) * hd];
730                        for (d, &v) in row.iter().enumerate() {
731                            ktpack[d * n + p] = v;
732                        }
733                    }
734                    // Accelerate threads its own GEMM; the NEON kernel
735                    // splits the m rows across the pool instead.
736                    let sp_q = SendPtr(qpanel.as_ptr() as *mut f32);
737                    let sp_s = SendPtr(scores.as_mut_ptr());
738                    let kt = &*ktpack;
739                    let run = |start: usize, end: usize| {
740                        if end > start {
741                            // SAFETY: workers write disjoint score rows.
742                            let a = unsafe {
743                                std::slice::from_raw_parts(sp_q.at(start * hd), (end - start) * hd)
744                            };
745                            let c = unsafe {
746                                std::slice::from_raw_parts_mut(
747                                    sp_s.at(start * n),
748                                    (end - start) * n,
749                                )
750                            };
751                            crate::qtensor::neon_gemm_rm(
752                                end - start,
753                                n,
754                                hd,
755                                scale,
756                                a,
757                                hd,
758                                kt,
759                                n,
760                                false,
761                                c,
762                                n,
763                            );
764                        }
765                    };
766                    match pool {
767                        Some(p) if m >= 64 => p.run_rows(m, &run),
768                        _ => run(0, m),
769                    }
770                } else {
771                    crate::qtensor::sgemm_rm(
772                        m, n, hd, scale, qpanel, hd, kmat, hd, true, scores, n,
773                    );
774                }
775                // Causal softmax, row-parallel (rows are disjoint).
776                let sp = SendPtr(scores.as_mut_ptr());
777                let run = |start: usize, end: usize| {
778                    for r in start..end {
779                        let allowed = s0 + (r % b) + 1;
780                        // Sliding-window layers see only the last W of
781                        // the causal range; the zeroed head contributes
782                        // nothing to P·V or Born importance.
783                        let lo = window.map(|w| allowed.saturating_sub(w)).unwrap_or(0);
784                        // SAFETY: workers cover disjoint row ranges.
785                        let row = unsafe { std::slice::from_raw_parts_mut(sp.at(r * n), n) };
786                        crate::attention::softmax_row(&mut row[lo..allowed]);
787                        row[..lo].fill(0.0);
788                        row[allowed..].fill(0.0);
789                    }
790                };
791                match pool {
792                    Some(p) if m >= 64 => p.run_rows(m, &run),
793                    _ => run(0, m),
794                }
795                // Born importance: masked column sums (probs of the
796                // zeroed tail contribute nothing, same as the CPU
797                // per-position accumulate).
798                let ni = self.imp.len().min(n);
799                for r in 0..m {
800                    let al = (s0 + (r % b) + 1).min(ni);
801                    for (dst, &p) in self.imp[..al].iter_mut().zip(&scores[r * n..r * n + al]) {
802                        *dst += p;
803                    }
804                }
805                if neon_gemm {
806                    let sp_s = SendPtr(scores.as_mut_ptr());
807                    let sp_o = SendPtr(aopanel.as_mut_ptr());
808                    let run = |start: usize, end: usize| {
809                        if end > start {
810                            // SAFETY: workers write disjoint output rows.
811                            let a = unsafe {
812                                std::slice::from_raw_parts(sp_s.at(start * n), (end - start) * n)
813                            };
814                            let c = unsafe {
815                                std::slice::from_raw_parts_mut(
816                                    sp_o.at(start * hd),
817                                    (end - start) * hd,
818                                )
819                            };
820                            crate::qtensor::neon_gemm_rm(
821                                end - start,
822                                hd,
823                                n,
824                                1.0,
825                                a,
826                                n,
827                                vmat,
828                                hd,
829                                false,
830                                c,
831                                hd,
832                            );
833                        }
834                    };
835                    match pool {
836                        Some(p) if m >= 64 => p.run_rows(m, &run),
837                        _ => run(0, m),
838                    }
839                } else {
840                    crate::qtensor::sgemm_rm(
841                        m, hd, n, 1.0, scores, n, vmat, hd, false, aopanel, hd,
842                    );
843                }
844                for hl in 0..heads_per_kv {
845                    let hh = g * heads_per_kv + hl;
846                    for bi in 0..b {
847                        out[bi * nh * hd + hh * hd..][..hd]
848                            .copy_from_slice(&aopanel[(hl * b + bi) * hd..(hl * b + bi + 1) * hd]);
849                    }
850                }
851            }
852        });
853    }
854
855    /// Roll back the last `n_drop` positions (speculative-decode reject).
856    pub fn truncate_last(&mut self, n_drop: usize) {
857        let d = n_drop.min(self.seq_len);
858        for h in 0..self.num_kv_heads {
859            let keep = self.k[h].len().saturating_sub(d * self.head_dim);
860            self.k[h].truncate(keep);
861            self.v[h].truncate(keep);
862            let ngk = self.head_dim.div_ceil(KV_K_GROUP);
863            let keep_q = self.kq[h].len().saturating_sub(d * self.head_dim);
864            self.kq[h].truncate(keep_q);
865            let keep_vq = self.vq[h].len().saturating_sub(d * self.head_dim);
866            self.vq[h].truncate(keep_vq);
867            let keep_ks = self.ks[h].len().saturating_sub(d * ngk);
868            self.ks[h].truncate(keep_ks);
869            let keep_vs = self.vs[h].len().saturating_sub(d);
870            self.vs[h].truncate(keep_vs);
871        }
872        self.imp.truncate(self.imp.len().saturating_sub(d));
873        self.seq_len -= d;
874    }
875
876    /// Accumulate attention mass per stored position (summed over heads).
877    pub fn accumulate_imp(&mut self, probs: &[f32]) {
878        for (dst, &p) in self.imp.iter_mut().zip(probs) {
879            *dst += p;
880        }
881    }
882
883    /// Contiguous keys of one head: `[stored_len × head_dim]`.
884    pub fn head_keys(&self, kv_head: usize) -> &[f32] {
885        &self.k[kv_head]
886    }
887
888    pub fn head_values(&self, kv_head: usize) -> &[f32] {
889        &self.v[kv_head]
890    }
891
892    /// Number of positions actually stored for a head (0 for dead heads).
893    pub fn head_len(&self, kv_head: usize) -> usize {
894        let ng = self.head_dim.div_ceil(KV_K_GROUP);
895        (self.k[kv_head].len() / self.head_dim)
896            .max(self.ks[kv_head].len() / ng)
897            .max(self.vs[kv_head].len())
898    }
899
900    /// Clear cache (e.g. on new conversation or task switch).
901    pub fn clear(&mut self) {
902        for h in 0..self.num_kv_heads {
903            self.k[h].clear();
904            self.v[h].clear();
905            self.kq[h].clear();
906            self.ks[h].clear();
907            self.vq[h].clear();
908            self.vs[h].clear();
909            self.kcol[h].clear();
910            self.vcol[h].clear();
911        }
912        self.imp.clear();
913        self.linear_state.clear();
914        self.linear_scratch.clear();
915        // Fresh conversation → the pipeline re-arms collection if the
916        // layer is o1-flagged (landmarks are per-prompt, never reused).
917        self.o1 = None;
918        self.seq_len = 0;
919    }
920
921    /// Memory usage in bytes.
922    pub fn memory_bytes(&self) -> usize {
923        let floats: usize = self.k.iter().map(Vec::len).sum::<usize>()
924            + self.v.iter().map(Vec::len).sum::<usize>()
925            + self.ks.iter().map(Vec::len).sum::<usize>()
926            + self.vs.iter().map(Vec::len).sum::<usize>()
927            + self.kcol.iter().map(Vec::len).sum::<usize>()
928            + self.vcol.iter().map(Vec::len).sum::<usize>();
929        let bytes: usize = self.kq.iter().map(Vec::len).sum::<usize>()
930            + self.vq.iter().map(Vec::len).sum::<usize>();
931        floats * std::mem::size_of::<f32>()
932            + bytes
933            // O(1) recurrent state of linear-core layers (vmf_phase/GDN):
934            // constant in context, but real memory — the honest "KV+state"
935            // line must count it (a pure-linear model reported 0 before).
936            + self.linear_state.len() * std::mem::size_of::<f32>()
937            // O(1) Nyström state (window + sinks + skeleton) — same
938            // discipline: constant in context, but real memory.
939            + self.o1_memory_bytes()
940    }
941
942    /// Drop oldest positions, keeping the last `keep_last`.
943    fn evict(&mut self, keep_last: usize) {
944        // A sealed o1 layer stores nothing per position — the Nyström
945        // state IS the eviction policy; resetting seq_len here would lie
946        // about the context depth.
947        if self.o1_sealed() || self.seq_len <= keep_last {
948            return;
949        }
950        let drop = self.seq_len - keep_last;
951        for h in 0..self.num_kv_heads {
952            // Dead heads store fewer positions; drop proportionally.
953            let stored = self.head_len(h);
954            let d = drop.min(stored);
955            let hd = self.head_dim;
956            fn drop_front<T>(v: &mut Vec<T>, n: usize) {
957                let n = n.min(v.len());
958                v.drain(..n);
959            }
960            drop_front(&mut self.k[h], d * hd);
961            drop_front(&mut self.v[h], d * hd);
962            drop_front(&mut self.kq[h], d * hd);
963            drop_front(&mut self.vq[h], d * hd);
964            drop_front(&mut self.ks[h], d * hd.div_ceil(KV_K_GROUP));
965            drop_front(&mut self.vs[h], d);
966        }
967        let d = drop.min(self.imp.len());
968        self.imp.drain(..d);
969        self.seq_len = keep_last;
970    }
971
972    /// Born eviction: keep `sink` earliest positions (attention sinks),
973    /// the `recent` latest, and fill the rest of the `keep_last` budget
974    /// with the positions carrying the highest accumulated attention
975    /// mass (vmfcore: PPL 8.342 vs 8.687 for recency-only, full 8.295).
976    fn evict_born(&mut self, keep_last: usize, sink: usize, recent: usize) {
977        if self.o1_sealed() {
978            return; // see evict(): the o1 state is its own eviction
979        }
980        let stored = self.imp.len();
981        if stored <= keep_last {
982            return;
983        }
984        // Budget discipline: sinks first, recents next, both clamped so
985        // the total never exceeds keep_last.
986        let sink_n = sink.min(keep_last);
987        let recent_n = recent.min(keep_last - sink_n);
988        let mut keep = vec![false; stored];
989        for k in keep.iter_mut().take(sink_n) {
990            *k = true;
991        }
992        for k in keep.iter_mut().skip(stored.saturating_sub(recent_n)) {
993            *k = true;
994        }
995        let mut budget = keep_last.saturating_sub(keep.iter().filter(|&&x| x).count());
996        // Highest accumulated mass first among the middle positions.
997        let mut order: Vec<usize> = (0..stored).filter(|&i| !keep[i]).collect();
998        order.sort_by(|&a, &b| {
999            self.imp[b]
1000                .partial_cmp(&self.imp[a])
1001                .unwrap_or(std::cmp::Ordering::Equal)
1002        });
1003        for i in order {
1004            if budget == 0 {
1005                break;
1006            }
1007            keep[i] = true;
1008            budget -= 1;
1009        }
1010
1011        let kept: Vec<usize> = (0..stored).filter(|&i| keep[i]).collect();
1012        let hd = self.head_dim;
1013        fn gather<T: Copy>(src: &[T], kept: &[usize], step: usize) -> Vec<T> {
1014            let mut out = Vec::with_capacity(kept.len() * step);
1015            for &i in kept {
1016                out.extend_from_slice(&src[i * step..(i + 1) * step]);
1017            }
1018            out
1019        }
1020        // Each storage is gathered INDEPENDENTLY: in mixed modes
1021        // (q8k/q8v) K and V live in different storages — the paired branch
1022        // panicked (q8v) or silently left V uncompressed (q8k);
1023        // found by adversarial review, closed by regression tests.
1024        for h in 0..self.num_kv_heads {
1025            if !self.k[h].is_empty() {
1026                self.k[h] = gather(&self.k[h], &kept, hd);
1027            }
1028            if !self.v[h].is_empty() {
1029                self.v[h] = gather(&self.v[h], &kept, hd);
1030            }
1031            if !self.kq[h].is_empty() {
1032                self.kq[h] = gather(&self.kq[h], &kept, hd);
1033                self.ks[h] = gather(&self.ks[h], &kept, hd.div_ceil(KV_K_GROUP));
1034            }
1035            if !self.vq[h].is_empty() {
1036                self.vq[h] = gather(&self.vq[h], &kept, hd);
1037                self.vs[h] = gather(&self.vs[h], &kept, 1);
1038            }
1039        }
1040        self.imp = kept.iter().map(|&i| self.imp[i]).collect();
1041        self.seq_len = kept.len();
1042    }
1043}
1044
1045/// Eviction policy for a bounded cache.
1046#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1047pub enum EvictionPolicy {
1048    /// Sliding window: keep only the most recent positions.
1049    Recent,
1050    /// Born rule: sinks + recents + top accumulated attention mass.
1051    Born { sink: usize },
1052}
1053
1054/// Full KV cache for all layers.
1055#[derive(Debug)]
1056pub struct KvCache {
1057    pub layers: Vec<LayerKvCache>,
1058    pub max_seq_len: usize,
1059    pub policy: EvictionPolicy,
1060}
1061
1062impl KvCache {
1063    pub fn new(
1064        num_layers: usize,
1065        num_kv_heads: usize,
1066        head_dim: usize,
1067        max_seq_len: usize,
1068    ) -> Self {
1069        let layers = (0..num_layers)
1070            .map(|_| LayerKvCache::new(num_kv_heads, head_dim))
1071            .collect();
1072        Self {
1073            layers,
1074            max_seq_len,
1075            policy: EvictionPolicy::Born { sink: 4 },
1076        }
1077    }
1078
1079    pub fn clear(&mut self) {
1080        for layer in &mut self.layers {
1081            layer.clear();
1082        }
1083    }
1084
1085    pub fn total_memory_bytes(&self) -> usize {
1086        self.layers.iter().map(|l| l.memory_bytes()).sum()
1087    }
1088
1089    /// Current sequence length (max across layers — dead layers may lag).
1090    pub fn seq_len(&self) -> usize {
1091        self.layers.iter().map(|l| l.seq_len).max().unwrap_or(0)
1092    }
1093
1094    pub fn needs_eviction(&self) -> bool {
1095        self.seq_len() >= self.max_seq_len
1096    }
1097
1098    /// Evict down to `keep_last` positions according to the policy.
1099    pub fn evict(&mut self, keep_last: usize) {
1100        match self.policy {
1101            EvictionPolicy::Recent => {
1102                for layer in &mut self.layers {
1103                    layer.evict(keep_last);
1104                }
1105            }
1106            EvictionPolicy::Born { sink } => {
1107                let recent = (keep_last / 2).max(1);
1108                for layer in &mut self.layers {
1109                    layer.evict_born(keep_last, sink, recent);
1110                }
1111            }
1112        }
1113    }
1114}
1115
1116#[cfg(test)]
1117mod tests {
1118    use super::*;
1119
1120    #[test]
1121    fn append_tracks_seq_len_and_layout() {
1122        let mut cache = LayerKvCache::new(4, 8);
1123        cache.mode = KvMode::F32;
1124        assert_eq!(cache.seq_len, 0);
1125
1126        let k: Vec<f32> = (0..32).map(|i| i as f32).collect();
1127        let v = vec![2.0f32; 32];
1128        cache.append(&k, &v, &[true; 4]);
1129
1130        assert_eq!(cache.seq_len, 1);
1131        assert_eq!(cache.head_len(0), 1);
1132        // head 1 slice is contiguous and equals its part of k_new
1133        assert_eq!(cache.head_keys(1), &k[8..16]);
1134        assert_eq!(cache.memory_bytes(), 256);
1135    }
1136
1137    #[test]
1138    fn dead_head_stores_nothing() {
1139        let mut cache = LayerKvCache::new(2, 4);
1140        cache.mode = KvMode::F32;
1141        let k = vec![1.0f32; 8];
1142        let v = vec![2.0f32; 8];
1143        cache.append(&k, &v, &[true, false]);
1144        cache.append(&k, &v, &[true, false]);
1145
1146        assert_eq!(cache.seq_len, 2);
1147        assert_eq!(cache.head_len(0), 2);
1148        assert_eq!(cache.head_len(1), 0, "dead head must not store KV");
1149        assert_eq!(cache.memory_bytes(), 2 * 2 * 4 * 4);
1150    }
1151
1152    #[test]
1153    fn eviction_keeps_recent() {
1154        let mut cache = KvCache::new(2, 4, 8, 10);
1155        cache.policy = EvictionPolicy::Recent;
1156        for l in &mut cache.layers {
1157            l.mode = KvMode::F32;
1158        }
1159        let k = vec![1.0f32; 32];
1160        let v = vec![2.0f32; 32];
1161        for _ in 0..8 {
1162            for layer in &mut cache.layers {
1163                layer.append(&k, &v, &[true; 4]);
1164            }
1165        }
1166        assert_eq!(cache.seq_len(), 8);
1167        assert!(!cache.needs_eviction());
1168
1169        cache.evict(4);
1170        assert_eq!(cache.seq_len(), 4);
1171        assert_eq!(cache.layers[0].head_len(0), 4);
1172    }
1173
1174    #[test]
1175    fn truncate_rolls_back_speculative_positions() {
1176        let mut cache = LayerKvCache::new(2, 4);
1177        cache.mode = KvMode::F32;
1178        for pos in 0..5 {
1179            let k = vec![pos as f32; 8];
1180            let v = vec![pos as f32; 8];
1181            cache.append(&k, &v, &[true; 2]);
1182        }
1183        cache.truncate_last(2);
1184        assert_eq!(cache.seq_len, 3);
1185        assert_eq!(cache.head_len(0), 3);
1186        assert_eq!(cache.head_keys(0)[2 * 4], 2.0, "position 2 survives");
1187    }
1188
1189    /// q8_2f-attend ≈ f32-attend: 100 positions (crosses the field freeze
1190    /// at the 64th), pseudo-random vectors, relative tolerance of the
1191    /// int8 grid. Plus rollback and Born eviction on the q8 storage.
1192    #[test]
1193    fn q8_attend_matches_f32_within_grid() {
1194        let (heads, hd) = (2, 32);
1195        let mut f = LayerKvCache::new(heads, hd);
1196        f.mode = KvMode::F32;
1197        let mut q8 = LayerKvCache::new(heads, hd);
1198        q8.mode = KvMode::Q8 { k: true, v: true };
1199
1200        let synth = |p: usize, salt: usize| -> Vec<f32> {
1201            (0..heads * hd)
1202                .map(|i| {
1203                    let x = ((i * 31 + p * 17 + salt * 7 + 3) % 97) as f32 / 97.0 - 0.5;
1204                    // channel structure: even channels ×4 (checks the 2f field)
1205                    if i % 2 == 0 { x * 4.0 } else { x * 0.25 }
1206                })
1207                .collect()
1208        };
1209        for p in 0..100 {
1210            let k = synth(p, 1);
1211            let v = synth(p, 2);
1212            f.append(&k, &v, &[true; 2]);
1213            q8.append(&k, &v, &[true; 2]);
1214        }
1215        let q: Vec<f32> = (0..hd)
1216            .map(|i| ((i * 13 + 5) % 89) as f32 / 89.0 - 0.5)
1217            .collect();
1218        for g in 0..heads {
1219            let (of, pf) = f.attend(&q, g);
1220            let (o8, p8) = q8.attend(&q, g);
1221            let scale = of.iter().fold(0f32, |m, x| m.max(x.abs())).max(1e-6);
1222            for d in 0..hd {
1223                assert!(
1224                    (of[d] - o8[d]).abs() <= scale * 0.03 + 1e-3,
1225                    "g{g} d{d}: f32 {} vs q8 {}",
1226                    of[d],
1227                    o8[d]
1228                );
1229            }
1230            for p in 0..100 {
1231                assert!((pf[p] - p8[p]).abs() < 0.02, "prob p{p}");
1232            }
1233        }
1234        // rollback + eviction live on the q8 storage
1235        q8.truncate_last(30);
1236        assert_eq!(q8.head_len(0), 70);
1237        let imp: Vec<f32> = (0..70).map(|i| i as f32).collect();
1238        q8.accumulate_imp(&imp);
1239        q8.evict_born(20, 2, 8);
1240        assert_eq!(q8.head_len(0), 20);
1241        let (o, _) = q8.attend(&q, 0);
1242        assert!(o.iter().all(|x| x.is_finite()));
1243        // memory: q8 ≈ 1 byte/element + scale per row (vs 4 for f32)
1244        assert!(q8.memory_bytes() * 3 < f.memory_bytes());
1245    }
1246
1247    /// Grouped GQA attend must be bit-identical to per-head attend in
1248    /// every KV mode (it is the same math with rows streamed once).
1249    #[test]
1250    fn attend_group_equals_per_head_attend_bitexact() {
1251        let (kv_heads, hd, hpk) = (2usize, 32usize, 3usize); // 6 Q-heads
1252        for mode in [KvMode::F32, KvMode::Q8 { k: true, v: true }] {
1253            let mut c = LayerKvCache::new(kv_heads, hd);
1254            c.mode = mode;
1255            for p in 0..70 {
1256                let k: Vec<f32> = (0..kv_heads * hd)
1257                    .map(|i| ((i * 31 + p * 17 + 3) % 97) as f32 / 97.0 - 0.5)
1258                    .collect();
1259                let v: Vec<f32> = (0..kv_heads * hd)
1260                    .map(|i| ((i * 13 + p * 29 + 7) % 89) as f32 / 89.0 - 0.5)
1261                    .collect();
1262                c.append(&k, &v, &[true; 2]);
1263            }
1264            let q: Vec<f32> = (0..kv_heads * hpk * hd)
1265                .map(|i| ((i * 11 + 5) % 83) as f32 / 83.0 - 0.5)
1266                .collect();
1267            for g in 0..kv_heads {
1268                let span = g * hpk * hd..(g + 1) * hpk * hd;
1269                let mut out = vec![0f32; hpk * hd];
1270                let mut imp = vec![0f32; 70];
1271                c.attend_group(
1272                    &q[span.clone()],
1273                    g,
1274                    &mut out,
1275                    &mut imp,
1276                    1.0 / (hd as f32).sqrt(),
1277                    0,
1278                );
1279                let mut imp_ref = vec![0f32; 70];
1280                for h in 0..hpk {
1281                    let qh = &q[span.start + h * hd..span.start + (h + 1) * hd];
1282                    let (o, probs) = c.attend(qh, g);
1283                    assert_eq!(
1284                        &out[h * hd..(h + 1) * hd],
1285                        &o[..],
1286                        "mode {mode:?} g{g} h{h}: grouped attend must be bit-identical"
1287                    );
1288                    for (dst, &p) in imp_ref.iter_mut().zip(&probs) {
1289                        *dst += p;
1290                    }
1291                }
1292                assert_eq!(imp, imp_ref, "mode {mode:?} g{g}: Born mass must match");
1293            }
1294        }
1295    }
1296
1297    /// Review regression: Born eviction in MIXED modes. q8v used to
1298    /// panic (gather over an empty v[h]), q8k silently left raw V
1299    /// uncompressed (stale rows under kept keys + memory leak).
1300    #[test]
1301    fn born_eviction_mixed_modes_stay_consistent() {
1302        for (mk, mv) in [(false, true), (true, false)] {
1303            let mut c = LayerKvCache::new(1, 4);
1304            c.mode = KvMode::Q8 { k: mk, v: mv };
1305            for p in 0..80 {
1306                let k = vec![p as f32 * 0.01; 4];
1307                let v = vec![p as f32; 4];
1308                c.append(&k, &v, &[true]);
1309            }
1310            let imp: Vec<f32> = (0..80).map(|i| i as f32).collect();
1311            c.accumulate_imp(&imp);
1312            let before = c.memory_bytes();
1313            c.evict_born(20, 4, 8); // q8v: used to panic here
1314            assert_eq!(c.head_len(0), 20, "k={mk} v={mv}");
1315            assert!(
1316                c.memory_bytes() < before / 2,
1317                "memory must shrink (k={mk} v={mv})"
1318            );
1319            // V rows match the kept set: the heaviest positions
1320            // (tail 60..79) must be present in the attend output.
1321            let (out, _) = c.attend(&[1.0, 1.0, 1.0, 1.0], 0);
1322            assert!(
1323                out[0] > 30.0,
1324                "V from the kept tail, not the stale head (k={mk} v={mv}, out {})",
1325                out[0]
1326            );
1327        }
1328    }
1329
1330    #[test]
1331    fn born_eviction_keeps_high_mass_position() {
1332        let mut cache = KvCache::new(1, 1, 2, 16);
1333        cache.policy = EvictionPolicy::Born { sink: 1 };
1334        for l in &mut cache.layers {
1335            l.mode = KvMode::F32;
1336        }
1337        let layer = &mut cache.layers[0];
1338        // 8 positions; keys carry the position index so we can verify
1339        // exactly which positions survive the gather.
1340        for pos in 0..8 {
1341            let k = vec![pos as f32; 2];
1342            let v = vec![pos as f32 + 100.0; 2];
1343            layer.append(&k, &v, &[true]);
1344        }
1345        // Position 3 carries the most attention mass (Born importance).
1346        let mut imp = vec![0.05f32; 8];
1347        imp[3] = 5.0;
1348        layer.accumulate_imp(&imp);
1349
1350        cache.evict(4); // sink 1 + recent 2 + 1 top-mass slot
1351        let layer = &cache.layers[0];
1352        assert_eq!(layer.seq_len, 4);
1353        let kept_keys: Vec<f32> = (0..4).map(|i| layer.head_keys(0)[i * 2]).collect();
1354        assert_eq!(
1355            kept_keys,
1356            vec![0.0, 3.0, 6.0, 7.0],
1357            "kept = sink(0) + Born-top(3) + recent(6,7)"
1358        );
1359        // imp stays aligned with the gathered positions.
1360        assert_eq!(layer.head_len(0), 4);
1361    }
1362}