Skip to main content

ruvector_turboquant/
bits1.rs

1//! 1-bit sign codes over the Turbo4 rotation (ADR-297 phase C).
2//!
3//! The cheap candidate-generation plane: `ceil(D/8)` bytes per vector plus
4//! two f32 constants, scored by pure AND+POPCNT. Codes are derived from the
5//! **same** rotated, standardized coordinates as Turbo4 nibbles — one
6//! rotation pass yields both representations, and both stay bit-stable
7//! across platforms.
8//!
9//! ## Code blob (`ceil(D/8) + 8` bytes)
10//!
11//! ```text
12//! [ sign bits (LSB-first in LE u64 words) | α: f32 | c: f32 ]
13//! ```
14//!
15//! `α = ‖v‖/√D` (as Turbo4), `c = mean|zᵢ|` — the per-vector MSE-optimal
16//! 1-bit reconstruction scale, so `v ≈ α·c·s`, `sᵢ ∈ {±1}` (RaBitQ-style).
17//!
18//! ## Query: bit-plane decomposition
19//!
20//! The int8 query is biased to u8 and split into 8 bit-planes. For plane
21//! `p`: `Σᵢ planeₚᵢ·sᵢ = 2·pop(planeₚ ∧ bits) − pop(planeₚ)`, so
22//!
23//! ```text
24//! Σ q_u8·s = Σₚ 2ᵖ·(2·pop(planeₚ ∧ bits) − popₚ)
25//! Σ q_i8·s = Σ q_u8·s − 128·(2·pop(bits) − D)
26//! dot(q,v) ≈ qscale · α · c · Σ q_i8·s
27//! ```
28//!
29//! Per candidate that is 9 masked-popcount passes over `D/64` words — with
30//! ~4× less memory traffic than the Turbo4 nibble kernel, which is the point:
31//! at scale, traversal is bandwidth-bound.
32
33use crate::codec::META_BYTES;
34use crate::score::Metric;
35use crate::TurboQuantError;
36
37/// Bytes of sign bits for `dim` dimensions, padded to whole u64 words so the
38/// popcount kernel needs no tail handling.
39#[inline]
40pub fn bits_len(dim: usize) -> usize {
41    dim.div_ceil(64) * 8
42}
43
44/// Full 1-bit code blob length.
45#[inline]
46pub fn code1_len(dim: usize) -> usize {
47    bits_len(dim) + META_BYTES
48}
49
50/// Encode the 1-bit blob from rotated coordinates and the standardization
51/// factor `alpha` (both already computed by the Turbo4 encoder — pass
52/// `rotated` and `alpha` straight through; `rotated` must be `dim` long).
53pub fn encode_bits(rotated: &[f32], alpha: f32) -> Vec<u8> {
54    let dim = rotated.len();
55    let n_words = dim.div_ceil(64);
56    let mut words = vec![0u64; n_words];
57    let mut abs_sum = 0.0f32;
58    let inv = if alpha > 0.0 { 1.0 / alpha } else { 0.0 };
59    for (i, &r) in rotated.iter().enumerate() {
60        let z = r * inv;
61        abs_sum += z.abs();
62        if z >= 0.0 {
63            words[i / 64] |= 1u64 << (i % 64);
64        }
65    }
66    let c = abs_sum / dim as f32;
67    let mut blob = Vec::with_capacity(code1_len(dim));
68    for w in &words {
69        blob.extend_from_slice(&w.to_le_bytes());
70    }
71    blob.extend_from_slice(&alpha.to_le_bytes());
72    blob.extend_from_slice(&c.to_le_bytes());
73    blob
74}
75
76/// A query prepared for popcount scoring against 1-bit codes.
77pub struct Bits1Query {
78    /// 8 bit-planes of the biased (u8) query, each `bits_len(dim)` bytes as
79    /// LE u64 words, plane 0 = LSB.
80    planes: [Vec<u64>; 8],
81    /// Per-plane popcount over real dims.
82    plane_pops: [u32; 8],
83    /// `qscale` from the int8 quantization (`q_i8 = round(q_rot/qscale)`).
84    qscale: f32,
85    /// Exact ‖q‖².
86    pub norm_sq: f32,
87    dim: usize,
88}
89
90impl Bits1Query {
91    /// Build from the rotated f32 query (same input the Turbo4 query prep
92    /// uses; `qscale`/`norm_sq` semantics identical to `encode_query`).
93    pub fn new(rotated: &[f32]) -> Result<Self, TurboQuantError> {
94        let dim = rotated.len();
95        if dim < 2 {
96            return Err(TurboQuantError::InvalidDimension(dim));
97        }
98        let norm_sq: f32 = rotated.iter().map(|x| x * x).sum();
99        let qmax = rotated.iter().fold(0.0f32, |m, x| m.max(x.abs()));
100        let qscale = if qmax > 0.0 { qmax / 127.0 } else { 0.0 };
101        let inv = if qscale > 0.0 { 1.0 / qscale } else { 0.0 };
102
103        let n_words = dim.div_ceil(64);
104        let mut planes: [Vec<u64>; 8] = std::array::from_fn(|_| vec![0u64; n_words]);
105        let mut plane_pops = [0u32; 8];
106        for (i, &x) in rotated.iter().enumerate() {
107            let q_u8 = ((x * inv).round() as i8 as i16 + 128) as u16 as u8;
108            for (p, plane) in planes.iter_mut().enumerate() {
109                if q_u8 >> p & 1 != 0 {
110                    plane[i / 64] |= 1u64 << (i % 64);
111                    plane_pops[p] += 1;
112                }
113            }
114        }
115        Ok(Self {
116            planes,
117            plane_pops,
118            qscale,
119            norm_sq,
120            dim,
121        })
122    }
123
124    /// Estimated distance to a 1-bit code blob under `metric`. Pure
125    /// AND+POPCNT per plane; conventions match the Turbo4 scorers.
126    pub fn distance_to(&self, metric: Metric, blob: &[u8]) -> f32 {
127        let qblob = self.to_blob();
128        query_blob_distance(metric, &qblob, blob, self.dim)
129    }
130
131    /// Serialize into the flat query-blob form consumed by
132    /// [`query_blob_distance`] — this is what travels through
133    /// `hnsw_rs::Distance<u8>::eval` in cascade mode.
134    ///
135    /// Layout: `[8 planes × bits_len | 8 × plane_pop u32 | qscale | ‖q‖²]`.
136    pub fn to_blob(&self) -> Vec<u8> {
137        let bl = bits_len(self.dim);
138        let mut out = Vec::with_capacity(query1_len(self.dim));
139        for plane in &self.planes {
140            for w in plane {
141                out.extend_from_slice(&w.to_le_bytes());
142            }
143        }
144        for p in &self.plane_pops {
145            out.extend_from_slice(&p.to_le_bytes());
146        }
147        out.extend_from_slice(&self.qscale.to_le_bytes());
148        out.extend_from_slice(&self.norm_sq.to_le_bytes());
149        debug_assert_eq!(out.len(), 8 * bl + 40);
150        out
151    }
152}
153
154/// Query-blob length for `dim` (`8·bits_len + 40`).
155#[inline]
156pub fn query1_len(dim: usize) -> usize {
157    8 * bits_len(dim) + 40
158}
159
160/// Score a serialized 1-bit query blob (see [`Bits1Query::to_blob`]) against
161/// a 1-bit code blob, on raw slices — no allocation, callable from inside a
162/// `Distance<u8>` functor.
163pub fn query_blob_distance(metric: Metric, qblob: &[u8], code: &[u8], dim: usize) -> f32 {
164    let n_words = dim.div_ceil(64);
165    let bl = n_words * 8;
166    assert!(dim >= 2, "1-bit query dimensions must be at least 2");
167    assert_eq!(qblob.len(), 8 * bl + 40, "invalid 1-bit query length");
168    assert_eq!(code.len(), bl + META_BYTES, "invalid 1-bit code length");
169
170    let alpha = f32::from_le_bytes(code[bl..bl + 4].try_into().unwrap());
171    let c = f32::from_le_bytes(code[bl + 4..bl + 8].try_into().unwrap());
172    let qscale = f32::from_le_bytes(qblob[8 * bl + 32..8 * bl + 36].try_into().unwrap());
173    let q_norm_sq = f32::from_le_bytes(qblob[8 * bl + 36..8 * bl + 40].try_into().unwrap());
174
175    let word = |bytes: &[u8], k: usize| -> u64 {
176        u64::from_le_bytes(bytes[k * 8..k * 8 + 8].try_into().unwrap())
177    };
178
179    let mut bits_pop = 0u32;
180    for k in 0..n_words {
181        bits_pop += word(code, k).count_ones();
182    }
183    let mut dot_u8 = 0i64;
184    for p in 0..8 {
185        let plane = &qblob[p * bl..(p + 1) * bl];
186        let pop_p = u32::from_le_bytes(
187            qblob[8 * bl + p * 4..8 * bl + p * 4 + 4]
188                .try_into()
189                .unwrap(),
190        );
191        let mut agree = 0u32;
192        for k in 0..n_words {
193            agree += (word(plane, k) & word(code, k)).count_ones();
194        }
195        dot_u8 += (1i64 << p) * (2 * agree as i64 - pop_p as i64);
196    }
197    let sum_s = 2 * bits_pop as i64 - dim as i64;
198    let dot_i8 = dot_u8 - 128 * sum_s;
199    let dot = qscale * alpha * c * dot_i8 as f32;
200
201    let norm_sq_v = alpha * alpha * dim as f32; // exact
202    match metric {
203        Metric::Euclidean => (q_norm_sq + norm_sq_v - 2.0 * dot).max(0.0).sqrt(),
204        Metric::Cosine => {
205            let denom = (q_norm_sq * norm_sq_v).sqrt();
206            if denom > 0.0 {
207                (1.0 - dot / denom).max(0.0)
208            } else {
209                1.0
210            }
211        }
212        Metric::DotProduct => (-dot).max(0.0),
213    }
214}
215
216#[cfg(test)]
217mod tests {
218    use super::*;
219    use crate::codec::Turbo4Codec;
220    use crate::rotation::{Rotation, SplitMix64};
221
222    fn gauss_vec(dim: usize, seed: u64) -> Vec<f32> {
223        let mut rng = SplitMix64(seed);
224        let mut out = Vec::with_capacity(dim);
225        while out.len() < dim {
226            let u1 = (rng.next_u64() >> 11) as f64 / (1u64 << 53) as f64;
227            let u2 = (rng.next_u64() >> 11) as f64 / (1u64 << 53) as f64;
228            let r = (-2.0 * u1.max(1e-12).ln()).sqrt();
229            let (s, c) = (2.0 * std::f64::consts::PI * u2).sin_cos();
230            out.push((r * c) as f32);
231            if out.len() < dim {
232                out.push((r * s) as f32);
233            }
234        }
235        out
236    }
237
238    /// Popcount path must equal the naive Σ q_i8·sᵢ sign-sum exactly.
239    #[test]
240    fn bitplane_dot_matches_naive_sign_sum() {
241        for dim in [64usize, 100, 384, 1536] {
242            let rot = Rotation::new(dim, 42);
243            for seed in 0..4u64 {
244                let v = gauss_vec(dim, seed + 1);
245                let q = gauss_vec(dim, seed + 100);
246                let rv = rot.apply(&v);
247                let rq = rot.apply(&q);
248                let norm_sq: f32 = rv.iter().map(|x| x * x).sum();
249                let alpha = (norm_sq / dim as f32).sqrt();
250                let blob = encode_bits(&rv, alpha);
251                let query = Bits1Query::new(&rq).unwrap();
252
253                // Naive: identical int8 quantization, direct sign sum.
254                let qmax = rq.iter().fold(0.0f32, |m, x| m.max(x.abs()));
255                let qscale = if qmax > 0.0 { qmax / 127.0 } else { 0.0 };
256                let inv_a = if alpha > 0.0 { 1.0 / alpha } else { 0.0 };
257                let mut naive = 0i64;
258                for i in 0..dim {
259                    let qi = (rq[i] / qscale).round() as i8 as i64;
260                    let s = if rv[i] * inv_a >= 0.0 { 1 } else { -1 };
261                    naive += qi * s;
262                }
263                let n_words = dim.div_ceil(64);
264                let alpha_read =
265                    f32::from_le_bytes(blob[n_words * 8..n_words * 8 + 4].try_into().unwrap());
266                let c =
267                    f32::from_le_bytes(blob[n_words * 8 + 4..n_words * 8 + 8].try_into().unwrap());
268                let expected_dot = qscale * alpha_read * c * naive as f32;
269                // Recover the kernel's dot from the Euclidean output.
270                let d = query.distance_to(Metric::Euclidean, &blob);
271                let norm_sq_v = alpha_read * alpha_read * dim as f32;
272                let kernel_dot = (query.norm_sq + norm_sq_v - d * d) / 2.0;
273                assert!(
274                    (kernel_dot - expected_dot).abs() <= 1e-2 * expected_dot.abs().max(1.0),
275                    "dim {dim} seed {seed}: kernel dot {kernel_dot} vs naive {expected_dot}"
276                );
277            }
278        }
279    }
280
281    /// 1-bit candidate generation: with 4x oversampling the 1-bit plane must
282    /// retain the true top-10 well enough for Turbo4 rescoring to recover it.
283    #[test]
284    fn candidate_generation_recall_with_oversampling() {
285        let dim = 128;
286        let n = 300;
287        let codec = Turbo4Codec::new(dim, 42).unwrap();
288        let rot = Rotation::new(dim, 42);
289        let base: Vec<Vec<f32>> = (0..n as u64).map(|i| gauss_vec(dim, 500 + i)).collect();
290        let blobs: Vec<Vec<u8>> = base
291            .iter()
292            .map(|v| {
293                let rv = rot.apply(v);
294                let norm_sq: f32 = rv.iter().map(|x| x * x).sum();
295                encode_bits(&rv, (norm_sq / dim as f32).sqrt())
296            })
297            .collect();
298        let t4codes: Vec<Vec<u8>> = base.iter().map(|v| codec.encode(v).unwrap()).collect();
299
300        let mut total_hits = 0usize;
301        for qs in 0..10u64 {
302            let q = gauss_vec(dim, 9000 + qs);
303            let rq = rot.apply(&q);
304            let bq = Bits1Query::new(&rq).unwrap();
305            let tq = codec.encode_query(&q).unwrap();
306
307            let l2 =
308                |a: &[f32], b: &[f32]| a.iter().zip(b).map(|(x, y)| (x - y) * (x - y)).sum::<f32>();
309            let mut truth: Vec<(usize, f32)> = base
310                .iter()
311                .enumerate()
312                .map(|(i, v)| (i, l2(&q, v)))
313                .collect();
314            truth.sort_by(|a, b| a.1.total_cmp(&b.1));
315            let top10: std::collections::HashSet<usize> =
316                truth[..10].iter().map(|(i, _)| *i).collect();
317
318            // Stage 1: 1-bit candidates, 4x oversampled.
319            let mut stage1: Vec<(usize, f32)> = blobs
320                .iter()
321                .enumerate()
322                .map(|(i, b)| (i, bq.distance_to(Metric::Euclidean, b)))
323                .collect();
324            stage1.sort_by(|a, b| a.1.total_cmp(&b.1));
325            // Stage 2: Turbo4 exact-LUT rescore of the top 40.
326            let mut stage2: Vec<(usize, f32)> = stage1[..40]
327                .iter()
328                .map(|&(i, _)| {
329                    (
330                        i,
331                        crate::score::rescore(Metric::Euclidean, &tq, &t4codes[i], dim),
332                    )
333                })
334                .collect();
335            stage2.sort_by(|a, b| a.1.total_cmp(&b.1));
336            total_hits += stage2[..10]
337                .iter()
338                .filter(|(i, _)| top10.contains(i))
339                .count();
340        }
341        let recall = total_hits as f32 / 100.0;
342        assert!(
343            recall >= 0.70,
344            "1-bit cascade recall@10 {recall} below floor on Gaussian worst case"
345        );
346    }
347
348    #[test]
349    fn zero_vector_is_safe() {
350        let dim = 64;
351        let blob = encode_bits(&vec![0.0; dim], 0.0);
352        let q = Bits1Query::new(&vec![0.0; dim]).unwrap();
353        let d = q.distance_to(Metric::Euclidean, &blob);
354        assert_eq!(d, 0.0);
355        assert_eq!(q.distance_to(Metric::Cosine, &blob), 1.0);
356    }
357
358    #[test]
359    fn blob_length_is_word_padded() {
360        assert_eq!(code1_len(64), 8 + 8);
361        assert_eq!(code1_len(100), 16 + 8);
362        assert_eq!(code1_len(1536), 192 + 8);
363    }
364}