Skip to main content

nodedb_vector/rerank/codecs/
bbq.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! `RerankCodec` wrapper for BBQ (Better Binary Quantization).
4//!
5//! BBQ is a training-based codec: `train()` calibrates a centroid from a
6//! sample of vectors. Until training is complete, `encode` and `prepare_query`
7//! return `RerankError::NotTrained`.
8//!
9//! Distance uses the asymmetric path from the BBQ paper: the query is kept in
10//! centered FP32; the stored vector is reconstructed from its 1-bit sign pack
11//! and `residual_norm` (≈ ±norm/√dim per dimension). The L2 distance between
12//! the exact centered query and the reconstructed candidate is returned.
13//!
14//! The prepared form is `PreparedQuery::Bytes` with the layout:
15//!   [0..4]         alpha (query_norm) as f32 little-endian
16//!   [4..4+dim*4]   centered f32 values, each as f32 little-endian
17
18use nodedb_codec::vector_quant::bbq::BbqCodec;
19use nodedb_codec::vector_quant::codec::VectorCodec as _;
20use nodedb_codec::vector_quant::layout::{QuantMode, UnifiedQuantizedVectorRef};
21
22use crate::{
23    rerank::codec::{CodecName, PreparedQuery, RerankCodec},
24    rerank::types::RerankError,
25};
26
27// ── Payload helpers ───────────────────────────────────────────────────────────
28
29fn encode_payload(query_norm: f32, centered: &[f32]) -> Vec<u8> {
30    // Layout: 4 bytes alpha (query_norm f32 LE) || dim * 4 bytes centered f32 LE
31    let mut buf = Vec::with_capacity(4 + centered.len() * 4);
32    buf.extend_from_slice(&query_norm.to_le_bytes());
33    for &x in centered {
34        buf.extend_from_slice(&x.to_le_bytes());
35    }
36    buf
37}
38
39/// Byte length of a prepared BBQ payload for `dim`: alpha + centered f32s.
40/// `None` when `dim` is large enough that the length itself overflows.
41fn payload_len(dim: usize) -> Option<usize> {
42    dim.checked_mul(4)?.checked_add(4)
43}
44
45// ── BbqRerank ─────────────────────────────────────────────────────────────────
46
47/// Default oversample multiplier used when the caller does not specify one.
48pub const DEFAULT_OVERSAMPLE: u8 = 4;
49
50/// Object-safe `RerankCodec` wrapper around `BbqCodec`.
51///
52/// The codec starts untrained. `encode` and `prepare_query` return
53/// `RerankError::NotTrained` until `train()` has been called with a
54/// representative sample of vectors.
55///
56/// `from_codec` accepts a pre-calibrated `BbqCodec` (used when restoring
57/// from a snapshot).
58pub struct BbqRerank {
59    codec: Option<BbqCodec>,
60    dim: usize,
61    oversample: u8,
62}
63
64impl BbqRerank {
65    /// Construct an untrained wrapper.
66    ///
67    /// `encode` / `distance_prepared` return `RerankError::NotTrained` until
68    /// `train()` is called.
69    pub fn new(dim: usize, oversample: u8) -> Self {
70        Self {
71            codec: None,
72            dim,
73            oversample,
74        }
75    }
76
77    /// Construct from a pre-calibrated codec (used when restoring from snapshot).
78    pub fn from_codec(codec: BbqCodec) -> Self {
79        let dim = codec.dim;
80        Self {
81            codec: Some(codec),
82            dim,
83            oversample: DEFAULT_OVERSAMPLE,
84        }
85    }
86}
87
88impl RerankCodec for BbqRerank {
89    /// Encode a full-precision vector to BBQ 1-bit bytes.
90    ///
91    /// The serialised form is the raw `UnifiedQuantizedVector` buffer
92    /// (`as_bytes()`): 32-byte `QuantHeader` followed by `dim.div_ceil(8)`
93    /// sign-packed bits plus 14 bytes of corrective factors in the header.
94    fn encode(&self, v: &[f32]) -> Result<Vec<u8>, RerankError> {
95        if v.len() != self.dim {
96            return Err(RerankError::BadInput(format!(
97                "bbq encode: vector len {} != codec dim {}",
98                v.len(),
99                self.dim
100            )));
101        }
102        let codec = self.codec.as_ref().ok_or_else(|| {
103            RerankError::NotTrained(
104                "bbq: codec must be trained before encoding (call train() with a sample of vectors)"
105                    .to_string(),
106            )
107        })?;
108        let quantized = codec.encode(v);
109        Ok(quantized.as_ref().as_bytes().to_vec())
110    }
111
112    /// Prepare the query by centering it and serialising the exact FP32 centered
113    /// vector alongside the query norm.
114    ///
115    /// The prepared form is `PreparedQuery::Bytes` with the layout:
116    ///   4 bytes query_norm (f32 LE) || dim × 4 bytes centered f32 LE.
117    fn prepare_query(&self, q: &[f32]) -> Result<PreparedQuery, RerankError> {
118        if q.len() != self.dim {
119            return Err(RerankError::BadInput(format!(
120                "bbq prepare_query: query len {} != codec dim {}",
121                q.len(),
122                self.dim
123            )));
124        }
125        let codec = self.codec.as_ref().ok_or_else(|| {
126            RerankError::NotTrained(
127                "bbq: codec must be trained before prepare_query (call train() with a sample of vectors)"
128                    .to_string(),
129            )
130        })?;
131        let query = codec.prepare_query(q);
132        Ok(PreparedQuery::Bytes(encode_payload(
133            query.query_norm,
134            &query.centered,
135        )))
136    }
137
138    /// Compute asymmetric L2 distance from a prepared query to a BBQ-encoded
139    /// candidate.
140    ///
141    /// The query is the exact centered FP32 vector. The stored candidate is
142    /// reconstructed from its sign bits and `residual_norm` (each dim ≈
143    /// ±norm/√dim). Returns L2 distance between them.
144    ///
145    /// Expects `PreparedQuery::Bytes` produced by `prepare_query`.
146    fn distance_prepared(
147        &self,
148        prepared: &PreparedQuery,
149        encoded: &[u8],
150    ) -> Result<f32, RerankError> {
151        let payload = match prepared {
152            PreparedQuery::Bytes(b) => b.as_slice(),
153            _ => {
154                return Err(RerankError::BadInput(
155                    "bbq distance: prepared query is not Bytes".to_string(),
156                ));
157            }
158        };
159
160        let Some(expected) = payload_len(self.dim) else {
161            return Err(RerankError::BadInput(format!(
162                "bbq distance: dim {} overflows the prepared payload length",
163                self.dim
164            )));
165        };
166        if payload.len() != expected {
167            return Err(RerankError::BadInput(format!(
168                "bbq distance: payload len {} != expected {} for dim {}",
169                payload.len(),
170                expected,
171                self.dim
172            )));
173        }
174
175        let packed_len = self.dim.div_ceil(8);
176        let uqv_ref = UnifiedQuantizedVectorRef::from_bytes(encoded, packed_len).map_err(|e| {
177            RerankError::BadInput(format!("bbq distance: failed to parse encoded bytes: {e}"))
178        })?;
179
180        // The header decides how the candidate is reconstructed, so a candidate
181        // encoded for another dimension or another quantizer must be rejected
182        // rather than scored: `from_bytes` only proves the buffer is long enough
183        // to parse, not that it belongs to this codec.
184        let header = uqv_ref.header();
185        if usize::from(header.dim) != self.dim {
186            return Err(RerankError::BadInput(format!(
187                "bbq distance: candidate dim {} != codec dim {}",
188                header.dim, self.dim
189            )));
190        }
191        if header.quant_mode != QuantMode::Bbq as u16 {
192            return Err(RerankError::BadInput(format!(
193                "bbq distance: candidate quant mode {} is not BBQ ({})",
194                header.quant_mode,
195                QuantMode::Bbq as u16
196            )));
197        }
198
199        // Fused and allocation-free: the kernel reads the centered query
200        // straight from the prepared payload bytes, so a rerank pass pays one
201        // pass and no allocation per candidate (nor per query).
202        Ok((crate::distance::simd::runtime().l2_bbq)(
203            &payload[4..],
204            uqv_ref.packed_bits(),
205            header.residual_norm,
206            self.dim,
207        ))
208    }
209
210    fn name(&self) -> CodecName {
211        CodecName::Bbq
212    }
213
214    fn to_bytes(&self) -> Result<Vec<u8>, RerankError> {
215        let codec = self.codec.as_ref().ok_or_else(|| {
216            RerankError::NotTrained("bbq sidecar serialize: codec not trained".to_string())
217        })?;
218        codec
219            .to_bytes()
220            .map_err(|e| RerankError::BadInput(format!("bbq to_bytes: {e}")))
221    }
222
223    /// Calibrate from a sample of vectors.
224    ///
225    /// Validates that:
226    /// - `samples` is non-empty.
227    /// - Every sample has length `self.dim`.
228    ///
229    /// On success, stores the calibrated codec; subsequent `encode` /
230    /// `distance_prepared` calls will succeed.
231    fn train(&mut self, samples: &[&[f32]]) -> Result<(), RerankError> {
232        if samples.is_empty() {
233            return Err(RerankError::BadInput(
234                "bbq train: empty sample set".to_string(),
235            ));
236        }
237        for s in samples {
238            if s.len() != self.dim {
239                return Err(RerankError::BadInput(format!(
240                    "bbq train: sample has len {} but codec dim is {}",
241                    s.len(),
242                    self.dim
243                )));
244            }
245        }
246        let codec = BbqCodec::calibrate(samples, self.dim, self.oversample);
247        self.codec = Some(codec);
248        Ok(())
249    }
250}
251
252// ── Tests ─────────────────────────────────────────────────────────────────────
253
254#[cfg(test)]
255mod tests {
256    use super::*;
257
258    const DIM: usize = 16;
259    const N: usize = 64;
260
261    fn det_vec(i: usize, dim: usize) -> Vec<f32> {
262        (0..dim)
263            .map(|j| ((i * 31 + j) % 100) as f32 / 100.0)
264            .collect()
265    }
266
267    fn trained() -> BbqRerank {
268        let vecs: Vec<Vec<f32>> = (0..N).map(|i| det_vec(i, DIM)).collect();
269        let refs: Vec<&[f32]> = vecs.iter().map(|v| v.as_slice()).collect();
270        let mut codec = BbqRerank::new(DIM, DEFAULT_OVERSAMPLE);
271        codec.train(&refs).expect("train must succeed");
272        codec
273    }
274
275    #[test]
276    fn train_then_encode_roundtrip() {
277        let codec = trained();
278        let v = det_vec(0, DIM);
279        let enc = codec.encode(&v).expect("encode");
280        let prep = codec.prepare_query(&v).expect("prepare_query");
281        let dist = codec.distance_prepared(&prep, &enc).expect("distance");
282        assert!(dist.is_finite(), "distance must be finite, got {dist}");
283        assert!(dist >= 0.0, "distance must be non-negative, got {dist}");
284    }
285
286    /// Pins the value `distance_prepared` returns: the asymmetric L2 between the
287    /// exact centred query and the `±residual_norm/√dim` reconstruction, computed
288    /// here in f64 by plain indexing. The fused kernel sits behind this seam, so
289    /// the reference is built from the wire layout rather than from the kernel.
290    #[test]
291    fn distance_prepared_matches_the_unfused_l2() {
292        let codec = trained();
293        let v = det_vec(7, DIM);
294        let enc = codec.encode(&v).expect("encode");
295        let prep = codec.prepare_query(&v).expect("prepare_query");
296        let got = codec.distance_prepared(&prep, &enc).expect("distance");
297
298        let PreparedQuery::Bytes(payload) = &prep else {
299            panic!("prepare_query must yield PreparedQuery::Bytes");
300        };
301
302        // Wire layout: 32-byte `QuantHeader` (residual_norm at bytes 8..12),
303        // then `dim.div_ceil(8)` sign-packed bytes; the prepared payload is the
304        // 4-byte alpha followed by `dim` centred f32s. The offsets are pinned on
305        // purpose: a change on either side of the seam must fail this test.
306        let residual_norm = f32::from_le_bytes([enc[8], enc[9], enc[10], enc[11]]);
307        let packed = &enc[32..32 + DIM.div_ceil(8)];
308        let centered: Vec<f64> = payload[4..]
309            .as_chunks::<4>()
310            .0
311            .iter()
312            .map(|b| f32::from_le_bytes(*b) as f64)
313            .collect();
314        assert_eq!(centered.len(), DIM, "centred query must carry `dim` lanes");
315
316        let scale = residual_norm as f64 / (DIM as f64).sqrt();
317        let expected = centered
318            .iter()
319            .enumerate()
320            .map(|(i, q)| {
321                let bit = (packed[i / 8] >> (7 - (i % 8))) & 1;
322                let recon = if bit != 0 { scale } else { -scale };
323                (q - recon).powi(2)
324            })
325            .sum::<f64>()
326            .sqrt();
327
328        // A degenerate reference would make the comparison vacuous.
329        assert!(
330            expected > 1e-3,
331            "reference distance collapsed to {expected}"
332        );
333
334        let rel = 1e-4f64;
335        let abs = 1e-6f64;
336        assert!(
337            ((got as f64) - expected).abs() <= abs.max(rel * expected.abs()),
338            "distance_prepared = {got}, unfused L2 = {expected}"
339        );
340    }
341
342    #[test]
343    fn encode_before_train_returns_not_trained() {
344        let codec = BbqRerank::new(DIM, DEFAULT_OVERSAMPLE);
345        let v = det_vec(0, DIM);
346        let err = codec.encode(&v).unwrap_err();
347        let msg = format!("{err}");
348        assert!(
349            msg.contains("not trained") || msg.contains("trained"),
350            "expected 'trained' in error, got: {msg}"
351        );
352    }
353
354    #[test]
355    fn train_with_empty_samples_fails() {
356        let mut codec = BbqRerank::new(DIM, DEFAULT_OVERSAMPLE);
357        let err = codec.train(&[]).unwrap_err();
358        let msg = format!("{err}");
359        assert!(
360            msg.contains("bad input") || msg.contains("empty"),
361            "expected bad input error, got: {msg}"
362        );
363    }
364
365    #[test]
366    fn train_with_dim_mismatch_fails() {
367        let vecs: Vec<Vec<f32>> = (0..N).map(|i| det_vec(i, DIM)).collect();
368        let mut refs: Vec<&[f32]> = vecs.iter().map(|v| v.as_slice()).collect();
369        let bad = det_vec(0, DIM + 4);
370        refs.push(bad.as_slice());
371        let mut codec = BbqRerank::new(DIM, DEFAULT_OVERSAMPLE);
372        let err = codec.train(&refs).unwrap_err();
373        let msg = format!("{err}");
374        assert!(
375            msg.contains("bad input") || msg.contains("dim"),
376            "expected bad input error, got: {msg}"
377        );
378    }
379
380    /// A candidate encoded by a codec of another dimension parses against this
381    /// codec's packed length when its buffer is long enough, so only the header
382    /// check can reject it. Scoring it would silently return a wrong distance.
383    #[test]
384    fn candidate_encoded_for_another_dim_is_rejected() {
385        let codec = trained();
386        let prep = codec
387            .prepare_query(&det_vec(0, DIM))
388            .expect("prepare_query");
389
390        let other_dim = DIM * 2;
391        let vecs: Vec<Vec<f32>> = (0..N).map(|i| det_vec(i, other_dim)).collect();
392        let refs: Vec<&[f32]> = vecs.iter().map(|v| v.as_slice()).collect();
393        let mut other = BbqRerank::new(other_dim, DEFAULT_OVERSAMPLE);
394        other.train(&refs).expect("train must succeed");
395        let enc = other.encode(&det_vec(0, other_dim)).expect("encode");
396
397        let err = codec.distance_prepared(&prep, &enc).unwrap_err();
398        let msg = format!("{err}");
399        assert!(
400            msg.contains("candidate dim"),
401            "expected a dimension mismatch, got: {msg}"
402        );
403    }
404
405    #[test]
406    fn candidate_with_another_quant_mode_is_rejected() {
407        let codec = trained();
408        let v = det_vec(0, DIM);
409        let prep = codec.prepare_query(&v).expect("prepare_query");
410        let mut enc = codec.encode(&v).expect("encode");
411
412        // Header bytes 0..2 carry the quant mode; Sq8 has the same header size.
413        enc[0..2].copy_from_slice(&(QuantMode::Sq8 as u16).to_le_bytes());
414
415        let err = codec.distance_prepared(&prep, &enc).unwrap_err();
416        let msg = format!("{err}");
417        assert!(
418            msg.contains("quant mode"),
419            "expected a quant-mode mismatch, got: {msg}"
420        );
421    }
422
423    #[test]
424    fn prepare_query_wrong_dim_fails() {
425        let codec = trained();
426        let bad = det_vec(0, DIM + 2);
427        match codec.prepare_query(&bad) {
428            Err(e) => {
429                let msg = format!("{e}");
430                assert!(
431                    msg.contains("bad input") || msg.contains("dim"),
432                    "expected bad input error, got: {msg}"
433                );
434            }
435            Ok(_) => panic!("expected an error for wrong dim"),
436        }
437    }
438
439    #[test]
440    fn distance_prepared_wrong_variant_fails() {
441        let codec = trained();
442        let v = det_vec(0, DIM);
443        let enc = codec.encode(&v).expect("encode");
444        let bad_prepared = PreparedQuery::Raw(vec![0.0f32; DIM]);
445        let err = codec.distance_prepared(&bad_prepared, &enc).unwrap_err();
446        let msg = format!("{err}");
447        assert!(
448            msg.contains("Bytes") || msg.contains("not Bytes"),
449            "error message should mention Bytes variant, got: {msg}"
450        );
451    }
452
453    #[test]
454    fn name_is_expected() {
455        let codec = BbqRerank::new(DIM, DEFAULT_OVERSAMPLE);
456        assert_eq!(codec.name(), CodecName::Bbq);
457    }
458}