Skip to main content

slate_core/
search.rs

1//! Search result types and the bounded top-k collector shared by every
2//! Slate-ANN backend.
3//!
4//! These live in `slate-core` — the shared-vocabulary crate — so that both the
5//! graph backends (`slate-graph`) and the top-level engine (`slate-index`) can
6//! produce and rank results with one canonical [`Neighbor`] type and one
7//! [`TopK`] collector, without any inter-crate dependency cycle.
8
9use crate::VectorId;
10use std::collections::BinaryHeap;
11
12/// A single search result: a vector identity paired with its distance score.
13///
14/// Scores follow the engine-wide **ascending** ranking convention (smaller =
15/// closer), matching [`crate::Metric`]. For `L2` the score is squared
16/// Euclidean distance; for `InnerProduct` it is the negated dot product; for
17/// `Cosine` it is `1 − cos`.
18#[derive(Debug, Clone, Copy, PartialEq)]
19pub struct Neighbor {
20    /// Identity of the matched vector.
21    pub id: VectorId,
22    /// Distance score (ascending: smaller is closer).
23    pub score: f32,
24}
25
26impl Neighbor {
27    /// Construct a neighbor from an id and score.
28    #[inline]
29    #[must_use]
30    pub const fn new(id: VectorId, score: f32) -> Self {
31        Self { id, score }
32    }
33}
34
35/// Total ordering over `(score, id)` for ranking.
36///
37/// `f32` has no total `Ord` (NaN), so we order by [`f32::total_cmp`] on the
38/// score and break ties by ascending `id`. This makes results **deterministic**
39/// regardless of visitation order. Smaller scores order first (ascending = best
40/// first); NaN scores — which would signal an upstream bug — sort to the end via
41/// `total_cmp` and so are never preferred over real neighbors.
42#[inline]
43#[must_use]
44pub fn cmp_ascending(a: &Neighbor, b: &Neighbor) -> core::cmp::Ordering {
45    a.score
46        .total_cmp(&b.score)
47        .then_with(|| a.id.cmp(&b.id))
48}
49
50/// Wrapper giving [`Neighbor`] a total `Ord` via [`cmp_ascending`].
51///
52/// A larger score (worse neighbor) compares **greater**, so a
53/// [`BinaryHeap`] of these — a max-heap — keeps the current worst neighbor at
54/// its top, ready for eviction.
55#[derive(Debug, Clone, Copy, PartialEq)]
56struct Ranked(Neighbor);
57
58impl Eq for Ranked {}
59
60impl PartialOrd for Ranked {
61    #[inline]
62    fn partial_cmp(&self, other: &Self) -> Option<core::cmp::Ordering> {
63        Some(self.cmp(other))
64    }
65}
66
67impl Ord for Ranked {
68    #[inline]
69    fn cmp(&self, other: &Self) -> core::cmp::Ordering {
70        cmp_ascending(&self.0, &other.0)
71    }
72}
73
74/// Collects the `k` best (smallest-score) neighbors from a stream of candidates
75/// in O(N log k) time and O(k) memory.
76///
77/// Never materializes all candidate distances — suitable for a brute-force scan
78/// over an arbitrarily large store, or for bounding a graph beam.
79#[derive(Debug)]
80pub struct TopK {
81    k: usize,
82    /// Max-heap: the top is the *worst* kept neighbor (largest score).
83    heap: BinaryHeap<Ranked>,
84}
85
86impl TopK {
87    /// Create a collector retaining up to `k` neighbors.
88    ///
89    /// `k == 0` yields a collector that keeps nothing.
90    #[must_use]
91    pub fn new(k: usize) -> Self {
92        Self {
93            k,
94            heap: BinaryHeap::with_capacity(k),
95        }
96    }
97
98    /// Offer a candidate. Kept only if it ranks among the best `k` seen so far.
99    pub fn offer(&mut self, candidate: Neighbor) {
100        if self.k == 0 {
101            return;
102        }
103        if self.heap.len() < self.k {
104            self.heap.push(Ranked(candidate));
105            return;
106        }
107        // Heap is full: replace the current worst iff the candidate is strictly
108        // better (smaller). `peek` is the max = worst kept neighbor.
109        if let Some(worst) = self.heap.peek() {
110            if cmp_ascending(&candidate, &worst.0) == core::cmp::Ordering::Less {
111                self.heap.pop();
112                self.heap.push(Ranked(candidate));
113            }
114        }
115    }
116
117    /// The current worst (largest) retained score, or `None` if empty.
118    ///
119    /// Useful as a beam-search cutoff: once `k` neighbors are held, any
120    /// candidate not better than this can be skipped.
121    #[must_use]
122    pub fn worst_score(&self) -> Option<f32> {
123        self.heap.peek().map(|r| r.0.score)
124    }
125
126    /// Whether the collector is full (holds exactly `k` neighbors).
127    #[must_use]
128    pub fn is_full(&self) -> bool {
129        self.heap.len() >= self.k
130    }
131
132    /// Number of neighbors currently retained.
133    #[must_use]
134    pub fn len(&self) -> usize {
135        self.heap.len()
136    }
137
138    /// Whether no neighbors are retained.
139    #[must_use]
140    pub fn is_empty(&self) -> bool {
141        self.heap.is_empty()
142    }
143
144    /// Consume the collector, returning neighbors sorted ascending (best first).
145    #[must_use]
146    pub fn into_sorted_vec(self) -> Vec<Neighbor> {
147        let mut out: Vec<Neighbor> = self.heap.into_iter().map(|r| r.0).collect();
148        out.sort_unstable_by(cmp_ascending);
149        out
150    }
151}
152
153#[cfg(test)]
154mod tests {
155    use super::*;
156    use core::cmp::Ordering;
157
158    fn n(id: u64, score: f32) -> Neighbor {
159        Neighbor::new(VectorId::new(id), score)
160    }
161
162    #[test]
163    fn orders_by_score_then_id() {
164        let a = Neighbor::new(VectorId::new(5), 1.0);
165        let b = Neighbor::new(VectorId::new(2), 2.0);
166        assert_eq!(cmp_ascending(&a, &b), Ordering::Less);
167    }
168
169    #[test]
170    fn breaks_ties_by_id() {
171        let a = Neighbor::new(VectorId::new(2), 1.0);
172        let b = Neighbor::new(VectorId::new(5), 1.0);
173        assert_eq!(cmp_ascending(&a, &b), Ordering::Less);
174        assert_eq!(cmp_ascending(&b, &a), Ordering::Greater);
175    }
176
177    #[test]
178    fn nan_sorts_last() {
179        let real = Neighbor::new(VectorId::new(1), 1.0);
180        let nan = Neighbor::new(VectorId::new(0), f32::NAN);
181        // total_cmp places NaN above any finite value, so the real neighbor is
182        // "less" (ranked better).
183        assert_eq!(cmp_ascending(&real, &nan), Ordering::Less);
184    }
185
186    #[test]
187    fn keeps_k_smallest() {
188        let mut t = TopK::new(3);
189        for (i, s) in [5.0, 1.0, 4.0, 2.0, 3.0].iter().enumerate() {
190            t.offer(n(i as u64, *s));
191        }
192        let got = t.into_sorted_vec();
193        let scores: Vec<f32> = got.iter().map(|x| x.score).collect();
194        assert_eq!(scores, vec![1.0, 2.0, 3.0]);
195    }
196
197    #[test]
198    fn k_larger_than_input_keeps_all_sorted() {
199        let mut t = TopK::new(10);
200        t.offer(n(0, 2.0));
201        t.offer(n(1, 1.0));
202        let got = t.into_sorted_vec();
203        assert_eq!(got.len(), 2);
204        assert_eq!(got[0].id, VectorId::new(1));
205        assert_eq!(got[1].id, VectorId::new(0));
206    }
207
208    #[test]
209    fn k_zero_keeps_nothing() {
210        let mut t = TopK::new(0);
211        t.offer(n(0, 1.0));
212        assert!(t.is_empty());
213        assert!(t.into_sorted_vec().is_empty());
214    }
215
216    #[test]
217    fn deterministic_tie_break_by_id() {
218        // All equal scores; only the k smallest ids should survive.
219        let mut t = TopK::new(2);
220        for id in [9, 3, 7, 1, 5] {
221            t.offer(n(id, 1.0));
222        }
223        let got = t.into_sorted_vec();
224        let ids: Vec<u64> = got.iter().map(|x| x.id.get()).collect();
225        assert_eq!(ids, vec![1, 3]);
226    }
227
228    #[test]
229    fn worst_score_and_fullness_track_the_beam() {
230        let mut t = TopK::new(2);
231        assert!(!t.is_full());
232        assert_eq!(t.worst_score(), None);
233        t.offer(n(0, 3.0));
234        assert!(!t.is_full());
235        assert_eq!(t.worst_score(), Some(3.0));
236        t.offer(n(1, 1.0));
237        assert!(t.is_full());
238        assert_eq!(t.worst_score(), Some(3.0));
239        // A better candidate evicts the worst and lowers the cutoff.
240        t.offer(n(2, 2.0));
241        assert!(t.is_full());
242        assert_eq!(t.worst_score(), Some(2.0));
243    }
244}