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