use std::collections::BTreeMap;
pub const TERMS_PER_BLOCK: usize = 16;
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub struct TermInfo {
pub document_frequency: u32,
pub postings_offset: u64,
pub postings_length: u32,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum DictError {
NotSorted {
term: String,
},
Duplicate {
term: String,
},
EmptyTerm,
}
impl std::fmt::Display for DictError {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::NotSorted { term } => write!(formatter, "term {term:?} is out of order"),
Self::Duplicate { term } => write!(formatter, "term {term:?} appears twice"),
Self::EmptyTerm => formatter.write_str("an empty term is not a valid query"),
}
}
}
impl std::error::Error for DictError {}
#[derive(Clone, Debug, Eq, PartialEq)]
struct BlockIndexEntry {
head: Vec<u8>,
first_term: usize,
}
#[derive(Clone, Debug, Default)]
pub struct TermDictionary {
terms: Vec<Vec<u8>>,
infos: Vec<TermInfo>,
index: Vec<BlockIndexEntry>,
encoded: Vec<u8>,
}
impl TermDictionary {
#[must_use]
pub fn from_map(entries: &BTreeMap<Vec<u8>, TermInfo>) -> Self {
let mut dictionary = Self::default();
for (term, info) in entries {
let _ = dictionary.push(term, *info);
}
dictionary
}
pub fn push(&mut self, term: &[u8], info: TermInfo) -> Result<(), DictError> {
if let Some(last) = self.terms.last() {
match term.cmp(last.as_slice()) {
std::cmp::Ordering::Less => {
return Err(DictError::NotSorted {
term: String::from_utf8_lossy(term).into_owned(),
});
}
std::cmp::Ordering::Equal => {
return Err(DictError::Duplicate {
term: String::from_utf8_lossy(term).into_owned(),
});
}
std::cmp::Ordering::Greater => {}
}
}
let position = self.terms.len();
if position.is_multiple_of(TERMS_PER_BLOCK) {
self.index.push(BlockIndexEntry {
head: term.to_vec(),
first_term: position,
});
self.encoded.push(0);
push_varint(&mut self.encoded, term.len());
self.encoded.extend_from_slice(term);
} else {
let shared = self
.terms
.last()
.map(|previous| shared_prefix_len(previous, term))
.unwrap_or(0)
.min(255);
let suffix = term.get(shared..).unwrap_or(&[]);
self.encoded.push(u8::try_from(shared).unwrap_or(u8::MAX));
push_varint(&mut self.encoded, suffix.len());
self.encoded.extend_from_slice(suffix);
}
self.terms.push(term.to_vec());
self.infos.push(info);
Ok(())
}
#[must_use]
pub fn len(&self) -> usize {
self.terms.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.terms.is_empty()
}
#[must_use]
pub fn encoded_bytes(&self) -> &[u8] {
&self.encoded
}
#[must_use]
pub fn term_at(&self, index: usize) -> Option<&[u8]> {
self.terms.get(index).map(Vec::as_slice)
}
#[must_use]
pub fn info_at(&self, index: usize) -> Option<TermInfo> {
self.infos.get(index).copied()
}
#[must_use]
pub fn get(&self, term: &[u8]) -> Option<TermInfo> {
let index = self.seek_exact(term)?;
self.infos.get(index).copied()
}
#[must_use]
pub fn seek_exact(&self, term: &[u8]) -> Option<usize> {
let block = self.block_for(term)?;
let start = self.index.get(block)?.first_term;
let end = (start + TERMS_PER_BLOCK).min(self.terms.len());
for index in start..end {
match self.terms.get(index)?.as_slice().cmp(term) {
std::cmp::Ordering::Less => {}
std::cmp::Ordering::Equal => return Some(index),
std::cmp::Ordering::Greater => return None,
}
}
None
}
#[must_use]
pub fn seek_ceiling(&self, term: &[u8]) -> Option<usize> {
let mut low = 0_usize;
let mut high = self.terms.len();
while low < high {
let middle = low + (high - low) / 2;
let candidate = self.terms.get(middle)?;
if candidate.as_slice() < term {
low = middle + 1;
} else {
high = middle;
}
}
(low < self.terms.len()).then_some(low)
}
pub fn prefix_range(&self, prefix: &[u8]) -> Result<std::ops::Range<usize>, DictError> {
if prefix.is_empty() {
return Err(DictError::EmptyTerm);
}
let Some(start) = self.seek_ceiling(prefix) else {
return Ok(self.terms.len()..self.terms.len());
};
let mut end = start;
while let Some(term) = self.terms.get(end) {
if term.starts_with(prefix) {
end += 1;
} else {
break;
}
}
Ok(start..end)
}
fn block_for(&self, term: &[u8]) -> Option<usize> {
if self.index.is_empty() {
return None;
}
let mut low = 0_usize;
let mut high = self.index.len();
while low < high {
let middle = low + (high - low) / 2;
let head = &self.index.get(middle)?.head;
if head.as_slice() <= term {
low = middle + 1;
} else {
high = middle;
}
}
low.checked_sub(1)
}
#[must_use]
pub fn decode_terms(&self) -> Option<Vec<Vec<u8>>> {
let mut out: Vec<Vec<u8>> = Vec::with_capacity(self.terms.len());
let mut cursor = 0_usize;
let mut previous: Vec<u8> = Vec::new();
let mut position = 0_usize;
while cursor < self.encoded.len() {
let shared = usize::from(*self.encoded.get(cursor)?);
cursor += 1;
let (length, consumed) = read_varint(self.encoded.get(cursor..)?)?;
cursor += consumed;
let suffix = self.encoded.get(cursor..cursor + length)?;
cursor += length;
let mut term = if position.is_multiple_of(TERMS_PER_BLOCK) {
Vec::new()
} else {
previous.get(..shared)?.to_vec()
};
term.extend_from_slice(suffix);
previous = term.clone();
out.push(term);
position += 1;
}
Some(out)
}
}
fn shared_prefix_len(left: &[u8], right: &[u8]) -> usize {
left.iter()
.zip(right.iter())
.take_while(|(a, b)| a == b)
.count()
}
fn push_varint(output: &mut Vec<u8>, mut value: usize) {
loop {
let byte = u8::try_from(value & 0x7F).unwrap_or(0);
value >>= 7;
if value == 0 {
output.push(byte);
return;
}
output.push(byte | 0x80);
}
}
fn read_varint(input: &[u8]) -> Option<(usize, usize)> {
let mut value = 0_usize;
let mut shift = 0_u32;
for (index, byte) in input.iter().enumerate() {
if shift >= usize::BITS {
return None;
}
value |= usize::from(byte & 0x7F) << shift;
if byte & 0x80 == 0 {
return Some((value, index + 1));
}
shift += 7;
}
None
}
#[cfg(test)]
#[allow(
clippy::expect_used,
clippy::indexing_slicing,
clippy::panic,
clippy::unwrap_used
)]
mod tests {
use super::*;
fn build(terms: &[&str]) -> TermDictionary {
let mut dictionary = TermDictionary::default();
for (index, term) in terms.iter().enumerate() {
dictionary
.push(
term.as_bytes(),
TermInfo {
document_frequency: u32::try_from(index + 1).expect("small"),
postings_offset: index as u64 * 16,
postings_length: 16,
},
)
.expect("sorted fixture");
}
dictionary
}
fn sorted_sample() -> Vec<String> {
let mut terms: Vec<String> = Vec::new();
for first in b'a'..=b'f' {
for second in b'a'..=b'z' {
terms.push(format!("{}{}", char::from(first), char::from(second)));
}
}
terms.sort();
terms
}
#[test]
fn exact_lookup_finds_every_term_and_only_those() {
let sample = sorted_sample();
let refs: Vec<&str> = sample.iter().map(String::as_str).collect();
let dictionary = build(&refs);
assert_eq!(dictionary.len(), sample.len());
for (index, term) in sample.iter().enumerate() {
let info = dictionary.get(term.as_bytes()).expect("term present");
assert_eq!(
info.document_frequency,
u32::try_from(index + 1).expect("small")
);
}
for absent in ["", "zzz", "aa0", "g", "ab_"] {
assert_eq!(dictionary.get(absent.as_bytes()), None, "{absent:?}");
}
}
#[test]
fn front_coding_round_trips_through_its_own_bytes() {
let sample = sorted_sample();
let refs: Vec<&str> = sample.iter().map(String::as_str).collect();
let dictionary = build(&refs);
let decoded = dictionary.decode_terms().expect("decodes");
let expected: Vec<Vec<u8>> = sample.iter().map(|t| t.as_bytes().to_vec()).collect();
assert_eq!(decoded, expected);
}
#[test]
fn front_coding_actually_shares_prefixes() {
let dictionary = build(&["engine", "engineer", "engineering", "engines"]);
assert!(
dictionary.encoded_bytes().len() < 30,
"front coding did not compress: {} bytes",
dictionary.encoded_bytes().len()
);
assert_eq!(dictionary.decode_terms().expect("decodes").len(), 4);
}
#[test]
fn prefix_range_equals_brute_force() {
let sample = sorted_sample();
let refs: Vec<&str> = sample.iter().map(String::as_str).collect();
let dictionary = build(&refs);
for prefix in ["a", "b", "ab", "zz", "f"] {
let range = dictionary.prefix_range(prefix.as_bytes()).expect("prefix");
let actual: Vec<&str> = range
.clone()
.filter_map(|index| dictionary.term_at(index))
.filter_map(|term| std::str::from_utf8(term).ok())
.collect();
let expected: Vec<&str> = sample
.iter()
.map(String::as_str)
.filter(|term| term.starts_with(prefix))
.collect();
assert_eq!(actual, expected, "prefix {prefix:?}");
}
}
#[test]
fn an_empty_prefix_is_a_typed_error_not_a_full_scan() {
let dictionary = build(&["alpha", "beta"]);
assert_eq!(dictionary.prefix_range(b""), Err(DictError::EmptyTerm));
}
#[test]
fn seek_ceiling_lands_on_the_first_term_at_or_after_the_key() {
let dictionary = build(&["ant", "bee", "cat", "dog"]);
assert_eq!(dictionary.seek_ceiling(b"ant"), Some(0));
assert_eq!(dictionary.seek_ceiling(b"apple"), Some(1));
assert_eq!(dictionary.seek_ceiling(b"bee"), Some(1));
assert_eq!(dictionary.seek_ceiling(b"cz"), Some(3));
assert_eq!(dictionary.seek_ceiling(b"zebra"), None);
assert_eq!(dictionary.seek_ceiling(b""), Some(0));
}
#[test]
fn out_of_order_and_duplicate_terms_are_refused() {
let mut dictionary = TermDictionary::default();
dictionary
.push(b"beta", TermInfo::default())
.expect("first");
assert_eq!(
dictionary.push(b"alpha", TermInfo::default()),
Err(DictError::NotSorted {
term: String::from("alpha")
})
);
assert_eq!(
dictionary.push(b"beta", TermInfo::default()),
Err(DictError::Duplicate {
term: String::from("beta")
})
);
}
#[test]
fn an_empty_dictionary_answers_every_query_negatively() {
let dictionary = TermDictionary::default();
assert!(dictionary.is_empty());
assert_eq!(dictionary.get(b"anything"), None);
assert_eq!(dictionary.seek_exact(b"anything"), None);
assert_eq!(dictionary.seek_ceiling(b"anything"), None);
assert_eq!(dictionary.prefix_range(b"a").expect("prefix"), 0..0);
}
#[test]
fn building_from_a_sorted_map_matches_incremental_pushes() {
let mut map = BTreeMap::new();
for (index, term) in ["ant", "bee", "cat"].into_iter().enumerate() {
map.insert(
term.as_bytes().to_vec(),
TermInfo {
document_frequency: u32::try_from(index + 1).expect("small"),
postings_offset: index as u64 * 16,
postings_length: 16,
},
);
}
let from_map = TermDictionary::from_map(&map);
let incremental = build(&["ant", "bee", "cat"]);
assert_eq!(from_map.encoded_bytes(), incremental.encoded_bytes());
assert_eq!(from_map.len(), 3);
}
#[test]
fn varints_round_trip_at_the_boundaries() {
for value in [0_usize, 1, 127, 128, 300, 16_383, 16_384, 1_000_000] {
let mut buffer = Vec::new();
push_varint(&mut buffer, value);
let (decoded, consumed) = read_varint(&buffer).expect("decodes");
assert_eq!(decoded, value);
assert_eq!(consumed, buffer.len());
}
assert_eq!(read_varint(&[0x80]), None, "an unterminated varint");
}
#[test]
fn lookups_work_across_many_blocks() {
let sample = sorted_sample();
assert!(sample.len() > TERMS_PER_BLOCK * 4);
let refs: Vec<&str> = sample.iter().map(String::as_str).collect();
let dictionary = build(&refs);
assert_eq!(
dictionary.seek_exact(sample.last().expect("non-empty").as_bytes()),
Some(sample.len() - 1)
);
assert_eq!(dictionary.seek_exact(sample[0].as_bytes()), Some(0));
}
}