Skip to main content

needle_core/v2/
model.rs

1//! The v2 forward pass.
2//!
3//! Port of `needle/model/decode.py::_forward_cached` and the helpers it calls
4//! (`_layer`, `_mhc`, `_block_cached`, `_attn_cached`, `_engram_kv`).
5//!
6//! One token is processed per call, which covers prefill and decode alike: with
7//! the KV cache and the Engram history ring in place, a step at position `p`
8//! depends only on state. That removes the reference's batch and sequence axes
9//! and, with them, the masking that existed to neutralise padding.
10//!
11//! # Deriving the Engram window
12//!
13//! `_engram_kv` builds a window `padded[pos .. pos + W + S]` where
14//! `W = conv_taps * conv_dilation` and `padded` is the token history with `W`
15//! zeros prepended, then applies `_shift_right` along it. Reading that at the
16//! kept output position collapses to plain history lookups:
17//!
18//! * the hash for an order-`o` table reads tokens `p, p-1, ..., p-(o-1)`
19//! * `ngram_ok` zeroes the fetched entry when `p < o - 1`
20//! * the value convolution reads `v(p - j * dilation)` for each tap `j`,
21//!   contributing nothing when `j * dilation > p`
22//!
23//! so the only state needed is the token history plus the un-convolved value
24//! vectors for the last `W` positions.
25
26use crate::cq::CqWeight;
27use crate::hadamard::hada_n;
28use crate::math;
29use crate::norm::zc_rms_norm_vec;
30use crate::ops::{sigmoid, softmax_inplace};
31use crate::rope::RopeCache;
32use crate::v2::config::V2Config;
33use crate::v2::kernels::{
34    engram_index, engram_table_order, hadamard_mlp, rms_unit, rms_unit_to, sinkhorn,
35};
36use alloc::vec;
37use alloc::vec::Vec;
38
39/// One transformer layer's weights.
40pub struct V2Layer {
41    /// ZCRMSNorm scale before attention (`ZCRMSNorm_0`).
42    pub norm_in: Vec<f32>,
43    pub q_proj: CqWeight,
44    pub k_proj: CqWeight,
45    pub v_proj: CqWeight,
46    /// Produces the sigmoid gate applied to the attention output.
47    pub gate_proj: CqWeight,
48    pub out_proj: CqWeight,
49    /// Per-head ZCRMSNorm scales, `head_dim` long.
50    pub q_norm: Vec<f32>,
51    pub k_norm: Vec<f32>,
52    /// ZCRMSNorm scale after attention (`post_attn_norm`).
53    pub post_norm: Vec<f32>,
54    /// Pre-sigmoid scalar gating the attention residual.
55    pub attn_gate: f32,
56    /// ZCRMSNorm scale before HadamardMLP.
57    pub pre_hada: Vec<f32>,
58    /// HadamardMLP diagonals, `hada_n` long each.
59    pub d1: Vec<f32>,
60    pub d2: Vec<f32>,
61    pub d3: Vec<f32>,
62}
63
64/// mHC router weights. Stored as one tall matrix per kind with a per-layer band.
65pub struct V2Mhc {
66    /// `[num_layers]` each.
67    pub a_pre: Vec<f32>,
68    pub a_post: Vec<f32>,
69    pub a_res: Vec<f32>,
70    /// `[num_layers * lanes]`.
71    pub b_pre: Vec<f32>,
72    pub b_post: Vec<f32>,
73    /// `[num_layers * lanes * lanes]`, row-major in `(i, j)`.
74    pub b_res: Vec<f32>,
75    /// `[num_layers * lanes, mhc_width]` — row `layer * lanes + lane`.
76    pub phi_pre: CqWeight,
77    pub phi_post: CqWeight,
78    /// `[num_layers * lanes^2, mhc_width]` — row `layer * lanes^2 + i * lanes + j`.
79    pub phi_res: CqWeight,
80}
81
82/// One Engram site's weights.
83pub struct V2Engram {
84    /// `[num_tables * slots, sub_dim]` — row `table * slots + index`.
85    pub tables: CqWeight,
86    /// `[d_model, fetched_dim]`.
87    pub key_proj: CqWeight,
88    /// `[d_model, fetched_dim]`.
89    pub value_proj: CqWeight,
90    /// `[conv_taps, d_model]`.
91    pub taps: Vec<f32>,
92}
93
94/// A loaded v2 model. Immutable during inference; per-sequence state lives in
95/// [`V2State`].
96pub struct V2Model {
97    pub cfg: V2Config,
98    /// Tied embedding, `[vocab_size, d_model]` — also the LM head, transposed.
99    pub embedding: CqWeight,
100    pub layers: Vec<V2Layer>,
101    pub mhc: V2Mhc,
102    pub engrams: Vec<V2Engram>,
103    pub final_norm: Vec<f32>,
104    rope: RopeCache,
105    embed_scale: f32,
106}
107
108/// Per-sequence mutable state: KV cache, token history, Engram ring, and every
109/// scratch buffer a step needs. Allocated once so a decode step does no heap work.
110pub struct V2State {
111    /// Per layer, `[kv_heads * cache_len * head_dim]`.
112    ///
113    /// `cache_len` is the attention window, not `max_seq_len`: with a sliding
114    /// window the model never reads further back than `kv_window`, so the cache
115    /// is a ring of that width indexed by `pos % cache_len`. At the shipped
116    /// geometry that is 14 MB instead of 113 MB.
117    ///
118    /// Writing slot `pos % cache_len` overwrites position `pos - cache_len`,
119    /// which is exactly the one that just fell out of the window — so the
120    /// overwrite is only safe while `kv_window <= cache_len`, which
121    /// [`V2State::set_kv_window`] enforces.
122    k_cache: Vec<Vec<f32>>,
123    v_cache: Vec<Vec<f32>>,
124    history: Vec<u32>,
125    pos: usize,
126    kv_heads: usize,
127    head_dim: usize,
128    /// Positions the KV ring holds.
129    cache_len: usize,
130    /// Attention window in force. Starts at the model's `kv_window`; the probe
131    /// heads run at 0 (full causal) because that is what `encode_contrastive`
132    /// defaults to upstream.
133    kv_window: usize,
134
135    /// Un-convolved Engram values, `[sites * ring * d_model]`, indexed by
136    /// `position % ring`.
137    engram_v: Vec<f32>,
138    ring: usize,
139
140    // ---- scratch
141    lanes: Vec<f32>,      // [lanes * d_model]  the mHC lane stream
142    lanes_prev: Vec<f32>, // [lanes * d_model]  lane stream before the update
143    nx: Vec<f32>,         // [lanes * d_model]  rms_unit of the lane stream
144    nx_prep: Vec<f32>,
145    u: Vec<f32>,  // [d_model]  lane-mixed block input
146    bx: Vec<f32>, // [d_model]  u plus the Engram contribution
147    y: Vec<f32>,  // [d_model]  block output, then block output minus u
148    h: Vec<f32>,  // [d_model]  normed sub-block input
149    h_prep: Vec<f32>,
150    hpre: Vec<f32>,     // [lanes]
151    hpost: Vec<f32>,    // [lanes]
152    hres: Vec<f32>,     // [lanes * lanes]
153    q: Vec<f32>,        // [attn_dim]
154    k: Vec<f32>,        // [kv_dim]
155    v: Vec<f32>,        // [kv_dim]
156    gate: Vec<f32>,     // [attn_dim]
157    attn_out: Vec<f32>, // [attn_dim]
158    attn_prep: Vec<f32>,
159    attn_result: Vec<f32>, // [d_model]  out_proj output
160    scores: Vec<f32>,      // [max_seq_len]
161    hada: Vec<f32>,        // [hada_n]
162    mlp_out: Vec<f32>,     // [d_model]
163    e_buf: Vec<f32>,       // [fetched_dim]
164    e_prep: Vec<f32>,
165    engram_k: Vec<f32>,   // [sites * d_model]
166    engram_val: Vec<f32>, // [sites * d_model]  after the tap convolution
167    /// Last Engram gate value per site, kept for tracing.
168    alpha: Vec<f32>, // [sites]
169    lm: Vec<f32>,         // [d_model]  pooled, normed hidden state
170    lm_prep: Vec<f32>,
171    tmp_a: Vec<f32>, // [d_model]
172    tmp_b: Vec<f32>, // [d_model]
173}
174
175impl V2Model {
176    pub fn new(
177        cfg: V2Config,
178        embedding: CqWeight,
179        layers: Vec<V2Layer>,
180        mhc: V2Mhc,
181        engrams: Vec<V2Engram>,
182        final_norm: Vec<f32>,
183    ) -> Result<Self, &'static str> {
184        cfg.validate()?;
185        if layers.len() != cfg.num_layers {
186            return Err("layer count does not match num_layers");
187        }
188        if engrams.len() != cfg.engram.sites.len() {
189            return Err("engram count does not match site count");
190        }
191        if final_norm.len() != cfg.d_model {
192            return Err("final_norm width does not match d_model");
193        }
194        if hada_n(cfg.d_model) != cfg.hada_n {
195            return Err("hada_n is not d_model rounded up to a power of two");
196        }
197        if embedding.out_feat != cfg.vocab_size || embedding.in_feat != cfg.d_model {
198            return Err("embedding shape does not match geometry");
199        }
200        let n = cfg.mhc_lanes;
201        let l = cfg.num_layers;
202        if mhc.a_pre.len() != l || mhc.a_post.len() != l || mhc.a_res.len() != l {
203            return Err("mhc a_* length does not match num_layers");
204        }
205        if mhc.b_pre.len() != l * n || mhc.b_post.len() != l * n || mhc.b_res.len() != l * n * n {
206            return Err("mhc b_* length does not match num_layers x lanes");
207        }
208        if mhc.phi_pre.out_feat != l * n
209            || mhc.phi_post.out_feat != l * n
210            || mhc.phi_res.out_feat != l * n * n
211        {
212            return Err("mhc phi_* row count does not match num_layers x lanes");
213        }
214        if mhc.phi_pre.in_feat != cfg.mhc_width() {
215            return Err("mhc phi_* width does not match lanes x d_model");
216        }
217        for layer in &layers {
218            if layer.d1.len() != cfg.hada_n
219                || layer.d2.len() != cfg.hada_n
220                || layer.d3.len() != cfg.hada_n
221            {
222                return Err("HadamardMLP diagonal width does not match hada_n");
223            }
224            if layer.q_norm.len() != cfg.head_dim || layer.k_norm.len() != cfg.head_dim {
225                return Err("q_norm/k_norm width does not match head_dim");
226            }
227        }
228        for e in &engrams {
229            if e.taps.len() != cfg.engram.conv_taps * cfg.d_model {
230                return Err("engram taps shape does not match conv_taps x d_model");
231            }
232            if e.tables.in_feat != cfg.engram.sub_dim {
233                return Err("engram table width does not match sub_dim");
234            }
235        }
236
237        let rope = RopeCache::new(cfg.max_seq_len, cfg.head_dim, cfg.rope_theta);
238        let embed_scale = math::sqrt(cfg.d_model as f32);
239        Ok(Self {
240            cfg,
241            embedding,
242            layers,
243            mhc,
244            engrams,
245            final_norm,
246            rope,
247            embed_scale,
248        })
249    }
250
251    /// Where this port follows the decode reference rather than the training graph.
252    ///
253    /// `architecture.Block` gates the Engram contribution on the lane-stacked `x`
254    /// (`einsum "btd,sbtd->sbt"`, scaled by `site_flags`).
255    /// `decode._forward_cached` gates it on `u`, the lane-mixed vector the block
256    /// actually consumes, with an explicit per-layer branch. This port follows
257    /// decode.py: it is the incremental path, and the one the parity fixtures come
258    /// from. Do not "fix" this toward architecture.py.
259    pub const ENGRAM_GATE_SOURCE: &'static str = "decode.py (_forward_cached), gate on u";
260
261    /// Allocate per-sequence state sized for the model's own attention window.
262    ///
263    /// This is what generation wants. The KV cache is a ring of `kv_window`
264    /// positions rather than `max_seq_len`, which is the difference between
265    /// 14 MB and 113 MB at the shipped geometry.
266    ///
267    /// Use [`make_state_full_causal`] for the probe heads, which attend over the
268    /// whole sequence and therefore need the whole cache.
269    ///
270    /// [`make_state_full_causal`]: V2Model::make_state_full_causal
271    pub fn make_state(&self) -> V2State {
272        let cache_len = if self.cfg.kv_window == 0 {
273            self.cfg.max_seq_len
274        } else {
275            self.cfg.kv_window.min(self.cfg.max_seq_len)
276        };
277        self.make_state_with_cache(cache_len, self.cfg.kv_window)
278    }
279
280    /// Allocate per-sequence state for full causal attention.
281    ///
282    /// Sizes the cache to `max_seq_len` and sets the window to 0. Needed by
283    /// `architecture.encode_contrastive` and `forward_confidence`, which default
284    /// to full causal even though the LM path is windowed.
285    pub fn make_state_full_causal(&self) -> V2State {
286        self.make_state_with_cache(self.cfg.max_seq_len, 0)
287    }
288
289    fn make_state_with_cache(&self, cache_len: usize, kv_window: usize) -> V2State {
290        let cfg = &self.cfg;
291        let (d, n) = (cfg.d_model, cfg.mhc_lanes);
292        let sites = cfg.engram.sites.len();
293        let ring = (cfg.engram.window() + 1).max(1);
294        let fetched = cfg.engram.fetched_dim().max(1);
295
296        let engram_prep = self
297            .engrams
298            .first()
299            .map_or(fetched, |e| e.value_proj.prepared_len());
300
301        V2State {
302            k_cache: (0..cfg.num_layers)
303                .map(|_| vec![0.0f32; cfg.num_kv_heads * cache_len * cfg.head_dim])
304                .collect(),
305            v_cache: (0..cfg.num_layers)
306                .map(|_| vec![0.0f32; cfg.num_kv_heads * cache_len * cfg.head_dim])
307                .collect(),
308            history: vec![0u32; cfg.max_seq_len],
309            pos: 0,
310            kv_heads: cfg.num_kv_heads,
311            head_dim: cfg.head_dim,
312            cache_len,
313            kv_window,
314            engram_v: vec![0.0; sites * ring * d],
315            ring,
316            lanes: vec![0.0; n * d],
317            lanes_prev: vec![0.0; n * d],
318            nx: vec![0.0; n * d],
319            nx_prep: vec![0.0; self.mhc.phi_pre.prepared_len()],
320            u: vec![0.0; d],
321            bx: vec![0.0; d],
322            y: vec![0.0; d],
323            h: vec![0.0; d],
324            h_prep: vec![0.0; self.layers[0].q_proj.prepared_len()],
325            hpre: vec![0.0; n],
326            hpost: vec![0.0; n],
327            hres: vec![0.0; n * n],
328            q: vec![0.0; cfg.attn_dim()],
329            k: vec![0.0; cfg.kv_dim()],
330            v: vec![0.0; cfg.kv_dim()],
331            gate: vec![0.0; cfg.attn_dim()],
332            attn_out: vec![0.0; cfg.attn_dim()],
333            attn_prep: vec![0.0; self.layers[0].out_proj.prepared_len()],
334            attn_result: vec![0.0; d],
335            scores: vec![0.0; cfg.max_seq_len],
336            hada: vec![0.0; cfg.hada_n],
337            mlp_out: vec![0.0; d],
338            e_buf: vec![0.0; fetched],
339            e_prep: vec![0.0; engram_prep],
340            engram_k: vec![0.0; sites * d],
341            engram_val: vec![0.0; sites * d],
342            alpha: vec![0.0; sites.max(1)],
343            lm: vec![0.0; d],
344            lm_prep: vec![0.0; self.embedding.prepared_len()],
345            tmp_a: vec![0.0; d],
346            tmp_b: vec![0.0; d],
347        }
348    }
349
350    /// Process one token at the next position and write logits over the vocabulary.
351    ///
352    /// Feed a prompt by calling this once per token in order; the logits from the
353    /// last call are the ones to sample from.
354    pub fn step(
355        &self,
356        token: u32,
357        state: &mut V2State,
358        logits: &mut [f32],
359    ) -> Result<(), &'static str> {
360        self.forward(token, state, Some(logits), &mut |_, _, _| {})
361    }
362
363    /// Process one token without evaluating the LM head.
364    ///
365    /// Prefill needs logits only at the final prompt position, and the tied LM
366    /// head is the single widest matmul in a step (`vocab_size x d_model` — 8192
367    /// rows against the 512 of any projection). Skipping it on the preceding
368    /// tokens removes that cost from every one of them.
369    ///
370    /// [`last_hidden`] is still valid afterwards.
371    ///
372    /// [`last_hidden`]: V2Model::last_hidden
373    pub fn step_prefill(&self, token: u32, state: &mut V2State) -> Result<(), &'static str> {
374        self.forward(token, state, None, &mut |_, _, _| {})
375    }
376
377    /// [`step`], with a callback at each of the capture points
378    /// `tools/gen_v2_forward_parity.py` records — the ladder used to locate a
379    /// divergence instead of only detecting one.
380    ///
381    /// `trace(name, layer, values)`; `layer` is `usize::MAX` for whole-forward
382    /// points. Generic rather than a trait object, so the no-op closure [`step`]
383    /// passes inlines away entirely.
384    ///
385    /// [`step`]: V2Model::step
386    pub fn step_traced<F: FnMut(&str, usize, &[f32])>(
387        &self,
388        token: u32,
389        state: &mut V2State,
390        logits: &mut [f32],
391        trace: &mut F,
392    ) -> Result<(), &'static str> {
393        self.forward(token, state, Some(logits), trace)
394    }
395
396    fn forward<F: FnMut(&str, usize, &[f32])>(
397        &self,
398        token: u32,
399        state: &mut V2State,
400        logits: Option<&mut [f32]>,
401        trace: &mut F,
402    ) -> Result<(), &'static str> {
403        const WHOLE: usize = usize::MAX;
404        let cfg = &self.cfg;
405        if state.pos >= cfg.max_seq_len {
406            return Err("sequence exceeds max_seq_len");
407        }
408        if token as usize >= cfg.vocab_size {
409            return Err("token id outside vocabulary");
410        }
411        if logits.as_ref().is_some_and(|l| l.len() != cfg.vocab_size) {
412            return Err("logits buffer is not vocab_size long");
413        }
414
415        let pos = state.pos;
416        let (d, n) = (cfg.d_model, cfg.mhc_lanes);
417        state.history[pos] = token;
418
419        // Lane stream starts as the scaled embedding, broadcast across lanes.
420        self.embedding
421            .dequantize_row(token as usize, &mut state.tmp_a);
422        for v in state.tmp_a.iter_mut() {
423            *v *= self.embed_scale;
424        }
425        trace("embed", WHOLE, &state.tmp_a);
426        for lane in 0..n {
427            state.lanes[lane * d..(lane + 1) * d].copy_from_slice(&state.tmp_a);
428        }
429
430        // `_engram_kv` runs once per forward, before the layer loop.
431        self.engram_fill(state, pos);
432        if !cfg.engram.sites.is_empty() {
433            trace("engram_k0", WHOLE, &state.engram_k[..d]);
434            trace("engram_v0", WHOLE, &state.engram_val[..d]);
435        }
436
437        for li in 0..cfg.num_layers {
438            self.mhc_pre(state, li);
439            trace("hpre", li, &state.hpre);
440            trace("u", li, &state.u);
441            self.engram_gate(state, li);
442            trace("bx", li, &state.bx);
443            if let Some(site) = cfg.engram.site_of_layer(li) {
444                trace("alpha", li, &state.alpha[site..site + 1]);
445            }
446            self.block(state, li, pos);
447            trace("y", li, &state.y);
448            self.mhc_post(state, li);
449            trace("hpost", li, &state.hpost);
450            trace("hres", li, &state.hres);
451            trace("x", li, &state.lanes);
452        }
453
454        // Mean over lanes, final norm, tied LM head.
455        for c in 0..d {
456            let mut acc = 0.0f32;
457            for lane in 0..n {
458                acc += state.lanes[lane * d + c];
459            }
460            state.lm[c] = acc / n as f32;
461        }
462        trace("lane_mean", WHOLE, &state.lm);
463        zc_rms_norm_vec(&mut state.lm, &self.final_norm);
464        trace("final_norm", WHOLE, &state.lm);
465        if let Some(logits) = logits {
466            self.embedding.prepare_input(&state.lm, &mut state.lm_prep);
467            self.embedding.matvec_prepared(&state.lm_prep, logits);
468            trace("logits", WHOLE, logits);
469        }
470
471        state.pos += 1;
472        Ok(())
473    }
474
475    /// The hidden state the probe heads pool over, for the position just stepped.
476    ///
477    /// Valid only immediately after a successful [`step`]; it is the post-final-norm
478    /// vector the LM head consumed.
479    ///
480    /// [`step`]: V2Model::step
481    pub fn last_hidden(state: &V2State) -> &[f32] {
482        &state.lm
483    }
484
485    /// Apply the shared RoPE cache. Exposed to the batched path so both use one
486    /// table.
487    pub(crate) fn rope_apply(
488        &self,
489        x: &mut [f32],
490        seq_len: usize,
491        num_heads: usize,
492        head_dim: usize,
493        offset: usize,
494    ) {
495        self.rope.apply(x, seq_len, num_heads, head_dim, offset);
496    }
497
498    /// mHC pre-routing: `hpre` and the lane mix that yields `u`.
499    fn mhc_pre(&self, state: &mut V2State, li: usize) {
500        let cfg = &self.cfg;
501        let (d, n) = (cfg.d_model, cfg.mhc_lanes);
502
503        rms_unit_to(&state.lanes, &mut state.nx);
504        // phi_pre, phi_post and phi_res all read nx with identical geometry, so
505        // the Hadamard rotation is paid once here and reused by mhc_post.
506        self.mhc
507            .phi_pre
508            .prepare_input(&state.nx, &mut state.nx_prep);
509
510        self.mhc
511            .phi_pre
512            .matvec_rows_prepared(&state.nx_prep, li * n, &mut state.hpre);
513        let a = self.mhc.a_pre[li];
514        for lane in 0..n {
515            let z = a * state.hpre[lane] + self.mhc.b_pre[li * n + lane] + cfg.pre_off(li, lane);
516            state.hpre[lane] = sigmoid(z);
517        }
518
519        for c in 0..d {
520            let mut acc = 0.0f32;
521            for lane in 0..n {
522                acc += state.hpre[lane] * state.lanes[lane * d + c];
523            }
524            state.u[c] = acc;
525        }
526        state.bx.copy_from_slice(&state.u);
527    }
528
529    /// `bx = u + sigmoid(dot(rms_unit(u), rms_unit(ek)) / sqrt(d_model)) * ev`.
530    fn engram_gate(&self, state: &mut V2State, li: usize) {
531        let Some(site) = self.cfg.engram.site_of_layer(li) else {
532            return;
533        };
534        let d = self.cfg.d_model;
535        let base = site * d;
536
537        rms_unit_to(&state.u, &mut state.tmp_a);
538        state.tmp_b.copy_from_slice(&state.engram_k[base..base + d]);
539        rms_unit(&mut state.tmp_b);
540        let mut dot = 0.0f32;
541        for c in 0..d {
542            dot += state.tmp_a[c] * state.tmp_b[c];
543        }
544        let alpha = sigmoid(dot / math::sqrt(d as f32));
545        state.alpha[site] = alpha;
546        for c in 0..d {
547            state.bx[c] = state.u[c] + alpha * state.engram_val[base + c];
548        }
549    }
550
551    /// `_block_cached`: gated attention residual, then HadamardMLP residual.
552    /// Leaves `block(bx) - u` in `state.y`, as the reference does.
553    fn block(&self, state: &mut V2State, li: usize, pos: usize) {
554        let layer = &self.layers[li];
555        let d = self.cfg.d_model;
556
557        state.h.copy_from_slice(&state.bx);
558        zc_rms_norm_vec(&mut state.h, &layer.norm_in);
559        self.attention(state, li, pos);
560        zc_rms_norm_vec(&mut state.attn_result, &layer.post_norm);
561        let g = sigmoid(layer.attn_gate);
562        for c in 0..d {
563            state.y[c] = state.bx[c] + g * state.attn_result[c];
564        }
565
566        state.h.copy_from_slice(&state.y);
567        zc_rms_norm_vec(&mut state.h, &layer.pre_hada);
568        hadamard_mlp(
569            &state.h,
570            &layer.d1,
571            &layer.d2,
572            &layer.d3,
573            &mut state.hada,
574            &mut state.mlp_out,
575        );
576        for c in 0..d {
577            state.y[c] += state.mlp_out[c];
578            state.y[c] -= state.u[c];
579        }
580    }
581
582    /// `_attn_cached` for a single query position. Result lands in `attn_result`.
583    fn attention(&self, state: &mut V2State, li: usize, pos: usize) {
584        let layer = &self.layers[li];
585        let cfg = &self.cfg;
586        let (h_n, kv_n, hd) = (cfg.num_heads, cfg.num_kv_heads, cfg.head_dim);
587        let reps = cfg.kv_repeat();
588
589        // All four projections read the same normed activation.
590        layer.q_proj.prepare_input(&state.h, &mut state.h_prep);
591        layer.q_proj.matvec_prepared(&state.h_prep, &mut state.q);
592        layer.k_proj.matvec_prepared(&state.h_prep, &mut state.k);
593        layer.v_proj.matvec_prepared(&state.h_prep, &mut state.v);
594        layer
595            .gate_proj
596            .matvec_prepared(&state.h_prep, &mut state.gate);
597
598        for head in 0..h_n {
599            zc_rms_norm_vec(&mut state.q[head * hd..(head + 1) * hd], &layer.q_norm);
600        }
601        for head in 0..kv_n {
602            zc_rms_norm_vec(&mut state.k[head * hd..(head + 1) * hd], &layer.k_norm);
603        }
604        self.rope.apply(&mut state.q, 1, h_n, hd, pos);
605        self.rope.apply(&mut state.k, 1, kv_n, hd, pos);
606
607        // Cache layout: [kv_head][slot][head_dim], slot = position % cache_len.
608        let k_src = core::mem::take(&mut state.k);
609        let v_src = core::mem::take(&mut state.v);
610        state.write_kv(li, pos, &k_src, &v_src);
611        state.k = k_src;
612        state.v = v_src;
613
614        // Valid keys: `l <= pos` and, when windowed, `pos - l < kv_window`.
615        // The reference also ORs a sink mask, but `_doc_prefix_len` returns 0 on
616        // every shipped path, leaving it empty.
617        let lo = state.window_lo(pos);
618        let span = pos - lo + 1;
619        let scale = 1.0 / math::sqrt(hd as f32);
620
621        // Ring geometry pulled out so the loops below can index `k_cache` and
622        // `attn_out` as separate fields: an accessor method would borrow all of
623        // `state` and collide with the accumulation.
624        let (cache_len, ring_hd) = (state.cache_len, state.head_dim);
625        let slot = |kv: usize, p: usize| (kv * cache_len + p % cache_len) * ring_hd;
626
627        for head in 0..h_n {
628            let kv = head / reps;
629
630            for s in 0..span {
631                let off = slot(kv, lo + s);
632                let krow = &state.k_cache[li][off..off + hd];
633                let qrow = &state.q[head * hd..(head + 1) * hd];
634                let mut acc = 0.0f32;
635                for i in 0..hd {
636                    acc += qrow[i] * krow[i];
637                }
638                state.scores[s] = acc * scale;
639            }
640            softmax_inplace(&mut state.scores[..span]);
641
642            let obase = head * hd;
643            state.attn_out[obase..obase + hd].fill(0.0);
644            for s in 0..span {
645                let w = state.scores[s];
646                let off = slot(kv, lo + s);
647                for i in 0..hd {
648                    state.attn_out[obase + i] += w * state.v_cache[li][off + i];
649                }
650            }
651        }
652
653        for (o, &g) in state.attn_out.iter_mut().zip(state.gate.iter()) {
654            *o *= sigmoid(g);
655        }
656        layer
657            .out_proj
658            .prepare_input(&state.attn_out, &mut state.attn_prep);
659        layer
660            .out_proj
661            .matvec_prepared(&state.attn_prep, &mut state.attn_result);
662    }
663
664    /// mHC post-routing: `hpost`, the Sinkhorn lane mix `hres`, and the update
665    /// `x[i] = sum_j hres[i][j] * x[j] + hpost[i] * y`.
666    fn mhc_post(&self, state: &mut V2State, li: usize) {
667        let cfg = &self.cfg;
668        let (d, n) = (cfg.d_model, cfg.mhc_lanes);
669
670        self.mhc
671            .phi_post
672            .matvec_rows_prepared(&state.nx_prep, li * n, &mut state.hpost);
673        let a_post = self.mhc.a_post[li];
674        for lane in 0..n {
675            let z = a_post * state.hpost[lane]
676                + self.mhc.b_post[li * n + lane]
677                + cfg.post_off(li, lane);
678            state.hpost[lane] = 2.0 * sigmoid(z);
679        }
680
681        self.mhc
682            .phi_res
683            .matvec_rows_prepared(&state.nx_prep, li * n * n, &mut state.hres);
684        let a_res = self.mhc.a_res[li];
685        for k in 0..n * n {
686            state.hres[k] = a_res * state.hres[k] + self.mhc.b_res[li * n * n + k];
687        }
688        sinkhorn(&mut state.hres, n);
689
690        // Every output lane reads every input lane, so snapshot first.
691        state.lanes_prev.copy_from_slice(&state.lanes);
692        for i in 0..n {
693            let hp = state.hpost[i];
694            for c in 0..d {
695                let mut acc = 0.0f32;
696                for j in 0..n {
697                    acc += state.hres[i * n + j] * state.lanes_prev[j * d + c];
698                }
699                state.lanes[i * d + c] = acc + hp * state.y[c];
700            }
701        }
702    }
703
704    /// `_engram_kv` for one position: fills `engram_k` and `engram_val`.
705    ///
706    /// Shared with the batched prefill path, which drives it position by position
707    /// and copies each result out — the Engram ring is keyed on absolute position,
708    /// so in-order feeding is all it requires.
709    pub(crate) fn engram_fill(&self, state: &mut V2State, pos: usize) {
710        let cfg = &self.cfg;
711        if cfg.engram.sites.is_empty() {
712            return;
713        }
714        let d = cfg.d_model;
715        let eg = &cfg.engram;
716        let heads = eg.heads();
717        let dil = eg.conv_dilation;
718        let ring = state.ring;
719
720        for site in 0..self.engrams.len() {
721            self.engram_fetch(state, pos, site, heads);
722            let weights = &self.engrams[site];
723            weights
724                .value_proj
725                .prepare_input(&state.e_buf, &mut state.e_prep);
726
727            // Un-convolved value at this position, into the ring.
728            let slot = (site * ring + pos % ring) * d;
729            weights
730                .value_proj
731                .matvec_prepared(&state.e_prep, &mut state.engram_v[slot..slot + d]);
732
733            let kbase = site * d;
734            weights
735                .key_proj
736                .matvec_prepared(&state.e_prep, &mut state.engram_k[kbase..kbase + d]);
737
738            // v_conv = sum_j taps[j] * v(pos - j*dilation), skipping taps whose
739            // source position falls before the sequence start.
740            let vbase = site * d;
741            state.engram_val[vbase..vbase + d].fill(0.0);
742            for j in 0..eg.conv_taps {
743                let shift = j * dil;
744                if shift > pos {
745                    continue;
746                }
747                let src = (site * ring + (pos - shift) % ring) * d;
748                let tap = &weights.taps[j * d..(j + 1) * d];
749                let v = &state.engram_v[src..src + d];
750                for (dst, (&t, &vc)) in state.engram_val[vbase..vbase + d]
751                    .iter_mut()
752                    .zip(tap.iter().zip(v.iter()))
753                {
754                    *dst += t * vc;
755                }
756            }
757        }
758    }
759
760    /// Build the flattened fetched-entry vector for one position and site.
761    fn engram_fetch(&self, state: &mut V2State, pos: usize, site: usize, heads: usize) {
762        let (hist, e_buf) = (&state.history, &mut state.e_buf);
763        self.engram_fetch_into(pos, site, heads, hist, e_buf);
764    }
765
766    /// As [`engram_fetch`], writing into caller-provided storage so the batched
767    /// prefill can hold one vector per position.
768    ///
769    /// [`engram_fetch`]: V2Model::engram_fetch
770    pub(crate) fn engram_fetch_into(
771        &self,
772        pos: usize,
773        site: usize,
774        heads: usize,
775        history: &[u32],
776        out: &mut [f32],
777    ) {
778        let eg = &self.cfg.engram;
779        let tables = &self.engrams[site].tables;
780        let sub = eg.sub_dim;
781
782        for table in 0..eg.num_tables {
783            let order = eg.orders[engram_table_order(table, heads)];
784            let idx = engram_index(table, order, eg.slots, |j| {
785                if j > pos {
786                    0
787                } else {
788                    history[pos - j]
789                }
790            });
791            let dst = &mut out[table * sub..(table + 1) * sub];
792            if order > 0 && order - 1 > pos {
793                // `ngram_ok`: the oldest token the hash needs is outside the
794                // window, so the entry is zeroed rather than fetched.
795                dst.fill(0.0);
796            } else {
797                tables.dequantize_row(table * eg.slots + idx, dst);
798            }
799        }
800    }
801}
802
803impl V2State {
804    /// Absolute position of the next token.
805    pub fn pos(&self) -> usize {
806        self.pos
807    }
808
809    /// Tokens fed so far, oldest first.
810    pub fn history(&self) -> &[u32] {
811        &self.history[..self.pos]
812    }
813
814    /// Lowest key position attention may read for a query at `pos`.
815    ///
816    /// `l <= pos` always; with a window, also `pos - l < kv_window`.
817    #[inline]
818    pub(crate) fn window_lo(&self, pos: usize) -> usize {
819        if self.kv_window == 0 {
820            0
821        } else {
822            pos + 1 - self.kv_window.min(pos + 1)
823        }
824    }
825
826    /// The attention window this sequence runs at.
827    pub fn kv_window(&self) -> usize {
828        self.kv_window
829    }
830
831    /// Override the attention window. `None` restores the model's own.
832    ///
833    /// Needed because the two heads upstream ships disagree: the LM path runs the
834    /// checkpoint's `kv_window` (256), while `architecture.encode_contrastive` and
835    /// `forward_confidence` default to `window=0`, full causal. Below 256 tokens
836    /// the two are bit-identical; above it they diverge — at 642 tokens the
837    /// contrastive embeddings differ by cosine 3.5e-4 and the confidence logit by
838    /// 0.096 — so this is a real fork, not a rounding detail.
839    ///
840    /// Takes effect from the next step; it does not rewrite the existing cache.
841    ///
842    /// Fails when the requested window needs more cached positions than this
843    /// state allocated — a window of 0 over a ring-sized cache would read stale
844    /// slots and quietly return wrong embeddings. Use
845    /// [`V2Model::make_state_full_causal`] for that case.
846    pub fn set_kv_window(
847        &mut self,
848        window: Option<usize>,
849        model: &V2Model,
850    ) -> Result<(), &'static str> {
851        let w = window.unwrap_or(model.cfg.kv_window);
852        let needed = if w == 0 { model.cfg.max_seq_len } else { w };
853        if needed > self.cache_len {
854            return Err(
855                "requested attention window exceeds this state's KV capacity; \
856                        allocate with make_state_full_causal",
857            );
858        }
859        self.kv_window = w;
860        Ok(())
861    }
862
863    /// Record a token at an absolute position without advancing.
864    pub(crate) fn set_history(&mut self, pos: usize, token: u32) {
865        self.history[pos] = token;
866    }
867
868    /// Advance the position counter after a batch of positions was processed.
869    pub(crate) fn advance(&mut self, n: usize) {
870        self.pos += n;
871    }
872
873    /// Byte offset of one `(kv_head, position)` slot in a layer's ring.
874    #[inline]
875    fn slot(&self, kv_head: usize, pos: usize) -> usize {
876        (kv_head * self.cache_len + pos % self.cache_len) * self.head_dim
877    }
878
879    /// Write one position's K and V into a layer's cache.
880    pub(crate) fn write_kv(&mut self, layer: usize, pos: usize, k: &[f32], v: &[f32]) {
881        let hd = self.head_dim;
882        for head in 0..self.kv_heads {
883            let off = self.slot(head, pos);
884            self.k_cache[layer][off..off + hd].copy_from_slice(&k[head * hd..(head + 1) * hd]);
885            self.v_cache[layer][off..off + hd].copy_from_slice(&v[head * hd..(head + 1) * hd]);
886        }
887    }
888
889    /// The cached K for one `(kv_head, position)`. `pos` must be inside the
890    /// window ending at the current position, which `window_lo` guarantees.
891    #[inline]
892    pub(crate) fn k_at(&self, layer: usize, kv_head: usize, pos: usize) -> &[f32] {
893        let off = self.slot(kv_head, pos);
894        &self.k_cache[layer][off..off + self.head_dim]
895    }
896
897    #[inline]
898    pub(crate) fn v_at(&self, layer: usize, kv_head: usize, pos: usize) -> &[f32] {
899        let off = self.slot(kv_head, pos);
900        &self.v_cache[layer][off..off + self.head_dim]
901    }
902
903    /// Positions the Engram value ring holds.
904    pub(crate) fn ring(&self) -> usize {
905        self.ring
906    }
907
908    pub(crate) fn history_slice(&self) -> &[u32] {
909        &self.history
910    }
911
912    pub(crate) fn engram_v_slot(&mut self, slot: usize, d: usize) -> &mut [f32] {
913        &mut self.engram_v[slot..slot + d]
914    }
915
916    pub(crate) fn engram_v_row(&self, slot: usize, d: usize) -> &[f32] {
917        &self.engram_v[slot..slot + d]
918    }
919
920    /// Positions the KV ring holds.
921    pub fn cache_len(&self) -> usize {
922        self.cache_len
923    }
924
925    /// Bytes the KV cache occupies — the dominant part of a session.
926    pub fn kv_bytes(&self) -> usize {
927        self.k_cache
928            .iter()
929            .chain(self.v_cache.iter())
930            .map(|c| c.len() * 4)
931            .sum()
932    }
933
934    /// Start a new sequence, keeping the allocations.
935    pub fn reset(&mut self) {
936        self.pos = 0;
937        for c in self.k_cache.iter_mut().chain(self.v_cache.iter_mut()) {
938            c.fill(0.0);
939        }
940        self.engram_v.fill(0.0);
941        self.history.fill(0);
942    }
943}