use hawdb_core::{HawDBError, Result};
use std::collections::BinaryHeap;
pub const BM25_K1: f64 = 1.2;
pub const BM25_B: f64 = 0.75;
pub const BM25_UPPER_BOUND_MARGIN: f64 = 1e-12;
pub fn idf(documents: u64, document_frequency: u64) -> f64 {
(1.0 + (documents as f64 - document_frequency as f64 + 0.5) / (document_frequency as f64 + 0.5))
.ln()
}
pub fn term_score(idf: f64, frequency: f64, document_length: f64, average_length: f64) -> f64 {
let denominator =
frequency + BM25_K1 * (1.0 - BM25_B + BM25_B * document_length / average_length.max(1.0));
idf * (frequency * (BM25_K1 + 1.0)) / denominator
}
pub fn term_upper_bound(idf: f64, max_frequency: u64, min_length: u64, average_length: f64) -> f64 {
term_score(idf, max_frequency as f64, min_length as f64, average_length)
* (1.0 + BM25_UPPER_BOUND_MARGIN)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Bm25Block {
pub last: u64,
pub max_frequency: u64,
pub min_length: u64,
}
pub trait Bm25PostingCursor {
fn position(&self) -> Option<u64>;
fn is_exact(&self) -> bool;
fn block_at(&mut self, target: u64) -> Result<Option<Bm25Block>>;
fn skip_to(&mut self, target: u64) -> Result<()>;
fn materialize(&mut self) -> Result<()>;
fn current(&self) -> Result<(u64, Bm25Block)>;
fn competitive_threshold(&mut self, _threshold: f64) {}
}
pub struct Bm25Term<C> {
pub cursor: C,
pub idf: f64,
pub max_bound: f64,
}
pub trait Bm25Documents {
fn length(&mut self, ordinal: u64) -> Result<u64>;
fn key(&mut self, ordinal: u64) -> Result<&[u8]>;
fn checkpoint(&mut self) -> Result<()>;
fn admit(&mut self, bytes: usize) -> Result<()>;
fn release(&mut self, bytes: usize);
}
#[derive(Debug, Clone, PartialEq)]
pub struct Bm25Hit {
pub ordinal: u64,
pub key: Vec<u8>,
pub score: f64,
}
impl Bm25Hit {
pub fn retained_bytes(key_len: usize) -> usize {
std::mem::size_of::<Self>().saturating_add(key_len)
}
fn worse_than(&self, score: f64, key: &[u8]) -> bool {
self.score
.total_cmp(&score)
.then_with(|| key.cmp(&self.key))
.is_lt()
}
}
struct Worst(Bm25Hit);
impl PartialEq for Worst {
fn eq(&self, other: &Self) -> bool {
self.cmp(other).is_eq()
}
}
impl Eq for Worst {}
impl PartialOrd for Worst {
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
Some(self.cmp(other))
}
}
impl Ord for Worst {
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
other
.0
.score
.total_cmp(&self.0.score)
.then_with(|| self.0.key.cmp(&other.0.key))
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct Bm25KernelStats {
pub documents_scored: u64,
pub block_skips: u64,
pub heap_insertions: u64,
pub terminated_early: bool,
}
#[derive(Debug, Clone, PartialEq)]
pub struct Bm25TopK {
pub hits: Vec<Bm25Hit>,
pub stats: Bm25KernelStats,
}
pub fn block_max_wand<C: Bm25PostingCursor, D: Bm25Documents>(
terms: &mut [Bm25Term<C>],
average_length: f64,
k: usize,
documents: &mut D,
) -> Result<Bm25TopK> {
let mut stats = Bm25KernelStats::default();
if k == 0 {
return Ok(Bm25TopK {
hits: Vec::new(),
stats,
});
}
let capacity = k.min(1 << 20);
documents.admit(capacity.saturating_mul(std::mem::size_of::<Worst>()))?;
let mut heap = BinaryHeap::<Worst>::with_capacity(capacity);
let mut order: Vec<usize> = Vec::with_capacity(terms.len());
let mut in_group = vec![false; terms.len()];
let total_bound = terms.iter().fold(0.0, |sum, term| sum + term.max_bound);
loop {
documents.checkpoint()?;
order.clear();
order.extend((0..terms.len()).filter(|&term| terms[term].cursor.position().is_some()));
if order.is_empty() {
break;
}
let position = |terms: &[Bm25Term<C>], term: usize| {
terms[term]
.cursor
.position()
.expect("active cursors have a position")
};
order.sort_by_key(|&term| (position(terms, term), term));
let floor = (heap.len() >= k).then(|| heap.peek().map(|worst| worst.0.score));
let floor = floor.flatten();
let pivot = match floor {
None => position(terms, order[0]),
Some(floor) => {
let reach = |candidate: u64| {
terms
.iter()
.fold(0.0, |sum, term| match term.cursor.position() {
Some(position) if position <= candidate => sum + term.max_bound,
_ => sum,
})
};
let pivot_index =
order.partition_point(|&term| reach(position(terms, term)) < floor);
let Some(&pivot_term) = order.get(pivot_index) else {
stats.terminated_early = true;
break;
};
position(terms, pivot_term)
}
};
let group_len = order.partition_point(|&term| position(terms, term) <= pivot);
if let Some(floor) = floor {
let mut bound = 0.0;
let mut boundary = u64::MAX;
for flag in in_group.iter_mut() {
*flag = false;
}
for &term in &order[..group_len] {
in_group[term] = true;
}
for (term, &member) in terms.iter_mut().zip(&in_group) {
if !member {
continue;
}
if let Some(block) = term.cursor.block_at(pivot)? {
bound += term_upper_bound(
term.idf,
block.max_frequency,
block.min_length,
average_length,
);
boundary = boundary.min(block.last.saturating_add(1));
}
}
if bound < floor {
let target = order
.get(group_len)
.map_or(boundary, |&next| boundary.min(position(terms, next)));
if target <= pivot {
return Err(HawDBError::StorageIntegrity(
"BM25 block bounds do not advance past their pivot".into(),
));
}
for &term in &order[..group_len] {
terms[term].cursor.skip_to(target)?;
}
stats.block_skips = stats.block_skips.saturating_add(1);
continue;
}
}
if position(terms, order[0]) < pivot {
for &term in &order[..group_len] {
if position(terms, term) < pivot {
terms[term].cursor.skip_to(pivot)?;
}
}
continue;
}
let mut moved = false;
for &term in &order[..group_len] {
let cursor = &mut terms[term].cursor;
if !cursor.is_exact() {
cursor.materialize()?;
moved |= cursor.position() != Some(pivot);
}
}
if moved {
continue;
}
let length = documents.length(pivot)?;
let mut score = 0.0;
for flag in in_group.iter_mut() {
*flag = false;
}
for &term in &order[..group_len] {
in_group[term] = true;
}
for (term, &member) in terms.iter().zip(&in_group) {
if !member {
continue;
}
let (frequency, block) = term.cursor.current()?;
if frequency > block.max_frequency || length < block.min_length {
return Err(HawDBError::StorageIntegrity(
"BM25 posting exceeds its block bounds".into(),
));
}
score += term_score(term.idf, frequency as f64, length as f64, average_length);
}
stats.documents_scored = stats.documents_scored.saturating_add(1);
if !score.is_finite() || score <= 0.0 {
return Err(HawDBError::StorageIntegrity(
"BM25 score is not a positive finite number".into(),
));
}
let full = heap.len() >= k;
let competitive = match heap.peek() {
Some(worst) if full => worst.0.score <= score,
_ => true,
};
if competitive {
let retained_key = {
let key = documents.key(pivot)?;
let enters = match heap.peek() {
Some(worst) if full => worst.0.worse_than(score, key),
_ => true,
};
enters.then_some(key.len())
};
if let Some(key_len) = retained_key {
documents.admit(Bm25Hit::retained_bytes(key_len))?;
let key = documents.key(pivot)?.to_vec();
if full && let Some(evicted) = heap.pop() {
documents.release(Bm25Hit::retained_bytes(evicted.0.key.len()));
}
heap.push(Worst(Bm25Hit {
ordinal: pivot,
key,
score,
}));
stats.heap_insertions = stats.heap_insertions.saturating_add(1);
if heap.len() >= k
&& let Some(worst) = heap.peek()
{
let floor = worst.0.score;
for term in terms.iter_mut() {
let others = total_bound - term.max_bound;
term.cursor.competitive_threshold(floor - others);
}
}
}
}
let next = pivot
.checked_add(1)
.ok_or_else(|| HawDBError::StorageIntegrity("BM25 ordinal overflow".into()))?;
for &term in &order[..group_len] {
terms[term].cursor.skip_to(next)?;
}
}
let mut hits = heap.into_iter().map(|worst| worst.0).collect::<Vec<_>>();
hits.sort_by(|left, right| {
right
.score
.total_cmp(&left.score)
.then_with(|| left.key.cmp(&right.key))
});
Ok(Bm25TopK { hits, stats })
}
#[cfg(test)]
mod tests;