use nodedb_types::{Surrogate, SurrogateBitmap};
use crate::bm25;
use crate::posting::Bm25Params;
use crate::search::doc_score::{Combine, combine, term_score};
use super::heap::TopKHeap;
use super::skip_index::TermBlocks;
const EXHAUSTED: Surrogate = Surrogate(u32::MAX);
pub struct BmwInput<'a> {
pub terms: &'a [TermBlocks],
pub term_groups: &'a [usize],
pub groups: usize,
pub total_docs: u32,
pub avg_doc_len: f32,
pub params: &'a Bm25Params,
pub top_k: usize,
pub allow: Option<&'a SurrogateBitmap>,
pub deny: Option<&'a SurrogateBitmap>,
pub combine: Combine,
}
struct TermCursor<'a> {
term: &'a TermBlocks,
term_idx: usize,
block_idx: usize,
pos_in_block: usize,
idf: f32,
max_score: f32,
}
impl<'a> TermCursor<'a> {
fn new(
term: &'a TermBlocks,
term_idx: usize,
total_docs: u32,
avg_doc_len: f32,
params: &Bm25Params,
) -> Self {
let idf = bm25::idf(term.df, total_docs);
let max_score = bm25::term_max_score(
term.global_max_tf,
term.global_min_fieldnorm,
term.df,
total_docs,
avg_doc_len,
params,
);
Self {
term,
term_idx,
block_idx: 0,
pos_in_block: 0,
idf,
max_score,
}
}
fn current_doc_id(&self) -> Surrogate {
if self.block_idx >= self.term.blocks.len() {
return EXHAUSTED;
}
let block = &self.term.blocks[self.block_idx];
if self.pos_in_block >= block.doc_ids.len() {
return EXHAUSTED;
}
block.doc_ids[self.pos_in_block]
}
fn advance_to(&mut self, target: Surrogate) {
if let Some(blk) = self.term.advance_to_block(target) {
if blk > self.block_idx {
self.block_idx = blk;
self.pos_in_block = 0;
}
} else {
self.block_idx = self.term.blocks.len();
return;
}
if self.block_idx < self.term.blocks.len() {
let block = &self.term.blocks[self.block_idx];
while self.pos_in_block < block.doc_ids.len()
&& block.doc_ids[self.pos_in_block] < target
{
self.pos_in_block += 1;
}
if self.pos_in_block >= block.doc_ids.len() {
self.block_idx += 1;
self.pos_in_block = 0;
}
}
}
fn next(&mut self) {
if self.block_idx >= self.term.blocks.len() {
return;
}
self.pos_in_block += 1;
let block = &self.term.blocks[self.block_idx];
if self.pos_in_block >= block.doc_ids.len() {
self.block_idx += 1;
self.pos_in_block = 0;
}
}
fn is_exhausted(&self) -> bool {
self.block_idx >= self.term.blocks.len()
}
fn block_upper_bound(&self, total_docs: u32, avg_doc_len: f32, params: &Bm25Params) -> f32 {
if self.block_idx >= self.term.blocks.len() {
return 0.0;
}
let meta = &self.term.skip[self.block_idx].meta;
bm25::bm25_block_upper_bound(
meta.block_max_tf,
meta.block_min_fieldnorm,
self.term.df,
total_docs,
avg_doc_len,
params,
)
}
fn score_current(&self, avg_doc_len: f32, params: &Bm25Params) -> f32 {
if self.is_exhausted() {
return 0.0;
}
let block = &self.term.blocks[self.block_idx];
term_score(
self.idf,
block.term_freqs[self.pos_in_block],
block.fieldnorms[self.pos_in_block],
avg_doc_len,
params,
)
}
}
fn admitted(input: &BmwInput<'_>, doc_id: Surrogate) -> bool {
input.allow.is_none_or(|bm| bm.contains(doc_id))
&& !input.deny.is_some_and(|bm| bm.contains(doc_id))
}
pub fn bmw_score(input: &BmwInput<'_>) -> TopKHeap {
let mut heap = TopKHeap::new(input.top_k);
let BmwInput {
terms,
term_groups,
groups,
total_docs,
avg_doc_len,
params,
..
} = *input;
if input.top_k == 0 || terms.is_empty() || input.allow.is_some_and(|bm| bm.is_empty()) {
return heap;
}
let mut cursors: Vec<TermCursor> = terms
.iter()
.enumerate()
.filter(|(_, tb)| tb.df > 0)
.map(|(idx, tb)| TermCursor::new(tb, idx, total_docs, avg_doc_len, params))
.collect();
if cursors.is_empty() {
return heap;
}
let mut contributions: Vec<Option<f32>> = vec![None; terms.len()];
loop {
cursors.sort_by_key(|c| c.current_doc_id());
while cursors.last().is_some_and(|c| c.is_exhausted()) {
cursors.pop();
}
if cursors.is_empty() {
break;
}
let threshold = heap.threshold();
let mut pivot_idx = 0;
let mut accumulated = 0.0f32;
for (i, cursor) in cursors.iter().enumerate() {
accumulated += cursor.max_score;
pivot_idx = i;
if accumulated > threshold {
break;
}
}
if accumulated <= threshold {
break;
}
let pivot_doc_id = cursors[pivot_idx].current_doc_id();
if pivot_doc_id == EXHAUSTED {
break;
}
let first_doc_id = cursors[0].current_doc_id();
if first_doc_id == pivot_doc_id {
if admitted(input, pivot_doc_id) {
let block_upper: f32 = cursors
.iter()
.filter(|c| c.current_doc_id() == pivot_doc_id)
.map(|c| c.block_upper_bound(total_docs, avg_doc_len, params))
.sum();
if block_upper > threshold {
contributions.fill(None);
for cursor in cursors.iter() {
if cursor.current_doc_id() == pivot_doc_id {
contributions[cursor.term_idx] =
Some(cursor.score_current(avg_doc_len, params));
}
}
if let Some((score, _)) =
combine(&contributions, term_groups, groups, input.combine)
{
heap.insert(score, pivot_doc_id);
}
}
}
for cursor in &mut cursors {
if cursor.current_doc_id() == pivot_doc_id {
cursor.next();
}
}
} else {
for cursor in cursors.iter_mut().take(pivot_idx) {
if cursor.current_doc_id() < pivot_doc_id {
cursor.advance_to(pivot_doc_id);
}
}
}
}
heap
}
#[cfg(test)]
mod tests {
use super::*;
use crate::block::CompactPosting;
use crate::codec::smallfloat;
fn make_term(doc_ids: &[u32], tf: u32) -> TermBlocks {
let postings: Vec<CompactPosting> = doc_ids
.iter()
.map(|&id| CompactPosting {
doc_id: Surrogate(id),
term_freq: tf,
fieldnorm: smallfloat::encode(100),
positions: vec![0],
})
.collect();
let blocks = crate::block::into_blocks(postings);
TermBlocks::from_blocks(blocks)
}
fn run(
terms: &[TermBlocks],
total_docs: u32,
top_k: usize,
allow: Option<&SurrogateBitmap>,
deny: Option<&SurrogateBitmap>,
) -> Vec<Surrogate> {
let params = Bm25Params::default();
let term_groups: Vec<usize> = (0..terms.len()).collect();
bmw_score(&BmwInput {
terms,
term_groups: &term_groups,
groups: terms.len(),
total_docs,
avg_doc_len: 100.0,
params: ¶ms,
top_k,
allow,
deny,
combine: Combine::Sum,
})
.into_sorted()
.iter()
.map(|d| d.doc_id)
.collect()
}
#[test]
fn bmw_basic() {
let term_a = make_term(&[0, 1, 2, 3, 4], 2);
let term_b = make_term(&[2, 3, 5, 6], 3);
let top_ids = run(&[term_a, term_b], 100, 3, None, None);
assert!(!top_ids.is_empty());
assert!(top_ids.contains(&Surrogate(2)));
assert!(top_ids.contains(&Surrogate(3)));
}
#[test]
fn bmw_single_term() {
let term = make_term(&[10, 20, 30, 40, 50], 1);
assert_eq!(run(&[term], 1000, 3, None, None).len(), 3);
}
#[test]
fn bmw_empty_terms() {
assert!(run(&[], 1000, 10, None, None).is_empty());
}
#[test]
fn bmw_respects_top_k() {
let ids: Vec<u32> = (0..500).collect();
let term = make_term(&ids, 1);
assert_eq!(run(&[term], 1000, 5, None, None).len(), 5);
}
#[test]
fn bmw_large_collection() {
let common_ids: Vec<u32> = (0..1000).collect();
let rare_ids = vec![50, 200, 500];
let term_common = make_term(&common_ids, 1);
let term_rare = make_term(&rare_ids, 5);
let top_ids = run(&[term_common, term_rare], 10_000, 3, None, None);
assert_eq!(top_ids.len(), 3);
assert!(top_ids.contains(&Surrogate(50)));
assert!(top_ids.contains(&Surrogate(200)));
assert!(top_ids.contains(&Surrogate(500)));
}
#[test]
fn deny_set_is_excluded_before_the_cut() {
let term = make_term(&[1, 2, 3, 4, 5], 1);
let mut deny = SurrogateBitmap::new();
deny.insert(Surrogate(1));
deny.insert(Surrogate(2));
let top_ids = run(&[term], 100, 2, None, Some(&deny));
assert_eq!(top_ids.len(), 2, "the cut counts only admitted documents");
assert!(!top_ids.contains(&Surrogate(1)));
assert!(!top_ids.contains(&Surrogate(2)));
}
#[test]
fn allow_set_restricts_candidates() {
let term = make_term(&[1, 2, 3, 4, 5], 1);
let mut allow = SurrogateBitmap::new();
allow.insert(Surrogate(4));
assert_eq!(
run(&[term], 100, 10, Some(&allow), None),
vec![Surrogate(4)]
);
}
#[test]
fn two_term_document_wins_top_one() {
let common: Vec<u32> = (0..300).collect();
let a = make_term(&common, 1);
let b = make_term(&[150], 1);
assert_eq!(run(&[a, b], 300, 1, None, None), vec![Surrogate(150)]);
}
}