Skip to main content

issundb_vector/
index.rs

1use std::cell::RefCell;
2use std::sync::Arc;
3
4use crate::backend::{VectorBackend, new_backend};
5use parking_lot::RwLock;
6use tracing::instrument;
7
8use crate::error::VectorError;
9use issundb_core::{Graph, NodeId};
10
11/// A single result from vector search.
12#[derive(Debug)]
13pub struct Hit {
14    pub node: NodeId,
15    pub distance: f32,
16}
17
18/// Options for `vector_search_with`.
19#[derive(Debug, Clone)]
20pub struct VectorSearchOptions {
21    /// Maximum number of results to return.
22    pub k: usize,
23    /// If set, only nodes carrying this exact label are included in results.
24    pub label: Option<String>,
25    /// Optional property key-value filters. Only nodes matching all filters are returned.
26    pub properties: Option<std::collections::HashMap<String, serde_json::Value>>,
27    /// Rescore factor. When greater than 1, search fetches `k * rescore_factor`
28    /// candidates from the index and re-ranks them by exact distance against
29    /// the full-precision vectors stored in LMDB. Defaults to 2 on a quantized
30    /// index and 1 (no rescore) on a Float32 index. Without the `hnsw` feature
31    /// the default is always 1, because that backend keeps the raw `f32` and so
32    /// has no precision to recover whatever the persisted tag says. The default applies to
33    /// filtered searches too, where the over-fetch means the traversal must
34    /// find `k * rescore_factor` predicate-matching candidates; pass
35    /// `Some(1)` to disable rescoring for a selective filter.
36    pub rescore_factor: Option<usize>,
37}
38
39impl Default for VectorSearchOptions {
40    fn default() -> Self {
41        Self {
42            k: 10,
43            label: None,
44            properties: None,
45            rescore_factor: None,
46        }
47    }
48}
49
50/// Distance metric for the vector index.
51#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
52pub enum VectorMetric {
53    /// Cosine similarity (default).
54    #[default]
55    Cosine,
56    /// Euclidean (L2) distance.
57    L2,
58    /// Inner product / dot product.
59    Dot,
60}
61
62/// Quantization format for in-memory vector storage.
63#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
64pub enum VectorQuantization {
65    /// Float32 quantization (default, full accuracy).
66    #[default]
67    Float32,
68    /// Float16 quantization (half memory footprint).
69    Float16,
70    /// Int8 quantization (quarter memory footprint).
71    Int8,
72}
73
74impl std::str::FromStr for VectorMetric {
75    type Err = VectorError;
76
77    /// Parse a metric name. Case-insensitive. Accepts `cosine`, `l2`, and `dot`
78    /// (with the alias `ip` for inner product). This is the one canonical
79    /// mapping every binding (CLI, REST, MCP, and Python) parses through.
80    fn from_str(s: &str) -> Result<Self, Self::Err> {
81        match s.to_lowercase().as_str() {
82            "cosine" => Ok(Self::Cosine),
83            "l2" => Ok(Self::L2),
84            "dot" | "ip" => Ok(Self::Dot),
85            other => Err(VectorError::InvalidConfig(format!(
86                "unknown metric '{other}' (expected 'cosine', 'l2', or 'dot')"
87            ))),
88        }
89    }
90}
91
92impl std::str::FromStr for VectorQuantization {
93    type Err = VectorError;
94
95    /// Parse a quantization name. Case-insensitive. Accepts `float32`,
96    /// `float16`, and `int8`. The one canonical mapping shared by every binding.
97    fn from_str(s: &str) -> Result<Self, Self::Err> {
98        match s.to_lowercase().as_str() {
99            "float32" => Ok(Self::Float32),
100            "float16" => Ok(Self::Float16),
101            "int8" => Ok(Self::Int8),
102            other => Err(VectorError::InvalidConfig(format!(
103                "unknown quantization '{other}' (expected 'float32', 'float16', or 'int8')"
104            ))),
105        }
106    }
107}
108
109/// Construction options for `VectorIndex`.
110#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
111pub struct VectorIndexOptions {
112    pub metric: VectorMetric,
113    pub quantization: VectorQuantization,
114}
115
116enum Inner {
117    Empty,
118    Ready {
119        index: Box<dyn VectorBackend>,
120        dims: usize,
121    },
122}
123
124/// An in-memory vector index over whichever backend this crate was compiled with
125/// (see [`crate::backend`]): an approximate HNSW index by default, an exact scan
126/// without the `hnsw` feature.
127///
128/// Internal building block for the `VectorGraphExt` implementation on `Graph`.
129/// It holds no persistence of its own, so it is not part of the public surface;
130/// callers use the graph-backed `VectorGraphExt` methods instead.
131pub(crate) struct VectorIndex {
132    opts: VectorIndexOptions,
133    inner: RwLock<Inner>,
134}
135
136impl Default for VectorIndex {
137    fn default() -> Self {
138        Self::new()
139    }
140}
141
142impl VectorIndex {
143    /// Construct a new empty vector index with default Cosine and Float32 options.
144    pub fn new() -> Self {
145        Self::new_with_options(VectorIndexOptions::default())
146    }
147
148    /// Construct a new empty vector index with custom metric and quantization.
149    pub fn new_with_options(opts: VectorIndexOptions) -> Self {
150        Self {
151            opts,
152            inner: RwLock::new(Inner::Empty),
153        }
154    }
155
156    /// Insert or replace the embedding for `node`.
157    ///
158    /// On the first call, the index is initialised with `v.len()` dimensions
159    /// using the metric and quantization from the construction options. Subsequent
160    /// calls with a different dimension count return `VectorError::DimensionMismatch`.
161    pub fn upsert(&self, node: NodeId, v: &[f32]) -> Result<(), VectorError> {
162        let dims = v.len();
163        if dims == 0 {
164            return Err(VectorError::IndexFault(
165                "embedding must not be empty".into(),
166            ));
167        }
168        let mut guard = self.inner.write();
169        match &mut *guard {
170            Inner::Empty => {
171                let mut index = new_backend(dims, &self.opts)?;
172                index.upsert(node, v)?;
173                *guard = Inner::Ready { index, dims };
174            }
175            Inner::Ready { index, dims: d } => {
176                if dims != *d {
177                    return Err(VectorError::DimensionMismatch {
178                        expected: *d,
179                        got: dims,
180                    });
181                }
182                index.upsert(node, v)?;
183            }
184        }
185        Ok(())
186    }
187
188    /// True when the index holds no vectors.
189    pub fn is_empty(&self) -> bool {
190        match &*self.inner.read() {
191            Inner::Empty => true,
192            Inner::Ready { index, .. } => index.len() == 0,
193        }
194    }
195
196    /// Remove the embedding for `node` from the index.
197    pub fn remove(&self, node: NodeId) -> Result<(), VectorError> {
198        let mut guard = self.inner.write();
199        if let Inner::Ready { index, .. } = &mut *guard {
200            index.remove(node)?;
201        }
202        Ok(())
203    }
204
205    /// Return the `k` nearest neighbors to `q` under this graph's configured metric
206    /// (default Cosine). Approximate with `hnsw`, exact without it.
207    ///
208    /// Returns an empty slice when the index has no vectors or `k == 0`.
209    /// `k` is silently clamped to the number of indexed vectors.
210    pub fn search(&self, q: &[f32], k: usize) -> Result<Vec<Hit>, VectorError> {
211        let guard = self.inner.read();
212        match &*guard {
213            Inner::Empty => Ok(vec![]),
214            Inner::Ready { index, .. } => index.search(q, k),
215        }
216    }
217
218    /// Return up to `k` nearest neighbors to `q` that satisfy `predicate`.
219    ///
220    /// The predicate is evaluated during the traversal, so the search keeps
221    /// expanding until it has `k` matching neighbors or exhausts the reachable
222    /// graph. Unlike post-filtering a fixed over-fetch, this does not silently
223    /// truncate the result set when the filter is selective.
224    pub fn search_filtered<F>(
225        &self,
226        q: &[f32],
227        k: usize,
228        predicate: F,
229    ) -> Result<Vec<Hit>, VectorError>
230    where
231        F: Fn(NodeId) -> bool,
232    {
233        let guard = self.inner.read();
234        match &*guard {
235            Inner::Empty => Ok(vec![]),
236            Inner::Ready { index, .. } => index.search_filtered(q, k, &predicate),
237        }
238    }
239}
240
241fn encode_vector(v: &[f32]) -> Result<Vec<u8>, VectorError> {
242    if v.is_empty() {
243        return Err(VectorError::IndexFault(
244            "embedding must not be empty".into(),
245        ));
246    }
247    // A NaN or infinity would be stored and then produce a NaN distance at every search,
248    // which no ranking can order meaningfully, so it is rejected at the boundary rather
249    // than silently poisoning every later query.
250    if let Some(position) = v.iter().position(|f| !f.is_finite()) {
251        return Err(VectorError::IndexFault(format!(
252            "embedding component {position} is not finite ({})",
253            v[position]
254        )));
255    }
256    Ok(v.iter().flat_map(|f| f.to_le_bytes()).collect())
257}
258
259fn decode_vector(bytes: &[u8]) -> Result<Vec<f32>, VectorError> {
260    if bytes.len() % 4 != 0 {
261        return Err(VectorError::IndexFault(format!(
262            "stored embedding byte length must be divisible by 4, got {}",
263            bytes.len()
264        )));
265    }
266    let vector = bytes
267        .chunks_exact(4)
268        .map(|c| f32::from_le_bytes([c[0], c[1], c[2], c[3]]))
269        .collect();
270    Ok(vector)
271}
272
273/// Vector search operations for `Graph`.
274pub trait VectorGraphExt {
275    /// Set the metric and quantization for this graph's vector index.
276    ///
277    /// The choice is persisted, so reopening the graph rebuilds the index with
278    /// the same configuration. Call this before upserting the first vector. The
279    /// HNSW graph is built per-metric, so the configuration cannot change once
280    /// vectors exist: a call that would change the persisted metric or
281    /// quantization while embeddings are present returns
282    /// `VectorError::AlreadyConfigured`. Re-applying the identical configuration
283    /// is a no-op. When no graph configuration is set, the index defaults to
284    /// `Cosine` and `Float32`.
285    ///
286    /// A build without `hnsw` accepts a non-`Float32` quantization and persists it, but
287    /// cannot honor it: that backend keeps the raw `f32`, so the memory reduction the
288    /// quantization names does not happen. The tag is still recorded rather than rejected,
289    /// because it is honored by any later build that does have `hnsw` opening the same
290    /// directory.
291    fn configure_vector_index(&self, opts: VectorIndexOptions) -> Result<(), VectorError>;
292
293    /// Change the metric and quantization and rebuild the index from the
294    /// persisted embeddings under the new configuration.
295    ///
296    /// Unlike `configure_vector_index`, this accepts a change after vectors
297    /// exist. The raw f32 embeddings are stored in LMDB independently of the
298    /// metric, so they are re-indexed under `opts`; switching back to `Float32`
299    /// recovers full precision from storage. This rebuilds the entire in-memory
300    /// index, so it is O(n) in the number of stored vectors and is intended
301    /// as an administrative operation. It is serialized against concurrent
302    /// upserts and removes, which block for the duration of the rebuild.
303    fn reindex_vector_index(&self, opts: VectorIndexOptions) -> Result<(), VectorError>;
304
305    /// Persist `v` under `n`.
306    fn upsert_vector(&self, n: NodeId, v: &[f32]) -> Result<(), VectorError>;
307
308    /// Remove the embedding for `n` from the index and from persistent storage.
309    fn remove_vector(&self, n: NodeId) -> Result<(), VectorError>;
310
311    /// Return the `k` nearest neighbors to `q` under this graph's configured metric
312    /// (default Cosine). Approximate with `hnsw`, exact without it.
313    ///
314    /// Returns `VectorError::EmptyIndex` when the graph holds no embeddings at
315    /// all, so a caller can distinguish "no semantic matches" from "there is
316    /// nothing to search".
317    fn vector_search(&self, q: &[f32], k: usize) -> Result<Vec<Hit>, VectorError>;
318
319    /// Return the `opts.k` nearest neighbors that satisfy the label and property
320    /// filters in `opts`.
321    ///
322    /// When neither `opts.label` nor `opts.properties` is set the call is
323    /// identical to `vector_search(q, opts.k)`. When a filter is set, it is
324    /// applied during the traversal through a predicate, so the search
325    /// keeps expanding until it has `opts.k` matching neighbors rather than
326    /// post-filtering a fixed over-fetch (which silently under-returns for
327    /// selective filters). A node matches when it carries `opts.label` (if set)
328    /// and every entry in `opts.properties` (if set) equals the node's value for
329    /// that property. Fewer than `opts.k` results are returned only when the
330    /// index genuinely contains fewer matching nodes.
331    fn vector_search_with(
332        &self,
333        q: &[f32],
334        opts: &VectorSearchOptions,
335    ) -> Result<Vec<Hit>, VectorError>;
336
337    /// Return the full-precision embedding stored for `n`, or `None` when the
338    /// node has no embedding. This is a point lookup against LMDB and does not
339    /// build or consult the in-memory index.
340    fn node_vector(&self, n: NodeId) -> Result<Option<Vec<f32>>, VectorError>;
341
342    /// Distance between two vectors under this graph's configured metric
343    /// (default Cosine). The convention matches `vector_search`: squared L2 for
344    /// `L2` and `1 - dot` for `Dot`. Returns `DimensionMismatch` when the two
345    /// vectors differ in length.
346    fn vector_distance(&self, a: &[f32], b: &[f32]) -> Result<f32, VectorError>;
347}
348
349/// Key type used to store the persistent HNSW cache in `Graph::extensions`.
350struct VectorIndexCache(VectorIndex);
351
352/// Serializes every vector mutation's two steps: the in-memory index update
353/// and the storage write. Without it, two calls for one node can interleave so
354/// the index ranks by one embedding while storage holds the other (poisoning
355/// the rescore pass and the next cold-start rebuild), or a remove racing an
356/// upsert leaves an index entry whose stored bytes are gone. It is its own
357/// extension rather than a field on `VectorIndexCache`, because
358/// `reindex_vector_index` swaps the cache and a lock inside the swapped value
359/// could not cover the swap itself.
360///
361/// This mutex is acquired first in the lock ordering, before the index's internal
362/// `RwLock` and before any storage transaction, and never while the
363/// `extensions` mutex is held (`get_or_init_extension_with` runs its
364/// initializer without that lock). Read paths take the index `RwLock` without
365/// this mutex, which is safe because no path acquires them in the reverse
366/// order.
367struct VectorMutationLock {
368    lock: parking_lot::Mutex<()>,
369    /// Test-only pause point fired in `upsert_vector` between the in-memory
370    /// index update and the storage write, so a test can hold one call open
371    /// inside that window deterministically. Instance-scoped through the graph
372    /// extension rather than global, so parallel tests cannot interfere.
373    #[cfg(test)]
374    upsert_pause: parking_lot::Mutex<Option<Box<dyn Fn() + Send>>>,
375}
376
377impl VectorMutationLock {
378    fn new() -> Self {
379        Self {
380            lock: parking_lot::Mutex::new(()),
381            #[cfg(test)]
382            upsert_pause: parking_lot::Mutex::new(None),
383        }
384    }
385
386    #[cfg(test)]
387    fn pause_after_index_update(&self) {
388        // Take the hook before running it, so it fires once and never runs
389        // while the slot's mutex is held: a hook that parks would otherwise
390        // deadlock the test thread trying to clear or replace the slot.
391        let hook = self.upsert_pause.lock().take();
392        if let Some(hook) = hook {
393            hook();
394        }
395    }
396}
397
398/// Return this graph's vector mutation lock, creating it on first use.
399fn mutation_lock(graph: &Graph) -> Arc<VectorMutationLock> {
400    let lock: Result<_, std::convert::Infallible> =
401        graph.get_or_init_extension_with(|| Ok(Arc::new(VectorMutationLock::new())));
402    match lock {
403        Ok(lock) => lock,
404        Err(never) => match never {},
405    }
406}
407
408impl VectorGraphExt for Graph {
409    fn configure_vector_index(&self, opts: VectorIndexOptions) -> Result<(), VectorError> {
410        // The mutation lock keeps the emptiness check, the persisted config,
411        // and the cache swap one step: an upsert cannot land between them and
412        // be indexed under the configuration this call replaces.
413        let lock = mutation_lock(self);
414        let _guard = lock.lock.lock();
415        // Compare against the EFFECTIVE config: when nothing is persisted the
416        // active configuration is the lazily built default, so re-applying that
417        // default (or any already-active config) is a no-op, as documented, not
418        // an `AlreadyConfigured` error.
419        let effective = load_config(self)?.unwrap_or_default();
420        if effective == opts {
421            return Ok(());
422        }
423        // The HNSW graph is built per-metric. Changing the metric or
424        // quantization once embeddings exist would silently reinterpret them on
425        // the next cold-start rebuild, so refuse it while vectors are present.
426        if !self.vector_bytes()?.is_empty() {
427            return Err(VectorError::AlreadyConfigured {
428                existing: format!("{effective:?}"),
429                requested: format!("{opts:?}"),
430            });
431        }
432        self.put_vector_config(&encode_config(opts))?;
433        // Replace any lazily built default cache so later upserts use the new
434        // configuration. Safe because no vectors exist yet.
435        self.set_extension(Arc::new(VectorIndexCache(VectorIndex::new_with_options(
436            opts,
437        ))));
438        Ok(())
439    }
440
441    fn reindex_vector_index(&self, opts: VectorIndexOptions) -> Result<(), VectorError> {
442        // The mutation lock serializes the rebuild against concurrent upserts
443        // and removes, so the snapshot read from storage cannot miss a
444        // mutation that landed between the scan and the cache swap.
445        let lock = mutation_lock(self);
446        let _guard = lock.lock.lock();
447        // Rebuild the index from the stored raw embeddings FIRST, then persist
448        // the new configuration and swap the cache. Building before persisting
449        // means a mid-rebuild failure leaves BOTH the previous cache and the
450        // previous persisted config in place, so the next cold-start rebuild does
451        // not silently reinterpret embeddings under a metric the failed operation
452        // never finished applying. `build_index` takes `opts` explicitly, so it
453        // does not depend on the persisted config.
454        let rebuilt = build_index(self, opts)?;
455        self.put_vector_config(&encode_config(opts))?;
456        self.set_extension(Arc::new(VectorIndexCache(rebuilt)));
457        Ok(())
458    }
459
460    #[instrument(skip(self, v), fields(node = %n, dims = v.len()))]
461    fn upsert_vector(&self, n: NodeId, v: &[f32]) -> Result<(), VectorError> {
462        // Reject an embedding for an id no node holds. Node ids are handed out monotonically, so a
463        // vector written ahead of its node is not inert: the next node allocated that id inherits
464        // it and answers a search at distance zero, having never been embedded. Nothing downstream
465        // could detect that, because a stored vector carries no evidence of who it was meant for.
466        //
467        // The cost is one key probe per upsert, inside a call that already opens a write
468        // transaction and rebuilds an index entry. `remove_vector` stays permissive on purpose, so
469        // a database that already holds such a vector can still be cleaned up.
470        // The mutation lock spans the index update and the storage write, so a
471        // concurrent call for the same node cannot leave the index ranking by
472        // one embedding while storage holds another.
473        let lock = mutation_lock(self);
474        let _guard = lock.lock.lock();
475        if !self.node_exists(n)? {
476            return Err(VectorError::NodeNotFound(n));
477        }
478        let bytes = encode_vector(v)?;
479        // Validate against (and update) the in-memory index BEFORE persisting to
480        // LMDB. `upsert` rejects empty or dimension-mismatched embeddings, so
481        // doing it first guarantees a rejected vector never reaches durable
482        // storage. If it did, the cold-start rebuild on the next `Graph::open`
483        // would hit the mismatch and fail to build the index, bricking every
484        // subsequent search. The reverse failure (index updated, LMDB write
485        // fails) only drops an in-memory entry that the next reopen rebuilds
486        // consistently, so it is the safe ordering.
487        let arc = get_or_init_cache(self)?;
488        arc.0.upsert(n, v)?;
489        #[cfg(test)]
490        lock.pause_after_index_update();
491        self.put_vector_bytes(n, &bytes)?;
492        Ok(())
493    }
494
495    fn remove_vector(&self, n: NodeId) -> Result<(), VectorError> {
496        // Same invariant as `upsert_vector`: the mutation lock spans the index
497        // update and the storage write. The cache is initialized rather than
498        // merely peeked at, or a cold-start build racing this call could
499        // re-admit the entry from bytes this call is about to delete. Index
500        // first, then storage: a failure between the two loses an in-memory
501        // entry the next reopen rebuilds, where the reverse would leave a live
502        // index entry whose stored bytes are gone.
503        let lock = mutation_lock(self);
504        let _guard = lock.lock.lock();
505        let arc = get_or_init_cache(self)?;
506        arc.0.remove(n)?;
507        self.delete_vector_bytes(n)?;
508        Ok(())
509    }
510
511    #[instrument(skip(self, q), fields(k = %k, dims = q.len()))]
512    fn vector_search(&self, q: &[f32], k: usize) -> Result<Vec<Hit>, VectorError> {
513        let opts = VectorSearchOptions {
514            k,
515            ..Default::default()
516        };
517        self.vector_search_with(q, &opts)
518    }
519
520    #[instrument(skip(self, q), fields(k = %opts.k, label = ?opts.label, dims = q.len()))]
521    fn vector_search_with(
522        &self,
523        q: &[f32],
524        opts: &VectorSearchOptions,
525    ) -> Result<Vec<Hit>, VectorError> {
526        let arc = get_or_init_cache(self)?;
527
528        // An empty index is an error, not an empty result: for a caller (and
529        // especially an agent surface like MCP) an empty hit list claims
530        // "nothing matched", which is wrong when there was nothing to search.
531        if arc.0.is_empty() {
532            return Err(VectorError::EmptyIndex);
533        }
534
535        let index_quantization = arc.0.opts.quantization;
536        // Rescoring re-reads and re-decodes `2k` stored vectors to recompute distances at
537        // full precision, which is only worth it against a backend that lost precision.
538        // The exact backend keeps the raw `f32` and already ranks through `exact_distance`,
539        // so a persisted quantization tag there would buy bit-identical distances for a
540        // second pass over storage.
541        let backend_quantizes =
542            cfg!(feature = "hnsw") && index_quantization != VectorQuantization::Float32;
543        let rescore_factor = opts
544            .rescore_factor
545            .unwrap_or(if backend_quantizes { 2 } else { 1 });
546
547        let fetch_k = if rescore_factor > 1 {
548            opts.k.saturating_mul(rescore_factor)
549        } else {
550            opts.k
551        };
552
553        // An empty property map with no label is a vacuous filter set: the
554        // predicate below would accept every candidate, including nodes
555        // deleted through the core API that linger in the HNSW index (the
556        // filtered path relies on its label and property lookups to reject
557        // those ghosts). Route it to the unfiltered path, which drops ghosts
558        // via its liveness backfill.
559        let has_filters =
560            opts.label.is_some() || opts.properties.as_ref().is_some_and(|m| !m.is_empty());
561        let hits = if has_filters {
562            // Evaluate the label and property filters during the HNSW traversal via
563            // a predicate, so the search keeps expanding until it has `opts.k`
564            // matching neighbors instead of post-filtering a fixed over-fetch, which
565            // silently under-returns when the filter is selective. The predicate
566            // reads through the core accessors (`label_filter` point lookup and the
567            // in-memory property columns via `node_prop_json`) rather than decoding
568            // raw node records, to respect the crate boundary. A storage error
569            // cannot travel through the `Fn(NodeId) -> bool` callback, so it is
570            // captured and surfaced after the search; once set, the predicate
571            // rejects every remaining candidate to end the traversal promptly.
572            let pred_err: RefCell<Option<VectorError>> = RefCell::new(None);
573            let matches_filters = |node: NodeId| -> Result<bool, VectorError> {
574                if let Some(label) = &opts.label {
575                    if self.label_filter(&[node], label)?.is_empty() {
576                        return Ok(false);
577                    }
578                }
579                if let Some(filters) = &opts.properties {
580                    for (key, want) in filters {
581                        match self.node_prop_json(node, key)? {
582                            Some(got) if &got == want => {}
583                            _ => return Ok(false),
584                        }
585                    }
586                }
587                Ok(true)
588            };
589            let predicate = |node: NodeId| -> bool {
590                if pred_err.borrow().is_some() {
591                    return false;
592                }
593                match matches_filters(node) {
594                    Ok(keep) => keep,
595                    Err(e) => {
596                        *pred_err.borrow_mut() = Some(e);
597                        false
598                    }
599                }
600            };
601
602            let results = arc.0.search_filtered(q, fetch_k, predicate)?;
603            if let Some(e) = pred_err.into_inner() {
604                return Err(e);
605            }
606            // The label and property predicate fails for a node deleted through
607            // the core API (it is gone from `label_idx` and the property columns),
608            // so `search_filtered` has already skipped those "ghost" hits and kept
609            // expanding, returning live matches only.
610            results
611        } else {
612            // Unfiltered: a node deleted through the core graph API stays in the
613            // in-memory HNSW index, because core deletion cannot reach this
614            // vector-crate extension. Over-fetch and drop those "ghost" hits,
615            // growing the fetch window until `fetch_k` live hits remain (or the
616            // index is exhausted). A fixed `fetch_k` would truncate the result
617            // below `opts.k` when enough top-ranked nodes had been deleted.
618            let mut want = fetch_k.max(1);
619            loop {
620                let raw = arc.0.search(q, want)?;
621                let raw_len = raw.len();
622                let live: Vec<Hit> = self.view(|txn| {
623                    let mut live = Vec::with_capacity(raw_len);
624                    for hit in raw {
625                        if txn.get_node(hit.node)?.is_some() {
626                            live.push(hit);
627                        }
628                    }
629                    Ok(live)
630                })?;
631                if live.len() >= fetch_k || raw_len < want {
632                    break live;
633                }
634                want = want.saturating_mul(2);
635            }
636        };
637
638        let mut final_hits = if rescore_factor > 1 && !hits.is_empty() {
639            // One read transaction covers every stored-vector lookup. A hit
640            // whose stored bytes are absent keeps its approximate distance,
641            // so a vacuous index entry degrades the estimate, not the call.
642            let byte_rows: Vec<(Hit, Option<Vec<u8>>)> = self.view(|txn| {
643                hits.into_iter()
644                    .map(|hit| {
645                        let bytes = txn.get_vector_bytes(hit.node)?;
646                        Ok((hit, bytes))
647                    })
648                    .collect()
649            })?;
650            let mut rescored = Vec::with_capacity(byte_rows.len());
651            for (hit, bytes) in byte_rows {
652                rescored.push(match bytes {
653                    Some(b) => Hit {
654                        node: hit.node,
655                        distance: exact_distance(q, &decode_vector(&b)?, arc.0.opts.metric),
656                    },
657                    None => hit,
658                });
659            }
660            // Node id included, because `truncate` below decides which of two equidistant
661            // hits survives at the k-th position and sorting on distance alone left that to
662            // whatever order the sort happened to leave. This makes the rescored path
663            // deterministic; it does not make the whole surface so, since a `Float32` HNSW
664            // index does not rescore and usearch breaks a distance tie by its own traversal
665            // order.
666            rescored.sort_unstable_by(|a, b| {
667                a.distance.total_cmp(&b.distance).then(a.node.cmp(&b.node))
668            });
669            rescored
670        } else {
671            hits
672        };
673
674        final_hits.truncate(opts.k);
675        Ok(final_hits)
676    }
677
678    fn node_vector(&self, n: NodeId) -> Result<Option<Vec<f32>>, VectorError> {
679        let bytes = self.view(|txn| txn.get_vector_bytes(n))?;
680        match bytes {
681            Some(b) => Ok(Some(decode_vector(&b)?)),
682            None => Ok(None),
683        }
684    }
685
686    fn vector_distance(&self, a: &[f32], b: &[f32]) -> Result<f32, VectorError> {
687        if a.len() != b.len() {
688            return Err(VectorError::DimensionMismatch {
689                expected: a.len(),
690                got: b.len(),
691            });
692        }
693        let metric = load_config(self)?.unwrap_or_default().metric;
694        Ok(exact_distance(a, b, metric))
695    }
696}
697
698/// Full-precision distance between `q` and a stored vector. Both backends report this
699/// same convention for a given metric (squared L2, and `1 - dot` for inner product), which
700/// is what lets a rescored distance and an approximate one be sorted into one list;
701/// `the_hnsw_backend_reports_the_same_convention_as_exact_distance` pins it.
702pub(crate) fn exact_distance(q: &[f32], v: &[f32], metric: VectorMetric) -> f32 {
703    match metric {
704        VectorMetric::Cosine => {
705            let mut dot = 0.0;
706            let mut norm_q = 0.0;
707            let mut norm_v = 0.0;
708            for (&qi, &vi) in q.iter().zip(v.iter()) {
709                dot += qi * vi;
710                norm_q += qi * qi;
711                norm_v += vi * vi;
712            }
713            if norm_q > 0.0 && norm_v > 0.0 {
714                // Clamped at zero: rounding can push the ratio past 1.
715                (1.0 - (dot / (norm_q.sqrt() * norm_v.sqrt()))).max(0.0)
716            } else {
717                1.0
718            }
719        }
720        VectorMetric::L2 => {
721            let mut sum = 0.0;
722            for (&qi, &vi) in q.iter().zip(v.iter()) {
723                let diff = qi - vi;
724                sum += diff * diff;
725            }
726            sum
727        }
728        VectorMetric::Dot => {
729            let mut dot = 0.0;
730            for (&qi, &vi) in q.iter().zip(v.iter()) {
731                dot += qi * vi;
732            }
733            1.0 - dot
734        }
735    }
736}
737
738/// Return the cached `VectorIndexCache` for this Graph, building it from LMDB
739/// if it has not been initialised yet.
740fn get_or_init_cache(graph: &Graph) -> Result<Arc<VectorIndexCache>, VectorError> {
741    // Cold start: load all vectors from LMDB into a fresh HNSW index, built with
742    // the graph's persisted metric and quantization (default Cosine and Float32
743    // when never configured). The initializer runs without the extensions lock
744    // held, so reading from storage here cannot deadlock against it.
745    graph.get_or_init_extension_with(|| {
746        let opts = load_config(graph)?.unwrap_or_default();
747        Ok(Arc::new(VectorIndexCache(build_index(graph, opts)?)))
748    })
749}
750
751/// Build a fresh in-memory HNSW index from every embedding persisted in LMDB,
752/// using `opts` for the metric and quantization. The stored vectors are raw
753/// f32 and metric-agnostic, so this re-indexes them correctly under any metric.
754fn build_index(graph: &Graph, opts: VectorIndexOptions) -> Result<VectorIndex, VectorError> {
755    let idx = VectorIndex::new_with_options(opts);
756    for (node_id, bytes) in graph.vector_bytes()? {
757        let v = decode_vector(&bytes)?;
758        idx.upsert(node_id, &v)?;
759    }
760    Ok(idx)
761}
762
763/// Load and decode this graph's persisted vector index configuration, or
764/// `None` when the graph has never been configured.
765fn load_config(graph: &Graph) -> Result<Option<VectorIndexOptions>, VectorError> {
766    match graph.get_vector_config()? {
767        Some(bytes) => Ok(Some(decode_config(&bytes)?)),
768        None => Ok(None),
769    }
770}
771
772/// Encode the index configuration as two stable tag bytes: `[metric, quantization]`.
773fn encode_config(opts: VectorIndexOptions) -> [u8; 2] {
774    let metric = match opts.metric {
775        VectorMetric::Cosine => 0,
776        VectorMetric::L2 => 1,
777        VectorMetric::Dot => 2,
778    };
779    let quant = match opts.quantization {
780        VectorQuantization::Float32 => 0,
781        VectorQuantization::Float16 => 1,
782        VectorQuantization::Int8 => 2,
783    };
784    [metric, quant]
785}
786
787/// Decode the two-byte index configuration written by `encode_config`.
788fn decode_config(bytes: &[u8]) -> Result<VectorIndexOptions, VectorError> {
789    let [metric, quant] = bytes.try_into().map_err(|_| {
790        VectorError::IndexFault(format!(
791            "vector config must be 2 bytes, got {}",
792            bytes.len()
793        ))
794    })?;
795    let metric = match metric {
796        0 => VectorMetric::Cosine,
797        1 => VectorMetric::L2,
798        2 => VectorMetric::Dot,
799        other => {
800            return Err(VectorError::IndexFault(format!(
801                "unknown vector metric tag {other}"
802            )));
803        }
804    };
805    let quantization = match quant {
806        0 => VectorQuantization::Float32,
807        1 => VectorQuantization::Float16,
808        2 => VectorQuantization::Int8,
809        other => {
810            return Err(VectorError::IndexFault(format!(
811                "unknown vector quantization tag {other}"
812            )));
813        }
814    };
815    Ok(VectorIndexOptions {
816        metric,
817        quantization,
818    })
819}
820
821#[cfg(test)]
822mod tests {
823    use serde_json::json;
824    use tempfile::TempDir;
825
826    use super::*;
827
828    fn open_tmp() -> (TempDir, Graph) {
829        let dir = TempDir::new().unwrap();
830        let graph = Graph::open(dir.path(), 1).unwrap();
831        (dir, graph)
832    }
833
834    /// An embedding for an id no node holds is refused.
835    ///
836    /// This used to be accepted, and because node ids are handed out monotonically, the next node
837    /// created with that id inherited it. The node below is never embedded and yet
838    /// answered a search at distance zero, with no error at any layer.
839    #[test]
840    fn a_vector_for_a_node_that_does_not_exist_is_refused() {
841        let (_dir, graph) = open_tmp();
842        let alice = graph
843            .add_node("Person", &json!({ "name": "Alice" }))
844            .unwrap();
845        graph.upsert_vector(alice, &[1.0, 0.0]).unwrap();
846
847        // The very next id, which no node holds yet.
848        let future = alice + 1;
849        let err = graph.upsert_vector(future, &[0.0, 1.0]).unwrap_err();
850        assert!(
851            matches!(err, VectorError::NodeNotFound(id) if id == future),
852            "expected NodeNotFound, got {err:?}"
853        );
854
855        // Bob takes that id and must own no embedding.
856        let bob = graph.add_node("Person", &json!({ "name": "Bob" })).unwrap();
857        assert_eq!(bob, future);
858        let hits = graph.vector_search(&[0.0, 1.0], 5).unwrap();
859        assert!(
860            hits.iter().all(|h| h.node != bob),
861            "a node that was never embedded must not appear in a vector search: {hits:?}"
862        );
863    }
864
865    /// The rejection happens before anything is written, so a refused upsert leaves neither an
866    /// index entry nor a stored vector behind. Checking after the fact is what a partial write
867    /// would defeat.
868    #[test]
869    fn a_refused_vector_reaches_neither_the_index_nor_storage() {
870        let (_dir, graph) = open_tmp();
871        let real = graph.add_node("N", &json!({})).unwrap();
872        graph.upsert_vector(real, &[1.0, 0.0]).unwrap();
873
874        assert!(graph.upsert_vector(real + 99, &[0.0, 1.0]).is_err());
875        let stored = graph.vector_bytes().unwrap();
876        assert!(
877            stored.iter().all(|(id, _)| *id != real + 99),
878            "the refused vector must not be in storage: {:?}",
879            stored.iter().map(|(id, _)| *id).collect::<Vec<_>>()
880        );
881        let hits = graph.vector_search(&[0.0, 1.0], 5).unwrap();
882        assert_eq!(hits.len(), 1, "only the one real embedding: {hits:?}");
883        assert_eq!(hits[0].node, real);
884    }
885
886    /// Removal stays permissive, which is the escape hatch for a database written before the check
887    /// existed: a caller has to be able to delete a vector whose node is already gone.
888    #[test]
889    fn removing_a_vector_for_a_missing_node_is_not_an_error() {
890        let (_dir, graph) = open_tmp();
891        let node = graph.add_node("N", &json!({})).unwrap();
892        graph.upsert_vector(node, &[1.0, 0.0]).unwrap();
893        graph.delete_node(node).unwrap();
894        graph.remove_vector(node).unwrap();
895    }
896
897    #[test]
898    fn metric_from_str_is_case_insensitive_with_alias() {
899        assert_eq!(
900            "cosine".parse::<VectorMetric>().unwrap(),
901            VectorMetric::Cosine
902        );
903        assert_eq!("L2".parse::<VectorMetric>().unwrap(), VectorMetric::L2);
904        assert_eq!("Dot".parse::<VectorMetric>().unwrap(), VectorMetric::Dot);
905        assert_eq!("ip".parse::<VectorMetric>().unwrap(), VectorMetric::Dot);
906        assert!("hamming".parse::<VectorMetric>().is_err());
907    }
908
909    #[test]
910    fn quantization_from_str_is_case_insensitive() {
911        assert_eq!(
912            "float32".parse::<VectorQuantization>().unwrap(),
913            VectorQuantization::Float32
914        );
915        assert_eq!(
916            "Float16".parse::<VectorQuantization>().unwrap(),
917            VectorQuantization::Float16
918        );
919        assert_eq!(
920            "INT8".parse::<VectorQuantization>().unwrap(),
921            VectorQuantization::Int8
922        );
923        assert!("b1".parse::<VectorQuantization>().is_err());
924    }
925
926    #[test]
927    fn upsert_vector_and_search_finds_nearest() {
928        let (_dir, graph) = open_tmp();
929        let a = graph.add_node("N", &json!({})).unwrap();
930        let b = graph.add_node("N", &json!({})).unwrap();
931        let c = graph.add_node("N", &json!({})).unwrap();
932
933        graph.upsert_vector(a, &[1.0f32, 0.0, 0.0]).unwrap();
934        graph.upsert_vector(b, &[0.0f32, 1.0, 0.0]).unwrap();
935        graph.upsert_vector(c, &[0.0f32, 0.0, 1.0]).unwrap();
936
937        let hits = graph.vector_search(&[1.0f32, 0.0, 0.0], 1).unwrap();
938        assert_eq!(hits.len(), 1);
939        assert_eq!(hits[0].node, a);
940    }
941
942    #[test]
943    fn vector_search_empty_index_is_an_error() {
944        let (_dir, graph) = open_tmp();
945        let err = graph.vector_search(&[1.0f32, 0.0, 0.0], 5).unwrap_err();
946        assert!(matches!(err, VectorError::EmptyIndex), "got {err:?}");
947    }
948
949    #[test]
950    fn vector_search_after_removing_all_vectors_is_an_error() {
951        let (_dir, graph) = open_tmp();
952        let a = graph.add_node("N", &json!({})).unwrap();
953        graph.upsert_vector(a, &[1.0f32, 0.0, 0.0]).unwrap();
954        graph.remove_vector(a).unwrap();
955        let err = graph.vector_search(&[1.0f32, 0.0, 0.0], 5).unwrap_err();
956        assert!(matches!(err, VectorError::EmptyIndex), "got {err:?}");
957    }
958
959    /// A node deleted through the core graph API lingers in the in-memory HNSW
960    /// index, but `vector_search` must not return it as a "ghost" hit.
961    #[test]
962    fn vector_search_excludes_deleted_nodes() {
963        let (_dir, graph) = open_tmp();
964        let a = graph.add_node("N", &json!({})).unwrap();
965        let b = graph.add_node("N", &json!({})).unwrap();
966        graph.upsert_vector(a, &[1.0f32, 0.0, 0.0]).unwrap();
967        graph.upsert_vector(b, &[0.0f32, 1.0, 0.0]).unwrap();
968
969        // Delete the closest node through the core API (not `remove_vector`), so
970        // its embedding stays in the HNSW index.
971        graph.delete_node(a).unwrap();
972
973        let hits = graph.vector_search(&[1.0f32, 0.0, 0.0], 5).unwrap();
974        assert!(
975            hits.iter().all(|h| h.node != a),
976            "deleted node must not appear in vector_search results"
977        );
978        assert!(
979            hits.iter().any(|h| h.node == b),
980            "the surviving node is still searchable"
981        );
982    }
983
984    /// A vacuous filter set (no label, empty property map) must not return
985    /// core-deleted ghosts either: the predicate-filtered path cannot rely on
986    /// its label and property lookups to reject deleted nodes when there are
987    /// no lookups to make. Reachable from the REST and MCP surfaces as
988    /// `"properties": {}` with no label.
989    #[test]
990    fn vector_search_with_empty_filters_excludes_deleted_nodes() {
991        let (_dir, graph) = open_tmp();
992        let a = graph.add_node("N", &json!({})).unwrap();
993        let b = graph.add_node("N", &json!({})).unwrap();
994        graph.upsert_vector(a, &[1.0f32, 0.0, 0.0]).unwrap();
995        graph.upsert_vector(b, &[0.0f32, 1.0, 0.0]).unwrap();
996        graph.delete_node(a).unwrap();
997
998        let opts = VectorSearchOptions {
999            k: 5,
1000            properties: Some(std::collections::HashMap::new()),
1001            ..Default::default()
1002        };
1003        let hits = graph
1004            .vector_search_with(&[1.0f32, 0.0, 0.0], &opts)
1005            .unwrap();
1006        assert!(
1007            hits.iter().all(|h| h.node != a),
1008            "deleted node must not appear under an empty filter set"
1009        );
1010        assert!(
1011            hits.iter().any(|h| h.node == b),
1012            "the surviving node is still searchable"
1013        );
1014    }
1015
1016    /// When enough top-ranked nodes are deleted (ghosts) on the default
1017    /// Float32 path, `vector_search` must still return `k` live hits by
1018    /// over-fetching past the ghosts, not truncate below `k`.
1019    #[test]
1020    fn vector_search_returns_k_live_hits_despite_deleted_top_ranked() {
1021        let (_dir, graph) = open_tmp();
1022        let close = [
1023            graph.add_node("N", &json!({})).unwrap(),
1024            graph.add_node("N", &json!({})).unwrap(),
1025            graph.add_node("N", &json!({})).unwrap(),
1026        ];
1027        graph.upsert_vector(close[0], &[1.0f32, 0.0]).unwrap();
1028        graph.upsert_vector(close[1], &[1.0f32, 0.1]).unwrap();
1029        graph.upsert_vector(close[2], &[1.0f32, 0.2]).unwrap();
1030        let far = [
1031            graph.add_node("N", &json!({})).unwrap(),
1032            graph.add_node("N", &json!({})).unwrap(),
1033            graph.add_node("N", &json!({})).unwrap(),
1034        ];
1035        graph.upsert_vector(far[0], &[1.0f32, 1.0]).unwrap();
1036        graph.upsert_vector(far[1], &[0.5f32, 1.0]).unwrap();
1037        graph.upsert_vector(far[2], &[0.0f32, 1.0]).unwrap();
1038
1039        // Delete the three closest through the core API (leaving them as ghosts
1040        // in the HNSW index), so a fixed `fetch_k == k` would drop all results.
1041        for n in close {
1042            graph.delete_node(n).unwrap();
1043        }
1044
1045        let hits = graph.vector_search(&[1.0f32, 0.0], 3).unwrap();
1046        assert_eq!(
1047            hits.len(),
1048            3,
1049            "must backfill past deleted nodes to return k live hits"
1050        );
1051        assert!(
1052            hits.iter().all(|h| far.contains(&h.node)),
1053            "only the live (far) nodes are returned"
1054        );
1055    }
1056
1057    /// Re-applying the effective default configuration on a graph that has
1058    /// vectors but no explicitly persisted config is a documented no-op, not an
1059    /// `AlreadyConfigured` error.
1060    #[test]
1061    fn configure_default_after_upsert_is_noop() {
1062        let (_dir, graph) = open_tmp();
1063        let a = graph.add_node("N", &json!({})).unwrap();
1064        graph.upsert_vector(a, &[1.0f32, 0.0]).unwrap();
1065        // No prior configure_vector_index call; the active config is the lazily
1066        // built default. Re-applying that default must succeed.
1067        assert!(
1068            graph
1069                .configure_vector_index(VectorIndexOptions::default())
1070                .is_ok()
1071        );
1072        // Requesting a DIFFERENT config while vectors exist is still refused.
1073        let other = VectorIndexOptions {
1074            metric: VectorMetric::L2,
1075            ..VectorIndexOptions::default()
1076        };
1077        assert!(graph.configure_vector_index(other).is_err());
1078    }
1079
1080    #[test]
1081    fn vector_search_k_larger_than_index_returns_all() {
1082        let (_dir, graph) = open_tmp();
1083        let a = graph.add_node("N", &json!({})).unwrap();
1084        let b = graph.add_node("N", &json!({})).unwrap();
1085        graph.upsert_vector(a, &[1.0f32, 0.0]).unwrap();
1086        graph.upsert_vector(b, &[0.0f32, 1.0]).unwrap();
1087
1088        let hits = graph.vector_search(&[1.0f32, 0.0], 100).unwrap();
1089        assert_eq!(hits.len(), 2);
1090    }
1091
1092    /// A stored non-finite component yields a NaN distance at every later search, which no
1093    /// ranking can order, so it is refused rather than persisted.
1094    #[test]
1095    fn upsert_vector_rejects_a_non_finite_component() {
1096        let (_dir, graph) = open_tmp();
1097        let n = graph.add_node("N", &json!({})).unwrap();
1098        for bad in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY] {
1099            let err = graph.upsert_vector(n, &[1.0, bad]).unwrap_err();
1100            assert!(
1101                err.to_string().contains("not finite"),
1102                "expected a non-finite rejection, got {err}"
1103            );
1104        }
1105        graph.upsert_vector(n, &[1.0, 2.0]).unwrap();
1106    }
1107
1108    #[test]
1109    fn upsert_vector_overwrites_existing_embedding() {
1110        let (_dir, graph) = open_tmp();
1111        let a = graph.add_node("N", &json!({})).unwrap();
1112        let b = graph.add_node("N", &json!({})).unwrap();
1113
1114        graph.upsert_vector(a, &[1.0f32, 0.0, 0.0]).unwrap();
1115        graph.upsert_vector(b, &[0.0f32, 1.0, 0.0]).unwrap();
1116        graph.upsert_vector(a, &[0.0f32, 1.0, 0.0]).unwrap();
1117
1118        let hits = graph.vector_search(&[0.0f32, 1.0, 0.0], 1).unwrap();
1119        assert_eq!(hits.len(), 1);
1120        assert!(
1121            (hits[0].distance).abs() < 1e-5,
1122            "distance to query should be near zero"
1123        );
1124    }
1125
1126    // Persistence-dependent: reopens the same path and expects the stored embeddings
1127    // or configuration to still be there. The in-memory storage backend starts empty
1128    // on every open by design, so this is gated rather than left to fail there.
1129    #[cfg(feature = "lmdb")]
1130    #[test]
1131    fn vector_index_rebuilds_from_lmdb_on_reopen() {
1132        let dir = TempDir::new().unwrap();
1133        let a = {
1134            let graph = Graph::open(dir.path(), 1).unwrap();
1135            let a = graph.add_node("N", &json!({})).unwrap();
1136            graph.upsert_vector(a, &[1.0f32, 0.0, 0.0]).unwrap();
1137            a
1138        };
1139
1140        let graph = Graph::open(dir.path(), 1).unwrap();
1141        let hits = graph.vector_search(&[1.0f32, 0.0, 0.0], 1).unwrap();
1142        assert_eq!(hits.len(), 1);
1143        assert_eq!(hits[0].node, a);
1144    }
1145
1146    #[test]
1147    fn remove_vector_deletes_from_index_and_lmdb() {
1148        let (_dir, graph) = open_tmp();
1149        let a = graph.add_node("N", &json!({})).unwrap();
1150        let b = graph.add_node("N", &json!({})).unwrap();
1151        graph.upsert_vector(a, &[1.0f32, 0.0, 0.0]).unwrap();
1152        graph.upsert_vector(b, &[0.0f32, 1.0, 0.0]).unwrap();
1153
1154        graph.remove_vector(a).unwrap();
1155
1156        let hits = graph.vector_search(&[1.0f32, 0.0, 0.0], 2).unwrap();
1157        assert!(
1158            hits.iter().all(|h| h.node != a),
1159            "removed node must not appear in search results"
1160        );
1161    }
1162
1163    #[test]
1164    fn vector_search_with_label_filter_excludes_other_labels() {
1165        let (_dir, graph) = open_tmp();
1166        let a = graph.add_node("Article", &json!({})).unwrap();
1167        let b = graph.add_node("Person", &json!({})).unwrap();
1168        let c = graph.add_node("Article", &json!({})).unwrap();
1169        graph.upsert_vector(a, &[1.0f32, 0.0, 0.0]).unwrap();
1170        graph.upsert_vector(b, &[1.0f32, 0.0, 0.0]).unwrap(); // same direction as a
1171        graph.upsert_vector(c, &[0.9f32, 0.1, 0.0]).unwrap();
1172
1173        let opts = VectorSearchOptions {
1174            k: 3,
1175            label: Some("Article".into()),
1176            properties: None,
1177            rescore_factor: None,
1178        };
1179        let hits = graph
1180            .vector_search_with(&[1.0f32, 0.0, 0.0], &opts)
1181            .unwrap();
1182        // Only Article nodes a and c must appear; Person node b must be absent.
1183        assert!(
1184            hits.iter().all(|h| h.node != b),
1185            "Person node must be filtered out"
1186        );
1187        assert!(hits.len() <= 2);
1188        assert!(hits.iter().any(|h| h.node == a));
1189    }
1190
1191    #[test]
1192    fn vector_search_with_selective_property_filter_finds_distant_matches() {
1193        // Regression guard: a selective property filter must not silently
1194        // under-return. Many non-matching nodes sit nearest the query, and the
1195        // matching nodes rank far below them. A post-filter over a fixed
1196        // over-fetch would discard every candidate and return nothing; the
1197        // predicate-driven traversal keeps expanding until it finds them.
1198        let (_dir, graph) = open_tmp();
1199        // 200 "red" decoys, all nearer the query than any "blue" node.
1200        for i in 0..200u32 {
1201            let n = graph.add_node("N", &json!({ "team": "red" })).unwrap();
1202            let jitter = (i as f32) * 1e-4;
1203            graph.upsert_vector(n, &[1.0, jitter, 0.0]).unwrap();
1204        }
1205        // 2 "blue" matches, farther from the query in cosine distance.
1206        let blue1 = graph.add_node("N", &json!({ "team": "blue" })).unwrap();
1207        let blue2 = graph.add_node("N", &json!({ "team": "blue" })).unwrap();
1208        graph.upsert_vector(blue1, &[0.6, 0.8, 0.0]).unwrap();
1209        graph.upsert_vector(blue2, &[0.5, 0.85, 0.0]).unwrap();
1210
1211        let mut filters = std::collections::HashMap::new();
1212        filters.insert("team".to_string(), json!("blue"));
1213        let opts = VectorSearchOptions {
1214            k: 2,
1215            label: None,
1216            properties: Some(filters),
1217            rescore_factor: None,
1218        };
1219        let hits = graph
1220            .vector_search_with(&[1.0f32, 0.0, 0.0], &opts)
1221            .unwrap();
1222
1223        assert_eq!(hits.len(), 2, "both blue matches must be returned");
1224        assert!(hits.iter().any(|h| h.node == blue1));
1225        assert!(hits.iter().any(|h| h.node == blue2));
1226    }
1227
1228    // Persistence-dependent: reopens the same path and expects the stored embeddings
1229    // or configuration to still be there. The in-memory storage backend starts empty
1230    // on every open by design, so this is gated rather than left to fail there.
1231    #[cfg(feature = "lmdb")]
1232    #[test]
1233    fn rejected_upsert_does_not_persist_and_brick_reopen() {
1234        // A dimension-mismatched upsert must not leave bytes in LMDB. If it did,
1235        // the cold-start rebuild on the next `Graph::open` would fail to decode
1236        // a consistent index and brick every subsequent search.
1237        let dir = TempDir::new().unwrap();
1238        let a = {
1239            let graph = Graph::open(dir.path(), 1).unwrap();
1240            let a = graph.add_node("N", &json!({})).unwrap();
1241            let b = graph.add_node("N", &json!({})).unwrap();
1242            graph.upsert_vector(a, &[1.0f32, 0.0, 0.0]).unwrap();
1243            // Wrong dimension count: must be rejected and must not persist.
1244            let bad = graph.upsert_vector(b, &[1.0f32, 0.0]);
1245            assert!(matches!(bad, Err(VectorError::DimensionMismatch { .. })));
1246            a
1247        };
1248
1249        // Reopen: the rebuild must succeed and search must still work.
1250        let graph = Graph::open(dir.path(), 1).unwrap();
1251        let hits = graph.vector_search(&[1.0f32, 0.0, 0.0], 1).unwrap();
1252        assert_eq!(hits.len(), 1);
1253        assert_eq!(hits[0].node, a);
1254    }
1255
1256    // Persistence-dependent: reopens the same path and expects the stored embeddings
1257    // or configuration to still be there. The in-memory storage backend starts empty
1258    // on every open by design, so this is gated rather than left to fail there.
1259    #[cfg(feature = "lmdb")]
1260    #[test]
1261    fn configure_vector_index_persists_metric_across_reopen() {
1262        let dir = TempDir::new().unwrap();
1263        let a = {
1264            let graph = Graph::open(dir.path(), 1).unwrap();
1265            graph
1266                .configure_vector_index(VectorIndexOptions {
1267                    metric: VectorMetric::L2,
1268                    quantization: VectorQuantization::Float32,
1269                })
1270                .unwrap();
1271            let a = graph.add_node("N", &json!({})).unwrap();
1272            let b = graph.add_node("N", &json!({})).unwrap();
1273            graph.upsert_vector(a, &[0.0f32, 0.0]).unwrap();
1274            graph.upsert_vector(b, &[5.0f32, 5.0]).unwrap();
1275            a
1276        };
1277
1278        // Reopen: the persisted L2 metric must be used by the cold-start rebuild.
1279        let graph = Graph::open(dir.path(), 1).unwrap();
1280        let hits = graph.vector_search(&[0.1f32, 0.1], 1).unwrap();
1281        assert_eq!(hits.len(), 1);
1282        assert_eq!(
1283            hits[0].node, a,
1284            "nearest under L2 must be the origin vector"
1285        );
1286    }
1287
1288    #[test]
1289    fn configure_vector_index_idempotent_with_same_options() {
1290        let (_dir, graph) = open_tmp();
1291        let opts = VectorIndexOptions {
1292            metric: VectorMetric::Dot,
1293            quantization: VectorQuantization::Float16,
1294        };
1295        graph.configure_vector_index(opts).unwrap();
1296        let a = graph.add_node("N", &json!({})).unwrap();
1297        graph.upsert_vector(a, &[1.0f32, 0.0]).unwrap();
1298        // Re-applying the identical configuration after vectors exist is a no-op.
1299        graph.configure_vector_index(opts).unwrap();
1300    }
1301
1302    #[test]
1303    fn configure_vector_index_rejects_change_after_vectors_exist() {
1304        let (_dir, graph) = open_tmp();
1305        graph
1306            .configure_vector_index(VectorIndexOptions {
1307                metric: VectorMetric::Cosine,
1308                quantization: VectorQuantization::Float32,
1309            })
1310            .unwrap();
1311        let a = graph.add_node("N", &json!({})).unwrap();
1312        graph.upsert_vector(a, &[1.0f32, 0.0]).unwrap();
1313
1314        let changed = graph.configure_vector_index(VectorIndexOptions {
1315            metric: VectorMetric::L2,
1316            quantization: VectorQuantization::Float32,
1317        });
1318        assert!(matches!(
1319            changed,
1320            Err(VectorError::AlreadyConfigured { .. })
1321        ));
1322    }
1323
1324    // Persistence-dependent: reopens the same path and expects the stored embeddings
1325    // or configuration to still be there. The in-memory storage backend starts empty
1326    // on every open by design, so this is gated rather than left to fail there.
1327    #[cfg(feature = "lmdb")]
1328    #[test]
1329    fn reindex_vector_index_switches_metric_on_populated_graph() {
1330        let dir = TempDir::new().unwrap();
1331        let (a, b) = {
1332            let graph = Graph::open(dir.path(), 1).unwrap();
1333            // Default Cosine configuration.
1334            let a = graph.add_node("N", &json!({})).unwrap();
1335            let b = graph.add_node("N", &json!({})).unwrap();
1336            graph.upsert_vector(a, &[0.0f32, 0.0]).unwrap();
1337            graph.upsert_vector(b, &[5.0f32, 5.0]).unwrap();
1338
1339            // configure must refuse the change while vectors exist.
1340            let refused = graph.configure_vector_index(VectorIndexOptions {
1341                metric: VectorMetric::L2,
1342                quantization: VectorQuantization::Float32,
1343            });
1344            assert!(matches!(
1345                refused,
1346                Err(VectorError::AlreadyConfigured { .. })
1347            ));
1348
1349            // reindex accepts it and rebuilds from the stored embeddings.
1350            graph
1351                .reindex_vector_index(VectorIndexOptions {
1352                    metric: VectorMetric::L2,
1353                    quantization: VectorQuantization::Float32,
1354                })
1355                .unwrap();
1356            (a, b)
1357        };
1358
1359        // The new metric persists, and search reflects L2 geometry after reopen.
1360        let graph = Graph::open(dir.path(), 1).unwrap();
1361        let hits = graph.vector_search(&[0.1f32, 0.1], 2).unwrap();
1362        assert_eq!(hits[0].node, a, "origin is nearest under L2");
1363        assert!(hits.iter().any(|h| h.node == b));
1364    }
1365
1366    #[test]
1367    fn vector_cache_is_reused_across_searches() {
1368        let (_dir, graph) = open_tmp();
1369        let a = graph.add_node("N", &json!({})).unwrap();
1370        graph.upsert_vector(a, &[1.0f32, 0.0, 0.0]).unwrap();
1371
1372        // Both calls should return consistent results; the second uses the cached index.
1373        let h1 = graph.vector_search(&[1.0f32, 0.0, 0.0], 1).unwrap();
1374        let h2 = graph.vector_search(&[1.0f32, 0.0, 0.0], 1).unwrap();
1375        assert_eq!(h1.len(), 1);
1376        assert_eq!(h2.len(), 1);
1377        assert_eq!(h1[0].node, h2[0].node);
1378    }
1379
1380    #[test]
1381    fn test_concurrent_vector_searches() {
1382        let (_dir, graph) = open_tmp();
1383        let a = graph.add_node("N", &json!({})).unwrap();
1384        graph.upsert_vector(a, &[1.0f32, 0.0, 0.0]).unwrap();
1385
1386        let graph = Arc::new(graph);
1387        let mut handles = vec![];
1388        for _ in 0..10 {
1389            let g = Arc::clone(&graph);
1390            let target_node = a;
1391            handles.push(std::thread::spawn(move || {
1392                let hits = g.vector_search(&[1.0f32, 0.0, 0.0], 1).unwrap();
1393                assert_eq!(hits.len(), 1);
1394                assert_eq!(hits[0].node, target_node);
1395            }));
1396        }
1397
1398        for h in handles {
1399            h.join().unwrap();
1400        }
1401    }
1402
1403    /// Concurrent upserts and removes for one node must leave the in-memory
1404    /// index and the stored bytes in agreement. The specific survivor is
1405    /// whichever call serialized last, so the assertion is agreement, not a
1406    /// particular value: a stored embedding must be the one the index ranks by
1407    /// (distance zero to itself), and removed bytes must not leave a live
1408    /// index entry behind.
1409    #[test]
1410    fn concurrent_upsert_and_remove_leave_index_and_storage_agreeing() {
1411        let (_dir, graph) = open_tmp();
1412        let n = graph.add_node("N", &json!({})).unwrap();
1413        let graph = Arc::new(graph);
1414
1415        let mut handles = vec![];
1416        for t in 0..4u32 {
1417            let g = Arc::clone(&graph);
1418            handles.push(std::thread::spawn(move || {
1419                for i in 0..50u32 {
1420                    if (t + i) % 5 == 0 {
1421                        g.remove_vector(n).unwrap();
1422                    } else {
1423                        // Distinct unit vectors: any two differ by well over
1424                        // 1e-3 in cosine distance, so a mismatch between the
1425                        // indexed and the stored embedding is measurable.
1426                        let angle = ((t * 50 + i) % 7) as f32 * 0.2;
1427                        g.upsert_vector(n, &[angle.cos(), angle.sin()]).unwrap();
1428                    }
1429                }
1430            }));
1431        }
1432        for h in handles {
1433            h.join().unwrap();
1434        }
1435
1436        match graph.node_vector(n).unwrap() {
1437            Some(stored) => {
1438                let hits = graph.vector_search(&stored, 1).unwrap();
1439                assert_eq!(hits.len(), 1);
1440                assert_eq!(hits[0].node, n);
1441                assert!(
1442                    hits[0].distance < 1e-4,
1443                    "the index must rank by the embedding storage holds, got distance {}",
1444                    hits[0].distance
1445                );
1446            }
1447            None => match graph.vector_search(&[1.0f32, 0.0], 1) {
1448                Err(VectorError::EmptyIndex) => {}
1449                Ok(hits) => {
1450                    panic!("the index holds an entry whose stored bytes were removed: {hits:?}")
1451                }
1452                Err(e) => panic!("unexpected error: {e:?}"),
1453            },
1454        }
1455    }
1456
1457    /// Deterministic form of the race the hammer test above cannot hit on
1458    /// demand. The test-only pause hook parks the first upsert inside the
1459    /// window between its index update and its storage write; a second upsert
1460    /// for the same node then runs to completion before the first is released.
1461    /// The mutation lock makes the second call wait, so index and storage
1462    /// agree; without it the first call finishes by writing v1 to storage
1463    /// while the index already ranks by v2.
1464    #[test]
1465    fn interleaved_upserts_for_one_node_leave_index_and_storage_agreeing() {
1466        use std::sync::mpsc;
1467
1468        let (_dir, graph) = open_tmp();
1469        let n = graph.add_node("N", &json!({})).unwrap();
1470        let graph = Arc::new(graph);
1471
1472        let lock = mutation_lock(&graph);
1473        let (parked_tx, parked_rx) = mpsc::channel::<()>();
1474        let (release_tx, release_rx) = mpsc::channel::<()>();
1475        *lock.upsert_pause.lock() = Some(Box::new(move || {
1476            parked_tx.send(()).unwrap();
1477            release_rx.recv().unwrap();
1478        }));
1479
1480        let v1 = [1.0f32, 0.0];
1481        let v2 = [0.0f32, 1.0];
1482
1483        let t1 = {
1484            let g = Arc::clone(&graph);
1485            std::thread::spawn(move || g.upsert_vector(n, &v1).unwrap())
1486        };
1487        parked_rx.recv().unwrap();
1488
1489        // The hook was taken when it fired, so the second upsert does not
1490        // park too.
1491        let t2 = {
1492            let g = Arc::clone(&graph);
1493            std::thread::spawn(move || g.upsert_vector(n, &v2).unwrap())
1494        };
1495        // Ordering help only: give T2 time to reach the mutation lock (or, in
1496        // the broken shape, to complete inside the window) before releasing
1497        // T1. T1 must be released from here, because under the fixed code it
1498        // parks while holding the mutation lock and T2 blocks on it, so
1499        // waiting on T2 first would deadlock.
1500        std::thread::sleep(std::time::Duration::from_millis(100));
1501        release_tx.send(()).unwrap();
1502        t1.join().unwrap();
1503        t2.join().unwrap();
1504
1505        let stored = graph.node_vector(n).unwrap().expect("bytes must exist");
1506        let hits = graph.vector_search(&stored, 1).unwrap();
1507        assert_eq!(hits.len(), 1);
1508        assert_eq!(hits[0].node, n);
1509        assert!(
1510            hits[0].distance < 1e-4,
1511            "the index ranks by a different embedding than storage holds, distance {}",
1512            hits[0].distance
1513        );
1514    }
1515
1516    #[test]
1517    fn vector_search_with_int8_quantization_finds_nearest() {
1518        // Int8 quantization is wired to usearch's ScalarKind::I8. Precision is
1519        // reduced, but well-separated vectors must still rank correctly.
1520        let (_dir, graph) = open_tmp();
1521        graph
1522            .configure_vector_index(VectorIndexOptions {
1523                metric: VectorMetric::Cosine,
1524                quantization: VectorQuantization::Int8,
1525            })
1526            .unwrap();
1527        let a = graph.add_node("N", &json!({})).unwrap();
1528        let b = graph.add_node("N", &json!({})).unwrap();
1529        let c = graph.add_node("N", &json!({})).unwrap();
1530        graph.upsert_vector(a, &[1.0, 0.0, 0.0]).unwrap();
1531        graph.upsert_vector(b, &[0.0, 1.0, 0.0]).unwrap();
1532        graph.upsert_vector(c, &[0.0, 0.0, 1.0]).unwrap();
1533
1534        let hits = graph.vector_search(&[1.0, 0.0, 0.0], 1).unwrap();
1535        assert_eq!(hits.len(), 1);
1536        assert_eq!(hits[0].node, a);
1537    }
1538
1539    #[test]
1540    fn vector_search_with_multiple_property_filters_requires_all() {
1541        // A property filter with several keys is an AND: only nodes matching
1542        // every key/value pair qualify. The nearest node matches one key but not
1543        // the other and must be excluded.
1544        let (_dir, graph) = open_tmp();
1545        let near = graph
1546            .add_node("N", &json!({ "team": "blue", "role": "ic" }))
1547            .unwrap();
1548        let far = graph
1549            .add_node("N", &json!({ "team": "blue", "role": "lead" }))
1550            .unwrap();
1551        graph.upsert_vector(near, &[1.0, 0.0, 0.0]).unwrap();
1552        graph.upsert_vector(far, &[0.9, 0.1, 0.0]).unwrap();
1553
1554        let mut filters = std::collections::HashMap::new();
1555        filters.insert("team".to_string(), json!("blue"));
1556        filters.insert("role".to_string(), json!("lead"));
1557        let opts = VectorSearchOptions {
1558            k: 2,
1559            label: None,
1560            properties: Some(filters),
1561            rescore_factor: None,
1562        };
1563        let hits = graph.vector_search_with(&[1.0, 0.0, 0.0], &opts).unwrap();
1564
1565        // `near` is closer but is role=ic, so only `far` satisfies both filters.
1566        assert_eq!(hits.len(), 1);
1567        assert_eq!(hits[0].node, far);
1568    }
1569
1570    #[test]
1571    fn vector_search_quantized_rescore() {
1572        let (_dir, graph) = open_tmp();
1573        graph
1574            .configure_vector_index(VectorIndexOptions {
1575                metric: VectorMetric::Cosine,
1576                quantization: VectorQuantization::Int8,
1577            })
1578            .unwrap();
1579
1580        let n1 = graph.add_node("N", &json!({})).unwrap();
1581        let n2 = graph.add_node("N", &json!({})).unwrap();
1582
1583        graph.upsert_vector(n1, &[0.9, 0.1]).unwrap();
1584        graph.upsert_vector(n2, &[0.95, 0.05]).unwrap();
1585
1586        let query = &[1.0, 0.0];
1587
1588        // Search with rescoring active
1589        let opts = VectorSearchOptions {
1590            k: 2,
1591            rescore_factor: Some(2),
1592            ..Default::default()
1593        };
1594        let hits = graph.vector_search_with(query, &opts).unwrap();
1595        assert_eq!(hits.len(), 2);
1596        assert_eq!(hits[0].node, n2);
1597        assert_eq!(hits[1].node, n1);
1598
1599        assert!(hits[0].distance < hits[1].distance);
1600    }
1601}