sparse_vector/wand/frontier.rs
1//! The frontier: the set of active cursors of a query, and what it knows
2//! about the records nobody has scored yet.
3
4use super::cursor::PostingCursor;
5use super::{RecordId, Weight};
6
7/// One query dimension paired with its cursor.
8#[derive(Debug)]
9pub struct Lane<C> {
10 pub query_weight: Weight,
11 pub cursor: C,
12}
13
14impl<C: PostingCursor> Lane<C> {
15 pub fn new(query_weight: Weight, cursor: C) -> Self {
16 Self {
17 query_weight,
18 cursor,
19 }
20 }
21
22 /// Id the cursor is on, if any.
23 #[inline]
24 pub fn current_id(&self) -> Option<RecordId> {
25 self.cursor.peek().map(|p| p.id)
26 }
27
28 /// The most this lane can add to the score of any record not yet
29 /// consumed by its cursor. A record absent from the lane adds nothing,
30 /// so the bound is never below zero.
31 pub fn headroom(&self) -> f64 {
32 let q = self.query_weight as f64;
33 let best = if q >= 0.0 {
34 q * self.cursor.upper_bound() as f64
35 } else {
36 q * self.cursor.lower_bound() as f64
37 };
38 if best.is_nan() {
39 // 0 * inf: an empty or unbounded lane with a zero weight.
40 0.0
41 } else {
42 best.max(0.0)
43 }
44 }
45}
46
47/// Outcome of asking the frontier to skip what cannot beat a threshold.
48#[derive(Clone, Copy, Debug, PartialEq, Eq)]
49pub enum Skip {
50 /// Some unscored record may still beat the threshold; cursors have been
51 /// moved so that [`Frontier::min_id`] is the first such candidate.
52 Candidates,
53 /// No remaining record can beat the threshold: the search is over.
54 Nothing,
55}
56
57/// Active cursors of a query.
58///
59/// Exhausted lanes are retired lazily by [`retire_exhausted`](Self::retire_exhausted)
60/// (also called by the methods that move cursors), so `len()` counts lanes
61/// that still have elements once that has run.
62#[derive(Debug)]
63pub struct Frontier<C> {
64 lanes: Vec<Lane<C>>,
65 /// Scratch: lane indices ordered by current id.
66 order: Vec<usize>,
67}
68
69impl<C: PostingCursor> Frontier<C> {
70 /// Build from lanes. Lanes with an exhausted cursor or a zero query
71 /// weight are dropped right away.
72 pub fn new(lanes: impl IntoIterator<Item = Lane<C>>) -> Self {
73 let lanes: Vec<Lane<C>> = lanes
74 .into_iter()
75 .filter(|l| l.query_weight != 0.0 && !l.cursor.is_exhausted())
76 .collect();
77 let order = Vec::with_capacity(lanes.len());
78 Self { lanes, order }
79 }
80
81 pub fn len(&self) -> usize {
82 self.lanes.len()
83 }
84
85 pub fn is_empty(&self) -> bool {
86 self.lanes.is_empty()
87 }
88
89 pub fn lanes(&self) -> &[Lane<C>] {
90 &self.lanes
91 }
92
93 /// Drop lanes whose cursor is past the end.
94 pub fn retire_exhausted(&mut self) {
95 self.lanes.retain(|l| !l.cursor.is_exhausted());
96 }
97
98 /// Smallest current id across lanes: the next record that could be
99 /// scored. `None` when every lane is exhausted.
100 pub fn min_id(&self) -> Option<RecordId> {
101 self.lanes.iter().filter_map(Lane::current_id).min()
102 }
103
104 /// Largest last id across lanes: no record beyond it exists in any lane.
105 pub fn max_last_id(&self) -> Option<RecordId> {
106 self.lanes.iter().filter_map(|l| l.cursor.last_id()).max()
107 }
108
109 /// Upper bound on the score of any record no lane has consumed yet: the
110 /// sum of every lane's headroom. `+inf` when a lane is unbounded.
111 pub fn best_possible(&self) -> f64 {
112 self.lanes.iter().map(Lane::headroom).sum()
113 }
114
115 /// Advance cursors past every id whose score cannot strictly exceed
116 /// `threshold`.
117 ///
118 /// Lanes are sorted by current id and their headrooms accumulated in
119 /// that order. The pivot is the first lane at which the running total
120 /// could beat the threshold. A record with an id below the pivot's
121 /// current id can only appear in lanes ordered before the pivot, whose
122 /// combined headroom is below the threshold; those lanes are seeked to
123 /// the pivot id. Without a pivot, nothing left can qualify.
124 pub fn skip_below(&mut self, threshold: f32) -> Skip {
125 self.retire_exhausted();
126 if self.lanes.is_empty() {
127 return Skip::Nothing;
128 }
129
130 self.order.clear();
131 self.order.extend(0..self.lanes.len());
132 let lanes = &self.lanes;
133 self.order
134 .sort_by_key(|&i| lanes[i].current_id().unwrap_or(RecordId::MAX));
135
136 let mut acc = 0.0f64;
137 let mut pivot_pos = None;
138 for (pos, &i) in self.order.iter().enumerate() {
139 acc += self.lanes[i].headroom();
140 if can_beat(acc, threshold) {
141 pivot_pos = Some(pos);
142 break;
143 }
144 }
145
146 let Some(pivot_pos) = pivot_pos else {
147 return Skip::Nothing;
148 };
149 let pivot_id = self.lanes[self.order[pivot_pos]]
150 .current_id()
151 .expect("pivot lane is not exhausted");
152 for &i in &self.order[..pivot_pos] {
153 self.lanes[i].cursor.seek(pivot_id);
154 }
155 self.retire_exhausted();
156 Skip::Candidates
157 }
158
159 /// Consume every element with id in `lo..=hi` from every lane and add
160 /// `query_weight * weight` into `scores[id - lo]`, marking
161 /// `seen[id - lo]`. Both buffers must be at least `hi - lo + 1` long
162 /// and `scores` must be zeroed for the slots that matter. Contributions
163 /// are added in lane order, so a record's score is the same f32 sum
164 /// regardless of which window it lands in.
165 pub fn score_window(&mut self, lo: RecordId, hi: RecordId, scores: &mut [f32], seen: &mut [bool]) {
166 debug_assert!(lo <= hi);
167 for lane in &mut self.lanes {
168 let q = lane.query_weight;
169 lane.cursor.drain_through(hi, |id, w| {
170 debug_assert!(id >= lo, "cursor positioned before the window");
171 let slot = (id - lo) as usize;
172 scores[slot] += q * w;
173 seen[slot] = true;
174 });
175 }
176 }
177}
178
179/// Whether a score bounded above by `bound` could strictly exceed
180/// `threshold`. The bound is accumulated in a different order than the
181/// scores themselves (and in f64), so a few ulps of slack keep the answer
182/// conservative under rounding.
183pub(crate) fn can_beat(bound: f64, threshold: f32) -> bool {
184 if bound.is_infinite() {
185 return bound > 0.0;
186 }
187 let t = threshold as f64;
188 let magnitude = bound.abs().max(t.abs());
189 let slack = magnitude * (f32::EPSILON as f64 * 8.0);
190 bound + slack > t
191}