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(¢roids[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, ¢roids[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 = ¢roids[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(¢roids, 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}