Skip to main content

nodedb_vector/quantize/
binary_codec.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! `VectorCodec` implementation for binary quantization.
4//!
5//! Introduces the `BinaryCodec` wrapper struct (holding `dim`) and implements
6//! the dual-phase trait using the sign-bit encoding and Hamming distance
7//! functions from `binary`. The prepared query is pre-encoded to bits; both
8//! symmetric and asymmetric distances delegate to `hamming_distance`.
9
10use nodedb_codec::vector_quant::{
11    codec::{AdcLut, VectorCodec},
12    layout::{QuantHeader, QuantMode, UnifiedQuantizedVector},
13};
14
15use crate::quantize::binary;
16
17// ── Codec struct ──────────────────────────────────────────────────────────────
18
19/// Binary quantization codec (sign-bit encoding, Hamming distance).
20///
21/// Stores only `dim` — no learned parameters. Suitable as a coarse pre-filter
22/// before exact reranking with a higher-fidelity codec.
23pub struct BinaryCodec {
24    pub dim: usize,
25}
26
27// ── Newtype ───────────────────────────────────────────────────────────────────
28
29/// Thin newtype wrapping `UnifiedQuantizedVector` for binary-encoded vectors.
30pub struct BinaryQuantized(pub UnifiedQuantizedVector);
31
32impl AsRef<UnifiedQuantizedVector> for BinaryQuantized {
33    #[inline]
34    fn as_ref(&self) -> &UnifiedQuantizedVector {
35        &self.0
36    }
37}
38
39// ── Helper ────────────────────────────────────────────────────────────────────
40
41#[inline]
42fn packed_bits_of(q: &BinaryQuantized) -> &[u8] {
43    q.0.packed_bits()
44}
45
46// ── VectorCodec impl ──────────────────────────────────────────────────────────
47
48impl VectorCodec for BinaryCodec {
49    type Quantized = BinaryQuantized;
50    /// Pre-encoded query bits (`ceil(dim/8)` bytes).
51    type Query = Vec<u8>;
52
53    /// Encode an FP32 vector into binary sign bits.
54    ///
55    /// # Panics
56    ///
57    /// `UnifiedQuantizedVector::new` fails only on outlier-count/bitmask
58    /// mismatches. With `outlier_bitmask = 0` and an empty outliers slice this
59    /// can never happen. The `expect` is therefore unreachable in practice.
60    fn encode(&self, v: &[f32]) -> Self::Quantized {
61        let bits = binary::encode(v);
62        let header = QuantHeader {
63            quant_mode: QuantMode::Binary as u16,
64            dim: self.dim as u16,
65            global_scale: 0.0,
66            residual_norm: 0.0,
67            dot_quantized: 0.0,
68            outlier_bitmask: 0,
69            reserved: [0; 8],
70        };
71        let uqv = UnifiedQuantizedVector::new(header, &bits, &[])
72            .expect("BinaryCodec::encode: layout construction is infallible (no outliers)");
73        BinaryQuantized(uqv)
74    }
75
76    /// Pre-encode the query to binary sign bits for fast Hamming comparison.
77    fn prepare_query(&self, q: &[f32]) -> Self::Query {
78        binary::encode(q)
79    }
80
81    /// Binary codec has no ADC table — returns `None`.
82    fn adc_lut(&self, _q: &Self::Query) -> Option<AdcLut> {
83        None
84    }
85
86    /// Symmetric Hamming distance between two binary-encoded vectors.
87    #[inline]
88    fn fast_symmetric_distance(&self, q: &Self::Quantized, v: &Self::Quantized) -> f32 {
89        binary::hamming_distance(packed_bits_of(q), packed_bits_of(v)) as f32
90    }
91
92    /// Asymmetric Hamming distance: pre-encoded query bits vs stored candidate.
93    #[inline]
94    fn exact_asymmetric_distance(&self, q: &Self::Query, v: &Self::Quantized) -> f32 {
95        binary::hamming_distance(q, packed_bits_of(v)) as f32
96    }
97}
98
99// ── Tests ─────────────────────────────────────────────────────────────────────
100
101#[cfg(test)]
102mod tests {
103    use super::*;
104
105    fn make_codec(dim: usize) -> BinaryCodec {
106        BinaryCodec { dim }
107    }
108
109    /// `encode` round-trip: packed_bits in the UQV must match `binary::encode`.
110    #[test]
111    fn encode_packed_bits_matches_raw_encode() {
112        let dim = 8;
113        let codec = make_codec(dim);
114        let v = vec![1.0f32, -1.0, 1.0, -1.0, 0.5, -0.5, 1.0, -1.0];
115        let raw = binary::encode(&v);
116        let quantized = codec.encode(&v);
117        assert_eq!(quantized.as_ref().packed_bits(), raw.as_slice());
118    }
119
120    /// `fast_symmetric_distance` returns a non-negative finite value.
121    #[test]
122    fn fast_symmetric_distance_is_non_negative_finite() {
123        let codec = make_codec(8);
124        let a = codec.encode(&[1.0, -1.0, 1.0, -1.0, 1.0, -1.0, 1.0, -1.0]);
125        let b = codec.encode(&[-1.0, 1.0, -1.0, 1.0, -1.0, 1.0, -1.0, 1.0]);
126        let d = codec.fast_symmetric_distance(&a, &b);
127        assert!(d.is_finite(), "expected finite distance, got {d}");
128        assert!(d >= 0.0, "expected non-negative distance, got {d}");
129    }
130
131    /// `exact_asymmetric_distance` returns a non-negative finite value.
132    #[test]
133    fn exact_asymmetric_distance_is_non_negative_finite() {
134        let codec = make_codec(8);
135        let q = codec.prepare_query(&[1.0, -1.0, 1.0, -1.0, 1.0, -1.0, 1.0, -1.0]);
136        let v = codec.encode(&[-1.0, 1.0, -1.0, 1.0, -1.0, 1.0, -1.0, 1.0]);
137        let d = codec.exact_asymmetric_distance(&q, &v);
138        assert!(d.is_finite(), "expected finite distance, got {d}");
139        assert!(d >= 0.0, "expected non-negative distance, got {d}");
140    }
141
142    /// Opposite-sign vectors should have maximum Hamming distance (= dim bits).
143    #[test]
144    fn opposite_vectors_have_max_hamming_distance() {
145        let dim = 8;
146        let codec = make_codec(dim);
147        let a = codec.encode(&[1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0]);
148        let b = codec.encode(&[-1.0, -1.0, -1.0, -1.0, -1.0, -1.0, -1.0, -1.0]);
149        let d = codec.fast_symmetric_distance(&a, &b);
150        assert_eq!(d, dim as f32);
151    }
152
153    /// Verify the trait impl compiles via a generic function.
154    fn use_vector_codec<C: VectorCodec>(c: &C, q: &[f32], v: &[f32]) -> f32 {
155        let qv = c.encode(v);
156        let qq = c.prepare_query(q);
157        c.fast_symmetric_distance(&qv, &qv) + c.exact_asymmetric_distance(&qq, &qv)
158    }
159
160    #[test]
161    fn trait_bounds_compile() {
162        let codec = make_codec(8);
163        let result = use_vector_codec(
164            &codec,
165            &[1.0, -1.0, 1.0, -1.0, 1.0, -1.0, 1.0, -1.0],
166            &[-1.0, 1.0, -1.0, 1.0, -1.0, 1.0, -1.0, 1.0],
167        );
168        assert!(result.is_finite());
169    }
170}