Skip to main content

nodedb_vector/hnsw/graph/index/
state.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! The `HnswIndex` value itself: its fields, its constructors, and the
4//! read-only properties that describe how it was built.
5
6use std::cell::RefCell;
7#[cfg(not(target_arch = "wasm32"))]
8use std::sync::Arc;
9
10use nodedb_types::hnsw::HnswParams;
11use nodedb_types::vector_dtype::VectorStorageDtype;
12
13use crate::hnsw::arena::BeamSearchArena;
14
15use super::super::ARENA_INITIAL_CAPACITY;
16use super::super::types::{Node, Xorshift64};
17
18/// Hierarchical Navigable Small World graph index.
19///
20/// - FP32 construction for structural integrity
21/// - Heuristic neighbor selection (Algorithm 4)
22/// - Beam search with configurable ef parameter
23pub struct HnswIndex {
24    pub(crate) params: HnswParams,
25    pub(crate) dim: usize,
26    pub(crate) nodes: Vec<Node>,
27    pub(crate) entry_point: Option<u32>,
28    pub(crate) max_layer: usize,
29    pub(crate) rng: Xorshift64,
30    /// Flat neighbor storage for zero-copy access after checkpoint restore.
31    /// When present, `neighbors_at()` reads from here instead of per-node Vecs.
32    /// Cleared on first mutation (insert/delete).
33    pub(crate) flat_neighbors: Option<crate::hnsw::flat_neighbors::FlatNeighborStore>,
34    /// Optional backing store for vector data.
35    ///
36    /// When set (graph-checkpoint-only restore path), per-node vector storage
37    /// is left empty and `dist_to_node` falls through to the backing.  Origin
38    /// never sets this field; it is only used by Lite's pagedb segment path.
39    #[cfg(not(target_arch = "wasm32"))]
40    pub(crate) backing: Option<Arc<dyn crate::segment_backing::VectorSegmentBacking>>,
41    /// Per-invocation scratch arena for beam-search heaps.
42    ///
43    /// Wrapped in `RefCell` so search methods keep `&self` receivers without
44    /// forcing `&mut self` across all call sites.  The borrow is taken at the
45    /// start of `search_layer` and released before returning.  The arena must
46    /// never be borrowed twice simultaneously — it is a per-call scratch buffer
47    /// owned exclusively by one Data Plane core.
48    pub(crate) arena: RefCell<BeamSearchArena>,
49}
50
51impl HnswIndex {
52    /// Create a new empty HNSW index.
53    pub fn new(dim: usize, params: HnswParams) -> Self {
54        Self::seeded(dim, params, 42)
55    }
56
57    /// Create with a specific RNG seed (for deterministic testing).
58    pub fn with_seed(dim: usize, params: HnswParams, seed: u64) -> Self {
59        Self::seeded(dim, params, seed)
60    }
61
62    fn seeded(dim: usize, params: HnswParams, seed: u64) -> Self {
63        let initial_capacity = params.ef_construction.max(ARENA_INITIAL_CAPACITY);
64        Self {
65            dim,
66            nodes: Vec::new(),
67            entry_point: None,
68            max_layer: 0,
69            rng: Xorshift64::new(seed),
70            flat_neighbors: None,
71            arena: RefCell::new(BeamSearchArena::new(initial_capacity)),
72            params,
73            #[cfg(not(target_arch = "wasm32"))]
74            backing: None,
75        }
76    }
77
78    /// The distance metric this index was built with. Search-time metric
79    /// overrides must match this; differing metrics require either rebuilding
80    /// the index or a metric-aware re-rank pass.
81    pub fn metric(&self) -> crate::distance::DistanceMetric {
82        self.params.metric
83    }
84
85    pub fn params(&self) -> &HnswParams {
86        &self.params
87    }
88
89    pub fn dim(&self) -> usize {
90        self.dim
91    }
92
93    /// Storage dtype this index was constructed with.
94    pub fn dtype(&self) -> VectorStorageDtype {
95        self.params.dtype
96    }
97
98    pub fn entry_point(&self) -> Option<u32> {
99        self.entry_point
100    }
101
102    pub fn max_layer(&self) -> usize {
103        self.max_layer
104    }
105
106    /// Current RNG state (for snapshot reproducibility).
107    pub fn rng_state(&self) -> u64 {
108        self.rng.0
109    }
110
111    /// Approximate memory usage in bytes (vector data + neighbor lists).
112    pub fn memory_usage_bytes(&self) -> usize {
113        let vector_bytes = self.nodes.len() * self.params.dtype.bytes_for_dim(self.dim);
114        let neighbor_bytes: usize = self
115            .nodes
116            .iter()
117            .map(|n| {
118                n.neighbors
119                    .iter()
120                    .map(|layer| layer.len() * 4)
121                    .sum::<usize>()
122            })
123            .sum();
124        let node_overhead = self.nodes.len() * std::mem::size_of::<Node>();
125        vector_bytes + neighbor_bytes + node_overhead
126    }
127}
128
129#[cfg(test)]
130mod tests {
131    // Only the backing test uses this, and that test is gated off on wasm32.
132    #[cfg(not(target_arch = "wasm32"))]
133    use std::sync::Arc;
134
135    use super::{HnswIndex, HnswParams};
136    use crate::distance::DistanceMetric;
137    use crate::error::VectorError;
138    use nodedb_types::vector_dtype::VectorStorageDtype;
139
140    fn make_params(dtype: VectorStorageDtype) -> HnswParams {
141        HnswParams {
142            m: 4,
143            m0: 8,
144            ef_construction: 32,
145            metric: DistanceMetric::L2,
146            dtype,
147        }
148    }
149
150    #[test]
151    fn create_empty_index() {
152        let idx = HnswIndex::new(3, HnswParams::default());
153        assert_eq!(idx.len(), 0);
154        assert!(idx.is_empty());
155        assert!(idx.entry_point().is_none());
156    }
157
158    #[test]
159    fn params_default() {
160        let p = HnswParams::default();
161        assert_eq!(p.m, 16);
162        assert_eq!(p.m0, 32);
163        assert_eq!(p.ef_construction, 200);
164        assert_eq!(p.metric, DistanceMetric::Cosine);
165        assert_eq!(p.dtype, VectorStorageDtype::F32);
166    }
167
168    #[test]
169    fn candidate_ordering() {
170        let a = crate::hnsw::graph::types::Candidate { dist: 0.1, id: 1 };
171        let b = crate::hnsw::graph::types::Candidate { dist: 0.5, id: 2 };
172        assert!(a < b);
173    }
174
175    #[test]
176    fn f32_default_unchanged() {
177        let mut idx = HnswIndex::with_seed(3, make_params(VectorStorageDtype::F32), 1);
178        assert_eq!(idx.dtype(), VectorStorageDtype::F32);
179        for i in 0..10u32 {
180            idx.insert(vec![i as f32, 0.0, 0.0]).unwrap();
181        }
182        // get_vector works on F32 indexes.
183        let v = idx.get_vector(3).unwrap();
184        assert_eq!(v[0], 3.0_f32);
185        // get_vector_bytes also works.
186        assert_eq!(idx.get_vector_bytes(3).unwrap().len(), 12); // 3 dims * 4 bytes
187    }
188
189    #[test]
190    fn f16_insert_search_smoke() {
191        let mut idx = HnswIndex::with_seed(3, make_params(VectorStorageDtype::F16), 42);
192        assert_eq!(idx.dtype(), VectorStorageDtype::F16);
193        for i in 0..10u32 {
194            idx.insert(vec![i as f32, 0.0, 0.0]).unwrap();
195        }
196        let results = idx.search(&[5.0, 0.0, 0.0], 3, 32).unwrap();
197        assert_eq!(results.len(), 3);
198        // Results must be in monotonically non-decreasing distance order.
199        for w in results.windows(2) {
200            assert!(
201                w[0].distance <= w[1].distance,
202                "results not sorted: {:?}",
203                results
204            );
205        }
206    }
207
208    #[test]
209    fn bf16_insert_search_smoke() {
210        let mut idx = HnswIndex::with_seed(3, make_params(VectorStorageDtype::BF16), 42);
211        assert_eq!(idx.dtype(), VectorStorageDtype::BF16);
212        for i in 0..10u32 {
213            idx.insert(vec![i as f32, 0.0, 0.0]).unwrap();
214        }
215        let results = idx.search(&[5.0, 0.0, 0.0], 3, 32).unwrap();
216        assert_eq!(results.len(), 3);
217        for w in results.windows(2) {
218            assert!(
219                w[0].distance <= w[1].distance,
220                "results not sorted: {:?}",
221                results
222            );
223        }
224    }
225
226    #[test]
227    fn get_vector_returns_none_on_non_f32_dtype() {
228        let mut idx = HnswIndex::with_seed(3, make_params(VectorStorageDtype::F16), 1);
229        idx.insert(vec![1.0, 2.0, 3.0]).unwrap();
230        // get_vector_bytes works for F16; get_vector does not (returns None in
231        // release, fires debug_assert in dev — so we only assert None in release).
232        assert!(idx.get_vector_bytes(0).is_some());
233        #[cfg(not(debug_assertions))]
234        assert!(idx.get_vector(0).is_none());
235    }
236
237    /// `materialize_vector` is the accessor every vector-copying caller uses, so it
238    /// must work for narrow dtypes where `get_vector` cannot return a borrow.
239    #[test]
240    fn materialize_vector_decodes_every_dtype() {
241        for dtype in [
242            VectorStorageDtype::F32,
243            VectorStorageDtype::F16,
244            VectorStorageDtype::BF16,
245        ] {
246            let mut idx = HnswIndex::with_seed(3, make_params(dtype), 1);
247            idx.insert(vec![1.0, 2.0, 4.0]).unwrap();
248            let v = idx
249                .materialize_vector(0)
250                .unwrap_or_else(|e| panic!("dtype={dtype:?} must materialize, got {e}"));
251            assert_eq!(v.len(), 3, "dtype={dtype:?}");
252            // F16/BF16 are lossy; these values are exactly representable in both.
253            assert_eq!(v, vec![1.0, 2.0, 4.0], "dtype={dtype:?}");
254        }
255    }
256
257    /// Rerank must see the vector for a narrow dtype too. Before the `Cow` return
258    /// this yielded `None` for F16/BF16, which made the FP32 rerank path fail with
259    /// "fetch_vector returned None" for every candidate in the collection.
260    ///
261    /// `get_vector_or_backing` is gated off on wasm32, so the test is too.
262    #[cfg(not(target_arch = "wasm32"))]
263    #[test]
264    fn get_vector_or_backing_serves_narrow_dtypes() {
265        for dtype in [VectorStorageDtype::F16, VectorStorageDtype::BF16] {
266            let mut idx = HnswIndex::with_seed(3, make_params(dtype), 1);
267            idx.insert(vec![1.0, 2.0, 4.0]).unwrap();
268            let v = idx
269                .get_vector_or_backing(0)
270                .unwrap_or_else(|| panic!("dtype={dtype:?} must serve a rerank vector"));
271            assert_eq!(&*v, &[1.0, 2.0, 4.0][..], "dtype={dtype:?}");
272        }
273    }
274
275    /// An index whose per-node storage is empty and which has no backing attached
276    /// cannot produce vectors. Every extractor must report that rather than hand back
277    /// empty vecs — serializing those writes a segment whose header declares vectors
278    /// the payload does not contain, silently destroying the collection.
279    #[test]
280    fn extractors_reject_an_index_with_no_vector_source() {
281        let mut idx = HnswIndex::with_seed(3, make_params(VectorStorageDtype::F32), 1);
282        idx.insert(vec![1.0, 2.0, 3.0]).unwrap();
283        let graph_only = idx.graph_checkpoint_to_bytes().unwrap();
284        // Restored WITHOUT `with_backing`: node storage is an empty placeholder.
285        let restored = HnswIndex::from_checkpoint(&graph_only)
286            .unwrap()
287            .expect("graph checkpoint must be recognized");
288        assert_eq!(restored.len(), 1, "graph topology must survive");
289
290        assert!(
291            matches!(
292                restored.materialize_vector(0),
293                Err(VectorError::VectorUnavailable { id: 0 })
294            ),
295            "materialize_vector must report the missing vector source"
296        );
297        assert!(
298            restored.export_vectors().is_err(),
299            "export_vectors must not yield empty placeholder vectors"
300        );
301        assert!(
302            restored.extract_vectors_and_surrogates().is_err(),
303            "extract_vectors_and_surrogates must not yield empty placeholder vectors"
304        );
305        assert!(
306            restored.checkpoint_to_bytes().is_err(),
307            "a full checkpoint cannot be written from an index with no vector data"
308        );
309    }
310
311    /// A backing is validated before it is attached. An attached-but-unserviceable
312    /// backing is the worst case: the graph looks healthy, so search proceeds and
313    /// then scores a node that has no vector — which is what made one poisoned
314    /// segment panic the daemon on every query.
315    ///
316    /// `segment_backing` is gated off on wasm32, so the test is too.
317    #[cfg(not(target_arch = "wasm32"))]
318    #[test]
319    fn with_backing_refuses_a_backing_that_cannot_serve_the_index() {
320        use crate::segment_backing::VectorSegmentBacking;
321
322        /// Declares `len`/`dim` in its header but serves `served` vectors, modelling
323        /// a segment whose header claims vectors its payload does not contain.
324        struct LyingBacking {
325            len: usize,
326            dim: usize,
327            served: Vec<Vec<f32>>,
328        }
329        impl VectorSegmentBacking for LyingBacking {
330            fn len(&self) -> usize {
331                self.len
332            }
333            fn dim(&self) -> usize {
334                self.dim
335            }
336            fn get_vector(&self, id: u32) -> Option<&[f32]> {
337                self.served.get(id as usize).map(Vec::as_slice)
338            }
339            fn get_surrogate(&self, _id: u32) -> Option<u64> {
340                None
341            }
342        }
343
344        let mut src = HnswIndex::with_seed(3, make_params(VectorStorageDtype::F32), 1);
345        src.insert(vec![1.0, 2.0, 3.0]).unwrap();
346        src.insert(vec![4.0, 5.0, 6.0]).unwrap();
347        let graph_only = src.graph_checkpoint_to_bytes().unwrap();
348        let restore = || {
349            HnswIndex::from_checkpoint(&graph_only)
350                .unwrap()
351                .expect("graph checkpoint must be recognized")
352        };
353
354        // Header claims 2 vectors of dim 3, payload serves none — the poisoned case.
355        let mut idx = restore();
356        assert!(
357            idx.with_backing(Arc::new(LyingBacking {
358                len: 2,
359                dim: 3,
360                served: Vec::new(),
361            }))
362            .is_err(),
363            "a backing that serves no vectors must be refused"
364        );
365
366        // Fewer vectors than the index has nodes.
367        let mut idx = restore();
368        assert!(
369            idx.with_backing(Arc::new(LyingBacking {
370                len: 1,
371                dim: 3,
372                served: vec![vec![1.0, 2.0, 3.0]],
373            }))
374            .is_err(),
375            "a backing shorter than the node count must be refused"
376        );
377
378        // Right count, wrong dimension.
379        let mut idx = restore();
380        assert!(
381            matches!(
382                idx.with_backing(Arc::new(LyingBacking {
383                    len: 2,
384                    dim: 4,
385                    served: vec![vec![0.0; 4], vec![0.0; 4]],
386                })),
387                Err(VectorError::StoredDimensionMismatch { .. })
388            ),
389            "a backing with the wrong dim must be refused"
390        );
391
392        // A backing that genuinely serves every node IS attached, and search on the
393        // restored index then resolves vectors through it without panicking.
394        let mut idx = restore();
395        idx.with_backing(Arc::new(LyingBacking {
396            len: 2,
397            dim: 3,
398            served: vec![vec![1.0, 2.0, 3.0], vec![4.0, 5.0, 6.0]],
399        }))
400        .expect("a complete backing must attach");
401        assert_eq!(idx.materialize_vector(0).unwrap(), vec![1.0, 2.0, 3.0]);
402        assert_eq!(idx.materialize_vector(1).unwrap(), vec![4.0, 5.0, 6.0]);
403    }
404
405    /// A node with no vector source is not a hit. Search must neither abort the
406    /// worker nor return such a node at infinite distance.
407    #[test]
408    fn search_before_with_backing_returns_no_vectorless_hits() {
409        use crate::test_support::{graph_only_restore, seeded_index, seeded_vectors};
410
411        let vectors = seeded_vectors(50, 3, 0x9E37_79B9_7F4A_7C15);
412        let src = seeded_index(&vectors, DistanceMetric::L2, VectorStorageDtype::F32);
413        // No backing attached: every node's storage is an empty placeholder.
414        let idx = graph_only_restore(&src);
415        assert_eq!(idx.len(), 50, "graph topology must survive");
416
417        let filter: roaring::RoaringBitmap = (0..50u32).collect();
418        let unfiltered = idx.search(&vectors[0], 5, 64).unwrap();
419        let filtered = idx.search_filtered(&vectors[0], 5, 64, &filter).unwrap();
420        assert!(unfiltered.is_empty(), "got {unfiltered:?}");
421        assert!(filtered.is_empty(), "got {filtered:?}");
422    }
423
424    #[test]
425    fn get_vector_bytes_works_for_all_dtypes() {
426        for (dtype, expected_byte_len) in [
427            (VectorStorageDtype::F32, 12usize), // 3 dims * 4 bytes
428            (VectorStorageDtype::F16, 6usize),  // 3 dims * 2 bytes
429            (VectorStorageDtype::BF16, 6usize), // 3 dims * 2 bytes
430        ] {
431            let mut idx = HnswIndex::with_seed(3, make_params(dtype), 1);
432            idx.insert(vec![1.0, 2.0, 3.0]).unwrap();
433            let bytes = idx.get_vector_bytes(0).expect("must be Some for valid id");
434            assert_eq!(
435                bytes.len(),
436                expected_byte_len,
437                "wrong byte len for dtype={dtype:?}"
438            );
439        }
440    }
441}