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