Skip to main content

clawft_kernel/
vector_diskann.rs

1//! DiskANN-backed [`VectorBackend`] implementation.
2//!
3//! When compiled with the `diskann` feature, this uses the real
4//! `ruvector-diskann` crate (Vamana graph + PQ + mmap persistence).
5//! Without it, a brute-force stub performs linear scans in memory.
6//!
7//! ## Feature/config mismatch (WEFT-656)
8//!
9//! `diskann` is **not** part of `clawft-kernel`'s default feature set
10//! (see `crates/clawft-kernel/Cargo.toml`: `diskann = ["ecc",
11//! "dep:ruvector-diskann"]`, not listed under `default`). If
12//! `kernel_config.vector.backend` selects `DiskAnn` or `Hybrid` but the
13//! binary was built without `--features diskann`, [`DiskAnnBackend`]
14//! **silently** falls back to the brute-force stub below: correctness is
15//! preserved (searches still return correct nearest neighbors), but recall
16//! quality and latency degrade from Vamana-graph ANN to an O(n) linear
17//! scan, with no visible signal by default.
18//!
19//! `Kernel::boot` (`boot.rs`, `BootPhase::Ecc`) detects this mismatch at
20//! boot time by calling [`diskann_feature_status`] with
21//! `cfg!(feature = "diskann")`:
22//!
23//! - **non-strict** (default, `vector.strict = false`): boot emits a
24//!   `Warn`-level [`crate::console::BootEvent`] naming the stub and how to
25//!   fix it, then continues with the degraded stub.
26//! - **strict** (`vector.strict = true`): the same mismatch aborts boot
27//!   with `KernelError::Boot`, so a misconfigured deployment fails loudly
28//!   instead of silently running O(n) scans.
29//!
30//! [`diskann_feature_status`] is a pure function (no I/O, no kernel/boot
31//! types) so the warn/fail decision is unit-testable without booting a
32//! kernel; see the `feature_status_*` tests below.
33//!
34//! ## Hardening features (Cognitum Seed WS1)
35//!
36//! - **Epoch-based versioning**: monotonic epoch bumped on every mutation.
37//! - **Optimistic concurrency**: `insert_with_epoch` rejects stale writes.
38//! - **Soft-delete + compaction**: tombstone records excluded from search.
39//! - **Capacity limits**: configurable `max_vectors` with `StoreFull` error.
40//!
41//! Compiled only when the `ecc` feature is enabled.
42
43use 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// ── Feature/config mismatch (WEFT-656) ──────────────────────────────────
57
58/// Outcome of reconciling a configured [`VectorBackendKind`] against
59/// whether the `diskann` cargo feature was actually compiled in.
60///
61/// See the module docs for the mismatch this guards against.
62#[derive(Debug, Clone, Copy, PartialEq, Eq)]
63pub enum DiskAnnFeatureStatus {
64    /// The backend doesn't need `diskann` (e.g. plain HNSW), or the
65    /// feature is compiled in — no mismatch.
66    Ok,
67    /// `DiskAnn`/`Hybrid` configured without the feature: falls back to
68    /// the brute-force stub. `vector.strict = false` (default): warn and
69    /// continue.
70    StubDegraded,
71    /// Same mismatch, but `vector.strict = true`: boot must fail rather
72    /// than silently downgrade to O(n) scans.
73    StubRejected,
74}
75
76/// Decide what should happen when `configured_kind` is booted in a build
77/// where `feature_compiled` tells whether the `diskann` cargo feature is
78/// present, honoring the `vector.strict` config knob.
79///
80/// Pure and side-effect free by design — `Kernel::boot` calls this with
81/// `cfg!(feature = "diskann")` and turns the result into a
82/// [`crate::console::BootEvent`] or a `KernelError::Boot`; keeping the
83/// decision itself free of kernel/boot types makes it testable without
84/// booting a kernel.
85pub 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// ── Configuration ────────────────────────────────────────────────────────
104
105/// DiskANN backend configuration.
106#[derive(Debug, Clone, Serialize, Deserialize)]
107pub struct DiskAnnConfig {
108    /// Maximum number of points the index can hold.
109    #[serde(default = "default_max_points")]
110    pub max_points: usize,
111
112    /// Vector dimensionality.
113    #[serde(default = "default_dimensions")]
114    pub dimensions: usize,
115
116    /// Number of neighbors per node in the graph.
117    #[serde(default = "default_num_neighbors")]
118    pub num_neighbors: usize,
119
120    /// Size of the search candidate list.
121    #[serde(default = "default_search_list_size")]
122    pub search_list_size: usize,
123
124    /// Directory path for SSD-backed data files.
125    #[serde(default = "default_data_path")]
126    pub data_path: String,
127
128    /// Whether to use product quantization for compression.
129    #[serde(default = "default_use_pq")]
130    pub use_pq: bool,
131
132    /// Number of PQ sub-quantizer chunks.
133    #[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// ── Stored entry ─────────────────────────────────────────────────────────
174
175/// A single vector entry stored in the brute-force stub.
176#[cfg(not(feature = "diskann"))]
177#[derive(Clone)]
178struct StoredEntry {
179    key: String,
180    vector: Vec<f32>,
181    metadata: serde_json::Value,
182}
183
184// ── Tombstone record ───────────────────────────────────────────────────
185
186/// A soft-deleted vector.
187#[derive(Debug, Clone)]
188struct Tombstone {
189    deleted_at_epoch: u64,
190}
191
192// ── Backend ─────────────────────────────────────────────────────────────
193
194/// DiskANN vector backend.
195///
196/// With `diskann` feature: uses `ruvector-diskann` (Vamana + PQ + mmap).
197/// Without: brute-force linear scan stub.
198pub struct DiskAnnBackend {
199    config: DiskAnnConfig,
200    /// Stub storage (used when `diskann` feature is off).
201    #[cfg(not(feature = "diskann"))]
202    entries: Mutex<HashMap<u64, StoredEntry>>,
203    /// Real DiskANN index (used when `diskann` feature is on).
204    #[cfg(feature = "diskann")]
205    index: Mutex<DiskAnnIndex>,
206    /// ID map: u64 -> string key (needed for real index too).
207    #[cfg(feature = "diskann")]
208    id_map: Mutex<HashMap<u64, String>>,
209    /// Reverse map: string key -> u64 (O(1) resolve of DiskANN hits → numeric id).
210    /// Kept in sync with `id_map` on insert/remove (WEFT-660).
211    #[cfg(feature = "diskann")]
212    key_to_id: Mutex<HashMap<String, u64>>,
213    /// Monotonic epoch counter.
214    epoch: AtomicU64,
215    /// Soft-deleted entries.
216    tombstones: Mutex<HashMap<u64, Tombstone>>,
217    /// Optional capacity limit (overrides config.max_points when set).
218    max_vectors_override: Mutex<Option<usize>>,
219}
220
221impl DiskAnnBackend {
222    /// Create a new DiskANN backend with the given configuration.
223    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    /// Create with default configuration.
264    pub fn with_defaults() -> Self {
265        Self::new(DiskAnnConfig::default())
266    }
267
268    /// Borrow the configuration.
269    pub fn config(&self) -> &DiskAnnConfig {
270        &self.config
271    }
272
273    /// Bump the epoch and return the new value.
274    fn bump_epoch(&self) -> u64 {
275        self.epoch.fetch_add(1, Ordering::SeqCst) + 1
276    }
277
278    /// Effective capacity limit: override > config.max_points.
279    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"))]
292/// Compute cosine distance between two vectors.
293///
294/// Returns `1.0 - cosine_similarity`. Handles zero-magnitude vectors
295/// by returning `1.0` (maximum distance).
296fn 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// ── Real DiskANN backend (feature = "diskann") ──────────────────────────
313
314#[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        // Lock order: id_map → key_to_id → tombstones → index.
324        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        // Capacity: live = mapped - tombstoned; allow upsert of existing id.
333        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        // Keep bidirectional maps in sync (HNSW-style reverse lookup for search).
347        if let Some(old_key) = id_map.insert(id, key.to_owned()) {
348            key_to_id.remove(&old_key);
349        }
350        // If this key was previously bound to another numeric id, drop that binding.
351        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        // Lock order: key_to_id → tombstones → index (no id_map needed).
362        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        // Real DiskANN returns string keys + L2² distances (metric vs hybrid
369        // cosine is WEFT-661). Resolve numeric ids via reverse map; skip
370        // unknown/tombstoned keys. Metadata is Null on this path (DiskANN
371        // index does not store caller metadata).
372        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        // Prefer id_map over index.count so soft-deleted rows (still in the
394        // index) are counted correctly against live capacity.
395        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        // Lock order: id_map → key_to_id → tombstones → index.
408        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// ── Stub backend (no diskann feature) ───────────────────────────────────
516
517#[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        // Un-tombstone on re-insert.
530        ts.remove(&id);
531
532        // Effective capacity check.
533        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// ── Tests ────────────────────────────────────────────────────────────────
707
708#[cfg(test)]
709mod tests {
710    use super::*;
711
712    fn make_backend() -> DiskAnnBackend {
713        #[cfg(feature = "diskann")]
714        {
715            // Isolated data_path + small dim so unit tests don't share the
716            // default `.weftos/diskann` tree or require 384-d vectors.
717            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    // ── Feature/config mismatch (WEFT-656) ─────────────────────────────
743
744    #[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        // Real DiskANN requires build/flush before search returns hits.
803        #[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    /// WEFT-660: real DiskANN must resolve distinct numeric ids (not hardcode 0).
813    #[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        // Nearest to [1,0,0] should be id 1.
861        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    /// Stub path: numeric ids stay distinct and non-zero (WEFT-660 regression guard).
868    #[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        // Updating existing id=1 should succeed.
941        b.insert(1, "a-updated", &[0.5], serde_json::json!({}))
942            .unwrap();
943        assert_eq!(b.len(), 2);
944    }
945
946    // `cosine_distance` is the STUB's scoring fn (cfg(not(diskann))) — the
947    // real backend scores inside ruvector-diskann. Gate its tests the same
948    // way or the `--features diskann` test build fails to compile (the
949    // feature-rot class WEFT-656's gate check exists to catch).
950    #[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        // Real DiskANN build requires at least one vector; stub flush is pure no-op.
977        #[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    // ── Epoch tests ─────────────────────────────────────────────────────
986
987    #[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    // ── Soft-delete tests ───────────────────────────────────────────────
1007
1008    #[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    // ── Capacity limit tests ────────────────────────────────────────────
1042
1043    #[test]
1044    fn set_max_vectors_override() {
1045        let b = make_backend();
1046        // Default effective max comes from config.max_points = 100.
1047        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        // Falls back to config.max_points.
1052        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        // Live count is now 1, so a new insert should succeed.
1063        b.insert(3, "c", &[0.5], serde_json::json!({})).unwrap();
1064    }
1065}