feox-ann 0.1.0

Dependency-free HNSW approximate nearest neighbor index with deterministic, reproducible builds
Documentation
use crate::{AnnConfig, AnnIndex, AnnQuery};

fn sample_records(count: usize) -> Vec<(String, Vec<f32>)> {
    (0..count)
        .map(|i| {
            let angle = i as f32 * 0.037;
            (
                format!("record-{i:05}"),
                vec![
                    angle.sin(),
                    angle.cos(),
                    (angle * 2.0).sin(),
                    (angle * 2.0).cos(),
                ],
            )
        })
        .collect()
}

fn query_results(index: &AnnIndex, seed: f32) -> Vec<crate::AnnCandidate> {
    let angle = seed * 0.17;
    index
        .query(AnnQuery {
            vector: &[
                angle.sin(),
                angle.cos(),
                (angle * 2.0).sin(),
                (angle * 2.0).cos(),
            ],
            top_k: 10,
            ef_search: Some(64),
            filter: None,
        })
        .unwrap()
}

#[test]
fn bulk_load_is_thread_count_invariant() {
    let records = sample_records(500);

    let mut single = AnnIndex::new(AnnConfig::for_dimensions(4)).unwrap();
    single.bulk_load(records.clone(), 1).unwrap();

    let mut parallel = AnnIndex::new(AnnConfig::for_dimensions(4)).unwrap();
    parallel.bulk_load(records, 8).unwrap();

    assert_eq!(single.len(), parallel.len());
    for i in 0..25 {
        assert_eq!(
            query_results(&single, i as f32),
            query_results(&parallel, i as f32),
            "thread count must not change the graph"
        );
    }
}

#[test]
fn bulk_load_finds_true_neighbors() {
    let records = sample_records(1_000);
    let mut index = AnnIndex::new(AnnConfig::for_dimensions(4)).unwrap();
    index.bulk_load(records, 4).unwrap();
    assert_eq!(index.len(), 1_000);

    let angle = 123_f32 * 0.037;
    let matches = index
        .query(AnnQuery {
            vector: &[
                angle.sin(),
                angle.cos(),
                (angle * 2.0).sin(),
                (angle * 2.0).cos(),
            ],
            top_k: 1,
            ef_search: Some(128),
            filter: None,
        })
        .unwrap();
    assert_eq!(matches[0].id, "record-00123");
    assert!(matches[0].score > 0.9999);
}

#[test]
fn bulk_load_upserts_duplicate_ids() {
    let mut index = AnnIndex::new(AnnConfig::for_dimensions(2)).unwrap();
    index
        .bulk_load(
            vec![
                ("item".to_string(), vec![1.0, 0.0]),
                ("other".to_string(), vec![0.5, 0.5]),
                ("item".to_string(), vec![0.0, 1.0]),
            ],
            2,
        )
        .unwrap();

    assert_eq!(index.len(), 2);
    let matches = index
        .query(AnnQuery {
            vector: &[0.0, 1.0],
            top_k: 1,
            ef_search: Some(8),
            filter: None,
        })
        .unwrap();
    assert_eq!(matches[0].id, "item");
    assert!(matches[0].score > 0.99);
}

#[test]
fn concurrent_queries_share_the_index() {
    let records = sample_records(300);
    let mut index = AnnIndex::new(AnnConfig::for_dimensions(4)).unwrap();
    index.bulk_load(records, 4).unwrap();

    let expected: Vec<_> = (0..8).map(|i| query_results(&index, i as f32)).collect();
    std::thread::scope(|scope| {
        for (i, expected) in expected.iter().enumerate() {
            let index = &index;
            scope.spawn(move || {
                for _ in 0..50 {
                    assert_eq!(&query_results(index, i as f32), expected);
                }
            });
        }
    });
}