clawft_kernel/vector_backend.rs
1//! Unified vector search backend trait.
2//!
3//! Provides a common interface for HNSW, DiskANN, and hybrid vector
4//! search backends. The kernel creates the appropriate backend at boot
5//! time based on [`VectorConfig`](clawft_types::config::VectorConfig).
6//!
7//! This module is compiled only when the `ecc` feature is enabled.
8
9use serde::{Deserialize, Serialize};
10
11// ── Search result ────────────────────────────────────────────────────────
12
13/// A single search result returned by any [`VectorBackend`].
14#[derive(Debug, Clone, Serialize, Deserialize)]
15pub struct SearchResult {
16 /// Entry identifier (numeric, for fast dedup/sorting).
17 pub id: u64,
18 /// String key originally associated with this vector (e.g., node id).
19 pub key: String,
20 /// Distance from the query vector (lower is closer).
21 pub distance: f32,
22 /// Arbitrary metadata stored alongside the embedding.
23 #[serde(default)]
24 pub metadata: serde_json::Value,
25}
26
27impl SearchResult {
28 /// Create a new search result.
29 pub fn new(id: u64, key: String, distance: f32, metadata: serde_json::Value) -> Self {
30 Self {
31 id,
32 key,
33 distance,
34 metadata,
35 }
36 }
37}
38
39// ── Error ────────────────────────────────────────────────────────────────
40
41/// Errors that can occur in vector backend operations.
42#[derive(Debug, thiserror::Error)]
43pub enum VectorError {
44 /// The vector dimensions do not match the expected dimensionality.
45 #[error("dimension mismatch: expected {expected}, got {got}")]
46 DimensionMismatch { expected: usize, got: usize },
47
48 /// I/O error during persistence operations.
49 #[error("io error: {0}")]
50 Io(#[from] std::io::Error),
51
52 /// The backend capacity has been exceeded.
53 #[error("capacity exceeded: max {max} entries")]
54 CapacityExceeded { max: usize },
55
56 /// The store has reached its configured vector limit.
57 #[error("store full: {current}/{max} vectors")]
58 StoreFull { max: usize, current: usize },
59
60 /// Optimistic concurrency conflict: the caller's epoch is stale.
61 #[error("epoch conflict: expected {expected}, actual {actual}")]
62 EpochConflict { expected: u64, actual: u64 },
63
64 /// Generic backend error.
65 #[error("{0}")]
66 Other(String),
67}
68
69/// Result alias for vector backend operations.
70pub type VectorResult<T> = Result<T, VectorError>;
71
72// ── Trait ────────────────────────────────────────────────────────────────
73
74/// Unified vector search backend interface.
75///
76/// Implementations must be `Send + Sync` so they can be shared across
77/// async tasks via `Arc`.
78pub trait VectorBackend: Send + Sync {
79 /// Insert a vector with the given numeric ID, string key, and metadata.
80 ///
81 /// If a vector with the same `id` already exists, it is replaced
82 /// (upsert semantics).
83 fn insert(
84 &self,
85 id: u64,
86 key: &str,
87 vector: &[f32],
88 metadata: serde_json::Value,
89 ) -> VectorResult<()>;
90
91 /// Search for the `k` nearest vectors to `query`.
92 ///
93 /// Results are sorted by ascending distance (closest first).
94 fn search(&self, query: &[f32], k: usize) -> Vec<SearchResult>;
95
96 /// Return the number of vectors currently stored.
97 fn len(&self) -> usize;
98
99 /// Return `true` if the store contains no vectors.
100 fn is_empty(&self) -> bool {
101 self.len() == 0
102 }
103
104 /// Check whether a vector with the given ID exists.
105 fn contains(&self, id: u64) -> bool;
106
107 /// Remove a vector by ID. Returns `true` if it was present.
108 fn remove(&self, id: u64) -> bool;
109
110 /// Persist any in-memory state to durable storage.
111 ///
112 /// For purely in-memory backends (HNSW), this is a no-op.
113 fn flush(&self) -> VectorResult<()>;
114
115 /// Return a human-readable name for this backend (e.g. "hnsw", "diskann", "hybrid").
116 fn backend_name(&self) -> &str;
117
118 // ── Epoch-based versioning ──────────────────────────────────────────
119
120 /// Return the current monotonic epoch.
121 ///
122 /// The epoch is bumped on every mutating operation (insert, delete,
123 /// update, soft-delete, compact). Implementations should persist the
124 /// epoch across restarts via [`flush`] / load.
125 fn current_epoch(&self) -> u64 {
126 0
127 }
128
129 // ── Optimistic concurrency control ──────────────────────────────────
130
131 /// Insert a vector only if the store's current epoch matches
132 /// `parent_epoch`. If the epoch has advanced past `parent_epoch`,
133 /// the call is rejected with [`VectorError::EpochConflict`].
134 ///
135 /// This enables optimistic concurrency control for multi-agent
136 /// writes: each agent reads the epoch, performs its work, then
137 /// submits with the epoch it observed.
138 fn insert_with_epoch(
139 &self,
140 id: u64,
141 key: &str,
142 vector: &[f32],
143 metadata: serde_json::Value,
144 parent_epoch: u64,
145 ) -> VectorResult<()> {
146 let current = self.current_epoch();
147 if parent_epoch < current {
148 return Err(VectorError::EpochConflict {
149 expected: parent_epoch,
150 actual: current,
151 });
152 }
153 self.insert(id, key, vector, metadata)
154 }
155
156 // ── Soft-delete + compaction ────────────────────────────────────────
157
158 /// Mark a vector as tombstoned instead of physically removing it.
159 ///
160 /// Tombstoned vectors are excluded from search results but remain
161 /// in storage until [`compact`] is called. Returns `true` if the
162 /// vector existed and was not already tombstoned.
163 fn soft_delete(&self, id: u64) -> bool {
164 // Default: fall back to hard delete for backends that do not
165 // implement tombstoning.
166 self.remove(id)
167 }
168
169 /// Physically remove tombstones whose deletion epoch is older than
170 /// `older_than_epoch`. Returns the number of entries purged.
171 fn compact(&self, _older_than_epoch: u64) -> usize {
172 0
173 }
174
175 /// Return the number of tombstoned (soft-deleted) entries.
176 fn tombstone_count(&self) -> usize {
177 0
178 }
179
180 // ── Capacity limits ────────────────────────────────────────────────
181
182 /// Return the configured maximum number of vectors, or `None` if
183 /// unbounded.
184 fn max_vectors(&self) -> Option<usize> {
185 None
186 }
187
188 /// Set the maximum number of vectors. Pass `None` for unbounded.
189 fn set_max_vectors(&self, _limit: Option<usize>) {
190 // Default: no-op. Implementations that support runtime
191 // reconfiguration should override.
192 }
193}
194
195// ── Backend configuration dispatch ───────────────────────────────────────
196
197/// Which backend to construct, read from configuration.
198#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
199#[serde(rename_all = "lowercase")]
200pub enum VectorBackendKind {
201 /// In-memory HNSW (default, fast, suitable for <1M vectors).
202 #[default]
203 Hnsw,
204 /// SSD-backed DiskANN (large scale, 1M+ vectors).
205 DiskAnn,
206 /// Hot HNSW cache + cold DiskANN store.
207 Hybrid,
208}
209
210#[cfg(test)]
211mod tests {
212 use super::*;
213
214 #[test]
215 fn search_result_new() {
216 let sr = SearchResult::new(1, "foo".into(), 0.5, serde_json::json!({"a": 1}));
217 assert_eq!(sr.id, 1);
218 assert_eq!(sr.key, "foo");
219 assert!((sr.distance - 0.5).abs() < f32::EPSILON);
220 }
221
222 #[test]
223 fn backend_kind_default_is_hnsw() {
224 assert_eq!(VectorBackendKind::default(), VectorBackendKind::Hnsw);
225 }
226
227 #[test]
228 fn backend_kind_deserialize() {
229 let kind: VectorBackendKind = serde_json::from_str(r#""hnsw""#).unwrap();
230 assert_eq!(kind, VectorBackendKind::Hnsw);
231 let kind: VectorBackendKind = serde_json::from_str(r#""diskann""#).unwrap();
232 assert_eq!(kind, VectorBackendKind::DiskAnn);
233 let kind: VectorBackendKind = serde_json::from_str(r#""hybrid""#).unwrap();
234 assert_eq!(kind, VectorBackendKind::Hybrid);
235 }
236
237 #[test]
238 fn error_store_full_display() {
239 let err = VectorError::StoreFull {
240 max: 100,
241 current: 100,
242 };
243 assert!(format!("{err}").contains("100"));
244 }
245
246 #[test]
247 fn error_epoch_conflict_display() {
248 let err = VectorError::EpochConflict {
249 expected: 5,
250 actual: 10,
251 };
252 let msg = format!("{err}");
253 assert!(msg.contains("5"));
254 assert!(msg.contains("10"));
255 }
256}