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 scale 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        /// Optional completed-row barrier. `None` means the caller will
69        /// request a full-prompt seal; `Some(B)` keeps a short prompt exact
70        /// until the first skeleton-safe boundary B.
71        seal_at: Option<usize>,
72        /// Rotated post-norm queries, `[pos × num_heads × head_dim]`.
73        q_buf: Vec<f32>,
74    },
75    /// One state per KV GROUP, each holding its group's Q heads. The
76    /// exact window / sinks / K̃ are stored once per group (every Q head
77    /// of the group reads the same k/v rows); only the far field, Q̃ and
78    /// M — the query-dependent pieces — stay per Q head. See
79    /// `NystromState` for which piece is which and why.
80    Sealed {
81        groups: Vec<crate::nystrom::NystromState>,
82    },
83}
84
85/// KV cache for a single layer, head-major.
86#[derive(Debug, Clone)]
87pub struct LayerKvCache {
88    pub mode: KvMode,
89    /// Per-KV-head keys: `k[h]` is `[seq_len × head_dim]` (empty if head is dead).
90    k: Vec<Vec<f32>>,
91    /// Per-KV-head values, same layout.
92    v: Vec<Vec<f32>>,
93    /// q8 storage (mode == Q8_2F): int8 rows + f32 scale per row.
94    kq: Vec<Vec<i8>>,
95    ks: Vec<Vec<f32>>,
96    vq: Vec<Vec<i8>>,
97    vs: Vec<Vec<f32>>,
98    /// Per-channel scale fields per head [head_dim]; empty until frozen.
99    kcol: Vec<Vec<f32>>,
100    vcol: Vec<Vec<f32>>,
101    /// Accumulated attention mass per stored position: importance of a
102    /// position is how much probability mass reads it.
103    imp: Vec<f32>,
104    /// Positions appended so far (grows once per token, dead heads included).
105    pub seq_len: usize,
106    pub num_kv_heads: usize,
107    pub head_dim: usize,
108    /// Linear-core recurrent state S (vmf_phase), f64; empty on full layers.
109    pub linear_state: Vec<f32>,
110    /// Tentative lane-2 state during speculative verify.
111    pub linear_scratch: Vec<f32>,
112    /// O(1) Nyström override (None = plain cache attention).
113    pub o1: Option<O1State>,
114    /// A deferred seal failure is terminal for the current request. The
115    /// attention functions return only a hidden vector, so the pipeline
116    /// consumes this side channel at its next forward boundary.
117    o1_error: Option<String>,
118    /// Set when a collecting state actually becomes sealed. Pipeline owns
119    /// the epoch bump and consumes this bit after a complete forward.
120    o1_transitioned: bool,
121    /// Learned per-Q-head attention-sink logits of this layer (gpt-oss /
122    /// MiMo-V2 `self_attn.sinks`, one f32 per Q head). The sink is an
123    /// extra softmax column with no value: it joins the max and the
124    /// denominator of every head's softmax and so lets a head attend to
125    /// "nothing". These are WEIGHTS, not sequence state — `clear()` and
126    /// the wire import keep them. None = an ordinary softmax.
127    pub sinks: Option<Vec<f32>>,
128}
129
130impl LayerKvCache {
131    pub fn new(num_kv_heads: usize, head_dim: usize) -> Self {
132        Self {
133            sinks: None,
134            mode: KvMode::from_env(),
135            k: vec![Vec::new(); num_kv_heads],
136            v: vec![Vec::new(); num_kv_heads],
137            kq: vec![Vec::new(); num_kv_heads],
138            ks: vec![Vec::new(); num_kv_heads],
139            vq: vec![Vec::new(); num_kv_heads],
140            vs: vec![Vec::new(); num_kv_heads],
141            kcol: vec![Vec::new(); num_kv_heads],
142            vcol: vec![Vec::new(); num_kv_heads],
143            imp: Vec::new(),
144            seq_len: 0,
145            num_kv_heads,
146            head_dim,
147            linear_state: Vec::new(),
148            linear_scratch: Vec::new(),
149            o1: None,
150            o1_error: None,
151            o1_transitioned: false,
152        }
153    }
154
155    /// Per-KV-head stored keys `[seq_len × head_dim]` (GPU token graph sync).
156    pub fn k_heads(&self) -> &[Vec<f32>] {
157        &self.k
158    }
159    /// Per-KV-head stored values `[seq_len × head_dim]`.
160    pub fn v_heads(&self) -> &[Vec<f32>] {
161        &self.v
162    }
163
164    // ── O(1) Nyström override ──
165
166    /// Arm query collection for a fresh prompt pass (a cleared cache).
167    pub fn o1_begin(&mut self, m: usize, w: usize, sink: usize, rect: crate::nystrom::O1Rect) {
168        self.o1_begin_with_boundary(m, w, sink, rect, None);
169    }
170
171    /// Arm query collection with an optional completed-row seal barrier.
172    /// The barrier is deliberately part of the existing collecting state:
173    /// no second history or scheduler is introduced for short prompts.
174    pub(crate) fn o1_begin_with_boundary(
175        &mut self,
176        m: usize,
177        w: usize,
178        sink: usize,
179        rect: crate::nystrom::O1Rect,
180        seal_at: Option<usize>,
181    ) {
182        self.o1 = Some(O1State::Collecting {
183            m,
184            w,
185            sink,
186            rect,
187            seal_at,
188            q_buf: Vec::new(),
189        });
190        self.o1_error = None;
191        self.o1_transitioned = false;
192    }
193
194    /// Record one position's rotated queries (`[num_heads × head_dim]`)
195    /// during the exact prompt pass. No-op unless collecting — the hook
196    /// sits inside qwen_attention so every prefill flavor (sequential,
197    /// batched) feeds the same trace.
198    pub fn o1_push_q(&mut self, q_all: &[f32]) {
199        if let Some(O1State::Collecting { q_buf, .. }) = &mut self.o1 {
200            q_buf.extend_from_slice(q_all);
201        }
202    }
203
204    pub fn o1_sealed(&self) -> bool {
205        matches!(self.o1, Some(O1State::Sealed { .. }))
206    }
207
208    /// Pending completed-row barrier, if any. A plain full-prompt seal has
209    /// no barrier until the caller asks to seal.
210    pub(crate) fn o1_pending_boundary(&self) -> Option<usize> {
211        match &self.o1 {
212            Some(O1State::Collecting { seal_at, .. }) => *seal_at,
213            _ => None,
214        }
215    }
216
217    /// Whether a batch of `count` exact rows would cross the deferred
218    /// boundary. This lets batched/pair callers split before row B rather
219    /// than appending exact KV past the point where conversion is required.
220    pub(crate) fn o1_boundary_crossed_by(&self, count: usize) -> bool {
221        let Some(target) = self.o1_pending_boundary() else {
222            return false;
223        };
224        target <= self.seq_len
225            || self
226                .seq_len
227                .checked_add(count)
228                .map_or(true, |next| next >= target)
229    }
230
231    pub(crate) fn take_o1_transition(&mut self) -> bool {
232        std::mem::take(&mut self.o1_transitioned)
233    }
234
235    pub(crate) fn take_o1_error(&self) -> Option<String> {
236        // Error observation is deliberately non-consuming.  The error is the
237        // append guard for this request; removing it would let a caller that
238        // ignored the returned Err resume ordinary KV growth after the
239        // bounded transition dropped its overlay.  `clear()` is the explicit
240        // reset boundary that clears the latch.
241        self.o1_error.clone()
242    }
243
244    /// Abort a malformed deferred transition after attention has already
245    /// produced its current row. Clearing the exact storage and dropping
246    /// the overlay makes the state unrecoverable by continued decode; the
247    /// pipeline then routes through its normal graph/cancel cleanup.
248    pub(crate) fn o1_abort(&mut self, err: String) {
249        self.k.iter_mut().for_each(Vec::clear);
250        self.v.iter_mut().for_each(Vec::clear);
251        self.kq.iter_mut().for_each(Vec::clear);
252        self.ks.iter_mut().for_each(Vec::clear);
253        self.vq.iter_mut().for_each(Vec::clear);
254        self.vs.iter_mut().for_each(Vec::clear);
255        self.kcol.iter_mut().for_each(Vec::clear);
256        self.vcol.iter_mut().for_each(Vec::clear);
257        self.imp.clear();
258        self.o1 = None;
259        self.seq_len = 0;
260        self.o1_transitioned = false;
261        self.o1_error = Some(err);
262    }
263
264    /// Freeze the prompt into per-KV-group Nyström states and drop this
265    /// layer's full KV. Returns false while a short collecting layer is
266    /// below its deferred boundary; malformed prerequisites abort the
267    /// layer instead of silently resuming exact KV growth. The seal needs
268    /// f32 KV rows (`CMF_KV=q8` stores int8), every group densely stored, a
269    /// full q trace, and a GQA fan-out that actually divides.
270    pub fn o1_seal(&mut self, num_heads: usize) -> bool {
271        match self.o1_seal_checked(num_heads) {
272            Ok(sealed) => sealed,
273            Err(err) => {
274                tracing::error!("o1: seal aborted: {err}");
275                self.o1_abort(err);
276                false
277            }
278        }
279    }
280
281    /// Checked seal implementation. Validation happens while the collecting
282    /// state and full KV are still intact; only a valid completed boundary
283    /// is allowed to destructively convert them.
284    pub(crate) fn o1_seal_checked(&mut self, num_heads: usize) -> Result<bool, String> {
285        if let Some(err) = self.o1_error.clone() {
286            return Err(err);
287        }
288        // Idempotent: sealing a sealed (or plain) layer must not disturb its
289        // state, and a plain layer is not an O(1) participant.
290        if !matches!(self.o1, Some(O1State::Collecting { .. })) {
291            return Ok(self.o1_sealed());
292        }
293        let (m, w, sink, requested_boundary, q_len) = match &self.o1 {
294            Some(O1State::Collecting {
295                m,
296                w,
297                sink,
298                rect: _,
299                seal_at,
300                q_buf,
301            }) => (*m, *w, *sink, *seal_at, q_buf.len()),
302            _ => unreachable!("checked above"),
303        };
304        let floor = crate::nystrom::o1_deferred_boundary(w, sink)
305            .ok_or_else(|| "o1 seal: w + sink + slack + 1 overflow".to_string())?;
306        let target = requested_boundary.unwrap_or(floor).max(floor);
307        let t = self.seq_len;
308        if t < target {
309            if let Some(O1State::Collecting { seal_at, .. }) = &mut self.o1 {
310                if *seal_at != Some(target) {
311                    *seal_at = Some(target);
312                    tracing::info!(
313                        "o1 deferred seal: current rows={t}, boundary={target} (floor={floor})"
314                    );
315                }
316            }
317            return Ok(false);
318        }
319
320        let hd = self.head_dim;
321        if t == 0 {
322            return Err("o1 seal: cannot seal an empty layer".into());
323        }
324        if self.mode != KvMode::F32 {
325            return Err("o1 seal: requires dense F32 KV storage".into());
326        }
327        if self.num_kv_heads == 0 || num_heads == 0 || num_heads % self.num_kv_heads != 0 {
328            return Err(format!(
329                "o1 seal: invalid GQA geometry num_heads={num_heads} num_kv_heads={}",
330                self.num_kv_heads
331            ));
332        }
333        let hpk = num_heads / self.num_kv_heads;
334        let expected_k = t
335            .checked_mul(hd)
336            .ok_or_else(|| "o1 seal: KV row length overflow".to_string())?;
337        let expected_q = expected_k
338            .checked_mul(num_heads)
339            .ok_or_else(|| "o1 seal: query trace length overflow".to_string())?;
340        if q_len != expected_q {
341            return Err(format!(
342                "o1 seal: query trace has {q_len} values, expected {expected_q}"
343            ));
344        }
345        if (0..self.num_kv_heads)
346            .any(|g| self.k[g].len() != expected_k || self.v[g].len() != expected_k)
347        {
348            return Err("o1 seal: KV heads are not densely populated".into());
349        }
350        if m < 4 || w == 0 {
351            return Err(format!("o1 seal: invalid geometry m={m} w={w}"));
352        }
353
354        let Some(O1State::Collecting {
355            m,
356            w,
357            sink,
358            rect,
359            q_buf,
360            ..
361        }) = self.o1.take()
362        else {
363            unreachable!("collecting state disappeared after validation");
364        };
365        let mut groups = Vec::with_capacity(self.num_kv_heads);
366        // Query trace is position-major; the state wants each head's
367        // queries contiguous, so transpose one group at a time.
368        let mut qh = vec![0.0f32; hpk * t * hd];
369        for g in 0..self.num_kv_heads {
370            for hh in 0..hpk {
371                let h = g * hpk + hh;
372                for p in 0..t {
373                    let src = (p * num_heads + h) * hd;
374                    let dst = (hh * t + p) * hd;
375                    qh[dst..dst + hd].copy_from_slice(&q_buf[src..src + hd]);
376                }
377            }
378            let qs: Vec<&[f32]> = (0..hpk)
379                .map(|hh| &qh[hh * t * hd..(hh + 1) * t * hd])
380                .collect();
381            let mut st = crate::nystrom::NystromState::new_group(m, w, sink, hpk).with_rect(rect);
382            st.prefill_group(&qs, &self.k[g], &self.v[g], t, hd, hd);
383            groups.push(st);
384        }
385        // The states now carry everything decode needs — release the
386        // O(context) storage (this is the memory claim, not a cosmetic).
387        for h in 0..self.num_kv_heads {
388            self.k[h] = Vec::new();
389            self.v[h] = Vec::new();
390        }
391        self.imp = Vec::new();
392        self.o1 = Some(O1State::Sealed { groups });
393        self.o1_transitioned = true;
394        Ok(true)
395    }
396
397    /// One decode step on a sealed layer: per KV group, insert the
398    /// group's fresh (k, v) ONCE and read every Q head's attention
399    /// output. Returns `[num_heads × head_dim]`. Head h belongs to group
400    /// h/hpk, so a group's Q heads are contiguous in `q_all`/`out` —
401    /// same math as the shared KV row the exact path appends once.
402    /// Device views of the sealed o1 groups, or None when o1 is not
403    /// sealed on this layer (or any group is in the degenerate
404    /// exact-only mode the GPU path does not carry).
405    pub fn o1_views(&self) -> Option<Vec<crate::nystrom::O1DeviceView<'_>>> {
406        let Some(O1State::Sealed { groups }) = &self.o1 else {
407            return None;
408        };
409        let views: Vec<_> = groups.iter().map(|g| g.device_view()).collect();
410        if views.iter().any(|v| v.exact_only) {
411            return None;
412        }
413        Some(views)
414    }
415
416    pub fn o1_step(
417        &mut self,
418        q_all: &[f32],
419        k_new: &[f32],
420        v_new: &[f32],
421        num_heads: usize,
422    ) -> Vec<f32> {
423        let hd = self.head_dim;
424        let hpk = num_heads / self.num_kv_heads.max(1);
425        let mut out = vec![0.0f32; num_heads * hd];
426        let Some(O1State::Sealed { groups }) = &mut self.o1 else {
427            debug_assert!(false, "o1_step on an unsealed layer");
428            return out;
429        };
430        for (g, st) in groups.iter_mut().enumerate() {
431            let (lo, hi) = (g * hpk * hd, (g + 1) * hpk * hd);
432            st.step_group(
433                &q_all[lo..hi],
434                &k_new[g * hd..(g + 1) * hd],
435                &v_new[g * hd..(g + 1) * hd],
436                &mut out[lo..hi],
437            );
438        }
439        // Track the true context depth for the honest memory/seq report
440        // (nothing is stored per position — the state is O(1)).
441        self.seq_len += 1;
442        out
443    }
444
445    /// Bytes held by the O(1) override (query trace while collecting,
446    /// per-KV-group states once sealed).
447    pub fn o1_memory_bytes(&self) -> usize {
448        match &self.o1 {
449            Some(O1State::Collecting { q_buf, .. }) => q_buf.len() * std::mem::size_of::<f32>(),
450            Some(O1State::Sealed { groups }) => groups.iter().map(|s| s.memory_bytes()).sum(),
451            None => 0,
452        }
453    }
454
455    /// Quantize one row against the per-channel field (empty col = 1);
456    /// `group` — elements per scale (the whole row or KV_K_GROUP).
457    fn quant_row(row: &[f32], col: &[f32], q: &mut Vec<i8>, sc: &mut Vec<f32>, group: usize) {
458        let mut resid = vec![0.0f32; row.len()];
459        for (d, &x) in row.iter().enumerate() {
460            resid[d] = if col.is_empty() { x } else { x / col[d] };
461        }
462        for g0 in (0..row.len()).step_by(group) {
463            let g1 = (g0 + group).min(row.len());
464            let mut absmax = 0.0f32;
465            for &r in &resid[g0..g1] {
466                absmax = absmax.max(r.abs());
467            }
468            let s = (absmax / 127.0).max(1e-12);
469            sc.push(s);
470            for &r in &resid[g0..g1] {
471                q.push((r / s).round().clamp(-127.0, 127.0) as i8);
472            }
473        }
474    }
475
476    /// Freeze the 2f field: col = RMS of channels over stored rows, old
477    /// rows are requantized against the new field (once per conversation).
478    fn freeze_cols(&mut self) {
479        let hd = self.head_dim;
480        let ngk = hd.div_ceil(KV_K_GROUP);
481        for h in 0..self.num_kv_heads {
482            for (qv, sv, colv, group) in [
483                (
484                    &mut self.kq[h],
485                    &mut self.ks[h],
486                    &mut self.kcol[h],
487                    KV_K_GROUP,
488                ),
489                (&mut self.vq[h], &mut self.vs[h], &mut self.vcol[h], hd),
490            ] {
491                let spp = if group == hd { 1 } else { ngk }; // scales per position
492                let n = sv.len() / spp;
493                if n == 0 {
494                    continue;
495                }
496                // Dequantize to f32, RMS over channels, requantize.
497                let mut rows = vec![0.0f32; n * hd];
498                for p in 0..n {
499                    for d in 0..hd {
500                        rows[p * hd + d] = qv[p * hd + d] as f32 * sv[p * spp + d / group];
501                    }
502                }
503                let mut col = vec![0.0f32; hd];
504                for p in 0..n {
505                    for d in 0..hd {
506                        col[d] += rows[p * hd + d] * rows[p * hd + d];
507                    }
508                }
509                for c in col.iter_mut() {
510                    *c = (*c / n as f32).sqrt().max(1e-6);
511                }
512                qv.clear();
513                sv.clear();
514                for p in 0..n {
515                    Self::quant_row(&rows[p * hd..(p + 1) * hd], &col, qv, sv, group);
516                }
517                *colv = col;
518            }
519        }
520    }
521
522    /// Append K/V for one position. `k_new`/`v_new` are
523    /// `[num_kv_heads × head_dim]`; heads with `alive[h] == false` are
524    /// skipped (their slices stay empty).
525    pub fn append(&mut self, k_new: &[f32], v_new: &[f32], alive: &[bool]) {
526        // A failed bounded transition is terminal until the sequence is
527        // cleared. Do not let an ignored boolean/result resume plain KV
528        // growth after the O(1) collector has aborted.
529        if self.o1_error.is_some() {
530            return;
531        }
532        debug_assert_eq!(k_new.len(), self.num_kv_heads * self.head_dim);
533        debug_assert_eq!(v_new.len(), self.num_kv_heads * self.head_dim);
534        // Freeze the 2f field AT THE START of append: only rows that
535        // survived verify are visible (a rejected lane-2 draft does not
536        // pollute the field — found in review), and the threshold uses >=
537        // rather than strict equality (in small windows eviction may
538        // oscillate across 64).
539        if matches!(self.mode, KvMode::Q8 { .. })
540            && self.seq_len >= KV_COL_WARMUP
541            && self.kcol.iter().all(Vec::is_empty)
542            && self.vcol.iter().all(Vec::is_empty)
543        {
544            self.freeze_cols();
545        }
546        for h in 0..self.num_kv_heads {
547            if !alive.get(h).copied().unwrap_or(true) {
548                continue;
549            }
550            let s = h * self.head_dim;
551            if self.mode.quant_k() {
552                Self::quant_row(
553                    &k_new[s..s + self.head_dim],
554                    &self.kcol[h],
555                    &mut self.kq[h],
556                    &mut self.ks[h],
557                    KV_K_GROUP,
558                );
559            } else {
560                self.k[h].extend_from_slice(&k_new[s..s + self.head_dim]);
561            }
562            if self.mode.quant_v() {
563                Self::quant_row(
564                    &v_new[s..s + self.head_dim],
565                    &self.vcol[h],
566                    &mut self.vq[h],
567                    &mut self.vs[h],
568                    self.head_dim,
569                );
570            } else {
571                self.v[h].extend_from_slice(&v_new[s..s + self.head_dim]);
572            }
573        }
574        self.imp.push(0.0);
575        self.seq_len += 1;
576    }
577
578    /// Per-head attention over its own storage: the f32 branch is
579    /// bit-for-bit equal to attention_head() over slices; the q8 branch
580    /// computes score = s_k·⟨q⊙col_k, k_q⟩ and the weighted sum of V in i8
581    /// with f32 accumulation. Returns (output [head_dim], probs [stored]).
582    pub fn attend(&self, q: &[f32], kv_head: usize) -> (Vec<f32>, Vec<f32>) {
583        let hd = self.head_dim;
584        if self.mode == KvMode::F32 {
585            let stored = self.k[kv_head].len() / hd;
586            return crate::attention::attention_head(
587                q,
588                &self.k[kv_head],
589                &self.v[kv_head],
590                hd,
591                stored,
592            );
593        }
594        let stored = self.head_len(kv_head);
595        let scale = 1.0 / (hd as f32).sqrt();
596        let mut scores = vec![0.0f32; stored];
597        if self.mode.quant_k() {
598            let (kq, ks) = (&self.kq[kv_head], &self.ks[kv_head]);
599            // q ⊙ col_k — once per call.
600            let kcol = &self.kcol[kv_head];
601            let mut qc = vec![0.0f32; hd];
602            for d in 0..hd {
603                qc[d] = if kcol.is_empty() {
604                    q[d]
605                } else {
606                    q[d] * kcol[d]
607                };
608            }
609            let ng = hd.div_ceil(KV_K_GROUP);
610            for p in 0..stored {
611                let row = &kq[p * hd..(p + 1) * hd];
612                // SAFETY: i8 and u8 share layout; dot_i8_f32 reads the
613                // bytes back as i8.
614                let row_u8 =
615                    unsafe { std::slice::from_raw_parts(row.as_ptr() as *const u8, row.len()) };
616                let mut dot = 0.0f32;
617                for g in 0..ng {
618                    let g0 = g * KV_K_GROUP;
619                    let g1 = (g0 + KV_K_GROUP).min(hd);
620                    dot +=
621                        crate::qtensor::dot_i8_f32(&row_u8[g0..g1], &qc[g0..g1]) * ks[p * ng + g];
622                }
623                scores[p] = dot * scale;
624            }
625        } else {
626            let k = &self.k[kv_head];
627            for p in 0..stored {
628                let row = &k[p * hd..(p + 1) * hd];
629                scores[p] = crate::attention::dot_f32(q, row) * scale;
630            }
631        }
632        let max_score = scores.iter().cloned().fold(f32::NEG_INFINITY, f32::max);
633        let mut sum = 0.0f32;
634        for s in scores.iter_mut() {
635            *s = (*s - max_score).exp();
636            sum += *s;
637        }
638        if sum > 0.0 {
639            for s in scores.iter_mut() {
640                *s /= sum;
641            }
642        }
643        let mut acc = vec![0.0f32; hd];
644        if self.mode.quant_v() {
645            let (vq, vs) = (&self.vq[kv_head], &self.vs[kv_head]);
646            for p in 0..stored {
647                let w = scores[p] * vs[p];
648                if w.abs() < 1e-12 {
649                    continue;
650                }
651                crate::qtensor::axpy_i8_f32(&mut acc, &vq[p * hd..(p + 1) * hd], w);
652            }
653            let vcol = &self.vcol[kv_head];
654            if !vcol.is_empty() {
655                for d in 0..hd {
656                    acc[d] *= vcol[d];
657                }
658            }
659        } else {
660            let v = &self.v[kv_head];
661            for p in 0..stored {
662                let w = scores[p];
663                if w.abs() < 1e-12 {
664                    continue;
665                }
666                crate::attention::axpy_f32(&mut acc, &v[p * hd..(p + 1) * hd], w);
667            }
668        }
669        (acc, scores)
670    }
671
672    /// Grouped GQA attention: all Q-heads of one KV group in a single
673    /// pass over the stored K rows and a single pass over the V rows
674    /// (per-head `attend` re-read the shared group storage
675    /// heads_per_kv times — roadmap §3 P1). Per-head score order,
676    /// softmax and V accumulation are IDENTICAL to `attend`, so each
677    /// head's output is bit-for-bit the same.
678    ///
679    /// `q_group`: `[n_heads_in_group × head_dim]` (global head order);
680    /// `out`: same shape; `imp_acc[0..stored]` accumulates the probabilities
681    /// of every head (attention importance), matching the caller's former loop.
682    /// `scale` is the score scale (1/√hd unless the arch overrides);
683    /// `first` is the earliest visible position — sliding-window layers
684    /// pass `stored − window` so older rows get zero probability.
685    /// `sinks` holds one learned sink logit per head of `q_group` (the
686    /// caller slices `self.sinks` to the group's heads) or is empty for
687    /// an ordinary softmax.
688    #[allow(clippy::too_many_arguments)]
689    pub fn attend_group(
690        &self,
691        q_group: &[f32],
692        kv_head: usize,
693        out: &mut [f32],
694        imp_acc: &mut [f32],
695        scale: f32,
696        first: usize,
697        softcap: f32,
698        sinks: &[f32],
699    ) {
700        self.attend_group_upto(
701            q_group,
702            kv_head,
703            out,
704            imp_acc,
705            scale,
706            first,
707            softcap,
708            usize::MAX,
709            sinks,
710        )
711    }
712
713    /// `attend_group` over the first `upto` stored rows only — what the
714    /// same call saw when the cache held exactly `upto` rows. A prefill
715    /// chunk appends all its rows first and then attends every position
716    /// in parallel; position `i` passes `upto = s0 + i + 1`, which makes
717    /// its result bit-identical to the sequential append-then-attend.
718    ///
719    /// Only the visible rows `[first, stored)` are scored, so a
720    /// sliding-window decode step costs O(window), not O(context). The
721    /// rows before `first` get probability exactly 0 — what the former
722    /// −inf-filled score row produced (exp(−inf) = 0 adds nothing to the
723    /// max, the sum, V or the importance), so the result is bit-identical
724    /// to scoring the whole row.
725    ///
726    /// Learned sinks (`sinks` non-empty, one per head): the sink logit
727    /// joins the softmax max and adds exp(sink − max) to the denominator;
728    /// it has no value row. Identical to appending a value-less column,
729    /// which is how gpt-oss and MiMo-V2 define it.
730    #[allow(clippy::too_many_arguments)]
731    pub fn attend_group_upto(
732        &self,
733        q_group: &[f32],
734        kv_head: usize,
735        out: &mut [f32],
736        imp_acc: &mut [f32],
737        scale: f32,
738        first: usize,
739        softcap: f32,
740        upto: usize,
741        sinks: &[f32],
742    ) {
743        let hd = self.head_dim;
744        let nheads = q_group.len() / hd;
745        debug_assert_eq!(out.len(), nheads * hd);
746        assert!(
747            sinks.is_empty() || sinks.len() == nheads,
748            "attend_group: {} sink logits for {nheads} heads",
749            sinks.len()
750        );
751        let stored = if self.mode == KvMode::F32 {
752            self.k[kv_head].len() / hd
753        } else {
754            self.head_len(kv_head)
755        }
756        .min(upto);
757        if stored == 0 {
758            out.fill(0.0);
759            return;
760        }
761        let first = first.min(stored.saturating_sub(1));
762        // Visible span: row p ∈ [first, stored) lives at column p − first.
763        let span = stored - first;
764
765        thread_local! {
766            /// scores [nheads × span] — reused across layers/tokens.
767            static GQA_SCORES: std::cell::RefCell<Vec<f32>> =
768                const { std::cell::RefCell::new(Vec::new()) };
769            /// q ⊙ col_k per head (q8 K mode).
770            static GQA_QC: std::cell::RefCell<Vec<f32>> =
771                const { std::cell::RefCell::new(Vec::new()) };
772        }
773
774        GQA_SCORES.with(|sc| {
775            let mut scores = sc.borrow_mut();
776            scores.resize(nheads * span, 0.0);
777
778            // ── score pass: each stored K row is read ONCE for all heads.
779            if self.mode.quant_k() {
780                let (kq, ks) = (&self.kq[kv_head], &self.ks[kv_head]);
781                let kcol = &self.kcol[kv_head];
782                let ng = hd.div_ceil(KV_K_GROUP);
783                GQA_QC.with(|qc| {
784                    let mut qcb = qc.borrow_mut();
785                    qcb.resize(nheads * hd, 0.0);
786                    for h in 0..nheads {
787                        for d in 0..hd {
788                            let qv = q_group[h * hd + d];
789                            qcb[h * hd + d] = if kcol.is_empty() { qv } else { qv * kcol[d] };
790                        }
791                    }
792                    for p in first..stored {
793                        let row = &kq[p * hd..(p + 1) * hd];
794                        // SAFETY: i8 and u8 share layout; dot_i8_f32 reads
795                        // the bytes back as i8.
796                        let row_u8 = unsafe {
797                            std::slice::from_raw_parts(row.as_ptr() as *const u8, row.len())
798                        };
799                        for h in 0..nheads {
800                            let qch = &qcb[h * hd..(h + 1) * hd];
801                            let mut dot = 0.0f32;
802                            for g in 0..ng {
803                                let g0 = g * KV_K_GROUP;
804                                let g1 = (g0 + KV_K_GROUP).min(hd);
805                                dot += crate::qtensor::dot_i8_f32(&row_u8[g0..g1], &qch[g0..g1])
806                                    * ks[p * ng + g];
807                            }
808                            scores[h * span + (p - first)] = dot * scale;
809                        }
810                    }
811                });
812            } else {
813                let k = &self.k[kv_head];
814                for p in first..stored {
815                    let row = &k[p * hd..(p + 1) * hd];
816                    for h in 0..nheads {
817                        scores[h * span + (p - first)] =
818                            crate::attention::dot_f32(&q_group[h * hd..(h + 1) * hd], row) * scale;
819                    }
820                }
821            }
822
823            // Gemma-2 attention-logit soft-capping: tanh-squash the
824            // COMPUTED scores before the softmax (every scored row is in
825            // the window; a learned sink is not a score and is not capped).
826            if softcap > 0.0 {
827                for v in scores.iter_mut() {
828                    *v = softcap * (*v / softcap).tanh();
829                }
830            }
831
832            // ── per-head softmax (identical to attend / attention_head;
833            // a sink joins the max and the denominator, never the rows).
834            for h in 0..nheads {
835                let s = &mut scores[h * span..(h + 1) * span];
836                let row_max = s.iter().cloned().fold(f32::NEG_INFINITY, f32::max);
837                let sink = sinks.get(h).copied();
838                let max_score = match sink {
839                    Some(z) => row_max.max(z),
840                    None => row_max,
841                };
842                let mut sum = 0.0f32;
843                for v in s.iter_mut() {
844                    *v = (*v - max_score).exp();
845                    sum += *v;
846                }
847                if let Some(z) = sink {
848                    sum += (z - max_score).exp();
849                }
850                if sum > 0.0 {
851                    for v in s.iter_mut() {
852                        *v /= sum;
853                    }
854                }
855            }
856
857            // ── value pass: each stored V row is read ONCE for all heads.
858            out.fill(0.0);
859            if self.mode.quant_v() {
860                let (vq, vs) = (&self.vq[kv_head], &self.vs[kv_head]);
861                for p in first..stored {
862                    let row = &vq[p * hd..(p + 1) * hd];
863                    for h in 0..nheads {
864                        let w = scores[h * span + (p - first)] * vs[p];
865                        if w.abs() < 1e-12 {
866                            continue;
867                        }
868                        crate::qtensor::axpy_i8_f32(&mut out[h * hd..(h + 1) * hd], row, w);
869                    }
870                }
871                let vcol = &self.vcol[kv_head];
872                if !vcol.is_empty() {
873                    for h in 0..nheads {
874                        for d in 0..hd {
875                            out[h * hd + d] *= vcol[d];
876                        }
877                    }
878                }
879            } else {
880                let v = &self.v[kv_head];
881                for p in first..stored {
882                    let row = &v[p * hd..(p + 1) * hd];
883                    for h in 0..nheads {
884                        let w = scores[h * span + (p - first)];
885                        if w.abs() < 1e-12 {
886                            continue;
887                        }
888                        crate::attention::axpy_f32(&mut out[h * hd..(h + 1) * hd], row, w);
889                    }
890                }
891            }
892
893            // ── Attention-importance accumulation (Σ probs over heads), same
894            // head order as the caller's former per-head loop. Rows before
895            // `first` carry probability 0 and are left untouched.
896            let n = imp_acc.len().min(stored);
897            if n > first {
898                for h in 0..nheads {
899                    let s = &scores[h * span..(h + 1) * span];
900                    for (dst, &p) in imp_acc[first..n].iter_mut().zip(s) {
901                        *dst += p;
902                    }
903                }
904            }
905        });
906    }
907
908    /// Batched causal attend for a prefill chunk (macOS/AArch64): the
909    /// cache already holds every chunk row (`s0` old + `b` new). Per
910    /// Q-head the scores GEMM `Q·Kᵀ` rides the AMX, the causal softmax
911    /// zeroes the not-yet-visible tail so the `P·V` GEMM needs no
912    /// mask, and attention importance takes the masked column sums. Same
913    /// math as the per-position attend; summation order differs
914    /// (tolerance-class, like the projection GEMMs).
915    #[cfg(target_arch = "aarch64")]
916    #[allow(clippy::too_many_arguments)]
917    pub fn attend_chunk(
918        &mut self,
919        q_all: &[f32],
920        b: usize,
921        s0: usize,
922        nh: usize,
923        heads_per_kv: usize,
924        hd: usize,
925        out: &mut [f32],
926        pool: Option<&crate::pool::Pool>,
927        scale: f32,
928        window: Option<usize>,
929    ) {
930        let n = s0 + b;
931        struct SendPtr(*mut f32);
932        unsafe impl Send for SendPtr {}
933        unsafe impl Sync for SendPtr {}
934        impl SendPtr {
935            fn at(&self, i: usize) -> *mut f32 {
936                // Method receiver keeps the closure capturing &SendPtr
937                // (2021 disjoint capture would grab the raw field).
938                unsafe { self.0.add(i) }
939            }
940        }
941        thread_local! {
942            static SCRATCH: std::cell::RefCell<(Vec<f32>, Vec<f32>, Vec<f32>, Vec<f32>)> =
943                const { std::cell::RefCell::new((Vec::new(), Vec::new(), Vec::new(), Vec::new())) };
944        }
945        // The portable NEON GEMM pays dearly for the gathered Bᵀ loads
946        // of the scores multiply — pack Kᵀ once per (group, chunk) and
947        // hand it the sequential-B fast path instead. Accelerate keeps
948        // the no-copy transposed call.
949        let neon_gemm = cfg!(not(target_os = "macos"))
950            || std::env::var("CMF_FORCE_NEON_GEMM")
951                .map(|v| v == "1")
952                .unwrap_or(false);
953        SCRATCH.with(|s| {
954            let mut s = s.borrow_mut();
955            let (qpanel, scores, aopanel, ktpack) = &mut *s;
956            // The whole KV-group attends in one GEMM pair: the group's
957            // Q-heads stack head-major into one tall panel [hpk·b, hd]
958            // (row hl·b + bi), so each layer costs 2 sgemm calls per
959            // group instead of 2 per head — fat M keeps the AMX fed.
960            let m = heads_per_kv * b;
961            qpanel.resize(m * hd, 0.0);
962            scores.resize(m * n, 0.0);
963            aopanel.resize(m * hd, 0.0);
964            for g in 0..self.num_kv_heads {
965                let kmat = &self.k[g];
966                let vmat = &self.v[g];
967                debug_assert_eq!(kmat.len(), n * hd);
968                for hl in 0..heads_per_kv {
969                    let hh = g * heads_per_kv + hl;
970                    for bi in 0..b {
971                        qpanel[(hl * b + bi) * hd..(hl * b + bi + 1) * hd]
972                            .copy_from_slice(&q_all[bi * nh * hd + hh * hd..][..hd]);
973                    }
974                }
975                if neon_gemm {
976                    ktpack.resize(hd * n, 0.0);
977                    for p in 0..n {
978                        let row = &kmat[p * hd..(p + 1) * hd];
979                        for (d, &v) in row.iter().enumerate() {
980                            ktpack[d * n + p] = v;
981                        }
982                    }
983                    // Accelerate threads its own GEMM; the NEON kernel
984                    // splits the m rows across the pool instead.
985                    let sp_q = SendPtr(qpanel.as_ptr() as *mut f32);
986                    let sp_s = SendPtr(scores.as_mut_ptr());
987                    let kt = &*ktpack;
988                    let run = |start: usize, end: usize| {
989                        if end > start {
990                            // SAFETY: workers write disjoint score rows.
991                            let a = unsafe {
992                                std::slice::from_raw_parts(sp_q.at(start * hd), (end - start) * hd)
993                            };
994                            let c = unsafe {
995                                std::slice::from_raw_parts_mut(
996                                    sp_s.at(start * n),
997                                    (end - start) * n,
998                                )
999                            };
1000                            crate::qtensor::neon_gemm_rm(
1001                                end - start,
1002                                n,
1003                                hd,
1004                                scale,
1005                                a,
1006                                hd,
1007                                kt,
1008                                n,
1009                                false,
1010                                c,
1011                                n,
1012                            );
1013                        }
1014                    };
1015                    match pool {
1016                        Some(p) if m >= 64 => p.run_rows(m, &run),
1017                        _ => run(0, m),
1018                    }
1019                } else {
1020                    crate::qtensor::sgemm_rm(
1021                        m, n, hd, scale, qpanel, hd, kmat, hd, true, scores, n,
1022                    );
1023                }
1024                // Causal softmax, row-parallel (rows are disjoint).
1025                let sp = SendPtr(scores.as_mut_ptr());
1026                let run = |start: usize, end: usize| {
1027                    for r in start..end {
1028                        let allowed = s0 + (r % b) + 1;
1029                        // Sliding-window layers see only the last W of
1030                        // the causal range; the zeroed head contributes
1031                        // nothing to P·V or attention importance.
1032                        let lo = window.map(|w| allowed.saturating_sub(w)).unwrap_or(0);
1033                        // SAFETY: workers cover disjoint row ranges.
1034                        let row = unsafe { std::slice::from_raw_parts_mut(sp.at(r * n), n) };
1035                        crate::attention::softmax_row(&mut row[lo..allowed]);
1036                        row[..lo].fill(0.0);
1037                        row[allowed..].fill(0.0);
1038                    }
1039                };
1040                match pool {
1041                    Some(p) if m >= 64 => p.run_rows(m, &run),
1042                    _ => run(0, m),
1043                }
1044                // Attention importance: masked column sums (probs of the
1045                // zeroed tail contribute nothing, same as the CPU
1046                // per-position accumulate).
1047                let ni = self.imp.len().min(n);
1048                for r in 0..m {
1049                    let al = (s0 + (r % b) + 1).min(ni);
1050                    for (dst, &p) in self.imp[..al].iter_mut().zip(&scores[r * n..r * n + al]) {
1051                        *dst += p;
1052                    }
1053                }
1054                if neon_gemm {
1055                    let sp_s = SendPtr(scores.as_mut_ptr());
1056                    let sp_o = SendPtr(aopanel.as_mut_ptr());
1057                    let run = |start: usize, end: usize| {
1058                        if end > start {
1059                            // SAFETY: workers write disjoint output rows.
1060                            let a = unsafe {
1061                                std::slice::from_raw_parts(sp_s.at(start * n), (end - start) * n)
1062                            };
1063                            let c = unsafe {
1064                                std::slice::from_raw_parts_mut(
1065                                    sp_o.at(start * hd),
1066                                    (end - start) * hd,
1067                                )
1068                            };
1069                            crate::qtensor::neon_gemm_rm(
1070                                end - start,
1071                                hd,
1072                                n,
1073                                1.0,
1074                                a,
1075                                n,
1076                                vmat,
1077                                hd,
1078                                false,
1079                                c,
1080                                hd,
1081                            );
1082                        }
1083                    };
1084                    match pool {
1085                        Some(p) if m >= 64 => p.run_rows(m, &run),
1086                        _ => run(0, m),
1087                    }
1088                } else {
1089                    crate::qtensor::sgemm_rm(
1090                        m, hd, n, 1.0, scores, n, vmat, hd, false, aopanel, hd,
1091                    );
1092                }
1093                for hl in 0..heads_per_kv {
1094                    let hh = g * heads_per_kv + hl;
1095                    for bi in 0..b {
1096                        out[bi * nh * hd + hh * hd..][..hd]
1097                            .copy_from_slice(&aopanel[(hl * b + bi) * hd..(hl * b + bi + 1) * hd]);
1098                    }
1099                }
1100            }
1101        });
1102    }
1103
1104    /// Roll back the last `n_drop` positions (speculative-decode reject).
1105    pub fn truncate_last(&mut self, n_drop: usize) {
1106        let d = n_drop.min(self.seq_len);
1107        for h in 0..self.num_kv_heads {
1108            let keep = self.k[h].len().saturating_sub(d * self.head_dim);
1109            self.k[h].truncate(keep);
1110            self.v[h].truncate(keep);
1111            let ngk = self.head_dim.div_ceil(KV_K_GROUP);
1112            let keep_q = self.kq[h].len().saturating_sub(d * self.head_dim);
1113            self.kq[h].truncate(keep_q);
1114            let keep_vq = self.vq[h].len().saturating_sub(d * self.head_dim);
1115            self.vq[h].truncate(keep_vq);
1116            let keep_ks = self.ks[h].len().saturating_sub(d * ngk);
1117            self.ks[h].truncate(keep_ks);
1118            let keep_vs = self.vs[h].len().saturating_sub(d);
1119            self.vs[h].truncate(keep_vs);
1120        }
1121        self.imp.truncate(self.imp.len().saturating_sub(d));
1122        self.seq_len -= d;
1123    }
1124
1125    /// Accumulate attention mass per stored position (summed over heads).
1126    pub fn accumulate_imp(&mut self, probs: &[f32]) {
1127        for (dst, &p) in self.imp.iter_mut().zip(probs) {
1128            *dst += p;
1129        }
1130    }
1131
1132    /// Contiguous keys of one head: `[stored_len × head_dim]`.
1133    pub fn head_keys(&self, kv_head: usize) -> &[f32] {
1134        &self.k[kv_head]
1135    }
1136
1137    pub fn head_values(&self, kv_head: usize) -> &[f32] {
1138        &self.v[kv_head]
1139    }
1140
1141    /// Number of positions actually stored for a head (0 for dead heads).
1142    pub fn head_len(&self, kv_head: usize) -> usize {
1143        let ng = self.head_dim.div_ceil(KV_K_GROUP);
1144        (self.k[kv_head].len() / self.head_dim)
1145            .max(self.ks[kv_head].len() / ng)
1146            .max(self.vs[kv_head].len())
1147    }
1148
1149    /// Clear cache (e.g. on new conversation or task switch).
1150    pub fn clear(&mut self) {
1151        for h in 0..self.num_kv_heads {
1152            self.k[h].clear();
1153            self.v[h].clear();
1154            self.kq[h].clear();
1155            self.ks[h].clear();
1156            self.vq[h].clear();
1157            self.vs[h].clear();
1158            self.kcol[h].clear();
1159            self.vcol[h].clear();
1160        }
1161        self.imp.clear();
1162        self.linear_state.clear();
1163        self.linear_scratch.clear();
1164        // Fresh conversation → the pipeline re-arms collection if the
1165        // layer is o1-flagged (landmarks are per-prompt, never reused).
1166        self.o1 = None;
1167        self.o1_error = None;
1168        self.o1_transitioned = false;
1169        self.seq_len = 0;
1170    }
1171
1172    /// Memory usage in bytes.
1173    /// Serialize this layer's state for the wire: `f16` halves it and is
1174    /// the caller's explicit choice, exactly like the hidden-state wire.
1175    ///
1176    /// REFUSES rather than travelling half-complete. A cache carrying
1177    /// frozen columns, accumulated importance, a Nyström overlay or q8
1178    /// storage holds state this format does not describe, and shipping
1179    /// the rest would land a plausible-looking cache that answers
1180    /// differently — the failure mode this whole format exists to avoid.
1181    pub fn export_wire(&self, f16: bool) -> Result<Vec<u8>, String> {
1182        if !matches!(self.mode, KvMode::F32) {
1183            return Err("kv export: only the F32 cache is described by this                         format (CMF_KV=q8 stores int8 rows and per-row scales)"
1184                .into());
1185        }
1186        if self.o1.is_some() {
1187            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"
1188                .into());
1189        }
1190        // Frozen columns only exist under q8 storage, which is refused
1191        // above. If one shows up under an F32 cache the format is lying
1192        // about something and the transfer must not proceed.
1193        if self.kcol.iter().any(|c| !c.is_empty()) || self.vcol.iter().any(|c| !c.is_empty()) {
1194            return Err(
1195                "kv export: frozen columns under an F32 cache — refusing to ship \
1196                        a state this format does not describe"
1197                    .into(),
1198            );
1199        }
1200        let mut out = Vec::with_capacity(self.memory_bytes() / if f16 { 2 } else { 1 } + 64);
1201        let u = |v: u32, o: &mut Vec<u8>| o.extend_from_slice(&v.to_le_bytes());
1202        u(u8::from(f16) as u32, &mut out);
1203        u(self.seq_len as u32, &mut out);
1204        u(self.num_kv_heads as u32, &mut out);
1205        u(self.head_dim as u32, &mut out);
1206        u(self.linear_state.len() as u32, &mut out);
1207        // Attention importance is ordinary state: every attention call
1208        // accumulates it and eviction reads it. Leaving it behind would
1209        // hand the far side a cache that forgets the RIGHT positions
1210        // later — a divergence that shows up only under pressure.
1211        u(self.imp.len() as u32, &mut out);
1212        let push = |xs: &[f32], o: &mut Vec<u8>| {
1213            if f16 {
1214                for &x in xs {
1215                    o.extend_from_slice(&cortiq_core::quant::f32_to_f16(x).to_le_bytes());
1216                }
1217            } else {
1218                for &x in xs {
1219                    o.extend_from_slice(&x.to_le_bytes());
1220                }
1221            }
1222        };
1223        // The recurrent state stays f32 whatever the wire dtype: it
1224        // is one small vector per layer and it is the ONLY state a linear
1225        // layer has — rounding it rounds the whole history.
1226        for &x in &self.linear_state {
1227            out.extend_from_slice(&x.to_le_bytes());
1228        }
1229        for &x in &self.imp {
1230            out.extend_from_slice(&x.to_le_bytes());
1231        }
1232        for h in 0..self.num_kv_heads {
1233            u(self.k[h].len() as u32, &mut out);
1234            push(&self.k[h], &mut out);
1235            u(self.v[h].len() as u32, &mut out);
1236            push(&self.v[h], &mut out);
1237        }
1238        Ok(out)
1239    }
1240
1241    /// Install a peer's state over this layer. The geometry must match the
1242    /// model both sides hold — it is checked, not assumed.
1243    pub fn import_wire(&mut self, buf: &[u8]) -> Result<(), String> {
1244        let mut o = 0usize;
1245        let u32_at = |o: &mut usize| -> Result<u32, String> {
1246            if *o + 4 > buf.len() {
1247                return Err("kv import: truncated header".into());
1248            }
1249            let v = u32::from_le_bytes(buf[*o..*o + 4].try_into().unwrap());
1250            *o += 4;
1251            Ok(v)
1252        };
1253        let f16 = u32_at(&mut o)? != 0;
1254        let seq_len = u32_at(&mut o)? as usize;
1255        let heads = u32_at(&mut o)? as usize;
1256        let hd = u32_at(&mut o)? as usize;
1257        let lin = u32_at(&mut o)? as usize;
1258        let nimp = u32_at(&mut o)? as usize;
1259        if heads != self.num_kv_heads || hd != self.head_dim {
1260            return Err(format!(
1261                "kv import: peer sent {heads}×{hd} per position, this layer is {}×{}",
1262                self.num_kv_heads, self.head_dim
1263            ));
1264        }
1265        let w = if f16 { 2 } else { 4 };
1266        let need = |n: usize, o: usize| -> Result<(), String> {
1267            if o + n > buf.len() {
1268                Err("kv import: truncated payload".into())
1269            } else {
1270                Ok(())
1271            }
1272        };
1273        need(lin * 4, o)?;
1274        self.linear_state = (0..lin)
1275            .map(|i| f32::from_le_bytes(buf[o + i * 4..o + i * 4 + 4].try_into().unwrap()))
1276            .collect();
1277        o += lin * 4;
1278        need(nimp * 4, o)?;
1279        let imp: Vec<f32> = (0..nimp)
1280            .map(|i| f32::from_le_bytes(buf[o + i * 4..o + i * 4 + 4].try_into().unwrap()))
1281            .collect();
1282        o += nimp * 4;
1283        let mut k: Vec<Vec<f32>> = Vec::with_capacity(heads);
1284        let mut v: Vec<Vec<f32>> = Vec::with_capacity(heads);
1285        for _ in 0..heads {
1286            for which in 0..2 {
1287                let n = u32_at(&mut o)? as usize;
1288                need(n * w, o)?;
1289                let xs: Vec<f32> = (0..n)
1290                    .map(|i| {
1291                        let at = o + i * w;
1292                        if f16 {
1293                            cortiq_core::quant::f16_to_f32(u16::from_le_bytes(
1294                                buf[at..at + 2].try_into().unwrap(),
1295                            ))
1296                        } else {
1297                            f32::from_le_bytes(buf[at..at + 4].try_into().unwrap())
1298                        }
1299                    })
1300                    .collect();
1301                o += n * w;
1302                if which == 0 { k.push(xs) } else { v.push(xs) }
1303            }
1304        }
1305        self.mode = KvMode::F32;
1306        self.k = k;
1307        self.v = v;
1308        self.kq = vec![Vec::new(); heads];
1309        self.ks = vec![Vec::new(); heads];
1310        self.vq = vec![Vec::new(); heads];
1311        self.vs = vec![Vec::new(); heads];
1312        self.kcol = vec![Vec::new(); heads];
1313        self.vcol = vec![Vec::new(); heads];
1314        self.imp = imp;
1315        self.o1 = None;
1316        self.o1_error = None;
1317        self.o1_transitioned = false;
1318        self.seq_len = seq_len;
1319        Ok(())
1320    }
1321
1322    pub fn memory_bytes(&self) -> usize {
1323        let floats: usize = self.k.iter().map(Vec::len).sum::<usize>()
1324            + self.v.iter().map(Vec::len).sum::<usize>()
1325            + self.ks.iter().map(Vec::len).sum::<usize>()
1326            + self.vs.iter().map(Vec::len).sum::<usize>()
1327            + self.kcol.iter().map(Vec::len).sum::<usize>()
1328            + self.vcol.iter().map(Vec::len).sum::<usize>();
1329        let bytes: usize = self.kq.iter().map(Vec::len).sum::<usize>()
1330            + self.vq.iter().map(Vec::len).sum::<usize>();
1331        floats * std::mem::size_of::<f32>()
1332            + bytes
1333            // O(1) recurrent state of linear-core layers (vmf_phase/GDN):
1334            // constant in context, but real memory — the honest "KV+state"
1335            // line must count it (a pure-linear model reported 0 before).
1336            + self.linear_state.len() * std::mem::size_of::<f32>()
1337            // O(1) Nyström state (window + sinks + skeleton) — same
1338            // discipline: constant in context, but real memory.
1339            + self.o1_memory_bytes()
1340    }
1341
1342    /// Drop oldest positions, keeping the last `keep_last`.
1343    fn evict(&mut self, keep_last: usize) {
1344        // A collecting o1 layer owns a still-needed exact prefix and query
1345        // trace.  Evicting it would lower the effective seal boundary while
1346        // leaving q_buf untouched, so conversion could never match its KV
1347        // rows.  A sealed layer stores nothing per position — the Nyström
1348        // state IS the eviction policy; resetting seq_len here would lie
1349        // about the context depth.  Both states therefore bypass ordinary
1350        // eviction until the transition or explicit reset completes.
1351        if self.o1.is_some() || self.seq_len <= keep_last {
1352            return;
1353        }
1354        let drop = self.seq_len - keep_last;
1355        for h in 0..self.num_kv_heads {
1356            // Dead heads store fewer positions; drop proportionally.
1357            let stored = self.head_len(h);
1358            let d = drop.min(stored);
1359            let hd = self.head_dim;
1360            fn drop_front<T>(v: &mut Vec<T>, n: usize) {
1361                let n = n.min(v.len());
1362                v.drain(..n);
1363            }
1364            drop_front(&mut self.k[h], d * hd);
1365            drop_front(&mut self.v[h], d * hd);
1366            drop_front(&mut self.kq[h], d * hd);
1367            drop_front(&mut self.vq[h], d * hd);
1368            drop_front(&mut self.ks[h], d * hd.div_ceil(KV_K_GROUP));
1369            drop_front(&mut self.vs[h], d);
1370        }
1371        let d = drop.min(self.imp.len());
1372        self.imp.drain(..d);
1373        self.seq_len = keep_last;
1374    }
1375
1376    /// Mass-based eviction: keep `sink` earliest positions (attention sinks),
1377    /// the `recent` latest, and fill the rest of the `keep_last` budget
1378    /// with the positions carrying the highest accumulated attention
1379    /// mass (vmfcore: PPL 8.342 vs 8.687 for recency-only, full 8.295).
1380    fn evict_born(&mut self, keep_last: usize, sink: usize, recent: usize) {
1381        if self.o1.is_some() {
1382            // See evict(): collecting must retain the exact prefix as well as
1383            // sealed O(1) state must retain its own bounded representation.
1384            return;
1385        }
1386        let stored = self.imp.len();
1387        if stored <= keep_last {
1388            return;
1389        }
1390        // Budget discipline: sinks first, recents next, both clamped so
1391        // the total never exceeds keep_last.
1392        let sink_n = sink.min(keep_last);
1393        let recent_n = recent.min(keep_last - sink_n);
1394        let mut keep = vec![false; stored];
1395        for k in keep.iter_mut().take(sink_n) {
1396            *k = true;
1397        }
1398        for k in keep.iter_mut().skip(stored.saturating_sub(recent_n)) {
1399            *k = true;
1400        }
1401        let mut budget = keep_last.saturating_sub(keep.iter().filter(|&&x| x).count());
1402        // Highest accumulated mass first among the middle positions.
1403        let mut order: Vec<usize> = (0..stored).filter(|&i| !keep[i]).collect();
1404        order.sort_by(|&a, &b| {
1405            self.imp[b]
1406                .partial_cmp(&self.imp[a])
1407                .unwrap_or(std::cmp::Ordering::Equal)
1408        });
1409        for i in order {
1410            if budget == 0 {
1411                break;
1412            }
1413            keep[i] = true;
1414            budget -= 1;
1415        }
1416
1417        let kept: Vec<usize> = (0..stored).filter(|&i| keep[i]).collect();
1418        let hd = self.head_dim;
1419        fn gather<T: Copy>(src: &[T], kept: &[usize], step: usize) -> Vec<T> {
1420            let mut out = Vec::with_capacity(kept.len() * step);
1421            for &i in kept {
1422                out.extend_from_slice(&src[i * step..(i + 1) * step]);
1423            }
1424            out
1425        }
1426        // Each storage is gathered INDEPENDENTLY: in mixed modes
1427        // (q8k/q8v) K and V live in different storages — the paired branch
1428        // panicked (q8v) or silently left V uncompressed (q8k);
1429        // found by adversarial review, closed by regression tests.
1430        for h in 0..self.num_kv_heads {
1431            if !self.k[h].is_empty() {
1432                self.k[h] = gather(&self.k[h], &kept, hd);
1433            }
1434            if !self.v[h].is_empty() {
1435                self.v[h] = gather(&self.v[h], &kept, hd);
1436            }
1437            if !self.kq[h].is_empty() {
1438                self.kq[h] = gather(&self.kq[h], &kept, hd);
1439                self.ks[h] = gather(&self.ks[h], &kept, hd.div_ceil(KV_K_GROUP));
1440            }
1441            if !self.vq[h].is_empty() {
1442                self.vq[h] = gather(&self.vq[h], &kept, hd);
1443                self.vs[h] = gather(&self.vs[h], &kept, 1);
1444            }
1445        }
1446        self.imp = kept.iter().map(|&i| self.imp[i]).collect();
1447        self.seq_len = kept.len();
1448    }
1449}
1450
1451/// Eviction policy for a bounded cache.
1452#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1453pub enum EvictionPolicy {
1454    /// Sliding window: keep only the most recent positions.
1455    Recent,
1456    /// Mass-based eviction: sinks + recents + top accumulated attention mass.
1457    Born { sink: usize },
1458}
1459
1460/// Full KV cache for all layers.
1461#[derive(Debug)]
1462pub struct KvCache {
1463    pub layers: Vec<LayerKvCache>,
1464    pub max_seq_len: usize,
1465    pub policy: EvictionPolicy,
1466}
1467
1468impl KvCache {
1469    pub fn new(
1470        num_layers: usize,
1471        num_kv_heads: usize,
1472        head_dim: usize,
1473        max_seq_len: usize,
1474    ) -> Self {
1475        let layers = (0..num_layers)
1476            .map(|_| LayerKvCache::new(num_kv_heads, head_dim))
1477            .collect();
1478        Self {
1479            layers,
1480            max_seq_len,
1481            policy: EvictionPolicy::Born { sink: 4 },
1482        }
1483    }
1484
1485    pub fn clear(&mut self) {
1486        for layer in &mut self.layers {
1487            layer.clear();
1488        }
1489    }
1490
1491    pub fn total_memory_bytes(&self) -> usize {
1492        self.layers.iter().map(|l| l.memory_bytes()).sum()
1493    }
1494
1495    /// Current sequence length (max across layers — dead layers may lag).
1496    pub fn seq_len(&self) -> usize {
1497        self.layers.iter().map(|l| l.seq_len).max().unwrap_or(0)
1498    }
1499
1500    pub fn needs_eviction(&self) -> bool {
1501        self.seq_len() >= self.max_seq_len
1502    }
1503
1504    /// Evict down to `keep_last` positions according to the policy.
1505    pub fn evict(&mut self, keep_last: usize) {
1506        match self.policy {
1507            EvictionPolicy::Recent => {
1508                for layer in &mut self.layers {
1509                    layer.evict(keep_last);
1510                }
1511            }
1512            EvictionPolicy::Born { sink } => {
1513                let recent = (keep_last / 2).max(1);
1514                for layer in &mut self.layers {
1515                    layer.evict_born(keep_last, sink, recent);
1516                }
1517            }
1518        }
1519    }
1520}
1521
1522#[cfg(test)]
1523mod tests {
1524    use super::*;
1525
1526    #[test]
1527    fn wire_round_trip_reproduces_attention() {
1528        // The state has to arrive as state, not as something that looks
1529        // like it: the oracle is what the layer ANSWERS, not what it
1530        // stores. Same query, same output, bit for bit.
1531        let (heads, hd) = (2usize, 4usize);
1532        let mut a = LayerKvCache::new(heads, hd);
1533        for p in 0..5 {
1534            let k: Vec<f32> = (0..heads * hd)
1535                .map(|i| (p * 10 + i) as f32 * 0.031)
1536                .collect();
1537            let v: Vec<f32> = (0..heads * hd)
1538                .map(|i| (p * 7 + i) as f32 * -0.017)
1539                .collect();
1540            a.append(&k, &v, &[true, true]);
1541        }
1542        a.linear_state = vec![0.5, -0.25, 1.0];
1543        let q: Vec<f32> = (0..hd).map(|i| 0.1 * (i as f32 + 1.0)).collect();
1544
1545        let bytes = a.export_wire(false).expect("f32 cache exports");
1546        let mut b = LayerKvCache::new(heads, hd);
1547        b.import_wire(&bytes).expect("import");
1548
1549        assert_eq!(b.seq_len, a.seq_len);
1550        assert_eq!(b.linear_state, a.linear_state);
1551        for h in 0..heads {
1552            let (oa, sa) = a.attend(&q, h);
1553            let (ob, sb) = b.attend(&q, h);
1554            assert_eq!(oa, ob, "head {h} attention output diverged");
1555            assert_eq!(sa, sb, "head {h} attention scores diverged");
1556        }
1557    }
1558
1559    #[test]
1560    fn wire_refuses_what_it_cannot_describe() {
1561        // A refusal is the feature: a cache whose extra state this format
1562        // does not carry must not travel looking complete.
1563        let mut c = LayerKvCache::new(1, 4);
1564        c.mode = KvMode::Q8 { k: true, v: true };
1565        let err = c.export_wire(false).unwrap_err();
1566        assert!(err.contains("F32"), "{err}");
1567    }
1568
1569    #[test]
1570    fn wire_import_checks_geometry() {
1571        let a = LayerKvCache::new(2, 4);
1572        let bytes = a.export_wire(false).unwrap();
1573        let mut wrong = LayerKvCache::new(2, 8);
1574        let err = wrong.import_wire(&bytes).unwrap_err();
1575        assert!(err.contains("2×4"), "{err}");
1576    }
1577
1578    #[test]
1579    fn append_tracks_seq_len_and_layout() {
1580        let mut cache = LayerKvCache::new(4, 8);
1581        cache.mode = KvMode::F32;
1582        assert_eq!(cache.seq_len, 0);
1583
1584        let k: Vec<f32> = (0..32).map(|i| i as f32).collect();
1585        let v = vec![2.0f32; 32];
1586        cache.append(&k, &v, &[true; 4]);
1587
1588        assert_eq!(cache.seq_len, 1);
1589        assert_eq!(cache.head_len(0), 1);
1590        // head 1 slice is contiguous and equals its part of k_new
1591        assert_eq!(cache.head_keys(1), &k[8..16]);
1592        assert_eq!(cache.memory_bytes(), 256);
1593    }
1594
1595    #[test]
1596    fn dead_head_stores_nothing() {
1597        let mut cache = LayerKvCache::new(2, 4);
1598        cache.mode = KvMode::F32;
1599        let k = vec![1.0f32; 8];
1600        let v = vec![2.0f32; 8];
1601        cache.append(&k, &v, &[true, false]);
1602        cache.append(&k, &v, &[true, false]);
1603
1604        assert_eq!(cache.seq_len, 2);
1605        assert_eq!(cache.head_len(0), 2);
1606        assert_eq!(cache.head_len(1), 0, "dead head must not store KV");
1607        assert_eq!(cache.memory_bytes(), 2 * 2 * 4 * 4);
1608    }
1609
1610    #[test]
1611    fn eviction_keeps_recent() {
1612        let mut cache = KvCache::new(2, 4, 8, 10);
1613        cache.policy = EvictionPolicy::Recent;
1614        for l in &mut cache.layers {
1615            l.mode = KvMode::F32;
1616        }
1617        let k = vec![1.0f32; 32];
1618        let v = vec![2.0f32; 32];
1619        for _ in 0..8 {
1620            for layer in &mut cache.layers {
1621                layer.append(&k, &v, &[true; 4]);
1622            }
1623        }
1624        assert_eq!(cache.seq_len(), 8);
1625        assert!(!cache.needs_eviction());
1626
1627        cache.evict(4);
1628        assert_eq!(cache.seq_len(), 4);
1629        assert_eq!(cache.layers[0].head_len(0), 4);
1630    }
1631
1632    #[test]
1633    fn collecting_o1_eviction_retains_exact_storage_until_boundary() {
1634        const B: usize = 19;
1635        let q = vec![0.1f32; 8];
1636        let k = vec![0.2f32; 4];
1637        let v = vec![0.3f32; 4];
1638
1639        for policy in [EvictionPolicy::Recent, EvictionPolicy::Born { sink: 2 }] {
1640            let mut cache = KvCache::new(1, 1, 4, 6);
1641            cache.policy = policy;
1642            cache.layers[0].mode = KvMode::F32;
1643            cache.layers[0].o1_begin_with_boundary(
1644                4,
1645                8,
1646                2,
1647                crate::nystrom::O1Rect::Aggregate,
1648                Some(B),
1649            );
1650
1651            for pos in 0..B {
1652                {
1653                    let layer = &mut cache.layers[0];
1654                    layer.o1_push_q(&q);
1655                    layer.append(&k, &v, &[]);
1656                }
1657                if pos + 1 < B {
1658                    cache.evict(3);
1659                }
1660            }
1661
1662            let layer = &cache.layers[0];
1663            let rows = B * layer.head_dim;
1664            assert_eq!(layer.seq_len, B, "policy {policy:?} retained depth");
1665            assert_eq!(layer.k[0].len(), rows, "policy {policy:?} K rows");
1666            assert_eq!(layer.v[0].len(), rows, "policy {policy:?} V rows");
1667            assert!(
1668                layer.k[0].capacity() >= rows,
1669                "policy {policy:?} K capacity"
1670            );
1671            assert!(
1672                layer.v[0].capacity() >= rows,
1673                "policy {policy:?} V capacity"
1674            );
1675            let q_capacity = match layer.o1.as_ref() {
1676                Some(O1State::Collecting { q_buf, .. }) => q_buf.capacity(),
1677                other => panic!("policy {policy:?} changed state early: {other:?}"),
1678            };
1679            assert!(
1680                q_capacity >= B * 8,
1681                "policy {policy:?} Q capacity must cover the exact prefix"
1682            );
1683
1684            assert!(cache.layers[0].o1_seal_checked(2).unwrap());
1685            assert_eq!(cache.layers[0].k[0].capacity(), 0, "K released after seal");
1686            assert_eq!(cache.layers[0].v[0].capacity(), 0, "V released after seal");
1687        }
1688    }
1689
1690    #[test]
1691    fn truncate_rolls_back_speculative_positions() {
1692        let mut cache = LayerKvCache::new(2, 4);
1693        cache.mode = KvMode::F32;
1694        for pos in 0..5 {
1695            let k = vec![pos as f32; 8];
1696            let v = vec![pos as f32; 8];
1697            cache.append(&k, &v, &[true; 2]);
1698        }
1699        cache.truncate_last(2);
1700        assert_eq!(cache.seq_len, 3);
1701        assert_eq!(cache.head_len(0), 3);
1702        assert_eq!(cache.head_keys(0)[2 * 4], 2.0, "position 2 survives");
1703    }
1704
1705    /// q8_2f-attend ≈ f32-attend: 100 positions (crosses the field freeze
1706    /// at the 64th), pseudo-random vectors, relative tolerance of the
1707    /// int8 grid. Plus rollback and mass-based eviction on the q8 storage.
1708    #[test]
1709    fn q8_attend_matches_f32_within_grid() {
1710        let (heads, hd) = (2, 32);
1711        let mut f = LayerKvCache::new(heads, hd);
1712        f.mode = KvMode::F32;
1713        let mut q8 = LayerKvCache::new(heads, hd);
1714        q8.mode = KvMode::Q8 { k: true, v: true };
1715
1716        let synth = |p: usize, salt: usize| -> Vec<f32> {
1717            (0..heads * hd)
1718                .map(|i| {
1719                    let x = ((i * 31 + p * 17 + salt * 7 + 3) % 97) as f32 / 97.0 - 0.5;
1720                    // channel structure: even channels ×4 (checks the 2f field)
1721                    if i % 2 == 0 { x * 4.0 } else { x * 0.25 }
1722                })
1723                .collect()
1724        };
1725        for p in 0..100 {
1726            let k = synth(p, 1);
1727            let v = synth(p, 2);
1728            f.append(&k, &v, &[true; 2]);
1729            q8.append(&k, &v, &[true; 2]);
1730        }
1731        let q: Vec<f32> = (0..hd)
1732            .map(|i| ((i * 13 + 5) % 89) as f32 / 89.0 - 0.5)
1733            .collect();
1734        for g in 0..heads {
1735            let (of, pf) = f.attend(&q, g);
1736            let (o8, p8) = q8.attend(&q, g);
1737            let scale = of.iter().fold(0f32, |m, x| m.max(x.abs())).max(1e-6);
1738            for d in 0..hd {
1739                assert!(
1740                    (of[d] - o8[d]).abs() <= scale * 0.03 + 1e-3,
1741                    "g{g} d{d}: f32 {} vs q8 {}",
1742                    of[d],
1743                    o8[d]
1744                );
1745            }
1746            for p in 0..100 {
1747                assert!((pf[p] - p8[p]).abs() < 0.02, "prob p{p}");
1748            }
1749        }
1750        // rollback + eviction live on the q8 storage
1751        q8.truncate_last(30);
1752        assert_eq!(q8.head_len(0), 70);
1753        let imp: Vec<f32> = (0..70).map(|i| i as f32).collect();
1754        q8.accumulate_imp(&imp);
1755        q8.evict_born(20, 2, 8);
1756        assert_eq!(q8.head_len(0), 20);
1757        let (o, _) = q8.attend(&q, 0);
1758        assert!(o.iter().all(|x| x.is_finite()));
1759        // memory: q8 ≈ 1 byte/element + scale per row (vs 4 for f32)
1760        assert!(q8.memory_bytes() * 3 < f.memory_bytes());
1761    }
1762
1763    /// Grouped GQA attend must be bit-identical to per-head attend in
1764    /// every KV mode (it is the same math with rows streamed once).
1765    #[test]
1766    fn attend_group_equals_per_head_attend_bitexact() {
1767        let (kv_heads, hd, hpk) = (2usize, 32usize, 3usize); // 6 Q-heads
1768        for mode in [KvMode::F32, KvMode::Q8 { k: true, v: true }] {
1769            let mut c = LayerKvCache::new(kv_heads, hd);
1770            c.mode = mode;
1771            for p in 0..70 {
1772                let k: Vec<f32> = (0..kv_heads * hd)
1773                    .map(|i| ((i * 31 + p * 17 + 3) % 97) as f32 / 97.0 - 0.5)
1774                    .collect();
1775                let v: Vec<f32> = (0..kv_heads * hd)
1776                    .map(|i| ((i * 13 + p * 29 + 7) % 89) as f32 / 89.0 - 0.5)
1777                    .collect();
1778                c.append(&k, &v, &[true; 2]);
1779            }
1780            let q: Vec<f32> = (0..kv_heads * hpk * hd)
1781                .map(|i| ((i * 11 + 5) % 83) as f32 / 83.0 - 0.5)
1782                .collect();
1783            for g in 0..kv_heads {
1784                let span = g * hpk * hd..(g + 1) * hpk * hd;
1785                let mut out = vec![0f32; hpk * hd];
1786                let mut imp = vec![0f32; 70];
1787                c.attend_group(
1788                    &q[span.clone()],
1789                    g,
1790                    &mut out,
1791                    &mut imp,
1792                    1.0 / (hd as f32).sqrt(),
1793                    0,
1794                    0.0,
1795                    &[],
1796                );
1797                let mut imp_ref = vec![0f32; 70];
1798                for h in 0..hpk {
1799                    let qh = &q[span.start + h * hd..span.start + (h + 1) * hd];
1800                    let (o, probs) = c.attend(qh, g);
1801                    assert_eq!(
1802                        &out[h * hd..(h + 1) * hd],
1803                        &o[..],
1804                        "mode {mode:?} g{g} h{h}: grouped attend must be bit-identical"
1805                    );
1806                    for (dst, &p) in imp_ref.iter_mut().zip(&probs) {
1807                        *dst += p;
1808                    }
1809                }
1810                assert_eq!(
1811                    imp, imp_ref,
1812                    "mode {mode:?} g{g}: attention mass must match"
1813                );
1814            }
1815        }
1816    }
1817
1818    /// Learned sinks (gpt-oss / MiMo-V2): the grouped softmax must equal a
1819    /// softmax over the visible rows PLUS an explicit value-less sink
1820    /// column, computed independently in f64 — at position 0 (one stored
1821    /// row), mid-sequence, and with the window truncating the rows. The
1822    /// importance row must be the row probabilities (the sink's share is
1823    /// nobody's importance).
1824    #[test]
1825    fn sink_attend_matches_explicit_sink_column() {
1826        let (nkv, hd, hpk) = (2usize, 8usize, 3usize);
1827        let rows = 9usize;
1828        let mut c = LayerKvCache::new(nkv, hd);
1829        c.mode = KvMode::F32;
1830        let kv = |r: usize, i: usize, a: usize, m: usize| {
1831            (((r * a + i * 7 + 3) % m) as f32 / m as f32 - 0.5) * 2.0
1832        };
1833        let mut ks = Vec::new();
1834        let mut vs = Vec::new();
1835        for r in 0..rows {
1836            let k: Vec<f32> = (0..nkv * hd).map(|i| kv(r, i, 31, 97)).collect();
1837            let v: Vec<f32> = (0..nkv * hd).map(|i| kv(r, i, 17, 89)).collect();
1838            c.append(&k, &v, &[]);
1839            ks.push(k);
1840            vs.push(v);
1841        }
1842        let q: Vec<f32> = (0..nkv * hpk * hd)
1843            .map(|i| (((i * 11 + 5) % 83) as f32 / 83.0 - 0.5) * 3.0)
1844            .collect();
1845        // Mixed signs and one sink that dominates its head.
1846        let sinks = [0.7f32, -1.3, 2.5, 0.0, -4.0, 6.0];
1847        let scale = 1.0 / (hd as f32).sqrt();
1848        let mut checked = 0usize;
1849        for upto in [1usize, 2, 5, 9] {
1850            for window in [None, Some(3usize), Some(1)] {
1851                let first = window.map(|w| upto.saturating_sub(w)).unwrap_or(0);
1852                for g in 0..nkv {
1853                    let qg = &q[g * hpk * hd..(g + 1) * hpk * hd];
1854                    let sg = &sinks[g * hpk..(g + 1) * hpk];
1855                    let mut out = vec![0f32; hpk * hd];
1856                    let mut imp = vec![0f32; upto];
1857                    c.attend_group_upto(qg, g, &mut out, &mut imp, scale, first, 0.0, upto, sg);
1858                    let mut imp_ref = vec![0f64; upto];
1859                    for h in 0..hpk {
1860                        let qh = &qg[h * hd..(h + 1) * hd];
1861                        // Logits of the visible rows, then the sink column.
1862                        let mut z: Vec<f64> = (first..upto)
1863                            .map(|p| {
1864                                let k = &ks[p][g * hd..(g + 1) * hd];
1865                                qh.iter()
1866                                    .zip(k)
1867                                    .map(|(&a, &b)| a as f64 * b as f64)
1868                                    .sum::<f64>()
1869                                    * scale as f64
1870                            })
1871                            .collect();
1872                        z.push(sg[h] as f64);
1873                        let m = z.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
1874                        let e: Vec<f64> = z.iter().map(|&x| (x - m).exp()).collect();
1875                        let s: f64 = e.iter().sum();
1876                        let p: Vec<f64> = e.iter().map(|&x| x / s).collect();
1877                        // The sink column carries a zero value vector.
1878                        for d in 0..hd {
1879                            let want: f64 = (first..upto)
1880                                .map(|r| p[r - first] * vs[r][g * hd + d] as f64)
1881                                .sum();
1882                            let got = out[h * hd + d] as f64;
1883                            assert!(
1884                                (got - want).abs() < 1e-6,
1885                                "upto {upto} window {window:?} g{g} h{h} d{d}: {got} vs {want}"
1886                            );
1887                        }
1888                        for r in first..upto {
1889                            imp_ref[r] += p[r - first];
1890                        }
1891                        checked += 1;
1892                    }
1893                    for r in 0..upto {
1894                        assert!(
1895                            (imp[r] as f64 - imp_ref[r]).abs() < 1e-6,
1896                            "imp upto {upto} window {window:?} g{g} row {r}: {} vs {}",
1897                            imp[r],
1898                            imp_ref[r]
1899                        );
1900                    }
1901                    // The sink takes real mass: rows sum below 1.
1902                    let row_mass: f32 = imp.iter().sum();
1903                    assert!(row_mass < hpk as f32, "sinks must absorb some mass");
1904                }
1905            }
1906        }
1907        assert_eq!(checked, 4 * 3 * nkv * hpk);
1908    }
1909
1910    /// Scoring only the window's rows must be bit-identical to the former
1911    /// whole-row scoring (−inf outside the window) — with and without a
1912    /// sink, output and importance. The reference is the plain per-head
1913    /// softmax over the same rows written out here.
1914    #[test]
1915    fn windowed_attend_equals_masked_full_row() {
1916        let (nkv, hd, hpk) = (1usize, 16usize, 2usize);
1917        let rows = 40usize;
1918        let mut c = LayerKvCache::new(nkv, hd);
1919        c.mode = KvMode::F32;
1920        for r in 0..rows {
1921            let k: Vec<f32> = (0..hd)
1922                .map(|i| ((r * 13 + i * 5) % 29) as f32 / 29.0 - 0.5)
1923                .collect();
1924            let v: Vec<f32> = (0..hd)
1925                .map(|i| ((r * 7 + i * 3) % 31) as f32 / 31.0 - 0.5)
1926                .collect();
1927            c.append(&k, &v, &[]);
1928        }
1929        let q: Vec<f32> = (0..hpk * hd)
1930            .map(|i| ((i * 19) % 23) as f32 / 23.0 - 0.5)
1931            .collect();
1932        let scale = 0.25f32;
1933        for w in [1usize, 7, 39, 40, 100] {
1934            let first = rows.saturating_sub(w);
1935            let mut out = vec![0f32; hpk * hd];
1936            let mut imp = vec![0f32; rows];
1937            c.attend_group(&q, 0, &mut out, &mut imp, scale, first, 0.0, &[]);
1938            // Reference: the historical full-row −inf-masked kernel.
1939            let mut out_ref = vec![0f32; hpk * hd];
1940            let mut imp_ref = vec![0f32; rows];
1941            for h in 0..hpk {
1942                let mut s = vec![f32::NEG_INFINITY; rows];
1943                for p in first..rows {
1944                    s[p] = crate::attention::dot_f32(
1945                        &q[h * hd..(h + 1) * hd],
1946                        &c.head_keys(0)[p * hd..(p + 1) * hd],
1947                    ) * scale;
1948                }
1949                let m = s.iter().cloned().fold(f32::NEG_INFINITY, f32::max);
1950                let mut sum = 0f32;
1951                for v in s.iter_mut() {
1952                    *v = (*v - m).exp();
1953                    sum += *v;
1954                }
1955                for v in s.iter_mut() {
1956                    *v /= sum;
1957                }
1958                for p in first..rows {
1959                    if s[p].abs() < 1e-12 {
1960                        continue;
1961                    }
1962                    crate::attention::axpy_f32(
1963                        &mut out_ref[h * hd..(h + 1) * hd],
1964                        &c.head_values(0)[p * hd..(p + 1) * hd],
1965                        s[p],
1966                    );
1967                }
1968                for (d, &p) in imp_ref.iter_mut().zip(&s) {
1969                    *d += p;
1970                }
1971            }
1972            let bits = |v: &[f32]| v.iter().map(|x| x.to_bits()).collect::<Vec<_>>();
1973            assert_eq!(bits(&out), bits(&out_ref), "out window {w}");
1974            assert_eq!(bits(&imp), bits(&imp_ref), "imp window {w}");
1975        }
1976    }
1977
1978    /// Sinks are weights: a new conversation (clear) and a wire import
1979    /// must keep them.
1980    #[test]
1981    fn sinks_survive_clear_and_wire_import() {
1982        let mut c = LayerKvCache::new(1, 4);
1983        c.mode = KvMode::F32;
1984        c.sinks = Some(vec![0.5, -0.5]);
1985        c.append(&[1.0; 4], &[2.0; 4], &[]);
1986        let wire = c.export_wire(false).unwrap();
1987        c.clear();
1988        assert_eq!(c.sinks.as_deref(), Some(&[0.5f32, -0.5][..]));
1989        c.import_wire(&wire).unwrap();
1990        assert_eq!(c.sinks.as_deref(), Some(&[0.5f32, -0.5][..]));
1991        assert_eq!(c.seq_len, 1);
1992    }
1993
1994    /// Review regression: mass-based eviction in MIXED modes. q8v used to
1995    /// panic (gather over an empty v[h]), q8k silently left raw V
1996    /// uncompressed (stale rows under kept keys + memory leak).
1997    #[test]
1998    fn born_eviction_mixed_modes_stay_consistent() {
1999        for (mk, mv) in [(false, true), (true, false)] {
2000            let mut c = LayerKvCache::new(1, 4);
2001            c.mode = KvMode::Q8 { k: mk, v: mv };
2002            for p in 0..80 {
2003                let k = vec![p as f32 * 0.01; 4];
2004                let v = vec![p as f32; 4];
2005                c.append(&k, &v, &[true]);
2006            }
2007            let imp: Vec<f32> = (0..80).map(|i| i as f32).collect();
2008            c.accumulate_imp(&imp);
2009            let before = c.memory_bytes();
2010            c.evict_born(20, 4, 8); // q8v: used to panic here
2011            assert_eq!(c.head_len(0), 20, "k={mk} v={mv}");
2012            assert!(
2013                c.memory_bytes() < before / 2,
2014                "memory must shrink (k={mk} v={mv})"
2015            );
2016            // V rows match the kept set: the heaviest positions
2017            // (tail 60..79) must be present in the attend output.
2018            let (out, _) = c.attend(&[1.0, 1.0, 1.0, 1.0], 0);
2019            assert!(
2020                out[0] > 30.0,
2021                "V from the kept tail, not the stale head (k={mk} v={mv}, out {})",
2022                out[0]
2023            );
2024        }
2025    }
2026
2027    #[test]
2028    fn born_eviction_keeps_high_mass_position() {
2029        let mut cache = KvCache::new(1, 1, 2, 16);
2030        cache.policy = EvictionPolicy::Born { sink: 1 };
2031        for l in &mut cache.layers {
2032            l.mode = KvMode::F32;
2033        }
2034        let layer = &mut cache.layers[0];
2035        // 8 positions; keys carry the position index so we can verify
2036        // exactly which positions survive the gather.
2037        for pos in 0..8 {
2038            let k = vec![pos as f32; 2];
2039            let v = vec![pos as f32 + 100.0; 2];
2040            layer.append(&k, &v, &[true]);
2041        }
2042        // Position 3 carries the most attention mass.
2043        let mut imp = vec![0.05f32; 8];
2044        imp[3] = 5.0;
2045        layer.accumulate_imp(&imp);
2046
2047        cache.evict(4); // sink 1 + recent 2 + 1 top-mass slot
2048        let layer = &cache.layers[0];
2049        assert_eq!(layer.seq_len, 4);
2050        let kept_keys: Vec<f32> = (0..4).map(|i| layer.head_keys(0)[i * 2]).collect();
2051        assert_eq!(
2052            kept_keys,
2053            vec![0.0, 3.0, 6.0, 7.0],
2054            "kept = sink(0) + mass-top(3) + recent(6,7)"
2055        );
2056        // imp stays aligned with the gathered positions.
2057        assert_eq!(layer.head_len(0), 4);
2058    }
2059}