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