Skip to main content

clawft_kernel/
vector_hnsw.rs

1//! HNSW-backed [`VectorBackend`] implementation.
2//!
3//! Wraps the existing [`HnswService`] behind the unified
4//! [`VectorBackend`] trait so that it can be used standalone or as the
5//! hot tier inside [`HybridBackend`](super::vector_hybrid::HybridBackend).
6//!
7//! ## Hardening features (Cognitum Seed WS1)
8//!
9//! - **Epoch-based versioning**: monotonic epoch bumped on every mutation.
10//! - **Optimistic concurrency**: `insert_with_epoch` rejects stale writes.
11//! - **Soft-delete + compaction**: tombstone records excluded from search.
12//! - **Capacity limits**: configurable `max_vectors` with `StoreFull` error.
13//! - **Tombstone persistence** (WEFT-127): [`HnswBackend::save_to_file`] /
14//!   [`HnswBackend::load_from_file`] round-trip id map, epoch, capacity,
15//!   and soft-delete tombstones so deleted entries stay gone after restart.
16//!
17//! ## Soft-delete and compaction
18//!
19//! Soft-delete ([`VectorBackend::soft_delete`]) marks a numeric id as
20//! tombstoned at the current epoch. Tombstoned ids are excluded from
21//! `search`, `contains`, and `len`, but the underlying HNSW entry remains
22//! until compaction.
23//!
24//! [`VectorBackend::compact`] with `older_than_epoch` **physically** removes
25//! tombstones whose `deleted_at_epoch` is strictly less than that threshold:
26//! the id-map entry is dropped and the string-keyed vector is deleted from
27//! the inner [`HnswService`]. Callers that want durable soft-delete across
28//! process restarts must call [`HnswBackend::save_to_file`] after deletes
29//! (and after compact, if they want the purged state on disk).
30//!
31//! On-disk format is a JSON superset of the core `HnswStore` snapshot:
32//! legacy files with only `entries` / `ef_search` / `ef_construction`
33//! load with an empty tombstone section (backward compatible).
34//!
35//! Compiled only when the `ecc` feature is enabled.
36
37use std::collections::HashMap;
38use std::path::Path;
39use std::sync::Mutex;
40use std::sync::atomic::{AtomicU64, Ordering};
41
42use serde::{Deserialize, Serialize};
43
44use clawft_core::embeddings::hnsw_store::{HnswEntry, HnswStore};
45
46use crate::hnsw_service::{HnswService, HnswServiceConfig};
47use crate::vector_backend::{SearchResult, VectorBackend, VectorError, VectorResult};
48
49// ── ID ↔ key mapping ────────────────────────────────────────────────────
50
51/// Internal mapping between numeric IDs (used by `VectorBackend`) and
52/// the string keys used by `HnswService`.
53struct IdMap {
54    id_to_key: HashMap<u64, String>,
55    key_to_id: HashMap<String, u64>,
56}
57
58impl IdMap {
59    fn new() -> Self {
60        Self {
61            id_to_key: HashMap::new(),
62            key_to_id: HashMap::new(),
63        }
64    }
65
66    fn insert(&mut self, id: u64, key: String) {
67        // Remove old key mapping if this id was previously used.
68        if let Some(old_key) = self.id_to_key.insert(id, key.clone())
69            && old_key != key
70        {
71            self.key_to_id.remove(&old_key);
72        }
73        self.key_to_id.insert(key, id);
74    }
75
76    fn contains_id(&self, id: u64) -> bool {
77        self.id_to_key.contains_key(&id)
78    }
79
80    fn key_for_id(&self, id: u64) -> Option<&str> {
81        self.id_to_key.get(&id).map(|s| s.as_str())
82    }
83
84    fn remove(&mut self, id: u64) -> Option<String> {
85        if let Some(key) = self.id_to_key.remove(&id) {
86            self.key_to_id.remove(&key);
87            Some(key)
88        } else {
89            None
90        }
91    }
92
93    fn len(&self) -> usize {
94        self.id_to_key.len()
95    }
96}
97
98// ── Tombstone record ───────────────────────────────────────────────────
99
100/// A soft-deleted vector. Keeps the ID and the epoch at which it was
101/// deleted so that [`VectorBackend::compact`] can purge old tombstones.
102#[derive(Debug, Clone, Serialize, Deserialize)]
103struct Tombstone {
104    /// Epoch at which the vector was soft-deleted.
105    deleted_at_epoch: u64,
106}
107
108// ── On-disk snapshot (WEFT-127) ─────────────────────────────────────────
109
110/// JSON format written by [`HnswBackend::save_to_file`].
111///
112/// Super-set of the core `HnswStore` snapshot so:
113/// - new files remain loadable by `HnswStore::load` (extra keys ignored);
114/// - old files (no `tombstones` / `id_map` / `epoch`) load with empty
115///   tombstone state via `#[serde(default)]`.
116#[derive(Debug, Serialize, Deserialize)]
117struct HnswBackendSnapshot {
118    entries: Vec<HnswEntry>,
119    ef_search: usize,
120    ef_construction: usize,
121    /// Numeric id → string key map. Missing on legacy snapshots → empty.
122    #[serde(default)]
123    id_map: Vec<(u64, String)>,
124    /// Soft-deleted ids with deletion epoch. Missing → empty (compat).
125    #[serde(default)]
126    tombstones: Vec<(u64, Tombstone)>,
127    /// Monotonic backend epoch. Missing → 0.
128    #[serde(default)]
129    epoch: u64,
130    /// Optional capacity limit. Missing → `None` (unbounded).
131    #[serde(default)]
132    max_vectors: Option<usize>,
133}
134
135// ── Backend ─────────────────────────────────────────────────────────────
136
137/// HNSW vector backend wrapping [`HnswService`].
138pub struct HnswBackend {
139    inner: HnswService,
140    id_map: Mutex<IdMap>,
141    /// Monotonic epoch counter -- bumped on every mutation.
142    epoch: AtomicU64,
143    /// Soft-deleted entries keyed by vector ID.
144    tombstones: Mutex<HashMap<u64, Tombstone>>,
145    /// Optional upper bound on stored vectors.
146    max_vectors: Mutex<Option<usize>>,
147}
148
149impl HnswBackend {
150    /// Create a new HNSW backend with the given configuration.
151    pub fn new(config: HnswServiceConfig) -> Self {
152        Self {
153            inner: HnswService::new(config),
154            id_map: Mutex::new(IdMap::new()),
155            epoch: AtomicU64::new(0),
156            tombstones: Mutex::new(HashMap::new()),
157            max_vectors: Mutex::new(None),
158        }
159    }
160
161    /// Create with default configuration.
162    pub fn with_defaults() -> Self {
163        Self::new(HnswServiceConfig::default())
164    }
165
166    /// Create with a capacity limit.
167    pub fn with_max_vectors(config: HnswServiceConfig, limit: usize) -> Self {
168        Self {
169            inner: HnswService::new(config),
170            id_map: Mutex::new(IdMap::new()),
171            epoch: AtomicU64::new(0),
172            tombstones: Mutex::new(HashMap::new()),
173            max_vectors: Mutex::new(Some(limit)),
174        }
175    }
176
177    /// Access the underlying [`HnswService`] for legacy code paths.
178    pub fn inner(&self) -> &HnswService {
179        &self.inner
180    }
181
182    /// Persist backend state (vectors, id map, tombstones, epoch, capacity).
183    ///
184    /// Soft-deleted entries remain in the vector list but are recorded in
185    /// the `tombstones` section so they stay excluded after
186    /// [`load_from_file`]. See module docs for the compaction story.
187    pub fn save_to_file(&self, path: &Path) -> VectorResult<()> {
188        if let Some(parent) = path.parent() {
189            std::fs::create_dir_all(parent)?;
190        }
191
192        let entries = self.inner.snapshot_entries();
193        let (ef_search, ef_construction) = self.inner.ef_params();
194
195        let map = self.id_map.lock().expect("IdMap lock poisoned");
196        let id_map: Vec<(u64, String)> = map
197            .id_to_key
198            .iter()
199            .map(|(&id, key)| (id, key.clone()))
200            .collect();
201
202        let ts = self.tombstones.lock().expect("tombstones lock poisoned");
203        let tombstones: Vec<(u64, Tombstone)> = ts
204            .iter()
205            .map(|(&id, t)| (id, t.clone()))
206            .collect();
207
208        let snapshot = HnswBackendSnapshot {
209            entries,
210            ef_search,
211            ef_construction,
212            id_map,
213            tombstones,
214            epoch: self.epoch.load(Ordering::SeqCst),
215            max_vectors: *self.max_vectors.lock().expect("max_vectors lock poisoned"),
216        };
217
218        let json = serde_json::to_string_pretty(&snapshot)
219            .map_err(|e| VectorError::Other(format!("serialize hnsw backend: {e}")))?;
220        std::fs::write(path, json)?;
221        Ok(())
222    }
223
224    /// Load a backend from a snapshot written by [`save_to_file`].
225    ///
226    /// **Backward compatible**: files produced by plain `HnswStore` /
227    /// `HnswService` save (entries + ef params only) load successfully
228    /// with empty `id_map`, empty tombstones, epoch 0, and no capacity
229    /// limit.
230    pub fn load_from_file(path: &Path) -> VectorResult<Self> {
231        if !path.exists() {
232            return Ok(Self::with_defaults());
233        }
234
235        let data = std::fs::read_to_string(path)?;
236        let snapshot: HnswBackendSnapshot = serde_json::from_str(&data)
237            .map_err(|e| VectorError::Other(format!("deserialize hnsw backend: {e}")))?;
238
239        let store = HnswStore::from_entries(
240            snapshot.entries,
241            snapshot.ef_search,
242            snapshot.ef_construction,
243        );
244        let config = HnswServiceConfig {
245            ef_search: snapshot.ef_search,
246            ef_construction: snapshot.ef_construction,
247            default_dimensions: 384,
248        };
249        let inner = HnswService::from_store(config, store);
250
251        let mut id_map = IdMap::new();
252        for (id, key) in snapshot.id_map {
253            id_map.insert(id, key);
254        }
255
256        let mut tombstones = HashMap::new();
257        for (id, t) in snapshot.tombstones {
258            tombstones.insert(id, t);
259        }
260
261        Ok(Self {
262            inner,
263            id_map: Mutex::new(id_map),
264            epoch: AtomicU64::new(snapshot.epoch),
265            tombstones: Mutex::new(tombstones),
266            max_vectors: Mutex::new(snapshot.max_vectors),
267        })
268    }
269
270    /// Bump the epoch and return the new value.
271    fn bump_epoch(&self) -> u64 {
272        self.epoch.fetch_add(1, Ordering::SeqCst) + 1
273    }
274
275    /// Number of live (non-tombstoned) vectors.
276    fn live_count(&self) -> usize {
277        let map = self.id_map.lock().expect("IdMap lock poisoned");
278        let ts = self.tombstones.lock().expect("tombstones lock poisoned");
279        map.len().saturating_sub(ts.len())
280    }
281
282    /// Check capacity and return an error if full.
283    fn check_capacity(
284        &self,
285        id_map: &IdMap,
286        tombstones: &HashMap<u64, Tombstone>,
287        id: u64,
288    ) -> VectorResult<()> {
289        let limit = self.max_vectors.lock().expect("max_vectors lock poisoned");
290        if let Some(max) = *limit {
291            let live = id_map.len().saturating_sub(tombstones.len());
292            // Allow upsert of existing id.
293            if live >= max && !id_map.contains_id(id) {
294                return Err(VectorError::StoreFull { max, current: live });
295            }
296        }
297        Ok(())
298    }
299}
300
301impl VectorBackend for HnswBackend {
302    fn insert(
303        &self,
304        id: u64,
305        key: &str,
306        vector: &[f32],
307        metadata: serde_json::Value,
308    ) -> VectorResult<()> {
309        let mut map = self.id_map.lock().expect("IdMap lock poisoned");
310        let mut ts = self.tombstones.lock().expect("tombstones lock poisoned");
311
312        // If this id was tombstoned, un-tombstone it (re-insert).
313        ts.remove(&id);
314
315        self.check_capacity(&map, &ts, id)?;
316
317        map.insert(id, key.to_owned());
318        drop(ts);
319        drop(map);
320
321        self.inner.insert(key.to_owned(), vector.to_vec(), metadata);
322        self.bump_epoch();
323        Ok(())
324    }
325
326    fn search(&self, query: &[f32], k: usize) -> Vec<SearchResult> {
327        let results = self.inner.search(query, k);
328        let map = self.id_map.lock().expect("IdMap lock poisoned");
329        let ts = self.tombstones.lock().expect("tombstones lock poisoned");
330
331        results
332            .into_iter()
333            .filter_map(|r| {
334                // Reverse-lookup the numeric id from the string key.
335                map.key_to_id.get(&r.id).and_then(|&numeric_id| {
336                    // Exclude tombstoned entries.
337                    if ts.contains_key(&numeric_id) {
338                        return None;
339                    }
340                    // HnswService returns cosine similarity (1.0 = identical).
341                    // Convert to distance: distance = 1.0 - similarity.
342                    let distance = 1.0 - r.score;
343                    Some(SearchResult::new(numeric_id, r.id, distance, r.metadata))
344                })
345            })
346            .collect()
347    }
348
349    fn len(&self) -> usize {
350        self.live_count()
351    }
352
353    fn contains(&self, id: u64) -> bool {
354        let map = self.id_map.lock().expect("IdMap lock poisoned");
355        let ts = self.tombstones.lock().expect("tombstones lock poisoned");
356        map.contains_id(id) && !ts.contains_key(&id)
357    }
358
359    fn remove(&self, id: u64) -> bool {
360        let mut map = self.id_map.lock().expect("IdMap lock poisoned");
361        let mut ts = self.tombstones.lock().expect("tombstones lock poisoned");
362        ts.remove(&id);
363        let key = map.remove(id);
364        let removed = key.is_some();
365        if removed {
366            drop(ts);
367            drop(map);
368            if let Some(key) = key {
369                self.inner.delete(&key);
370            }
371            self.bump_epoch();
372        }
373        removed
374    }
375
376    fn flush(&self) -> VectorResult<()> {
377        // Pure in-memory HNSW has no automatic durable path. Callers that
378        // need durability (including tombstones) use [`Self::save_to_file`].
379        Ok(())
380    }
381
382    fn backend_name(&self) -> &str {
383        "hnsw"
384    }
385
386    // ── Epoch ───────────────────────────────────────────────────────────
387
388    fn current_epoch(&self) -> u64 {
389        self.epoch.load(Ordering::SeqCst)
390    }
391
392    fn insert_with_epoch(
393        &self,
394        id: u64,
395        key: &str,
396        vector: &[f32],
397        metadata: serde_json::Value,
398        parent_epoch: u64,
399    ) -> VectorResult<()> {
400        let current = self.epoch.load(Ordering::SeqCst);
401        if parent_epoch < current {
402            return Err(VectorError::EpochConflict {
403                expected: parent_epoch,
404                actual: current,
405            });
406        }
407        self.insert(id, key, vector, metadata)
408    }
409
410    // ── Soft-delete + compaction ────────────────────────────────────────
411
412    fn soft_delete(&self, id: u64) -> bool {
413        let map = self.id_map.lock().expect("IdMap lock poisoned");
414        let mut ts = self.tombstones.lock().expect("tombstones lock poisoned");
415
416        if !map.contains_id(id) || ts.contains_key(&id) {
417            return false;
418        }
419
420        let epoch = self.bump_epoch();
421        ts.insert(
422            id,
423            Tombstone {
424                deleted_at_epoch: epoch,
425            },
426        );
427        true
428    }
429
430    fn compact(&self, older_than_epoch: u64) -> usize {
431        let mut map = self.id_map.lock().expect("IdMap lock poisoned");
432        let mut ts = self.tombstones.lock().expect("tombstones lock poisoned");
433
434        let to_purge: Vec<(u64, String)> = ts
435            .iter()
436            .filter(|(_, t)| t.deleted_at_epoch < older_than_epoch)
437            .filter_map(|(&id, _)| map.key_for_id(id).map(|k| (id, k.to_owned())))
438            .collect();
439
440        // Also drop orphan tombstones with no id_map entry.
441        let orphan_ids: Vec<u64> = ts
442            .iter()
443            .filter(|(_, t)| t.deleted_at_epoch < older_than_epoch)
444            .map(|(&id, _)| id)
445            .filter(|id| !to_purge.iter().any(|(p, _)| p == id))
446            .collect();
447
448        let count = to_purge.len() + orphan_ids.len();
449        let mut keys_to_delete = Vec::with_capacity(to_purge.len());
450        for (id, key) in to_purge {
451            ts.remove(&id);
452            map.remove(id);
453            keys_to_delete.push(key);
454        }
455        for id in orphan_ids {
456            ts.remove(&id);
457        }
458
459        if count > 0 {
460            drop(ts);
461            drop(map);
462            for key in keys_to_delete {
463                self.inner.delete(&key);
464            }
465            self.bump_epoch();
466        }
467
468        count
469    }
470
471    fn tombstone_count(&self) -> usize {
472        let ts = self.tombstones.lock().expect("tombstones lock poisoned");
473        ts.len()
474    }
475
476    // ── Capacity limits ────────────────────────────────────────────────
477
478    fn max_vectors(&self) -> Option<usize> {
479        *self.max_vectors.lock().expect("max_vectors lock poisoned")
480    }
481
482    fn set_max_vectors(&self, limit: Option<usize>) {
483        *self.max_vectors.lock().expect("max_vectors lock poisoned") = limit;
484    }
485}
486
487impl std::fmt::Debug for HnswBackend {
488    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
489        f.debug_struct("HnswBackend")
490            .field("len", &self.len())
491            .field("epoch", &self.current_epoch())
492            .field("tombstones", &self.tombstone_count())
493            .field("max_vectors", &self.max_vectors())
494            .finish()
495    }
496}
497
498// ── Tests ────────────────────────────────────────────────────────────────
499
500#[cfg(test)]
501mod tests {
502    use super::*;
503
504    fn make_backend() -> HnswBackend {
505        HnswBackend::with_defaults()
506    }
507
508    #[test]
509    fn insert_and_search() {
510        let b = make_backend();
511        b.insert(1, "a", &[1.0, 0.0, 0.0], serde_json::json!({}))
512            .unwrap();
513        b.insert(2, "b", &[0.0, 1.0, 0.0], serde_json::json!({}))
514            .unwrap();
515        b.insert(3, "c", &[0.0, 0.0, 1.0], serde_json::json!({}))
516            .unwrap();
517
518        assert_eq!(b.len(), 3);
519        assert!(!b.is_empty());
520
521        let results = b.search(&[1.0, 0.0, 0.0], 2);
522        assert_eq!(results.len(), 2);
523        assert_eq!(results[0].id, 1);
524        assert_eq!(results[0].key, "a");
525        assert!(results[0].distance < 0.01);
526    }
527
528    #[test]
529    fn contains_and_remove() {
530        let b = make_backend();
531        b.insert(10, "x", &[1.0, 0.0], serde_json::json!({}))
532            .unwrap();
533        assert!(b.contains(10));
534        assert!(!b.contains(99));
535
536        assert!(b.remove(10));
537        assert!(!b.contains(10));
538        assert!(!b.remove(10));
539    }
540
541    #[test]
542    fn flush_is_noop() {
543        let b = make_backend();
544        b.flush().unwrap();
545    }
546
547    #[test]
548    fn backend_name() {
549        let b = make_backend();
550        assert_eq!(b.backend_name(), "hnsw");
551    }
552
553    #[test]
554    fn empty_search() {
555        let b = make_backend();
556        let results = b.search(&[1.0, 0.0], 5);
557        assert!(results.is_empty());
558    }
559
560    // ── Epoch tests ─────────────────────────────────────────────────────
561
562    #[test]
563    fn epoch_starts_at_zero() {
564        let b = make_backend();
565        assert_eq!(b.current_epoch(), 0);
566    }
567
568    #[test]
569    fn epoch_increments_on_insert() {
570        let b = make_backend();
571        b.insert(1, "a", &[1.0], serde_json::json!({})).unwrap();
572        assert_eq!(b.current_epoch(), 1);
573        b.insert(2, "b", &[0.0], serde_json::json!({})).unwrap();
574        assert_eq!(b.current_epoch(), 2);
575    }
576
577    #[test]
578    fn epoch_increments_on_remove() {
579        let b = make_backend();
580        b.insert(1, "a", &[1.0], serde_json::json!({})).unwrap();
581        assert_eq!(b.current_epoch(), 1);
582        b.remove(1);
583        assert_eq!(b.current_epoch(), 2);
584    }
585
586    // ── Optimistic concurrency tests ────────────────────────────────────
587
588    #[test]
589    fn insert_with_epoch_succeeds_when_current() {
590        let b = make_backend();
591        let epoch = b.current_epoch();
592        b.insert_with_epoch(1, "a", &[1.0], serde_json::json!({}), epoch)
593            .unwrap();
594        assert_eq!(b.len(), 1);
595    }
596
597    #[test]
598    fn insert_with_epoch_rejects_stale() {
599        let b = make_backend();
600        b.insert(1, "a", &[1.0], serde_json::json!({})).unwrap();
601        // Epoch is now 1. Trying with parent_epoch=0 should fail.
602        let result = b.insert_with_epoch(2, "b", &[0.0], serde_json::json!({}), 0);
603        assert!(result.is_err());
604        match result.unwrap_err() {
605            VectorError::EpochConflict { expected, actual } => {
606                assert_eq!(expected, 0);
607                assert_eq!(actual, 1);
608            }
609            other => panic!("expected EpochConflict, got: {other}"),
610        }
611    }
612
613    #[test]
614    fn insert_with_epoch_accepts_current_epoch() {
615        let b = make_backend();
616        b.insert(1, "a", &[1.0], serde_json::json!({})).unwrap();
617        let epoch = b.current_epoch(); // 1
618        b.insert_with_epoch(2, "b", &[0.0], serde_json::json!({}), epoch)
619            .unwrap();
620        assert_eq!(b.len(), 2);
621    }
622
623    // ── Soft-delete tests ───────────────────────────────────────────────
624
625    #[test]
626    fn soft_delete_hides_from_search() {
627        let b = make_backend();
628        b.insert(1, "a", &[1.0, 0.0, 0.0], serde_json::json!({}))
629            .unwrap();
630        b.insert(2, "b", &[0.9, 0.1, 0.0], serde_json::json!({}))
631            .unwrap();
632
633        assert!(b.soft_delete(1));
634        assert_eq!(b.tombstone_count(), 1);
635        assert!(!b.contains(1));
636
637        // Search should not return tombstoned vector.
638        let results = b.search(&[1.0, 0.0, 0.0], 5);
639        for r in &results {
640            assert_ne!(r.id, 1, "tombstoned vector should not appear in search");
641        }
642    }
643
644    #[test]
645    fn soft_delete_nonexistent_returns_false() {
646        let b = make_backend();
647        assert!(!b.soft_delete(999));
648    }
649
650    #[test]
651    fn soft_delete_already_tombstoned_returns_false() {
652        let b = make_backend();
653        b.insert(1, "a", &[1.0], serde_json::json!({})).unwrap();
654        assert!(b.soft_delete(1));
655        assert!(!b.soft_delete(1));
656    }
657
658    #[test]
659    fn soft_delete_reduces_len() {
660        let b = make_backend();
661        b.insert(1, "a", &[1.0], serde_json::json!({})).unwrap();
662        b.insert(2, "b", &[0.0], serde_json::json!({})).unwrap();
663        assert_eq!(b.len(), 2);
664        b.soft_delete(1);
665        assert_eq!(b.len(), 1);
666    }
667
668    #[test]
669    fn reinsert_after_soft_delete() {
670        let b = make_backend();
671        b.insert(1, "a", &[1.0], serde_json::json!({})).unwrap();
672        b.soft_delete(1);
673        assert_eq!(b.len(), 0);
674        assert_eq!(b.tombstone_count(), 1);
675
676        // Re-insert same id clears the tombstone.
677        b.insert(1, "a", &[0.5], serde_json::json!({})).unwrap();
678        assert_eq!(b.len(), 1);
679        assert_eq!(b.tombstone_count(), 0);
680    }
681
682    // ── Compaction tests ────────────────────────────────────────────────
683
684    #[test]
685    fn compact_purges_old_tombstones() {
686        let b = make_backend();
687        b.insert(1, "a", &[1.0], serde_json::json!({})).unwrap();
688        b.insert(2, "b", &[0.0], serde_json::json!({})).unwrap();
689
690        b.soft_delete(1);
691        let delete_epoch = b.current_epoch();
692
693        b.soft_delete(2);
694
695        // Compact tombstones older than the second soft-delete epoch.
696        // Only tombstone for id=1 should be purged.
697        let purged = b.compact(delete_epoch + 1);
698        assert_eq!(purged, 1);
699        assert_eq!(b.tombstone_count(), 1);
700    }
701
702    #[test]
703    fn compact_returns_zero_when_nothing_to_purge() {
704        let b = make_backend();
705        b.insert(1, "a", &[1.0], serde_json::json!({})).unwrap();
706        assert_eq!(b.compact(100), 0);
707    }
708
709    // ── Capacity limit tests ────────────────────────────────────────────
710
711    #[test]
712    fn capacity_limit_enforced() {
713        let b = HnswBackend::with_max_vectors(HnswServiceConfig::default(), 2);
714        b.insert(1, "a", &[1.0], serde_json::json!({})).unwrap();
715        b.insert(2, "b", &[0.0], serde_json::json!({})).unwrap();
716
717        let result = b.insert(3, "c", &[0.5], serde_json::json!({}));
718        assert!(result.is_err());
719        match result.unwrap_err() {
720            VectorError::StoreFull { max, current } => {
721                assert_eq!(max, 2);
722                assert_eq!(current, 2);
723            }
724            other => panic!("expected StoreFull, got: {other}"),
725        }
726    }
727
728    #[test]
729    fn capacity_upsert_does_not_count_double() {
730        let b = HnswBackend::with_max_vectors(HnswServiceConfig::default(), 2);
731        b.insert(1, "a", &[1.0], serde_json::json!({})).unwrap();
732        b.insert(2, "b", &[0.0], serde_json::json!({})).unwrap();
733        // Upsert existing id=1 should succeed.
734        b.insert(1, "a-v2", &[0.5], serde_json::json!({})).unwrap();
735    }
736
737    #[test]
738    fn capacity_default_is_none() {
739        let b = make_backend();
740        assert_eq!(b.max_vectors(), None);
741    }
742
743    #[test]
744    fn set_max_vectors_at_runtime() {
745        let b = make_backend();
746        assert_eq!(b.max_vectors(), None);
747        b.set_max_vectors(Some(10));
748        assert_eq!(b.max_vectors(), Some(10));
749        b.set_max_vectors(None);
750        assert_eq!(b.max_vectors(), None);
751    }
752
753    #[test]
754    fn soft_deleted_vectors_do_not_count_against_capacity() {
755        let b = HnswBackend::with_max_vectors(HnswServiceConfig::default(), 2);
756        b.insert(1, "a", &[1.0], serde_json::json!({})).unwrap();
757        b.insert(2, "b", &[0.0], serde_json::json!({})).unwrap();
758
759        // Soft-delete one -- frees a slot.
760        b.soft_delete(1);
761
762        // Should now accept a new insert.
763        b.insert(3, "c", &[0.5], serde_json::json!({})).unwrap();
764        assert_eq!(b.len(), 2);
765    }
766
767    // ── Persistence / tombstone round-trip (WEFT-127) ───────────────────
768
769    fn temp_backend_path(label: &str) -> std::path::PathBuf {
770        use std::sync::atomic::{AtomicU64, Ordering as AtomicOrdering};
771        static N: AtomicU64 = AtomicU64::new(0);
772        let n = N.fetch_add(1, AtomicOrdering::Relaxed);
773        std::env::temp_dir().join(format!(
774            "weft127_hnsw_backend_{label}_{}_{n}.json",
775            std::process::id()
776        ))
777    }
778
779    #[test]
780    fn soft_delete_save_load_keeps_tombstones() {
781        let path = temp_backend_path("tombstone_roundtrip");
782        let _ = std::fs::remove_file(&path);
783
784        {
785            let b = make_backend();
786            b.insert(1, "a", &[1.0, 0.0, 0.0], serde_json::json!({"k": "a"}))
787                .unwrap();
788            b.insert(2, "b", &[0.0, 1.0, 0.0], serde_json::json!({"k": "b"}))
789                .unwrap();
790            b.insert(3, "c", &[0.0, 0.0, 1.0], serde_json::json!({"k": "c"}))
791                .unwrap();
792
793            assert!(b.soft_delete(1));
794            assert_eq!(b.tombstone_count(), 1);
795            assert!(!b.contains(1));
796            assert_eq!(b.len(), 2);
797
798            let epoch_before = b.current_epoch();
799            b.save_to_file(&path).unwrap();
800
801            // Sanity: epoch captured in snapshot should match.
802            assert!(epoch_before > 0);
803        }
804
805        let loaded = HnswBackend::load_from_file(&path).unwrap();
806        assert_eq!(loaded.tombstone_count(), 1, "tombstone must survive load");
807        assert!(!loaded.contains(1), "soft-deleted id must stay gone");
808        assert!(loaded.contains(2));
809        assert!(loaded.contains(3));
810        assert_eq!(loaded.len(), 2);
811
812        // Search must not resurface the soft-deleted vector.
813        let results = loaded.search(&[1.0, 0.0, 0.0], 5);
814        for r in &results {
815            assert_ne!(r.id, 1, "tombstoned vector must not appear after load");
816        }
817
818        let _ = std::fs::remove_file(&path);
819    }
820
821    #[test]
822    fn load_legacy_store_snapshot_has_empty_tombstones() {
823        // Write a plain HnswStore snapshot (no id_map / tombstones section).
824        let path = temp_backend_path("legacy_compat");
825        let _ = std::fs::remove_file(&path);
826
827        {
828            let mut store = HnswStore::new();
829            store.insert("legacy-a".into(), vec![1.0, 0.0], serde_json::json!({}));
830            store.insert("legacy-b".into(), vec![0.0, 1.0], serde_json::json!({}));
831            store.save(&path).unwrap();
832        }
833
834        let loaded = HnswBackend::load_from_file(&path).unwrap();
835        assert_eq!(
836            loaded.tombstone_count(),
837            0,
838            "missing tombstone section must default to empty"
839        );
840        assert_eq!(loaded.current_epoch(), 0);
841        assert_eq!(loaded.max_vectors(), None);
842        // id_map is empty on legacy files — vectors live only in the store
843        // until re-inserted through the backend API.
844        assert_eq!(loaded.len(), 0);
845        assert_eq!(loaded.inner().len(), 2);
846
847        let _ = std::fs::remove_file(&path);
848    }
849
850    #[test]
851    fn compact_physically_removes_and_survives_save() {
852        let path = temp_backend_path("compact_save");
853        let _ = std::fs::remove_file(&path);
854
855        let b = make_backend();
856        b.insert(1, "a", &[1.0], serde_json::json!({})).unwrap();
857        b.insert(2, "b", &[0.0], serde_json::json!({})).unwrap();
858        b.soft_delete(1);
859        let delete_epoch = b.current_epoch();
860
861        let purged = b.compact(delete_epoch + 1);
862        assert_eq!(purged, 1);
863        assert_eq!(b.tombstone_count(), 0);
864        assert!(!b.contains(1));
865        // Physical delete from inner store.
866        assert_eq!(b.inner().len(), 1);
867
868        b.save_to_file(&path).unwrap();
869        let loaded = HnswBackend::load_from_file(&path).unwrap();
870        assert_eq!(loaded.tombstone_count(), 0);
871        assert!(!loaded.contains(1));
872        assert!(loaded.contains(2));
873        assert_eq!(loaded.inner().len(), 1);
874
875        let _ = std::fs::remove_file(&path);
876    }
877}