Skip to main content

csm_memory/index/
mod.rs

1//! ANN Index traits and backends (ADR-0068).
2
3use serde::{Deserialize, Serialize};
4use std::collections::HashMap;
5use std::fmt::Debug;
6
7use crate::singularity::Concept;
8use csm_core_lib::error::Result;
9use csm_core_lib::hyperdim::{HVec10240, Hypervector};
10
11pub mod brute_force;
12#[cfg(feature = "ann-hnsw")]
13pub mod hnsw;
14#[cfg(feature = "ann-lsh")]
15pub mod lsh;
16
17/// Statistics for an ANN index.
18#[derive(Debug, Clone, Serialize, Deserialize, Default)]
19pub struct IndexStats {
20    pub backend: String,
21    pub count: usize,
22    pub memory_usage_bytes: usize,
23}
24
25/// Supported ANN index backends.
26#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
27pub enum IndexBackend {
28    /// Exact search via linear scan.
29    #[default]
30    BruteForce,
31    /// Hierarchical Navigable Small Worlds (HNSW) index.
32    #[cfg(feature = "ann-hnsw")]
33    Hnsw {
34        /// Number of bi-directional links for each element (default: 16).
35        m: usize,
36        /// Size of the dynamic list for the nearest neighbors (default: 200).
37        ef_construction: usize,
38        /// Size of the dynamic list for the nearest neighbors during search (default: 50).
39        ef_search: usize,
40    },
41    /// Locality-Sensitive Hashing (LSH) index.
42    #[cfg(feature = "ann-lsh")]
43    Lsh {
44        /// Number of hash tables.
45        num_tables: usize,
46        /// Number of hash bits per table.
47        hash_bits: usize,
48    },
49}
50
51/// Trait for Approximate Nearest Neighbor (ANN) indices.
52pub trait AnnIndex<H: Hypervector = HVec10240>: Send + Sync + Debug + 'static {
53    /// Insert a concept into the index.
54    fn insert(&mut self, id: String, vec: &H) -> Result<()>;
55
56    /// Delete a concept from the index.
57    fn delete(&mut self, id: &str) -> Result<()>;
58
59    /// Search for the top-k nearest neighbors.
60    fn search(&self, query: &H, top_k: usize) -> Result<Vec<(String, f32)>>;
61
62    /// Search for the nearest neighbors with a metadata filter.
63    fn search_filtered(
64        &self,
65        query: &H,
66        top_k: usize,
67        filter: &crate::metadata_filter::MetadataFilter,
68        concepts: &std::collections::HashMap<String, crate::singularity::Concept<H>>,
69    ) -> Result<Vec<(String, f32)>>;
70
71    /// Rebuild the index from scratch using all concepts.
72    fn rebuild(&mut self, concepts: &HashMap<String, Concept<H>>) -> Result<()>;
73
74    /// Get statistics for the index.
75    fn stats(&self) -> IndexStats;
76
77    /// Serialize the index state for persistence.
78    fn serialize(&self) -> Result<Vec<u8>>;
79
80    /// Deserialize the index state from persistence.
81    fn deserialize(&mut self, data: &[u8]) -> Result<()>;
82}
83
84/// Create an ANN index backend based on configuration.
85pub fn create_index<H: Hypervector + 'static>(
86    backend: &IndexBackend,
87) -> Result<Box<dyn AnnIndex<H>>> {
88    let index: Box<dyn AnnIndex<H>> = match backend {
89        IndexBackend::BruteForce => Box::new(brute_force::BruteForce::new()),
90        #[cfg(feature = "ann-hnsw")]
91        IndexBackend::Hnsw {
92            m,
93            ef_construction,
94            ef_search,
95        } => Box::new(hnsw::HnswIndex::new(*m, *ef_construction, *ef_search)?),
96        #[cfg(feature = "ann-lsh")]
97        IndexBackend::Lsh {
98            num_tables,
99            hash_bits,
100        } => Box::new(lsh::LshIndex::new(*num_tables, *hash_bits)?),
101        #[allow(unreachable_patterns)]
102        _ => Box::new(brute_force::BruteForce::new()),
103    };
104    Ok(index)
105}
106
107/// Validate that `backend` can be constructed.
108///
109/// Builds a throwaway [`HVec10240`] index via [`create_index`] so checks stay in
110/// lock-step with constructors (HNSW `m` ∈ [1, 256], LSH `num_tables > 0`, etc.).
111/// Call at framework build time so invalid configs fail closed with
112/// `MemoryError::InvalidInput` rather than panicking later.
113pub fn validate_index_backend(backend: &IndexBackend) -> Result<()> {
114    let _index: Box<dyn AnnIndex<HVec10240>> = create_index(backend)?;
115    Ok(())
116}
117
118#[cfg(test)]
119mod tests {
120    #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
121    use super::*;
122    #[cfg(any(feature = "ann-hnsw", feature = "ann-lsh"))]
123    use csm_core_lib::error::MemoryError;
124
125    #[test]
126    fn create_index_bruteforce_ok() {
127        let idx = create_index::<HVec10240>(&IndexBackend::BruteForce);
128        assert!(idx.is_ok());
129        assert!(validate_index_backend(&IndexBackend::BruteForce).is_ok());
130    }
131
132    #[cfg(feature = "ann-hnsw")]
133    #[test]
134    fn create_index_hnsw_valid_ok() {
135        let backend = IndexBackend::Hnsw {
136            m: 16,
137            ef_construction: 200,
138            ef_search: 50,
139        };
140        assert!(create_index::<HVec10240>(&backend).is_ok());
141        assert!(validate_index_backend(&backend).is_ok());
142    }
143
144    #[cfg(feature = "ann-hnsw")]
145    #[test]
146    fn create_index_hnsw_m_zero_is_invalid_input() {
147        let backend = IndexBackend::Hnsw {
148            m: 0,
149            ef_construction: 200,
150            ef_search: 50,
151        };
152        match create_index::<HVec10240>(&backend) {
153            Err(MemoryError::InvalidInput { field, .. }) => assert_eq!(field, "m"),
154            other => panic!("expected InvalidInput for m=0, got {other:?}"),
155        }
156        assert!(matches!(
157            validate_index_backend(&backend),
158            Err(MemoryError::InvalidInput { .. })
159        ));
160    }
161
162    #[cfg(feature = "ann-hnsw")]
163    #[test]
164    fn create_index_hnsw_m_too_large_is_invalid_input() {
165        let backend = IndexBackend::Hnsw {
166            m: 257,
167            ef_construction: 200,
168            ef_search: 50,
169        };
170        match create_index::<HVec10240>(&backend) {
171            Err(MemoryError::InvalidInput { field, .. }) => assert_eq!(field, "m"),
172            other => panic!("expected InvalidInput for m=257, got {other:?}"),
173        }
174    }
175
176    #[cfg(feature = "ann-lsh")]
177    #[test]
178    fn create_index_lsh_valid_ok() {
179        let backend = IndexBackend::Lsh {
180            num_tables: 4,
181            hash_bits: 8,
182        };
183        assert!(create_index::<HVec10240>(&backend).is_ok());
184        assert!(validate_index_backend(&backend).is_ok());
185    }
186
187    #[cfg(feature = "ann-lsh")]
188    #[test]
189    fn create_index_lsh_zero_tables_is_invalid_input() {
190        let backend = IndexBackend::Lsh {
191            num_tables: 0,
192            hash_bits: 8,
193        };
194        match create_index::<HVec10240>(&backend) {
195            Err(MemoryError::InvalidInput { field, .. }) => assert_eq!(field, "num_tables"),
196            other => panic!("expected InvalidInput for num_tables=0, got {other:?}"),
197        }
198        assert!(matches!(
199            validate_index_backend(&backend),
200            Err(MemoryError::InvalidInput { .. })
201        ));
202    }
203}