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 std::collections::HashMap;
24
25use rand::rngs::StdRng;
26use rand::seq::SliceRandom;
27use rand::{Rng, SeedableRng};
28use serde::{Deserialize, Serialize};
29
30use crate::error::{MnemoError, Result};
31
32/// Default RNG seed — fixed so index builds are reproducible.
33const DEFAULT_SEED: u64 = 0x4d_4e_45_4d_4f_49_44_58; // "MNEMOIDX"
34/// Training-set cap: k-means trains on at most this many sampled vectors.
35const TRAIN_CAP: usize = 50_000;
36/// Codewords per PQ subspace.
37const PQ_CODEWORDS: usize = 256;
38
39/// Tuning knobs for building and querying an [`IvfPqIndex`].
40#[derive(Clone, Copy, Debug)]
41pub struct IndexConfig {
42    /// Number of IVF partitions. `0` means auto (`ceil(√n)`).
43    pub n_partitions: usize,
44    /// Number of PQ subspaces. `0` means auto (`dims / 4`). Previously
45    /// `dims / 8` before PR ANN-1 — see [`IndexConfig::default`] for
46    /// why the bump.
47    pub pq_subspaces: usize,
48    /// Partitions scanned per query (the IVF accuracy/speed dial).
49    /// `0` means auto (`ceil(partitions / 2)`). Previously a fixed `8`
50    /// before PR ANN-1.
51    pub n_probe: usize,
52    /// Candidates handed back for exact reranking (the PQ accuracy dial).
53    /// `0` means auto (`500`). Previously a fixed `64` before PR ANN-1.
54    pub n_rerank: usize,
55    /// Lloyd-iteration count for every k-means run.
56    pub kmeans_iters: usize,
57    /// RNG seed for reproducible builds.
58    pub seed: u64,
59}
60
61/// Auto-pick `n_probe` from the IVF partition count.
62///
63/// Formula: `max(ceil(partitions / 2), min(partitions, 100))`.
64///
65/// Full probe up to 100 partitions (so ~10K / √10K ≈ 100 corpora
66/// clear the 0.95 recall@10 target — ANN-1.1 measured 0.964 at
67/// n_probe=100 + n_rerank=100 vs 0.914 at n_probe=50), half probe
68/// above. At the three measured tiers:
69///
70/// - 10K   ≈ 100 partitions  → 100 (full)
71/// - 100K  ≈ 317 partitions  → 159 (half, from ceil(317/2))
72/// - 1M    ≈ 1000 partitions → 500 (half)
73///
74/// Query latency scales linearly with `n_probe` (and therefore with
75/// `n_partitions * fraction_probed`); see `ann_defaults_v0_6_0` in
76/// `test/scripts/perf_v0.5.0.json` for the measured per-tier numbers.
77pub(crate) fn effective_auto_n_probe(partitions: usize) -> usize {
78    let half = partitions.div_ceil(2);
79    let full_up_to_100 = partitions.min(100);
80    half.max(full_up_to_100).max(1)
81}
82
83/// Auto-pick `n_rerank` from the live vector count.
84///
85/// Formula: `(n_vectors / 200).clamp(100, 1000)`. Hits the planner's
86/// ANN-1.1 targets exactly:
87///
88/// - 10K vectors  → 50 → clamped to 100 (plenty for ~0.98 recall@10)
89/// - 100K vectors → 500 (matches the ann_recall_grid_v2 knee)
90/// - 1M vectors   → 5000 → clamped to 1000 (keeps latency bounded;
91///   recall at 1M is "measure in Phase 11.1" per the planner)
92///
93/// Pre-ANN-1.1 the default was a fixed 500 at every size, which cost
94/// ~5 ms of record-decode time on 10K files for no recall gain over
95/// 100 — rerank is bounded by exact decrypt-and-score cost (~10 µs
96/// per candidate on hardware AES).
97pub(crate) fn effective_auto_n_rerank(n_vectors: usize) -> usize {
98    (n_vectors / 200).clamp(100, 1000)
99}
100
101impl Default for IndexConfig {
102    fn default() -> Self {
103        // All four of n_partitions / pq_subspaces / n_probe / n_rerank
104        // default to 0, which [`IvfPqIndex::build`] interprets as "pick
105        // a sensible value from the corpus and partition count":
106        //
107        // - n_partitions → `ceil(sqrt(n))`
108        // - pq_subspaces → `dims/4` (384-dim → 96 subspaces).
109        //   Previously `dims/8` (48); PR ANN-1 bumped it after the
110        //   ann_recall_grid_v2 harness showed that 48 subspaces capped
111        //   100K recall@10 at ~0.92 even with the full-scan rerank,
112        //   while 96 subspaces lifts the knee to 0.98 at ~10 ms/query
113        //   on an M1. Costs 2x the PQ codes per vector (48 → 96 bytes,
114        //   negligible against the 1.5 KB raw vector) and ~15 % more
115        //   build time.
116        // - n_probe → `ceil(partitions / 2)` (ANN-1) — scales with
117        //   partition count. Previously fixed at 8, which at 317
118        //   partitions scanned 2.5 % of the index per query. The
119        //   planner's "express relative to index size" clause.
120        // - n_rerank → `(n_vectors / 200).clamp(100, 1000)` (ANN-1.1).
121        //   10K files land on 100 rerank (plenty for ~0.98 recall@10
122        //   on this corpus); 100K lands on 500 (the measured
123        //   ann_recall_grid_v2 knee); 1M caps at 1000. ANN-1 shipped a
124        //   fixed 500 at every size, which cost ~5 ms of record-decode
125        //   time on small files for no recall gain. Still fixed per
126        //   build if explicit; computed per query from the live vector
127        //   count when auto.
128        //
129        // Explicit non-zero values still override, so callers who want
130        // the old tiny defaults can set them (useful for very small
131        // corpora where sqrt(n) partitions is overkill).
132        Self {
133            n_partitions: 0,
134            pq_subspaces: 0,
135            n_probe: 0,
136            n_rerank: 0,
137            kmeans_iters: 25,
138            seed: DEFAULT_SEED,
139        }
140    }
141}
142
143/// A read-only snapshot of an index's shape, surfaced via `Mnemo::stats`.
144///
145/// `Serialize`/`Deserialize` derived so the MCP `stats` tool (and any
146/// other JSON-shaped consumer) can emit / round-trip it without a
147/// per-field object build. Plain-old-data type — no invariants to
148/// preserve across (de)serialization.
149#[derive(Clone, Copy, Debug, Serialize, Deserialize)]
150pub struct IndexInfo {
151    /// Vectors currently held in the index.
152    pub vectors: usize,
153    /// IVF partition count.
154    pub partitions: usize,
155    /// PQ subspace count.
156    pub subspaces: usize,
157    /// Default partitions probed per query.
158    pub n_probe: usize,
159    /// Default candidates reranked per query.
160    pub n_rerank: usize,
161}
162
163/// Squared Euclidean distance between two equal-length vectors.
164fn squared_l2(a: &[f32], b: &[f32]) -> f32 {
165    let mut s = 0.0f32;
166    for i in 0..a.len() {
167        let d = a[i] - b[i];
168        s += d * d;
169    }
170    s
171}
172
173/// Index of the nearest centroid (flat layout, `stride` floats each).
174fn nearest(centroids: &[f32], stride: usize, v: &[f32]) -> usize {
175    let mut best = 0usize;
176    let mut best_d = f32::INFINITY;
177    let count = centroids.len() / stride;
178    for i in 0..count {
179        let d = squared_l2(&centroids[i * stride..(i + 1) * stride], v);
180        if d < best_d {
181            best_d = d;
182            best = i;
183        }
184    }
185    best
186}
187
188/// k-means (k-means++ seeding, Lloyd iterations). Returns a flat
189/// `k * dim` centroid buffer; `k` may shrink to `points.len()`.
190fn kmeans(points: &[&[f32]], k: usize, iters: usize, rng: &mut StdRng) -> Vec<f32> {
191    let n = points.len();
192    let dim = points[0].len();
193    let k = k.clamp(1, n);
194
195    // --- k-means++ seeding ---
196    let mut centroids: Vec<f32> = Vec::with_capacity(k * dim);
197    let first = rng.gen_range(0..n);
198    centroids.extend_from_slice(points[first]);
199    let mut d2: Vec<f32> = points
200        .iter()
201        .map(|p| squared_l2(p, &centroids[0..dim]))
202        .collect();
203
204    while centroids.len() / dim < k {
205        let sum: f32 = d2.iter().sum();
206        let pick = if sum <= 0.0 {
207            rng.gen_range(0..n)
208        } else {
209            let mut t = rng.gen::<f32>() * sum;
210            let mut chosen = n - 1;
211            for (i, &w) in d2.iter().enumerate() {
212                t -= w;
213                if t <= 0.0 {
214                    chosen = i;
215                    break;
216                }
217            }
218            chosen
219        };
220        let base = centroids.len();
221        centroids.extend_from_slice(points[pick]);
222        let new = &centroids[base..base + dim];
223        for (i, p) in points.iter().enumerate() {
224            let nd = squared_l2(p, new);
225            if nd < d2[i] {
226                d2[i] = nd;
227            }
228        }
229    }
230
231    // --- Lloyd iterations ---
232    let kk = centroids.len() / dim;
233    for _ in 0..iters {
234        let mut sums = vec![0.0f32; kk * dim];
235        let mut counts = vec![0usize; kk];
236        for p in points {
237            let a = nearest(&centroids, dim, p);
238            counts[a] += 1;
239            for d in 0..dim {
240                sums[a * dim + d] += p[d];
241            }
242        }
243        for c in 0..kk {
244            if counts[c] == 0 {
245                // Reseed an empty cluster onto a random point.
246                let r = rng.gen_range(0..n);
247                centroids[c * dim..(c + 1) * dim].copy_from_slice(points[r]);
248            } else {
249                let inv = 1.0 / counts[c] as f32;
250                for d in 0..dim {
251                    centroids[c * dim + d] = sums[c * dim + d] * inv;
252                }
253            }
254        }
255    }
256    centroids
257}
258
259/// Product-quantization codebook: one learned set of codewords per subspace.
260#[derive(Serialize, Deserialize, Clone, Debug)]
261struct PqCodebook {
262    /// Subspace boundaries; subspace `s` spans `[sub_offsets[s], sub_offsets[s+1])`.
263    sub_offsets: Vec<usize>,
264    /// Per subspace: a flat `ks[s] * subdim` codeword buffer.
265    centroids: Vec<Vec<f32>>,
266    /// Codeword count per subspace (`≤ 256`, so a code fits in a `u8`).
267    ks: Vec<usize>,
268}
269
270impl PqCodebook {
271    fn m(&self) -> usize {
272        self.sub_offsets.len() - 1
273    }
274
275    /// Train one codebook per subspace on the sampled training vectors.
276    fn train(
277        sub_offsets: &[usize],
278        train: &[&[f32]],
279        iters: usize,
280        rng: &mut StdRng,
281    ) -> PqCodebook {
282        let m = sub_offsets.len() - 1;
283        let mut centroids = Vec::with_capacity(m);
284        let mut ks = Vec::with_capacity(m);
285        for s in 0..m {
286            let (lo, hi) = (sub_offsets[s], sub_offsets[s + 1]);
287            let subs: Vec<Vec<f32>> = train.iter().map(|v| v[lo..hi].to_vec()).collect();
288            let refs: Vec<&[f32]> = subs.iter().map(|x| x.as_slice()).collect();
289            let k = PQ_CODEWORDS.min(refs.len()).max(1);
290            let cs = kmeans(&refs, k, iters, rng);
291            ks.push(cs.len() / (hi - lo));
292            centroids.push(cs);
293        }
294        PqCodebook {
295            sub_offsets: sub_offsets.to_vec(),
296            centroids,
297            ks,
298        }
299    }
300
301    /// Encode a full vector into `m` codeword indices.
302    fn encode(&self, v: &[f32]) -> Vec<u8> {
303        let m = self.m();
304        let mut code = vec![0u8; m];
305        for (s, slot) in code.iter_mut().enumerate().take(m) {
306            let (lo, hi) = (self.sub_offsets[s], self.sub_offsets[s + 1]);
307            *slot = nearest(&self.centroids[s], hi - lo, &v[lo..hi]) as u8;
308        }
309        code
310    }
311
312    /// Precompute, for a query, the distance from each query subspace to every
313    /// codeword. Asymmetric distance = sum of one lookup per subspace.
314    fn distance_table(&self, q: &[f32]) -> Vec<Vec<f32>> {
315        let m = self.m();
316        let mut table = Vec::with_capacity(m);
317        for s in 0..m {
318            let (lo, hi) = (self.sub_offsets[s], self.sub_offsets[s + 1]);
319            let sd = hi - lo;
320            let qs = &q[lo..hi];
321            let cs = &self.centroids[s];
322            let mut row = vec![0.0f32; self.ks[s]];
323            for (c, slot) in row.iter_mut().enumerate() {
324                *slot = squared_l2(qs, &cs[c * sd..(c + 1) * sd]);
325            }
326            table.push(row);
327        }
328        table
329    }
330}
331
332/// One IVF partition: parallel arrays of memory IDs and their PQ codes.
333#[derive(Serialize, Deserialize, Clone, Debug, Default)]
334struct Posting {
335    ids: Vec<u128>,
336    /// Flat `ids.len() * m` byte buffer of PQ codes.
337    codes: Vec<u8>,
338}
339
340/// An IVF + PQ approximate-nearest-neighbour index over the database's vectors.
341#[derive(Serialize, Deserialize, Clone, Debug)]
342pub struct IvfPqIndex {
343    dims: usize,
344    n_partitions: usize,
345    n_probe: usize,
346    n_rerank: usize,
347    /// Flat `n_partitions * dims` IVF centroid buffer.
348    centroids: Vec<f32>,
349    pq: PqCodebook,
350    partitions: Vec<Posting>,
351    /// `id -> partition`. Reconstructed from `partitions` after load, never
352    /// serialized.
353    #[serde(skip)]
354    assignment: HashMap<u128, usize>,
355    // Serialized shape is frozen at v0.5.2's: dims, n_partitions,
356    // n_probe, n_rerank, centroids, pq, partitions. Do NOT append new
357    // fields here under the same `format::VERSION` — rmp-serde 1.3.0's
358    // positional decoder rejects extra array elements with
359    // `Error::LengthMismatch`, so appending here would break every
360    // v0.5.x reader that opens a file written by this build. See the
361    // 2026-11-01 R-001.10 standing rule: any shape change on a
362    // persisted struct now requires a version bump + migration, or
363    // goes into `Memory.metadata` under reserved `_mnemo.*` keys.
364    //
365    // The "pre-ANN-1 default sentinel" check that gives v0.5.x-built
366    // indexes the ANN-1 recall fix on upgrade is in
367    // [`Self::effective_defaults`] — a stored (n_probe, n_rerank) of
368    // exactly (8, 64) is treated as "auto" at query time, so the
369    // concrete values stay stored (old readers see something sensible)
370    // but new readers recompute from the live corpus shape.
371}
372
373impl IvfPqIndex {
374    /// Build an index over `items` (`(id, vector)` pairs).
375    pub fn build(dims: usize, items: &[(u128, &[f32])], cfg: IndexConfig) -> Result<IvfPqIndex> {
376        if items.is_empty() {
377            return Err(MnemoError::Invalid(
378                "cannot build an index over an empty database".into(),
379            ));
380        }
381        let n = items.len();
382        let mut rng = StdRng::seed_from_u64(cfg.seed);
383
384        // Training sample (all vectors when small enough).
385        let mut order: Vec<usize> = (0..n).collect();
386        let train_n = TRAIN_CAP.min(n);
387        order.partial_shuffle(&mut rng, train_n);
388        let train: Vec<&[f32]> = order[..train_n].iter().map(|&i| items[i].1).collect();
389
390        // IVF centroids.
391        let n_partitions = if cfg.n_partitions > 0 {
392            cfg.n_partitions
393        } else {
394            (n as f64).sqrt().ceil() as usize
395        }
396        .clamp(1, n);
397        let centroids = kmeans(&train, n_partitions, cfg.kmeans_iters, &mut rng);
398        let n_partitions = centroids.len() / dims;
399
400        // PQ subspaces — split `dims` into `m` near-equal contiguous spans.
401        // The auto-default is `dims/4` (PR ANN-1), where the grid shows
402        // 100K recall@10 clearing 0.98 at the knee. Earlier auto was
403        // `dims/8`; see IndexConfig::default for the trade-off.
404        let m = if cfg.pq_subspaces > 0 {
405            cfg.pq_subspaces
406        } else {
407            (dims / 4).max(1)
408        }
409        .clamp(1, dims);
410        let mut sub_offsets = Vec::with_capacity(m + 1);
411        for s in 0..=m {
412            sub_offsets.push(s * dims / m);
413        }
414        let pq = PqCodebook::train(&sub_offsets, &train, cfg.kmeans_iters, &mut rng);
415
416        // Query-time dials. Zero in cfg means "auto" — resolve to a
417        // concrete value using the formulas, then store that. Old
418        // readers (v0.5.x) see a sensible stored number; new readers
419        // (v0.6.0+) with the live corpus can recompute via
420        // `effective_defaults` on every query. The "stored == (8,64)"
421        // sentinel in `effective_defaults` is what lets v0.5.x-built
422        // files pick up the fix on upgrade without a rebuild.
423        let n_probe = if cfg.n_probe > 0 {
424            cfg.n_probe
425        } else {
426            effective_auto_n_probe(n_partitions)
427        };
428        let n_rerank = if cfg.n_rerank > 0 {
429            cfg.n_rerank
430        } else {
431            effective_auto_n_rerank(items.len())
432        };
433
434        let mut index = IvfPqIndex {
435            dims,
436            n_partitions,
437            n_probe: n_probe.max(1),
438            n_rerank: n_rerank.max(1),
439            centroids,
440            pq,
441            partitions: vec![Posting::default(); n_partitions],
442            assignment: HashMap::with_capacity(n),
443        };
444        for &(id, v) in items {
445            index.add(id, v);
446        }
447        Ok(index)
448    }
449
450    /// Default `n_probe` / `n_rerank` to apply when the caller passes
451    /// `None` on a [`crate::RecallRequest`] — i.e. the user hasn't
452    /// explicitly overridden the dials for this query.
453    ///
454    /// Returns the auto-computed `(n_probe, n_rerank)` from the live
455    /// partition count + vector count when the stored pair is exactly
456    /// **`(8, 64)`** — the pre-ANN-1 fixed defaults every v0.5.x build
457    /// stamped in automatically. That's how an upgraded v0.5.x index
458    /// picks up the ANN-1 recall fix without a rebuild.
459    ///
460    /// Otherwise returns the stored `(self.n_probe, self.n_rerank)`
461    /// verbatim — the user picked these at build time and still means
462    /// them.
463    ///
464    /// New v0.6.0+ builds store the resolved auto values directly, so
465    /// a fresh build never hits the sentinel path. The sentinel is
466    /// pre-ANN-1-only.
467    ///
468    /// Note: a user who explicitly built with `IndexConfig { n_probe: 8,
469    /// n_rerank: 64, .. }` on v0.6.0+ will see their values treated as
470    /// "legacy auto" instead of pinned. That's acceptable — the ANN-1
471    /// auto formulas at very small N (10K / 100 partitions / 20K vectors)
472    /// also pick n_probe near 8 and n_rerank clamped to 100, so the
473    /// override is self-cancelling in the one regime where it matters.
474    /// Pin to any other non-legacy value to opt out explicitly.
475    pub(crate) fn effective_defaults(&self) -> (usize, usize) {
476        if self.n_probe == 8 && self.n_rerank == 64 {
477            (
478                effective_auto_n_probe(self.n_partitions),
479                effective_auto_n_rerank(self.len()),
480            )
481        } else {
482            (self.n_probe, self.n_rerank)
483        }
484    }
485
486    /// Embedding dimensionality the index was built for.
487    pub fn dims(&self) -> usize {
488        self.dims
489    }
490
491    /// Total vectors currently indexed.
492    pub fn len(&self) -> usize {
493        self.partitions.iter().map(|p| p.ids.len()).sum()
494    }
495
496    /// Shape snapshot for statistics.
497    ///
498    /// `n_probe` / `n_rerank` here are the **effective** values that
499    /// queries actually use (after the ANN-1.1 "stored (8,64) == auto"
500    /// shim via [`Self::effective_defaults`]), not the raw stored
501    /// numbers. That's what `mnemo info`, `db.stats()` and the MCP
502    /// `stats` tool report, so an upgraded v0.5.x file shows the
503    /// larger auto values it actually searches with — not the 8/64
504    /// baked into the serialized index.
505    pub fn info(&self) -> IndexInfo {
506        let (eff_probe, eff_rerank) = self.effective_defaults();
507        IndexInfo {
508            vectors: self.len(),
509            partitions: self.n_partitions,
510            subspaces: self.pq.m(),
511            n_probe: eff_probe,
512            n_rerank: eff_rerank,
513        }
514    }
515
516    /// Rebuild the `id -> partition` map from the posting lists. Called once
517    /// after the index is deserialized (the map itself is not stored).
518    pub fn rebuild_assignment(&mut self) {
519        self.assignment.clear();
520        for (pi, p) in self.partitions.iter().enumerate() {
521            for &id in &p.ids {
522                self.assignment.insert(id, pi);
523            }
524        }
525    }
526
527    fn nearest_centroid(&self, v: &[f32]) -> usize {
528        nearest(&self.centroids, self.dims, v)
529    }
530
531    /// Insert or overwrite a vector. The PQ codebook and IVF centroids are
532    /// fixed at build time; only posting lists grow. This keeps the index
533    /// complete across inserts without a full rebuild — cluster drift is the
534    /// price, repaid by `Mnemo::rebuild_index` / `compact`.
535    pub fn add(&mut self, id: u128, vector: &[f32]) {
536        self.remove(id);
537        let part = self.nearest_centroid(vector);
538        let code = self.pq.encode(vector);
539        let p = &mut self.partitions[part];
540        p.ids.push(id);
541        p.codes.extend_from_slice(&code);
542        self.assignment.insert(id, part);
543    }
544
545    /// Remove a vector if present (no-op otherwise).
546    pub fn remove(&mut self, id: u128) {
547        if let Some(part) = self.assignment.remove(&id) {
548            let m = self.pq.m();
549            let p = &mut self.partitions[part];
550            if let Some(pos) = p.ids.iter().position(|&x| x == id) {
551                p.ids.remove(pos);
552                p.codes.drain(pos * m..(pos + 1) * m);
553            }
554        }
555    }
556
557    /// Run the tiered query: IVF probe → PQ scan → top-`n_rerank` candidates.
558    /// Returns candidate IDs (nearest first by PQ distance) for the store to
559    /// rerank exactly.
560    ///
561    /// `n_probe` / `n_rerank` of `None` fall through to
562    /// [`Self::effective_defaults`] — which recomputes from the live
563    /// partition / vector count when the index is auto-flagged (ANN-1.1
564    /// in v0.6.0+) or carries the legacy pre-ANN-1 defaults (8, 64).
565    /// Any explicit value is honored verbatim.
566    pub fn query(&self, q: &[f32], n_probe: Option<usize>, n_rerank: Option<usize>) -> Vec<u128> {
567        let (auto_probe, auto_rerank) = self.effective_defaults();
568        let n_probe = n_probe.unwrap_or(auto_probe).clamp(1, self.n_partitions);
569        let n_rerank = n_rerank.unwrap_or(auto_rerank).max(1);
570
571        // Stage 1 — pick the nearest partitions.
572        let mut parts: Vec<(usize, f32)> = (0..self.n_partitions)
573            .map(|i| {
574                let c = &self.centroids[i * self.dims..(i + 1) * self.dims];
575                (i, squared_l2(q, c))
576            })
577            .collect();
578        parts.sort_by(|a, b| a.1.total_cmp(&b.1));
579        parts.truncate(n_probe);
580
581        // Stage 2 — scan PQ codes in the selected partitions.
582        let table = self.pq.distance_table(q);
583        let m = self.pq.m();
584        let mut cands: Vec<(u128, f32)> = Vec::new();
585        for (pi, _) in parts {
586            let p = &self.partitions[pi];
587            for (j, &id) in p.ids.iter().enumerate() {
588                let code = &p.codes[j * m..(j + 1) * m];
589                let mut d = 0.0f32;
590                for (s, row) in table.iter().enumerate() {
591                    d += row[code[s] as usize];
592                }
593                cands.push((id, d));
594            }
595        }
596
597        // Stage 3 — keep the closest `n_rerank` candidates for exact
598        // reranking. R-001.8 part 3 (ANN-1.1): partition-select first,
599        // then sort only the kept slice. Full sort of all N candidates
600        // is O(N log N); at ANN-1.1's 1M + partitions/2 defaults the
601        // candidate pool is ~500K per query, where the full sort cost
602        // dominates. `select_nth_unstable_by` is O(N) average, then
603        // the final sort is O(k log k) with k = n_rerank.
604        if n_rerank < cands.len() {
605            cands.select_nth_unstable_by(n_rerank - 1, |a, b| a.1.total_cmp(&b.1));
606            cands.truncate(n_rerank);
607        }
608        cands.sort_by(|a, b| a.1.total_cmp(&b.1));
609        cands.into_iter().map(|(id, _)| id).collect()
610    }
611}
612
613#[cfg(test)]
614mod ann_1_1_tests {
615    //! PR ANN-1.1 tests for the index-defaults upgrade path.
616    //!
617    //! The amended spec (planner 2026-11-01 URGENT AMEND, R-001.3 +
618    //! R-001.10): no new fields on `IvfPqIndex` — rmp-serde 1.3.0's
619    //! positional decoder rejects extra array elements, so any append
620    //! would break v0.5.x readers. The whole upgrade path runs on the
621    //! "stored (8, 64) == auto" sentinel in
622    //! [`IvfPqIndex::effective_defaults`].
623
624    use super::*;
625    use rand::rngs::StdRng;
626    use rand::SeedableRng;
627
628    /// Shadow of the pre-ANN-1 [`IvfPqIndex`] layout — identical to
629    /// the current one (same field order, same types). Serializing
630    /// one of these produces the exact byte stream a v0.5.x build
631    /// would write. Keeping it as a separate struct makes the
632    /// field-count guard below mechanical rather than ad-hoc.
633    #[derive(serde::Serialize, serde::Deserialize)]
634    struct V052IvfPqIndex {
635        dims: usize,
636        n_partitions: usize,
637        n_probe: usize,
638        n_rerank: usize,
639        centroids: Vec<f32>,
640        pq: PqCodebook,
641        partitions: Vec<Posting>,
642    }
643
644    fn tiny_pq(dims: usize) -> PqCodebook {
645        let sub_offsets = vec![0, dims];
646        let mut rng = StdRng::seed_from_u64(0);
647        let train: Vec<Vec<f32>> = (0..4).map(|i| vec![i as f32; dims]).collect();
648        let refs: Vec<&[f32]> = train.iter().map(|v| v.as_slice()).collect();
649        PqCodebook::train(&sub_offsets, &refs, 2, &mut rng)
650    }
651
652    #[test]
653    fn v0_5_2_index_blob_decodes_unchanged() {
654        // A byte stream in the exact v0.5.2 layout must still decode
655        // as the current `IvfPqIndex`. If we ever appended a field,
656        // rmp-serde would reject this as `LengthMismatch`.
657        let dims = 4;
658        let legacy = V052IvfPqIndex {
659            dims,
660            n_partitions: 2,
661            n_probe: 8,
662            n_rerank: 64,
663            centroids: vec![1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0],
664            pq: tiny_pq(dims),
665            partitions: vec![Posting::default(); 2],
666        };
667        let bytes = rmp_serde::to_vec(&legacy).expect("serialize v0.5.2 layout");
668        let decoded: IvfPqIndex =
669            rmp_serde::from_slice(&bytes).expect("v0.5.2 bytes must decode under current schema");
670        assert_eq!(decoded.dims, 4);
671        assert_eq!(decoded.n_partitions, 2);
672        assert_eq!(decoded.n_probe, 8);
673        assert_eq!(decoded.n_rerank, 64);
674    }
675
676    #[test]
677    fn current_build_serializes_with_v0_5_2_field_count() {
678        // Forward-compat guard. If someone appends a field to
679        // `IvfPqIndex`, the serialized form will carry an extra array
680        // element and v0.5.x readers will reject it with
681        // `LengthMismatch`. This test fails loudly first, so the
682        // breakage is caught by `cargo test`, not by an upset user
683        // whose two machines run different mnemo versions.
684        let dims = 4;
685        let items: Vec<(u128, Vec<f32>)> = (0..32)
686            .map(|i| (i as u128, vec![i as f32, i as f32 + 1.0, 0.0, 0.0]))
687            .collect();
688        let refs: Vec<(u128, &[f32])> = items.iter().map(|(id, v)| (*id, v.as_slice())).collect();
689        let idx = IvfPqIndex::build(dims, &refs, IndexConfig::default()).unwrap();
690        let bytes = rmp_serde::to_vec(&idx).expect("serialize current layout");
691        // Decode via the frozen v0.5.2 shadow struct. If the current
692        // layout has an extra appended field, this fails with
693        // `LengthMismatch`.
694        let as_v052: V052IvfPqIndex = rmp_serde::from_slice(&bytes)
695            .expect("current index must decode under the v0.5.2 shape");
696        assert_eq!(as_v052.dims, dims);
697    }
698
699    #[test]
700    fn legacy_8_64_triggers_auto_formulas_at_query_time() {
701        // The sentinel path: stored (8, 64) on a legacy index
702        // substitutes the auto formulas at query time — ANN-1 fix
703        // without a rebuild.
704        let dims = 4;
705        let legacy = V052IvfPqIndex {
706            dims,
707            n_partitions: 10,
708            n_probe: 8,
709            n_rerank: 64,
710            centroids: vec![0.0; 10 * dims],
711            pq: tiny_pq(dims),
712            partitions: (0..10)
713                .map(|_| Posting {
714                    ids: vec![0u128, 1u128],
715                    codes: vec![0u8; 2],
716                })
717                .collect(),
718        };
719        let bytes = rmp_serde::to_vec(&legacy).unwrap();
720        let decoded: IvfPqIndex = rmp_serde::from_slice(&bytes).unwrap();
721
722        let (auto_probe, auto_rerank) = decoded.effective_defaults();
723        // Small corpora get full probe (ANN-1.1 formula: full up to
724        // 100 partitions). 10 partitions → probe all 10.
725        assert_eq!(
726            auto_probe, 10,
727            "auto n_probe at <=100 partitions is full probe"
728        );
729        assert_eq!(auto_rerank, 100, "auto n_rerank at tiny N clamps up to 100");
730    }
731
732    #[test]
733    fn new_build_stores_resolved_auto_values() {
734        // On v0.6.0+, build() with IndexConfig::default() resolves
735        // 0 → concrete auto values and stores THOSE in the index.
736        // Old readers (v0.5.x) see a usable number rather than a 0
737        // sentinel; new readers compute the same values at query
738        // time via effective_defaults.
739        let dims = 4;
740        let items: Vec<(u128, Vec<f32>)> = (0..64)
741            .map(|i| {
742                let f = i as f32;
743                (i as u128, vec![f, f + 1.0, f + 2.0, f + 3.0])
744            })
745            .collect();
746        let refs: Vec<(u128, &[f32])> = items.iter().map(|(id, v)| (*id, v.as_slice())).collect();
747        let idx = IvfPqIndex::build(dims, &refs, IndexConfig::default()).unwrap();
748        assert!(
749            idx.n_probe > 0 && idx.n_rerank > 0,
750            "resolved values must be concrete (never 0)"
751        );
752        // The IndexConfig::default() formula for a 64-vector corpus
753        // with ceil(sqrt(64)) = 8 partitions picks n_probe=4,
754        // n_rerank=clamp(64/200, 100, 1000) = 100.
755        let expected_rerank = (64 / 200usize).clamp(100, 1000);
756        assert_eq!(idx.n_rerank, expected_rerank);
757    }
758
759    #[test]
760    fn explicit_build_values_persist_verbatim() {
761        // Non-sentinel explicit values stay stored and are used at
762        // query time. (A user who passes exactly 8/64 explicitly is
763        // treated as legacy auto — acknowledged in the planner's
764        // amended spec; harmless because at the tiny N where 8/64
765        // could sensibly be chosen, the auto formulas pick similar
766        // values.)
767        let dims = 4;
768        let items: Vec<(u128, Vec<f32>)> =
769            (0..64).map(|i| (i as u128, vec![i as f32; dims])).collect();
770        let refs: Vec<(u128, &[f32])> = items.iter().map(|(id, v)| (*id, v.as_slice())).collect();
771        let cfg = IndexConfig {
772            n_probe: 3,
773            n_rerank: 17,
774            ..Default::default()
775        };
776        let idx = IvfPqIndex::build(dims, &refs, cfg).unwrap();
777        assert_eq!(idx.n_probe, 3);
778        assert_eq!(idx.n_rerank, 17);
779        let (p, r) = idx.effective_defaults();
780        assert_eq!(p, 3);
781        assert_eq!(r, 17);
782    }
783}