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