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}