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};
6use std::collections::HashMap;
7
8/// A quantized vector database in memory.
9///
10/// Each vector is stored as `padded_dim / 2` bytes of 4-bit Lloyd-Max codes
11/// (computed after RHDH rotation) plus one f32 correction factor. The score
12/// against an f32 query is an unbiased estimate of the cosine similarity
13/// after undoing the per-vector quantization scale.
14///
15/// Vectors can be stored anonymously via [`VecqIndex::add`] or under a
16/// caller-chosen `u64` key via [`VecqIndex::add_keyed`]. Keyed vectors can be
17/// removed in place (tombstoned); tombstoned slots keep their storage but are
18/// skipped by searches and dropped by [`VecqIndex::compact`] and by
19/// [`VecqIndex::to_bytes`](crate::format). Slot indices stay stable until a
20/// compaction, so integrators can treat a slot as a transient handle while
21/// keys are the durable identity.
22pub struct VecqIndex {
23    pub(crate) dim: usize,
24    /// Dimensions actually used: vectors and queries are truncated to the
25    /// leading `working_dim` coords *before* normalization and rotation
26    /// (Matryoshka-style truncation must happen pre-rotation, because RHDH
27    /// mixes dimensions). Equal to `dim` unless built via
28    /// [`VecqIndex::with_working_dim`].
29    working_dim: usize,
30    padded: usize,
31    pub(crate) seed: u64,
32    transform: Rhdh,
33    pub(crate) codes: Vec<u8>, // n * bpv codes, bit-packed LSB-first (4-bit = nibbles)
34    pub(crate) scales: Vec<f32>, // per-vector dequantization scale
35    pub(crate) n: usize,       // total slots in use (live + tombstoned)
36    keys: Vec<Option<u64>>,    // slot -> caller key (keyed slots only)
37    key_to_slot: HashMap<u64, usize>,
38    // Multi-key slots: keys carrying 2+ vectors (`add_keyed_multi`). A key
39    // lives in exactly one of the two maps; single-slot keys stay in
40    // `key_to_slot` to keep the common case allocation-free.
41    key_to_slots: HashMap<u64, Vec<usize>>,
42    // 2-bit signatures for cascade search (issue #22), derived from the
43    // stored nibbles by [`VecqIndex::enable_cascade`]: `padded/4` bytes per
44    // slot. Any codes mutation (add/replace/compact) drops it; removals keep
45    // it — tombstoned slots are filtered at search time.
46    signature: Option<Vec<u8>>,
47    alive: Vec<bool>, // slot -> not tombstoned
48    live: usize,      // number of non-tombstoned slots
49    // Residual quantization (issue #23): second-pass codes quantizing the
50    // residual (rotated value minus its code0 centroid) re-scaled by a
51    // per-vector RMS, plus that RMS as the correction scale. Empty in plain
52    // mode.
53    pub(crate) residual: bool,
54    pub(crate) codes2: Vec<u8>,   // n * bpv residual codes
55    pub(crate) scales2: Vec<f32>, // per-vector residual RMS
56    /// Lloyd-Max code width in bits (4, 5, or 6; issue #39). Frozen once
57    /// vectors are added (`set_bits` asserts emptiness).
58    pub(crate) bits: u8,
59}
60
61impl VecqIndex {
62    /// Create an empty index for `dim`-dimensional unit vectors.
63    /// `seed` must be persisted with the index for cross-platform determinism.
64    pub fn new(dim: usize, seed: u64) -> Self {
65        Self::with_working_dim(dim, dim, seed)
66    }
67
68    /// Create an empty index over the leading `working_dim` dimensions of
69    /// `dim`-dimensional vectors (Matryoshka truncation).
70    ///
71    /// Vectors and queries are always passed at full `dim` length; the index
72    /// truncates them to `working_dim` *before* normalization and the RHDH
73    /// rotation (truncating after rotation would not be equivalent, since the
74    /// transform mixes dimensions). Scores are computed in the
75    /// `working_dim`-dimensional space and are only comparable with indexes
76    /// built with the same `working_dim` and `seed`.
77    pub fn with_working_dim(dim: usize, working_dim: usize, seed: u64) -> Self {
78        assert!(
79            working_dim >= 1 && working_dim <= dim,
80            "working_dim must be in 1..={dim}, got {working_dim}"
81        );
82        // The file format stores a non-default working_dim in a u16 header
83        // field; anything wider would silently truncate on save (cora review
84        // caught exactly that wrap). working_dim == dim is stored as 0 and
85        // may be arbitrarily large.
86        assert!(
87            working_dim == dim || working_dim <= u16::MAX as usize,
88            "working_dim {working_dim} exceeds the u16 file-format range and does not equal dim"
89        );
90        let padded = padded_dim(working_dim);
91        Self {
92            dim,
93            working_dim,
94            padded,
95            seed,
96            transform: Rhdh::new(working_dim, seed),
97            codes: Vec::new(),
98            scales: Vec::new(),
99            n: 0,
100            keys: Vec::new(),
101            key_to_slot: HashMap::new(),
102            key_to_slots: HashMap::new(),
103            alive: Vec::new(),
104            live: 0,
105            signature: None,
106            residual: false,
107            codes2: Vec::new(),
108            scales2: Vec::new(),
109            // Default width: 5-bit, the compression/recall sweet spot (#39):
110            // 4.79x, recall@10 0.983 real — ≈ residual at half its storage.
111            bits: 5,
112        }
113    }
114
115    /// Create an empty index with second-pass residual codes (issue #23).
116    ///
117    /// Doubles the code storage (~2x padded/2 bytes + one extra f16 scale per
118    /// vector) in exchange for a finer distance estimate: the residual left
119    /// by the first Lloyd pass is itself Lloyd-quantized and added to the
120    /// score. Recall improves most on noise-dominated data; scan cost
121    /// roughly doubles. Composable with the keyed and cascade layers.
122    pub fn with_residual(dim: usize, seed: u64) -> Self {
123        let mut idx = Self::with_working_dim(dim, dim, seed);
124        idx.residual = true;
125        idx.bits = 4; // residual is 4-bit-base only (issue #39)
126        idx
127    }
128
129    /// Set the Lloyd-Max code width: 4 (legacy, max compression 5.98x),
130    /// 5 (default sweet spot, 4.79x @ recall@10 0.983), or 6 (recall ≈
131    /// residual at 25% less storage, single-pass). Must be called on an
132    /// empty index before the first `add`.
133    pub fn set_bits(&mut self, bits: u8) -> &mut Self {
134        assert!(
135            matches!(bits, 4..=6),
136            "unsupported bit width {bits} (supported: 4, 5, 6)"
137        );
138        assert!(self.n == 0, "bit width is frozen once vectors are added");
139        assert!(
140            !(self.residual && bits != 4),
141            "residual mode requires the 4-bit width"
142        );
143        self.bits = bits;
144        self
145    }
146
147    /// Configured Lloyd-Max code width in bits (4, 5, or 6).
148    pub fn bits(&self) -> u8 {
149        self.bits
150    }
151
152    /// Stored code bytes per vector at the configured width (bit-packed
153    /// LSB-first; exact since `padded % 8 == 0`).
154    pub fn bytes_per_vector(&self) -> usize {
155        self.bpv()
156    }
157
158    fn bpv(&self) -> usize {
159        (self.padded * self.bits as usize).div_ceil(8)
160    }
161
162    /// Whether this index carries second-pass residual codes.
163    pub fn is_residual(&self) -> bool {
164        self.residual
165    }
166
167    /// Number of live (searchable) vectors.
168    pub fn len(&self) -> usize {
169        self.live
170    }
171
172    pub fn is_empty(&self) -> bool {
173        self.live == 0
174    }
175
176    /// Total slots in use, including tombstoned ones
177    /// (`slots() == len() + tombstones()`).
178    pub fn slots(&self) -> usize {
179        self.n
180    }
181
182    /// Number of tombstoned slots awaiting [`VecqIndex::compact`].
183    pub fn tombstones(&self) -> usize {
184        self.n - self.live
185    }
186
187    pub fn dim(&self) -> usize {
188        self.dim
189    }
190
191    /// Dimensions actually quantized (see [`VecqIndex::with_working_dim`]).
192    pub fn working_dim(&self) -> usize {
193        self.working_dim
194    }
195
196    pub fn seed(&self) -> u64 {
197        self.seed
198    }
199
200    #[cfg(test)]
201    pub(crate) fn padded(&self) -> usize {
202        self.padded
203    }
204
205    // -- crate-internal accessors used by the persistence format ---------
206
207    pub(crate) fn live_slots(&self) -> usize {
208        self.live
209    }
210
211    pub(crate) fn slot_alive(&self, slot: usize) -> bool {
212        self.alive[slot]
213    }
214
215    pub(crate) fn slot_scale(&self, slot: usize) -> f32 {
216        self.scales[slot]
217    }
218
219    pub(crate) fn slot_codes(&self, slot: usize, bpv: usize) -> &[u8] {
220        &self.codes[slot * bpv..(slot + 1) * bpv]
221    }
222
223    pub(crate) fn slot_scale2(&self, slot: usize) -> f32 {
224        self.scales2[slot]
225    }
226
227    pub(crate) fn slot_codes2(&self, slot: usize, bpv: usize) -> &[u8] {
228        &self.codes2[slot * bpv..(slot + 1) * bpv]
229    }
230
231    /// Mark the index as holding `count` dense (all-live, keyless) slots;
232    /// used after loading from the file format.
233    pub(crate) fn init_dense(&mut self, count: usize) {
234        self.keys = vec![None; count];
235        self.alive = vec![true; count];
236        self.key_to_slot.clear();
237        self.key_to_slots.clear();
238        self.live = count;
239        self.signature = None;
240    }
241
242    /// Restore a dense index's slot→key table (file format v1.3) and rebuild
243    /// the key maps from it.
244    pub(crate) fn restore_keys(&mut self, table: Vec<Option<u64>>) {
245        let count = table.len();
246        self.keys = table;
247        self.alive = vec![true; count];
248        self.live = count;
249        self.key_to_slot.clear();
250        self.key_to_slots.clear();
251        for (slot, key) in self.keys.iter().enumerate() {
252            if let Some(key) = *key {
253                if let Some(slots) = self.key_to_slots.get_mut(&key) {
254                    slots.push(slot);
255                } else if let Some(first) = self.key_to_slot.remove(&key) {
256                    self.key_to_slots.insert(key, vec![first, slot]);
257                } else {
258                    self.key_to_slot.insert(key, slot);
259                }
260            }
261        }
262    }
263
264    /// Quantize and add one vector (any norm; normalized internally).
265    ///
266    /// Returns the slot index holding the vector (stable until compaction).
267    pub fn add(&mut self, v: &[f32]) -> usize {
268        self.append_slot(v, None)
269    }
270
271    /// Quantize and add one vector under a caller-chosen `u64` key.
272    ///
273    /// If `key` already exists, the vector replaces the key's primary slot in
274    /// place (the slot index is preserved, matching usearch's insert
275    /// semantics). Otherwise a new slot is appended. Returns the slot index
276    /// holding the vector.
277    pub fn add_keyed(&mut self, key: u64, v: &[f32]) -> usize {
278        let slot = self.primary_slot(key);
279        match slot {
280            Some(slot) => {
281                // Replace in place: codes change, cascade signatures go stale.
282                self.signature = None;
283                let (scale0, scale2) = self.encode_into(slot * (self.bpv()), v);
284                self.scales[slot] = scale0;
285                if let Some(s2) = scale2 {
286                    self.scales2[slot] = s2;
287                }
288                slot
289            }
290            None => {
291                let slot = self.append_slot(v, Some(key));
292                self.key_to_slot.insert(key, slot);
293                slot
294            }
295        }
296    }
297
298    /// Quantize and add one more vector under an existing (or new) key.
299    ///
300    /// Unlike [`VecqIndex::add_keyed`] this never replaces: the key accumulates
301    /// vectors (usearch's `multi` mode). Returns the new slot index.
302    /// [`VecqIndex::search_keyed`] reports each key once, scored by its best
303    /// slot; [`VecqIndex::remove_keyed`] removes all of a key's slots, while
304    /// [`VecqIndex::remove_keyed_at`] removes one.
305    pub fn add_keyed_multi(&mut self, key: u64, v: &[f32]) -> usize {
306        if let Some(&first) = self.key_to_slot.get(&key) {
307            // Promote the single-slot key to a multi-slot key.
308            self.key_to_slot.remove(&key);
309            let slot = self.append_slot(v, Some(key));
310            self.key_to_slots.insert(key, vec![first, slot]);
311            return slot;
312        }
313        if self.key_to_slots.contains_key(&key) {
314            let slot = self.append_slot(v, Some(key));
315            self.key_to_slots
316                .get_mut(&key)
317                .expect("key checked above")
318                .push(slot);
319            return slot;
320        }
321        let slot = self.append_slot(v, Some(key));
322        self.key_to_slot.insert(key, slot);
323        slot
324    }
325
326    /// Rename `old_key` to `new_key` in place (slot indices untouched).
327    ///
328    /// Returns `false` if `old_key` is unknown or `new_key` is already taken;
329    /// renaming a key onto itself is a successful no-op.
330    pub fn relabel(&mut self, old_key: u64, new_key: u64) -> bool {
331        if old_key == new_key {
332            return self.contains_key(old_key);
333        }
334        if self.contains_key(new_key) {
335            return false;
336        }
337        if let Some(slot) = self.key_to_slot.remove(&old_key) {
338            self.key_to_slot.insert(new_key, slot);
339            self.keys[slot] = Some(new_key);
340            return true;
341        }
342        if let Some(slots) = self.key_to_slots.remove(&old_key) {
343            for &slot in &slots {
344                self.keys[slot] = Some(new_key);
345            }
346            self.key_to_slots.insert(new_key, slots);
347            return true;
348        }
349        false
350    }
351
352    /// Remove a keyed vector. The slot becomes a tombstone: its storage is
353    /// kept (slot indices stay stable) but searches skip it until
354    /// [`VecqIndex::compact`]. For multi-slot keys every slot of the key is
355    /// removed. Returns `false` if the key is unknown.
356    pub fn remove_keyed(&mut self, key: u64) -> bool {
357        if let Some(slot) = self.key_to_slot.remove(&key) {
358            self.alive[slot] = false;
359            self.keys[slot] = None;
360            self.live -= 1;
361            return true;
362        }
363        if let Some(slots) = self.key_to_slots.remove(&key) {
364            for slot in slots {
365                self.alive[slot] = false;
366                self.keys[slot] = None;
367                self.live -= 1;
368            }
369            return true;
370        }
371        false
372    }
373
374    /// Remove one slot of a (possibly multi-slot) key.
375    ///
376    /// Returns `false` if the key is unknown or `slot` is not one of its live
377    /// slots. Removing a single-slot key's slot removes the key entirely; a
378    /// multi-slot key survives while at least one slot remains.
379    pub fn remove_keyed_at(&mut self, key: u64, slot: usize) -> bool {
380        if let Some(&primary) = self.key_to_slot.get(&key) {
381            if primary != slot {
382                return false;
383            }
384            return self.remove_keyed(key);
385        }
386        let Some(slots) = self.key_to_slots.get_mut(&key) else {
387            return false;
388        };
389        let Some(pos) = slots.iter().position(|&s| s == slot) else {
390            return false;
391        };
392        if !self.alive[slot] {
393            return false;
394        }
395        let dead = slots.swap_remove(pos);
396        self.alive[dead] = false;
397        self.keys[dead] = None;
398        self.live -= 1;
399        if slots.len() == 1 {
400            // Shrink back to the single-slot representation.
401            let last = slots[0];
402            self.key_to_slots.remove(&key);
403            self.key_to_slot.insert(key, last);
404        } else if slots.is_empty() {
405            self.key_to_slots.remove(&key);
406        }
407        true
408    }
409
410    /// Primary (first) live slot of `key`, if any.
411    fn primary_slot(&self, key: u64) -> Option<usize> {
412        self.key_to_slot
413            .get(&key)
414            .copied()
415            .or_else(|| self.key_to_slots.get(&key).and_then(|s| s.first().copied()))
416    }
417
418    /// Look up the key stored at `slot` (`None` for anonymous slots,
419    /// tombstones, or out-of-range indices).
420    pub fn key_of(&self, slot: usize) -> Option<u64> {
421        self.keys.get(slot).copied().flatten()
422    }
423
424    /// Whether `key` currently identifies at least one live vector.
425    pub fn contains_key(&self, key: u64) -> bool {
426        self.key_to_slot.contains_key(&key) || self.key_to_slots.contains_key(&key)
427    }
428
429    // -- cascade search (2-bit prefilter + 4-bit rescore) -------------------
430
431    /// Derive the 2-bit signatures used by [`VecqIndex::search_cascade`]:
432    /// the two high bits of each stored nibble (`nibble >> 2`), i.e. a
433    /// coarse Lloyd re-quantization of the rotated dims. Costs
434    /// `n * padded/4` bytes of memory. Adding, replacing or compacting
435    /// vectors drops the signatures — call this again to re-enable.
436    pub fn enable_cascade(&mut self) {
437        assert!(
438            self.bits == 4,
439            "cascade search requires the 4-bit width (got {}-bit)",
440            self.bits
441        );
442        self.signature = Some(self.derive_signature());
443    }
444
445    /// Whether cascade signatures are currently available.
446    pub fn cascade_enabled(&self) -> bool {
447        self.signature.is_some()
448    }
449
450    fn sig_bytes(&self) -> usize {
451        self.padded.div_ceil(4)
452    }
453
454    /// Extract the per-slot 2-bit signature codes from the packed nibbles.
455    fn derive_signature(&self) -> Vec<u8> {
456        let bytes_per = self.sig_bytes();
457        let mut sig = vec![0u8; self.n * bytes_per];
458        let bpv = self.bpv();
459        for slot in 0..self.n {
460            let base = slot * bpv;
461            let sig_base = slot * bytes_per;
462            for (i, &byte) in self.codes[base..base + bpv].iter().enumerate() {
463                // Dim 2i uses the low nibble, 2i+1 the high nibble; each
464                // contributes 2 bits to signature byte i/2 at nibble i%2.
465                let lo = (byte & 0x0F) >> 2;
466                let hi = (byte >> 4) >> 2;
467                sig[sig_base + i / 2] |= if i % 2 == 0 {
468                    lo | (hi << 2)
469                } else {
470                    (lo << 4) | (hi << 6)
471                };
472            }
473        }
474        sig
475    }
476
477    /// Approximate top-k search: rank slots by L1 distance between the
478    /// query's and each slot's 2-bit signature codes (pure integer math),
479    /// keep the `r` closest, rescore those with the standard 4-bit path, and
480    /// return the top k. Slot indices are stable until compaction;
481    /// tombstoned slots are skipped.
482    ///
483    /// Requires [`VecqIndex::enable_cascade`] (panics otherwise). `r` is
484    /// clamped to `[k, live]`; with `r >= live` the result is identical to
485    /// [`VecqIndex::search`] bit for bit. The cascade is deterministic: same
486    /// file + query -> same result bits on any platform.
487    ///
488    /// Prefilter quality is data-dependent: the coarser the codes, the
489    /// larger `r` must be. Measure recall@k vs `r` on your data (the
490    /// synthetic clustered set in the tests needs r ~ 100 for ~0.9
491    /// recall@10 at n=1k; real embeddings need far less).
492    pub fn search_cascade(&self, q: &[f32], k: usize, r: usize) -> Vec<(usize, f32)> {
493        let Some(sig) = &self.signature else {
494            panic!("search_cascade requires enable_cascade() first");
495        };
496        use std::cmp::Reverse;
497        use std::collections::BinaryHeap;
498
499        let bytes_per = self.sig_bytes();
500        let pq = self.prepare_query(q);
501        let k = k.min(self.live).max(1);
502        let r = r.max(k).min(self.live);
503        // Query signature: re-quantize the (unnormalized) rotated dims to
504        // their 2-bit codes, matching the database derivation.
505        let rnorm = pq.rnorm;
506        let mut qsig = vec![0u8; bytes_per];
507        for (i, &x) in pq.rotated[..self.padded].iter().enumerate() {
508            let c2 = lloyd::quantize_4bit(x * rnorm) >> 2;
509            qsig[i / 4] |= c2 << ((i % 4) * 2);
510        }
511        // Pairwise L1 table for 2-bit codes: PAIR[(qa << 2) | da].
512        let pair: [u8; 16] = core::array::from_fn(|i| {
513            let (qa, da) = ((i >> 2) as u32, (i & 3) as u32);
514            qa.abs_diff(da) as u8
515        });
516
517        // Prefilter: L1 over 2-bit codes, 4 dim-pairs per signature byte.
518        // Max-heap of (distance, slot) keeps the r smallest, tie-breaking
519        // toward smaller slots.
520        let mut heap: BinaryHeap<(u32, usize)> = BinaryHeap::with_capacity(r + 1);
521        for slot in 0..self.n {
522            if !self.alive[slot] {
523                continue;
524            }
525            let base = slot * bytes_per;
526            let mut d = 0u32;
527            for (j, &db) in sig[base..base + bytes_per].iter().enumerate() {
528                let qb = qsig[j];
529                d += (pair[(qb & 0x03) as usize * 4 + (db & 0x03) as usize]
530                    + pair[((qb >> 2) & 0x03) as usize * 4 + ((db >> 2) & 0x03) as usize]
531                    + pair[((qb >> 4) & 0x03) as usize * 4 + ((db >> 4) & 0x03) as usize]
532                    + pair[((qb >> 6) & 0x03) as usize * 4 + ((db >> 6) & 0x03) as usize])
533                    as u32;
534            }
535            if heap.len() < r {
536                heap.push((d, slot));
537            } else if d < heap.peek().map(|e| e.0).unwrap_or(u32::MAX) {
538                heap.push((d, slot));
539                heap.pop();
540            }
541        }
542        let mut candidates: Vec<usize> = heap.into_iter().map(|(_, s)| s).collect();
543        candidates.sort_unstable();
544
545        // Rescore the candidates with the standard 4-bit path (bit-identical
546        // to `search`), same bounded-heap top-k.
547        let key = |s: f32| -> u32 {
548            let b = s.to_bits();
549            if b & 0x8000_0000 != 0 {
550                !b
551            } else {
552                b ^ 0x8000_0000
553            }
554        };
555        let mut top: BinaryHeap<Reverse<(u32, usize)>> = BinaryHeap::with_capacity(k + 1);
556        let consider = |s: f32, slot: usize, top: &mut BinaryHeap<Reverse<(u32, usize)>>| {
557            let ks = key(s);
558            if top.len() < k {
559                top.push(Reverse((ks, slot)));
560            } else if ks > top.peek().map(|e| e.0 .0).unwrap_or(0) {
561                top.push(Reverse((ks, slot)));
562                top.pop();
563            }
564        };
565        for slot in candidates {
566            consider(self.score(&pq, slot), slot, &mut top);
567        }
568        let key_undo = |k: u32| -> u32 {
569            if k & 0x8000_0000 != 0 {
570                k ^ 0x8000_0000 // was a positive float
571            } else {
572                !k // was a negative float
573            }
574        };
575        let mut out: Vec<(usize, f32)> = top
576            .into_iter()
577            .map(|e| (e.0 .1, f32::from_bits(key_undo(e.0 .0))))
578            .collect();
579        out.sort_by(|a, b| b.1.partial_cmp(&a.1).expect("no NaN scores"));
580        out
581    }
582
583    /// Rebuild the index in place, dropping tombstoned slots.
584    ///
585    /// All remaining vectors keep their keys; **slot indices shift** to become
586    /// dense (0..len). Search results are unchanged.
587    pub fn compact(&mut self) {
588        if self.live == self.n {
589            return;
590        }
591        // Slots shift: cascade signatures go stale (re-enable after).
592        self.signature = None;
593        let bpv = self.bpv();
594        let mut codes = Vec::with_capacity(self.live * bpv);
595        let mut scales = Vec::with_capacity(self.live);
596        let mut alive = Vec::with_capacity(self.live);
597        let mut new_keys: Vec<Option<u64>> = Vec::with_capacity(self.live);
598        for slot in 0..self.n {
599            if self.alive[slot] {
600                codes.extend_from_slice(&self.codes[slot * bpv..(slot + 1) * bpv]);
601                scales.push(self.scales[slot]);
602                new_keys.push(self.keys[slot]);
603                alive.push(true);
604            }
605        }
606        self.key_to_slot.clear();
607        self.key_to_slots.clear();
608        for (new_slot, key) in new_keys.iter().enumerate() {
609            if let Some(key) = key {
610                if let Some(slots) = self.key_to_slots.get_mut(key) {
611                    // Third and later slots of a multi key.
612                    slots.push(new_slot);
613                } else if let Some(first) = self.key_to_slot.remove(key) {
614                    // Second slot: promote to the multi-slot map.
615                    self.key_to_slots.insert(*key, vec![first, new_slot]);
616                } else {
617                    self.key_to_slot.insert(*key, new_slot);
618                }
619            }
620        }
621        self.codes = codes;
622        self.scales = scales;
623        self.keys = new_keys;
624        self.alive = alive;
625        self.n = self.live;
626    }
627
628    /// Append one vector as a new slot; returns the slot index.
629    /// Append one vector as a new slot and stamp `key` on it (the caller is
630    /// responsible for registering the key in the right map); returns the
631    /// slot index.
632    fn append_slot(&mut self, v: &[f32], key: Option<u64>) -> usize {
633        self.signature = None; // codes change: cascade signatures go stale
634        let slot = self.n;
635        let (scale0, scale2) = self.encode_into(slot * (self.bpv()), v);
636        self.scales.push(scale0);
637        if let Some(s2) = scale2 {
638            self.scales2.push(s2);
639        }
640        self.keys.push(key);
641        self.alive.push(true);
642        self.n += 1;
643        self.live += 1;
644        slot
645    }
646
647    /// Quantize `v` into the code bytes starting at `base` (extending
648    /// `codes` when appending); returns the unit-norm correction scale.
649    fn encode_into(&mut self, base: usize, v: &[f32]) -> (f32, Option<f32>) {
650        assert_eq!(v.len(), self.dim, "vector dim mismatch");
651        // Normalize over the working dims only (Matryoshka truncation happens
652        // before rotation — see with_working_dim).
653        let norm: f32 = v[..self.working_dim]
654            .iter()
655            .map(|x| x * x)
656            .sum::<f32>()
657            .sqrt();
658        assert!(norm > 0.0, "zero vector");
659        let unit: Vec<f32> = v[..self.working_dim].iter().map(|x| x / norm).collect();
660
661        let mut rotated = Vec::with_capacity(self.padded);
662        self.transform.apply(&unit, &mut rotated);
663
664        // Quantize at the configured width, bit-packed LSB-first.
665        let bits = self.bits;
666        let bytes_per_vec = self.bpv();
667        if self.codes.len() < base + bytes_per_vec {
668            self.codes.resize(base + bytes_per_vec, 0);
669        }
670        let mut sum_sq = 0f32;
671        let mut residual_buf = Vec::new();
672        for (i, &x) in rotated.iter().enumerate() {
673            let code = lloyd::quantize(x, bits);
674            pack_code(&mut self.codes, base, i, bits, code);
675            let d = lloyd::dequantize(code, bits);
676            sum_sq += d.powi(2);
677            if self.residual {
678                if residual_buf.is_empty() {
679                    residual_buf.resize(self.padded, 0.0);
680                }
681                residual_buf[i] = x - d;
682            }
683        }
684
685        // Scale so that the stored vector is unit-norm: dequantized vector q
686        // has norm sqrt(sum_sq); asymmetric scoring multiplies by 1/sqrt(sum_sq).
687        let scale0 = 1.0 / sum_sq.sqrt();
688        if !self.residual {
689            return (scale0, None);
690        }
691
692        // Second pass (issue #23): the residual left by the first Lloyd pass
693        // is roughly Gaussian after scaling by its own RMS, so the same
694        // N(0,1) codebook applies. Scoring adds raw1 * rms to the estimate.
695        let rms: f32 = (residual_buf.iter().map(|x| x * x).sum::<f32>() / self.padded as f32)
696            .max(1e-12)
697            .sqrt();
698        if self.codes2.len() < base + bytes_per_vec {
699            self.codes2.resize(base + bytes_per_vec, 0);
700        }
701        for (i, &r) in residual_buf.iter().enumerate() {
702            let code = lloyd::quantize(r / rms, bits);
703            pack_code(&mut self.codes2, base, i, bits, code);
704        }
705        // Term coefficients: both terms estimate the cosine of the full
706        // reconstruction x̂ = x̂0 + rms·d1, so both divide by ‖x̂‖. Term1's
707        // numerator is rms·raw1 (raw1 = q·dequant(c1) estimates q·(r/rms)).
708        //
709        // #23 (estimator fix): ‖x̂‖² must be the EXACT squared norm of the
710        // vector we actually score against — including the cross term
711        // 2·rms·⟨d0, d1⟩ (nonzero: the residual d1 correlates with d0's
712        // quantization error pattern, not with an independent Gaussian) and
713        // the actual quantized second-pass energy ⟨d1, d1⟩ (not the raw
714        // residual rms²·padded). Using sqrt(sum_sq + rms²·padded) instead
715        // injects per-vector score distortion that RAISES score variance
716        // enough to flip top-10 rankings: measured recall 0.58 vs plain
717        // 0.66 on the adversarial clustered set despite 4.5x better MSE
718        // (bias identical, sd +40%). The cross term and ⟨d1,d1⟩ are folded
719        // into the stored coefficients at encode time — no format change,
720        // scoring kernels untouched.
721        let mut cross = 0f32; // ⟨d0, d1⟩
722        let mut norm1_sq = 0f32; // ⟨d1, d1⟩
723        for i in 0..self.padded {
724            let b = base + i / 2;
725            let (c0, c1) = if i % 2 == 0 {
726                (self.codes[b] & 0x0F, self.codes2[b] & 0x0F)
727            } else {
728                (self.codes[b] >> 4, self.codes2[b] >> 4)
729            };
730            let d0 = lloyd::dequantize_4bit(c0);
731            let d1 = lloyd::dequantize_4bit(c1);
732            cross += d0 * d1;
733            norm1_sq += d1 * d1;
734        }
735        let norm_sq = sum_sq + 2.0 * rms * cross + rms * rms * norm1_sq;
736        let denom = norm_sq.max(1e-12).sqrt();
737        ((scale0 * sum_sq.sqrt()) / denom, Some(rms / denom))
738    }
739
740    /// Prepare an f32 query in rotated space (call once per query).
741    pub fn prepare_query(&self, q: &[f32]) -> PreparedQuery {
742        assert_eq!(q.len(), self.dim);
743        // Truncate + normalize over the working dims, mirroring encode_into.
744        let norm: f32 = q[..self.working_dim]
745            .iter()
746            .map(|x| x * x)
747            .sum::<f32>()
748            .sqrt();
749        assert!(norm > 0.0, "zero vector");
750        let unit: Vec<f32> = q[..self.working_dim].iter().map(|x| x / norm).collect();
751        let mut rotated = Vec::with_capacity(self.padded);
752        self.transform.apply(&unit, &mut rotated);
753        // Normalize so ||rotated|| == 1 despite the unnormalized FWHT
754        // (which scales norms by sqrt(padded)).
755        let rnorm: f32 = rotated.iter().map(|x| x * x).sum::<f32>().sqrt();
756        for x in rotated.iter_mut() {
757            *x /= rnorm;
758        }
759        // Precompute dequantized lookup for fast scoring: for each of the 16
760        // codes, the contribution when multiplied with the query coordinate.
761        let mut lut = [0f32; 16];
762        for (c, slot) in lut.iter_mut().enumerate() {
763            *slot = lloyd::dequantize_4bit(c as u8);
764        }
765        PreparedQuery {
766            rotated,
767            lut,
768            norm,
769            rnorm,
770        }
771    }
772
773    /// Asymmetric score of vector `idx` against a prepared query.
774    /// Returns estimated cosine similarity in [-1, 1].
775    ///
776    /// Dispatches to the explicit NEON path on aarch64, the explicit AVX2
777    /// path on x86_64 when the host supports it (runtime detection), and the
778    /// fixed 8-bucket scalar path otherwise. All use the identical
779    /// association order (per code byte: mul, mul, add, then add into bucket
780    /// j; final pairwise tree), so they produce the same f32 bits — guarded
781    /// by `neon_matches_scalar_bitwise` / `avx2_matches_scalar_bitwise` in
782    /// tests.
783    #[inline]
784    pub fn score(&self, pq: &PreparedQuery, idx: usize) -> f32 {
785        let bits = self.bits;
786        let base = idx * self.bpv();
787        let codes = &self.codes[base..base + self.bpv()];
788        let q = &pq.rotated[..self.padded];
789        let raw0 = self.score_raw(codes, q, &pq.lut, bits);
790        if !self.residual {
791            return raw0 * self.scales[idx];
792        }
793        // Residual term: same kernel, same association order, added after the
794        // first-pass term (bit-identical across all paths).
795        let codes1 = &self.codes2[base..base + self.bpv()];
796        let raw1 = self.score_raw(codes1, q, &pq.lut, bits);
797        raw0 * self.scales[idx] + raw1 * self.scales2[idx]
798    }
799
800    /// Score one vector's code bytes against a prepared query, dispatching to
801    /// the best available kernel for the target.
802    #[inline]
803    fn score_raw(&self, codes: &[u8], q: &[f32], lut: &[f32; 16], bits: u8) -> f32 {
804        score_raw_dispatch(codes, q, lut, bits)
805    }
806
807    /// Brute-force top-k search. Returns (slot index, score) sorted by score
808    /// desc. Tombstoned slots are skipped.
809    ///
810    /// Uses a bounded min-heap of size k (no O(n log n) sort, no O(n)
811    /// allocation per query): push while the heap is not full, then only
812    /// push-and-pop when the candidate beats the current k-th score.
813    pub fn search(&self, q: &[f32], k: usize) -> Vec<(usize, f32)> {
814        self.search_slots(q, k)
815    }
816
817    /// Keyed variant of [`VecqIndex::search`]: returns (key, score) sorted by
818    /// score desc, restricted to live keyed vectors. Multi-slot keys appear
819    /// once, scored by their best slot — so the result can hold fewer than
820    /// `k` entries when keys occupy several of the top slots.
821    pub fn search_keyed(&self, q: &[f32], k: usize) -> Vec<(u64, f32)> {
822        let mut seen = std::collections::HashSet::new();
823        self.search_slots(q, k)
824            .into_iter()
825            .filter_map(|(slot, s)| self.key_of(slot).map(|key| (key, s)))
826            .filter(|(key, _)| seen.insert(*key))
827            .collect()
828    }
829
830    fn search_slots(&self, q: &[f32], k: usize) -> Vec<(usize, f32)> {
831        use std::cmp::Reverse;
832        use std::collections::BinaryHeap;
833
834        let pq = self.prepare_query(q);
835        let k = k.min(self.live).max(1);
836        let bpv = self.bpv();
837        // f32 -> u32 monotonic key (NaN-safe, preserves total order):
838        // flip all bits for negatives, flip sign bit for positives.
839        let key = |s: f32| -> u32 {
840            let b = s.to_bits();
841            if b & 0x8000_0000 != 0 {
842                !b
843            } else {
844                b ^ 0x8000_0000
845            }
846        };
847        let mut heap: BinaryHeap<Reverse<(u32, usize)>> = BinaryHeap::with_capacity(k + 1);
848        let consider = |s: f32, idx: usize, heap: &mut BinaryHeap<Reverse<(u32, usize)>>| {
849            let ks = key(s);
850            if heap.len() < k {
851                heap.push(Reverse((ks, idx)));
852            } else if ks > heap.peek().map(|r| r.0 .0).unwrap_or(0) {
853                heap.push(Reverse((ks, idx)));
854                heap.pop();
855            }
856        };
857        #[cfg(target_arch = "aarch64")]
858        let q_rot = &pq.rotated[..self.padded];
859        #[cfg(target_arch = "x86_64")]
860        let use_avx2 = avx2::available();
861        let mut idx = 0;
862        // Residual mode combines both code planes per slot; plain mode scales
863        // only the first pass. The expression order is identical across all
864        // kernel paths (bit-identity requirement).
865        let combine = |r0: f32, r1: Option<f32>, si: usize| -> f32 {
866            match r1 {
867                Some(r1) => r0 * self.scales[si] + r1 * self.scales2[si],
868                None => r0 * self.scales[si],
869            }
870        };
871        #[cfg(target_arch = "aarch64")]
872        {
873            // Batch 4 vectors per pass: shared q loads + LUT setup. Tombstoned
874            // slots are still scored (keeping the batch dense) but filtered
875            // before entering the heap. NEON batch kernels: 4-bit LUT path
876            // and 5/6-bit wide path (#40); residual rescore stays narrow
877            // (residual indexes are 4-bit by construction).
878            while idx + 4 <= self.n {
879                let codes4 = &self.codes[idx * bpv..(idx + 4) * bpv];
880                let raw: [f32; 4] = if self.bits == 4 {
881                    unsafe { neon::score_neon4(codes4, q_rot, &pq.lut) }
882                } else {
883                    unsafe { neon::score_neon_wide4(codes4, q_rot, self.bits) }
884                };
885                let raw1 = if self.residual {
886                    Some(unsafe {
887                        neon::score_neon4(&self.codes2[idx * bpv..(idx + 4) * bpv], q_rot, &pq.lut)
888                    })
889                } else {
890                    None
891                };
892                for (v, &r) in raw.iter().enumerate() {
893                    let si = idx + v;
894                    if self.alive[si] {
895                        consider(combine(r, raw1.map(|a| a[v]), si), si, &mut heap);
896                    }
897                }
898                idx += 4;
899            }
900        }
901        #[cfg(target_arch = "x86_64")]
902        {
903            if use_avx2 && self.bits == 4 {
904                // Batch 4 vectors per pass: shared q deinterleave. Tombstoned
905                // slots are still scored (keeping the batch dense) but
906                // filtered before entering the heap. AVX2 batch kernels are
907                // 4-bit-only; wider widths fall through to the scalar loop.
908                while idx + 4 <= self.n {
909                    let codes4 = &self.codes[idx * bpv..(idx + 4) * bpv];
910                    // SAFETY: AVX2 availability checked via `use_avx2`.
911                    let raw =
912                        unsafe { avx2::score_avx24(codes4, &pq.rotated[..self.padded], &pq.lut) };
913                    let raw1 = if self.residual {
914                        Some(unsafe {
915                            avx2::score_avx24(
916                                &self.codes2[idx * bpv..(idx + 4) * bpv],
917                                &pq.rotated[..self.padded],
918                                &pq.lut,
919                            )
920                        })
921                    } else {
922                        None
923                    };
924                    for (v, &r) in raw.iter().enumerate() {
925                        let si = idx + v;
926                        if self.alive[si] {
927                            consider(combine(r, raw1.map(|a| a[v]), si), si, &mut heap);
928                        }
929                    }
930                    idx += 4;
931                }
932            }
933        }
934        while idx < self.n {
935            if self.alive[idx] {
936                consider(self.score(&pq, idx), idx, &mut heap);
937            }
938            idx += 1;
939        }
940        // Inverse of `key`: undo the sign flip to recover the exact f32 bits.
941        let key_undo = |k: u32| -> u32 {
942            if k & 0x8000_0000 != 0 {
943                k ^ 0x8000_0000 // was a positive float
944            } else {
945                !k // was a negative float
946            }
947        };
948        let mut out: Vec<(usize, f32)> = heap
949            .into_iter()
950            .map(|r| (r.0 .1, f32::from_bits(key_undo(r.0 .0))))
951            .collect();
952        out.sort_by(|a, b| b.1.partial_cmp(&a.1).expect("no NaN scores"));
953        out
954    }
955}
956
957/// Batch-4 scoring over raw code slices, shared by the index search loop
958/// and the zero-copy view (issue #25): both must hit the same kernels with
959/// the same association order. 4-bit uses the nibble-LUT kernels; 5/6-bit
960/// uses the wide kernel; residual planes are 4-bit by construction.
961pub(crate) fn score_batch4(codes4: &[u8], q_rot: &[f32], lut: &[f32; 16], bits: u8) -> [f32; 4] {
962    #[cfg(target_arch = "aarch64")]
963    {
964        if bits == 4 {
965            // SAFETY: NEON is baseline on aarch64.
966            return unsafe { neon::score_neon4(codes4, q_rot, lut) };
967        }
968        // SAFETY: NEON is baseline on aarch64.
969        unsafe { neon::score_neon_wide4(codes4, q_rot, bits) }
970    }
971    #[cfg(target_arch = "x86_64")]
972    {
973        if avx2::available() && bits == 4 {
974            // SAFETY: feature availability checked immediately above.
975            return unsafe { avx2::score_avx24(codes4, q_rot, lut) };
976        }
977        let nb = codes4.len() / 4;
978        let mut out = [0f32; 4];
979        for v in 0..4 {
980            out[v] = score_raw_dispatch(&codes4[v * nb..(v + 1) * nb], q_rot, lut, bits);
981        }
982        out
983    }
984    #[cfg(not(any(target_arch = "aarch64", target_arch = "x86_64")))]
985    {
986        let nb = codes4.len() / 4;
987        let mut out = [0f32; 4];
988        for v in 0..4 {
989            out[v] = score_raw_dispatch(&codes4[v * nb..(v + 1) * nb], q_rot, lut, bits);
990        }
991        out
992    }
993}
994
995/// Free-function kernel dispatch shared by [`VecqIndex`] and the zero-copy
996/// [`crate::view::VecqView`] — one place guarantees both owners pick the
997/// same kernel with the same association order (bit-identity).
998pub(crate) fn score_raw_dispatch(codes: &[u8], q: &[f32], lut: &[f32; 16], bits: u8) -> f32 {
999    if bits != 4 {
1000        return score_wide_scalar(codes, q, bits);
1001    }
1002    #[cfg(target_arch = "aarch64")]
1003    {
1004        // NEON is baseline on aarch64.
1005        unsafe { neon::score_neon(codes, q, lut) }
1006    }
1007    #[cfg(target_arch = "x86_64")]
1008    {
1009        if avx2::available() {
1010            // SAFETY: feature availability checked immediately above.
1011            unsafe { avx2::score_avx2(codes, q, lut) }
1012        } else {
1013            score_scalar(codes, q, lut)
1014        }
1015    }
1016    #[cfg(not(any(target_arch = "aarch64", target_arch = "x86_64")))]
1017    {
1018        score_scalar(codes, q, lut)
1019    }
1020}
1021
1022/// Write `code` for dimension `i` into the bit-packed code block at
1023/// `base` (LSB-first: dimension i occupies bits [i·w, (i+1)·w) of the
1024/// block). Read-modify-write on a 16-bit window so codes spanning a byte
1025/// boundary (5/6-bit) and sibling codes (4-bit pairs) survive replaces.
1026#[inline]
1027fn pack_code(bytes: &mut [u8], base: usize, i: usize, bits: u8, code: u8) {
1028    let w = bits as usize;
1029    let bit_off = i * w;
1030    let b = base + bit_off / 8;
1031    let shift = (bit_off % 8) as u32;
1032    let hi = bytes.get(b + 1).copied().unwrap_or(0);
1033    let window = u16::from_le_bytes([bytes[b], hi]);
1034    let mask = ((1u16 << w) - 1) << shift;
1035    let out = (window & !mask) | ((code as u16) << shift);
1036    bytes[b] = out as u8;
1037    if let Some(next) = bytes.get_mut(b + 1) {
1038        *next = (out >> 8) as u8;
1039    }
1040}
1041
1042/// Read `code` for dimension `i` from a bit-packed code block (LSB-first,
1043/// relative to the start of the block).
1044#[inline]
1045fn unpack_code(bytes: &[u8], i: usize, bits: u8) -> u8 {
1046    let w = bits as usize;
1047    let bit_off = i * w;
1048    let b = bit_off / 8;
1049    let shift = (bit_off % 8) as u32;
1050    let hi = bytes.get(b + 1).copied().unwrap_or(0);
1051    let window = u16::from_le_bytes([bytes[b], hi]);
1052    ((window >> shift) & ((1u16 << w) - 1)) as u8
1053}
1054
1055/// Scalar scoring for widths > 4 (5/6-bit): one unpack + centroid lookup per
1056/// dimension. The NEON/AVX2 batch kernels remain 4-bit-only (fast-follow);
1057/// this path is single-pass like `score_scalar`, just with wider codes.
1058fn score_wide_scalar(codes: &[u8], q: &[f32], bits: u8) -> f32 {
1059    let centroids = lloyd::centroids(bits);
1060    // 4-lane bucket accumulation (lane j takes dims j, j+4, j+8, ...) with a
1061    // fixed pairwise tree — the exact order the NEON wide kernel replicates,
1062    // mirroring the score_scalar/score_neon pairing at 4-bit.
1063    let mut acc = [0f32; 4];
1064    for (i, &qi) in q.iter().enumerate() {
1065        let c = unpack_code(codes, i, bits) as usize;
1066        acc[i & 3] += qi * centroids[c];
1067    }
1068    let s01 = acc[0] + acc[1];
1069    let s23 = acc[2] + acc[3];
1070    s01 + s23
1071}
1072
1073/// Reference 8-bucket scalar scoring. Bucket j accumulates byte j, j+8, ...
1074/// of every 8-byte block; final reduction is a fixed pairwise tree.
1075#[cfg_attr(target_arch = "aarch64", cfg(test))]
1076pub(crate) fn score_scalar(codes: &[u8], q: &[f32], lut: &[f32; 16]) -> f32 {
1077    let nb = codes.len();
1078    let mut acc = [0f32; 8];
1079    let mut i = 0;
1080    while i + 8 <= nb {
1081        for j in 0..8 {
1082            let b = codes[i + j];
1083            let c = (i + j) * 2;
1084            acc[j] += q[c] * lut[(b & 0x0F) as usize] + q[c + 1] * lut[(b >> 4) as usize];
1085        }
1086        i += 8;
1087    }
1088    let mut tail = 0f32;
1089    while i < nb {
1090        let b = codes[i];
1091        let c = i * 2;
1092        tail += q[c] * lut[(b & 0x0F) as usize] + q[c + 1] * lut[(b >> 4) as usize];
1093        i += 1;
1094    }
1095    let s01 = acc[0] + acc[1];
1096    let s23 = acc[2] + acc[3];
1097    let s45 = acc[4] + acc[5];
1098    let s67 = acc[6] + acc[7];
1099    (s01 + s23) + (s45 + s67) + tail
1100}
1101
1102#[cfg(target_arch = "aarch64")]
1103mod neon {
1104    //! Explicit NEON scoring path, bit-identical to [`score_scalar`].
1105    //!
1106    //! Why manual intrinsics: the scalar loop gathers `lut[nibble]` with a
1107    //! data-dependent index, which LLVM's vectorizer refuses to
1108    //! auto-vectorize (verified in disassembly: zero fmla in `search`).
1109    //! The LUT gather maps naturally to `vqtbl4q_u8`.
1110    //!
1111    //! Bit-identity with the scalar path is structural: per 8-byte block,
1112    //! byte j's term `q_even*lut[lo] + q_odd*lut[hi]` (vmul, vmul, vadd —
1113    //! Rust never contracts into FMA) is added into accumulator lane j,
1114    //! blocks in increasing order, and the final reduction uses the same
1115    //! pairwise tree. Guarded by `neon_matches_scalar_bitwise`.
1116    use std::arch::aarch64::*;
1117
1118    /// Gather 16 f32 from the 16-entry LUT given per-lane nibble indices.
1119    ///
1120    /// The 64-byte LUT (16 little-endian f32) is a `uint8x16x4_t` table.
1121    /// Four `vqtbl4q_u8` gathers produce byte-plane k (k=0..3) of all 16
1122    /// floats; a 4x16 byte transpose then rebuilds the 4 f32x4 registers.
1123    #[inline]
1124    unsafe fn gather16(tbl: uint8x16x4_t, nibbles: uint8x16_t) -> [float32x4_t; 4] {
1125        let idx = vmulq_u8(nibbles, vdupq_n_u8(4)); // byte offset of each lane's float
1126        let one = vdupq_n_u8(1);
1127        let two = vdupq_n_u8(2);
1128        let b0 = vqtbl4q_u8(tbl, idx);
1129        let b1 = vqtbl4q_u8(tbl, vaddq_u8(idx, one));
1130        let b2 = vqtbl4q_u8(tbl, vaddq_u8(idx, two));
1131        let b3 = vqtbl4q_u8(tbl, vaddq_u8(idx, vdupq_n_u8(3)));
1132        // Transpose: float j = (b0[j], b1[j], b2[j], b3[j]).
1133        let z01 = vzip1q_u8(b0, b1); // u16 lanes (b0j, b1j)
1134        let z23 = vzip1q_u8(b2, b3); // u16 lanes (b2j, b3j)
1135        let z01b = vzip2q_u8(b0, b1);
1136        let z23b = vzip2q_u8(b2, b3);
1137        let lo16 = vreinterpretq_u16_u8(z01);
1138        let hi16 = vreinterpretq_u16_u8(z23);
1139        let lo16b = vreinterpretq_u16_u8(z01b);
1140        let hi16b = vreinterpretq_u16_u8(z23b);
1141        [
1142            vreinterpretq_f32_u8(vreinterpretq_u8_u16(vzip1q_u16(lo16, hi16))),
1143            vreinterpretq_f32_u8(vreinterpretq_u8_u16(vzip2q_u16(lo16, hi16))),
1144            vreinterpretq_f32_u8(vreinterpretq_u8_u16(vzip1q_u16(lo16b, hi16b))),
1145            vreinterpretq_f32_u8(vreinterpretq_u8_u16(vzip2q_u16(lo16b, hi16b))),
1146        ]
1147    }
1148
1149    /// NEON scoring over one vector's codes. See module docs.
1150    #[inline]
1151    pub unsafe fn score_neon(codes: &[u8], q: &[f32], lut: &[f32; 16]) -> f32 {
1152        // Build the 64-byte LUT table for vqtbl4q_u8.
1153        let mut bytes = [0u8; 64];
1154        for (c, &v) in lut.iter().enumerate() {
1155            bytes[c * 4..c * 4 + 4].copy_from_slice(&v.to_le_bytes());
1156        }
1157        let tbl = uint8x16x4_t(
1158            vld1q_u8(bytes[0..16].as_ptr()),
1159            vld1q_u8(bytes[16..32].as_ptr()),
1160            vld1q_u8(bytes[32..48].as_ptr()),
1161            vld1q_u8(bytes[48..64].as_ptr()),
1162        );
1163        // acc_lo lanes 0-3 = scalar buckets 0-3; acc_hi lanes 0-3 = 4-7.
1164        let mut acc_lo = vdupq_n_f32(0.0);
1165        let mut acc_hi = vdupq_n_f32(0.0);
1166        let nb = codes.len();
1167        let mut i = 0;
1168        while i + 8 <= nb {
1169            let b8 = vld1_u8(codes.as_ptr().add(i)); // 8 code bytes (safe load)
1170                                                     // Nibble layout for the gather: lanes 0-7 = low nibbles (even
1171                                                     // dims), lanes 8-15 = high nibbles (odd dims).
1172            let lo = vand_u8(b8, vdup_n_u8(0x0F));
1173            let hi = vshr_n_u8(b8, 4);
1174            let nibbles = vcombine_u8(lo, hi); // [lo_0..lo_7, hi_0..hi_7]
1175            let g = gather16(tbl, nibbles);
1176            // g[0] = lut[lo_0..3], g[1] = lut[lo_4..7],
1177            // g[2] = lut[hi_0..3], g[3] = lut[hi_4..7].
1178            // Load q[2i .. 2i+16) and deinterleave even/odd dims.
1179            let q0 = vld1q_f32(q.as_ptr().add(i * 2));
1180            let q1 = vld1q_f32(q.as_ptr().add(i * 2 + 4));
1181            let q2 = vld1q_f32(q.as_ptr().add(i * 2 + 8));
1182            let q3 = vld1q_f32(q.as_ptr().add(i * 2 + 12));
1183            let q_even_lo = vuzp1q_f32(q0, q1); // dims 2i, 2i+2, 2i+4, 2i+6
1184            let q_even_hi = vuzp1q_f32(q2, q3);
1185            let q_odd_lo = vuzp2q_f32(q0, q1);
1186            let q_odd_hi = vuzp2q_f32(q2, q3);
1187            // term = q_even*lut[lo] + q_odd*lut[hi]  (mul, mul, add — no FMA)
1188            let t_lo = vaddq_f32(vmulq_f32(q_even_lo, g[0]), vmulq_f32(q_odd_lo, g[2]));
1189            let t_hi = vaddq_f32(vmulq_f32(q_even_hi, g[1]), vmulq_f32(q_odd_hi, g[3]));
1190            acc_lo = vaddq_f32(acc_lo, t_lo);
1191            acc_hi = vaddq_f32(acc_hi, t_hi);
1192            i += 8;
1193        }
1194        // Extract buckets and reduce with the scalar pairwise tree.
1195        let mut acc = [0f32; 8];
1196        acc[0] = vgetq_lane_f32(acc_lo, 0);
1197        acc[1] = vgetq_lane_f32(acc_lo, 1);
1198        acc[2] = vgetq_lane_f32(acc_lo, 2);
1199        acc[3] = vgetq_lane_f32(acc_lo, 3);
1200        acc[4] = vgetq_lane_f32(acc_hi, 0);
1201        acc[5] = vgetq_lane_f32(acc_hi, 1);
1202        acc[6] = vgetq_lane_f32(acc_hi, 2);
1203        acc[7] = vgetq_lane_f32(acc_hi, 3);
1204        // Scalar tail for the last (< 8) code bytes. padded is a multiple of
1205        // 8 elements (padded/2 bytes multiple of 4), so nb % 8 is 0 or 4.
1206        let mut tail = 0f32;
1207        while i < nb {
1208            let b = codes[i];
1209            let c = i * 2;
1210            tail += q[c] * lut[(b & 0x0F) as usize] + q[c + 1] * lut[(b >> 4) as usize];
1211            i += 1;
1212        }
1213        let s01 = acc[0] + acc[1];
1214        let s23 = acc[2] + acc[3];
1215        let s45 = acc[4] + acc[5];
1216        let s67 = acc[6] + acc[7];
1217        (s01 + s23) + (s45 + s67) + tail
1218    }
1219
1220    /// Score 4 consecutive vectors at once, amortizing the q loads and LUT
1221    /// table setup across all 4. Each vector accumulates in the exact same
1222    /// per-lane order as [`score_neon`], so results are bit-identical.
1223    ///
1224    /// Returns raw (pre-scale) scores; the caller multiplies by `scales`.
1225    #[inline]
1226    pub unsafe fn score_neon4(codes4: &[u8], q: &[f32], lut: &[f32; 16]) -> [f32; 4] {
1227        let mut bytes = [0u8; 64];
1228        for (c, &v) in lut.iter().enumerate() {
1229            bytes[c * 4..c * 4 + 4].copy_from_slice(&v.to_le_bytes());
1230        }
1231        let tbl = uint8x16x4_t(
1232            vld1q_u8(bytes[0..16].as_ptr()),
1233            vld1q_u8(bytes[16..32].as_ptr()),
1234            vld1q_u8(bytes[32..48].as_ptr()),
1235            vld1q_u8(bytes[48..64].as_ptr()),
1236        );
1237        let nb = codes4.len() / 4; // bytes per vector
1238        let mut acc_lo = [vdupq_n_f32(0.0); 4];
1239        let mut acc_hi = [vdupq_n_f32(0.0); 4];
1240        let mut i = 0;
1241        while i + 8 <= nb {
1242            // Shared q loads for this block: q[2i .. 2i+16), deinterleaved.
1243            let q0 = vld1q_f32(q.as_ptr().add(i * 2));
1244            let q1 = vld1q_f32(q.as_ptr().add(i * 2 + 4));
1245            let q2 = vld1q_f32(q.as_ptr().add(i * 2 + 8));
1246            let q3 = vld1q_f32(q.as_ptr().add(i * 2 + 12));
1247            let q_even_lo = vuzp1q_f32(q0, q1);
1248            let q_even_hi = vuzp1q_f32(q2, q3);
1249            let q_odd_lo = vuzp2q_f32(q0, q1);
1250            let q_odd_hi = vuzp2q_f32(q2, q3);
1251            for v in 0..4 {
1252                let b8 = vld1_u8(codes4.as_ptr().add(v * nb + i));
1253                let lo = vand_u8(b8, vdup_n_u8(0x0F));
1254                let hi = vshr_n_u8(b8, 4);
1255                let nibbles = vcombine_u8(lo, hi);
1256                let g = gather16(tbl, nibbles);
1257                let t_lo = vaddq_f32(vmulq_f32(q_even_lo, g[0]), vmulq_f32(q_odd_lo, g[2]));
1258                let t_hi = vaddq_f32(vmulq_f32(q_even_hi, g[1]), vmulq_f32(q_odd_hi, g[3]));
1259                acc_lo[v] = vaddq_f32(acc_lo[v], t_lo);
1260                acc_hi[v] = vaddq_f32(acc_hi[v], t_hi);
1261            }
1262            i += 8;
1263        }
1264        let mut out = [0f32; 4];
1265        for v in 0..4 {
1266            // Same lane extraction + pairwise reduction as score_neon.
1267            let mut a = [0f32; 8];
1268            a[0] = vgetq_lane_f32(acc_lo[v], 0);
1269            a[1] = vgetq_lane_f32(acc_lo[v], 1);
1270            a[2] = vgetq_lane_f32(acc_lo[v], 2);
1271            a[3] = vgetq_lane_f32(acc_lo[v], 3);
1272            a[4] = vgetq_lane_f32(acc_hi[v], 0);
1273            a[5] = vgetq_lane_f32(acc_hi[v], 1);
1274            a[6] = vgetq_lane_f32(acc_hi[v], 2);
1275            a[7] = vgetq_lane_f32(acc_hi[v], 3);
1276            // Scalar tail for the last (< 8) code bytes.
1277            let mut tail = 0f32;
1278            let mut j = i;
1279            while j < nb {
1280                let b = codes4[v * nb + j];
1281                let c = j * 2;
1282                tail += q[c] * lut[(b & 0x0F) as usize] + q[c + 1] * lut[(b >> 4) as usize];
1283                j += 1;
1284            }
1285            let s01 = a[0] + a[1];
1286            let s23 = a[2] + a[3];
1287            let s45 = a[4] + a[5];
1288            let s67 = a[6] + a[7];
1289            out[v] = (s01 + s23) + (s45 + s67) + tail;
1290        }
1291        out
1292    }
1293
1294    /// Batch-4 NEON scoring for 5/6-bit codes (#40). Bit-identity with
1295    /// [`score_wide_scalar`] is structural: every dimension d contributes
1296    /// `q[d] * centroids[code_d]` to lane `d & 3` of its vector's
1297    /// accumulator, dims in increasing order, reduced with the same
1298    /// pairwise tree (tail dims included in the lanes before reducing).
1299    ///
1300    /// Extraction reads one unaligned u64 window per 8 dims — 8*w+7 <= 62
1301    /// bits always fits for both widths — replacing 8 per-dim `unpack_code`
1302    /// calls with shifts on two registers. Centroids are gathered through
1303    /// L1 (the 128/256-byte table stays hot); NEON `vqtbl` cannot address
1304    /// tables that wide, and a range-split costs more ops than the scalar
1305    /// gathers it replaces — measured, not guessed (#40 notes).
1306    ///
1307    /// Residual indexes never reach this kernel: they are 4-bit-only.
1308    pub unsafe fn score_neon_wide4(codes4: &[u8], q: &[f32], bits: u8) -> [f32; 4] {
1309        debug_assert!(matches!(bits, 5..=6));
1310        use super::unpack_code;
1311        let centroids = crate::lloyd::centroids(bits);
1312        let nb = codes4.len() / 4;
1313        let n_dims = nb * 8 / bits as usize; // == padded (WHT pads to pow2)
1314        let w = bits as u64;
1315        let mask = (1u64 << w) - 1;
1316        let mut acc = [vdupq_n_f32(0.0); 4];
1317        let mut i = 0;
1318        while i + 8 <= n_dims {
1319            #[allow(clippy::needless_range_loop)] // v is both a vector index and the acc lane
1320            for v in 0..4 {
1321                let base = v * nb;
1322                let b0 = i * bits as usize;
1323                let off = base + b0 / 8;
1324                // Only the bits [b0, b0 + 8*w) matter: ceil((s0+8w)/8) <= 7
1325                // bytes. Loading a fixed 8-byte u64 would run past the end
1326                // of the last vector's block.
1327                let need = (b0 % 8 + 8 * bits as usize).div_ceil(8);
1328                let mut wb = [0u8; 8];
1329                wb[..need].copy_from_slice(&codes4[off..off + need]);
1330                let win = u64::from_le_bytes(wb);
1331                let s0 = (b0 % 8) as u64;
1332                let mut g = [0f32; 8];
1333                #[allow(clippy::needless_range_loop)] // l indexes g and feeds the shift math
1334                for l in 0..8usize {
1335                    let c = ((win >> (s0 + l as u64 * w)) & mask) as usize;
1336                    debug_assert_eq!(c, unpack_code(&codes4[base..], i + l, bits) as usize);
1337                    g[l] = centroids[c];
1338                }
1339                let gv = vld1q_f32(g.as_ptr());
1340                let gv2 = vld1q_f32(g.as_ptr().add(4));
1341                let qv = vld1q_f32(q.as_ptr().add(i));
1342                let qv2 = vld1q_f32(q.as_ptr().add(i + 4));
1343                acc[v] = vaddq_f32(acc[v], vmulq_f32(qv, gv));
1344                acc[v] = vaddq_f32(acc[v], vmulq_f32(qv2, gv2));
1345            }
1346            i += 8;
1347        }
1348        // Extract lane accumulators, fold remaining tail dims into the same
1349        // lane order the scalar reference uses, then pairwise-reduce.
1350        let mut lanes = [[0f32; 4]; 4];
1351        for v in 0..4 {
1352            lanes[v] = [
1353                vgetq_lane_f32(acc[v], 0),
1354                vgetq_lane_f32(acc[v], 1),
1355                vgetq_lane_f32(acc[v], 2),
1356                vgetq_lane_f32(acc[v], 3),
1357            ];
1358        }
1359        while i < n_dims {
1360            for v in 0..4 {
1361                let c = unpack_code(&codes4[v * nb..], i, bits) as usize;
1362                lanes[v][i & 3] += q[i] * centroids[c];
1363            }
1364            i += 1;
1365        }
1366        let mut out = [0f32; 4];
1367        for v in 0..4 {
1368            let s01 = lanes[v][0] + lanes[v][1];
1369            let s23 = lanes[v][2] + lanes[v][3];
1370            out[v] = s01 + s23;
1371        }
1372        out
1373    }
1374}
1375
1376/// Explicit AVX2 scoring path (x86_64), bit-identical to [`score_scalar`].
1377///
1378/// Unlike NEON (baseline on aarch64), AVX2 is not universal on x86_64, so the
1379/// path is selected at runtime with `is_x86_feature_detected!` and the
1380/// kernels are `#[target_feature(enable = "avx2")]`.
1381///
1382/// Bit-identity with the scalar path is structural: lane j of the
1383/// accumulator corresponds to scalar bucket j. Per 8-byte block, lane j
1384/// computes `q[2b]*lut[lo_b] + q[2b+1]*lut[hi_b]` (b = block start + j;
1385/// vmul, vmul, vadd — no FMA contraction) and adds it into lane j, blocks
1386/// in increasing order — the same per-bucket term and accumulation order as
1387/// the scalar loop. The LUT gather uses `vgatherdps` on the 16-entry table
1388/// where NEON uses `vqtbl4q_u8`. Final reduction is the same pairwise tree.
1389#[cfg(target_arch = "x86_64")]
1390mod avx2 {
1391    use std::arch::x86_64::*;
1392
1393    /// Whether the host CPU supports AVX2.
1394    pub fn available() -> bool {
1395        std::is_x86_feature_detected!("avx2")
1396    }
1397
1398    /// Gather `lut[nibble]` for 8 nibbles into an 8-lane vector.
1399    #[inline]
1400    unsafe fn gather8(lut: &[f32; 16], nibbles: __m128i) -> __m256 {
1401        // The gather's scale of 4 turns each nibble index into an f32 byte
1402        // offset — no pre-shift needed.
1403        let idx = _mm256_cvtepu8_epi32(nibbles);
1404        _mm256_i32gather_ps(lut.as_ptr(), idx, 4)
1405    }
1406
1407    /// Deinterleave the 16 f32 at `q` into even dims (8 lanes) and odd dims
1408    /// (8 lanes): {d0,d2,..,d14} and {d1,d3,..,d15}.
1409    #[inline]
1410    unsafe fn deinterleave16(q: *const f32) -> (__m256, __m256) {
1411        let qa = _mm256_loadu_ps(q);
1412        let qb = _mm256_loadu_ps(q.add(8));
1413        // shuffle_ps picks {a0,a2,b0,b2} (even) / {a1,a3,b1,b3} (odd) per
1414        // 128-bit half; the vpermps index vector then interleaves the halves
1415        // into contiguous even/odd streams {d0,d2,..,d14} / {d1,d3,..,d15}.
1416        let fixup = _mm256_setr_epi32(0, 1, 4, 5, 2, 3, 6, 7);
1417        let even = _mm256_permutevar8x32_ps(_mm256_shuffle_ps(qa, qb, 0x88), fixup);
1418        let odd = _mm256_permutevar8x32_ps(_mm256_shuffle_ps(qa, qb, 0xDD), fixup);
1419        (even, odd)
1420    }
1421
1422    /// AVX2 scoring over one vector's codes. See module docs.
1423    #[inline]
1424    #[target_feature(enable = "avx2")]
1425    pub unsafe fn score_avx2(codes: &[u8], q: &[f32], lut: &[f32; 16]) -> f32 {
1426        let mut acc = _mm256_setzero_ps();
1427        let nb = codes.len();
1428        let mut i = 0;
1429        while i + 8 <= nb {
1430            let b8 = _mm_loadl_epi64(codes.as_ptr().add(i) as *const __m128i);
1431            let g_lo = gather8(lut, _mm_and_si128(b8, _mm_set1_epi8(0x0F)));
1432            let g_hi = gather8(
1433                lut,
1434                _mm_and_si128(_mm_srli_epi16(b8, 4), _mm_set1_epi8(0x0F)),
1435            );
1436            let (even, odd) = deinterleave16(q.as_ptr().add(i * 2));
1437            // term = q_even*lut[lo] + q_odd*lut[hi]  (mul, mul, add — no FMA)
1438            let term = _mm256_add_ps(_mm256_mul_ps(even, g_lo), _mm256_mul_ps(odd, g_hi));
1439            acc = _mm256_add_ps(acc, term);
1440            i += 8;
1441        }
1442        // Extract lanes and reduce with the scalar pairwise tree.
1443        let mut a = [0f32; 8];
1444        _mm256_storeu_ps(a.as_mut_ptr(), acc);
1445        // Scalar tail for the last (< 8) code bytes. padded is a multiple of
1446        // 8 elements (padded/2 bytes multiple of 4), so nb % 8 is 0 or 4.
1447        let mut tail = 0f32;
1448        while i < nb {
1449            let b = codes[i];
1450            let c = i * 2;
1451            tail += q[c] * lut[(b & 0x0F) as usize] + q[c + 1] * lut[(b >> 4) as usize];
1452            i += 1;
1453        }
1454        let s01 = a[0] + a[1];
1455        let s23 = a[2] + a[3];
1456        let s45 = a[4] + a[5];
1457        let s67 = a[6] + a[7];
1458        (s01 + s23) + (s45 + s67) + tail
1459    }
1460
1461    /// Score 4 consecutive vectors at once, amortizing the q deinterleave
1462    /// across all 4. Each vector accumulates in the exact same per-lane order
1463    /// as [`score_avx2`], so results are bit-identical. Returns raw
1464    /// (pre-scale) scores; the caller multiplies by `scales`.
1465    #[inline]
1466    #[target_feature(enable = "avx2")]
1467    pub unsafe fn score_avx24(codes4: &[u8], q: &[f32], lut: &[f32; 16]) -> [f32; 4] {
1468        let nb = codes4.len() / 4; // bytes per vector
1469        let mut acc = [_mm256_setzero_ps(); 4];
1470        let mut i = 0;
1471        while i + 8 <= nb {
1472            // Shared q loads + deinterleave for this block.
1473            let (even, odd) = deinterleave16(q.as_ptr().add(i * 2));
1474            for (v, acc_v) in acc.iter_mut().enumerate() {
1475                let b8 = _mm_loadl_epi64(codes4.as_ptr().add(v * nb + i) as *const __m128i);
1476                let g_lo = gather8(lut, _mm_and_si128(b8, _mm_set1_epi8(0x0F)));
1477                let g_hi = gather8(
1478                    lut,
1479                    _mm_and_si128(_mm_srli_epi16(b8, 4), _mm_set1_epi8(0x0F)),
1480                );
1481                let term = _mm256_add_ps(_mm256_mul_ps(even, g_lo), _mm256_mul_ps(odd, g_hi));
1482                *acc_v = _mm256_add_ps(*acc_v, term);
1483            }
1484            i += 8;
1485        }
1486        let mut out = [0f32; 4];
1487        for v in 0..4 {
1488            let mut a = [0f32; 8];
1489            _mm256_storeu_ps(a.as_mut_ptr(), acc[v]);
1490            // Scalar tail for the last (< 8) code bytes.
1491            let mut tail = 0f32;
1492            let mut j = i;
1493            while j < nb {
1494                let b = codes4[v * nb + j];
1495                let c = j * 2;
1496                tail += q[c] * lut[(b & 0x0F) as usize] + q[c + 1] * lut[(b >> 4) as usize];
1497                j += 1;
1498            }
1499            let s01 = a[0] + a[1];
1500            let s23 = a[2] + a[3];
1501            let s45 = a[4] + a[5];
1502            let s67 = a[6] + a[7];
1503            out[v] = (s01 + s23) + (s45 + s67) + tail;
1504        }
1505        out
1506    }
1507}
1508
1509/// A query preprocessed in the quantized domain.
1510pub struct PreparedQuery {
1511    rotated: Vec<f32>,
1512    lut: [f32; 16],
1513    #[allow(dead_code)]
1514    norm: f32,
1515    /// Norm of the rotated query before the final normalization — the
1516    /// cascade search rescales its sign-threshold by this to match the
1517    /// unnormalized domain the database codes were quantized in.
1518    rnorm: f32,
1519}
1520
1521/// Exact cosine similarity between two f32 vectors (ground truth helper).
1522pub fn cosine_f32(a: &[f32], b: &[f32]) -> f32 {
1523    let mut dot = 0f32;
1524    let mut na = 0f32;
1525    let mut nb = 0f32;
1526    for i in 0..a.len() {
1527        dot += a[i] * b[i];
1528        na += a[i] * a[i];
1529        nb += b[i] * b[i];
1530    }
1531    dot / (na.sqrt() * nb.sqrt())
1532}
1533
1534#[cfg(test)]
1535mod tests {
1536    use super::*;
1537
1538    fn rand_unit(dim: usize, seed: u64) -> Vec<f32> {
1539        // xorshift normals, then normalize
1540        let mut x = seed | 1;
1541        let mut v = Vec::with_capacity(dim);
1542        for _ in 0..dim {
1543            x ^= x << 13;
1544            x ^= x >> 7;
1545            x ^= x << 17;
1546            let u1 = ((x >> 11) as f64 / (1u64 << 53) as f64).max(1e-12);
1547            x ^= x << 16;
1548            let u2 = (x >> 11) as f64 / (1u64 << 53) as f64;
1549            v.push(((-2.0 * u1.ln()).sqrt() * (std::f64::consts::TAU * u2).cos()) as f32);
1550        }
1551        let norm: f32 = v.iter().map(|a| a * a).sum::<f32>().sqrt();
1552        v.into_iter().map(|a| a / norm).collect()
1553    }
1554
1555    #[test]
1556    fn score_correlates_with_exact_cosine() {
1557        let dim = 128;
1558        let mut idx = VecqIndex::new(dim, 7);
1559        let base: Vec<Vec<f32>> = (0..200).map(|i| rand_unit(dim, i + 1)).collect();
1560        for v in &base {
1561            idx.add(v);
1562        }
1563        let q = rand_unit(dim, 999);
1564        let pq = idx.prepare_query(&q);
1565        let exact: Vec<f32> = base.iter().map(|v| cosine_f32(&q, v)).collect();
1566        let mut max_err = 0f32;
1567        for (i, &e) in exact.iter().enumerate().take(200) {
1568            let est = idx.score(&pq, i);
1569            max_err = max_err.max((est - e).abs());
1570        }
1571        assert!(max_err < 0.2, "max score error {max_err}");
1572    }
1573
1574    #[test]
1575    fn score_reproducible_and_close_to_naive() {
1576        let dim = 128;
1577        // Cover every width: score must equal a naive unpack+dequantize walk
1578        // of the stored codes, and be bit-reproducible.
1579        for bits in [4u8, 5, 6] {
1580            let mut idx = VecqIndex::new(dim, 13);
1581            idx.set_bits(bits);
1582            for i in 0..50 {
1583                idx.add(&rand_unit(dim, i + 21));
1584            }
1585            let q = rand_unit(dim, 321);
1586            let pq = idx.prepare_query(&q);
1587            for vi in 0..50 {
1588                let base = vi * idx.bytes_per_vector();
1589                let mut naive = 0f32;
1590                for i in 0..idx.padded() {
1591                    let code = unpack_code(&idx.codes[base..], i, bits) as usize;
1592                    naive += pq.rotated[i] * lloyd::centroids(bits)[code];
1593                }
1594                let s = idx.score(&pq, vi);
1595                assert_eq!(s.to_bits(), idx.score(&pq, vi).to_bits());
1596                assert!(
1597                    (s - naive * idx.scales[vi]).abs() < 1e-5,
1598                    "bits {bits} vector {vi}"
1599                );
1600            }
1601        }
1602    }
1603
1604    #[test]
1605    fn neon_matches_scalar_bitwise() {
1606        let dim = 128;
1607        let mut idx = VecqIndex::new(dim, 42);
1608        for i in 0..30 {
1609            idx.add(&rand_unit(dim, i + 500));
1610        }
1611        let q = rand_unit(dim, 777);
1612        let pq = idx.prepare_query(&q);
1613        for vi in 0..30 {
1614            let base = vi * (idx.padded() / 2);
1615            let codes = &idx.codes[base..base + idx.padded() / 2];
1616            let qslice = &pq.rotated[..idx.padded()];
1617            #[cfg(target_arch = "aarch64")]
1618            {
1619                let neon = unsafe { neon::score_neon(codes, qslice, &pq.lut) };
1620                let scalar = score_scalar(codes, qslice, &pq.lut);
1621                assert_eq!(
1622                    neon.to_bits(),
1623                    scalar.to_bits(),
1624                    "vector {vi}: NEON and scalar diverged"
1625                );
1626            }
1627            #[cfg(not(target_arch = "aarch64"))]
1628            {
1629                let _ = (base, codes, qslice);
1630            }
1631        }
1632    }
1633
1634    #[cfg(target_arch = "aarch64")]
1635    #[test]
1636    fn neon4_matches_neon_bitwise() {
1637        let dim = 128;
1638        let mut idx = VecqIndex::new(dim, 91);
1639        idx.set_bits(4); // this test exercises the 4-bit kernels specifically
1640        for i in 0..12 {
1641            idx.add(&rand_unit(dim, i + 90));
1642        }
1643        let q = rand_unit(dim, 1234);
1644        let pq = idx.prepare_query(&q);
1645        let bpv = idx.padded() / 2;
1646        for chunk_start in (0..12).step_by(4) {
1647            let codes4 = &idx.codes[chunk_start * bpv..(chunk_start + 4) * bpv];
1648            let qslice = &pq.rotated[..idx.padded()];
1649            {
1650                let batched = unsafe { neon::score_neon4(codes4, qslice, &pq.lut) };
1651                for v in 0..4 {
1652                    let single = unsafe {
1653                        neon::score_neon(&codes4[v * bpv..(v + 1) * bpv], qslice, &pq.lut)
1654                    };
1655                    assert_eq!(
1656                        batched[v].to_bits(),
1657                        single.to_bits(),
1658                        "chunk {chunk_start} vec {v}: neon4 diverged from neon"
1659                    );
1660                }
1661            }
1662        }
1663    }
1664
1665    #[cfg(target_arch = "aarch64")]
1666    #[test]
1667    fn neon_wide_matches_scalar_bitwise() {
1668        // The #40 wide kernel must be bit-identical to score_wide_scalar for
1669        // every 5/6-bit vector, including per-vector batch boundaries and
1670        // padded tail dims.
1671        for bits in [5u8, 6] {
1672            for dim in [128usize, 384] {
1673                let mut idx = VecqIndex::new(dim, 91);
1674                idx.set_bits(bits);
1675                for i in 0..12 {
1676                    idx.add(&rand_unit(dim, i + 90));
1677                }
1678                let q = rand_unit(dim, 1234);
1679                let pq = idx.prepare_query(&q);
1680                let bpv = idx.bytes_per_vector();
1681                for chunk_start in (0..12).step_by(4) {
1682                    let codes4 = &idx.codes[chunk_start * bpv..(chunk_start + 4) * bpv];
1683                    let qslice = &pq.rotated[..idx.padded()];
1684                    let batched = unsafe { neon::score_neon_wide4(codes4, qslice, bits) };
1685                    for v in 0..4 {
1686                        let scalar =
1687                            score_wide_scalar(&codes4[v * bpv..(v + 1) * bpv], qslice, bits);
1688                        assert_eq!(
1689                            batched[v].to_bits(),
1690                            scalar.to_bits(),
1691                            "bits {bits} dim {dim} chunk {chunk_start} vec {v}"
1692                        );
1693                    }
1694                }
1695            }
1696        }
1697    }
1698
1699    #[cfg(target_arch = "x86_64")]
1700    #[test]
1701    fn avx2_matches_scalar_bitwise() {
1702        if !avx2::available() {
1703            return; // host without AVX2: scalar path is the only path
1704        }
1705        // dim 128: padded 128 (bpv 32, 4 full blocks). dim 8: padded 8
1706        // (bpv 4) — exercises the 4-byte scalar tail after the block loop.
1707        for (dim, seed) in [(128, 42), (8, 43)] {
1708            let mut idx = VecqIndex::new(dim, seed);
1709            for i in 0..30 {
1710                idx.add(&rand_unit(dim, i + 500));
1711            }
1712            let q = rand_unit(dim, 777);
1713            let pq = idx.prepare_query(&q);
1714            for vi in 0..30 {
1715                let base = vi * (idx.padded() / 2);
1716                let codes = &idx.codes[base..base + idx.padded() / 2];
1717                let qslice = &pq.rotated[..idx.padded()];
1718                let avx2raw = unsafe { avx2::score_avx2(codes, qslice, &pq.lut) };
1719                let scalar = score_scalar(codes, qslice, &pq.lut);
1720                assert_eq!(
1721                    avx2raw.to_bits(),
1722                    scalar.to_bits(),
1723                    "dim {dim} vector {vi}: AVX2 and scalar diverged"
1724                );
1725            }
1726        }
1727    }
1728
1729    #[cfg(target_arch = "x86_64")]
1730    #[test]
1731    fn avx24_matches_avx2_bitwise() {
1732        if !avx2::available() {
1733            return;
1734        }
1735        let dim = 128;
1736        let mut idx = VecqIndex::new(dim, 91);
1737        for i in 0..12 {
1738            idx.add(&rand_unit(dim, i + 90));
1739        }
1740        let q = rand_unit(dim, 1234);
1741        let pq = idx.prepare_query(&q);
1742        let bpv = idx.padded() / 2;
1743        for chunk_start in (0..12).step_by(4) {
1744            let codes4 = &idx.codes[chunk_start * bpv..(chunk_start + 4) * bpv];
1745            let qslice = &pq.rotated[..idx.padded()];
1746            let batched = unsafe { avx2::score_avx24(codes4, qslice, &pq.lut) };
1747            for v in 0..4 {
1748                let single =
1749                    unsafe { avx2::score_avx2(&codes4[v * bpv..(v + 1) * bpv], qslice, &pq.lut) };
1750                assert_eq!(
1751                    batched[v].to_bits(),
1752                    single.to_bits(),
1753                    "chunk {chunk_start} vec {v}: avx24 diverged from avx2"
1754                );
1755            }
1756        }
1757    }
1758
1759    #[cfg(target_arch = "x86_64")]
1760    #[test]
1761    fn search_dispatch_matches_scalar_on_avx2_hosts() {
1762        // Whatever path dispatch picks, results must equal the scalar
1763        // reference bit for bit — at every width.
1764        let dim = 128;
1765        for bits in [4u8, 5, 6] {
1766            let mut idx = VecqIndex::new(dim, 55);
1767            idx.set_bits(bits);
1768            for i in 0..30 {
1769                idx.add(&rand_unit(dim, i + 800));
1770            }
1771            let q = rand_unit(dim, 888);
1772            let pq = idx.prepare_query(&q);
1773            for vi in 0..30 {
1774                let base = vi * idx.bytes_per_vector();
1775                let codes = &idx.codes[base..base + idx.bytes_per_vector()];
1776                let qslice = &pq.rotated[..idx.padded()];
1777                let scalar = if bits == 4 {
1778                    score_scalar(codes, qslice, &pq.lut)
1779                } else {
1780                    score_wide_scalar(codes, qslice, bits)
1781                };
1782                assert_eq!(
1783                    idx.score(&pq, vi).to_bits(),
1784                    (scalar * idx.scales[vi]).to_bits(),
1785                    "bits {bits} vec {vi}"
1786                );
1787            }
1788        }
1789    }
1790
1791    #[test]
1792    fn search_returns_sorted_results() {
1793        let dim = 64;
1794        let mut idx = VecqIndex::new(dim, 3);
1795        for i in 0..50 {
1796            idx.add(&rand_unit(dim, i * 31 + 5));
1797        }
1798        let q = rand_unit(dim, 77);
1799        let res = idx.search(&q, 5);
1800        assert_eq!(res.len(), 5);
1801        for w in res.windows(2) {
1802            assert!(w[0].1 >= w[1].1);
1803        }
1804    }
1805
1806    #[test]
1807    fn quantized_size_is_one_eighth() {
1808        let dim = 384;
1809        let mut idx = VecqIndex::new(dim, 1);
1810        idx.add(&rand_unit(dim, 11));
1811        // Default (5-bit): padded 512 * 5 bits = 320 bytes.
1812        assert_eq!(idx.codes.len(), 320);
1813        assert_eq!(idx.bytes_per_vector(), 320);
1814        // 4-bit legacy: 256 bytes. 6-bit: 384 bytes.
1815        let mut idx4 = VecqIndex::new(dim, 1);
1816        idx4.set_bits(4);
1817        idx4.add(&rand_unit(dim, 11));
1818        assert_eq!(idx4.codes.len(), 256);
1819        let mut idx6 = VecqIndex::new(dim, 1);
1820        idx6.set_bits(6);
1821        idx6.add(&rand_unit(dim, 11));
1822        assert_eq!(idx6.codes.len(), 384);
1823    }
1824
1825    #[test]
1826    fn keyed_add_search_remove() {
1827        let dim = 64;
1828        let mut idx = VecqIndex::new(dim, 5);
1829        for i in 0..50u64 {
1830            idx.add_keyed(1000 + i, &rand_unit(dim, i * 17 + 3));
1831        }
1832        assert_eq!(idx.len(), 50);
1833        assert!(idx.contains_key(1000));
1834        assert!(!idx.contains_key(999));
1835
1836        let q = rand_unit(dim, 77);
1837        let keyed = idx.search_keyed(&q, 5);
1838        assert_eq!(keyed.len(), 5);
1839        for w in keyed.windows(2) {
1840            assert!(w[0].1 >= w[1].1);
1841        }
1842        // Keys from search_keyed must all exist and match positional results.
1843        let positional = idx.search(&q, 5);
1844        for ((key, ks), (slot, ps)) in keyed.iter().zip(positional.iter()) {
1845            assert_eq!(key, &idx.key_of(*slot).unwrap());
1846            assert_eq!(ks.to_bits(), ps.to_bits());
1847        }
1848
1849        // Remove the top hit: it must vanish from results, others keep scores.
1850        let top_key = keyed[0].0;
1851        assert!(idx.remove_keyed(top_key));
1852        assert!(!idx.remove_keyed(top_key), "second remove is a no-op");
1853        assert!(!idx.remove_keyed(12345), "unknown key returns false");
1854        assert_eq!(idx.len(), 49);
1855        assert_eq!(idx.tombstones(), 1);
1856        let keyed2 = idx.search_keyed(&q, 5);
1857        assert!(!keyed2.iter().any(|(k, _)| *k == top_key));
1858        for (k, s) in keyed2.iter() {
1859            let old = keyed.iter().find(|(ok, _)| ok == k).map(|(_, os)| *os);
1860            if let Some(os) = old {
1861                assert_eq!(s.to_bits(), os.to_bits(), "key {k} score changed");
1862            }
1863        }
1864    }
1865
1866    #[test]
1867    fn keyed_add_same_key_replaces() {
1868        let dim = 32;
1869        let mut idx = VecqIndex::new(dim, 9);
1870        idx.add_keyed(7, &rand_unit(dim, 101));
1871        idx.add_keyed(7, &rand_unit(dim, 202));
1872        assert_eq!(idx.len(), 1, "replace must not grow the index");
1873        assert_eq!(idx.tombstones(), 0);
1874        // The stored vector is the second one: query near it, key 7 wins.
1875        let q = rand_unit(dim, 202);
1876        let res = idx.search_keyed(&q, 1);
1877        assert_eq!(res[0].0, 7);
1878    }
1879
1880    #[test]
1881    fn keyed_slot_indices_stay_stable_across_remove_and_serialize() {
1882        let dim = 64;
1883        let mut idx = VecqIndex::new(dim, 15);
1884        for i in 0..20u64 {
1885            idx.add_keyed(i, &rand_unit(dim, i + 300));
1886        }
1887        let q = rand_unit(dim, 404);
1888        let before = idx.search(&q, 20);
1889        // Remove two vectors: remaining slot indices must not shift.
1890        idx.remove_keyed(idx.key_of(before[0].0).unwrap());
1891        idx.remove_keyed(idx.key_of(before[5].0).unwrap());
1892        let after = idx.search(&q, 20);
1893        assert_eq!(after.len(), 18);
1894        for (slot, s) in &after {
1895            let old = before.iter().find(|(os, _)| os == slot);
1896            assert!(old.is_some(), "slot {slot} moved after remove");
1897            assert_eq!(old.unwrap().1.to_bits(), s.to_bits());
1898        }
1899        // Serializing drops tombstones on disk but must not disturb memory.
1900        let bytes = idx.to_bytes();
1901        let disk = VecqIndex::from_bytes(&bytes).unwrap();
1902        assert_eq!(disk.len(), 18);
1903        assert_eq!(idx.search(&q, 20), after, "in-memory results unchanged");
1904    }
1905
1906    #[test]
1907    fn compact_drops_tombstones_and_preserves_results() {
1908        let dim = 64;
1909        let mut idx = VecqIndex::new(dim, 21);
1910        for i in 0..40u64 {
1911            idx.add_keyed(10 * i, &rand_unit(dim, i + 61));
1912        }
1913        for i in 0..20u64 {
1914            assert!(idx.remove_keyed(10 * i));
1915        }
1916        let q = rand_unit(dim, 123);
1917        let expected = idx.search_keyed(&q, 20);
1918        idx.compact();
1919        assert_eq!(idx.tombstones(), 0);
1920        assert_eq!(idx.len(), 20);
1921        assert_eq!(idx.search_keyed(&q, 20), expected);
1922        // Round-trip after compact: keys are not persisted by design, so the
1923        // reloaded index is searchable positionally. f16 scales perturb
1924        // scores by <1e-3, so compare order and approximate scores.
1925        let bytes = idx.to_bytes();
1926        let back = VecqIndex::from_bytes(&bytes).unwrap();
1927        let reloaded = back.search(&q, 20);
1928        assert_eq!(reloaded.len(), 20);
1929        for ((slot, s), (key, ks)) in reloaded.iter().zip(expected.iter()) {
1930            assert_eq!(idx.key_of(*slot), Some(*key));
1931            assert!(
1932                (s - ks).abs() < 1e-3,
1933                "key {key} score drifted: {s} vs {ks}"
1934            );
1935        }
1936    }
1937
1938    #[test]
1939    fn keyed_search_on_empty_and_drained_index() {
1940        let dim = 32;
1941        let mut idx = VecqIndex::new(dim, 31);
1942        assert!(idx.search_keyed(&rand_unit(dim, 1), 3).is_empty());
1943        idx.add_keyed(1, &rand_unit(dim, 2));
1944        idx.add_keyed(2, &rand_unit(dim, 3));
1945        assert!(idx.remove_keyed(1));
1946        assert!(idx.remove_keyed(2));
1947        assert!(idx.is_empty(), "drained index reports empty");
1948        assert_eq!(idx.tombstones(), 2);
1949        assert!(idx.search_keyed(&rand_unit(dim, 4), 3).is_empty());
1950    }
1951
1952    #[test]
1953    fn keyed_keys_survive_file_round_trip() {
1954        // Regression for issue #32: the keyed map must survive a save/reload.
1955        let dim = 64;
1956        let mut idx = VecqIndex::new(dim, 42);
1957        let v = rand_unit(dim, 11);
1958        idx.add_keyed(10, &v);
1959        idx.add_keyed(20, &rand_unit(dim, 22));
1960        let bytes = idx.to_bytes();
1961        let mut back = VecqIndex::from_bytes(&bytes).expect("parse");
1962        assert_eq!(back.len(), 2);
1963        assert!(back.contains_key(10));
1964        assert!(back.contains_key(20));
1965        assert_eq!(back.key_of(0), Some(10));
1966        assert_eq!(back.key_of(1), Some(20));
1967        let hits = back.search_keyed(&v, 5);
1968        assert!(!hits.is_empty(), "keys must survive reload (issue #32)");
1969        assert_eq!(hits[0].0, 10);
1970        // The reloaded index is fully keyed-capable.
1971        assert!(back.remove_keyed(20));
1972        assert_eq!(back.len(), 1);
1973        let slot = back.add_keyed_multi(10, &rand_unit(dim, 33));
1974        assert_eq!(back.key_of(slot), Some(10));
1975        assert!(back.relabel(10, 30));
1976        assert_eq!(back.key_of(slot), Some(30));
1977    }
1978
1979    #[test]
1980    fn multi_key_round_trip_and_compact() {
1981        let dim = 64;
1982        let mut idx = VecqIndex::new(dim, 7);
1983        idx.add_keyed(1, &rand_unit(dim, 101));
1984        idx.add_keyed_multi(1, &rand_unit(dim, 202));
1985        idx.add_keyed_multi(1, &rand_unit(dim, 303));
1986        idx.add_keyed(2, &rand_unit(dim, 404));
1987        let bytes = idx.to_bytes();
1988        let mut back = VecqIndex::from_bytes(&bytes).expect("parse");
1989        assert_eq!(back.len(), 4);
1990        // Multi structure restored: remove one slot, key survives with two.
1991        assert!(back.remove_keyed_at(1, 1));
1992        assert!(back.contains_key(1));
1993        assert_eq!(back.tombstones(), 1);
1994        // Compact keeps keys and multi grouping.
1995        back.compact();
1996        assert_eq!(back.tombstones(), 0);
1997        assert!(back.contains_key(1));
1998        assert!(back.contains_key(2));
1999        let probe = rand_unit(dim, 404);
2000        assert_eq!(back.search_keyed(&probe, 5)[0].0, 2);
2001        // Re-serialize stays stable.
2002        let bytes2 = back.to_bytes();
2003        assert_eq!(bytes2, back.to_bytes());
2004        assert_eq!(VecqIndex::from_bytes(&bytes2).unwrap().len(), 3);
2005    }
2006
2007    #[test]
2008    fn keyed_index_from_file_supports_keyed_adds() {
2009        let dim = 64;
2010        let mut idx = VecqIndex::new(dim, 41);
2011        for i in 0..10u64 {
2012            idx.add(&rand_unit(dim, i + 700));
2013        }
2014        let bytes = idx.to_bytes();
2015        let mut back = VecqIndex::from_bytes(&bytes).unwrap();
2016        back.add_keyed(555, &rand_unit(dim, 999));
2017        assert!(back.contains_key(555));
2018        assert_eq!(back.len(), 11);
2019        let q = rand_unit(dim, 999);
2020        assert_eq!(back.search_keyed(&q, 1)[0].0, 555);
2021    }
2022
2023    #[test]
2024    #[should_panic(expected = "vector dim mismatch")]
2025    fn keyed_add_dim_mismatch_panics() {
2026        let mut idx = VecqIndex::new(32, 3);
2027        idx.add_keyed(1, &[0.5; 64]);
2028    }
2029
2030    // -- keyed parity: relabel + multi-vectors-per-key -----------------------
2031
2032    #[test]
2033    fn relabel_moves_key_and_rejects_conflicts() {
2034        let dim = 32;
2035        let mut idx = VecqIndex::new(dim, 5);
2036        idx.add_keyed(1, &rand_unit(dim, 11));
2037        assert!(idx.relabel(1, 2), "relabel to a free key succeeds");
2038        assert!(!idx.contains_key(1));
2039        assert!(idx.contains_key(2));
2040        // The vector moved with the key.
2041        let q = rand_unit(dim, 11);
2042        assert_eq!(idx.search_keyed(&q, 1)[0].0, 2);
2043        // Unknown source key fails.
2044        assert!(!idx.relabel(1, 3));
2045        // Taken target key fails.
2046        idx.add_keyed(3, &rand_unit(dim, 22));
2047        assert!(!idx.relabel(3, 2));
2048        // Relabeling onto itself is a no-op success.
2049        assert!(idx.relabel(2, 2));
2050        assert!(idx.contains_key(2));
2051    }
2052
2053    #[test]
2054    fn multi_key_add_search_and_dedupe() {
2055        let dim = 32;
2056        let mut idx = VecqIndex::new(dim, 9);
2057        let v1 = rand_unit(dim, 101);
2058        let v2 = rand_unit(dim, 202);
2059        idx.add_keyed(7, &v1);
2060        idx.add_keyed_multi(7, &v2);
2061        assert_eq!(idx.len(), 2, "multi add appends a slot");
2062        assert_eq!(idx.tombstones(), 0);
2063        // search_keyed returns the key once, with its best slot's score.
2064        let q_v2 = v2.clone();
2065        let hits = idx.search_keyed(&q_v2, 5);
2066        assert_eq!(hits.len(), 1, "key must be deduped across its slots");
2067        assert_eq!(hits[0].0, 7);
2068        let q_v1 = v1.clone();
2069        assert_eq!(idx.search_keyed(&q_v1, 5)[0].0, 7);
2070        // Adding a second key: top hit is the closer key, still deduped.
2071        idx.add_keyed(8, &rand_unit(dim, 303));
2072        let hits = idx.search_keyed(&q_v2, 5);
2073        assert_eq!(hits.len(), 2);
2074        assert_eq!(hits[0].0, 7);
2075    }
2076
2077    #[test]
2078    fn multi_key_remove_and_remove_at() {
2079        let dim = 32;
2080        let mut idx = VecqIndex::new(dim, 13);
2081        idx.add_keyed(7, &rand_unit(dim, 101));
2082        idx.add_keyed_multi(7, &rand_unit(dim, 202));
2083        // remove_keyed drops every slot of the key.
2084        assert!(idx.remove_keyed(7));
2085        assert_eq!(idx.len(), 0);
2086        assert_eq!(idx.tombstones(), 2);
2087        assert!(!idx.contains_key(7));
2088        assert!(!idx.remove_keyed(7));
2089        // remove_keyed_at drops one slot; the key survives while slots remain.
2090        let mut idx2 = VecqIndex::new(dim, 17);
2091        idx2.add_keyed(9, &rand_unit(dim, 111));
2092        let slot0 = idx2.add_keyed_multi(9, &rand_unit(dim, 222));
2093        let slot1 = idx2.add_keyed_multi(9, &rand_unit(dim, 333));
2094        assert!(idx2.remove_keyed_at(9, slot0));
2095        assert!(idx2.contains_key(9));
2096        assert!(idx2.remove_keyed_at(9, slot1));
2097        assert!(idx2.contains_key(9), "primary slot keeps the key alive");
2098        assert!(!idx2.remove_keyed_at(9, slot0), "already-dead slot");
2099        assert_eq!(idx2.len(), 1);
2100    }
2101
2102    #[test]
2103    fn multi_key_replace_and_relabel_and_compact() {
2104        let dim = 32;
2105        let mut idx = VecqIndex::new(dim, 21);
2106        idx.add_keyed(7, &rand_unit(dim, 101));
2107        idx.add_keyed_multi(7, &rand_unit(dim, 202));
2108        // add_keyed on an existing multi key replaces its primary slot.
2109        let v3 = rand_unit(dim, 404);
2110        let replaced = idx.add_keyed(7, &v3);
2111        assert_eq!(idx.len(), 2, "replace must not grow the index");
2112        let hits = idx.search_keyed(&v3, 5);
2113        assert_eq!(hits[0].0, 7);
2114        let _ = replaced;
2115        // Relabel a multi key.
2116        assert!(idx.relabel(7, 8));
2117        assert_eq!(idx.search_keyed(&v3, 5)[0].0, 8);
2118        // Compact preserves the multi mapping and deduped search.
2119        let q = v3.clone();
2120        let expected = idx.search_keyed(&q, 5);
2121        idx.compact();
2122        assert_eq!(idx.tombstones(), 0);
2123        assert_eq!(idx.search_keyed(&q, 5), expected);
2124    }
2125
2126    // -- Matryoshka working_dim ----------------------------------------------
2127
2128    #[test]
2129    fn working_dim_equal_to_dim_matches_new_bitwise() {
2130        let dim = 128;
2131        let mut a = VecqIndex::new(dim, 7);
2132        let mut b = VecqIndex::with_working_dim(dim, dim, 7);
2133        for i in 0..20 {
2134            a.add(&rand_unit(dim, i + 61));
2135            b.add(&rand_unit(dim, i + 61));
2136        }
2137        let q = rand_unit(dim, 321);
2138        let ra = a.search(&q, 5);
2139        let rb = b.search(&q, 5);
2140        assert_eq!(ra.len(), rb.len());
2141        for ((sa, fa), (sb, fb)) in ra.iter().zip(rb.iter()) {
2142            assert_eq!(sa, sb);
2143            assert_eq!(fa.to_bits(), fb.to_bits());
2144        }
2145        assert_eq!(a.to_bytes(), b.to_bytes());
2146        assert_eq!(b.working_dim(), dim);
2147    }
2148
2149    #[test]
2150    fn working_dim_truncates_storage_and_keeps_leading_signal() {
2151        // Matryoshka-style synthetic: signal lives in the leading dims, the
2152        // tail is noise. A working_dim index over the leading dims must keep
2153        // the neighbor ranking (truncation is the Matryoshka contract) at a
2154        // fraction of the storage.
2155        let (dim, working, n) = (256, 64, 40);
2156        let signal: Vec<Vec<f32>> = (0..n).map(|i| rand_unit(working, i * 13 + 5)).collect();
2157        let mut full = VecqIndex::new(dim, 11);
2158        let mut trunc = VecqIndex::with_working_dim(dim, working, 11);
2159        for (i, s) in signal.iter().enumerate() {
2160            // leading dims carry the identity, tail is per-vector noise
2161            let mut v = vec![0f32; dim];
2162            v[..working].copy_from_slice(s);
2163            let noise = rand_unit(dim - working, 9_000 + i as u64);
2164            v[working..].copy_from_slice(&noise.iter().map(|x: &f32| x * 0.05).collect::<Vec<_>>());
2165            let norm: f32 = v.iter().map(|x| x * x).sum::<f32>().sqrt();
2166            for x in v.iter_mut() {
2167                *x /= norm;
2168            }
2169            full.add(&v);
2170            trunc.add(&v);
2171        }
2172        assert_eq!(trunc.working_dim(), working);
2173        // Storage: padded(64)/2 + 2 = 34 B/vec vs padded(256)/2 + 2 = 130.
2174        assert!(
2175            trunc.to_bytes().len() * 3 < full.to_bytes().len(),
2176            "expected ~3x smaller"
2177        );
2178        // Query: same construction as the true neighbor of vector 0.
2179        let q = &signal[0];
2180        let mut qv = vec![0f32; dim];
2181        qv[..working].copy_from_slice(q);
2182        let noise = rand_unit(dim - working, 9_000);
2183        qv[working..].copy_from_slice(&noise.iter().map(|x: &f32| x * 0.05).collect::<Vec<_>>());
2184        let norm: f32 = qv.iter().map(|x| x * x).sum::<f32>().sqrt();
2185        for x in qv.iter_mut() {
2186            *x /= norm;
2187        }
2188        let rt = trunc.search(&qv, 1);
2189        assert_eq!(rt[0].0, 0, "truncated index must rank vector 0 first");
2190    }
2191
2192    #[test]
2193    fn working_dim_round_trips_through_file() {
2194        let (dim, working) = (256, 64);
2195        let mut idx = VecqIndex::with_working_dim(dim, working, 21);
2196        for i in 0..10 {
2197            idx.add(&rand_unit(dim, i + 700));
2198        }
2199        let q = rand_unit(dim, 999);
2200        let expected = idx.search(&q, 5);
2201        let bytes = idx.to_bytes();
2202        let back = VecqIndex::from_bytes(&bytes).expect("parse v1.2");
2203        assert_eq!(back.working_dim(), working);
2204        assert_eq!(back.dim(), dim);
2205        // f16 scales perturb scores by <1e-3: compare top-1 exactly, then
2206        // overlap and approximate scores (mirrors the v1.1 round-trip test).
2207        let reloaded = back.search(&q, 5);
2208        assert_eq!(reloaded[0].0, expected[0].0);
2209        assert_eq!(reloaded.len(), expected.len());
2210        for ((s0, f0), (_, f1)) in reloaded.iter().zip(expected.iter()) {
2211            assert!((f0 - f1).abs() < 1e-3, "slot {s0}: {f0} vs {f1}");
2212        }
2213    }
2214
2215    #[test]
2216    fn legacy_v11_file_still_loads() {
2217        // A v1.1 file (reserved = 0) written before working_dim existed must
2218        // load as a full-dim index. v1.1 and v1.2 payloads are identical when
2219        // working_dim == dim (only the version field differs), so both loads
2220        // must agree bit for bit.
2221        let mut idx = VecqIndex::new(128, 33);
2222        idx.set_bits(4); // legacy v1.1 payloads are 4-bit
2223        for i in 0..8 {
2224            idx.add(&rand_unit(128, i + 50));
2225        }
2226        let bytes = idx.to_bytes(); // v1.3 now
2227        assert_eq!(u16::from_le_bytes([bytes[4], bytes[5]]), 259);
2228        let mut v11 = bytes.clone();
2229        v11[4] = 257u16.to_le_bytes()[0];
2230        v11[5] = 257u16.to_le_bytes()[1];
2231        let q = rand_unit(128, 777);
2232        assert_eq!(
2233            VecqIndex::from_bytes(&v11).unwrap().search(&q, 5),
2234            VecqIndex::from_bytes(&bytes).unwrap().search(&q, 5)
2235        );
2236    }
2237
2238    #[test]
2239    #[should_panic(expected = "working_dim")]
2240    fn working_dim_greater_than_dim_panics() {
2241        let _ = VecqIndex::with_working_dim(64, 128, 1);
2242    }
2243
2244    #[test]
2245    #[should_panic(expected = "working_dim")]
2246    fn working_dim_zero_panics() {
2247        let _ = VecqIndex::with_working_dim(64, 0, 1);
2248    }
2249
2250    #[test]
2251    #[should_panic(expected = "u16")]
2252    fn working_dim_beyond_u16_range_panics() {
2253        // Regression: `working_dim as u16` in to_bytes silently wrapped for
2254        // working_dim > 65535, producing files that parse with the wrong
2255        // code layout. Only working_dim == dim may exceed u16::MAX (stored
2256        // as 0 in the header).
2257        let _ = VecqIndex::with_working_dim(100_000, 70_000, 1);
2258    }
2259
2260    #[test]
2261    fn full_dim_index_may_exceed_u16_dim() {
2262        // working_dim == dim is stored as 0 in the header, so huge dims are
2263        // representable.
2264        let mut idx = VecqIndex::with_working_dim(70_000, 70_000, 1);
2265        let mut v = vec![0f32; 70_000];
2266        v[0] = 1.0;
2267        idx.add(&v);
2268        let q = vec![0f32; 70_000];
2269        let mut q = q;
2270        q[0] = 1.0;
2271        assert_eq!(idx.search(&q, 1)[0].0, 0);
2272    }
2273
2274    // -- cascade search (1-bit Hamming prefilter + 4-bit rescore) ------------
2275
2276    /// Clustered dataset in the style of the recall benchmark. `spread`
2277    /// scales the per-dim noise around the centroid (0.5 = noise-dominated
2278    /// and adversarial for coarse codes; ~0.1 = realistic embedding
2279    /// structure).
2280    fn clustered(n: usize, dim: usize, clusters: usize, seed: u64, spread: f32) -> Vec<Vec<f32>> {
2281        fn next(x: &mut u64) -> f32 {
2282            // Centered uniform in [-0.5, 0.5): uncentered noise makes every
2283            // vector share a large positive component, which collapses the
2284            // 1-bit signatures (all bits 1) and destroys Hamming ranking.
2285            *x ^= *x << 13;
2286            *x ^= *x >> 7;
2287            *x ^= *x << 17;
2288            *x as f32 / u32::MAX as f32 - 0.5
2289        }
2290        let mut x = seed | 1;
2291        let mut centroids: Vec<Vec<f32>> = Vec::new();
2292        for _ in 0..clusters {
2293            let mut v: Vec<f32> = (0..dim).map(|_| next(&mut x)).collect();
2294            let norm: f32 = v.iter().map(|a| a * a).sum::<f32>().sqrt();
2295            v.iter_mut().for_each(|a| *a /= norm);
2296            centroids.push(v);
2297        }
2298        (0..n)
2299            .map(|i| {
2300                let c = &centroids[i % clusters];
2301                let mut v: Vec<f32> = c.iter().map(|&a| a + spread * next(&mut x)).collect();
2302                let norm: f32 = v.iter().map(|a| a * a).sum::<f32>().sqrt();
2303                v.iter_mut().for_each(|a| *a /= norm);
2304                v
2305            })
2306            .collect()
2307    }
2308
2309    fn exact_top(base: &[Vec<f32>], q: &[f32], k: usize) -> Vec<usize> {
2310        let mut s: Vec<(usize, f32)> = base
2311            .iter()
2312            .enumerate()
2313            .map(|(i, v)| (i, cosine_f32(q, v)))
2314            .collect();
2315        s.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap());
2316        s.into_iter().take(k).map(|(i, _)| i).collect()
2317    }
2318
2319    #[test]
2320    fn cascade_with_full_r_matches_exact_search_bitwise() {
2321        let dim = 128;
2322        let base = clustered(60, dim, 10, 5, 0.5);
2323        let mut idx = VecqIndex::new(dim, 42);
2324        idx.set_bits(4); // cascade signatures are 4-bit-only
2325        for v in &base {
2326            idx.add(v);
2327        }
2328        idx.enable_cascade();
2329        assert!(idx.cascade_enabled());
2330        let q = &base[0];
2331        let exact = idx.search(q, 10);
2332        let casc = idx.search_cascade(q, 10, 60);
2333        assert_eq!(casc.len(), exact.len());
2334        for ((sa, fa), (sb, fb)) in exact.iter().zip(casc.iter()) {
2335            assert_eq!(sa, sb, "slot order must match with r >= n");
2336            assert_eq!(fa.to_bits(), fb.to_bits(), "scores must be bit-identical");
2337        }
2338    }
2339
2340    #[test]
2341    fn cascade_recall_grows_with_r() {
2342        // This synthetic clustered set is adversarial for coarse codes
2343        // (per-dim noise dominates the centroid signal), so absolute recall
2344        // at small r is modest. What must hold: recall is monotone in the
2345        // prefilter width r and reaches near-exact recall once r covers a
2346        // fifth of the collection. Real embeddings need far smaller r —
2347        // measure on your data (see search_cascade docs).
2348        let recall = |idx: &VecqIndex, base: &[Vec<f32>], r: usize| -> f32 {
2349            let queries = &base[990..];
2350            let mut sum = 0f32;
2351            for q in queries {
2352                let truth = exact_top(base, q, 10);
2353                let got: Vec<usize> = idx
2354                    .search_cascade(q, 10, r)
2355                    .into_iter()
2356                    .map(|(i, _)| i)
2357                    .collect();
2358                sum += truth.iter().filter(|t| got.contains(t)).count() as f32 / 10.0;
2359            }
2360            sum / queries.len() as f32
2361        };
2362        // Moderate structure: a 10% scan recovers the majority of the true
2363        // top-10. (Real embeddings show much stronger sign correlation than
2364        // any synthetic set here; the 0.9+ gate belongs to that validation.)
2365        let dim = 128;
2366        let base = clustered(1000, dim, 50, 9, 0.1);
2367        let mut idx = VecqIndex::new(dim, 42);
2368        idx.set_bits(4); // cascade signatures are 4-bit-only
2369        for v in &base {
2370            idx.add(v);
2371        }
2372        idx.enable_cascade();
2373        let r100 = recall(&idx, &base, 100);
2374        assert!(r100 >= 0.5, "moderate set: r=100 (10% scan) recall {r100}");
2375        // Adversarial set (noise-dominated): recall stays monotone in r and
2376        // a 20% scan still recovers most of the truth.
2377        let base = clustered(1000, dim, 50, 9, 0.5);
2378        let mut idx = VecqIndex::new(dim, 42);
2379        idx.set_bits(4); // cascade signatures are 4-bit-only
2380        for v in &base {
2381            idx.add(v);
2382        }
2383        idx.enable_cascade();
2384        let (r25, r50, r100, r200) = (
2385            recall(&idx, &base, 25),
2386            recall(&idx, &base, 50),
2387            recall(&idx, &base, 100),
2388            recall(&idx, &base, 200),
2389        );
2390        assert!(
2391            r25 <= r50 && r50 <= r100 && r100 <= r200,
2392            "recall must be monotone in r: {r25} {r50} {r100} {r200}"
2393        );
2394        assert!(r200 >= 0.6, "adversarial set: r=200 recall {r200}");
2395    }
2396
2397    #[test]
2398    fn cascade_skips_tombstones_and_stays_deterministic() {
2399        let dim = 64;
2400        let base = clustered(80, dim, 8, 3, 0.5);
2401        let mut idx = VecqIndex::new(dim, 42);
2402        idx.set_bits(4); // cascade signatures are 4-bit-only
2403        for v in &base {
2404            idx.add(v);
2405        }
2406        idx.enable_cascade();
2407        idx.remove_keyed(0);
2408        idx.remove_keyed(1);
2409        let q = &base[5];
2410        let a = idx.search_cascade(q, 10, 80);
2411        let b = idx.search_cascade(q, 10, 80);
2412        assert_eq!(a, b, "deterministic across calls");
2413        assert!(
2414            !a.iter().any(|(s, _)| *s < 2),
2415            "tombstoned slots must be skipped"
2416        );
2417        // With r covering everything, results equal exact search.
2418        let exact = idx.search(q, 10);
2419        assert_eq!(a, exact);
2420    }
2421
2422    #[test]
2423    fn cascade_works_after_reload_and_compact() {
2424        let dim = 64;
2425        let base = clustered(40, dim, 6, 11, 0.5);
2426        let mut idx = VecqIndex::new(dim, 42);
2427        idx.set_bits(4); // cascade signatures are 4-bit-only
2428        for v in &base {
2429            idx.add_keyed(100 + 1, v);
2430        }
2431        let bytes = idx.to_bytes();
2432        let mut back = VecqIndex::from_bytes(&bytes).unwrap();
2433        assert!(!back.cascade_enabled(), "signatures are not persisted");
2434        back.enable_cascade();
2435        let q = &base[3];
2436        let casc = back.search_cascade(q, 5, 40);
2437        assert_eq!(casc, back.search(q, 5));
2438        // Compact keeps cascade working and correct.
2439        back.remove_keyed(101);
2440        back.compact();
2441        back.enable_cascade();
2442        let casc2 = back.search_cascade(q, 5, 39);
2443        assert_eq!(casc2, back.search(q, 5));
2444    }
2445
2446    #[test]
2447    fn cascade_on_empty_index_returns_empty() {
2448        let mut idx = VecqIndex::new(64, 1);
2449        idx.set_bits(4); // cascade signatures are 4-bit-only
2450        idx.enable_cascade();
2451        let q = rand_unit(64, 2);
2452        assert!(idx.search_cascade(&q, 5, 50).is_empty());
2453    }
2454
2455    #[test]
2456    fn cascade_r_clamps_to_live_count() {
2457        let dim = 32;
2458        let mut idx = VecqIndex::new(dim, 3);
2459        idx.set_bits(4); // cascade signatures are 4-bit-only
2460        for i in 0..4 {
2461            idx.add(&rand_unit(dim, i + 80));
2462        }
2463        idx.enable_cascade();
2464        let q = rand_unit(dim, 90);
2465        let casc = idx.search_cascade(&q, 2, 1000);
2466        assert_eq!(casc.len(), 2);
2467        assert_eq!(casc, idx.search(&q, 2));
2468    }
2469
2470    #[test]
2471    fn cascade_works_with_working_dim_index() {
2472        let dim = 128;
2473        let working = 64;
2474        let base = clustered(50, dim, 7, 13, 0.5);
2475        let mut idx = VecqIndex::with_working_dim(dim, working, 42);
2476        idx.set_bits(4); // cascade signatures are 4-bit-only
2477        for v in &base {
2478            idx.add(v);
2479        }
2480        idx.enable_cascade();
2481        let q = &base[2];
2482        assert_eq!(idx.search_cascade(q, 5, 50), idx.search(q, 5));
2483    }
2484
2485    // -- residual quantization (second-pass codes) ---------------------------
2486
2487    #[test]
2488    fn residual_improves_recall_over_plain_on_noisy_data() {
2489        // The adversarial clustered set is exactly where a second-pass
2490        // residual code should pay off: plain 4-bit codes quantize the
2491        // noise-dominated rotated dims coarsely.
2492        let dim = 128;
2493        let base = clustered(600, dim, 30, 19, 0.5);
2494        let mut plain = VecqIndex::new(dim, 42);
2495        plain.set_bits(4); // the 4-bit-vs-residual comparison from #38
2496        let mut resid = VecqIndex::with_residual(dim, 42);
2497        for v in &base {
2498            plain.add(v);
2499            resid.add(v);
2500        }
2501        let queries = &base[590..];
2502        let recall = |idx: &VecqIndex| -> f32 {
2503            let mut sum = 0f32;
2504            for q in queries {
2505                let truth = exact_top(&base, q, 10);
2506                let got: Vec<usize> = idx.search(q, 10).into_iter().map(|(i, _)| i).collect();
2507                sum += truth.iter().filter(|t| got.contains(t)).count() as f32 / 10.0;
2508            }
2509            sum / queries.len() as f32
2510        };
2511        let r_plain = recall(&plain);
2512        let r_resid = recall(&resid);
2513        // Residual must clearly BEAT plain on this set: same 514 B/vector
2514        // budget, finer reconstruction. (Estimator note: the two-term score
2515        // must divide by the EXACT reconstruction norm including the
2516        // 2·rms·⟨d0,d1⟩ cross term — see encode_into. With the approximate
2517        // sqrt(sum_sq + rms²·padded) denominator, score variance rose ~40%
2518        // and recall DROPPED to 0.58 despite 4.5x better MSE.)
2519        assert!(
2520            r_resid > r_plain,
2521            "residual must beat plain: resid {r_resid} vs plain {r_plain}"
2522        );
2523        assert!(
2524            r_resid - r_plain >= 0.05,
2525            "residual gain must be substantial: resid {r_resid} vs plain {r_plain}"
2526        );
2527        assert!(
2528            r_resid >= 0.8,
2529            "residual recall {r_resid} (plain {r_plain})"
2530        );
2531    }
2532
2533    #[test]
2534    fn residual_search_sorted_and_deterministic() {
2535        let dim = 64;
2536        let mut idx = VecqIndex::with_residual(dim, 7);
2537        for i in 0..40 {
2538            idx.add(&rand_unit(dim, i * 7 + 1));
2539        }
2540        let q = rand_unit(dim, 555);
2541        let r1 = idx.search(&q, 10);
2542        let r2 = idx.search(&q, 10);
2543        assert_eq!(r1, r2);
2544        assert_eq!(r1.len(), 10);
2545        for w in r1.windows(2) {
2546            assert!(w[0].1 >= w[1].1);
2547        }
2548        assert!(idx.is_residual());
2549    }
2550
2551    #[test]
2552    fn residual_round_trips_through_file() {
2553        let dim = 64;
2554        let mut idx = VecqIndex::with_residual(dim, 21);
2555        for i in 0..12 {
2556            idx.add(&rand_unit(dim, i + 400));
2557        }
2558        let q = rand_unit(dim, 888);
2559        let expected = idx.search(&q, 5);
2560        let bytes = idx.to_bytes();
2561        assert_eq!(u16::from_le_bytes([bytes[4], bytes[5]]), 260, "v1.4 = 260");
2562        let back = VecqIndex::from_bytes(&bytes).expect("parse v1.4");
2563        assert!(back.is_residual());
2564        let reloaded = back.search(&q, 5);
2565        assert_eq!(reloaded[0].0, expected[0].0);
2566        for ((_, f0), (_, f1)) in reloaded.iter().zip(expected.iter()) {
2567            assert!((f0 - f1).abs() < 1e-3, "{f0} vs {f1}");
2568        }
2569        // Keyless residual re-serialize stays stable.
2570        assert_eq!(bytes, back.to_bytes());
2571    }
2572
2573    #[test]
2574    fn residual_keyed_and_cascade_compose() {
2575        let dim = 64;
2576        let mut idx = VecqIndex::with_residual(dim, 31);
2577        idx.add_keyed(5, &rand_unit(dim, 101));
2578        idx.add_keyed(6, &rand_unit(dim, 202));
2579        idx.enable_cascade();
2580        let q = rand_unit(dim, 101);
2581        let hits = idx.search_keyed(&q, 5);
2582        assert_eq!(hits[0].0, 5);
2583        let casc = idx.search_cascade(&q, 2, 2);
2584        assert_eq!(casc, idx.search(&q, 2));
2585        let bytes = idx.to_bytes();
2586        let back = VecqIndex::from_bytes(&bytes).unwrap();
2587        assert!(back.contains_key(5) && back.contains_key(6));
2588        assert_eq!(back.search_keyed(&q, 5)[0].0, 5);
2589    }
2590
2591    #[test]
2592    fn plain_index_stays_v13() {
2593        // 4-bit plain stays v1.3 (byte-identical with pre-#39 files);
2594        // 5/6-bit plain promotes to v1.5 with an explicit width byte.
2595        let mut idx = VecqIndex::new(64, 3);
2596        idx.add(&rand_unit(64, 1));
2597        assert!(!idx.is_residual());
2598        let bytes = idx.to_bytes();
2599        assert_eq!(u16::from_le_bytes([bytes[4], bytes[5]]), 261, "v1.5 = 261");
2600        assert_eq!(bytes[24], 5, "width byte");
2601        let mut idx4 = VecqIndex::new(64, 3);
2602        idx4.set_bits(4);
2603        idx4.add(&rand_unit(64, 1));
2604        let bytes4 = idx4.to_bytes();
2605        assert_eq!(
2606            u16::from_le_bytes([bytes4[4], bytes4[5]]),
2607            259,
2608            "v1.3 = 259"
2609        );
2610        // Residual stays v1.4 regardless of nothing (4-bit base only).
2611        let mut res = VecqIndex::with_residual(64, 3);
2612        res.add(&rand_unit(64, 1));
2613        let bytes_r = res.to_bytes();
2614        assert_eq!(
2615            u16::from_le_bytes([bytes_r[4], bytes_r[5]]),
2616            260,
2617            "v1.4 = 260"
2618        );
2619    }
2620
2621    #[test]
2622    fn wide_plain_matches_or_beats_residual_recall() {
2623        // Issue #39 headline: a single-pass plain index at 5 or 6 bits
2624        // matches/beats the two-pass residual index on recall — at a
2625        // smaller or equal byte budget — so width is the preferred lever
2626        // and residual stays an opt-in for tiny-code regimes.
2627        let dim = 128;
2628        let base = clustered(600, dim, 30, 19, 0.5);
2629        let mut plain6 = VecqIndex::new(dim, 42);
2630        plain6.set_bits(6);
2631        let mut resid = VecqIndex::with_residual(dim, 42);
2632        for v in &base {
2633            plain6.add(v);
2634            resid.add(v);
2635        }
2636        let queries = &base[590..];
2637        let recall = |idx: &VecqIndex| -> f32 {
2638            let mut sum = 0f32;
2639            for q in queries {
2640                let truth = exact_top(&base, q, 10);
2641                let got: Vec<usize> = idx.search(q, 10).into_iter().map(|(i, _)| i).collect();
2642                sum += truth.iter().filter(|t| got.contains(t)).count() as f32 / 10.0;
2643            }
2644            sum / queries.len() as f32
2645        };
2646        let r6 = recall(&plain6);
2647        let rr = recall(&resid);
2648        // Byte budget: 6-bit plain = 384 B/vec vs residual (2 x 256 B) = 512 B/vec.
2649        assert!(
2650            2 * resid.bytes_per_vector() > plain6.bytes_per_vector(),
2651            "6-bit plain must be smaller than residual"
2652        );
2653        assert!(
2654            r6 >= rr - 0.01,
2655            "plain 6-bit recall {r6} must match/beats residual {rr}"
2656        );
2657    }
2658}
2659
2660#[cfg(test)]
2661mod residual_tests {
2662    use super::*;
2663
2664    #[test]
2665    fn residual_reconstruction_mse_halved() {
2666        fn next(x: &mut u64) -> f32 {
2667            *x ^= *x << 13;
2668            *x ^= *x >> 7;
2669            *x ^= *x << 17;
2670            *x as f32 / u32::MAX as f32 - 0.5
2671        }
2672        let dim = 128;
2673        let mut x = 19 | 1;
2674        let mut centroids = Vec::new();
2675        for _ in 0..10 {
2676            let mut v: Vec<f32> = (0..dim).map(|_| next(&mut x)).collect();
2677            let n: f32 = v.iter().map(|a| a * a).sum::<f32>().sqrt();
2678            v.iter_mut().for_each(|a| *a /= n);
2679            centroids.push(v);
2680        }
2681        let base: Vec<Vec<f32>> = (0..50)
2682            .map(|i| {
2683                let c = &centroids[i % 10];
2684                let mut v: Vec<f32> = c.iter().map(|&a| a + 0.5 * next(&mut x)).collect();
2685                let n: f32 = v.iter().map(|a| a * a).sum::<f32>().sqrt();
2686                v.iter_mut().for_each(|a| *a /= n);
2687                v
2688            })
2689            .collect();
2690        let mut plain = VecqIndex::new(dim, 42);
2691        plain.set_bits(4); // the 4-bit-vs-residual comparison from #38
2692        let mut resid = VecqIndex::with_residual(dim, 42);
2693        for v in &base {
2694            plain.add(v);
2695            resid.add(v);
2696        }
2697        // Reconstruct: decode code0 (+code1·scale2) per slot, compare to the
2698        // rotated original (recompute rotation locally).
2699        let bpv = plain.padded() / 2;
2700        let mut mse0 = 0f32;
2701        let mut mse1 = 0f32;
2702        for (slot, v) in base.iter().enumerate() {
2703            let unit: Vec<f32> = {
2704                let n: f32 = v.iter().map(|a| a * a).sum::<f32>().sqrt();
2705                v.iter().map(|a| a / n).collect()
2706            };
2707            let mut rot = Vec::new();
2708            resid.transform.apply(&unit, &mut rot);
2709            let deq = |codes: &[u8], scale: f32| -> Vec<f32> {
2710                let mut acc = vec![0f32; rot.len()];
2711                for (i, &b) in codes.iter().enumerate() {
2712                    acc[2 * i] += lloyd::dequantize_4bit(b & 0x0F) * scale;
2713                    acc[2 * i + 1] += lloyd::dequantize_4bit(b >> 4) * scale;
2714                }
2715                acc
2716            };
2717            // Reconstructions are unit-norm-scaled; the true rotated vector
2718            // has norm sqrt(padded), so scale back up before comparing.
2719            let sp = (plain.padded() as f32).sqrt();
2720            let x0 = deq(
2721                &plain.codes[slot * bpv..(slot + 1) * bpv],
2722                plain.scales[slot] * sp,
2723            );
2724            let x1 = deq(
2725                &resid.codes[slot * bpv..(slot + 1) * bpv],
2726                resid.scales[slot] * sp,
2727            );
2728            let x2 = deq(
2729                &resid.codes2[slot * bpv..(slot + 1) * bpv],
2730                resid.scales2[slot] * sp,
2731            );
2732            let err = |xh: &[f32]| -> f32 {
2733                xh.iter()
2734                    .zip(rot.iter())
2735                    .map(|(a, b)| (a - b) * (a - b))
2736                    .sum::<f32>()
2737                    / rot.len() as f32
2738            };
2739            mse0 += err(&x0);
2740            // residual reconstruction = x1 + x2
2741            let xtot: Vec<f32> = x1.iter().zip(x2.iter()).map(|(a, b)| a + b).collect();
2742            mse1 += err(&xtot);
2743        }
2744        let m0 = mse0 / base.len() as f32;
2745        let m1 = mse1 / base.len() as f32;
2746        println!("mse plain={:.5} residual={:.5}", m0, m1);
2747        // The whole point of the second pass: reconstruction must improve.
2748        assert!(
2749            m1 < m0 * 0.5,
2750            "residual reconstruction must at least halve plain MSE: {m0} vs {m1}"
2751        );
2752    }
2753}