Skip to main content

semantic_memory/
vector_backend.rs

1//! Vector index backend trait.
2//!
3//! This trait is the abstraction layer over the concrete ANN backend (hnsw_rs
4//! or usearch). It exposes a stable interface that the rest of the crate
5//! (search.rs, hnsw_ops.rs, config.rs, lib.rs) uses, so that switching the
6//! backend is a matter of which impl is wired in at the `hnsw_ops::rebuild_*`
7//! and `HnswIndex` factory call sites.
8//!
9//! ## Design notes
10//!
11//! - The `VectorBackend` trait is intentionally minimal: just enough surface
12//!   to satisfy the 8 call sites that currently use hnsw_rs.
13//! - All backends return `Result<_, MemoryError>` so error handling at the
14//!   trait boundary doesn't leak backend-specific error types.
15//! - `HnswHit` is renamed to `VectorHit` in the trait surface, but a
16//!   type alias `pub type HnswHit = VectorHit;` is preserved for source
17//!   compatibility with downstream consumers.
18//! - `HnswConfig` is kept as the user-facing config name (it's a public
19//!   type). The trait receives a `VectorIndexConfig` internally; the
20//!   `From<HnswConfig>` impl bridges them.
21//!
22//! ## Backend implementations
23//!
24//! - `HnswBackend` (in `hnsw_backend.rs`, gated on `feature = "hnsw"`):
25//!   the existing hnsw_rs 0.3 wrapper, behavior-preserving.
26//! - `UsearchBackend` (in `usearch_backend.rs`, gated on
27//!   `feature = "usearch-backend"`): the new cxx-bridge to usearch 2.25.
28//!   This file is the destination of the migration; until it's wired in
29//!   it is a stub that returns MemoryError::Unimplemented.
30//!
31//! ## Backwards compatibility
32//!
33//! `HnswIndex` is renamed to `VectorIndex` (a thin newtype around
34//! `Arc<dyn VectorBackend + Send + Sync>`). The old name is preserved as a
35//! deprecated type alias to avoid breaking downstream consumers like
36//! forge-pilot, llm-pipeline, and kernel-conformance that import
37//! `semantic_memory::hnsw::HnswIndex` directly.
38
39use std::path::Path;
40use std::sync::Arc;
41
42use crate::error::MemoryError;
43
44/// User-facing hit from a vector search.
45#[derive(Debug, Clone)]
46pub struct VectorHit {
47    pub key: String,
48    pub distance: f32,
49}
50
51impl VectorHit {
52    pub fn similarity(&self) -> f32 {
53        (1.0 - self.distance).max(0.0)
54    }
55
56    /// Split the sidecar key into `(domain, identifier)`.
57    pub fn parse_key(&self) -> Result<(&str, &str), MemoryError> {
58        self.key
59            .split_once(':')
60            .ok_or_else(|| MemoryError::InvalidKey(self.key.clone()))
61    }
62}
63
64/// Configuration for the vector index.
65///
66/// Field names and semantics match the existing `HnswConfig` so that
67/// `From<HnswConfig> for VectorIndexConfig` is a no-op. Backend-specific
68/// fields (e.g. `simsimd` flags for usearch) are abstracted away — the
69/// usearch backend picks its own defaults from these top-level knobs.
70#[derive(Debug, Clone)]
71pub struct VectorIndexConfig {
72    pub m: usize,
73    pub ef_construction: usize,
74    pub ef_search: usize,
75    pub dimensions: usize,
76    pub max_elements: usize,
77    pub compaction_threshold: f32,
78    pub flush_interval_secs: Option<u64>,
79}
80
81impl Default for VectorIndexConfig {
82    fn default() -> Self {
83        Self {
84            m: 16,
85            ef_construction: 200,
86            ef_search: 50,
87            dimensions: 768,
88            max_elements: 100_000,
89            compaction_threshold: 0.3,
90            flush_interval_secs: None,
91        }
92    }
93}
94
95/// Core vector index operations. All concrete backends implement this.
96///
97/// Object-safe: all methods take `&self`, no generic parameters. The
98/// factory functions (`new` and `load`) are provided as free `fn` items
99/// rather than trait methods, so the trait itself is dyn-compatible.
100pub trait VectorBackend: Send + Sync {
101    /// Insert a key+vector pair. If the key already exists, the vector is
102    /// updated.
103    fn insert(&self, key: String, vector: &[f32]) -> Result<(), MemoryError>;
104
105    /// Delete the key (if present). Idempotent.
106    fn delete(&self, key: &str) -> Result<(), MemoryError>;
107
108    /// Update the vector for an existing key (or insert if absent).
109    fn update(&self, key: String, vector: &[f32]) -> Result<(), MemoryError>;
110
111    /// k-NN search over the index. Returns up to `top_k` hits sorted by
112    /// ascending distance.
113    fn search(&self, query: &[f32], top_k: usize) -> Result<Vec<VectorHit>, MemoryError>;
114
115    /// Number of live (non-deleted) entries.
116    fn len(&self) -> usize;
117
118    /// Whether the index is empty.
119    fn is_empty(&self) -> bool {
120        self.len() == 0
121    }
122
123    /// Flush the index to a backend-specific sidecar. Implementations may
124    /// write additional files (manifest, digests, etc.) in the same dir.
125    fn save(&self, dir: &Path, basename: &str) -> Result<(), MemoryError>;
126
127    /// Human-readable backend name (e.g. "hnsw_rs 0.3", "usearch 2.25").
128    /// Used in build receipts and `VectorArtifactBuildReceiptV1`.
129    fn backend_name(&self) -> &'static str;
130}
131
132/// Thread-safe handle to a vector index.
133#[derive(Clone)]
134pub struct VectorIndex {
135    inner: Arc<dyn VectorBackend>,
136}
137
138impl VectorIndex {
139    /// Construct a new index from a config. Dispatches to the active
140    /// backend (selected at compile time via feature flag).
141    pub fn new(config: VectorIndexConfig) -> Result<Self, MemoryError> {
142        let backend = build_active_backend(config)?;
143        Ok(Self { inner: backend })
144    }
145
146    /// Load a previously saved index. Dispatches to the active backend.
147    pub fn load(dir: &Path, basename: &str, config: VectorIndexConfig) -> Result<Self, MemoryError> {
148        let backend = load_active_backend(dir, basename, config)?;
149        Ok(Self { inner: backend })
150    }
151
152    pub fn insert(&self, key: String, vector: &[f32]) -> Result<(), MemoryError> {
153        self.inner.insert(key, vector)
154    }
155
156    pub fn delete(&self, key: &str) -> Result<(), MemoryError> {
157        self.inner.delete(key)
158    }
159
160    pub fn update(&self, key: String, vector: &[f32]) -> Result<(), MemoryError> {
161        self.inner.update(key, vector)
162    }
163
164    pub fn search(&self, query: &[f32], top_k: usize) -> Result<Vec<VectorHit>, MemoryError> {
165        self.inner.search(query, top_k)
166    }
167
168    pub fn len(&self) -> usize {
169        self.inner.len()
170    }
171
172    pub fn is_empty(&self) -> bool {
173        self.inner.is_empty()
174    }
175
176    pub fn save(&self, dir: &Path, basename: &str) -> Result<(), MemoryError> {
177        self.inner.save(dir, basename)
178    }
179
180    pub fn backend_name(&self) -> &'static str {
181        self.inner.backend_name()
182    }
183
184    /// Note: downcasting to a concrete backend type is not supported
185    /// through the trait. Tests that need backend-specific introspection
186    /// should use the `backend_name()` method or read the sidecar
187    /// manifest. This is intentional — keeping the trait free of `Any`
188    /// avoids the vtable overhead and keeps the public surface minimal.
189    pub fn _placeholder(&self) {}
190}
191
192/// Factory: build a new backend using the active backend.
193///
194/// This is the single dispatch point that the rest of the crate uses to
195/// select between hnsw_rs and usearch at compile time. The dispatch is
196/// gated by `#[cfg(feature = ...)]` so the unused backend's code is not
197/// compiled.
198fn build_active_backend(
199    config: VectorIndexConfig,
200) -> Result<Arc<dyn VectorBackend>, MemoryError> {
201    #[cfg(feature = "hnsw")]
202    {
203        return Ok(Arc::new(super::hnsw_backend::HnswBackend::new(config)?));
204    }
205    #[cfg(feature = "usearch-backend")]
206    {
207        return Ok(Arc::new(super::usearch_backend::UsearchBackend::new(config)?));
208    }
209    // If neither feature is enabled, fall through to a stub that returns
210    // an explicit error. The lib.rs compile_error! guard should prevent
211    // this from being reached in practice.
212    #[allow(unreachable_code)]
213    {
214        let _ = config;
215        Err(MemoryError::NotImplemented(
216            "no vector backend feature enabled (need `hnsw` or `usearch-backend`)".to_string(),
217        ))
218    }
219}
220
221fn load_active_backend(
222    dir: &Path,
223    basename: &str,
224    config: VectorIndexConfig,
225) -> Result<Arc<dyn VectorBackend>, MemoryError> {
226    #[cfg(feature = "hnsw")]
227    {
228        return Ok(Arc::new(super::hnsw_backend::HnswBackend::load(
229            dir, basename, config,
230        )?));
231    }
232    #[cfg(feature = "usearch-backend")]
233    {
234        return Ok(Arc::new(super::usearch_backend::UsearchBackend::load(
235            dir, basename, config,
236        )?));
237    }
238    #[allow(unreachable_code)]
239    {
240        let _ = (dir, basename, config);
241        Err(MemoryError::NotImplemented(
242            "no vector backend feature enabled (need `hnsw` or `usearch-backend`)".to_string(),
243        ))
244    }
245}
246
247#[cfg(test)]
248mod tests {
249    use super::*;
250
251    #[test]
252    fn vector_hit_similarity_below_zero_clamps_to_zero() {
253        let h = VectorHit { key: "fact:1".to_string(), distance: 2.0 };
254        assert_eq!(h.similarity(), 0.0);
255    }
256
257    #[test]
258    fn vector_hit_similarity_normal() {
259        let h = VectorHit { key: "fact:1".to_string(), distance: 0.3 };
260        assert!((h.similarity() - 0.7).abs() < 1e-6);
261    }
262
263    #[test]
264    fn vector_hit_parse_key_valid() {
265        let h = VectorHit { key: "chunk:abc-123".to_string(), distance: 0.0 };
266        let (domain, id) = h.parse_key().unwrap();
267        assert_eq!(domain, "chunk");
268        assert_eq!(id, "abc-123");
269    }
270
271    #[test]
272    fn vector_hit_parse_key_invalid() {
273        let h = VectorHit { key: "no_colon".to_string(), distance: 0.0 };
274        assert!(h.parse_key().is_err());
275    }
276
277    #[test]
278    fn config_default_matches_hnsw_default() {
279        let c = VectorIndexConfig::default();
280        assert_eq!(c.m, 16);
281        assert_eq!(c.ef_construction, 200);
282        assert_eq!(c.ef_search, 50);
283        assert_eq!(c.dimensions, 768);
284        assert_eq!(c.max_elements, 100_000);
285    }
286}