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 index at this leaf count. Below
17/// it, a SIMD-friendly flat pass is normally cheaper than another level of
18/// indirection.
19pub const HNSW_AUTO_THRESHOLD: usize = 4_096;
20
21/// Extra leaf coverage requested from the parent level. A beam of four times
22/// the minimum parent count avoids the recall cliff of greedy one-parent
23/// hierarchical routing while keeping parent/leaf scoring sublinear.
24const PARENT_BEAM_OVERSAMPLE: usize = 4;
25/// Construction assignments become permanent, so inspect multiple populated
26/// parent cells even when the query-time leaf budget fits under one parent.
27const MIN_BUILD_PARENT_BEAM: usize = 4;
28
29const HNSW_M: usize = 32;
30const HNSW_EF_CONSTRUCTION: usize = 200;
31const HNSW_QUERY_OVERSAMPLE: usize = 4;
32const HNSW_MIN_EF_SEARCH: usize = 128;
33/// Index construction happens once per vector generation and can afford a
34/// wider centroid search than latency-sensitive queries. Keeping the budgets
35/// separate prevents an approximate query-router miss from permanently
36/// assigning a vector to a needlessly distant leaf.
37const HNSW_BUILD_OVERSAMPLE: usize = 8;
38const HNSW_MIN_EF_BUILD: usize = 512;
39
40#[derive(Clone, Copy, Debug)]
41struct GraphCandidate {
42    node: u32,
43    distance: f32,
44}
45
46impl PartialEq for GraphCandidate {
47    fn eq(&self, other: &Self) -> bool {
48        self.node == other.node && self.distance.to_bits() == other.distance.to_bits()
49    }
50}
51
52impl Eq for GraphCandidate {}
53
54impl PartialOrd for GraphCandidate {
55    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
56        Some(self.cmp(other))
57    }
58}
59
60impl Ord for GraphCandidate {
61    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
62        self.distance
63            .total_cmp(&other.distance)
64            .then_with(|| self.node.cmp(&other.node))
65    }
66}
67
68struct VisitedNodes {
69    epochs: Vec<u32>,
70    current: u32,
71}
72
73impl VisitedNodes {
74    fn new(nodes: usize) -> Self {
75        Self {
76            epochs: vec![0; nodes],
77            current: 0,
78        }
79    }
80
81    fn reset(&mut self) {
82        self.current = self.current.wrapping_add(1);
83        if self.current == 0 {
84            self.epochs.fill(0);
85            self.current = 1;
86        }
87    }
88
89    fn ensure_nodes(&mut self, nodes: usize) {
90        if self.epochs.len() < nodes {
91            self.epochs.resize(nodes, 0);
92        }
93    }
94
95    fn insert(&mut self, node: u32) -> bool {
96        let slot = &mut self.epochs[node as usize];
97        if *slot == self.current {
98            false
99        } else {
100            *slot = self.current;
101            true
102        }
103    }
104}
105
106struct HnswQueryScratch {
107    visited: VisitedNodes,
108    candidates: BinaryHeap<Reverse<GraphCandidate>>,
109    best: BinaryHeap<GraphCandidate>,
110    ordered: Vec<GraphCandidate>,
111}
112
113impl HnswQueryScratch {
114    fn new() -> Self {
115        Self {
116            visited: VisitedNodes::new(0),
117            candidates: BinaryHeap::new(),
118            best: BinaryHeap::new(),
119            ordered: Vec::new(),
120        }
121    }
122}
123
124thread_local! {
125    /// Segment construction routes millions of vectors through the same graph.
126    /// Retaining scratch per worker avoids zeroing the visited bitmap and
127    /// reallocating both heaps for every assignment.
128    static HNSW_QUERY_SCRATCH: std::cell::RefCell<HnswQueryScratch> =
129        std::cell::RefCell::new(HnswQueryScratch::new());
130}
131
132/// Compact, centroid-free HNSW topology. Node IDs are global leaf IDs, so the
133/// graph shares the quantizer's existing centroid matrix rather than storing a
134/// second copy of every vector.
135#[derive(Debug, Clone, Serialize, Deserialize)]
136pub struct HnswRoutingGraph {
137    m: u16,
138    ef_construction: u32,
139    entry_point: u32,
140    max_level: u8,
141    node_levels: Vec<u8>,
142    /// Per-node ranges into `level_offsets`; each node owns level_count + 1
143    /// offsets so every adjacency is a direct pair of indexed loads.
144    node_offsets: Vec<u32>,
145    level_offsets: Vec<u32>,
146    neighbors: Vec<u32>,
147}
148
149impl HnswRoutingGraph {
150    pub fn build(node_count: usize, distance: impl Fn(u32, u32) -> f32, seed: u64) -> Self {
151        assert!(node_count > 0 && node_count <= u32::MAX as usize);
152        let mut rng = rand::rngs::StdRng::seed_from_u64(seed);
153        let level_multiplier = 1.0 / (HNSW_M as f64).ln();
154        let node_levels: Vec<u8> = (0..node_count)
155            .map(|_| {
156                let uniform = rng.random::<f64>().clamp(f64::MIN_POSITIVE, 1.0);
157                (-uniform.ln() * level_multiplier).floor().min(31.0) as u8
158            })
159            .collect();
160        let mut insertion_order: Vec<u32> = (0..node_count as u32).collect();
161        insertion_order.shuffle(&mut rng);
162        let mut links: Vec<Vec<Vec<u32>>> = node_levels
163            .iter()
164            .map(|&level| vec![Vec::new(); level as usize + 1])
165            .collect();
166        let mut visited = VisitedNodes::new(node_count);
167        let mut entry_point = insertion_order[0];
168        let mut max_level = node_levels[entry_point as usize];
169
170        for &node in insertion_order.iter().skip(1) {
171            let node_level = node_levels[node as usize];
172            let mut entry = entry_point;
173            let node_distance = |candidate| distance(node, candidate);
174
175            for level in ((node_level as usize + 1)..=max_level as usize).rev() {
176                entry = greedy_search_level(&links, entry, level, &node_distance);
177            }
178
179            for level in (0..=usize::min(node_level as usize, max_level as usize)).rev() {
180                let candidates = search_graph_layer(
181                    &links,
182                    entry,
183                    level,
184                    HNSW_EF_CONSTRUCTION,
185                    &node_distance,
186                    &mut visited,
187                );
188                if let Some(best) = candidates.first() {
189                    entry = best.node;
190                }
191                let max_connections = if level == 0 { HNSW_M * 2 } else { HNSW_M };
192                let selected =
193                    select_diverse_neighbors(node, candidates, max_connections, &distance);
194                links[node as usize][level] = selected.clone();
195                for neighbor in selected {
196                    let adjacency = &mut links[neighbor as usize][level];
197                    if !adjacency.contains(&node) {
198                        adjacency.push(node);
199                    }
200                    if adjacency.len() > max_connections {
201                        let candidates = adjacency
202                            .iter()
203                            .copied()
204                            .map(|candidate| GraphCandidate {
205                                node: candidate,
206                                distance: distance(neighbor, candidate),
207                            })
208                            .collect();
209                        *adjacency = select_diverse_neighbors(
210                            neighbor,
211                            candidates,
212                            max_connections,
213                            &distance,
214                        );
215                    }
216                }
217            }
218
219            if node_level > max_level {
220                entry_point = node;
221                max_level = node_level;
222            }
223        }
224
225        Self::compact(
226            HNSW_M,
227            HNSW_EF_CONSTRUCTION,
228            entry_point,
229            max_level,
230            node_levels,
231            links,
232        )
233    }
234
235    fn compact(
236        m: usize,
237        ef_construction: usize,
238        entry_point: u32,
239        max_level: u8,
240        node_levels: Vec<u8>,
241        links: Vec<Vec<Vec<u32>>>,
242    ) -> Self {
243        let mut node_offsets = Vec::with_capacity(links.len() + 1);
244        let level_count: usize = links.iter().map(|levels| levels.len() + 1).sum();
245        let neighbor_count: usize = links
246            .iter()
247            .flat_map(|levels| levels.iter())
248            .map(Vec::len)
249            .sum();
250        let mut level_offsets = Vec::with_capacity(level_count);
251        let mut neighbors = Vec::with_capacity(neighbor_count);
252        for levels in links {
253            node_offsets.push(level_offsets.len() as u32);
254            for mut adjacency in levels {
255                adjacency.sort_unstable();
256                adjacency.dedup();
257                level_offsets.push(neighbors.len() as u32);
258                neighbors.extend(adjacency);
259            }
260            level_offsets.push(neighbors.len() as u32);
261        }
262        node_offsets.push(level_offsets.len() as u32);
263        Self {
264            m: m as u16,
265            ef_construction: ef_construction as u32,
266            entry_point,
267            max_level,
268            node_levels,
269            node_offsets,
270            level_offsets,
271            neighbors,
272        }
273    }
274
275    #[inline]
276    pub fn neighbors(&self, node: u32, level: usize) -> &[u32] {
277        if (self.node_levels[node as usize] as usize) < level {
278            return &[];
279        }
280        let offset_index = self.node_offsets[node as usize] as usize + level;
281        let start = self.level_offsets[offset_index] as usize;
282        let end = self.level_offsets[offset_index + 1] as usize;
283        &self.neighbors[start..end]
284    }
285
286    pub fn search(&self, query_distance: impl Fn(u32) -> f32, take: usize) -> Vec<u32> {
287        let take = take.min(self.node_levels.len());
288        if take == 0 {
289            return Vec::new();
290        }
291        let ef_search = take
292            .saturating_mul(HNSW_QUERY_OVERSAMPLE)
293            .max(HNSW_MIN_EF_SEARCH)
294            .min(self.node_levels.len());
295        self.search_with_budget(query_distance, take, ef_search)
296    }
297
298    /// Higher-recall centroid search used only while constructing postings.
299    pub(crate) fn search_for_build(
300        &self,
301        query_distance: impl Fn(u32) -> f32,
302        take: usize,
303    ) -> Vec<u32> {
304        let take = take.min(self.node_levels.len());
305        if take == 0 {
306            return Vec::new();
307        }
308        let ef_search = take
309            .saturating_mul(HNSW_BUILD_OVERSAMPLE)
310            .max(HNSW_MIN_EF_BUILD)
311            .min(self.node_levels.len());
312        self.search_with_budget(query_distance, take, ef_search)
313    }
314
315    fn search_with_budget(
316        &self,
317        query_distance: impl Fn(u32) -> f32,
318        take: usize,
319        ef_search: usize,
320    ) -> Vec<u32> {
321        let mut entry = self.entry_point;
322        for level in (1..=self.max_level as usize).rev() {
323            entry = greedy_search_compact(self, entry, level, &query_distance);
324        }
325        HNSW_QUERY_SCRATCH.with(|scratch| {
326            let mut scratch = scratch.borrow_mut();
327            search_compact_layer_reusing(self, entry, ef_search, &query_distance, &mut scratch);
328            scratch
329                .ordered
330                .iter()
331                .take(take)
332                .map(|candidate| candidate.node)
333                .collect()
334        })
335    }
336
337    pub fn validate(&self, expected_nodes: usize) -> bool {
338        if self.m as usize != HNSW_M
339            || self.ef_construction as usize != HNSW_EF_CONSTRUCTION
340            || expected_nodes == 0
341            || self.node_levels.len() != expected_nodes
342            || self.node_offsets.len() != expected_nodes + 1
343            || self.node_offsets.first() != Some(&0)
344            || self.node_offsets.last().copied() != Some(self.level_offsets.len() as u32)
345            || self.node_offsets.windows(2).any(|pair| pair[0] > pair[1])
346            || self
347                .node_offsets
348                .iter()
349                .any(|&offset| offset as usize > self.level_offsets.len())
350            || self.entry_point as usize >= expected_nodes
351            || self.node_levels[self.entry_point as usize] != self.max_level
352            || self.node_levels.iter().copied().max() != Some(self.max_level)
353            || self.level_offsets.last().copied() != Some(self.neighbors.len() as u32)
354            || self.level_offsets.windows(2).any(|pair| pair[0] > pair[1])
355            || self
356                .neighbors
357                .iter()
358                .any(|&node| node as usize >= expected_nodes)
359        {
360            return false;
361        }
362        for node in 0..expected_nodes {
363            let start = self.node_offsets[node] as usize;
364            let end = self.node_offsets[node + 1] as usize;
365            if end.saturating_sub(start) != self.node_levels[node] as usize + 2 {
366                return false;
367            }
368            for level in 0..=self.node_levels[node] as usize {
369                let adjacency = self.neighbors(node as u32, level);
370                let max_connections = if level == 0 { HNSW_M * 2 } else { HNSW_M };
371                if adjacency.len() > max_connections
372                    || adjacency.contains(&(node as u32))
373                    || adjacency.windows(2).any(|pair| pair[0] >= pair[1])
374                {
375                    return false;
376                }
377            }
378        }
379        true
380    }
381
382    pub fn size_bytes(&self) -> usize {
383        self.node_levels.len()
384            + self.node_offsets.len() * size_of::<u32>()
385            + self.level_offsets.len() * size_of::<u32>()
386            + self.neighbors.len() * size_of::<u32>()
387            + 32
388    }
389
390    /// Visit the compact, immutable arrays touched by every HNSW route.
391    /// Query scratch is thread-local and intentionally excluded.
392    #[cfg(feature = "native")]
393    pub(crate) fn visit_resident_regions(&self, visit: &mut dyn FnMut(&'static str, &[u8])) {
394        visit("HNSW node levels", bytes_of_slice(&self.node_levels));
395        visit("HNSW node offsets", bytes_of_slice(&self.node_offsets));
396        visit("HNSW level offsets", bytes_of_slice(&self.level_offsets));
397        visit("HNSW neighbors", bytes_of_slice(&self.neighbors));
398    }
399}
400
401fn greedy_search_level(
402    links: &[Vec<Vec<u32>>],
403    mut current: u32,
404    level: usize,
405    query_distance: &impl Fn(u32) -> f32,
406) -> u32 {
407    let mut current_distance = query_distance(current);
408    loop {
409        let mut changed = false;
410        for &candidate in &links[current as usize][level] {
411            let distance = query_distance(candidate);
412            if distance < current_distance || (distance == current_distance && candidate < current)
413            {
414                current = candidate;
415                current_distance = distance;
416                changed = true;
417            }
418        }
419        if !changed {
420            return current;
421        }
422    }
423}
424
425fn greedy_search_compact(
426    graph: &HnswRoutingGraph,
427    mut current: u32,
428    level: usize,
429    query_distance: &impl Fn(u32) -> f32,
430) -> u32 {
431    let mut current_distance = query_distance(current);
432    loop {
433        let mut changed = false;
434        for &candidate in graph.neighbors(current, level) {
435            let distance = query_distance(candidate);
436            if distance < current_distance || (distance == current_distance && candidate < current)
437            {
438                current = candidate;
439                current_distance = distance;
440                changed = true;
441            }
442        }
443        if !changed {
444            return current;
445        }
446    }
447}
448
449fn search_graph_layer(
450    links: &[Vec<Vec<u32>>],
451    entry: u32,
452    level: usize,
453    ef: usize,
454    query_distance: &impl Fn(u32) -> f32,
455    visited: &mut VisitedNodes,
456) -> Vec<GraphCandidate> {
457    search_layer_impl(entry, ef, query_distance, visited, |node| {
458        &links[node as usize][level]
459    })
460}
461
462fn search_compact_layer_reusing(
463    graph: &HnswRoutingGraph,
464    entry: u32,
465    ef: usize,
466    query_distance: &impl Fn(u32) -> f32,
467    scratch: &mut HnswQueryScratch,
468) {
469    scratch.visited.ensure_nodes(graph.node_levels.len());
470    scratch.visited.reset();
471    scratch.candidates.clear();
472    scratch.best.clear();
473    scratch.ordered.clear();
474    scratch.visited.insert(entry);
475    let first = GraphCandidate {
476        node: entry,
477        distance: query_distance(entry),
478    };
479    scratch.candidates.push(Reverse(first));
480    scratch.best.push(first);
481
482    while let Some(Reverse(current)) = scratch.candidates.pop() {
483        if scratch.best.len() >= ef
484            && scratch
485                .best
486                .peek()
487                .is_some_and(|worst| current.distance > worst.distance)
488        {
489            break;
490        }
491        for &neighbor in graph.neighbors(current.node, 0) {
492            if !scratch.visited.insert(neighbor) {
493                continue;
494            }
495            let candidate = GraphCandidate {
496                node: neighbor,
497                distance: query_distance(neighbor),
498            };
499            if scratch.best.len() < ef
500                || scratch.best.peek().is_some_and(|worst| candidate < *worst)
501            {
502                scratch.candidates.push(Reverse(candidate));
503                scratch.best.push(candidate);
504                if scratch.best.len() > ef {
505                    scratch.best.pop();
506                }
507            }
508        }
509    }
510    scratch.ordered.extend(scratch.best.drain());
511    scratch.ordered.sort_unstable();
512}
513
514fn search_layer_impl<'a>(
515    entry: u32,
516    ef: usize,
517    query_distance: &impl Fn(u32) -> f32,
518    visited: &mut VisitedNodes,
519    neighbors: impl Fn(u32) -> &'a [u32],
520) -> Vec<GraphCandidate> {
521    visited.reset();
522    visited.insert(entry);
523    let first = GraphCandidate {
524        node: entry,
525        distance: query_distance(entry),
526    };
527    let mut candidates = BinaryHeap::new();
528    let mut best = BinaryHeap::new();
529    candidates.push(Reverse(first));
530    best.push(first);
531
532    while let Some(Reverse(current)) = candidates.pop() {
533        if best.len() >= ef
534            && best
535                .peek()
536                .is_some_and(|worst| current.distance > worst.distance)
537        {
538            break;
539        }
540        for &neighbor in neighbors(current.node) {
541            if !visited.insert(neighbor) {
542                continue;
543            }
544            let candidate = GraphCandidate {
545                node: neighbor,
546                distance: query_distance(neighbor),
547            };
548            if best.len() < ef || best.peek().is_some_and(|worst| candidate < *worst) {
549                candidates.push(Reverse(candidate));
550                best.push(candidate);
551                if best.len() > ef {
552                    best.pop();
553                }
554            }
555        }
556    }
557    best.into_sorted_vec()
558}
559
560fn select_diverse_neighbors(
561    query_node: u32,
562    mut candidates: Vec<GraphCandidate>,
563    limit: usize,
564    distance: &impl Fn(u32, u32) -> f32,
565) -> Vec<u32> {
566    candidates.sort_unstable();
567    candidates.dedup_by_key(|candidate| candidate.node);
568    let mut selected = Vec::with_capacity(limit);
569    let mut deferred = Vec::new();
570    for candidate in candidates {
571        if candidate.node == query_node {
572            continue;
573        }
574        if selected
575            .iter()
576            .all(|&neighbor| distance(candidate.node, neighbor) > candidate.distance)
577        {
578            selected.push(candidate.node);
579            if selected.len() == limit {
580                return selected;
581            }
582        } else {
583            deferred.push(candidate.node);
584        }
585    }
586    for candidate in deferred {
587        if selected.len() == limit {
588            break;
589        }
590        selected.push(candidate);
591    }
592    selected
593}
594
595/// Compact parent-to-leaf adjacency shared by float and binary quantizers.
596/// Offsets avoid one heap allocation per parent and serialize as two flat
597/// arrays in the single index-level quantizer artifact.
598#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
599pub struct IvfRoutingTopology {
600    child_offsets: Vec<u32>,
601    leaf_ids: Vec<u32>,
602}
603
604impl IvfRoutingTopology {
605    pub fn from_children(children: &[Vec<u32>]) -> Self {
606        let mut child_offsets = Vec::with_capacity(children.len() + 1);
607        let mut leaf_ids = Vec::new();
608        child_offsets.push(0);
609        for child_list in children {
610            leaf_ids.extend_from_slice(child_list);
611            child_offsets.push(leaf_ids.len() as u32);
612        }
613        Self {
614            child_offsets,
615            leaf_ids,
616        }
617    }
618
619    pub fn parent_count(&self) -> usize {
620        self.child_offsets.len().saturating_sub(1)
621    }
622
623    pub fn children(&self, parent: usize) -> &[u32] {
624        let start = self.child_offsets[parent] as usize;
625        let end = self.child_offsets[parent + 1] as usize;
626        &self.leaf_ids[start..end]
627    }
628
629    pub fn validate(&self, num_leaves: usize) -> bool {
630        if self.parent_count() == 0 {
631            return self.child_offsets.is_empty() && self.leaf_ids.is_empty();
632        }
633        self.child_offsets.first() == Some(&0)
634            && self.child_offsets.last().copied() == Some(self.leaf_ids.len() as u32)
635            && self.child_offsets.windows(2).all(|pair| pair[0] <= pair[1])
636            && self.leaf_ids.len() == num_leaves
637            && self.leaf_ids.iter().all(|&leaf| leaf < num_leaves as u32)
638            && {
639                let mut leaves = self.leaf_ids.clone();
640                leaves.sort_unstable();
641                leaves.iter().copied().eq(0..num_leaves as u32)
642            }
643    }
644
645    #[cfg(feature = "native")]
646    pub(crate) fn visit_resident_regions(&self, visit: &mut dyn FnMut(&'static str, &[u8])) {
647        visit(
648            "two-level child offsets",
649            bytes_of_slice(&self.child_offsets),
650        );
651        visit("two-level leaf IDs", bytes_of_slice(&self.leaf_ids));
652    }
653}
654
655/// View an initialized plain-data slice as bytes for residency operations.
656/// The returned slice cannot outlive the source and is never mutated.
657#[cfg(feature = "native")]
658pub(crate) fn bytes_of_slice<T>(slice: &[T]) -> &[u8] {
659    let byte_len = std::mem::size_of_val(slice);
660    if byte_len == 0 {
661        return &[];
662    }
663    // SAFETY: every byte in an initialized `T` allocation may be read as u8;
664    // the lifetime remains tied to `slice`, and callers receive no mutation.
665    unsafe { std::slice::from_raw_parts(slice.as_ptr().cast::<u8>(), byte_len) }
666}
667
668pub fn routing_parent_count(num_leaves: usize) -> usize {
669    if num_leaves <= 1 {
670        return num_leaves;
671    }
672    ((num_leaves as f64).sqrt().ceil() as usize)
673        .clamp(2, 4_096)
674        .min(num_leaves)
675}
676
677/// Allocate `total_clusters` child cells proportionally to populated parent
678/// groups, or one per training point when fewer points are available.
679pub fn allocate_child_clusters(group_sizes: &[usize], total_clusters: usize) -> Vec<usize> {
680    let total_points: u128 = group_sizes.iter().map(|&size| size as u128).sum();
681    let target = (total_clusters as u128).min(total_points) as usize;
682    if target == 0 {
683        return vec![0; group_sizes.len()];
684    }
685
686    let populated = group_sizes.iter().filter(|&&size| size > 0).count();
687    let guarantee_populated = target >= populated;
688    let mut allocated = vec![0usize; group_sizes.len()];
689    let mut fixed = vec![false; group_sizes.len()];
690    let mut fixed_cells = 0usize;
691
692    if guarantee_populated {
693        // Solve the lower-bounded proportional allocation
694        //
695        //     allocation_i = max(1, lambda * group_size_i)
696        //
697        // by successively fixing cells whose unconstrained quota is at most
698        // one. This avoids letting the one-per-parent guarantee distort a
699        // 90/10 population into an 80/20 child split.
700        loop {
701            let active_weight: u128 = group_sizes
702                .iter()
703                .enumerate()
704                .filter(|(index, size)| **size > 0 && !fixed[*index])
705                .map(|(_, &size)| size as u128)
706                .sum();
707            let active_target = target - fixed_cells;
708            if active_weight == 0 || active_target == 0 {
709                break;
710            }
711            let newly_fixed = group_sizes
712                .iter()
713                .enumerate()
714                .filter_map(|(index, &size)| {
715                    (size > 0
716                        && !fixed[index]
717                        && (size as u128) * (active_target as u128) <= active_weight)
718                        .then_some(index)
719                })
720                .collect::<Vec<_>>();
721            if newly_fixed.is_empty() {
722                break;
723            }
724            for index in newly_fixed {
725                fixed[index] = true;
726                allocated[index] = 1;
727                fixed_cells += 1;
728            }
729        }
730    }
731
732    let remaining = target - fixed_cells;
733    if remaining == 0 {
734        return allocated;
735    }
736    let active_weight: u128 = group_sizes
737        .iter()
738        .enumerate()
739        .filter(|(index, size)| **size > 0 && !fixed[*index])
740        .map(|(_, &size)| size as u128)
741        .sum();
742    debug_assert!(active_weight > 0);
743
744    let mut remainders = Vec::with_capacity(group_sizes.len());
745    for (index, &size) in group_sizes.iter().enumerate() {
746        if size == 0 || fixed[index] {
747            continue;
748        }
749        let numerator = (remaining as u128) * (size as u128);
750        let whole = (numerator / active_weight) as usize;
751        allocated[index] = whole;
752        if whole < size {
753            remainders.push((index, numerator % active_weight));
754        }
755    }
756
757    let remainder_cells = target - allocated.iter().sum::<usize>();
758    remainders.sort_unstable_by(|(left_index, left), (right_index, right)| {
759        right.cmp(left).then_with(|| left_index.cmp(right_index))
760    });
761    debug_assert!(remainder_cells <= remainders.len());
762    for (index, _) in remainders.into_iter().take(remainder_cells) {
763        allocated[index] += 1;
764    }
765    debug_assert_eq!(allocated.iter().sum::<usize>(), target);
766    debug_assert!(
767        allocated
768            .iter()
769            .zip(group_sizes)
770            .all(|(&cells, &size)| cells <= size)
771    );
772    allocated
773}
774
775/// A centroid selection computed once and reused by every segment.
776#[derive(Debug, Clone, PartialEq, Eq)]
777pub struct IvfProbePlan {
778    pub quantizer_version: u64,
779    /// Hash of the query, routing mode, and requested leaf count. This keeps a
780    /// reused mutable query object from accidentally reusing an older route.
781    pub request_fingerprint: u64,
782    pub cluster_ids: Arc<[u32]>,
783}
784
785impl IvfProbePlan {
786    pub fn new(quantizer_version: u64, request_fingerprint: u64, cluster_ids: Vec<u32>) -> Self {
787        Self {
788            quantizer_version,
789            request_fingerprint,
790            cluster_ids: cluster_ids.into(),
791        }
792    }
793}
794
795fn fingerprint_words(
796    mode: IvfRoutingMode,
797    nprobe: usize,
798    words: impl IntoIterator<Item = u64>,
799) -> u64 {
800    // FNV-1a with an extra avalanche. This is a cache key, not a persisted
801    // identity or an adversarial hash table key.
802    let mut hash = 0xcbf2_9ce4_8422_2325u64;
803    let mode_tag = match mode {
804        IvfRoutingMode::Auto => 0u64,
805        IvfRoutingMode::Flat => 1,
806        IvfRoutingMode::TwoLevel => 2,
807        IvfRoutingMode::Hnsw => 3,
808    };
809    for word in std::iter::once(mode_tag)
810        .chain(std::iter::once(nprobe as u64))
811        .chain(words)
812    {
813        hash ^= word;
814        hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
815    }
816    hash ^= hash >> 33;
817    hash = hash.wrapping_mul(0xff51_afd7_ed55_8ccd);
818    hash ^ (hash >> 33)
819}
820
821pub fn float_probe_fingerprint(query: &[f32], nprobe: usize, mode: IvfRoutingMode) -> u64 {
822    fingerprint_words(
823        mode,
824        nprobe,
825        query.iter().map(|value| value.to_bits() as u64),
826    )
827}
828
829pub(crate) fn normalize_cosine_in_place(vector: &mut [f32]) {
830    let norm = crate::structures::simd::dot_product_f32(vector, vector, vector.len()).sqrt();
831    let inverse_norm = if norm.is_finite() && norm > 0.0 {
832        1.0 / norm
833    } else {
834        0.0
835    };
836    vector.iter_mut().for_each(|value| *value *= inverse_norm);
837}
838
839pub(crate) fn normalized_cosine_query(query: &[f32]) -> Vec<f32> {
840    let mut normalized = query.to_vec();
841    normalize_cosine_in_place(&mut normalized);
842    normalized
843}
844
845pub(crate) fn cosine_probe_fingerprint(query: &[f32], nprobe: usize, mode: IvfRoutingMode) -> u64 {
846    let norm = crate::structures::simd::dot_product_f32(query, query, query.len()).sqrt();
847    let inverse_norm = if norm.is_finite() && norm > 0.0 {
848        1.0 / norm
849    } else {
850        0.0
851    };
852    fingerprint_words(
853        mode,
854        nprobe,
855        query
856            .iter()
857            .map(|value| (value * inverse_norm).to_bits() as u64),
858    )
859}
860
861pub fn binary_probe_fingerprint(query: &[u8], nprobe: usize, mode: IvfRoutingMode) -> u64 {
862    fingerprint_words(mode, nprobe, query.iter().map(|&value| value as u64))
863}
864
865#[inline]
866pub fn effective_routing_mode(mode: IvfRoutingMode, num_leaves: usize) -> IvfRoutingMode {
867    match mode {
868        IvfRoutingMode::Auto if num_leaves >= HNSW_AUTO_THRESHOLD => IvfRoutingMode::Hnsw,
869        IvfRoutingMode::Auto => IvfRoutingMode::Flat,
870        explicit => explicit,
871    }
872}
873
874/// Number of parent cells to put in the routing beam.
875pub fn parent_probe_count(nprobe: usize, num_leaves: usize, num_parents: usize) -> usize {
876    if num_parents == 0 || num_leaves == 0 {
877        return 0;
878    }
879    let leaves_per_parent = num_leaves.div_ceil(num_parents).max(1);
880    nprobe
881        .saturating_mul(PARENT_BEAM_OVERSAMPLE)
882        .div_ceil(leaves_per_parent)
883        .clamp(1, num_parents)
884}
885
886/// Select the closest parent beam while guaranteeing enough child leaves to
887/// satisfy the requested leaf budget. The usual oversubscribed beam remains
888/// the fast path; only an uneven topology that underfills the budget pays for
889/// ranking additional parents.
890pub fn select_parent_beam<const HIGHER_IS_BETTER: bool>(
891    scores: &[f32],
892    topology: &IvfRoutingTopology,
893    requested_leaves: usize,
894) -> Vec<u32> {
895    let parent_count = scores.len().min(topology.parent_count());
896    let leaf_count = topology.leaf_ids.len();
897    let requested_leaves = requested_leaves.min(leaf_count);
898    if parent_count == 0 || requested_leaves == 0 {
899        return Vec::new();
900    }
901
902    let initial_take =
903        parent_probe_count(requested_leaves, leaf_count, parent_count).min(parent_count);
904    let scores = &scores[..parent_count];
905    let initial = select_best::<HIGHER_IS_BETTER>(scores, initial_take);
906    let initial_coverage: usize = initial
907        .iter()
908        .map(|&parent| topology.children(parent as usize).len())
909        .sum();
910    if initial_coverage >= requested_leaves || initial_take == parent_count {
911        return initial;
912    }
913
914    let mut ranked = select_best::<HIGHER_IS_BETTER>(scores, parent_count);
915    let mut coverage = 0usize;
916    let mut take = parent_count;
917    for (index, &parent) in ranked.iter().enumerate() {
918        coverage = coverage.saturating_add(topology.children(parent as usize).len());
919        if index + 1 >= initial_take && coverage >= requested_leaves {
920            take = index + 1;
921            break;
922        }
923    }
924    ranked.truncate(take);
925    ranked
926}
927
928/// Select a construction-time parent beam without narrowing query routing.
929///
930/// The query beam remains the lower bound for leaf coverage, while offline
931/// construction inspects at least four populated parents when available. Empty
932/// parents are removed because they contribute no leaf candidates.
933pub fn select_parent_beam_for_build<const HIGHER_IS_BETTER: bool>(
934    scores: &[f32],
935    topology: &IvfRoutingTopology,
936    requested_leaves: usize,
937) -> Vec<u32> {
938    if requested_leaves == 0 {
939        return Vec::new();
940    }
941    let parent_count = scores.len().min(topology.parent_count());
942    if parent_count == 0 {
943        return Vec::new();
944    }
945
946    let query_parents = select_parent_beam::<HIGHER_IS_BETTER>(scores, topology, requested_leaves);
947    let query_populated = query_parents
948        .iter()
949        .filter(|&&parent| !topology.children(parent as usize).is_empty())
950        .count();
951
952    let scores = &scores[..parent_count];
953    let mut ranked = select_best::<HIGHER_IS_BETTER>(scores, parent_count);
954    ranked.retain(|&parent| !topology.children(parent as usize).is_empty());
955    let take = query_populated
956        .max(MIN_BUILD_PARENT_BEAM.min(ranked.len()))
957        .min(ranked.len());
958    ranked.truncate(take);
959    ranked
960}
961
962/// Deterministically select the best score indexes without fully sorting the
963/// input. `HIGHER_IS_BETTER` covers Hamming similarity; `false` covers L2.
964pub fn select_best<const HIGHER_IS_BETTER: bool>(scores: &[f32], take: usize) -> Vec<u32> {
965    let take = take.min(scores.len());
966    if take == 0 {
967        return Vec::new();
968    }
969    let mut order: Vec<u32> = (0..scores.len() as u32).collect();
970    let compare = |left: &u32, right: &u32| {
971        let left_score = scores[*left as usize];
972        let right_score = scores[*right as usize];
973        let score_order = if HIGHER_IS_BETTER {
974            right_score.total_cmp(&left_score)
975        } else {
976            left_score.total_cmp(&right_score)
977        };
978        score_order.then_with(|| left.cmp(right))
979    };
980    if take < order.len() {
981        order.select_nth_unstable_by(take, compare);
982        order.truncate(take);
983    }
984    order.sort_unstable_by(compare);
985    order
986}
987
988/// Select leaf IDs from a scored candidate set. Candidate IDs need not be
989/// contiguous, which lets both metrics share the exact same two-level beam
990/// implementation.
991pub fn select_best_candidates<const HIGHER_IS_BETTER: bool>(
992    candidates: &mut Vec<(u32, f32)>,
993    take: usize,
994) -> Vec<u32> {
995    let take = take.min(candidates.len());
996    if take == 0 {
997        return Vec::new();
998    }
999    let compare = |left: &(u32, f32), right: &(u32, f32)| {
1000        let score_order = if HIGHER_IS_BETTER {
1001            right.1.total_cmp(&left.1)
1002        } else {
1003            left.1.total_cmp(&right.1)
1004        };
1005        score_order.then_with(|| left.0.cmp(&right.0))
1006    };
1007    if take < candidates.len() {
1008        candidates.select_nth_unstable_by(take, compare);
1009        candidates.truncate(take);
1010    }
1011    candidates.sort_unstable_by(compare);
1012    candidates
1013        .iter()
1014        .map(|(cluster_id, _)| *cluster_id)
1015        .collect()
1016}
1017
1018#[cfg(test)]
1019mod tests {
1020    use super::*;
1021
1022    #[test]
1023    fn deterministic_selection_supports_both_metric_directions() {
1024        let scores = [0.5, 0.9, 0.1, 0.9];
1025        assert_eq!(select_best::<true>(&scores, 2), vec![1, 3]);
1026        assert_eq!(select_best::<false>(&scores, 2), vec![2, 0]);
1027    }
1028
1029    #[test]
1030    fn two_level_beam_is_oversubscribed_but_bounded() {
1031        assert_eq!(parent_probe_count(32, 65_536, 256), 1);
1032        assert_eq!(parent_probe_count(256, 65_536, 256), 4);
1033        assert_eq!(parent_probe_count(65_536, 65_536, 256), 256);
1034    }
1035
1036    #[test]
1037    fn two_level_beam_expands_until_skewed_parents_cover_leaf_budget() {
1038        let mut children: Vec<Vec<u32>> = (0..9).map(|leaf| vec![leaf]).collect();
1039        children.push((9..100).collect());
1040        let topology = IvfRoutingTopology::from_children(&children);
1041
1042        // The average-size heuristic initially chooses two parents. The four
1043        // closest parents contain only one leaf each, so the beam must expand
1044        // to four to honor nprobe=4.
1045        let lower_is_better: Vec<f32> = (0..10).map(|score| score as f32).collect();
1046        assert_eq!(
1047            select_parent_beam::<false>(&lower_is_better, &topology, 4),
1048            vec![0, 1, 2, 3]
1049        );
1050
1051        let higher_is_better: Vec<f32> = (0..10).rev().map(|score| score as f32).collect();
1052        assert_eq!(
1053            select_parent_beam::<true>(&higher_is_better, &topology, 4),
1054            vec![0, 1, 2, 3]
1055        );
1056    }
1057
1058    #[test]
1059    fn build_parent_beam_uses_four_populated_parents_when_query_uses_one() {
1060        let children: Vec<Vec<u32>> = (0..4)
1061            .map(|parent| {
1062                let first = parent * 512;
1063                (first..first + 512).map(|leaf| leaf as u32).collect()
1064            })
1065            .collect();
1066        let topology = IvfRoutingTopology::from_children(&children);
1067
1068        let lower_is_better = [0.0, 1.0, 2.0, 3.0];
1069        assert_eq!(
1070            select_parent_beam::<false>(&lower_is_better, &topology, 128),
1071            vec![0]
1072        );
1073        assert_eq!(
1074            select_parent_beam_for_build::<false>(&lower_is_better, &topology, 128),
1075            vec![0, 1, 2, 3]
1076        );
1077
1078        let higher_is_better = [4.0, 3.0, 2.0, 1.0];
1079        assert_eq!(
1080            select_parent_beam::<true>(&higher_is_better, &topology, 128),
1081            vec![0]
1082        );
1083        assert_eq!(
1084            select_parent_beam_for_build::<true>(&higher_is_better, &topology, 128),
1085            vec![0, 1, 2, 3]
1086        );
1087    }
1088
1089    #[test]
1090    fn build_parent_beam_uses_every_available_populated_parent() {
1091        let children = vec![vec![0], vec![], vec![1], vec![], vec![2]];
1092        let topology = IvfRoutingTopology::from_children(&children);
1093        let scores = [1.0, 0.0, 2.0, -1.0, 3.0];
1094
1095        assert_eq!(
1096            select_parent_beam_for_build::<false>(&scores, &topology, 1),
1097            vec![0, 2, 4]
1098        );
1099    }
1100
1101    #[test]
1102    fn child_allocation_uses_largest_remainders_instead_of_largest_parent() {
1103        assert_eq!(allocate_child_clusters(&[100, 90], 4), vec![2, 2]);
1104        assert_eq!(allocate_child_clusters(&[5, 5, 5], 5), vec![2, 2, 1]);
1105        assert_eq!(allocate_child_clusters(&[90, 10], 10), vec![9, 1]);
1106    }
1107
1108    #[test]
1109    fn child_allocation_is_exact_capacity_bounded_and_deterministic() {
1110        assert_eq!(
1111            allocate_child_clusters(&[1, 100, 7, 0], 100),
1112            vec![1, 93, 6, 0]
1113        );
1114        assert_eq!(allocate_child_clusters(&[1, 2, 0], 10), vec![1, 2, 0]);
1115        assert_eq!(allocate_child_clusters(&[10, 9, 8], 2), vec![1, 1, 0]);
1116
1117        for target in 0..=140 {
1118            let sizes = [100, 30, 0, 7];
1119            let allocation = allocate_child_clusters(&sizes, target);
1120            assert_eq!(
1121                allocation.iter().sum::<usize>(),
1122                target.min(sizes.iter().sum())
1123            );
1124            assert!(
1125                allocation
1126                    .iter()
1127                    .zip(sizes)
1128                    .all(|(&cells, size)| cells <= size)
1129            );
1130            if target >= sizes.iter().filter(|&&size| size > 0).count() {
1131                assert!(
1132                    allocation
1133                        .iter()
1134                        .zip(sizes)
1135                        .all(|(&cells, size)| size == 0 || cells > 0)
1136                );
1137            }
1138        }
1139    }
1140
1141    #[test]
1142    fn compact_hnsw_routes_without_copying_points() {
1143        let points: Vec<[f32; 2]> = (0..512)
1144            .map(|index| {
1145                let angle = index as f32 * std::f32::consts::TAU / 512.0;
1146                [angle.cos(), angle.sin()]
1147            })
1148            .collect();
1149        let distance = |left: u32, right: u32| {
1150            let [lx, ly] = points[left as usize];
1151            let [rx, ry] = points[right as usize];
1152            (lx - rx).powi(2) + (ly - ry).powi(2)
1153        };
1154        let graph = HnswRoutingGraph::build(points.len(), distance, 42);
1155        assert!(graph.validate(points.len()));
1156        assert!(graph.size_bytes() < points.len() * 512);
1157
1158        let query = [0.37f32, -0.91];
1159        let query_distance_calls = std::cell::Cell::new(0usize);
1160        let routed = graph.search(
1161            |node| {
1162                query_distance_calls.set(query_distance_calls.get() + 1);
1163                let [x, y] = points[node as usize];
1164                (x - query[0]).powi(2) + (y - query[1]).powi(2)
1165            },
1166            10,
1167        );
1168        let mut exact: Vec<u32> = (0..points.len() as u32).collect();
1169        exact.sort_unstable_by(|&left, &right| {
1170            let score = |node: u32| {
1171                let [x, y] = points[node as usize];
1172                (x - query[0]).powi(2) + (y - query[1]).powi(2)
1173            };
1174            score(left)
1175                .total_cmp(&score(right))
1176                .then_with(|| left.cmp(&right))
1177        });
1178        assert_eq!(routed, exact[..10]);
1179
1180        let build_distance_calls = std::cell::Cell::new(0usize);
1181        let build_routed = graph.search_for_build(
1182            |node| {
1183                build_distance_calls.set(build_distance_calls.get() + 1);
1184                let [x, y] = points[node as usize];
1185                (x - query[0]).powi(2) + (y - query[1]).powi(2)
1186            },
1187            10,
1188        );
1189        assert_eq!(build_routed, exact[..10]);
1190        assert!(
1191            build_distance_calls.get() > query_distance_calls.get(),
1192            "offline construction should spend its wider search budget"
1193        );
1194
1195        let bytes = bincode::serde::encode_to_vec(&graph, bincode::config::standard()).unwrap();
1196        let (decoded, consumed): (HnswRoutingGraph, usize) =
1197            bincode::serde::decode_from_slice(&bytes, bincode::config::standard()).unwrap();
1198        assert_eq!(consumed, bytes.len());
1199        assert!(decoded.validate(points.len()));
1200
1201        let mut corrupted = decoded;
1202        corrupted.node_offsets[1] = u32::MAX;
1203        assert!(!corrupted.validate(points.len()));
1204    }
1205}