1use std::cmp::{Ordering, Reverse};
16use std::collections::BinaryHeap;
17use std::sync::Arc;
18
19use uqa_core::{DocId, FieldName, Payload, PostingEntry, PostingList};
20use uqa_storage::{
21 BlockMaxIndex, InvertedIndex, PostingCursor, StorageBackendError, StorageBackendResult,
22};
23
24use crate::error::invalid_input;
25use crate::scorer::Scorer;
26use crate::ScoringResult;
27
28const INF_DOC: u64 = u64::MAX;
29
30#[derive(Debug, Clone, Copy)]
32struct HeapEntry {
33 score: f64,
34 doc_id: DocId,
35}
36
37impl PartialEq for HeapEntry {
38 fn eq(&self, other: &Self) -> bool {
39 self.score == other.score && self.doc_id == other.doc_id
40 }
41}
42
43impl Eq for HeapEntry {}
44
45impl PartialOrd for HeapEntry {
46 fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
47 Some(self.cmp(other))
48 }
49}
50
51impl Ord for HeapEntry {
52 fn cmp(&self, other: &Self) -> Ordering {
53 match other.score.total_cmp(&self.score) {
59 Ordering::Equal => self.doc_id.cmp(&other.doc_id),
60 ord => ord,
61 }
62 }
63}
64
65fn update_top_k(
66 top_k: &mut BinaryHeap<HeapEntry>,
67 k: usize,
68 score: f64,
69 doc_id: DocId,
70 threshold: &mut f64,
71) {
72 let candidate = HeapEntry { score, doc_id };
73 if top_k.len() < k {
74 top_k.push(candidate);
75 if top_k.len() == k {
76 *threshold = top_k.peek().map_or(0.0, |entry| entry.score);
77 }
78 return;
79 }
80 let Some(eviction) = top_k.peek() else {
81 return;
82 };
83 if score > eviction.score || (score == eviction.score && doc_id < eviction.doc_id) {
84 top_k.pop();
85 top_k.push(candidate);
86 *threshold = top_k.peek().map_or(*threshold, |entry| entry.score);
87 }
88}
89
90struct TermCursor<'a> {
94 entries: &'a [PostingEntry],
95 position: usize,
96 doc_freq: u64,
97 upper_bound: f64,
98}
99
100impl<'a> TermCursor<'a> {
101 fn current_doc(&self) -> u64 {
102 self.entries
103 .get(self.position)
104 .map_or(INF_DOC, |e| e.doc_id)
105 }
106
107 fn current(&self) -> Option<&'a PostingEntry> {
108 self.entries.get(self.position)
109 }
110
111 fn advance_to(&mut self, target: u64) {
113 let mut lo = self.position;
114 let mut hi = self.entries.len();
115 while lo < hi {
116 let mid = lo + (hi - lo) / 2;
117 if self.entries[mid].doc_id < target {
118 lo = mid + 1;
119 } else {
120 hi = mid;
121 }
122 }
123 self.position = lo;
124 }
125}
126
127pub struct WANDQuery {
129 pub posting_lists: Vec<PostingList>,
130 pub scorers: Vec<Arc<dyn Scorer>>,
131 pub fields: Vec<FieldName>,
132 pub terms: Vec<String>,
133 pub k: usize,
134}
135
136impl WANDQuery {
137 pub fn new(
138 posting_lists: Vec<PostingList>,
139 scorers: Vec<Arc<dyn Scorer>>,
140 fields: Vec<FieldName>,
141 terms: Vec<String>,
142 k: usize,
143 ) -> StorageBackendResult<Self> {
144 let expected = posting_lists.len();
145 if scorers.len() != expected || fields.len() != expected || terms.len() != expected {
146 return Err(invalid_wand_input(format!(
147 "WAND term arrays must have equal lengths: posting_lists={expected}, scorers={}, fields={}, terms={}",
148 scorers.len(),
149 fields.len(),
150 terms.len()
151 )));
152 }
153 Ok(Self {
154 posting_lists,
155 scorers,
156 fields,
157 terms,
158 k,
159 })
160 }
161}
162
163#[derive(Debug, Default, Clone, Copy, PartialEq)]
173pub struct WANDStats {
174 pub scored: u64,
175 pub total_candidates: u64,
176 pub cursor_advances: u64,
177}
178
179impl WANDStats {
180 pub fn skip_rate(&self) -> f64 {
181 if self.total_candidates == 0 {
182 0.0
183 } else {
184 1.0 - (self.scored as f64 / self.total_candidates as f64)
185 }
186 }
187}
188
189#[derive(Debug, Clone)]
190pub struct WANDResult {
191 pub top_k: PostingList,
192 pub stats: WANDStats,
193}
194
195pub struct WANDScorer<'a> {
197 query: &'a WANDQuery,
198 inverted_index: Option<&'a dyn InvertedIndex>,
199}
200
201impl<'a> WANDScorer<'a> {
202 pub fn new(query: &'a WANDQuery, inverted_index: Option<&'a dyn InvertedIndex>) -> Self {
203 Self {
204 query,
205 inverted_index,
206 }
207 }
208
209 pub fn score_top_k(&self) -> StorageBackendResult<WANDResult> {
210 validate_query(self.query)?;
211 let mut cursors = build_cursors(self.query)?;
212 run_pivot_loop(self.query, &mut cursors, self.inverted_index, |_, _, _| {
213 Ok(false)
214 })
215 }
216}
217
218pub struct BlockMaxWANDScorer<'a> {
221 query: &'a WANDQuery,
222 inverted_index: Option<&'a dyn InvertedIndex>,
223 block_max_index: &'a BlockMaxIndex,
224 table: String,
225}
226
227impl<'a> BlockMaxWANDScorer<'a> {
228 pub fn new(
229 query: &'a WANDQuery,
230 inverted_index: Option<&'a dyn InvertedIndex>,
231 block_max_index: &'a BlockMaxIndex,
232 table: impl Into<String>,
233 ) -> Self {
234 Self {
235 query,
236 inverted_index,
237 block_max_index,
238 table: table.into(),
239 }
240 }
241
242 pub fn score_top_k(&self) -> StorageBackendResult<WANDResult> {
243 validate_query(self.query)?;
244 let mut cursors = build_cursors(self.query)?;
245 let q = self.query;
246 let bmi = self.block_max_index;
247 let table = &self.table;
248 let suffix_bounds = q
249 .fields
250 .iter()
251 .zip(&q.terms)
252 .map(|(field, term)| {
253 let Some(blocks) = bmi.block_maxes(table, field, term) else {
254 return Vec::new();
255 };
256 let mut suffix = vec![0.0_f64; blocks.len()];
257 let mut maximum = 0.0_f64;
258 for (index, score) in blocks.iter().enumerate().rev() {
259 maximum = maximum.max(*score);
260 suffix[index] = maximum;
261 }
262 suffix
263 })
264 .collect::<Vec<_>>();
265 run_pivot_loop(
266 q,
267 &mut cursors,
268 self.inverted_index,
269 |sorted_terms, cursors, bounds| {
270 for &(doc_val, ti) in sorted_terms {
271 if doc_val == INF_DOC {
272 bounds.push(0.0);
273 continue;
274 }
275 let cur_block = bmi.block_index_for(cursors[ti].position)?;
276 let bm = suffix_bounds[ti].get(cur_block).copied().unwrap_or(0.0);
283 let bound = if bm > 0.0 {
287 bm
288 } else {
289 cursors[ti].upper_bound
290 };
291 bounds.push(bound);
292 }
293 Ok(true)
294 },
295 )
296 }
297}
298
299fn build_cursors(query: &WANDQuery) -> StorageBackendResult<Vec<TermCursor<'_>>> {
300 let mut cursors = Vec::with_capacity(query.posting_lists.len());
301 for i in 0..query.posting_lists.len() {
302 let entries = query.posting_lists[i].entries();
303 let df = u64::try_from(entries.len())
304 .map_err(|_| invalid_wand_input("posting-list length does not fit in u64"))?;
305 let upper_bound = query.scorers[i].term_upper_bound(df);
306 require_nonnegative_finite(upper_bound, "WAND term upper bound")?;
307 cursors.push(TermCursor {
308 entries,
309 position: 0,
310 doc_freq: df,
311 upper_bound,
312 });
313 }
314 Ok(cursors)
315}
316
317fn invalid_wand_input(message: impl Into<String>) -> StorageBackendError {
318 StorageBackendError::Other(format!("invalid WAND input: {}", message.into()))
319}
320
321fn validate_query(query: &WANDQuery) -> StorageBackendResult<()> {
322 let expected = query.posting_lists.len();
323 if query.scorers.len() == expected
324 && query.fields.len() == expected
325 && query.terms.len() == expected
326 {
327 Ok(())
328 } else {
329 Err(invalid_wand_input(format!(
330 "WAND term arrays must have equal lengths: posting_lists={expected}, scorers={}, fields={}, terms={}",
331 query.scorers.len(),
332 query.fields.len(),
333 query.terms.len()
334 )))
335 }
336}
337
338fn require_nonnegative_finite(value: f64, name: &str) -> StorageBackendResult<()> {
339 if value.is_finite() && value >= 0.0 {
340 Ok(())
341 } else {
342 Err(invalid_wand_input(format!(
343 "{name} must be finite and non-negative, got {value}"
344 )))
345 }
346}
347
348fn build_field_slots(fields: &[FieldName]) -> (Vec<usize>, usize) {
349 let mut unique_fields = Vec::<&str>::with_capacity(fields.len());
350 let slots = fields
351 .iter()
352 .map(|field| {
353 if let Some(slot) = unique_fields.iter().position(|known| *known == field) {
354 slot
355 } else {
356 let slot = unique_fields.len();
357 unique_fields.push(field);
358 slot
359 }
360 })
361 .collect();
362 (slots, unique_fields.len())
363}
364
365fn run_pivot_loop<F>(
369 query: &WANDQuery,
370 cursors: &mut [TermCursor<'_>],
371 inverted_index: Option<&dyn InvertedIndex>,
372 mut bound_provider: F,
373) -> StorageBackendResult<WANDResult>
374where
375 F: FnMut(&[(u64, usize)], &[TermCursor<'_>], &mut Vec<f64>) -> StorageBackendResult<bool>,
376{
377 let num_terms = query.posting_lists.len();
378 let total_candidates = candidate_union(&query.posting_lists)?;
379 if num_terms == 0 || query.k == 0 {
380 return Ok(WANDResult {
381 top_k: PostingList::new(),
382 stats: WANDStats {
383 total_candidates,
384 ..WANDStats::default()
385 },
386 });
387 }
388 let candidate_capacity = usize::try_from(total_candidates).unwrap_or(usize::MAX);
389 let mut top_k: BinaryHeap<HeapEntry> =
390 BinaryHeap::with_capacity(query.k.min(candidate_capacity));
391 let mut threshold = 0.0_f64;
392 let mut stats = WANDStats {
393 total_candidates,
394 ..WANDStats::default()
395 };
396
397 let mut sorted_terms: Vec<(u64, usize)> = (0..num_terms)
398 .map(|i| (cursors[i].current_doc(), i))
399 .collect();
400 sorted_terms.sort_unstable();
401 let mut bounds = Vec::with_capacity(num_terms);
402 let mut term_scores = Vec::with_capacity(num_terms);
403 let (field_slots, field_count) = build_field_slots(&query.fields);
404 let mut doc_lengths = vec![None; field_count];
405
406 while !sorted_terms.is_empty() {
407 if sorted_terms[0].0 == INF_DOC {
408 break;
409 }
410
411 bounds.clear();
412 if !bound_provider(&sorted_terms, cursors, &mut bounds)? {
413 bounds.extend(sorted_terms.iter().map(|&(doc_val, ti)| {
414 if doc_val == INF_DOC {
415 0.0
416 } else {
417 cursors[ti].upper_bound
418 }
419 }));
420 }
421 let Some(pivot_idx) = select_pivot(query, &sorted_terms, &bounds, threshold)? else {
422 break;
423 };
424
425 let pivot_doc = sorted_terms[pivot_idx].0;
426 let first_doc = sorted_terms[0].0;
427
428 if first_doc == pivot_doc {
429 let actual_score = score_document(
430 query,
431 cursors,
432 inverted_index,
433 pivot_doc as DocId,
434 &field_slots,
435 &mut doc_lengths,
436 &mut term_scores,
437 )?;
438 stats.scored = stats
439 .scored
440 .checked_add(1)
441 .ok_or_else(|| invalid_wand_input("scored-document counter overflowed"))?;
442
443 update_top_k(
444 &mut top_k,
445 query.k,
446 actual_score,
447 pivot_doc as DocId,
448 &mut threshold,
449 );
450
451 for st in &mut sorted_terms {
453 let ti = st.1;
454 if cursors[ti].current_doc() == pivot_doc {
455 cursors[ti].position += 1;
456 st.0 = cursors[ti].current_doc();
457 }
458 }
459 sorted_terms.sort_unstable();
460 } else {
461 let first_term = sorted_terms[0].1;
463 cursors[first_term].advance_to(pivot_doc);
464 stats.cursor_advances = stats
465 .cursor_advances
466 .checked_add(1)
467 .ok_or_else(|| invalid_wand_input("cursor-advance counter overflowed"))?;
468 sorted_terms[0].0 = cursors[first_term].current_doc();
469 sorted_terms.sort_unstable();
470 }
471 }
472
473 let mut entries: Vec<PostingEntry> = top_k
474 .into_sorted_vec()
475 .into_iter()
476 .rev()
477 .map(|h| PostingEntry::new(h.doc_id, Payload::with_score(h.score)))
478 .collect();
479 entries.sort_by_key(|e| e.doc_id);
480 Ok(WANDResult {
481 top_k: PostingList::from_sorted_unchecked(entries),
482 stats,
483 })
484}
485
486fn select_pivot(
487 query: &WANDQuery,
488 sorted_terms: &[(u64, usize)],
489 bounds: &[f64],
490 threshold: f64,
491) -> StorageBackendResult<Option<usize>> {
492 if bounds.len() != sorted_terms.len() {
493 return Err(invalid_wand_input(format!(
494 "bound provider returned {} bounds for {} terms",
495 bounds.len(),
496 sorted_terms.len()
497 )));
498 }
499 for bound in bounds {
500 require_nonnegative_finite(*bound, "WAND pruning bound")?;
501 }
502 for (index, &(doc_id, _)) in sorted_terms.iter().enumerate() {
503 if doc_id == INF_DOC {
504 break;
505 }
506 let cumulative = query.scorers[0].finalize_upper_bound(&bounds[..=index]);
507 require_nonnegative_finite(cumulative, "WAND cumulative upper bound")?;
508 if cumulative >= threshold {
509 return Ok(Some(index));
510 }
511 }
512 Ok(None)
513}
514
515fn candidate_union(posting_lists: &[PostingList]) -> StorageBackendResult<u64> {
516 let mut positions = vec![0_usize; posting_lists.len()];
517 let mut next = BinaryHeap::<Reverse<(DocId, usize)>>::with_capacity(posting_lists.len());
518 for (list_index, posting) in posting_lists.iter().enumerate() {
519 if let Some(entry) = posting.entries().first() {
520 next.push(Reverse((entry.doc_id, list_index)));
521 }
522 }
523 let mut count = 0_u64;
524 let mut previous = None;
525 while let Some(Reverse((doc_id, list_index))) = next.pop() {
526 if previous != Some(doc_id) {
527 count = count
528 .checked_add(1)
529 .ok_or_else(|| invalid_wand_input("candidate union length does not fit in u64"))?;
530 previous = Some(doc_id);
531 }
532 let entries = posting_lists[list_index].entries();
533 let position = &mut positions[list_index];
534 while entries
535 .get(*position)
536 .is_some_and(|entry| entry.doc_id == doc_id)
537 {
538 *position += 1;
539 }
540 if let Some(entry) = entries.get(*position) {
541 next.push(Reverse((entry.doc_id, list_index)));
542 }
543 }
544 Ok(count)
545}
546
547fn score_document(
552 query: &WANDQuery,
553 cursors: &[TermCursor<'_>],
554 inverted_index: Option<&dyn InvertedIndex>,
555 target: DocId,
556 field_slots: &[usize],
557 doc_lengths: &mut [Option<u64>],
558 term_scores: &mut Vec<f64>,
559) -> StorageBackendResult<f64> {
560 doc_lengths.fill(None);
561 term_scores.clear();
562 for (i, cursor) in cursors.iter().enumerate() {
563 let Some(entry) = cursor.current() else {
564 continue;
565 };
566 if entry.doc_id != target {
567 continue;
568 }
569 let tf = if entry.payload.positions.is_empty() {
570 1
571 } else {
572 u64::try_from(entry.payload.positions.len())
573 .map_err(|_| invalid_wand_input("term frequency does not fit in u64"))?
574 };
575 let df = cursor.doc_freq;
576 let doc_length = match inverted_index {
577 Some(idx) => {
578 let slot = field_slots[i];
579 let length = if let Some(length) = doc_lengths[slot] {
580 length
581 } else {
582 let length = idx.get_doc_length(target, &query.fields[i])?;
583 doc_lengths[slot] = Some(length);
584 length
585 };
586 length.max(tf)
587 }
588 None => tf,
589 };
590 let term_score = query.scorers[i].term_score(tf, doc_length, df);
591 require_nonnegative_finite(term_score, "WAND term score")?;
592 term_scores.push(term_score);
593 }
594 let score = query.scorers[0].finalize_score(term_scores);
595 require_nonnegative_finite(score, "WAND finalized score")?;
596 Ok(score)
597}
598
599pub struct CursorWANDQuery {
604 pub cursors: Vec<Box<dyn PostingCursor>>,
605 pub scorers: Vec<Arc<dyn Scorer>>,
606 pub fields: Vec<FieldName>,
607 pub terms: Vec<String>,
608 pub k: usize,
609}
610
611impl CursorWANDQuery {
612 pub fn new(
613 cursors: Vec<Box<dyn PostingCursor>>,
614 scorers: Vec<Arc<dyn Scorer>>,
615 fields: Vec<FieldName>,
616 terms: Vec<String>,
617 k: usize,
618 ) -> StorageBackendResult<Self> {
619 let expected = cursors.len();
620 if scorers.len() != expected || fields.len() != expected || terms.len() != expected {
621 return Err(invalid_wand_input(format!(
622 "cursor WAND term arrays must have equal lengths: cursors={expected}, scorers={}, fields={}, terms={}",
623 scorers.len(),
624 fields.len(),
625 terms.len()
626 )));
627 }
628 Ok(Self {
629 cursors,
630 scorers,
631 fields,
632 terms,
633 k,
634 })
635 }
636}
637
638struct ScoreTermCursor {
639 cursor: Box<dyn PostingCursor>,
640 upper_bound: f64,
641}
642
643impl ScoreTermCursor {
644 fn current_doc(&self) -> DocId {
645 self.cursor.current().map_or(INF_DOC, |entry| entry.doc_id)
646 }
647
648 fn block_ordinal(&self) -> StorageBackendResult<usize> {
649 usize::try_from(self.cursor.ordinal())
650 .map_err(|_| invalid_wand_input("posting cursor ordinal does not fit in usize"))
651 }
652}
653
654pub struct CursorWANDScorer<'a> {
656 query: &'a CursorWANDQuery,
657}
658
659impl<'a> CursorWANDScorer<'a> {
660 pub fn new(query: &'a CursorWANDQuery) -> Self {
661 Self { query }
662 }
663
664 pub fn score_top_k(&self) -> StorageBackendResult<WANDResult> {
665 validate_cursor_query(self.query)?;
666 let mut cursors = build_score_cursors(self.query)?;
667 run_cursor_pivot_loop(self.query, &mut cursors, |_, _, _| Ok(false))
668 }
669}
670
671pub struct CursorBlockMaxWANDScorer<'a> {
673 query: &'a CursorWANDQuery,
674 block_max_index: &'a BlockMaxIndex,
675 table: String,
676}
677
678impl<'a> CursorBlockMaxWANDScorer<'a> {
679 pub fn new(
680 query: &'a CursorWANDQuery,
681 block_max_index: &'a BlockMaxIndex,
682 table: impl Into<String>,
683 ) -> Self {
684 Self {
685 query,
686 block_max_index,
687 table: table.into(),
688 }
689 }
690
691 pub fn score_top_k(&self) -> StorageBackendResult<WANDResult> {
692 validate_cursor_query(self.query)?;
693 let mut cursors = build_score_cursors(self.query)?;
694 let query = self.query;
695 let block_max = self.block_max_index;
696 let suffix_bounds = query
697 .fields
698 .iter()
699 .zip(&query.terms)
700 .map(|(field, term)| {
701 let Some(blocks) = block_max.block_maxes(&self.table, field, term) else {
702 return Vec::new();
703 };
704 let mut suffix = vec![0.0_f64; blocks.len()];
705 let mut maximum = 0.0_f64;
706 for (index, score) in blocks.iter().enumerate().rev() {
707 maximum = maximum.max(*score);
708 suffix[index] = maximum;
709 }
710 suffix
711 })
712 .collect::<Vec<_>>();
713 run_cursor_pivot_loop(query, &mut cursors, |sorted_terms, cursors, bounds| {
714 for &(doc_id, term_index) in sorted_terms {
715 if doc_id == INF_DOC {
716 bounds.push(0.0);
717 continue;
718 }
719 let block_index =
720 block_max.block_index_for(cursors[term_index].block_ordinal()?)?;
721 let block_bound = suffix_bounds[term_index]
722 .get(block_index)
723 .copied()
724 .unwrap_or(0.0);
725 bounds.push(if block_bound > 0.0 {
726 block_bound
727 } else {
728 cursors[term_index].upper_bound
729 });
730 }
731 Ok(true)
732 })
733 }
734}
735
736fn validate_cursor_query(query: &CursorWANDQuery) -> StorageBackendResult<()> {
737 let expected = query.cursors.len();
738 if query.scorers.len() == expected
739 && query.fields.len() == expected
740 && query.terms.len() == expected
741 {
742 Ok(())
743 } else {
744 Err(invalid_wand_input(format!(
745 "cursor WAND term arrays must have equal lengths: cursors={expected}, scorers={}, fields={}, terms={}",
746 query.scorers.len(),
747 query.fields.len(),
748 query.terms.len()
749 )))
750 }
751}
752
753fn build_score_cursors(query: &CursorWANDQuery) -> StorageBackendResult<Vec<ScoreTermCursor>> {
754 query
755 .cursors
756 .iter()
757 .cloned()
758 .zip(&query.scorers)
759 .map(|(cursor, scorer)| {
760 let upper_bound = scorer.term_upper_bound(cursor.doc_freq());
761 require_nonnegative_finite(upper_bound, "cursor WAND term upper bound")?;
762 Ok(ScoreTermCursor {
763 cursor,
764 upper_bound,
765 })
766 })
767 .collect()
768}
769
770fn cursor_candidate_upper_bound(query: &CursorWANDQuery) -> StorageBackendResult<u64> {
771 query.cursors.iter().try_fold(0_u64, |total, cursor| {
772 total
773 .checked_add(cursor.doc_freq())
774 .ok_or_else(|| invalid_wand_input("cursor candidate count overflowed"))
775 })
776}
777
778fn run_cursor_pivot_loop<F>(
779 query: &CursorWANDQuery,
780 cursors: &mut [ScoreTermCursor],
781 mut bound_provider: F,
782) -> StorageBackendResult<WANDResult>
783where
784 F: FnMut(&[(DocId, usize)], &[ScoreTermCursor], &mut Vec<f64>) -> StorageBackendResult<bool>,
785{
786 let total_candidates = cursor_candidate_upper_bound(query)?;
787 if cursors.is_empty() || query.k == 0 {
788 return Ok(WANDResult {
789 top_k: PostingList::new(),
790 stats: WANDStats {
791 total_candidates,
792 ..WANDStats::default()
793 },
794 });
795 }
796 let candidate_capacity = usize::try_from(total_candidates).unwrap_or(usize::MAX);
797 let mut top_k = BinaryHeap::with_capacity(query.k.min(candidate_capacity));
798 let mut threshold = 0.0_f64;
799 let mut stats = WANDStats {
800 total_candidates,
801 ..WANDStats::default()
802 };
803 let mut sorted_terms = cursors
804 .iter()
805 .enumerate()
806 .map(|(index, cursor)| (cursor.current_doc(), index))
807 .collect::<Vec<_>>();
808 sorted_terms.sort_unstable();
809 let mut bounds = Vec::with_capacity(cursors.len());
810 let mut term_scores = Vec::with_capacity(cursors.len());
811
812 while sorted_terms
813 .first()
814 .is_some_and(|(doc_id, _)| *doc_id != INF_DOC)
815 {
816 bounds.clear();
817 if !bound_provider(&sorted_terms, cursors, &mut bounds)? {
818 bounds.extend(sorted_terms.iter().map(|&(doc_id, term_index)| {
819 if doc_id == INF_DOC {
820 0.0
821 } else {
822 cursors[term_index].upper_bound
823 }
824 }));
825 }
826 let Some(pivot_index) = select_cursor_pivot(query, &sorted_terms, &bounds, threshold)?
827 else {
828 break;
829 };
830 let pivot_doc = sorted_terms[pivot_index].0;
831 if sorted_terms[0].0 == pivot_doc {
832 let score = score_cursor_document(query, cursors, pivot_doc, &mut term_scores)?;
833 stats.scored = stats
834 .scored
835 .checked_add(1)
836 .ok_or_else(|| invalid_wand_input("scored-document counter overflowed"))?;
837 update_top_k(&mut top_k, query.k, score, pivot_doc, &mut threshold);
838 for sorted in &mut sorted_terms {
839 let term_index = sorted.1;
840 if cursors[term_index].current_doc() == pivot_doc {
841 cursors[term_index].cursor.advance()?;
842 sorted.0 = cursors[term_index].current_doc();
843 }
844 }
845 sorted_terms.sort_unstable();
846 } else {
847 let term_index = sorted_terms[0].1;
848 cursors[term_index].cursor.advance_to(pivot_doc)?;
849 stats.cursor_advances = stats
850 .cursor_advances
851 .checked_add(1)
852 .ok_or_else(|| invalid_wand_input("cursor-advance counter overflowed"))?;
853 sorted_terms[0].0 = cursors[term_index].current_doc();
854 sorted_terms.sort_unstable();
855 }
856 }
857
858 let mut entries = top_k
859 .into_sorted_vec()
860 .into_iter()
861 .rev()
862 .map(|entry| PostingEntry::new(entry.doc_id, Payload::with_score(entry.score)))
863 .collect::<Vec<_>>();
864 entries.sort_by_key(|entry| entry.doc_id);
865 Ok(WANDResult {
866 top_k: PostingList::from_sorted_unchecked(entries),
867 stats,
868 })
869}
870
871fn select_cursor_pivot(
872 query: &CursorWANDQuery,
873 sorted_terms: &[(DocId, usize)],
874 bounds: &[f64],
875 threshold: f64,
876) -> StorageBackendResult<Option<usize>> {
877 if bounds.len() != sorted_terms.len() {
878 return Err(invalid_wand_input(format!(
879 "cursor bound provider returned {} bounds for {} terms",
880 bounds.len(),
881 sorted_terms.len()
882 )));
883 }
884 for bound in bounds {
885 require_nonnegative_finite(*bound, "cursor WAND pruning bound")?;
886 }
887 for (index, &(doc_id, _)) in sorted_terms.iter().enumerate() {
888 if doc_id == INF_DOC {
889 break;
890 }
891 let cumulative = query.scorers[0].finalize_upper_bound(&bounds[..=index]);
892 require_nonnegative_finite(cumulative, "cursor WAND cumulative upper bound")?;
893 if cumulative >= threshold {
894 return Ok(Some(index));
895 }
896 }
897 Ok(None)
898}
899
900fn score_cursor_document(
901 query: &CursorWANDQuery,
902 cursors: &[ScoreTermCursor],
903 target: DocId,
904 term_scores: &mut Vec<f64>,
905) -> StorageBackendResult<f64> {
906 term_scores.clear();
907 for (index, cursor) in cursors.iter().enumerate() {
908 let Some(entry) = cursor.cursor.current() else {
909 continue;
910 };
911 if entry.doc_id != target {
912 continue;
913 }
914 let term_score = query.scorers[index].term_score(
915 entry.term_freq,
916 entry.doc_length.max(entry.term_freq),
917 cursor.cursor.doc_freq(),
918 );
919 require_nonnegative_finite(term_score, "cursor WAND term score")?;
920 term_scores.push(term_score);
921 }
922 let score = query.scorers[0].finalize_score(term_scores);
923 require_nonnegative_finite(score, "cursor WAND finalized score")?;
924 Ok(score)
925}
926
927#[derive(Debug, Default, Clone)]
930pub struct BoundTightnessAnalyzer {
931 pairs: Vec<(f64, f64)>,
932}
933
934impl BoundTightnessAnalyzer {
935 pub fn record(&mut self, upper_bound: f64, actual_max: f64) -> ScoringResult<()> {
936 if !upper_bound.is_finite() || upper_bound < 0.0 {
937 return Err(invalid_input(format!(
938 "upper bound must be finite and non-negative, got {upper_bound}"
939 )));
940 }
941 if !actual_max.is_finite() || actual_max < 0.0 {
942 return Err(invalid_input(format!(
943 "actual maximum must be finite and non-negative, got {actual_max}"
944 )));
945 }
946 if actual_max > upper_bound {
947 return Err(invalid_input(format!(
948 "actual maximum {actual_max} exceeds upper bound {upper_bound}"
949 )));
950 }
951 self.pairs.push((upper_bound, actual_max));
952 Ok(())
953 }
954
955 pub fn tightness_ratio(&self) -> f64 {
956 if self.pairs.is_empty() {
957 return 1.0;
958 }
959 let n = self.pairs.len() as f64;
960 let s: f64 = self
961 .pairs
962 .iter()
963 .map(|&(ub, am)| if ub > 0.0 { (am / ub).min(1.0) } else { 1.0 })
964 .sum();
965 s / n
966 }
967
968 pub fn slack(&self) -> f64 {
969 1.0 - self.tightness_ratio()
970 }
971
972 pub fn worst_bound_index(&self) -> usize {
973 self.pairs
974 .iter()
975 .enumerate()
976 .min_by(|(_, (ub_a, actual_a)), (_, (ub_b, actual_b))| {
977 let ratio_a = if *ub_a > 0.0 {
978 (*actual_a / *ub_a).min(1.0)
979 } else {
980 1.0
981 };
982 let ratio_b = if *ub_b > 0.0 {
983 (*actual_b / *ub_b).min(1.0)
984 } else {
985 1.0
986 };
987 ratio_a.total_cmp(&ratio_b)
988 })
989 .map_or(0, |(idx, _)| idx)
990 }
991
992 pub fn clear(&mut self) {
993 self.pairs.clear();
994 }
995}
996
997pub struct AdaptiveWANDScorer {
998 pub scorers: Vec<Arc<dyn Scorer>>,
999 pub k: usize,
1000 pub posting_lists: Vec<PostingList>,
1001 pub tightening_factor: f64,
1002 pub analyzer: BoundTightnessAnalyzer,
1003}
1004
1005impl AdaptiveWANDScorer {
1006 pub fn new(
1007 scorers: Vec<Arc<dyn Scorer>>,
1008 k: usize,
1009 posting_lists: Vec<PostingList>,
1010 tightening_factor: f64,
1011 ) -> ScoringResult<Self> {
1012 validate_adaptive_inputs(&scorers, &posting_lists, tightening_factor)?;
1013 Ok(Self {
1014 scorers,
1015 k,
1016 posting_lists,
1017 tightening_factor,
1018 analyzer: BoundTightnessAnalyzer::default(),
1019 })
1020 }
1021
1022 pub fn compute_upper_bounds(&self) -> ScoringResult<Vec<f64>> {
1023 validate_adaptive_inputs(&self.scorers, &self.posting_lists, self.tightening_factor)?;
1024 self.scorers
1025 .iter()
1026 .zip(&self.posting_lists)
1027 .map(|(scorer, pl)| {
1028 let df = u64::try_from(pl.len())
1029 .map_err(|_| invalid_input("posting-list length does not fit in u64"))?;
1030 let bound = scorer.term_upper_bound(df) * self.tightening_factor;
1031 if bound.is_finite() && bound >= 0.0 {
1032 Ok(bound)
1033 } else {
1034 Err(invalid_input(format!(
1035 "adaptive WAND bound must be finite and non-negative, got {bound}"
1036 )))
1037 }
1038 })
1039 .collect()
1040 }
1041
1042 pub fn score_top_k(&mut self) -> ScoringResult<PostingList> {
1043 validate_adaptive_inputs(&self.scorers, &self.posting_lists, self.tightening_factor)?;
1044 self.analyzer.clear();
1045 for (scorer, pl) in self.scorers.iter().zip(&self.posting_lists) {
1046 let df = u64::try_from(pl.len())
1047 .map_err(|_| invalid_input("posting-list length does not fit in u64"))?;
1048 let upper = scorer.term_upper_bound(df);
1049 let actual = pl
1050 .iter()
1051 .map(|entry| entry.payload.score)
1052 .fold(0.0_f64, f64::max);
1053 self.analyzer.record(upper, actual)?;
1054 }
1055
1056 let mut scores: std::collections::BTreeMap<DocId, f64> = std::collections::BTreeMap::new();
1057 for pl in &self.posting_lists {
1058 for entry in pl {
1059 let score = scores.entry(entry.doc_id).or_insert(0.0);
1060 *score += entry.payload.score;
1061 if !score.is_finite() || *score < 0.0 {
1062 return Err(invalid_input(format!(
1063 "adaptive WAND aggregate score must be finite and non-negative, got {score}"
1064 )));
1065 }
1066 }
1067 }
1068 let mut entries: Vec<PostingEntry> = scores
1069 .into_iter()
1070 .map(|(doc_id, score)| PostingEntry::new(doc_id, Payload::with_score(score)))
1071 .collect();
1072 entries.sort_by(|a, b| {
1073 b.payload
1074 .score
1075 .total_cmp(&a.payload.score)
1076 .then_with(|| a.doc_id.cmp(&b.doc_id))
1077 });
1078 entries.truncate(self.k);
1079 Ok(PostingList::from_unsorted(entries))
1080 }
1081}
1082
1083fn validate_adaptive_inputs(
1084 scorers: &[Arc<dyn Scorer>],
1085 posting_lists: &[PostingList],
1086 tightening_factor: f64,
1087) -> ScoringResult<()> {
1088 if scorers.len() != posting_lists.len() {
1089 return Err(invalid_input(format!(
1090 "adaptive WAND requires one scorer per posting list, got {} scorers and {} lists",
1091 scorers.len(),
1092 posting_lists.len()
1093 )));
1094 }
1095 if !tightening_factor.is_finite() || !(0.0..=1.0).contains(&tightening_factor) {
1096 return Err(invalid_input(format!(
1097 "adaptive WAND tightening factor must be finite and in [0, 1], got {tightening_factor}"
1098 )));
1099 }
1100 for posting_list in posting_lists {
1101 for entry in posting_list {
1102 if !entry.payload.score.is_finite() || entry.payload.score < 0.0 {
1103 return Err(invalid_input(format!(
1104 "adaptive WAND input score must be finite and non-negative, got {} for document {}",
1105 entry.payload.score, entry.doc_id
1106 )));
1107 }
1108 }
1109 }
1110 Ok(())
1111}
1112
1113#[cfg(test)]
1114mod tests {
1115 use super::*;
1116 use uqa_core::IndexStats;
1117 use uqa_storage::{MaterializedPostingCursor, PostingScore};
1118
1119 use crate::bayesian_bm25::{BayesianBM25Params, BayesianBM25Scorer};
1120 use crate::bm25::{BM25Params, BM25Scorer};
1121
1122 fn pl_from_tfs(tfs: &[(DocId, u64)]) -> PostingList {
1123 let entries: Vec<PostingEntry> = tfs
1124 .iter()
1125 .map(|&(doc_id, tf)| {
1126 let positions: Vec<u32> = (0..tf as u32).collect();
1127 PostingEntry::new(
1128 doc_id,
1129 Payload {
1130 positions,
1131 score: 0.0,
1132 fields: std::collections::BTreeMap::default(),
1133 },
1134 )
1135 })
1136 .collect();
1137 PostingList::from_unsorted(entries)
1138 }
1139
1140 fn bm25(stats: Arc<IndexStats>) -> Arc<dyn Scorer> {
1141 Arc::new(BM25Scorer::new(BM25Params::default(), stats))
1142 }
1143
1144 fn score_cursor(tfs: &[(DocId, u64)]) -> Box<dyn PostingCursor> {
1145 Box::new(
1146 MaterializedPostingCursor::new(
1147 tfs.iter()
1148 .map(|&(doc_id, term_freq)| PostingScore {
1149 doc_id,
1150 term_freq,
1151 doc_length: term_freq,
1152 })
1153 .collect(),
1154 )
1155 .unwrap(),
1156 )
1157 }
1158
1159 fn assert_same_scores(left: &PostingList, right: &PostingList) {
1160 assert_eq!(left.len(), right.len());
1161 for (left, right) in left.iter().zip(right) {
1162 assert_eq!(left.doc_id, right.doc_id);
1163 assert!((left.payload.score - right.payload.score).abs() < 1e-12);
1164 }
1165 }
1166
1167 struct InvalidScorer;
1168
1169 impl Scorer for InvalidScorer {
1170 fn idf(&self, _doc_freq: u64) -> f64 {
1171 f64::NAN
1172 }
1173
1174 fn term_score(&self, _term_freq: u64, _doc_length: u64, _doc_freq: u64) -> f64 {
1175 f64::NAN
1176 }
1177
1178 fn term_score_with_idf(&self, _term_freq: u64, _doc_length: u64, _idf_value: f64) -> f64 {
1179 f64::NAN
1180 }
1181
1182 fn finalize_score(&self, _term_scores: &[f64]) -> f64 {
1183 f64::NAN
1184 }
1185
1186 fn term_upper_bound(&self, _doc_freq: u64) -> f64 {
1187 f64::NAN
1188 }
1189 }
1190
1191 #[test]
1192 fn wand_rejects_mismatched_shapes_and_non_finite_bounds() {
1193 let posting_list = pl_from_tfs(&[(1, 1)]);
1194 assert!(WANDQuery::new(
1195 vec![posting_list.clone()],
1196 Vec::new(),
1197 vec!["body".into()],
1198 vec!["term".into()],
1199 1,
1200 )
1201 .is_err());
1202
1203 let query = WANDQuery::new(
1204 vec![posting_list],
1205 vec![Arc::new(InvalidScorer)],
1206 vec!["body".into()],
1207 vec!["term".into()],
1208 1,
1209 )
1210 .unwrap();
1211 assert!(WANDScorer::new(&query, None).score_top_k().is_err());
1212 }
1213
1214 #[test]
1215 fn zero_k_returns_no_results() {
1216 let mut stats = IndexStats::default();
1217 stats.total_docs = 10;
1218 stats.avg_doc_length = 5.0;
1219 let query = WANDQuery::new(
1220 vec![pl_from_tfs(&[(1, 1)])],
1221 vec![bm25(Arc::new(stats))],
1222 vec!["body".into()],
1223 vec!["term".into()],
1224 0,
1225 )
1226 .unwrap();
1227 assert!(WANDScorer::new(&query, None)
1228 .score_top_k()
1229 .unwrap()
1230 .top_k
1231 .is_empty());
1232 }
1233
1234 #[test]
1235 fn candidate_union_merges_sorted_postings_without_materializing_ids() {
1236 let postings = vec![
1237 pl_from_tfs(&[(1, 1), (4, 1), (9, 1)]),
1238 pl_from_tfs(&[(2, 1), (4, 1), (7, 1)]),
1239 pl_from_tfs(&[(1, 1), (8, 1), (9, 1)]),
1240 ];
1241 assert_eq!(candidate_union(&postings).unwrap(), 6);
1242 assert_eq!(candidate_union(&[]).unwrap(), 0);
1243 }
1244
1245 #[test]
1246 fn wand_top_k_matches_exhaustive_scoring() {
1247 let mut stats = IndexStats::default();
1248 stats.total_docs = 10;
1249 stats.avg_doc_length = 5.0;
1250 let stats = Arc::new(stats);
1251
1252 let pl_rust = pl_from_tfs(&[(1, 3), (2, 1), (4, 2), (5, 5), (8, 1)]);
1253 let pl_lang = pl_from_tfs(&[(1, 1), (3, 4), (4, 1), (6, 2), (8, 3)]);
1254 let scorers = vec![bm25(stats.clone()), bm25(stats.clone())];
1255
1256 let q = WANDQuery::new(
1257 vec![pl_rust.clone(), pl_lang.clone()],
1258 scorers.clone(),
1259 vec!["title".into(), "title".into()],
1260 vec!["rust".into(), "lang".into()],
1261 3,
1262 )
1263 .unwrap();
1264 let wand = WANDScorer::new(&q, None);
1265 let result = wand.score_top_k().unwrap();
1266
1267 let mut expected: Vec<(DocId, f64)> = Vec::new();
1270 let mut seen: std::collections::BTreeSet<DocId> = std::collections::BTreeSet::default();
1271 for pl in [&pl_rust, &pl_lang] {
1272 for entry in pl {
1273 seen.insert(entry.doc_id);
1274 }
1275 }
1276 for &doc_id in &seen {
1277 let mut term_scores = Vec::new();
1278 for (pl, scorer) in [&pl_rust, &pl_lang].iter().zip(scorers.iter()) {
1279 if let Some(e) = pl.get_entry(doc_id) {
1280 let tf = if e.payload.positions.is_empty() {
1281 1
1282 } else {
1283 e.payload.positions.len() as u64
1284 };
1285 term_scores.push(scorer.term_score(tf, tf, pl.len() as u64));
1286 }
1287 }
1288 let s = scorers[0].finalize_score(&term_scores);
1289 expected.push((doc_id, s));
1290 }
1291 expected.sort_by(|a, b| {
1292 b.1.partial_cmp(&a.1)
1293 .unwrap_or(Ordering::Equal)
1294 .then_with(|| a.0.cmp(&b.0))
1295 });
1296 expected.truncate(3);
1297
1298 let mut got: Vec<(DocId, f64)> = result
1299 .top_k
1300 .iter()
1301 .map(|e| (e.doc_id, e.payload.score))
1302 .collect();
1303 got.sort_by(|a, b| {
1304 b.1.partial_cmp(&a.1)
1305 .unwrap_or(Ordering::Equal)
1306 .then_with(|| a.0.cmp(&b.0))
1307 });
1308 assert_eq!(got.len(), expected.len());
1309 for ((d1, s1), (d2, s2)) in got.iter().zip(&expected) {
1310 assert_eq!(d1, d2);
1311 assert!((s1 - s2).abs() < 1e-9, "{s1} vs {s2}");
1312 }
1313 }
1314
1315 #[test]
1316 fn score_cursor_wand_and_bmw_match_materialized_wand() {
1317 let mut stats = IndexStats::default();
1318 stats.total_docs = 12;
1319 stats.avg_doc_length = 3.0;
1320 let stats = Arc::new(stats);
1321 let rust = [(1, 3), (2, 1), (4, 2), (5, 5), (8, 1)];
1322 let lang = [(1, 1), (3, 4), (4, 1), (6, 2), (8, 3)];
1323 let posting_lists = vec![pl_from_tfs(&rust), pl_from_tfs(&lang)];
1324 let scorers = vec![bm25(stats.clone()), bm25(stats)];
1325 let materialized = WANDQuery::new(
1326 posting_lists.clone(),
1327 scorers.clone(),
1328 vec!["title".into(), "title".into()],
1329 vec!["rust".into(), "lang".into()],
1330 3,
1331 )
1332 .unwrap();
1333 let expected = WANDScorer::new(&materialized, None).score_top_k().unwrap();
1334 let cursors = CursorWANDQuery::new(
1335 vec![score_cursor(&rust), score_cursor(&lang)],
1336 scorers.clone(),
1337 vec!["title".into(), "title".into()],
1338 vec!["rust".into(), "lang".into()],
1339 3,
1340 )
1341 .unwrap();
1342 let cursor_wand = CursorWANDScorer::new(&cursors).score_top_k().unwrap();
1343 assert_same_scores(&cursor_wand.top_k, &expected.top_k);
1344
1345 let mut block_max = BlockMaxIndex::new(2).unwrap();
1346 for ((term, posting), scorer) in ["rust", "lang"]
1347 .into_iter()
1348 .zip(&posting_lists)
1349 .zip(&scorers)
1350 {
1351 let doc_freq = posting.len() as u64;
1352 let block_upper_bounds = posting
1353 .entries()
1354 .chunks(2)
1355 .map(|block| {
1356 block
1357 .iter()
1358 .map(|entry| {
1359 let term_freq = entry.payload.positions.len() as u64;
1360 scorer.term_score(term_freq, term_freq, doc_freq)
1361 })
1362 .fold(0.0_f64, f64::max)
1363 })
1364 .collect();
1365 block_max
1366 .set_block_maxes("articles", "title", term, block_upper_bounds)
1367 .unwrap();
1368 }
1369 let cursor_bmw = CursorBlockMaxWANDScorer::new(&cursors, &block_max, "articles")
1370 .score_top_k()
1371 .unwrap();
1372 assert_same_scores(&cursor_bmw.top_k, &expected.top_k);
1373 }
1374
1375 #[test]
1376 fn bayesian_wand_finalizes_the_complete_query_once() {
1377 let mut stats = IndexStats::default();
1378 stats.total_docs = 10;
1379 stats.avg_doc_length = 5.0;
1380 let stats = Arc::new(stats);
1381 let params = BayesianBM25Params {
1382 alpha: 1.4,
1383 beta: 0.7,
1384 base_rate: 0.1,
1385 ..BayesianBM25Params::default()
1386 };
1387 let posting_lists = vec![
1388 pl_from_tfs(&[(1, 3), (2, 1), (4, 2), (5, 5), (8, 1)]),
1389 pl_from_tfs(&[(1, 1), (3, 4), (4, 1), (6, 2), (8, 3)]),
1390 ];
1391 let scorers: Vec<Arc<dyn Scorer>> = (0..2)
1392 .map(|_| {
1393 Arc::new(BayesianBM25Scorer::new(params, stats.clone()).unwrap()) as Arc<dyn Scorer>
1394 })
1395 .collect();
1396 let query = WANDQuery::new(
1397 posting_lists.clone(),
1398 scorers.clone(),
1399 vec!["title".into(), "title".into()],
1400 vec!["rust".into(), "language".into()],
1401 3,
1402 )
1403 .unwrap();
1404 let result = WANDScorer::new(&query, None).score_top_k().unwrap();
1405
1406 let mut candidate_ids = std::collections::BTreeSet::new();
1407 for posting_list in &posting_lists {
1408 candidate_ids.extend(posting_list.iter().map(|entry| entry.doc_id));
1409 }
1410 let mut expected = Vec::new();
1411 for doc_id in candidate_ids {
1412 let mut term_scores = Vec::new();
1413 for (posting_list, scorer) in posting_lists.iter().zip(&scorers) {
1414 if let Some(entry) = posting_list.get_entry(doc_id) {
1415 let term_frequency = entry.payload.positions.len() as u64;
1416 term_scores.push(scorer.term_score(
1417 term_frequency,
1418 term_frequency,
1419 posting_list.len() as u64,
1420 ));
1421 }
1422 }
1423 expected.push((doc_id, scorers[0].finalize_score(&term_scores)));
1424 }
1425 expected.sort_by(|left, right| {
1426 right
1427 .1
1428 .partial_cmp(&left.1)
1429 .unwrap_or(Ordering::Equal)
1430 .then_with(|| left.0.cmp(&right.0))
1431 });
1432 expected.truncate(3);
1433
1434 let mut actual: Vec<(DocId, f64)> = result
1435 .top_k
1436 .iter()
1437 .map(|entry| (entry.doc_id, entry.payload.score))
1438 .collect();
1439 actual.sort_by(|left, right| {
1440 right
1441 .1
1442 .partial_cmp(&left.1)
1443 .unwrap_or(Ordering::Equal)
1444 .then_with(|| left.0.cmp(&right.0))
1445 });
1446 assert_eq!(actual.len(), expected.len());
1447 for ((actual_doc, actual_score), (expected_doc, expected_score)) in
1448 actual.iter().zip(&expected)
1449 {
1450 assert_eq!(actual_doc, expected_doc);
1451 assert!((actual_score - expected_score).abs() < 1e-12);
1452 }
1453 }
1454
1455 #[test]
1456 fn bound_tightness_default_is_one() {
1457 let a = BoundTightnessAnalyzer::default();
1458 assert!((a.tightness_ratio() - 1.0).abs() < 1e-12);
1459 assert!((a.slack() - 0.0).abs() < 1e-12);
1460 }
1461
1462 #[test]
1463 fn bound_tightness_records_ratio() {
1464 let mut a = BoundTightnessAnalyzer::default();
1465 a.record(1.0, 0.8).unwrap();
1466 a.record(2.0, 1.0).unwrap();
1467 assert!((a.tightness_ratio() - 0.65).abs() < 1e-9);
1469 }
1470}