Skip to main content

needle_core/v3/
heads.rs

1//! Needle 3 probe heads.
2//!
3//! The published `needle3.cact` exports one: confidence. Its `heads.manifest`
4//! lists a single code, `2`.
5//!
6//! That is a property of the released weights, not of the architecture.
7//! Upstream defines three heads — embedding (code 1), confidence (2) and
8//! router (3) — and `export.py` writes whichever are present in the
9//! checkpoint (`present = [h for h in HEADS if h.key in params]`). The
10//! released Needle 3 checkpoint carries no `embedding_head`, so none is in the
11//! container and `retrieve_tools` has no v3 equivalent *from these weights* —
12//! the same situation as the published v1 checkpoint.
13//!
14//! A container that did carry code 1 would load here unchanged: the embedding
15//! head is a `ProbeHead` like the others and exports the same six tensors
16//! (only the router adds a seventh), so the canon walk already handles it.
17//! Producing the embedding would then be this head's `forward` followed by an
18//! L2 normalisation, with the output width read off the projection.
19//!
20//! A probe head reads the per-layer cells rather than the final hidden state,
21//! and pools them twice: once over positions, per (layer, probe), and once
22//! over (layer, probe), per query. Both poolings are softmaxes, so the head
23//! sees the whole sequence rather than just the last token.
24
25extern crate alloc;
26use alloc::vec;
27use alloc::vec::Vec;
28
29use crate::kernels::rms_unit;
30use crate::math::{exp, sqrt};
31
32/// Head codes in `heads.manifest`, in upstream's canonical order.
33pub const HEAD_EMBEDDING: u8 = 1;
34pub const HEAD_CONFIDENCE: u8 = 2;
35pub const HEAD_ROUTER: u8 = 3;
36
37/// One probe head's weights, already dequantised.
38///
39/// Small enough that packing buys nothing: on the shipped model this is
40/// 84x768 + 4x768 + a few vectors.
41pub struct ProbeHead {
42    /// `(cells * probes, d_model)`.
43    pub probes: Vec<f32>,
44    /// `(cells, probes)`.
45    pub gain: Vec<f32>,
46    /// `(queries, d_model)`.
47    pub query: Vec<f32>,
48    /// `(queries, cells, probes)`.
49    pub row_bias: Vec<f32>,
50    /// `(out_dim, queries * d_model)`.
51    pub proj: Vec<f32>,
52    /// `(out_dim)`.
53    pub bias: Vec<f32>,
54    pub cells: usize,
55    pub probes_per_cell: usize,
56    pub queries: usize,
57    pub out_dim: usize,
58}
59
60impl ProbeHead {
61    /// Run the head over `cells`, shaped `(seq, self.cells, d_model)`.
62    ///
63    /// Returns `out_dim` logits. Mirrors upstream `probe_pool` followed by
64    /// `HeadProjection`.
65    pub fn forward(&self, cells: &[f32], seq: usize, d: usize) -> Vec<f32> {
66        debug_assert_eq!(cells.len(), seq * self.cells * d);
67        let (l1, k, q) = (self.cells, self.probes_per_cell, self.queries);
68        let scale = 1.0 / sqrt(d as f32);
69
70        // r[l][k] — each (layer, probe) pair's softmax-weighted summary of the
71        // sequence.
72        let mut r = vec![0.0f32; l1 * k * d];
73        let mut scores = vec![0.0f32; seq];
74        for l in 0..l1 {
75            for kk in 0..k {
76                let pr = &self.probes[(l * k + kk) * d..(l * k + kk + 1) * d];
77                let mut max = f32::NEG_INFINITY;
78                for (t, sc) in scores.iter_mut().enumerate() {
79                    let cell = &cells[(t * l1 + l) * d..(t * l1 + l + 1) * d];
80                    let mut acc = 0.0f32;
81                    for (a, b) in cell.iter().zip(pr) {
82                        acc += a * b;
83                    }
84                    *sc = acc * scale;
85                    if *sc > max {
86                        max = *sc;
87                    }
88                }
89                let mut sum = 0.0f32;
90                for sc in scores.iter_mut() {
91                    *sc = exp(*sc - max);
92                    sum += *sc;
93                }
94                let inv = 1.0 / sum;
95
96                let dst = &mut r[(l * k + kk) * d..(l * k + kk + 1) * d];
97                dst.fill(0.0);
98                for (t, &sc) in scores.iter().enumerate() {
99                    let w = sc * inv;
100                    let cell = &cells[(t * l1 + l) * d..(t * l1 + l + 1) * d];
101                    for (o, &c) in dst.iter_mut().zip(cell) {
102                        *o += w * c;
103                    }
104                }
105                // Normalise, then scale by this pair's learned gain.
106                rms_unit(dst);
107                let g = self.gain[l * k + kk];
108                for o in dst.iter_mut() {
109                    *o *= g;
110                }
111            }
112        }
113
114        self.pool_and_project(&r, l1, k, q, d)
115    }
116
117    /// The second softmax — over `(layer, probe)` pairs, per query — and the
118    /// final projection. Shared by the two-pass and streaming paths so they
119    /// cannot drift apart.
120    fn pool_and_project(&self, r: &[f32], l1: usize, k: usize, q: usize, d: usize) -> Vec<f32> {
121        let scale = 1.0 / sqrt(d as f32);
122        // For each query, a softmax over all (layer, probe) pairs.
123        let m = l1 * k;
124        let mut pooled = vec![0.0f32; q * d];
125        let mut u = vec![0.0f32; m];
126        for qi in 0..q {
127            let qv = &self.query[qi * d..(qi + 1) * d];
128            let mut max = f32::NEG_INFINITY;
129            for (idx, uu) in u.iter_mut().enumerate() {
130                let rv = &r[idx * d..(idx + 1) * d];
131                let mut acc = 0.0f32;
132                for (a, b) in rv.iter().zip(qv) {
133                    acc += a * b;
134                }
135                *uu = acc * scale + self.row_bias[qi * m + idx];
136                if *uu > max {
137                    max = *uu;
138                }
139            }
140            let mut sum = 0.0f32;
141            for uu in u.iter_mut() {
142                *uu = exp(*uu - max);
143                sum += *uu;
144            }
145            let inv = 1.0 / sum;
146
147            let dst = &mut pooled[qi * d..(qi + 1) * d];
148            dst.fill(0.0);
149            for (idx, &uu) in u.iter().enumerate() {
150                let w = uu * inv;
151                let rv = &r[idx * d..(idx + 1) * d];
152                for (o, &v) in dst.iter_mut().zip(rv) {
153                    *o += w * v;
154                }
155            }
156        }
157
158        // Final projection over the flattened (queries * d_model) vector.
159        let mut out = vec![0.0f32; self.out_dim];
160        for (o, dst) in out.iter_mut().enumerate() {
161            let row = &self.proj[o * q * d..(o + 1) * q * d];
162            let mut acc = 0.0f32;
163            for (a, b) in row.iter().zip(&pooled) {
164                acc += a * b;
165            }
166            *dst = acc + self.bias.get(o).copied().unwrap_or(0.0);
167        }
168        out
169    }
170}
171
172/// Pools a probe head's first softmax **as positions arrive**, so the cells
173/// are never all held at once.
174///
175/// Materialising them costs `seq * cells * d_model` floats — 504 MB at full
176/// context on the shipped model, which is not survivable in a browser tab.
177/// This holds `cells * probes * d_model` regardless of sequence length: 252 KB.
178///
179/// The trick is the online softmax. `r = Σ_t softmax(s)_t · cell_t` is computed
180/// by carrying a running maximum, denominator and numerator, and rescaling both
181/// whenever a larger score appears — algebraically identical to the two-pass
182/// form, which [`ProbeHead::forward`] still does and the tests compare against.
183pub struct ProbePool<'a> {
184    head: &'a ProbeHead,
185    d: usize,
186    /// Running max per (cell, probe).
187    max: Vec<f32>,
188    /// Running denominator per (cell, probe).
189    den: Vec<f32>,
190    /// Running numerator, `(cells * probes, d_model)`.
191    num: Vec<f32>,
192}
193
194impl<'a> ProbePool<'a> {
195    pub fn new(head: &'a ProbeHead, d_model: usize) -> Self {
196        let m = head.cells * head.probes_per_cell;
197        Self {
198            head,
199            d: d_model,
200            max: vec![f32::NEG_INFINITY; m],
201            den: vec![0.0; m],
202            num: vec![0.0; m * d_model],
203        }
204    }
205
206    /// Feed one position's cell for one cell index (0 is the input embedding).
207    ///
208    /// Order does not matter — the pooling is a sum over positions — so a
209    /// caller may stream all positions for one cell before moving to the next,
210    /// which is how the forward pass is laid out.
211    pub fn observe(&mut self, cell_index: usize, cell: &[f32]) {
212        debug_assert_eq!(cell.len(), self.d);
213        let (k, d) = (self.head.probes_per_cell, self.d);
214        let scale = 1.0 / sqrt(d as f32);
215        for kk in 0..k {
216            let idx = cell_index * k + kk;
217            let pr = &self.head.probes[idx * d..(idx + 1) * d];
218            let mut dot = 0.0f32;
219            for (a, b) in cell.iter().zip(pr) {
220                dot += a * b;
221            }
222            let s = dot * scale;
223
224            let m0 = self.max[idx];
225            let m1 = if s > m0 { s } else { m0 };
226            // Rescale what is already accumulated onto the new maximum.
227            let shift = if m0.is_finite() { exp(m0 - m1) } else { 0.0 };
228            let w = exp(s - m1);
229            self.den[idx] = self.den[idx] * shift + w;
230            let dst = &mut self.num[idx * d..(idx + 1) * d];
231            for (o, &c) in dst.iter_mut().zip(cell) {
232                *o = *o * shift + w * c;
233            }
234            self.max[idx] = m1;
235        }
236    }
237
238    /// Finish the pooling and run the head's second softmax and projection.
239    pub fn finish(self) -> Vec<f32> {
240        let (l1, k, q, d) = (
241            self.head.cells,
242            self.head.probes_per_cell,
243            self.head.queries,
244            self.d,
245        );
246        let mut r = self.num;
247        for idx in 0..l1 * k {
248            let inv = 1.0 / self.den[idx];
249            let dst = &mut r[idx * d..(idx + 1) * d];
250            for o in dst.iter_mut() {
251                *o *= inv;
252            }
253            rms_unit(dst);
254            let g = self.head.gain[idx];
255            for o in dst.iter_mut() {
256                *o *= g;
257            }
258        }
259        self.head.pool_and_project(&r, l1, k, q, d)
260    }
261}
262
263#[cfg(test)]
264mod tests {
265    use super::*;
266
267    /// A head with one cell, one probe and one query, so the pooling collapses
268    /// to something checkable by hand.
269    fn trivial(d: usize) -> ProbeHead {
270        ProbeHead {
271            probes: vec![0.0; d],
272            gain: vec![1.0],
273            query: vec![0.0; d],
274            row_bias: vec![0.0],
275            proj: vec![1.0; d],
276            bias: vec![0.5],
277            cells: 1,
278            probes_per_cell: 1,
279            queries: 1,
280            out_dim: 1,
281        }
282    }
283
284    #[test]
285    fn a_zero_probe_averages_the_sequence() {
286        // With a zero probe every position scores 0, so the softmax is uniform
287        // and r is the mean cell — then rms_unit normalises it.
288        let d = 4;
289        let h = trivial(d);
290        let cells = vec![
291            1.0f32, 0.0, 0.0, 0.0, // t=0
292            3.0, 0.0, 0.0, 0.0, // t=1
293        ];
294        let out = h.forward(&cells, 2, d);
295        // mean = [2,0,0,0]; rms over 4 dims = sqrt(4/4) = 1 → unit = [2,0,0,0]/1
296        // proj is all ones, so the logit is the sum plus the bias.
297        assert!(
298            (out[0] - (2.0 / (4.0f32 / 4.0).sqrt() + 0.5)).abs() < 1e-5,
299            "{out:?}"
300        );
301    }
302
303    #[test]
304    fn the_gain_scales_the_summary() {
305        let d = 4;
306        let mut h = trivial(d);
307        let cells = vec![2.0f32, 0.0, 0.0, 0.0];
308        let base = h.forward(&cells, 1, d)[0] - 0.5;
309        h.gain[0] = 3.0;
310        let scaled = h.forward(&cells, 1, d)[0] - 0.5;
311        assert!((scaled - 3.0 * base).abs() < 1e-4, "{scaled} vs 3 * {base}");
312    }
313}