Skip to main content

cortiq_engine/
kv_cache.rs

1//! KV cache — per-layer, head-major storage.
2//!
3//! Layout: one contiguous `Vec<f32>` per KV head (`[pos × head_dim]`),
4//! so per-head attention reads a straight slice — no per-head gather
5//! copies per token. Dead GQA groups (all Q heads masked) store
6//! nothing at all: masked heads cost neither FLOPs nor memory.
7
8/// KV storage mode. `CMF_KV=q8` enables the q8_2f cache: an int8 row per
9/// (position, head) + an f32 scale per row + a per-channel field 𝒲×θ,
10/// frozen after WARMUP positions with retroactive requantization
11/// (D4: "KV-quant 2f"). Memory ×~3.7 smaller than f32.
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub enum KvMode {
14    F32,
15    /// Quantized components: (K, V) — sensitivity diagnostics.
16    Q8 { k: bool, v: bool },
17}
18
19impl KvMode {
20    pub fn from_env() -> Self {
21        match std::env::var("CMF_KV").as_deref() {
22            Ok("q8") | Ok("q8_2f") => KvMode::Q8 { k: true, v: true },
23            Ok("q8k") => KvMode::Q8 { k: true, v: false },
24            Ok("q8v") => KvMode::Q8 { k: false, v: true },
25            _ => KvMode::F32,
26        }
27    }
28
29    fn quant_k(self) -> bool {
30        matches!(self, KvMode::Q8 { k: true, .. })
31    }
32
33    fn quant_v(self) -> bool {
34        matches!(self, KvMode::Q8 { v: true, .. })
35    }
36}
37
38/// Positions before freezing the per-channel field (2f): before — col ≡ 1,
39/// after — col = RMS over channels of the stored rows, old rows are requantized.
40const KV_COL_WARMUP: usize = 64;
41
42/// K-rows are quantized in groups of 32 channels (scale per group):
43/// attention logits are sensitive to the dot-product error, per-group scales
44/// localize it along RoPE bands (35B: +4.6% PPL with a per-row scale
45/// → target <1% with a per-group one). V — per-row scale (measured +0.56%).
46const KV_K_GROUP: usize = 32;
47
48/// Per-layer O(1) Nyström attention state (runtime `attn_type`
49/// override — spec §7 presence-driven pattern, no format change).
50///
51/// Collecting: the prompt pass still runs EXACT cache attention (the
52/// prefill outputs feed the residual stream, so they cannot be
53/// deferred) while the per-position rotated queries are buffered;
54/// `o1_seal()` then freezes landmarks + M from the full prompt, replays
55/// it into per-Q-head streaming states, and DROPS the full KV. Sealed:
56/// decode replaces cache attention with `NystromState::step()`.
57#[derive(Debug, Clone)]
58pub enum O1State {
59    Collecting {
60        m: usize,
61        w: usize,
62        sink: usize,
63        /// Rotated post-norm queries, `[pos × num_heads × head_dim]`.
64        q_buf: Vec<f32>,
65    },
66    /// One state per Q head: the far field T̂/Ẑ depends on that head's
67    /// own query landmarks, so GQA groups cannot share it. The window
68    /// K/V is duplicated across the group's Q heads (×heads_per_kv) —
69    /// the price of keeping the golden-parity kernel intact; still O(1)
70    /// in context and far below full KV at depth.
71    Sealed { states: Vec<crate::nystrom::NystromState> },
72}
73
74/// KV cache for a single layer, head-major.
75#[derive(Debug, Clone)]
76pub struct LayerKvCache {
77    pub mode: KvMode,
78    /// Per-KV-head keys: `k[h]` is `[seq_len × head_dim]` (empty if head is dead).
79    k: Vec<Vec<f32>>,
80    /// Per-KV-head values, same layout.
81    v: Vec<Vec<f32>>,
82    /// q8 storage (mode == Q8_2F): int8 rows + f32 scale per row.
83    kq: Vec<Vec<i8>>,
84    ks: Vec<Vec<f32>>,
85    vq: Vec<Vec<i8>>,
86    vs: Vec<Vec<f32>>,
87    /// Per-channel fields 𝒲×θ per head [head_dim]; empty until frozen.
88    kcol: Vec<Vec<f32>>,
89    vcol: Vec<Vec<f32>>,
90    /// Accumulated attention mass per stored position (Born rule:
91    /// importance of a position = how much probability mass reads it).
92    imp: Vec<f32>,
93    /// Positions appended so far (grows once per token, dead heads included).
94    pub seq_len: usize,
95    pub num_kv_heads: usize,
96    pub head_dim: usize,
97    /// Linear-core condensate S (vmf_phase), f64; empty on full layers.
98    pub linear_state: Vec<f64>,
99    /// Tentative lane-2 state during speculative verify.
100    pub linear_scratch: Vec<f64>,
101    /// O(1) Nyström override (None = plain cache attention).
102    pub o1: Option<O1State>,
103}
104
105impl LayerKvCache {
106    pub fn new(num_kv_heads: usize, head_dim: usize) -> Self {
107        Self {
108            mode: KvMode::from_env(),
109            k: vec![Vec::new(); num_kv_heads],
110            v: vec![Vec::new(); num_kv_heads],
111            kq: vec![Vec::new(); num_kv_heads],
112            ks: vec![Vec::new(); num_kv_heads],
113            vq: vec![Vec::new(); num_kv_heads],
114            vs: vec![Vec::new(); num_kv_heads],
115            kcol: vec![Vec::new(); num_kv_heads],
116            vcol: vec![Vec::new(); num_kv_heads],
117            imp: Vec::new(),
118            seq_len: 0,
119            num_kv_heads,
120            head_dim,
121            linear_state: Vec::new(),
122            linear_scratch: Vec::new(),
123            o1: None,
124        }
125    }
126
127    // ── O(1) Nyström override ──
128
129    /// Arm query collection for a fresh prompt pass (a cleared cache).
130    pub fn o1_begin(&mut self, m: usize, w: usize, sink: usize) {
131        self.o1 = Some(O1State::Collecting { m, w, sink, q_buf: Vec::new() });
132    }
133
134    /// Record one position's rotated queries (`[num_heads × head_dim]`)
135    /// during the exact prompt pass. No-op unless collecting — the hook
136    /// sits inside qwen_attention so every prefill flavor (sequential,
137    /// batched) feeds the same trace.
138    pub fn o1_push_q(&mut self, q_all: &[f32]) {
139        if let Some(O1State::Collecting { q_buf, .. }) = &mut self.o1 {
140            q_buf.extend_from_slice(q_all);
141        }
142    }
143
144    pub fn o1_sealed(&self) -> bool {
145        matches!(self.o1, Some(O1State::Sealed { .. }))
146    }
147
148    /// Freeze the prompt into per-Q-head Nyström states and drop this
149    /// layer's full KV. Returns false (layer stays exact, KV kept) when
150    /// the preconditions fail: the seal needs f32 KV rows (`CMF_KV=q8`
151    /// stores int8), every group densely stored, and a full q trace.
152    pub fn o1_seal(&mut self, num_heads: usize) -> bool {
153        // Idempotent: sealing a sealed (or plain) layer must not
154        // disturb its state — check before take().
155        if !matches!(self.o1, Some(O1State::Collecting { .. })) {
156            return self.o1_sealed();
157        }
158        let Some(O1State::Collecting { m, w, sink, q_buf }) = self.o1.take() else {
159            unreachable!("checked above");
160        };
161        let (hd, t) = (self.head_dim, self.seq_len);
162        let hpk = num_heads / self.num_kv_heads.max(1);
163        let ok = t > 0
164            && self.mode == KvMode::F32
165            && q_buf.len() == t * num_heads * hd
166            && (0..self.num_kv_heads).all(|g| self.head_len(g) == t);
167        if !ok {
168            tracing::warn!(
169                "o1: cannot seal (needs f32 KV mode, dense heads, full query \
170                 trace) — layer keeps exact attention"
171            );
172            return false;
173        }
174        let mut states = Vec::with_capacity(num_heads);
175        let mut qh = vec![0.0f32; t * hd];
176        for h in 0..num_heads {
177            let g = h / hpk;
178            for p in 0..t {
179                let src = (p * num_heads + h) * hd;
180                qh[p * hd..(p + 1) * hd].copy_from_slice(&q_buf[src..src + hd]);
181            }
182            let mut st = crate::nystrom::NystromState::new(m, w, sink);
183            st.prefill(&qh, &self.k[g], &self.v[g], t, hd, hd);
184            states.push(st);
185        }
186        // The states now carry everything decode needs — release the
187        // O(context) storage (this is the memory claim, not a cosmetic).
188        for h in 0..self.num_kv_heads {
189            self.k[h] = Vec::new();
190            self.v[h] = Vec::new();
191        }
192        self.imp = Vec::new();
193        self.o1 = Some(O1State::Sealed { states });
194        true
195    }
196
197    /// One decode step on a sealed layer: per Q head, insert the GQA
198    /// group's fresh (k, v) and read the attention output. Returns
199    /// `[num_heads × head_dim]`. The same (k, v) is inserted into each
200    /// of the group's per-Q-head states — same math as the shared KV
201    /// row the exact path would have appended once.
202    pub fn o1_step(
203        &mut self,
204        q_all: &[f32],
205        k_new: &[f32],
206        v_new: &[f32],
207        num_heads: usize,
208    ) -> Vec<f32> {
209        let hd = self.head_dim;
210        let hpk = num_heads / self.num_kv_heads.max(1);
211        let mut out = vec![0.0f32; num_heads * hd];
212        let Some(O1State::Sealed { states }) = &mut self.o1 else {
213            debug_assert!(false, "o1_step on an unsealed layer");
214            return out;
215        };
216        for h in 0..num_heads {
217            let g = h / hpk;
218            states[h].step(
219                &q_all[h * hd..(h + 1) * hd],
220                &k_new[g * hd..(g + 1) * hd],
221                &v_new[g * hd..(g + 1) * hd],
222                &mut out[h * hd..(h + 1) * hd],
223            );
224        }
225        // Track the true context depth for the honest memory/seq report
226        // (nothing is stored per position — the state is O(1)).
227        self.seq_len += 1;
228        out
229    }
230
231    /// Bytes held by the O(1) override (query trace while collecting,
232    /// per-head states once sealed).
233    pub fn o1_memory_bytes(&self) -> usize {
234        match &self.o1 {
235            Some(O1State::Collecting { q_buf, .. }) => {
236                q_buf.len() * std::mem::size_of::<f32>()
237            }
238            Some(O1State::Sealed { states }) => {
239                states.iter().map(|s| s.memory_bytes()).sum()
240            }
241            None => 0,
242        }
243    }
244
245    /// Quantize one row against the per-channel field (empty col = 1);
246    /// `group` — elements per scale (the whole row or KV_K_GROUP).
247    fn quant_row(row: &[f32], col: &[f32], q: &mut Vec<i8>, sc: &mut Vec<f32>,
248                 group: usize) {
249        let mut resid = vec![0.0f32; row.len()];
250        for (d, &x) in row.iter().enumerate() {
251            resid[d] = if col.is_empty() { x } else { x / col[d] };
252        }
253        for g0 in (0..row.len()).step_by(group) {
254            let g1 = (g0 + group).min(row.len());
255            let mut absmax = 0.0f32;
256            for &r in &resid[g0..g1] {
257                absmax = absmax.max(r.abs());
258            }
259            let s = (absmax / 127.0).max(1e-12);
260            sc.push(s);
261            for &r in &resid[g0..g1] {
262                q.push((r / s).round().clamp(-127.0, 127.0) as i8);
263            }
264        }
265    }
266
267    /// Freeze the 2f field: col = RMS of channels over stored rows, old
268    /// rows are requantized against the new field (once per conversation).
269    fn freeze_cols(&mut self) {
270        let hd = self.head_dim;
271        let ngk = hd.div_ceil(KV_K_GROUP);
272        for h in 0..self.num_kv_heads {
273            for (qv, sv, colv, group) in [
274                (&mut self.kq[h], &mut self.ks[h], &mut self.kcol[h], KV_K_GROUP),
275                (&mut self.vq[h], &mut self.vs[h], &mut self.vcol[h], hd),
276            ] {
277                let spp = if group == hd { 1 } else { ngk }; // scales per position
278                let n = sv.len() / spp;
279                if n == 0 {
280                    continue;
281                }
282                // Dequantize to f32, RMS over channels, requantize.
283                let mut rows = vec![0.0f32; n * hd];
284                for p in 0..n {
285                    for d in 0..hd {
286                        rows[p * hd + d] =
287                            qv[p * hd + d] as f32 * sv[p * spp + d / group];
288                    }
289                }
290                let mut col = vec![0.0f32; hd];
291                for p in 0..n {
292                    for d in 0..hd {
293                        col[d] += rows[p * hd + d] * rows[p * hd + d];
294                    }
295                }
296                for c in col.iter_mut() {
297                    *c = (*c / n as f32).sqrt().max(1e-6);
298                }
299                qv.clear();
300                sv.clear();
301                for p in 0..n {
302                    Self::quant_row(&rows[p * hd..(p + 1) * hd], &col, qv, sv, group);
303                }
304                *colv = col;
305            }
306        }
307    }
308
309    /// Append K/V for one position. `k_new`/`v_new` are
310    /// `[num_kv_heads × head_dim]`; heads with `alive[h] == false` are
311    /// skipped (their slices stay empty).
312    pub fn append(&mut self, k_new: &[f32], v_new: &[f32], alive: &[bool]) {
313        debug_assert_eq!(k_new.len(), self.num_kv_heads * self.head_dim);
314        debug_assert_eq!(v_new.len(), self.num_kv_heads * self.head_dim);
315        // Freeze the 2f field AT THE START of append: only rows that
316        // survived verify are visible (a rejected lane-2 draft does not
317        // pollute the field — found in review), and the threshold uses >=
318        // rather than strict equality (in small windows eviction may
319        // oscillate across 64).
320        if matches!(self.mode, KvMode::Q8 { .. })
321            && self.seq_len >= KV_COL_WARMUP
322            && self.kcol.iter().all(Vec::is_empty)
323            && self.vcol.iter().all(Vec::is_empty)
324        {
325            self.freeze_cols();
326        }
327        for h in 0..self.num_kv_heads {
328            if !alive.get(h).copied().unwrap_or(true) {
329                continue;
330            }
331            let s = h * self.head_dim;
332            if self.mode.quant_k() {
333                Self::quant_row(&k_new[s..s + self.head_dim],
334                                &self.kcol[h], &mut self.kq[h], &mut self.ks[h],
335                                KV_K_GROUP);
336            } else {
337                self.k[h].extend_from_slice(&k_new[s..s + self.head_dim]);
338            }
339            if self.mode.quant_v() {
340                Self::quant_row(&v_new[s..s + self.head_dim],
341                                &self.vcol[h], &mut self.vq[h], &mut self.vs[h],
342                                self.head_dim);
343            } else {
344                self.v[h].extend_from_slice(&v_new[s..s + self.head_dim]);
345            }
346        }
347        self.imp.push(0.0);
348        self.seq_len += 1;
349    }
350
351    /// Per-head attention over its own storage: the f32 branch is
352    /// bit-for-bit equal to attention_head() over slices; the q8 branch
353    /// computes score = s_k·⟨q⊙col_k, k_q⟩ and the weighted sum of V in i8
354    /// with f32 accumulation. Returns (output [head_dim], probs [stored]).
355    pub fn attend(&self, q: &[f32], kv_head: usize) -> (Vec<f32>, Vec<f32>) {
356        let hd = self.head_dim;
357        if self.mode == KvMode::F32 {
358            let stored = self.k[kv_head].len() / hd;
359            return crate::attention::attention_head(
360                q, &self.k[kv_head], &self.v[kv_head], hd, stored);
361        }
362        let stored = self.head_len(kv_head);
363        let scale = 1.0 / (hd as f32).sqrt();
364        let mut scores = vec![0.0f32; stored];
365        if self.mode.quant_k() {
366            let (kq, ks) = (&self.kq[kv_head], &self.ks[kv_head]);
367            // q ⊙ col_k — once per call.
368            let kcol = &self.kcol[kv_head];
369            let mut qc = vec![0.0f32; hd];
370            for d in 0..hd {
371                qc[d] = if kcol.is_empty() { q[d] } else { q[d] * kcol[d] };
372            }
373            let ng = hd.div_ceil(KV_K_GROUP);
374            for p in 0..stored {
375                let row = &kq[p * hd..(p + 1) * hd];
376                // SAFETY: i8 and u8 share layout; dot_i8_f32 reads the
377                // bytes back as i8.
378                let row_u8 = unsafe {
379                    std::slice::from_raw_parts(row.as_ptr() as *const u8, row.len())
380                };
381                let mut dot = 0.0f32;
382                for g in 0..ng {
383                    let g0 = g * KV_K_GROUP;
384                    let g1 = (g0 + KV_K_GROUP).min(hd);
385                    dot += crate::qtensor::dot_i8_f32(&row_u8[g0..g1], &qc[g0..g1])
386                        * ks[p * ng + g];
387                }
388                scores[p] = dot * scale;
389            }
390        } else {
391            let k = &self.k[kv_head];
392            for p in 0..stored {
393                let row = &k[p * hd..(p + 1) * hd];
394                scores[p] = crate::attention::dot_f32(q, row) * scale;
395            }
396        }
397        let max_score = scores.iter().cloned().fold(f32::NEG_INFINITY, f32::max);
398        let mut sum = 0.0f32;
399        for s in scores.iter_mut() {
400            *s = (*s - max_score).exp();
401            sum += *s;
402        }
403        if sum > 0.0 {
404            for s in scores.iter_mut() {
405                *s /= sum;
406            }
407        }
408        let mut acc = vec![0.0f32; hd];
409        if self.mode.quant_v() {
410            let (vq, vs) = (&self.vq[kv_head], &self.vs[kv_head]);
411            for p in 0..stored {
412                let w = scores[p] * vs[p];
413                if w.abs() < 1e-12 {
414                    continue;
415                }
416                crate::qtensor::axpy_i8_f32(&mut acc, &vq[p * hd..(p + 1) * hd], w);
417            }
418            let vcol = &self.vcol[kv_head];
419            if !vcol.is_empty() {
420                for d in 0..hd {
421                    acc[d] *= vcol[d];
422                }
423            }
424        } else {
425            let v = &self.v[kv_head];
426            for p in 0..stored {
427                let w = scores[p];
428                if w.abs() < 1e-12 {
429                    continue;
430                }
431                crate::attention::axpy_f32(&mut acc, &v[p * hd..(p + 1) * hd], w);
432            }
433        }
434        (acc, scores)
435    }
436
437    /// Roll back the last `n_drop` positions (speculative-decode reject).
438    pub fn truncate_last(&mut self, n_drop: usize) {
439        let d = n_drop.min(self.seq_len);
440        for h in 0..self.num_kv_heads {
441            let keep = self.k[h].len().saturating_sub(d * self.head_dim);
442            self.k[h].truncate(keep);
443            self.v[h].truncate(keep);
444            let ngk = self.head_dim.div_ceil(KV_K_GROUP);
445            let keep_q = self.kq[h].len().saturating_sub(d * self.head_dim);
446            self.kq[h].truncate(keep_q);
447            let keep_vq = self.vq[h].len().saturating_sub(d * self.head_dim);
448            self.vq[h].truncate(keep_vq);
449            let keep_ks = self.ks[h].len().saturating_sub(d * ngk);
450            self.ks[h].truncate(keep_ks);
451            let keep_vs = self.vs[h].len().saturating_sub(d);
452            self.vs[h].truncate(keep_vs);
453        }
454        self.imp.truncate(self.imp.len().saturating_sub(d));
455        self.seq_len -= d;
456    }
457
458    /// Accumulate attention mass per stored position (summed over heads).
459    pub fn accumulate_imp(&mut self, probs: &[f32]) {
460        for (dst, &p) in self.imp.iter_mut().zip(probs) {
461            *dst += p;
462        }
463    }
464
465    /// Contiguous keys of one head: `[stored_len × head_dim]`.
466    pub fn head_keys(&self, kv_head: usize) -> &[f32] {
467        &self.k[kv_head]
468    }
469
470    pub fn head_values(&self, kv_head: usize) -> &[f32] {
471        &self.v[kv_head]
472    }
473
474    /// Number of positions actually stored for a head (0 for dead heads).
475    pub fn head_len(&self, kv_head: usize) -> usize {
476        let ng = self.head_dim.div_ceil(KV_K_GROUP);
477        (self.k[kv_head].len() / self.head_dim)
478            .max(self.ks[kv_head].len() / ng)
479            .max(self.vs[kv_head].len())
480    }
481
482    /// Clear cache (e.g. on new conversation or task switch).
483    pub fn clear(&mut self) {
484        for h in 0..self.num_kv_heads {
485            self.k[h].clear();
486            self.v[h].clear();
487            self.kq[h].clear();
488            self.ks[h].clear();
489            self.vq[h].clear();
490            self.vs[h].clear();
491            self.kcol[h].clear();
492            self.vcol[h].clear();
493        }
494        self.imp.clear();
495        self.linear_state.clear();
496        self.linear_scratch.clear();
497        // Fresh conversation → the pipeline re-arms collection if the
498        // layer is o1-flagged (landmarks are per-prompt, never reused).
499        self.o1 = None;
500        self.seq_len = 0;
501    }
502
503    /// Memory usage in bytes.
504    pub fn memory_bytes(&self) -> usize {
505        let floats: usize = self.k.iter().map(Vec::len).sum::<usize>()
506            + self.v.iter().map(Vec::len).sum::<usize>()
507            + self.ks.iter().map(Vec::len).sum::<usize>()
508            + self.vs.iter().map(Vec::len).sum::<usize>()
509            + self.kcol.iter().map(Vec::len).sum::<usize>()
510            + self.vcol.iter().map(Vec::len).sum::<usize>();
511        let bytes: usize = self.kq.iter().map(Vec::len).sum::<usize>()
512            + self.vq.iter().map(Vec::len).sum::<usize>();
513        floats * std::mem::size_of::<f32>()
514            + bytes
515            // O(1) recurrent state of linear-core layers (vmf_phase/GDN):
516            // constant in context, but real memory — the honest "KV+state"
517            // line must count it (a pure-linear model reported 0 before).
518            + self.linear_state.len() * std::mem::size_of::<f64>()
519            // O(1) Nyström state (window + sinks + skeleton) — same
520            // discipline: constant in context, but real memory.
521            + self.o1_memory_bytes()
522    }
523
524    /// Drop oldest positions, keeping the last `keep_last`.
525    fn evict(&mut self, keep_last: usize) {
526        // A sealed o1 layer stores nothing per position — the Nyström
527        // state IS the eviction policy; resetting seq_len here would lie
528        // about the context depth.
529        if self.o1_sealed() || self.seq_len <= keep_last {
530            return;
531        }
532        let drop = self.seq_len - keep_last;
533        for h in 0..self.num_kv_heads {
534            // Dead heads store fewer positions; drop proportionally.
535            let stored = self.head_len(h);
536            let d = drop.min(stored);
537            let hd = self.head_dim;
538            fn drop_front<T>(v: &mut Vec<T>, n: usize) {
539                let n = n.min(v.len());
540                v.drain(..n);
541            }
542            drop_front(&mut self.k[h], d * hd);
543            drop_front(&mut self.v[h], d * hd);
544            drop_front(&mut self.kq[h], d * hd);
545            drop_front(&mut self.vq[h], d * hd);
546            drop_front(&mut self.ks[h], d * hd.div_ceil(KV_K_GROUP));
547            drop_front(&mut self.vs[h], d);
548        }
549        let d = drop.min(self.imp.len());
550        self.imp.drain(..d);
551        self.seq_len = keep_last;
552    }
553
554    /// Born eviction: keep `sink` earliest positions (attention sinks),
555    /// the `recent` latest, and fill the rest of the `keep_last` budget
556    /// with the positions carrying the highest accumulated attention
557    /// mass (vmfcore: PPL 8.342 vs 8.687 for recency-only, full 8.295).
558    fn evict_born(&mut self, keep_last: usize, sink: usize, recent: usize) {
559        if self.o1_sealed() {
560            return; // see evict(): the o1 state is its own eviction
561        }
562        let stored = self.imp.len();
563        if stored <= keep_last {
564            return;
565        }
566        // Budget discipline: sinks first, recents next, both clamped so
567        // the total never exceeds keep_last.
568        let sink_n = sink.min(keep_last);
569        let recent_n = recent.min(keep_last - sink_n);
570        let mut keep = vec![false; stored];
571        for k in keep.iter_mut().take(sink_n) {
572            *k = true;
573        }
574        for k in keep.iter_mut().skip(stored.saturating_sub(recent_n)) {
575            *k = true;
576        }
577        let mut budget = keep_last.saturating_sub(keep.iter().filter(|&&x| x).count());
578        // Highest accumulated mass first among the middle positions.
579        let mut order: Vec<usize> = (0..stored).filter(|&i| !keep[i]).collect();
580        order.sort_by(|&a, &b| {
581            self.imp[b].partial_cmp(&self.imp[a]).unwrap_or(std::cmp::Ordering::Equal)
582        });
583        for i in order {
584            if budget == 0 {
585                break;
586            }
587            keep[i] = true;
588            budget -= 1;
589        }
590
591        let kept: Vec<usize> = (0..stored).filter(|&i| keep[i]).collect();
592        let hd = self.head_dim;
593        fn gather<T: Copy>(src: &[T], kept: &[usize], step: usize) -> Vec<T> {
594            let mut out = Vec::with_capacity(kept.len() * step);
595            for &i in kept {
596                out.extend_from_slice(&src[i * step..(i + 1) * step]);
597            }
598            out
599        }
600        // Each storage is gathered INDEPENDENTLY: in mixed modes
601        // (q8k/q8v) K and V live in different storages — the paired branch
602        // panicked (q8v) or silently left V uncompressed (q8k);
603        // found by adversarial review, closed by regression tests.
604        for h in 0..self.num_kv_heads {
605            if !self.k[h].is_empty() {
606                self.k[h] = gather(&self.k[h], &kept, hd);
607            }
608            if !self.v[h].is_empty() {
609                self.v[h] = gather(&self.v[h], &kept, hd);
610            }
611            if !self.kq[h].is_empty() {
612                self.kq[h] = gather(&self.kq[h], &kept, hd);
613                self.ks[h] = gather(&self.ks[h], &kept, hd.div_ceil(KV_K_GROUP));
614            }
615            if !self.vq[h].is_empty() {
616                self.vq[h] = gather(&self.vq[h], &kept, hd);
617                self.vs[h] = gather(&self.vs[h], &kept, 1);
618            }
619        }
620        self.imp = kept.iter().map(|&i| self.imp[i]).collect();
621        self.seq_len = kept.len();
622    }
623}
624
625/// Eviction policy for a bounded cache.
626#[derive(Debug, Clone, Copy, PartialEq, Eq)]
627pub enum EvictionPolicy {
628    /// Sliding window: keep only the most recent positions.
629    Recent,
630    /// Born rule: sinks + recents + top accumulated attention mass.
631    Born { sink: usize },
632}
633
634/// Full KV cache for all layers.
635#[derive(Debug)]
636pub struct KvCache {
637    pub layers: Vec<LayerKvCache>,
638    pub max_seq_len: usize,
639    pub policy: EvictionPolicy,
640}
641
642impl KvCache {
643    pub fn new(num_layers: usize, num_kv_heads: usize, head_dim: usize, max_seq_len: usize) -> Self {
644        let layers = (0..num_layers)
645            .map(|_| LayerKvCache::new(num_kv_heads, head_dim))
646            .collect();
647        Self {
648            layers,
649            max_seq_len,
650            policy: EvictionPolicy::Born { sink: 4 },
651        }
652    }
653
654    pub fn clear(&mut self) {
655        for layer in &mut self.layers {
656            layer.clear();
657        }
658    }
659
660    pub fn total_memory_bytes(&self) -> usize {
661        self.layers.iter().map(|l| l.memory_bytes()).sum()
662    }
663
664    /// Current sequence length (max across layers — dead layers may lag).
665    pub fn seq_len(&self) -> usize {
666        self.layers.iter().map(|l| l.seq_len).max().unwrap_or(0)
667    }
668
669    pub fn needs_eviction(&self) -> bool {
670        self.seq_len() >= self.max_seq_len
671    }
672
673    /// Evict down to `keep_last` positions according to the policy.
674    pub fn evict(&mut self, keep_last: usize) {
675        match self.policy {
676            EvictionPolicy::Recent => {
677                for layer in &mut self.layers {
678                    layer.evict(keep_last);
679                }
680            }
681            EvictionPolicy::Born { sink } => {
682                let recent = (keep_last / 2).max(1);
683                for layer in &mut self.layers {
684                    layer.evict_born(keep_last, sink, recent);
685                }
686            }
687        }
688    }
689}
690
691#[cfg(test)]
692mod tests {
693    use super::*;
694
695    #[test]
696    fn append_tracks_seq_len_and_layout() {
697        let mut cache = LayerKvCache::new(4, 8);
698        cache.mode = KvMode::F32;
699        assert_eq!(cache.seq_len, 0);
700
701        let k: Vec<f32> = (0..32).map(|i| i as f32).collect();
702        let v = vec![2.0f32; 32];
703        cache.append(&k, &v, &[true; 4]);
704
705        assert_eq!(cache.seq_len, 1);
706        assert_eq!(cache.head_len(0), 1);
707        // head 1 slice is contiguous and equals its part of k_new
708        assert_eq!(cache.head_keys(1), &k[8..16]);
709        assert_eq!(cache.memory_bytes(), 256);
710    }
711
712    #[test]
713    fn dead_head_stores_nothing() {
714        let mut cache = LayerKvCache::new(2, 4);
715        cache.mode = KvMode::F32;
716        let k = vec![1.0f32; 8];
717        let v = vec![2.0f32; 8];
718        cache.append(&k, &v, &[true, false]);
719        cache.append(&k, &v, &[true, false]);
720
721        assert_eq!(cache.seq_len, 2);
722        assert_eq!(cache.head_len(0), 2);
723        assert_eq!(cache.head_len(1), 0, "dead head must not store KV");
724        assert_eq!(cache.memory_bytes(), 2 * 2 * 4 * 4);
725    }
726
727    #[test]
728    fn eviction_keeps_recent() {
729        let mut cache = KvCache::new(2, 4, 8, 10);
730        cache.policy = EvictionPolicy::Recent;
731        for l in &mut cache.layers { l.mode = KvMode::F32; }
732        let k = vec![1.0f32; 32];
733        let v = vec![2.0f32; 32];
734        for _ in 0..8 {
735            for layer in &mut cache.layers {
736                layer.append(&k, &v, &[true; 4]);
737            }
738        }
739        assert_eq!(cache.seq_len(), 8);
740        assert!(!cache.needs_eviction());
741
742        cache.evict(4);
743        assert_eq!(cache.seq_len(), 4);
744        assert_eq!(cache.layers[0].head_len(0), 4);
745    }
746
747    #[test]
748    fn truncate_rolls_back_speculative_positions() {
749        let mut cache = LayerKvCache::new(2, 4);
750        cache.mode = KvMode::F32;
751        for pos in 0..5 {
752            let k = vec![pos as f32; 8];
753            let v = vec![pos as f32; 8];
754            cache.append(&k, &v, &[true; 2]);
755        }
756        cache.truncate_last(2);
757        assert_eq!(cache.seq_len, 3);
758        assert_eq!(cache.head_len(0), 3);
759        assert_eq!(cache.head_keys(0)[2 * 4], 2.0, "position 2 survives");
760    }
761
762    /// q8_2f-attend ≈ f32-attend: 100 positions (crosses the field freeze
763    /// at the 64th), pseudo-random vectors, relative tolerance of the
764    /// int8 grid. Plus rollback and Born eviction on the q8 storage.
765    #[test]
766    fn q8_attend_matches_f32_within_grid() {
767        let (heads, hd) = (2, 32);
768        let mut f = LayerKvCache::new(heads, hd);
769        f.mode = KvMode::F32;
770        let mut q8 = LayerKvCache::new(heads, hd);
771        q8.mode = KvMode::Q8 { k: true, v: true };
772
773        let synth = |p: usize, salt: usize| -> Vec<f32> {
774            (0..heads * hd)
775                .map(|i| {
776                    let x = ((i * 31 + p * 17 + salt * 7 + 3) % 97) as f32 / 97.0 - 0.5;
777                    // channel structure: even channels ×4 (checks the 2f field)
778                    if i % 2 == 0 { x * 4.0 } else { x * 0.25 }
779                })
780                .collect()
781        };
782        for p in 0..100 {
783            let k = synth(p, 1);
784            let v = synth(p, 2);
785            f.append(&k, &v, &[true; 2]);
786            q8.append(&k, &v, &[true; 2]);
787        }
788        let q: Vec<f32> = (0..hd).map(|i| ((i * 13 + 5) % 89) as f32 / 89.0 - 0.5).collect();
789        for g in 0..heads {
790            let (of, pf) = f.attend(&q, g);
791            let (o8, p8) = q8.attend(&q, g);
792            let scale = of.iter().fold(0f32, |m, x| m.max(x.abs())).max(1e-6);
793            for d in 0..hd {
794                assert!(
795                    (of[d] - o8[d]).abs() <= scale * 0.03 + 1e-3,
796                    "g{g} d{d}: f32 {} vs q8 {}", of[d], o8[d]
797                );
798            }
799            for p in 0..100 {
800                assert!((pf[p] - p8[p]).abs() < 0.02, "prob p{p}");
801            }
802        }
803        // rollback + eviction live on the q8 storage
804        q8.truncate_last(30);
805        assert_eq!(q8.head_len(0), 70);
806        let imp: Vec<f32> = (0..70).map(|i| i as f32).collect();
807        q8.accumulate_imp(&imp);
808        q8.evict_born(20, 2, 8);
809        assert_eq!(q8.head_len(0), 20);
810        let (o, _) = q8.attend(&q, 0);
811        assert!(o.iter().all(|x| x.is_finite()));
812        // memory: q8 ≈ 1 byte/element + scale per row (vs 4 for f32)
813        assert!(q8.memory_bytes() * 3 < f.memory_bytes());
814    }
815
816    /// Review regression: Born eviction in MIXED modes. q8v used to
817    /// panic (gather over an empty v[h]), q8k silently left raw V
818    /// uncompressed (stale rows under kept keys + memory leak).
819    #[test]
820    fn born_eviction_mixed_modes_stay_consistent() {
821        for (mk, mv) in [(false, true), (true, false)] {
822            let mut c = LayerKvCache::new(1, 4);
823            c.mode = KvMode::Q8 { k: mk, v: mv };
824            for p in 0..80 {
825                let k = vec![p as f32 * 0.01; 4];
826                let v = vec![p as f32; 4];
827                c.append(&k, &v, &[true]);
828            }
829            let imp: Vec<f32> = (0..80).map(|i| i as f32).collect();
830            c.accumulate_imp(&imp);
831            let before = c.memory_bytes();
832            c.evict_born(20, 4, 8); // q8v: used to panic here
833            assert_eq!(c.head_len(0), 20, "k={mk} v={mv}");
834            assert!(c.memory_bytes() < before / 2,
835                    "memory must shrink (k={mk} v={mv})");
836            // V rows match the kept set: the heaviest positions
837            // (tail 60..79) must be present in the attend output.
838            let (out, _) = c.attend(&[1.0, 1.0, 1.0, 1.0], 0);
839            assert!(out[0] > 30.0,
840                    "V from the kept tail, not the stale head (k={mk} v={mv}, out {})",
841                    out[0]);
842        }
843    }
844
845    #[test]
846    fn born_eviction_keeps_high_mass_position() {
847        let mut cache = KvCache::new(1, 1, 2, 16);
848        cache.policy = EvictionPolicy::Born { sink: 1 };
849        for l in &mut cache.layers { l.mode = KvMode::F32; }
850        let layer = &mut cache.layers[0];
851        // 8 positions; keys carry the position index so we can verify
852        // exactly which positions survive the gather.
853        for pos in 0..8 {
854            let k = vec![pos as f32; 2];
855            let v = vec![pos as f32 + 100.0; 2];
856            layer.append(&k, &v, &[true]);
857        }
858        // Position 3 carries the most attention mass (Born importance).
859        let mut imp = vec![0.05f32; 8];
860        imp[3] = 5.0;
861        layer.accumulate_imp(&imp);
862
863        cache.evict(4); // sink 1 + recent 2 + 1 top-mass slot
864        let layer = &cache.layers[0];
865        assert_eq!(layer.seq_len, 4);
866        let kept_keys: Vec<f32> = (0..4).map(|i| layer.head_keys(0)[i * 2]).collect();
867        assert_eq!(
868            kept_keys,
869            vec![0.0, 3.0, 6.0, 7.0],
870            "kept = sink(0) + Born-top(3) + recent(6,7)"
871        );
872        // imp stays aligned with the gathered positions.
873        assert_eq!(layer.head_len(0), 4);
874    }
875}