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}