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