1use std::cmp::Reverse;
10use std::collections::BinaryHeap;
11use std::sync::Arc;
12
13use uqa_core::{DocId, FieldName, Payload, PostingEntry, PostingList};
14use uqa_storage::{BlockMaxIndex, InvertedIndex, StorageBackendResult};
15
16use crate::scorer::Scorer;
17use uqa_storage::TokenTermKey;
18
19use super::common::{
20 invalid_wand_input, require_nonnegative_finite, update_top_k, HeapEntry, WANDResult, WANDStats,
21};
22
23struct TermCursor<'a> {
27 entries: &'a [PostingEntry],
28 position: usize,
29 doc_freq: u64,
30 upper_bound: f64,
31}
32
33impl<'a> TermCursor<'a> {
34 fn current_doc(&self) -> Option<DocId> {
35 self.entries.get(self.position).map(|e| e.doc_id)
36 }
37
38 fn current(&self) -> Option<&'a PostingEntry> {
39 self.entries.get(self.position)
40 }
41
42 fn advance_to(&mut self, target: u64) {
44 let mut lo = self.position;
45 let mut hi = self.entries.len();
46 while lo < hi {
47 let mid = lo + (hi - lo) / 2;
48 if self.entries[mid].doc_id < target {
49 lo = mid + 1;
50 } else {
51 hi = mid;
52 }
53 }
54 self.position = lo;
55 }
56}
57
58pub struct WANDQuery {
60 pub posting_lists: Vec<PostingList>,
61 pub scorers: Vec<Arc<dyn Scorer>>,
62 pub fields: Vec<FieldName>,
63 pub terms: Vec<TokenTermKey>,
64 pub k: usize,
65}
66
67impl WANDQuery {
68 pub fn new(
69 posting_lists: Vec<PostingList>,
70 scorers: Vec<Arc<dyn Scorer>>,
71 fields: Vec<FieldName>,
72 terms: Vec<String>,
73 k: usize,
74 ) -> StorageBackendResult<Self> {
75 Self::new_keys(
76 posting_lists,
77 scorers,
78 fields,
79 terms.into_iter().map(TokenTermKey::from).collect(),
80 k,
81 )
82 }
83}
84
85impl WANDQuery {
86 pub fn new_keys(
87 posting_lists: Vec<PostingList>,
88 scorers: Vec<Arc<dyn Scorer>>,
89 fields: Vec<FieldName>,
90 terms: Vec<TokenTermKey>,
91 k: usize,
92 ) -> StorageBackendResult<Self> {
93 let expected = posting_lists.len();
94 if scorers.len() != expected || fields.len() != expected || terms.len() != expected {
95 return Err(invalid_wand_input(format!(
96 "WAND term arrays must have equal lengths: posting_lists={expected}, scorers={}, fields={}, terms={}",
97 scorers.len(),
98 fields.len(),
99 terms.len()
100 )));
101 }
102 Ok(Self {
103 posting_lists,
104 scorers,
105 fields,
106 terms,
107 k,
108 })
109 }
110}
111
112pub struct WANDScorer<'a> {
114 query: &'a WANDQuery,
115 inverted_index: Option<&'a dyn InvertedIndex>,
116}
117
118impl<'a> WANDScorer<'a> {
119 pub fn new(query: &'a WANDQuery, inverted_index: Option<&'a dyn InvertedIndex>) -> Self {
120 Self {
121 query,
122 inverted_index,
123 }
124 }
125
126 pub fn score_top_k(&self) -> StorageBackendResult<WANDResult> {
127 validate_query(self.query)?;
128 let mut cursors = build_cursors(self.query)?;
129 run_pivot_loop(self.query, &mut cursors, self.inverted_index, |_, _, _| {
130 Ok(false)
131 })
132 }
133}
134
135pub struct BlockMaxWANDScorer<'a> {
138 query: &'a WANDQuery,
139 inverted_index: Option<&'a dyn InvertedIndex>,
140 block_max_index: &'a BlockMaxIndex,
141 table: String,
142}
143
144impl<'a> BlockMaxWANDScorer<'a> {
145 pub fn new(
146 query: &'a WANDQuery,
147 inverted_index: Option<&'a dyn InvertedIndex>,
148 block_max_index: &'a BlockMaxIndex,
149 table: impl Into<String>,
150 ) -> Self {
151 Self {
152 query,
153 inverted_index,
154 block_max_index,
155 table: table.into(),
156 }
157 }
158
159 pub fn score_top_k(&self) -> StorageBackendResult<WANDResult> {
160 validate_query(self.query)?;
161 let mut cursors = build_cursors(self.query)?;
162 let q = self.query;
163 let bmi = self.block_max_index;
164 let table = &self.table;
165 let suffix_bounds = q
166 .fields
167 .iter()
168 .zip(&q.terms)
169 .map(|(field, term)| {
170 let Some(blocks) = bmi.block_maxes_key(table, field, term) else {
171 return Vec::new();
172 };
173 let mut suffix = vec![0.0_f64; blocks.len()];
174 let mut maximum = 0.0_f64;
175 for (index, score) in blocks.iter().enumerate().rev() {
176 maximum = maximum.max(*score);
177 suffix[index] = maximum;
178 }
179 suffix
180 })
181 .collect::<Vec<_>>();
182 run_pivot_loop(
183 q,
184 &mut cursors,
185 self.inverted_index,
186 |sorted_terms, cursors, bounds| {
187 for &(_, ti) in sorted_terms {
188 let cur_block = bmi.block_index_for(cursors[ti].position)?;
189 let bm = suffix_bounds[ti].get(cur_block).copied().unwrap_or(0.0);
196 let bound = if bm > 0.0 {
200 bm
201 } else {
202 cursors[ti].upper_bound
203 };
204 bounds.push(bound);
205 }
206 Ok(true)
207 },
208 )
209 }
210}
211
212fn build_cursors(query: &WANDQuery) -> StorageBackendResult<Vec<TermCursor<'_>>> {
213 let mut cursors = Vec::with_capacity(query.posting_lists.len());
214 for i in 0..query.posting_lists.len() {
215 let entries = query.posting_lists[i].entries();
216 let df = u64::try_from(entries.len())
217 .map_err(|_| invalid_wand_input("posting-list length does not fit in u64"))?;
218 let upper_bound = query.scorers[i].term_upper_bound(df);
219 require_nonnegative_finite(upper_bound, "WAND term upper bound")?;
220 cursors.push(TermCursor {
221 entries,
222 position: 0,
223 doc_freq: df,
224 upper_bound,
225 });
226 }
227 Ok(cursors)
228}
229
230fn validate_query(query: &WANDQuery) -> StorageBackendResult<()> {
231 let expected = query.posting_lists.len();
232 if query.scorers.len() == expected
233 && query.fields.len() == expected
234 && query.terms.len() == expected
235 {
236 Ok(())
237 } else {
238 Err(invalid_wand_input(format!(
239 "WAND term arrays must have equal lengths: posting_lists={expected}, scorers={}, fields={}, terms={}",
240 query.scorers.len(),
241 query.fields.len(),
242 query.terms.len()
243 )))
244 }
245}
246
247fn build_field_slots(fields: &[FieldName]) -> (Vec<usize>, usize) {
248 let mut unique_fields = Vec::<&str>::with_capacity(fields.len());
249 let slots = fields
250 .iter()
251 .map(|field| {
252 if let Some(slot) = unique_fields.iter().position(|known| *known == field) {
253 slot
254 } else {
255 let slot = unique_fields.len();
256 unique_fields.push(field);
257 slot
258 }
259 })
260 .collect();
261 (slots, unique_fields.len())
262}
263
264fn run_pivot_loop<F>(
268 query: &WANDQuery,
269 cursors: &mut [TermCursor<'_>],
270 inverted_index: Option<&dyn InvertedIndex>,
271 mut bound_provider: F,
272) -> StorageBackendResult<WANDResult>
273where
274 F: FnMut(&[(u64, usize)], &[TermCursor<'_>], &mut Vec<f64>) -> StorageBackendResult<bool>,
275{
276 let num_terms = query.posting_lists.len();
277 let total_candidates = candidate_union(&query.posting_lists)?;
278 if num_terms == 0 || query.k == 0 {
279 return Ok(WANDResult {
280 top_k: PostingList::new(),
281 stats: WANDStats {
282 total_candidates,
283 ..WANDStats::default()
284 },
285 });
286 }
287 let candidate_capacity = usize::try_from(total_candidates).unwrap_or(usize::MAX);
288 let mut top_k: BinaryHeap<HeapEntry> =
289 BinaryHeap::with_capacity(query.k.min(candidate_capacity));
290 let mut threshold = 0.0_f64;
291 let mut stats = WANDStats {
292 total_candidates,
293 ..WANDStats::default()
294 };
295
296 let mut sorted_terms: Vec<(u64, usize)> = (0..num_terms)
297 .filter_map(|i| cursors[i].current_doc().map(|doc_id| (doc_id, i)))
298 .collect();
299 sorted_terms.sort_unstable();
300 let mut bounds = Vec::with_capacity(num_terms);
301 let mut term_scores = Vec::with_capacity(num_terms);
302 let (field_slots, field_count) = build_field_slots(&query.fields);
303 let mut doc_lengths = vec![None; field_count];
304
305 while !sorted_terms.is_empty() {
306 bounds.clear();
307 if !bound_provider(&sorted_terms, cursors, &mut bounds)? {
308 bounds.extend(sorted_terms.iter().map(|&(_, ti)| cursors[ti].upper_bound));
309 }
310 let Some(pivot_idx) = select_pivot(query, &sorted_terms, &bounds, threshold)? else {
311 break;
312 };
313
314 let pivot_doc = sorted_terms[pivot_idx].0;
315 let first_doc = sorted_terms[0].0;
316
317 if first_doc == pivot_doc {
318 let actual_score = score_document(
319 query,
320 cursors,
321 inverted_index,
322 pivot_doc as DocId,
323 &field_slots,
324 &mut doc_lengths,
325 &mut term_scores,
326 )?;
327 stats.scored = stats
328 .scored
329 .checked_add(1)
330 .ok_or_else(|| invalid_wand_input("scored-document counter overflowed"))?;
331
332 update_top_k(
333 &mut top_k,
334 query.k,
335 actual_score,
336 pivot_doc as DocId,
337 &mut threshold,
338 );
339
340 for st in &mut sorted_terms {
342 let ti = st.1;
343 if cursors[ti].current_doc() == Some(pivot_doc) {
344 cursors[ti].position += 1;
345 }
346 }
347 } else {
348 let first_term = sorted_terms[0].1;
350 cursors[first_term].advance_to(pivot_doc);
351 stats.cursor_advances = stats
352 .cursor_advances
353 .checked_add(1)
354 .ok_or_else(|| invalid_wand_input("cursor-advance counter overflowed"))?;
355 }
356 sorted_terms.retain_mut(|(doc_id, term_index)| {
357 if let Some(current) = cursors[*term_index].current_doc() {
358 *doc_id = current;
359 true
360 } else {
361 false
362 }
363 });
364 sorted_terms.sort_unstable();
365 }
366
367 let mut entries: Vec<PostingEntry> = top_k
368 .into_sorted_vec()
369 .into_iter()
370 .rev()
371 .map(|h| PostingEntry::new(h.doc_id, Payload::with_score(h.score)))
372 .collect();
373 entries.sort_by_key(|e| e.doc_id);
374 Ok(WANDResult {
375 top_k: PostingList::from_sorted_unchecked(entries),
376 stats,
377 })
378}
379
380fn select_pivot(
381 query: &WANDQuery,
382 sorted_terms: &[(u64, usize)],
383 bounds: &[f64],
384 threshold: f64,
385) -> StorageBackendResult<Option<usize>> {
386 if bounds.len() != sorted_terms.len() {
387 return Err(invalid_wand_input(format!(
388 "bound provider returned {} bounds for {} terms",
389 bounds.len(),
390 sorted_terms.len()
391 )));
392 }
393 for bound in bounds {
394 require_nonnegative_finite(*bound, "WAND pruning bound")?;
395 }
396 for index in 0..sorted_terms.len() {
397 let cumulative = query.scorers[0].finalize_upper_bound(&bounds[..=index]);
398 require_nonnegative_finite(cumulative, "WAND cumulative upper bound")?;
399 if cumulative >= threshold {
400 return Ok(Some(index));
401 }
402 }
403 Ok(None)
404}
405
406pub(super) fn candidate_union(posting_lists: &[PostingList]) -> StorageBackendResult<u64> {
407 let mut positions = vec![0_usize; posting_lists.len()];
408 let mut next = BinaryHeap::<Reverse<(DocId, usize)>>::with_capacity(posting_lists.len());
409 for (list_index, posting) in posting_lists.iter().enumerate() {
410 if let Some(entry) = posting.entries().first() {
411 next.push(Reverse((entry.doc_id, list_index)));
412 }
413 }
414 let mut count = 0_u64;
415 let mut previous = None;
416 while let Some(Reverse((doc_id, list_index))) = next.pop() {
417 if previous != Some(doc_id) {
418 count = count
419 .checked_add(1)
420 .ok_or_else(|| invalid_wand_input("candidate union length does not fit in u64"))?;
421 previous = Some(doc_id);
422 }
423 let entries = posting_lists[list_index].entries();
424 let position = &mut positions[list_index];
425 while entries
426 .get(*position)
427 .is_some_and(|entry| entry.doc_id == doc_id)
428 {
429 *position += 1;
430 }
431 if let Some(entry) = entries.get(*position) {
432 next.push(Reverse((entry.doc_id, list_index)));
433 }
434 }
435 Ok(count)
436}
437
438fn score_document(
443 query: &WANDQuery,
444 cursors: &[TermCursor<'_>],
445 inverted_index: Option<&dyn InvertedIndex>,
446 target: DocId,
447 field_slots: &[usize],
448 doc_lengths: &mut [Option<u64>],
449 term_scores: &mut Vec<f64>,
450) -> StorageBackendResult<f64> {
451 doc_lengths.fill(None);
452 term_scores.clear();
453 for (i, cursor) in cursors.iter().enumerate() {
454 let Some(entry) = cursor.current() else {
455 continue;
456 };
457 if entry.doc_id != target {
458 continue;
459 }
460 let tf = if let Some(index) = inverted_index {
461 index.get_term_freq_key(target, &query.fields[i], &query.terms[i])?
462 } else if entry.payload.positions.is_empty() {
463 1
464 } else {
465 u64::try_from(entry.payload.positions.len())
466 .map_err(|_| invalid_wand_input("term frequency does not fit in u64"))?
467 };
468 let df = cursor.doc_freq;
469 let doc_length = match inverted_index {
470 Some(idx) => {
471 let slot = field_slots[i];
472 if let Some(length) = doc_lengths[slot] {
473 length
474 } else {
475 let length = idx.get_doc_length(target, &query.fields[i])?;
476 doc_lengths[slot] = Some(length);
477 length
478 }
479 }
480 None => tf,
481 };
482 let term_score = query.scorers[i].term_score(tf, doc_length, df);
483 require_nonnegative_finite(term_score, "WAND term score")?;
484 term_scores.push(term_score);
485 }
486 let score = query.scorers[0].finalize_score(term_scores);
487 require_nonnegative_finite(score, "WAND finalized score")?;
488 Ok(score)
489}