#![allow(
clippy::similar_names, // Reason: clippy 1.90 flags idiomatic test bindings (dir/dim, ids/idx)
clippy::cast_precision_loss,
clippy::cast_sign_loss,
clippy::cast_possible_truncation,
clippy::redundant_closure_for_method_calls
)]
use super::index::{HnswIndex, VacuumError};
use super::params::{HnswParams, SearchQuality};
use crate::distance::DistanceMetric;
use crate::index::VectorIndex;
#[test]
fn test_batch_search_matches_single_query_on_large_dataset_issue_694() {
use rand::rngs::StdRng;
use rand::{RngExt, SeedableRng};
let dim = 16_usize;
let n = 40_000_usize;
let mut rng = StdRng::seed_from_u64(0x00C0_FFEE);
let index = HnswIndex::new(dim, DistanceMetric::Cosine).unwrap();
let vectors: Vec<(u64, Vec<f32>)> = (0..n)
.map(|id| {
let v: Vec<f32> = (0..dim).map(|_| rng.random::<f32>() - 0.5).collect();
(id as u64, v)
})
.collect();
let refs: Vec<(u64, &[f32])> = vectors.iter().map(|(id, v)| (*id, v.as_slice())).collect();
index.insert_batch_parallel(refs);
assert_eq!(index.len(), n);
let queries: Vec<Vec<f32>> = (0..8)
.map(|_| (0..dim).map(|_| rng.random::<f32>() - 0.5).collect())
.collect();
let query_refs: Vec<&[f32]> = queries.iter().map(Vec::as_slice).collect();
let k = 10_usize;
let quality = SearchQuality::Fast;
let single: Vec<Vec<u64>> = queries
.iter()
.map(|q| {
let r = index.search_with_quality(q.as_slice(), k, quality).unwrap();
r.iter().map(|x| x.id).collect()
})
.collect();
let batch_results = index
.search_batch_parallel(&query_refs, k, quality)
.expect("batch search should succeed");
let batch: Vec<Vec<u64>> = batch_results
.iter()
.map(|r| r.iter().map(|x| x.id).collect())
.collect();
for (i, (s, b)) in single.iter().zip(batch.iter()).enumerate() {
assert_eq!(
s, b,
"query {i}: batch and single results must match after #694 fix.\nsingle={s:?}\nbatch={b:?}"
);
}
}
#[test]
fn test_tombstone_count_empty_index() {
let index = HnswIndex::new(64, DistanceMetric::Cosine).unwrap();
assert_eq!(index.tombstone_count(), 0);
assert!((index.tombstone_ratio() - 0.0).abs() < f64::EPSILON);
assert!(!index.needs_vacuum());
}
#[test]
fn test_tombstone_count_after_deletions() {
let index = HnswIndex::new(64, DistanceMetric::Cosine).unwrap();
for i in 0..10 {
let v: Vec<f32> = (0..64).map(|j| (i + j) as f32 * 0.01).collect();
index.insert(i as u64, &v);
}
index.remove(1);
index.remove(3);
index.remove(5);
assert_eq!(index.len(), 7);
assert_eq!(index.tombstone_count(), 3);
assert!((index.tombstone_ratio() - 0.3).abs() < 0.01);
assert!(index.needs_vacuum()); }
#[test]
fn test_vacuum_rebuilds_index() {
let index = HnswIndex::new(64, DistanceMetric::Cosine).unwrap();
for i in 0..20 {
let v: Vec<f32> = (0..64).map(|j| (i + j) as f32 * 0.01).collect();
index.insert(i as u64, &v);
}
for i in 0..10 {
index.remove(i as u64);
}
assert_eq!(index.len(), 10);
assert!(index.needs_vacuum());
let result = index.vacuum();
assert!(result.is_ok());
assert_eq!(result.unwrap(), 10);
assert_eq!(index.len(), 10);
assert_eq!(index.tombstone_count(), 0);
assert!(!index.needs_vacuum());
}
#[test]
fn test_vacuum_preserves_search_results() {
let index = HnswIndex::new(64, DistanceMetric::Cosine).unwrap();
for i in 0..50 {
let v: Vec<f32> = (0..64).map(|j| (i * 100 + j) as f32 * 0.001).collect();
index.insert(i as u64, &v);
}
for i in 0..25 {
index.remove(i as u64);
}
let query: Vec<f32> = (0..64).map(|j| (30 * 100 + j) as f32 * 0.001).collect();
let _results_before = index.search(&query, 5);
let _ = index.vacuum();
let results_after = index.search(&query, 5);
assert_eq!(results_after.len(), 5);
for sr in &results_after {
assert!(sr.id >= 25 && sr.id < 50);
}
}
#[test]
fn test_drop_after_vacuum_and_reload_is_safe() {
use tempfile::tempdir;
let dir = tempdir().expect("Failed to create temp dir");
{
let index = HnswIndex::new(32, DistanceMetric::Euclidean).unwrap();
for i in 0u64..120 {
let vector: Vec<f32> = (0..32).map(|j| (i + j as u64) as f32 * 0.01).collect();
index.insert(i, &vector);
}
for i in 0u64..40 {
index.remove(i);
}
let rebuilt = index.vacuum().expect("vacuum should succeed");
assert_eq!(rebuilt, 80);
index.save(dir.path()).expect("Failed to save");
}
let loaded = HnswIndex::load(dir.path(), 32, DistanceMetric::Euclidean)
.expect("Failed to load after vacuum+drop");
let query = vec![0.42; 32];
let results = loaded.search(&query, 10);
assert!(!results.is_empty(), "Loaded index should remain searchable");
}
#[test]
fn test_vacuum_fails_with_fast_insert_mode() {
let index = HnswIndex::new_fast_insert(64, DistanceMetric::Cosine).unwrap();
for i in 0..10 {
let v: Vec<f32> = (0..64).map(|j| (i + j) as f32 * 0.01).collect();
index.insert(i as u64, &v);
}
let result = index.vacuum();
assert!(result.is_err());
assert_eq!(result.unwrap_err(), VacuumError::VectorStorageDisabled);
}
#[test]
fn test_vacuum_empty_index() {
let index = HnswIndex::new(64, DistanceMetric::Cosine).unwrap();
let result = index.vacuum();
assert!(result.is_ok());
assert_eq!(result.unwrap(), 0);
}
#[test]
fn test_hnsw_new_creates_empty_index() {
let index = HnswIndex::new(768, DistanceMetric::Cosine).unwrap();
assert!(index.is_empty());
assert_eq!(index.len(), 0);
assert_eq!(index.dimension(), 768);
assert_eq!(index.metric(), DistanceMetric::Cosine);
}
#[test]
fn test_hnsw_new_turbo_mode() {
let index = HnswIndex::new_turbo(64, DistanceMetric::Cosine).unwrap();
for i in 0..100 {
let v: Vec<f32> = (0..64).map(|j| (i + j) as f32 * 0.01).collect();
index.insert(i as u64, &v);
}
assert_eq!(index.len(), 100);
let query: Vec<f32> = (0..64).map(|j| j as f32 * 0.01).collect();
let results = index.search(&query, 10);
assert!(!results.is_empty()); }
#[test]
fn test_hnsw_new_fast_insert_mode() {
let index = HnswIndex::new_fast_insert(64, DistanceMetric::Cosine).unwrap();
for i in 0..100 {
let v: Vec<f32> = (0..64).map(|j| (i + j) as f32 * 0.01).collect();
index.insert(i as u64, &v);
}
assert_eq!(index.len(), 100);
let query: Vec<f32> = (0..64).map(|j| j as f32 * 0.01).collect();
let results = index.search(&query, 10);
assert_eq!(results.len(), 10);
}
#[test]
fn test_hnsw_insert_single_vector() {
let index = HnswIndex::new(3, DistanceMetric::Cosine).unwrap();
let vector = vec![1.0, 0.0, 0.0];
index.insert(1, &vector);
assert_eq!(index.len(), 1);
assert!(!index.is_empty());
}
#[test]
fn test_hnsw_insert_multiple_vectors() {
let index = HnswIndex::new(3, DistanceMetric::Cosine).unwrap();
index.insert(1, &[1.0, 0.0, 0.0]);
index.insert(2, &[0.0, 1.0, 0.0]);
index.insert(3, &[0.0, 0.0, 1.0]);
assert_eq!(index.len(), 3);
}
#[test]
fn test_hnsw_search_returns_k_nearest() {
let index = HnswIndex::new(3, DistanceMetric::Cosine).unwrap();
index.insert(1, &[1.0, 0.0, 0.0]);
index.insert(2, &[0.9, 0.1, 0.0]); index.insert(3, &[0.0, 1.0, 0.0]); index.insert(4, &[0.8, 0.2, 0.0]); index.insert(5, &[0.0, 0.0, 1.0]);
let results = index.search(&[1.0, 0.0, 0.0], 3);
assert!(
!results.is_empty() && results.len() <= 3,
"Should return 1-3 results, got {}",
results.len()
);
let top_ids: Vec<u64> = results.iter().map(|sr| sr.id).collect();
assert!(top_ids.contains(&1), "Exact match should be in top results");
}
#[test]
fn test_hnsw_search_empty_index() {
let index = HnswIndex::new(3, DistanceMetric::Cosine).unwrap();
let results = index.search(&[1.0, 0.0, 0.0], 10);
assert!(results.is_empty());
}
#[test]
fn test_hnsw_remove_existing_vector() {
let index = HnswIndex::new(3, DistanceMetric::Cosine).unwrap();
index.insert(1, &[1.0, 0.0, 0.0]);
index.insert(2, &[0.0, 1.0, 0.0]);
let removed = index.remove(1);
assert!(removed);
assert_eq!(index.len(), 1);
}
#[test]
fn test_hnsw_remove_nonexistent_vector() {
let index = HnswIndex::new(3, DistanceMetric::Cosine).unwrap();
index.insert(1, &[1.0, 0.0, 0.0]);
let removed = index.remove(999);
assert!(!removed);
assert_eq!(index.len(), 1);
}
#[test]
fn test_hnsw_euclidean_metric() {
let index = HnswIndex::new(3, DistanceMetric::Euclidean).unwrap();
index.insert(1, &[0.0, 0.0, 0.0]);
index.insert(2, &[1.0, 0.0, 0.0]); index.insert(3, &[3.0, 4.0, 0.0]); index.insert(4, &[2.0, 0.0, 0.0]); index.insert(5, &[0.5, 0.5, 0.0]);
let results = index.search(&[0.0, 0.0, 0.0], 3);
assert!(!results.is_empty(), "Should return results");
assert_eq!(results[0].id, 1, "Closest should be exact match");
}
#[test]
fn test_hnsw_dot_product_metric() {
let index = HnswIndex::new(3, DistanceMetric::DotProduct).unwrap();
index.insert(1, &[1.0, 0.0, 0.0]); index.insert(2, &[0.5, 0.5, 0.5]); index.insert(3, &[0.1, 0.1, 0.1]); index.insert(4, &[0.8, 0.2, 0.0]); index.insert(5, &[0.3, 0.3, 0.3]);
let query = [1.0, 0.0, 0.0];
let results = index.search(&query, 3);
assert!(!results.is_empty(), "Should return results");
assert_eq!(results[0].id, 1, "Highest dot product should be first");
}
#[test]
fn test_hnsw_insert_wrong_dimension_is_noop() {
let index = HnswIndex::new(3, DistanceMetric::Cosine).unwrap();
index.insert(1, &[1.0, 0.0]);
assert_eq!(index.len(), 0, "Wrong-dimension insert should be a no-op");
}
#[test]
fn test_hnsw_search_wrong_dimension_returns_error() {
let index = HnswIndex::new(3, DistanceMetric::Cosine).unwrap();
index.insert(1, &[1.0, 0.0, 0.0]);
let result = index.search_with_quality(&[1.0, 0.0], 10, SearchQuality::Balanced);
assert!(result.is_err(), "Should return error on dimension mismatch");
let results = index.search(&[1.0, 0.0], 10);
assert!(
results.is_empty(),
"Trait search should return empty on mismatch"
);
}
#[test]
fn test_hnsw_duplicate_insert_upserts_vector() {
let index = HnswIndex::new(3, DistanceMetric::Cosine).unwrap();
index.insert(1, &[1.0, 0.0, 0.0]);
index.insert(1, &[0.0, 1.0, 0.0]);
assert_eq!(index.len(), 1);
let results = index.search(&[0.0, 1.0, 0.0], 1);
assert_eq!(results.len(), 1);
assert_eq!(results[0].id, 1);
assert!(
results[0].score > 0.9,
"Updated vector should be indexed, got score {}",
results[0].score,
);
}
#[test]
fn test_hnsw_thread_safety() {
use std::sync::Arc;
use std::thread;
let index = Arc::new(HnswIndex::new(3, DistanceMetric::Cosine).unwrap());
let mut handles = vec![];
for i in 0..10 {
let index_clone = Arc::clone(&index);
handles.push(thread::spawn(move || {
#[allow(clippy::cast_precision_loss)]
index_clone.insert(i, &[i as f32, 0.0, 0.0]);
}));
}
for handle in handles {
handle.join().expect("Thread panicked");
}
index.set_searching_mode();
assert_eq!(index.len(), 10);
}
#[test]
fn test_hnsw_persistence() {
use tempfile::tempdir;
let dir = tempdir().unwrap();
let index = HnswIndex::new(3, DistanceMetric::Cosine).unwrap();
index.insert(1, &[1.0, 0.0, 0.0]);
index.insert(2, &[0.0, 1.0, 0.0]);
index.save(dir.path()).unwrap();
let loaded_index = HnswIndex::load(dir.path(), 3, DistanceMetric::Cosine).unwrap();
assert_eq!(loaded_index.len(), 2);
assert_eq!(loaded_index.dimension(), 3);
assert_eq!(loaded_index.metric(), DistanceMetric::Cosine);
let results = loaded_index.search(&[1.0, 0.0, 0.0], 1);
assert_eq!(results.len(), 1);
assert_eq!(results[0].id, 1);
}
#[test]
fn test_hnsw_snapshot_without_vectors_file_keeps_vector_features() {
use tempfile::tempdir;
let dir = tempdir().unwrap();
let index = HnswIndex::new(3, DistanceMetric::Cosine).unwrap();
index.insert(1, &[1.0, 0.0, 0.0]);
index.insert(2, &[0.0, 1.0, 0.0]);
index.save(dir.path()).unwrap();
assert!(!dir.path().join("native_vectors.bin").exists());
let loaded_index = HnswIndex::load(dir.path(), 3, DistanceMetric::Cosine).unwrap();
assert_eq!(loaded_index.len(), 2);
assert!(loaded_index.has_vector_storage());
assert_eq!(loaded_index.vacuum(), Ok(2));
assert_eq!(loaded_index.len(), 2);
}
#[test]
fn test_hnsw_fast_insert_flag_survives_save_load() {
use tempfile::tempdir;
let dir = tempdir().unwrap();
let index = HnswIndex::new_fast_insert(3, DistanceMetric::Cosine).unwrap();
index.insert(1, &[1.0, 0.0, 0.0]);
index.insert(2, &[0.0, 1.0, 0.0]);
index.save(dir.path()).unwrap();
assert!(!dir.path().join("native_vectors.bin").exists());
let loaded = HnswIndex::load(dir.path(), 3, DistanceMetric::Cosine).unwrap();
assert!(!loaded.has_vector_storage());
assert_eq!(loaded.vacuum(), Err(VacuumError::VectorStorageDisabled));
}
#[test]
fn test_hnsw_save_removes_stale_legacy_vectors_file() {
use tempfile::tempdir;
let dir = tempdir().unwrap();
let index = HnswIndex::new(3, DistanceMetric::Cosine).unwrap();
index.insert(1, &[1.0, 0.0, 0.0]);
index.save(dir.path()).unwrap();
std::fs::write(dir.path().join("native_vectors.bin"), [0_u8; 8]).unwrap();
index.insert(2, &[0.0, 1.0, 0.0]);
index.save(dir.path()).unwrap();
assert!(!dir.path().join("native_vectors.bin").exists());
let loaded = HnswIndex::load(dir.path(), 3, DistanceMetric::Cosine).unwrap();
assert_eq!(loaded.len(), 2);
}
#[test]
fn test_hnsw_insert_batch_parallel() {
let index = HnswIndex::new(3, DistanceMetric::Cosine).unwrap();
let vectors: Vec<(u64, Vec<f32>)> = vec![
(1, vec![1.0, 0.0, 0.0]),
(2, vec![0.0, 1.0, 0.0]),
(3, vec![0.0, 0.0, 1.0]),
(4, vec![0.5, 0.5, 0.0]),
(5, vec![0.5, 0.0, 0.5]),
];
let inserted = index.insert_batch_parallel(vectors.iter().map(|(id, v)| (*id, v.as_slice())));
index.set_searching_mode();
assert_eq!(inserted, 5);
assert_eq!(index.len(), 5);
let results = index.search(&[1.0, 0.0, 0.0], 3);
assert_eq!(results.len(), 3);
let result_ids: Vec<u64> = results.iter().map(|r| r.id).collect();
assert!(result_ids.contains(&1), "ID 1 should be in top 3 results");
}
#[test]
fn test_hnsw_insert_batch_parallel_upserts_duplicates() {
let index = HnswIndex::new(3, DistanceMetric::Cosine).unwrap();
index.insert(1, &[1.0, 0.0, 0.0]);
let vectors: Vec<(u64, Vec<f32>)> = vec![
(1, vec![0.0, 1.0, 0.0]), (2, vec![0.0, 0.0, 1.0]), ];
let inserted = index.insert_batch_parallel(vectors.iter().map(|(id, v)| (*id, v.as_slice())));
index.set_searching_mode();
assert_eq!(inserted, 2);
assert_eq!(index.len(), 2);
}
#[test]
fn test_hnsw_insert_batch_parallel_empty() {
let index = HnswIndex::new(3, DistanceMetric::Cosine).unwrap();
let vectors: Vec<(u64, &[f32])> = vec![];
let inserted = index.insert_batch_parallel(vectors);
assert_eq!(inserted, 0);
assert!(index.is_empty());
}
#[test]
fn test_hnsw_insert_batch_parallel_wrong_dimension_returns_zero() {
let index = HnswIndex::new(3, DistanceMetric::Cosine).unwrap();
let vectors: Vec<(u64, &[f32])> = vec![(1, &[1.0, 0.0])];
let inserted = index.insert_batch_parallel(vectors);
assert_eq!(inserted, 0, "Wrong-dimension batch should insert nothing");
assert_eq!(index.len(), 0, "Index should remain empty");
}
#[test]
fn test_hnsw_with_params() {
let params = HnswParams::custom(48, 600, 500_000);
let index = HnswIndex::with_params(1536, DistanceMetric::Cosine, params).unwrap();
assert_eq!(index.dimension(), 1536);
assert!(index.is_empty());
}
#[test]
fn test_search_with_rerank_returns_k_results() {
let index = HnswIndex::new(3, DistanceMetric::Cosine).unwrap();
index.insert(1, &[1.0, 0.0, 0.0]);
index.insert(2, &[0.9, 0.1, 0.0]);
index.insert(3, &[0.8, 0.2, 0.0]);
index.insert(4, &[0.0, 1.0, 0.0]);
index.insert(5, &[0.0, 0.0, 1.0]);
let results = index.search_with_rerank(&[1.0, 0.0, 0.0], 3, 5).unwrap();
assert_eq!(results.len(), 3, "Should return exactly k results");
}
#[test]
#[allow(clippy::cast_precision_loss)]
fn test_search_with_rerank_improves_ranking() {
let index = HnswIndex::new(128, DistanceMetric::Cosine).unwrap();
let base: Vec<f32> = (0..128).map(|i| (i as f32 * 0.01).sin()).collect();
let mut v1 = base.clone();
v1[0] += 0.001;
let mut v2 = base.clone();
v2[0] += 0.01;
let mut v3 = base.clone();
v3[0] += 0.1;
index.insert(1, &v1);
index.insert(2, &v2);
index.insert(3, &v3);
let results = index.search_with_rerank(&base, 3, 3).unwrap();
assert_eq!(results[0].id, 1, "Most similar vector should be first");
}
#[test]
fn test_search_with_rerank_handles_rerank_k_greater_than_index_size() {
let index = HnswIndex::new(3, DistanceMetric::Cosine).unwrap();
index.insert(1, &[1.0, 0.0, 0.0]);
index.insert(2, &[0.0, 1.0, 0.0]);
index.insert(3, &[0.0, 0.0, 1.0]);
index.insert(4, &[0.5, 0.5, 0.0]);
index.insert(5, &[0.5, 0.0, 0.5]);
let results = index.search_with_rerank(&[1.0, 0.0, 0.0], 3, 100).unwrap();
assert!(!results.is_empty(), "Should return results");
assert!(results.len() <= 5, "Should not exceed index size");
}
#[test]
#[allow(clippy::cast_precision_loss, clippy::cast_sign_loss)]
fn test_search_with_rerank_uses_simd_distances() {
let index = HnswIndex::new(768, DistanceMetric::Cosine).unwrap();
for i in 0..100_u64 {
let v: Vec<f32> = (0..768)
.map(|j| ((i + j as u64) as f32 * 0.01).sin())
.collect();
index.insert(i, &v);
}
let query: Vec<f32> = (0..768).map(|j| (j as f32 * 0.01).sin()).collect();
let results = index.search_with_rerank(&query, 10, 50).unwrap();
assert!(!results.is_empty(), "Should return at least one result");
for sr in &results {
assert!(
sr.score >= -1.0 && sr.score <= 1.0,
"Cosine should be in [-1, 1]"
);
}
for i in 1..results.len() {
assert!(
results[i - 1].score >= results[i].score,
"Results should be sorted by similarity descending"
);
}
}
#[test]
fn test_search_with_rerank_euclidean_metric() {
let index = HnswIndex::new(3, DistanceMetric::Euclidean).unwrap();
index.insert(1, &[0.0, 0.0, 0.0]);
index.insert(2, &[1.0, 0.0, 0.0]);
index.insert(3, &[2.0, 0.0, 0.0]);
let results = index.search_with_rerank(&[0.0, 0.0, 0.0], 3, 3).unwrap();
assert_eq!(results[0].id, 1, "Origin should be closest to itself");
for i in 1..results.len() {
assert!(
results[i - 1].score <= results[i].score,
"Euclidean results should be sorted ascending"
);
}
}
#[test]
#[allow(
clippy::cast_precision_loss,
clippy::cast_sign_loss,
clippy::uninlined_format_args
)]
fn test_hnsw_multi_tenant_load_unload() {
use tempfile::tempdir;
let dir = tempdir().expect("Failed to create temp dir");
{
let index = HnswIndex::new(128, DistanceMetric::Cosine).unwrap();
for i in 0..100_u64 {
let v: Vec<f32> = (0..128)
.map(|j| ((i + j as u64) as f32 * 0.01).sin())
.collect();
index.insert(i, &v);
}
index.save(dir.path()).expect("Failed to save index");
}
for iteration in 0..5 {
let loaded =
HnswIndex::load(dir.path(), 128, DistanceMetric::Cosine).expect("Failed to load index");
assert_eq!(
loaded.len(),
100,
"Iteration {}: Should have 100 vectors",
iteration
);
let query: Vec<f32> = (0..128).map(|j| (j as f32 * 0.01).sin()).collect();
let results = loaded.search(&query, 5);
assert!(
!results.is_empty() && results.len() <= 5,
"Iteration {}: Should return 1-5 results, got {}",
iteration,
results.len()
);
}
}
#[test]
fn test_hnsw_drop_cleans_up_properly() {
use tempfile::tempdir;
let dir = tempdir().expect("Failed to create temp dir");
{
let index = HnswIndex::new(64, DistanceMetric::Euclidean).unwrap();
index.insert(1, &vec![0.5; 64]);
index.insert(2, &vec![0.3; 64]);
index.save(dir.path()).expect("Failed to save");
}
{
let _loaded =
HnswIndex::load(dir.path(), 64, DistanceMetric::Euclidean).expect("Failed to load");
}
{
let loaded = HnswIndex::load(dir.path(), 64, DistanceMetric::Euclidean)
.expect("Failed to load after previous drop");
assert_eq!(loaded.len(), 2);
}
}
#[test]
#[allow(clippy::cast_precision_loss, clippy::uninlined_format_args)]
fn test_hnsw_save_load_preserves_all_metrics() {
use tempfile::tempdir;
for metric in [DistanceMetric::Cosine, DistanceMetric::Euclidean] {
let dir = tempdir().expect("Failed to create temp dir");
let dim = 32;
let v1: Vec<f32> = (0..dim).map(|i| (i as f32 * 0.1).sin()).collect();
let v2: Vec<f32> = (0..dim).map(|i| (i as f32 * 0.2).cos()).collect();
let query: Vec<f32> = (0..dim).map(|i| (i as f32 * 0.15).sin()).collect();
{
let index = HnswIndex::new(dim, metric).unwrap();
index.insert(1, &v1);
index.insert(2, &v2);
index.save(dir.path()).expect("Failed to save");
}
{
let loaded = HnswIndex::load(dir.path(), dim, metric).expect("Failed to load");
assert_eq!(
loaded.len(),
2,
"Metric {:?}: Should have 2 vectors",
metric
);
assert_eq!(loaded.metric(), metric, "Metric should be preserved");
assert_eq!(loaded.dimension(), dim, "Dimension should be preserved");
let results = loaded.search(&query, 2);
assert!(
!results.is_empty(),
"Metric {:?}: Should return results",
metric
);
}
}
}
#[test]
fn test_search_quality_fast() {
let index = HnswIndex::new(3, DistanceMetric::Cosine).unwrap();
index.insert(1, &[1.0, 0.0, 0.0]);
index.insert(2, &[0.9, 0.1, 0.0]);
index.insert(3, &[0.8, 0.2, 0.0]);
index.insert(4, &[0.7, 0.3, 0.0]);
index.insert(5, &[0.0, 1.0, 0.0]);
let results = index
.search_with_quality(&[1.0, 0.0, 0.0], 2, SearchQuality::Fast)
.unwrap();
assert!(!results.is_empty(), "Should return at least one result");
assert!(results.len() <= 2, "Should not exceed requested k");
}
#[test]
fn test_search_quality_balanced() {
let index = HnswIndex::new(3, DistanceMetric::Cosine).unwrap();
index.insert(1, &[1.0, 0.0, 0.0]);
index.insert(2, &[0.9, 0.1, 0.0]);
let results = index
.search_with_quality(&[1.0, 0.0, 0.0], 2, SearchQuality::Balanced)
.unwrap();
assert!(!results.is_empty(), "Should return at least one result");
assert_eq!(
results[0].id, 1,
"Balanced search should find exact match first"
);
}
#[test]
fn test_search_quality_custom_ef() {
let index = HnswIndex::new(3, DistanceMetric::Cosine).unwrap();
index.insert(1, &[1.0, 0.0, 0.0]);
index.insert(2, &[0.9, 0.1, 0.0]);
index.insert(3, &[0.8, 0.2, 0.0]);
index.insert(4, &[0.0, 1.0, 0.0]);
index.insert(5, &[0.0, 0.0, 1.0]);
let results = index
.search_with_quality(&[1.0, 0.0, 0.0], 3, SearchQuality::Custom(512))
.unwrap();
assert_eq!(results.len(), 3);
}
#[test]
fn test_hnsw_load_nonexistent_path() {
let result = HnswIndex::load("nonexistent_path_12345", 128, DistanceMetric::Cosine);
assert!(result.is_err(), "Loading from nonexistent path should fail");
}
#[test]
fn test_hnsw_search_with_rerank_empty_index() {
let index = HnswIndex::new(3, DistanceMetric::Cosine).unwrap();
let results = index.search_with_rerank(&[1.0, 0.0, 0.0], 10, 50).unwrap();
assert!(
results.is_empty(),
"Empty index should return empty results"
);
}
#[test]
fn test_hnsw_search_with_rerank_dot_product() {
let index = HnswIndex::new(3, DistanceMetric::DotProduct).unwrap();
index.insert(1, &[1.0, 0.0, 0.0]);
index.insert(2, &[0.5, 0.5, 0.0]);
index.insert(3, &[0.0, 1.0, 0.0]);
let results = index.search_with_rerank(&[1.0, 0.0, 0.0], 3, 3).unwrap();
assert!(!results.is_empty(), "Should return at least one result");
assert_eq!(results[0].id, 1, "Highest dot product should be first");
}
#[test]
fn test_hnsw_io_holder_is_none_for_new_index() {
let index = HnswIndex::new(3, DistanceMetric::Cosine).unwrap();
assert!(
index.io_holder.is_none(),
"new native index must have io_holder == None"
);
index.insert(1, &[1.0, 0.0, 0.0]);
assert!(
index.io_holder.is_none(),
"io_holder must stay None after insert (native backend never borrows from disk)"
);
}
#[test]
#[allow(clippy::cast_precision_loss, clippy::cast_sign_loss)]
fn test_hnsw_large_batch_parallel_insert() {
let index = HnswIndex::new(128, DistanceMetric::Cosine).unwrap();
let vectors: Vec<(u64, Vec<f32>)> = (0..200)
.map(|i| {
let v: Vec<f32> = (0..128).map(|j| ((i + j) as f32 * 0.001).sin()).collect();
(i as u64, v)
})
.collect();
let inserted = index.insert_batch_parallel(vectors.iter().map(|(id, v)| (*id, v.as_slice())));
index.set_searching_mode();
assert_eq!(inserted, 200, "Should insert 200 vectors");
assert_eq!(index.len(), 200);
let query: Vec<f32> = (0..128).map(|j| (j as f32 * 0.001).sin()).collect();
let results = index.search(&query, 10);
assert_eq!(results.len(), 10);
}
#[test]
#[allow(clippy::cast_precision_loss, clippy::cast_sign_loss)]
fn test_search_with_rerank_768d_prefetch() {
let index = HnswIndex::new(768, DistanceMetric::Cosine).unwrap();
for i in 0u64..100 {
let v: Vec<f32> = (0..768)
.map(|j| ((i + j as u64) as f32 * 0.001).sin())
.collect();
index.insert(i, &v);
}
let query: Vec<f32> = (0..768).map(|j| (j as f32 * 0.001).sin()).collect();
let results = index.search_with_rerank(&query, 10, 50).unwrap();
assert!(!results.is_empty(), "Should return results");
assert!(results.len() <= 10, "Should not exceed k");
assert_eq!(
results[0].id, 0,
"Self-match: query equals vector 0, should rank top-1"
);
for sr in &results {
assert!(sr.score >= -1.0 && sr.score <= 1.0, "Cosine in [-1,1]");
}
for i in 1..results.len() {
assert!(
results[i - 1].score >= results[i].score,
"Sorted descending"
);
}
}
#[test]
#[allow(clippy::cast_precision_loss, clippy::cast_sign_loss)]
fn test_search_with_rerank_small_dim_prefetch() {
let index = HnswIndex::new(32, DistanceMetric::Cosine).unwrap();
for i in 0u64..50 {
let v: Vec<f32> = (0..32)
.map(|j| ((i + j as u64) as f32 * 0.01).sin())
.collect();
index.insert(i, &v);
}
let query: Vec<f32> = (0..32).map(|j| (j as f32 * 0.01).sin()).collect();
let results = index.search_with_rerank(&query, 5, 20).unwrap();
assert!(!results.is_empty(), "Should return results");
assert!(results.len() <= 5, "Should not exceed k");
assert_eq!(
results[0].id, 0,
"Exact-match vector should rank first after rerank"
);
}
#[test]
#[allow(clippy::cast_precision_loss, clippy::cast_sign_loss)]
fn test_search_batch_parallel_consistency() {
let index = HnswIndex::new(64, DistanceMetric::Cosine).unwrap();
for i in 0u64..100 {
let v: Vec<f32> = (0..64)
.map(|j| ((i + j as u64) as f32 * 0.01).sin())
.collect();
index.insert(i, &v);
}
let queries: Vec<Vec<f32>> = (0..10)
.map(|i| {
(0..64)
.map(|j| ((200 + i + j) as f32 * 0.01).sin())
.collect()
})
.collect();
let query_refs: Vec<&[f32]> = queries.iter().map(Vec::as_slice).collect();
let batch_results = index
.search_batch_parallel(&query_refs, 5, SearchQuality::Balanced)
.unwrap();
let individual_results: Vec<Vec<crate::scored_result::ScoredResult>> = queries
.iter()
.map(|q| {
index
.search_with_quality(q, 5, SearchQuality::Balanced)
.unwrap()
})
.collect();
assert_eq!(batch_results.len(), individual_results.len());
for (batch, individual) in batch_results.iter().zip(&individual_results) {
assert_eq!(batch.len(), individual.len(), "Result counts should match");
let batch_ids: std::collections::HashSet<u64> = batch.iter().map(|r| r.id).collect();
let individual_ids: std::collections::HashSet<u64> =
individual.iter().map(|r| r.id).collect();
assert_eq!(
batch_ids, individual_ids,
"Batch and individual results must return the same IDs"
);
}
}
#[test]
fn test_search_batch_parallel_empty_queries() {
let index = HnswIndex::new(3, DistanceMetric::Cosine).unwrap();
index.insert(1, &[1.0, 0.0, 0.0]);
let queries: Vec<&[f32]> = vec![];
let results = index
.search_batch_parallel(&queries, 5, SearchQuality::Fast)
.unwrap();
assert!(
results.is_empty(),
"Empty queries should return empty results"
);
}
#[test]
#[allow(clippy::cast_precision_loss, clippy::cast_sign_loss)]
fn test_search_batch_parallel_large_batch() {
let index = HnswIndex::new(128, DistanceMetric::Cosine).unwrap();
for i in 0u64..150 {
let v: Vec<f32> = (0..128)
.map(|j| ((i + j as u64) as f32 * 0.001).sin())
.collect();
index.insert(i, &v);
}
index.set_searching_mode();
let queries: Vec<Vec<f32>> = (0..20)
.map(|i| {
(0..128)
.map(|j| ((150 + i + j) as f32 * 0.001).sin())
.collect()
})
.collect();
let query_refs: Vec<&[f32]> = queries.iter().map(Vec::as_slice).collect();
let results = index
.search_batch_parallel(&query_refs, 10, SearchQuality::Balanced)
.unwrap();
assert_eq!(results.len(), 20, "Should return 20 result sets");
for result in &results {
assert_eq!(result.len(), 10, "Each result should have 10 neighbors");
}
}
#[test]
#[allow(clippy::cast_precision_loss)]
fn test_recall_quality_minimum_threshold() {
let dim = 64;
let n = 500;
let k = 10;
let index = HnswIndex::new(dim, DistanceMetric::Cosine).unwrap();
let dataset: Vec<Vec<f32>> = (0..n)
.map(|i| {
(0..dim)
.map(|j| ((i * dim + j) as f32 * 0.001).sin())
.collect()
})
.collect();
for (idx, vec) in dataset.iter().enumerate() {
#[allow(clippy::cast_possible_truncation)]
index.insert(idx as u64, vec);
}
let query: Vec<f32> = (0..dim).map(|j| (j as f32 * 0.001).sin()).collect();
let mut distances: Vec<crate::scored_result::ScoredResult> = dataset
.iter()
.enumerate()
.map(|(idx, vec)| {
let sim = crate::simd_native::cosine_similarity_native(&query, vec);
#[allow(clippy::cast_possible_truncation)]
crate::scored_result::ScoredResult::new(idx as u64, sim)
})
.collect();
distances.sort_by(|a, b| {
b.score
.partial_cmp(&a.score)
.unwrap_or(std::cmp::Ordering::Equal)
});
let ground_truth: Vec<u64> = distances.iter().take(k).map(|sr| sr.id).collect();
let results = index
.search_with_quality(&query, k, SearchQuality::Accurate)
.unwrap();
let result_ids: std::collections::HashSet<u64> = results.iter().map(|sr| sr.id).collect();
let gt_set: std::collections::HashSet<u64> = ground_truth.iter().copied().collect();
let recall = result_ids.intersection(>_set).count() as f64 / k as f64;
assert!(
recall >= 0.8,
"Recall@{k} should be >= 80% for Accurate, got {:.1}%",
recall * 100.0
);
}
#[test]
fn test_rerank_latency_target_configuration_roundtrip() {
let index = HnswIndex::new(32, DistanceMetric::Cosine).unwrap();
assert_eq!(index.rerank_latency_target_us(), 0);
index.set_rerank_latency_target_us(250);
assert_eq!(index.rerank_latency_target_us(), 250);
}
#[test]
fn test_rerank_latency_ema_updates_after_two_stage_search() {
let index = HnswIndex::new(64, DistanceMetric::Cosine).unwrap();
index.set_rerank_latency_target_us(1);
for i in 0u64..1500 {
let v: Vec<f32> = (0..64)
.map(|j| ((i * 5 + j as u64) as f32 * 0.0013).sin())
.collect();
index.insert(i, &v);
}
let query: Vec<f32> = (0..64).map(|j| (j as f32 * 0.009).cos()).collect();
let _ = index
.search_with_quality(&query, 20, SearchQuality::Accurate)
.unwrap();
assert!(index.rerank_latency_ema_us() > 0);
}
#[test]
fn test_update_rerank_latency_ema_large_current_does_not_overflow() {
let index = HnswIndex::new(64, DistanceMetric::Cosine).unwrap();
for i in 0u64..400 {
let v: Vec<f32> = (0..64)
.map(|j| ((i * 3 + j as u64) as f32 * 0.0021).sin())
.collect();
index.insert(i, &v);
}
index
.rerank_latency_ema_us
.store(u64::MAX, std::sync::atomic::Ordering::Relaxed);
let query: Vec<f32> = (0..64).map(|j| (j as f32 * 0.011).cos()).collect();
let results = index
.search_with_quality(&query, 20, SearchQuality::Accurate)
.unwrap();
assert!(!results.is_empty());
let ema = index.rerank_latency_ema_us();
let expected_min = (u64::MAX / 10) * 7;
assert!(ema >= expected_min, "EMA underflow/wrap detected: {ema}");
}
#[test]
fn test_search_with_quality_custom_ef_uses_high_recall_path_without_regression() {
let index = HnswIndex::new(64, DistanceMetric::Cosine).unwrap();
for i in 0u64..2000 {
let v: Vec<f32> = (0..64)
.map(|j| ((i + j as u64) as f32 * 0.001).sin())
.collect();
index.insert(i, &v);
}
let query: Vec<f32> = (0..64).map(|j| (j as f32 * 0.013).cos()).collect();
let results = index
.search_with_quality(&query, 20, SearchQuality::Custom(512))
.unwrap();
assert!(!results.is_empty());
assert!(results.len() <= 20);
}
#[test]
fn test_search_with_quality_accurate_stays_stable_on_medium_dataset() {
let index = HnswIndex::new(128, DistanceMetric::Cosine).unwrap();
for i in 0u64..5000 {
let v: Vec<f32> = (0..128)
.map(|j| ((i * 3 + j as u64) as f32 * 0.0007).sin())
.collect();
index.insert(i, &v);
}
let query: Vec<f32> = (0..128).map(|j| (j as f32 * 0.007).sin()).collect();
let results = index
.search_with_quality(&query, 15, SearchQuality::Accurate)
.unwrap();
assert!(!results.is_empty());
assert!(results.len() <= 15);
}
#[test]
fn test_brute_force_buffered_same_results_as_original() {
let index = HnswIndex::new(32, DistanceMetric::Cosine).unwrap();
for i in 0u64..50 {
let v: Vec<f32> = (0..32)
.map(|j| ((i + j as u64) as f32 * 0.01).sin())
.collect();
index.insert(i, &v);
}
let query: Vec<f32> = (0..32).map(|j| (j as f32 * 0.02).cos()).collect();
let original = index.search_brute_force(&query, 10).unwrap();
let buffered = index.search_brute_force_buffered(&query, 10).unwrap();
assert_eq!(original.len(), buffered.len());
for (orig, buf) in original.iter().zip(buffered.iter()) {
assert_eq!(orig.id, buf.id, "IDs should match");
assert!(
(orig.score - buf.score).abs() < 1e-6,
"Distances should match"
);
}
}
#[test]
fn test_brute_force_buffered_empty_index() {
let index = HnswIndex::new(16, DistanceMetric::Euclidean).unwrap();
let query: Vec<f32> = vec![0.0; 16];
let results = index.search_brute_force_buffered(&query, 5).unwrap();
assert!(results.is_empty());
}
#[test]
fn test_brute_force_buffered_all_metrics() {
for metric in [
DistanceMetric::Cosine,
DistanceMetric::Euclidean,
DistanceMetric::DotProduct,
DistanceMetric::Hamming,
DistanceMetric::Jaccard,
] {
let index = HnswIndex::new(8, metric).unwrap();
index.insert(1, &[1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0]);
index.insert(2, &[0.5, 0.5, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0]);
index.insert(3, &[0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0]);
let results = index
.search_brute_force_buffered(&[1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0], 3)
.unwrap();
assert_eq!(results.len(), 3, "Should return 3 results for {metric:?}");
}
}
#[test]
fn test_brute_force_buffered_repeated_calls_stable() {
let index = HnswIndex::new(16, DistanceMetric::Cosine).unwrap();
for i in 0u64..20 {
let v: Vec<f32> = (0..16)
.map(|j| ((i + j as u64) as f32 * 0.1).sin())
.collect();
index.insert(i, &v);
}
let query: Vec<f32> = vec![0.5; 16];
let r1 = index.search_brute_force_buffered(&query, 5).unwrap();
let r2 = index.search_brute_force_buffered(&query, 5).unwrap();
let r3 = index.search_brute_force_buffered(&query, 5).unwrap();
assert_eq!(r1, r2);
assert_eq!(r2, r3);
}
fn jaccard_fixture() -> (HnswIndex, Vec<f32>) {
let index = HnswIndex::new(4, DistanceMetric::Jaccard).unwrap();
index.insert(1, &[1.0, 1.0, 0.0, 0.0]);
index.insert(2, &[1.0, 0.0, 0.0, 0.0]);
index.insert(3, &[0.0, 0.0, 1.0, 1.0]);
(index, vec![1.0, 1.0, 0.0, 0.0])
}
#[test]
fn test_jaccard_hnsw_scores_are_similarities() {
let (index, query) = jaccard_fixture();
let results = index.search(&query, 3);
assert_eq!(results.len(), 3);
assert_eq!(
results[0].id, 1,
"exact match must rank first, got {results:?}"
);
for (result, want) in results.iter().zip([1.0f32, 0.5, 0.0]) {
assert!(
(result.score - want).abs() < 1e-6,
"id {} score {} != similarity {want}",
result.id,
result.score
);
}
}
#[test]
fn test_jaccard_bitmap_filtered_search_keeps_best_not_worst() {
let (index, query) = jaccard_fixture();
let allowed: roaring::RoaringBitmap = [1u32, 2, 3].into_iter().collect();
let results = index
.search_with_quality_and_bitmap(&query, 1, SearchQuality::Balanced, &allowed)
.unwrap();
assert_eq!(results.len(), 1);
assert_eq!(results[0].id, 1, "top-1 must be the exact match");
assert!(
(results[0].score - 1.0).abs() < 1e-6,
"exact match must score similarity 1.0, got {}",
results[0].score
);
}
#[test]
#[allow(clippy::cast_precision_loss, clippy::cast_sign_loss)]
fn test_concurrent_search_stress() {
use std::sync::Arc;
use std::thread;
let index = Arc::new(HnswIndex::new(64, DistanceMetric::Cosine).unwrap());
for i in 0u64..100 {
let v: Vec<f32> = (0..64)
.map(|j| ((i + j as u64) as f32 * 0.01).sin())
.collect();
index.insert(i, &v);
}
let handles: Vec<_> = (0..4)
.map(|t| {
let idx = Arc::clone(&index);
thread::spawn(move || {
for i in 0..50 {
let query: Vec<f32> = (0..64)
.map(|j| ((t * 100 + i + j) as f32 * 0.01).sin())
.collect();
let results = idx.search(&query, 5);
assert!(!results.is_empty());
}
})
})
.collect();
for handle in handles {
handle.join().expect("Thread panicked");
}
}
#[test]
fn test_all_distance_metrics_search_with_rerank() {
for metric in [
DistanceMetric::Cosine,
DistanceMetric::Euclidean,
DistanceMetric::DotProduct,
DistanceMetric::Hamming,
DistanceMetric::Jaccard,
] {
let index = HnswIndex::new(8, metric).unwrap();
index.insert(1, &[1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0]);
index.insert(2, &[0.5, 0.5, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0]);
index.insert(3, &[0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0]);
let results = index
.search_with_rerank(&[1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0], 3, 3)
.unwrap();
assert!(
!results.is_empty(),
"search_with_rerank should work for {metric:?}"
);
}
}
#[test]
fn test_drop_safety_loaded_index_no_segfault() {
use tempfile::tempdir;
let dir = tempdir().expect("Failed to create temp dir");
{
let index = HnswIndex::new(4, DistanceMetric::Cosine).unwrap();
index.insert(1, &[1.0, 0.0, 0.0, 0.0]);
index.insert(2, &[0.0, 1.0, 0.0, 0.0]);
index.insert(3, &[0.0, 0.0, 1.0, 0.0]);
index.save(dir.path()).expect("Failed to save");
}
for _ in 0..5 {
let loaded =
HnswIndex::load(dir.path(), 4, DistanceMetric::Cosine).expect("Failed to load");
let results = loaded.search(&[1.0, 0.0, 0.0, 0.0], 2);
assert!(!results.is_empty(), "Search should return results");
}
}
#[test]
fn test_drop_safety_loaded_index_concurrent_drop() {
use std::sync::Arc;
use std::thread;
use tempfile::tempdir;
let dir = tempdir().expect("Failed to create temp dir");
{
let index = HnswIndex::new(4, DistanceMetric::Cosine).unwrap();
for i in 0u64..10 {
let v = vec![i as f32, 0.0, 0.0, 0.0];
index.insert(i, &v);
}
index.save(dir.path()).expect("Failed to save");
}
let path = Arc::new(dir.path().to_path_buf());
let handles: Vec<_> = (0..4)
.map(|_| {
let p = Arc::clone(&path);
thread::spawn(move || {
for _ in 0..3 {
let loaded =
HnswIndex::load(&*p, 4, DistanceMetric::Cosine).expect("Failed to load");
let results = loaded.search(&[1.0, 0.0, 0.0, 0.0], 3);
assert!(!results.is_empty());
}
})
})
.collect();
for h in handles {
h.join().expect("Thread should not panic from Drop");
}
}
#[test]
fn test_drop_safety_search_after_partial_operations() {
use tempfile::tempdir;
let dir = tempdir().expect("Failed to create temp dir");
{
let index = HnswIndex::new(8, DistanceMetric::Euclidean).unwrap();
for i in 0u64..20 {
let v: Vec<f32> = (0..8).map(|j| (i + j) as f32 * 0.1).collect();
index.insert(i, &v);
}
index.save(dir.path()).expect("Failed to save");
}
let loaded = HnswIndex::load(dir.path(), 8, DistanceMetric::Euclidean).expect("Failed to load");
for i in 0..10 {
let query: Vec<f32> = (0..8).map(|j| (i + j) as f32 * 0.1).collect();
let results = loaded.search(&query, 5);
assert!(results.len() <= 5);
}
let queries: Vec<Vec<f32>> = (0..5)
.map(|i| (0..8).map(|j| (i + j) as f32 * 0.1).collect())
.collect();
let query_refs: Vec<&[f32]> = queries.iter().map(|v| v.as_slice()).collect();
let batch_results = loaded
.search_batch_parallel(&query_refs, 3, SearchQuality::Balanced)
.unwrap();
assert_eq!(batch_results.len(), 5);
drop(loaded);
}
#[test]
fn test_drop_stress_concurrent_create_destroy_loop() {
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
let success_count = Arc::new(AtomicUsize::new(0));
let iterations = 50;
for _ in 0..iterations {
let success = Arc::clone(&success_count);
let index = Arc::new(HnswIndex::new(16, DistanceMetric::Cosine).unwrap());
let handles: Vec<_> = (0..4)
.map(|t| {
let idx = Arc::clone(&index);
std::thread::spawn(move || {
for i in 0..10 {
let id = (t * 100 + i) as u64;
let v: Vec<f32> = (0..16).map(|j| (id + j) as f32 * 0.01).collect();
idx.insert(id, &v);
}
let q: Vec<f32> = (0..16).map(|i| i as f32 * 0.01).collect();
let _ = idx.search(&q, 5);
})
})
.collect();
for h in handles {
h.join().expect("Thread panicked during stress test");
}
drop(index);
success.fetch_add(1, Ordering::SeqCst);
}
assert_eq!(
success_count.load(Ordering::SeqCst),
iterations,
"All iterations should complete without panic"
);
}
#[test]
fn test_drop_stress_load_search_destroy_cycle() {
use tempfile::tempdir;
let dir = tempdir().expect("Failed to create temp dir");
{
let index = HnswIndex::new(32, DistanceMetric::Euclidean).unwrap();
for i in 0u64..100 {
let v: Vec<f32> = (0..32).map(|j| ((i + j) as f32).sin()).collect();
index.insert(i, &v);
}
index.save(dir.path()).expect("Failed to save");
}
for cycle in 0..20 {
let loaded = HnswIndex::load(dir.path(), 32, DistanceMetric::Euclidean)
.unwrap_or_else(|e| panic!("Cycle {cycle}: Failed to load: {e}"));
for i in 0..50 {
let q: Vec<f32> = (0..32).map(|j| ((i + j) as f32).cos()).collect();
let results = loaded.search(&q, 10);
assert!(
results.len() <= 10,
"Cycle {cycle}: Search returned too many results"
);
}
drop(loaded);
}
}
#[test]
fn test_drop_stress_parallel_insert_then_drop() {
for _ in 0..5 {
let index = HnswIndex::new(64, DistanceMetric::Euclidean).unwrap();
let batch: Vec<(u64, Vec<f32>)> = (0..100)
.map(|i| {
let v: Vec<f32> = (0..64).map(|j| (i + j) as f32 * 0.01).collect();
(i as u64, v)
})
.collect();
let inserted = index.insert_batch_parallel(batch.iter().map(|(id, v)| (*id, v.as_slice())));
assert!(inserted > 0, "Should insert at least some vectors");
drop(index);
}
}
#[test]
#[cfg(feature = "gpu")]
fn test_search_brute_force_gpu_returns_same_results_as_cpu() {
let index = HnswIndex::new(128, DistanceMetric::Cosine).unwrap();
for i in 0u64..100 {
let v: Vec<f32> = (0..128)
.map(|j| ((i + j as u64) as f32 * 0.01).sin())
.collect();
index.insert(i, &v);
}
let query: Vec<f32> = (0..128).map(|j| (j as f32 * 0.02).cos()).collect();
let cpu_results = index.search_brute_force(&query, 10).unwrap();
if let Some(gpu_results) = index.search_brute_force_gpu(&query, 10).unwrap() {
assert_eq!(
cpu_results.len(),
gpu_results.len(),
"Result count mismatch"
);
let cpu_ids: std::collections::HashSet<u64> = cpu_results.iter().map(|sr| sr.id).collect();
let gpu_ids: std::collections::HashSet<u64> = gpu_results.iter().map(|sr| sr.id).collect();
let overlap = cpu_ids.intersection(&gpu_ids).count();
assert!(
overlap >= 8,
"GPU and CPU should return mostly same IDs (got {overlap}/10 overlap)"
);
}
}
#[test]
fn test_search_brute_force_gpu_fallback_to_none_without_gpu() {
let index = HnswIndex::new(64, DistanceMetric::Cosine).unwrap();
index.insert(1, &vec![0.5; 64]);
let query = vec![0.5; 64];
let result = index.search_brute_force_gpu(&query, 5).unwrap();
#[cfg(not(feature = "gpu"))]
assert!(result.is_none(), "Should return None without GPU feature");
#[cfg(feature = "gpu")]
let _ = result;
}
#[test]
#[cfg(feature = "gpu")]
fn test_brute_force_gpu_euclidean() {
let index = HnswIndex::new(4, DistanceMetric::Euclidean).unwrap();
index.insert(10, &[0.0, 0.0, 0.0, 0.0]);
index.insert(20, &[1.0, 0.0, 0.0, 0.0]);
index.insert(30, &[2.0, 0.0, 0.0, 0.0]);
let query = [0.0, 0.0, 0.0, 0.0];
if let Some(results) = index.search_brute_force_gpu(&query, 3).unwrap() {
assert_eq!(results.len(), 3, "Should return 3 results");
assert_eq!(results[0].id, 10, "Closest should be id=10 (at origin)");
assert_eq!(results[1].id, 20, "Second closest should be id=20");
assert_eq!(results[2].id, 30, "Farthest should be id=30");
assert!(
results[0].score.abs() < 0.01,
"Distance to origin should be ~0, got {}",
results[0].score
);
assert!(
(results[1].score - 1.0).abs() < 0.01,
"Distance to (1,0,0,0) should be ~1, got {}",
results[1].score
);
}
}
#[test]
#[cfg(feature = "gpu")]
fn test_brute_force_gpu_dot_product() {
let index = HnswIndex::new(4, DistanceMetric::DotProduct).unwrap();
index.insert(10, &[1.0, 0.0, 0.0, 0.0]); index.insert(20, &[2.0, 0.0, 0.0, 0.0]); index.insert(30, &[3.0, 0.0, 0.0, 0.0]);
let query = [1.0, 0.0, 0.0, 0.0];
if let Some(results) = index.search_brute_force_gpu(&query, 3).unwrap() {
assert_eq!(results.len(), 3, "Should return 3 results");
assert_eq!(
results[0].id, 30,
"Highest dot product should be id=30, got id={}",
results[0].id
);
assert_eq!(
results[1].id, 20,
"Second highest dot product should be id=20, got id={}",
results[1].id
);
assert_eq!(
results[2].id, 10,
"Lowest dot product should be id=10, got id={}",
results[2].id
);
assert!(
(results[0].score - 3.0).abs() < 0.01,
"Dot product with (3,0,0,0) should be ~3, got {}",
results[0].score
);
}
}
mod proptest_tests {
use super::*;
use proptest::prelude::*;
fn dimension_strategy() -> impl Strategy<Value = usize> {
8usize..=256
}
#[allow(dead_code)]
fn vector_strategy(dim: usize) -> impl Strategy<Value = Vec<f32>> {
proptest::collection::vec(-1.0f32..1.0, dim)
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(50))]
#[test]
fn prop_len_equals_insertions(
dim in dimension_strategy(),
vectors in proptest::collection::vec(
proptest::collection::vec(-1.0f32..1.0, 8usize..=64),
1usize..=20
)
) {
let index = HnswIndex::new(dim, DistanceMetric::Euclidean).unwrap();
let mut inserted = 0usize;
for (i, v) in vectors.into_iter().enumerate() {
if v.len() == dim {
index.insert(i as u64, &v);
inserted += 1;
}
}
prop_assert_eq!(index.len(), inserted);
}
#[test]
fn prop_search_returns_at_most_k(
dim in 16usize..=64,
k in 1usize..=20,
num_vectors in 5usize..=50
) {
let index = HnswIndex::new(dim, DistanceMetric::Euclidean).unwrap();
for i in 0..num_vectors {
let v: Vec<f32> = (0..dim).map(|j| ((i + j) as f32 * 0.01).sin()).collect();
index.insert(i as u64, &v);
}
let query: Vec<f32> = (0..dim).map(|j| (j as f32 * 0.02).cos()).collect();
let results = index.search(&query, k);
prop_assert!(results.len() <= k, "Search returned {} results, expected <= {}", results.len(), k);
}
#[test]
fn prop_brute_force_exact(
dim in 8usize..=32,
num_vectors in 3usize..=20
) {
let index = HnswIndex::new(dim, DistanceMetric::Euclidean).unwrap();
for i in 0..num_vectors {
let mut v = vec![0.0f32; dim];
v[0] = i as f32; index.insert(i as u64, &v);
}
let query = vec![0.0f32; dim];
let results = index.search_brute_force(&query, 3).unwrap();
if !results.is_empty() {
prop_assert_eq!(results[0].id, 0, "Closest should be id=0 (at origin)");
}
}
#[test]
fn prop_remove_decreases_len(
dim in 16usize..=32,
id_to_remove in 0u64..10
) {
let index = HnswIndex::new(dim, DistanceMetric::Cosine).unwrap();
for i in 0u64..10 {
let v: Vec<f32> = (0..dim).map(|j| ((i + j as u64) as f32 * 0.01).sin()).collect();
index.insert(i, &v);
}
let len_before = index.len();
let removed = index.remove(id_to_remove);
if removed {
prop_assert_eq!(index.len(), len_before - 1);
} else {
prop_assert_eq!(index.len(), len_before);
}
}
#[test]
fn prop_duplicate_insert_idempotent(
dim in 16usize..=32
) {
let index = HnswIndex::new(dim, DistanceMetric::Euclidean).unwrap();
let v: Vec<f32> = (0..dim).map(|j| j as f32 * 0.1).collect();
index.insert(42, &v);
let len_after_first = index.len();
index.insert(42, &v); let len_after_second = index.len();
prop_assert_eq!(len_after_first, len_after_second, "Duplicate insert should be idempotent");
}
#[test]
fn prop_batch_insert_count(
dim in 16usize..=32,
batch_size in 5usize..=30
) {
let index = HnswIndex::new(dim, DistanceMetric::Euclidean).unwrap();
let batch: Vec<(u64, Vec<f32>)> = (0..batch_size)
.map(|i| {
let v: Vec<f32> = (0..dim).map(|j| ((i + j) as f32 * 0.01).sin()).collect();
(i as u64, v)
})
.collect();
let count = index.insert_batch_parallel(batch.iter().map(|(id, v)| (*id, v.as_slice())));
prop_assert_eq!(count, batch_size, "Batch insert count mismatch");
prop_assert_eq!(index.len(), batch_size, "Index len mismatch after batch");
}
}
}
#[test]
fn test_manuallydrop_pattern_integrity() {
let index = HnswIndex::new(64, DistanceMetric::Cosine).unwrap();
for i in 0..10 {
let v: Vec<f32> = (0..64).map(|j| (i + j) as f32 * 0.01).collect();
index.insert(i as u64, &v);
}
assert_eq!(
index.len(),
10,
"all 10 vectors must be present before drop"
);
let results = index.search(&vec![0.0f32; 64], 5);
assert!(
!results.is_empty() && results.len() <= 5,
"index must be searchable before drop"
);
drop(index);
}
#[test]
fn test_load_and_drop_safety() {
use tempfile::TempDir;
let temp_dir = TempDir::new().expect("Failed to create temp dir");
let path = temp_dir.path();
{
let index = HnswIndex::new(64, DistanceMetric::Cosine).unwrap();
for i in 0..50 {
let v: Vec<f32> = (0..64).map(|j| (i + j) as f32 * 0.01).collect();
index.insert(i as u64, &v);
}
index.save(path).expect("Save failed");
}
for _ in 0..3 {
let loaded = HnswIndex::load(path, 64, DistanceMetric::Cosine).expect("Load failed");
let results = loaded.search(&vec![0.0f32; 64], 5);
assert!(!results.is_empty(), "Search should return results");
drop(loaded);
}
}
#[test]
#[allow(clippy::cast_precision_loss)]
fn test_adaptive_search_spread_positive_for_distance_metrics() {
let dim = 32;
let index = HnswIndex::new(dim, DistanceMetric::Euclidean).unwrap();
for i in 0u64..120 {
let v: Vec<f32> = (0..dim)
.map(|j| ((i + j as u64) as f32 * 0.001).sin() * 0.1)
.collect();
index.insert(i, &v);
}
for i in 120u64..150 {
let v: Vec<f32> = (0..dim)
.map(|j| 10.0 + (i + j as u64) as f32 * 0.05)
.collect();
index.insert(i, &v);
}
let query: Vec<f32> = vec![0.0; dim];
let results = index
.search_with_quality(
&query,
10,
SearchQuality::Adaptive {
min_ef: 32,
max_ef: 256,
},
)
.unwrap();
assert!(
!results.is_empty(),
"Adaptive Euclidean search should return results"
);
assert!(results.len() <= 10, "Should not exceed requested k");
for pair in results.windows(2) {
assert!(
pair[0].score <= pair[1].score + f32::EPSILON,
"Euclidean results must be sorted ascending: {} > {}",
pair[0].score,
pair[1].score,
);
}
let closest_id = results[0].id;
assert!(
closest_id < 120,
"Closest result should be from the tight cluster, got id={closest_id}"
);
}
#[test]
#[allow(clippy::cast_precision_loss)]
fn test_adaptive_search_spread_works_for_similarity_metrics() {
let dim = 32;
let index = HnswIndex::new(dim, DistanceMetric::Cosine).unwrap();
for i in 0u64..150 {
let v: Vec<f32> = (0..dim)
.map(|j| ((i * 7 + j as u64) as f32 * 0.013).sin())
.collect();
index.insert(i, &v);
}
let query: Vec<f32> = (0..dim).map(|j| (j as f32 * 0.013).sin()).collect();
let results = index
.search_with_quality(
&query,
10,
SearchQuality::Adaptive {
min_ef: 32,
max_ef: 256,
},
)
.unwrap();
assert!(
!results.is_empty(),
"Adaptive Cosine search should return results"
);
assert!(results.len() <= 10, "Should not exceed requested k");
for pair in results.windows(2) {
assert!(
pair[0].score >= pair[1].score - f32::EPSILON,
"Cosine results must be sorted descending: {} < {}",
pair[0].score,
pair[1].score,
);
}
}
#[test]
fn test_insert_same_id_updates_vector() {
let index = HnswIndex::new(4, DistanceMetric::Cosine).unwrap();
let vector_a = [1.0, 0.0, 0.0, 0.0];
index.insert(1, &vector_a);
let vector_b = [0.0, 1.0, 0.0, 0.0];
index.insert(1, &vector_b);
assert_eq!(index.len(), 1, "Upsert must not create duplicate entries");
let results = index.search(&vector_b, 1);
assert_eq!(results.len(), 1, "Should find exactly one result");
assert_eq!(results[0].id, 1, "Result must be id=1");
assert!(
results[0].score > 0.9,
"Similarity to updated vector B should be > 0.9, got {}",
results[0].score,
);
}
#[test]
fn test_upsert_tombstone_accumulation() {
let dim = 10;
let index = HnswIndex::new(dim, DistanceMetric::Cosine).unwrap();
for i in 0..10_usize {
index.insert(i as u64, &make_dominant_vector(dim, i, 0));
}
assert_eq!(
index.tombstone_count(),
0,
"Fresh index should have 0 tombstones"
);
assert_eq!(index.len(), 10, "Should have 10 active vectors");
for i in 0..10_usize {
index.insert(i as u64, &make_dominant_vector(dim, i, 1));
}
assert_eq!(
index.tombstone_count(),
10,
"Updating 10 vectors should leave 10 tombstones",
);
assert_eq!(index.len(), 10, "Active count must remain 10 after upserts");
assert!(
index.needs_vacuum(),
"Tombstone ratio {:.2} should exceed 0.20 threshold",
index.tombstone_ratio(),
);
}
#[test]
fn test_upsert_then_vacuum_cleans() {
let dim = 10;
let index = HnswIndex::new(dim, DistanceMetric::Cosine).unwrap();
for i in 0..10_usize {
index.insert(i as u64, &make_dominant_vector(dim, i, 0));
}
for i in 0..5_usize {
index.insert(i as u64, &make_dominant_vector(dim, i, 1));
}
assert_eq!(
index.tombstone_count(),
5,
"Updating 5 vectors should create 5 tombstones",
);
assert_eq!(index.len(), 10);
let rebuilt = index.vacuum().expect("vacuum should succeed");
assert_eq!(rebuilt, 10, "Vacuum should report 10 active vectors");
assert_eq!(
index.tombstone_count(),
0,
"Vacuum must eliminate all tombstones",
);
assert_eq!(index.len(), 10, "All 10 vectors must survive vacuum");
for i in 0..10_usize {
let gen = u8::from(i < 5);
let query = make_dominant_vector(dim, i, gen);
let results = index.search(&query, 1);
assert!(
!results.is_empty(),
"Search for id={i} returned no results after vacuum",
);
assert_eq!(
results[0].id, i as u64,
"Top-1 for id={i} should be itself, got id={}",
results[0].id,
);
}
}
#[test]
fn test_batch_upsert_updates_existing() {
let dim = 10;
let index = HnswIndex::new(dim, DistanceMetric::Cosine).unwrap();
let original_vectors: Vec<(u64, Vec<f32>)> = (0..10)
.map(|id| (id, make_dominant_vector(dim, id as usize, 0)))
.collect();
let refs: Vec<(u64, &[f32])> = original_vectors
.iter()
.map(|(id, v)| (*id, v.as_slice()))
.collect();
let inserted = index.insert_batch_parallel(refs);
assert_eq!(inserted, 10, "Should insert all 10 vectors");
assert_eq!(index.len(), 10, "Index should contain 10 entries");
let updated_vectors: Vec<(u64, Vec<f32>)> = (0..5)
.map(|id| (id, make_dominant_vector(dim, id as usize, 1)))
.collect();
let update_refs: Vec<(u64, &[f32])> = updated_vectors
.iter()
.map(|(id, v)| (*id, v.as_slice()))
.collect();
let upserted = index.insert_batch_parallel(update_refs);
assert_eq!(upserted, 5, "Should upsert all 5 vectors");
assert_eq!(
index.len(),
10,
"Batch upsert must not create duplicate entries: expected 10, got {}",
index.len(),
);
for id in 0..5_u64 {
let query = make_dominant_vector(dim, id as usize, 1);
let results = index.search(&query, 1);
assert_eq!(
results.len(),
1,
"Updated id={id}: should find exactly one result"
);
assert_eq!(
results[0].id, id,
"Updated id={id}: top-1 result should be the updated vector"
);
assert!(
results[0].score > 0.9,
"Updated id={id}: similarity to updated vector should be > 0.9, got {}",
results[0].score,
);
}
for id in 5..10_u64 {
let query = make_dominant_vector(dim, id as usize, 0);
let results = index.search(&query, 1);
assert!(
!results.is_empty(),
"Non-updated id={id}: should find results"
);
assert_eq!(
results[0].id, id,
"Non-updated id={id}: top-1 result should be the original vector"
);
}
}
#[allow(clippy::similar_names)] #[test]
fn test_upsert_search_recall() {
let index = HnswIndex::new(8, DistanceMetric::Cosine).unwrap();
let origin_center: Vec<f32> = vec![1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0];
let target_center: Vec<f32> = vec![0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0];
let origin_vectors: Vec<(u64, Vec<f32>)> = (0..50)
.map(|i| {
let mut v = origin_center.clone();
for (d, component) in v.iter_mut().enumerate() {
*component += 0.01 * (i * 8 + d) as f32;
}
(i as u64, v)
})
.collect();
let target_vectors: Vec<(u64, Vec<f32>)> = (0..50)
.map(|i| {
let mut v = target_center.clone();
for (d, component) in v.iter_mut().enumerate() {
*component += 0.01 * (i * 8 + d) as f32;
}
(i as u64 + 50, v)
})
.collect();
let all_vectors: Vec<(u64, Vec<f32>)> = origin_vectors
.iter()
.chain(target_vectors.iter())
.map(|(id, v)| (*id, v.clone()))
.collect();
let refs: Vec<(u64, &[f32])> = all_vectors
.iter()
.map(|(id, v)| (*id, v.as_slice()))
.collect();
let inserted = index.insert_batch_parallel(refs);
assert_eq!(inserted, 100, "Should insert all 100 vectors");
let moved_vector: Vec<f32> = vec![0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0];
index.insert(25, &moved_vector);
assert_eq!(index.len(), 100, "Length must remain 100 after upsert");
let results_target = index.search(&target_center, 10);
let found_in_target = results_target.iter().any(|r| r.id == 25);
assert!(
found_in_target,
"id=25 should appear in target cluster results after upsert, got ids: {:?}",
results_target.iter().map(|r| r.id).collect::<Vec<_>>(),
);
let results_origin = index.search(&origin_center, 10);
let found_in_origin = results_origin.iter().any(|r| r.id == 25);
assert!(
!found_in_origin,
"id=25 should NOT appear in origin cluster results after upsert, got ids: {:?}",
results_origin.iter().map(|r| r.id).collect::<Vec<_>>(),
);
}
#[test]
fn test_upsert_entry_point_vector() {
let index = HnswIndex::new(4, DistanceMetric::Cosine).unwrap();
let ep_original = vec![1.0, 0.0, 0.0, 0.0];
index.insert(0, &ep_original);
let ep_updated = vec![0.0, 0.0, 0.0, 1.0];
index.insert(0, &ep_updated);
let additional_vectors: Vec<(u64, Vec<f32>)> = (1..=20)
.map(|i| {
let dominant_dim = (i as usize) % 4;
let mut v = vec![0.1_f32; 4];
v[dominant_dim] += 1.0 + 0.05 * i as f32;
(i as u64, v)
})
.collect();
let refs: Vec<(u64, &[f32])> = additional_vectors
.iter()
.map(|(id, v)| (*id, v.as_slice()))
.collect();
let inserted = index.insert_batch_parallel(refs);
assert_eq!(inserted, 20, "Should insert all 20 vectors");
assert_eq!(index.len(), 21, "Index should contain 21 entries");
let results_ep = index.search(&ep_updated, 1);
assert_eq!(results_ep.len(), 1, "Should find at least 1 result for EP");
assert_eq!(
results_ep[0].id, 0,
"Top-1 for updated entry point should be id=0, got id={}",
results_ep[0].id,
);
for (id, vec) in &additional_vectors {
let results = index.search(vec.as_slice(), 1);
assert!(
!results.is_empty(),
"Search for id={id} should return results",
);
assert_eq!(
results[0].id, *id,
"Top-1 for id={id} should be itself, got id={}",
results[0].id,
);
}
}
#[test]
fn test_batch_upsert_within_batch_duplicates() {
let dim = 4;
let index = HnswIndex::new(dim, DistanceMetric::Cosine).unwrap();
let vec_a = vec![1.0_f32, 0.0, 0.0, 0.0];
let vec_b = vec![0.0_f32, 1.0, 0.0, 0.0];
let batch: Vec<(u64, &[f32])> = vec![(1, &vec_a), (1, &vec_b), (2, &vec_a)];
let inserted = index.insert_batch_parallel(batch);
assert!(
inserted >= 2,
"At least 2 entries processed, got {inserted}"
);
assert_eq!(index.len(), 2, "Only 2 unique IDs should be mapped");
let results = index.search(&vec_b, 1);
assert_eq!(results.len(), 1);
assert_eq!(
results[0].id, 1,
"id=1 must be findable after within-batch upsert"
);
assert!(
results[0].score > 0.9,
"id=1 should match vec_b, got score {}",
results[0].score,
);
let results2 = index.search(&vec_a, 1);
assert_eq!(results2[0].id, 2, "id=2 must be findable");
}
fn make_dominant_vector(dim: usize, dominant_dim: usize, gen: u8) -> Vec<f32> {
let mut v = vec![0.1_f32; dim];
v[dominant_dim % dim] += 1.0 + f32::from(gen) * 0.5;
v
}
#[test]
fn test_upsert_after_delete() {
let dim = 8;
let index = HnswIndex::new(dim, DistanceMetric::Cosine).unwrap();
let vec_a = make_dominant_vector(dim, 0, 0);
index.insert(1, &vec_a);
assert_eq!(index.len(), 1);
assert!(index.remove(1));
assert_eq!(index.len(), 0);
let vec_b = make_dominant_vector(dim, 4, 1);
index.insert(1, &vec_b);
assert_eq!(index.len(), 1, "Re-inserted id should count as 1 vector");
let results = index.search(&vec_b, 1);
assert_eq!(results.len(), 1);
assert_eq!(
results[0].id, 1,
"After delete + re-insert, search must find re-inserted id=1"
);
}
#[test]
fn test_repeated_upsert_accumulates_tombstones() {
let dim = 8;
let index = HnswIndex::new(dim, DistanceMetric::Cosine).unwrap();
let num_upserts: usize = 20;
for gen in 0..num_upserts {
let v = make_dominant_vector(dim, gen % dim, gen as u8);
index.insert(1, &v);
}
assert_eq!(
index.len(),
1,
"Repeated upserts must not duplicate entries"
);
assert_eq!(
index.tombstone_count(),
num_upserts - 1,
"Each upsert after the first should leave one tombstone"
);
let latest = make_dominant_vector(dim, (num_upserts - 1) % dim, (num_upserts - 1) as u8);
let results = index.search(&latest, 1);
assert_eq!(results.len(), 1);
assert_eq!(
results[0].id, 1,
"Search must return the latest upserted vector"
);
}
#[test]
fn test_an_id_is_mapped_to_the_slot_the_graph_gave() {
let dim = 4;
let index = HnswIndex::new(dim, DistanceMetric::Euclidean).unwrap();
index.insert(100, &[1.0, 0.0, 0.0, 0.0]);
assert_eq!(index.mappings.get_idx(100), Some(0));
assert_eq!(index.mappings.get_id(0), Some(100));
let unmapped = index.inner.read().insert(&[0.0, 1.0, 0.0, 0.0]).unwrap();
assert_eq!(unmapped, 1, "the graph hands out the next slot");
assert!(index.insert_and_assign(200, &[0.0, 0.0, 1.0, 0.0]));
assert_eq!(
index.mappings.get_idx(200),
Some(2),
"id 200 must name the slot holding its vector"
);
assert_eq!(index.mappings.get_id(2), Some(200));
assert_eq!(
index.mappings.get_id(1),
None,
"the unmapped slot stays unmapped"
);
assert_eq!(index.len(), 2);
}
#[test]
fn test_search_finds_an_id_placed_after_an_unmapped_slot() {
let dim = 4;
let index = HnswIndex::new(dim, DistanceMetric::Euclidean).unwrap();
index.insert(100, &[1.0, 0.0, 0.0, 0.0]);
index.inner.read().insert(&[0.5, 0.5, 0.0, 0.0]).unwrap();
index.insert(200, &[0.0, 0.0, 0.0, 1.0]);
let results = index.search(&[0.0, 0.0, 0.0, 1.0], 1);
assert_eq!(results.len(), 1);
assert_eq!(results[0].id, 200, "search must resolve the slot to id 200");
}
#[test]
fn test_a_batch_repeating_an_id_keeps_its_last_vector() {
let index = HnswIndex::new(4, DistanceMetric::Euclidean).unwrap();
let batch: Vec<(u64, &[f32])> = vec![
(7, &[1.0, 0.0, 0.0, 0.0]),
(8, &[0.0, 1.0, 0.0, 0.0]),
(7, &[0.0, 0.0, 1.0, 0.0]),
];
assert_eq!(index.insert_batch_parallel(batch), 3);
assert_eq!(index.len(), 2);
assert_eq!(
index.mappings.get_idx(7),
Some(2),
"id 7 follows its last occurrence"
);
assert_eq!(
index.mappings.get_id(0),
None,
"id 7's first slot must be retired"
);
let results = index.search(&[0.0, 0.0, 1.0, 0.0], 1);
assert_eq!(results[0].id, 7);
}
#[test]
fn test_batch_after_single_insert_mapping_consistency() {
let index = HnswIndex::new(4, DistanceMetric::Euclidean).unwrap();
index.insert(100, &[1.0, 0.0, 0.0, 0.0]);
assert_eq!(index.len(), 1);
let batch: Vec<(u64, &[f32])> = vec![
(200, &[0.0, 1.0, 0.0, 0.0]),
(201, &[0.0, 0.0, 1.0, 0.0]),
(202, &[0.0, 0.0, 0.0, 1.0]),
(203, &[0.5, 0.5, 0.0, 0.0]),
(204, &[0.0, 0.5, 0.5, 0.0]),
];
let inserted = index.insert_batch_parallel(batch);
assert_eq!(inserted, 5);
assert_eq!(index.len(), 6);
for &ext_id in &[100u64, 200, 201, 202, 203, 204] {
let idx = index.mappings.get_idx(ext_id);
assert!(idx.is_some(), "get_idx({ext_id}) must return Some");
let reverse = index.mappings.get_id(idx.unwrap());
assert_eq!(
reverse,
Some(ext_id),
"Reverse mapping for idx {} must be {ext_id}, got {reverse:?}",
idx.unwrap()
);
}
let results = index.search(&[1.0, 0.0, 0.0, 0.0], 1);
assert_eq!(results.len(), 1);
assert_eq!(results[0].id, 100, "Nearest to [1,0,0,0] must be id=100");
}
#[test]
fn test_batch_insert_vector_storage_uses_assigned_ids() {
let index = HnswIndex::new(4, DistanceMetric::Euclidean).unwrap();
for i in 0..3u64 {
let v = [i as f32, 0.0, 0.0, 0.0];
index.insert(i, &v);
}
assert_eq!(index.len(), 3);
let batch: Vec<(u64, &[f32])> = vec![
(10, &[10.0, 0.0, 0.0, 0.0]),
(11, &[11.0, 0.0, 0.0, 0.0]),
(12, &[12.0, 0.0, 0.0, 0.0]),
(13, &[13.0, 0.0, 0.0, 0.0]),
];
let inserted = index.insert_batch_parallel(batch);
assert_eq!(inserted, 4);
for ext_id in 10..=13u64 {
let idx = index.mappings.get_idx(ext_id).expect("mapping must exist");
let stored = index
.inner
.read()
.with_contiguous_vectors(|cv| cv.get(idx).map(<[f32]>::to_vec));
assert!(
stored.is_some(),
"ContiguousVectors must have a vector at idx {idx} for id {ext_id}"
);
let expected_first = ext_id as f32;
let stored_vec = stored.unwrap();
assert!(
(stored_vec[0] - expected_first).abs() < f32::EPSILON,
"Vector for id {ext_id} at idx {idx}: expected first component {expected_first}, got {}",
stored_vec[0]
);
}
}
#[test]
fn test_batch_upsert_mapping_consistency() {
let index = HnswIndex::new(4, DistanceMetric::Euclidean).unwrap();
for i in 0..5u64 {
index.insert(i, &[i as f32, 0.0, 0.0, 0.0]);
}
assert_eq!(index.len(), 5);
let batch: Vec<(u64, &[f32])> = vec![
(1, &[100.0, 0.0, 0.0, 0.0]),
(2, &[200.0, 0.0, 0.0, 0.0]),
(3, &[300.0, 0.0, 0.0, 0.0]),
];
let inserted = index.insert_batch_parallel(batch);
assert_eq!(inserted, 3);
assert_eq!(index.len(), 5);
for ext_id in 0..5u64 {
let idx = index.mappings.get_idx(ext_id);
assert!(idx.is_some(), "get_idx({ext_id}) must return Some");
let reverse = index.mappings.get_id(idx.unwrap());
assert_eq!(
reverse,
Some(ext_id),
"Reverse mapping for idx {} must be {ext_id}, got {reverse:?}",
idx.unwrap()
);
}
let results = index.search(&[100.0, 0.0, 0.0, 0.0], 1);
assert_eq!(results.len(), 1);
assert_eq!(results[0].id, 1, "Nearest to [100,0,0,0] must be id=1");
}
#[test]
fn test_search_with_quality_and_bitmap_exists() {
let index = HnswIndex::new(3, DistanceMetric::Cosine).unwrap();
for i in 0u64..20 {
#[allow(clippy::cast_precision_loss)]
let v = vec![i as f32 * 0.1, 1.0 - i as f32 * 0.05, 0.5];
index.insert(i, &v);
}
let mut bitmap = roaring::RoaringBitmap::new();
for i in 0u32..10 {
bitmap.insert(i);
}
let results = index
.search_with_quality_and_bitmap(&[0.5, 0.5, 0.5], 5, SearchQuality::Balanced, &bitmap)
.unwrap();
assert!(!results.is_empty(), "should return results");
for sr in &results {
assert!(
sr.id < 10,
"all result IDs should be in bitmap, got id={}",
sr.id
);
}
}
#[test]
fn test_ids_exceeding_u32_max_pass_through() {
let index = HnswIndex::new(3, DistanceMetric::Cosine).unwrap();
index.insert(1, &[1.0, 0.0, 0.0]);
index.insert(2, &[0.0, 1.0, 0.0]);
index.insert(3, &[0.0, 0.0, 1.0]);
let mut bitmap = roaring::RoaringBitmap::new();
bitmap.insert(1);
let results = index
.search_with_quality_and_bitmap(&[1.0, 0.0, 0.0], 3, SearchQuality::Balanced, &bitmap)
.unwrap();
for sr in &results {
assert_eq!(
sr.id, 1,
"only id=1 should pass bitmap filter, got {}",
sr.id
);
}
}
#[cfg(test)]
mod bitmap_property_tests {
use super::*;
use proptest::prelude::*;
proptest! {
#![proptest_config(ProptestConfig::with_cases(100))]
#[test]
fn prop_bitmap_filtering_invariant(
bitmap_ids in proptest::collection::vec(0u32..200, 1..50),
quality_idx in 0u8..3,
) {
let dim = 8;
let index = HnswIndex::new(dim, DistanceMetric::Cosine).unwrap();
for i in 0u64..200 {
#[allow(clippy::cast_precision_loss)]
let v: Vec<f32> = (0..dim).map(|d| ((i + d as u64) as f32 * 0.01).sin()).collect();
index.insert(i, &v);
}
let mut bitmap = roaring::RoaringBitmap::new();
for &id in &bitmap_ids {
bitmap.insert(id);
}
let quality = match quality_idx {
0 => SearchQuality::Fast,
1 => SearchQuality::Balanced,
_ => SearchQuality::Accurate,
};
let query: Vec<f32> = (0..dim).map(|d| (d as f32 * 0.05).cos()).collect();
let results = index
.search_with_quality_and_bitmap(&query, 10, quality, &bitmap)
.unwrap();
for sr in &results {
let in_bitmap = u32::try_from(sr.id)
.is_ok_and(|id32| bitmap.contains(id32));
let exceeds_u32 = u32::try_from(sr.id).is_err();
prop_assert!(
in_bitmap || exceeds_u32,
"result id={} must be in bitmap or exceed u32::MAX",
sr.id
);
}
}
}
}
#[test]
fn test_full_scan_empty_bitmap_returns_empty() {
let index = HnswIndex::new(3, DistanceMetric::Cosine).unwrap();
index.insert(1, &[1.0, 0.0, 0.0]);
index.insert(2, &[0.0, 1.0, 0.0]);
let bitmap = roaring::RoaringBitmap::new();
let results = index
.full_scan_with_bitmap(&[1.0, 0.0, 0.0], 5, &bitmap)
.unwrap();
assert!(
results.is_empty(),
"empty bitmap should return empty results"
);
}
#[test]
#[allow(clippy::cast_precision_loss)]
fn test_full_scan_returns_exact_results() {
let dim = 8;
let index = HnswIndex::new(dim, DistanceMetric::Cosine).unwrap();
for i in 0u64..20 {
let v: Vec<f32> = (0..dim)
.map(|d| ((i + d as u64) as f32 * 0.1).sin())
.collect();
index.insert(i, &v);
}
let mut bitmap = roaring::RoaringBitmap::new();
for i in 0u32..5 {
bitmap.insert(i);
}
let query: Vec<f32> = (0..dim).map(|d| (d as f32 * 0.1).sin()).collect();
let results = index.full_scan_with_bitmap(&query, 3, &bitmap).unwrap();
assert!(!results.is_empty(), "should return results");
assert!(results.len() <= 3, "should return at most k results");
for sr in &results {
assert!(
sr.id < 5,
"all results should be from bitmap, got id={}",
sr.id
);
}
assert_eq!(results[0].id, 0, "closest vector should be id=0");
}
#[cfg(test)]
mod full_scan_property_tests {
use super::*;
use proptest::prelude::*;
proptest! {
#![proptest_config(ProptestConfig::with_cases(100))]
#[test]
#[expect(clippy::significant_drop_tightening)] fn prop_full_scan_exactness(
n_vectors in 5u64..50,
bitmap_ids in proptest::collection::vec(0u32..50, 1..20),
k in 1usize..10,
) {
let dim = 4;
let index = HnswIndex::new(dim, DistanceMetric::Cosine).unwrap();
for i in 0..n_vectors {
#[allow(clippy::cast_precision_loss)]
let v: Vec<f32> = (0..dim)
.map(|d| ((i + d as u64) as f32 * 0.07).sin())
.collect();
index.insert(i, &v);
}
let mut bitmap = roaring::RoaringBitmap::new();
for &id in &bitmap_ids {
if u64::from(id) < n_vectors {
bitmap.insert(id);
}
}
if bitmap.is_empty() {
return Ok(());
}
#[allow(clippy::cast_precision_loss)]
let query: Vec<f32> = (0..dim).map(|d| (d as f32 * 0.05).cos()).collect();
let results = index
.full_scan_with_bitmap(&query, k, &bitmap)
.unwrap();
let mut naive: Vec<(u64, f32)> = Vec::new();
for id32 in &bitmap {
let id = u64::from(id32);
if let Some(idx) = index.mappings.get_idx(id) {
let inner = index.inner.read();
let score = inner.with_contiguous_vectors(|vectors| {
vectors.get(idx).map(|v| index.compute_distance(&query, v))
});
if let Some(s) = score {
naive.push((id, s));
}
}
}
naive.sort_by(|a, b| b.1.total_cmp(&a.1));
naive.truncate(k);
prop_assert_eq!(
results.len(),
naive.len(),
"result count mismatch"
);
for (sr, (expected_id, expected_score)) in results.iter().zip(naive.iter()) {
prop_assert_eq!(sr.id, *expected_id, "ID mismatch");
prop_assert!(
(sr.score - expected_score).abs() < 1e-6,
"score mismatch for id={}: got {}, expected {}",
sr.id, sr.score, expected_score
);
}
}
}
}
#[cfg(feature = "persistence")]
#[test]
fn test_hnsw_save_load_roundtrips_rabitq_backend_and_quantizer() {
use crate::quantization::{RaBitQIndex, StorageMode};
let dir = tempfile::tempdir().expect("temp dir");
let dim = 32;
let mut params = HnswParams::auto(dim);
params.storage_mode = StorageMode::RaBitQ;
let index = HnswIndex::with_params(dim, DistanceMetric::Euclidean, params).expect("create");
let vectors: Vec<Vec<f32>> = (0..200)
.map(|i| {
(0..dim)
.map(|d| ((i * dim + d) as f32 * 0.01).sin())
.collect()
})
.collect();
for (i, v) in vectors.iter().enumerate() {
VectorIndex::insert(&index, i as u64, v);
}
RaBitQIndex::train(&vectors, 42)
.expect("train")
.save(dir.path())
.expect("save rabitq.idx");
index.save(dir.path()).expect("save index");
let loaded = HnswIndex::load(dir.path(), dim, DistanceMetric::Euclidean).expect("load");
assert!(
loaded.is_rabitq_quantizer_trained(),
"persisted storage mode + rabitq.idx must restore a trained RaBitQ backend"
);
let results = VectorIndex::search(&loaded, &vectors[42], 5);
assert_eq!(
results.first().map(|r| r.id),
Some(42),
"self-query must return itself as top-1 after reload"
);
}
#[test]
fn test_with_params_propagates_alpha_to_native_graph() {
let params = HnswParams::auto(4).with_alpha(1.5);
let index = HnswIndex::with_params(4, crate::DistanceMetric::Cosine, params)
.expect("with_params must succeed with valid alpha");
let actual = index.inner.read().alpha();
assert!(
(actual - 1.5).abs() < f32::EPSILON,
"alpha 1.5 must propagate to native graph, got {actual}"
);
}
fn resume_test_vector(dim: usize, seed: u64) -> Vec<f32> {
let mut state = seed.wrapping_mul(0x9E37_79B9_7F4A_7C15) | 1;
let mut v: Vec<f32> = (0..dim)
.map(|_| {
state ^= state << 13;
state ^= state >> 7;
state ^= state << 17;
(state as f64 / u64::MAX as f64) as f32 - 0.5
})
.collect();
let norm = v.iter().map(|x| x * x).sum::<f32>().sqrt();
for x in &mut v {
*x /= norm;
}
v
}
#[test]
fn bitmap_underfill_retry_widens_ef_and_finds_more_matches() {
const DIM: usize = 64;
const N: u64 = 1_500;
const K: usize = 20;
let index = HnswIndex::new(DIM, DistanceMetric::Cosine).unwrap();
for id in 0..N {
index.insert(id, &resume_test_vector(DIM, id + 1));
}
let query = resume_test_vector(DIM, 987_654_321);
let mut ranked: Vec<(u64, f32)> = (0..N)
.map(|id| {
let v = resume_test_vector(DIM, id + 1);
let dot: f32 = query.iter().zip(&v).map(|(a, b)| a * b).sum();
(id, dot)
})
.collect();
ranked.sort_by(|a, b| b.1.total_cmp(&a.1));
let allowed: Vec<u64> = ranked[300..380].iter().map(|&(id, _)| id).collect();
let mut bitmap = roaring::RoaringBitmap::new();
for &id in &allowed {
bitmap.insert(u32::try_from(id).unwrap());
}
let quality = SearchQuality::Balanced;
let ef = quality.ef_search_for_scale(K, index.len());
let candidates_k = K.saturating_mul(4).max(ef);
let base = index.search_hnsw_only_filtered(&query, candidates_k, ef, &bitmap);
let same_ef_double_k = index.search_hnsw_only_filtered(&query, candidates_k * 2, ef, &bitmap);
let base_ids: Vec<u64> = base.iter().map(|r| r.id).collect();
let double_k_ids: Vec<u64> = same_ef_double_k.iter().map(|r| r.id).collect();
assert_eq!(
base_ids, double_k_ids,
"same-ef retry must be a no-op: candidates_k is not the exploration knob"
);
assert!(
base.len() < K,
"test setup: base pass must under-fill (got {} >= K={K})",
base.len()
);
let full = index
.search_with_quality_and_bitmap(&query, K, quality, &bitmap)
.unwrap();
assert!(
full.len() > base.len(),
"widened-ef retry must find more allowed matches (base {}, full {})",
base.len(),
full.len()
);
}
#[test]
fn adaptive_resume_is_deterministic_across_pool_reuse() {
const DIM: usize = 128;
const N: u64 = 1_200;
const K: usize = 10;
let index = HnswIndex::new(DIM, DistanceMetric::Cosine).unwrap();
let pocket_dir = resume_test_vector(DIM, 42);
for id in 0..4u64 {
let mut v = pocket_dir.clone();
let noise = resume_test_vector(DIM, 1_000 + id);
for (x, n) in v.iter_mut().zip(&noise) {
*x += 0.05 * n;
}
let norm = v.iter().map(|x| x * x).sum::<f32>().sqrt();
for x in &mut v {
*x /= norm;
}
index.insert(id, &v);
}
for id in 4..N {
index.insert(id, &resume_test_vector(DIM, id + 1));
}
let quality = SearchQuality::Adaptive {
min_ef: 32,
max_ef: 64,
};
let first = index.search_with_quality(&pocket_dir, K, quality).unwrap();
assert!(!first.is_empty());
for _ in 0..50 {
let run = index.search_with_quality(&pocket_dir, K, quality).unwrap();
let first_ids: Vec<u64> = first.iter().map(|r| r.id).collect();
let run_ids: Vec<u64> = run.iter().map(|r| r.id).collect();
assert_eq!(
first_ids, run_ids,
"adaptive resume drifted across pooled reuse"
);
}
}
fn fast_insert_and_twin() -> (HnswIndex, HnswIndex, Vec<f32>) {
let index = HnswIndex::new_fast_insert(128, DistanceMetric::Cosine).unwrap();
let twin = HnswIndex::new(128, DistanceMetric::Cosine).unwrap();
for i in 0u64..100 {
let v: Vec<f32> = (0..128)
.map(|j| ((i + j as u64) as f32 * 0.01).sin())
.collect();
index.insert(i, &v);
twin.insert(i, &v);
}
let query = (0..128).map(|j| (j as f32 * 0.02).cos()).collect();
(index, twin, query)
}
#[test]
fn cpu_brute_force_returns_nothing_with_exact_distance_features_off() {
let (index, twin, query) = fast_insert_and_twin();
assert_eq!(
twin.brute_force_search_parallel(&query, 10).unwrap().len(),
10
);
assert!(index
.brute_force_search_parallel(&query, 10)
.unwrap()
.is_empty());
}
#[cfg(feature = "gpu")]
#[test]
fn gpu_brute_force_returns_nothing_with_exact_distance_features_off() {
let (index, twin, query) = fast_insert_and_twin();
if !crate::gpu::GpuAccelerator::is_available() {
eprintln!("GPU unavailable: the exact-distance guard is not exercised here");
return;
}
let scanned = twin.search_brute_force_gpu(&query, 10).unwrap();
let scanned = scanned.expect("test: with a GPU, the twin built with new() scans");
assert_eq!(scanned.len(), 10);
assert!(index.search_brute_force_gpu(&query, 10).unwrap().is_none());
}
#[test]
fn deletes_racing_a_renumber_stay_deleted() {
use super::native_inner::Placed;
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
use std::sync::Barrier;
const IDS: usize = 4_000;
let index = HnswIndex::new(4, DistanceMetric::Euclidean).unwrap();
for id in 0..IDS {
assert_eq!(index.mappings.assign(id as u64, Placed::for_test(id)), None);
}
let swap: Vec<usize> = (0..IDS).map(|slot| slot ^ 1).collect();
let deleting = AtomicBool::new(true);
let start = Barrier::new(2);
let rounds = AtomicU32::new(0);
let mut unmapped = Vec::new();
let mut stalled = false;
std::thread::scope(|scope| {
let renumbers = scope.spawn(|| {
start.wait();
while deleting.load(Ordering::Acquire) || rounds.load(Ordering::Acquire) % 2 == 1 {
let graph = index.inner.write();
index.mappings.remap_indices(&swap);
drop(graph);
rounds.fetch_add(1, Ordering::AcqRel);
std::thread::yield_now();
}
});
start.wait();
for (n, id) in (0..IDS as u64).step_by(2).enumerate() {
if n % 200 == 0 && !a_round_passes(&rounds, &renumbers) {
stalled = true;
break;
}
if !index.remove(id) {
unmapped.push(id);
}
}
deleting.store(false, Ordering::Release);
});
assert!(!stalled, "the renumbering thread stopped making rounds");
assert!(
unmapped.is_empty(),
"ids found unmapped when removed: {unmapped:?}"
);
for id in 0..IDS as u64 {
match index.mappings.get_idx(id) {
Some(slot) => {
assert_eq!(id % 2, 1, "deleted id {id} is mapped again, at slot {slot}");
assert_eq!(
index.mappings.get_id(slot),
Some(id),
"slot {slot} names another id"
);
}
None => assert_eq!(id % 2, 0, "kept id {id} lost its slot"),
}
}
let (_, reverse, _) = index.mappings.as_parts();
assert_eq!(reverse.len(), IDS / 2, "reverse entries");
assert!(
reverse
.iter()
.all(|(&slot, &id)| id % 2 == 1 && slot == id as usize),
"a slot names a deleted id, or a kept id sits outside its own slot"
);
}
fn a_round_passes<T>(
rounds: &std::sync::atomic::AtomicU32,
peer: &std::thread::ScopedJoinHandle<'_, T>,
) -> bool {
use std::sync::atomic::Ordering;
let seen = rounds.load(Ordering::Acquire);
seen_before_it_ends(peer, || rounds.load(Ordering::Acquire) != seen)
}
fn seen_before_it_ends<T>(
peer: &std::thread::ScopedJoinHandle<'_, T>,
seen: impl Fn() -> bool,
) -> bool {
loop {
let ended = peer.is_finished();
if seen() {
return true;
}
if ended {
return false;
}
std::thread::yield_now();
}
}
fn racing_vector(id: u64, version: u8) -> Vec<f32> {
let angle = id as f32 * 0.37;
vec![id as f32, f32::from(version), angle.sin(), angle.cos()]
}
#[test]
fn writes_racing_a_vacuum_survive_it() {
let index = HnswIndex::new(4, DistanceMetric::Euclidean).unwrap();
for id in 0..RACED_BASE {
index.insert(id, &racing_vector(id, 0));
}
assert_writes_survive_vacuums(&index);
}
#[cfg(feature = "persistence")]
#[test]
fn writes_racing_a_vacuum_of_an_sq8_index_survive_it() {
let params = HnswParams {
storage_mode: crate::StorageMode::SQ8,
..HnswParams::auto(4)
};
let index = HnswIndex::with_params(4, DistanceMetric::Euclidean, params).unwrap();
let vectors: Vec<Vec<f32>> = (0..RACED_BASE).map(|id| racing_vector(id, 0)).collect();
for (id, vector) in (0..RACED_BASE).zip(&vectors) {
index.insert(id, vector);
}
let samples: Vec<&[f32]> = vectors.iter().map(Vec::as_slice).collect();
let quantizer = crate::index::hnsw::native::ScalarQuantizer::train(&samples).unwrap();
assert!(
index
.inner
.read()
.install_trained_sq8(std::sync::Arc::new(quantizer))
.unwrap(),
"the index has no SQ8 backend to install the quantizer into"
);
assert_writes_survive_vacuums(&index);
assert_eq!(index.inner.read().storage_mode(), crate::StorageMode::SQ8);
assert!(
index.inner.read().is_sq8_quantizer_trained(),
"the vacuums dropped the quantizer"
);
}
const RACED_WRITES: u64 = 300;
const RACED_BASE: u64 = 3 * RACED_WRITES;
fn assert_writes_survive_vacuums(index: &HnswIndex) {
let vacuums = write_beside_vacuums(index, RACED_WRITES, RACED_BASE);
let [lost, stale, resurrected] = missed_writes(index, RACED_WRITES, RACED_BASE);
assert!(
lost.is_empty() && stale.is_empty() && resurrected.is_empty(),
"after {vacuums} vacuums: {} inserts lost (first {:?}), {} upserts still on \
their old vector (first {:?}), {} deletes back in the scan (first {:?})",
lost.len(),
lost.first(),
stale.len(),
stale.first(),
resurrected.len(),
resurrected.first(),
);
assert_eq!(index.len(), RACED_BASE as usize, "live ids");
}
fn write_beside_vacuums(index: &HnswIndex, writes: u64, base: u64) -> u32 {
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
use std::sync::Barrier;
let writing = AtomicBool::new(true);
let start = Barrier::new(2);
let rounds = AtomicU32::new(0);
let mut stalled = false;
let vacuumed = std::thread::scope(|scope| {
let vacuums = scope.spawn(|| {
start.wait();
while writing.load(Ordering::Acquire) {
index.vacuum()?;
rounds.fetch_add(1, Ordering::AcqRel);
}
Ok::<(), VacuumError>(())
});
start.wait();
for n in 0..writes {
if n % 50 == 0 && !a_round_passes(&rounds, &vacuums) {
stalled = true;
break;
}
index.insert(base + n, &racing_vector(base + n, 0));
index.insert(n, &racing_vector(n, 1));
index.remove(writes + n);
}
writing.store(false, Ordering::Release);
vacuums.join()
});
assert_eq!(
vacuumed.expect("test: the vacuuming thread panicked"),
Ok(())
);
assert!(!stalled, "the vacuuming thread stopped making rounds");
rounds.load(Ordering::Acquire)
}
fn missed_writes(index: &HnswIndex, writes: u64, base: u64) -> [Vec<u64>; 3] {
let top = |id, version| {
let hits = index
.search_brute_force(&racing_vector(id, version), 1)
.unwrap();
hits.first().map(|hit| (hit.id, hit.score))
};
let on_top = |id, version| top(id, version).map(|(hit, _)| hit) == Some(id);
let scanned: std::collections::HashSet<u64> = index
.search_brute_force(&racing_vector(0, 0), (base + writes) as usize)
.unwrap()
.iter()
.map(|hit| hit.id)
.collect();
[
(base..base + writes)
.filter(|&id| !(index.mappings.contains(id) && on_top(id, 0)))
.collect(),
(0..writes)
.filter(|&id| !on_top(id, 1) || top(id, 0) == Some((id, 0.0)))
.collect(),
(writes..2 * writes)
.filter(|&id| index.mappings.contains(id) || scanned.contains(&id))
.collect(),
]
}
fn check_reload(
dir: &std::path::Path,
ids: impl IntoIterator<Item = u64>,
own: impl Fn(u64, Option<&[f32]>) -> bool,
) -> std::io::Result<(Vec<u64>, (usize, usize))> {
let reloaded = HnswIndex::load(dir, 4, DistanceMetric::Euclidean)?;
let counts = (
reloaded.tombstone_count(),
reloaded.graph_vector_count() - reloaded.len(),
);
let graph = reloaded.inner.read();
let misresolved = |vectors: &crate::perf_optimizations::ContiguousVectors| {
ids.into_iter()
.filter(|&id| {
let stored = reloaded
.mappings
.get_idx(id)
.and_then(|slot| vectors.get(slot));
!own(id, stored)
})
.collect()
};
Ok((graph.with_contiguous_vectors(misresolved), counts))
}
#[test]
fn a_write_in_the_save_window_never_names_a_slot_the_graph_lacks() {
const BASE: u64 = 400;
const INJECTED: u64 = BASE + 1;
let dir = tempfile::tempdir().unwrap();
let index = std::sync::Arc::new(HnswIndex::new(4, DistanceMetric::Euclidean).unwrap());
for id in 0..BASE {
index.insert(id, &racing_vector(id, 0));
}
let injected = std::sync::Arc::new(std::sync::atomic::AtomicU64::new(INJECTED));
let window = {
let (index, injected) = (
std::sync::Arc::clone(&index),
std::sync::Arc::clone(&injected),
);
crate::index::hnsw::persistence::save_window::install(dir.path(), move || {
let id = injected.fetch_add(1, std::sync::atomic::Ordering::AcqRel);
let index = std::sync::Arc::clone(&index);
std::thread::spawn(move || index.insert(id, &racing_vector(id, 0)))
.join()
.expect("test: the injected write panicked");
})
};
index.save(dir.path()).expect("test: the save");
drop(window);
let last_injected = injected.load(std::sync::atomic::Ordering::Acquire);
let own = |id, stored: Option<&[f32]>| {
let is_own = stored == Some(racing_vector(id, 0).as_slice());
if id < BASE {
is_own
} else {
stored.is_none() || is_own
}
};
let (misresolved, (tombstones, dead)) =
check_reload(dir.path(), 0..last_injected, own).expect("test: the reload");
assert!(
misresolved.is_empty(),
"{} ids resolve to a vector that is not theirs after a write in the save \
window (first {:?})",
misresolved.len(),
misresolved.first()
);
assert_eq!(
tombstones, dead,
"the reloaded index counts {tombstones} tombstones over {dead} slots no id names"
);
}
#[test]
fn a_save_racing_a_vacuum_reloads_consistent() {
use std::sync::atomic::{AtomicBool, Ordering};
const IDS: u64 = 1_000;
const ROUNDS: usize = 10;
let dir = tempfile::tempdir().unwrap();
let mut saves = Vec::new();
for round in 0..ROUNDS {
let index = HnswIndex::new(4, DistanceMetric::Euclidean).unwrap();
for id in 0..IDS {
index.insert(id, &racing_vector(id, 0));
}
let vacuumed = AtomicBool::new(false);
let vacuum = std::thread::scope(|scope| {
let vacuum = scope.spawn(|| {
let result = index.vacuum();
vacuumed.store(true, Ordering::Release);
result
});
while !vacuumed.load(Ordering::Acquire) {
let path = dir.path().join(saves.len().to_string());
let saved = index.save(&path);
saves.push((round, path, saved));
}
vacuum.join()
});
let vacuum = vacuum.expect("test: the vacuuming thread panicked");
assert_eq!(vacuum, Ok(IDS as usize), "round {round}");
assert!(
(0..IDS).any(|id| index.mappings.get_idx(id) != Some(id as usize)),
"round {round}: the vacuum renumbered nothing"
);
}
let made = saves.len();
assert!(made >= ROUNDS, "{made} saves overlapped a vacuum");
let inconsistent: Vec<_> = saves
.into_iter()
.filter_map(|(round, path, saved)| {
let own = |id, stored: Option<&[f32]>| stored == Some(racing_vector(id, 0).as_slice());
match saved.and_then(|()| check_reload(&path, 0..IDS, own)) {
Ok((ids, (count, dead))) if ids.is_empty() && count == dead => None,
outcome => Some((round, outcome.map(|(ids, counts)| (ids.len(), counts)))),
}
})
.collect();
assert!(
inconsistent.is_empty(),
"{} of the {made} saves made during a vacuum reloaded inconsistent \
(round, misresolved ids and (tombstone count, dead slots), or error): \
{inconsistent:?}",
inconsistent.len()
);
}
#[test]
fn deletes_racing_a_vacuum_keep_the_tombstone_count_exact() {
use std::sync::Barrier;
const IDS: u64 = 2_000;
const KEEP: usize = 10;
const ROUNDS: usize = 5;
let mut counts = Vec::new();
let mut dead_tails = 0;
for round in 0..ROUNDS {
let index = HnswIndex::new(4, DistanceMetric::Euclidean).unwrap();
for id in 0..IDS {
index.insert(id, &racing_vector(id, 0));
}
let order: Vec<u64> = index.mappings.iter().map(|(id, _)| id).collect();
let start = Barrier::new(2);
let (vacuumed, unmapped) = std::thread::scope(|scope| {
let vacuum = scope.spawn(|| {
start.wait();
index.vacuum()
});
start.wait();
let unmapped = order[KEEP..]
.iter()
.rev()
.filter(|&&id| !index.remove(id))
.count();
(vacuum.join(), unmapped)
});
let vacuumed = vacuumed.expect("test: the vacuuming thread panicked");
assert!(vacuumed.is_ok(), "round {round}: {vacuumed:?}");
assert_eq!(
unmapped, 0,
"round {round}: ids found unmapped when removed"
);
assert_eq!(index.len(), KEEP, "round {round}: live ids");
let dead = || index.graph_vector_count() - index.len();
let named_extent = index.mappings.iter().map(|(_, slot)| slot + 1).max();
dead_tails += usize::from(named_extent < Some(index.graph_vector_count()));
let raced = (index.tombstone_count(), dead());
index.vacuum().unwrap();
counts.push((round, raced, (index.tombstone_count(), dead())));
}
assert!(
dead_tails > 0,
"no round left dead slots at the end of the new graph"
);
let wrong: Vec<_> = counts
.iter()
.filter(|&&(_, (count, dead), after)| count != dead || after != (0, 0))
.collect();
assert!(
wrong.is_empty(),
"(round, (tombstone count, dead slots) after the race, then after the next \
vacuum): {wrong:?}"
);
}
#[test]
fn writes_racing_a_save_reload_consistent() {
const WRITES: u64 = 300;
const BASE: u64 = 3 * WRITES;
let dir = tempfile::tempdir().unwrap();
let index = HnswIndex::new(4, DistanceMetric::Euclidean).unwrap();
for id in 0..BASE {
index.insert(id, &racing_vector(id, 0));
}
let mut stalled = false;
let saves = save_beside(&index, dir.path(), |a_save_passes| {
for n in 0..WRITES {
if n % 50 == 0 && !a_save_passes() {
stalled = true;
break;
}
index.insert(n, &racing_vector(n, 1));
index.remove(WRITES + n);
index.insert(BASE + n, &racing_vector(BASE + n, 0));
}
});
assert!(!stalled, "the saving thread stopped making rounds");
let own = |id, stored: Option<&[f32]>| {
let is = |version| stored == Some(racing_vector(id, version).as_slice());
match id / WRITES {
0 => is(0) || is(1),
1 | 3 => stored.is_none() || is(0),
_ => is(0),
}
};
let made = saves.len();
let torn = torn_saves(saves, &(0..BASE + WRITES), own);
assert!(
torn.is_empty(),
"{} of the {made} saves made during writes reloaded torn: {torn:?}",
torn.len()
);
}
#[test]
fn batch_inserts_racing_a_save_reload_consistent() {
const BASE: u64 = 4_000;
const BATCH: u64 = 200;
const BATCHES: u64 = 40;
let dir = tempfile::tempdir().unwrap();
let index = wide_index_with_base(BASE);
let insert_batch = |first: u64, len: u64| insert_wide_batch(&index, first, len);
let mut stalled = false;
let mut placed = 0;
let saves = save_beside(&index, dir.path(), |a_save_passes| {
for n in 0..BATCHES {
if n % 5 == 0 && !a_save_passes() {
stalled = true;
break;
}
placed += insert_batch(BASE + n * BATCH, BATCH);
}
});
assert!(!stalled, "the saving thread stopped making rounds");
assert_eq!(u64::try_from(placed), Ok(BATCHES * BATCH), "vectors placed");
let own = |id, stored: Option<&[f32]>| {
(id >= BASE && stored.is_none()) || stored == Some(wide_vector(id).as_slice())
};
let made = saves.len();
let torn = torn_saves(saves, &(0..BASE + BATCHES * BATCH), own);
assert!(
torn.is_empty(),
"{} of the {made} saves made during batch inserts reloaded torn: {torn:?}",
torn.len()
);
}
const WIDE: usize = 128;
fn wide_vector(id: u64) -> Vec<f32> {
let angle = id as f32 * 0.37;
let mut vector: Vec<f32> = (0..WIDE)
.map(|i| (angle + f32::from(u16::try_from(i).unwrap())).sin())
.collect();
vector[0] = id as f32;
vector
}
fn insert_wide_batch(index: &HnswIndex, first: u64, len: u64) -> usize {
let batch: Vec<Vec<f32>> = (first..first + len).map(wide_vector).collect();
index.insert_batch_parallel((first..).zip(batch.iter().map(Vec::as_slice)))
}
fn wide_index_with_base(base: u64) -> HnswIndex {
let index = HnswIndex::new(WIDE, DistanceMetric::Euclidean).unwrap();
assert_eq!(
u64::try_from(insert_wide_batch(&index, 0, base)),
Ok(base),
"base placed"
);
index
}
fn save_beside(
index: &HnswIndex,
dir: &std::path::Path,
write: impl FnOnce(&dyn Fn() -> bool),
) -> Vec<(std::path::PathBuf, std::io::Result<()>)> {
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
use std::sync::Barrier;
let writing = AtomicBool::new(true);
let start = Barrier::new(2);
let rounds = AtomicU32::new(0);
let saves = std::thread::scope(|scope| {
let saver = scope.spawn(|| {
let mut saves = Vec::new();
start.wait();
while writing.load(Ordering::Acquire) {
let path = dir.join(saves.len().to_string());
let saved = index.save(&path);
saves.push((path, saved));
rounds.fetch_add(1, Ordering::AcqRel);
}
saves
});
start.wait();
write(&|| a_round_passes(&rounds, &saver));
writing.store(false, Ordering::Release);
saver.join()
});
saves.expect("test: the saving thread panicked")
}
fn torn_saves(
saves: Vec<(std::path::PathBuf, std::io::Result<()>)>,
ids: &std::ops::Range<u64>,
own: impl Fn(u64, Option<&[f32]>) -> bool + Copy,
) -> Vec<String> {
saves
.into_iter()
.filter_map(|(path, saved)| {
match saved.and_then(|()| check_reload(&path, ids.clone(), own)) {
Ok((ids, (count, dead))) if ids.is_empty() && count == dead => None,
Ok((ids, counts)) => Some(format!(
"{}: {} ids misresolved (first {:?}), (tombstone count, dead slots) {counts:?}",
path.display(),
ids.len(),
ids.first()
)),
Err(e) => Some(format!("{}: {e}", path.display())),
}
})
.collect()
}
#[test]
fn vacuum_keeps_the_index_parameters() {
let built = |index: &HnswIndex| {
let graph = index.inner.read();
(
graph.max_connections(),
graph.ef_construction(),
graph.alpha().to_bits(),
)
};
let params = HnswParams {
max_connections: 12,
ef_construction: 90,
alpha: 1.0,
..HnswParams::auto(4)
};
let expected = (12, 90, 1.0_f32.to_bits());
let index = HnswIndex::with_params(4, DistanceMetric::Euclidean, params).unwrap();
for id in 0..300 {
index.insert(id, &racing_vector(id, 0));
}
for id in 0..100 {
index.remove(id);
}
assert_eq!(built(&index), expected, "as built");
assert_eq!(index.vacuum(), Ok(200));
assert_eq!(built(&index), expected, "after a vacuum");
let dir = tempfile::tempdir().unwrap();
index.save(dir.path()).unwrap();
let reloaded = HnswIndex::load(dir.path(), 4, DistanceMetric::Euclidean).unwrap();
assert_eq!(built(&reloaded), expected, "saved after a vacuum");
}
#[test]
fn vacuum_of_an_index_with_no_live_id_empties_it() {
let index = HnswIndex::new(4, DistanceMetric::Euclidean).unwrap();
for id in 0..200 {
index.insert(id, &racing_vector(id, 0));
}
let removed = (0..200).filter(|&id| index.remove(id)).count();
assert_eq!((removed, index.tombstone_count()), (200, 200), "before");
let state = |index: &HnswIndex| {
(
index.tombstone_count(),
index.graph_vector_count(),
index.len(),
)
};
assert_eq!(index.vacuum(), Ok(0));
assert_eq!(state(&index), (0, 0, 0), "after a vacuum");
let query = racing_vector(7, 0);
assert!(
VectorIndex::search(&index, &query, 5).is_empty(),
"a search of the emptied index"
);
assert_eq!(index.vacuum(), Ok(0));
assert_eq!(state(&index), (0, 0, 0), "after a second vacuum");
index.insert(500, &racing_vector(500, 0));
let hits = VectorIndex::search(&index, &racing_vector(500, 0), 1);
assert_eq!(
hits.first().map(|hit| hit.id),
Some(500),
"a search after an insert"
);
}
#[test]
fn saves_racing_into_one_directory_reload_consistent() {
const BASE: u64 = 2_000;
const BATCH: u64 = 200;
const ROUNDS: u64 = 20;
let dir = tempfile::tempdir().unwrap();
let index = wide_index_with_base(BASE);
let insert_batch = |first: u64, len: u64| insert_wide_batch(&index, first, len);
let own = |id, stored: Option<&[f32]>| {
(id >= BASE && stored.is_none()) || stored == Some(wide_vector(id).as_slice())
};
let (mut torn, mut unseen, mut short) = (Vec::new(), 0, 0);
for round in 0..ROUNDS {
let first = BASE + round * BATCH;
let (seen, saved, placed) =
two_saves_while_a_batch_links(&index, dir.path(), || insert_batch(first, BATCH));
unseen += usize::from(!seen);
short += usize::from(u64::try_from(placed) != Ok(BATCH));
match saved.and_then(|()| check_reload(dir.path(), 0..first + BATCH, own)) {
Ok((ids, (count, dead))) if ids.is_empty() && count == dead => {}
outcome => torn.push(format!(
"round {round}: {:?}",
outcome.map(|(ids, counts)| (ids.len(), counts))
)),
}
}
assert_eq!(
(unseen, short),
(0, 0),
"(rounds whose batch was never seen pushed, batches placed short)"
);
assert!(
torn.is_empty(),
"{} of the {ROUNDS} rounds left a directory that reloads torn \
(misresolved ids, (tombstone count, dead slots)), or error: {torn:?}",
torn.len()
);
}
fn two_saves_while_a_batch_links(
index: &HnswIndex,
dir: &std::path::Path,
batch: impl FnOnce() -> usize + Send,
) -> (bool, std::io::Result<()>, usize) {
use std::sync::Barrier;
let slots = index.graph_vector_count();
let start = Barrier::new(2);
let save = || {
start.wait();
index.save(dir)
};
let (seen, joined, placed) = std::thread::scope(|scope| {
let batch = scope.spawn(batch);
let seen = seen_before_it_ends(&batch, || index.graph_vector_count() > slots);
let (a, b) = (scope.spawn(save), scope.spawn(save));
(seen, [a.join(), b.join()], batch.join())
});
let saved = joined
.into_iter()
.try_for_each(|save| save.expect("test: a saving thread panicked"));
(
seen,
saved,
placed.expect("test: the batch thread panicked"),
)
}
const RENUMBERED: u64 = 1_200;
#[test]
fn reordering_renumbers_the_slots_and_the_mappings_follow() {
use std::collections::{BTreeMap, BTreeSet};
let index = wide_index_with_base(RENUMBERED);
let before: BTreeMap<u64, usize> = index.mappings.iter().collect();
index.reorder_for_locality().expect("test: reorder");
let after: BTreeMap<u64, usize> = index.mappings.iter().collect();
let moved = after
.iter()
.filter(|&(id, idx)| before.get(id) != Some(idx))
.count();
println!("{moved} of {} mapped ids changed slot", before.len());
assert!(
moved > 0,
"reorder_for_locality left every one of the {} mapped ids on the slot \
it already held: it renumbered nothing",
before.len()
);
assert_eq!(
before.keys().copied().collect::<Vec<_>>(),
after.keys().copied().collect::<Vec<_>>(),
"the renumbering lost or invented ids"
);
assert_eq!(
before.values().copied().collect::<BTreeSet<usize>>(),
after.values().copied().collect::<BTreeSet<usize>>(),
"{moved} of the {} ids moved, but not onto the slots they started \
from: the renumbering is not a permutation of them",
before.len()
);
for id in [0, RENUMBERED / 2, RENUMBERED - 1] {
let hits = index
.search_with_quality(&wide_vector(id), 1, SearchQuality::Perfect)
.expect("test: exact search");
assert_eq!(
hits.first().map(|hit| hit.id),
Some(id),
"after the renumbering {id}'s own vector no longer finds {id}: \
the mappings did not follow the slots"
);
}
}
#[test]
fn reorder_waits_for_the_maintenance_lock() {
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Instant;
const BLOCKED_FACTOR: u32 = 50;
let index = wide_index_with_base(RENUMBERED);
let started = Instant::now();
index
.reorder_for_locality()
.expect("test: the unheld reorder");
let baseline = started.elapsed();
let window = BLOCKED_FACTOR * baseline;
println!("unheld reorder {baseline:?}, window {window:?} ({BLOCKED_FACTOR}x)");
assert!(
!window.is_zero(),
"the unheld reorder was not measurable, so the window it sizes bounds \
nothing"
);
let held = index.lock_maintenance();
let renumbered = AtomicBool::new(false);
std::thread::scope(|scope| {
let blocked = scope.spawn(|| {
index
.reorder_for_locality()
.expect("test: the held reorder");
renumbered.store(true, Ordering::Release);
});
std::thread::sleep(window);
let escaped = renumbered.load(Ordering::Acquire);
drop(held);
blocked.join().expect("test: the reorder thread panicked");
assert!(
!escaped,
"reorder_for_locality renumbered within {window:?} while the \
maintenance lock was held: it does not take the lock its doc \
claims, and a vacuum's swap can be renumbered under it"
);
});
}