Skip to main content

yo_vector/
partition.rs

1//! The partition index, and the in place update protocol that means it never
2//! has to be rebuilt (`10` sections 2, 4 and 5).
3//!
4//! A vector index is two decisions and the quantiser was the easy one. This is
5//! the other: what holds the codes, and what happens to it when the collection
6//! changes.
7//!
8//! The answer is not a graph. Redis shipped HNSW vector sets in 8.0 in May 2025
9//! and they were still beta three minor releases later, which is the vendor's
10//! own evidence about how that goes. The reason is the update path: a graph
11//! index tombstones a delete, degrades as the tombstones pile up, and only gets
12//! better again when somebody rebuilds it, which on a collection anyone cares
13//! about is an outage with a nicer name.
14//!
15//! So this is partitions. Every vector belongs to the partition whose centroid
16//! it is nearest, the centroids are resident, and a partition's members are a
17//! flat run of codes that the scan walks end to end. An insert is an append. A
18//! delete takes a member out and moves the last one into the hole. Neither one
19//! touches anything else.
20//!
21//! # Search
22//!
23//! Rank the centroids, take the nearest `probe` of them, scan those partitions
24//! with the estimator, keep the best `rerank` candidates, and then look up the
25//! full precision vectors for exactly those and measure them properly. Rerank
26//! costs nothing structurally here, because the vector is already in the record
27//! log at an address the id resolves to. Every other system that quantises has
28//! to keep the raw vectors somewhere on purpose.
29//!
30//! The scan is the shape hardware likes, a linear walk over contiguous bytes,
31//! and that is why an index of this family beats a graph on a modern core even
32//! though it looks at more candidates.
33//!
34//! # It never rebuilds
35//!
36//! This is SPFresh's LIRE, and it is four bounded operations rather than a
37//! background rebuild.
38//!
39//! A posting that grows past twice its target splits, by two means over its own
40//! members. A posting that falls under a quarter of its target merges, by
41//! handing its members to whichever centroid is nearest now. Both are bounded
42//! work on one partition.
43//!
44//! The third is the one that matters and it is what LIRE actually contributes.
45//! After a split, the members of the partitions *around* the one that split may
46//! now be nearer one of the two new centroids than the one they are filed
47//! under. Nobody told them, and a plain partitioned index just lets that drift,
48//! which is why a plain partitioned index measures beautifully on a freshly
49//! built corpus and badly after a week of writes. So a split is followed by a
50//! sweep of the neighbouring partitions, and anything whose nearest centroid
51//! has changed is moved. The test for this writes a stream and checks recall at
52//! the end of it rather than on a fresh build, because a fresh build is exactly
53//! the measurement that hides the problem.
54//!
55//! # Everything here is in rotated space
56//!
57//! The rotation is linear, so `rotate(v - c)` is `rotate(v) - rotate(c)`, and
58//! distances and angles come through it unchanged. That means a centroid can be
59//! stored already rotated and a query can be rotated once, and then meeting a
60//! partition is a subtraction rather than another rotation. The rotation is the
61//! expensive half of preparing a query, so on a search that probes tens of
62//! partitions this is most of what preparation costs.
63//!
64//! # What it costs to build
65//!
66//! `examples/ingest.rs` measures the rate at every doubling and splits it
67//! between the insert and the maintenance, because a rate that falls as the
68//! collection grows and a rate that is just low need different work and a single
69//! number cannot tell them apart. On 128 dimensional vectors on one core of an
70//! M-series Mac:
71//!
72//! ```text
73//!         at  partitions    a second      insert    maintain   touched
74//!      12500          36      115952       20.4%       79.6%       5.3
75//!      50000         132       72013       39.4%       60.6%       6.9
76//!     200000         595       46917       58.7%       41.3%       5.5
77//!     800000        2141       42459       60.9%       39.1%       4.4
78//! ```
79//!
80//! `touched` is how many vectors maintenance moved or looked at per vector
81//! inserted. It is flat, and that is the number which says the update protocol
82//! is doing bounded work rather than quietly turning into a rebuild.
83//!
84//! Both halves of that took a fix to get there and they were different fixes.
85//! Maintenance was 80 percent of the time and most of it was `sweep` measuring
86//! every member it looked at against every centroid in the collection, which is
87//! not what LIRE says and is several full scans per vector inserted. The insert
88//! was the other half and it was a scan over every centroid by definition, which
89//! is why the coarse layer in `src/coarse.rs` is there, and that file is where
90//! the reasoning about it lives. Before either fix, the rate halved on every
91//! doubling and was 13563 a second by 800 thousand.
92//!
93//! # What is not here yet
94//!
95//! A `.yo` file. None of this is written down yet, and the format freezes at the
96//! end of M6, so that is the next thing.
97//!
98//! A search that fits in a millisecond. SIFT1M on a 13900K gets recall 0.9598 at
99//! probe 64 rerank 16, which clears the gate, with p50 at 1.1 ms and p99 at 1.5
100//! ms, which does not. `examples/search.rs` is the breakdown of where that time
101//! goes and the answer is that two thirds of it is the estimator meeting one
102//! code at a time.
103//!
104//! The commands that put all of this on the wire are the rest of M6.
105
106use std::collections::HashMap;
107
108use crate::coarse::Coarse;
109use crate::rabitq::{Bits, Coded, Quantizer};
110
111/// Where the full precision vectors live.
112///
113/// A real collection answers this out of the record log, which already holds
114/// the vector at an address the id resolves to. A test answers it out of a map.
115/// Either way the index itself never stores a raw vector, which is the whole
116/// point of quantising one.
117pub trait Vectors {
118    /// Write the vector `id` stands for into `into` and say so, or say that the
119    /// id is gone.
120    ///
121    /// An id that is gone is dropped from the index the next time maintenance
122    /// walks over it, so a collection that deletes from the log without telling
123    /// the index heals rather than lying.
124    fn get(&self, id: u64, into: &mut [f32]) -> bool;
125}
126
127/// The knobs, all of which have a defensible default and none of which anybody
128/// should have to touch.
129#[derive(Debug, Clone, Copy, PartialEq, Eq)]
130pub struct Tuning {
131    /// How many members a partition wants. It splits past twice this and merges
132    /// under a quarter of it.
133    ///
134    /// This is what sets how many partitions a collection ends up with, and so
135    /// it trades the cost of ranking centroids against the cost of scanning a
136    /// posting. A few hundred is where those two are near enough even.
137    pub posting: usize,
138    /// How many partitions a search scans.
139    pub probe: usize,
140    /// How many candidates are reranked per answer asked for.
141    ///
142    /// Four is the number the recall table was measured at: one bit codes put
143    /// the true ten inside the best forty better than 98 times in a hundred.
144    pub rerank: usize,
145    /// How many neighbouring partitions a split sweeps for members that should
146    /// move.
147    ///
148    /// This is the cost of never drifting. Zero would make a split free and
149    /// would make recall fall off over a long write stream, which is the thing
150    /// this index exists to not do.
151    pub sweep: usize,
152    /// How much further than `probe` a filtered search will go looking when the
153    /// filter is selective enough that the nearest partitions do not hold `k`
154    /// members that pass, as a multiple of `probe`.
155    ///
156    /// This is the only knob here with a genuinely hard trade behind it. Too
157    /// small and a filter matching one document in a thousand returns nothing
158    /// while the answer sat two partitions further out. Too large and the same
159    /// filter reads the whole collection to prove there is nothing there.
160    pub widen: usize,
161}
162
163impl Default for Tuning {
164    fn default() -> Tuning {
165        Tuning {
166            posting: 256,
167            probe: 8,
168            rerank: 4,
169            sweep: 4,
170            widen: 8,
171        }
172    }
173}
174
175/// The fewest candidates a search will rerank, whatever `k` and `rerank`
176/// multiply out to.
177///
178/// Four times `k` is the right ratio and it is the wrong number when `k` is
179/// small: asking for one answer and reranking four candidates puts the whole
180/// weight of the answer on the estimator getting its top four right, which is
181/// not what the estimator is for. Reranking a few dozen costs a few dozen
182/// squared distances, which is nothing next to the scan that produced them.
183const FLOOR: usize = 32;
184
185/// What decides whether the scan bothers with a member.
186///
187/// A filtered vector search is a recall lottery when the filter runs after the
188/// search: ask for ten English passages, get the best forty by vector, find
189/// three of them are English, and the other seven English passages that were
190/// nearer never had a chance. The fix is to filter inside the scan, so that
191/// only members that can be answers are ranked at all, and that means the thing
192/// the filter reads has to sit next to the codes rather than behind a lookup
193/// into somebody else's table.
194///
195/// So every member carries a `u64` tag, given at insert, and a filter is a
196/// predicate on that tag. What the tag means is the caller's business. A
197/// handful of low cardinality attributes pack into it exactly, one field each,
198/// and the filter is then exact. Anything wider goes through [`Signature`],
199/// which is exact in the direction that matters: it never rejects a member that
200/// should have matched, so the caller's real predicate over the answers still
201/// decides.
202pub trait Filter {
203    /// Whether a member with this tag is worth ranking.
204    fn allows(&self, tag: u64) -> bool;
205}
206
207/// The filter that lets everything through, which is what an unfiltered search
208/// runs.
209#[derive(Debug, Clone, Copy, Default)]
210pub struct Any;
211
212impl Filter for Any {
213    fn allows(&self, _tag: u64) -> bool {
214        true
215    }
216}
217
218impl<F: Fn(u64) -> bool> Filter for F {
219    fn allows(&self, tag: u64) -> bool {
220        self(tag)
221    }
222}
223
224/// A tag built by setting one bit per attribute value, so that a conjunction of
225/// required values is a subset test.
226///
227/// Superimposed coding, which is old and still the right answer when the test
228/// has to be one instruction on a value that is already in a register. Each
229/// attribute and value pair hashes to one of 64 bits. A member's tag is the
230/// bits for the values it has. A query's tag is the bits for the values it
231/// requires. The member is worth ranking when it has all of the query's bits.
232///
233/// Two different values can land on the same bit, so a member can pass a filter
234/// it does not really match. It can never fail one it does match, which is the
235/// direction that matters: the answers are a superset of the truth and the
236/// caller's own predicate cuts them down, where the other way round would lose
237/// answers silently.
238///
239/// ```
240/// use yo_vector::Signature;
241///
242/// // What a document is tagged with, and what a query asks for.
243/// let doc = Signature::of(&[("lang", "en".as_bytes()), ("topic", "finance".as_bytes())]);
244/// let english = Signature::of(&[("lang", "en".as_bytes())]);
245///
246/// assert!(doc.covers(english));
247/// // The other way round only holds if the two bits happened to collide.
248/// assert!(!english.covers(doc) || english.bits() == doc.bits());
249/// ```
250#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
251pub struct Signature(u64);
252
253impl Signature {
254    /// The signature of a set of attribute and value pairs.
255    #[must_use]
256    pub fn of(values: &[(&str, &[u8])]) -> Signature {
257        let mut bits = 0u64;
258        for (attribute, value) in values {
259            bits |= 1u64 << (hash(attribute.as_bytes(), value) % 64);
260        }
261        Signature(bits)
262    }
263
264    /// The signature as the tag to hand to [`Partitions::insert_tagged`].
265    #[must_use]
266    pub fn bits(self) -> u64 {
267        self.0
268    }
269
270    /// The signature of a tag that came back out of the index.
271    #[must_use]
272    pub fn from_bits(bits: u64) -> Signature {
273        Signature(bits)
274    }
275
276    /// Whether this has every bit `want` has, which is the test the scan runs.
277    #[must_use]
278    pub fn covers(self, want: Signature) -> bool {
279        self.0 & want.0 == want.0
280    }
281}
282
283impl Filter for Signature {
284    fn allows(&self, tag: u64) -> bool {
285        Signature(tag).covers(*self)
286    }
287}
288
289/// FNV over the attribute and then the value, which is small, has no state and
290/// spreads a short value over the whole word well enough to pick a bit.
291fn hash(attribute: &[u8], value: &[u8]) -> u64 {
292    let mut h = 0xcbf2_9ce4_8422_2325u64;
293    for byte in attribute.iter().chain(b":").chain(value) {
294        h ^= u64::from(*byte);
295        h = h.wrapping_mul(0x1000_0000_01b3);
296    }
297    h
298}
299
300/// An answer: a document id and how far it really is, not how far it was
301/// estimated to be.
302#[derive(Debug, Clone, Copy, PartialEq)]
303pub struct Hit {
304    /// The id that was inserted.
305    pub id: u64,
306    /// The exact squared distance, measured against the full precision vector.
307    pub distance: f32,
308}
309
310/// Where a member sits.
311#[derive(Debug, Clone, Copy)]
312struct Slot {
313    partition: u32,
314    slot: u32,
315}
316
317/// One partition's members: the ids, their codes end to end, and what each code
318/// needs beside it.
319#[derive(Default)]
320struct Posting {
321    ids: Vec<u64>,
322    /// One tag per member, in the same order, which is what a filter meets.
323    ///
324    /// Beside the ids rather than behind a pointer, because the whole point is
325    /// that the scan can skip a member without touching anything that is not
326    /// already in cache.
327    tags: Vec<u64>,
328    codes: Vec<u8>,
329    meta: Vec<Coded>,
330    /// The size at which a split was tried and found there was no cut, which
331    /// happens when every member is the same vector. It is not tried again
332    /// until the posting has grown past it.
333    stuck: usize,
334}
335
336impl Posting {
337    fn len(&self) -> usize {
338        self.ids.len()
339    }
340}
341
342/// A collection of vectors, quantised, partitioned, and updated in place.
343pub struct Partitions {
344    quant: Quantizer,
345    tuning: Tuning,
346    /// The centroids, already rotated, `dim` floats each end to end.
347    centroids: Vec<f32>,
348    postings: Vec<Posting>,
349    /// Which partition and which slot every id is in, which is what makes a
350    /// delete a constant time operation rather than a search.
351    at: HashMap<u64, Slot>,
352    /// The index over the centroids. See [`crate::coarse`].
353    coarse: Coarse,
354    /// The shortlist a placement fills in, kept here so that placing a vector
355    /// does not allocate.
356    scratch: Vec<u32>,
357}
358
359impl Partitions {
360    /// An empty collection of `dim` dimensional vectors.
361    ///
362    /// The first vector inserted becomes the first centroid, and the index
363    /// grows by splitting from there, so there is no build step and no moment
364    /// where the shape of the collection has to be known in advance.
365    ///
366    /// # Panics
367    ///
368    /// If `dim` is zero.
369    #[must_use]
370    pub fn new(dim: usize, bits: Bits, seed: u64, tuning: Tuning) -> Partitions {
371        Partitions {
372            quant: Quantizer::new(dim, bits, seed),
373            tuning,
374            centroids: Vec::new(),
375            postings: Vec::new(),
376            at: HashMap::new(),
377            coarse: Coarse::default(),
378            scratch: Vec::new(),
379        }
380    }
381
382    /// How many coordinates a vector here has.
383    #[must_use]
384    pub fn dim(&self) -> usize {
385        self.quant.dim()
386    }
387
388    /// How many vectors are in the collection.
389    #[must_use]
390    pub fn len(&self) -> usize {
391        self.at.len()
392    }
393
394    /// Whether there are none.
395    #[must_use]
396    pub fn is_empty(&self) -> bool {
397        self.at.is_empty()
398    }
399
400    /// How many partitions the collection has grown to.
401    #[must_use]
402    pub fn partitions(&self) -> usize {
403        self.postings.len()
404    }
405
406    /// The knobs.
407    #[must_use]
408    pub fn tuning(&self) -> Tuning {
409        self.tuning
410    }
411
412    /// Change the knobs on a collection that already has vectors in it.
413    ///
414    /// [`Tuning::probe`], [`Tuning::rerank`] and [`Tuning::widen`] are read by
415    /// each search, so they take effect on the next one. That is what makes a
416    /// recall against latency curve measurable on one built index rather than on
417    /// one built per row, and it is what `EF_RUNTIME` means to a client that
418    /// thinks it is talking to a graph.
419    ///
420    /// [`Tuning::posting`] and [`Tuning::sweep`] are what maintenance aims at,
421    /// so lowering `posting` does not split anything by itself. The partitions
422    /// move towards the new size as [`Partitions::maintain`] gets called, which
423    /// is the same way they got to the old one.
424    pub fn retune(&mut self, tuning: Tuning) {
425        self.tuning = tuning;
426    }
427
428    /// The quantiser, whose seed and width a catalogue entry has to record.
429    #[must_use]
430    pub fn quantizer(&self) -> &Quantizer {
431        &self.quant
432    }
433
434    /// How many bytes the codes take, which is the searchable size of the
435    /// collection and the number the 32x claim is about.
436    #[must_use]
437    pub fn code_bytes(&self) -> usize {
438        self.postings.iter().map(|p| p.codes.len()).sum()
439    }
440
441    /// Put a vector in, replacing whatever was under `id`.
442    ///
443    /// Two appends and no locks: the code goes on the end of the nearest
444    /// partition's posting, and the caller puts the full precision vector in the
445    /// log. Nothing else in the index is touched, which is the difference
446    /// between this and a graph.
447    ///
448    /// # Panics
449    ///
450    /// If `v` is not [`Partitions::dim`] long.
451    pub fn insert(&mut self, id: u64, v: &[f32]) {
452        self.insert_tagged(id, v, 0);
453    }
454
455    /// The same, with the tag a filter will meet in the scan.
456    ///
457    /// See [`Filter`] for what a tag is and [`Signature`] for the encoding to
458    /// reach for when the attributes do not fit in one exactly.
459    ///
460    /// # Panics
461    ///
462    /// If `v` is not [`Partitions::dim`] long.
463    pub fn insert_tagged(&mut self, id: u64, v: &[f32], tag: u64) {
464        assert_eq!(
465            v.len(),
466            self.dim(),
467            "this collection holds {} dimensional vectors and was handed {}",
468            self.dim(),
469            v.len()
470        );
471        self.remove(id);
472        let x = self.quant.rotate(v);
473        let p = if self.postings.is_empty() {
474            // The first vector is the first centroid. There is nothing to
475            // average it with yet, and the first split is what starts the
476            // centroids being means rather than members.
477            self.add_partition(&x)
478        } else {
479            let mut short = core::mem::take(&mut self.scratch);
480            let p = self.roughly_nearest(&x, &mut short);
481            self.scratch = short;
482            p
483        };
484        self.place(p, id, tag, &x);
485    }
486
487    /// The tag `id` was inserted with, if it is still here.
488    #[must_use]
489    pub fn tag(&self, id: u64) -> Option<u64> {
490        let at = self.at.get(&id)?;
491        Some(self.postings[at.partition as usize].tags[at.slot as usize])
492    }
493
494    /// Take a vector out, saying whether it was there.
495    ///
496    /// The last member of the posting moves into the hole. There is no
497    /// tombstone, so there is nothing to accumulate and nothing to compact.
498    pub fn remove(&mut self, id: u64) -> bool {
499        let Some(Slot { partition, slot }) = self.at.remove(&id) else {
500            return false;
501        };
502        let moved = self.pull(partition as usize, slot as usize);
503        if let Some(other) = moved {
504            self.at.insert(other, Slot { partition, slot });
505        }
506        true
507    }
508
509    /// Whether `id` is in the collection.
510    #[must_use]
511    pub fn contains(&self, id: u64) -> bool {
512        self.at.contains_key(&id)
513    }
514
515    /// The `k` nearest vectors to `q`, measured exactly.
516    ///
517    /// The codes pick the candidates and the log settles the order, so the
518    /// answer is as exact as brute force whenever the candidates contained the
519    /// truth, and the recall table is about how often they do.
520    ///
521    /// # Panics
522    ///
523    /// If `q` is not [`Partitions::dim`] long.
524    #[must_use]
525    pub fn search(&self, q: &[f32], k: usize, vectors: &impl Vectors) -> Vec<Hit> {
526        self.search_where(q, k, &Any, vectors)
527    }
528
529    /// The `k` nearest vectors to `q` that a filter allows.
530    ///
531    /// The filter runs inside the scan, on the tag that sits next to the code,
532    /// so a member the filter rejects is never ranked and never takes a place
533    /// that an answer should have had. Filtering afterwards instead is what
534    /// makes a filtered vector search a lottery, and the more selective the
535    /// filter the worse a lottery it is.
536    ///
537    /// A selective filter also means the nearest few partitions may not hold `k`
538    /// members that pass, so the scan keeps going into further partitions until
539    /// it has enough or until it has spent [`Tuning::widen`]. A filter that
540    /// matches almost nothing returns fewer answers rather than reading the
541    /// whole collection, which is the trade every engine makes here and is worth
542    /// saying out loud.
543    ///
544    /// # Panics
545    ///
546    /// If `q` is not [`Partitions::dim`] long.
547    #[must_use]
548    pub fn search_where(
549        &self,
550        q: &[f32],
551        k: usize,
552        filter: &impl Filter,
553        vectors: &impl Vectors,
554    ) -> Vec<Hit> {
555        if k == 0 {
556            return Vec::new();
557        }
558        let candidates = self.candidates_where(q, (k * self.tuning.rerank).max(FLOOR), filter);
559        let mut buf = vec![0.0f32; self.dim()];
560        let mut hits = Vec::with_capacity(candidates.len());
561        for (id, _) in candidates {
562            if vectors.get(id, &mut buf) {
563                hits.push(Hit {
564                    id,
565                    distance: sqdist(q, &buf),
566                });
567            }
568        }
569        hits.sort_by(|a, b| a.distance.total_cmp(&b.distance));
570        hits.truncate(k);
571        hits
572    }
573
574    /// The `want` best candidates by the estimator, without rerank.
575    ///
576    /// This is what a filter will eventually push into, and it is what the
577    /// recall of the codes alone is measured on.
578    ///
579    /// # Panics
580    ///
581    /// If `q` is not [`Partitions::dim`] long.
582    #[must_use]
583    pub fn candidates(&self, q: &[f32], want: usize) -> Vec<(u64, f32)> {
584        self.candidates_where(q, want, &Any)
585    }
586
587    /// The same, with the filter run inside the scan.
588    ///
589    /// # Panics
590    ///
591    /// If `q` is not [`Partitions::dim`] long.
592    #[must_use]
593    pub fn candidates_where(
594        &self,
595        q: &[f32],
596        want: usize,
597        filter: &impl Filter,
598    ) -> Vec<(u64, f32)> {
599        assert_eq!(
600            q.len(),
601            self.dim(),
602            "this collection holds {} dimensional vectors and was handed {}",
603            self.dim(),
604            q.len()
605        );
606        if want == 0 || self.postings.is_empty() {
607            return Vec::new();
608        }
609        // Rotated once here and never again, which is what lets a search probe
610        // tens of partitions without paying for tens of rotations.
611        let u = self.quant.rotate(q);
612        let mut best = Bounded::new(want);
613        // One buffer for the whole search rather than one per partition, and
614        // grown rather than cleared, because every partition after the first
615        // wants the same room the one before it did.
616        let mut scores: Vec<f32> = Vec::new();
617        let reach = self.tuning.probe.saturating_mul(self.tuning.widen.max(1));
618        for (n, p) in self.near_partitions(&u, reach).into_iter().enumerate() {
619            // Past the partitions an unfiltered search would have read, keep
620            // going only while there is still not enough to answer with. An
621            // unfiltered search never gets here, because the first `probe`
622            // partitions of a collection worth probing hold more than `want`.
623            if n >= self.tuning.probe && best.full() {
624                break;
625            }
626            let prepared = self.quant.query_rotated(&u, self.centroid(p));
627            let posting = &self.postings[p];
628            let held = posting.ids.len();
629            if scores.len() < held {
630                scores.resize(held, 0.0);
631            }
632            // The whole posting at once, so the estimator's inner loops know
633            // how wide a code is. Then a second pass, which for most members is
634            // one comparison against the worst answer so far and no more, and
635            // which does not read the id or the tag of a member that lost.
636            prepared.scan(&posting.codes, &posting.meta, &mut scores[..held]);
637            for (i, &at) in scores[..held].iter().enumerate() {
638                if !best.wants(at) {
639                    continue;
640                }
641                if !filter.allows(posting.tags[i]) {
642                    continue;
643                }
644                best.put(posting.ids[i], at);
645            }
646        }
647        best.sorted()
648    }
649
650    /// Whether there is a split or a merge waiting.
651    #[must_use]
652    pub fn needs_maintenance(&self) -> bool {
653        self.job().is_some()
654    }
655
656    /// Do bounded maintenance, and say how many vectors it looked at.
657    ///
658    /// `budget` is in vectors touched rather than in time, because time is not
659    /// something a storage engine gets to measure cheaply and a vector is the
660    /// unit all of this work is actually made of. Call it from a maintenance
661    /// slice until it returns less than the budget, which means there was
662    /// nothing left to do.
663    pub fn maintain(&mut self, vectors: &impl Vectors, budget: usize) -> usize {
664        let mut done = 0;
665        while done < budget {
666            let Some(job) = self.job() else { break };
667            done += match job {
668                Job::Split(p) => self.split(p, vectors),
669                Job::Merge(p) => self.merge(p, vectors),
670            };
671        }
672        done
673    }
674
675    /// The next thing worth doing, biggest problem first.
676    fn job(&self) -> Option<Job> {
677        let big = (0..self.postings.len())
678            .filter(|&p| self.postings[p].len() > self.postings[p].stuck)
679            .max_by_key(|&p| self.postings[p].len());
680        if let Some(big) = big
681            && self.postings[big].len() > self.tuning.posting * 2
682        {
683            return Some(Job::Split(big));
684        }
685        if self.postings.len() > 1 {
686            let small = (0..self.postings.len()).min_by_key(|&p| self.postings[p].len())?;
687            if self.postings[small].len() * 4 < self.tuning.posting {
688                return Some(Job::Merge(small));
689            }
690        }
691        None
692    }
693
694    /// Cut a partition in two by two means over its own members, then sweep the
695    /// neighbours for anything that should have come along.
696    fn split(&mut self, p: usize, vectors: &impl Vectors) -> usize {
697        let (members, xs) = self.take(p, vectors);
698        let dim = self.dim();
699        if members.len() < 2 {
700            for (i, m) in members.iter().enumerate() {
701                self.place(p, m.id, m.tag, &xs[i * dim..(i + 1) * dim]);
702            }
703            return members.len();
704        }
705        let (a, b) = two_means(&xs, dim);
706        let sides: Vec<bool> = (0..members.len())
707            .map(|i| {
708                sqdist(&xs[i * dim..(i + 1) * dim], &a) <= sqdist(&xs[i * dim..(i + 1) * dim], &b)
709            })
710            .collect();
711        // A thousand copies of the same vector is one point as far as two means
712        // is concerned, and there is no cut that divides it. Put them back, and
713        // do not come back until the posting has doubled, so that a collection
714        // that really is all one vector costs a re-encode of it a logarithmic
715        // number of times rather than once per insert.
716        if sides.iter().all(|&s| s) || sides.iter().all(|&s| !s) {
717            for (i, m) in members.iter().enumerate() {
718                self.place(p, m.id, m.tag, &xs[i * dim..(i + 1) * dim]);
719            }
720            self.postings[p].stuck = members.len() * 2;
721            return members.len();
722        }
723        self.centroids[p * dim..(p + 1) * dim].copy_from_slice(&a);
724        self.coarse.moved(p, &a, dim);
725        let q = self.add_partition(&b);
726        for (i, m) in members.iter().enumerate() {
727            let to = if sides[i] { p } else { q };
728            self.place(to, m.id, m.tag, &xs[i * dim..(i + 1) * dim]);
729        }
730        members.len() + self.sweep(&[p, q], vectors)
731    }
732
733    /// Hand a partition's members to whoever is nearest now, and drop it.
734    fn merge(&mut self, p: usize, vectors: &impl Vectors) -> usize {
735        let (members, xs) = self.take(p, vectors);
736        let dim = self.dim();
737        self.drop_partition(p);
738        for (i, m) in members.iter().enumerate() {
739            let x = &xs[i * dim..(i + 1) * dim];
740            let to = self.nearest(x);
741            self.place(to, m.id, m.tag, x);
742        }
743        members.len()
744    }
745
746    /// LIRE: after the centroids move, anything nearby that is now filed under
747    /// the wrong one gets moved.
748    ///
749    /// Only the partitions near the ones that just changed are looked at,
750    /// because those are the only ones whose members can have a new nearest
751    /// centroid, and looking at all of them would be the rebuild this index
752    /// exists to avoid.
753    ///
754    /// # Why a member is only measured against what changed
755    ///
756    /// Every member is already filed under the centroid it was nearest to, and a
757    /// split moves one centroid and adds one. Nothing else moved, so for a member
758    /// of some other partition the nearest of all the centroids that did not
759    /// change is still the one it is already under, and the only way it can have
760    /// a new answer is if one of the two new centroids beats that. That is a
761    /// comparison against two, not a search over all of them.
762    ///
763    /// This is not a shortcut, it is what LIRE says, and getting it wrong is
764    /// expensive in a way that is easy to miss. A sweep after a split walks about
765    /// four partitions' worth of members, and a split happens every posting's
766    /// worth of inserts, so a full centroid scan per member works out at several
767    /// scans of every centroid in the collection per vector inserted. That is the
768    /// whole ingest cost at any size worth talking about: measured on 128
769    /// dimensional vectors it was 74 thousand a second at twelve thousand vectors
770    /// and 13 thousand at two hundred thousand, with maintenance three quarters
771    /// of it, and `examples/ingest.rs` is the harness that says so.
772    fn sweep(&mut self, changed: &[usize], vectors: &impl Vectors) -> usize {
773        let dim = self.dim();
774        let mut look: Vec<usize> = Vec::new();
775        for &p in changed {
776            let centre = self.centroid(p).to_vec();
777            for q in self.near_partitions(&centre, self.tuning.sweep) {
778                if !changed.contains(&q) && !look.contains(&q) {
779                    look.push(q);
780                }
781            }
782        }
783        // Copied out because placing a member borrows the index, and safe to
784        // copy because nothing below here moves a centroid: `place` appends a
785        // code to a posting and leaves the centroids alone.
786        let fresh: Vec<(usize, Vec<f32>)> = changed
787            .iter()
788            .map(|&p| (p, self.centroid(p).to_vec()))
789            .collect();
790        let mut seen = 0;
791        let mut buf = vec![0.0f32; dim];
792        for p in look {
793            let here = self.centroid(p).to_vec();
794            // Backwards, because taking a member out moves the last one into
795            // its slot and a backwards walk never steps over the one that moved.
796            for i in (0..self.postings[p].len()).rev() {
797                seen += 1;
798                let id = self.postings[p].ids[i];
799                let tag = self.postings[p].tags[i];
800                if !vectors.get(id, &mut buf) {
801                    self.pull_and_forget(p, i);
802                    continue;
803                }
804                let x = self.quant.rotate(&buf);
805                let mut best = (p, sqdist(&x, &here));
806                for (q, centre) in &fresh {
807                    let d = sqdist(&x, centre);
808                    if d < best.1 {
809                        best = (*q, d);
810                    }
811                }
812                if best.0 != p {
813                    self.pull_and_forget(p, i);
814                    self.place(best.0, id, tag, &x);
815                }
816            }
817        }
818        seen
819    }
820
821    /// Empty a partition out, handing back its members and their rotated
822    /// vectors. Ids the source has forgotten are dropped.
823    fn take(&mut self, p: usize, vectors: &impl Vectors) -> (Vec<Member>, Vec<f32>) {
824        let dim = self.dim();
825        let ids = std::mem::take(&mut self.postings[p].ids);
826        let tags = std::mem::take(&mut self.postings[p].tags);
827        self.postings[p].codes.clear();
828        self.postings[p].meta.clear();
829        let mut kept = Vec::with_capacity(ids.len());
830        let mut xs = Vec::with_capacity(ids.len() * dim);
831        let mut buf = vec![0.0f32; dim];
832        for (id, tag) in ids.into_iter().zip(tags) {
833            self.at.remove(&id);
834            if vectors.get(id, &mut buf) {
835                xs.extend_from_slice(&self.quant.rotate(&buf));
836                kept.push(Member { id, tag });
837            }
838        }
839        (kept, xs)
840    }
841
842    /// The `n` partitions whose centroids are nearest `x`, nearest first.
843    fn near_partitions(&self, x: &[f32], n: usize) -> Vec<usize> {
844        let mut by: Vec<(usize, f32)> = (0..self.postings.len())
845            .map(|p| (p, sqdist(x, self.centroid(p))))
846            .collect();
847        let n = n.min(by.len());
848        by.select_nth_unstable_by(n.saturating_sub(1), |a, b| a.1.total_cmp(&b.1));
849        by.truncate(n);
850        by.sort_by(|a, b| a.1.total_cmp(&b.1));
851        by.into_iter().map(|(p, _)| p).collect()
852    }
853
854    /// The partition `x` belongs to, as far as the coarse layer can tell.
855    fn roughly_nearest(&self, x: &[f32], short: &mut Vec<u32>) -> usize {
856        if !self.coarse.ready() {
857            return self.nearest(x);
858        }
859        self.coarse.shortlist(x, self.dim(), short);
860        short
861            .iter()
862            .map(|&p| (p as usize, sqdist(x, self.centroid(p as usize))))
863            .min_by(|a, b| a.1.total_cmp(&b.1))
864            .map_or(0, |(p, _)| p)
865    }
866
867    /// The partition `x` belongs to.
868    fn nearest(&self, x: &[f32]) -> usize {
869        (0..self.postings.len())
870            .map(|p| (p, sqdist(x, self.centroid(p))))
871            .min_by(|a, b| a.1.total_cmp(&b.1))
872            .map_or(0, |(p, _)| p)
873    }
874
875    fn centroid(&self, p: usize) -> &[f32] {
876        let dim = self.dim();
877        &self.centroids[p * dim..(p + 1) * dim]
878    }
879
880    /// A new empty partition around `centroid`, which is already rotated.
881    fn add_partition(&mut self, centroid: &[f32]) -> usize {
882        let dim = self.quant.dim();
883        self.centroids.extend_from_slice(centroid);
884        self.postings.push(Posting::default());
885        let p = self.postings.len() - 1;
886        self.coarse.added(p, centroid, dim);
887        self.refresh_coarse();
888        p
889    }
890
891    /// Rebuild the coarse layer if the partition count has moved far enough
892    /// since the anchors were last chosen.
893    fn refresh_coarse(&mut self) {
894        let n = self.postings.len();
895        if self.coarse.stale(n) {
896            let dim = self.quant.dim();
897            self.coarse.rebuild(&self.centroids, dim, n);
898        }
899    }
900
901    /// Drop an empty partition, moving the last one into its place.
902    fn drop_partition(&mut self, p: usize) {
903        debug_assert_eq!(self.postings[p].len(), 0, "a partition is emptied first");
904        let dim = self.dim();
905        let last = self.postings.len() - 1;
906        self.coarse.dropped(p);
907        self.postings.swap_remove(p);
908        for i in 0..dim {
909            self.centroids[p * dim + i] = self.centroids[last * dim + i];
910        }
911        self.centroids.truncate(last * dim);
912        if p != last {
913            // The partition that used to be last is at `p` now, so everything
914            // filed under it has to be told.
915            for &id in &self.postings[p].ids {
916                if let Some(slot) = self.at.get_mut(&id) {
917                    slot.partition = p as u32;
918                }
919            }
920        }
921        self.refresh_coarse();
922    }
923
924    /// Append a member to a partition. `x` is rotated.
925    fn place(&mut self, p: usize, id: u64, tag: u64, x: &[f32]) {
926        let dim = self.dim();
927        let width = self.quant.code_bytes();
928        let slot = self.postings[p].len();
929        self.postings[p].codes.resize((slot + 1) * width, 0);
930        let centroid = &self.centroids[p * dim..(p + 1) * dim];
931        let coded = self.quant.encode_rotated(
932            x,
933            centroid,
934            &mut self.postings[p].codes[slot * width..(slot + 1) * width],
935        );
936        self.postings[p].ids.push(id);
937        self.postings[p].tags.push(tag);
938        self.postings[p].meta.push(coded);
939        self.at.insert(
940            id,
941            Slot {
942                partition: p as u32,
943                slot: slot as u32,
944            },
945        );
946    }
947
948    /// Take slot `s` out of partition `p`, returning the id that moved into it.
949    fn pull(&mut self, p: usize, s: usize) -> Option<u64> {
950        let width = self.quant.code_bytes();
951        let posting = &mut self.postings[p];
952        let last = posting.len() - 1;
953        posting.ids.swap_remove(s);
954        posting.tags.swap_remove(s);
955        posting.meta.swap_remove(s);
956        if s != last {
957            let (head, tail) = posting.codes.split_at_mut(last * width);
958            head[s * width..(s + 1) * width].copy_from_slice(&tail[..width]);
959        }
960        posting.codes.truncate(last * width);
961        (s != last).then(|| posting.ids[s])
962    }
963
964    /// The same, keeping the map straight, for the paths that are about to put
965    /// the member somewhere else.
966    fn pull_and_forget(&mut self, p: usize, s: usize) {
967        let id = self.postings[p].ids[s];
968        self.at.remove(&id);
969        if let Some(moved) = self.pull(p, s) {
970            self.at.insert(
971                moved,
972                Slot {
973                    partition: p as u32,
974                    slot: s as u32,
975                },
976            );
977        }
978    }
979}
980
981/// A member on its way from one partition to another, which is the only time
982/// its id and its tag travel together without a posting around them.
983#[derive(Clone, Copy)]
984struct Member {
985    id: u64,
986    tag: u64,
987}
988
989enum Job {
990    Split(usize),
991    Merge(usize),
992}
993
994/// Two means over a set of vectors laid out end to end.
995///
996/// The seeds are the member furthest from the middle and then the member
997/// furthest from that one, which is deterministic, needs no generator, and
998/// starts on the axis the cloud is actually longest along. Eight rounds is
999/// past where this stops moving on anything shaped like an embedding.
1000fn two_means(xs: &[f32], dim: usize) -> (Vec<f32>, Vec<f32>) {
1001    let n = xs.len() / dim;
1002    let mut middle = vec![0.0f32; dim];
1003    for i in 0..n {
1004        for (m, c) in middle.iter_mut().zip(&xs[i * dim..(i + 1) * dim]) {
1005            *m += c;
1006        }
1007    }
1008    for m in &mut middle {
1009        *m /= n as f32;
1010    }
1011    let far = |from: &[f32]| {
1012        (0..n)
1013            .max_by(|&i, &j| {
1014                sqdist(from, &xs[i * dim..(i + 1) * dim])
1015                    .total_cmp(&sqdist(from, &xs[j * dim..(j + 1) * dim]))
1016            })
1017            .unwrap_or(0)
1018    };
1019    let i = far(&middle);
1020    let mut a = xs[i * dim..(i + 1) * dim].to_vec();
1021    let j = far(&a);
1022    let mut b = xs[j * dim..(j + 1) * dim].to_vec();
1023
1024    for _ in 0..8 {
1025        let mut sums = (vec![0.0f32; dim], vec![0.0f32; dim]);
1026        let mut counts = (0usize, 0usize);
1027        for i in 0..n {
1028            let x = &xs[i * dim..(i + 1) * dim];
1029            if sqdist(x, &a) <= sqdist(x, &b) {
1030                for (s, c) in sums.0.iter_mut().zip(x) {
1031                    *s += c;
1032                }
1033                counts.0 += 1;
1034            } else {
1035                for (s, c) in sums.1.iter_mut().zip(x) {
1036                    *s += c;
1037                }
1038                counts.1 += 1;
1039            }
1040        }
1041        // A side that ended up with nothing keeps the seed it had, because a
1042        // mean of no points is not a place and the next round would put every
1043        // member on the other side for ever.
1044        if counts.0 > 0 {
1045            for (m, s) in a.iter_mut().zip(&sums.0) {
1046                *m = s / counts.0 as f32;
1047            }
1048        }
1049        if counts.1 > 0 {
1050            for (m, s) in b.iter_mut().zip(&sums.1) {
1051                *m = s / counts.1 as f32;
1052            }
1053        }
1054    }
1055    (a, b)
1056}
1057
1058/// The squared distance, because the square root is monotone and nothing here
1059/// needs it.
1060///
1061/// Written with eight running totals rather than one, and that is not a
1062/// flourish. Adding floats is not associative, so a compiler is not allowed to
1063/// turn a single accumulator into a vector of them, and the obvious one line
1064/// version is a chain of dependent adds four cycles apart. It is the hottest
1065/// loop in the file, because every insert measures a query against every
1066/// centroid and every search does it twice over, and writing it this way was
1067/// worth two thirds of what an insert cost at 768 dimensions.
1068///
1069/// The eight totals are summed in a fixed order at the end, so the answer is
1070/// deterministic. It is a different answer from the one line version, by the
1071/// last bit or so, in the same way that any two orderings of a float sum are.
1072fn sqdist(a: &[f32], b: &[f32]) -> f32 {
1073    let mut totals = [0.0f32; 8];
1074    let mut i = 0;
1075    while i + 8 <= a.len() {
1076        for (k, total) in totals.iter_mut().enumerate() {
1077            let d = a[i + k] - b[i + k];
1078            *total += d * d;
1079        }
1080        i += 8;
1081    }
1082    let mut sum = 0.0f32;
1083    for total in totals {
1084        sum += total;
1085    }
1086    while i < a.len() {
1087        let d = a[i] - b[i];
1088        sum += d * d;
1089        i += 1;
1090    }
1091    sum
1092}
1093
1094/// One candidate, ordered by its estimated distance.
1095///
1096/// The tie break on the id is not decoration. Two members of the same partition
1097/// can get the same estimate out of codes that are 16 bytes wide, and without a
1098/// tie break which of them survives depends on the order the heap happened to
1099/// be in, which makes a search answer depend on the insertion history of the
1100/// collection rather than on the collection.
1101#[derive(PartialEq)]
1102struct Ranked {
1103    at: f32,
1104    id: u64,
1105}
1106
1107impl Eq for Ranked {}
1108
1109impl Ord for Ranked {
1110    fn cmp(&self, other: &Ranked) -> std::cmp::Ordering {
1111        self.at.total_cmp(&other.at).then(self.id.cmp(&other.id))
1112    }
1113}
1114
1115impl PartialOrd for Ranked {
1116    fn partial_cmp(&self, other: &Ranked) -> Option<std::cmp::Ordering> {
1117        Some(self.cmp(other))
1118    }
1119}
1120
1121/// The best `want` candidates seen so far, and nothing else.
1122///
1123/// The scan used to push every member of every partition it read into one
1124/// vector and then select from it, which at probe 64 is 24 thousand entries
1125/// pushed and 24 thousand selected over to keep 160. That is 384 kilobytes of
1126/// writes per search and it was a tenth of the search's time.
1127///
1128/// A bounded heap makes the common case one comparison. Once `want` candidates
1129/// are in, a member is only touched further if it beats the worst of them,
1130/// which after the first partition or two is a small fraction of them, and a
1131/// member that loses never has its id or its tag read at all.
1132struct Bounded {
1133    want: usize,
1134    heap: std::collections::BinaryHeap<Ranked>,
1135}
1136
1137impl Bounded {
1138    fn new(want: usize) -> Bounded {
1139        Bounded {
1140            want,
1141            heap: std::collections::BinaryHeap::with_capacity(want + 1),
1142        }
1143    }
1144
1145    /// Whether there are already `want` answers, which is what says a search
1146    /// that was widening for a filter can stop widening.
1147    fn full(&self) -> bool {
1148        self.heap.len() >= self.want
1149    }
1150
1151    /// Whether `at` could still be one of the answers.
1152    #[inline]
1153    fn wants(&self, at: f32) -> bool {
1154        match self.heap.peek() {
1155            Some(worst) if self.heap.len() >= self.want => at < worst.at,
1156            _ => true,
1157        }
1158    }
1159
1160    fn put(&mut self, id: u64, at: f32) {
1161        if self.heap.len() >= self.want {
1162            self.heap.pop();
1163        }
1164        self.heap.push(Ranked { at, id });
1165    }
1166
1167    /// The answers, nearest first.
1168    fn sorted(self) -> Vec<(u64, f32)> {
1169        self.heap
1170            .into_sorted_vec()
1171            .into_iter()
1172            .map(|r| (r.id, r.at))
1173            .collect()
1174    }
1175}
1176
1177#[cfg(test)]
1178mod tests {
1179    use super::*;
1180    use yo_common::Rng;
1181
1182    /// The record log, for a test: every vector by id, where the id is where it
1183    /// sits.
1184    struct Store(Vec<Vec<f32>>);
1185
1186    impl Vectors for Store {
1187        fn get(&self, id: u64, into: &mut [f32]) -> bool {
1188            match self.0.get(id as usize) {
1189                Some(v) => {
1190                    into.copy_from_slice(v);
1191                    true
1192                }
1193                None => false,
1194            }
1195        }
1196    }
1197
1198    /// A store with a hole in it, for the case where the log forgot something
1199    /// the index still thinks it has.
1200    struct Holey(Vec<Vec<f32>>, u64);
1201
1202    impl Vectors for Holey {
1203        fn get(&self, id: u64, into: &mut [f32]) -> bool {
1204            if id == self.1 {
1205                return false;
1206            }
1207            match self.0.get(id as usize) {
1208                Some(v) => {
1209                    into.copy_from_slice(v);
1210                    true
1211                }
1212                None => false,
1213            }
1214        }
1215    }
1216
1217    /// Vectors with the two things real embeddings have and uniform noise does
1218    /// not: a few coordinates carrying most of the energy, so that two vectors
1219    /// are genuinely near each other rather than all being equally far apart,
1220    /// and clusters, so that the partitioning has something to get right.
1221    ///
1222    /// A corpus without the first of those is not a hard test, it is an
1223    /// impossible one. The nearest ten of three hundred uniform points are
1224    /// arbitrary, no quantiser can pick them out, and the recall it measures
1225    /// says nothing about the index.
1226    fn corpus(dim: usize, n: usize, clusters: usize, seed: u64) -> Store {
1227        let mut rng = Rng::new(seed);
1228        let centres: Vec<Vec<f32>> = (0..clusters).map(|_| draw(dim, &mut rng)).collect();
1229        Store(
1230            (0..n)
1231                .map(|i| {
1232                    let off = draw(dim, &mut rng);
1233                    let mut v: Vec<f32> = centres[i % clusters]
1234                        .iter()
1235                        .zip(&off)
1236                        .map(|(c, o)| c + o * 0.7)
1237                        .collect();
1238                    unit(&mut v);
1239                    v
1240                })
1241                .collect(),
1242        )
1243    }
1244
1245    /// One vector of the shape above, unit length.
1246    fn draw(dim: usize, rng: &mut Rng) -> Vec<f32> {
1247        let mut v: Vec<f32> = (0..dim)
1248            .map(|i| {
1249                let u = (rng.next_u64() >> 40) as f32 / (1u32 << 24) as f32;
1250                let heavy = if i < dim / 16 { 6.0 } else { 1.0 };
1251                (u * 2.0 - 1.0) * heavy
1252            })
1253            .collect();
1254        unit(&mut v);
1255        v
1256    }
1257
1258    fn unit(v: &mut [f32]) {
1259        let len = v.iter().map(|c| c * c).sum::<f32>().sqrt();
1260        for c in v {
1261            *c /= len;
1262        }
1263    }
1264
1265    fn truth(store: &Store, q: &[f32], k: usize) -> Vec<u64> {
1266        let mut all: Vec<(u64, f32)> = store
1267            .0
1268            .iter()
1269            .enumerate()
1270            .map(|(i, v)| (i as u64, sqdist(q, v)))
1271            .collect();
1272        all.sort_by(|a, b| a.1.total_cmp(&b.1));
1273        all.truncate(k);
1274        all.into_iter().map(|(i, _)| i).collect()
1275    }
1276
1277    /// Build an index over a whole store, running maintenance as it goes the way
1278    /// a maintenance slice would.
1279    fn build(store: &Store, dim: usize, tuning: Tuning) -> Partitions {
1280        let mut ix = Partitions::new(dim, Bits::One, 7, tuning);
1281        for (i, v) in store.0.iter().enumerate() {
1282            ix.insert(i as u64, v);
1283            if i % 64 == 0 {
1284                ix.maintain(store, 4096);
1285            }
1286        }
1287        ix.maintain(store, 1 << 20);
1288        ix
1289    }
1290
1291    /// How often the true `k` nearest come back.
1292    fn recall(ix: &Partitions, store: &Store, k: usize, queries: usize) -> f32 {
1293        let mut hits = 0usize;
1294        for i in 0..queries {
1295            // A query near a real vector rather than anywhere at all, because
1296            // that is what a search looks like.
1297            let q = &store.0[i * 7 % store.0.len()];
1298            let want = truth(store, q, k);
1299            let got: Vec<u64> = ix.search(q, k, store).into_iter().map(|h| h.id).collect();
1300            hits += want.iter().filter(|id| got.contains(id)).count();
1301        }
1302        hits as f32 / (queries * k) as f32
1303    }
1304
1305    /// Everything the index believes about itself, checked.
1306    fn consistent(ix: &Partitions) {
1307        assert_eq!(ix.centroids.len(), ix.postings.len() * ix.dim());
1308        let width = ix.quant.code_bytes();
1309        let mut seen = 0usize;
1310        for (p, posting) in ix.postings.iter().enumerate() {
1311            assert_eq!(posting.codes.len(), posting.len() * width, "partition {p}");
1312            assert_eq!(posting.meta.len(), posting.len(), "partition {p}");
1313            assert_eq!(posting.tags.len(), posting.len(), "partition {p}");
1314            for (s, id) in posting.ids.iter().enumerate() {
1315                let at = ix.at.get(id).expect("every member is in the map");
1316                assert_eq!(at.partition as usize, p, "id {id}");
1317                assert_eq!(at.slot as usize, s, "id {id}");
1318                seen += 1;
1319            }
1320        }
1321        assert_eq!(seen, ix.at.len(), "the map has entries with no member");
1322    }
1323
1324    /// Build an index where every vector carries a tag, so the filter has
1325    /// something to meet.
1326    fn build_tagged(
1327        store: &Store,
1328        dim: usize,
1329        tuning: Tuning,
1330        tag: impl Fn(u64) -> u64,
1331    ) -> Partitions {
1332        let mut ix = Partitions::new(dim, Bits::One, 7, tuning);
1333        for (i, v) in store.0.iter().enumerate() {
1334            ix.insert_tagged(i as u64, v, tag(i as u64));
1335            if i % 64 == 0 {
1336                ix.maintain(store, 4096);
1337            }
1338        }
1339        ix.maintain(store, 1 << 20);
1340        ix
1341    }
1342
1343    /// The whole point of pushing a filter into the scan, measured against the
1344    /// thing it replaces.
1345    ///
1346    /// One document in fifty carries the tag. Filtering inside the scan finds
1347    /// the true ten of those. Taking the best forty by vector and then throwing
1348    /// away the ones that do not match, which is what a search that cannot push
1349    /// a filter down has to do, finds almost none of them, and the ones it
1350    /// misses were nearer than the ones it kept.
1351    #[test]
1352    fn a_filter_in_the_scan_finds_what_a_filter_after_it_cannot() {
1353        let dim = 96;
1354        let store = corpus(dim, 3000, 12, 47);
1355        let tuning = Tuning {
1356            posting: 64,
1357            ..Tuning::default()
1358        };
1359        let wanted = |id: u64| id.is_multiple_of(50);
1360        let ix = build_tagged(&store, dim, tuning, |id| u64::from(wanted(id)));
1361
1362        let (mut pushed, mut after) = (0usize, 0usize);
1363        let k = 10;
1364        for i in 0..40 {
1365            let q = &store.0[i * 71 % store.0.len()];
1366
1367            // What the answer is: brute force over the members that match.
1368            let mut all: Vec<(u64, f32)> = store
1369                .0
1370                .iter()
1371                .enumerate()
1372                .filter(|(id, _)| wanted(*id as u64))
1373                .map(|(id, v)| (id as u64, sqdist(q, v)))
1374                .collect();
1375            all.sort_by(|a, b| a.1.total_cmp(&b.1));
1376            let want: Vec<u64> = all[..k].iter().map(|(id, _)| *id).collect();
1377
1378            let got: Vec<u64> = ix
1379                .search_where(q, k, &|tag: u64| tag == 1, &store)
1380                .into_iter()
1381                .map(|h| h.id)
1382                .collect();
1383            pushed += want.iter().filter(|id| got.contains(id)).count();
1384
1385            let late: Vec<u64> = ix
1386                .search(q, k * tuning.rerank, &store)
1387                .into_iter()
1388                .map(|h| h.id)
1389                .filter(|id| wanted(*id))
1390                .take(k)
1391                .collect();
1392            after += want.iter().filter(|id| late.contains(id)).count();
1393        }
1394        let (pushed, after) = (pushed as f32 / 400.0, after as f32 / 400.0);
1395        assert!(pushed >= 0.95, "pushing the filter down gave {pushed}");
1396        assert!(
1397            after < pushed / 2.0,
1398            "filtering afterwards gave {after} against {pushed}, which is not the point being made"
1399        );
1400    }
1401
1402    #[test]
1403    fn a_filter_that_matches_nothing_answers_nothing() {
1404        let dim = 64;
1405        let store = corpus(dim, 500, 4, 53);
1406        let ix = build_tagged(&store, dim, Tuning::default(), |_| 1);
1407        assert!(
1408            ix.search_where(&store.0[0], 10, &|tag: u64| tag == 2, &store)
1409                .is_empty()
1410        );
1411        // And the same filter matching everything is the unfiltered answer.
1412        let all = ix.search_where(&store.0[0], 10, &Any, &store);
1413        assert_eq!(all, ix.search(&store.0[0], 10, &store));
1414    }
1415
1416    #[test]
1417    fn a_tag_survives_a_split_and_a_merge() {
1418        let dim = 64;
1419        let store = corpus(dim, 800, 6, 59);
1420        let tuning = Tuning {
1421            posting: 24,
1422            ..Tuning::default()
1423        };
1424        let mut ix = build_tagged(&store, dim, tuning, |id| id * 7 + 1);
1425        assert!(ix.partitions() > 4, "it never split");
1426        for id in 0..800u64 {
1427            assert_eq!(ix.tag(id), Some(id * 7 + 1), "id {id} after the splits");
1428        }
1429
1430        // Now shrink it until partitions merge, and the survivors keep theirs.
1431        for id in 0..760u64 {
1432            ix.remove(id);
1433        }
1434        ix.maintain(&store, 1 << 20);
1435        consistent(&ix);
1436        for id in 760..800u64 {
1437            assert_eq!(ix.tag(id), Some(id * 7 + 1), "id {id} after the merges");
1438        }
1439        assert_eq!(ix.tag(0), None);
1440    }
1441
1442    /// A selective filter means the answers are not in the nearest partitions,
1443    /// and a search that will not look further returns fewer than it should.
1444    #[test]
1445    fn a_selective_filter_makes_the_search_look_further() {
1446        let dim = 64;
1447        let store = corpus(dim, 2000, 10, 61);
1448        let tuning = Tuning {
1449            posting: 32,
1450            ..Tuning::default()
1451        };
1452        let tag = |id: u64| u64::from(id.is_multiple_of(100));
1453        let ix = build_tagged(&store, dim, tuning, tag);
1454        let narrow = build_tagged(&store, dim, Tuning { widen: 1, ..tuning }, tag);
1455
1456        let mut wide_found = 0usize;
1457        let mut narrow_found = 0usize;
1458        for i in 0..20 {
1459            let q = &store.0[i * 91 % store.0.len()];
1460            wide_found += ix.search_where(q, 10, &|t: u64| t == 1, &store).len();
1461            narrow_found += narrow.search_where(q, 10, &|t: u64| t == 1, &store).len();
1462        }
1463        assert_eq!(
1464            wide_found, 200,
1465            "one in a hundred of two thousand is twenty"
1466        );
1467        assert!(
1468            narrow_found < wide_found,
1469            "not widening found {narrow_found} of {wide_found}"
1470        );
1471    }
1472
1473    #[test]
1474    fn a_signature_never_rejects_something_it_should_have_matched() {
1475        let english = Signature::of(&[("lang", b"en")]);
1476        let doc = Signature::of(&[("lang", b"en"), ("topic", b"finance"), ("year", b"2026")]);
1477        assert!(doc.covers(english));
1478        assert!(english.allows(doc.bits()));
1479        assert_eq!(Signature::from_bits(doc.bits()), doc);
1480
1481        // And over a lot of values, nothing that matches is ever turned away.
1482        for i in 0..500u32 {
1483            let value = i.to_string();
1484            let one = Signature::of(&[("id", value.as_bytes())]);
1485            let with = Signature::of(&[("id", value.as_bytes()), ("kind", b"page")]);
1486            assert!(with.covers(one), "value {value}");
1487        }
1488    }
1489
1490    #[test]
1491    fn an_empty_index_answers_nothing() {
1492        let ix = Partitions::new(32, Bits::One, 1, Tuning::default());
1493        let store = Store(Vec::new());
1494        assert!(ix.is_empty());
1495        assert_eq!(ix.partitions(), 0);
1496        assert!(ix.search(&[0.0; 32], 10, &store).is_empty());
1497        assert!(!ix.needs_maintenance());
1498    }
1499
1500    #[test]
1501    fn the_first_vector_is_the_first_partition() {
1502        let store = corpus(32, 1, 1, 3);
1503        let mut ix = Partitions::new(32, Bits::One, 1, Tuning::default());
1504        ix.insert(0, &store.0[0]);
1505        assert_eq!(ix.partitions(), 1);
1506        assert_eq!(ix.len(), 1);
1507        let hits = ix.search(&store.0[0], 5, &store);
1508        assert_eq!(hits.len(), 1);
1509        assert_eq!(hits[0].id, 0);
1510        assert!(hits[0].distance < 1e-6, "{}", hits[0].distance);
1511        consistent(&ix);
1512    }
1513
1514    #[test]
1515    fn a_search_finds_what_brute_force_finds() {
1516        let dim = 128;
1517        let store = corpus(dim, 2000, 12, 5);
1518        let ix = build(&store, dim, Tuning::default());
1519        assert!(ix.partitions() > 1, "it never split");
1520        consistent(&ix);
1521        let r = recall(&ix, &store, 10, 50);
1522        assert!(r >= 0.95, "recall at 10 was {r}");
1523    }
1524
1525    #[test]
1526    fn a_posting_that_grows_too_big_splits() {
1527        let dim = 64;
1528        let tuning = Tuning {
1529            posting: 32,
1530            ..Tuning::default()
1531        };
1532        let store = corpus(dim, 600, 6, 9);
1533        let ix = build(&store, dim, tuning);
1534        assert!(
1535            ix.partitions() >= 600 / (32 * 2),
1536            "600 vectors in {} partitions",
1537            ix.partitions()
1538        );
1539        for posting in &ix.postings {
1540            assert!(
1541                posting.len() <= 32 * 2,
1542                "a posting is {} long",
1543                posting.len()
1544            );
1545        }
1546        consistent(&ix);
1547    }
1548
1549    #[test]
1550    fn a_posting_that_shrinks_merges() {
1551        let dim = 64;
1552        let tuning = Tuning {
1553            posting: 32,
1554            ..Tuning::default()
1555        };
1556        let store = corpus(dim, 600, 6, 9);
1557        let mut ix = build(&store, dim, tuning);
1558        let grown = ix.partitions();
1559        assert!(grown > 4);
1560
1561        // Take away almost everything and let maintenance settle.
1562        for id in 0..570u64 {
1563            assert!(ix.remove(id));
1564        }
1565        ix.maintain(&store, 1 << 20);
1566        consistent(&ix);
1567        assert_eq!(ix.len(), 30);
1568        assert!(
1569            ix.partitions() < grown,
1570            "{} partitions for 30 vectors, was {grown}",
1571            ix.partitions()
1572        );
1573        // And it still answers.
1574        let hits = ix.search(&store.0[599], 1, &store);
1575        assert_eq!(hits[0].id, 599);
1576    }
1577
1578    #[test]
1579    fn a_removed_vector_stops_coming_back() {
1580        let dim = 64;
1581        let store = corpus(dim, 400, 4, 11);
1582        let mut ix = build(&store, dim, Tuning::default());
1583        let q = store.0[7].clone();
1584        assert_eq!(ix.search(&q, 1, &store)[0].id, 7);
1585
1586        assert!(ix.remove(7));
1587        assert!(!ix.remove(7), "removing it twice should say so");
1588        assert!(!ix.contains(7));
1589        assert_eq!(ix.len(), 399);
1590        consistent(&ix);
1591        assert!(ix.search(&q, 5, &store).iter().all(|h| h.id != 7));
1592    }
1593
1594    #[test]
1595    fn inserting_the_same_id_twice_replaces_it() {
1596        let dim = 64;
1597        let store = corpus(dim, 200, 2, 13);
1598        let mut ix = build(&store, dim, Tuning::default());
1599        let before = ix.len();
1600        ix.insert(3, &store.0[3]);
1601        assert_eq!(ix.len(), before);
1602        consistent(&ix);
1603        assert_eq!(ix.search(&store.0[3], 1, &store)[0].id, 3);
1604    }
1605
1606    /// A collection of copies of one vector has no cut in it, and maintenance
1607    /// has to notice that rather than try the same split for ever.
1608    ///
1609    /// This is not a corner case anybody has to go looking for. It is what a
1610    /// collection looks like when a pipeline embeds the same document a thousand
1611    /// times, and getting it wrong is a hang rather than a wrong answer.
1612    #[test]
1613    fn a_thousand_copies_of_one_vector_do_not_spin() {
1614        let dim = 32;
1615        let one = corpus(dim, 1, 1, 41).0.pop().expect("one vector");
1616        let store = Store(vec![one; 1000]);
1617        let tuning = Tuning {
1618            posting: 16,
1619            ..Tuning::default()
1620        };
1621        let mut ix = Partitions::new(dim, Bits::One, 7, tuning);
1622        for (i, v) in store.0.iter().enumerate() {
1623            ix.insert(i as u64, v);
1624            ix.maintain(&store, 4096);
1625        }
1626        ix.maintain(&store, 1 << 20);
1627        consistent(&ix);
1628        assert_eq!(ix.len(), 1000);
1629        assert!(!ix.needs_maintenance(), "it still thinks there is work");
1630        // And it still answers, with the exact distance rather than an estimate.
1631        let hits = ix.search(&store.0[0], 5, &store);
1632        assert_eq!(hits.len(), 5);
1633        assert!(hits.iter().all(|h| h.distance < 1e-6));
1634    }
1635
1636    /// G13's actual claim. Recall is measured at the end of a long stream of
1637    /// writes and deletes rather than on a fresh build, because a fresh build is
1638    /// the measurement that hides drift.
1639    #[test]
1640    fn recall_holds_over_a_write_stream_with_no_rebuild() {
1641        let dim = 96;
1642        let store = corpus(dim, 3000, 15, 17);
1643        let tuning = Tuning {
1644            posting: 64,
1645            ..Tuning::default()
1646        };
1647        let mut ix = Partitions::new(dim, Bits::One, 7, tuning);
1648
1649        // Write everything, and churn a tenth of it as we go, which is what
1650        // moves the centroids around under the members that are already filed.
1651        let mut rng = Rng::new(23);
1652        for (i, v) in store.0.iter().enumerate() {
1653            ix.insert(i as u64, v);
1654            if i > 100 && i % 10 == 0 {
1655                let victim = rng.below(i) as u64;
1656                ix.remove(victim);
1657                ix.insert(victim, &store.0[victim as usize]);
1658            }
1659            ix.maintain(&store, 512);
1660        }
1661        ix.maintain(&store, 1 << 20);
1662        consistent(&ix);
1663        assert_eq!(ix.len(), store.0.len());
1664
1665        let r = recall(&ix, &store, 10, 60);
1666        assert!(r >= 0.95, "recall at 10 after the stream was {r}");
1667    }
1668
1669    /// What the sweep is for, measured as the thing it actually fixes rather
1670    /// than through recall.
1671    ///
1672    /// Drift is members filed under a partition that is no longer their nearest,
1673    /// which is what a split leaves behind in the partitions around it. It shows
1674    /// up in recall eventually, but recall is a blunt instrument here and moves
1675    /// by a percent for reasons that have nothing to do with this, so the
1676    /// straight count is the honest measurement.
1677    #[test]
1678    fn the_sweep_is_what_keeps_members_under_their_nearest_centroid() {
1679        let dim = 96;
1680        let store = corpus(dim, 2000, 10, 29);
1681        let tuning = Tuning {
1682            posting: 48,
1683            ..Tuning::default()
1684        };
1685        let with = misfiled(&build(&store, dim, tuning), &store);
1686        let without = misfiled(&build(&store, dim, Tuning { sweep: 0, ..tuning }), &store);
1687        assert!(
1688            with * 4 < without,
1689            "sweeping left {with} members drifted and not sweeping left {without}"
1690        );
1691    }
1692
1693    /// How many members are filed under something that is not their nearest
1694    /// centroid.
1695    fn misfiled(ix: &Partitions, store: &Store) -> usize {
1696        let mut buf = vec![0.0f32; ix.dim()];
1697        let mut wrong = 0;
1698        for (p, posting) in ix.postings.iter().enumerate() {
1699            for &id in &posting.ids {
1700                assert!(store.get(id, &mut buf));
1701                if ix.nearest(&ix.quant.rotate(&buf)) != p {
1702                    wrong += 1;
1703                }
1704            }
1705        }
1706        wrong
1707    }
1708
1709    #[test]
1710    fn a_vector_the_log_forgot_is_dropped_rather_than_returned() {
1711        let dim = 64;
1712        let store = corpus(dim, 400, 4, 31);
1713        let tuning = Tuning {
1714            posting: 24,
1715            ..Tuning::default()
1716        };
1717        let mut ix = build(&store, dim, tuning);
1718        assert!(ix.contains(11));
1719
1720        // The log loses one without telling the index, which is the state a
1721        // crash between two appends leaves behind.
1722        let holey = Holey(store.0.clone(), 11);
1723        assert!(
1724            ix.search(&store.0[11], 5, &holey)
1725                .iter()
1726                .all(|h| h.id != 11)
1727        );
1728
1729        // And maintenance walking over it takes it out for good.
1730        for id in 0..300u64 {
1731            ix.remove(id);
1732        }
1733        ix.maintain(&holey, 1 << 20);
1734        consistent(&ix);
1735        assert!(!ix.contains(11));
1736    }
1737
1738    #[test]
1739    fn rotating_first_is_the_same_as_rotating_inside() {
1740        // The whole index rests on the rotation being linear, so this is the
1741        // property, not an implementation detail.
1742        let dim = 128;
1743        let q = Quantizer::new(dim, Bits::One, 5);
1744        let store = corpus(dim, 2, 1, 37);
1745        let (v, c) = (&store.0[0], &store.0[1]);
1746
1747        let mut a = vec![0u8; q.code_bytes()];
1748        let one = q.encode(v, c, &mut a);
1749        let mut b = vec![0u8; q.code_bytes()];
1750        let two = q.encode_rotated(&q.rotate(v), &q.rotate(c), &mut b);
1751
1752        assert_eq!(a, b, "the two ways round should write the same code");
1753        assert!((one.norm - two.norm).abs() < 1e-4);
1754        assert!((one.scale - two.scale).abs() < 1e-4);
1755    }
1756
1757    #[test]
1758    fn two_means_splits_two_clouds_apart() {
1759        let dim = 8;
1760        let mut xs = Vec::new();
1761        for i in 0..40 {
1762            let far = if i % 2 == 0 { 0.0 } else { 10.0 };
1763            for d in 0..dim {
1764                xs.push(far + (i as f32 + d as f32) * 0.01);
1765            }
1766        }
1767        let (a, b) = two_means(&xs, dim);
1768        let (near, away) = if a[0] < b[0] { (a, b) } else { (b, a) };
1769        assert!(near[0] < 1.0, "{near:?}");
1770        assert!(away[0] > 9.0, "{away:?}");
1771    }
1772}