Skip to main content

hermes_core/structures/vector/ivf/
routing.rs

1//! Metric-agnostic IVF routing primitives.
2//!
3//! Quantizers provide metric-specific centroid scores. This module owns the
4//! topology-independent parts: flat/two-level policy, bounded beam sizing,
5//! deterministic top selection, and the versioned probe plan shared by every
6//! segment participating in one query.
7
8use std::cmp::Reverse;
9use std::collections::BinaryHeap;
10use std::sync::Arc;
11
12use crate::dsl::IvfRoutingMode;
13use crate::structures::vector::progress::PhaseProgress;
14use rand::prelude::*;
15use serde::{Deserialize, Serialize};
16
17/// Automatic routing switches to a centroid graph at this leaf count.
18///
19/// Float centroids are wide (a 768-dim leaf is 3 KiB), so a flat pass over them
20/// gets expensive at far fewer leaves than for packed binary codes. This value
21/// is the historical one and is *not* backed by a crossover measurement; see
22/// [`BINARY_HNSW_AUTO_THRESHOLD`] for the measured binary counterpart, and
23/// measure the float case the same way before changing this.
24pub const HNSW_AUTO_THRESHOLD: usize = 4_096;
25
26/// Automatic routing switch for packed binary centroids.
27///
28/// A base-layer search with beam `ef` and degree `2 * HNSW_M` touches on the
29/// order of `ef * 2M` adjacency slots, so below roughly that many leaves it
30/// visits the whole codebook anyway — by random access with heap traffic, and
31/// approximately, where the flat pass is one sequential SIMD scan that is
32/// *exact*. Measured on 2,560-bit binary centroids at `nprobe = 64`
33/// (`benches/binary_vectors.rs`, `binary_routing_crossover`, aarch64/NEON):
34///
35/// | leaves | flat probe | graph probe |
36/// |-------:|-----------:|------------:|
37/// |  4,096 |    28.6 µs |    116.3 µs |
38/// | 16,384 |   126.0 µs |    212.9 µs |
39/// | 32,768 |   249.3 µs |    255.1 µs |
40/// | 65,536 |   513.3 µs |    444.6 µs |
41///
42/// The graph only starts paying off past ~32k leaves, so that is the switch.
43/// Explicit `hnsw` routing is still honoured at any size.
44///
45/// Hierarchical *training* has its own threshold — see
46/// [`HIERARCHICAL_TRAINING_THRESHOLD`] — because O(N·K) seeding becomes
47/// unaffordable long before graph routing becomes profitable.
48pub const BINARY_HNSW_AUTO_THRESHOLD: usize = 32_768;
49
50/// Codebook size past which coarse training becomes hierarchical.
51///
52/// Direct k-means/k-majority seeding costs one full pass over the sample per
53/// centroid; beyond a few thousand centroids that dominates training, so large
54/// codebooks train as `sqrt(K)` parents plus per-parent child codebooks
55/// regardless of which router is used at query time.
56pub const HIERARCHICAL_TRAINING_THRESHOLD: usize = 4_096;
57
58/// Extra leaf coverage requested from the parent level. A beam of four times
59/// the minimum parent count avoids the recall cliff of greedy one-parent
60/// hierarchical routing while keeping parent/leaf scoring sublinear.
61const PARENT_BEAM_OVERSAMPLE: usize = 4;
62/// Construction assignments become permanent, so inspect multiple populated
63/// parent cells even when the query-time leaf budget fits under one parent.
64const MIN_BUILD_PARENT_BEAM: usize = 4;
65
66const HNSW_M: usize = 32;
67const HNSW_EF_CONSTRUCTION: usize = 200;
68const HNSW_QUERY_OVERSAMPLE: usize = 4;
69const HNSW_MIN_EF_SEARCH: usize = 128;
70/// Index construction happens once per vector generation and can afford a
71/// wider centroid search than latency-sensitive queries. Keeping the budgets
72/// separate prevents an approximate query-router miss from permanently
73/// assigning a vector to a needlessly distant leaf.
74const HNSW_BUILD_OVERSAMPLE: usize = 8;
75/// Floor for the construction beam.
76///
77/// This is a *floor*, so it is what single-candidate assignment actually pays:
78/// every vector in a rebuilt segment routes with `take = 1`. Recall@1 against
79/// exact centroid assignment saturates well before 512 at `M = 32` — see
80/// `hnsw_build_beam_recall_saturates_before_the_floor` — while the cost is
81/// linear in the beam, so a 512 floor spent ~4x the distance work of a 128 one
82/// for no measurable assignment gain. Multi-candidate build routing still
83/// widens through `HNSW_BUILD_OVERSAMPLE`.
84pub(crate) const HNSW_MIN_EF_BUILD: usize = 128;
85
86/// Neighbour lists are capped at `2 * HNSW_M`; one stack block therefore covers
87/// a whole expansion, letting the batched distance form run without touching
88/// the allocator.
89const NEIGHBOR_BLOCK: usize = HNSW_M * 4;
90
91/// Distance from one query to graph nodes.
92///
93/// Float centroids vectorise across the dimension, so the pairwise form is
94/// already efficient there. Binary centroids are single-row popcounts: scoring
95/// a whole neighbour list per call is what keeps the SIMD kernel fed and pays
96/// the `#[target_feature]` dispatch once per expansion instead of per node.
97pub trait QueryDistance {
98    fn distance(&self, node: u32) -> f32;
99
100    /// Score a whole neighbour list. Defaults to repeated pairwise calls.
101    fn distances(&self, nodes: &[u32], out: &mut [f32]) {
102        debug_assert_eq!(nodes.len(), out.len());
103        for (slot, &node) in out.iter_mut().zip(nodes) {
104            *slot = self.distance(node);
105        }
106    }
107}
108
109impl<F: Fn(u32) -> f32> QueryDistance for F {
110    #[inline]
111    fn distance(&self, node: u32) -> f32 {
112        self(node)
113    }
114}
115
116/// Distance between two graph nodes, used while constructing the graph.
117pub trait PairDistance {
118    fn distance(&self, left: u32, right: u32) -> f32;
119
120    /// Score `left` against a whole node list. Defaults to pairwise calls.
121    fn distances_from(&self, left: u32, rights: &[u32], out: &mut [f32]) {
122        debug_assert_eq!(rights.len(), out.len());
123        for (slot, &right) in out.iter_mut().zip(rights) {
124            *slot = self.distance(left, right);
125        }
126    }
127}
128
129impl<F: Fn(u32, u32) -> f32> PairDistance for F {
130    #[inline]
131    fn distance(&self, left: u32, right: u32) -> f32 {
132        self(left, right)
133    }
134}
135
136/// One inserted node's view of a [`PairDistance`], so construction reuses the
137/// same batched search as queries.
138struct PairQueryDistance<'a, P: ?Sized> {
139    pair: &'a P,
140    left: u32,
141}
142
143impl<P: PairDistance + ?Sized> QueryDistance for PairQueryDistance<'_, P> {
144    #[inline]
145    fn distance(&self, node: u32) -> f32 {
146        self.pair.distance(self.left, node)
147    }
148
149    #[inline]
150    fn distances(&self, nodes: &[u32], out: &mut [f32]) {
151        self.pair.distances_from(self.left, nodes, out);
152    }
153}
154
155#[derive(Clone, Copy, Debug)]
156struct GraphCandidate {
157    node: u32,
158    distance: f32,
159}
160
161impl PartialEq for GraphCandidate {
162    fn eq(&self, other: &Self) -> bool {
163        self.node == other.node && self.distance.to_bits() == other.distance.to_bits()
164    }
165}
166
167impl Eq for GraphCandidate {}
168
169impl PartialOrd for GraphCandidate {
170    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
171        Some(self.cmp(other))
172    }
173}
174
175impl Ord for GraphCandidate {
176    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
177        self.distance
178            .total_cmp(&other.distance)
179            .then_with(|| self.node.cmp(&other.node))
180    }
181}
182
183struct VisitedNodes {
184    epochs: Vec<u32>,
185    current: u32,
186}
187
188impl VisitedNodes {
189    fn new(nodes: usize) -> Self {
190        Self {
191            epochs: vec![0; nodes],
192            current: 0,
193        }
194    }
195
196    fn reset(&mut self) {
197        self.current = self.current.wrapping_add(1);
198        if self.current == 0 {
199            self.epochs.fill(0);
200            self.current = 1;
201        }
202    }
203
204    fn ensure_nodes(&mut self, nodes: usize) {
205        if self.epochs.len() < nodes {
206            self.epochs.resize(nodes, 0);
207        }
208    }
209
210    fn insert(&mut self, node: u32) -> bool {
211        let slot = &mut self.epochs[node as usize];
212        if *slot == self.current {
213            false
214        } else {
215            *slot = self.current;
216            true
217        }
218    }
219}
220
221struct HnswQueryScratch {
222    visited: VisitedNodes,
223    candidates: BinaryHeap<Reverse<GraphCandidate>>,
224    best: BinaryHeap<GraphCandidate>,
225    ordered: Vec<GraphCandidate>,
226    /// Unvisited neighbours of the node being expanded, plus their distances,
227    /// so one expansion is one batched distance call.
228    pending: Vec<u32>,
229    pending_distances: Vec<f32>,
230}
231
232impl HnswQueryScratch {
233    fn new() -> Self {
234        Self {
235            visited: VisitedNodes::new(0),
236            candidates: BinaryHeap::new(),
237            best: BinaryHeap::new(),
238            ordered: Vec::new(),
239            pending: Vec::new(),
240            pending_distances: Vec::new(),
241        }
242    }
243}
244
245thread_local! {
246    /// Segment construction routes millions of vectors through the same graph.
247    /// Retaining scratch per worker avoids zeroing the visited bitmap and
248    /// reallocating both heaps for every assignment.
249    static HNSW_QUERY_SCRATCH: std::cell::RefCell<HnswQueryScratch> =
250        std::cell::RefCell::new(HnswQueryScratch::new());
251}
252
253/// Compact, centroid-free HNSW topology. Node IDs are global leaf IDs, so the
254/// graph shares the quantizer's existing centroid matrix rather than storing a
255/// second copy of every vector.
256#[derive(Debug, Clone, Serialize, Deserialize)]
257pub struct HnswRoutingGraph {
258    m: u16,
259    ef_construction: u32,
260    entry_point: u32,
261    max_level: u8,
262    node_levels: Vec<u8>,
263    /// Per-node ranges into `level_offsets`; each node owns level_count + 1
264    /// offsets so every adjacency is a direct pair of indexed loads.
265    node_offsets: Vec<u32>,
266    level_offsets: Vec<u32>,
267    neighbors: Vec<u32>,
268}
269
270impl HnswRoutingGraph {
271    pub fn build(
272        node_count: usize,
273        distance: impl PairDistance,
274        seed: u64,
275        index_label: &str,
276    ) -> Self {
277        assert!(node_count > 0 && node_count <= u32::MAX as usize);
278        let mut rng = rand::rngs::StdRng::seed_from_u64(seed);
279        let level_multiplier = 1.0 / (HNSW_M as f64).ln();
280        let node_levels: Vec<u8> = (0..node_count)
281            .map(|_| {
282                let uniform = rng.random::<f64>().clamp(f64::MIN_POSITIVE, 1.0);
283                (-uniform.ln() * level_multiplier).floor().min(31.0) as u8
284            })
285            .collect();
286        let mut insertion_order: Vec<u32> = (0..node_count as u32).collect();
287        insertion_order.shuffle(&mut rng);
288        let mut links: Vec<Vec<Vec<u32>>> = node_levels
289            .iter()
290            .map(|&level| vec![Vec::new(); level as usize + 1])
291            .collect();
292        let mut visited = VisitedNodes::new(node_count);
293        let mut entry_point = insertion_order[0];
294        let mut max_level = node_levels[entry_point as usize];
295
296        let mut progress = PhaseProgress::start(
297            index_label,
298            "hnsw graph build",
299            format!("{node_count} centroids, M={HNSW_M}, ef={HNSW_EF_CONSTRUCTION}"),
300            node_count,
301        );
302        for (inserted, &node) in insertion_order.iter().enumerate().skip(1) {
303            progress.advance(inserted);
304            let node_level = node_levels[node as usize];
305            let mut entry = entry_point;
306            let node_distance = PairQueryDistance {
307                pair: &distance,
308                left: node,
309            };
310
311            for level in ((node_level as usize + 1)..=max_level as usize).rev() {
312                entry = greedy_search_level(&links, entry, level, &node_distance);
313            }
314
315            for level in (0..=usize::min(node_level as usize, max_level as usize)).rev() {
316                let candidates = search_graph_layer(
317                    &links,
318                    entry,
319                    level,
320                    HNSW_EF_CONSTRUCTION,
321                    &node_distance,
322                    &mut visited,
323                );
324                if let Some(best) = candidates.first() {
325                    entry = best.node;
326                }
327                let max_connections = if level == 0 { HNSW_M * 2 } else { HNSW_M };
328                let selected =
329                    select_diverse_neighbors(node, candidates, max_connections, &distance);
330                links[node as usize][level] = selected.clone();
331                for neighbor in selected {
332                    let adjacency = &mut links[neighbor as usize][level];
333                    if !adjacency.contains(&node) {
334                        adjacency.push(node);
335                    }
336                    if adjacency.len() > max_connections {
337                        let candidates = adjacency
338                            .iter()
339                            .copied()
340                            .map(|candidate| GraphCandidate {
341                                node: candidate,
342                                distance: distance.distance(neighbor, candidate),
343                            })
344                            .collect();
345                        *adjacency = select_diverse_neighbors(
346                            neighbor,
347                            candidates,
348                            max_connections,
349                            &distance,
350                        );
351                    }
352                }
353            }
354
355            if node_level > max_level {
356                entry_point = node;
357                max_level = node_level;
358            }
359        }
360        progress.finish();
361
362        Self::compact(
363            HNSW_M,
364            HNSW_EF_CONSTRUCTION,
365            entry_point,
366            max_level,
367            node_levels,
368            links,
369        )
370    }
371
372    fn compact(
373        m: usize,
374        ef_construction: usize,
375        entry_point: u32,
376        max_level: u8,
377        node_levels: Vec<u8>,
378        links: Vec<Vec<Vec<u32>>>,
379    ) -> Self {
380        let mut node_offsets = Vec::with_capacity(links.len() + 1);
381        let level_count: usize = links.iter().map(|levels| levels.len() + 1).sum();
382        let neighbor_count: usize = links
383            .iter()
384            .flat_map(|levels| levels.iter())
385            .map(Vec::len)
386            .sum();
387        let mut level_offsets = Vec::with_capacity(level_count);
388        let mut neighbors = Vec::with_capacity(neighbor_count);
389        for levels in links {
390            node_offsets.push(level_offsets.len() as u32);
391            for mut adjacency in levels {
392                adjacency.sort_unstable();
393                adjacency.dedup();
394                level_offsets.push(neighbors.len() as u32);
395                neighbors.extend(adjacency);
396            }
397            level_offsets.push(neighbors.len() as u32);
398        }
399        node_offsets.push(level_offsets.len() as u32);
400        Self {
401            m: m as u16,
402            ef_construction: ef_construction as u32,
403            entry_point,
404            max_level,
405            node_levels,
406            node_offsets,
407            level_offsets,
408            neighbors,
409        }
410    }
411
412    #[inline]
413    pub fn neighbors(&self, node: u32, level: usize) -> &[u32] {
414        if (self.node_levels[node as usize] as usize) < level {
415            return &[];
416        }
417        let offset_index = self.node_offsets[node as usize] as usize + level;
418        let start = self.level_offsets[offset_index] as usize;
419        let end = self.level_offsets[offset_index + 1] as usize;
420        &self.neighbors[start..end]
421    }
422
423    pub fn search(&self, query_distance: impl QueryDistance, take: usize) -> Vec<u32> {
424        let take = take.min(self.node_levels.len());
425        if take == 0 {
426            return Vec::new();
427        }
428        let ef_search = take
429            .saturating_mul(HNSW_QUERY_OVERSAMPLE)
430            .max(HNSW_MIN_EF_SEARCH)
431            .min(self.node_levels.len());
432        self.search_with_budget(query_distance, take, ef_search)
433    }
434
435    /// Higher-recall centroid search used only while constructing postings.
436    pub(crate) fn search_for_build(
437        &self,
438        query_distance: impl QueryDistance,
439        take: usize,
440    ) -> Vec<u32> {
441        let take = take.min(self.node_levels.len());
442        if take == 0 {
443            return Vec::new();
444        }
445        self.search_with_budget(query_distance, take, self.build_budget(take))
446    }
447
448    /// Single nearest node, for the assignment of one vector to one leaf.
449    ///
450    /// Construction routes every vector in a segment through here, so it avoids
451    /// both the result `Vec` and the full ranking of the beam that
452    /// [`Self::search_for_build`] needs — the minimum of the bounded heap is
453    /// the same node the ranked list would have put first.
454    pub(crate) fn search_best_for_build(&self, query_distance: impl QueryDistance) -> Option<u32> {
455        if self.node_levels.is_empty() {
456            return None;
457        }
458        let ef_search = self.build_budget(1);
459        let mut entry = self.entry_point;
460        for level in (1..=self.max_level as usize).rev() {
461            entry = greedy_search_compact(self, entry, level, &query_distance);
462        }
463        HNSW_QUERY_SCRATCH.with(|scratch| {
464            let mut scratch = scratch.borrow_mut();
465            search_compact_layer_reusing(self, entry, ef_search, &query_distance, &mut scratch);
466            Some(
467                scratch
468                    .best
469                    .iter()
470                    .min()
471                    .map_or(entry, |candidate| candidate.node),
472            )
473        })
474    }
475
476    #[inline]
477    fn build_budget(&self, take: usize) -> usize {
478        take.saturating_mul(HNSW_BUILD_OVERSAMPLE)
479            .max(HNSW_MIN_EF_BUILD)
480            .min(self.node_levels.len())
481    }
482
483    /// Nearest node under an explicit beam, so tests can measure how assignment
484    /// recall responds to the budget instead of asserting a constant.
485    #[cfg(test)]
486    pub(crate) fn search_best_with_ef(
487        &self,
488        query_distance: impl QueryDistance,
489        ef: usize,
490    ) -> Option<u32> {
491        if self.node_levels.is_empty() {
492            return None;
493        }
494        let ef_search = ef.clamp(1, self.node_levels.len());
495        let mut entry = self.entry_point;
496        for level in (1..=self.max_level as usize).rev() {
497            entry = greedy_search_compact(self, entry, level, &query_distance);
498        }
499        HNSW_QUERY_SCRATCH.with(|scratch| {
500            let mut scratch = scratch.borrow_mut();
501            search_compact_layer_reusing(self, entry, ef_search, &query_distance, &mut scratch);
502            Some(
503                scratch
504                    .best
505                    .iter()
506                    .min()
507                    .map_or(entry, |candidate| candidate.node),
508            )
509        })
510    }
511
512    fn search_with_budget(
513        &self,
514        query_distance: impl QueryDistance,
515        take: usize,
516        ef_search: usize,
517    ) -> Vec<u32> {
518        let mut entry = self.entry_point;
519        for level in (1..=self.max_level as usize).rev() {
520            entry = greedy_search_compact(self, entry, level, &query_distance);
521        }
522        HNSW_QUERY_SCRATCH.with(|scratch| {
523            let mut scratch = scratch.borrow_mut();
524            search_compact_layer_reusing(self, entry, ef_search, &query_distance, &mut scratch);
525            order_scratch_candidates(&mut scratch);
526            scratch
527                .ordered
528                .iter()
529                .take(take)
530                .map(|candidate| candidate.node)
531                .collect()
532        })
533    }
534
535    pub fn validate(&self, expected_nodes: usize) -> bool {
536        if self.m as usize != HNSW_M
537            || self.ef_construction as usize != HNSW_EF_CONSTRUCTION
538            || expected_nodes == 0
539            || self.node_levels.len() != expected_nodes
540            || self.node_offsets.len() != expected_nodes + 1
541            || self.node_offsets.first() != Some(&0)
542            || self.node_offsets.last().copied() != Some(self.level_offsets.len() as u32)
543            || self.node_offsets.windows(2).any(|pair| pair[0] > pair[1])
544            || self
545                .node_offsets
546                .iter()
547                .any(|&offset| offset as usize > self.level_offsets.len())
548            || self.entry_point as usize >= expected_nodes
549            || self.node_levels[self.entry_point as usize] != self.max_level
550            || self.node_levels.iter().copied().max() != Some(self.max_level)
551            || self.level_offsets.last().copied() != Some(self.neighbors.len() as u32)
552            || self.level_offsets.windows(2).any(|pair| pair[0] > pair[1])
553            || self
554                .neighbors
555                .iter()
556                .any(|&node| node as usize >= expected_nodes)
557        {
558            return false;
559        }
560        for node in 0..expected_nodes {
561            let start = self.node_offsets[node] as usize;
562            let end = self.node_offsets[node + 1] as usize;
563            if end.saturating_sub(start) != self.node_levels[node] as usize + 2 {
564                return false;
565            }
566            for level in 0..=self.node_levels[node] as usize {
567                let adjacency = self.neighbors(node as u32, level);
568                let max_connections = if level == 0 { HNSW_M * 2 } else { HNSW_M };
569                if adjacency.len() > max_connections
570                    || adjacency.contains(&(node as u32))
571                    || adjacency.windows(2).any(|pair| pair[0] >= pair[1])
572                {
573                    return false;
574                }
575            }
576        }
577        true
578    }
579
580    pub fn size_bytes(&self) -> usize {
581        self.node_levels.len()
582            + self.node_offsets.len() * size_of::<u32>()
583            + self.level_offsets.len() * size_of::<u32>()
584            + self.neighbors.len() * size_of::<u32>()
585            + 32
586    }
587
588    /// Visit the compact, immutable arrays touched by every HNSW route.
589    /// Query scratch is thread-local and intentionally excluded.
590    #[cfg(feature = "native")]
591    pub(crate) fn visit_resident_regions(&self, visit: &mut dyn FnMut(&'static str, &[u8])) {
592        visit("HNSW node levels", bytes_of_slice(&self.node_levels));
593        visit("HNSW node offsets", bytes_of_slice(&self.node_offsets));
594        visit("HNSW level offsets", bytes_of_slice(&self.level_offsets));
595        visit("HNSW neighbors", bytes_of_slice(&self.neighbors));
596    }
597}
598
599fn greedy_search_level(
600    links: &[Vec<Vec<u32>>],
601    mut current: u32,
602    level: usize,
603    query_distance: &impl QueryDistance,
604) -> u32 {
605    let mut current_distance = query_distance.distance(current);
606    let mut scores = [0f32; NEIGHBOR_BLOCK];
607    loop {
608        let mut changed = false;
609        for chunk in links[current as usize][level].chunks(NEIGHBOR_BLOCK) {
610            let scored = &mut scores[..chunk.len()];
611            query_distance.distances(chunk, scored);
612            for (&candidate, &distance) in chunk.iter().zip(scored.iter()) {
613                if distance < current_distance
614                    || (distance == current_distance && candidate < current)
615                {
616                    current = candidate;
617                    current_distance = distance;
618                    changed = true;
619                }
620            }
621        }
622        if !changed {
623            return current;
624        }
625    }
626}
627
628fn greedy_search_compact(
629    graph: &HnswRoutingGraph,
630    mut current: u32,
631    level: usize,
632    query_distance: &impl QueryDistance,
633) -> u32 {
634    let mut current_distance = query_distance.distance(current);
635    let mut scores = [0f32; NEIGHBOR_BLOCK];
636    loop {
637        let mut changed = false;
638        for chunk in graph.neighbors(current, level).chunks(NEIGHBOR_BLOCK) {
639            let scored = &mut scores[..chunk.len()];
640            query_distance.distances(chunk, scored);
641            for (&candidate, &distance) in chunk.iter().zip(scored.iter()) {
642                if distance < current_distance
643                    || (distance == current_distance && candidate < current)
644                {
645                    current = candidate;
646                    current_distance = distance;
647                    changed = true;
648                }
649            }
650        }
651        if !changed {
652            return current;
653        }
654    }
655}
656
657fn search_graph_layer(
658    links: &[Vec<Vec<u32>>],
659    entry: u32,
660    level: usize,
661    ef: usize,
662    query_distance: &impl QueryDistance,
663    visited: &mut VisitedNodes,
664) -> Vec<GraphCandidate> {
665    search_layer_impl(entry, ef, query_distance, visited, |node| {
666        &links[node as usize][level]
667    })
668}
669
670/// Expand the base layer into `scratch.best`, leaving `scratch.ordered` empty.
671///
672/// Callers that need a ranked list finish with [`order_scratch_candidates`];
673/// single-candidate assignment skips that and scans the bounded heap instead.
674fn search_compact_layer_reusing(
675    graph: &HnswRoutingGraph,
676    entry: u32,
677    ef: usize,
678    query_distance: &impl QueryDistance,
679    scratch: &mut HnswQueryScratch,
680) {
681    scratch.visited.ensure_nodes(graph.node_levels.len());
682    scratch.visited.reset();
683    scratch.candidates.clear();
684    scratch.best.clear();
685    scratch.ordered.clear();
686    scratch.visited.insert(entry);
687    let first = GraphCandidate {
688        node: entry,
689        distance: query_distance.distance(entry),
690    };
691    scratch.candidates.push(Reverse(first));
692    scratch.best.push(first);
693
694    while let Some(Reverse(current)) = scratch.candidates.pop() {
695        if scratch.best.len() >= ef
696            && scratch
697                .best
698                .peek()
699                .is_some_and(|worst| current.distance > worst.distance)
700        {
701            break;
702        }
703        // Score the whole unvisited frontier of this node in one call. The
704        // accept test below still runs in adjacency order, so results are
705        // identical to scoring node by node.
706        scratch.pending.clear();
707        for &neighbor in graph.neighbors(current.node, 0) {
708            if scratch.visited.insert(neighbor) {
709                scratch.pending.push(neighbor);
710            }
711        }
712        if scratch.pending.is_empty() {
713            continue;
714        }
715        scratch.pending_distances.clear();
716        scratch.pending_distances.resize(scratch.pending.len(), 0.0);
717        query_distance.distances(&scratch.pending, &mut scratch.pending_distances);
718
719        for (&node, &distance) in scratch.pending.iter().zip(scratch.pending_distances.iter()) {
720            let candidate = GraphCandidate { node, distance };
721            if scratch.best.len() < ef
722                || scratch.best.peek().is_some_and(|worst| candidate < *worst)
723            {
724                scratch.candidates.push(Reverse(candidate));
725                scratch.best.push(candidate);
726                if scratch.best.len() > ef {
727                    scratch.best.pop();
728                }
729            }
730        }
731    }
732}
733
734fn order_scratch_candidates(scratch: &mut HnswQueryScratch) {
735    scratch.ordered.extend(scratch.best.drain());
736    scratch.ordered.sort_unstable();
737}
738
739fn search_layer_impl<'a>(
740    entry: u32,
741    ef: usize,
742    query_distance: &impl QueryDistance,
743    visited: &mut VisitedNodes,
744    neighbors: impl Fn(u32) -> &'a [u32],
745) -> Vec<GraphCandidate> {
746    visited.reset();
747    visited.insert(entry);
748    let first = GraphCandidate {
749        node: entry,
750        distance: query_distance.distance(entry),
751    };
752    let mut candidates = BinaryHeap::new();
753    let mut best = BinaryHeap::new();
754    candidates.push(Reverse(first));
755    best.push(first);
756    let mut pending: Vec<u32> = Vec::new();
757    let mut pending_distances: Vec<f32> = Vec::new();
758
759    while let Some(Reverse(current)) = candidates.pop() {
760        if best.len() >= ef
761            && best
762                .peek()
763                .is_some_and(|worst| current.distance > worst.distance)
764        {
765            break;
766        }
767        pending.clear();
768        for &neighbor in neighbors(current.node) {
769            if visited.insert(neighbor) {
770                pending.push(neighbor);
771            }
772        }
773        if pending.is_empty() {
774            continue;
775        }
776        pending_distances.clear();
777        pending_distances.resize(pending.len(), 0.0);
778        query_distance.distances(&pending, &mut pending_distances);
779
780        for (&node, &distance) in pending.iter().zip(pending_distances.iter()) {
781            let candidate = GraphCandidate { node, distance };
782            if best.len() < ef || best.peek().is_some_and(|worst| candidate < *worst) {
783                candidates.push(Reverse(candidate));
784                best.push(candidate);
785                if best.len() > ef {
786                    best.pop();
787                }
788            }
789        }
790    }
791    best.into_sorted_vec()
792}
793
794fn select_diverse_neighbors(
795    query_node: u32,
796    mut candidates: Vec<GraphCandidate>,
797    limit: usize,
798    distance: &impl PairDistance,
799) -> Vec<u32> {
800    candidates.sort_unstable();
801    candidates.dedup_by_key(|candidate| candidate.node);
802    let mut selected = Vec::with_capacity(limit);
803    let mut deferred = Vec::new();
804    for candidate in candidates {
805        if candidate.node == query_node {
806            continue;
807        }
808        if selected
809            .iter()
810            .all(|&neighbor| distance.distance(candidate.node, neighbor) > candidate.distance)
811        {
812            selected.push(candidate.node);
813            if selected.len() == limit {
814                return selected;
815            }
816        } else {
817            deferred.push(candidate.node);
818        }
819    }
820    for candidate in deferred {
821        if selected.len() == limit {
822            break;
823        }
824        selected.push(candidate);
825    }
826    selected
827}
828
829fn contiguous_leaf_run(children: &[u32]) -> bool {
830    children
831        .windows(2)
832        .all(|pair| pair[1] == pair[0].saturating_add(1))
833}
834
835/// Compact parent-to-leaf adjacency shared by float and binary quantizers.
836/// Offsets avoid one heap allocation per parent and serialize as two flat
837/// arrays in the single index-level quantizer artifact.
838#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
839pub struct IvfRoutingTopology {
840    child_offsets: Vec<u32>,
841    leaf_ids: Vec<u32>,
842}
843
844impl IvfRoutingTopology {
845    pub fn from_children(children: &[Vec<u32>]) -> Self {
846        let mut child_offsets = Vec::with_capacity(children.len() + 1);
847        let mut leaf_ids = Vec::new();
848        child_offsets.push(0);
849        for child_list in children {
850            leaf_ids.extend_from_slice(child_list);
851            child_offsets.push(leaf_ids.len() as u32);
852        }
853        Self {
854            child_offsets,
855            leaf_ids,
856        }
857    }
858
859    pub fn parent_count(&self) -> usize {
860        self.child_offsets.len().saturating_sub(1)
861    }
862
863    pub fn children(&self, parent: usize) -> &[u32] {
864        let start = self.child_offsets[parent] as usize;
865        let end = self.child_offsets[parent + 1] as usize;
866        &self.leaf_ids[start..end]
867    }
868
869    /// Children of `parent` as a `(first_leaf, count)` run.
870    ///
871    /// Both trainers append each parent's leaves as one contiguous block, which
872    /// lets a caller score a whole parent with a single batched pass over the
873    /// centroid matrix instead of one kernel call per leaf. Returns `None` for
874    /// an empty parent, and — defensively — for any non-contiguous list, so the
875    /// scoring path stays correct even if the invariant is ever relaxed.
876    pub fn children_run(&self, parent: usize) -> Option<(u32, usize)> {
877        let children = self.children(parent);
878        let first = *children.first()?;
879        contiguous_leaf_run(children).then_some((first, children.len()))
880    }
881
882    pub fn validate(&self, num_leaves: usize) -> bool {
883        if self.parent_count() == 0 {
884            return self.child_offsets.is_empty() && self.leaf_ids.is_empty();
885        }
886        self.child_offsets.first() == Some(&0)
887            && self.child_offsets.last().copied() == Some(self.leaf_ids.len() as u32)
888            && self.child_offsets.windows(2).all(|pair| pair[0] <= pair[1])
889            && self.leaf_ids.len() == num_leaves
890            && self.leaf_ids.iter().all(|&leaf| leaf < num_leaves as u32)
891            // Contiguity is a build invariant of both trainers; a topology
892            // without it did not come from this codebase, so refuse it rather
893            // than silently routing through a slower path.
894            && (0..self.parent_count()).all(|parent| contiguous_leaf_run(self.children(parent)))
895            && {
896                let mut leaves = self.leaf_ids.clone();
897                leaves.sort_unstable();
898                leaves.iter().copied().eq(0..num_leaves as u32)
899            }
900    }
901
902    #[cfg(feature = "native")]
903    pub(crate) fn visit_resident_regions(&self, visit: &mut dyn FnMut(&'static str, &[u8])) {
904        visit(
905            "two-level child offsets",
906            bytes_of_slice(&self.child_offsets),
907        );
908        visit("two-level leaf IDs", bytes_of_slice(&self.leaf_ids));
909    }
910}
911
912/// View an initialized plain-data slice as bytes for residency operations.
913/// The returned slice cannot outlive the source and is never mutated.
914#[cfg(feature = "native")]
915pub(crate) fn bytes_of_slice<T>(slice: &[T]) -> &[u8] {
916    let byte_len = std::mem::size_of_val(slice);
917    if byte_len == 0 {
918        return &[];
919    }
920    // SAFETY: every byte in an initialized `T` allocation may be read as u8;
921    // the lifetime remains tied to `slice`, and callers receive no mutation.
922    unsafe { std::slice::from_raw_parts(slice.as_ptr().cast::<u8>(), byte_len) }
923}
924
925pub fn routing_parent_count(num_leaves: usize) -> usize {
926    if num_leaves <= 1 {
927        return num_leaves;
928    }
929    ((num_leaves as f64).sqrt().ceil() as usize)
930        .clamp(2, 4_096)
931        .min(num_leaves)
932}
933
934/// Allocate `total_clusters` child cells proportionally to populated parent
935/// groups, or one per training point when fewer points are available.
936pub fn allocate_child_clusters(group_sizes: &[usize], total_clusters: usize) -> Vec<usize> {
937    let total_points: u128 = group_sizes.iter().map(|&size| size as u128).sum();
938    let target = (total_clusters as u128).min(total_points) as usize;
939    if target == 0 {
940        return vec![0; group_sizes.len()];
941    }
942
943    let populated = group_sizes.iter().filter(|&&size| size > 0).count();
944    let guarantee_populated = target >= populated;
945    let mut allocated = vec![0usize; group_sizes.len()];
946    let mut fixed = vec![false; group_sizes.len()];
947    let mut fixed_cells = 0usize;
948
949    if guarantee_populated {
950        // Solve the lower-bounded proportional allocation
951        //
952        //     allocation_i = max(1, lambda * group_size_i)
953        //
954        // by successively fixing cells whose unconstrained quota is at most
955        // one. This avoids letting the one-per-parent guarantee distort a
956        // 90/10 population into an 80/20 child split.
957        loop {
958            let active_weight: u128 = group_sizes
959                .iter()
960                .enumerate()
961                .filter(|(index, size)| **size > 0 && !fixed[*index])
962                .map(|(_, &size)| size as u128)
963                .sum();
964            let active_target = target - fixed_cells;
965            if active_weight == 0 || active_target == 0 {
966                break;
967            }
968            let newly_fixed = group_sizes
969                .iter()
970                .enumerate()
971                .filter_map(|(index, &size)| {
972                    (size > 0
973                        && !fixed[index]
974                        && (size as u128) * (active_target as u128) <= active_weight)
975                        .then_some(index)
976                })
977                .collect::<Vec<_>>();
978            if newly_fixed.is_empty() {
979                break;
980            }
981            for index in newly_fixed {
982                fixed[index] = true;
983                allocated[index] = 1;
984                fixed_cells += 1;
985            }
986        }
987    }
988
989    let remaining = target - fixed_cells;
990    if remaining == 0 {
991        return allocated;
992    }
993    let active_weight: u128 = group_sizes
994        .iter()
995        .enumerate()
996        .filter(|(index, size)| **size > 0 && !fixed[*index])
997        .map(|(_, &size)| size as u128)
998        .sum();
999    debug_assert!(active_weight > 0);
1000
1001    let mut remainders = Vec::with_capacity(group_sizes.len());
1002    for (index, &size) in group_sizes.iter().enumerate() {
1003        if size == 0 || fixed[index] {
1004            continue;
1005        }
1006        let numerator = (remaining as u128) * (size as u128);
1007        let whole = (numerator / active_weight) as usize;
1008        allocated[index] = whole;
1009        if whole < size {
1010            remainders.push((index, numerator % active_weight));
1011        }
1012    }
1013
1014    let remainder_cells = target - allocated.iter().sum::<usize>();
1015    remainders.sort_unstable_by(|(left_index, left), (right_index, right)| {
1016        right.cmp(left).then_with(|| left_index.cmp(right_index))
1017    });
1018    debug_assert!(remainder_cells <= remainders.len());
1019    for (index, _) in remainders.into_iter().take(remainder_cells) {
1020        allocated[index] += 1;
1021    }
1022    debug_assert_eq!(allocated.iter().sum::<usize>(), target);
1023    debug_assert!(
1024        allocated
1025            .iter()
1026            .zip(group_sizes)
1027            .all(|(&cells, &size)| cells <= size)
1028    );
1029    allocated
1030}
1031
1032/// A centroid selection computed once and reused by every segment.
1033#[derive(Debug, Clone, PartialEq, Eq)]
1034pub struct IvfProbePlan {
1035    pub quantizer_version: u64,
1036    /// Hash of the query, routing mode, and requested leaf count. This keeps a
1037    /// reused mutable query object from accidentally reusing an older route.
1038    pub request_fingerprint: u64,
1039    pub cluster_ids: Arc<[u32]>,
1040}
1041
1042impl IvfProbePlan {
1043    pub fn new(quantizer_version: u64, request_fingerprint: u64, cluster_ids: Vec<u32>) -> Self {
1044        Self {
1045            quantizer_version,
1046            request_fingerprint,
1047            cluster_ids: cluster_ids.into(),
1048        }
1049    }
1050}
1051
1052fn fingerprint_words(
1053    mode: IvfRoutingMode,
1054    nprobe: usize,
1055    words: impl IntoIterator<Item = u64>,
1056) -> u64 {
1057    // FNV-1a with an extra avalanche. This is a cache key, not a persisted
1058    // identity or an adversarial hash table key.
1059    let mut hash = 0xcbf2_9ce4_8422_2325u64;
1060    let mode_tag = match mode {
1061        IvfRoutingMode::Auto => 0u64,
1062        IvfRoutingMode::Flat => 1,
1063        IvfRoutingMode::TwoLevel => 2,
1064        IvfRoutingMode::Hnsw => 3,
1065    };
1066    for word in std::iter::once(mode_tag)
1067        .chain(std::iter::once(nprobe as u64))
1068        .chain(words)
1069    {
1070        hash ^= word;
1071        hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
1072    }
1073    hash ^= hash >> 33;
1074    hash = hash.wrapping_mul(0xff51_afd7_ed55_8ccd);
1075    hash ^ (hash >> 33)
1076}
1077
1078pub fn float_probe_fingerprint(query: &[f32], nprobe: usize, mode: IvfRoutingMode) -> u64 {
1079    fingerprint_words(
1080        mode,
1081        nprobe,
1082        query.iter().map(|value| value.to_bits() as u64),
1083    )
1084}
1085
1086pub(crate) fn normalize_cosine_in_place(vector: &mut [f32]) {
1087    let norm = crate::structures::simd::dot_product_f32(vector, vector, vector.len()).sqrt();
1088    let inverse_norm = if norm.is_finite() && norm > 0.0 {
1089        1.0 / norm
1090    } else {
1091        0.0
1092    };
1093    vector.iter_mut().for_each(|value| *value *= inverse_norm);
1094}
1095
1096pub(crate) fn normalized_cosine_query(query: &[f32]) -> Vec<f32> {
1097    let mut normalized = query.to_vec();
1098    normalize_cosine_in_place(&mut normalized);
1099    normalized
1100}
1101
1102pub(crate) fn cosine_probe_fingerprint(query: &[f32], nprobe: usize, mode: IvfRoutingMode) -> u64 {
1103    let norm = crate::structures::simd::dot_product_f32(query, query, query.len()).sqrt();
1104    let inverse_norm = if norm.is_finite() && norm > 0.0 {
1105        1.0 / norm
1106    } else {
1107        0.0
1108    };
1109    fingerprint_words(
1110        mode,
1111        nprobe,
1112        query
1113            .iter()
1114            .map(|value| (value * inverse_norm).to_bits() as u64),
1115    )
1116}
1117
1118pub fn binary_probe_fingerprint(query: &[u8], nprobe: usize, mode: IvfRoutingMode) -> u64 {
1119    fingerprint_words(mode, nprobe, query.iter().map(|&value| value as u64))
1120}
1121
1122/// Resolve `Auto` for float centroids.
1123#[inline]
1124pub fn effective_routing_mode(mode: IvfRoutingMode, num_leaves: usize) -> IvfRoutingMode {
1125    resolve_auto_routing(mode, num_leaves, HNSW_AUTO_THRESHOLD)
1126}
1127
1128/// Resolve `Auto` for packed binary centroids, whose flat pass stays cheap much
1129/// further up the leaf-count range.
1130///
1131/// Every binary site — training, validation, probing and assignment — must use
1132/// this, or a codebook trained without a graph would be asked to route through
1133/// one.
1134#[inline]
1135pub fn effective_binary_routing_mode(mode: IvfRoutingMode, num_leaves: usize) -> IvfRoutingMode {
1136    resolve_auto_routing(mode, num_leaves, BINARY_HNSW_AUTO_THRESHOLD)
1137}
1138
1139#[inline]
1140fn resolve_auto_routing(
1141    mode: IvfRoutingMode,
1142    num_leaves: usize,
1143    auto_threshold: usize,
1144) -> IvfRoutingMode {
1145    match mode {
1146        IvfRoutingMode::Auto if num_leaves >= auto_threshold => IvfRoutingMode::Hnsw,
1147        IvfRoutingMode::Auto => IvfRoutingMode::Flat,
1148        explicit => explicit,
1149    }
1150}
1151
1152/// Number of parent cells to put in the routing beam.
1153pub fn parent_probe_count(nprobe: usize, num_leaves: usize, num_parents: usize) -> usize {
1154    if num_parents == 0 || num_leaves == 0 {
1155        return 0;
1156    }
1157    let leaves_per_parent = num_leaves.div_ceil(num_parents).max(1);
1158    nprobe
1159        .saturating_mul(PARENT_BEAM_OVERSAMPLE)
1160        .div_ceil(leaves_per_parent)
1161        .clamp(1, num_parents)
1162}
1163
1164/// Select the closest parent beam while guaranteeing enough child leaves to
1165/// satisfy the requested leaf budget. The usual oversubscribed beam remains
1166/// the fast path; only an uneven topology that underfills the budget pays for
1167/// ranking additional parents.
1168pub fn select_parent_beam<const HIGHER_IS_BETTER: bool>(
1169    scores: &[f32],
1170    topology: &IvfRoutingTopology,
1171    requested_leaves: usize,
1172) -> Vec<u32> {
1173    let parent_count = scores.len().min(topology.parent_count());
1174    let leaf_count = topology.leaf_ids.len();
1175    let requested_leaves = requested_leaves.min(leaf_count);
1176    if parent_count == 0 || requested_leaves == 0 {
1177        return Vec::new();
1178    }
1179
1180    let initial_take =
1181        parent_probe_count(requested_leaves, leaf_count, parent_count).min(parent_count);
1182    let scores = &scores[..parent_count];
1183    let initial = select_best::<HIGHER_IS_BETTER>(scores, initial_take);
1184    let initial_coverage: usize = initial
1185        .iter()
1186        .map(|&parent| topology.children(parent as usize).len())
1187        .sum();
1188    if initial_coverage >= requested_leaves || initial_take == parent_count {
1189        return initial;
1190    }
1191
1192    let mut ranked = select_best::<HIGHER_IS_BETTER>(scores, parent_count);
1193    let mut coverage = 0usize;
1194    let mut take = parent_count;
1195    for (index, &parent) in ranked.iter().enumerate() {
1196        coverage = coverage.saturating_add(topology.children(parent as usize).len());
1197        if index + 1 >= initial_take && coverage >= requested_leaves {
1198            take = index + 1;
1199            break;
1200        }
1201    }
1202    ranked.truncate(take);
1203    ranked
1204}
1205
1206/// Select a construction-time parent beam without narrowing query routing.
1207///
1208/// The query beam remains the lower bound for leaf coverage, while offline
1209/// construction inspects at least four populated parents when available. Empty
1210/// parents are removed because they contribute no leaf candidates.
1211pub fn select_parent_beam_for_build<const HIGHER_IS_BETTER: bool>(
1212    scores: &[f32],
1213    topology: &IvfRoutingTopology,
1214    requested_leaves: usize,
1215) -> Vec<u32> {
1216    if requested_leaves == 0 {
1217        return Vec::new();
1218    }
1219    let parent_count = scores.len().min(topology.parent_count());
1220    if parent_count == 0 {
1221        return Vec::new();
1222    }
1223
1224    let query_parents = select_parent_beam::<HIGHER_IS_BETTER>(scores, topology, requested_leaves);
1225    let query_populated = query_parents
1226        .iter()
1227        .filter(|&&parent| !topology.children(parent as usize).is_empty())
1228        .count();
1229
1230    let scores = &scores[..parent_count];
1231    let mut ranked = select_best::<HIGHER_IS_BETTER>(scores, parent_count);
1232    ranked.retain(|&parent| !topology.children(parent as usize).is_empty());
1233    let take = query_populated
1234        .max(MIN_BUILD_PARENT_BEAM.min(ranked.len()))
1235        .min(ranked.len());
1236    ranked.truncate(take);
1237    ranked
1238}
1239
1240/// Deterministically select the best score indexes without fully sorting the
1241/// input. `HIGHER_IS_BETTER` covers Hamming similarity; `false` covers L2.
1242pub fn select_best<const HIGHER_IS_BETTER: bool>(scores: &[f32], take: usize) -> Vec<u32> {
1243    let take = take.min(scores.len());
1244    if take == 0 {
1245        return Vec::new();
1246    }
1247    let mut order: Vec<u32> = (0..scores.len() as u32).collect();
1248    let compare = |left: &u32, right: &u32| {
1249        let left_score = scores[*left as usize];
1250        let right_score = scores[*right as usize];
1251        let score_order = if HIGHER_IS_BETTER {
1252            right_score.total_cmp(&left_score)
1253        } else {
1254            left_score.total_cmp(&right_score)
1255        };
1256        score_order.then_with(|| left.cmp(right))
1257    };
1258    if take < order.len() {
1259        order.select_nth_unstable_by(take, compare);
1260        order.truncate(take);
1261    }
1262    order.sort_unstable_by(compare);
1263    order
1264}
1265
1266/// Select leaf IDs from a scored candidate set. Candidate IDs need not be
1267/// contiguous, which lets both metrics share the exact same two-level beam
1268/// implementation.
1269pub fn select_best_candidates<const HIGHER_IS_BETTER: bool>(
1270    candidates: &mut Vec<(u32, f32)>,
1271    take: usize,
1272) -> Vec<u32> {
1273    let take = take.min(candidates.len());
1274    if take == 0 {
1275        return Vec::new();
1276    }
1277    let compare = |left: &(u32, f32), right: &(u32, f32)| {
1278        let score_order = if HIGHER_IS_BETTER {
1279            right.1.total_cmp(&left.1)
1280        } else {
1281            left.1.total_cmp(&right.1)
1282        };
1283        score_order.then_with(|| left.0.cmp(&right.0))
1284    };
1285    if take < candidates.len() {
1286        candidates.select_nth_unstable_by(take, compare);
1287        candidates.truncate(take);
1288    }
1289    candidates.sort_unstable_by(compare);
1290    candidates
1291        .iter()
1292        .map(|(cluster_id, _)| *cluster_id)
1293        .collect()
1294}
1295
1296#[cfg(test)]
1297mod tests {
1298    use super::*;
1299
1300    /// For binary centroids, `Auto` must not pick the graph where an exact flat
1301    /// scan is both faster and more accurate; the switch point comes from the
1302    /// measured crossover documented on `BINARY_HNSW_AUTO_THRESHOLD`. The float
1303    /// threshold is separate because float leaves are ~10x wider per centroid.
1304    #[test]
1305    fn auto_routing_prefers_exact_flat_probing_below_the_measured_crossover() {
1306        for leaves in [1usize, 4_096, 16_384, BINARY_HNSW_AUTO_THRESHOLD - 1] {
1307            assert_eq!(
1308                effective_binary_routing_mode(IvfRoutingMode::Auto, leaves),
1309                IvfRoutingMode::Flat,
1310                "{leaves} binary leaves"
1311            );
1312        }
1313        for leaves in [BINARY_HNSW_AUTO_THRESHOLD, 114_309] {
1314            assert_eq!(
1315                effective_binary_routing_mode(IvfRoutingMode::Auto, leaves),
1316                IvfRoutingMode::Hnsw,
1317                "{leaves} binary leaves"
1318            );
1319        }
1320        // Float routing keeps its own, lower threshold.
1321        assert_eq!(
1322            effective_routing_mode(IvfRoutingMode::Auto, HNSW_AUTO_THRESHOLD),
1323            IvfRoutingMode::Hnsw
1324        );
1325        assert_eq!(
1326            effective_routing_mode(IvfRoutingMode::Auto, HNSW_AUTO_THRESHOLD - 1),
1327            IvfRoutingMode::Flat
1328        );
1329        // Explicit modes are honoured at any size, and large codebooks still
1330        // train hierarchically even when routing stays flat.
1331        for leaves in [64usize, 1_000_000] {
1332            assert_eq!(
1333                effective_binary_routing_mode(IvfRoutingMode::Hnsw, leaves),
1334                IvfRoutingMode::Hnsw
1335            );
1336            assert_eq!(
1337                effective_binary_routing_mode(IvfRoutingMode::TwoLevel, leaves),
1338                IvfRoutingMode::TwoLevel
1339            );
1340        }
1341        const {
1342            assert!(HIERARCHICAL_TRAINING_THRESHOLD <= BINARY_HNSW_AUTO_THRESHOLD);
1343        }
1344    }
1345
1346    #[test]
1347    fn deterministic_selection_supports_both_metric_directions() {
1348        let scores = [0.5, 0.9, 0.1, 0.9];
1349        assert_eq!(select_best::<true>(&scores, 2), vec![1, 3]);
1350        assert_eq!(select_best::<false>(&scores, 2), vec![2, 0]);
1351    }
1352
1353    #[test]
1354    fn two_level_beam_is_oversubscribed_but_bounded() {
1355        assert_eq!(parent_probe_count(32, 65_536, 256), 1);
1356        assert_eq!(parent_probe_count(256, 65_536, 256), 4);
1357        assert_eq!(parent_probe_count(65_536, 65_536, 256), 256);
1358    }
1359
1360    #[test]
1361    fn two_level_beam_expands_until_skewed_parents_cover_leaf_budget() {
1362        let mut children: Vec<Vec<u32>> = (0..9).map(|leaf| vec![leaf]).collect();
1363        children.push((9..100).collect());
1364        let topology = IvfRoutingTopology::from_children(&children);
1365
1366        // The average-size heuristic initially chooses two parents. The four
1367        // closest parents contain only one leaf each, so the beam must expand
1368        // to four to honor nprobe=4.
1369        let lower_is_better: Vec<f32> = (0..10).map(|score| score as f32).collect();
1370        assert_eq!(
1371            select_parent_beam::<false>(&lower_is_better, &topology, 4),
1372            vec![0, 1, 2, 3]
1373        );
1374
1375        let higher_is_better: Vec<f32> = (0..10).rev().map(|score| score as f32).collect();
1376        assert_eq!(
1377            select_parent_beam::<true>(&higher_is_better, &topology, 4),
1378            vec![0, 1, 2, 3]
1379        );
1380    }
1381
1382    #[test]
1383    fn build_parent_beam_uses_four_populated_parents_when_query_uses_one() {
1384        let children: Vec<Vec<u32>> = (0..4)
1385            .map(|parent| {
1386                let first = parent * 512;
1387                (first..first + 512).map(|leaf| leaf as u32).collect()
1388            })
1389            .collect();
1390        let topology = IvfRoutingTopology::from_children(&children);
1391
1392        let lower_is_better = [0.0, 1.0, 2.0, 3.0];
1393        assert_eq!(
1394            select_parent_beam::<false>(&lower_is_better, &topology, 128),
1395            vec![0]
1396        );
1397        assert_eq!(
1398            select_parent_beam_for_build::<false>(&lower_is_better, &topology, 128),
1399            vec![0, 1, 2, 3]
1400        );
1401
1402        let higher_is_better = [4.0, 3.0, 2.0, 1.0];
1403        assert_eq!(
1404            select_parent_beam::<true>(&higher_is_better, &topology, 128),
1405            vec![0]
1406        );
1407        assert_eq!(
1408            select_parent_beam_for_build::<true>(&higher_is_better, &topology, 128),
1409            vec![0, 1, 2, 3]
1410        );
1411    }
1412
1413    #[test]
1414    fn build_parent_beam_uses_every_available_populated_parent() {
1415        let children = vec![vec![0], vec![], vec![1], vec![], vec![2]];
1416        let topology = IvfRoutingTopology::from_children(&children);
1417        let scores = [1.0, 0.0, 2.0, -1.0, 3.0];
1418
1419        assert_eq!(
1420            select_parent_beam_for_build::<false>(&scores, &topology, 1),
1421            vec![0, 2, 4]
1422        );
1423    }
1424
1425    #[test]
1426    fn child_allocation_uses_largest_remainders_instead_of_largest_parent() {
1427        assert_eq!(allocate_child_clusters(&[100, 90], 4), vec![2, 2]);
1428        assert_eq!(allocate_child_clusters(&[5, 5, 5], 5), vec![2, 2, 1]);
1429        assert_eq!(allocate_child_clusters(&[90, 10], 10), vec![9, 1]);
1430    }
1431
1432    #[test]
1433    fn child_allocation_is_exact_capacity_bounded_and_deterministic() {
1434        assert_eq!(
1435            allocate_child_clusters(&[1, 100, 7, 0], 100),
1436            vec![1, 93, 6, 0]
1437        );
1438        assert_eq!(allocate_child_clusters(&[1, 2, 0], 10), vec![1, 2, 0]);
1439        assert_eq!(allocate_child_clusters(&[10, 9, 8], 2), vec![1, 1, 0]);
1440
1441        for target in 0..=140 {
1442            let sizes = [100, 30, 0, 7];
1443            let allocation = allocate_child_clusters(&sizes, target);
1444            assert_eq!(
1445                allocation.iter().sum::<usize>(),
1446                target.min(sizes.iter().sum())
1447            );
1448            assert!(
1449                allocation
1450                    .iter()
1451                    .zip(sizes)
1452                    .all(|(&cells, size)| cells <= size)
1453            );
1454            if target >= sizes.iter().filter(|&&size| size > 0).count() {
1455                assert!(
1456                    allocation
1457                        .iter()
1458                        .zip(sizes)
1459                        .all(|(&cells, size)| size == 0 || cells > 0)
1460                );
1461            }
1462        }
1463    }
1464
1465    #[test]
1466    fn compact_hnsw_routes_without_copying_points() {
1467        let points: Vec<[f32; 2]> = (0..512)
1468            .map(|index| {
1469                let angle = index as f32 * std::f32::consts::TAU / 512.0;
1470                [angle.cos(), angle.sin()]
1471            })
1472            .collect();
1473        let distance = |left: u32, right: u32| {
1474            let [lx, ly] = points[left as usize];
1475            let [rx, ry] = points[right as usize];
1476            (lx - rx).powi(2) + (ly - ry).powi(2)
1477        };
1478        let graph = HnswRoutingGraph::build(points.len(), distance, 42, "test");
1479        assert!(graph.validate(points.len()));
1480        assert!(graph.size_bytes() < points.len() * 512);
1481
1482        let query = [0.37f32, -0.91];
1483        let query_distance_calls = std::cell::Cell::new(0usize);
1484        let routed = graph.search(
1485            |node| {
1486                query_distance_calls.set(query_distance_calls.get() + 1);
1487                let [x, y] = points[node as usize];
1488                (x - query[0]).powi(2) + (y - query[1]).powi(2)
1489            },
1490            10,
1491        );
1492        let mut exact: Vec<u32> = (0..points.len() as u32).collect();
1493        exact.sort_unstable_by(|&left, &right| {
1494            let score = |node: u32| {
1495                let [x, y] = points[node as usize];
1496                (x - query[0]).powi(2) + (y - query[1]).powi(2)
1497            };
1498            score(left)
1499                .total_cmp(&score(right))
1500                .then_with(|| left.cmp(&right))
1501        });
1502        assert_eq!(routed, exact[..10]);
1503
1504        let build_distance_calls = std::cell::Cell::new(0usize);
1505        let build_routed = graph.search_for_build(
1506            |node| {
1507                build_distance_calls.set(build_distance_calls.get() + 1);
1508                let [x, y] = points[node as usize];
1509                (x - query[0]).powi(2) + (y - query[1]).powi(2)
1510            },
1511            10,
1512        );
1513        assert_eq!(build_routed, exact[..10]);
1514        // Both budgets share the same floor, so a small take costs the same
1515        // either way; construction only widens once its oversample exceeds the
1516        // floor. The floor is what per-vector assignment pays, and it is set
1517        // from measured recall (see
1518        // `index::binary_ivf::tests::hnsw_build_beam_recall_saturates_before_the_floor`).
1519        assert_eq!(build_distance_calls.get(), query_distance_calls.get());
1520
1521        let wide_query_calls = std::cell::Cell::new(0usize);
1522        graph.search(
1523            |node| {
1524                wide_query_calls.set(wide_query_calls.get() + 1);
1525                let [x, y] = points[node as usize];
1526                (x - query[0]).powi(2) + (y - query[1]).powi(2)
1527            },
1528            64,
1529        );
1530        let wide_build_calls = std::cell::Cell::new(0usize);
1531        graph.search_for_build(
1532            |node| {
1533                wide_build_calls.set(wide_build_calls.get() + 1);
1534                let [x, y] = points[node as usize];
1535                (x - query[0]).powi(2) + (y - query[1]).powi(2)
1536            },
1537            64,
1538        );
1539        assert!(
1540            wide_build_calls.get() > wide_query_calls.get(),
1541            "multi-candidate construction should spend its wider search budget"
1542        );
1543
1544        // Single-candidate assignment returns the same leaf the ranked search
1545        // would have put first, without allocating a result list.
1546        let assigned = graph.search_best_for_build(|node| {
1547            let [x, y] = points[node as usize];
1548            (x - query[0]).powi(2) + (y - query[1]).powi(2)
1549        });
1550        assert_eq!(assigned, Some(exact[0]));
1551
1552        let bytes = bincode::serde::encode_to_vec(&graph, bincode::config::standard()).unwrap();
1553        let (decoded, consumed): (HnswRoutingGraph, usize) =
1554            bincode::serde::decode_from_slice(&bytes, bincode::config::standard()).unwrap();
1555        assert_eq!(consumed, bytes.len());
1556        assert!(decoded.validate(points.len()));
1557
1558        let mut corrupted = decoded;
1559        corrupted.node_offsets[1] = u32::MAX;
1560        assert!(!corrupted.validate(points.len()));
1561    }
1562}