Skip to main content

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}