Skip to main content

needle_core/v2/
heads.rs

1//! Probe-pooling heads: contrastive retrieval and confidence.
2//!
3//! Port of `architecture.probe_pool`, `ContrastiveHead` and `ConfidenceHead`.
4//!
5//! # What a cell is
6//!
7//! These heads do not read the final hidden state. They pool over *cells* —
8//! `hidden_cells` returns `stack([x0, *hidden], axis=2)`, so for a `T`-token
9//! input there are `T * (L + 1)` cells: the scaled embedding, plus the lane-mean
10//! of the residual stream after each of the `L` layers. Every cell of every
11//! position competes in one softmax per probe.
12//!
13//! # Streaming instead of storing
14//!
15//! The reference materialises all `T * (L + 1)` cells and softmaxes across them:
16//! at `T = 2048` that is 2048 x 28 x 512 floats, 117 MB. Since a softmax-weighted
17//! average is exactly what an online (running-max) softmax computes, the same
18//! result is reachable in `O(probes * d_model)` state — 16 KB for the confidence
19//! head — with cells consumed as the forward pass produces them and never
20//! retained.
21//!
22//! [`ProbePool::push`] takes one cell; [`ProbePool::pooled`] finishes. The
23//! arithmetic is a reassociation of the reference's, so results agree to f32
24//! rounding rather than bit-exactly.
25//!
26//! # Masking
27//!
28//! `hidden_cells` runs `make_causal_mask & make_padding_mask`, and `probe_pool`
29//! additionally drops cells whose position holds a pad token. The caller is
30//! responsible for not pushing cells for pad positions — see
31//! `V2Engine::encode_contrastive`.
32//!
33//! Note also that `encode_contrastive` and `forward_confidence` default to
34//! `window=0` (full causal), while the LM path runs the checkpoint's `kv_window`.
35//! See [`V2State::set_kv_window`].
36//!
37//! [`V2State::set_kv_window`]: crate::v2::model::V2State::set_kv_window
38
39use crate::math;
40use alloc::vec;
41use alloc::vec::Vec;
42
43/// Head codes as stored in the container's `heads.manifest`.
44pub const HEAD_CONTRASTIVE: u8 = 1;
45pub const HEAD_CONFIDENCE: u8 = 2;
46
47/// Epsilon inside the contrastive L2 normalisation, matching the reference.
48pub const L2_EPS: f32 = 1e-12;
49
50/// Streaming softmax-weighted pooling over cells, one accumulator per probe.
51pub struct ProbePool {
52    num_probes: usize,
53    d: usize,
54    /// `[num_probes * d]`.
55    probes: Vec<f32>,
56    /// Running max score per probe.
57    m: Vec<f32>,
58    /// Running sum of `exp(score - m)` per probe.
59    s: Vec<f32>,
60    /// Running `sum(exp(score - m) * cell)`, `[num_probes * d]`.
61    acc: Vec<f32>,
62    /// Divisor on the raw dot product: `sqrt(d_model)`.
63    denom: f32,
64    cells: usize,
65}
66
67impl ProbePool {
68    /// `probes` is `[num_probes, d]`, row-major.
69    pub fn new(probes: &[f32], num_probes: usize, d: usize) -> Result<Self, &'static str> {
70        if num_probes == 0 || d == 0 {
71            return Err("probe pool needs a non-empty probe matrix");
72        }
73        if probes.len() != num_probes * d {
74            return Err("probe matrix length does not match num_probes * d");
75        }
76        Ok(Self {
77            num_probes,
78            d,
79            probes: probes.to_vec(),
80            m: vec![f32::NEG_INFINITY; num_probes],
81            s: vec![0.0; num_probes],
82            acc: vec![0.0; num_probes * d],
83            denom: math::sqrt(d as f32),
84            cells: 0,
85        })
86    }
87
88    pub fn num_probes(&self) -> usize {
89        self.num_probes
90    }
91
92    /// Width of the pooled output, `num_probes * d_model`.
93    pub fn pooled_len(&self) -> usize {
94        self.num_probes * self.d
95    }
96
97    /// Cells consumed so far.
98    pub fn cells(&self) -> usize {
99        self.cells
100    }
101
102    /// Fold one cell into the pool.
103    pub fn push(&mut self, cell: &[f32]) {
104        debug_assert_eq!(cell.len(), self.d);
105        self.cells += 1;
106        for k in 0..self.num_probes {
107            let probe = &self.probes[k * self.d..(k + 1) * self.d];
108            let mut dot = 0.0f32;
109            for c in 0..self.d {
110                dot += cell[c] * probe[c];
111            }
112            let z = dot / self.denom;
113
114            let acc = &mut self.acc[k * self.d..(k + 1) * self.d];
115            if z > self.m[k] {
116                // Rebase onto the new maximum.
117                let r = if self.m[k] == f32::NEG_INFINITY {
118                    0.0
119                } else {
120                    math::exp(self.m[k] - z)
121                };
122                self.s[k] *= r;
123                for a in acc.iter_mut() {
124                    *a *= r;
125                }
126                self.m[k] = z;
127            }
128            let w = math::exp(z - self.m[k]);
129            self.s[k] += w;
130            for (a, &v) in acc.iter_mut().zip(cell.iter()) {
131                *a += w * v;
132            }
133        }
134    }
135
136    /// Write the pooled vector, `[num_probes * d]` with probe-major layout —
137    /// the flattening `probe_pool` produces with `reshape(b, -1)`.
138    pub fn pooled(&self, out: &mut [f32]) -> Result<(), &'static str> {
139        if self.cells == 0 {
140            return Err("probe pool received no cells");
141        }
142        debug_assert_eq!(out.len(), self.pooled_len());
143        for k in 0..self.num_probes {
144            let inv = 1.0 / self.s[k];
145            for c in 0..self.d {
146                out[k * self.d + c] = self.acc[k * self.d + c] * inv;
147            }
148        }
149        Ok(())
150    }
151
152    /// Clear, keeping the probe matrix and the allocations.
153    pub fn reset(&mut self) {
154        self.m.fill(f32::NEG_INFINITY);
155        self.s.fill(0.0);
156        self.acc.fill(0.0);
157        self.cells = 0;
158    }
159}
160
161/// A probe head's projection: `out = proj · pooled + bias`.
162pub struct ProbeHeadWeights {
163    /// [`HEAD_CONTRASTIVE`] or [`HEAD_CONFIDENCE`].
164    pub code: u8,
165    /// `[num_probes, d_model]`.
166    pub probes: Vec<f32>,
167    /// `[out_dim, num_probes * d_model]`, row-major — the container stores this
168    /// pre-transposed, which is the orientation needed here.
169    pub proj: Vec<f32>,
170    /// `[out_dim]`. Zero for the contrastive head, which is bias-free upstream.
171    pub bias: Vec<f32>,
172    pub num_probes: usize,
173    pub out_dim: usize,
174    pub d_model: usize,
175}
176
177impl ProbeHeadWeights {
178    pub fn validate(&self) -> Result<(), &'static str> {
179        if self.probes.len() != self.num_probes * self.d_model {
180            return Err("head probes shape does not match num_probes * d_model");
181        }
182        if self.proj.len() != self.out_dim * self.num_probes * self.d_model {
183            return Err("head proj shape does not match out_dim * num_probes * d_model");
184        }
185        if self.bias.len() != self.out_dim {
186            return Err("head bias length does not match out_dim");
187        }
188        Ok(())
189    }
190
191    pub fn pool(&self) -> Result<ProbePool, &'static str> {
192        ProbePool::new(&self.probes, self.num_probes, self.d_model)
193    }
194
195    /// Project a pooled vector. `out` is `out_dim` long.
196    pub fn project(&self, pooled: &[f32], out: &mut [f32]) {
197        debug_assert_eq!(pooled.len(), self.num_probes * self.d_model);
198        debug_assert_eq!(out.len(), self.out_dim);
199        let n = pooled.len();
200        for (o, slot) in out.iter_mut().enumerate() {
201            let row = &self.proj[o * n..(o + 1) * n];
202            let mut acc = 0.0f32;
203            for (r, p) in row.iter().zip(pooled.iter()) {
204                acc += r * p;
205            }
206            *slot = acc + self.bias[o];
207        }
208    }
209
210    /// Project and L2-normalise, as `ContrastiveHead` does.
211    pub fn project_normalized(&self, pooled: &[f32], out: &mut [f32]) {
212        self.project(pooled, out);
213        let sq: f32 = out.iter().map(|v| v * v).sum();
214        let inv = 1.0 / math::sqrt(sq + L2_EPS);
215        for v in out.iter_mut() {
216            *v *= inv;
217        }
218    }
219}
220
221#[cfg(test)]
222mod tests {
223    use super::*;
224
225    /// Batch reference: the softmax-then-average the streaming pool replaces.
226    fn pooled_reference(
227        cells: &[Vec<f32>],
228        probes: &[f32],
229        num_probes: usize,
230        d: usize,
231    ) -> Vec<f32> {
232        let denom = math::sqrt(d as f32);
233        let mut out = vec![0.0f32; num_probes * d];
234        for k in 0..num_probes {
235            let probe = &probes[k * d..(k + 1) * d];
236            let scores: Vec<f32> = cells
237                .iter()
238                .map(|c| c.iter().zip(probe).map(|(a, b)| a * b).sum::<f32>() / denom)
239                .collect();
240            let max = scores.iter().copied().fold(f32::NEG_INFINITY, f32::max);
241            let exps: Vec<f32> = scores.iter().map(|&z| math::exp(z - max)).collect();
242            let total: f32 = exps.iter().sum();
243            for (w, cell) in exps.iter().zip(cells.iter()) {
244                for c in 0..d {
245                    out[k * d + c] += (w / total) * cell[c];
246                }
247            }
248        }
249        out
250    }
251
252    fn synth(n_cells: usize, d: usize, scale: f32) -> Vec<Vec<f32>> {
253        (0..n_cells)
254            .map(|i| {
255                (0..d)
256                    .map(|c| ((i * 31 + c * 7) as f32 * 0.017).sin() * scale)
257                    .collect()
258            })
259            .collect()
260    }
261
262    #[test]
263    fn streaming_pool_matches_batch_softmax() {
264        for &(n_cells, d, num_probes, scale) in &[
265            (28usize, 16usize, 4usize, 1.0f32),
266            (200, 32, 8, 3.0),
267            (1, 8, 2, 1.0),
268            (2800, 16, 4, 2.0),
269        ] {
270            let cells = synth(n_cells, d, scale);
271            let probes: Vec<f32> = (0..num_probes * d)
272                .map(|i| (i as f32 * 0.031).cos() * 0.7)
273                .collect();
274
275            let mut pool = ProbePool::new(&probes, num_probes, d).unwrap();
276            for c in &cells {
277                pool.push(c);
278            }
279            let mut got = vec![0.0f32; num_probes * d];
280            pool.pooled(&mut got).unwrap();
281            assert_eq!(pool.cells(), n_cells);
282
283            let want = pooled_reference(&cells, &probes, num_probes, d);
284            let mag = want.iter().fold(0.0f32, |a, b| a.max(b.abs())).max(1e-6);
285            for i in 0..got.len() {
286                assert!(
287                    (got[i] - want[i]).abs() <= 1e-5 * mag,
288                    "cells={n_cells} d={d} probes={num_probes} i={i}: {} vs {}",
289                    got[i],
290                    want[i]
291                );
292            }
293        }
294    }
295
296    /// Large score spreads are where a naive streaming sum would overflow; the
297    /// running max has to keep it finite.
298    #[test]
299    fn streaming_pool_survives_extreme_scores() {
300        let d = 16;
301        let mut cells = synth(50, d, 1.0);
302        // One cell with a huge projection onto the probe, one with a huge negative.
303        cells.push(vec![90.0; d]);
304        cells.push(vec![-90.0; d]);
305        let probes: Vec<f32> = vec![1.0; d];
306        let mut pool = ProbePool::new(&probes, 1, d).unwrap();
307        for c in &cells {
308            pool.push(c);
309        }
310        let mut got = vec![0.0f32; d];
311        pool.pooled(&mut got).unwrap();
312        assert!(got.iter().all(|v| v.is_finite()), "{got:?}");
313        // The dominant cell should win the softmax outright.
314        for &v in &got {
315            assert!((v - 90.0).abs() < 1e-2, "expected ~90, got {v}");
316        }
317    }
318
319    /// Cell order must not matter — softmax is symmetric in its inputs.
320    #[test]
321    fn pooling_is_order_independent() {
322        let d = 24;
323        let cells = synth(60, d, 2.0);
324        let probes: Vec<f32> = (0..3 * d).map(|i| (i as f32 * 0.05).sin()).collect();
325
326        let mut a = ProbePool::new(&probes, 3, d).unwrap();
327        for c in &cells {
328            a.push(c);
329        }
330        let mut b = ProbePool::new(&probes, 3, d).unwrap();
331        for c in cells.iter().rev() {
332            b.push(c);
333        }
334        let (mut x, mut y) = (vec![0.0f32; 3 * d], vec![0.0f32; 3 * d]);
335        a.pooled(&mut x).unwrap();
336        b.pooled(&mut y).unwrap();
337        for i in 0..x.len() {
338            assert!((x[i] - y[i]).abs() < 1e-4, "i={i}: {} vs {}", x[i], y[i]);
339        }
340    }
341
342    #[test]
343    fn reset_restores_a_fresh_pool() {
344        let d = 8;
345        let cells = synth(10, d, 1.0);
346        let probes: Vec<f32> = (0..2 * d).map(|i| i as f32 * 0.1).collect();
347        let mut pool = ProbePool::new(&probes, 2, d).unwrap();
348        for c in &cells {
349            pool.push(c);
350        }
351        let mut first = vec![0.0f32; 2 * d];
352        pool.pooled(&mut first).unwrap();
353
354        pool.reset();
355        assert_eq!(pool.cells(), 0);
356        assert!(pool.pooled(&mut first.clone()).is_err());
357        for c in &cells {
358            pool.push(c);
359        }
360        let mut again = vec![0.0f32; 2 * d];
361        pool.pooled(&mut again).unwrap();
362        assert_eq!(first, again);
363    }
364
365    #[test]
366    fn rejects_bad_probe_shapes() {
367        assert!(ProbePool::new(&[1.0, 2.0], 0, 2).is_err());
368        assert!(ProbePool::new(&[1.0, 2.0], 2, 0).is_err());
369        assert!(ProbePool::new(&[1.0, 2.0, 3.0], 2, 2).is_err());
370    }
371
372    #[test]
373    fn contrastive_projection_is_unit_norm() {
374        let (p, d, out_dim) = (4usize, 8usize, 6usize);
375        let h = ProbeHeadWeights {
376            code: HEAD_CONTRASTIVE,
377            probes: (0..p * d).map(|i| i as f32 * 0.01).collect(),
378            proj: (0..out_dim * p * d)
379                .map(|i| ((i * 13 % 29) as f32 - 14.0) * 0.05)
380                .collect(),
381            bias: vec![0.0; out_dim],
382            num_probes: p,
383            out_dim,
384            d_model: d,
385        };
386        h.validate().unwrap();
387        let pooled: Vec<f32> = (0..p * d).map(|i| (i as f32 * 0.07).sin()).collect();
388        let mut out = vec![0.0f32; out_dim];
389        h.project_normalized(&pooled, &mut out);
390        let norm: f32 = out.iter().map(|v| v * v).sum::<f32>().sqrt();
391        assert!((norm - 1.0).abs() < 1e-5, "norm={norm}");
392    }
393
394    /// An all-zero projection must not produce NaN — that is what the epsilon
395    /// inside the norm is for.
396    #[test]
397    fn contrastive_projection_handles_zero_vector() {
398        let (p, d, out_dim) = (2usize, 4usize, 3usize);
399        let h = ProbeHeadWeights {
400            code: HEAD_CONTRASTIVE,
401            probes: vec![0.0; p * d],
402            proj: vec![0.0; out_dim * p * d],
403            bias: vec![0.0; out_dim],
404            num_probes: p,
405            out_dim,
406            d_model: d,
407        };
408        let mut out = vec![0.0f32; out_dim];
409        h.project_normalized(&vec![0.0; p * d], &mut out);
410        assert!(out.iter().all(|v| v.is_finite()));
411    }
412
413    #[test]
414    fn confidence_projection_applies_bias() {
415        let (p, d) = (2usize, 4usize);
416        let h = ProbeHeadWeights {
417            code: HEAD_CONFIDENCE,
418            probes: vec![0.5; p * d],
419            proj: vec![1.0; p * d],
420            bias: vec![-2.5],
421            num_probes: p,
422            out_dim: 1,
423            d_model: d,
424        };
425        h.validate().unwrap();
426        let pooled = vec![1.0f32; p * d];
427        let mut out = vec![0.0f32; 1];
428        h.project(&pooled, &mut out);
429        // sum of eight ones, minus the bias.
430        assert!((out[0] - (8.0 - 2.5)).abs() < 1e-6, "{}", out[0]);
431    }
432
433    #[test]
434    fn validate_rejects_shape_mismatches() {
435        let mut h = ProbeHeadWeights {
436            code: HEAD_CONFIDENCE,
437            probes: vec![0.0; 8],
438            proj: vec![0.0; 8],
439            bias: vec![0.0; 1],
440            num_probes: 2,
441            out_dim: 1,
442            d_model: 4,
443        };
444        h.validate().unwrap();
445        h.bias.push(0.0);
446        assert!(h.validate().is_err());
447        h.bias.pop();
448        h.probes.push(0.0);
449        assert!(h.validate().is_err());
450    }
451}