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