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(
148 dir: &Path,
149 basename: &str,
150 config: VectorIndexConfig,
151 ) -> Result<Self, MemoryError> {
152 let backend = load_active_backend(dir, basename, config)?;
153 Ok(Self { inner: backend })
154 }
155
156 pub fn insert(&self, key: String, vector: &[f32]) -> Result<(), MemoryError> {
157 self.inner.insert(key, vector)
158 }
159
160 pub fn delete(&self, key: &str) -> Result<(), MemoryError> {
161 self.inner.delete(key)
162 }
163
164 pub fn update(&self, key: String, vector: &[f32]) -> Result<(), MemoryError> {
165 self.inner.update(key, vector)
166 }
167
168 pub fn search(&self, query: &[f32], top_k: usize) -> Result<Vec<VectorHit>, MemoryError> {
169 self.inner.search(query, top_k)
170 }
171
172 pub fn len(&self) -> usize {
173 self.inner.len()
174 }
175
176 pub fn is_empty(&self) -> bool {
177 self.inner.is_empty()
178 }
179
180 pub fn save(&self, dir: &Path, basename: &str) -> Result<(), MemoryError> {
181 self.inner.save(dir, basename)
182 }
183
184 pub fn backend_name(&self) -> &'static str {
185 self.inner.backend_name()
186 }
187
188 /// Note: downcasting to a concrete backend type is not supported
189 /// through the trait. Tests that need backend-specific introspection
190 /// should use the `backend_name()` method or read the sidecar
191 /// manifest. This is intentional — keeping the trait free of `Any`
192 /// avoids the vtable overhead and keeps the public surface minimal.
193 pub fn _placeholder(&self) {}
194}
195
196/// Factory: build a new backend using the active backend.
197///
198/// This is the single dispatch point that the rest of the crate uses to
199/// select between hnsw_rs and usearch at compile time. The dispatch is
200/// gated by `#[cfg(feature = ...)]` so the unused backend's code is not
201/// compiled.
202fn build_active_backend(config: VectorIndexConfig) -> Result<Arc<dyn VectorBackend>, MemoryError> {
203 #[cfg(feature = "hnsw")]
204 {
205 return Ok(Arc::new(super::hnsw_backend::HnswBackend::new(config)?));
206 }
207 #[cfg(feature = "usearch-backend")]
208 {
209 return Ok(Arc::new(super::usearch_backend::UsearchBackend::new(
210 config,
211 )?));
212 }
213 // If neither feature is enabled, fall through to a stub that returns
214 // an explicit error. The lib.rs compile_error! guard should prevent
215 // this from being reached in practice.
216 #[allow(unreachable_code)]
217 {
218 let _ = config;
219 Err(MemoryError::NotImplemented(
220 "no vector backend feature enabled (need `hnsw` or `usearch-backend`)".to_string(),
221 ))
222 }
223}
224
225fn load_active_backend(
226 dir: &Path,
227 basename: &str,
228 config: VectorIndexConfig,
229) -> Result<Arc<dyn VectorBackend>, MemoryError> {
230 #[cfg(feature = "hnsw")]
231 {
232 return Ok(Arc::new(super::hnsw_backend::HnswBackend::load(
233 dir, basename, config,
234 )?));
235 }
236 #[cfg(feature = "usearch-backend")]
237 {
238 return Ok(Arc::new(super::usearch_backend::UsearchBackend::load(
239 dir, basename, config,
240 )?));
241 }
242 #[allow(unreachable_code)]
243 {
244 let _ = (dir, basename, config);
245 Err(MemoryError::NotImplemented(
246 "no vector backend feature enabled (need `hnsw` or `usearch-backend`)".to_string(),
247 ))
248 }
249}
250
251#[cfg(test)]
252mod tests {
253 use super::*;
254
255 #[test]
256 fn vector_hit_similarity_below_zero_clamps_to_zero() {
257 let h = VectorHit {
258 key: "fact:1".to_string(),
259 distance: 2.0,
260 };
261 assert_eq!(h.similarity(), 0.0);
262 }
263
264 #[test]
265 fn vector_hit_similarity_normal() {
266 let h = VectorHit {
267 key: "fact:1".to_string(),
268 distance: 0.3,
269 };
270 assert!((h.similarity() - 0.7).abs() < 1e-6);
271 }
272
273 #[test]
274 fn vector_hit_parse_key_valid() {
275 let h = VectorHit {
276 key: "chunk:abc-123".to_string(),
277 distance: 0.0,
278 };
279 let (domain, id) = h.parse_key().unwrap();
280 assert_eq!(domain, "chunk");
281 assert_eq!(id, "abc-123");
282 }
283
284 #[test]
285 fn vector_hit_parse_key_invalid() {
286 let h = VectorHit {
287 key: "no_colon".to_string(),
288 distance: 0.0,
289 };
290 assert!(h.parse_key().is_err());
291 }
292
293 #[test]
294 fn config_default_matches_hnsw_default() {
295 let c = VectorIndexConfig::default();
296 assert_eq!(c.m, 16);
297 assert_eq!(c.ef_construction, 200);
298 assert_eq!(c.ef_search, 50);
299 assert_eq!(c.dimensions, 768);
300 assert_eq!(c.max_elements, 100_000);
301 }
302}