use super::persistence::{self, HnswVectorsData};
use super::{HnswIndex, SearchQuality};
use crate::distance::DistanceMetric;
use crate::index::VectorIndex;
use tempfile::tempdir;
fn sample_vectors(n: u64, dimension: usize, seed: u64) -> Vec<(u64, Vec<f32>)> {
let mut state = seed | 1;
let mut next = move || {
state = state
.wrapping_mul(6_364_136_223_846_793_005)
.wrapping_add(1);
#[allow(clippy::cast_precision_loss)] let bits = (state >> 40) as f32;
(bits + 1.0) / 16_777_216.0
};
(0..n)
.map(|id| (id, (0..dimension).map(|_| next()).collect()))
.collect()
}
fn live_graph_pairs(index: &HnswIndex) -> Vec<(usize, Vec<f32>)> {
let inner = index.inner.read();
inner.with_contiguous_vectors(|vectors| {
index
.mappings
.iter()
.filter_map(|(_id, idx)| vectors.get(idx).map(|v| (idx, v.to_vec())))
.collect()
})
}
fn assert_results_parity(
a: &[crate::scored_result::ScoredResult],
b: &[crate::scored_result::ScoredResult],
) {
assert_eq!(a.len(), b.len(), "result counts must match");
for (ra, rb) in a.iter().zip(b) {
assert_eq!(ra.id, rb.id, "result ids must match");
assert!(
(ra.score - rb.score).abs() < 1e-5,
"score drift for id {}: {} vs {}",
ra.id,
ra.score,
rb.score
);
}
}
#[test]
fn test_legacy_fixture_with_vectors_file_loads_with_parity() {
let dir = tempdir().unwrap();
let path = dir.path();
let dimension = 8;
let index = HnswIndex::new(dimension, DistanceMetric::Cosine).unwrap();
let data = sample_vectors(50, dimension, 42);
for (id, v) in &data {
index.insert(*id, v);
}
index.remove(7);
index.insert(9, &data[11].1);
index.save(path).unwrap();
let meta = persistence::load_meta(path).unwrap();
persistence::save_vectors(
path,
&HnswVectorsData {
vectors: live_graph_pairs(&index),
generation: meta.generation,
},
)
.unwrap();
assert!(path.join("native_vectors.bin").exists());
let loaded = HnswIndex::load(path, dimension, DistanceMetric::Cosine).unwrap();
assert_eq!(loaded.len(), index.len());
assert!(loaded.has_vector_storage());
let query = &data[3].1;
assert_results_parity(
&index.search_brute_force(query, 10).unwrap(),
&loaded.search_brute_force(query, 10).unwrap(),
);
assert_results_parity(
&index.search_with_rerank(query, 5, 20).unwrap(),
&loaded.search_with_rerank(query, 5, 20).unwrap(),
);
loaded.save(path).unwrap();
assert!(!path.join("native_vectors.bin").exists());
HnswIndex::load(path, dimension, DistanceMetric::Cosine).expect("reload after cleanup");
}
#[test]
fn test_legacy_vectors_file_generation_mismatch_is_invalid_data() {
let dir = tempdir().unwrap();
let path = dir.path();
let dimension = 4;
let index = HnswIndex::new(dimension, DistanceMetric::Euclidean).unwrap();
index.insert(1, &[1.0, 0.0, 0.0, 0.0]);
index.insert(2, &[0.0, 1.0, 0.0, 0.0]);
index.save(path).unwrap();
let meta = persistence::load_meta(path).unwrap();
persistence::save_vectors(
path,
&HnswVectorsData {
vectors: live_graph_pairs(&index),
generation: meta.generation + 1, },
)
.unwrap();
let Err(err) = HnswIndex::load(path, dimension, DistanceMetric::Euclidean) else {
panic!("generation mismatch must fail the load")
};
assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
assert!(
err.to_string().contains("vectors generation"),
"error should mention the vectors generation, got: {err}"
);
}
#[test]
fn test_new_snapshot_reload_full_functionality() {
let dir = tempdir().unwrap();
let path = dir.path();
let dimension = 8;
let index = HnswIndex::new(dimension, DistanceMetric::Cosine).unwrap();
let data = sample_vectors(60, dimension, 7);
for (id, v) in &data {
index.insert(*id, v);
}
index.save(path).unwrap();
assert!(!path.join("native_vectors.bin").exists());
let meta = persistence::load_meta(path).unwrap();
let mappings = persistence::load_mappings(path).unwrap();
let graph_gen = persistence::load_graph_generation(path).unwrap();
assert_eq!(meta.generation, mappings.generation);
assert_eq!(meta.generation, graph_gen);
let loaded = HnswIndex::load(path, dimension, DistanceMetric::Cosine).unwrap();
assert!(loaded.has_vector_storage());
let query = &data[5].1;
let brute = loaded.search_brute_force(query, 5).unwrap();
assert_eq!(brute[0].id, 5, "brute-force must find the exact vector");
let reranked = loaded.search_with_rerank(query, 5, 20).unwrap();
assert_eq!(reranked[0].id, 5, "rerank must find the exact vector");
assert_eq!(loaded.vacuum(), Ok(60), "vacuum must work after reload");
let post_vacuum = loaded.search_brute_force(query, 5).unwrap();
assert_eq!(post_vacuum[0].id, 5, "vacuumed index must stay searchable");
}
#[test]
fn test_cosine_brute_force_score_parity_across_roundtrip() {
let dir = tempdir().unwrap();
let dimension = 16;
let index = HnswIndex::new(dimension, DistanceMetric::Cosine).unwrap();
let data = sample_vectors(40, dimension, 1234);
for (id, v) in &data {
index.insert(*id, v);
}
let query = &data[20].1;
let before = index.brute_force_search_parallel(query, 40).unwrap();
index.save(dir.path()).unwrap();
let loaded = HnswIndex::load(dir.path(), dimension, DistanceMetric::Cosine).unwrap();
let after = loaded.brute_force_search_parallel(query, 40).unwrap();
assert_results_parity(&before, &after);
assert_eq!(after[0].id, 20);
assert!((after[0].score - 1.0).abs() < 1e-5);
}
#[test]
fn test_brute_force_hides_deleted_tombstones() {
let dimension = 4;
let index = HnswIndex::new(dimension, DistanceMetric::Cosine).unwrap();
let data = sample_vectors(10, dimension, 99);
for (id, v) in &data {
index.insert(*id, v);
}
assert!(index.remove(3));
let results = index.brute_force_search_parallel(&data[3].1, 10).unwrap();
assert_eq!(results.len(), 9);
assert!(
!results.iter().any(|r| r.id == 3),
"deleted id must not appear in brute-force results"
);
}
#[test]
fn test_brute_force_upsert_does_not_return_old_slot() {
let dimension = 4;
let index = HnswIndex::new(dimension, DistanceMetric::Cosine).unwrap();
let old = [1.0_f32, 0.0, 0.0, 0.0];
let new = [0.0_f32, 1.0, 0.0, 0.0];
index.insert(1, &old);
index.insert(2, &[0.0, 0.0, 1.0, 0.0]);
index.insert(1, &new);
let results = index.brute_force_search_parallel(&old, 10).unwrap();
let id1: Vec<_> = results.iter().filter(|r| r.id == 1).collect();
assert_eq!(id1.len(), 1, "id 1 must appear exactly once");
assert!(
id1[0].score.abs() < 1e-5,
"id 1 must score as its new (orthogonal) vector, got {}",
id1[0].score
);
}
#[test]
fn test_vacuum_cosine_preserves_recall() {
let dimension = 16;
let index = HnswIndex::new(dimension, DistanceMetric::Cosine).unwrap();
let data = sample_vectors(200, dimension, 2024);
for (id, v) in &data {
index.insert(*id, v);
}
for id in 0..50u64 {
index.remove(id);
}
let vacuumed = index.vacuum().expect("vacuum must succeed");
assert_eq!(vacuumed, 150);
assert_eq!(index.len(), 150);
for (id, v) in data.iter().filter(|(id, _)| *id >= 50).step_by(10) {
let brute = index.search_brute_force(v, 1).unwrap();
assert_eq!(brute[0].id, *id, "brute-force self-recall lost for {id}");
assert!((brute[0].score - 1.0).abs() < 1e-5);
let hnsw = index
.search_with_quality(v, 1, SearchQuality::Accurate)
.unwrap();
assert_eq!(hnsw[0].id, *id, "HNSW self-recall lost for {id}");
}
let results = index.brute_force_search_parallel(&data[10].1, 200).unwrap();
assert!(!results.iter().any(|r| r.id < 50));
assert_eq!(results.len(), 150);
}