1use 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
49struct 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 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#[derive(Debug, Clone, Serialize, Deserialize)]
103struct Tombstone {
104 deleted_at_epoch: u64,
106}
107
108#[derive(Debug, Serialize, Deserialize)]
117struct HnswBackendSnapshot {
118 entries: Vec<HnswEntry>,
119 ef_search: usize,
120 ef_construction: usize,
121 #[serde(default)]
123 id_map: Vec<(u64, String)>,
124 #[serde(default)]
126 tombstones: Vec<(u64, Tombstone)>,
127 #[serde(default)]
129 epoch: u64,
130 #[serde(default)]
132 max_vectors: Option<usize>,
133}
134
135pub struct HnswBackend {
139 inner: HnswService,
140 id_map: Mutex<IdMap>,
141 epoch: AtomicU64,
143 tombstones: Mutex<HashMap<u64, Tombstone>>,
145 max_vectors: Mutex<Option<usize>>,
147}
148
149impl HnswBackend {
150 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 pub fn with_defaults() -> Self {
163 Self::new(HnswServiceConfig::default())
164 }
165
166 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 pub fn inner(&self) -> &HnswService {
179 &self.inner
180 }
181
182 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 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 fn bump_epoch(&self) -> u64 {
272 self.epoch.fetch_add(1, Ordering::SeqCst) + 1
273 }
274
275 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 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 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 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 map.key_to_id.get(&r.id).and_then(|&numeric_id| {
336 if ts.contains_key(&numeric_id) {
338 return None;
339 }
340 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 Ok(())
380 }
381
382 fn backend_name(&self) -> &str {
383 "hnsw"
384 }
385
386 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 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 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 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#[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 #[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 #[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 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(); b.insert_with_epoch(2, "b", &[0.0], serde_json::json!({}), epoch)
619 .unwrap();
620 assert_eq!(b.len(), 2);
621 }
622
623 #[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 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 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 #[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 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 #[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 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 b.soft_delete(1);
761
762 b.insert(3, "c", &[0.5], serde_json::json!({})).unwrap();
764 assert_eq!(b.len(), 2);
765 }
766
767 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 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 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 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 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 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}