Skip to main content

plugmem_core/index/
vecpool.rs

1//! The vector index: flat quantized-vector storage with a two-phase
2//! flat search.
3//!
4//! Vectors are the fourth recall source. Storage is deliberately *not* an
5//! arena or a blob heap but one contiguous `Vec<u8>` of fixed-stride
6//! slots: flat search reads every live slot, so perfect locality matters
7//! more than sorted lookup. Slots are append-only; dead ones are dropped
8//! by `maintain`, never reused in place.
9//!
10//! One slot, all little-endian, `stride = 8 + 8·words + dim` bytes where
11//! `words = ceil(dim / 64)`:
12//!
13//! | off | size | field |
14//! |---|---|---|
15//! | 0 | 4 | `fact` — owning [`FactId`] |
16//! | 4 | 4 | `scale` — f32 quantization scale |
17//! | 8 | 8·words | `sig` — sign signature, bit `i` set iff `q[i] >= 0` |
18//! | 8+8·words | dim | `q` — the i8 components |
19//!
20//! Quantization is symmetric i8 over the L2-normalized vector
21//! (`scale = max|x/‖x‖| / 127`, `q = round((x/‖x‖) / scale)`), so a
22//! quantized cosine is `scale_a · scale_b · Σ qa·qb`. It is a pure
23//! function of the input — journal replay re-quantizes and reproduces
24//! every slot byte for byte.
25//!
26//! Search is two-phase: the sign signatures give a cheap Hamming
27//! prefilter (popcount over u64 words, SIMD-friendly), then only the best
28//! `max(4·k, 64)` candidates pay an exact quantized-cosine rescore. The
29//! `counters` feature counts those exact dot products — the deterministic
30//! cost metric the perf gate holds to `min(candidates, live)`.
31
32use alloc::vec::Vec;
33
34#[cfg(feature = "counters")]
35use core::cell::Cell;
36
37use crate::error::Error;
38use crate::id::FactId;
39
40/// Serialized width of a slot's owning `fact` id (little-endian `u32`).
41const FACT_BYTES: usize = core::mem::size_of::<u32>();
42
43/// Serialized width of a slot's quantization `scale` (little-endian `f32`).
44const SCALE_BYTES: usize = core::mem::size_of::<f32>();
45
46/// Fixed slot header: `fact u32` + `scale f32`.
47const HEAD: usize = FACT_BYTES + SCALE_BYTES;
48
49/// Bytes of one signature word (a little-endian `u64`): the sign signature is
50/// packed into `words(dim)` of these.
51const SIG_WORD_BYTES: usize = core::mem::size_of::<u64>();
52
53/// Reusable vector-search scratch, owned by the engine (the zero-alloc
54/// recall invariant: after warm-up a search allocates nothing).
55#[derive(Debug, Default)]
56pub struct VecScratch {
57    /// `(hamming, slot)` prefilter buffer.
58    cand: Vec<(u32, u32)>,
59    /// `(cosine, fact)` rescore buffer.
60    top: Vec<(f32, u32)>,
61    /// The quantized query slot (`stride` bytes; `fact` unused).
62    query: Vec<u8>,
63}
64
65impl VecScratch {
66    /// Empty scratch buffers.
67    pub fn new() -> Self {
68        Self::default()
69    }
70}
71
72/// Flat store of quantized vectors.
73///
74/// The slot bytes are an **overlay** of a borrowed base and an owned tail:
75/// the owned path (`new`/`push`/`from_parts`) keeps `base = &[]` with every
76/// slot in `tail`, byte-for-byte unchanged; the borrowed/overlay path
77/// (`from_parts_borrowed`/`from_parts_overlay`) maps an mmap'd section as
78/// `base` and appends new slots to `tail`. Because dead slots are
79/// dropped by `maintain` and never rewritten in place, a slot is wholly in
80/// `base` or wholly in `tail`, so a heap opened over a multi-gigabyte mmap
81/// grows without cloning it — reads dispatch on one comparison per slot.
82#[derive(Debug)]
83pub struct VecPool<'a> {
84    /// Borrowed base slots (an mmap'd section) — empty on the owned path.
85    base: &'a [u8],
86    /// Owned append tail; `push`/`copy_slot` extend this, never `base`.
87    tail: Vec<u8>,
88    dim: usize,
89    max_bytes: usize,
90    /// Exact dot products computed by searches (feature `counters`).
91    #[cfg(feature = "counters")]
92    dots: Cell<u64>,
93}
94
95impl<'a> VecPool<'a> {
96    /// Signature words for a dimension.
97    #[inline]
98    fn words(dim: usize) -> usize {
99        dim.div_ceil(64)
100    }
101
102    /// Creates an empty pool for `dim`-dimensional vectors (`dim == 0`
103    /// leaves the layer inert — the pool stays empty).
104    pub fn new(dim: usize, max_bytes: usize) -> Self {
105        Self {
106            base: &[],
107            tail: Vec::new(),
108            dim,
109            max_bytes,
110            #[cfg(feature = "counters")]
111            dots: Cell::new(0),
112        }
113    }
114
115    /// Byte stride of one slot.
116    #[inline]
117    pub fn stride(&self) -> usize {
118        HEAD + Self::words(self.dim) * SIG_WORD_BYTES + self.dim
119    }
120
121    /// Total bytes of the logical pool (`base` + `tail`).
122    #[inline]
123    fn pool_len(&self) -> usize {
124        self.base.len() + self.tail.len()
125    }
126
127    /// The `stride` bytes of slot `i`, dispatched to `base` or `tail`. The
128    /// base is a whole number of slots (framed on load), so slot `i` never
129    /// straddles the boundary. `pub(crate)` so the disk-first rebuild can stream
130    /// a survivor slot straight into a `Scratch`.
131    #[inline]
132    pub(crate) fn slot_bytes(&self, i: usize) -> &[u8] {
133        let stride = self.stride();
134        let start = i * stride;
135        let base_len = self.base.len();
136        if start < base_len {
137            &self.base[start..start + stride]
138        } else {
139            let at = start - base_len;
140            &self.tail[at..at + stride]
141        }
142    }
143
144    /// Number of stored slots.
145    #[inline]
146    pub fn len(&self) -> usize {
147        let pool_len = self.pool_len();
148        if pool_len == 0 {
149            0
150        } else {
151            pool_len / self.stride()
152        }
153    }
154
155    /// `true` when no vector is stored.
156    pub fn is_empty(&self) -> bool {
157        self.pool_len() == 0
158    }
159
160    /// Total bytes held.
161    pub fn pool_bytes(&self) -> usize {
162        self.pool_len()
163    }
164
165    /// The owning fact of slot `i`.
166    #[inline]
167    pub fn slot_fact(&self, i: usize) -> u32 {
168        let slot = self.slot_bytes(i);
169        u32::from_le_bytes(slot[..FACT_BYTES].try_into().unwrap())
170    }
171
172    /// The scale of slot `i`.
173    #[inline]
174    fn slot_scale(&self, i: usize) -> f32 {
175        let slot = self.slot_bytes(i);
176        f32::from_le_bytes(slot[FACT_BYTES..HEAD].try_into().unwrap())
177    }
178
179    /// The `(scale, q)` pair of slot `i` — the quantized payload a
180    /// distance evaluation needs. Shared with the HNSW graph, which
181    /// stores only neighbor ids and reads vectors from here.
182    #[inline]
183    pub(crate) fn quant(&self, i: usize) -> (f32, &[u8]) {
184        let stride = self.stride();
185        let q_off = HEAD + Self::words(self.dim) * SIG_WORD_BYTES;
186        let slot = self.slot_bytes(i);
187        let scale = f32::from_le_bytes(slot[FACT_BYTES..HEAD].try_into().unwrap());
188        (scale, &slot[q_off..stride])
189    }
190
191    /// Quantized cosine of two slots by index — the graph's edge metric.
192    /// Both indices must be in range (graph neighbors are validated on
193    /// load).
194    #[inline]
195    pub(crate) fn sim(&self, a: u32, b: u32) -> f32 {
196        self.cosine_at(a as usize, b as usize)
197    }
198
199    /// Quantizes `v` into `out` (which is sized to `stride`), writing the
200    /// full slot for `fact`. A pure, deterministic function of `v`.
201    ///
202    /// # Errors
203    ///
204    /// [`Error::DimMismatch`] if `v.len() != dim`; [`Error::Invalid`] if
205    /// `v` is not finite or is the zero vector (no direction to encode).
206    fn encode_slot(&self, fact: u32, v: &[f32], out: &mut [u8]) -> Result<(), Error> {
207        if v.len() != self.dim {
208            return Err(Error::DimMismatch {
209                got: v.len(),
210                want: self.dim,
211            });
212        }
213        let mut norm_sq = 0.0f32;
214        for &x in v {
215            if !x.is_finite() {
216                return Err(Error::Invalid("vector must be finite"));
217            }
218            norm_sq += x * x;
219        }
220        // `norm` is finite and non-negative (inputs are finite), so this
221        // rejects exactly the zero vector.
222        let norm = libm::sqrtf(norm_sq);
223        if norm <= 0.0 {
224            return Err(Error::Invalid("vector must be nonzero"));
225        }
226        let inv_norm = 1.0 / norm;
227        let mut max_abs = 0.0f32;
228        for &x in v {
229            max_abs = max_abs.max(libm::fabsf(x * inv_norm));
230        }
231        // Nonzero norm guarantees a nonzero max component, so scale > 0.
232        let scale = max_abs / 127.0;
233        out[..FACT_BYTES].copy_from_slice(&fact.to_le_bytes());
234        out[FACT_BYTES..HEAD].copy_from_slice(&scale.to_le_bytes());
235        let words = Self::words(self.dim);
236        let q_off = HEAD + words * SIG_WORD_BYTES;
237        for (i, &x) in v.iter().enumerate() {
238            let qf = libm::roundf((x * inv_norm) / scale);
239            let qi = qf.clamp(-127.0, 127.0) as i32 as i8;
240            out[q_off + i] = qi as u8;
241        }
242        // Sign signature from the quantized components.
243        for w in 0..words {
244            let mut word = 0u64;
245            for b in 0..64 {
246                let i = w * 64 + b;
247                if i >= self.dim {
248                    break;
249                }
250                if out[q_off + i] as i8 >= 0 {
251                    word |= 1 << b;
252                }
253            }
254            out[HEAD + w * SIG_WORD_BYTES..HEAD + w * SIG_WORD_BYTES + SIG_WORD_BYTES]
255                .copy_from_slice(&word.to_le_bytes());
256        }
257        Ok(())
258    }
259
260    /// Encodes one complete slot into reusable caller-owned scratch without
261    /// appending it to this pool. The disk-first reembed path writes that slot
262    /// straight to a [`Scratch`](crate::Scratch), keeping the new vector pool
263    /// out of RAM while it is produced.
264    pub(crate) fn encode_slot_into(
265        &self,
266        fact: FactId,
267        v: &[f32],
268        out: &mut Vec<u8>,
269    ) -> Result<(), Error> {
270        out.clear();
271        out.resize(self.stride(), 0);
272        self.encode_slot(fact.0, v, out)
273    }
274
275    /// Quantized cosine between a complete encoded query slot and one stored
276    /// slot. Similarity guarding encodes the incoming vector once, then uses
277    /// this for its bounded candidate scan without appending to the pool.
278    pub(crate) fn cosine_encoded_slot(&self, encoded: &[u8], slot: u32) -> f32 {
279        let stride = self.stride();
280        if encoded.len() != stride || slot as usize >= self.len() {
281            return 0.0;
282        }
283        let q_off = HEAD + Self::words(self.dim) * SIG_WORD_BYTES;
284        let stored = self.slot_bytes(slot as usize);
285        let query_scale = f32::from_le_bytes(encoded[FACT_BYTES..HEAD].try_into().unwrap());
286        let dot = dot_i8(&encoded[q_off..stride], &stored[q_off..stride]);
287        query_scale * self.slot_scale(slot as usize) * dot as f32
288    }
289
290    /// Quantizes `v` and appends it as `fact`'s slot, returning the slot
291    /// index. Ids are not stored sorted — the fact record keeps the index.
292    ///
293    /// # Errors
294    ///
295    /// [`Error::DimMismatch`]/[`Error::Invalid`] from quantization, or
296    /// [`Error::CapacityExceeded`] at the byte ceiling.
297    pub fn push(&mut self, fact: FactId, v: &[f32]) -> Result<u32, Error> {
298        let stride = self.stride();
299        let pool_len = self.pool_len();
300        if pool_len + stride > self.max_bytes {
301            return Err(Error::CapacityExceeded { what: "vectors" });
302        }
303        let index = u32::try_from(pool_len / stride).map_err(|_| Error::CapacityExceeded {
304            what: "vector slots",
305        })?;
306        // Take the tail out so `encode_slot(&self, ...)` can borrow `self`
307        // while we write into the (now-detached) buffer; the new slot always
308        // lands in the tail, never the borrowed base.
309        let mut tail = core::mem::take(&mut self.tail);
310        let at = tail.len();
311        tail.resize(at + stride, 0);
312        let res = match self.encode_slot(fact.0, v, &mut tail[at..]) {
313            Ok(()) => Ok(index),
314            Err(e) => {
315                // Roll the failed append back so the pool stays canonical.
316                tail.truncate(at);
317                Err(e)
318            }
319        };
320        self.tail = tail;
321        res
322    }
323
324    /// The `(scale, q)` view of a query previously quantized into
325    /// `scratch` by [`VecPool::quantize_query`] — what the graph search
326    /// and the tail scan consume.
327    pub(crate) fn quantized<'s>(&self, scratch: &'s VecScratch) -> (f32, &'s [u8]) {
328        let stride = self.stride();
329        let q_off = HEAD + Self::words(self.dim) * SIG_WORD_BYTES;
330        debug_assert_eq!(scratch.query.len(), stride);
331        (
332            f32::from_le_bytes(scratch.query[FACT_BYTES..HEAD].try_into().unwrap()),
333            &scratch.query[q_off..stride],
334        )
335    }
336
337    /// Quantizes a query vector into `scratch.query` (sized to `stride`,
338    /// `fact` left as `0`). Reused across searches.
339    pub fn quantize_query(&self, v: &[f32], scratch: &mut VecScratch) -> Result<(), Error> {
340        let stride = self.stride();
341        scratch.query.clear();
342        scratch.query.resize(stride, 0);
343        let mut buf = core::mem::take(&mut scratch.query);
344        let res = self.encode_slot(0, v, &mut buf);
345        scratch.query = buf;
346        res
347    }
348
349    /// Copies slot `i` of `src` verbatim (already quantized) into `self`,
350    /// returning its new index. Used by `maintain` compaction — the
351    /// quantized bytes are reproduced exactly, so a compacted snapshot is
352    /// byte-identical to a replayed one. `src` must share this pool's `dim`.
353    pub(crate) fn copy_slot(&mut self, src: &VecPool<'_>, i: u32) -> u32 {
354        debug_assert_eq!(self.dim, src.dim, "copy_slot across differing dims");
355        let stride = self.stride();
356        let index = (self.pool_len() / stride) as u32;
357        self.tail.extend_from_slice(src.slot_bytes(i as usize));
358        index
359    }
360
361    /// Appends an exact copy of one of this pool's slots for a new owning
362    /// fact. Retag revisions preserve embeddings without a model round trip or
363    /// a lossy dequantize/requantize cycle.
364    pub(crate) fn clone_slot_for_fact(&mut self, fact: FactId, source: u32) -> Result<u32, Error> {
365        let stride = self.stride();
366        let pool_len = self.pool_len();
367        if source as usize >= self.len() {
368            return Err(Error::Corrupt("retag vector slot is out of range"));
369        }
370        if pool_len + stride > self.max_bytes {
371            return Err(Error::CapacityExceeded { what: "vectors" });
372        }
373        let index = u32::try_from(pool_len / stride).map_err(|_| Error::CapacityExceeded {
374            what: "vector slots",
375        })?;
376        let source_start = source as usize * stride;
377        if source_start < self.base.len() {
378            self.tail
379                .extend_from_slice(&self.base[source_start..source_start + stride]);
380        } else {
381            let at = source_start - self.base.len();
382            let dst = self.tail.len();
383            self.tail.resize(dst + stride, 0);
384            self.tail.copy_within(at..at + stride, dst);
385        }
386        let at = self.tail.len() - stride;
387        self.tail[at..at + FACT_BYTES].copy_from_slice(&fact.0.to_le_bytes());
388        Ok(index)
389    }
390
391    /// The exact quantized cosine of two slots by their scales and i8
392    /// components.
393    fn cosine_at(&self, a: usize, b: usize) -> f32 {
394        let stride = self.stride();
395        let q_off = HEAD + Self::words(self.dim) * SIG_WORD_BYTES;
396        let (sa, sb) = (self.slot_bytes(a), self.slot_bytes(b));
397        let dot = dot_i8(&sa[q_off..stride], &sb[q_off..stride]);
398        self.slot_scale(a) * self.slot_scale(b) * dot as f32
399    }
400
401    /// Quantized cosine of slots `a` and `b` (similar-detection uses it on
402    /// two stored facts). Returns `0.0` if either index is out of range.
403    pub fn cosine_slots(&self, a: u32, b: u32) -> f32 {
404        let n = self.len();
405        if a as usize >= n || b as usize >= n {
406            return 0.0;
407        }
408        self.cosine_at(a as usize, b as usize)
409    }
410
411    /// Flat two-phase search: Hamming prefilter on signatures, then exact
412    /// quantized-cosine rescore of the best `max(4·k, 64)` candidates.
413    /// `admit` filters by the shared recall rule; writes the top `k`
414    /// `(fact, cosine)` into `out`, descending.
415    pub fn search(
416        &self,
417        query: &[f32],
418        k: usize,
419        admit: &mut dyn FnMut(FactId) -> bool,
420        scratch: &mut VecScratch,
421        out: &mut Vec<(FactId, f32)>,
422    ) -> Result<(), Error> {
423        out.clear();
424        let n = self.len();
425        if n == 0 || k == 0 {
426            return Ok(());
427        }
428        self.quantize_query(query, scratch)?;
429        let stride = self.stride();
430        let words = Self::words(self.dim);
431        let q_off = HEAD + words * SIG_WORD_BYTES;
432
433        // Phase 1: Hamming distance of every slot's signature to the query.
434        //
435        // Two things keep this O(n) pass cheap. The slots are walked as two
436        // contiguous runs — `base` then `tail` — instead of through
437        // `slot_bytes`, so the per-slot base/tail branch disappears and the
438        // optimizer sees a fixed-stride chunk iterator. And the candidates are
439        // *not* materialized: only the `c` best are kept, behind a running
440        // limit that rejects the overwhelming majority with one comparison.
441        //
442        // The kept set is exactly what pushing all `n` and partitioning at
443        // `c - 1` produced. Ordering on `(hamming, slot)` is total, slots
444        // arrive strictly ascending, and the limit is the largest of the `c`
445        // currently kept — so an entry that fails `< limit` can never belong to
446        // the best `c`, and one that ties on hamming is greater by its slot.
447        let VecScratch {
448            cand, top, query, ..
449        } = scratch;
450        let q_sig = &query[HEAD..HEAD + words * SIG_WORD_BYTES];
451        // Saturating because `k` is the caller's and this is a public entry
452        // point: a wrapping `4 * k` could land under 64 and quietly narrow the
453        // rescore band. `min(n)` then holds `c` at or below the slot count.
454        let c = k.saturating_mul(4).max(64).min(n);
455        // Compacting at twice the target amortizes the partition to O(n): each
456        // pass discards half the buffer, so it runs at most n/c times.
457        let cap = c * 2;
458        cand.clear();
459        // Only `n` entries can ever be pushed, so the buffer never needs the
460        // full doubled threshold when the pool is smaller than it.
461        cand.reserve(cap.min(n));
462        let mut limit: Option<(u32, u32)> = None;
463        let slots = self
464            .base
465            .chunks_exact(stride)
466            .chain(self.tail.chunks_exact(stride));
467        for (i, slot) in slots.enumerate() {
468            let s_sig = &slot[HEAD..HEAD + words * SIG_WORD_BYTES];
469            let mut ham = 0u32;
470            for (qw, sw) in q_sig
471                .chunks_exact(SIG_WORD_BYTES)
472                .zip(s_sig.chunks_exact(SIG_WORD_BYTES))
473            {
474                let a = u64::from_le_bytes(qw.try_into().unwrap());
475                let b = u64::from_le_bytes(sw.try_into().unwrap());
476                ham += (a ^ b).count_ones();
477            }
478            let entry = (ham, i as u32);
479            if limit.is_some_and(|worst| entry >= worst) {
480                continue;
481            }
482            cand.push(entry);
483            if cand.len() == cap {
484                cand.select_nth_unstable(c - 1);
485                cand.truncate(c);
486                limit = Some(cand[c - 1]);
487            }
488        }
489        if cand.len() > c {
490            cand.select_nth_unstable(c - 1);
491        }
492
493        // Phase 2: exact quantized cosine on the survivors.
494        let q_scale = f32::from_le_bytes(query[FACT_BYTES..HEAD].try_into().unwrap());
495        let q_q = &query[q_off..q_off + self.dim];
496        top.clear();
497        #[cfg(feature = "counters")]
498        let mut dots = 0u64;
499        for &(_, slot) in cand[..c].iter() {
500            let sb = self.slot_bytes(slot as usize);
501            let fact = FactId(u32::from_le_bytes(sb[..FACT_BYTES].try_into().unwrap()));
502            if !admit(fact) {
503                continue;
504            }
505            let s_scale = f32::from_le_bytes(sb[FACT_BYTES..HEAD].try_into().unwrap());
506            let dot = dot_i8(q_q, &sb[q_off..stride]);
507            top.push((q_scale * s_scale * dot as f32, fact.0));
508            #[cfg(feature = "counters")]
509            {
510                dots += 1;
511            }
512        }
513        #[cfg(feature = "counters")]
514        self.dots.set(self.dots.get() + dots);
515        // Only the first `k` survivors are read, and the ordering below is
516        // total (score descending, then id), so partitioning at `k` and
517        // ordering that prefix yields the same sequence a full sort would —
518        // for the price of the prefix rather than of every candidate.
519        let order = |a: &(f32, u32), b: &(f32, u32)| b.0.total_cmp(&a.0).then(a.1.cmp(&b.1));
520        let band = k.min(top.len());
521        if band < top.len() {
522            top.select_nth_unstable_by(band, order);
523        }
524        top[..band].sort_unstable_by(order);
525        for &(score, id) in top.iter().take(k) {
526            out.push((FactId(id), score));
527        }
528        Ok(())
529    }
530
531    /// The vector section as one contiguous buffer (`base ++ tail`).
532    /// Byte-identical to the owned pool holding the same slots, so an overlay
533    /// snapshot is canonical. Test-only: production streams via
534    /// [`VecPool::pieces`], which needs no owned copy.
535    #[cfg(test)]
536    pub(crate) fn dump(&self) -> Vec<u8> {
537        let mut out = Vec::with_capacity(self.pool_len());
538        out.extend_from_slice(self.base);
539        out.extend_from_slice(&self.tail);
540        out
541    }
542
543    /// The vector section as its two contiguous pieces (`base`, `tail`)
544    /// without concatenating them — lets the streaming snapshot writer
545    /// emit the dominant pool with no owned full-section copy.
546    /// Concatenated, the pieces equal the section (`base ++ tail`).
547    pub(crate) fn pieces(&self) -> [&[u8]; 2] {
548        [self.base, &self.tail]
549    }
550
551    /// Rebuilds a pool from its dumped section, checking only the framing
552    /// (length is a whole number of slots, fits the ceiling). Slot content
553    /// — scales, signatures, the fact bijection — is validated by the
554    /// engine's `validate_references` (it needs the fact records too).
555    pub(crate) fn from_parts(dim: usize, max_bytes: usize, bytes: &[u8]) -> Result<Self, Error> {
556        Self::frame_check(dim, max_bytes, bytes.len())?;
557        let mut pool = Self::new(dim, max_bytes);
558        pool.tail = bytes.to_vec();
559        Ok(pool)
560    }
561
562    /// Zero-copy sibling of [`VecPool::from_parts`]: the pool borrows the
563    /// dumped section (an mmap'd byte range) as its base instead of copying
564    /// it. Same framing checks; the lifetime ties the pool to `bytes`
565    /// Under the overlay write path a later [`VecPool::push`]
566    /// appends to an owned tail without cloning the base.
567    pub(crate) fn from_parts_borrowed(
568        dim: usize,
569        max_bytes: usize,
570        bytes: &'a [u8],
571    ) -> Result<Self, Error> {
572        Self::frame_check(dim, max_bytes, bytes.len())?;
573        let mut pool = Self::new(dim, max_bytes);
574        pool.base = bytes;
575        Ok(pool)
576    }
577
578    /// Frames the dumped section: a length within the ceiling and, for a
579    /// live layer, a whole number of slots. Shared by both `from_parts`
580    /// constructors so the owned and borrowed paths validate identically.
581    fn frame_check(dim: usize, max_bytes: usize, len: usize) -> Result<(), Error> {
582        if len > max_bytes {
583            return Err(Error::Corrupt("vector pool exceeds the configured ceiling"));
584        }
585        if dim == 0 {
586            if len != 0 {
587                return Err(Error::Corrupt("vector pool present with dim 0"));
588            }
589            return Ok(());
590        }
591        let stride = HEAD + Self::words(dim) * SIG_WORD_BYTES + dim;
592        if !len.is_multiple_of(stride) {
593            return Err(Error::Corrupt("vector pool is not a whole number of slots"));
594        }
595        Ok(())
596    }
597
598    /// Structural self-check of every slot (A.4): each scale is
599    /// finite and non-negative, and each signature bit agrees with the
600    /// sign of its quantized component. Keeps the panic-free contract:
601    /// after this, a search over the pool cannot read a malformed slot
602    /// into a NaN or disagree with the prefilter.
603    pub(crate) fn validate(&self) -> Result<(), Error> {
604        if self.dim == 0 {
605            return Ok(());
606        }
607        let words = Self::words(self.dim);
608        let q_off = HEAD + words * SIG_WORD_BYTES;
609        for i in 0..self.len() {
610            let slot = self.slot_bytes(i);
611            let scale = f32::from_le_bytes(slot[FACT_BYTES..HEAD].try_into().unwrap());
612            if !scale.is_finite() || scale < 0.0 {
613                return Err(Error::Corrupt(
614                    "vector slot scale is not finite and non-negative",
615                ));
616            }
617            for w in 0..words {
618                let stored = u64::from_le_bytes(
619                    slot[HEAD + w * SIG_WORD_BYTES..HEAD + w * SIG_WORD_BYTES + SIG_WORD_BYTES]
620                        .try_into()
621                        .unwrap(),
622                );
623                let mut expect = 0u64;
624                for b in 0..64 {
625                    let j = w * 64 + b;
626                    if j >= self.dim {
627                        break;
628                    }
629                    if slot[q_off + j] as i8 >= 0 {
630                        expect |= 1 << b;
631                    }
632                }
633                if stored != expect {
634                    return Err(Error::Corrupt(
635                        "vector slot signature disagrees with its components",
636                    ));
637                }
638            }
639        }
640        Ok(())
641    }
642
643    /// Exact dot products computed so far (feature `counters`).
644    #[cfg(feature = "counters")]
645    pub fn dots(&self) -> u64 {
646        self.dots.get()
647    }
648
649    /// Resets the dot counter (feature `counters`).
650    #[cfg(feature = "counters")]
651    pub fn reset_dots(&self) {
652        self.dots.set(0);
653    }
654}
655
656/// Lanes the dot product accumulates in parallel. Sixteen i8 pairs are one
657/// 128-bit vector on every target this runs on — baseline SSE2, NEON, and
658/// wasm's 128-bit SIMD — so a chunk maps to one widening multiply-add
659/// without naming an intrinsic. The crate is `#![forbid(unsafe_code)]`, so
660/// `core::arch` is not available and autovectorization is the whole lever.
661const DOT_LANES: usize = 16;
662
663/// Integer dot product of two equal-length i8 slices held as bytes.
664/// `dim ≤ 4096` and `|q| ≤ 127`, so the sum fits `i32`
665/// (`4096 · 127² < 2³¹`).
666///
667/// Summing per lane and folding at the end changes the *order* of the
668/// additions, not the result: this is `i32` arithmetic that cannot overflow
669/// at these bounds, and integer addition is associative. A quantized cosine
670/// is therefore bit-for-bit what the scalar loop produced.
671#[inline]
672pub(crate) fn dot_i8(a: &[u8], b: &[u8]) -> i32 {
673    let mut lanes = [0i32; DOT_LANES];
674    let mut a_chunks = a.chunks_exact(DOT_LANES);
675    let mut b_chunks = b.chunks_exact(DOT_LANES);
676    for (x, y) in a_chunks.by_ref().zip(b_chunks.by_ref()) {
677        for (lane, (&x, &y)) in lanes.iter_mut().zip(x.iter().zip(y.iter())) {
678            *lane += i32::from(x as i8) * i32::from(y as i8);
679        }
680    }
681    let mut acc: i32 = lanes.iter().sum();
682    for (&x, &y) in a_chunks.remainder().iter().zip(b_chunks.remainder().iter()) {
683        acc += i32::from(x as i8) * i32::from(y as i8);
684    }
685    acc
686}
687
688#[cfg(test)]
689mod tests {
690    use super::*;
691    use alloc::vec;
692
693    /// A tiny deterministic LCG yielding `f32` in `[-1, 1)` — no rng crate
694    /// in the core's test surface, and determinism is the repo law.
695    struct Lcg(u64);
696    impl Lcg {
697        fn next(&mut self) -> f32 {
698            self.0 = self
699                .0
700                .wrapping_mul(6_364_136_223_846_793_005)
701                .wrapping_add(1_442_695_040_888_963_407);
702            ((self.0 >> 40) as f32 / (1u64 << 24) as f32) * 2.0 - 1.0
703        }
704        fn vector(&mut self, dim: usize) -> Vec<f32> {
705            (0..dim).map(|_| self.next()).collect()
706        }
707    }
708
709    /// True (unquantized) cosine of two vectors.
710    fn cosine_f32(a: &[f32], b: &[f32]) -> f32 {
711        let dot: f32 = a.iter().zip(b).map(|(x, y)| x * y).sum();
712        let na: f32 = libm::sqrtf(a.iter().map(|x| x * x).sum());
713        let nb: f32 = libm::sqrtf(b.iter().map(|x| x * x).sum());
714        dot / (na * nb)
715    }
716
717    /// The quantized cosine tracks the true cosine within the documented
718    /// error band across many random pairs.
719    #[test]
720    fn quantized_cosine_tracks_f32() {
721        let dim = 384;
722        let mut rng = Lcg(0x1234_5678);
723        let mut worst = 0.0f32;
724        for i in 0..200u32 {
725            let a = rng.vector(dim);
726            let b = rng.vector(dim);
727            let mut pool = VecPool::new(dim, usize::MAX);
728            pool.push(FactId(2 * i), &a).unwrap();
729            pool.push(FactId(2 * i + 1), &b).unwrap();
730            let q = pool.cosine_slots(0, 1);
731            let t = cosine_f32(&a, &b);
732            worst = worst.max(libm::fabsf(q - t));
733        }
734        assert!(
735            worst < 0.05,
736            "worst quantization error {worst} exceeds 0.05"
737        );
738    }
739
740    /// Golden: a hand-built dim-4 example. `a` and `b` share a direction,
741    /// `c` is orthogonal-ish; quantized cosine ranks them accordingly and
742    /// the sign signature matches the component signs.
743    #[test]
744    fn golden_dim4() {
745        let dim = 4;
746        let mut pool = VecPool::new(dim, usize::MAX);
747        pool.push(FactId(0), &[1.0, 1.0, 0.0, 0.0]).unwrap();
748        pool.push(FactId(1), &[2.0, 2.0, 0.0, 0.0]).unwrap(); // same direction
749        pool.push(FactId(2), &[0.0, 0.0, 1.0, 1.0]).unwrap(); // orthogonal
750        // Parallel vectors → cosine ≈ 1.
751        assert!((pool.cosine_slots(0, 1) - 1.0).abs() < 1e-3);
752        // Orthogonal vectors → cosine ≈ 0.
753        assert!(pool.cosine_slots(0, 2).abs() < 1e-3);
754        // Structural self-check passes and the signature is well-formed.
755        pool.validate().unwrap();
756        // Slot 0 signature: components (positive, positive, +0, +0) → all
757        // sign bits set for the first four bits.
758        let stride = pool.stride();
759        let sig = u64::from_le_bytes(pool.dump()[HEAD..HEAD + SIG_WORD_BYTES].try_into().unwrap());
760        assert_eq!(sig & 0b1111, 0b1111);
761        assert_eq!(pool.len(), 3);
762        // dim 4: one signature word + 4 i8 components.
763        assert_eq!(stride, HEAD + SIG_WORD_BYTES + 4);
764    }
765
766    /// The two-phase search returns the true nearest neighbor at the top.
767    #[test]
768    fn search_surfaces_the_nearest() {
769        let dim = 64;
770        let mut rng = Lcg(0xdead_beef);
771        let mut pool = VecPool::new(dim, usize::MAX);
772        let target = rng.vector(dim);
773        // 200 random vectors, then the target itself as fact 500.
774        for i in 0..200u32 {
775            pool.push(FactId(i), &rng.vector(dim)).unwrap();
776        }
777        pool.push(FactId(500), &target).unwrap();
778        let mut scratch = VecScratch::new();
779        let mut out = Vec::new();
780        pool.search(&target, 5, &mut |_| true, &mut scratch, &mut out)
781            .unwrap();
782        assert_eq!(out[0].0, FactId(500), "exact match must rank first");
783        assert!(out[0].1 > 0.99, "self-cosine ≈ 1, got {}", out[0].1);
784    }
785
786    /// A zero or non-finite vector has no direction to quantize.
787    #[test]
788    fn degenerate_vectors_are_invalid() {
789        let mut pool = VecPool::new(3, usize::MAX);
790        assert_eq!(
791            pool.push(FactId(0), &[0.0, 0.0, 0.0]).unwrap_err(),
792            Error::Invalid("vector must be nonzero")
793        );
794        assert_eq!(
795            pool.push(FactId(0), &[1.0, f32::NAN, 0.0]).unwrap_err(),
796            Error::Invalid("vector must be finite")
797        );
798        assert!(matches!(
799            pool.push(FactId(0), &[1.0, 2.0]).unwrap_err(),
800            Error::DimMismatch { got: 2, want: 3 }
801        ));
802        // A failed push leaves the pool canonical (nothing appended).
803        assert_eq!(pool.len(), 0);
804        assert!(pool.is_empty());
805    }
806
807    /// Accessors and the edge branches: empty pool, `k == 0`, an
808    /// out-of-range cosine, and the byte ceiling.
809    #[test]
810    fn accessors_and_edges() {
811        let dim = 4;
812        let mut pool = VecPool::new(dim, usize::MAX);
813        assert!(pool.is_empty());
814        assert_eq!(pool.pool_bytes(), 0);
815        let mut scratch = VecScratch::new();
816        let mut out = vec![(FactId(9), 1.0)];
817        // Empty pool: search clears the output and returns nothing.
818        pool.search(&[1.0; 4], 5, &mut |_| true, &mut scratch, &mut out)
819            .unwrap();
820        assert!(out.is_empty());
821
822        pool.push(FactId(0), &[1.0, 0.0, 0.0, 0.0]).unwrap();
823        assert!(!pool.is_empty());
824        assert_eq!(pool.pool_bytes(), pool.stride());
825        // k == 0 short-circuits.
826        pool.search(&[1.0; 4], 0, &mut |_| true, &mut scratch, &mut out)
827            .unwrap();
828        assert!(out.is_empty());
829        // An out-of-range slot index yields a zero cosine, never a panic.
830        assert_eq!(pool.cosine_slots(0, 9), 0.0);
831
832        // The byte ceiling: a push past `max_bytes` is a typed error.
833        let mut tight = VecPool::new(dim, 4);
834        assert_eq!(
835            tight.push(FactId(0), &[1.0, 0.0, 0.0, 0.0]).unwrap_err(),
836            Error::CapacityExceeded { what: "vectors" }
837        );
838    }
839
840    /// `from_parts` accepts a whole number of slots and rejects the rest.
841    #[test]
842    fn from_parts_frames_slots() {
843        let dim = 8;
844        let mut pool = VecPool::new(dim, usize::MAX);
845        pool.push(FactId(0), &vec![0.5; dim]).unwrap();
846        pool.push(FactId(1), &vec![-0.5; dim]).unwrap();
847        let bytes = pool.dump();
848        let rebuilt = VecPool::from_parts(dim, usize::MAX, &bytes).unwrap();
849        assert_eq!(rebuilt.len(), 2);
850        rebuilt.validate().unwrap();
851        // One byte short of a slot boundary is corrupt.
852        assert!(VecPool::from_parts(dim, usize::MAX, &bytes[..bytes.len() - 1]).is_err());
853        // A non-empty pool with dim 0 is corrupt.
854        assert!(VecPool::from_parts(0, usize::MAX, &bytes).is_err());
855        // Bytes past the configured ceiling are corrupt.
856        assert!(VecPool::from_parts(dim, bytes.len() - 1, &bytes).is_err());
857    }
858
859    /// The structural self-check rejects malformed slots — the panic-free
860    /// contract for the vector section on hostile input.
861    #[test]
862    fn validate_rejects_malformed_slots() {
863        let dim = 8;
864        let mut pool = VecPool::new(dim, usize::MAX);
865        pool.push(FactId(0), &vec![0.5; dim]).unwrap();
866        let good = pool.dump();
867
868        // A non-finite scale (bytes 4..8) is rejected.
869        let mut bad = good.clone();
870        bad[FACT_BYTES..HEAD].copy_from_slice(&f32::NAN.to_le_bytes());
871        assert!(
872            VecPool::from_parts(dim, usize::MAX, &bad)
873                .unwrap()
874                .validate()
875                .is_err()
876        );
877
878        // A signature bit that disagrees with its component's sign is
879        // rejected: flip one i8 component negative without touching sig.
880        let mut bad = good.clone();
881        let q_off = HEAD + VecPool::words(dim) * SIG_WORD_BYTES;
882        bad[q_off] = (-1i8) as u8; // was positive (sig bit 0 set)
883        assert!(
884            VecPool::from_parts(dim, usize::MAX, &bad)
885                .unwrap()
886                .validate()
887                .is_err()
888        );
889    }
890
891    /// Overlay open: a pool over a borrowed base grows through an owned tail
892    /// without touching the base, and every accessor (fact/scale/cosine/
893    /// search) spans the base/tail boundary. The dump is byte-identical to
894    /// the fully-owned pool holding the same slots.
895    #[test]
896    fn overlay_appends_to_tail_and_reads_span_the_boundary() {
897        let dim = 16;
898        let mut rng = Lcg(0x0ace_1a75);
899        let (va, vb, vc) = (rng.vector(dim), rng.vector(dim), rng.vector(dim));
900
901        // Fully-owned reference pool with all three vectors.
902        let mut owned = VecPool::new(dim, usize::MAX);
903        owned.push(FactId(10), &va).unwrap();
904        owned.push(FactId(11), &vb).unwrap();
905        owned.push(FactId(12), &vc).unwrap();
906
907        // Base = first two vectors, serialized as if from an mmap; the third
908        // is appended through the overlay open.
909        let mut seed = VecPool::new(dim, usize::MAX);
910        seed.push(FactId(10), &va).unwrap();
911        seed.push(FactId(11), &vb).unwrap();
912        let base = seed.dump();
913        let base_snapshot = base.clone();
914
915        // For this append-only store, the overlay open is `from_parts_borrowed`
916        // (a borrowed base that a later `push` extends via the owned tail).
917        let mut pool = VecPool::from_parts_borrowed(dim, usize::MAX, &base).unwrap();
918        assert_eq!(pool.len(), 2);
919        let idx = pool.push(FactId(12), &vc).unwrap();
920        assert_eq!(idx, 2);
921        assert_eq!(pool.len(), 3);
922
923        // Accessors read base slots (0,1) and the tail slot (2) alike.
924        assert_eq!(pool.slot_fact(0), 10); // base
925        assert_eq!(pool.slot_fact(2), 12); // tail
926        // A cosine between a base slot and the tail slot matches the owned
927        // pool's — the overlay changes representation, not values.
928        assert!((pool.cosine_slots(0, 2) - owned.cosine_slots(0, 2)).abs() < 1e-6);
929        pool.validate().unwrap();
930
931        // Search finds the appended (tail) vector by querying it exactly.
932        let mut scratch = VecScratch::new();
933        let mut out = Vec::new();
934        pool.search(&vc, 1, &mut |_| true, &mut scratch, &mut out)
935            .unwrap();
936        assert_eq!(out[0].0, FactId(12));
937
938        // The dump is canonical (== owned) and the borrowed base is untouched.
939        assert_eq!(pool.dump(), owned.dump());
940        assert_eq!(base, base_snapshot);
941    }
942}