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    /// Serialize this layer's state for the wire: `f16` halves it and is
950    /// the caller's explicit choice, exactly like the hidden-state wire.
951    ///
952    /// REFUSES rather than travelling half-complete. A cache carrying
953    /// frozen columns, accumulated importance, a Nyström overlay or q8
954    /// storage holds state this format does not describe, and shipping
955    /// the rest would land a plausible-looking cache that answers
956    /// differently — the failure mode this whole format exists to avoid.
957    pub fn export_wire(&self, f16: bool) -> Result<Vec<u8>, String> {
958        if !matches!(self.mode, KvMode::F32) {
959            return Err("kv export: only the F32 cache is described by this                         format (CMF_KV=q8 stores int8 rows and per-row scales)"
960                .into());
961        }
962        if self.o1.is_some() {
963            return Err("kv export: an O(1) Nyström overlay is not part of                         this format — the skeletons are irreversible and                         would have to travel with it"
964                .into());
965        }
966        // Frozen columns only exist under q8 storage, which is refused
967        // above. If one shows up under an F32 cache the format is lying
968        // about something and the transfer must not proceed.
969        if self.kcol.iter().any(|c| !c.is_empty()) || self.vcol.iter().any(|c| !c.is_empty()) {
970            return Err("kv export: frozen columns under an F32 cache — refusing to ship \
971                        a state this format does not describe"
972                .into());
973        }
974        let mut out = Vec::with_capacity(self.memory_bytes() / if f16 { 2 } else { 1 } + 64);
975        let mut u = |v: u32, o: &mut Vec<u8>| o.extend_from_slice(&v.to_le_bytes());
976        u(u8::from(f16) as u32, &mut out);
977        u(self.seq_len as u32, &mut out);
978        u(self.num_kv_heads as u32, &mut out);
979        u(self.head_dim as u32, &mut out);
980        u(self.linear_state.len() as u32, &mut out);
981        // Born-rule importance is ordinary state: every attention call
982        // accumulates it and eviction reads it. Leaving it behind would
983        // hand the far side a cache that forgets the RIGHT positions
984        // later — a divergence that shows up only under pressure.
985        u(self.imp.len() as u32, &mut out);
986        let push = |xs: &[f32], o: &mut Vec<u8>| {
987            if f16 {
988                for &x in xs {
989                    o.extend_from_slice(&cortiq_core::quant::f32_to_f16(x).to_le_bytes());
990                }
991            } else {
992                for &x in xs {
993                    o.extend_from_slice(&x.to_le_bytes());
994                }
995            }
996        };
997        // The recurrent condensate stays f32 whatever the wire dtype: it
998        // is one small vector per layer and it is the ONLY state a linear
999        // layer has — rounding it rounds the whole history.
1000        for &x in &self.linear_state {
1001            out.extend_from_slice(&x.to_le_bytes());
1002        }
1003        for &x in &self.imp {
1004            out.extend_from_slice(&x.to_le_bytes());
1005        }
1006        for h in 0..self.num_kv_heads {
1007            u(self.k[h].len() as u32, &mut out);
1008            push(&self.k[h], &mut out);
1009            u(self.v[h].len() as u32, &mut out);
1010            push(&self.v[h], &mut out);
1011        }
1012        Ok(out)
1013    }
1014
1015    /// Install a peer's state over this layer. The geometry must match the
1016    /// model both sides hold — it is checked, not assumed.
1017    pub fn import_wire(&mut self, buf: &[u8]) -> Result<(), String> {
1018        let mut o = 0usize;
1019        let mut u32_at = |o: &mut usize| -> Result<u32, String> {
1020            if *o + 4 > buf.len() {
1021                return Err("kv import: truncated header".into());
1022            }
1023            let v = u32::from_le_bytes(buf[*o..*o + 4].try_into().unwrap());
1024            *o += 4;
1025            Ok(v)
1026        };
1027        let f16 = u32_at(&mut o)? != 0;
1028        let seq_len = u32_at(&mut o)? as usize;
1029        let heads = u32_at(&mut o)? as usize;
1030        let hd = u32_at(&mut o)? as usize;
1031        let lin = u32_at(&mut o)? as usize;
1032        let nimp = u32_at(&mut o)? as usize;
1033        if heads != self.num_kv_heads || hd != self.head_dim {
1034            return Err(format!(
1035                "kv import: peer sent {heads}×{hd} per position, this layer is {}×{}",
1036                self.num_kv_heads, self.head_dim
1037            ));
1038        }
1039        let w = if f16 { 2 } else { 4 };
1040        let need = |n: usize, o: usize| -> Result<(), String> {
1041            if o + n > buf.len() {
1042                Err("kv import: truncated payload".into())
1043            } else {
1044                Ok(())
1045            }
1046        };
1047        need(lin * 4, o)?;
1048        self.linear_state = (0..lin)
1049            .map(|i| f32::from_le_bytes(buf[o + i * 4..o + i * 4 + 4].try_into().unwrap()))
1050            .collect();
1051        o += lin * 4;
1052        need(nimp * 4, o)?;
1053        let imp: Vec<f32> = (0..nimp)
1054            .map(|i| f32::from_le_bytes(buf[o + i * 4..o + i * 4 + 4].try_into().unwrap()))
1055            .collect();
1056        o += nimp * 4;
1057        let mut k: Vec<Vec<f32>> = Vec::with_capacity(heads);
1058        let mut v: Vec<Vec<f32>> = Vec::with_capacity(heads);
1059        for _ in 0..heads {
1060            for which in 0..2 {
1061                let n = u32_at(&mut o)? as usize;
1062                need(n * w, o)?;
1063                let xs: Vec<f32> = (0..n)
1064                    .map(|i| {
1065                        let at = o + i * w;
1066                        if f16 {
1067                            cortiq_core::quant::f16_to_f32(u16::from_le_bytes(
1068                                buf[at..at + 2].try_into().unwrap(),
1069                            ))
1070                        } else {
1071                            f32::from_le_bytes(buf[at..at + 4].try_into().unwrap())
1072                        }
1073                    })
1074                    .collect();
1075                o += n * w;
1076                if which == 0 { k.push(xs) } else { v.push(xs) }
1077            }
1078        }
1079        self.mode = KvMode::F32;
1080        self.k = k;
1081        self.v = v;
1082        self.kq = vec![Vec::new(); heads];
1083        self.ks = vec![Vec::new(); heads];
1084        self.vq = vec![Vec::new(); heads];
1085        self.vs = vec![Vec::new(); heads];
1086        self.kcol = vec![Vec::new(); heads];
1087        self.vcol = vec![Vec::new(); heads];
1088        self.imp = imp;
1089        self.o1 = None;
1090        self.seq_len = seq_len;
1091        Ok(())
1092    }
1093
1094    pub fn memory_bytes(&self) -> usize {
1095        let floats: usize = self.k.iter().map(Vec::len).sum::<usize>()
1096            + self.v.iter().map(Vec::len).sum::<usize>()
1097            + self.ks.iter().map(Vec::len).sum::<usize>()
1098            + self.vs.iter().map(Vec::len).sum::<usize>()
1099            + self.kcol.iter().map(Vec::len).sum::<usize>()
1100            + self.vcol.iter().map(Vec::len).sum::<usize>();
1101        let bytes: usize = self.kq.iter().map(Vec::len).sum::<usize>()
1102            + self.vq.iter().map(Vec::len).sum::<usize>();
1103        floats * std::mem::size_of::<f32>()
1104            + bytes
1105            // O(1) recurrent state of linear-core layers (vmf_phase/GDN):
1106            // constant in context, but real memory — the honest "KV+state"
1107            // line must count it (a pure-linear model reported 0 before).
1108            + self.linear_state.len() * std::mem::size_of::<f32>()
1109            // O(1) Nyström state (window + sinks + skeleton) — same
1110            // discipline: constant in context, but real memory.
1111            + self.o1_memory_bytes()
1112    }
1113
1114    /// Drop oldest positions, keeping the last `keep_last`.
1115    fn evict(&mut self, keep_last: usize) {
1116        // A sealed o1 layer stores nothing per position — the Nyström
1117        // state IS the eviction policy; resetting seq_len here would lie
1118        // about the context depth.
1119        if self.o1_sealed() || self.seq_len <= keep_last {
1120            return;
1121        }
1122        let drop = self.seq_len - keep_last;
1123        for h in 0..self.num_kv_heads {
1124            // Dead heads store fewer positions; drop proportionally.
1125            let stored = self.head_len(h);
1126            let d = drop.min(stored);
1127            let hd = self.head_dim;
1128            fn drop_front<T>(v: &mut Vec<T>, n: usize) {
1129                let n = n.min(v.len());
1130                v.drain(..n);
1131            }
1132            drop_front(&mut self.k[h], d * hd);
1133            drop_front(&mut self.v[h], d * hd);
1134            drop_front(&mut self.kq[h], d * hd);
1135            drop_front(&mut self.vq[h], d * hd);
1136            drop_front(&mut self.ks[h], d * hd.div_ceil(KV_K_GROUP));
1137            drop_front(&mut self.vs[h], d);
1138        }
1139        let d = drop.min(self.imp.len());
1140        self.imp.drain(..d);
1141        self.seq_len = keep_last;
1142    }
1143
1144    /// Born eviction: keep `sink` earliest positions (attention sinks),
1145    /// the `recent` latest, and fill the rest of the `keep_last` budget
1146    /// with the positions carrying the highest accumulated attention
1147    /// mass (vmfcore: PPL 8.342 vs 8.687 for recency-only, full 8.295).
1148    fn evict_born(&mut self, keep_last: usize, sink: usize, recent: usize) {
1149        if self.o1_sealed() {
1150            return; // see evict(): the o1 state is its own eviction
1151        }
1152        let stored = self.imp.len();
1153        if stored <= keep_last {
1154            return;
1155        }
1156        // Budget discipline: sinks first, recents next, both clamped so
1157        // the total never exceeds keep_last.
1158        let sink_n = sink.min(keep_last);
1159        let recent_n = recent.min(keep_last - sink_n);
1160        let mut keep = vec![false; stored];
1161        for k in keep.iter_mut().take(sink_n) {
1162            *k = true;
1163        }
1164        for k in keep.iter_mut().skip(stored.saturating_sub(recent_n)) {
1165            *k = true;
1166        }
1167        let mut budget = keep_last.saturating_sub(keep.iter().filter(|&&x| x).count());
1168        // Highest accumulated mass first among the middle positions.
1169        let mut order: Vec<usize> = (0..stored).filter(|&i| !keep[i]).collect();
1170        order.sort_by(|&a, &b| {
1171            self.imp[b]
1172                .partial_cmp(&self.imp[a])
1173                .unwrap_or(std::cmp::Ordering::Equal)
1174        });
1175        for i in order {
1176            if budget == 0 {
1177                break;
1178            }
1179            keep[i] = true;
1180            budget -= 1;
1181        }
1182
1183        let kept: Vec<usize> = (0..stored).filter(|&i| keep[i]).collect();
1184        let hd = self.head_dim;
1185        fn gather<T: Copy>(src: &[T], kept: &[usize], step: usize) -> Vec<T> {
1186            let mut out = Vec::with_capacity(kept.len() * step);
1187            for &i in kept {
1188                out.extend_from_slice(&src[i * step..(i + 1) * step]);
1189            }
1190            out
1191        }
1192        // Each storage is gathered INDEPENDENTLY: in mixed modes
1193        // (q8k/q8v) K and V live in different storages — the paired branch
1194        // panicked (q8v) or silently left V uncompressed (q8k);
1195        // found by adversarial review, closed by regression tests.
1196        for h in 0..self.num_kv_heads {
1197            if !self.k[h].is_empty() {
1198                self.k[h] = gather(&self.k[h], &kept, hd);
1199            }
1200            if !self.v[h].is_empty() {
1201                self.v[h] = gather(&self.v[h], &kept, hd);
1202            }
1203            if !self.kq[h].is_empty() {
1204                self.kq[h] = gather(&self.kq[h], &kept, hd);
1205                self.ks[h] = gather(&self.ks[h], &kept, hd.div_ceil(KV_K_GROUP));
1206            }
1207            if !self.vq[h].is_empty() {
1208                self.vq[h] = gather(&self.vq[h], &kept, hd);
1209                self.vs[h] = gather(&self.vs[h], &kept, 1);
1210            }
1211        }
1212        self.imp = kept.iter().map(|&i| self.imp[i]).collect();
1213        self.seq_len = kept.len();
1214    }
1215}
1216
1217/// Eviction policy for a bounded cache.
1218#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1219pub enum EvictionPolicy {
1220    /// Sliding window: keep only the most recent positions.
1221    Recent,
1222    /// Born rule: sinks + recents + top accumulated attention mass.
1223    Born { sink: usize },
1224}
1225
1226/// Full KV cache for all layers.
1227#[derive(Debug)]
1228pub struct KvCache {
1229    pub layers: Vec<LayerKvCache>,
1230    pub max_seq_len: usize,
1231    pub policy: EvictionPolicy,
1232}
1233
1234impl KvCache {
1235    pub fn new(
1236        num_layers: usize,
1237        num_kv_heads: usize,
1238        head_dim: usize,
1239        max_seq_len: usize,
1240    ) -> Self {
1241        let layers = (0..num_layers)
1242            .map(|_| LayerKvCache::new(num_kv_heads, head_dim))
1243            .collect();
1244        Self {
1245            layers,
1246            max_seq_len,
1247            policy: EvictionPolicy::Born { sink: 4 },
1248        }
1249    }
1250
1251    pub fn clear(&mut self) {
1252        for layer in &mut self.layers {
1253            layer.clear();
1254        }
1255    }
1256
1257    pub fn total_memory_bytes(&self) -> usize {
1258        self.layers.iter().map(|l| l.memory_bytes()).sum()
1259    }
1260
1261    /// Current sequence length (max across layers — dead layers may lag).
1262    pub fn seq_len(&self) -> usize {
1263        self.layers.iter().map(|l| l.seq_len).max().unwrap_or(0)
1264    }
1265
1266    pub fn needs_eviction(&self) -> bool {
1267        self.seq_len() >= self.max_seq_len
1268    }
1269
1270    /// Evict down to `keep_last` positions according to the policy.
1271    pub fn evict(&mut self, keep_last: usize) {
1272        match self.policy {
1273            EvictionPolicy::Recent => {
1274                for layer in &mut self.layers {
1275                    layer.evict(keep_last);
1276                }
1277            }
1278            EvictionPolicy::Born { sink } => {
1279                let recent = (keep_last / 2).max(1);
1280                for layer in &mut self.layers {
1281                    layer.evict_born(keep_last, sink, recent);
1282                }
1283            }
1284        }
1285    }
1286}
1287
1288#[cfg(test)]
1289mod tests {
1290    use super::*;
1291
1292    #[test]
1293    fn wire_round_trip_reproduces_attention() {
1294        // The state has to arrive as state, not as something that looks
1295        // like it: the oracle is what the layer ANSWERS, not what it
1296        // stores. Same query, same output, bit for bit.
1297        let (heads, hd) = (2usize, 4usize);
1298        let mut a = LayerKvCache::new(heads, hd);
1299        for p in 0..5 {
1300            let k: Vec<f32> = (0..heads * hd).map(|i| (p * 10 + i) as f32 * 0.031).collect();
1301            let v: Vec<f32> = (0..heads * hd).map(|i| (p * 7 + i) as f32 * -0.017).collect();
1302            a.append(&k, &v, &[true, true]);
1303        }
1304        a.linear_state = vec![0.5, -0.25, 1.0];
1305        let q: Vec<f32> = (0..hd).map(|i| 0.1 * (i as f32 + 1.0)).collect();
1306
1307        let bytes = a.export_wire(false).expect("f32 cache exports");
1308        let mut b = LayerKvCache::new(heads, hd);
1309        b.import_wire(&bytes).expect("import");
1310
1311        assert_eq!(b.seq_len, a.seq_len);
1312        assert_eq!(b.linear_state, a.linear_state);
1313        for h in 0..heads {
1314            let (oa, sa) = a.attend(&q, h);
1315            let (ob, sb) = b.attend(&q, h);
1316            assert_eq!(oa, ob, "head {h} attention output diverged");
1317            assert_eq!(sa, sb, "head {h} attention scores diverged");
1318        }
1319    }
1320
1321    #[test]
1322    fn wire_refuses_what_it_cannot_describe() {
1323        // A refusal is the feature: a cache whose extra state this format
1324        // does not carry must not travel looking complete.
1325        let mut c = LayerKvCache::new(1, 4);
1326        c.mode = KvMode::Q8 { k: true, v: true };
1327        let err = c.export_wire(false).unwrap_err();
1328        assert!(err.contains("F32"), "{err}");
1329    }
1330
1331    #[test]
1332    fn wire_import_checks_geometry() {
1333        let a = LayerKvCache::new(2, 4);
1334        let bytes = a.export_wire(false).unwrap();
1335        let mut wrong = LayerKvCache::new(2, 8);
1336        let err = wrong.import_wire(&bytes).unwrap_err();
1337        assert!(err.contains("2×4"), "{err}");
1338    }
1339
1340    #[test]
1341    fn append_tracks_seq_len_and_layout() {
1342        let mut cache = LayerKvCache::new(4, 8);
1343        cache.mode = KvMode::F32;
1344        assert_eq!(cache.seq_len, 0);
1345
1346        let k: Vec<f32> = (0..32).map(|i| i as f32).collect();
1347        let v = vec![2.0f32; 32];
1348        cache.append(&k, &v, &[true; 4]);
1349
1350        assert_eq!(cache.seq_len, 1);
1351        assert_eq!(cache.head_len(0), 1);
1352        // head 1 slice is contiguous and equals its part of k_new
1353        assert_eq!(cache.head_keys(1), &k[8..16]);
1354        assert_eq!(cache.memory_bytes(), 256);
1355    }
1356
1357    #[test]
1358    fn dead_head_stores_nothing() {
1359        let mut cache = LayerKvCache::new(2, 4);
1360        cache.mode = KvMode::F32;
1361        let k = vec![1.0f32; 8];
1362        let v = vec![2.0f32; 8];
1363        cache.append(&k, &v, &[true, false]);
1364        cache.append(&k, &v, &[true, false]);
1365
1366        assert_eq!(cache.seq_len, 2);
1367        assert_eq!(cache.head_len(0), 2);
1368        assert_eq!(cache.head_len(1), 0, "dead head must not store KV");
1369        assert_eq!(cache.memory_bytes(), 2 * 2 * 4 * 4);
1370    }
1371
1372    #[test]
1373    fn eviction_keeps_recent() {
1374        let mut cache = KvCache::new(2, 4, 8, 10);
1375        cache.policy = EvictionPolicy::Recent;
1376        for l in &mut cache.layers {
1377            l.mode = KvMode::F32;
1378        }
1379        let k = vec![1.0f32; 32];
1380        let v = vec![2.0f32; 32];
1381        for _ in 0..8 {
1382            for layer in &mut cache.layers {
1383                layer.append(&k, &v, &[true; 4]);
1384            }
1385        }
1386        assert_eq!(cache.seq_len(), 8);
1387        assert!(!cache.needs_eviction());
1388
1389        cache.evict(4);
1390        assert_eq!(cache.seq_len(), 4);
1391        assert_eq!(cache.layers[0].head_len(0), 4);
1392    }
1393
1394    #[test]
1395    fn truncate_rolls_back_speculative_positions() {
1396        let mut cache = LayerKvCache::new(2, 4);
1397        cache.mode = KvMode::F32;
1398        for pos in 0..5 {
1399            let k = vec![pos as f32; 8];
1400            let v = vec![pos as f32; 8];
1401            cache.append(&k, &v, &[true; 2]);
1402        }
1403        cache.truncate_last(2);
1404        assert_eq!(cache.seq_len, 3);
1405        assert_eq!(cache.head_len(0), 3);
1406        assert_eq!(cache.head_keys(0)[2 * 4], 2.0, "position 2 survives");
1407    }
1408
1409    /// q8_2f-attend ≈ f32-attend: 100 positions (crosses the field freeze
1410    /// at the 64th), pseudo-random vectors, relative tolerance of the
1411    /// int8 grid. Plus rollback and Born eviction on the q8 storage.
1412    #[test]
1413    fn q8_attend_matches_f32_within_grid() {
1414        let (heads, hd) = (2, 32);
1415        let mut f = LayerKvCache::new(heads, hd);
1416        f.mode = KvMode::F32;
1417        let mut q8 = LayerKvCache::new(heads, hd);
1418        q8.mode = KvMode::Q8 { k: true, v: true };
1419
1420        let synth = |p: usize, salt: usize| -> Vec<f32> {
1421            (0..heads * hd)
1422                .map(|i| {
1423                    let x = ((i * 31 + p * 17 + salt * 7 + 3) % 97) as f32 / 97.0 - 0.5;
1424                    // channel structure: even channels ×4 (checks the 2f field)
1425                    if i % 2 == 0 { x * 4.0 } else { x * 0.25 }
1426                })
1427                .collect()
1428        };
1429        for p in 0..100 {
1430            let k = synth(p, 1);
1431            let v = synth(p, 2);
1432            f.append(&k, &v, &[true; 2]);
1433            q8.append(&k, &v, &[true; 2]);
1434        }
1435        let q: Vec<f32> = (0..hd)
1436            .map(|i| ((i * 13 + 5) % 89) as f32 / 89.0 - 0.5)
1437            .collect();
1438        for g in 0..heads {
1439            let (of, pf) = f.attend(&q, g);
1440            let (o8, p8) = q8.attend(&q, g);
1441            let scale = of.iter().fold(0f32, |m, x| m.max(x.abs())).max(1e-6);
1442            for d in 0..hd {
1443                assert!(
1444                    (of[d] - o8[d]).abs() <= scale * 0.03 + 1e-3,
1445                    "g{g} d{d}: f32 {} vs q8 {}",
1446                    of[d],
1447                    o8[d]
1448                );
1449            }
1450            for p in 0..100 {
1451                assert!((pf[p] - p8[p]).abs() < 0.02, "prob p{p}");
1452            }
1453        }
1454        // rollback + eviction live on the q8 storage
1455        q8.truncate_last(30);
1456        assert_eq!(q8.head_len(0), 70);
1457        let imp: Vec<f32> = (0..70).map(|i| i as f32).collect();
1458        q8.accumulate_imp(&imp);
1459        q8.evict_born(20, 2, 8);
1460        assert_eq!(q8.head_len(0), 20);
1461        let (o, _) = q8.attend(&q, 0);
1462        assert!(o.iter().all(|x| x.is_finite()));
1463        // memory: q8 ≈ 1 byte/element + scale per row (vs 4 for f32)
1464        assert!(q8.memory_bytes() * 3 < f.memory_bytes());
1465    }
1466
1467    /// Grouped GQA attend must be bit-identical to per-head attend in
1468    /// every KV mode (it is the same math with rows streamed once).
1469    #[test]
1470    fn attend_group_equals_per_head_attend_bitexact() {
1471        let (kv_heads, hd, hpk) = (2usize, 32usize, 3usize); // 6 Q-heads
1472        for mode in [KvMode::F32, KvMode::Q8 { k: true, v: true }] {
1473            let mut c = LayerKvCache::new(kv_heads, hd);
1474            c.mode = mode;
1475            for p in 0..70 {
1476                let k: Vec<f32> = (0..kv_heads * hd)
1477                    .map(|i| ((i * 31 + p * 17 + 3) % 97) as f32 / 97.0 - 0.5)
1478                    .collect();
1479                let v: Vec<f32> = (0..kv_heads * hd)
1480                    .map(|i| ((i * 13 + p * 29 + 7) % 89) as f32 / 89.0 - 0.5)
1481                    .collect();
1482                c.append(&k, &v, &[true; 2]);
1483            }
1484            let q: Vec<f32> = (0..kv_heads * hpk * hd)
1485                .map(|i| ((i * 11 + 5) % 83) as f32 / 83.0 - 0.5)
1486                .collect();
1487            for g in 0..kv_heads {
1488                let span = g * hpk * hd..(g + 1) * hpk * hd;
1489                let mut out = vec![0f32; hpk * hd];
1490                let mut imp = vec![0f32; 70];
1491                c.attend_group(
1492                    &q[span.clone()],
1493                    g,
1494                    &mut out,
1495                    &mut imp,
1496                    1.0 / (hd as f32).sqrt(),
1497                    0,
1498                    0.0,
1499                );
1500                let mut imp_ref = vec![0f32; 70];
1501                for h in 0..hpk {
1502                    let qh = &q[span.start + h * hd..span.start + (h + 1) * hd];
1503                    let (o, probs) = c.attend(qh, g);
1504                    assert_eq!(
1505                        &out[h * hd..(h + 1) * hd],
1506                        &o[..],
1507                        "mode {mode:?} g{g} h{h}: grouped attend must be bit-identical"
1508                    );
1509                    for (dst, &p) in imp_ref.iter_mut().zip(&probs) {
1510                        *dst += p;
1511                    }
1512                }
1513                assert_eq!(imp, imp_ref, "mode {mode:?} g{g}: Born mass must match");
1514            }
1515        }
1516    }
1517
1518    /// Review regression: Born eviction in MIXED modes. q8v used to
1519    /// panic (gather over an empty v[h]), q8k silently left raw V
1520    /// uncompressed (stale rows under kept keys + memory leak).
1521    #[test]
1522    fn born_eviction_mixed_modes_stay_consistent() {
1523        for (mk, mv) in [(false, true), (true, false)] {
1524            let mut c = LayerKvCache::new(1, 4);
1525            c.mode = KvMode::Q8 { k: mk, v: mv };
1526            for p in 0..80 {
1527                let k = vec![p as f32 * 0.01; 4];
1528                let v = vec![p as f32; 4];
1529                c.append(&k, &v, &[true]);
1530            }
1531            let imp: Vec<f32> = (0..80).map(|i| i as f32).collect();
1532            c.accumulate_imp(&imp);
1533            let before = c.memory_bytes();
1534            c.evict_born(20, 4, 8); // q8v: used to panic here
1535            assert_eq!(c.head_len(0), 20, "k={mk} v={mv}");
1536            assert!(
1537                c.memory_bytes() < before / 2,
1538                "memory must shrink (k={mk} v={mv})"
1539            );
1540            // V rows match the kept set: the heaviest positions
1541            // (tail 60..79) must be present in the attend output.
1542            let (out, _) = c.attend(&[1.0, 1.0, 1.0, 1.0], 0);
1543            assert!(
1544                out[0] > 30.0,
1545                "V from the kept tail, not the stale head (k={mk} v={mv}, out {})",
1546                out[0]
1547            );
1548        }
1549    }
1550
1551    #[test]
1552    fn born_eviction_keeps_high_mass_position() {
1553        let mut cache = KvCache::new(1, 1, 2, 16);
1554        cache.policy = EvictionPolicy::Born { sink: 1 };
1555        for l in &mut cache.layers {
1556            l.mode = KvMode::F32;
1557        }
1558        let layer = &mut cache.layers[0];
1559        // 8 positions; keys carry the position index so we can verify
1560        // exactly which positions survive the gather.
1561        for pos in 0..8 {
1562            let k = vec![pos as f32; 2];
1563            let v = vec![pos as f32 + 100.0; 2];
1564            layer.append(&k, &v, &[true]);
1565        }
1566        // Position 3 carries the most attention mass (Born importance).
1567        let mut imp = vec![0.05f32; 8];
1568        imp[3] = 5.0;
1569        layer.accumulate_imp(&imp);
1570
1571        cache.evict(4); // sink 1 + recent 2 + 1 top-mass slot
1572        let layer = &cache.layers[0];
1573        assert_eq!(layer.seq_len, 4);
1574        let kept_keys: Vec<f32> = (0..4).map(|i| layer.head_keys(0)[i * 2]).collect();
1575        assert_eq!(
1576            kept_keys,
1577            vec![0.0, 3.0, 6.0, 7.0],
1578            "kept = sink(0) + Born-top(3) + recent(6,7)"
1579        );
1580        // imp stays aligned with the gathered positions.
1581        assert_eq!(layer.head_len(0), 4);
1582    }
1583}