Skip to main content

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}