1use crate::math;
40use alloc::vec;
41use alloc::vec::Vec;
42
43pub const HEAD_CONTRASTIVE: u8 = 1;
45pub const HEAD_CONFIDENCE: u8 = 2;
46
47pub const L2_EPS: f32 = 1e-12;
49
50pub struct ProbePool {
52 num_probes: usize,
53 d: usize,
54 probes: Vec<f32>,
56 m: Vec<f32>,
58 s: Vec<f32>,
60 acc: Vec<f32>,
62 denom: f32,
64 cells: usize,
65}
66
67impl ProbePool {
68 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 pub fn pooled_len(&self) -> usize {
94 self.num_probes * self.d
95 }
96
97 pub fn cells(&self) -> usize {
99 self.cells
100 }
101
102 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 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 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 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
161pub struct ProbeHeadWeights {
163 pub code: u8,
165 pub probes: Vec<f32>,
167 pub proj: Vec<f32>,
170 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 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 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 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 #[test]
299 fn streaming_pool_survives_extreme_scores() {
300 let d = 16;
301 let mut cells = synth(50, d, 1.0);
302 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 for &v in &got {
315 assert!((v - 90.0).abs() < 1e-2, "expected ~90, got {v}");
316 }
317 }
318
319 #[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 #[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 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}