Skip to main content

ruvector_turboquant/
codec.rs

1//! Turbo4 encoder: rotated, standardized, 4-bit Lloyd-Max packed codes.
2//!
3//! ## Code blob layout (`code_len = D/2 + 8` bytes)
4//!
5//! ```text
6//! [ D/2 packed nibbles | α: f32 LE | S: f32 LE ]
7//! ```
8//!
9//! * byte `i` holds dim `i` in the **low** nibble and dim `i + D/2` in the
10//!   **high** nibble — so SIMD unpacking yields two *contiguous* dimension
11//!   runs (`0..D/2` and `D/2..D`) with no cross-lane shuffling;
12//! * `α = ‖v‖₂ / √D` — the standardization factor (rotated coords are divided
13//!   by α before table lookup, so they're ~N(0,1));
14//! * `S = Σ level(cᵢ)²` — precomputed for the L2 decomposition.
15//!
16//! ## Query blob layout (`query_len = D + 8` bytes)
17//!
18//! ```text
19//! [ D int8 codes | qscale: f32 LE | ‖q‖²: f32 LE ]
20//! ```
21//!
22//! `q_i8[i] = round(q_rot[i] / qscale)`, `qscale = max|q_rot| / 127`.
23//!
24//! Blob lengths are structurally disjoint (`D/2+8` vs `D+8` for `D ≥ 2`), so a
25//! scorer can tell the roles apart from slice lengths alone — this is what lets
26//! `hnsw_rs::Distance<u8>::eval` run asymmetric scoring during traversal and
27//! symmetric scoring during graph construction with one distance functor.
28//!
29//! The original f32 vector is **never stored** — decoding reconstructs an
30//! approximation only, and only for tests/debugging.
31
32use crate::rotation::Rotation;
33use crate::tables::{level, quantize_coord, LEVELS_F32};
34use crate::TurboQuantError;
35
36/// Bytes of per-blob constants (α + S, or qscale + ‖q‖²).
37pub const META_BYTES: usize = 8;
38
39/// A prepared query: the persisted-format blob plus the exact rotated f32
40/// coordinates for final rescoring.
41pub struct Turbo4Query {
42    /// `[D i8 | qscale | ‖q‖²]` — feed this to the traversal scorer.
43    pub blob: Vec<u8>,
44    /// Exact rotated query, for `rescore` (never persisted).
45    pub rotated: Vec<f32>,
46    /// Exact squared norm of the query.
47    pub norm_sq: f32,
48}
49
50/// The Turbo4 codec for a fixed (dimension, rotation-seed) pair.
51pub struct Turbo4Codec {
52    dim: usize,
53    rotation: Rotation,
54}
55
56impl Turbo4Codec {
57    /// Build a codec. `dim` must be even and ≥ 2 (all practical embedding
58    /// widths are; evenness keeps the two-run nibble layout exact).
59    pub fn new(dim: usize, rotation_seed: u64) -> Result<Self, TurboQuantError> {
60        if dim < 2 || dim % 2 != 0 {
61            return Err(TurboQuantError::InvalidDimension(dim));
62        }
63        Ok(Self {
64            dim,
65            rotation: Rotation::new(dim, rotation_seed),
66        })
67    }
68
69    #[inline]
70    pub fn dim(&self) -> usize {
71        self.dim
72    }
73
74    /// Stored bytes per vector: `D/2` nibbles + 8 bytes of constants.
75    #[inline]
76    pub fn code_len(&self) -> usize {
77        self.dim / 2 + META_BYTES
78    }
79
80    /// Query blob length: `D` int8 codes + 8 bytes of constants.
81    #[inline]
82    pub fn query_len(&self) -> usize {
83        self.dim + META_BYTES
84    }
85
86    /// Encode a vector into its Turbo4 code blob.
87    pub fn encode(&self, v: &[f32]) -> Result<Vec<u8>, TurboQuantError> {
88        if v.len() != self.dim {
89            return Err(TurboQuantError::DimensionMismatch {
90                expected: self.dim,
91                actual: v.len(),
92            });
93        }
94        let rotated = self.rotation.apply(v);
95        let norm_sq: f32 = rotated.iter().map(|x| x * x).sum();
96        let alpha = (norm_sq / self.dim as f32).sqrt();
97        Ok(self.encode_rotated(&rotated, alpha))
98    }
99
100    /// Encode both planes from one rotation pass: the Turbo4 code and the
101    /// 1-bit candidate-generation code (ADR-297 phase C). The bits blob
102    /// shares this codec's rotation, so a single query prep serves both.
103    pub fn encode_dual(&self, v: &[f32]) -> Result<(Vec<u8>, Vec<u8>), TurboQuantError> {
104        if v.len() != self.dim {
105            return Err(TurboQuantError::DimensionMismatch {
106                expected: self.dim,
107                actual: v.len(),
108            });
109        }
110        let rotated = self.rotation.apply(v);
111        let norm_sq: f32 = rotated.iter().map(|x| x * x).sum();
112        let alpha = (norm_sq / self.dim as f32).sqrt();
113        let bits = crate::bits1::encode_bits(&rotated, alpha);
114        let turbo4 = self.encode_rotated(&rotated, alpha);
115        Ok((turbo4, bits))
116    }
117
118    /// Pack an already-rotated vector (with its standardization factor) into
119    /// the Turbo4 blob — the shared tail of `encode` / `encode_dual`.
120    fn encode_rotated(&self, rotated: &[f32], alpha: f32) -> Vec<u8> {
121        let inv = if alpha > 0.0 { 1.0 / alpha } else { 0.0 };
122        let half = self.dim / 2;
123        let mut blob = vec![0u8; self.code_len()];
124        let mut s = 0.0f32;
125        for i in 0..half {
126            let c_lo = quantize_coord(rotated[i] * inv);
127            let c_hi = quantize_coord(rotated[i + half] * inv);
128            s += level(c_lo) * level(c_lo) + level(c_hi) * level(c_hi);
129            blob[i] = c_lo | (c_hi << 4);
130        }
131        blob[half..half + 4].copy_from_slice(&alpha.to_le_bytes());
132        blob[half + 4..half + 8].copy_from_slice(&s.to_le_bytes());
133        blob
134    }
135
136    /// Prepare a query for traversal + rescoring.
137    pub fn encode_query(&self, q: &[f32]) -> Result<Turbo4Query, TurboQuantError> {
138        if q.len() != self.dim {
139            return Err(TurboQuantError::DimensionMismatch {
140                expected: self.dim,
141                actual: q.len(),
142            });
143        }
144        let rotated = self.rotation.apply(q);
145        let norm_sq: f32 = rotated.iter().map(|x| x * x).sum();
146        let qmax = rotated.iter().fold(0.0f32, |m, x| m.max(x.abs()));
147        let qscale = if qmax > 0.0 { qmax / 127.0 } else { 0.0 };
148        let inv = if qscale > 0.0 { 1.0 / qscale } else { 0.0 };
149
150        let mut blob = vec![0u8; self.query_len()];
151        for (i, &x) in rotated.iter().enumerate() {
152            blob[i] = ((x * inv).round() as i8) as u8;
153        }
154        blob[self.dim..self.dim + 4].copy_from_slice(&qscale.to_le_bytes());
155        blob[self.dim + 4..self.dim + 8].copy_from_slice(&norm_sq.to_le_bytes());
156        Ok(Turbo4Query {
157            blob,
158            rotated,
159            norm_sq,
160        })
161    }
162
163    /// Reconstruct the *rotated-space* approximation from a code blob
164    /// (tests/debugging only — the search path never reconstructs).
165    pub fn decode_rotated(&self, blob: &[u8]) -> Vec<f32> {
166        let (nibbles, alpha, _) = split_code(blob, self.dim);
167        let half = self.dim / 2;
168        let mut out = vec![0.0f32; self.dim];
169        for i in 0..half {
170            out[i] = LEVELS_F32[(nibbles[i] & 0x0F) as usize] * alpha;
171            out[i + half] = LEVELS_F32[(nibbles[i] >> 4) as usize] * alpha;
172        }
173        out
174    }
175
176    /// Reconstruct the original-space approximation (inverse rotation applied).
177    pub fn decode(&self, blob: &[u8]) -> Vec<f32> {
178        self.rotation.apply_inverse(&self.decode_rotated(blob))
179    }
180}
181
182/// Split a code blob into (packed nibbles, α, S). `blob.len()` must equal
183/// `dim/2 + META_BYTES`.
184#[inline]
185pub fn split_code(blob: &[u8], dim: usize) -> (&[u8], f32, f32) {
186    let half = dim / 2;
187    assert_eq!(dim % 2, 0, "Turbo4 dimensions must be even");
188    assert_eq!(blob.len(), half + META_BYTES, "invalid Turbo4 code length");
189    let alpha = f32::from_le_bytes(blob[half..half + 4].try_into().unwrap());
190    let s = f32::from_le_bytes(blob[half + 4..half + 8].try_into().unwrap());
191    (&blob[..half], alpha, s)
192}
193
194/// Split a query blob into (int8 codes, qscale, ‖q‖²).
195#[inline]
196pub fn split_query(blob: &[u8], dim: usize) -> (&[u8], f32, f32) {
197    assert_eq!(dim % 2, 0, "Turbo4 dimensions must be even");
198    assert_eq!(blob.len(), dim + META_BYTES, "invalid Turbo4 query length");
199    let qscale = f32::from_le_bytes(blob[dim..dim + 4].try_into().unwrap());
200    let norm_sq = f32::from_le_bytes(blob[dim + 4..dim + 8].try_into().unwrap());
201    (&blob[..dim], qscale, norm_sq)
202}
203
204#[cfg(test)]
205mod tests {
206    use super::*;
207    use crate::rotation::SplitMix64;
208
209    fn gauss_vec(dim: usize, seed: u64) -> Vec<f32> {
210        let mut rng = SplitMix64(seed);
211        let mut out = Vec::with_capacity(dim);
212        while out.len() < dim {
213            let u1 = (rng.next_u64() >> 11) as f64 / (1u64 << 53) as f64;
214            let u2 = (rng.next_u64() >> 11) as f64 / (1u64 << 53) as f64;
215            let r = (-2.0 * u1.max(1e-12).ln()).sqrt();
216            let (s, c) = (2.0 * std::f64::consts::PI * u2).sin_cos();
217            out.push((r * c) as f32);
218            if out.len() < dim {
219                out.push((r * s) as f32);
220            }
221        }
222        out
223    }
224
225    #[test]
226    fn code_len_is_8x_compression() {
227        let codec = Turbo4Codec::new(1536, 42).unwrap();
228        assert_eq!(codec.code_len(), 768 + 8);
229        // 6144 f32 bytes / 776 = 7.92x
230        assert!(1536.0 * 4.0 / codec.code_len() as f32 > 7.5);
231    }
232
233    #[test]
234    fn rejects_odd_or_tiny_dims() {
235        assert!(Turbo4Codec::new(3, 42).is_err());
236        assert!(Turbo4Codec::new(0, 42).is_err());
237        assert!(Turbo4Codec::new(128, 42).is_ok());
238    }
239
240    #[test]
241    fn roundtrip_error_is_bounded() {
242        let dim = 256;
243        let codec = Turbo4Codec::new(dim, 42).unwrap();
244        let v = gauss_vec(dim, 3);
245        let blob = codec.encode(&v).unwrap();
246        let back = codec.decode(&blob);
247        // Lloyd-Max 4-bit on N(0,1) has ~0.009 MSE per unit variance;
248        // allow generous slack for rotation Gaussianization error.
249        let norm_sq: f32 = v.iter().map(|x| x * x).sum();
250        let err_sq: f32 = v.iter().zip(&back).map(|(a, b)| (a - b) * (a - b)).sum();
251        assert!(
252            err_sq / norm_sq < 0.05,
253            "relative sq error {}",
254            err_sq / norm_sq
255        );
256    }
257
258    #[test]
259    fn zero_vector_is_safe() {
260        let codec = Turbo4Codec::new(64, 42).unwrap();
261        let blob = codec.encode(&vec![0.0; 64]).unwrap();
262        let (_, alpha, _) = split_code(&blob, 64);
263        assert_eq!(alpha, 0.0);
264        assert!(codec.decode(&blob).iter().all(|&x| x == 0.0));
265        let q = codec.encode_query(&vec![0.0; 64]).unwrap();
266        assert_eq!(q.norm_sq, 0.0);
267    }
268
269    #[test]
270    fn blob_lengths_are_disjoint() {
271        for dim in [2usize, 64, 384, 1536] {
272            let codec = Turbo4Codec::new(dim, 1).unwrap();
273            assert_ne!(codec.code_len(), codec.query_len());
274        }
275    }
276
277    #[test]
278    fn encode_dual_matches_single_encoders() {
279        let dim = 128;
280        let codec = Turbo4Codec::new(dim, 42).unwrap();
281        let v = gauss_vec(dim, 17);
282        let (t4, bits) = codec.encode_dual(&v).unwrap();
283        assert_eq!(t4, codec.encode(&v).unwrap());
284        assert_eq!(bits.len(), crate::bits1::code1_len(dim));
285    }
286
287    #[test]
288    fn encoding_is_deterministic() {
289        let dim = 384;
290        let v = gauss_vec(dim, 9);
291        let c1 = Turbo4Codec::new(dim, 42).unwrap();
292        let c2 = Turbo4Codec::new(dim, 42).unwrap();
293        assert_eq!(c1.encode(&v).unwrap(), c2.encode(&v).unwrap());
294    }
295}