Skip to main content

uqa_scoring/
wand.rs

1//
2// Unified Query Algebra
3//
4// Copyright (c) 2023-2026 Cognica, Inc.
5//
6
7//! WAND and Block-Max WAND top-k scorers (Section 6, Paper 3).
8//!
9//! Both implementations advance posting-list cursors through pivot
10//! resolution. Pruning is *exact* under their respective upper-bound
11//! contracts: for WAND the per-term `term_upper_bound(df)`; for BMW the
12//! tighter per-block max stored in [`BlockMaxIndex`]. The output top-k
13//! is identical to exhaustive scoring.
14
15use 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/// Min-heap entry by score for top-k selection.
31#[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        // `BinaryHeap` is a max-heap; flip the score comparison so the
54        // root holds the *minimum* score (the eviction candidate). On a
55        // score tie, the entry with the *larger* doc id sits at the
56        // root, matching the conventional "lower doc id wins" tie break
57        // applied at output time.
58        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
90/// Common per-term cursor state: the entry slice and current position.
91/// Field, term, and scorer live on [`WANDQuery`] so a single cursor
92/// stays small and pivot reordering touches just one cache line.
93struct 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    /// Binary search advance to the first entry with `doc_id >= target`.
112    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
127/// WAND configuration shared by both algorithms.
128pub 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/// Stats collected during a top-k pass; tests use these to assert the
164/// exit-criterion skip rates from the master plan.
165///
166/// Skip rate semantics are `1 - scored / total_candidates`. The materialized
167/// path reports the exact union of posting-list document ids. The score-cursor
168/// path deliberately avoids a complete pre-scan and reports the sum of term
169/// document frequencies, a safe upper bound on that union. `scored` counts
170/// documents for which the complete query score was evaluated;
171/// `cursor_advances` counts pivot-driven skips and is informational only.
172#[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
195/// Standard WAND with per-term `term_upper_bound(df)` pruning.
196pub 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
218/// Block-Max WAND: pivot pruning uses per-block max scores from
219/// [`BlockMaxIndex`] for tighter bounds.
220pub 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                    // Take the max block-max across the remaining blocks
277                    // for this term so the bound stays valid for any
278                    // pivot doc the cursor could still reach. Anchoring
279                    // on just `cur_block` under-counts a later block
280                    // whose max score exceeds the current block, which
281                    // would prune candidates BMW must still consider.
282                    let bm = suffix_bounds[ti].get(cur_block).copied().unwrap_or(0.0);
283                    // Fall back to the per-term `term_upper_bound(df)` if no
284                    // block was recorded; an unindexed term must not get
285                    // pruned more aggressively than plain WAND.
286                    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
365/// Core pivot loop shared by WAND and BMW. `bound_provider` fills a reusable
366/// per-term bound vector aligned with the current `sorted_terms` order and
367/// returns `true`; `false` selects each cursor's precomputed upper bound.
368fn 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            // Advance every cursor at pivot_doc.
452            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            // Skip first cursor forward to pivot_doc.
462            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
547/// Score a single document against every term cursor. Cursors that
548/// point at a different `doc_id` contribute nothing; cursors that point
549/// at `target` contribute a raw term score. The query score is finalized
550/// once after all term contributions have been collected.
551fn 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
599/// WAND query backed directly by score-only posting cursors.
600///
601/// Unlike [`WANDQuery`], this form never materializes positional payloads and
602/// carries document length beside term frequency in each cursor entry.
603pub 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
654/// Standard WAND over score-only posting cursors.
655pub 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
671/// Block-Max WAND over score-only posting cursors.
672pub 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/// Track upper-bound tightness: ratio of `actual_max / upper_bound` per
928/// posting list, averaged across all observations.
929#[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        // Exhaustive baseline: score every doc that appears in either
1268        // list and sort.
1269        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        // ratios: 0.8, 0.5; mean 0.65
1468        assert!((a.tightness_ratio() - 0.65).abs() < 1e-9);
1469    }
1470}