Skip to main content

mnemo_engine/
index.rs

1//! IVF + PQ approximate-nearest-neighbour index (Phase 2 of the build plan).
2//!
3//! Exact search is `O(n)`. This module makes retrieval sub-linear:
4//!
5//! 1. **IVF** (inverted file): vectors are grouped into roughly `√n`
6//!    partitions by k-means. A query scans only the `n_probe` partitions
7//!    whose centroids are nearest to it.
8//! 2. **PQ** (product quantization): each vector is cut into `m` contiguous
9//!    subspaces; every subspace is quantized to one of up to 256 learned
10//!    codewords, so a whole vector collapses to `m` bytes. Candidate
11//!    distances are summed from precomputed per-subspace lookup tables — the
12//!    scan never touches a full float vector.
13//! 3. **Rerank**: the `n_rerank` closest candidates by PQ distance are
14//!    returned to the store, which loads their exact vectors for final,
15//!    precise ranking.
16//!
17//! All distances *inside* the index are squared-L2. The index only has to
18//! produce a good candidate *set*; the store reranks with the caller's actual
19//! metric (cosine / dot / L2). For embedding vectors — which tend to have
20//! similar norms — L2-nearest and cosine-nearest largely agree, and a
21//! generous `n_rerank` absorbs the rest.
22
23use rand::rngs::StdRng;
24use rand::seq::SliceRandom;
25use rand::{Rng, SeedableRng};
26use serde::{Deserialize, Serialize};
27
28use crate::error::{MnemoError, Result};
29
30/// Slot marker for an empty [`U128Map`] slot (values are partition or row
31/// numbers, so `u32::MAX` is never a real value).
32const VACANT: u32 = u32::MAX;
33
34/// Home slot of a ULID (or any `u128` id) in a table of `cap` slots.
35/// ULIDs carry 80 random low bits, so folding the halves and a
36/// multiplicative mix is enough; the high bits of `hash * cap` then pick
37/// the slot, so `cap` needn't be a power of two (a power-of-two table at
38/// <= 3/4 load can sit at 3/8, doubling its size for nothing).
39pub(crate) fn slot_home(k: u128, cap: usize) -> usize {
40    let x = (k as u64) ^ ((k >> 64) as u64).rotate_left(29);
41    let h = x.wrapping_mul(0x9E37_79B9_7F4A_7C15);
42    ((h as u128 * cap as u128) >> 64) as usize
43}
44
45/// The slot after `i`, wrapping at `cap`.
46pub(crate) fn next_slot(i: usize, cap: usize) -> usize {
47    if i + 1 == cap {
48        0
49    } else {
50        i + 1
51    }
52}
53
54/// Forward distance from slot `from` to slot `to`, wrapping at `cap`.
55fn cyclic_dist(from: usize, to: usize, cap: usize) -> usize {
56    if to >= from {
57        to - from
58    } else {
59        to + cap - from
60    }
61}
62
63/// Slot count that holds `n` entries at <= 3/4 load.
64pub(crate) fn table_capacity_for(n: usize) -> usize {
65    (n * 4 / 3 + 1).max(16)
66}
67
68/// PERF-1 step 4: a compact `u128 → u32` map (linear probing,
69/// backward-shift deletion) for the ANN's id → partition assignment. It
70/// takes 20 bytes a slot at <= 3/4 load, about 27 bytes an entry, against
71/// about 43 for `HashMap<u128, usize>` (whose 32-byte, 16-aligned slots
72/// dominate).
73#[derive(Clone, Debug, Default)]
74pub(crate) struct U128Map {
75    keys: Vec<u128>,
76    vals: Vec<u32>,
77    len: usize,
78}
79
80impl U128Map {
81    pub(crate) fn with_capacity(n: usize) -> Self {
82        let mut m = Self::default();
83        m.resize(table_capacity_for(n));
84        m
85    }
86
87    fn resize(&mut self, cap: usize) {
88        let keys = std::mem::replace(&mut self.keys, vec![0; cap]);
89        let vals = std::mem::replace(&mut self.vals, vec![VACANT; cap]);
90        self.len = 0;
91        for (k, v) in keys.into_iter().zip(vals) {
92            if v != VACANT {
93                self.insert(k, v);
94            }
95        }
96    }
97
98    fn find(&self, k: u128) -> Option<usize> {
99        if self.vals.is_empty() {
100            return None;
101        }
102        let cap = self.vals.len();
103        let mut i = slot_home(k, cap);
104        loop {
105            if self.vals[i] == VACANT {
106                return None;
107            }
108            if self.keys[i] == k {
109                return Some(i);
110            }
111            i = next_slot(i, cap);
112        }
113    }
114
115    #[cfg(test)]
116    pub(crate) fn get(&self, k: u128) -> Option<u32> {
117        self.find(k).map(|i| self.vals[i])
118    }
119
120    pub(crate) fn contains_key(&self, k: u128) -> bool {
121        self.find(k).is_some()
122    }
123
124    pub(crate) fn insert(&mut self, k: u128, v: u32) {
125        debug_assert_ne!(v, VACANT);
126        if (self.len + 1) * 4 > self.vals.len() * 3 {
127            let cap = (self.vals.len() * 2).max(table_capacity_for(self.len + 1));
128            self.resize(cap);
129        }
130        let cap = self.vals.len();
131        let mut i = slot_home(k, cap);
132        loop {
133            if self.vals[i] == VACANT {
134                self.keys[i] = k;
135                self.vals[i] = v;
136                self.len += 1;
137                return;
138            }
139            if self.keys[i] == k {
140                self.vals[i] = v;
141                return;
142            }
143            i = next_slot(i, cap);
144        }
145    }
146
147    pub(crate) fn remove(&mut self, k: u128) -> Option<u32> {
148        let i = self.find(k)?;
149        let out = self.vals[i];
150        let cap = self.vals.len();
151        // Backward-shift: pull later entries of the probe run into the
152        // hole when the hole lies on their path from home.
153        let mut hole = i;
154        let mut j = i;
155        loop {
156            j = next_slot(j, cap);
157            if self.vals[j] == VACANT {
158                break;
159            }
160            let home = slot_home(self.keys[j], cap);
161            let from_home = cyclic_dist(home, j, cap);
162            let from_hole = cyclic_dist(hole, j, cap);
163            if from_home >= from_hole {
164                self.keys[hole] = self.keys[j];
165                self.vals[hole] = self.vals[j];
166                hole = j;
167            }
168        }
169        self.vals[hole] = VACANT;
170        self.len -= 1;
171        Some(out)
172    }
173
174    #[cfg(test)]
175    pub(crate) fn len(&self) -> usize {
176        self.len
177    }
178
179    /// Heap bytes held by the two slot arrays.
180    pub(crate) fn heap_bytes(&self) -> usize {
181        self.keys.capacity() * std::mem::size_of::<u128>()
182            + self.vals.capacity() * std::mem::size_of::<u32>()
183    }
184}
185
186/// Estimated heap bytes of a `HashMap<K, V>` with the given capacity:
187/// one `(K, V)` slot plus one control byte per bucket, with buckets
188/// rounded up from capacity at the 7/8 load factor. An estimate for the
189/// P-0 device budget, not an exact allocator figure.
190pub(crate) fn hash_map_bytes<K, V>(capacity: usize) -> usize {
191    if capacity == 0 {
192        return 0;
193    }
194    let buckets = (capacity * 8 / 7).next_power_of_two();
195    buckets * (std::mem::size_of::<(K, V)>() + 1)
196}
197
198/// Default RNG seed — fixed so index builds are reproducible.
199const DEFAULT_SEED: u64 = 0x4d_4e_45_4d_4f_49_44_58; // "MNEMOIDX"
200/// Training-set cap: k-means trains on at most this many sampled vectors.
201const TRAIN_CAP: usize = 50_000;
202/// Codewords per PQ subspace.
203const PQ_CODEWORDS: usize = 256;
204
205/// Tuning knobs for building and querying an [`IvfPqIndex`].
206#[derive(Clone, Copy, Debug)]
207pub struct IndexConfig {
208    /// Number of IVF partitions. `0` means auto (`ceil(√n)`).
209    pub n_partitions: usize,
210    /// Number of PQ subspaces. `0` means auto (`dims / 4`). Previously
211    /// `dims / 8` before PR ANN-1 — see [`IndexConfig::default`] for
212    /// why the bump.
213    pub pq_subspaces: usize,
214    /// Partitions scanned per query (the IVF accuracy/speed dial).
215    /// `0` means auto (`ceil(partitions / 2)`). Previously a fixed `8`
216    /// before PR ANN-1.
217    pub n_probe: usize,
218    /// Candidates handed back for exact reranking (the PQ accuracy dial).
219    /// `0` means auto (`500`). Previously a fixed `64` before PR ANN-1.
220    pub n_rerank: usize,
221    /// Lloyd-iteration count for every k-means run.
222    pub kmeans_iters: usize,
223    /// RNG seed for reproducible builds.
224    pub seed: u64,
225}
226
227/// Auto-pick `n_probe` from the IVF partition count.
228///
229/// Formula: `max(ceil(partitions / 2), min(partitions, 100))`.
230///
231/// Full probe up to 100 partitions (so ~10K / √10K ≈ 100 corpora
232/// clear the 0.95 recall@10 target — ANN-1.1 measured 0.964 at
233/// n_probe=100 + n_rerank=100 vs 0.914 at n_probe=50), half probe
234/// above. At the three measured tiers:
235///
236/// - 10K   ≈ 100 partitions  → 100 (full)
237/// - 100K  ≈ 317 partitions  → 159 (half, from ceil(317/2))
238/// - 1M    ≈ 1000 partitions → 500 (half)
239///
240/// Query latency scales linearly with `n_probe` (and therefore with
241/// `n_partitions * fraction_probed`); see `ann_defaults_v0_6_0` in
242/// `test/scripts/perf_v0.5.0.json` for the measured per-tier numbers.
243pub(crate) fn effective_auto_n_probe(partitions: usize) -> usize {
244    let half = partitions.div_ceil(2);
245    let full_up_to_100 = partitions.min(100);
246    half.max(full_up_to_100).max(1)
247}
248
249/// Auto-pick `n_rerank` from the live vector count.
250///
251/// Formula: `(n_vectors / 200).clamp(100, 1000)`. Hits the planner's
252/// ANN-1.1 targets exactly:
253///
254/// - 10K vectors  → 50 → clamped to 100 (plenty for ~0.98 recall@10)
255/// - 100K vectors → 500 (matches the ann_recall_grid_v2 knee)
256/// - 1M vectors   → 5000 → clamped to 1000 (keeps latency bounded;
257///   recall at 1M is "measure in Phase 11.1" per the planner)
258///
259/// Pre-ANN-1.1 the default was a fixed 500 at every size, which cost
260/// ~5 ms of record-decode time on 10K files for no recall gain over
261/// 100 — rerank is bounded by exact decrypt-and-score cost (~10 µs
262/// per candidate on hardware AES).
263pub(crate) fn effective_auto_n_rerank(n_vectors: usize) -> usize {
264    (n_vectors / 200).clamp(100, 1000)
265}
266
267impl Default for IndexConfig {
268    fn default() -> Self {
269        // All four of n_partitions / pq_subspaces / n_probe / n_rerank
270        // default to 0, which [`IvfPqIndex::build`] interprets as "pick
271        // a sensible value from the corpus and partition count":
272        //
273        // - n_partitions → `ceil(sqrt(n))`
274        // - pq_subspaces → `dims/4` (384-dim → 96 subspaces).
275        //   Previously `dims/8` (48); PR ANN-1 bumped it after the
276        //   ann_recall_grid_v2 harness showed that 48 subspaces capped
277        //   100K recall@10 at ~0.92 even with the full-scan rerank,
278        //   while 96 subspaces lifts the knee to 0.98 at ~10 ms/query
279        //   on an M1. Costs 2x the PQ codes per vector (48 → 96 bytes,
280        //   negligible against the 1.5 KB raw vector) and ~15 % more
281        //   build time.
282        // - n_probe → `ceil(partitions / 2)` (ANN-1) — scales with
283        //   partition count. Previously fixed at 8, which at 317
284        //   partitions scanned 2.5 % of the index per query. The
285        //   planner's "express relative to index size" clause.
286        // - n_rerank → `(n_vectors / 200).clamp(100, 1000)` (ANN-1.1).
287        //   10K files land on 100 rerank (plenty for ~0.98 recall@10
288        //   on this corpus); 100K lands on 500 (the measured
289        //   ann_recall_grid_v2 knee); 1M caps at 1000. ANN-1 shipped a
290        //   fixed 500 at every size, which cost ~5 ms of record-decode
291        //   time on small files for no recall gain. Still fixed per
292        //   build if explicit; computed per query from the live vector
293        //   count when auto.
294        //
295        // Explicit non-zero values still override, so callers who want
296        // the old tiny defaults can set them (useful for very small
297        // corpora where sqrt(n) partitions is overkill).
298        Self {
299            n_partitions: 0,
300            pq_subspaces: 0,
301            n_probe: 0,
302            n_rerank: 0,
303            kmeans_iters: 25,
304            seed: DEFAULT_SEED,
305        }
306    }
307}
308
309/// A read-only snapshot of an index's shape, surfaced via `Mnemo::stats`.
310///
311/// `Serialize`/`Deserialize` derived so the MCP `stats` tool (and any
312/// other JSON-shaped consumer) can emit / round-trip it without a
313/// per-field object build. Plain-old-data type — no invariants to
314/// preserve across (de)serialization.
315#[derive(Clone, Copy, Debug, Serialize, Deserialize)]
316pub struct IndexInfo {
317    /// Vectors currently held in the index.
318    pub vectors: usize,
319    /// IVF partition count.
320    pub partitions: usize,
321    /// PQ subspace count.
322    pub subspaces: usize,
323    /// Default partitions probed per query.
324    pub n_probe: usize,
325    /// Default candidates reranked per query.
326    pub n_rerank: usize,
327}
328
329/// Squared Euclidean distance between two equal-length vectors.
330fn squared_l2(a: &[f32], b: &[f32]) -> f32 {
331    let mut s = 0.0f32;
332    for i in 0..a.len() {
333        let d = a[i] - b[i];
334        s += d * d;
335    }
336    s
337}
338
339/// Index of the nearest centroid (flat layout, `stride` floats each).
340fn nearest(centroids: &[f32], stride: usize, v: &[f32]) -> usize {
341    let mut best = 0usize;
342    let mut best_d = f32::INFINITY;
343    let count = centroids.len() / stride;
344    for i in 0..count {
345        let d = squared_l2(&centroids[i * stride..(i + 1) * stride], v);
346        if d < best_d {
347            best_d = d;
348            best = i;
349        }
350    }
351    best
352}
353
354/// k-means (k-means++ seeding, Lloyd iterations). Returns a flat
355/// `k * dim` centroid buffer; `k` may shrink to `points.len()`.
356fn kmeans(points: &[&[f32]], k: usize, iters: usize, rng: &mut StdRng) -> Vec<f32> {
357    let n = points.len();
358    let dim = points[0].len();
359    let k = k.clamp(1, n);
360
361    // --- k-means++ seeding ---
362    let mut centroids: Vec<f32> = Vec::with_capacity(k * dim);
363    let first = rng.gen_range(0..n);
364    centroids.extend_from_slice(points[first]);
365    let mut d2: Vec<f32> = points
366        .iter()
367        .map(|p| squared_l2(p, &centroids[0..dim]))
368        .collect();
369
370    while centroids.len() / dim < k {
371        let sum: f32 = d2.iter().sum();
372        let pick = if sum <= 0.0 {
373            rng.gen_range(0..n)
374        } else {
375            let mut t = rng.gen::<f32>() * sum;
376            let mut chosen = n - 1;
377            for (i, &w) in d2.iter().enumerate() {
378                t -= w;
379                if t <= 0.0 {
380                    chosen = i;
381                    break;
382                }
383            }
384            chosen
385        };
386        let base = centroids.len();
387        centroids.extend_from_slice(points[pick]);
388        let new = &centroids[base..base + dim];
389        for (i, p) in points.iter().enumerate() {
390            let nd = squared_l2(p, new);
391            if nd < d2[i] {
392                d2[i] = nd;
393            }
394        }
395    }
396
397    // --- Lloyd iterations ---
398    let kk = centroids.len() / dim;
399    for _ in 0..iters {
400        let mut sums = vec![0.0f32; kk * dim];
401        let mut counts = vec![0usize; kk];
402        for p in points {
403            let a = nearest(&centroids, dim, p);
404            counts[a] += 1;
405            for d in 0..dim {
406                sums[a * dim + d] += p[d];
407            }
408        }
409        for c in 0..kk {
410            if counts[c] == 0 {
411                // Reseed an empty cluster onto a random point.
412                let r = rng.gen_range(0..n);
413                centroids[c * dim..(c + 1) * dim].copy_from_slice(points[r]);
414            } else {
415                let inv = 1.0 / counts[c] as f32;
416                for d in 0..dim {
417                    centroids[c * dim + d] = sums[c * dim + d] * inv;
418                }
419            }
420        }
421    }
422    centroids
423}
424
425/// Product-quantization codebook: one learned set of codewords per subspace.
426#[derive(Serialize, Deserialize, Clone, Debug)]
427struct PqCodebook {
428    /// Subspace boundaries; subspace `s` spans `[sub_offsets[s], sub_offsets[s+1])`.
429    sub_offsets: Vec<usize>,
430    /// Per subspace: a flat `ks[s] * subdim` codeword buffer.
431    centroids: Vec<Vec<f32>>,
432    /// Codeword count per subspace (`≤ 256`, so a code fits in a `u8`).
433    ks: Vec<usize>,
434}
435
436impl PqCodebook {
437    fn m(&self) -> usize {
438        self.sub_offsets.len() - 1
439    }
440
441    /// Train one codebook per subspace on the sampled training vectors.
442    fn train(
443        sub_offsets: &[usize],
444        train: &[&[f32]],
445        iters: usize,
446        rng: &mut StdRng,
447    ) -> PqCodebook {
448        let m = sub_offsets.len() - 1;
449        let mut centroids = Vec::with_capacity(m);
450        let mut ks = Vec::with_capacity(m);
451        for s in 0..m {
452            let (lo, hi) = (sub_offsets[s], sub_offsets[s + 1]);
453            let subs: Vec<Vec<f32>> = train.iter().map(|v| v[lo..hi].to_vec()).collect();
454            let refs: Vec<&[f32]> = subs.iter().map(|x| x.as_slice()).collect();
455            let k = PQ_CODEWORDS.min(refs.len()).max(1);
456            let cs = kmeans(&refs, k, iters, rng);
457            ks.push(cs.len() / (hi - lo));
458            centroids.push(cs);
459        }
460        PqCodebook {
461            sub_offsets: sub_offsets.to_vec(),
462            centroids,
463            ks,
464        }
465    }
466
467    /// Encode a full vector into `m` codeword indices.
468    fn encode(&self, v: &[f32]) -> Vec<u8> {
469        let m = self.m();
470        let mut code = vec![0u8; m];
471        for (s, slot) in code.iter_mut().enumerate().take(m) {
472            let (lo, hi) = (self.sub_offsets[s], self.sub_offsets[s + 1]);
473            *slot = nearest(&self.centroids[s], hi - lo, &v[lo..hi]) as u8;
474        }
475        code
476    }
477
478    /// Precompute, for a query, the distance from each query subspace to every
479    /// codeword. Asymmetric distance = sum of one lookup per subspace.
480    ///
481    /// PERF-1 step 5: one flat buffer with a fixed [`PQ_CODEWORDS`]-entry row
482    /// per subspace (rows of subspaces with fewer codewords are padded and
483    /// never read), so the scan indexes a row with a `u8` code and no bounds
484    /// check, instead of chasing one heap `Vec` per subspace.
485    fn distance_table(&self, q: &[f32]) -> Vec<f32> {
486        let m = self.m();
487        let mut table = vec![0.0f32; m * PQ_CODEWORDS];
488        for (s, row) in table.chunks_exact_mut(PQ_CODEWORDS).enumerate() {
489            let (lo, hi) = (self.sub_offsets[s], self.sub_offsets[s + 1]);
490            let sd = hi - lo;
491            let qs = &q[lo..hi];
492            let cs = &self.centroids[s];
493            for (c, slot) in row.iter_mut().enumerate().take(self.ks[s]) {
494                *slot = squared_l2(qs, &cs[c * sd..(c + 1) * sd]);
495            }
496        }
497        table
498    }
499}
500
501/// Asymmetric (PQ) distance of one code against a query's flat distance
502/// table: one lookup per subspace, summed.
503///
504/// PERF-1 step 5: four named accumulators over 4-subspace blocks instead of
505/// one running sum. A single sum is a chain of `m` dependent float adds (96
506/// at 384 dims), which made this loop latency-bound and about half of a
507/// warm recall at 100K. (An accumulator *array* indexed by `s & 3` was
508/// tried first and ran 2.3x slower than the single chain: it stays in
509/// memory.) Micro-benchmark, 50K codes of 96: 4.0 ms → 1.45 ms. The order
510/// of the additions changes, so the sum can differ in the last bits; it
511/// only ranks the rerank shortlist, and `ann_recall_grid_v2` recall@10 is
512/// unchanged in every cell.
513fn adc_distance(code: &[u8], table: &[f32]) -> f32 {
514    const K: usize = PQ_CODEWORDS;
515    let (mut a0, mut a1, mut a2, mut a3) = (0.0f32, 0.0f32, 0.0f32, 0.0f32);
516    let mut codes = code.chunks_exact(4);
517    let mut rows = table.chunks_exact(4 * K);
518    for (c, r) in (&mut codes).zip(&mut rows) {
519        a0 += r[c[0] as usize];
520        a1 += r[K + c[1] as usize];
521        a2 += r[2 * K + c[2] as usize];
522        a3 += r[3 * K + c[3] as usize];
523    }
524    let done = code.len() / 4 * 4;
525    for (&c, r) in codes
526        .remainder()
527        .iter()
528        .zip(table[done * K..].chunks_exact(K))
529    {
530        a0 += r[c as usize];
531    }
532    (a0 + a1) + (a2 + a3)
533}
534
535/// One IVF partition: parallel arrays of memory IDs and their PQ codes.
536#[derive(Serialize, Deserialize, Clone, Debug, Default)]
537struct Posting {
538    ids: Vec<u128>,
539    /// Flat `ids.len() * m` byte buffer of PQ codes.
540    codes: Vec<u8>,
541}
542
543/// An IVF + PQ approximate-nearest-neighbour index over the database's vectors.
544#[derive(Serialize, Deserialize, Clone, Debug)]
545pub struct IvfPqIndex {
546    dims: usize,
547    n_partitions: usize,
548    n_probe: usize,
549    n_rerank: usize,
550    /// Flat `n_partitions * dims` IVF centroid buffer.
551    centroids: Vec<f32>,
552    pq: PqCodebook,
553    partitions: Vec<Posting>,
554    /// `id -> partition`. Reconstructed from `partitions` after load, never
555    /// serialized.
556    #[serde(skip)]
557    assignment: U128Map,
558    // Serialized shape is frozen at v0.5.2's: dims, n_partitions,
559    // n_probe, n_rerank, centroids, pq, partitions. Do NOT append new
560    // fields here under the same `format::VERSION` — rmp-serde 1.3.0's
561    // positional decoder rejects extra array elements with
562    // `Error::LengthMismatch`, so appending here would break every
563    // v0.5.x reader that opens a file written by this build. See the
564    // 2026-11-01 R-001.10 standing rule: any shape change on a
565    // persisted struct now requires a version bump + migration, or
566    // goes into `Memory.metadata` under reserved `_mnemo.*` keys.
567    //
568    // The "pre-ANN-1 default sentinel" check that gives v0.5.x-built
569    // indexes the ANN-1 recall fix on upgrade is in
570    // [`Self::effective_defaults`] — a stored (n_probe, n_rerank) of
571    // exactly (8, 64) is treated as "auto" at query time, so the
572    // concrete values stay stored (old readers see something sensible)
573    // but new readers recompute from the live corpus shape.
574}
575
576impl IvfPqIndex {
577    /// Build an index over `items` (`(id, vector)` pairs).
578    pub fn build(dims: usize, items: &[(u128, &[f32])], cfg: IndexConfig) -> Result<IvfPqIndex> {
579        if items.is_empty() {
580            return Err(MnemoError::Invalid(
581                "cannot build an index over an empty database".into(),
582            ));
583        }
584        let n = items.len();
585        let mut rng = StdRng::seed_from_u64(cfg.seed);
586
587        // Training sample (all vectors when small enough).
588        let mut order: Vec<usize> = (0..n).collect();
589        let train_n = TRAIN_CAP.min(n);
590        order.partial_shuffle(&mut rng, train_n);
591        let train: Vec<&[f32]> = order[..train_n].iter().map(|&i| items[i].1).collect();
592
593        // IVF centroids.
594        let n_partitions = if cfg.n_partitions > 0 {
595            cfg.n_partitions
596        } else {
597            (n as f64).sqrt().ceil() as usize
598        }
599        .clamp(1, n);
600        let centroids = kmeans(&train, n_partitions, cfg.kmeans_iters, &mut rng);
601        let n_partitions = centroids.len() / dims;
602
603        // PQ subspaces — split `dims` into `m` near-equal contiguous spans.
604        // The auto-default is `dims/4` (PR ANN-1), where the grid shows
605        // 100K recall@10 clearing 0.98 at the knee. Earlier auto was
606        // `dims/8`; see IndexConfig::default for the trade-off.
607        let m = if cfg.pq_subspaces > 0 {
608            cfg.pq_subspaces
609        } else {
610            (dims / 4).max(1)
611        }
612        .clamp(1, dims);
613        let mut sub_offsets = Vec::with_capacity(m + 1);
614        for s in 0..=m {
615            sub_offsets.push(s * dims / m);
616        }
617        let pq = PqCodebook::train(&sub_offsets, &train, cfg.kmeans_iters, &mut rng);
618
619        // Query-time dials. Zero in cfg means "auto" — resolve to a
620        // concrete value using the formulas, then store that. Old
621        // readers (v0.5.x) see a sensible stored number; new readers
622        // (v0.6.0+) with the live corpus can recompute via
623        // `effective_defaults` on every query. The "stored == (8,64)"
624        // sentinel in `effective_defaults` is what lets v0.5.x-built
625        // files pick up the fix on upgrade without a rebuild.
626        let n_probe = if cfg.n_probe > 0 {
627            cfg.n_probe
628        } else {
629            effective_auto_n_probe(n_partitions)
630        };
631        let n_rerank = if cfg.n_rerank > 0 {
632            cfg.n_rerank
633        } else {
634            effective_auto_n_rerank(items.len())
635        };
636
637        let mut index = IvfPqIndex {
638            dims,
639            n_partitions,
640            n_probe: n_probe.max(1),
641            n_rerank: n_rerank.max(1),
642            centroids,
643            pq,
644            partitions: vec![Posting::default(); n_partitions],
645            assignment: U128Map::with_capacity(n),
646        };
647        for &(id, v) in items {
648            index.add(id, v);
649        }
650        Ok(index)
651    }
652
653    /// Default `n_probe` / `n_rerank` to apply when the caller passes
654    /// `None` on a [`crate::RecallRequest`] — i.e. the user hasn't
655    /// explicitly overridden the dials for this query.
656    ///
657    /// Returns the auto-computed `(n_probe, n_rerank)` from the live
658    /// partition count + vector count when the stored pair is exactly
659    /// **`(8, 64)`** — the pre-ANN-1 fixed defaults every v0.5.x build
660    /// stamped in automatically. That's how an upgraded v0.5.x index
661    /// picks up the ANN-1 recall fix without a rebuild.
662    ///
663    /// Otherwise returns the stored `(self.n_probe, self.n_rerank)`
664    /// verbatim — the user picked these at build time and still means
665    /// them.
666    ///
667    /// New v0.6.0+ builds store the resolved auto values directly, so
668    /// a fresh build never hits the sentinel path. The sentinel is
669    /// pre-ANN-1-only.
670    ///
671    /// Note: a user who explicitly built with `IndexConfig { n_probe: 8,
672    /// n_rerank: 64, .. }` on v0.6.0+ will see their values treated as
673    /// "legacy auto" instead of pinned. That's acceptable — the ANN-1
674    /// auto formulas at very small N (10K / 100 partitions / 20K vectors)
675    /// also pick n_probe near 8 and n_rerank clamped to 100, so the
676    /// override is self-cancelling in the one regime where it matters.
677    /// Pin to any other non-legacy value to opt out explicitly.
678    pub(crate) fn effective_defaults(&self) -> (usize, usize) {
679        if self.n_probe == 8 && self.n_rerank == 64 {
680            (
681                effective_auto_n_probe(self.n_partitions),
682                effective_auto_n_rerank(self.len()),
683            )
684        } else {
685            (self.n_probe, self.n_rerank)
686        }
687    }
688
689    /// Embedding dimensionality the index was built for.
690    pub fn dims(&self) -> usize {
691        self.dims
692    }
693
694    /// Total vectors currently indexed.
695    pub fn len(&self) -> usize {
696        self.partitions.iter().map(|p| p.ids.len()).sum()
697    }
698
699    /// Whether `id` is indexed.
700    pub(crate) fn contains(&self, id: u128) -> bool {
701        self.assignment.contains_key(id)
702    }
703
704    /// Every indexed id (PERF-1 reconcile).
705    pub(crate) fn ids(&self) -> impl Iterator<Item = u128> + '_ {
706        self.partitions.iter().flat_map(|p| p.ids.iter().copied())
707    }
708
709    /// Heap bytes held by the index, by part (P-0 device budget). Counts
710    /// buffer capacities; the hash map is estimated from its capacity.
711    /// Returns `(coarse_centroids, pq_codebook, posting_ids, posting_codes,
712    /// assignment_map)`.
713    pub(crate) fn heap_bytes(&self) -> (usize, usize, usize, usize, usize) {
714        let f32s = std::mem::size_of::<f32>();
715        let centroids = self.centroids.capacity() * f32s;
716        let codebook = self
717            .pq
718            .centroids
719            .iter()
720            .map(|c| c.capacity() * f32s)
721            .sum::<usize>()
722            + self.pq.sub_offsets.capacity() * std::mem::size_of::<usize>()
723            + self.pq.ks.capacity() * std::mem::size_of::<usize>();
724        let ids = self
725            .partitions
726            .iter()
727            .map(|p| p.ids.capacity() * std::mem::size_of::<u128>())
728            .sum();
729        let codes = self.partitions.iter().map(|p| p.codes.capacity()).sum();
730        let assignment = self.assignment.heap_bytes();
731        (centroids, codebook, ids, codes, assignment)
732    }
733
734    /// Shape snapshot for statistics.
735    ///
736    /// `n_probe` / `n_rerank` here are the **effective** values that
737    /// queries actually use (after the ANN-1.1 "stored (8,64) == auto"
738    /// shim via [`Self::effective_defaults`]), not the raw stored
739    /// numbers. That's what `mnemo info`, `db.stats()` and the MCP
740    /// `stats` tool report, so an upgraded v0.5.x file shows the
741    /// larger auto values it actually searches with — not the 8/64
742    /// baked into the serialized index.
743    pub fn info(&self) -> IndexInfo {
744        let (eff_probe, eff_rerank) = self.effective_defaults();
745        IndexInfo {
746            vectors: self.len(),
747            partitions: self.n_partitions,
748            subspaces: self.pq.m(),
749            n_probe: eff_probe,
750            n_rerank: eff_rerank,
751        }
752    }
753
754    /// Rebuild the `id -> partition` map from the posting lists. Called once
755    /// after the index is deserialized (the map itself is not stored).
756    pub fn rebuild_assignment(&mut self) {
757        let n = self.partitions.iter().map(|p| p.ids.len()).sum();
758        self.assignment = U128Map::with_capacity(n);
759        for (pi, p) in self.partitions.iter().enumerate() {
760            for &id in &p.ids {
761                self.assignment.insert(id, pi as u32);
762            }
763        }
764    }
765
766    fn nearest_centroid(&self, v: &[f32]) -> usize {
767        nearest(&self.centroids, self.dims, v)
768    }
769
770    /// Insert or overwrite a vector. The PQ codebook and IVF centroids are
771    /// fixed at build time; only posting lists grow. This keeps the index
772    /// complete across inserts without a full rebuild — cluster drift is the
773    /// price, repaid by `Mnemo::rebuild_index` / `compact`.
774    pub fn add(&mut self, id: u128, vector: &[f32]) {
775        self.remove(id);
776        let part = self.nearest_centroid(vector);
777        let code = self.pq.encode(vector);
778        let p = &mut self.partitions[part];
779        p.ids.push(id);
780        p.codes.extend_from_slice(&code);
781        self.assignment.insert(id, part as u32);
782    }
783
784    /// Remove a vector if present (no-op otherwise).
785    pub fn remove(&mut self, id: u128) {
786        if let Some(part) = self.assignment.remove(id) {
787            let m = self.pq.m();
788            let p = &mut self.partitions[part as usize];
789            if let Some(pos) = p.ids.iter().position(|&x| x == id) {
790                p.ids.remove(pos);
791                p.codes.drain(pos * m..(pos + 1) * m);
792            }
793        }
794    }
795
796    /// Run the tiered query: IVF probe → PQ scan → top-`n_rerank` candidates.
797    /// Returns candidate IDs (nearest first by PQ distance) for the store to
798    /// rerank exactly.
799    ///
800    /// `n_probe` / `n_rerank` of `None` fall through to
801    /// [`Self::effective_defaults`] — which recomputes from the live
802    /// partition / vector count when the index is auto-flagged (ANN-1.1
803    /// in v0.6.0+) or carries the legacy pre-ANN-1 defaults (8, 64).
804    /// Any explicit value is honored verbatim.
805    pub fn query(&self, q: &[f32], n_probe: Option<usize>, n_rerank: Option<usize>) -> Vec<u128> {
806        let (auto_probe, auto_rerank) = self.effective_defaults();
807        let n_probe = n_probe.unwrap_or(auto_probe).clamp(1, self.n_partitions);
808        let n_rerank = n_rerank.unwrap_or(auto_rerank).max(1);
809
810        // Stage 1 — pick the nearest partitions.
811        let mut parts: Vec<(usize, f32)> = (0..self.n_partitions)
812            .map(|i| {
813                let c = &self.centroids[i * self.dims..(i + 1) * self.dims];
814                (i, squared_l2(q, c))
815            })
816            .collect();
817        parts.sort_by(|a, b| a.1.total_cmp(&b.1));
818        parts.truncate(n_probe);
819
820        // Stage 2 — scan PQ codes in the selected partitions.
821        let table = self.pq.distance_table(q);
822        let m = self.pq.m();
823        let total: usize = parts
824            .iter()
825            .map(|&(pi, _)| self.partitions[pi].ids.len())
826            .sum();
827        let mut cands: Vec<(u128, f32)> = Vec::with_capacity(total);
828        for (pi, _) in parts {
829            let p = &self.partitions[pi];
830            for (&id, code) in p.ids.iter().zip(p.codes.chunks_exact(m)) {
831                cands.push((id, adc_distance(code, &table)));
832            }
833        }
834
835        // Stage 3 — keep the closest `n_rerank` candidates for exact
836        // reranking. R-001.8 part 3 (ANN-1.1): partition-select first,
837        // then sort only the kept slice. Full sort of all N candidates
838        // is O(N log N); at ANN-1.1's 1M + partitions/2 defaults the
839        // candidate pool is ~500K per query, where the full sort cost
840        // dominates. `select_nth_unstable_by` is O(N) average, then
841        // the final sort is O(k log k) with k = n_rerank.
842        if n_rerank < cands.len() {
843            cands.select_nth_unstable_by(n_rerank - 1, |a, b| a.1.total_cmp(&b.1));
844            cands.truncate(n_rerank);
845        }
846        cands.sort_by(|a, b| a.1.total_cmp(&b.1));
847        cands.into_iter().map(|(id, _)| id).collect()
848    }
849}
850
851#[cfg(test)]
852mod ann_1_1_tests {
853    //! PR ANN-1.1 tests for the index-defaults upgrade path.
854    //!
855    //! The amended spec (planner 2026-11-01 URGENT AMEND, R-001.3 +
856    //! R-001.10): no new fields on `IvfPqIndex` — rmp-serde 1.3.0's
857    //! positional decoder rejects extra array elements, so any append
858    //! would break v0.5.x readers. The whole upgrade path runs on the
859    //! "stored (8, 64) == auto" sentinel in
860    //! [`IvfPqIndex::effective_defaults`].
861
862    use super::*;
863    use rand::rngs::StdRng;
864    use rand::SeedableRng;
865
866    /// Shadow of the pre-ANN-1 [`IvfPqIndex`] layout — identical to
867    /// the current one (same field order, same types). Serializing
868    /// one of these produces the exact byte stream a v0.5.x build
869    /// would write. Keeping it as a separate struct makes the
870    /// field-count guard below mechanical rather than ad-hoc.
871    #[derive(serde::Serialize, serde::Deserialize)]
872    struct V052IvfPqIndex {
873        dims: usize,
874        n_partitions: usize,
875        n_probe: usize,
876        n_rerank: usize,
877        centroids: Vec<f32>,
878        pq: PqCodebook,
879        partitions: Vec<Posting>,
880    }
881
882    fn tiny_pq(dims: usize) -> PqCodebook {
883        let sub_offsets = vec![0, dims];
884        let mut rng = StdRng::seed_from_u64(0);
885        let train: Vec<Vec<f32>> = (0..4).map(|i| vec![i as f32; dims]).collect();
886        let refs: Vec<&[f32]> = train.iter().map(|v| v.as_slice()).collect();
887        PqCodebook::train(&sub_offsets, &refs, 2, &mut rng)
888    }
889
890    #[test]
891    fn v0_5_2_index_blob_decodes_unchanged() {
892        // A byte stream in the exact v0.5.2 layout must still decode
893        // as the current `IvfPqIndex`. If we ever appended a field,
894        // rmp-serde would reject this as `LengthMismatch`.
895        let dims = 4;
896        let legacy = V052IvfPqIndex {
897            dims,
898            n_partitions: 2,
899            n_probe: 8,
900            n_rerank: 64,
901            centroids: vec![1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0],
902            pq: tiny_pq(dims),
903            partitions: vec![Posting::default(); 2],
904        };
905        let bytes = rmp_serde::to_vec(&legacy).expect("serialize v0.5.2 layout");
906        let decoded: IvfPqIndex =
907            rmp_serde::from_slice(&bytes).expect("v0.5.2 bytes must decode under current schema");
908        assert_eq!(decoded.dims, 4);
909        assert_eq!(decoded.n_partitions, 2);
910        assert_eq!(decoded.n_probe, 8);
911        assert_eq!(decoded.n_rerank, 64);
912    }
913
914    #[test]
915    fn current_build_serializes_with_v0_5_2_field_count() {
916        // Forward-compat guard. If someone appends a field to
917        // `IvfPqIndex`, the serialized form will carry an extra array
918        // element and v0.5.x readers will reject it with
919        // `LengthMismatch`. This test fails loudly first, so the
920        // breakage is caught by `cargo test`, not by an upset user
921        // whose two machines run different mnemo versions.
922        let dims = 4;
923        let items: Vec<(u128, Vec<f32>)> = (0..32)
924            .map(|i| (i as u128, vec![i as f32, i as f32 + 1.0, 0.0, 0.0]))
925            .collect();
926        let refs: Vec<(u128, &[f32])> = items.iter().map(|(id, v)| (*id, v.as_slice())).collect();
927        let idx = IvfPqIndex::build(dims, &refs, IndexConfig::default()).unwrap();
928        let bytes = rmp_serde::to_vec(&idx).expect("serialize current layout");
929        // Decode via the frozen v0.5.2 shadow struct. If the current
930        // layout has an extra appended field, this fails with
931        // `LengthMismatch`.
932        let as_v052: V052IvfPqIndex = rmp_serde::from_slice(&bytes)
933            .expect("current index must decode under the v0.5.2 shape");
934        assert_eq!(as_v052.dims, dims);
935    }
936
937    #[test]
938    fn legacy_8_64_triggers_auto_formulas_at_query_time() {
939        // The sentinel path: stored (8, 64) on a legacy index
940        // substitutes the auto formulas at query time — ANN-1 fix
941        // without a rebuild.
942        let dims = 4;
943        let legacy = V052IvfPqIndex {
944            dims,
945            n_partitions: 10,
946            n_probe: 8,
947            n_rerank: 64,
948            centroids: vec![0.0; 10 * dims],
949            pq: tiny_pq(dims),
950            partitions: (0..10)
951                .map(|_| Posting {
952                    ids: vec![0u128, 1u128],
953                    codes: vec![0u8; 2],
954                })
955                .collect(),
956        };
957        let bytes = rmp_serde::to_vec(&legacy).unwrap();
958        let decoded: IvfPqIndex = rmp_serde::from_slice(&bytes).unwrap();
959
960        let (auto_probe, auto_rerank) = decoded.effective_defaults();
961        // Small corpora get full probe (ANN-1.1 formula: full up to
962        // 100 partitions). 10 partitions → probe all 10.
963        assert_eq!(
964            auto_probe, 10,
965            "auto n_probe at <=100 partitions is full probe"
966        );
967        assert_eq!(auto_rerank, 100, "auto n_rerank at tiny N clamps up to 100");
968    }
969
970    #[test]
971    fn new_build_stores_resolved_auto_values() {
972        // On v0.6.0+, build() with IndexConfig::default() resolves
973        // 0 → concrete auto values and stores THOSE in the index.
974        // Old readers (v0.5.x) see a usable number rather than a 0
975        // sentinel; new readers compute the same values at query
976        // time via effective_defaults.
977        let dims = 4;
978        let items: Vec<(u128, Vec<f32>)> = (0..64)
979            .map(|i| {
980                let f = i as f32;
981                (i as u128, vec![f, f + 1.0, f + 2.0, f + 3.0])
982            })
983            .collect();
984        let refs: Vec<(u128, &[f32])> = items.iter().map(|(id, v)| (*id, v.as_slice())).collect();
985        let idx = IvfPqIndex::build(dims, &refs, IndexConfig::default()).unwrap();
986        assert!(
987            idx.n_probe > 0 && idx.n_rerank > 0,
988            "resolved values must be concrete (never 0)"
989        );
990        // The IndexConfig::default() formula for a 64-vector corpus
991        // with ceil(sqrt(64)) = 8 partitions picks n_probe=4,
992        // n_rerank=clamp(64/200, 100, 1000) = 100.
993        let expected_rerank = (64 / 200usize).clamp(100, 1000);
994        assert_eq!(idx.n_rerank, expected_rerank);
995    }
996
997    #[test]
998    fn explicit_build_values_persist_verbatim() {
999        // Non-sentinel explicit values stay stored and are used at
1000        // query time. (A user who passes exactly 8/64 explicitly is
1001        // treated as legacy auto — acknowledged in the planner's
1002        // amended spec; harmless because at the tiny N where 8/64
1003        // could sensibly be chosen, the auto formulas pick similar
1004        // values.)
1005        let dims = 4;
1006        let items: Vec<(u128, Vec<f32>)> =
1007            (0..64).map(|i| (i as u128, vec![i as f32; dims])).collect();
1008        let refs: Vec<(u128, &[f32])> = items.iter().map(|(id, v)| (*id, v.as_slice())).collect();
1009        let cfg = IndexConfig {
1010            n_probe: 3,
1011            n_rerank: 17,
1012            ..Default::default()
1013        };
1014        let idx = IvfPqIndex::build(dims, &refs, cfg).unwrap();
1015        assert_eq!(idx.n_probe, 3);
1016        assert_eq!(idx.n_rerank, 17);
1017        let (p, r) = idx.effective_defaults();
1018        assert_eq!(p, 3);
1019        assert_eq!(r, 17);
1020    }
1021}
1022
1023#[cfg(test)]
1024mod u128_map_tests {
1025    use super::U128Map;
1026    use std::collections::HashMap;
1027
1028    /// Random inserts, overwrites and removes agree with `HashMap`,
1029    /// including keys that collide on their home slot.
1030    #[test]
1031    fn matches_hashmap_under_random_ops() {
1032        let mut ours = U128Map::default();
1033        let mut reference: HashMap<u128, u32> = HashMap::new();
1034        let mut x: u64 = 0x9E37_79B9;
1035        let mut next = || {
1036            x ^= x << 13;
1037            x ^= x >> 7;
1038            x ^= x << 17;
1039            x
1040        };
1041        for step in 0..60_000u32 {
1042            // A small key space forces overwrites, removes and long runs.
1043            let k = (next() % 5_000) as u128 | ((next() % 3) as u128) << 100;
1044            match next() % 4 {
1045                0 | 1 => {
1046                    ours.insert(k, step);
1047                    reference.insert(k, step);
1048                }
1049                2 => assert_eq!(ours.remove(k), reference.remove(&k)),
1050                _ => assert_eq!(ours.get(k), reference.get(&k).copied()),
1051            }
1052            assert_eq!(ours.len(), reference.len());
1053        }
1054        for (k, v) in &reference {
1055            assert_eq!(ours.get(*k), Some(*v));
1056        }
1057    }
1058}
1059
1060#[cfg(test)]
1061mod adc_tests {
1062    use super::{adc_distance, PQ_CODEWORDS};
1063
1064    /// The unrolled sum agrees with the plain one for every subspace count,
1065    /// including those that aren't a multiple of four.
1066    #[test]
1067    fn unrolled_adc_matches_the_plain_sum() {
1068        let mut x: u32 = 0xA5A5_1234;
1069        let mut next = || {
1070            x ^= x << 13;
1071            x ^= x >> 17;
1072            x ^= x << 5;
1073            x
1074        };
1075        for m in 1..=13usize {
1076            let table: Vec<f32> = (0..m * PQ_CODEWORDS)
1077                .map(|_| (next() % 10_000) as f32 / 100.0)
1078                .collect();
1079            for _ in 0..50 {
1080                let code: Vec<u8> = (0..m).map(|_| next() as u8).collect();
1081                let plain: f32 = code
1082                    .iter()
1083                    .enumerate()
1084                    .map(|(s, &c)| table[s * PQ_CODEWORDS + c as usize])
1085                    .sum();
1086                let fast = adc_distance(&code, &table);
1087                assert!(
1088                    (plain - fast).abs() <= 1e-3 * plain.abs().max(1.0),
1089                    "m={m}: {plain} vs {fast}"
1090                );
1091            }
1092        }
1093    }
1094}