use std::collections::HashMap;
use crate::coarse::Coarse;
use crate::rabitq::{Bits, Coded, Quantizer};
pub trait Vectors {
fn get(&self, id: u64, into: &mut [f32]) -> bool;
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Tuning {
pub posting: usize,
pub probe: usize,
pub rerank: usize,
pub sweep: usize,
pub widen: usize,
}
impl Default for Tuning {
fn default() -> Tuning {
Tuning {
posting: 256,
probe: 8,
rerank: 4,
sweep: 4,
widen: 8,
}
}
}
const FLOOR: usize = 32;
pub trait Filter {
fn allows(&self, tag: u64) -> bool;
}
#[derive(Debug, Clone, Copy, Default)]
pub struct Any;
impl Filter for Any {
fn allows(&self, _tag: u64) -> bool {
true
}
}
impl<F: Fn(u64) -> bool> Filter for F {
fn allows(&self, tag: u64) -> bool {
self(tag)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct Signature(u64);
impl Signature {
#[must_use]
pub fn of(values: &[(&str, &[u8])]) -> Signature {
let mut bits = 0u64;
for (attribute, value) in values {
bits |= 1u64 << (hash(attribute.as_bytes(), value) % 64);
}
Signature(bits)
}
#[must_use]
pub fn bits(self) -> u64 {
self.0
}
#[must_use]
pub fn from_bits(bits: u64) -> Signature {
Signature(bits)
}
#[must_use]
pub fn covers(self, want: Signature) -> bool {
self.0 & want.0 == want.0
}
}
impl Filter for Signature {
fn allows(&self, tag: u64) -> bool {
Signature(tag).covers(*self)
}
}
fn hash(attribute: &[u8], value: &[u8]) -> u64 {
let mut h = 0xcbf2_9ce4_8422_2325u64;
for byte in attribute.iter().chain(b":").chain(value) {
h ^= u64::from(*byte);
h = h.wrapping_mul(0x1000_0000_01b3);
}
h
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Hit {
pub id: u64,
pub distance: f32,
}
#[derive(Debug, Clone, Copy)]
struct Slot {
partition: u32,
slot: u32,
}
#[derive(Default)]
struct Posting {
ids: Vec<u64>,
tags: Vec<u64>,
codes: Vec<u8>,
meta: Vec<Coded>,
stuck: usize,
}
impl Posting {
fn len(&self) -> usize {
self.ids.len()
}
}
pub struct Partitions {
quant: Quantizer,
tuning: Tuning,
centroids: Vec<f32>,
postings: Vec<Posting>,
at: HashMap<u64, Slot>,
coarse: Coarse,
scratch: Vec<u32>,
}
impl Partitions {
#[must_use]
pub fn new(dim: usize, bits: Bits, seed: u64, tuning: Tuning) -> Partitions {
Partitions {
quant: Quantizer::new(dim, bits, seed),
tuning,
centroids: Vec::new(),
postings: Vec::new(),
at: HashMap::new(),
coarse: Coarse::default(),
scratch: Vec::new(),
}
}
#[must_use]
pub fn dim(&self) -> usize {
self.quant.dim()
}
#[must_use]
pub fn len(&self) -> usize {
self.at.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.at.is_empty()
}
#[must_use]
pub fn partitions(&self) -> usize {
self.postings.len()
}
#[must_use]
pub fn tuning(&self) -> Tuning {
self.tuning
}
pub fn retune(&mut self, tuning: Tuning) {
self.tuning = tuning;
}
#[must_use]
pub fn quantizer(&self) -> &Quantizer {
&self.quant
}
#[must_use]
pub fn code_bytes(&self) -> usize {
self.postings.iter().map(|p| p.codes.len()).sum()
}
pub fn insert(&mut self, id: u64, v: &[f32]) {
self.insert_tagged(id, v, 0);
}
pub fn insert_tagged(&mut self, id: u64, v: &[f32], tag: u64) {
assert_eq!(
v.len(),
self.dim(),
"this collection holds {} dimensional vectors and was handed {}",
self.dim(),
v.len()
);
self.remove(id);
let x = self.quant.rotate(v);
let p = if self.postings.is_empty() {
self.add_partition(&x)
} else {
let mut short = core::mem::take(&mut self.scratch);
let p = self.roughly_nearest(&x, &mut short);
self.scratch = short;
p
};
self.place(p, id, tag, &x);
}
#[must_use]
pub fn tag(&self, id: u64) -> Option<u64> {
let at = self.at.get(&id)?;
Some(self.postings[at.partition as usize].tags[at.slot as usize])
}
pub fn remove(&mut self, id: u64) -> bool {
let Some(Slot { partition, slot }) = self.at.remove(&id) else {
return false;
};
let moved = self.pull(partition as usize, slot as usize);
if let Some(other) = moved {
self.at.insert(other, Slot { partition, slot });
}
true
}
#[must_use]
pub fn contains(&self, id: u64) -> bool {
self.at.contains_key(&id)
}
#[must_use]
pub fn search(&self, q: &[f32], k: usize, vectors: &impl Vectors) -> Vec<Hit> {
self.search_where(q, k, &Any, vectors)
}
#[must_use]
pub fn search_where(
&self,
q: &[f32],
k: usize,
filter: &impl Filter,
vectors: &impl Vectors,
) -> Vec<Hit> {
if k == 0 {
return Vec::new();
}
let candidates = self.candidates_where(q, (k * self.tuning.rerank).max(FLOOR), filter);
let mut buf = vec![0.0f32; self.dim()];
let mut hits = Vec::with_capacity(candidates.len());
for (id, _) in candidates {
if vectors.get(id, &mut buf) {
hits.push(Hit {
id,
distance: sqdist(q, &buf),
});
}
}
hits.sort_by(|a, b| a.distance.total_cmp(&b.distance));
hits.truncate(k);
hits
}
#[must_use]
pub fn candidates(&self, q: &[f32], want: usize) -> Vec<(u64, f32)> {
self.candidates_where(q, want, &Any)
}
#[must_use]
pub fn candidates_where(
&self,
q: &[f32],
want: usize,
filter: &impl Filter,
) -> Vec<(u64, f32)> {
assert_eq!(
q.len(),
self.dim(),
"this collection holds {} dimensional vectors and was handed {}",
self.dim(),
q.len()
);
if want == 0 || self.postings.is_empty() {
return Vec::new();
}
let u = self.quant.rotate(q);
let mut best = Bounded::new(want);
let mut scores: Vec<f32> = Vec::new();
let reach = self.tuning.probe.saturating_mul(self.tuning.widen.max(1));
for (n, p) in self.near_partitions(&u, reach).into_iter().enumerate() {
if n >= self.tuning.probe && best.full() {
break;
}
let prepared = self.quant.query_rotated(&u, self.centroid(p));
let posting = &self.postings[p];
let held = posting.ids.len();
if scores.len() < held {
scores.resize(held, 0.0);
}
prepared.scan(&posting.codes, &posting.meta, &mut scores[..held]);
for (i, &at) in scores[..held].iter().enumerate() {
if !best.wants(at) {
continue;
}
if !filter.allows(posting.tags[i]) {
continue;
}
best.put(posting.ids[i], at);
}
}
best.sorted()
}
#[must_use]
pub fn needs_maintenance(&self) -> bool {
self.job().is_some()
}
pub fn maintain(&mut self, vectors: &impl Vectors, budget: usize) -> usize {
let mut done = 0;
while done < budget {
let Some(job) = self.job() else { break };
done += match job {
Job::Split(p) => self.split(p, vectors),
Job::Merge(p) => self.merge(p, vectors),
};
}
done
}
fn job(&self) -> Option<Job> {
let big = (0..self.postings.len())
.filter(|&p| self.postings[p].len() > self.postings[p].stuck)
.max_by_key(|&p| self.postings[p].len());
if let Some(big) = big
&& self.postings[big].len() > self.tuning.posting * 2
{
return Some(Job::Split(big));
}
if self.postings.len() > 1 {
let small = (0..self.postings.len()).min_by_key(|&p| self.postings[p].len())?;
if self.postings[small].len() * 4 < self.tuning.posting {
return Some(Job::Merge(small));
}
}
None
}
fn split(&mut self, p: usize, vectors: &impl Vectors) -> usize {
let (members, xs) = self.take(p, vectors);
let dim = self.dim();
if members.len() < 2 {
for (i, m) in members.iter().enumerate() {
self.place(p, m.id, m.tag, &xs[i * dim..(i + 1) * dim]);
}
return members.len();
}
let (a, b) = two_means(&xs, dim);
let sides: Vec<bool> = (0..members.len())
.map(|i| {
sqdist(&xs[i * dim..(i + 1) * dim], &a) <= sqdist(&xs[i * dim..(i + 1) * dim], &b)
})
.collect();
if sides.iter().all(|&s| s) || sides.iter().all(|&s| !s) {
for (i, m) in members.iter().enumerate() {
self.place(p, m.id, m.tag, &xs[i * dim..(i + 1) * dim]);
}
self.postings[p].stuck = members.len() * 2;
return members.len();
}
self.centroids[p * dim..(p + 1) * dim].copy_from_slice(&a);
self.coarse.moved(p, &a, dim);
let q = self.add_partition(&b);
for (i, m) in members.iter().enumerate() {
let to = if sides[i] { p } else { q };
self.place(to, m.id, m.tag, &xs[i * dim..(i + 1) * dim]);
}
members.len() + self.sweep(&[p, q], vectors)
}
fn merge(&mut self, p: usize, vectors: &impl Vectors) -> usize {
let (members, xs) = self.take(p, vectors);
let dim = self.dim();
self.drop_partition(p);
for (i, m) in members.iter().enumerate() {
let x = &xs[i * dim..(i + 1) * dim];
let to = self.nearest(x);
self.place(to, m.id, m.tag, x);
}
members.len()
}
fn sweep(&mut self, changed: &[usize], vectors: &impl Vectors) -> usize {
let dim = self.dim();
let mut look: Vec<usize> = Vec::new();
for &p in changed {
let centre = self.centroid(p).to_vec();
for q in self.near_partitions(¢re, self.tuning.sweep) {
if !changed.contains(&q) && !look.contains(&q) {
look.push(q);
}
}
}
let fresh: Vec<(usize, Vec<f32>)> = changed
.iter()
.map(|&p| (p, self.centroid(p).to_vec()))
.collect();
let mut seen = 0;
let mut buf = vec![0.0f32; dim];
for p in look {
let here = self.centroid(p).to_vec();
for i in (0..self.postings[p].len()).rev() {
seen += 1;
let id = self.postings[p].ids[i];
let tag = self.postings[p].tags[i];
if !vectors.get(id, &mut buf) {
self.pull_and_forget(p, i);
continue;
}
let x = self.quant.rotate(&buf);
let mut best = (p, sqdist(&x, &here));
for (q, centre) in &fresh {
let d = sqdist(&x, centre);
if d < best.1 {
best = (*q, d);
}
}
if best.0 != p {
self.pull_and_forget(p, i);
self.place(best.0, id, tag, &x);
}
}
}
seen
}
fn take(&mut self, p: usize, vectors: &impl Vectors) -> (Vec<Member>, Vec<f32>) {
let dim = self.dim();
let ids = std::mem::take(&mut self.postings[p].ids);
let tags = std::mem::take(&mut self.postings[p].tags);
self.postings[p].codes.clear();
self.postings[p].meta.clear();
let mut kept = Vec::with_capacity(ids.len());
let mut xs = Vec::with_capacity(ids.len() * dim);
let mut buf = vec![0.0f32; dim];
for (id, tag) in ids.into_iter().zip(tags) {
self.at.remove(&id);
if vectors.get(id, &mut buf) {
xs.extend_from_slice(&self.quant.rotate(&buf));
kept.push(Member { id, tag });
}
}
(kept, xs)
}
fn near_partitions(&self, x: &[f32], n: usize) -> Vec<usize> {
let mut by: Vec<(usize, f32)> = (0..self.postings.len())
.map(|p| (p, sqdist(x, self.centroid(p))))
.collect();
let n = n.min(by.len());
by.select_nth_unstable_by(n.saturating_sub(1), |a, b| a.1.total_cmp(&b.1));
by.truncate(n);
by.sort_by(|a, b| a.1.total_cmp(&b.1));
by.into_iter().map(|(p, _)| p).collect()
}
fn roughly_nearest(&self, x: &[f32], short: &mut Vec<u32>) -> usize {
if !self.coarse.ready() {
return self.nearest(x);
}
self.coarse.shortlist(x, self.dim(), short);
short
.iter()
.map(|&p| (p as usize, sqdist(x, self.centroid(p as usize))))
.min_by(|a, b| a.1.total_cmp(&b.1))
.map_or(0, |(p, _)| p)
}
fn nearest(&self, x: &[f32]) -> usize {
(0..self.postings.len())
.map(|p| (p, sqdist(x, self.centroid(p))))
.min_by(|a, b| a.1.total_cmp(&b.1))
.map_or(0, |(p, _)| p)
}
fn centroid(&self, p: usize) -> &[f32] {
let dim = self.dim();
&self.centroids[p * dim..(p + 1) * dim]
}
fn add_partition(&mut self, centroid: &[f32]) -> usize {
let dim = self.quant.dim();
self.centroids.extend_from_slice(centroid);
self.postings.push(Posting::default());
let p = self.postings.len() - 1;
self.coarse.added(p, centroid, dim);
self.refresh_coarse();
p
}
fn refresh_coarse(&mut self) {
let n = self.postings.len();
if self.coarse.stale(n) {
let dim = self.quant.dim();
self.coarse.rebuild(&self.centroids, dim, n);
}
}
fn drop_partition(&mut self, p: usize) {
debug_assert_eq!(self.postings[p].len(), 0, "a partition is emptied first");
let dim = self.dim();
let last = self.postings.len() - 1;
self.coarse.dropped(p);
self.postings.swap_remove(p);
for i in 0..dim {
self.centroids[p * dim + i] = self.centroids[last * dim + i];
}
self.centroids.truncate(last * dim);
if p != last {
for &id in &self.postings[p].ids {
if let Some(slot) = self.at.get_mut(&id) {
slot.partition = p as u32;
}
}
}
self.refresh_coarse();
}
fn place(&mut self, p: usize, id: u64, tag: u64, x: &[f32]) {
let dim = self.dim();
let width = self.quant.code_bytes();
let slot = self.postings[p].len();
self.postings[p].codes.resize((slot + 1) * width, 0);
let centroid = &self.centroids[p * dim..(p + 1) * dim];
let coded = self.quant.encode_rotated(
x,
centroid,
&mut self.postings[p].codes[slot * width..(slot + 1) * width],
);
self.postings[p].ids.push(id);
self.postings[p].tags.push(tag);
self.postings[p].meta.push(coded);
self.at.insert(
id,
Slot {
partition: p as u32,
slot: slot as u32,
},
);
}
fn pull(&mut self, p: usize, s: usize) -> Option<u64> {
let width = self.quant.code_bytes();
let posting = &mut self.postings[p];
let last = posting.len() - 1;
posting.ids.swap_remove(s);
posting.tags.swap_remove(s);
posting.meta.swap_remove(s);
if s != last {
let (head, tail) = posting.codes.split_at_mut(last * width);
head[s * width..(s + 1) * width].copy_from_slice(&tail[..width]);
}
posting.codes.truncate(last * width);
(s != last).then(|| posting.ids[s])
}
fn pull_and_forget(&mut self, p: usize, s: usize) {
let id = self.postings[p].ids[s];
self.at.remove(&id);
if let Some(moved) = self.pull(p, s) {
self.at.insert(
moved,
Slot {
partition: p as u32,
slot: s as u32,
},
);
}
}
}
#[derive(Clone, Copy)]
struct Member {
id: u64,
tag: u64,
}
enum Job {
Split(usize),
Merge(usize),
}
fn two_means(xs: &[f32], dim: usize) -> (Vec<f32>, Vec<f32>) {
let n = xs.len() / dim;
let mut middle = vec![0.0f32; dim];
for i in 0..n {
for (m, c) in middle.iter_mut().zip(&xs[i * dim..(i + 1) * dim]) {
*m += c;
}
}
for m in &mut middle {
*m /= n as f32;
}
let far = |from: &[f32]| {
(0..n)
.max_by(|&i, &j| {
sqdist(from, &xs[i * dim..(i + 1) * dim])
.total_cmp(&sqdist(from, &xs[j * dim..(j + 1) * dim]))
})
.unwrap_or(0)
};
let i = far(&middle);
let mut a = xs[i * dim..(i + 1) * dim].to_vec();
let j = far(&a);
let mut b = xs[j * dim..(j + 1) * dim].to_vec();
for _ in 0..8 {
let mut sums = (vec![0.0f32; dim], vec![0.0f32; dim]);
let mut counts = (0usize, 0usize);
for i in 0..n {
let x = &xs[i * dim..(i + 1) * dim];
if sqdist(x, &a) <= sqdist(x, &b) {
for (s, c) in sums.0.iter_mut().zip(x) {
*s += c;
}
counts.0 += 1;
} else {
for (s, c) in sums.1.iter_mut().zip(x) {
*s += c;
}
counts.1 += 1;
}
}
if counts.0 > 0 {
for (m, s) in a.iter_mut().zip(&sums.0) {
*m = s / counts.0 as f32;
}
}
if counts.1 > 0 {
for (m, s) in b.iter_mut().zip(&sums.1) {
*m = s / counts.1 as f32;
}
}
}
(a, b)
}
fn sqdist(a: &[f32], b: &[f32]) -> f32 {
let mut totals = [0.0f32; 8];
let mut i = 0;
while i + 8 <= a.len() {
for (k, total) in totals.iter_mut().enumerate() {
let d = a[i + k] - b[i + k];
*total += d * d;
}
i += 8;
}
let mut sum = 0.0f32;
for total in totals {
sum += total;
}
while i < a.len() {
let d = a[i] - b[i];
sum += d * d;
i += 1;
}
sum
}
#[derive(PartialEq)]
struct Ranked {
at: f32,
id: u64,
}
impl Eq for Ranked {}
impl Ord for Ranked {
fn cmp(&self, other: &Ranked) -> std::cmp::Ordering {
self.at.total_cmp(&other.at).then(self.id.cmp(&other.id))
}
}
impl PartialOrd for Ranked {
fn partial_cmp(&self, other: &Ranked) -> Option<std::cmp::Ordering> {
Some(self.cmp(other))
}
}
struct Bounded {
want: usize,
heap: std::collections::BinaryHeap<Ranked>,
}
impl Bounded {
fn new(want: usize) -> Bounded {
Bounded {
want,
heap: std::collections::BinaryHeap::with_capacity(want + 1),
}
}
fn full(&self) -> bool {
self.heap.len() >= self.want
}
#[inline]
fn wants(&self, at: f32) -> bool {
match self.heap.peek() {
Some(worst) if self.heap.len() >= self.want => at < worst.at,
_ => true,
}
}
fn put(&mut self, id: u64, at: f32) {
if self.heap.len() >= self.want {
self.heap.pop();
}
self.heap.push(Ranked { at, id });
}
fn sorted(self) -> Vec<(u64, f32)> {
self.heap
.into_sorted_vec()
.into_iter()
.map(|r| (r.id, r.at))
.collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
use yo_common::Rng;
struct Store(Vec<Vec<f32>>);
impl Vectors for Store {
fn get(&self, id: u64, into: &mut [f32]) -> bool {
match self.0.get(id as usize) {
Some(v) => {
into.copy_from_slice(v);
true
}
None => false,
}
}
}
struct Holey(Vec<Vec<f32>>, u64);
impl Vectors for Holey {
fn get(&self, id: u64, into: &mut [f32]) -> bool {
if id == self.1 {
return false;
}
match self.0.get(id as usize) {
Some(v) => {
into.copy_from_slice(v);
true
}
None => false,
}
}
}
fn corpus(dim: usize, n: usize, clusters: usize, seed: u64) -> Store {
let mut rng = Rng::new(seed);
let centres: Vec<Vec<f32>> = (0..clusters).map(|_| draw(dim, &mut rng)).collect();
Store(
(0..n)
.map(|i| {
let off = draw(dim, &mut rng);
let mut v: Vec<f32> = centres[i % clusters]
.iter()
.zip(&off)
.map(|(c, o)| c + o * 0.7)
.collect();
unit(&mut v);
v
})
.collect(),
)
}
fn draw(dim: usize, rng: &mut Rng) -> Vec<f32> {
let mut v: Vec<f32> = (0..dim)
.map(|i| {
let u = (rng.next_u64() >> 40) as f32 / (1u32 << 24) as f32;
let heavy = if i < dim / 16 { 6.0 } else { 1.0 };
(u * 2.0 - 1.0) * heavy
})
.collect();
unit(&mut v);
v
}
fn unit(v: &mut [f32]) {
let len = v.iter().map(|c| c * c).sum::<f32>().sqrt();
for c in v {
*c /= len;
}
}
fn truth(store: &Store, q: &[f32], k: usize) -> Vec<u64> {
let mut all: Vec<(u64, f32)> = store
.0
.iter()
.enumerate()
.map(|(i, v)| (i as u64, sqdist(q, v)))
.collect();
all.sort_by(|a, b| a.1.total_cmp(&b.1));
all.truncate(k);
all.into_iter().map(|(i, _)| i).collect()
}
fn build(store: &Store, dim: usize, tuning: Tuning) -> Partitions {
let mut ix = Partitions::new(dim, Bits::One, 7, tuning);
for (i, v) in store.0.iter().enumerate() {
ix.insert(i as u64, v);
if i % 64 == 0 {
ix.maintain(store, 4096);
}
}
ix.maintain(store, 1 << 20);
ix
}
fn recall(ix: &Partitions, store: &Store, k: usize, queries: usize) -> f32 {
let mut hits = 0usize;
for i in 0..queries {
let q = &store.0[i * 7 % store.0.len()];
let want = truth(store, q, k);
let got: Vec<u64> = ix.search(q, k, store).into_iter().map(|h| h.id).collect();
hits += want.iter().filter(|id| got.contains(id)).count();
}
hits as f32 / (queries * k) as f32
}
fn consistent(ix: &Partitions) {
assert_eq!(ix.centroids.len(), ix.postings.len() * ix.dim());
let width = ix.quant.code_bytes();
let mut seen = 0usize;
for (p, posting) in ix.postings.iter().enumerate() {
assert_eq!(posting.codes.len(), posting.len() * width, "partition {p}");
assert_eq!(posting.meta.len(), posting.len(), "partition {p}");
assert_eq!(posting.tags.len(), posting.len(), "partition {p}");
for (s, id) in posting.ids.iter().enumerate() {
let at = ix.at.get(id).expect("every member is in the map");
assert_eq!(at.partition as usize, p, "id {id}");
assert_eq!(at.slot as usize, s, "id {id}");
seen += 1;
}
}
assert_eq!(seen, ix.at.len(), "the map has entries with no member");
}
fn build_tagged(
store: &Store,
dim: usize,
tuning: Tuning,
tag: impl Fn(u64) -> u64,
) -> Partitions {
let mut ix = Partitions::new(dim, Bits::One, 7, tuning);
for (i, v) in store.0.iter().enumerate() {
ix.insert_tagged(i as u64, v, tag(i as u64));
if i % 64 == 0 {
ix.maintain(store, 4096);
}
}
ix.maintain(store, 1 << 20);
ix
}
#[test]
fn a_filter_in_the_scan_finds_what_a_filter_after_it_cannot() {
let dim = 96;
let store = corpus(dim, 3000, 12, 47);
let tuning = Tuning {
posting: 64,
..Tuning::default()
};
let wanted = |id: u64| id.is_multiple_of(50);
let ix = build_tagged(&store, dim, tuning, |id| u64::from(wanted(id)));
let (mut pushed, mut after) = (0usize, 0usize);
let k = 10;
for i in 0..40 {
let q = &store.0[i * 71 % store.0.len()];
let mut all: Vec<(u64, f32)> = store
.0
.iter()
.enumerate()
.filter(|(id, _)| wanted(*id as u64))
.map(|(id, v)| (id as u64, sqdist(q, v)))
.collect();
all.sort_by(|a, b| a.1.total_cmp(&b.1));
let want: Vec<u64> = all[..k].iter().map(|(id, _)| *id).collect();
let got: Vec<u64> = ix
.search_where(q, k, &|tag: u64| tag == 1, &store)
.into_iter()
.map(|h| h.id)
.collect();
pushed += want.iter().filter(|id| got.contains(id)).count();
let late: Vec<u64> = ix
.search(q, k * tuning.rerank, &store)
.into_iter()
.map(|h| h.id)
.filter(|id| wanted(*id))
.take(k)
.collect();
after += want.iter().filter(|id| late.contains(id)).count();
}
let (pushed, after) = (pushed as f32 / 400.0, after as f32 / 400.0);
assert!(pushed >= 0.95, "pushing the filter down gave {pushed}");
assert!(
after < pushed / 2.0,
"filtering afterwards gave {after} against {pushed}, which is not the point being made"
);
}
#[test]
fn a_filter_that_matches_nothing_answers_nothing() {
let dim = 64;
let store = corpus(dim, 500, 4, 53);
let ix = build_tagged(&store, dim, Tuning::default(), |_| 1);
assert!(
ix.search_where(&store.0[0], 10, &|tag: u64| tag == 2, &store)
.is_empty()
);
let all = ix.search_where(&store.0[0], 10, &Any, &store);
assert_eq!(all, ix.search(&store.0[0], 10, &store));
}
#[test]
fn a_tag_survives_a_split_and_a_merge() {
let dim = 64;
let store = corpus(dim, 800, 6, 59);
let tuning = Tuning {
posting: 24,
..Tuning::default()
};
let mut ix = build_tagged(&store, dim, tuning, |id| id * 7 + 1);
assert!(ix.partitions() > 4, "it never split");
for id in 0..800u64 {
assert_eq!(ix.tag(id), Some(id * 7 + 1), "id {id} after the splits");
}
for id in 0..760u64 {
ix.remove(id);
}
ix.maintain(&store, 1 << 20);
consistent(&ix);
for id in 760..800u64 {
assert_eq!(ix.tag(id), Some(id * 7 + 1), "id {id} after the merges");
}
assert_eq!(ix.tag(0), None);
}
#[test]
fn a_selective_filter_makes_the_search_look_further() {
let dim = 64;
let store = corpus(dim, 2000, 10, 61);
let tuning = Tuning {
posting: 32,
..Tuning::default()
};
let tag = |id: u64| u64::from(id.is_multiple_of(100));
let ix = build_tagged(&store, dim, tuning, tag);
let narrow = build_tagged(&store, dim, Tuning { widen: 1, ..tuning }, tag);
let mut wide_found = 0usize;
let mut narrow_found = 0usize;
for i in 0..20 {
let q = &store.0[i * 91 % store.0.len()];
wide_found += ix.search_where(q, 10, &|t: u64| t == 1, &store).len();
narrow_found += narrow.search_where(q, 10, &|t: u64| t == 1, &store).len();
}
assert_eq!(
wide_found, 200,
"one in a hundred of two thousand is twenty"
);
assert!(
narrow_found < wide_found,
"not widening found {narrow_found} of {wide_found}"
);
}
#[test]
fn a_signature_never_rejects_something_it_should_have_matched() {
let english = Signature::of(&[("lang", b"en")]);
let doc = Signature::of(&[("lang", b"en"), ("topic", b"finance"), ("year", b"2026")]);
assert!(doc.covers(english));
assert!(english.allows(doc.bits()));
assert_eq!(Signature::from_bits(doc.bits()), doc);
for i in 0..500u32 {
let value = i.to_string();
let one = Signature::of(&[("id", value.as_bytes())]);
let with = Signature::of(&[("id", value.as_bytes()), ("kind", b"page")]);
assert!(with.covers(one), "value {value}");
}
}
#[test]
fn an_empty_index_answers_nothing() {
let ix = Partitions::new(32, Bits::One, 1, Tuning::default());
let store = Store(Vec::new());
assert!(ix.is_empty());
assert_eq!(ix.partitions(), 0);
assert!(ix.search(&[0.0; 32], 10, &store).is_empty());
assert!(!ix.needs_maintenance());
}
#[test]
fn the_first_vector_is_the_first_partition() {
let store = corpus(32, 1, 1, 3);
let mut ix = Partitions::new(32, Bits::One, 1, Tuning::default());
ix.insert(0, &store.0[0]);
assert_eq!(ix.partitions(), 1);
assert_eq!(ix.len(), 1);
let hits = ix.search(&store.0[0], 5, &store);
assert_eq!(hits.len(), 1);
assert_eq!(hits[0].id, 0);
assert!(hits[0].distance < 1e-6, "{}", hits[0].distance);
consistent(&ix);
}
#[test]
fn a_search_finds_what_brute_force_finds() {
let dim = 128;
let store = corpus(dim, 2000, 12, 5);
let ix = build(&store, dim, Tuning::default());
assert!(ix.partitions() > 1, "it never split");
consistent(&ix);
let r = recall(&ix, &store, 10, 50);
assert!(r >= 0.95, "recall at 10 was {r}");
}
#[test]
fn a_posting_that_grows_too_big_splits() {
let dim = 64;
let tuning = Tuning {
posting: 32,
..Tuning::default()
};
let store = corpus(dim, 600, 6, 9);
let ix = build(&store, dim, tuning);
assert!(
ix.partitions() >= 600 / (32 * 2),
"600 vectors in {} partitions",
ix.partitions()
);
for posting in &ix.postings {
assert!(
posting.len() <= 32 * 2,
"a posting is {} long",
posting.len()
);
}
consistent(&ix);
}
#[test]
fn a_posting_that_shrinks_merges() {
let dim = 64;
let tuning = Tuning {
posting: 32,
..Tuning::default()
};
let store = corpus(dim, 600, 6, 9);
let mut ix = build(&store, dim, tuning);
let grown = ix.partitions();
assert!(grown > 4);
for id in 0..570u64 {
assert!(ix.remove(id));
}
ix.maintain(&store, 1 << 20);
consistent(&ix);
assert_eq!(ix.len(), 30);
assert!(
ix.partitions() < grown,
"{} partitions for 30 vectors, was {grown}",
ix.partitions()
);
let hits = ix.search(&store.0[599], 1, &store);
assert_eq!(hits[0].id, 599);
}
#[test]
fn a_removed_vector_stops_coming_back() {
let dim = 64;
let store = corpus(dim, 400, 4, 11);
let mut ix = build(&store, dim, Tuning::default());
let q = store.0[7].clone();
assert_eq!(ix.search(&q, 1, &store)[0].id, 7);
assert!(ix.remove(7));
assert!(!ix.remove(7), "removing it twice should say so");
assert!(!ix.contains(7));
assert_eq!(ix.len(), 399);
consistent(&ix);
assert!(ix.search(&q, 5, &store).iter().all(|h| h.id != 7));
}
#[test]
fn inserting_the_same_id_twice_replaces_it() {
let dim = 64;
let store = corpus(dim, 200, 2, 13);
let mut ix = build(&store, dim, Tuning::default());
let before = ix.len();
ix.insert(3, &store.0[3]);
assert_eq!(ix.len(), before);
consistent(&ix);
assert_eq!(ix.search(&store.0[3], 1, &store)[0].id, 3);
}
#[test]
fn a_thousand_copies_of_one_vector_do_not_spin() {
let dim = 32;
let one = corpus(dim, 1, 1, 41).0.pop().expect("one vector");
let store = Store(vec![one; 1000]);
let tuning = Tuning {
posting: 16,
..Tuning::default()
};
let mut ix = Partitions::new(dim, Bits::One, 7, tuning);
for (i, v) in store.0.iter().enumerate() {
ix.insert(i as u64, v);
ix.maintain(&store, 4096);
}
ix.maintain(&store, 1 << 20);
consistent(&ix);
assert_eq!(ix.len(), 1000);
assert!(!ix.needs_maintenance(), "it still thinks there is work");
let hits = ix.search(&store.0[0], 5, &store);
assert_eq!(hits.len(), 5);
assert!(hits.iter().all(|h| h.distance < 1e-6));
}
#[test]
fn recall_holds_over_a_write_stream_with_no_rebuild() {
let dim = 96;
let store = corpus(dim, 3000, 15, 17);
let tuning = Tuning {
posting: 64,
..Tuning::default()
};
let mut ix = Partitions::new(dim, Bits::One, 7, tuning);
let mut rng = Rng::new(23);
for (i, v) in store.0.iter().enumerate() {
ix.insert(i as u64, v);
if i > 100 && i % 10 == 0 {
let victim = rng.below(i) as u64;
ix.remove(victim);
ix.insert(victim, &store.0[victim as usize]);
}
ix.maintain(&store, 512);
}
ix.maintain(&store, 1 << 20);
consistent(&ix);
assert_eq!(ix.len(), store.0.len());
let r = recall(&ix, &store, 10, 60);
assert!(r >= 0.95, "recall at 10 after the stream was {r}");
}
#[test]
fn the_sweep_is_what_keeps_members_under_their_nearest_centroid() {
let dim = 96;
let store = corpus(dim, 2000, 10, 29);
let tuning = Tuning {
posting: 48,
..Tuning::default()
};
let with = misfiled(&build(&store, dim, tuning), &store);
let without = misfiled(&build(&store, dim, Tuning { sweep: 0, ..tuning }), &store);
assert!(
with * 4 < without,
"sweeping left {with} members drifted and not sweeping left {without}"
);
}
fn misfiled(ix: &Partitions, store: &Store) -> usize {
let mut buf = vec![0.0f32; ix.dim()];
let mut wrong = 0;
for (p, posting) in ix.postings.iter().enumerate() {
for &id in &posting.ids {
assert!(store.get(id, &mut buf));
if ix.nearest(&ix.quant.rotate(&buf)) != p {
wrong += 1;
}
}
}
wrong
}
#[test]
fn a_vector_the_log_forgot_is_dropped_rather_than_returned() {
let dim = 64;
let store = corpus(dim, 400, 4, 31);
let tuning = Tuning {
posting: 24,
..Tuning::default()
};
let mut ix = build(&store, dim, tuning);
assert!(ix.contains(11));
let holey = Holey(store.0.clone(), 11);
assert!(
ix.search(&store.0[11], 5, &holey)
.iter()
.all(|h| h.id != 11)
);
for id in 0..300u64 {
ix.remove(id);
}
ix.maintain(&holey, 1 << 20);
consistent(&ix);
assert!(!ix.contains(11));
}
#[test]
fn rotating_first_is_the_same_as_rotating_inside() {
let dim = 128;
let q = Quantizer::new(dim, Bits::One, 5);
let store = corpus(dim, 2, 1, 37);
let (v, c) = (&store.0[0], &store.0[1]);
let mut a = vec![0u8; q.code_bytes()];
let one = q.encode(v, c, &mut a);
let mut b = vec![0u8; q.code_bytes()];
let two = q.encode_rotated(&q.rotate(v), &q.rotate(c), &mut b);
assert_eq!(a, b, "the two ways round should write the same code");
assert!((one.norm - two.norm).abs() < 1e-4);
assert!((one.scale - two.scale).abs() < 1e-4);
}
#[test]
fn two_means_splits_two_clouds_apart() {
let dim = 8;
let mut xs = Vec::new();
for i in 0..40 {
let far = if i % 2 == 0 { 0.0 } else { 10.0 };
for d in 0..dim {
xs.push(far + (i as f32 + d as f32) * 0.01);
}
}
let (a, b) = two_means(&xs, dim);
let (near, away) = if a[0] < b[0] { (a, b) } else { (b, a) };
assert!(near[0] < 1.0, "{near:?}");
assert!(away[0] > 9.0, "{away:?}");
}
}