1use std::collections::BinaryHeap;
10use std::sync::Arc;
11
12use uqa_core::{DocId, FieldName, Payload, PostingEntry, PostingList};
13use uqa_storage::{BlockMaxIndex, PostingCursor, StorageBackendResult};
14
15use crate::scorer::Scorer;
16use uqa_storage::TokenTermKey;
17
18use super::common::{
19 invalid_wand_input, require_nonnegative_finite, update_top_k, WANDResult, WANDStats,
20};
21
22pub struct CursorWANDQuery {
27 pub cursors: Vec<Box<dyn PostingCursor>>,
28 pub scorers: Vec<Arc<dyn Scorer>>,
29 pub fields: Vec<FieldName>,
30 pub terms: Vec<TokenTermKey>,
31 pub k: usize,
32}
33
34impl CursorWANDQuery {
35 pub fn new(
36 cursors: Vec<Box<dyn PostingCursor>>,
37 scorers: Vec<Arc<dyn Scorer>>,
38 fields: Vec<FieldName>,
39 terms: Vec<String>,
40 k: usize,
41 ) -> StorageBackendResult<Self> {
42 Self::new_keys(
43 cursors,
44 scorers,
45 fields,
46 terms.into_iter().map(TokenTermKey::from).collect(),
47 k,
48 )
49 }
50}
51
52impl CursorWANDQuery {
53 pub fn new_keys(
54 cursors: Vec<Box<dyn PostingCursor>>,
55 scorers: Vec<Arc<dyn Scorer>>,
56 fields: Vec<FieldName>,
57 terms: Vec<TokenTermKey>,
58 k: usize,
59 ) -> StorageBackendResult<Self> {
60 let expected = cursors.len();
61 if scorers.len() != expected || fields.len() != expected || terms.len() != expected {
62 return Err(invalid_wand_input(format!(
63 "cursor WAND term arrays must have equal lengths: cursors={expected}, scorers={}, fields={}, terms={}",
64 scorers.len(),
65 fields.len(),
66 terms.len()
67 )));
68 }
69 Ok(Self {
70 cursors,
71 scorers,
72 fields,
73 terms,
74 k,
75 })
76 }
77}
78
79struct ScoreTermCursor {
80 cursor: Box<dyn PostingCursor>,
81 upper_bound: f64,
82}
83
84impl ScoreTermCursor {
85 fn current_doc(&self) -> Option<DocId> {
86 self.cursor.current().map(|entry| entry.doc_id)
87 }
88
89 fn block_ordinal(&self) -> StorageBackendResult<usize> {
90 usize::try_from(self.cursor.ordinal())
91 .map_err(|_| invalid_wand_input("posting cursor ordinal does not fit in usize"))
92 }
93}
94
95pub struct CursorWANDScorer<'a> {
97 query: &'a CursorWANDQuery,
98}
99
100impl<'a> CursorWANDScorer<'a> {
101 pub fn new(query: &'a CursorWANDQuery) -> Self {
102 Self { query }
103 }
104
105 pub fn score_top_k(&self) -> StorageBackendResult<WANDResult> {
106 validate_cursor_query(self.query)?;
107 let mut cursors = build_score_cursors(self.query)?;
108 run_cursor_pivot_loop(self.query, &mut cursors, |_, _, _| Ok(false))
109 }
110}
111
112pub struct CursorBlockMaxWANDScorer<'a> {
114 query: &'a CursorWANDQuery,
115 block_max_index: &'a BlockMaxIndex,
116 table: String,
117}
118
119impl<'a> CursorBlockMaxWANDScorer<'a> {
120 pub fn new(
121 query: &'a CursorWANDQuery,
122 block_max_index: &'a BlockMaxIndex,
123 table: impl Into<String>,
124 ) -> Self {
125 Self {
126 query,
127 block_max_index,
128 table: table.into(),
129 }
130 }
131
132 pub fn score_top_k(&self) -> StorageBackendResult<WANDResult> {
133 validate_cursor_query(self.query)?;
134 let mut cursors = build_score_cursors(self.query)?;
135 let query = self.query;
136 let block_max = self.block_max_index;
137 let suffix_bounds = query
138 .fields
139 .iter()
140 .zip(&query.terms)
141 .map(|(field, term)| {
142 let Some(blocks) = block_max.block_maxes_key(&self.table, field, term) else {
143 return Vec::new();
144 };
145 let mut suffix = vec![0.0_f64; blocks.len()];
146 let mut maximum = 0.0_f64;
147 for (index, score) in blocks.iter().enumerate().rev() {
148 maximum = maximum.max(*score);
149 suffix[index] = maximum;
150 }
151 suffix
152 })
153 .collect::<Vec<_>>();
154 run_cursor_pivot_loop(query, &mut cursors, |sorted_terms, cursors, bounds| {
155 for &(_, term_index) in sorted_terms {
156 let block_index =
157 block_max.block_index_for(cursors[term_index].block_ordinal()?)?;
158 let block_bound = suffix_bounds[term_index]
159 .get(block_index)
160 .copied()
161 .unwrap_or(0.0);
162 bounds.push(if block_bound > 0.0 {
163 block_bound
164 } else {
165 cursors[term_index].upper_bound
166 });
167 }
168 Ok(true)
169 })
170 }
171}
172
173fn validate_cursor_query(query: &CursorWANDQuery) -> StorageBackendResult<()> {
174 let expected = query.cursors.len();
175 if query.scorers.len() == expected
176 && query.fields.len() == expected
177 && query.terms.len() == expected
178 {
179 Ok(())
180 } else {
181 Err(invalid_wand_input(format!(
182 "cursor WAND term arrays must have equal lengths: cursors={expected}, scorers={}, fields={}, terms={}",
183 query.scorers.len(),
184 query.fields.len(),
185 query.terms.len()
186 )))
187 }
188}
189
190fn build_score_cursors(query: &CursorWANDQuery) -> StorageBackendResult<Vec<ScoreTermCursor>> {
191 query
192 .cursors
193 .iter()
194 .cloned()
195 .zip(&query.scorers)
196 .map(|(cursor, scorer)| {
197 let upper_bound = scorer.term_upper_bound(cursor.doc_freq());
198 require_nonnegative_finite(upper_bound, "cursor WAND term upper bound")?;
199 Ok(ScoreTermCursor {
200 cursor,
201 upper_bound,
202 })
203 })
204 .collect()
205}
206
207fn cursor_candidate_upper_bound(query: &CursorWANDQuery) -> StorageBackendResult<u64> {
208 query.cursors.iter().try_fold(0_u64, |total, cursor| {
209 total
210 .checked_add(cursor.doc_freq())
211 .ok_or_else(|| invalid_wand_input("cursor candidate count overflowed"))
212 })
213}
214
215fn run_cursor_pivot_loop<F>(
216 query: &CursorWANDQuery,
217 cursors: &mut [ScoreTermCursor],
218 mut bound_provider: F,
219) -> StorageBackendResult<WANDResult>
220where
221 F: FnMut(&[(DocId, usize)], &[ScoreTermCursor], &mut Vec<f64>) -> StorageBackendResult<bool>,
222{
223 let total_candidates = cursor_candidate_upper_bound(query)?;
224 if cursors.is_empty() || query.k == 0 {
225 return Ok(WANDResult {
226 top_k: PostingList::new(),
227 stats: WANDStats {
228 total_candidates,
229 ..WANDStats::default()
230 },
231 });
232 }
233 let candidate_capacity = usize::try_from(total_candidates).unwrap_or(usize::MAX);
234 let mut top_k = BinaryHeap::with_capacity(query.k.min(candidate_capacity));
235 let mut threshold = 0.0_f64;
236 let mut stats = WANDStats {
237 total_candidates,
238 ..WANDStats::default()
239 };
240 let mut sorted_terms = cursors
241 .iter()
242 .enumerate()
243 .filter_map(|(index, cursor)| cursor.current_doc().map(|doc_id| (doc_id, index)))
244 .collect::<Vec<_>>();
245 sorted_terms.sort_unstable();
246 let mut bounds = Vec::with_capacity(cursors.len());
247 let mut term_scores = Vec::with_capacity(cursors.len());
248
249 while !sorted_terms.is_empty() {
250 bounds.clear();
251 if !bound_provider(&sorted_terms, cursors, &mut bounds)? {
252 bounds.extend(
253 sorted_terms
254 .iter()
255 .map(|&(_, term_index)| cursors[term_index].upper_bound),
256 );
257 }
258 let Some(pivot_index) = select_cursor_pivot(query, &sorted_terms, &bounds, threshold)?
259 else {
260 break;
261 };
262 let pivot_doc = sorted_terms[pivot_index].0;
263 if sorted_terms[0].0 == pivot_doc {
264 let score = score_cursor_document(query, cursors, pivot_doc, &mut term_scores)?;
265 stats.scored = stats
266 .scored
267 .checked_add(1)
268 .ok_or_else(|| invalid_wand_input("scored-document counter overflowed"))?;
269 update_top_k(&mut top_k, query.k, score, pivot_doc, &mut threshold);
270 for sorted in &mut sorted_terms {
271 let term_index = sorted.1;
272 if cursors[term_index].current_doc() == Some(pivot_doc) {
273 cursors[term_index].cursor.advance()?;
274 }
275 }
276 } else {
277 let term_index = sorted_terms[0].1;
278 cursors[term_index].cursor.advance_to(pivot_doc)?;
279 stats.cursor_advances = stats
280 .cursor_advances
281 .checked_add(1)
282 .ok_or_else(|| invalid_wand_input("cursor-advance counter overflowed"))?;
283 }
284 sorted_terms.retain_mut(|(doc_id, term_index)| {
285 if let Some(current) = cursors[*term_index].current_doc() {
286 *doc_id = current;
287 true
288 } else {
289 false
290 }
291 });
292 sorted_terms.sort_unstable();
293 }
294
295 let mut entries = top_k
296 .into_sorted_vec()
297 .into_iter()
298 .rev()
299 .map(|entry| PostingEntry::new(entry.doc_id, Payload::with_score(entry.score)))
300 .collect::<Vec<_>>();
301 entries.sort_by_key(|entry| entry.doc_id);
302 Ok(WANDResult {
303 top_k: PostingList::from_sorted_unchecked(entries),
304 stats,
305 })
306}
307
308fn select_cursor_pivot(
309 query: &CursorWANDQuery,
310 sorted_terms: &[(DocId, usize)],
311 bounds: &[f64],
312 threshold: f64,
313) -> StorageBackendResult<Option<usize>> {
314 if bounds.len() != sorted_terms.len() {
315 return Err(invalid_wand_input(format!(
316 "cursor bound provider returned {} bounds for {} terms",
317 bounds.len(),
318 sorted_terms.len()
319 )));
320 }
321 for bound in bounds {
322 require_nonnegative_finite(*bound, "cursor WAND pruning bound")?;
323 }
324 for index in 0..sorted_terms.len() {
325 let cumulative = query.scorers[0].finalize_upper_bound(&bounds[..=index]);
326 require_nonnegative_finite(cumulative, "cursor WAND cumulative upper bound")?;
327 if cumulative >= threshold {
328 return Ok(Some(index));
329 }
330 }
331 Ok(None)
332}
333
334fn score_cursor_document(
335 query: &CursorWANDQuery,
336 cursors: &[ScoreTermCursor],
337 target: DocId,
338 term_scores: &mut Vec<f64>,
339) -> StorageBackendResult<f64> {
340 term_scores.clear();
341 for (index, cursor) in cursors.iter().enumerate() {
342 let Some(entry) = cursor.cursor.current() else {
343 continue;
344 };
345 if entry.doc_id != target {
346 continue;
347 }
348 let term_score = query.scorers[index].term_score(
349 entry.term_freq,
350 entry.doc_length,
351 cursor.cursor.doc_freq(),
352 );
353 require_nonnegative_finite(term_score, "cursor WAND term score")?;
354 term_scores.push(term_score);
355 }
356 let score = query.scorers[0].finalize_score(term_scores);
357 require_nonnegative_finite(score, "cursor WAND finalized score")?;
358 Ok(score)
359}