Skip to main content

plugmem_core/index/
hnsw.rs

1//! HNSW graph over the quantized vector pool (
2//! phase 2).
3//!
4//! The port follows the blueprint: the rust-cv skeleton (flat
5//! layers, an external reusable scratch) with the hnswlib algorithmics
6//! (the Algorithm-4 neighbor heuristic with `keep_pruned`, the classic
7//! early-stopped beam search). Four systematic replacements make it fit
8//! this engine:
9//!
10//! - **levels are a pure function of the fact id** — `level_of` counts
11//!   precomputed integer thresholds below `xxh3(fact_id)`, so there is
12//!   no PRNG state and journal replay reproduces the graph bit for bit;
13//! - **no hash sets** — the visited set is an epoch-stamped `u32` array
14//!   in the scratch (reset is one increment);
15//! - **distances are quantized cosines** read straight from the
16//!   [`VecPool`] slots (higher = closer; every comparison breaks ties by
17//!   slot id, so the whole build is deterministic);
18//! - **storage is flat**: level 0 is one `Vec<u32>` of `m0`-wide
19//!   neighbor blocks indexed by vector-slot id; upper levels live as
20//!   [`ChunkPool`] lists behind an [`Arena`] of `[slot | level]` keyed
21//!   handles. Both dump/load with the same canonical-image contract as
22//!   every other structure.
23//!
24//! Graph nodes are **vector-slot indices** (`u32`). Only slots below
25//! `indexed` are in the graph; slots appended after the last build form
26//! the *flat tail* the engine scans separately and merges (:
27//! inserts amortize into `maintain`, `remember` stays microseconds).
28//!
29//! The beam search does not consult admission filters while walking —
30//! connectivity must not depend on the query — the engine filters the
31//! returned candidates.
32
33use alloc::vec::Vec;
34
35#[cfg(feature = "counters")]
36use core::cell::Cell;
37
38use plugmem_arena::{Arena, ArenaCfg, ChunkPool, ChunkPoolCfg, ListHandle, ShardMode, Slot, key};
39use xxhash_rust::xxh3::xxh3_64;
40
41use crate::error::Error;
42use crate::id::NONE_U32;
43use crate::index::vecpool::{VecPool, dot_i8};
44
45/// Deepest level tracked. With `m = 16` the probability of level 8 is
46/// `16^-8 ≈ 2^-32` — unreachable at the capacity passport; 16 is sheer
47/// paranoia at 4 bytes a threshold.
48const MAX_LEVEL: usize = 16;
49
50/// Shard count of the (small) upper-level handle arena.
51const UPPER_SHARDS: usize = 64;
52
53/// Serialized width of one level-0 neighbor id (a little-endian `u32`).
54const NEIGHBOR_BYTES: usize = core::mem::size_of::<u32>();
55
56/// Serialized size of the graph meta section: `[entry u32][indexed u32]`.
57const META_BYTES: usize = 2 * core::mem::size_of::<u32>();
58
59/// Upper-level adjacency handle: key `[slot BE | level BE]`, payload the
60/// neighbor list's chunk handle. 20-byte slot.
61#[derive(Clone, Copy, Debug, PartialEq, Eq)]
62struct UpperSlot {
63    /// Graph node (vector-slot id).
64    slot: u32,
65    /// Level (1-based; level 0 lives in the flat blocks).
66    level: u32,
67    /// The node's neighbor list at that level.
68    handle: ListHandle,
69}
70
71impl Slot for UpperSlot {
72    const SIZE: usize = 20;
73    const KEY_LEN: usize = 8;
74
75    fn write(&self, out: &mut [u8]) {
76        key::write_u32(out, self.slot);
77        key::write_u32(&mut out[4..], self.level);
78        out[8..20].copy_from_slice(&self.handle.to_bytes());
79    }
80
81    fn read(bytes: &[u8]) -> Self {
82        Self {
83            slot: key::read_u32(bytes),
84            level: key::read_u32(&bytes[4..]),
85            handle: ListHandle::from_bytes(bytes[8..20].try_into().unwrap()),
86        }
87    }
88}
89
90/// Reusable search/build scratch (zero allocations after warm-up).
91#[derive(Debug, Default)]
92pub struct HnswScratch {
93    /// Epoch-stamped visited marks per slot.
94    visited: Vec<u32>,
95    /// Current epoch; bumping it clears `visited` in O(1).
96    epoch: u32,
97    /// Candidate beam, ascending by `(sim, slot)` order — the maximum is
98    /// popped from the tail.
99    cand: Vec<(f32, u32)>,
100    /// The `ef` best found so far, ascending — the worst sits at 0.
101    found: Vec<(f32, u32)>,
102    /// Neighbor staging buffer.
103    nbrs: Vec<u32>,
104    /// Heuristic selection output.
105    sel: Vec<u32>,
106    /// Heuristic rejects (recycled by `keep_pruned`).
107    pruned: Vec<u32>,
108    /// Per-candidate sims staged for a relink prune.
109    relink: Vec<(f32, u32)>,
110}
111
112/// The deterministic candidate order: higher cosine wins, ties go to the
113/// smaller slot id.
114#[inline]
115fn better(a: (f32, u32), b: (f32, u32)) -> core::cmp::Ordering {
116    a.0.total_cmp(&b.0).then(b.1.cmp(&a.1))
117}
118
119/// The navigable small-world graph. See the module docs.
120///
121/// The upper-level pools carry a lifetime so a read-only open can borrow
122/// them from an mmap; `level0` stays owned (it is rebuilt into
123/// a `Vec<u32>` on load — small metadata, not content-scaled). The owned
124/// build/load paths are `'static`.
125pub struct HnswGraph<'a> {
126    /// Upper-level degree cap.
127    m: usize,
128    /// Level-0 degree cap.
129    m0: usize,
130    /// `indexed × m0` neighbor slots, `NONE_U32`-padded.
131    level0: Vec<u32>,
132    /// Upper-level handles.
133    upper: Arena<'a, UpperSlot>,
134    /// Upper-level neighbor lists (4-byte LE slot ids).
135    lists: ChunkPool<'a>,
136    /// Entry point (highest-level node), or `NONE_U32` when empty.
137    entry: u32,
138    /// Slots `[0, indexed)` are in the graph; the rest are the flat tail.
139    indexed: u32,
140    /// `thresholds[k]` = `2^64 / m^(k+1)`: a fact whose hash falls below
141    /// it reaches level `k + 1`.
142    thresholds: [u64; MAX_LEVEL],
143    /// Exact distance evaluations (feature `counters`) — the
144    /// deterministic cost metric of graph search.
145    #[cfg(feature = "counters")]
146    dist_evals: Cell<u64>,
147}
148
149impl<'a> HnswGraph<'a> {
150    /// An empty graph for the given degree caps (`m >= 2`, `m0 >= m` —
151    /// enforced by `Config::validate`).
152    pub fn new(m: usize, m0: usize, max_bytes: usize) -> Result<Self, Error> {
153        let mut thresholds = [0u64; MAX_LEVEL];
154        let mut t = u64::MAX;
155        for slot in &mut thresholds {
156            t /= m as u64;
157            *slot = t;
158        }
159        Ok(Self {
160            m,
161            m0,
162            level0: Vec::new(),
163            upper: Arena::new(
164                ArenaCfg::new(UPPER_SHARDS, ShardMode::Uniform).with_max_bytes(max_bytes),
165            )?,
166            lists: ChunkPool::new(ChunkPoolCfg::new().with_max_bytes(max_bytes)),
167            entry: NONE_U32,
168            indexed: 0,
169            thresholds,
170            #[cfg(feature = "counters")]
171            dist_evals: Cell::new(0),
172        })
173    }
174
175    /// Number of slots covered by the graph (the flat tail starts here).
176    pub fn indexed(&self) -> u32 {
177        self.indexed
178    }
179
180    /// The level of a fact: a pure function of its id, so replay and
181    /// rebuilds reproduce the same graph geometry.
182    fn level_of(&self, fact: u32) -> usize {
183        let h = xxh3_64(&fact.to_le_bytes());
184        self.thresholds.iter().take_while(|&&t| h < t).count()
185    }
186
187    /// Quantized cosine between an external query and a slot.
188    #[inline]
189    fn sim_q(&self, pool: &VecPool<'_>, q: (f32, &[u8]), slot: u32) -> f32 {
190        #[cfg(feature = "counters")]
191        self.dist_evals.set(self.dist_evals.get() + 1);
192        let (s, qb) = pool.quant(slot as usize);
193        q.0 * s * dot_i8(q.1, qb) as f32
194    }
195
196    /// The level-0 neighbor block of a node.
197    #[inline]
198    fn block(&self, slot: u32) -> &[u32] {
199        let at = slot as usize * self.m0;
200        &self.level0[at..at + self.m0]
201    }
202
203    /// Copies the neighbors of `slot` at `level` into `out`.
204    fn neighbors_into(&self, slot: u32, level: usize, out: &mut Vec<u32>) {
205        out.clear();
206        if level == 0 {
207            out.extend(
208                self.block(slot)
209                    .iter()
210                    .copied()
211                    .take_while(|&n| n != NONE_U32),
212            );
213            return;
214        }
215        let mut kb = [0u8; 8];
216        key::write_u32(&mut kb, slot);
217        key::write_u32(&mut kb[4..], level as u32);
218        let Some(entry) = self.upper.get(&kb) else {
219            return;
220        };
221        for chunk in self.lists.iter(&entry.handle) {
222            for raw in chunk.chunks_exact(4) {
223                out.push(u32::from_le_bytes(raw.try_into().unwrap()));
224            }
225        }
226    }
227
228    /// Marks `slot` visited; `true` if it already was.
229    #[inline]
230    fn visit(scratch: &mut HnswScratch, slot: u32) -> bool {
231        let at = slot as usize;
232        if scratch.visited[at] == scratch.epoch {
233            return true;
234        }
235        scratch.visited[at] = scratch.epoch;
236        false
237    }
238
239    /// Classic beam search inside one level (Alg. 2 with the
240    /// early stop). Results land in `scratch.found`, ascending by the
241    /// deterministic order — best last.
242    fn search_layer(
243        &self,
244        pool: &VecPool<'_>,
245        q: (f32, &[u8]),
246        level: usize,
247        ep: u32,
248        ef: usize,
249        scratch: &mut HnswScratch,
250    ) {
251        scratch.epoch = scratch.epoch.wrapping_add(1);
252        if scratch.epoch == 0 {
253            // The epoch wrapped: stamp everything stale explicitly.
254            scratch.visited.fill(u32::MAX);
255            scratch.epoch = 1;
256        }
257        scratch
258            .visited
259            .resize(self.indexed as usize, scratch.epoch.wrapping_sub(1));
260        scratch.cand.clear();
261        scratch.found.clear();
262
263        Self::visit(scratch, ep);
264        let s = self.sim_q(pool, q, ep);
265        scratch.cand.push((s, ep));
266        scratch.found.push((s, ep));
267
268        while let Some(best) = scratch.cand.pop() {
269            // Early stop: the best remaining candidate cannot improve the
270            // worst of the ef found.
271            if scratch.found.len() >= ef && better(best, scratch.found[0]).is_lt() {
272                break;
273            }
274            let nbrs = core::mem::take(&mut scratch.nbrs);
275            let mut nbrs = nbrs;
276            self.neighbors_into(best.1, level, &mut nbrs);
277            for &nb in &nbrs {
278                if Self::visit(scratch, nb) {
279                    continue;
280                }
281                let s = self.sim_q(pool, q, nb);
282                let entry = (s, nb);
283                if scratch.found.len() < ef || better(entry, scratch.found[0]).is_gt() {
284                    let at = scratch.found.partition_point(|&e| better(e, entry).is_lt());
285                    scratch.found.insert(at, entry);
286                    if scratch.found.len() > ef {
287                        scratch.found.remove(0);
288                    }
289                    let at = scratch.cand.partition_point(|&e| better(e, entry).is_lt());
290                    scratch.cand.insert(at, entry);
291                }
292            }
293            scratch.nbrs = nbrs;
294        }
295    }
296
297    /// The Algorithm-4 neighbor heuristic with `keep_pruned`:
298    /// walks `scratch.found` best-first, keeps a candidate only if it is
299    /// closer to the query than to any already-kept neighbor (spread over
300    /// clusters), then tops up from the rejects. Result in `scratch.sel`.
301    fn select_neighbors(&self, pool: &VecPool<'_>, cap: usize, scratch: &mut HnswScratch) {
302        scratch.sel.clear();
303        scratch.pruned.clear();
304        for i in (0..scratch.found.len()).rev() {
305            let (sim, cand) = scratch.found[i];
306            if scratch.sel.len() >= cap {
307                break;
308            }
309            let dominated = scratch.sel.iter().any(|&kept| {
310                #[cfg(feature = "counters")]
311                self.dist_evals.set(self.dist_evals.get() + 1);
312                pool.sim(cand, kept) > sim
313            });
314            if dominated {
315                scratch.pruned.push(cand);
316            } else {
317                scratch.sel.push(cand);
318            }
319        }
320        for &p in scratch.pruned.iter() {
321            if scratch.sel.len() >= cap {
322                break;
323            }
324            scratch.sel.push(p);
325        }
326    }
327
328    /// Overwrites the neighbor list of `slot` at `level` with
329    /// `scratch.sel`.
330    fn write_list(&mut self, slot: u32, level: usize, sel: &[u32]) -> Result<(), Error> {
331        if level == 0 {
332            let at = slot as usize * self.m0;
333            let block = &mut self.level0[at..at + self.m0];
334            block.fill(NONE_U32);
335            block[..sel.len()].copy_from_slice(sel);
336            return Ok(());
337        }
338        let mut kb = [0u8; 8];
339        key::write_u32(&mut kb, slot);
340        key::write_u32(&mut kb[4..], level as u32);
341        let mut handle = match self.upper.get(&kb) {
342            Some(entry) => {
343                let mut h = entry.handle;
344                self.lists.free(&mut h);
345                h
346            }
347            None => ListHandle::EMPTY,
348        };
349        for &n in sel {
350            self.lists.push(&mut handle, &n.to_le_bytes())?;
351        }
352        let updated = UpperSlot {
353            slot,
354            level: level as u32,
355            handle,
356        };
357        if self.upper.contains(&kb) {
358            let payload = self.upper.payload_mut(&kb).expect("checked above");
359            let mut full = [0u8; UpperSlot::SIZE];
360            updated.write(&mut full);
361            payload.copy_from_slice(&full[UpperSlot::KEY_LEN..]);
362        } else {
363            self.upper.insert(&updated)?;
364        }
365        Ok(())
366    }
367
368    /// Adds `new` to `v`'s list at `level`, pruning with the heuristic
369    /// when the degree cap overflows (the hnswlib backlink rule).
370    fn add_link(
371        &mut self,
372        pool: &VecPool<'_>,
373        v: u32,
374        new: u32,
375        level: usize,
376        scratch: &mut HnswScratch,
377    ) -> Result<(), Error> {
378        let cap = if level == 0 { self.m0 } else { self.m };
379        let mut nbrs = core::mem::take(&mut scratch.nbrs);
380        self.neighbors_into(v, level, &mut nbrs);
381        if nbrs.len() < cap {
382            nbrs.push(new);
383            let sel = core::mem::take(&mut scratch.sel);
384            let mut sel = sel;
385            sel.clear();
386            sel.extend_from_slice(&nbrs);
387            let res = self.write_list(v, level, &sel);
388            scratch.sel = sel;
389            scratch.nbrs = nbrs;
390            return res;
391        }
392        // Overflow: re-select the cap best around `v` from old + new.
393        let vq = pool.quant(v as usize);
394        scratch.relink.clear();
395        for &n in nbrs.iter().chain(core::iter::once(&new)) {
396            let s = self.sim_q(pool, (vq.0, vq.1), n);
397            scratch.relink.push((s, n));
398        }
399        scratch.nbrs = nbrs;
400        scratch.relink.sort_unstable_by(|a, b| better(*a, *b));
401        scratch.found.clear();
402        scratch.found.extend_from_slice(&scratch.relink);
403        self.select_neighbors(pool, cap, scratch);
404        let sel = core::mem::take(&mut scratch.sel);
405        let res = self.write_list(v, level, &sel);
406        scratch.sel = sel;
407        res
408    }
409
410    /// Inserts every slot in `[indexed, upto)` into the graph (
411    /// Alg. 1). Called from `maintain` — bulk, deterministic, never from
412    /// `remember`.
413    pub fn insert_bulk(
414        &mut self,
415        pool: &VecPool<'_>,
416        upto: u32,
417        ef_construction: usize,
418        scratch: &mut HnswScratch,
419    ) -> Result<(), Error> {
420        debug_assert!(upto as usize <= pool.len());
421        self.level0.resize(upto as usize * self.m0, NONE_U32);
422        for slot in self.indexed..upto {
423            self.insert_one(pool, slot, ef_construction, scratch)?;
424            // Advance per slot: a capacity failure leaves a graph that is
425            // still consistent for the slots it admits.
426            self.indexed = slot + 1;
427        }
428        Ok(())
429    }
430
431    fn insert_one(
432        &mut self,
433        pool: &VecPool<'_>,
434        slot: u32,
435        ef_construction: usize,
436        scratch: &mut HnswScratch,
437    ) -> Result<(), Error> {
438        let level = self.level_of(pool.slot_fact(slot as usize));
439        if self.entry == NONE_U32 {
440            self.entry = slot;
441            return Ok(());
442        }
443        let q = pool.quant(slot as usize);
444        let q = (q.0, q.1);
445        let top = self.level_of(pool.slot_fact(self.entry as usize));
446        let mut ep = self.entry;
447        // Greedy descent through the levels above the new node's.
448        let mut lev = top;
449        while lev > level {
450            self.search_layer(pool, q, lev, ep, 1, scratch);
451            ep = scratch.found.last().expect("entry is always found").1;
452            lev -= 1;
453        }
454        // Beam-search and link downward from min(level, top) to 0.
455        let mut lev = level.min(top);
456        loop {
457            self.search_layer(pool, q, lev, ep, ef_construction, scratch);
458            ep = scratch.found.last().expect("entry is always found").1;
459            let cap = if lev == 0 { self.m0 } else { self.m };
460            self.select_neighbors(pool, cap, scratch);
461            let sel = core::mem::take(&mut scratch.sel);
462            self.write_list(slot, lev, &sel)?;
463            for &nb in &sel {
464                self.add_link(pool, nb, slot, lev, scratch)?;
465            }
466            scratch.sel = sel;
467            if lev == 0 {
468                break;
469            }
470            lev -= 1;
471        }
472        if level > top {
473            self.entry = slot;
474        }
475        Ok(())
476    }
477
478    /// k-NN query over a raw embedding: quantizes it through the pool
479    /// (into `vec_scratch`) and runs the level-descending beam search.
480    /// Results land in `out` as `(slot, cosine)`, best first — the
481    /// caller filters and merges with the flat tail.
482    ///
483    /// # Errors
484    ///
485    /// The pool's quantization errors ([`crate::Error::DimMismatch`],
486    /// [`crate::Error::Invalid`] for non-finite or zero vectors).
487    pub fn search(
488        &self,
489        pool: &VecPool<'_>,
490        query: &[f32],
491        ef: usize,
492        vec_scratch: &mut crate::index::vecpool::VecScratch,
493        scratch: &mut HnswScratch,
494        out: &mut Vec<(u32, f32)>,
495    ) -> Result<(), Error> {
496        pool.quantize_query(query, vec_scratch)?;
497        let q = pool.quantized(vec_scratch);
498        self.search_quantized(pool, q, ef, scratch, out);
499        Ok(())
500    }
501
502    /// k-NN query (Alg. 5): greedy descent to level 1, one beam
503    /// search on level 0 with `ef`, results appended to `out` as
504    /// `(slot, cosine)`, best first.
505    pub(crate) fn search_quantized(
506        &self,
507        pool: &VecPool<'_>,
508        q: (f32, &[u8]),
509        ef: usize,
510        scratch: &mut HnswScratch,
511        out: &mut Vec<(u32, f32)>,
512    ) {
513        out.clear();
514        if self.entry == NONE_U32 {
515            return;
516        }
517        let mut ep = self.entry;
518        let top = self.level_of(pool.slot_fact(self.entry as usize));
519        for lev in (1..=top).rev() {
520            self.search_layer(pool, q, lev, ep, 1, scratch);
521            ep = scratch.found.last().expect("entry is always found").1;
522        }
523        self.search_layer(pool, q, 0, ep, ef.max(1), scratch);
524        for &(sim, slot) in scratch.found.iter().rev() {
525            out.push((slot, sim));
526        }
527    }
528
529    /// Bytes held by the graph's pools.
530    pub(crate) fn pool_bytes(&self) -> usize {
531        self.level0.len() * NEIGHBOR_BYTES + self.upper.pool_bytes() + self.lists.pool_bytes()
532    }
533
534    /// Carries the graph across a `maintain` compaction: `map[old_slot]`
535    /// is the new slot id or [`NONE_U32`] for a purged vector. Neighbor
536    /// lists are remapped in place-order (dead neighbors drop out), the
537    /// entry follows the map or is re-picked deterministically (highest
538    /// level, smallest slot). The caller then bulk-inserts the tail.
539    pub(crate) fn remapped(
540        &self,
541        map: &[u32],
542        new_pool: &VecPool<'_>,
543        max_bytes: usize,
544    ) -> Result<HnswGraph<'static>, Error> {
545        // The remapped graph is freshly built (owned), so it is `'static`
546        // and swaps into a `Memory<'a>` field by covariance.
547        let mut g: HnswGraph<'static> = HnswGraph::new(self.m, self.m0, max_bytes)?;
548        let old_indexed = self.indexed as usize;
549        let new_indexed = map[..old_indexed]
550            .iter()
551            .filter(|&&m| m != NONE_U32)
552            .count() as u32;
553        g.level0 = alloc::vec![NONE_U32; new_indexed as usize * self.m0];
554        g.indexed = new_indexed;
555        let mut nbrs = Vec::new();
556        let mut sel = Vec::new();
557        for old in 0..old_indexed as u32 {
558            let new = map[old as usize];
559            if new == NONE_U32 {
560                continue;
561            }
562            self.neighbors_into(old, 0, &mut nbrs);
563            sel.clear();
564            sel.extend(
565                nbrs.iter()
566                    .map(|&n| map[n as usize])
567                    .filter(|&n| n != NONE_U32),
568            );
569            g.write_list(new, 0, &sel)?;
570            let levels = g.level_of(new_pool.slot_fact(new as usize));
571            for level in 1..=levels {
572                self.neighbors_into(old, level, &mut nbrs);
573                if nbrs.is_empty() {
574                    continue;
575                }
576                sel.clear();
577                sel.extend(
578                    nbrs.iter()
579                        .map(|&n| map[n as usize])
580                        .filter(|&n| n != NONE_U32),
581                );
582                g.write_list(new, level, &sel)?;
583            }
584        }
585        g.entry = if self.entry != NONE_U32 && map[self.entry as usize] != NONE_U32 {
586            map[self.entry as usize]
587        } else {
588            // The old entry died: the highest-level survivor takes over
589            // (ties to the smallest slot — scan order settles it).
590            let mut best = NONE_U32;
591            let mut best_level = 0usize;
592            for slot in 0..new_indexed {
593                let level = g.level_of(new_pool.slot_fact(slot as usize));
594                if best == NONE_U32 || level > best_level {
595                    best = slot;
596                    best_level = level;
597                }
598            }
599            best
600        };
601        Ok(g)
602    }
603
604    /// The graph-header section: `[entry u32 LE][indexed u32 LE]`.
605    pub(crate) fn dump_meta(&self) -> Vec<u8> {
606        let mut out = Vec::with_capacity(META_BYTES);
607        out.extend_from_slice(&self.entry.to_le_bytes());
608        out.extend_from_slice(&self.indexed.to_le_bytes());
609        out
610    }
611
612    /// The level-0 section: the neighbor blocks as `u32 LE` values.
613    pub(crate) fn dump_level0(&self) -> Vec<u8> {
614        let mut out = Vec::with_capacity(self.level0.len() * NEIGHBOR_BYTES);
615        for &n in &self.level0 {
616            out.extend_from_slice(&n.to_le_bytes());
617        }
618        out
619    }
620
621    /// The four upper-level sections (handle arena + list pool).
622    pub(crate) fn dump_upper(&self) -> [Vec<u8>; 4] {
623        let (mut am, mut ap) = (Vec::new(), Vec::new());
624        self.upper.dump_meta(&mut am);
625        self.upper.dump_pool(&mut ap);
626        let (mut cm, mut cp) = (Vec::new(), Vec::new());
627        self.lists.dump_meta(&mut cm);
628        self.lists.dump_pool(&mut cp);
629        [am, ap, cm, cp]
630    }
631
632    /// Rebuilds a graph from its six dumped sections. Structural framing
633    /// only — the reference validation that needs the vector pool happens
634    /// in [`HnswGraph::validate`].
635    #[allow(clippy::too_many_arguments)]
636    pub(crate) fn from_parts(
637        m: usize,
638        m0: usize,
639        max_bytes: usize,
640        meta: &[u8],
641        level0: &[u8],
642        upper_meta: &[u8],
643        upper_pool: &[u8],
644        lists_meta: &[u8],
645        lists_pool: &[u8],
646    ) -> Result<Self, Error> {
647        let mut g = Self::new(m, m0, max_bytes)?;
648        if meta.len() != META_BYTES {
649            return Err(Error::Corrupt("hnsw meta section has a wrong length"));
650        }
651        g.entry = u32::from_le_bytes(meta[0..4].try_into().unwrap());
652        g.indexed = u32::from_le_bytes(meta[4..8].try_into().unwrap());
653        if level0.len() as u64 != u64::from(g.indexed) * m0 as u64 * NEIGHBOR_BYTES as u64 {
654            return Err(Error::Corrupt("hnsw level0 length mismatch"));
655        }
656        g.level0 = level0
657            .chunks_exact(NEIGHBOR_BYTES)
658            .map(|b| u32::from_le_bytes(b.try_into().unwrap()))
659            .collect();
660        g.upper = Arena::load(
661            ArenaCfg::new(UPPER_SHARDS, ShardMode::Uniform).with_max_bytes(max_bytes),
662            upper_meta,
663            upper_pool,
664        )?;
665        g.lists = ChunkPool::load(
666            ChunkPoolCfg::new().with_max_bytes(max_bytes),
667            lists_meta,
668            lists_pool,
669        )?;
670        Ok(g)
671    }
672
673    /// Zero-copy sibling of [`HnswGraph::from_parts`]: the upper-level
674    /// arena pool and the list pool borrow their mmap'd sections instead
675    /// of copying. `level0` and the small arena/chunk metadata
676    /// are still rebuilt owned. Same framing checks as `from_parts`; the
677    /// lifetime ties the graph to `upper_pool`/`lists_pool`.
678    #[allow(clippy::too_many_arguments)]
679    pub(crate) fn from_parts_borrowed(
680        m: usize,
681        m0: usize,
682        max_bytes: usize,
683        meta: &[u8],
684        level0: &[u8],
685        upper_meta: &[u8],
686        upper_pool: &'a [u8],
687        lists_meta: &[u8],
688        lists_pool: &'a [u8],
689    ) -> Result<Self, Error> {
690        let mut g = Self::new(m, m0, max_bytes)?;
691        if meta.len() != META_BYTES {
692            return Err(Error::Corrupt("hnsw meta section has a wrong length"));
693        }
694        g.entry = u32::from_le_bytes(meta[0..4].try_into().unwrap());
695        g.indexed = u32::from_le_bytes(meta[4..8].try_into().unwrap());
696        if level0.len() as u64 != u64::from(g.indexed) * m0 as u64 * NEIGHBOR_BYTES as u64 {
697            return Err(Error::Corrupt("hnsw level0 length mismatch"));
698        }
699        g.level0 = level0
700            .chunks_exact(NEIGHBOR_BYTES)
701            .map(|b| u32::from_le_bytes(b.try_into().unwrap()))
702            .collect();
703        g.upper = Arena::load_borrowed(
704            ArenaCfg::new(UPPER_SHARDS, ShardMode::Uniform).with_max_bytes(max_bytes),
705            upper_meta,
706            upper_pool,
707        )?;
708        g.lists = ChunkPool::load_borrowed(
709            ChunkPoolCfg::new().with_max_bytes(max_bytes),
710            lists_meta,
711            lists_pool,
712        )?;
713        Ok(g)
714    }
715
716    /// Full reference validation against the pool (the load-time
717    /// panic-free contract, O(edges)): the entry and every neighbor in
718    /// range, no self-links, canonical `NONE` padding of level-0 blocks,
719    /// upper handles keyed inside the graph with levels their fact's hash
720    /// admits, list chains exclusive and cycle-free, no orphan chunks,
721    /// degrees within the caps.
722    pub(crate) fn validate(&self, pool: &VecPool<'_>) -> Result<(), Error> {
723        if self.indexed as usize > pool.len() {
724            return Err(Error::Corrupt("hnsw indexes more slots than the pool"));
725        }
726        if self.level0.len() != self.indexed as usize * self.m0 {
727            return Err(Error::Corrupt("hnsw level0 disagrees with indexed"));
728        }
729        if self.indexed == 0 {
730            if self.entry != NONE_U32 || !self.upper.is_empty() || self.lists.chunks() != 0 {
731                return Err(Error::Corrupt("hnsw empty graph carries state"));
732            }
733            return Ok(());
734        }
735        if self.entry >= self.indexed {
736            return Err(Error::Corrupt("hnsw entry out of range"));
737        }
738        for slot in 0..self.indexed {
739            let block = self.block(slot);
740            let mut ended = false;
741            for &n in block {
742                if n == NONE_U32 {
743                    ended = true;
744                    continue;
745                }
746                if ended {
747                    return Err(Error::Corrupt("hnsw level0 padding is not canonical"));
748                }
749                if n >= self.indexed || n == slot {
750                    return Err(Error::Corrupt("hnsw level0 neighbor out of range"));
751                }
752            }
753        }
754        let mut visited = alloc::vec![false; self.lists.chunks()];
755        for entry in self.upper.iter() {
756            if entry.slot >= self.indexed {
757                return Err(Error::Corrupt("hnsw upper handle out of range"));
758            }
759            let max_level = self.level_of(pool.slot_fact(entry.slot as usize));
760            if entry.level == 0 || entry.level as usize > max_level {
761                return Err(Error::Corrupt("hnsw upper level disagrees with the hash"));
762            }
763            self.lists.validate_chain(&entry.handle, &mut visited)?;
764            let mut count = 0u32;
765            for chunk in self.lists.iter(&entry.handle) {
766                if !chunk.len().is_multiple_of(4) {
767                    return Err(Error::Corrupt("hnsw upper list is not a slot sequence"));
768                }
769                for raw in chunk.chunks_exact(4) {
770                    let n = u32::from_le_bytes(raw.try_into().unwrap());
771                    if n >= self.indexed || n == entry.slot {
772                        return Err(Error::Corrupt("hnsw upper neighbor out of range"));
773                    }
774                    count += 1;
775                }
776            }
777            if count != entry.handle.len() || count as usize > self.m {
778                return Err(Error::Corrupt("hnsw upper list disagrees with its handle"));
779            }
780        }
781        if self.lists.orphan_count(&visited) != 0 {
782            return Err(Error::Corrupt("hnsw list pool has orphan chunks"));
783        }
784        Ok(())
785    }
786
787    /// Exact distance evaluations so far (feature `counters`).
788    #[cfg(feature = "counters")]
789    pub fn dist_evals(&self) -> u64 {
790        self.dist_evals.get()
791    }
792
793    /// Resets the distance counter (feature `counters`).
794    #[cfg(feature = "counters")]
795    pub fn reset_dist_evals(&self) {
796        self.dist_evals.set(0);
797    }
798}
799
800#[cfg(test)]
801mod tests {
802    use super::*;
803    use crate::id::FactId;
804    use alloc::vec;
805
806    /// Deterministic LCG for cluster corpora (the vecpool tests' twin).
807    struct Lcg(u64);
808    impl Lcg {
809        fn next(&mut self) -> f32 {
810            self.0 = self
811                .0
812                .wrapping_mul(6_364_136_223_846_793_005)
813                .wrapping_add(1_442_695_040_888_963_407);
814            ((self.0 >> 40) as f32 / (1u64 << 24) as f32) * 2.0 - 1.0
815        }
816    }
817
818    /// A pool of `n` vectors in `clusters` clusters on the sphere.
819    fn cluster_pool(n: usize, dim: usize, clusters: usize, seed: u64) -> VecPool<'static> {
820        let mut rng = Lcg(seed);
821        let centers: Vec<Vec<f32>> = (0..clusters)
822            .map(|_| (0..dim).map(|_| rng.next()).collect())
823            .collect();
824        let mut pool = VecPool::new(dim, usize::MAX);
825        for i in 0..n {
826            let c = &centers[i % clusters];
827            let v: Vec<f32> = c.iter().map(|&x| x + rng.next() * 0.3).collect();
828            pool.push(FactId(i as u32), &v).unwrap();
829        }
830        pool
831    }
832
833    /// Builds a graph over the whole pool.
834    fn build(pool: &VecPool<'_>, m: usize, m0: usize) -> HnswGraph<'static> {
835        let mut g = HnswGraph::new(m, m0, usize::MAX).unwrap();
836        let mut scratch = HnswScratch::default();
837        g.insert_bulk(pool, pool.len() as u32, 200, &mut scratch)
838            .unwrap();
839        g
840    }
841
842    /// Brute-force top-k by exact quantized cosine.
843    fn brute_force(pool: &VecPool<'_>, q: u32, k: usize) -> Vec<u32> {
844        let mut all: Vec<(f32, u32)> = (0..pool.len() as u32)
845            .map(|i| (pool.sim(q, i), i))
846            .collect();
847        all.sort_unstable_by(|a, b| better(*b, *a));
848        all.into_iter().take(k).map(|(_, s)| s).collect()
849    }
850
851    /// Levels follow the geometric distribution: ~1/m of the nodes reach
852    /// level 1, ~1/m² level 2, and the counts are exact across runs.
853    #[test]
854    #[cfg_attr(miri, ignore)] // data-heavy; the small graphs cover the logic
855    fn levels_are_geometric_and_pure() {
856        let g = HnswGraph::new(16, 32, usize::MAX).unwrap();
857        let n = 100_000u32;
858        let mut per_level = [0usize; 4];
859        for fact in 0..n {
860            let l = g.level_of(fact).min(3);
861            per_level[l] += 1;
862        }
863        // Expected ~6250 at ≥1 for m=16; allow a generous band.
864        let at_least_1: usize = per_level[1..].iter().sum();
865        assert!(
866            (4_000..9_000).contains(&at_least_1),
867            "level>=1 count {at_least_1} is out of band"
868        );
869        let at_least_2: usize = per_level[2..].iter().sum();
870        assert!(
871            (150..800).contains(&at_least_2),
872            "level>=2 count {at_least_2} is out of band"
873        );
874        // Purity: the same fact maps to the same level, always.
875        assert_eq!(g.level_of(42), g.level_of(42));
876    }
877
878    /// Graph search agrees with brute force: recall@10 >= 0.9 at ef 64
879    /// (the verification gate).
880    #[test]
881    #[cfg_attr(miri, ignore)] // data-heavy; the small graphs cover the logic
882    fn recall_against_brute_force() {
883        let dim = 32;
884        let pool = cluster_pool(2_000, dim, 64, 0xA11CE);
885        let g = build(&pool, 16, 32);
886        let mut scratch = HnswScratch::default();
887        let mut out = Vec::new();
888        let mut hits = 0usize;
889        let mut total = 0usize;
890        for q in (0..2_000u32).step_by(97) {
891            let truth = brute_force(&pool, q, 10);
892            let (scale, qb) = pool.quant(q as usize);
893            g.search_quantized(&pool, (scale, qb), 64, &mut scratch, &mut out);
894            let got: Vec<u32> = out.iter().take(10).map(|&(s, _)| s).collect();
895            hits += truth.iter().filter(|t| got.contains(t)).count();
896            total += truth.len();
897        }
898        let recall = hits as f64 / total as f64;
899        assert!(recall >= 0.9, "recall@10 {recall} below the 0.9 gate");
900    }
901
902    /// Two builds over the same pool are byte-identical (determinism is
903    /// what makes maintain-replay reproduce the graph).
904    #[test]
905    #[cfg_attr(miri, ignore)] // data-heavy; the small graphs cover the logic
906    fn build_is_deterministic() {
907        let pool = cluster_pool(600, 24, 16, 7);
908        let a = build(&pool, 8, 16);
909        let b = build(&pool, 8, 16);
910        assert_eq!(a.level0, b.level0);
911        assert_eq!(a.entry, b.entry);
912        assert_eq!(a.indexed, b.indexed);
913        let (mut am, mut bm) = (Vec::new(), Vec::new());
914        a.upper.dump_meta(&mut am);
915        b.upper.dump_meta(&mut bm);
916        assert_eq!(am, bm);
917        let (mut ap, mut bp) = (Vec::new(), Vec::new());
918        a.lists.dump_pool(&mut ap);
919        b.lists.dump_pool(&mut bp);
920        assert_eq!(ap, bp);
921    }
922
923    /// Degree caps hold everywhere.
924    #[test]
925    #[cfg_attr(miri, ignore)] // data-heavy; the small graphs cover the logic
926    fn degree_caps_hold() {
927        let pool = cluster_pool(800, 16, 8, 3);
928        let g = build(&pool, 6, 12);
929        let mut nbrs = Vec::new();
930        for slot in 0..g.indexed() {
931            g.neighbors_into(slot, 0, &mut nbrs);
932            assert!(nbrs.len() <= 12);
933            // No self-links, no duplicates, all in range.
934            assert!(!nbrs.contains(&slot));
935            let mut sorted = nbrs.clone();
936            sorted.sort_unstable();
937            sorted.dedup();
938            assert_eq!(sorted.len(), nbrs.len());
939            assert!(nbrs.iter().all(|&n| n < g.indexed()));
940            for level in 1..=g.level_of(pool.slot_fact(slot as usize)) {
941                g.neighbors_into(slot, level, &mut nbrs);
942                assert!(nbrs.len() <= 6, "level {level} degree overflow");
943            }
944        }
945    }
946
947    /// Remap carries the graph across a compaction: dead nodes (the
948    /// entry included) drop out, survivors stay searchable, and the
949    /// result passes reference validation.
950    #[test]
951    #[cfg_attr(miri, ignore)] // data-heavy; the small graphs cover the logic
952    fn remap_survives_a_dead_entry() {
953        let pool = cluster_pool(300, 16, 8, 21);
954        let g = build(&pool, 6, 12);
955        let entry = g.entry;
956        // Compaction map: the entry and every 7th slot die; survivors
957        // keep their relative order (as maintain produces).
958        let mut map = alloc::vec![NONE_U32; 300];
959        let mut new_pool = VecPool::new(16, usize::MAX);
960        let mut next = 0u32;
961        for old in 0..300u32 {
962            if old == entry || old % 7 == 0 {
963                continue;
964            }
965            map[old as usize] = next;
966            new_pool.copy_slot(&pool, old);
967            next += 1;
968        }
969        let remapped = g.remapped(&map, &new_pool, usize::MAX).unwrap();
970        assert_eq!(remapped.indexed(), next);
971        assert_ne!(remapped.entry, NONE_U32, "a survivor takes the entry");
972        remapped.validate(&new_pool).unwrap();
973        // A surviving vector still finds itself.
974        let probe_old = 1u32; // 1 % 7 != 0; if it was the entry pick 2
975        let probe_old = if probe_old == entry { 2 } else { probe_old };
976        let probe_new = map[probe_old as usize];
977        let (scale, qb) = new_pool.quant(probe_new as usize);
978        let mut scratch = HnswScratch::default();
979        let mut out = Vec::new();
980        remapped.search_quantized(&new_pool, (scale, qb), 32, &mut scratch, &mut out);
981        assert_eq!(out[0].0, probe_new);
982    }
983
984    /// Reference validation rejects structural lies: an out-of-range
985    /// entry, a non-canonical level-0 padding, a neighbor past
986    /// `indexed`, and state on an allegedly empty graph.
987    #[test]
988    fn validate_rejects_malformed_graphs() {
989        let pool = cluster_pool(50, 8, 4, 5);
990        let g = build(&pool, 4, 8);
991        let dump = (g.dump_meta(), g.dump_level0(), g.dump_upper());
992        let load = |meta: &[u8], level0: &[u8]| {
993            HnswGraph::from_parts(
994                4,
995                8,
996                usize::MAX,
997                meta,
998                level0,
999                &dump.2[0],
1000                &dump.2[1],
1001                &dump.2[2],
1002                &dump.2[3],
1003            )
1004        };
1005        // The clean image round-trips and validates.
1006        load(&dump.0, &dump.1).unwrap().validate(&pool).unwrap();
1007        // Entry out of range.
1008        let mut meta = dump.0.clone();
1009        meta[0..4].copy_from_slice(&999u32.to_le_bytes());
1010        assert!(load(&meta, &dump.1).unwrap().validate(&pool).is_err());
1011        // A neighbor past `indexed`.
1012        let mut level0 = dump.1.clone();
1013        level0[0..4].copy_from_slice(&500u32.to_le_bytes());
1014        assert!(load(&dump.0, &level0).unwrap().validate(&pool).is_err());
1015        // Non-canonical padding: NONE followed by a live neighbor.
1016        let mut level0 = dump.1.clone();
1017        level0[0..4].copy_from_slice(&NONE_U32.to_le_bytes());
1018        level0[4..8].copy_from_slice(&1u32.to_le_bytes());
1019        assert!(load(&dump.0, &level0).unwrap().validate(&pool).is_err());
1020        // A wrong level0 length is rejected at framing already.
1021        assert!(load(&dump.0, &dump.1[..dump.1.len() - 4]).is_err());
1022        // An "empty" graph carrying an entry is corrupt.
1023        let mut meta = dump.0.clone();
1024        meta[4..8].copy_from_slice(&0u32.to_le_bytes());
1025        assert!(load(&meta, &[]).unwrap().validate(&pool).is_err());
1026    }
1027
1028    /// An empty graph answers empty; a single node answers itself.
1029    #[test]
1030    fn tiny_graphs() {
1031        let pool = cluster_pool(1, 8, 1, 1);
1032        let mut g = HnswGraph::new(4, 8, usize::MAX).unwrap();
1033        let mut scratch = HnswScratch::default();
1034        let mut out = vec![(0u32, 0.0f32)];
1035        let (scale, qb) = pool.quant(0);
1036        g.search_quantized(&pool, (scale, qb), 8, &mut scratch, &mut out);
1037        assert!(out.is_empty(), "an empty graph must answer empty");
1038        g.insert_bulk(&pool, 1, 50, &mut scratch).unwrap();
1039        g.search_quantized(&pool, (scale, qb), 8, &mut scratch, &mut out);
1040        assert_eq!(out.len(), 1);
1041        assert_eq!(out[0].0, 0);
1042    }
1043}