Skip to main content

vecq_core/
store.rs

1//! Quantized store: nibble-packed 4-bit codes + per-vector norm correction,
2//! with asymmetric inner-product scoring (query f32, database 4-bit).
3
4use crate::lloyd;
5use crate::rhdh::{padded_dim, Rhdh};
6
7/// A quantized vector database in memory.
8///
9/// Each vector is stored as `padded_dim / 2` bytes of 4-bit Lloyd-Max codes
10/// (computed after RHDH rotation) plus one f32 correction factor. The score
11/// against an f32 query is an unbiased estimate of the cosine similarity
12/// after undoing the per-vector quantization scale.
13pub struct VecqIndex {
14    pub(crate) dim: usize,
15    padded: usize,
16    pub(crate) seed: u64,
17    transform: Rhdh,
18    pub(crate) codes: Vec<u8>, // n * padded/2 nibbles, low nibble = dim i*2
19    pub(crate) scales: Vec<f32>, // per-vector dequantization scale
20    pub(crate) n: usize,
21}
22
23impl VecqIndex {
24    /// Create an empty index for `dim`-dimensional unit vectors.
25    /// `seed` must be persisted with the index for cross-platform determinism.
26    pub fn new(dim: usize, seed: u64) -> Self {
27        let padded = padded_dim(dim);
28        Self {
29            dim,
30            padded,
31            seed,
32            transform: Rhdh::new(dim, seed),
33            codes: Vec::new(),
34            scales: Vec::new(),
35            n: 0,
36        }
37    }
38
39    pub fn len(&self) -> usize {
40        self.n
41    }
42
43    pub fn is_empty(&self) -> bool {
44        self.n == 0
45    }
46
47    pub fn dim(&self) -> usize {
48        self.dim
49    }
50
51    pub fn seed(&self) -> u64 {
52        self.seed
53    }
54
55    #[cfg(test)]
56    pub(crate) fn padded(&self) -> usize {
57        self.padded
58    }
59
60    /// Quantize and add one vector (any norm; normalized internally).
61    pub fn add(&mut self, v: &[f32]) {
62        assert_eq!(v.len(), self.dim, "vector dim mismatch");
63        let norm: f32 = v.iter().map(|x| x * x).sum::<f32>().sqrt();
64        assert!(norm > 0.0, "zero vector");
65        let unit: Vec<f32> = v.iter().map(|x| x / norm).collect();
66
67        let mut rotated = Vec::with_capacity(self.padded);
68        self.transform.apply(&unit, &mut rotated);
69
70        // Quantize to 4-bit codes, nibble-packed.
71        let bytes_per_vec = self.padded / 2;
72        let base = self.codes.len();
73        self.codes.resize(base + bytes_per_vec, 0);
74        let mut sum_sq = 0f32;
75        for (i, &x) in rotated.iter().enumerate() {
76            let code = lloyd::quantize_4bit(x);
77            let b = base + i / 2;
78            if i % 2 == 0 {
79                self.codes[b] |= code;
80            } else {
81                self.codes[b] |= code << 4;
82            }
83            sum_sq += lloyd::dequantize_4bit(code).powi(2);
84        }
85
86        // Scale so that the stored vector is unit-norm: dequantized vector q
87        // has norm sqrt(sum_sq); asymmetric scoring multiplies by 1/sqrt(sum_sq).
88        self.scales.push(1.0 / sum_sq.sqrt());
89        self.n += 1;
90    }
91
92    /// Prepare an f32 query in rotated space (call once per query).
93    pub fn prepare_query(&self, q: &[f32]) -> PreparedQuery {
94        assert_eq!(q.len(), self.dim);
95        let norm: f32 = q.iter().map(|x| x * x).sum::<f32>().sqrt();
96        let unit: Vec<f32> = q.iter().map(|x| x / norm).collect();
97        let mut rotated = Vec::with_capacity(self.padded);
98        self.transform.apply(&unit, &mut rotated);
99        // Normalize so ||rotated|| == 1 despite the unnormalized FWHT
100        // (which scales norms by sqrt(padded)).
101        let rnorm: f32 = rotated.iter().map(|x| x * x).sum::<f32>().sqrt();
102        for x in rotated.iter_mut() {
103            *x /= rnorm;
104        }
105        // Precompute dequantized lookup for fast scoring: for each of the 16
106        // codes, the contribution when multiplied with the query coordinate.
107        let mut lut = [0f32; 16];
108        for (c, slot) in lut.iter_mut().enumerate() {
109            *slot = lloyd::dequantize_4bit(c as u8);
110        }
111        PreparedQuery { rotated, lut, norm }
112    }
113
114    /// Asymmetric score of vector `idx` against a prepared query.
115    /// Returns estimated cosine similarity in [-1, 1].
116    ///
117    /// Dispatches to the explicit NEON path on aarch64 and the fixed
118    /// 8-bucket scalar path elsewhere. Both use the identical association
119    /// order (per code byte: mul, mul, add, then add into bucket j; final
120    /// pairwise tree), so they produce the same f32 bits — guarded by
121    /// `neon_matches_scalar_bitwise` in tests.
122    #[inline]
123    pub fn score(&self, pq: &PreparedQuery, idx: usize) -> f32 {
124        let base = idx * (self.padded / 2);
125        let codes = &self.codes[base..base + self.padded / 2];
126        let q = &pq.rotated[..self.padded];
127        #[cfg(target_arch = "aarch64")]
128        {
129            // NEON is baseline on aarch64.
130            let raw = unsafe { neon::score_neon(codes, q, &pq.lut) };
131            raw * self.scales[idx]
132        }
133        #[cfg(not(target_arch = "aarch64"))]
134        {
135            score_scalar(codes, q, &pq.lut) * self.scales[idx]
136        }
137    }
138
139    /// Brute-force top-k search. Returns (index, score) sorted by score desc.
140    ///
141    /// Uses a bounded min-heap of size k (no O(n log n) sort, no O(n)
142    /// allocation per query): push while the heap is not full, then only
143    /// push-and-pop when the candidate beats the current k-th score.
144    pub fn search(&self, q: &[f32], k: usize) -> Vec<(usize, f32)> {
145        use std::cmp::Reverse;
146        use std::collections::BinaryHeap;
147
148        let pq = self.prepare_query(q);
149        let k = k.min(self.n).max(1);
150        #[cfg(target_arch = "aarch64")]
151        let bpv = self.padded / 2;
152        // f32 -> u32 monotonic key (NaN-safe, preserves total order):
153        // flip all bits for negatives, flip sign bit for positives.
154        let key = |s: f32| -> u32 {
155            let b = s.to_bits();
156            if b & 0x8000_0000 != 0 {
157                !b
158            } else {
159                b ^ 0x8000_0000
160            }
161        };
162        let mut heap: BinaryHeap<Reverse<(u32, usize)>> = BinaryHeap::with_capacity(k + 1);
163        let consider = |s: f32, idx: usize, heap: &mut BinaryHeap<Reverse<(u32, usize)>>| {
164            let ks = key(s);
165            if heap.len() < k {
166                heap.push(Reverse((ks, idx)));
167            } else if ks > heap.peek().map(|r| r.0 .0).unwrap_or(0) {
168                heap.push(Reverse((ks, idx)));
169                heap.pop();
170            }
171        };
172        #[cfg(target_arch = "aarch64")]
173        let q_rot = &pq.rotated[..self.padded];
174        let mut idx = 0;
175        #[cfg(target_arch = "aarch64")]
176        {
177            // Batch 4 vectors per pass: shared q loads + LUT setup.
178            while idx + 4 <= self.n {
179                let codes4 = &self.codes[idx * bpv..(idx + 4) * bpv];
180                let raw = unsafe { neon::score_neon4(codes4, q_rot, &pq.lut) };
181                for (v, &r) in raw.iter().enumerate() {
182                    consider(r * self.scales[idx + v], idx + v, &mut heap);
183                }
184                idx += 4;
185            }
186        }
187        while idx < self.n {
188            consider(self.score(&pq, idx), idx, &mut heap);
189            idx += 1;
190        }
191        // Inverse of `key`: undo the sign flip to recover the exact f32 bits.
192        let key_undo = |k: u32| -> u32 {
193            if k & 0x8000_0000 != 0 {
194                k ^ 0x8000_0000 // was a positive float
195            } else {
196                !k // was a negative float
197            }
198        };
199        let mut out: Vec<(usize, f32)> = heap
200            .into_iter()
201            .map(|r| (r.0 .1, f32::from_bits(key_undo(r.0 .0))))
202            .collect();
203        out.sort_by(|a, b| b.1.partial_cmp(&a.1).expect("no NaN scores"));
204        out
205    }
206}
207
208/// Reference 8-bucket scalar scoring. Bucket j accumulates byte j, j+8, ...
209/// of every 8-byte block; final reduction is a fixed pairwise tree.
210#[cfg_attr(target_arch = "aarch64", cfg(test))]
211pub(crate) fn score_scalar(codes: &[u8], q: &[f32], lut: &[f32; 16]) -> f32 {
212    let nb = codes.len();
213    let mut acc = [0f32; 8];
214    let mut i = 0;
215    while i + 8 <= nb {
216        for j in 0..8 {
217            let b = codes[i + j];
218            let c = (i + j) * 2;
219            acc[j] += q[c] * lut[(b & 0x0F) as usize] + q[c + 1] * lut[(b >> 4) as usize];
220        }
221        i += 8;
222    }
223    let mut tail = 0f32;
224    while i < nb {
225        let b = codes[i];
226        let c = i * 2;
227        tail += q[c] * lut[(b & 0x0F) as usize] + q[c + 1] * lut[(b >> 4) as usize];
228        i += 1;
229    }
230    let s01 = acc[0] + acc[1];
231    let s23 = acc[2] + acc[3];
232    let s45 = acc[4] + acc[5];
233    let s67 = acc[6] + acc[7];
234    (s01 + s23) + (s45 + s67) + tail
235}
236
237#[cfg(target_arch = "aarch64")]
238mod neon {
239    //! Explicit NEON scoring path, bit-identical to [`score_scalar`].
240    //!
241    //! Why manual intrinsics: the scalar loop gathers `lut[nibble]` with a
242    //! data-dependent index, which LLVM's vectorizer refuses to
243    //! auto-vectorize (verified in disassembly: zero fmla in `search`).
244    //! The LUT gather maps naturally to `vqtbl4q_u8`.
245    //!
246    //! Bit-identity with the scalar path is structural: per 8-byte block,
247    //! byte j's term `q_even*lut[lo] + q_odd*lut[hi]` (vmul, vmul, vadd —
248    //! Rust never contracts into FMA) is added into accumulator lane j,
249    //! blocks in increasing order, and the final reduction uses the same
250    //! pairwise tree. Guarded by `neon_matches_scalar_bitwise`.
251    use std::arch::aarch64::*;
252
253    /// Gather 16 f32 from the 16-entry LUT given per-lane nibble indices.
254    ///
255    /// The 64-byte LUT (16 little-endian f32) is a `uint8x16x4_t` table.
256    /// Four `vqtbl4q_u8` gathers produce byte-plane k (k=0..3) of all 16
257    /// floats; a 4x16 byte transpose then rebuilds the 4 f32x4 registers.
258    #[inline]
259    unsafe fn gather16(tbl: uint8x16x4_t, nibbles: uint8x16_t) -> [float32x4_t; 4] {
260        let idx = vmulq_u8(nibbles, vdupq_n_u8(4)); // byte offset of each lane's float
261        let one = vdupq_n_u8(1);
262        let two = vdupq_n_u8(2);
263        let b0 = vqtbl4q_u8(tbl, idx);
264        let b1 = vqtbl4q_u8(tbl, vaddq_u8(idx, one));
265        let b2 = vqtbl4q_u8(tbl, vaddq_u8(idx, two));
266        let b3 = vqtbl4q_u8(tbl, vaddq_u8(idx, vdupq_n_u8(3)));
267        // Transpose: float j = (b0[j], b1[j], b2[j], b3[j]).
268        let z01 = vzip1q_u8(b0, b1); // u16 lanes (b0j, b1j)
269        let z23 = vzip1q_u8(b2, b3); // u16 lanes (b2j, b3j)
270        let z01b = vzip2q_u8(b0, b1);
271        let z23b = vzip2q_u8(b2, b3);
272        let lo16 = vreinterpretq_u16_u8(z01);
273        let hi16 = vreinterpretq_u16_u8(z23);
274        let lo16b = vreinterpretq_u16_u8(z01b);
275        let hi16b = vreinterpretq_u16_u8(z23b);
276        [
277            vreinterpretq_f32_u8(vreinterpretq_u8_u16(vzip1q_u16(lo16, hi16))),
278            vreinterpretq_f32_u8(vreinterpretq_u8_u16(vzip2q_u16(lo16, hi16))),
279            vreinterpretq_f32_u8(vreinterpretq_u8_u16(vzip1q_u16(lo16b, hi16b))),
280            vreinterpretq_f32_u8(vreinterpretq_u8_u16(vzip2q_u16(lo16b, hi16b))),
281        ]
282    }
283
284    /// NEON scoring over one vector's codes. See module docs.
285    #[inline]
286    pub unsafe fn score_neon(codes: &[u8], q: &[f32], lut: &[f32; 16]) -> f32 {
287        // Build the 64-byte LUT table for vqtbl4q_u8.
288        let mut bytes = [0u8; 64];
289        for (c, &v) in lut.iter().enumerate() {
290            bytes[c * 4..c * 4 + 4].copy_from_slice(&v.to_le_bytes());
291        }
292        let tbl = uint8x16x4_t(
293            vld1q_u8(bytes[0..16].as_ptr()),
294            vld1q_u8(bytes[16..32].as_ptr()),
295            vld1q_u8(bytes[32..48].as_ptr()),
296            vld1q_u8(bytes[48..64].as_ptr()),
297        );
298        // acc_lo lanes 0-3 = scalar buckets 0-3; acc_hi lanes 0-3 = 4-7.
299        let mut acc_lo = vdupq_n_f32(0.0);
300        let mut acc_hi = vdupq_n_f32(0.0);
301        let nb = codes.len();
302        let mut i = 0;
303        while i + 8 <= nb {
304            let b8 = vld1_u8(codes.as_ptr().add(i)); // 8 code bytes (safe load)
305                                                     // Nibble layout for the gather: lanes 0-7 = low nibbles (even
306                                                     // dims), lanes 8-15 = high nibbles (odd dims).
307            let lo = vand_u8(b8, vdup_n_u8(0x0F));
308            let hi = vshr_n_u8(b8, 4);
309            let nibbles = vcombine_u8(lo, hi); // [lo_0..lo_7, hi_0..hi_7]
310            let g = gather16(tbl, nibbles);
311            // g[0] = lut[lo_0..3], g[1] = lut[lo_4..7],
312            // g[2] = lut[hi_0..3], g[3] = lut[hi_4..7].
313            // Load q[2i .. 2i+16) and deinterleave even/odd dims.
314            let q0 = vld1q_f32(q.as_ptr().add(i * 2));
315            let q1 = vld1q_f32(q.as_ptr().add(i * 2 + 4));
316            let q2 = vld1q_f32(q.as_ptr().add(i * 2 + 8));
317            let q3 = vld1q_f32(q.as_ptr().add(i * 2 + 12));
318            let q_even_lo = vuzp1q_f32(q0, q1); // dims 2i, 2i+2, 2i+4, 2i+6
319            let q_even_hi = vuzp1q_f32(q2, q3);
320            let q_odd_lo = vuzp2q_f32(q0, q1);
321            let q_odd_hi = vuzp2q_f32(q2, q3);
322            // term = q_even*lut[lo] + q_odd*lut[hi]  (mul, mul, add — no FMA)
323            let t_lo = vaddq_f32(vmulq_f32(q_even_lo, g[0]), vmulq_f32(q_odd_lo, g[2]));
324            let t_hi = vaddq_f32(vmulq_f32(q_even_hi, g[1]), vmulq_f32(q_odd_hi, g[3]));
325            acc_lo = vaddq_f32(acc_lo, t_lo);
326            acc_hi = vaddq_f32(acc_hi, t_hi);
327            i += 8;
328        }
329        // Extract buckets and reduce with the scalar pairwise tree.
330        let mut acc = [0f32; 8];
331        acc[0] = vgetq_lane_f32(acc_lo, 0);
332        acc[1] = vgetq_lane_f32(acc_lo, 1);
333        acc[2] = vgetq_lane_f32(acc_lo, 2);
334        acc[3] = vgetq_lane_f32(acc_lo, 3);
335        acc[4] = vgetq_lane_f32(acc_hi, 0);
336        acc[5] = vgetq_lane_f32(acc_hi, 1);
337        acc[6] = vgetq_lane_f32(acc_hi, 2);
338        acc[7] = vgetq_lane_f32(acc_hi, 3);
339        // Scalar tail for the last (< 8) code bytes. padded is a multiple of
340        // 8 elements (padded/2 bytes multiple of 4), so nb % 8 is 0 or 4.
341        let mut tail = 0f32;
342        while i < nb {
343            let b = codes[i];
344            let c = i * 2;
345            tail += q[c] * lut[(b & 0x0F) as usize] + q[c + 1] * lut[(b >> 4) as usize];
346            i += 1;
347        }
348        let s01 = acc[0] + acc[1];
349        let s23 = acc[2] + acc[3];
350        let s45 = acc[4] + acc[5];
351        let s67 = acc[6] + acc[7];
352        (s01 + s23) + (s45 + s67) + tail
353    }
354
355    /// Score 4 consecutive vectors at once, amortizing the q loads and LUT
356    /// table setup across all 4. Each vector accumulates in the exact same
357    /// per-lane order as [`score_neon`], so results are bit-identical.
358    ///
359    /// Returns raw (pre-scale) scores; the caller multiplies by `scales`.
360    #[inline]
361    pub unsafe fn score_neon4(codes4: &[u8], q: &[f32], lut: &[f32; 16]) -> [f32; 4] {
362        let mut bytes = [0u8; 64];
363        for (c, &v) in lut.iter().enumerate() {
364            bytes[c * 4..c * 4 + 4].copy_from_slice(&v.to_le_bytes());
365        }
366        let tbl = uint8x16x4_t(
367            vld1q_u8(bytes[0..16].as_ptr()),
368            vld1q_u8(bytes[16..32].as_ptr()),
369            vld1q_u8(bytes[32..48].as_ptr()),
370            vld1q_u8(bytes[48..64].as_ptr()),
371        );
372        let nb = codes4.len() / 4; // bytes per vector
373        let mut acc_lo = [vdupq_n_f32(0.0); 4];
374        let mut acc_hi = [vdupq_n_f32(0.0); 4];
375        let mut i = 0;
376        while i + 8 <= nb {
377            // Shared q loads for this block: q[2i .. 2i+16), deinterleaved.
378            let q0 = vld1q_f32(q.as_ptr().add(i * 2));
379            let q1 = vld1q_f32(q.as_ptr().add(i * 2 + 4));
380            let q2 = vld1q_f32(q.as_ptr().add(i * 2 + 8));
381            let q3 = vld1q_f32(q.as_ptr().add(i * 2 + 12));
382            let q_even_lo = vuzp1q_f32(q0, q1);
383            let q_even_hi = vuzp1q_f32(q2, q3);
384            let q_odd_lo = vuzp2q_f32(q0, q1);
385            let q_odd_hi = vuzp2q_f32(q2, q3);
386            for v in 0..4 {
387                let b8 = vld1_u8(codes4.as_ptr().add(v * nb + i));
388                let lo = vand_u8(b8, vdup_n_u8(0x0F));
389                let hi = vshr_n_u8(b8, 4);
390                let nibbles = vcombine_u8(lo, hi);
391                let g = gather16(tbl, nibbles);
392                let t_lo = vaddq_f32(vmulq_f32(q_even_lo, g[0]), vmulq_f32(q_odd_lo, g[2]));
393                let t_hi = vaddq_f32(vmulq_f32(q_even_hi, g[1]), vmulq_f32(q_odd_hi, g[3]));
394                acc_lo[v] = vaddq_f32(acc_lo[v], t_lo);
395                acc_hi[v] = vaddq_f32(acc_hi[v], t_hi);
396            }
397            i += 8;
398        }
399        let mut out = [0f32; 4];
400        for v in 0..4 {
401            // Same lane extraction + pairwise reduction as score_neon.
402            let mut a = [0f32; 8];
403            a[0] = vgetq_lane_f32(acc_lo[v], 0);
404            a[1] = vgetq_lane_f32(acc_lo[v], 1);
405            a[2] = vgetq_lane_f32(acc_lo[v], 2);
406            a[3] = vgetq_lane_f32(acc_lo[v], 3);
407            a[4] = vgetq_lane_f32(acc_hi[v], 0);
408            a[5] = vgetq_lane_f32(acc_hi[v], 1);
409            a[6] = vgetq_lane_f32(acc_hi[v], 2);
410            a[7] = vgetq_lane_f32(acc_hi[v], 3);
411            // Scalar tail for the last (< 8) code bytes.
412            let mut tail = 0f32;
413            let mut j = i;
414            while j < nb {
415                let b = codes4[v * nb + j];
416                let c = j * 2;
417                tail += q[c] * lut[(b & 0x0F) as usize] + q[c + 1] * lut[(b >> 4) as usize];
418                j += 1;
419            }
420            let s01 = a[0] + a[1];
421            let s23 = a[2] + a[3];
422            let s45 = a[4] + a[5];
423            let s67 = a[6] + a[7];
424            out[v] = (s01 + s23) + (s45 + s67) + tail;
425        }
426        out
427    }
428}
429
430/// A query preprocessed in the quantized domain.
431pub struct PreparedQuery {
432    rotated: Vec<f32>,
433    lut: [f32; 16],
434    #[allow(dead_code)]
435    norm: f32,
436}
437
438/// Exact cosine similarity between two f32 vectors (ground truth helper).
439pub fn cosine_f32(a: &[f32], b: &[f32]) -> f32 {
440    let mut dot = 0f32;
441    let mut na = 0f32;
442    let mut nb = 0f32;
443    for i in 0..a.len() {
444        dot += a[i] * b[i];
445        na += a[i] * a[i];
446        nb += b[i] * b[i];
447    }
448    dot / (na.sqrt() * nb.sqrt())
449}
450
451#[cfg(test)]
452mod tests {
453    use super::*;
454
455    fn rand_unit(dim: usize, seed: u64) -> Vec<f32> {
456        // xorshift normals, then normalize
457        let mut x = seed | 1;
458        let mut v = Vec::with_capacity(dim);
459        for _ in 0..dim {
460            x ^= x << 13;
461            x ^= x >> 7;
462            x ^= x << 17;
463            let u1 = ((x >> 11) as f64 / (1u64 << 53) as f64).max(1e-12);
464            x ^= x << 16;
465            let u2 = (x >> 11) as f64 / (1u64 << 53) as f64;
466            v.push(((-2.0 * u1.ln()).sqrt() * (std::f64::consts::TAU * u2).cos()) as f32);
467        }
468        let norm: f32 = v.iter().map(|a| a * a).sum::<f32>().sqrt();
469        v.into_iter().map(|a| a / norm).collect()
470    }
471
472    #[test]
473    fn score_correlates_with_exact_cosine() {
474        let dim = 128;
475        let mut idx = VecqIndex::new(dim, 7);
476        let base: Vec<Vec<f32>> = (0..200).map(|i| rand_unit(dim, i + 1)).collect();
477        for v in &base {
478            idx.add(v);
479        }
480        let q = rand_unit(dim, 999);
481        let pq = idx.prepare_query(&q);
482        let exact: Vec<f32> = base.iter().map(|v| cosine_f32(&q, v)).collect();
483        let mut max_err = 0f32;
484        for (i, &e) in exact.iter().enumerate().take(200) {
485            let est = idx.score(&pq, i);
486            max_err = max_err.max((est - e).abs());
487        }
488        assert!(max_err < 0.2, "max score error {max_err}");
489    }
490
491    #[test]
492    fn score_reproducible_and_close_to_naive() {
493        let dim = 128;
494        let mut idx = VecqIndex::new(dim, 13);
495        for i in 0..50 {
496            idx.add(&rand_unit(dim, i + 21));
497        }
498        let q = rand_unit(dim, 321);
499        let pq = idx.prepare_query(&q);
500        for vi in 0..50 {
501            let base = vi * (idx.padded() / 2);
502            let mut naive = 0f32;
503            for i in 0..idx.padded() {
504                let b = idx.codes[base + i / 2];
505                let code = if i % 2 == 0 { b & 0x0F } else { b >> 4 };
506                naive += pq.rotated[i] * pq.lut[code as usize];
507            }
508            let s = idx.score(&pq, vi);
509            assert_eq!(s.to_bits(), idx.score(&pq, vi).to_bits());
510            assert!((s - naive * idx.scales[vi]).abs() < 1e-5, "vector {vi}");
511        }
512    }
513
514    #[test]
515    fn neon_matches_scalar_bitwise() {
516        let dim = 128;
517        let mut idx = VecqIndex::new(dim, 42);
518        for i in 0..30 {
519            idx.add(&rand_unit(dim, i + 500));
520        }
521        let q = rand_unit(dim, 777);
522        let pq = idx.prepare_query(&q);
523        for vi in 0..30 {
524            let base = vi * (idx.padded() / 2);
525            let codes = &idx.codes[base..base + idx.padded() / 2];
526            let qslice = &pq.rotated[..idx.padded()];
527            #[cfg(target_arch = "aarch64")]
528            {
529                let neon = unsafe { neon::score_neon(codes, qslice, &pq.lut) };
530                let scalar = score_scalar(codes, qslice, &pq.lut);
531                assert_eq!(
532                    neon.to_bits(),
533                    scalar.to_bits(),
534                    "vector {vi}: NEON and scalar diverged"
535                );
536            }
537            #[cfg(not(target_arch = "aarch64"))]
538            {
539                let _ = (base, codes, qslice);
540            }
541        }
542    }
543
544    #[cfg(target_arch = "aarch64")]
545    #[test]
546    fn neon4_matches_neon_bitwise() {
547        let dim = 128;
548        let mut idx = VecqIndex::new(dim, 91);
549        for i in 0..12 {
550            idx.add(&rand_unit(dim, i + 90));
551        }
552        let q = rand_unit(dim, 1234);
553        let pq = idx.prepare_query(&q);
554        let bpv = idx.padded() / 2;
555        for chunk_start in (0..12).step_by(4) {
556            let codes4 = &idx.codes[chunk_start * bpv..(chunk_start + 4) * bpv];
557            let qslice = &pq.rotated[..idx.padded()];
558            {
559                let batched = unsafe { neon::score_neon4(codes4, qslice, &pq.lut) };
560                for v in 0..4 {
561                    let single = unsafe {
562                        neon::score_neon(&codes4[v * bpv..(v + 1) * bpv], qslice, &pq.lut)
563                    };
564                    assert_eq!(
565                        batched[v].to_bits(),
566                        single.to_bits(),
567                        "chunk {chunk_start} vec {v}: neon4 diverged from neon"
568                    );
569                }
570            }
571        }
572    }
573
574    #[test]
575    fn search_returns_sorted_results() {
576        let dim = 64;
577        let mut idx = VecqIndex::new(dim, 3);
578        for i in 0..50 {
579            idx.add(&rand_unit(dim, i * 31 + 5));
580        }
581        let q = rand_unit(dim, 77);
582        let res = idx.search(&q, 5);
583        assert_eq!(res.len(), 5);
584        for w in res.windows(2) {
585            assert!(w[0].1 >= w[1].1);
586        }
587    }
588
589    #[test]
590    fn quantized_size_is_one_eighth() {
591        let dim = 384;
592        let mut idx = VecqIndex::new(dim, 1);
593        idx.add(&rand_unit(dim, 11));
594        assert_eq!(idx.codes.len(), 512 / 2);
595    }
596}