Skip to main content

uqa_scoring/wand/
materialized.rs

1//
2// Unified Query Algebra
3//
4// Copyright (c) 2023-2026 Cognica, Inc.
5//
6
7//! Materialized posting-list cursor state and exact WAND/BMW pivot loops.
8
9use 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
23/// Common per-term cursor state: the entry slice and current position.
24/// Field, term, and scorer live on [`WANDQuery`] so a single cursor
25/// stays small and pivot reordering touches just one cache line.
26struct 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    /// Binary search advance to the first entry with `doc_id >= target`.
43    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
58/// WAND configuration shared by both algorithms.
59pub 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
112/// Standard WAND with per-term `term_upper_bound(df)` pruning.
113pub 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
135/// Block-Max WAND: pivot pruning uses per-block max scores from
136/// [`BlockMaxIndex`] for tighter bounds.
137pub 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                    // Take the max block-max across the remaining blocks
190                    // for this term so the bound stays valid for any
191                    // pivot doc the cursor could still reach. Anchoring
192                    // on just `cur_block` under-counts a later block
193                    // whose max score exceeds the current block, which
194                    // would prune candidates BMW must still consider.
195                    let bm = suffix_bounds[ti].get(cur_block).copied().unwrap_or(0.0);
196                    // Fall back to the per-term `term_upper_bound(df)` if no
197                    // block was recorded; an unindexed term must not get
198                    // pruned more aggressively than plain WAND.
199                    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
264/// Core pivot loop shared by WAND and BMW. `bound_provider` fills a reusable
265/// per-term bound vector aligned with the current `sorted_terms` order and
266/// returns `true`; `false` selects each cursor's precomputed upper bound.
267fn 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            // Advance every cursor at pivot_doc.
341            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            // Skip first cursor forward to pivot_doc.
349            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
438/// Score a single document against every term cursor. Cursors that
439/// point at a different `doc_id` contribute nothing; cursors that point
440/// at `target` contribute a raw term score. The query score is finalized
441/// once after all term contributions have been collected.
442fn 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}