1use std::collections::HashMap;
44use std::sync::Mutex;
45use std::sync::atomic::{AtomicU64, Ordering};
46
47use serde::{Deserialize, Serialize};
48
49#[cfg(feature = "diskann")]
50use ruvector_diskann::{DiskAnnConfig as RealDiskAnnConfig, DiskAnnIndex};
51
52use clawft_types::config::VectorBackendKind;
53
54use crate::vector_backend::{SearchResult, VectorBackend, VectorError, VectorResult};
55
56#[derive(Debug, Clone, Copy, PartialEq, Eq)]
63pub enum DiskAnnFeatureStatus {
64 Ok,
67 StubDegraded,
71 StubRejected,
74}
75
76pub fn diskann_feature_status(
86 configured_kind: VectorBackendKind,
87 feature_compiled: bool,
88 strict: bool,
89) -> DiskAnnFeatureStatus {
90 let needs_diskann = matches!(
91 configured_kind,
92 VectorBackendKind::DiskAnn | VectorBackendKind::Hybrid
93 );
94 if !needs_diskann || feature_compiled {
95 DiskAnnFeatureStatus::Ok
96 } else if strict {
97 DiskAnnFeatureStatus::StubRejected
98 } else {
99 DiskAnnFeatureStatus::StubDegraded
100 }
101}
102
103#[derive(Debug, Clone, Serialize, Deserialize)]
107pub struct DiskAnnConfig {
108 #[serde(default = "default_max_points")]
110 pub max_points: usize,
111
112 #[serde(default = "default_dimensions")]
114 pub dimensions: usize,
115
116 #[serde(default = "default_num_neighbors")]
118 pub num_neighbors: usize,
119
120 #[serde(default = "default_search_list_size")]
122 pub search_list_size: usize,
123
124 #[serde(default = "default_data_path")]
126 pub data_path: String,
127
128 #[serde(default = "default_use_pq")]
130 pub use_pq: bool,
131
132 #[serde(default = "default_pq_num_chunks")]
134 pub pq_num_chunks: usize,
135}
136
137fn default_max_points() -> usize {
138 10_000_000
139}
140fn default_dimensions() -> usize {
141 384
142}
143fn default_num_neighbors() -> usize {
144 64
145}
146fn default_search_list_size() -> usize {
147 100
148}
149fn default_data_path() -> String {
150 ".weftos/diskann".to_owned()
151}
152fn default_use_pq() -> bool {
153 true
154}
155fn default_pq_num_chunks() -> usize {
156 48
157}
158
159impl Default for DiskAnnConfig {
160 fn default() -> Self {
161 Self {
162 max_points: default_max_points(),
163 dimensions: default_dimensions(),
164 num_neighbors: default_num_neighbors(),
165 search_list_size: default_search_list_size(),
166 data_path: default_data_path(),
167 use_pq: default_use_pq(),
168 pq_num_chunks: default_pq_num_chunks(),
169 }
170 }
171}
172
173#[cfg(not(feature = "diskann"))]
177#[derive(Clone)]
178struct StoredEntry {
179 key: String,
180 vector: Vec<f32>,
181 metadata: serde_json::Value,
182}
183
184#[derive(Debug, Clone)]
188struct Tombstone {
189 deleted_at_epoch: u64,
190}
191
192pub struct DiskAnnBackend {
199 config: DiskAnnConfig,
200 #[cfg(not(feature = "diskann"))]
202 entries: Mutex<HashMap<u64, StoredEntry>>,
203 #[cfg(feature = "diskann")]
205 index: Mutex<DiskAnnIndex>,
206 #[cfg(feature = "diskann")]
208 id_map: Mutex<HashMap<u64, String>>,
209 #[cfg(feature = "diskann")]
212 key_to_id: Mutex<HashMap<String, u64>>,
213 epoch: AtomicU64,
215 tombstones: Mutex<HashMap<u64, Tombstone>>,
217 max_vectors_override: Mutex<Option<usize>>,
219}
220
221impl DiskAnnBackend {
222 pub fn new(config: DiskAnnConfig) -> Self {
224 #[cfg(feature = "diskann")]
225 {
226 let real_config = RealDiskAnnConfig {
227 dim: config.dimensions,
228 max_degree: config.num_neighbors,
229 build_beam: config.search_list_size,
230 search_beam: config.search_list_size,
231 alpha: 1.2,
232 pq_subspaces: if config.use_pq {
233 config.pq_num_chunks
234 } else {
235 0
236 },
237 pq_iterations: 10,
238 storage_path: Some(std::path::PathBuf::from(&config.data_path)),
239 };
240 let index = DiskAnnIndex::new(real_config);
241 Self {
242 config,
243 index: Mutex::new(index),
244 id_map: Mutex::new(HashMap::new()),
245 key_to_id: Mutex::new(HashMap::new()),
246 epoch: AtomicU64::new(0),
247 tombstones: Mutex::new(HashMap::new()),
248 max_vectors_override: Mutex::new(None),
249 }
250 }
251 #[cfg(not(feature = "diskann"))]
252 {
253 Self {
254 config,
255 entries: Mutex::new(HashMap::new()),
256 epoch: AtomicU64::new(0),
257 tombstones: Mutex::new(HashMap::new()),
258 max_vectors_override: Mutex::new(None),
259 }
260 }
261 }
262
263 pub fn with_defaults() -> Self {
265 Self::new(DiskAnnConfig::default())
266 }
267
268 pub fn config(&self) -> &DiskAnnConfig {
270 &self.config
271 }
272
273 fn bump_epoch(&self) -> u64 {
275 self.epoch.fetch_add(1, Ordering::SeqCst) + 1
276 }
277
278 fn effective_max(&self) -> Option<usize> {
280 let ov = self
281 .max_vectors_override
282 .lock()
283 .expect("max_vectors lock poisoned");
284 if ov.is_some() {
285 return *ov;
286 }
287 Some(self.config.max_points)
288 }
289}
290
291#[cfg(not(feature = "diskann"))]
292fn cosine_distance(a: &[f32], b: &[f32]) -> f32 {
297 let mut dot = 0.0f32;
298 let mut norm_a = 0.0f32;
299 let mut norm_b = 0.0f32;
300 for (x, y) in a.iter().zip(b.iter()) {
301 dot += x * y;
302 norm_a += x * x;
303 norm_b += y * y;
304 }
305 let denom = norm_a.sqrt() * norm_b.sqrt();
306 if denom < f32::EPSILON {
307 return 1.0;
308 }
309 1.0 - (dot / denom)
310}
311
312#[cfg(feature = "diskann")]
315impl VectorBackend for DiskAnnBackend {
316 fn insert(
317 &self,
318 id: u64,
319 key: &str,
320 vector: &[f32],
321 _metadata: serde_json::Value,
322 ) -> VectorResult<()> {
323 let mut id_map = self.id_map.lock().expect("DiskAnn id_map lock poisoned");
325 let mut key_to_id = self
326 .key_to_id
327 .lock()
328 .expect("DiskAnn key_to_id lock poisoned");
329 let mut ts = self.tombstones.lock().expect("tombstones lock poisoned");
330 ts.remove(&id);
331
332 let live = id_map.len().saturating_sub(ts.len());
334 if let Some(max) = self.effective_max()
335 && live >= max
336 && !id_map.contains_key(&id)
337 {
338 return Err(VectorError::StoreFull { max, current: live });
339 }
340 drop(ts);
341
342 let mut index = self.index.lock().expect("DiskAnn lock poisoned");
343 index
344 .insert(key.to_owned(), vector.to_vec())
345 .map_err(|e| VectorError::Other(format!("diskann insert: {e}")))?;
346 if let Some(old_key) = id_map.insert(id, key.to_owned()) {
348 key_to_id.remove(&old_key);
349 }
350 if let Some(old_id) = key_to_id.insert(key.to_owned(), id)
352 && old_id != id
353 {
354 id_map.remove(&old_id);
355 }
356 self.bump_epoch();
357 Ok(())
358 }
359
360 fn search(&self, query: &[f32], k: usize) -> Vec<SearchResult> {
361 let key_to_id = self
363 .key_to_id
364 .lock()
365 .expect("DiskAnn key_to_id lock poisoned");
366 let ts = self.tombstones.lock().expect("tombstones lock poisoned");
367 let index = self.index.lock().expect("DiskAnn lock poisoned");
368 match index.search(query, k) {
373 Ok(results) => results
374 .into_iter()
375 .filter_map(|r| {
376 let numeric_id = *key_to_id.get(&r.id)?;
377 if ts.contains_key(&numeric_id) {
378 return None;
379 }
380 Some(SearchResult::new(
381 numeric_id,
382 r.id,
383 r.distance,
384 serde_json::Value::Null,
385 ))
386 })
387 .collect(),
388 Err(_) => Vec::new(),
389 }
390 }
391
392 fn len(&self) -> usize {
393 let id_map = self.id_map.lock().expect("DiskAnn id_map lock poisoned");
396 let ts = self.tombstones.lock().expect("tombstones lock poisoned");
397 id_map.len().saturating_sub(ts.len())
398 }
399
400 fn contains(&self, id: u64) -> bool {
401 let id_map = self.id_map.lock().expect("DiskAnn id_map lock poisoned");
402 let ts = self.tombstones.lock().expect("tombstones lock poisoned");
403 id_map.contains_key(&id) && !ts.contains_key(&id)
404 }
405
406 fn remove(&self, id: u64) -> bool {
407 let mut id_map = self.id_map.lock().expect("DiskAnn id_map lock poisoned");
409 let mut key_to_id = self
410 .key_to_id
411 .lock()
412 .expect("DiskAnn key_to_id lock poisoned");
413 let mut ts = self.tombstones.lock().expect("tombstones lock poisoned");
414 ts.remove(&id);
415 drop(ts);
416
417 if let Some(key) = id_map.remove(&id) {
418 key_to_id.remove(&key);
419 let mut index = self.index.lock().expect("DiskAnn lock poisoned");
420 let _ = index.delete(&key);
421 self.bump_epoch();
422 true
423 } else {
424 false
425 }
426 }
427
428 fn flush(&self) -> VectorResult<()> {
429 let mut index = self.index.lock().expect("DiskAnn lock poisoned");
430 index
431 .build()
432 .map_err(|e| VectorError::Other(format!("diskann build: {e}")))?;
433 let path = std::path::Path::new(&self.config.data_path);
434 std::fs::create_dir_all(path)
435 .map_err(|e| VectorError::Other(format!("diskann mkdir: {e}")))?;
436 index
437 .save(path)
438 .map_err(|e| VectorError::Other(format!("diskann save: {e}")))?;
439 Ok(())
440 }
441
442 fn backend_name(&self) -> &str {
443 "diskann (ruvector)"
444 }
445
446 fn current_epoch(&self) -> u64 {
447 self.epoch.load(Ordering::SeqCst)
448 }
449
450 fn soft_delete(&self, id: u64) -> bool {
451 let id_map = self.id_map.lock().expect("DiskAnn id_map lock poisoned");
452 let mut ts = self.tombstones.lock().expect("tombstones lock poisoned");
453 if !id_map.contains_key(&id) || ts.contains_key(&id) {
454 return false;
455 }
456 let epoch = self.bump_epoch();
457 ts.insert(
458 id,
459 Tombstone {
460 deleted_at_epoch: epoch,
461 },
462 );
463 true
464 }
465
466 fn compact(&self, older_than_epoch: u64) -> usize {
467 let mut id_map = self.id_map.lock().expect("DiskAnn id_map lock poisoned");
468 let mut key_to_id = self
469 .key_to_id
470 .lock()
471 .expect("DiskAnn key_to_id lock poisoned");
472 let mut ts = self.tombstones.lock().expect("tombstones lock poisoned");
473 let mut index = self.index.lock().expect("DiskAnn lock poisoned");
474
475 let to_purge: Vec<u64> = ts
476 .iter()
477 .filter(|(_, t)| t.deleted_at_epoch < older_than_epoch)
478 .map(|(&id, _)| id)
479 .collect();
480
481 let count = to_purge.len();
482 for id in to_purge {
483 ts.remove(&id);
484 if let Some(key) = id_map.remove(&id) {
485 key_to_id.remove(&key);
486 let _ = index.delete(&key);
487 }
488 }
489
490 if count > 0 {
491 self.bump_epoch();
492 }
493 count
494 }
495
496 fn tombstone_count(&self) -> usize {
497 self.tombstones
498 .lock()
499 .expect("tombstones lock poisoned")
500 .len()
501 }
502
503 fn max_vectors(&self) -> Option<usize> {
504 self.effective_max()
505 }
506
507 fn set_max_vectors(&self, limit: Option<usize>) {
508 *self
509 .max_vectors_override
510 .lock()
511 .expect("max_vectors lock poisoned") = limit;
512 }
513}
514
515#[cfg(not(feature = "diskann"))]
518impl VectorBackend for DiskAnnBackend {
519 fn insert(
520 &self,
521 id: u64,
522 key: &str,
523 vector: &[f32],
524 metadata: serde_json::Value,
525 ) -> VectorResult<()> {
526 let mut entries = self.entries.lock().expect("DiskAnn lock poisoned");
527 let mut ts = self.tombstones.lock().expect("tombstones lock poisoned");
528
529 ts.remove(&id);
531
532 let live = entries.len().saturating_sub(ts.len());
534 if let Some(max) = self.effective_max()
535 && live >= max
536 && !entries.contains_key(&id)
537 {
538 return Err(VectorError::StoreFull { max, current: live });
539 }
540
541 drop(ts);
542
543 entries.insert(
544 id,
545 StoredEntry {
546 key: key.to_owned(),
547 vector: vector.to_vec(),
548 metadata,
549 },
550 );
551 drop(entries);
552 self.bump_epoch();
553 Ok(())
554 }
555
556 fn search(&self, query: &[f32], k: usize) -> Vec<SearchResult> {
557 let entries = self.entries.lock().expect("DiskAnn lock poisoned");
558 let ts = self.tombstones.lock().expect("tombstones lock poisoned");
559
560 let mut scored: Vec<(u64, &StoredEntry, f32)> = entries
561 .iter()
562 .filter(|(id, _)| !ts.contains_key(id))
563 .map(|(&id, entry)| {
564 let dist = cosine_distance(query, &entry.vector);
565 (id, entry, dist)
566 })
567 .collect();
568
569 scored.sort_by(|a, b| a.2.partial_cmp(&b.2).unwrap_or(std::cmp::Ordering::Equal));
570 scored.truncate(k);
571
572 scored
573 .into_iter()
574 .map(|(id, entry, dist)| {
575 SearchResult::new(id, entry.key.clone(), dist, entry.metadata.clone())
576 })
577 .collect()
578 }
579
580 fn len(&self) -> usize {
581 let entries = self.entries.lock().expect("DiskAnn lock poisoned");
582 let ts = self.tombstones.lock().expect("tombstones lock poisoned");
583 entries.len().saturating_sub(ts.len())
584 }
585
586 fn contains(&self, id: u64) -> bool {
587 let entries = self.entries.lock().expect("DiskAnn lock poisoned");
588 let ts = self.tombstones.lock().expect("tombstones lock poisoned");
589 entries.contains_key(&id) && !ts.contains_key(&id)
590 }
591
592 fn remove(&self, id: u64) -> bool {
593 let mut entries = self.entries.lock().expect("DiskAnn lock poisoned");
594 let mut ts = self.tombstones.lock().expect("tombstones lock poisoned");
595 ts.remove(&id);
596 let removed = entries.remove(&id).is_some();
597 if removed {
598 drop(entries);
599 drop(ts);
600 self.bump_epoch();
601 }
602 removed
603 }
604
605 fn flush(&self) -> VectorResult<()> {
606 Ok(())
607 }
608
609 fn backend_name(&self) -> &str {
610 "diskann (stub)"
611 }
612
613 fn current_epoch(&self) -> u64 {
614 self.epoch.load(Ordering::SeqCst)
615 }
616
617 fn insert_with_epoch(
618 &self,
619 id: u64,
620 key: &str,
621 vector: &[f32],
622 metadata: serde_json::Value,
623 parent_epoch: u64,
624 ) -> VectorResult<()> {
625 let current = self.epoch.load(Ordering::SeqCst);
626 if parent_epoch < current {
627 return Err(VectorError::EpochConflict {
628 expected: parent_epoch,
629 actual: current,
630 });
631 }
632 self.insert(id, key, vector, metadata)
633 }
634
635 fn soft_delete(&self, id: u64) -> bool {
636 let entries = self.entries.lock().expect("DiskAnn lock poisoned");
637 let mut ts = self.tombstones.lock().expect("tombstones lock poisoned");
638 if !entries.contains_key(&id) || ts.contains_key(&id) {
639 return false;
640 }
641 let epoch = self.bump_epoch();
642 ts.insert(
643 id,
644 Tombstone {
645 deleted_at_epoch: epoch,
646 },
647 );
648 true
649 }
650
651 fn compact(&self, older_than_epoch: u64) -> usize {
652 let mut entries = self.entries.lock().expect("DiskAnn lock poisoned");
653 let mut ts = self.tombstones.lock().expect("tombstones lock poisoned");
654
655 let to_purge: Vec<u64> = ts
656 .iter()
657 .filter(|(_, t)| t.deleted_at_epoch < older_than_epoch)
658 .map(|(&id, _)| id)
659 .collect();
660
661 let count = to_purge.len();
662 for id in to_purge {
663 ts.remove(&id);
664 entries.remove(&id);
665 }
666
667 if count > 0 {
668 drop(ts);
669 drop(entries);
670 self.bump_epoch();
671 }
672
673 count
674 }
675
676 fn tombstone_count(&self) -> usize {
677 self.tombstones
678 .lock()
679 .expect("tombstones lock poisoned")
680 .len()
681 }
682
683 fn max_vectors(&self) -> Option<usize> {
684 self.effective_max()
685 }
686
687 fn set_max_vectors(&self, limit: Option<usize>) {
688 *self
689 .max_vectors_override
690 .lock()
691 .expect("max_vectors lock poisoned") = limit;
692 }
693}
694
695impl std::fmt::Debug for DiskAnnBackend {
696 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
697 f.debug_struct("DiskAnnBackend")
698 .field("config", &self.config)
699 .field("len", &self.len())
700 .field("epoch", &self.current_epoch())
701 .field("tombstones", &self.tombstone_count())
702 .finish()
703 }
704}
705
706#[cfg(test)]
709mod tests {
710 use super::*;
711
712 fn make_backend() -> DiskAnnBackend {
713 #[cfg(feature = "diskann")]
714 {
715 let dir = std::env::temp_dir().join(format!(
718 "weft-diskann-test-{}-{}",
719 std::process::id(),
720 std::time::SystemTime::now()
721 .duration_since(std::time::UNIX_EPOCH)
722 .map(|d| d.as_nanos())
723 .unwrap_or(0)
724 ));
725 DiskAnnBackend::new(DiskAnnConfig {
726 max_points: 100,
727 dimensions: 3,
728 data_path: dir.to_string_lossy().into_owned(),
729 use_pq: false,
730 ..DiskAnnConfig::default()
731 })
732 }
733 #[cfg(not(feature = "diskann"))]
734 {
735 DiskAnnBackend::new(DiskAnnConfig {
736 max_points: 100,
737 ..DiskAnnConfig::default()
738 })
739 }
740 }
741
742 #[test]
745 fn feature_status_ok_when_feature_compiled() {
746 assert_eq!(
747 diskann_feature_status(VectorBackendKind::DiskAnn, true, false),
748 DiskAnnFeatureStatus::Ok
749 );
750 assert_eq!(
751 diskann_feature_status(VectorBackendKind::Hybrid, true, true),
752 DiskAnnFeatureStatus::Ok
753 );
754 }
755
756 #[test]
757 fn feature_status_ok_for_hnsw_regardless_of_feature_or_strict() {
758 assert_eq!(
759 diskann_feature_status(VectorBackendKind::Hnsw, false, false),
760 DiskAnnFeatureStatus::Ok
761 );
762 assert_eq!(
763 diskann_feature_status(VectorBackendKind::Hnsw, false, true),
764 DiskAnnFeatureStatus::Ok
765 );
766 }
767
768 #[test]
769 fn feature_status_degraded_when_missing_and_not_strict() {
770 assert_eq!(
771 diskann_feature_status(VectorBackendKind::DiskAnn, false, false),
772 DiskAnnFeatureStatus::StubDegraded
773 );
774 assert_eq!(
775 diskann_feature_status(VectorBackendKind::Hybrid, false, false),
776 DiskAnnFeatureStatus::StubDegraded
777 );
778 }
779
780 #[test]
781 fn feature_status_rejected_when_missing_and_strict() {
782 assert_eq!(
783 diskann_feature_status(VectorBackendKind::DiskAnn, false, true),
784 DiskAnnFeatureStatus::StubRejected
785 );
786 assert_eq!(
787 diskann_feature_status(VectorBackendKind::Hybrid, false, true),
788 DiskAnnFeatureStatus::StubRejected
789 );
790 }
791
792 #[test]
793 fn insert_and_search() {
794 let b = make_backend();
795 b.insert(1, "a", &[1.0, 0.0, 0.0], serde_json::json!({}))
796 .unwrap();
797 b.insert(2, "b", &[0.0, 1.0, 0.0], serde_json::json!({}))
798 .unwrap();
799 b.insert(3, "c", &[0.0, 0.0, 1.0], serde_json::json!({}))
800 .unwrap();
801
802 #[cfg(feature = "diskann")]
804 b.flush().unwrap();
805
806 let results = b.search(&[1.0, 0.0, 0.0], 2);
807 assert_eq!(results.len(), 2);
808 assert_eq!(results[0].id, 1);
809 assert!(results[0].distance < 0.01);
810 }
811
812 #[cfg(feature = "diskann")]
814 #[test]
815 fn search_returns_distinct_numeric_ids() {
816 let dir = std::env::temp_dir().join(format!(
817 "weft-660-diskann-ids-{}",
818 std::process::id()
819 ));
820 let _ = std::fs::remove_dir_all(&dir);
821 let b = DiskAnnBackend::new(DiskAnnConfig {
822 max_points: 100,
823 dimensions: 3,
824 data_path: dir.to_string_lossy().into_owned(),
825 use_pq: false,
826 ..DiskAnnConfig::default()
827 });
828 b.insert(1, "key-a", &[1.0, 0.0, 0.0], serde_json::json!({}))
829 .unwrap();
830 b.insert(2, "key-b", &[0.0, 1.0, 0.0], serde_json::json!({}))
831 .unwrap();
832 b.insert(3, "key-c", &[0.0, 0.0, 1.0], serde_json::json!({}))
833 .unwrap();
834 b.flush().unwrap();
835
836 let results = b.search(&[1.0, 0.0, 0.0], 3);
837 assert!(
838 results.len() >= 3,
839 "expected ≥3 hits, got {}",
840 results.len()
841 );
842
843 let allowed: std::collections::HashSet<u64> = [1u64, 2, 3].into_iter().collect();
844 let mut seen = std::collections::HashSet::new();
845 for r in &results {
846 assert_ne!(r.id, 0, "hit key={} must not hardcode id=0", r.key);
847 assert!(
848 allowed.contains(&r.id),
849 "unexpected id {} for key {}",
850 r.id,
851 r.key
852 );
853 assert!(
854 seen.insert(r.id),
855 "duplicate numeric id {} (key={})",
856 r.id,
857 r.key
858 );
859 }
860 assert_eq!(results[0].id, 1);
862 assert_eq!(results[0].key, "key-a");
863
864 let _ = std::fs::remove_dir_all(&dir);
865 }
866
867 #[cfg(not(feature = "diskann"))]
869 #[test]
870 fn stub_search_returns_distinct_numeric_ids() {
871 let b = make_backend();
872 b.insert(1, "key-a", &[1.0, 0.0, 0.0], serde_json::json!({}))
873 .unwrap();
874 b.insert(2, "key-b", &[0.0, 1.0, 0.0], serde_json::json!({}))
875 .unwrap();
876 b.insert(3, "key-c", &[0.0, 0.0, 1.0], serde_json::json!({}))
877 .unwrap();
878
879 let results = b.search(&[1.0, 0.0, 0.0], 3);
880 assert_eq!(results.len(), 3);
881 let mut ids: Vec<u64> = results.iter().map(|r| r.id).collect();
882 ids.sort_unstable();
883 assert_eq!(ids, vec![1, 2, 3]);
884 assert!(results.iter().all(|r| r.id != 0));
885 }
886
887 #[test]
888 fn remove_entry() {
889 let b = make_backend();
890 b.insert(1, "a", &[1.0, 0.0, 0.0], serde_json::json!({}))
891 .unwrap();
892 assert!(b.contains(1));
893 assert!(b.remove(1));
894 assert!(!b.contains(1));
895 assert_eq!(b.len(), 0);
896 }
897
898 fn small_capacity_backend(max_points: usize) -> DiskAnnBackend {
899 #[cfg(feature = "diskann")]
900 {
901 let dir = std::env::temp_dir().join(format!(
902 "weft-diskann-cap-{}-{}",
903 std::process::id(),
904 std::time::SystemTime::now()
905 .duration_since(std::time::UNIX_EPOCH)
906 .map(|d| d.as_nanos())
907 .unwrap_or(0)
908 ));
909 DiskAnnBackend::new(DiskAnnConfig {
910 max_points,
911 dimensions: 1,
912 data_path: dir.to_string_lossy().into_owned(),
913 use_pq: false,
914 ..DiskAnnConfig::default()
915 })
916 }
917 #[cfg(not(feature = "diskann"))]
918 {
919 DiskAnnBackend::new(DiskAnnConfig {
920 max_points,
921 ..DiskAnnConfig::default()
922 })
923 }
924 }
925
926 #[test]
927 fn capacity_exceeded() {
928 let b = small_capacity_backend(2);
929 b.insert(1, "a", &[1.0], serde_json::json!({})).unwrap();
930 b.insert(2, "b", &[0.0], serde_json::json!({})).unwrap();
931 let result = b.insert(3, "c", &[0.5], serde_json::json!({}));
932 assert!(result.is_err());
933 }
934
935 #[test]
936 fn upsert_does_not_exceed_capacity() {
937 let b = small_capacity_backend(2);
938 b.insert(1, "a", &[1.0], serde_json::json!({})).unwrap();
939 b.insert(2, "b", &[0.0], serde_json::json!({})).unwrap();
940 b.insert(1, "a-updated", &[0.5], serde_json::json!({}))
942 .unwrap();
943 assert_eq!(b.len(), 2);
944 }
945
946 #[cfg(not(feature = "diskann"))]
951 #[test]
952 fn cosine_distance_identical() {
953 let d = cosine_distance(&[1.0, 0.0], &[1.0, 0.0]);
954 assert!(d.abs() < 0.001);
955 }
956
957 #[cfg(not(feature = "diskann"))]
958 #[test]
959 fn cosine_distance_orthogonal() {
960 let d = cosine_distance(&[1.0, 0.0], &[0.0, 1.0]);
961 assert!((d - 1.0).abs() < 0.001);
962 }
963
964 #[test]
965 fn backend_name() {
966 let b = make_backend();
967 #[cfg(not(feature = "diskann"))]
968 assert_eq!(b.backend_name(), "diskann (stub)");
969 #[cfg(feature = "diskann")]
970 assert_eq!(b.backend_name(), "diskann (ruvector)");
971 }
972
973 #[test]
974 fn flush_noop() {
975 let b = make_backend();
976 #[cfg(feature = "diskann")]
978 {
979 b.insert(1, "a", &[1.0, 0.0, 0.0], serde_json::json!({}))
980 .unwrap();
981 }
982 b.flush().unwrap();
983 }
984
985 #[test]
988 fn epoch_increments_on_insert() {
989 let b = make_backend();
990 assert_eq!(b.current_epoch(), 0);
991 b.insert(1, "a", &[1.0, 0.0, 0.0], serde_json::json!({}))
992 .unwrap();
993 assert_eq!(b.current_epoch(), 1);
994 }
995
996 #[test]
997 fn insert_with_epoch_rejects_stale() {
998 let b = make_backend();
999 b.insert(1, "a", &[1.0, 0.0, 0.0], serde_json::json!({}))
1000 .unwrap();
1001 let result =
1002 b.insert_with_epoch(2, "b", &[0.0, 1.0, 0.0], serde_json::json!({}), 0);
1003 assert!(matches!(result, Err(VectorError::EpochConflict { .. })));
1004 }
1005
1006 #[test]
1009 fn soft_delete_hides_from_search() {
1010 let b = make_backend();
1011 b.insert(1, "a", &[1.0, 0.0, 0.0], serde_json::json!({}))
1012 .unwrap();
1013 b.insert(2, "b", &[0.0, 1.0, 0.0], serde_json::json!({}))
1014 .unwrap();
1015
1016 #[cfg(feature = "diskann")]
1017 b.flush().unwrap();
1018
1019 assert!(b.soft_delete(1));
1020 assert_eq!(b.tombstone_count(), 1);
1021 assert_eq!(b.len(), 1);
1022
1023 let results = b.search(&[1.0, 0.0, 0.0], 5);
1024 for r in &results {
1025 assert_ne!(r.id, 1);
1026 }
1027 }
1028
1029 #[test]
1030 fn compact_purges_old_tombstones() {
1031 let b = make_backend();
1032 b.insert(1, "a", &[1.0, 0.0, 0.0], serde_json::json!({}))
1033 .unwrap();
1034 b.soft_delete(1);
1035 let epoch = b.current_epoch();
1036 let purged = b.compact(epoch + 1);
1037 assert_eq!(purged, 1);
1038 assert_eq!(b.tombstone_count(), 0);
1039 }
1040
1041 #[test]
1044 fn set_max_vectors_override() {
1045 let b = make_backend();
1046 assert_eq!(b.max_vectors(), Some(100));
1048 b.set_max_vectors(Some(5));
1049 assert_eq!(b.max_vectors(), Some(5));
1050 b.set_max_vectors(None);
1051 assert_eq!(b.max_vectors(), Some(100));
1053 }
1054
1055 #[test]
1056 fn soft_delete_frees_capacity_slot() {
1057 let b = small_capacity_backend(2);
1058 b.insert(1, "a", &[1.0], serde_json::json!({})).unwrap();
1059 b.insert(2, "b", &[0.0], serde_json::json!({})).unwrap();
1060
1061 b.soft_delete(1);
1062 b.insert(3, "c", &[0.5], serde_json::json!({})).unwrap();
1064 }
1065}