Skip to main content

needle_core/v3/
cache.rs

1//! Incremental decode state for Needle 3.
2//!
3//! v3 carries three separate histories, not one, and each has a different
4//! reach. Missing any of them produces output that looks plausible and is
5//! wrong, so they are modelled explicitly:
6//!
7//! 1. **Keys and values**, per layer. Sixteen layers attend over a 1024
8//!    window and get a fixed ring; four attend over everything and grow.
9//!    This is the only place v2's single-ring design does not transfer.
10//! 2. **Raw Q/K/V projections**, the last `qkv_conv_taps - 1` of them per
11//!    layer, because the causal conv over the sequence needs them to form the
12//!    current position's post-conv vectors.
13//! 3. **Engram history** — the last few token ids for the n-gram hash, and the
14//!    pre-convolution value vectors reaching back `(conv_taps - 1) · dilation`
15//!    positions per site.
16//!
17//! Memory is sized to the session, not to the windows. Every layer starts at
18//! the caller's hint and doubles into its cap — a local layer stops at its
19//! 1024 window and then wraps as a ring, a global layer keeps growing. While a
20//! ring is shorter than its cap the modulo is the identity, so growing can
21//! never scramble what is already written.
22//!
23//! That is worth roughly 4x on a typical session: reserving every window and
24//! the full context costs 41 MB at f32, where a 512-token session pays 8.8 MB.
25//! The container declares `kv_bits = 8`, so upstream post-trained this model
26//! for an int8 cache — another 4x — but quantising here would change numerics
27//! away from the verified path, so it is tracked as separate work rather than
28//! done silently.
29
30extern crate alloc;
31use alloc::vec;
32use alloc::vec::Vec;
33
34use crate::math::round;
35use crate::v3::attention::KvStore;
36use crate::v3::config::V3Config;
37
38/// How the key/value cache stores its entries.
39///
40/// The container declares what the model was post-trained for in `kv_bits`.
41/// Needle 3 declares **8**, so [`KvPrecision::Int8`] is the width upstream
42/// intends; `< 8` maps to a different scheme upstream (Cactus-Quants at group
43/// 64) and is not implemented here, because no shipped checkpoint asks for it.
44#[derive(Debug, Clone, Copy, PartialEq, Eq)]
45pub enum KvPrecision {
46    /// Store as `f32`. Bit-identical to the verified forward pass, and the
47    /// default for that reason.
48    F32,
49    /// Per-head symmetric 8-bit, as upstream's `a8_fake_quant_kv` defines it.
50    ///
51    /// Roughly a quarter of the memory. It is **not** bit-identical to the f32
52    /// path — it is the numerics upstream's native engine runs — so it is
53    /// opt-in rather than default.
54    Int8,
55}
56
57/// Quantise one vector to symmetric `bits`-bit and immediately dequantise,
58/// exactly as upstream's `fake_quant` does with `group_size == x.len()`.
59///
60/// ```text
61/// qmax  = 2^(bits-1) - 1
62/// scale = absmax > 0 ? absmax / qmax : 1
63/// out   = clamp(round(x / scale), -qmax - 1, qmax) * scale
64/// ```
65///
66/// An all-zero vector keeps scale 1 and stays zero, which is why the guard is
67/// on `absmax > 0` rather than on a tolerance.
68pub fn fake_quant_vec(x: &mut [f32], bits: u32) {
69    let (scale, inv, qmax) = quant_scale(x, bits);
70    for v in x.iter_mut() {
71        *v = round(*v * inv).clamp(-qmax - 1.0, qmax) * scale;
72    }
73}
74
75/// The scale `fake_quant_vec` would use, with its reciprocal and `qmax`.
76///
77/// Shared so the stored-integer path and the round-trip reference cannot drift
78/// apart: the parity fixtures pin `fake_quant_vec`, and this is the same
79/// arithmetic.
80fn quant_scale(x: &[f32], bits: u32) -> (f32, f32, f32) {
81    let qmax = ((1u32 << (bits - 1)) - 1) as f32;
82    let mut absmax = 0.0f32;
83    for v in x.iter() {
84        let a = if *v < 0.0 { -*v } else { *v };
85        if a > absmax {
86            absmax = a;
87        }
88    }
89    let scale = if absmax > 0.0 { absmax / qmax } else { 1.0 };
90    (scale, 1.0 / scale, qmax)
91}
92
93/// Quantise `src` into `dst` as stored integers, returning the scale.
94///
95/// `dst[i] as f32 * scale` reproduces exactly what [`fake_quant_vec`] would
96/// have left in place, which is what makes the stored-int8 cache numerically
97/// identical to the round-trip the parity fixtures verify.
98fn quant_into(src: &[f32], dst: &mut [i8]) -> f32 {
99    let (scale, inv, qmax) = quant_scale(src, 8);
100    for (d, v) in dst.iter_mut().zip(src) {
101        *d = round(*v * inv).clamp(-qmax - 1.0, qmax) as i8;
102    }
103    scale
104}
105
106/// Quantise `rows` head-vectors of `head_dim` into stored integers plus one
107/// scale each — the same layout [`V3Cache`] holds internally.
108///
109/// The batched prefill path needs this so it can attend through exactly the
110/// arithmetic [`crate::v3::attend_step`] uses on the stored cache. Attending
111/// over dequantised `f32` instead is algebraically the same and numerically
112/// is not, which would make a prefilled session disagree with a stepped one.
113pub fn quantize_rows(x: &[f32], head_dim: usize) -> (Vec<i8>, Vec<f32>) {
114    let rows = x.len() / head_dim;
115    let mut ints = vec![0i8; x.len()];
116    let mut scales = vec![1.0f32; rows];
117    for (r, scale) in scales.iter_mut().enumerate() {
118        let o = r * head_dim;
119        *scale = quant_into(&x[o..o + head_dim], &mut ints[o..o + head_dim]);
120    }
121    (ints, scales)
122}
123
124/// Per-layer key/value storage.
125struct LayerCache {
126    /// Populated when the precision is `F32`; empty otherwise.
127    k: Vec<f32>,
128    v: Vec<f32>,
129    /// Populated when the precision is `Int8`; empty otherwise. One `i8` per
130    /// element, plus one `f32` scale per `(slot, kv_head)`.
131    kq: Vec<i8>,
132    vq: Vec<i8>,
133    k_scale: Vec<f32>,
134    v_scale: Vec<f32>,
135    /// Ring capacity in positions, grown on demand up to `cap`.
136    slots: usize,
137    /// The most this layer will ever hold: its window, or `max_seq_len` for a
138    /// global layer.
139    cap: usize,
140    /// Raw, pre-convolution projections for the conv tail, most recent last.
141    /// `(taps - 1)` entries of `q_dim`, `k_dim`, `v_dim`.
142    q_tail: Vec<f32>,
143    k_tail: Vec<f32>,
144    v_tail: Vec<f32>,
145}
146
147/// Decode state for one session.
148pub struct V3Cache {
149    cfg: V3Config,
150    precision: KvPrecision,
151    layers: Vec<LayerCache>,
152    /// Token ids, kept only as far back as the largest n-gram order needs.
153    tokens: Vec<u32>,
154    /// Pre-convolution Engram values per site, most recent last, reaching
155    /// `(conv_taps - 1) * dilation` positions.
156    engram_tail: Vec<Vec<f32>>,
157    /// Number of positions written so far; the next write lands here.
158    pos: usize,
159}
160
161impl V3Cache {
162    /// A cache for a session expected to reach `hint` positions.
163    ///
164    /// `hint` only sizes the first allocation for global layers — exceeding it
165    /// grows rather than fails. Local layers are always exactly their window.
166    pub fn new(cfg: &V3Config, hint: usize) -> Self {
167        Self::with_precision(cfg, hint, KvPrecision::F32)
168    }
169
170    /// A cache that stores its keys and values at `precision` from the first
171    /// write.
172    ///
173    /// Precision is fixed at construction because it decides the allocation:
174    /// an `Int8` cache never allocates the `f32` buffers at all, which is the
175    /// entire point. Switching afterwards would either reinterpret what is
176    /// already stored or silently throw it away.
177    pub fn with_precision(cfg: &V3Config, hint: usize, precision: KvPrecision) -> Self {
178        let hint = hint.clamp(1, cfg.max_seq_len);
179        let taps_tail = cfg.qkv_conv_taps.saturating_sub(1);
180        let layers = (0..cfg.num_layers)
181            .map(|li| {
182                // Grow into the window rather than reserving it. While the
183                // ring is still shorter than its cap the modulo is the
184                // identity, so growing can never scramble what is already
185                // written; once it reaches the cap it wraps as a ring.
186                let cap = match cfg.attention_span(li) {
187                    Some(w) => w.min(cfg.max_seq_len),
188                    None => cfg.max_seq_len,
189                };
190                let slots = hint.min(cap);
191                let int8 = precision == KvPrecision::Int8;
192                let heads = slots * cfg.num_kv_heads;
193                LayerCache {
194                    k: if int8 {
195                        Vec::new()
196                    } else {
197                        vec![0.0; slots * cfg.k_dim()]
198                    },
199                    v: if int8 {
200                        Vec::new()
201                    } else {
202                        vec![0.0; slots * cfg.v_dim()]
203                    },
204                    kq: if int8 {
205                        vec![0; slots * cfg.k_dim()]
206                    } else {
207                        Vec::new()
208                    },
209                    vq: if int8 {
210                        vec![0; slots * cfg.v_dim()]
211                    } else {
212                        Vec::new()
213                    },
214                    k_scale: if int8 { vec![1.0; heads] } else { Vec::new() },
215                    v_scale: if int8 { vec![1.0; heads] } else { Vec::new() },
216                    slots,
217                    cap,
218                    q_tail: vec![0.0; taps_tail * cfg.q_dim()],
219                    k_tail: vec![0.0; taps_tail * cfg.k_dim()],
220                    v_tail: vec![0.0; taps_tail * cfg.v_dim()],
221                }
222            })
223            .collect();
224
225        let reach = cfg.engram.conv_taps.saturating_sub(1) * cfg.engram.conv_dilation;
226        let engram_tail = (0..cfg.engram.sites.len())
227            .map(|_| vec![0.0; reach * cfg.d_model])
228            .collect();
229
230        Self {
231            cfg: cfg.clone(),
232            precision,
233            layers,
234            tokens: Vec::with_capacity(hint),
235            engram_tail,
236            pos: 0,
237        }
238    }
239
240    pub fn precision(&self) -> KvPrecision {
241        self.precision
242    }
243
244    /// Positions written so far.
245    pub fn len(&self) -> usize {
246        self.pos
247    }
248
249    pub fn is_empty(&self) -> bool {
250        self.pos == 0
251    }
252
253    /// Bytes currently held, so a caller on a constrained target can see what
254    /// a session actually costs rather than guessing.
255    pub fn bytes(&self) -> usize {
256        let per_layer: usize = self
257            .layers
258            .iter()
259            .map(|l| {
260                (l.k.len() + l.v.len() + l.k_scale.len() + l.v_scale.len()) * 4
261                    + l.kq.len()
262                    + l.vq.len()
263                    + (l.q_tail.len() + l.k_tail.len() + l.v_tail.len()) * 4
264            })
265            .sum();
266        let engram: usize = self.engram_tail.iter().map(|t| t.len() * 4).sum();
267        per_layer + engram + self.tokens.len() * 4
268    }
269
270    /// Token ids retained for the n-gram hash, oldest first.
271    pub fn token_history(&self) -> &[u32] {
272        &self.tokens
273    }
274
275    /// Record a token, dropping history no longer reachable by any hash.
276    pub fn push_token(&mut self, tok: u32) {
277        let keep = self.cfg.engram.orders.iter().copied().max().unwrap_or(1);
278        self.tokens.push(tok);
279        if self.tokens.len() > keep {
280            let drop = self.tokens.len() - keep;
281            self.tokens.drain(..drop);
282        }
283    }
284
285    /// Grow layer `li` so `pos` is addressable, up to its cap.
286    ///
287    /// A local layer stops at its window and then wraps; a global layer keeps
288    /// growing to `max_seq_len`. Doubling keeps the amortised cost linear
289    /// without ever reserving the maximum up front — which is the difference
290    /// between a 2 MB session and a 14 MB one.
291    fn ensure(&mut self, li: usize, pos: usize) {
292        let (k_dim, v_dim) = (self.cfg.k_dim(), self.cfg.v_dim());
293        let heads = self.cfg.num_kv_heads;
294        let layer = &mut self.layers[li];
295        if pos < layer.slots || layer.slots >= layer.cap {
296            return;
297        }
298        let want = (layer.slots * 2).max(pos + 1).min(layer.cap);
299        if layer.kq.is_empty() && layer.vq.is_empty() {
300            layer.k.resize(want * k_dim, 0.0);
301            layer.v.resize(want * v_dim, 0.0);
302        } else {
303            layer.kq.resize(want * k_dim, 0);
304            layer.vq.resize(want * v_dim, 0);
305            layer.k_scale.resize(want * heads, 1.0);
306            layer.v_scale.resize(want * heads, 1.0);
307        }
308        layer.slots = want;
309    }
310
311    /// The inclusive logical range layer `li` may attend to at `pos`.
312    ///
313    /// Clamped to the ring's capacity. A global layer past `max_seq_len` would
314    /// otherwise ask for more history than it stores, and because slots are
315    /// addressed modulo the capacity it would read *itself* as its own past —
316    /// silently, since the bounds check is a debug assertion. Degrading to the
317    /// most recent `slots` positions is wrong too, but it is bounded, visible
318    /// through [`Self::is_saturated`], and cannot corrupt.
319    ///
320    /// Callers should avoid reaching this: see `V3Engine`, which bounds the
321    /// prompt and reports `StopReason::MaxSeqLen`.
322    pub fn span(&self, li: usize, pos: usize) -> (usize, usize) {
323        let slots = self.layers[li].slots;
324        let lo = match self.cfg.attention_span(li) {
325            Some(w) if pos + 1 > w => pos + 1 - w,
326            _ => 0,
327        };
328        // Never ask for a wider window than the ring holds.
329        let lo = lo.max((pos + 1).saturating_sub(slots));
330        (lo, pos)
331    }
332
333    /// True once any layer has run past what it can store, so attention is no
334    /// longer seeing the history the model was trained to see.
335    pub fn is_saturated(&self) -> bool {
336        (0..self.cfg.num_layers)
337            .any(|li| self.cfg.attention_span(li).is_none() && self.pos > self.layers[li].slots)
338    }
339
340    /// Ring capacity for layer `li`.
341    pub fn slots(&self, li: usize) -> usize {
342        self.layers[li].slots
343    }
344
345    /// Write this position's post-convolution key and value.
346    pub fn write_kv(&mut self, li: usize, pos: usize, k: &[f32], v: &[f32]) {
347        self.ensure(li, pos);
348        let (k_dim, v_dim) = (self.cfg.k_dim(), self.cfg.v_dim());
349        let precision = self.precision;
350        let num_kv_heads = self.cfg.num_kv_heads;
351        let (qk, vh) = (self.cfg.qk_head_dim, self.cfg.v_head_dim);
352        let layer = &mut self.layers[li];
353        let slot = pos % layer.slots;
354        match precision {
355            KvPrecision::F32 => {
356                layer.k[slot * k_dim..(slot + 1) * k_dim].copy_from_slice(k);
357                layer.v[slot * v_dim..(slot + 1) * v_dim].copy_from_slice(v);
358            }
359            KvPrecision::Int8 => {
360                // One scale per head vector, as upstream's `a8_fake_quant_kv`
361                // defines it. The rounded integers are what is stored; the
362                // dequantisation happens in attention, where the scale factors
363                // out of the dot product.
364                let base = slot * num_kv_heads;
365                for h in 0..num_kv_heads {
366                    let sk = quant_into(
367                        &k[h * qk..(h + 1) * qk],
368                        &mut layer.kq[slot * k_dim + h * qk..slot * k_dim + (h + 1) * qk],
369                    );
370                    layer.k_scale[base + h] = sk;
371                    let sv = quant_into(
372                        &v[h * vh..(h + 1) * vh],
373                        &mut layer.vq[slot * v_dim + h * vh..slot * v_dim + (h + 1) * vh],
374                    );
375                    layer.v_scale[base + h] = sv;
376                }
377            }
378        }
379    }
380
381    /// How layer `li` hands its keys and values to attention.
382    pub fn kv(&self, li: usize) -> KvStore<'_> {
383        let l = &self.layers[li];
384        match self.precision {
385            KvPrecision::F32 => KvStore::F32 { k: &l.k, v: &l.v },
386            KvPrecision::Int8 => KvStore::Int8 {
387                k: &l.kq,
388                k_scale: &l.k_scale,
389                v: &l.vq,
390                v_scale: &l.v_scale,
391            },
392        }
393    }
394
395    /// Apply the causal conv for one position using the retained raw tail,
396    /// then roll the tail forward.
397    ///
398    /// `raw` is this position's pre-conv projection and is replaced with the
399    /// post-conv result. History before the start of the session contributes
400    /// zero, exactly as the reference's zero-padded shift does.
401    pub fn conv_step(&mut self, li: usize, which: Qkv, taps: &[f32], raw: &mut [f32]) {
402        let n_taps = self.cfg.qkv_conv_taps;
403        if n_taps == 0 {
404            return;
405        }
406        let dim = raw.len();
407        let pos = self.pos;
408        let layer = &mut self.layers[li];
409        let tail = match which {
410            Qkv::Q => &mut layer.q_tail,
411            Qkv::K => &mut layer.k_tail,
412            Qkv::V => &mut layer.v_tail,
413        };
414        debug_assert_eq!(tail.len(), (n_taps - 1) * dim);
415
416        let mut out = vec![0.0f32; dim];
417        for c in 0..dim {
418            let mut acc = taps[c] * raw[c];
419            for j in 1..n_taps {
420                if j > pos {
421                    break;
422                }
423                // tail is most-recent-last, so position pos-j is at index
424                // (n_taps - 1 - j).
425                let slot = n_taps - 1 - j;
426                acc += taps[j * dim + c] * tail[slot * dim + c];
427            }
428            out[c] = acc;
429        }
430
431        // Roll: drop the oldest, append this position's raw vector.
432        if n_taps > 1 {
433            tail.copy_within(dim.., 0);
434            let last = (n_taps - 2) * dim;
435            tail[last..last + dim].copy_from_slice(raw);
436        }
437        raw.copy_from_slice(&out);
438    }
439
440    /// The Engram value convolution for one position, using the retained tail.
441    ///
442    /// `raw` is this position's pre-conv Engram value and is replaced with the
443    /// post-conv result.
444    pub fn engram_conv_step(&mut self, site: usize, taps: &[f32], raw: &mut [f32]) {
445        let e = &self.cfg.engram;
446        let (n_taps, dil) = (e.conv_taps, e.conv_dilation);
447        let max_order = e.orders.iter().copied().max().unwrap_or(1);
448        let d = self.cfg.d_model;
449        let pos = self.pos;
450        let reach = (n_taps - 1) * dil;
451        let tail = &mut self.engram_tail[site];
452
453        let mut out = vec![0.0f32; d];
454        for c in 0..d {
455            let mut acc = 0.0f32;
456            for j in 0..n_taps {
457                let back = j * dil;
458                if back > pos || pos < j * max_order {
459                    continue;
460                }
461                let val = if j == 0 {
462                    raw[c]
463                } else {
464                    // tail holds the last `reach` positions, most recent last,
465                    // so position pos-back sits at index reach-back.
466                    tail[(reach - back) * d + c]
467                };
468                acc += taps[j * d + c] * val;
469            }
470            out[c] = acc;
471        }
472
473        if reach > 0 {
474            tail.copy_within(d.., 0);
475            let last = (reach - 1) * d;
476            tail[last..last + d].copy_from_slice(raw);
477        }
478        raw.copy_from_slice(&out);
479    }
480
481    /// Seed the conv tail for a layer after a batched prefill.
482    ///
483    /// `raw` holds the last `qkv_conv_taps - 1` **pre-convolution** vectors for
484    /// this projection, oldest first. The batched path convolves in place, so
485    /// the caller has to keep these before it does.
486    pub fn seed_conv_tail(&mut self, li: usize, which: Qkv, raw: &[f32]) {
487        let layer = &mut self.layers[li];
488        let tail = match which {
489            Qkv::Q => &mut layer.q_tail,
490            Qkv::K => &mut layer.k_tail,
491            Qkv::V => &mut layer.v_tail,
492        };
493        debug_assert_eq!(raw.len(), tail.len());
494        tail.copy_from_slice(raw);
495    }
496
497    /// Seed one site's Engram value tail, oldest first, pre-convolution.
498    pub fn seed_engram_tail(&mut self, site: usize, raw: &[f32]) {
499        let tail = &mut self.engram_tail[site];
500        debug_assert_eq!(raw.len(), tail.len());
501        tail.copy_from_slice(raw);
502    }
503
504    /// Seed the token history the n-gram hash reads, oldest first.
505    pub fn seed_tokens(&mut self, tokens: &[u32]) {
506        let keep = self.cfg.engram.orders.iter().copied().max().unwrap_or(1);
507        self.tokens.clear();
508        let start = tokens.len().saturating_sub(keep);
509        self.tokens.extend_from_slice(&tokens[start..]);
510    }
511
512    /// Set the next write position after a batched prefill.
513    pub fn set_pos(&mut self, pos: usize) {
514        self.pos = pos;
515    }
516
517    /// Advance to the next position. Call once per decoded token, after every
518    /// layer has written its key and value.
519    pub fn advance(&mut self) {
520        self.pos += 1;
521    }
522
523    /// Current write position.
524    pub fn pos(&self) -> usize {
525        self.pos
526    }
527}
528
529/// Which projection a conv step is rolling.
530#[derive(Debug, Clone, Copy, PartialEq, Eq)]
531pub enum Qkv {
532    Q,
533    K,
534    V,
535}
536
537#[cfg(test)]
538mod tests {
539    use super::*;
540    use crate::v3::config::V3Engram;
541
542    fn cfg() -> V3Config {
543        V3Config {
544            vocab_size: 8192,
545            out_vocab: 8192,
546            d_model: 8,
547            num_heads: 2,
548            num_kv_heads: 1,
549            num_layers: 4,
550            qk_head_dim: 4,
551            v_head_dim: 4,
552            max_seq_len: 64,
553            hada_n: 8,
554            mhc_lanes: 2,
555            rope_theta: 10000.0,
556            sliding_window: 4,
557            global_layers: vec![3],
558            qkv_conv_taps: 3,
559            engram: V3Engram {
560                orders: vec![2, 3],
561                heads: 1,
562                slots: 16,
563                sub_dim: 4,
564                sites: vec![1],
565                conv_taps: 4,
566                conv_dilation: 3,
567                seed_heads: 0,
568            },
569        }
570    }
571
572    /// An int8 ring must wrap and grow exactly as the f32 one does.
573    ///
574    /// The int8 layout has a second array — one scale per `(slot, kv_head)` —
575    /// indexed off `slots`, which both `ensure` and the wrap arithmetic change.
576    /// A scale written for one slot and read back for another would not fail
577    /// loudly; it would rescale a head by a neighbour's factor.
578    #[test]
579    fn an_int8_ring_wraps_and_grows_like_the_f32_one() {
580        let c = cfg();
581        let mut cache = V3Cache::with_precision(&c, 2, KvPrecision::Int8);
582        let heads = c.num_kv_heads;
583
584        // Distinct magnitudes per position, so every slot gets its own scale
585        // and a misindexed one is visible.
586        let write = |cache: &mut V3Cache, pos: usize| {
587            let m = (pos + 1) as f32;
588            let k: Vec<f32> = (0..c.k_dim()).map(|i| m * (i as f32 + 1.0)).collect();
589            let v: Vec<f32> = (0..c.v_dim()).map(|i| -m * (i as f32 + 1.0)).collect();
590            cache.write_kv(0, pos, &k, &v);
591        };
592
593        // Fill past the window so the ring both grows (2 -> 4) and wraps.
594        for pos in 0..10 {
595            write(&mut cache, pos);
596        }
597        assert_eq!(cache.slots(0), 4, "local layer is capped at its window");
598
599        let KvStore::Int8 {
600            k,
601            k_scale,
602            v_scale,
603            ..
604        } = cache.kv(0)
605        else {
606            panic!("an int8 cache must hand out an int8 store");
607        };
608        assert_eq!(k.len(), 4 * c.k_dim());
609        assert_eq!(k_scale.len(), 4 * heads);
610        assert_eq!(v_scale.len(), 4 * heads);
611
612        // Position 9 landed in slot 9 % 4 == 1, and its scale is the largest
613        // magnitude written there. Reading it back through the stored integers
614        // must reproduce the quantised input rather than a neighbour's.
615        let slot = 9 % 4;
616        let want = {
617            let m = 10.0f32;
618            let mut x: Vec<f32> = (0..c.k_dim()).map(|i| m * (i as f32 + 1.0)).collect();
619            for ch in x.chunks_mut(c.qk_head_dim) {
620                fake_quant_vec(ch, 8);
621            }
622            x
623        };
624        for h in 0..heads {
625            let sc = k_scale[slot * heads + h];
626            for i in 0..c.qk_head_dim {
627                let idx = slot * c.k_dim() + h * c.qk_head_dim + i;
628                let got = k[idx] as f32 * sc;
629                let expect = want[h * c.qk_head_dim + i];
630                assert!(
631                    (got - expect).abs() <= 1e-4 * expect.abs().max(1.0),
632                    "slot {slot} head {h} element {i}: {got} against {expect}"
633                );
634            }
635        }
636    }
637
638    #[test]
639    fn layers_grow_to_their_cap_and_no_further() {
640        let c = cfg();
641        let mut cache = V3Cache::new(&c, 2);
642        assert_eq!(
643            cache.slots(0),
644            2,
645            "local layer starts at the hint, not its window"
646        );
647        assert_eq!(cache.slots(3), 2, "global layer starts at the hint");
648
649        let k = vec![1.0f32; c.k_dim()];
650        let v = vec![1.0f32; c.v_dim()];
651
652        // Layer 0 is local with a window of 4: it grows to 4 and then wraps.
653        cache.write_kv(0, 3, &k, &v);
654        assert_eq!(cache.slots(0), 4);
655        cache.write_kv(0, 99, &k, &v);
656        assert_eq!(
657            cache.slots(0),
658            4,
659            "a local layer must never exceed its window"
660        );
661
662        // Layer 3 is global: it keeps growing.
663        cache.write_kv(3, 9, &k, &v);
664        assert!(cache.slots(3) >= 10, "global layer should have grown");
665    }
666
667    /// The geometry the shipped needle3.cact declares, so the byte counts
668    /// below are the ones a real session pays.
669    fn shipped() -> V3Config {
670        V3Config {
671            vocab_size: 8192,
672            out_vocab: 8192,
673            d_model: 768,
674            num_heads: 12,
675            num_kv_heads: 2,
676            num_layers: 20,
677            qk_head_dim: 48,
678            v_head_dim: 64,
679            max_seq_len: 8192,
680            hada_n: 1024,
681            mhc_lanes: 4,
682            rope_theta: 100_000.0,
683            sliding_window: 1024,
684            global_layers: vec![4, 9, 14, 19],
685            qkv_conv_taps: 3,
686            engram: V3Engram {
687                orders: vec![2, 3],
688                heads: 3,
689                slots: 18432,
690                sub_dim: 128,
691                sites: vec![3, 7, 11, 15, 19],
692                conv_taps: 4,
693                conv_dilation: 3,
694                seed_heads: 0,
695            },
696        }
697    }
698
699    #[test]
700    fn a_global_layer_never_asks_for_more_than_it_stores() {
701        // Past max_seq_len the ring wraps, and an unclamped span would make a
702        // position read itself as its own past — silently in release, where
703        // the bounds check is compiled out.
704        let mut c = shipped();
705        c.max_seq_len = 8;
706        c.sliding_window = 4;
707        c.num_layers = 2;
708        c.global_layers = vec![1];
709        let mut cache = V3Cache::new(&c, 8);
710        let k = vec![0.0f32; c.k_dim()];
711        let v = vec![0.0f32; c.v_dim()];
712
713        for pos in 0..12 {
714            cache.write_kv(1, pos, &k, &v);
715            let (lo, hi) = cache.span(1, pos);
716            let slots = cache.slots(1);
717            assert!(
718                hi - lo < slots,
719                "span {lo}..={hi} exceeds {slots} slots at position {pos}"
720            );
721        }
722        // And the caller can see that history was lost.
723        for _ in 0..12 {
724            cache.advance();
725        }
726        assert!(
727            cache.is_saturated(),
728            "running past the context must be visible"
729        );
730    }
731
732    #[test]
733    fn a_short_session_pays_for_what_it_uses() {
734        let c = shipped();
735        let k = vec![0.0f32; c.k_dim()];
736        let v = vec![0.0f32; c.v_dim()];
737
738        let mut short = V3Cache::new(&c, 512);
739        for li in 0..c.num_layers {
740            short.write_kv(li, 511, &k, &v);
741        }
742        let short_mb = short.bytes() as f64 / 1024.0 / 1024.0;
743
744        // Reserving every window and the full context instead, which is what
745        // a fixed allocation would cost.
746        let reserved = (16 * 1024 + 4 * 8192) * (c.k_dim() + c.v_dim()) * 4;
747        let reserved_mb = reserved as f64 / 1024.0 / 1024.0;
748
749        assert!(
750            short_mb < reserved_mb / 4.0,
751            "a 512-token session should cost far less than reserving the \
752             windows: {short_mb:.1} MB vs {reserved_mb:.1} MB"
753        );
754        // 8.75 MB at f32: 20 layers x 512 slots x (96 + 128) x 4 bytes.
755        //
756        // The container declares kv_bits = 8, so upstream post-trained this
757        // model for an int8 KV cache, which would be 2.2 MB. Storing f32 is
758        // deliberate for now: it is what keeps decode bit-identical to the
759        // verified prefill, and the reference's own decode path runs
760        // unquantised. Quantising the cache is a real 4x saving on constrained
761        // targets but needs its own parity story against the reference's
762        // quant=True path, so it is a separate piece of work rather than a
763        // silent numerics change.
764        assert!(
765            short_mb < 9.5,
766            "expected under 9.5 MB at f32, got {short_mb:.1} MB"
767        );
768        assert!(
769            short_mb > 8.0,
770            "unexpectedly small — has the KV representation changed? {short_mb:.1} MB"
771        );
772    }
773
774    #[test]
775    fn a_long_session_caps_the_local_layers() {
776        let c = shipped();
777        let k = vec![0.0f32; c.k_dim()];
778        let v = vec![0.0f32; c.v_dim()];
779        let mut cache = V3Cache::new(&c, 64);
780        for li in 0..c.num_layers {
781            cache.write_kv(li, 4095, &k, &v);
782        }
783        for li in 0..c.num_layers {
784            if c.is_global(li) {
785                assert!(
786                    cache.slots(li) >= 4096,
787                    "global layer {li} must hold all of it"
788                );
789            } else {
790                assert_eq!(cache.slots(li), 1024, "local layer {li} caps at its window");
791            }
792        }
793    }
794
795    #[test]
796    fn spans_bound_local_layers_only() {
797        let c = cfg();
798        let mut cache = V3Cache::new(&c, 8);
799        let k = vec![0.0f32; c.k_dim()];
800        let v = vec![0.0f32; c.v_dim()];
801        // Written before queried, as the decode path does — a global layer's
802        // ring grows on write, and the span is clamped to what it holds.
803        for pos in 0..=10 {
804            cache.write_kv(0, pos, &k, &v);
805            cache.write_kv(3, pos, &k, &v);
806        }
807        // Layer 0 is local with a window of 4.
808        assert_eq!(cache.span(0, 2), (0, 2));
809        assert_eq!(cache.span(0, 10), (7, 10));
810        // Layer 3 is global: it kept everything, so it sees everything.
811        assert_eq!(cache.span(3, 10), (0, 10));
812    }
813
814    #[test]
815    fn conv_step_matches_the_sequence_conv() {
816        use crate::v3::attention::causal_depthwise_conv;
817        let c = cfg();
818        let dim = c.q_dim();
819        let taps: Vec<f32> = (0..3 * dim).map(|i| (i as f32 * 0.37).sin()).collect();
820
821        // Reference: the whole sequence at once, already parity-verified.
822        let seq = 6;
823        let raw: Vec<f32> = (0..seq * dim).map(|i| (i as f32 * 0.11).cos()).collect();
824        let mut want = raw.clone();
825        causal_depthwise_conv(&mut want, &taps, seq, dim, 3);
826
827        // Step form, one position at a time.
828        let mut cache = V3Cache::new(&c, seq);
829        for t in 0..seq {
830            let mut cur = raw[t * dim..(t + 1) * dim].to_vec();
831            cache.conv_step(0, Qkv::Q, &taps, &mut cur);
832            for (i, &g) in cur.iter().enumerate() {
833                let e = want[t * dim + i];
834                assert!(
835                    (g - e).abs() < 1e-5,
836                    "position {t} dim {i}: step {g} vs sequence {e}"
837                );
838            }
839            cache.advance();
840        }
841    }
842
843    #[test]
844    fn engram_conv_step_matches_the_sequence_conv() {
845        use crate::v3::engram::{value_conv, EngramDims};
846        let c = cfg();
847        let d = c.d_model;
848        let dims = EngramDims {
849            num_tables: 2,
850            slots: 16,
851            sub_dim: 4,
852            d_model: d,
853            conv_taps: 4,
854            conv_dilation: 3,
855            max_order: 3,
856        };
857        let taps: Vec<f32> = (0..4 * d).map(|i| (i as f32 * 0.23).cos()).collect();
858        let seq = 12;
859        let raw: Vec<f32> = (0..seq * d).map(|i| (i as f32 * 0.19).sin()).collect();
860        let mut want = raw.clone();
861        value_conv(&mut want, &taps, seq, &dims);
862
863        let mut cache = V3Cache::new(&c, seq);
864        for t in 0..seq {
865            let mut cur = raw[t * d..(t + 1) * d].to_vec();
866            cache.engram_conv_step(0, &taps, &mut cur);
867            for (i, &g) in cur.iter().enumerate() {
868                let e = want[t * d + i];
869                assert!(
870                    (g - e).abs() < 1e-5,
871                    "position {t} dim {i}: step {g} vs sequence {e}"
872                );
873            }
874            cache.advance();
875        }
876    }
877}