Skip to main content

holographic_memory/core/qgraph/
vertex.rs

1// Copyright 2024-2026 WritersLogic Contributors
2// SPDX-License-Identifier: Apache-2.0
3//! Graph index with one compact code per vertex instead of one code per edge.
4//!
5//! Every vertex stores its mean-centred vector as 8-bit integers with one scale (locally
6//! adaptive scalar quantization, LVQ; Aguerrebere et al., VLDB 2023). Traversal estimates
7//! `<q, x> - <q, mean> ~= s_x * s_q * <c_q, c_x>` with an int8 query code and one integer dot
8//! product per neighbour, so a neighbour costs one short code fetch (`dim` bytes, the scales
9//! live in their own small array) rather than a full-precision vector. An optional second code
10//! of the quantization residual (8-bit, LVQ-8x8, or 4-bit) re-ranks the best `rerank`
11//! candidates of the final pool with a float query.
12//!
13//! Memory per vertex is `4 * degree + dim + 4` bytes, plus `dim + 4` with the 8-bit residual
14//! (`dim / 2 + 4` with the 4-bit one), against `4 * dim` for the vectors alone in an HNSW
15//! index.
16
17use std::collections::BinaryHeap;
18use std::sync::OnceLock;
19
20use rayon::prelude::*;
21
22use super::adjacency::Adjacency;
23use super::fastscan::{pack32, scan32, BLOCK};
24use super::kernels::{
25    dot_f32_i4, dot_f32_i8, dot_i4_inline, dot_i4_inline_x4, dot_i8_inline, interleave_for_i4,
26    prefetch, prefetch_bytes, raw_one, QueryCode, Rotation, SplitMix,
27};
28use super::{invalid, prepare_rows, upper_bytes, BuildParams, Graph, Metric, NONE};
29use crate::core::error::HmsError;
30
31mod file;
32pub use file::Residency;
33use file::Slab;
34
35/// Search parameters of a [`VGraph`].
36#[derive(Clone, Copy, Debug)]
37pub struct VSearchParams {
38    /// Candidate pool size (beam width).
39    pub ef: usize,
40    /// Number of best pool candidates re-scored with the float query (and the residual code,
41    /// when built); 0 returns the pool order.
42    pub rerank: usize,
43    /// Early stop: end the traversal once this many consecutive expansions have left the
44    /// `k` best estimates unchanged (0 = the pool rule alone). The pool rule still applies.
45    pub patience: usize,
46    /// With a screen (see [`VGraph::from_graph_with`]): a fresh neighbour gets the 8-bit
47    /// estimate only if its 1-bit estimate minus this many standard deviations of the
48    /// screen's error could still enter the full pool. Larger values skip less.
49    pub screen_sigmas: f32,
50    /// Consult the screen when the index has one (false runs the plain search on it).
51    pub screen: bool,
52    /// With neighbour blocks: fuse the block estimates a vertex receives from every expanded
53    /// vertex that lists it (precision-weighted by the edge residual's scale) and re-admit
54    /// it to the pool when the fused estimate qualifies, instead of keeping its first one.
55    pub fuse: bool,
56    /// Per-row search: estimate only the first this many neighbours of each expanded vertex
57    /// (lists keep the build's order, nearest first); 0 = all.
58    pub neighbour_cap: usize,
59}
60
61impl Default for VSearchParams {
62    fn default() -> Self {
63        Self {
64            ef: 64,
65            rerank: 0,
66            patience: 0,
67            screen_sigmas: 1.0,
68            screen: true,
69            fuse: false,
70            neighbour_cap: 0,
71        }
72    }
73}
74
75/// Runtime knobs of the search loop, each off by default so that the results are those of the
76/// plain search. [`VGraph::searcher`] reads them once per process from the environment
77/// ([`SearchKnobs::from_env`]); [`VGraph::searcher_with`] takes them explicitly.
78///
79/// Mechanisms, and what they may change:
80/// * `prefetch` (`HMS_QGRAPH_PREFETCH`, 0..=3, results never change): 0 prefetches the next
81///   candidate's neighbour list and the code row of every admitted neighbour; 1 adds the
82///   admitted neighbours' scales and the rows the re-rank reads; 2 adds the neighbour list of
83///   the second-nearest unexpanded candidate; 3 adds a lookahead that, after an expansion,
84///   prefetches the code rows (or screen rows) of the next candidate's unvisited neighbours.
85/// * `batch_est` (`HMS_QGRAPH_BATCH_EST`, results never change): the admitted neighbours'
86///   4-bit rows are estimated four at a time with shared query loads (`dot_i4_inline_x4` in
87///   the kernels module); the integer sums are the same.
88/// * `frontier` (`HMS_QGRAPH_FRONTIER`, `0.0..=1.0`, 1.0 = off): with a full pool, stop when
89///   the nearest unexpanded estimate is past the point `frontier` of the way from the `k`-th
90///   best to the `ef`-th best. Fewer expansions, so lower recall at a given `ef`; the
91///   recall/QPS curve over `ef` is what to compare.
92/// * `block_screen` (`HMS_QGRAPH_BLOCK_SCREEN`, needs [`VGraph::with_blocks`]): the neighbour
93///   block screens instead of ordering; survivors fetch their code row and the pool holds row
94///   estimates, as in the plain search. A fresh neighbour is skipped when its block estimate,
95///   improved by `screen_sigmas` deviations of the calibrated edge error, could not enter the
96///   full pool. Off, the block search is the existing one.
97#[derive(Clone, Copy, Debug, PartialEq)]
98pub struct SearchKnobs {
99    /// Prefetch depth, 0 to 3 (values above 3 act as 3).
100    pub prefetch: u8,
101    /// Estimate admitted neighbours four rows at a time.
102    pub batch_est: bool,
103    /// Frontier stop fraction in `0.0..=1.0`; 1.0 (or a non-finite value) is off.
104    pub frontier: f32,
105    /// Screen with the neighbour blocks and estimate survivors from their rows.
106    pub block_screen: bool,
107}
108
109impl SearchKnobs {
110    /// Every knob off: the plain search.
111    pub const OFF: Self = Self {
112        prefetch: 0,
113        batch_est: false,
114        frontier: 1.0,
115        block_screen: false,
116    };
117
118    /// The knobs set in the environment, read once per process; unset or invalid values
119    /// (reported once) take the [`SearchKnobs::OFF`] value.
120    pub fn from_env() -> Self {
121        static KNOBS: OnceLock<SearchKnobs> = OnceLock::new();
122        *KNOBS.get_or_init(|| Self {
123            prefetch: env_knob::<u8>("HMS_QGRAPH_PREFETCH").unwrap_or(0).min(3),
124            batch_est: env_knob::<u8>("HMS_QGRAPH_BATCH_EST").unwrap_or(0) != 0,
125            frontier: env_knob::<f32>("HMS_QGRAPH_FRONTIER")
126                .filter(|f| f.is_finite())
127                .map_or(1.0, |f| f.clamp(0.0, 1.0)),
128            block_screen: env_knob::<u8>("HMS_QGRAPH_BLOCK_SCREEN").unwrap_or(0) != 0,
129        })
130    }
131}
132
133impl Default for SearchKnobs {
134    fn default() -> Self {
135        Self::OFF
136    }
137}
138
139/// `name` parsed as `T`: `None` when unset, and when the value does not parse, which is
140/// reported.
141fn env_knob<T: std::str::FromStr>(name: &str) -> Option<T> {
142    let value = std::env::var(name).ok()?;
143    match value.trim().parse::<T>() {
144        Ok(v) => Some(v),
145        Err(_) => {
146            eprintln!("{name}={value:?} is not a valid value; using the default");
147            None
148        }
149    }
150}
151
152/// `HMS_QGRAPH_HUGEPAGES` set to a non-zero value: the large arrays of an index built in this
153/// process (code rows, neighbour lists, scales, residual rows, neighbour blocks) are advised
154/// into transparent huge pages before they are first touched, so each random row fetch costs
155/// one TLB entry per 2 MiB instead of per 4 KiB. Linux only; a no-op elsewhere. Read once.
156fn huge_pages() -> bool {
157    static HUGE: OnceLock<bool> = OnceLock::new();
158    *HUGE.get_or_init(|| env_knob::<u8>("HMS_QGRAPH_HUGEPAGES").unwrap_or(0) != 0)
159}
160
161/// Asks the kernel to back the 2 MiB-aligned interior of `[ptr, ptr + len)` with transparent
162/// huge pages (Linux `MADV_HUGEPAGE`). Must run before the pages are first touched, so the
163/// page-fault path allocates them huge where THP is `always` or `madvise`; afterwards only
164/// `khugepaged` could collapse them, slowly. A no-op outside Linux. Errors are ignored: the
165/// advice changes paging policy only, never the mapping or its contents.
166fn advise_huge_pages(ptr: *const u8, len: usize) {
167    #[cfg(target_os = "linux")]
168    {
169        const HUGE: usize = 2 << 20;
170        const MADV_HUGEPAGE: i32 = 14;
171        extern "C" {
172            fn madvise(addr: *mut std::ffi::c_void, len: usize, advice: i32) -> i32;
173        }
174        let start = (ptr as usize).next_multiple_of(HUGE);
175        let end = (ptr as usize + len) & !(HUGE - 1);
176        if start < end {
177            // SAFETY: `[start, end)` lies inside the live allocation `[ptr, ptr + len)` and
178            // `madvise` with `MADV_HUGEPAGE` reads or writes nothing in it.
179            unsafe {
180                madvise(start as *mut std::ffi::c_void, end - start, MADV_HUGEPAGE);
181            }
182        }
183    }
184    #[cfg(not(target_os = "linux"))]
185    let _ = (ptr, len);
186}
187
188/// [`advise_huge_pages`] over the capacity of a freshly allocated, still untouched vector when
189/// [`huge_pages`] is on.
190fn huge_capacity<T>(ptr: *const T, capacity: usize) {
191    if huge_pages() {
192        advise_huge_pages(ptr.cast::<u8>(), capacity * std::mem::size_of::<T>());
193    }
194}
195
196/// One 128-byte cache line (the line size of Apple cores).
197#[repr(C, align(128))]
198#[derive(Clone, Copy)]
199struct Line([i8; 128]);
200
201/// Bytes starting on a cache-line boundary (zeroed when built here).
202struct Lines {
203    /// `len.div_ceil(128)` lines.
204    lines: Slab<Line>,
205    len: usize,
206}
207
208impl Lines {
209    fn zeroed(len: usize) -> Self {
210        let count = len.div_ceil(128);
211        let mut lines = Vec::with_capacity(count);
212        huge_capacity(lines.as_ptr(), count);
213        lines.resize(count, Line([0; 128]));
214        Self {
215            lines: lines.into(),
216            len,
217        }
218    }
219
220    #[inline(always)]
221    fn bytes(&self) -> &[i8] {
222        // SAFETY: `Line` is `repr(C)` around `[i8; 128]` (size 128, no padding), so `lines` is
223        // `128 * lines.len() >= len` initialized, contiguous bytes.
224        unsafe { std::slice::from_raw_parts(self.lines.as_ptr().cast::<i8>(), self.len) }
225    }
226
227    fn bytes_mut(&mut self) -> &mut [i8] {
228        let len = self.len;
229        let lines = self.lines.as_mut_slice();
230        // SAFETY: as in `bytes`, with the exclusive borrow of `lines`.
231        unsafe { std::slice::from_raw_parts_mut(lines.as_mut_ptr().cast::<i8>(), len) }
232    }
233}
234
235pub struct VGraph {
236    n: usize,
237    dim: usize,
238    /// `dim` rounded up to a multiple of 16 (32 with 4-bit codes), the integer kernels' width.
239    padded: usize,
240    /// Bits per traversal code coordinate: 8, or 4 (two per byte).
241    bits: u8,
242    /// Bytes of one traversal code: `padded` or `padded / 2`.
243    cbytes: usize,
244    /// Bytes from one code row to the next: `cbytes`, or with aligned rows the smallest
245    /// power of two (up to 64) or multiple of 128 that holds it, so no row straddles a line.
246    stride: usize,
247    degree: usize,
248    mean: Vec<f32>,
249    /// Euclidean: `|x|^2 / 2` per stored vertex, the norm term of the distance order; empty
250    /// for angular.
251    half_norms: Slab<f32>,
252    /// Neighbour ids, `degree` per vertex padded with `NONE`; empty with 3-byte ids.
253    adj: Slab<u32>,
254    /// The same as 3-byte little-endian ids (padded with `NONE3`); empty with `u32` ids.
255    adj3: Slab<u8>,
256    /// Delta-coded lists (`id_bytes` 0): per vertex, the sorted neighbour ids as LEB128
257    /// varints of the zigzag first difference to the vertex and the gaps less one after,
258    /// with `adjv_off[v]..adjv_off[v + 1]` the byte span of vertex `v`; empty otherwise.
259    adjv: Slab<u8>,
260    adjv_off: Slab<u32>,
261    codes: Lines,
262    scales: Slab<f32>,
263    /// 0 (none), 4 or 8.
264    res_bits: u8,
265    /// Bytes per residual row: `padded` (8-bit) or `padded / 2` (4-bit, two per byte).
266    res_stride: usize,
267    residual: Slab<i8>,
268    res_scales: Slab<f32>,
269    entry: u32,
270    upper_ids: Vec<u32>,
271    layers: Vec<Vec<Vec<u32>>>,
272    /// Original id of each stored vertex when the vertices were renumbered; empty otherwise.
273    ids: Slab<u32>,
274    /// 1-bit screen rows: `screen_words` u64 of sign bits of the rotated centred vector, then
275    /// one u64 holding the f32 factor (low half) and the popcount (high half); empty without
276    /// a screen.
277    screen: Slab<u64>,
278    screen_words: usize,
279    screen_rot: Option<Rotation>,
280    /// Standard deviation of (screen estimate - 8-bit estimate), in estimate units, measured
281    /// at build on index vectors used as queries.
282    screen_sigma: f32,
283    /// Bits per coordinate of the neighbour-block codes (0 = no blocks; 1, 2 or 4). See
284    /// [`VGraph::with_blocks`].
285    block_bits: u8,
286    /// Coordinates of a block code: the block rotation's width.
287    block_dim: usize,
288    /// Nibble groups per block code: `block_dim * block_bits / 4`.
289    block_m: usize,
290    /// Blocks per vertex: `degree.div_ceil(32)`.
291    blocks_per_vertex: usize,
292    /// Per vertex, `blocks_per_vertex` records of `16 * block_m` code bytes in the fast-scan
293    /// layout followed by the 32 neighbours' scales as little-endian f32.
294    blocks: Lines,
295    block_rot: Option<Rotation>,
296    /// Error model of the block estimates: `var = block_kappa * s^2 * |q - x_v|^2` for an
297    /// edge of scale `s` seen from the expanded vertex `v`, calibrated at build.
298    block_kappa: f32,
299}
300
301/// Seed of the screen's rotation (fixed: the screen is a property of the encoding).
302const SCREEN_SEED: u64 = 0x5C12_EE00;
303/// Seed of the neighbour-block rotation.
304const BLOCK_SEED: u64 = 0xB10C_5CA0;
305
306/// Bytes of one block record: codes, then 32 scales, then 32 offsets (little-endian f32).
307fn block_record_bytes(m: usize) -> usize {
308    16 * m + 8 * BLOCK
309}
310
311/// Level of coordinate `i` of a block code (see [`encode_block_code`]).
312#[inline]
313fn block_level(nibbles: &[u8], bits: u8, i: usize) -> f32 {
314    match bits {
315        1 => {
316            if nibbles[i / 4] >> (i % 4) & 1 == 1 {
317                1.0
318            } else {
319                -1.0
320            }
321        }
322        2 => 2.0 * f32::from(nibbles[i / 2] >> (2 * (i % 2)) & 3) - 3.0,
323        _ => f32::from(nibbles[i]) - 7.0,
324    }
325}
326
327/// Little-endian f32 number `i` of `bytes`.
328#[inline]
329fn le_f32(bytes: &[u8], i: usize) -> f32 {
330    f32::from_le_bytes([
331        bytes[4 * i],
332        bytes[4 * i + 1],
333        bytes[4 * i + 2],
334        bytes[4 * i + 3],
335    ])
336}
337
338/// Encodes a rotated, centred vector as `m` nibbles for a `bits`-per-coordinate block code
339/// and returns its scale, so that coordinate `i ~= scale * level(i)` with the levels
340/// `{-1, 1}` (1 bit), `{-3, -1, 1, 3}` (2 bits) or `-7..=7` (4 bits; nibble `level + 7`).
341/// The levels of the 2- and 4-bit codes are chosen with the scale among eight candidates
342/// that minimizes the squared reconstruction error. The returned scale, for every width, is
343/// `|x|^2 / <levels, x>`: it makes the code error orthogonal to `x` itself (for one bit this
344/// is the RaBitQ factor `|x|^2 / sum |x_i|`), so inner products with queries that are
345/// correlated with `x` are unbiased, where the least-squares scale underestimates them by
346/// the relative error energy.
347fn encode_block_code(x: &[f32], bits: u8, nibbles: &mut [u8]) -> f32 {
348    nibbles.fill(0);
349    let m = x.iter().fold(0f32, |a, &v| a.max(v.abs()));
350    if m == 0.0 {
351        return 0.0;
352    }
353    match bits {
354        1 => {
355            let (mut norm2, mut abs) = (0f32, 0f32);
356            for (i, &v) in x.iter().enumerate() {
357                norm2 += v * v;
358                abs += v.abs();
359                if v >= 0.0 {
360                    nibbles[i / 4] |= 1 << (i % 4);
361                }
362            }
363            norm2 / abs
364        }
365        2 => {
366            // Levels {-3, -1, 1, 3} * s; index t = (level + 3) / 2.
367            let err = |s: f32| -> f32 {
368                x.iter()
369                    .map(|&v| {
370                        let t = ((v / s + 3.0) / 2.0).round().clamp(0.0, 3.0);
371                        let d = v - s * (2.0 * t - 3.0);
372                        d * d
373                    })
374                    .sum()
375            };
376            let (mut best, mut best_err) = (m / 3.0, f32::INFINITY);
377            for k in 0..8 {
378                let s = m / 3.0 * (1.0 - 0.1 * k as f32);
379                let e = err(s);
380                if e < best_err {
381                    (best, best_err) = (s, e);
382                }
383            }
384            let mut dot = 0f32;
385            for (i, &v) in x.iter().enumerate() {
386                let t = ((v / best + 3.0) / 2.0).round().clamp(0.0, 3.0) as u8;
387                nibbles[i / 2] |= t << (2 * (i % 2));
388                dot += v * (2.0 * f32::from(t) - 3.0);
389            }
390            unbiased_scale(x, dot, best)
391        }
392        _ => {
393            let err = |s: f32| -> f32 {
394                x.iter()
395                    .map(|&v| {
396                        let d = v - s * (v / s).round().clamp(-7.0, 7.0);
397                        d * d
398                    })
399                    .sum()
400            };
401            let (mut best, mut best_err) = (m / 7.0, f32::INFINITY);
402            for k in 0..8 {
403                let s = m / 7.0 * (1.0 - 0.06 * k as f32);
404                let e = err(s);
405                if e < best_err {
406                    (best, best_err) = (s, e);
407                }
408            }
409            let mut dot = 0f32;
410            for (i, &v) in x.iter().enumerate() {
411                let level = (v / best).round().clamp(-7.0, 7.0);
412                nibbles[i] = (level as i8 + 7) as u8;
413                dot += v * level;
414            }
415            unbiased_scale(x, dot, best)
416        }
417    }
418}
419
420/// `|x|^2 / <levels, x>` (see [`encode_block_code`]), or the least-squares scale when the
421/// levels do not correlate with `x`.
422fn unbiased_scale(x: &[f32], dot: f32, fallback: f32) -> f32 {
423    let norm2: f32 = x.iter().map(|v| v * v).sum();
424    if dot > 0.0 {
425        norm2 / dot
426    } else {
427        fallback
428    }
429}
430
431/// Fills the float lookup table `t` (`16 * m` entries) of a rotated query for `bits`-per-
432/// coordinate block codes: `t[16 * j + v] = sum over the coordinates of nibble j of
433/// q_i * level_i(v)`.
434fn block_table(q: &[f32], bits: u8, m: usize, t: &mut [f32]) {
435    match bits {
436        1 => {
437            for j in 0..m {
438                let qs = &q[4 * j..4 * j + 4];
439                let total: f32 = qs.iter().sum();
440                for v in 0..16usize {
441                    let on: f32 = (0..4).filter(|b| v >> b & 1 == 1).map(|b| qs[b]).sum();
442                    t[16 * j + v] = 2.0 * on - total;
443                }
444            }
445        }
446        2 => {
447            for j in 0..m {
448                let (qa, qb) = (q[2 * j], q[2 * j + 1]);
449                for v in 0..16usize {
450                    let (ta, tb) = ((v & 3) as f32, (v >> 2) as f32);
451                    t[16 * j + v] = qa * (2.0 * ta - 3.0) + qb * (2.0 * tb - 3.0);
452                }
453            }
454        }
455        _ => {
456            for j in 0..m {
457                for v in 0..16usize {
458                    t[16 * j + v] = q[j] * (v as f32 - 7.0);
459                }
460            }
461        }
462    }
463}
464
465/// Per-query block lookup table: `lut` as the scan reads it, with the affine map
466/// `entry ~= delta * lut + min_j` so that a code's inner product is
467/// `scale * (delta * sum + min_sum)`.
468pub struct BlockQuery {
469    table: Vec<f32>,
470    lut: Vec<u8>,
471    delta: f32,
472    min_sum: f32,
473    /// `1 / s_q`, which puts the block estimate in [`VGraph::est`] units.
474    inv_sq: f32,
475}
476
477impl BlockQuery {
478    fn new(m: usize) -> Self {
479        Self {
480            table: vec![0.0; 16 * m],
481            lut: vec![0; 16 * m],
482            delta: 1.0,
483            min_sum: 0.0,
484            inv_sq: 1.0,
485        }
486    }
487
488    /// Quantizes the float table to bytes: per group, entries are offset by the group's
489    /// minimum; one step `delta` serves every group.
490    fn quantize(&mut self, m: usize) {
491        let mut range = 0f32;
492        let mut min_sum = 0f32;
493        for g in self.table.as_chunks::<16>().0 {
494            let lo = g.iter().fold(f32::INFINITY, |a, &v| a.min(v));
495            let hi = g.iter().fold(f32::NEG_INFINITY, |a, &v| a.max(v));
496            min_sum += lo;
497            range = range.max(hi - lo);
498        }
499        let delta = if range > 0.0 { range / 255.0 } else { 1.0 };
500        for (j, g) in self.table.as_chunks::<16>().0.iter().enumerate() {
501            let lo = g.iter().fold(f32::INFINITY, |a, &v| a.min(v));
502            for (v, &e) in g.iter().enumerate() {
503                self.lut[16 * j + v] = ((e - lo) / delta).round().clamp(0.0, 255.0) as u8;
504            }
505        }
506        debug_assert_eq!(self.lut.len(), 16 * m);
507        self.delta = delta;
508        self.min_sum = min_sum;
509    }
510}
511
512/// Inverse of [`key`] for the distance part.
513#[inline]
514fn key_dist(k: u64) -> f32 {
515    let o = (k >> 32) as u32;
516    f32::from_bits(if o >> 31 == 1 { o & 0x7FFF_FFFF } else { !o })
517}
518
519/// Writes the symmetric 8-bit code of `x` into `code` (zero padded) and returns its scale, so
520/// that `x_i ~= scale * code_i`.
521pub(super) fn quantize(x: &[f32], code: &mut [i8]) -> f32 {
522    let m = x.iter().fold(0f32, |a, &v| a.max(v.abs()));
523    code.fill(0);
524    if m == 0.0 {
525        return 0.0;
526    }
527    let scale = m / 127.0;
528    for (c, &v) in code.iter_mut().zip(x) {
529        *c = (v / scale).round().clamp(-127.0, 127.0) as i8;
530    }
531    scale
532}
533
534/// [`quantize`] with 4-bit codes in `-7..=7`, packed two per byte as [`dot_f32_i4`] reads them
535/// (`code` holds `code.len() * 2 >= x.len()` values).
536pub(super) fn quantize4(x: &[f32], code: &mut [i8]) -> f32 {
537    let m = x.iter().fold(0f32, |a, &v| a.max(v.abs()));
538    code.fill(0);
539    if m == 0.0 {
540        return 0.0;
541    }
542    let scale = m / 7.0;
543    let q = |i: usize| {
544        x.get(i)
545            .map_or(0, |&v| (v / scale).round().clamp(-7.0, 7.0) as i8)
546    };
547    for (j, c) in code.iter_mut().enumerate() {
548        *c = ((q(2 * j) as u8 & 0x0F) | ((q(2 * j + 1) as u8) << 4)) as i8;
549    }
550    scale
551}
552
553/// Breadth-first order of the bottom layer from the entry (unreached vertices follow in id
554/// order): neighbours get nearby positions, so a search touches fewer distinct pages and lines.
555fn bfs_order(g: &Graph) -> Vec<u32> {
556    let mut seen = vec![false; g.n];
557    let mut order = Vec::with_capacity(g.n);
558    for s in std::iter::once(g.entry as usize).chain(0..g.n) {
559        if seen[s] {
560            continue;
561        }
562        seen[s] = true;
563        let mut head = order.len();
564        order.push(s as u32);
565        while head < order.len() {
566            let v = order[head] as usize;
567            head += 1;
568            for &u in g.neighbors(v) {
569                if !std::mem::replace(&mut seen[u as usize], true) {
570                    order.push(u);
571                }
572            }
573        }
574    }
575    order
576}
577
578/// Coordinate `i` of a code row of `bits` bits (0 past its end).
579pub(super) fn code_at(c: &[i8], bits: u8, i: usize) -> i8 {
580    if bits == 8 {
581        c.get(i).copied().unwrap_or(0)
582    } else {
583        c.get(i / 2).map_or(0, |&b| {
584            if i.is_multiple_of(2) {
585                (((b as u8) << 4) as i8) >> 4
586            } else {
587                b >> 4
588            }
589        })
590    }
591}
592
593/// Padding of a 3-byte neighbour list.
594const NONE3: u32 = 0x00FF_FFFF;
595
596#[inline]
597/// LEB128 varint (7 bits per byte, low bits first).
598fn put_varint(out: &mut Vec<u8>, mut v: u64) {
599    while v >= 0x80 {
600        out.push((v as u8) | 0x80);
601        v >>= 7;
602    }
603    out.push(v as u8);
604}
605
606/// Decodes one LEB128 varint; returns the value and the bytes used.
607#[inline]
608fn get_varint(bytes: &[u8]) -> (u64, usize) {
609    let mut v = 0u64;
610    for (i, &b) in bytes.iter().enumerate() {
611        v |= u64::from(b & 0x7f) << (7 * i);
612        if b & 0x80 == 0 {
613            return (v, i + 1);
614        }
615    }
616    (v, bytes.len())
617}
618
619fn zigzag(v: i64) -> u64 {
620    ((v << 1) ^ (v >> 63)) as u64
621}
622
623fn unzigzag(v: u64) -> i64 {
624    ((v >> 1) as i64) ^ -((v & 1) as i64)
625}
626
627fn prefetch_u8(bytes: &[u8]) {
628    // SAFETY: any initialized `u8` slice is also initialized `i8`s.
629    prefetch_bytes(unsafe { std::slice::from_raw_parts(bytes.as_ptr().cast::<i8>(), bytes.len()) });
630}
631
632#[inline]
633fn prefetch_f32(x: &f32) {
634    // SAFETY: an initialized `f32` is four initialized bytes.
635    prefetch_bytes(unsafe { std::slice::from_raw_parts((x as *const f32).cast::<i8>(), 4) });
636}
637
638#[inline]
639fn prefetch_ids(ids: &[u32]) {
640    // SAFETY: any initialized `u32` slice is also `4 * len` initialized bytes.
641    prefetch_bytes(unsafe { std::slice::from_raw_parts(ids.as_ptr().cast::<i8>(), ids.len() * 4) });
642}
643
644/// Orders by distance, then id; `f32` order is kept for every non-NaN distance.
645#[inline]
646fn key(dist: f32, id: u32) -> u64 {
647    let b = dist.to_bits();
648    let o = if b >> 31 == 1 { !b } else { b | 0x8000_0000 };
649    (u64::from(o) << 32) | u64::from(id)
650}
651
652#[inline]
653fn key_id(k: u64) -> u32 {
654    k as u32
655}
656
657impl VGraph {
658    /// Encodes a built graph with the 8-bit residual code used by `rerank` (or none), unaligned
659    /// rows and the build's vertex order.
660    pub fn from_graph(g: &Graph, residual: bool) -> Result<Self, HmsError> {
661        Self::from_graph_with(g, 8, if residual { 8 } else { 0 }, false, false, 4, false)
662    }
663
664    /// Encodes a built graph. `bits` (8 or 4) sizes the traversal code and `residual_bits`
665    /// (0, 4 or 8) the re-rank code of its residual; `align` keeps every code row inside as
666    /// few 128-byte lines as its size allows; `reorder` renumbers the vertices in breadth-first
667    /// order (search still returns the original ids); `id_bytes` 3 stores neighbour ids in
668    /// three bytes (graphs below 2^24 - 1 vertices) instead of 4, and 0 stores each list
669    /// sorted and delta-coded as varints (sizes vary; `reorder` makes the deltas small); `screen` adds a 1-bit
670    /// sign code of the rotated centred vector per vertex (RaBitQ-style, with a per-vertex
671    /// factor) that the search consults before paying for the 8-bit estimate.
672    pub fn from_graph_with(
673        g: &Graph,
674        bits: u8,
675        residual_bits: u8,
676        align: bool,
677        reorder: bool,
678        id_bytes: u8,
679        screen: bool,
680    ) -> Result<Self, HmsError> {
681        if !matches!(bits, 4 | 8) {
682            return Err(invalid(format!(
683                "vertex code bits must be 4 or 8, not {bits}"
684            )));
685        }
686        if !(id_bytes == 4 || id_bytes == 0 || (id_bytes == 3 && (g.n as u64) < u64::from(NONE3))) {
687            return Err(invalid(format!(
688                "id_bytes must be 4, 0 (delta varints), or 3 below 2^24 - 1 vertices (got {id_bytes} for {} vertices)",
689                g.n
690            )));
691        }
692        if !matches!(residual_bits, 0 | 4 | 8) {
693            return Err(invalid(format!(
694                "residual code bits must be 0, 4 or 8, not {residual_bits}"
695            )));
696        }
697        let euclidean = g.metric == Metric::Euclidean;
698        if euclidean && screen {
699            return Err(invalid("the 1-bit screen is angular only".into()));
700        }
701        let (n, dim) = (g.n, g.dim);
702        let padded = dim.next_multiple_of(if bits == 4 { 32 } else { 16 });
703        let cbytes = if bits == 4 { padded / 2 } else { padded };
704        let stride = match (align, cbytes) {
705            (false, _) => cbytes,
706            (true, c) if c <= 64 => c.next_power_of_two(),
707            (true, c) => c.next_multiple_of(128),
708        };
709        let res_stride = match residual_bits {
710            8 => padded,
711            4 => padded / 2,
712            _ => 0,
713        };
714        let mut sum = vec![0f64; dim];
715        for x in g.vectors.chunks_exact(dim) {
716            sum.iter_mut().zip(x).for_each(|(s, &v)| *s += f64::from(v));
717        }
718        let mut mean: Vec<f32> = sum.iter().map(|s| (s / n as f64) as f32).collect();
719        mean.resize(padded, 0.0);
720
721        let ids = if reorder { bfs_order(g) } else { Vec::new() };
722        let old = |v: usize| if reorder { ids[v] as usize } else { v };
723        let mut new_id: Vec<u32> = Vec::new();
724        if reorder {
725            new_id = vec![0; n];
726            for (v, &o) in ids.iter().enumerate() {
727                new_id[o as usize] = v as u32;
728            }
729        }
730        let new = |o: u32| if reorder { new_id[o as usize] } else { o };
731        let half_norms: Vec<f32> = if euclidean {
732            (0..n).map(|v| 0.5 * g.norms[old(v)]).collect()
733        } else {
734            Vec::new()
735        };
736
737        let screen_rot = screen.then(|| Rotation::new(dim, 0, SCREEN_SEED));
738        let screen_words = screen_rot.as_ref().map_or(0, |r| r.padded() / 64);
739        // The centred row of stored vertex `v`, which every code below encodes.
740        let centred = |v: usize| -> Vec<f32> {
741            let o = old(v);
742            g.vectors[o * dim..(o + 1) * dim]
743                .iter()
744                .zip(&mean)
745                .map(|(a, m)| a - m)
746                .collect()
747        };
748        // Codes are written in place, so no per-vertex buffers sit next to the final arrays.
749        let mut codes = Lines::zeroed(n * stride);
750        let mut scales = Vec::with_capacity(n);
751        huge_capacity(scales.as_ptr(), n);
752        scales.resize(n, 0f32);
753        codes
754            .bytes_mut()
755            .par_chunks_exact_mut(stride)
756            .zip(scales.par_iter_mut())
757            .enumerate()
758            .for_each(|(v, (dst, s))| {
759                let x = centred(v);
760                *s = if bits == 8 {
761                    quantize(&x, &mut dst[..cbytes])
762                } else {
763                    quantize4(&x, &mut dst[..cbytes])
764                };
765            });
766        let mut residual = Vec::with_capacity(n * res_stride);
767        huge_capacity(residual.as_ptr(), n * res_stride);
768        residual.resize(n * res_stride, 0i8);
769        let mut res_scales = vec![0f32; if residual_bits > 0 { n } else { 0 }];
770        if residual_bits > 0 {
771            let code_bytes = codes.bytes();
772            residual
773                .par_chunks_exact_mut(res_stride)
774                .zip(res_scales.par_iter_mut())
775                .enumerate()
776                .for_each(|(v, (r, sr))| {
777                    let mut x = centred(v);
778                    let c = &code_bytes[v * stride..v * stride + cbytes];
779                    let s = scales[v];
780                    x.iter_mut()
781                        .enumerate()
782                        .for_each(|(i, a)| *a -= s * f32::from(code_at(c, bits, i)));
783                    *sr = if residual_bits == 8 {
784                        quantize(&x, r)
785                    } else {
786                        quantize4(&x, r)
787                    };
788                });
789        }
790        let mut screen_rows = vec![0u64; if screen { n * (screen_words + 1) } else { 0 }];
791        if let Some(rot) = &screen_rot {
792            screen_rows
793                .par_chunks_exact_mut(screen_words + 1)
794                .enumerate()
795                .for_each(|(v, srow)| {
796                    let x = centred(v);
797                    let mut xr = vec![0f32; rot.padded()];
798                    rot.apply(&x, &mut xr);
799                    let (mut norm2, mut abs) = (0f32, 0f32);
800                    let mut pop = 0u32;
801                    for (i, &a) in xr.iter().enumerate() {
802                        norm2 += a * a;
803                        abs += a.abs();
804                        if a >= 0.0 {
805                            srow[i / 64] |= 1u64 << (i % 64);
806                            pop += 1;
807                        }
808                    }
809                    // x_r ~= f * sign(x_r) with f = |x_r|^2 / sum |x_r,i| (the RaBitQ factor).
810                    let f = if abs > 0.0 { norm2 / abs } else { 0.0 };
811                    srow[screen_words] = u64::from(f.to_bits()) | (u64::from(pop) << 32);
812                });
813        }
814
815        let r = g.degree;
816        // Stored vertex `v`'s list: the build's list of the vertex, renumbered, at most `r`.
817        let list = |v: usize| g.neighbors(old(v)).iter().take(r).map(|&u| new(u));
818        let (mut adj, mut adj3) = (Vec::new(), Vec::new());
819        let (mut adjv, mut adjv_off) = (Vec::new(), Vec::new());
820        if id_bytes == 3 {
821            adj3 = Vec::<u8>::with_capacity(3 * n * r);
822            huge_capacity(adj3.as_ptr(), adj3.capacity());
823            for v in 0..n {
824                let mut count = 0;
825                for u in list(v) {
826                    adj3.extend_from_slice(&u.min(NONE3).to_le_bytes()[..3]);
827                    count += 1;
828                }
829                for _ in count..r {
830                    adj3.extend_from_slice(&NONE3.to_le_bytes()[..3]);
831                }
832            }
833        } else if id_bytes == 4 {
834            adj = vec![NONE; n * r];
835            for (v, dst) in adj.chunks_exact_mut(r).enumerate() {
836                for (d, u) in dst.iter_mut().zip(list(v)) {
837                    *d = u;
838                }
839            }
840        } else {
841            adjv_off.reserve(n + 1);
842            let mut sorted: Vec<u32> = Vec::with_capacity(r);
843            for v in 0..n {
844                adjv_off.push(adjv.len() as u32);
845                sorted.clear();
846                sorted.extend(list(v).take_while(|&u| u != NONE));
847                sorted.sort_unstable();
848                let list = &sorted;
849                let mut prev = v as u32;
850                for (i, &u) in list.iter().enumerate() {
851                    let delta = if i == 0 {
852                        zigzag(i64::from(u) - i64::from(prev))
853                    } else {
854                        u64::from(u - prev - 1)
855                    };
856                    put_varint(&mut adjv, delta);
857                    prev = u;
858                }
859            }
860            adjv_off.push(adjv.len() as u32);
861        }
862        let entry = new(g.entry);
863        let upper_ids = g.upper_ids.iter().map(|&u| new(u)).collect();
864        let mut out = Self {
865            n,
866            dim,
867            padded,
868            bits,
869            cbytes,
870            stride,
871            degree: r,
872            mean,
873            half_norms: half_norms.into(),
874            adj: adj.into(),
875            adj3: adj3.into(),
876            adjv: adjv.into(),
877            adjv_off: adjv_off.into(),
878            codes,
879            scales: scales.into(),
880            res_bits: residual_bits,
881            res_stride,
882            residual: residual.into(),
883            res_scales: res_scales.into(),
884            entry,
885            upper_ids,
886            layers: g.layers.clone(),
887            ids: ids.into(),
888            screen: screen_rows.into(),
889            screen_words,
890            screen_rot,
891            screen_sigma: 0.0,
892            block_bits: 0,
893            block_dim: 0,
894            block_m: 0,
895            blocks_per_vertex: 0,
896            blocks: Lines::zeroed(0),
897            block_rot: None,
898            block_kappa: 0.0,
899        };
900        if screen {
901            out.screen_sigma = out.measure_screen_sigma(g);
902        }
903        Ok(out)
904    }
905
906    /// Adds neighbour-block codes: every vertex stores, next to its neighbour list, a
907    /// `bits`-per-coordinate code of each neighbour's rotated centred vector in the fast-scan
908    /// layout (32 codes per block), so one streamed read and one table scan estimate all
909    /// neighbours of an expanded vertex instead of one random row fetch per neighbour
910    /// (SymphonyQG's layout; Gou et al., SIGMOD 2025). `bits` is 1, 2 or 4.
911    pub fn with_blocks(mut self, g: &Graph, bits: u8) -> Result<Self, HmsError> {
912        if !matches!(bits, 1 | 2 | 4) {
913            return Err(invalid(format!("block bits must be 1, 2 or 4, not {bits}")));
914        }
915        if !self.half_norms.is_empty() {
916            return Err(invalid("neighbour blocks are angular only".into()));
917        }
918        let (n, dim, r) = (self.n, self.dim, self.degree);
919        let rot = Rotation::new(dim, 0, BLOCK_SEED);
920        let bd = rot.padded();
921        let m = bd * usize::from(bits) / 4;
922        let per_vertex = r.div_ceil(BLOCK);
923        let record = block_record_bytes(m);
924        let ids = &self.ids;
925        let old = |v: usize| if ids.is_empty() { v } else { ids[v] as usize };
926        let row = |v: usize| &g.vectors[old(v) * dim..(old(v) + 1) * dim];
927        let mut blocks = Lines::zeroed(n * per_vertex * record);
928        blocks
929            .bytes_mut()
930            .par_chunks_exact_mut(per_vertex * record)
931            .enumerate()
932            .for_each(|(v, dst)| {
933                // Codes are of the residual `r = x_u - x_v` of each neighbour `u` to the
934                // vertex `v` (as in SymphonyQG), so the code error scales with `|r|`; `e`
935                // folds in the query-independent terms `<x_v, r> - s * <levels, rot(x_v)>`,
936                // which makes the estimate's error `<rot(q - x_v), code error>`, scaled by
937                // the query's distance to `v` as well:
938                // `<q, x_u - mean> = <q, x_v - mean> + s * <levels, rot(q)> + e`.
939                let xv = row(v);
940                let mut xr = vec![0f32; bd];
941                rot.apply(xv, &mut xr);
942                let mut diff = vec![0f32; dim];
943                let mut rr = vec![0f32; bd];
944                let mut nibbles = vec![0u8; BLOCK * m];
945                let mut scales = [0f32; BLOCK];
946                let mut offsets = [0f32; BLOCK];
947                let mut list: Vec<u32> = Vec::with_capacity(r);
948                self.for_each_neighbour(v, |u| list.push(u));
949                for (b, rec) in dst.chunks_exact_mut(record).enumerate() {
950                    nibbles.fill(0);
951                    scales.fill(0.0);
952                    offsets.fill(0.0);
953                    for i in 0..BLOCK {
954                        let Some(&u) = list.get(b * BLOCK + i) else {
955                            break;
956                        };
957                        let xu = row(u as usize);
958                        for (d, (a, b)) in diff.iter_mut().zip(xu.iter().zip(xv)) {
959                            *d = a - b;
960                        }
961                        rot.apply(&diff, &mut rr);
962                        let nib = &mut nibbles[i * m..(i + 1) * m];
963                        let s = encode_block_code(&rr, bits, nib);
964                        let dot_lv: f32 = (0..bd).map(|j| block_level(nib, bits, j) * xr[j]).sum();
965                        let dot_vr: f32 = xv.iter().zip(&diff).map(|(a, b)| a * b).sum();
966                        scales[i] = s;
967                        offsets[i] = dot_vr - s * dot_lv;
968                    }
969                    // SAFETY-free reinterpretation: the record is plain bytes.
970                    let rec_u8 = unsafe {
971                        std::slice::from_raw_parts_mut(rec.as_mut_ptr().cast::<u8>(), rec.len())
972                    };
973                    pack32(&nibbles, m, &mut rec_u8[..16 * m]);
974                    for (i, (s, e)) in scales.iter().zip(&offsets).enumerate() {
975                        let at = 16 * m + 4 * i;
976                        rec_u8[at..at + 4].copy_from_slice(&s.to_le_bytes());
977                        let at = 16 * m + 4 * BLOCK + 4 * i;
978                        rec_u8[at..at + 4].copy_from_slice(&e.to_le_bytes());
979                    }
980                }
981            });
982        self.block_bits = bits;
983        self.block_dim = bd;
984        self.block_m = m;
985        self.blocks_per_vertex = per_vertex;
986        self.blocks = blocks;
987        self.block_rot = Some(rot);
988        self.block_kappa = self.measure_block_kappa(g);
989        Ok(self)
990    }
991
992    /// Unit row `v` (stored numbering) of the build data.
993    fn unit_row<'g>(&self, g: &'g Graph, v: usize) -> &'g [f32] {
994        let o = if self.ids.is_empty() {
995            v
996        } else {
997            self.ids[v] as usize
998        };
999        &g.vectors[o * self.dim..(o + 1) * self.dim]
1000    }
1001
1002    /// Fits `block_kappa` by least squares on a deterministic sample: the squared error of
1003    /// the block estimate against the exact centred inner product, regressed on
1004    /// `s^2 * |q - x_v|^2` (index vectors as queries, random expanded vertices).
1005    fn measure_block_kappa(&self, g: &Graph) -> f32 {
1006        let (nq, nv) = (32.min(self.n), 64.min(self.n));
1007        let mut rng = SplitMix(BLOCK_SEED ^ 1);
1008        let mut s = self.searcher();
1009        let (mut num, mut den) = (0f64, 0f64);
1010        let (mut diag_sum2, mut diag_n) = (0f64, 0f64);
1011        let mut scan = [0u32; BLOCK];
1012        for _ in 0..nq {
1013            let qi = (rng.next_u64() % self.n as u64) as usize;
1014            let q = self.unit_row(g, qi).to_vec();
1015            s.scratch.qf[..self.dim].copy_from_slice(&q);
1016            let sq = self.encode_query(
1017                &s.scratch.qf,
1018                &mut s.scratch.qc,
1019                &mut s.scratch.qtmp,
1020                &mut s.scratch.qrot,
1021                &mut s.scratch.qs,
1022            );
1023            if sq == 0.0 {
1024                continue;
1025            }
1026            self.encode_block_query(&s.scratch.qf, sq, &mut s.scratch.bqrot, &mut s.scratch.bq);
1027            let centred = |x: &[f32]| -> f32 {
1028                q.iter()
1029                    .zip(x)
1030                    .zip(&self.mean)
1031                    .map(|((a, b), m)| a * (b - m))
1032                    .sum()
1033            };
1034            let mut list = Vec::with_capacity(self.degree);
1035            for _ in 0..nv {
1036                let v = (rng.next_u64() % self.n as u64) as usize;
1037                let xv = self.unit_row(g, v);
1038                let ip_v = centred(xv);
1039                let d2: f32 = q.iter().zip(xv).map(|(a, b)| (a - b) * (a - b)).sum();
1040                list.clear();
1041                self.for_each_neighbour(v, |u| list.push(u));
1042                for b in 0..self.blocks_per_vertex {
1043                    let (codes, scales, offsets) = self.block(v, b);
1044                    scan32(codes, &s.scratch.bq.lut, self.block_m, &mut scan);
1045                    for (i, &sum) in scan.iter().enumerate() {
1046                        let Some(&u) = list.get(b * BLOCK + i) else {
1047                            break;
1048                        };
1049                        let sc = le_f32(scales, i);
1050                        let est = ip_v
1051                            + sc * (s.scratch.bq.delta * sum as f32 + s.scratch.bq.min_sum)
1052                            + le_f32(offsets, i);
1053                        let exact = centred(self.unit_row(g, u as usize));
1054                        let err = f64::from(est - exact);
1055                        num += err * err;
1056                        den += f64::from(sc * sc * d2);
1057                        diag_sum2 += f64::from(exact - ip_v).powi(2);
1058                        diag_n += 1.0;
1059                    }
1060                }
1061            }
1062        }
1063        if std::env::var_os("HMS_BLOCK_DIAG").is_some() && diag_n > 0.0 {
1064            eprintln!(
1065                "block diag: bits {} kappa {:.4} rms_err {:.5} rms_residual_ip {:.5} pairs {}",
1066                self.block_bits,
1067                num / den.max(1e-30),
1068                (num / diag_n).sqrt(),
1069                (diag_sum2 / diag_n).sqrt(),
1070                diag_n
1071            );
1072        }
1073        if den > 0.0 {
1074            (num / den) as f32
1075        } else {
1076            0.0
1077        }
1078    }
1079
1080    /// True if the index carries neighbour-block codes.
1081    pub fn has_blocks(&self) -> bool {
1082        self.block_bits > 0
1083    }
1084
1085    /// Bytes of the neighbour-block codes and scales.
1086    pub fn block_bytes(&self) -> usize {
1087        self.blocks.len
1088    }
1089
1090    /// Block `b` of vertex `v`: its packed codes, its 32 scales and its 32 offsets (both as
1091    /// little-endian f32 bytes).
1092    #[inline]
1093    fn block(&self, v: usize, b: usize) -> (&[u8], &[u8], &[u8]) {
1094        let record = block_record_bytes(self.block_m);
1095        let at = (v * self.blocks_per_vertex + b) * record;
1096        let bytes = self.blocks.bytes();
1097        // SAFETY: `i8` and `u8` have the same size and alignment; the slice stays in bounds.
1098        let rec =
1099            unsafe { std::slice::from_raw_parts(bytes.as_ptr().add(at).cast::<u8>(), record) };
1100        let (codes, rest) = rec.split_at(16 * self.block_m);
1101        let (scales, offsets) = rest.split_at(4 * BLOCK);
1102        (codes, scales, offsets)
1103    }
1104
1105    #[inline]
1106    fn prefetch_blocks(&self, v: usize) {
1107        let record = block_record_bytes(self.block_m) * self.blocks_per_vertex;
1108        let at = v * record;
1109        prefetch_bytes(&self.blocks.bytes()[at..at + record]);
1110    }
1111
1112    /// Builds the block lookup table of a query (`qf` padded, `sq` its 8-bit code scale).
1113    fn encode_block_query(&self, qf: &[f32], sq: f32, qrot: &mut [f32], bq: &mut BlockQuery) {
1114        let rot = self.block_rot.as_ref().expect("blocks present");
1115        rot.apply(&qf[..self.dim], qrot);
1116        block_table(qrot, self.block_bits, self.block_m, &mut bq.table);
1117        bq.quantize(self.block_m);
1118        bq.inv_sq = if sq > 0.0 { 1.0 / sq } else { 1.0 };
1119    }
1120
1121    /// Standard deviation of the screen estimate's error against the 8-bit estimate over
1122    /// index vectors used as queries (deterministic sample), in estimate units.
1123    fn measure_screen_sigma(&self, g: &Graph) -> f32 {
1124        let (nq, nv) = (128.min(self.n), 1024.min(self.n));
1125        let mut rng = SplitMix(SCREEN_SEED ^ 1);
1126        let mut qc = vec![0i8; self.padded];
1127        let mut qtmp = vec![0i8; self.padded];
1128        let mut qf = vec![0f32; self.padded];
1129        let mut qrot = vec![0f32; self.screen_words * 64];
1130        let mut qs = QueryCode::new(self.screen_words);
1131        let (mut sum, mut sum2, mut count) = (0f64, 0f64, 0usize);
1132        for _ in 0..nq {
1133            let q = (rng.next_u64() % self.n as u64) as usize;
1134            qf[..self.dim].copy_from_slice(&g.vectors[q * self.dim..(q + 1) * self.dim]);
1135            let sq = self.encode_query(&qf, &mut qc, &mut qtmp, &mut qrot, &mut qs);
1136            if sq == 0.0 {
1137                continue;
1138            }
1139            for _ in 0..nv {
1140                let v = (rng.next_u64() % self.n as u64) as u32;
1141                let d = f64::from(self.screen_est(&qs, v) - self.est(&qc, v, 1.0));
1142                sum += d;
1143                sum2 += d * d;
1144                count += 1;
1145            }
1146        }
1147        if count == 0 {
1148            return 0.0;
1149        }
1150        let mean = sum / count as f64;
1151        ((sum2 / count as f64) - mean * mean).max(0.0).sqrt() as f32
1152    }
1153
1154    /// Writes the traversal code of `qf` (padded) into `qc` and, with a screen, the rotated
1155    /// query scaled like the code into `qs`; returns the query's quantization scale.
1156    fn encode_query(
1157        &self,
1158        qf: &[f32],
1159        qc: &mut [i8],
1160        qtmp: &mut [i8],
1161        qrot: &mut [f32],
1162        qs: &mut QueryCode,
1163    ) -> f32 {
1164        let sq = if self.bits == 8 {
1165            quantize(qf, qc)
1166        } else {
1167            let s = quantize(qf, qtmp);
1168            interleave_for_i4(qtmp, qc);
1169            s
1170        };
1171        if let Some(rot) = &self.screen_rot {
1172            rot.apply(&qf[..self.dim], qrot);
1173            if sq > 0.0 {
1174                // The 8-bit estimate drops the query scale, so the screen works in the same
1175                // units: rotated query divided by s_q.
1176                qrot.iter_mut().for_each(|v| *v /= sq);
1177            }
1178            qs.encode(qrot);
1179        }
1180        sq
1181    }
1182
1183    /// True if the index carries the 1-bit screen.
1184    pub fn has_screen(&self) -> bool {
1185        !self.screen.is_empty()
1186    }
1187
1188    /// Bytes of the screen rows.
1189    pub fn screen_bytes(&self) -> usize {
1190        self.screen.len() * 8
1191    }
1192
1193    /// 1-bit estimate in the units of [`VGraph::est`]: `-f * sum_i sign_i * q_i`, with the
1194    /// signed sum from the query's bit planes as `2 * (lo * pop + delta * raw) - sum(q)`.
1195    #[inline]
1196    fn screen_est(&self, qs: &QueryCode, v: u32) -> f32 {
1197        let w = self.screen_words;
1198        let row = &self.screen[v as usize * (w + 1)..(v as usize + 1) * (w + 1)];
1199        let raw = raw_one(&row[..w], &qs.planes, w) as f32;
1200        let meta = row[w];
1201        let f = f32::from_bits(meta as u32);
1202        let pop = (meta >> 32) as f32;
1203        -(f * (2.0 * (qs.lo * pop + qs.delta * raw) - qs.sum))
1204    }
1205
1206    #[inline]
1207    fn prefetch_screen(&self, v: u32) {
1208        let w = self.screen_words;
1209        prefetch(&self.screen[v as usize * (w + 1)..(v as usize + 1) * (w + 1)]);
1210    }
1211
1212    pub fn len(&self) -> usize {
1213        self.n
1214    }
1215
1216    pub fn is_empty(&self) -> bool {
1217        self.n == 0
1218    }
1219
1220    pub fn has_residual(&self) -> bool {
1221        self.res_bits > 0
1222    }
1223
1224    /// Bytes held by the index, residual and id map included.
1225    pub fn index_bytes(&self) -> usize {
1226        self.adj.len() * 4
1227            + self.adj3.len()
1228            + self.adjv.len()
1229            + self.adjv_off.len() * 4
1230            + self.n * self.stride
1231            + self.scales.len() * 4
1232            + self.residual_bytes()
1233            + self.mean.len() * 4
1234            + self.half_norms.len() * 4
1235            + self.ids.len() * 4
1236            + self.screen_bytes()
1237            + self.block_bytes()
1238            + upper_bytes(&self.upper_ids, &self.layers)
1239    }
1240
1241    /// Bytes of the residual codes and their scales, which only `rerank` reads.
1242    pub fn residual_bytes(&self) -> usize {
1243        self.residual.len() + self.res_scales.len() * 4
1244    }
1245
1246    #[inline]
1247    fn code(&self, v: u32) -> &[i8] {
1248        let at = v as usize * self.stride;
1249        &self.codes.bytes()[at..at + self.cbytes]
1250    }
1251
1252    /// Calls `f` on every neighbour of `v`, in list order.
1253    #[inline]
1254    fn for_each_neighbour(&self, v: usize, mut f: impl FnMut(u32)) {
1255        let r = self.degree;
1256        if !self.adjv_off.is_empty() {
1257            return self.for_each_delta_neighbour(v, f);
1258        }
1259        if self.adj3.is_empty() {
1260            for &u in &self.adj[v * r..(v + 1) * r] {
1261                if u == NONE {
1262                    break;
1263                }
1264                f(u);
1265            }
1266        } else {
1267            for b in self.adj3[3 * v * r..3 * (v + 1) * r].as_chunks::<3>().0 {
1268                let u = u32::from_le_bytes([b[0], b[1], b[2], 0]);
1269                if u == NONE3 {
1270                    break;
1271                }
1272                f(u);
1273            }
1274        }
1275    }
1276
1277    /// [`VGraph::for_each_neighbour`] over a delta-coded list.
1278    #[cold]
1279    #[inline(never)]
1280    fn for_each_delta_neighbour(&self, v: usize, mut f: impl FnMut(u32)) {
1281        let span = &self.adjv[self.adjv_off[v] as usize..self.adjv_off[v + 1] as usize];
1282        let mut at = 0;
1283        let mut prev = v as u32;
1284        let mut first = true;
1285        while at < span.len() {
1286            let (delta, used) = get_varint(&span[at..]);
1287            at += used;
1288            let u = if first {
1289                (i64::from(prev) + unzigzag(delta)) as u32
1290            } else {
1291                prev + delta as u32 + 1
1292            };
1293            first = false;
1294            prev = u;
1295            f(u);
1296        }
1297    }
1298
1299    #[inline]
1300    fn prefetch_neighbours(&self, v: usize) {
1301        let r = self.degree;
1302        if !self.adjv_off.is_empty() {
1303            prefetch_u8(&self.adjv[self.adjv_off[v] as usize..self.adjv_off[v + 1] as usize]);
1304        } else if self.adj3.is_empty() {
1305            prefetch_ids(&self.adj[v * r..(v + 1) * r]);
1306        } else {
1307            prefetch_u8(&self.adj3[3 * v * r..3 * (v + 1) * r]);
1308        }
1309    }
1310
1311    /// Prefetches the scale of `v` (its own array, one line per random vertex).
1312    #[inline]
1313    fn prefetch_scale(&self, v: u32) {
1314        prefetch_f32(&self.scales[v as usize]);
1315    }
1316
1317    /// Prefetches what [`VGraph::fine`] reads for `v`.
1318    #[inline]
1319    fn prefetch_rerank_rows(&self, v: u32) {
1320        prefetch_bytes(self.code(v));
1321        self.prefetch_scale(v);
1322        if self.has_residual() {
1323            let v = v as usize;
1324            prefetch_bytes(&self.residual[v * self.res_stride..(v + 1) * self.res_stride]);
1325            prefetch_f32(&self.res_scales[v]);
1326        }
1327    }
1328
1329    #[inline]
1330    fn original(&self, v: u32) -> u32 {
1331        if self.ids.is_empty() {
1332            v
1333        } else {
1334            self.ids[v as usize]
1335        }
1336    }
1337
1338    /// Estimated negative inner product with the query code (up to the per-query terms
1339    /// `<q, mean>` and `s_q`, which do not change the order). Euclidean adds `w |x|^2 / 2`
1340    /// with `w = 1 / s_q`, the order of `|q - x|^2` in the same units.
1341    #[inline]
1342    fn est(&self, qc: &[i8], v: u32, w: f32) -> f32 {
1343        let dot = if self.bits == 8 {
1344            dot_i8_inline(qc, self.code(v))
1345        } else {
1346            dot_i4_inline(qc, self.code(v))
1347        };
1348        self.order(v, self.scales[v as usize] * dot as f32, w)
1349    }
1350
1351    /// The distance order of `v` from its estimated (or re-ranked) inner product `ip` in
1352    /// units of `1 / w`: `-ip` for angular, `w |x|^2 / 2 - ip` for Euclidean.
1353    #[inline]
1354    fn order(&self, v: u32, ip: f32, w: f32) -> f32 {
1355        if self.half_norms.is_empty() {
1356            -ip
1357        } else {
1358            self.half_norms[v as usize] * w - ip
1359        }
1360    }
1361
1362    /// [`VGraph::est`] of four vertices with shared query loads; the same integer sums, so
1363    /// the same values.
1364    #[inline]
1365    fn est4(&self, qc: &[i8], v: [u32; 4], w: f32) -> [f32; 4] {
1366        let dots = if self.bits == 8 {
1367            v.map(|u| dot_i8_inline(qc, self.code(u)))
1368        } else {
1369            dot_i4_inline_x4(qc, v.map(|u| self.code(u)))
1370        };
1371        std::array::from_fn(|i| self.order(v[i], self.scales[v[i] as usize] * dots[i] as f32, w))
1372    }
1373
1374    /// Re-rank score with the float query: negative estimated inner product with the
1375    /// reconstructed centred vector (Euclidean: plus `|x|^2 / 2`).
1376    fn fine(&self, qf: &[f32], v: u32) -> f32 {
1377        let primary = if self.bits == 8 {
1378            dot_f32_i8(qf, self.code(v))
1379        } else {
1380            dot_f32_i4(qf, self.code(v))
1381        };
1382        let mut ip = self.scales[v as usize] * primary;
1383        if self.has_residual() {
1384            let v = v as usize;
1385            let r = &self.residual[v * self.res_stride..(v + 1) * self.res_stride];
1386            let d = if self.res_bits == 8 {
1387                dot_f32_i8(qf, r)
1388            } else {
1389                dot_f32_i4(qf, r)
1390            };
1391            ip += self.res_scales[v] * d;
1392        }
1393        self.order(v, ip, 1.0)
1394    }
1395
1396    /// Greedy descent through the upper layers on estimated distances; returns the bottom-layer
1397    /// entry vertex and the number of estimates.
1398    fn descend(&self, qc: &[i8], w: f32) -> (u32, usize) {
1399        if self.layers.is_empty() {
1400            return (self.entry, 0);
1401        }
1402        let mut p = 0u32;
1403        let mut best = self.est(qc, self.upper_ids[0], w);
1404        let mut evals = 1;
1405        for layer in self.layers.iter().rev() {
1406            loop {
1407                let mut moved = false;
1408                for &u in &layer[p as usize] {
1409                    let d = self.est(qc, self.upper_ids[u as usize], w);
1410                    evals += 1;
1411                    if d < best {
1412                        best = d;
1413                        p = u;
1414                        moved = true;
1415                    }
1416                }
1417                if !moved {
1418                    break;
1419                }
1420            }
1421        }
1422        (self.upper_ids[p as usize], evals)
1423    }
1424
1425    /// A searcher with the knobs of the environment ([`SearchKnobs::from_env`]).
1426    pub fn searcher(&self) -> VSearcher<'_> {
1427        self.searcher_with(SearchKnobs::from_env())
1428    }
1429
1430    /// A searcher with explicit [`SearchKnobs`].
1431    pub fn searcher_with(&self, knobs: SearchKnobs) -> VSearcher<'_> {
1432        VSearcher {
1433            index: self,
1434            scratch: SearchScratch {
1435                #[cfg(not(target_arch = "wasm32"))]
1436                dim: self.dim,
1437                knobs,
1438                visited: Marks {
1439                    marks: vec![0; self.n],
1440                    epoch: 0,
1441                    traces: vec![Trace::default(); if self.has_blocks() { self.n } else { 0 }],
1442                },
1443                qf: vec![0.0; self.padded],
1444                qc: vec![0; self.padded],
1445                qtmp: vec![0; self.padded],
1446                qrot: vec![0.0; self.screen_words * 64],
1447                qs: QueryCode::new(self.screen_words.max(1)),
1448                bqrot: vec![0.0; self.block_dim],
1449                bq: BlockQuery::new(self.block_m),
1450                scan: [0; BLOCK],
1451                fresh: Vec::with_capacity(self.degree),
1452                survivors: Vec::with_capacity(self.degree),
1453                screened: 0,
1454                pool: Pool {
1455                    keys: Vec::new(),
1456                    expanded: Vec::new(),
1457                    cap: 1,
1458                    cursor: 0,
1459                },
1460                expanded: BinaryHeap::new(),
1461                keys: Vec::new(),
1462                results: Vec::new(),
1463            },
1464        }
1465    }
1466
1467    #[cfg(not(target_arch = "wasm32"))]
1468    pub(super) fn update_encoding(&self) -> super::update::Encoding {
1469        super::update::Encoding {
1470            bits: self.bits,
1471            residual_bits: self.res_bits,
1472            align: self.stride != self.cbytes,
1473            reorder: !self.ids.is_empty(),
1474            id_bytes: if !self.adjv_off.is_empty() {
1475                0
1476            } else if !self.adj3.is_empty() {
1477                3
1478            } else {
1479                4
1480            },
1481            screen: self.has_screen(),
1482            block_bits: self.block_bits,
1483        }
1484    }
1485
1486    #[cfg(not(target_arch = "wasm32"))]
1487    pub(super) fn search_reusing(
1488        &self,
1489        cache: &mut Option<SearchScratch>,
1490        query: &[f32],
1491        k: usize,
1492        params: VSearchParams,
1493        out: &mut Vec<u32>,
1494    ) -> Result<usize, HmsError> {
1495        let compatible = cache.as_ref().is_some_and(|s| {
1496            s.dim == self.dim
1497                && s.visited.marks.len() == self.n
1498                && s.qf.len() == self.padded
1499                && s.qrot.len() == self.screen_words * 64
1500                && s.bqrot.len() == self.block_dim
1501                && s.bq.table.len() == 16 * self.block_m
1502                && s.visited.traces.len() == if self.has_blocks() { self.n } else { 0 }
1503        });
1504        if !compatible {
1505            *cache = Some(self.searcher().scratch);
1506        }
1507        cache
1508            .as_mut()
1509            .expect("initialized search scratch")
1510            .search_validated(self, query, k, params, out)
1511    }
1512}
1513
1514/// Visited marks with an 8-bit epoch: a quarter of the cache footprint of `u32` epochs, at the
1515/// cost of clearing every 255 searches.
1516struct Marks {
1517    marks: Vec<u8>,
1518    epoch: u8,
1519    /// With neighbour blocks, one record per vertex instead of the byte mark.
1520    traces: Vec<Trace>,
1521}
1522
1523/// What one search knows about a vertex met through neighbour blocks: the precision-weighted
1524/// sum of the block estimates it received (one per expanded vertex listing it), their
1525/// weight, and whether it was expanded itself.
1526#[derive(Clone, Copy, Default)]
1527struct Trace {
1528    epoch: u8,
1529    expanded: bool,
1530    w: f32,
1531    wsum: f32,
1532    /// The vertex's key in the pool, or `u64::MAX` when it holds none.
1533    key: u64,
1534}
1535
1536impl Marks {
1537    fn next(&mut self) {
1538        self.epoch = self.epoch.wrapping_add(1);
1539        if self.epoch == 0 {
1540            self.marks.fill(0);
1541            self.traces.fill(Trace::default());
1542            self.epoch = 1;
1543        }
1544    }
1545
1546    /// The trace of `id` in this epoch, and whether it was just created (first encounter).
1547    #[inline]
1548    fn trace(&mut self, id: u32) -> (bool, &mut Trace) {
1549        let t = &mut self.traces[id as usize];
1550        let fresh = t.epoch != self.epoch;
1551        if fresh {
1552            *t = Trace {
1553                epoch: self.epoch,
1554                key: u64::MAX,
1555                ..Trace::default()
1556            };
1557        }
1558        (fresh, t)
1559    }
1560
1561    /// True if `id` was visited in this epoch (no mark is set).
1562    #[inline]
1563    fn seen(&self, id: u32) -> bool {
1564        self.marks[id as usize] == self.epoch
1565    }
1566
1567    /// True if `id` was not yet visited in this epoch.
1568    #[inline]
1569    fn insert(&mut self, id: u32) -> bool {
1570        let m = &mut self.marks[id as usize];
1571        let fresh = *m != self.epoch;
1572        *m = self.epoch;
1573        fresh
1574    }
1575}
1576
1577/// Best-first candidate pool as one sorted array: the `ef` best keys seen, nearest first,
1578/// each with an expanded flag, and a cursor at the nearest unexpanded entry. One insert is a
1579/// binary search and a short move; one expansion step is a scan from the cursor; the pool
1580/// rule ("the nearest unexpanded candidate is among the `ef` best seen") holds by
1581/// construction, so the search ends when no unexpanded entry is left. Two binary heaps
1582/// (queue and bounded pool) cost two sifts per admitted candidate and a pop per expansion,
1583/// and their results came back unsorted; the array is cache-resident at any practical `ef`.
1584struct Pool {
1585    keys: Vec<u64>,
1586    expanded: Vec<bool>,
1587    cap: usize,
1588    cursor: usize,
1589}
1590
1591impl Pool {
1592    fn reset(&mut self, cap: usize) {
1593        self.keys.clear();
1594        self.expanded.clear();
1595        self.cap = cap.max(1);
1596        self.cursor = 0;
1597    }
1598
1599    fn is_full(&self) -> bool {
1600        self.keys.len() >= self.cap
1601    }
1602
1603    /// The worst key held, once the pool is full.
1604    fn worst(&self) -> Option<u64> {
1605        self.is_full().then(|| self.keys[self.keys.len() - 1])
1606    }
1607
1608    /// Admits `key` unless the pool is full of better ones; returns its position and the
1609    /// key evicted to make room.
1610    #[inline]
1611    fn insert(&mut self, key: u64) -> Option<(usize, Option<u64>)> {
1612        let n = self.keys.len();
1613        if n >= self.cap && key >= self.keys[n - 1] {
1614            return None;
1615        }
1616        let pos = self.keys.partition_point(|&t| t < key);
1617        let mut evicted = None;
1618        if n >= self.cap {
1619            evicted = self.keys.pop();
1620            self.expanded.pop();
1621        }
1622        self.keys.insert(pos, key);
1623        self.expanded.insert(pos, false);
1624        if pos < self.cursor {
1625            self.cursor = pos;
1626        }
1627        Some((pos, evicted))
1628    }
1629
1630    /// Drops `key` if present (a re-admitted vertex's older entry).
1631    fn remove(&mut self, key: u64) {
1632        let pos = self.keys.partition_point(|&t| t < key);
1633        if pos < self.keys.len() && self.keys[pos] == key {
1634            self.keys.remove(pos);
1635            self.expanded.remove(pos);
1636            if pos < self.cursor {
1637                self.cursor -= 1;
1638            }
1639        }
1640    }
1641
1642    /// The nearest unexpanded key, now marked expanded.
1643    #[inline]
1644    fn next(&mut self) -> Option<u64> {
1645        while self.cursor < self.keys.len() {
1646            if !self.expanded[self.cursor] {
1647                self.expanded[self.cursor] = true;
1648                return Some(self.keys[self.cursor]);
1649            }
1650            self.cursor += 1;
1651        }
1652        None
1653    }
1654
1655    /// The key [`Pool::next`] would return.
1656    #[inline]
1657    fn peek_next(&self) -> Option<u64> {
1658        self.peek_unexpanded(0)
1659    }
1660
1661    /// The `n`-th (from 0) unexpanded key in pool order.
1662    #[inline]
1663    fn peek_unexpanded(&self, n: usize) -> Option<u64> {
1664        (self.cursor..self.keys.len())
1665            .filter(|&i| !self.expanded[i])
1666            .nth(n)
1667            .map(|i| self.keys[i])
1668    }
1669}
1670
1671/// Per-thread search state for one [`VGraph`].
1672pub struct VSearcher<'a> {
1673    index: &'a VGraph,
1674    scratch: SearchScratch,
1675}
1676
1677pub(super) struct SearchScratch {
1678    #[cfg(not(target_arch = "wasm32"))]
1679    dim: usize,
1680    visited: Marks,
1681    qf: Vec<f32>,
1682    /// Query code in the traversal kernel's layout.
1683    qc: Vec<i8>,
1684    qtmp: Vec<i8>,
1685    /// Rotated, scaled query and its bit planes for the screen.
1686    qrot: Vec<f32>,
1687    qs: QueryCode,
1688    /// Query rotated for the neighbour blocks, its lookup table, and one block's sums.
1689    bqrot: Vec<f32>,
1690    bq: BlockQuery,
1691    scan: [u32; BLOCK],
1692    fresh: Vec<u32>,
1693    /// Fresh neighbours that passed the screen.
1694    survivors: Vec<u32>,
1695    /// Neighbours the screen skipped in the last search.
1696    screened: usize,
1697    /// The `ef` best candidates seen, with their expansion state.
1698    pool: Pool,
1699    /// With blocks: the best `max(k, rerank)` expanded vertices by their own-code estimate,
1700    /// farthest on top. Block estimates only order the exploration; results come from here.
1701    expanded: BinaryHeap<u64>,
1702    keys: Vec<u64>,
1703    results: Vec<(f32, u32)>,
1704    knobs: SearchKnobs,
1705}
1706
1707impl VSearcher<'_> {
1708    /// Neighbours the screen skipped (no 8-bit estimate) in the last search.
1709    pub fn last_screened(&self) -> usize {
1710        self.scratch.screened
1711    }
1712
1713    /// The knobs this searcher runs with.
1714    pub fn knobs(&self) -> SearchKnobs {
1715        self.scratch.knobs
1716    }
1717
1718    /// Searches this index with the searcher's reusable buffers.
1719    #[inline]
1720    pub fn search(
1721        &mut self,
1722        query: &[f32],
1723        k: usize,
1724        params: VSearchParams,
1725        out: &mut Vec<u32>,
1726    ) -> Result<usize, HmsError> {
1727        self.scratch.search(self.index, query, k, params, out)
1728    }
1729}
1730
1731impl SearchScratch {
1732    /// Row estimates of the admitted neighbours in `fresh`, inserted into the pool in list
1733    /// order (four rows at a time with `batch_est`; the same values either way); returns the
1734    /// number of estimates. `stale` resets on an insert among the `k` best when `patience`
1735    /// is on.
1736    #[inline]
1737    fn score_fresh(
1738        &mut self,
1739        g: &VGraph,
1740        k: usize,
1741        patience: usize,
1742        stale: &mut usize,
1743        w: f32,
1744    ) -> usize {
1745        let qc = &self.qc[..];
1746        let n = self.fresh.len();
1747        let mut i = 0;
1748        if self.knobs.batch_est {
1749            while i + 4 <= n {
1750                let ids = [
1751                    self.fresh[i],
1752                    self.fresh[i + 1],
1753                    self.fresh[i + 2],
1754                    self.fresh[i + 3],
1755                ];
1756                for (e, u) in g.est4(qc, ids, w).into_iter().zip(ids) {
1757                    if let Some((pos, _)) = self.pool.insert(key(e, u)) {
1758                        if patience > 0 && pos < k {
1759                            *stale = 0;
1760                        }
1761                    }
1762                }
1763                i += 4;
1764            }
1765        }
1766        for &u in &self.fresh[i..] {
1767            let kk = key(g.est(qc, u, w), u);
1768            if let Some((pos, _)) = self.pool.insert(kk) {
1769                if patience > 0 && pos < k {
1770                    *stale = 0;
1771                }
1772            }
1773        }
1774        n
1775    }
1776
1777    /// Writes the ids of the (approximate) `k` nearest vectors by the index's metric (cosine
1778    /// or Euclidean) into `out`, nearest first, and returns the number of distance estimates
1779    /// (descent, traversal and re-rank). A query of the wrong dimension, with a non-finite
1780    /// value, or all zero on an angular index (cosine is undefined) is an error; the pool and
1781    /// heaps are bounded by the vertex count whatever `ef` is.
1782    fn search(
1783        &mut self,
1784        g: &VGraph,
1785        query: &[f32],
1786        k: usize,
1787        params: VSearchParams,
1788        out: &mut Vec<u32>,
1789    ) -> Result<usize, HmsError> {
1790        if query.len() != g.dim {
1791            return Err(invalid(format!(
1792                "query has {} dimensions, the index has {}",
1793                query.len(),
1794                g.dim
1795            )));
1796        }
1797        if let Some(at) = query.iter().position(|v| !v.is_finite()) {
1798            return Err(invalid(format!(
1799                "non-finite query value at coordinate {at}"
1800            )));
1801        }
1802        if g.half_norms.is_empty() && query.iter().all(|&v| v == 0.0) {
1803            return Err(invalid("zero query: cosine similarity is undefined".into()));
1804        }
1805        self.search_validated(g, query, k, params, out)
1806    }
1807
1808    // The caller has checked dimension, finite coordinates, and nonzero angular norm.
1809    fn search_validated(
1810        &mut self,
1811        g: &VGraph,
1812        query: &[f32],
1813        k: usize,
1814        params: VSearchParams,
1815        out: &mut Vec<u32>,
1816    ) -> Result<usize, HmsError> {
1817        let ef = params.ef.max(k).max(1);
1818        // Only the order matters, so the query is used as given (no centring needed: the
1819        // `<q, mean>` term is common to all candidates) and its scale is dropped.
1820        self.qf[..g.dim].copy_from_slice(query);
1821        let sq = g.encode_query(
1822            &self.qf,
1823            &mut self.qc,
1824            &mut self.qtmp,
1825            &mut self.qrot,
1826            &mut self.qs,
1827        );
1828        // Weight of the Euclidean norm term in the estimate's units (unused for angular); a
1829        // zero query has every inner product zero, so the norms alone order it.
1830        let w = if sq > 0.0 { 1.0 / sq } else { 1.0 };
1831        let qc = &self.qc[..];
1832        let blocks = g.has_blocks();
1833        if blocks {
1834            g.encode_block_query(&self.qf, sq, &mut self.bqrot, &mut self.bq);
1835        }
1836        let knobs = self.knobs;
1837        let block_screen = blocks && knobs.block_screen;
1838        let screen = g.has_screen() && params.screen && !blocks;
1839        let margin = params.screen_sigmas * g.screen_sigma;
1840        self.screened = 0;
1841
1842        self.visited.next();
1843        let (entry, mut evals) = g.descend(qc, w);
1844        self.visited.insert(entry);
1845        let fuse = blocks && params.fuse && !block_screen;
1846        if fuse {
1847            self.visited.trace(entry);
1848        }
1849        // Query terms of the error model: `|q - x_v|^2 = |q|^2 + 1 - 2 (<q, x_v - mean> + <q, mean>)`
1850        // for unit index rows.
1851        let (qn2, qm) = if blocks {
1852            let q = &self.qf[..g.dim];
1853            (
1854                q.iter().map(|a| a * a).sum::<f32>(),
1855                q.iter().zip(&g.mean).map(|(a, m)| a * m).sum::<f32>(),
1856            )
1857        } else {
1858            (0.0, 0.0)
1859        };
1860        self.pool.reset(ef);
1861        self.expanded.clear();
1862        let take = params.rerank.max(k).min(g.n);
1863        let k0 = key(g.est(qc, entry, w), entry);
1864        self.pool.insert(k0);
1865        if fuse {
1866            self.visited.trace(entry).1.key = k0;
1867        }
1868        evals += 1;
1869        // Best-first expansion of the nearest unexpanded candidate among the `ef` best seen;
1870        // the pool holds exactly those, so the search ends when none is left. With
1871        // `patience`, it also ends after that many expansions without an insert among the
1872        // `k` best.
1873        let patience = params.patience;
1874        let frontier = if knobs.frontier.is_finite() {
1875            knobs.frontier.clamp(0.0, 1.0)
1876        } else {
1877            1.0
1878        };
1879        let mut stale = 0usize;
1880        while let Some(c) = self.pool.next() {
1881            if patience > 0 && stale >= patience {
1882                break;
1883            }
1884            if frontier < 1.0 && k > 0 && self.pool.is_full() {
1885                // Frontier rule: the nearest unexpanded candidate already lies past the point
1886                // `frontier` of the way from the `k`-th best estimate to the `ef`-th. At 1.0
1887                // it never fires (every pool key is at most the `ef`-th).
1888                let len = self.pool.keys.len();
1889                let kth = key_dist(self.pool.keys[k.min(len) - 1]);
1890                let worst = key_dist(self.pool.keys[len - 1]);
1891                if key_dist(c) > kth + frontier * (worst - kth) {
1892                    break;
1893                }
1894            }
1895            if fuse {
1896                // A re-admitted vertex may sit in the pool twice; expand it once.
1897                let (_, t) = self.visited.trace(key_id(c));
1898                if t.expanded {
1899                    continue;
1900                }
1901                t.expanded = true;
1902            }
1903            stale += 1;
1904            if let Some(next) = self.pool.peek_next() {
1905                g.prefetch_neighbours(key_id(next) as usize);
1906                if blocks {
1907                    g.prefetch_blocks(key_id(next) as usize);
1908                    if !block_screen {
1909                        prefetch_bytes(g.code(key_id(next)));
1910                    }
1911                }
1912            }
1913            if knobs.prefetch >= 2 {
1914                if let Some(second) = self.pool.peek_unexpanded(1) {
1915                    g.prefetch_neighbours(key_id(second) as usize);
1916                    if blocks {
1917                        g.prefetch_blocks(key_id(second) as usize);
1918                    }
1919                }
1920            }
1921            if block_screen {
1922                // The neighbour block screens and the pool holds row estimates, so the popped
1923                // key carries the row estimate of `v` (`key` round-trips the f32 bits). A fresh
1924                // neighbour whose block estimate, improved by `screen_sigmas` deviations of
1925                // the calibrated edge error, could not enter the full pool is skipped (it
1926                // stays visited); the survivors' rows are prefetched together, then estimated.
1927                let v = key_id(c) as usize;
1928                let ip_v = -key_dist(c) * sq;
1929                let d2_v = (qn2 + 1.0 - 2.0 * (ip_v + qm)).max(1e-6);
1930                let (delta, min_sum, inv_sq) = (self.bq.delta, self.bq.min_sum, self.bq.inv_sq);
1931                let worst = self.pool.worst().map(key_dist);
1932                let sigmas = params.screen_sigmas;
1933                self.fresh.clear();
1934                let list = &mut self.fresh;
1935                g.for_each_neighbour(v, |u| list.push(u));
1936                self.survivors.clear();
1937                'screen: for b in 0..g.blocks_per_vertex {
1938                    let (codes, scales, offsets) = g.block(v, b);
1939                    scan32(codes, &self.bq.lut, g.block_m, &mut self.scan);
1940                    for i in 0..BLOCK {
1941                        let Some(&u) = self.fresh.get(b * BLOCK + i) else {
1942                            break 'screen;
1943                        };
1944                        if !self.visited.insert(u) {
1945                            continue;
1946                        }
1947                        let s = le_f32(scales, i);
1948                        let ip =
1949                            ip_v + s * (delta * self.scan[i] as f32 + min_sum) + le_f32(offsets, i);
1950                        if let Some(w) = worst {
1951                            let sigma = (g.block_kappa * s * s * d2_v).sqrt();
1952                            if -(ip + sigmas * sigma) * inv_sq >= w {
1953                                self.screened += 1;
1954                                continue;
1955                            }
1956                        }
1957                        prefetch_bytes(g.code(u));
1958                        if knobs.prefetch >= 1 {
1959                            g.prefetch_scale(u);
1960                        }
1961                        self.survivors.push(u);
1962                    }
1963                }
1964                std::mem::swap(&mut self.fresh, &mut self.survivors);
1965                evals += self.score_fresh(g, k, patience, &mut stale, w);
1966                continue;
1967            }
1968            if blocks {
1969                // The expanded vertex is scored with its own code (the per-row estimate the
1970                // plain search uses everywhere) into the result pool. One table scan per
1971                // block then estimates all its neighbours, ordering the exploration; list
1972                // padding is skipped, and a vertex met again is re-admitted only when its
1973                // fused estimate improves on the entry it holds.
1974                let v = key_id(c) as usize;
1975                let est_v = g.est(&self.qc, v as u32, w);
1976                let ek = key(est_v, v as u32);
1977                evals += 1;
1978                // `<q, x_v - mean>` in float units: the estimate dropped the query scale.
1979                let ip_v = -est_v * sq;
1980                let d2_v = (qn2 + 1.0 - 2.0 * (ip_v + qm)).max(1e-6);
1981                if self.expanded.len() < take {
1982                    self.expanded.push(ek);
1983                } else if let Some(mut worst) = self.expanded.peek_mut() {
1984                    if ek < *worst {
1985                        *worst = ek;
1986                    }
1987                }
1988                let (delta, min_sum, inv_sq) = (self.bq.delta, self.bq.min_sum, self.bq.inv_sq);
1989                self.fresh.clear();
1990                let list = &mut self.fresh;
1991                g.for_each_neighbour(v, |u| list.push(u));
1992                'blocks: for b in 0..g.blocks_per_vertex {
1993                    let (codes, scales, offsets) = g.block(v, b);
1994                    scan32(codes, &self.bq.lut, g.block_m, &mut self.scan);
1995                    for i in 0..BLOCK {
1996                        let Some(&u) = self.fresh.get(b * BLOCK + i) else {
1997                            break 'blocks;
1998                        };
1999                        let s = le_f32(scales, i);
2000                        let ip =
2001                            ip_v + s * (delta * self.scan[i] as f32 + min_sum) + le_f32(offsets, i);
2002                        if !fuse {
2003                            // Without fusion the block path keeps the plain path's byte marks
2004                            // (the per-vertex trace records would not stay in cache).
2005                            if !self.visited.insert(u) {
2006                                continue;
2007                            }
2008                            evals += 1;
2009                            if let Some((pos, _)) = self.pool.insert(key(-ip * inv_sq, u)) {
2010                                if patience > 0 && pos < k {
2011                                    stale = 0;
2012                                }
2013                            }
2014                            continue;
2015                        }
2016                        // Precision of this estimate under the calibrated error model.
2017                        let w = 1.0 / (g.block_kappa * s * s * d2_v + 1e-12);
2018                        let (fresh, t) = self.visited.trace(u);
2019                        let ip = if fresh {
2020                            t.w = w;
2021                            t.wsum = w * ip;
2022                            ip
2023                        } else {
2024                            if t.expanded {
2025                                continue;
2026                            }
2027                            t.w += w;
2028                            t.wsum += w * ip;
2029                            t.wsum / t.w
2030                        };
2031                        let kk = key(-ip * inv_sq, u);
2032                        let old = t.key;
2033                        evals += 1;
2034                        if !fresh && kk >= old {
2035                            continue;
2036                        }
2037                        if old != u64::MAX {
2038                            self.pool.remove(old);
2039                        }
2040                        let Some((pos, evicted)) = self.pool.insert(kk) else {
2041                            if old != u64::MAX {
2042                                self.visited.trace(u).1.key = u64::MAX;
2043                            }
2044                            continue;
2045                        };
2046                        self.visited.trace(u).1.key = kk;
2047                        if let Some(e) = evicted {
2048                            self.visited.trace(key_id(e)).1.key = u64::MAX;
2049                        }
2050                        if patience > 0 && pos < k {
2051                            stale = 0;
2052                        }
2053                    }
2054                }
2055                continue;
2056            }
2057            self.fresh.clear();
2058            let (visited, fresh) = (&mut self.visited, &mut self.fresh);
2059            let scale_too = knobs.prefetch >= 1;
2060            let admit = |u: u32, visited: &mut Marks, fresh: &mut Vec<u32>| {
2061                if visited.insert(u) {
2062                    if screen {
2063                        g.prefetch_screen(u);
2064                    } else {
2065                        prefetch_bytes(g.code(u));
2066                        if scale_too {
2067                            g.prefetch_scale(u);
2068                        }
2069                    }
2070                    fresh.push(u);
2071                }
2072            };
2073            if params.neighbour_cap == 0 {
2074                g.for_each_neighbour(key_id(c) as usize, |u| admit(u, visited, fresh));
2075            } else {
2076                let mut seen = 0usize;
2077                g.for_each_neighbour(key_id(c) as usize, |u| {
2078                    seen += 1;
2079                    if seen <= params.neighbour_cap {
2080                        admit(u, visited, fresh);
2081                    }
2082                });
2083            }
2084            if screen {
2085                // A neighbour whose optimistic 1-bit estimate cannot enter the full pool is
2086                // skipped (it stays visited); the rest get the 8-bit estimate.
2087                let worst = self.pool.worst().map(key_dist);
2088                self.survivors.clear();
2089                for &u in &self.fresh {
2090                    if let Some(w) = worst {
2091                        if g.screen_est(&self.qs, u) - margin >= w {
2092                            self.screened += 1;
2093                            continue;
2094                        }
2095                    }
2096                    prefetch_bytes(g.code(u));
2097                    if scale_too {
2098                        g.prefetch_scale(u);
2099                    }
2100                    self.survivors.push(u);
2101                }
2102                std::mem::swap(&mut self.fresh, &mut self.survivors);
2103            }
2104            evals += self.score_fresh(g, k, patience, &mut stale, w);
2105            if knobs.prefetch >= 3 {
2106                // Lookahead: what the next expansion's admission touches for its unvisited
2107                // neighbours, one expansion early (its list was prefetched above).
2108                if let Some(next) = self.pool.peek_next() {
2109                    let visited = &self.visited;
2110                    g.for_each_neighbour(key_id(next) as usize, |u| {
2111                        if !visited.seen(u) {
2112                            if screen {
2113                                g.prefetch_screen(u);
2114                            } else {
2115                                prefetch_bytes(g.code(u));
2116                                g.prefetch_scale(u);
2117                            }
2118                        }
2119                    });
2120                }
2121            }
2122        }
2123
2124        self.keys.clear();
2125        if blocks && !block_screen {
2126            self.keys.extend(self.expanded.drain());
2127            self.keys.sort_unstable();
2128        } else {
2129            self.keys.extend_from_slice(&self.pool.keys);
2130        }
2131        self.results.clear();
2132        let take = take.min(self.keys.len());
2133        if params.rerank > 0 {
2134            if knobs.prefetch >= 1 {
2135                for &c in &self.keys[..take] {
2136                    g.prefetch_rerank_rows(key_id(c));
2137                }
2138            }
2139            // Ties (duplicate vectors) break by the original id, independent of the layout.
2140            self.results.extend(self.keys[..take].iter().map(|&c| {
2141                let id = key_id(c);
2142                (g.fine(&self.qf, id), g.original(id))
2143            }));
2144            evals += take;
2145            self.results
2146                .sort_unstable_by(|a, b| a.0.total_cmp(&b.0).then(a.1.cmp(&b.1)));
2147        } else {
2148            self.results.extend(
2149                self.keys[..take]
2150                    .iter()
2151                    .map(|&c| (0.0, g.original(key_id(c)))),
2152            );
2153        }
2154        out.clear();
2155        out.extend(self.results.iter().take(k).map(|r| r.1));
2156        Ok(evals)
2157    }
2158}
2159
2160/// Format 02 adds the build parameters and a fingerprint of the vectors to the header, so a
2161/// cache written for other data or parameters is rejected instead of silently reused; 03 the
2162/// quantized-search flag; 04 the metric.
2163const CACHE_MAGIC: &[u8; 8] = b"HMSGRF04";
2164
2165/// Fingerprint of the prepared vectors (normalized for angular) a graph was built over.
2166fn data_fingerprint(vectors: &[f32]) -> u64 {
2167    let mut h = super::Fnv::new();
2168    vectors.iter().for_each(|x| h.word(x.to_bits()));
2169    h.finish()
2170}
2171
2172fn put_u32s(w: &mut impl std::io::Write, xs: &[u32]) -> std::io::Result<()> {
2173    w.write_all(&(xs.len() as u64).to_le_bytes())?;
2174    for x in xs {
2175        w.write_all(&x.to_le_bytes())?;
2176    }
2177    Ok(())
2178}
2179
2180fn get_u64(r: &mut impl std::io::Read) -> std::io::Result<u64> {
2181    let mut b = [0u8; 8];
2182    r.read_exact(&mut b)?;
2183    Ok(u64::from_le_bytes(b))
2184}
2185
2186fn get_u32s(r: &mut impl std::io::Read, max: u64) -> std::io::Result<Vec<u32>> {
2187    let len = get_u64(r)?;
2188    if len > max {
2189        return Err(std::io::Error::new(
2190            std::io::ErrorKind::InvalidData,
2191            "graph cache: list longer than the graph",
2192        ));
2193    }
2194    let mut bytes = vec![0u8; len as usize * 4];
2195    r.read_exact(&mut bytes)?;
2196    Ok(bytes
2197        .as_chunks::<4>()
2198        .0
2199        .iter()
2200        .map(|c| u32::from_le_bytes(*c))
2201        .collect())
2202}
2203
2204fn put_lists<'a>(
2205    w: &mut impl std::io::Write,
2206    lists: impl Iterator<Item = &'a [u32]> + Clone,
2207) -> std::io::Result<()> {
2208    let lens: Vec<u32> = lists.clone().map(|l| l.len() as u32).collect();
2209    put_u32s(w, &lens)?;
2210    put_u32s(w, &lists.flatten().copied().collect::<Vec<u32>>())
2211}
2212
2213fn get_lists(r: &mut impl std::io::Read, n: u64) -> std::io::Result<Vec<Vec<u32>>> {
2214    let lens = get_u32s(r, n)?;
2215    let flat = get_u32s(r, lens.iter().map(|&l| u64::from(l)).sum())?;
2216    let mut out = Vec::with_capacity(lens.len());
2217    let mut at = 0;
2218    for l in lens {
2219        out.push(flat[at..at + l as usize].to_vec());
2220        at += l as usize;
2221    }
2222    Ok(out)
2223}
2224
2225/// Benchmark support: the graph structure (not the vectors) on disk, so that search
2226/// experiments on one graph need not rebuild it.
2227impl Graph {
2228    /// Writes the header (n, dim, degree, build_ef, alpha, seed, entry, data fingerprint,
2229    /// quantized_search, metric), the adjacency and the upper layers; the vectors are not
2230    /// stored.
2231    pub fn save(&self, path: &std::path::Path) -> std::io::Result<()> {
2232        let mut w = std::io::BufWriter::new(std::fs::File::create(path)?);
2233        use std::io::Write;
2234        w.write_all(CACHE_MAGIC)?;
2235        let header = [
2236            self.n as u64,
2237            self.dim as u64,
2238            self.degree as u64,
2239            self.build.build_ef as u64,
2240            u64::from(self.build.alpha.to_bits()),
2241            self.build.seed,
2242            u64::from(self.entry),
2243            data_fingerprint(&self.vectors),
2244            u64::from(self.build.quantized_search),
2245            self.metric.code(),
2246        ];
2247        for x in header {
2248            w.write_all(&x.to_le_bytes())?;
2249        }
2250        put_lists(&mut w, self.adj.iter())?;
2251        put_u32s(&mut w, &self.upper_ids)?;
2252        w.write_all(&(self.layers.len() as u64).to_le_bytes())?;
2253        for l in &self.layers {
2254            put_lists(&mut w, l.iter().map(Vec::as_slice))?;
2255        }
2256        w.flush()
2257    }
2258
2259    /// Reads a graph written by [`Graph::save`] for the same `data` (prepared here exactly
2260    /// as [`Graph::build`] does) and the same `params`. Any mismatch in the vector count,
2261    /// dimension, degree, build_ef, alpha, seed, quantized search, metric or the data
2262    /// fingerprint is an error, so a stale cache never stands in for a build; delete the file
2263    /// to rebuild.
2264    pub fn load(
2265        data: &[f32],
2266        dim: usize,
2267        path: &std::path::Path,
2268        params: &BuildParams,
2269    ) -> std::io::Result<Self> {
2270        let bad = |m: String| std::io::Error::new(std::io::ErrorKind::InvalidData, m);
2271        let mut r = std::io::BufReader::new(std::fs::File::open(path)?);
2272        let mut magic = [0u8; 8];
2273        std::io::Read::read_exact(&mut r, &mut magic)?;
2274        if &magic != CACHE_MAGIC {
2275            return Err(bad(
2276                "graph cache: bad magic (an older format or not a cache file); delete it to rebuild"
2277                    .into(),
2278            ));
2279        }
2280        let mut header = [0u64; 10];
2281        for x in &mut header {
2282            *x = get_u64(&mut r)?;
2283        }
2284        let [n, d, degree, build_ef, alpha_bits, seed, entry, fingerprint, quantized, metric] =
2285            header;
2286        if dim == 0 || !data.len().is_multiple_of(dim) {
2287            return Err(bad("graph cache: ragged data".into()));
2288        }
2289        let mut vectors = data.to_vec();
2290        let norms = prepare_rows(&mut vectors, dim, params.metric);
2291        let checks: [(&str, u64, u64); 9] = [
2292            ("metric", metric, params.metric.code()),
2293            ("n", n, (data.len() / dim) as u64),
2294            ("dim", d, dim as u64),
2295            ("degree", degree, params.degree as u64),
2296            ("build_ef", build_ef, params.build_ef as u64),
2297            ("alpha", alpha_bits, u64::from(params.alpha.to_bits())),
2298            ("seed", seed, params.seed),
2299            ("data fingerprint", fingerprint, data_fingerprint(&vectors)),
2300            (
2301                "quantized_search",
2302                quantized,
2303                u64::from(params.quantized_search),
2304            ),
2305        ];
2306        if let Some((what, file, want)) = checks.iter().find(|(_, a, b)| a != b) {
2307            let show = |x: u64| {
2308                if *what == "alpha" {
2309                    f32::from_bits(x as u32).to_string()
2310                } else {
2311                    format!("{x:#x}")
2312                }
2313            };
2314            return Err(bad(format!(
2315                "graph cache: {what} {} in the file, {} requested; delete the file to rebuild",
2316                show(*file),
2317                show(*want)
2318            )));
2319        }
2320        let n = n as usize;
2321        if entry >= n as u64 {
2322            return Err(bad("graph cache: entry out of range".into()));
2323        }
2324        let lens = get_u32s(&mut r, n as u64)?;
2325        let flat = get_u32s(&mut r, lens.iter().map(|&l| u64::from(l)).sum())?;
2326        let adj = Adjacency::from_flat(lens, &flat, degree as usize)
2327            .ok_or_else(|| bad("graph cache: list longer than the degree".into()))?;
2328        drop(flat);
2329        let upper_ids = get_u32s(&mut r, n as u64)?;
2330        let nl = get_u64(&mut r)?;
2331        if nl > 64 {
2332            return Err(bad("graph cache: too many layers".into()));
2333        }
2334        let layers = (0..nl)
2335            .map(|_| get_lists(&mut r, n as u64))
2336            .collect::<std::io::Result<Vec<_>>>()?;
2337        let ok = adj.len() == n
2338            && adj.iter().flatten().all(|&u| (u as usize) < n)
2339            && upper_ids.iter().all(|&u| (u as usize) < n)
2340            && layers.iter().all(|l| {
2341                l.len() <= upper_ids.len() && l.iter().flatten().all(|&u| (u as usize) < l.len())
2342            });
2343        if !ok {
2344            return Err(bad("graph cache: id out of range".into()));
2345        }
2346        Ok(Self {
2347            n,
2348            dim,
2349            degree: degree as usize,
2350            metric: params.metric,
2351            vectors,
2352            norms,
2353            adj,
2354            entry: entry as u32,
2355            upper_ids,
2356            layers,
2357            build: params.clone(),
2358        })
2359    }
2360}
2361
2362#[cfg(test)]
2363mod tests {
2364    use super::super::kernels::SplitMix;
2365    use super::super::tests::{brute_force, clustered, gaussian};
2366    use super::*;
2367
2368    /// The cache round-trips the structure for the same data and parameters, and rejects a
2369    /// file written for other data or for any other build parameter.
2370    #[test]
2371    fn graph_cache_round_trips_and_rejects_mismatches() {
2372        let d = 16;
2373        let data = clustered(600, d, 4);
2374        let params = BuildParams {
2375            build_ef: 32,
2376            degree: 8,
2377            alpha: 1.1,
2378            seed: 9,
2379            ..BuildParams::default()
2380        };
2381        let graph = Graph::build(&data, d, &params).expect("build");
2382        let path = std::env::temp_dir().join(format!("hms-qgraph-cache-{}", std::process::id()));
2383        graph.save(&path).unwrap();
2384        let back = Graph::load(&data, d, &path, &params).unwrap();
2385        assert_eq!(back.structure_hash(), graph.structure_hash());
2386        assert!(back.adj == graph.adj && back.layers == graph.layers);
2387        assert_eq!(
2388            (back.entry, &back.upper_ids),
2389            (graph.entry, &graph.upper_ids)
2390        );
2391        assert_eq!(back.vectors, graph.vectors);
2392        let rejects = |data: &[f32], p: &BuildParams, what: &str| {
2393            let err = Graph::load(data, d, &path, p)
2394                .err()
2395                .expect("a mismatched cache was accepted")
2396                .to_string();
2397            assert!(err.contains(what), "{what}: {err}");
2398        };
2399        for (what, p) in [
2400            (
2401                "degree",
2402                BuildParams {
2403                    degree: 10,
2404                    ..params.clone()
2405                },
2406            ),
2407            (
2408                "build_ef",
2409                BuildParams {
2410                    build_ef: 33,
2411                    ..params.clone()
2412                },
2413            ),
2414            (
2415                "alpha",
2416                BuildParams {
2417                    alpha: 1.0,
2418                    ..params.clone()
2419                },
2420            ),
2421            (
2422                "seed",
2423                BuildParams {
2424                    seed: 10,
2425                    ..params.clone()
2426                },
2427            ),
2428            (
2429                "metric",
2430                BuildParams {
2431                    metric: Metric::Euclidean,
2432                    ..params.clone()
2433                },
2434            ),
2435        ] {
2436            rejects(&data, &p, what);
2437        }
2438        let mut other = data.clone();
2439        other[d * 7 + 3] += 0.5;
2440        rejects(&other, &params, "data fingerprint");
2441        rejects(&data[..d * 599], &params, "n");
2442        std::fs::write(&path, b"HMSGRF01").unwrap();
2443        rejects(&data, &params, "bad magic");
2444        std::fs::remove_file(&path).unwrap();
2445    }
2446
2447    /// On clustered rows whose norms spread over [0.1, 10], a Euclidean index (4-bit codes,
2448    /// 8-bit residual re-rank, reordered 3-byte ids: the benchmark layout) finds the exact
2449    /// Euclidean neighbours, while an angular index over the same rows, which ranks by
2450    /// direction alone, clearly does not.
2451    #[test]
2452    fn euclidean_index_finds_euclidean_neighbours() {
2453        let (d, n, k) = (32, 4000, 10);
2454        let spread = |rows: &mut [f32], salt: usize| {
2455            let count = rows.len() / d;
2456            for (i, x) in rows.chunks_exact_mut(d).enumerate() {
2457                let s = 0.1 * 100f32.powf(((i * 7919 + salt) % count) as f32 / count as f32);
2458                x.iter_mut().for_each(|v| *v *= s);
2459            }
2460        };
2461        // Queries are held-out rows of the same clusters.
2462        let mut data = clustered(n + 100, d, 7);
2463        spread(&mut data, 0);
2464        let queries = data.split_off(n * d);
2465        let l2 = |a: &[f32], b: &[f32]| -> f64 {
2466            a.iter()
2467                .zip(b)
2468                .map(|(x, y)| (f64::from(*x) - f64::from(*y)).powi(2))
2469                .sum()
2470        };
2471        let recall = |metric: Metric| -> f64 {
2472            let params = BuildParams {
2473                metric,
2474                degree: 32,
2475                build_ef: 100,
2476                ..BuildParams::default()
2477            };
2478            let g = Graph::build(&data, d, &params).expect("build");
2479            let index = VGraph::from_graph_with(&g, 4, 8, true, true, 3, false).expect("encode");
2480            let mut s = index.searcher_with(SearchKnobs::OFF);
2481            let mut out = Vec::new();
2482            let mut hits = 0;
2483            for q in queries.chunks_exact(d) {
2484                let mut all: Vec<(f64, u32)> = data
2485                    .chunks_exact(d)
2486                    .enumerate()
2487                    .map(|(i, x)| (l2(q, x), i as u32))
2488                    .collect();
2489                all.sort_by(|a, b| a.0.total_cmp(&b.0));
2490                let truth: Vec<u32> = all[..k].iter().map(|t| t.1).collect();
2491                let params = VSearchParams {
2492                    ef: 64,
2493                    rerank: 32,
2494                    ..VSearchParams::default()
2495                };
2496                s.search(q, k, params, &mut out).expect("search");
2497                hits += out.iter().filter(|x| truth.contains(x)).count();
2498            }
2499            hits as f64 / (k * 100) as f64
2500        };
2501        let (euclidean, angular) = (recall(Metric::Euclidean), recall(Metric::Angular));
2502        assert!(euclidean >= 0.95, "Euclidean recall {euclidean}");
2503        assert!(
2504            angular < euclidean - 0.3,
2505            "angular recall {angular} against Euclidean {euclidean}"
2506        );
2507    }
2508
2509    /// A Euclidean index refuses the angular-only encodings and accepts a zero query (its
2510    /// nearest rows are the ones of smallest norm).
2511    #[test]
2512    fn euclidean_index_limits() {
2513        let d = 16;
2514        let mut data = clustered(500, d, 2);
2515        for (i, x) in data.chunks_exact_mut(d).enumerate() {
2516            x.iter_mut().for_each(|v| *v *= 1.0 + i as f32 / 50.0);
2517        }
2518        let params = BuildParams {
2519            metric: Metric::Euclidean,
2520            degree: 16,
2521            build_ef: 64,
2522            ..BuildParams::default()
2523        };
2524        let g = Graph::build(&data, d, &params).expect("build");
2525        assert!(VGraph::from_graph_with(&g, 8, 0, false, false, 4, true).is_err());
2526        assert!(super::super::QGraph::from_graph(&g, &params).is_err());
2527        let blocks = VGraph::from_graph(&g, false)
2528            .expect("encode")
2529            .with_blocks(&g, 4);
2530        assert!(blocks.is_err());
2531        let index = VGraph::from_graph(&g, true).expect("encode");
2532        let mut out = Vec::new();
2533        let params = VSearchParams {
2534            ef: 500,
2535            rerank: 500,
2536            ..VSearchParams::default()
2537        };
2538        index
2539            .searcher_with(SearchKnobs::OFF)
2540            .search(&[0.0; 16], 1, params, &mut out)
2541            .expect("zero query");
2542        let smallest = data
2543            .chunks_exact(d)
2544            .enumerate()
2545            .min_by(|a, b| {
2546                let n2 = |x: &[f32]| x.iter().map(|v| v * v).sum::<f32>();
2547                n2(a.1).total_cmp(&n2(b.1))
2548            })
2549            .map(|m| m.0 as u32);
2550        assert_eq!(out.first().copied(), smallest);
2551    }
2552
2553    /// Per coordinate the 8-bit code is off by at most half a step, and the residual code
2554    /// shrinks that error by about another factor of 127.
2555    #[test]
2556    fn quantization_error_is_bounded() {
2557        let mut rng = SplitMix(12);
2558        let x: Vec<f32> = (0..100).map(|_| gaussian(&mut rng)).collect();
2559        let mut c = vec![0i8; 112];
2560        let s = quantize(&x, &mut c);
2561        let mut r: Vec<f32> = x
2562            .iter()
2563            .zip(&c)
2564            .map(|(a, &q)| a - s * f32::from(q))
2565            .collect();
2566        assert!(r.iter().all(|e| e.abs() <= s / 2.0 + 1e-6));
2567        assert!(c[100..].iter().all(|&q| q == 0));
2568        let mut rc = vec![0i8; 112];
2569        let sr = quantize(&r, &mut rc);
2570        r.iter_mut()
2571            .zip(&rc)
2572            .for_each(|(a, &q)| *a -= sr * f32::from(q));
2573        assert!(r.iter().all(|e| e.abs() <= s / 254.0 + 1e-6));
2574        assert_eq!(quantize(&[0.0; 4], &mut c[..16]), 0.0);
2575    }
2576
2577    fn recall(index: &VGraph, data: &[f32], queries: &[f32], d: usize, p: VSearchParams) -> f64 {
2578        let mut s = index.searcher();
2579        let mut out = Vec::new();
2580        let mut hits = 0;
2581        for q in queries.chunks_exact(d) {
2582            let truth = brute_force(data, d, q, 10);
2583            s.search(q, 10, p, &mut out).expect("search");
2584            assert_eq!(out.len(), 10);
2585            hits += out.iter().filter(|x| truth.contains(x)).count();
2586        }
2587        hits as f64 / (queries.len() / d * 10) as f64
2588    }
2589
2590    #[test]
2591    fn recall_on_clustered_data() {
2592        let d = 24;
2593        let data = clustered(4000, d, 1);
2594        let queries = clustered(100, d, 2);
2595        let graph = Graph::build(&data, d, &BuildParams::default()).expect("build");
2596        let plain = VGraph::from_graph(&graph, false).expect("encode");
2597        let fine = VGraph::from_graph(&graph, true).expect("encode");
2598        assert!(plain.index_bytes() < fine.index_bytes());
2599        let pool = VSearchParams {
2600            ef: 64,
2601            ..VSearchParams::default()
2602        };
2603        let rr = VSearchParams {
2604            ef: 64,
2605            rerank: 32,
2606            ..VSearchParams::default()
2607        };
2608        let r0 = recall(&plain, &data, &queries, d, pool);
2609        let r1 = recall(&fine, &data, &queries, d, rr);
2610        // Same regression guard as the edge-code index on this deliberately hard set.
2611        assert!(r0 >= 0.9, "recall {r0}");
2612        assert!(r1 >= r0, "rerank {r1} below pool order {r0}");
2613        let fine4 = VGraph::from_graph_with(&graph, 8, 4, false, false, 4, false).expect("encode");
2614        assert!(plain.index_bytes() < fine4.index_bytes());
2615        assert!(fine4.index_bytes() < fine.index_bytes());
2616        let r2 = recall(&fine4, &data, &queries, d, rr);
2617        assert!(r2 >= r0 - 0.02, "4-bit rerank {r2} below pool order {r0}");
2618        let lvq48 = VGraph::from_graph_with(&graph, 4, 8, true, false, 4, false).expect("encode");
2619        assert!(lvq48.index_bytes() < fine.index_bytes());
2620        // A sanity bound: 4-bit codes of 24 coordinates are coarse, so a wider pool and re-rank.
2621        let wide = VSearchParams {
2622            ef: 96,
2623            rerank: 64,
2624            ..VSearchParams::default()
2625        };
2626        let r3 = recall(&lvq48, &data, &queries, d, wide);
2627        assert!(
2628            r3 >= r0 - 0.05,
2629            "4-bit traversal with 8-bit residual {r3} vs {r0}"
2630        );
2631    }
2632
2633    /// Neighbour-block codes estimate every neighbour from the expanded vertex's record; the
2634    /// traversal must reach the same recall band as the per-row 8-bit estimates after the
2635    /// residual re-rank, at every block width, with and without renumbering or 3-byte ids.
2636    #[test]
2637    fn block_codes_keep_recall() {
2638        let d = 24;
2639        let data = clustered(4000, d, 1);
2640        let queries = clustered(100, d, 2);
2641        let graph = Graph::build(&data, d, &BuildParams::default()).expect("build");
2642        let plain = VGraph::from_graph(&graph, true).expect("encode");
2643        let rr = VSearchParams {
2644            ef: 96,
2645            rerank: 64,
2646            ..VSearchParams::default()
2647        };
2648        let r0 = recall(&plain, &data, &queries, d, rr);
2649        for bits in [4u8, 2, 1] {
2650            for (reorder, id_bytes) in [(false, 4u8), (true, 3)] {
2651                let b = VGraph::from_graph_with(&graph, 8, 8, true, reorder, id_bytes, false)
2652                    .expect("encode")
2653                    .with_blocks(&graph, bits)
2654                    .expect("blocks");
2655                assert!(b.has_blocks());
2656                assert!(b.index_bytes() > plain.index_bytes());
2657                let r = recall(&b, &data, &queries, d, rr);
2658                // 24 coordinates are few for 1-bit codes (a 1/sqrt(dim) relative error on
2659                // this deliberately hard set), so the band widens with coarseness; the
2660                // estimator itself is checked against exact inner products below.
2661                let slack = match bits {
2662                    4 => 0.03,
2663                    2 => 0.06,
2664                    _ => 0.30,
2665                };
2666                assert!(
2667                    r >= r0 - slack,
2668                    "{bits}-bit blocks (reorder {reorder}, ids {id_bytes}) recall {r} vs {r0}"
2669                );
2670            }
2671        }
2672        let e = match VGraph::from_graph(&graph, true)
2673            .expect("encode")
2674            .with_blocks(&graph, 3)
2675        {
2676            Ok(_) => panic!("3 bits accepted"),
2677            Err(e) => e.to_string(),
2678        };
2679        assert!(e.contains("block bits"), "{e}");
2680    }
2681
2682    /// The block estimate of a neighbour, in `est` units, must track the exact centred inner
2683    /// product: Pearson correlation over the neighbours of 64 vertices above a per-width bar.
2684    #[test]
2685    fn block_estimates_track_exact_inner_products() {
2686        let d = 64;
2687        let data = clustered(3000, d, 5);
2688        let graph = Graph::build(&data, d, &BuildParams::default()).expect("build");
2689        // The index works on unit rows; so must the exact products.
2690        let unit = &graph.vectors;
2691        for (bits, bar) in [(4u8, 0.998), (2, 0.98), (1, 0.90)] {
2692            let g = VGraph::from_graph(&graph, false)
2693                .expect("encode")
2694                .with_blocks(&graph, bits)
2695                .expect("blocks");
2696            let mut s = g.searcher();
2697            let q: Vec<f32> = unit[7 * d..8 * d].to_vec();
2698            s.scratch.qf[..d].copy_from_slice(&q);
2699            let sq = g.encode_query(
2700                &s.scratch.qf,
2701                &mut s.scratch.qc,
2702                &mut s.scratch.qtmp,
2703                &mut s.scratch.qrot,
2704                &mut s.scratch.qs,
2705            );
2706            g.encode_block_query(&s.scratch.qf, sq, &mut s.scratch.bqrot, &mut s.scratch.bq);
2707            let centred =
2708                |u: usize| -> f32 { (0..d).map(|j| q[j] * (unit[u * d + j] - g.mean[j])).sum() };
2709            let (mut xs, mut ys) = (Vec::new(), Vec::new());
2710            for v in 0..64usize {
2711                // The exact `<q, x_v - mean>` stands in for the expanded vertex's estimate.
2712                let ip_v = centred(v);
2713                let mut list = Vec::new();
2714                g.for_each_neighbour(v, |u| list.push(u));
2715                for b in 0..g.blocks_per_vertex {
2716                    let (codes, scales, offsets) = g.block(v, b);
2717                    scan32(codes, &s.scratch.bq.lut, g.block_m, &mut s.scratch.scan);
2718                    for i in 0..BLOCK {
2719                        let Some(&u) = list.get(b * BLOCK + i) else {
2720                            break;
2721                        };
2722                        let ip = ip_v
2723                            + le_f32(scales, i)
2724                                * (s.scratch.bq.delta * s.scratch.scan[i] as f32
2725                                    + s.scratch.bq.min_sum)
2726                            + le_f32(offsets, i);
2727                        xs.push(f64::from(ip));
2728                        ys.push(f64::from(centred(u as usize)));
2729                    }
2730                }
2731            }
2732            let n = xs.len() as f64;
2733            let (mx, my) = (xs.iter().sum::<f64>() / n, ys.iter().sum::<f64>() / n);
2734            let (mut sxy, mut sxx, mut syy) = (0.0, 0.0, 0.0);
2735            for (x, y) in xs.iter().zip(&ys) {
2736                sxy += (x - mx) * (y - my);
2737                sxx += (x - mx) * (x - mx);
2738                syy += (y - my) * (y - my);
2739            }
2740            let r = sxy / (sxx * syy).sqrt();
2741            eprintln!(
2742                "{bits}-bit block estimates: correlation {r:.4} over {} pairs",
2743                xs.len()
2744            );
2745            assert!(
2746                r > bar,
2747                "{bits}-bit block estimates: correlation {r} below {bar}"
2748            );
2749        }
2750    }
2751
2752    /// Delta-coded lists hold the same neighbour sets as the 4-byte ones, in fewer bytes,
2753    /// and the search returns the same ids; the block index decodes them in the same order
2754    /// it was built in.
2755    #[test]
2756    fn delta_coded_adjacency_round_trips() {
2757        let d = 24;
2758        let data = clustered(4000, d, 1);
2759        let queries = clustered(50, d, 2);
2760        let graph = Graph::build(&data, d, &BuildParams::default()).expect("build");
2761        let wide = VGraph::from_graph_with(&graph, 4, 8, true, true, 4, false).expect("encode");
2762        let delta = VGraph::from_graph_with(&graph, 4, 8, true, true, 0, false).expect("encode");
2763        assert!(
2764            delta.index_bytes() < wide.index_bytes(),
2765            "{} vs {}",
2766            delta.index_bytes(),
2767            wide.index_bytes()
2768        );
2769        for v in 0..wide.n {
2770            let (mut a, mut b) = (Vec::new(), Vec::new());
2771            wide.for_each_neighbour(v, |u| a.push(u));
2772            delta.for_each_neighbour(v, |u| b.push(u));
2773            a.sort_unstable();
2774            assert_eq!(a, b, "vertex {v}");
2775        }
2776        let p = VSearchParams {
2777            ef: 48,
2778            rerank: 16,
2779            ..VSearchParams::default()
2780        };
2781        let (mut s1, mut s2) = (wide.searcher(), delta.searcher());
2782        let (mut o1, mut o2) = (Vec::new(), Vec::new());
2783        for q in queries.chunks_exact(d) {
2784            s1.search(q, 10, p, &mut o1).expect("search");
2785            s2.search(q, 10, p, &mut o2).expect("search");
2786            assert_eq!(o1, o2);
2787        }
2788        let blocks = VGraph::from_graph_with(&graph, 8, 8, true, true, 0, false)
2789            .expect("encode")
2790            .with_blocks(&graph, 2)
2791            .expect("blocks");
2792        assert!(recall(&blocks, &data, &queries, d, p) > 0.8);
2793        assert_eq!(zigzag(-1), 1);
2794        assert_eq!(unzigzag(zigzag(-123_456)), -123_456);
2795        let mut buf = Vec::new();
2796        put_varint(&mut buf, 300);
2797        assert_eq!(get_varint(&buf), (300, 2));
2798    }
2799
2800    /// Renumbering and aligning the rows change the layout, not the search: the same ids come
2801    /// back (up to ties between equal estimates, which break by the stored id).
2802    #[test]
2803    fn reordered_aligned_index_returns_the_same_ids() {
2804        let d = 24;
2805        let data = clustered(3000, d, 5);
2806        let graph = Graph::build(&data, d, &BuildParams::default()).expect("build");
2807        let a = VGraph::from_graph(&graph, true).expect("encode");
2808        let b = VGraph::from_graph_with(&graph, 8, 8, true, true, 4, false).expect("encode");
2809        // 32-byte rows already sit inside one line; the id map is the only extra.
2810        assert_eq!(b.index_bytes(), a.index_bytes() + b.n * 4);
2811        let (mut sa, mut sb) = (a.searcher(), b.searcher());
2812        let (mut oa, mut ob) = (Vec::new(), Vec::new());
2813        let c = VGraph::from_graph_with(&graph, 8, 8, true, true, 3, false).expect("encode");
2814        assert_eq!(c.index_bytes(), b.index_bytes() - c.n * c.degree);
2815        let mut sc = c.searcher();
2816        let mut oc = Vec::new();
2817        let mut same = 0;
2818        for q in data.chunks_exact(d).take(200) {
2819            for p in [
2820                VSearchParams {
2821                    ef: 32,
2822                    ..VSearchParams::default()
2823                },
2824                VSearchParams {
2825                    ef: 48,
2826                    rerank: 16,
2827                    ..VSearchParams::default()
2828                },
2829            ] {
2830                sa.search(q, 10, p, &mut oa).expect("search");
2831                sb.search(q, 10, p, &mut ob).expect("search");
2832                sc.search(q, 10, p, &mut oc).expect("search");
2833                assert_eq!(ob, oc, "3-byte ids changed the search");
2834                same += usize::from(oa == ob);
2835            }
2836        }
2837        assert!(same >= 396, "{same} of 400 searches agree");
2838    }
2839
2840    #[test]
2841    fn build_and_search_are_deterministic() {
2842        let d = 20;
2843        let data = clustered(3000, d, 3);
2844        let params = BuildParams {
2845            seed: 9,
2846            ..BuildParams::default()
2847        };
2848        let a = VGraph::from_graph(&Graph::build(&data, d, &params).expect("build"), true)
2849            .expect("encode");
2850        let b = VGraph::from_graph(&Graph::build(&data, d, &params).expect("build"), true)
2851            .expect("encode");
2852        assert!(
2853            a.codes.bytes() == b.codes.bytes()
2854                && a.residual[..] == b.residual[..]
2855                && a.adj[..] == b.adj[..]
2856        );
2857        assert!(a.scales[..] == b.scales[..] && a.res_scales[..] == b.res_scales[..]);
2858        assert_eq!((a.entry, &a.mean), (b.entry, &b.mean));
2859        let (mut sa, mut sb) = (a.searcher(), b.searcher());
2860        let (mut oa, mut ob) = (Vec::new(), Vec::new());
2861        let p = VSearchParams {
2862            ef: 32,
2863            rerank: 16,
2864            ..VSearchParams::default()
2865        };
2866        for q in data.chunks_exact(d).take(50) {
2867            sa.search(q, 10, p, &mut oa).expect("search");
2868            sb.search(q, 10, p, &mut ob).expect("search");
2869            assert_eq!(oa, ob);
2870        }
2871    }
2872
2873    /// Every failure mode is an error that names its cause; no input panics.
2874    #[test]
2875    fn invalid_inputs_are_errors_that_name_the_cause() {
2876        let d = 16;
2877        let data = clustered(500, d, 4);
2878        let err = |r: Result<Graph, HmsError>| match r {
2879            Err(e) => e.to_string(),
2880            Ok(_) => panic!("an invalid input was accepted"),
2881        };
2882        assert!(err(Graph::build(&[], d, &BuildParams::default())).contains("empty"));
2883        assert!(err(Graph::build(&data[..d + 1], d, &BuildParams::default())).contains("ragged"));
2884        assert!(err(Graph::build(&data, 0, &BuildParams::default())).contains("ragged"));
2885        let mut bad = data.clone();
2886        bad[3 * d + 2] = f32::NAN;
2887        let e = err(Graph::build(&bad, d, &BuildParams::default()));
2888        assert!(e.contains("non-finite") && e.contains("row 3"), "{e}");
2889        let odd = BuildParams {
2890            degree: 7,
2891            ..BuildParams::default()
2892        };
2893        assert!(err(Graph::build(&data, d, &odd)).contains("even"));
2894        let graph = Graph::build(&data, d, &BuildParams::default()).expect("build");
2895        let e = match VGraph::from_graph_with(&graph, 5, 8, false, false, 4, false) {
2896            Err(e) => e.to_string(),
2897            Ok(_) => panic!("bits 5 was accepted"),
2898        };
2899        assert!(e.contains("bits"), "{e}");
2900        let index = VGraph::from_graph(&graph, true).expect("encode");
2901        let mut s = index.searcher();
2902        let mut out = Vec::new();
2903        let p = VSearchParams::default();
2904        let e = s
2905            .search(&data[..d - 1], 10, p, &mut out)
2906            .expect_err("short query")
2907            .to_string();
2908        assert!(e.contains("dimension"), "{e}");
2909        let mut q = data[..d].to_vec();
2910        q[5] = f32::INFINITY;
2911        let e = s.search(&q, 10, p, &mut out).expect_err("inf").to_string();
2912        assert!(e.contains("non-finite") && e.contains('5'), "{e}");
2913        let e = s
2914            .search(&vec![0.0; d], 10, p, &mut out)
2915            .expect_err("zero")
2916            .to_string();
2917        assert!(e.contains("zero"), "{e}");
2918        // Adversarial sizes stay bounded by the vertex count: a huge ef and k finish and
2919        // return at most n ids.
2920        let huge = VSearchParams {
2921            ef: usize::MAX,
2922            rerank: usize::MAX,
2923            ..VSearchParams::default()
2924        };
2925        let evals = s.search(&data[..d], 10, huge, &mut out).expect("search");
2926        assert_eq!(out.len(), 10);
2927        assert!(evals <= 2 * index.len() + 10);
2928        s.search(&data[..d], 5000, huge, &mut out).expect("search");
2929        assert!(out.len() <= index.len());
2930        // A zero data row is allowed and never returned ahead of real neighbours by a NaN.
2931        let mut with_zero = data.clone();
2932        with_zero[..d].fill(0.0);
2933        let g0 = Graph::build(&with_zero, d, &BuildParams::default()).expect("build");
2934        let i0 = VGraph::from_graph(&g0, true).expect("encode");
2935        let mut s0 = i0.searcher();
2936        s0.search(&data[d..2 * d], 10, p, &mut out).expect("search");
2937        assert_eq!(out.len(), 10);
2938        assert!(out.iter().all(|&v| (v as usize) < i0.len()));
2939    }
2940
2941    /// The screen only decides which neighbours get the 8-bit estimate: with a wide margin
2942    /// it skips nothing and the ids are identical; at one sigma recall stays close.
2943    #[test]
2944    fn screen_is_transparent_at_a_wide_margin() {
2945        let d = 24;
2946        let data = clustered(3000, d, 5);
2947        let queries = clustered(100, d, 2);
2948        let graph = Graph::build(&data, d, &BuildParams::default()).expect("build");
2949        let plain = VGraph::from_graph(&graph, true).expect("encode");
2950        let screened =
2951            VGraph::from_graph_with(&graph, 8, 8, false, false, 4, true).expect("encode");
2952        assert!(screened.has_screen() && screened.index_bytes() > plain.index_bytes());
2953        let (mut sa, mut sb) = (plain.searcher(), screened.searcher());
2954        let (mut oa, mut ob) = (Vec::new(), Vec::new());
2955        let wide = VSearchParams {
2956            ef: 64,
2957            rerank: 16,
2958            screen_sigmas: 1e6,
2959            ..VSearchParams::default()
2960        };
2961        for q in queries.chunks_exact(d) {
2962            let ea = sa.search(q, 10, wide, &mut oa).expect("search");
2963            let eb = sb.search(q, 10, wide, &mut ob).expect("search");
2964            assert_eq!((ea, &oa), (eb, &ob));
2965            assert_eq!(sb.last_screened(), 0);
2966        }
2967        let one = VSearchParams {
2968            ef: 64,
2969            rerank: 16,
2970            ..VSearchParams::default()
2971        };
2972        let r0 = recall(&plain, &data, &queries, d, one);
2973        let r1 = recall(&screened, &data, &queries, d, one);
2974        assert!(r1 >= r0 - 0.02, "screened {r1} vs {r0}");
2975        let mut skipped = 0;
2976        for q in queries.chunks_exact(d) {
2977            sb.search(q, 10, one, &mut ob).expect("search");
2978            skipped += sb.last_screened();
2979        }
2980        assert!(skipped > 0, "the screen never skipped a neighbour");
2981    }
2982}