use std::collections::BTreeMap;
use std::ops::Bound;
use uqa_core::{DocId, Payload, PostingEntry, PostingList, Predicate, Value};
fn sort_and_dedup_doc_ids(doc_ids: &mut Vec<DocId>) {
if doc_ids.len() < 2 {
return;
}
let mut min_doc_id = DocId::MAX;
let mut max_doc_id = DocId::MIN;
for doc_id in doc_ids.iter().copied() {
min_doc_id = min_doc_id.min(doc_id);
max_doc_id = max_doc_id.max(doc_id);
}
let dense_span = max_doc_id
.checked_sub(min_doc_id)
.and_then(|span| span.checked_add(1))
.and_then(|span| usize::try_from(span).ok())
.filter(|span| *span <= doc_ids.len().saturating_mul(8));
let Some(span) = dense_span else {
doc_ids.sort_unstable();
doc_ids.dedup();
return;
};
let Some(offsets) = doc_ids
.iter()
.map(|doc_id| usize::try_from(*doc_id - min_doc_id).ok())
.collect::<Option<Vec<_>>>()
else {
doc_ids.sort_unstable();
doc_ids.dedup();
return;
};
let Ok(word_bits) = usize::try_from(u64::BITS) else {
doc_ids.sort_unstable();
doc_ids.dedup();
return;
};
let mut words = vec![0u64; span.div_ceil(word_bits)];
for offset in offsets {
words[offset / word_bits] |= 1u64 << (offset % word_bits);
}
let mut dense_doc_ids = Vec::with_capacity(doc_ids.len());
for (word_index, mut word) in words.into_iter().enumerate() {
while word != 0 {
let bit = word.trailing_zeros();
let Some(doc_id) = u64::try_from(word_index)
.ok()
.and_then(|index| index.checked_mul(u64::from(u64::BITS)))
.and_then(|offset| offset.checked_add(u64::from(bit)))
.and_then(|offset| min_doc_id.checked_add(offset))
else {
doc_ids.sort_unstable();
doc_ids.dedup();
return;
};
dense_doc_ids.push(doc_id);
word &= word - 1;
}
}
*doc_ids = dense_doc_ids;
}
#[derive(Debug, Default, Clone)]
pub struct BTreeIndex {
field: String,
entries: BTreeMap<Value, Vec<DocId>>,
}
impl BTreeIndex {
pub fn new(field: impl Into<String>) -> Self {
Self {
field: field.into(),
entries: BTreeMap::new(),
}
}
pub fn field(&self) -> &str {
&self.field
}
pub fn insert(&mut self, doc_id: DocId, value: Value) {
let bucket = self.entries.entry(value).or_default();
if let Err(pos) = bucket.binary_search(&doc_id) {
bucket.insert(pos, doc_id);
}
}
pub fn remove(&mut self, doc_id: DocId, value: &Value) {
let mut prune = false;
if let Some(bucket) = self.entries.get_mut(value) {
if let Ok(pos) = bucket.binary_search(&doc_id) {
bucket.remove(pos);
}
prune = bucket.is_empty();
}
if prune {
self.entries.remove(value);
}
}
pub fn clear(&mut self) {
self.entries.clear();
}
fn matching_entries<'a>(
&'a self,
predicate: &'a Predicate,
) -> Box<dyn Iterator<Item = (&'a Value, &'a Vec<DocId>)> + 'a> {
match predicate {
Predicate::Equals(t) => match self.entries.get_key_value(t) {
Some(entry) => Box::new(std::iter::once(entry)),
None => Box::new(std::iter::empty()),
},
Predicate::GreaterThan(t) => Box::new(
self.entries
.range((Bound::Excluded(t.clone()), Bound::Unbounded)),
),
Predicate::GreaterThanOrEqual(t) => Box::new(
self.entries
.range((Bound::Included(t.clone()), Bound::Unbounded)),
),
Predicate::LessThan(t) => Box::new(
self.entries
.range((Bound::Unbounded, Bound::Excluded(t.clone()))),
),
Predicate::LessThanOrEqual(t) => Box::new(
self.entries
.range((Bound::Unbounded, Bound::Included(t.clone()))),
),
Predicate::Between { low, high } => Box::new(
self.entries
.range((Bound::Included(low.clone()), Bound::Included(high.clone()))),
),
Predicate::InSet(values) => Box::new(
values
.iter()
.filter_map(move |v| self.entries.get_key_value(v)),
),
Predicate::NotEquals(_) | Predicate::IsNull | Predicate::IsNotNull => {
Box::new(self.entries.iter())
}
}
}
fn entry_matches(predicate: &Predicate, value: &Value) -> bool {
match predicate {
Predicate::NotEquals(_) | Predicate::IsNotNull => predicate.evaluate(Some(value)),
Predicate::IsNull => false,
_ => true,
}
}
pub fn estimate_cardinality(&self, predicate: &Predicate) -> usize {
self.matching_entries(predicate)
.filter(|(value, _)| Self::entry_matches(predicate, value))
.fold(0usize, |total, (_, bucket)| {
total.saturating_add(bucket.len())
})
}
pub fn scan(&self, predicate: &Predicate) -> PostingList {
let range_iter = self.matching_entries(predicate);
let mut all_ids: Vec<DocId> = Vec::new();
for (value, bucket) in range_iter {
if Self::entry_matches(predicate, value) {
all_ids.extend_from_slice(bucket);
}
}
sort_and_dedup_doc_ids(&mut all_ids);
let entries = all_ids
.into_iter()
.map(|doc_id| PostingEntry::new(doc_id, Payload::default()))
.collect();
PostingList::from_sorted_unchecked(entries)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn idx_with_ints() -> BTreeIndex {
let mut idx = BTreeIndex::new("year");
idx.insert(1, Value::Int(2020));
idx.insert(2, Value::Int(2022));
idx.insert(3, Value::Int(2025));
idx.insert(4, Value::Int(2025));
idx.insert(5, Value::Int(2030));
idx
}
fn ids(pl: &PostingList) -> Vec<DocId> {
pl.iter().map(|e| e.doc_id).collect()
}
#[test]
fn equals_returns_exact_matches() {
let idx = idx_with_ints();
let pl = idx.scan(&Predicate::Equals(Value::Int(2025)));
assert_eq!(ids(&pl), vec![3, 4]);
}
#[test]
fn greater_than_excludes_endpoint() {
let idx = idx_with_ints();
let pl = idx.scan(&Predicate::GreaterThan(Value::Int(2022)));
assert_eq!(ids(&pl), vec![3, 4, 5]);
}
#[test]
fn between_inclusive_bounds() {
let idx = idx_with_ints();
let pl = idx.scan(&Predicate::Between {
low: Value::Int(2022),
high: Value::Int(2025),
});
assert_eq!(ids(&pl), vec![2, 3, 4]);
}
#[test]
fn remove_evicts_doc_id_and_empty_bucket() {
let mut idx = idx_with_ints();
idx.remove(3, &Value::Int(2025));
let pl = idx.scan(&Predicate::Equals(Value::Int(2025)));
assert_eq!(ids(&pl), vec![4]);
idx.remove(4, &Value::Int(2025));
let pl = idx.scan(&Predicate::Equals(Value::Int(2025)));
assert!(pl.is_empty());
}
#[test]
fn in_set_returns_union_of_buckets() {
let idx = idx_with_ints();
let mut s = std::collections::BTreeSet::new();
s.insert(Value::Int(2020));
s.insert(Value::Int(2030));
let pl = idx.scan(&Predicate::InSet(s));
assert_eq!(ids(&pl), vec![1, 5]);
}
#[test]
fn dense_scan_deduplicates_docs_present_in_multiple_value_buckets() {
let mut idx = idx_with_ints();
idx.insert(3, Value::Int(2030));
let pl = idx.scan(&Predicate::Between {
low: Value::Int(2020),
high: Value::Int(2030),
});
assert_eq!(ids(&pl), vec![1, 2, 3, 4, 5]);
}
#[test]
fn sparse_extreme_doc_ids_fall_back_to_comparison_sort() {
let mut doc_ids = vec![DocId::MAX, 0, 42, 0];
sort_and_dedup_doc_ids(&mut doc_ids);
assert_eq!(doc_ids, vec![0, 42, DocId::MAX]);
}
#[test]
fn cardinality_estimate_is_exact_for_single_valued_documents() {
let index = idx_with_ints();
let predicate = Predicate::Between {
low: Value::Int(2022),
high: Value::Int(2025),
};
assert_eq!(
index.estimate_cardinality(&predicate),
index.scan(&predicate).len()
);
}
#[test]
fn cardinality_estimate_is_a_safe_upper_bound_for_duplicate_documents() {
let mut index = idx_with_ints();
index.insert(3, Value::Int(2030));
let predicate = Predicate::Between {
low: Value::Int(2020),
high: Value::Int(2030),
};
assert_eq!(index.estimate_cardinality(&predicate), 6);
assert_eq!(index.scan(&predicate).len(), 5);
}
}