use ahash::AHashMap;
use bitpacking::{BitPacker, BitPacker4x};
use crate::error::{LaurusError, Result};
use crate::storage::structured::{StructReader, StructWriter};
use crate::storage::{StorageInput, StorageOutput};
const POSTING_BLOCK_LEN: usize = BitPacker4x::BLOCK_LEN;
pub const SKIP_INTERVAL: usize = 8;
pub fn build_skip_levels(doc_ids: &[u32]) -> Vec<Vec<u32>> {
let n = doc_ids.len();
if n < SKIP_INTERVAL {
return Vec::new();
}
let mut levels: Vec<Vec<u32>> = Vec::new();
let mut step = SKIP_INTERVAL;
loop {
let len = n / step;
if len == 0 {
break;
}
let mut level = Vec::with_capacity(len);
for i in 0..len {
level.push(doc_ids[(i + 1) * step - 1]);
}
levels.push(level);
if len <= 1 {
break;
}
step = match step.checked_mul(SKIP_INTERVAL) {
Some(s) => s,
None => break,
};
}
levels
}
#[derive(Debug, Clone, PartialEq)]
pub struct Posting {
pub doc_id: u64,
pub frequency: u32,
pub positions: Option<Vec<u32>>,
pub weight: f32,
}
impl Posting {
pub fn new(doc_id: u64) -> Self {
Posting {
doc_id,
frequency: 1,
positions: None,
weight: 1.0,
}
}
pub fn with_frequency(doc_id: u64, frequency: u32) -> Self {
Posting {
doc_id,
frequency,
positions: None,
weight: 1.0,
}
}
pub fn with_positions(doc_id: u64, positions: Vec<u32>) -> Self {
let frequency = positions.len() as u32;
Posting {
doc_id,
frequency,
positions: Some(positions),
weight: 1.0,
}
}
pub fn with_weight(mut self, weight: f32) -> Self {
self.weight = weight;
self
}
pub fn add_position(&mut self, position: u32) {
match &mut self.positions {
Some(positions) => {
positions.push(position);
self.frequency = positions.len() as u32;
}
None => {
self.positions = Some(vec![position]);
self.frequency = 1;
}
}
}
pub fn frequency(&self) -> u32 {
self.frequency
}
pub fn positions(&self) -> Option<&[u32]> {
self.positions.as_deref()
}
}
#[derive(Debug, Clone)]
pub struct SoAPostingList {
pub term: String,
pub doc_ids: Vec<u64>,
pub frequencies: Vec<u32>,
pub weights: Vec<f32>,
pub total_frequency: u64,
pub doc_frequency: u64,
}
impl SoAPostingList {
pub fn len(&self) -> usize {
self.doc_ids.len()
}
pub fn is_empty(&self) -> bool {
self.doc_ids.is_empty()
}
pub fn iter(&self) -> SoAPostingIterator<'_> {
SoAPostingIterator {
list: self,
position: 0,
}
}
}
#[derive(Debug)]
pub struct SoAPostingIterator<'a> {
list: &'a SoAPostingList,
position: usize,
}
impl<'a> SoAPostingIterator<'a> {
pub fn skip_to(&mut self, target: u64) -> bool {
while self.position < self.list.doc_ids.len() {
if self.list.doc_ids[self.position] >= target {
return true;
}
self.position += 1;
}
true
}
}
impl<'a> Iterator for SoAPostingIterator<'a> {
type Item = (u64, u32, f32);
fn next(&mut self) -> Option<Self::Item> {
if self.position < self.list.doc_ids.len() {
let i = self.position;
self.position += 1;
Some((
self.list.doc_ids[i],
self.list.frequencies[i],
self.list.weights[i],
))
} else {
None
}
}
fn size_hint(&self) -> (usize, Option<usize>) {
let remaining = self.list.doc_ids.len() - self.position;
(remaining, Some(remaining))
}
}
impl<'a> ExactSizeIterator for SoAPostingIterator<'a> {}
#[derive(Debug, Clone, PartialEq)]
pub struct CompactPosting {
pub doc_id: u64,
pub frequency: u32,
pub weight: f32,
}
impl CompactPosting {
pub fn new(doc_id: u64, frequency: u32, weight: f32) -> Self {
CompactPosting {
doc_id,
frequency,
weight,
}
}
}
#[derive(Debug, Clone)]
pub struct PostingList {
pub term: String,
pub postings: Vec<Posting>,
pub total_frequency: u64,
pub doc_frequency: u64,
}
#[derive(Debug, Clone)]
pub struct DecodedPostingList {
pub term: String,
pub doc_ids: Vec<u32>,
pub frequencies: Vec<u32>,
pub weights: Vec<f32>,
pub positions: Option<Vec<Option<Vec<u32>>>>,
pub skip_levels: Vec<Vec<u32>>,
pub total_frequency: u64,
pub doc_frequency: u64,
}
impl DecodedPostingList {
pub fn len(&self) -> usize {
self.doc_ids.len()
}
pub fn is_empty(&self) -> bool {
self.doc_ids.is_empty()
}
pub fn from_posting_list(list: &PostingList) -> Self {
let n = list.postings.len();
let mut doc_ids = Vec::with_capacity(n);
let mut frequencies = Vec::with_capacity(n);
let mut weights = Vec::with_capacity(n);
let any_positions = list.postings.iter().any(|p| p.positions.is_some());
let mut positions: Option<Vec<Option<Vec<u32>>>> = if any_positions {
Some(Vec::with_capacity(n))
} else {
None
};
for posting in &list.postings {
doc_ids.push(posting.doc_id as u32);
frequencies.push(posting.frequency);
weights.push(posting.weight);
if let Some(out) = positions.as_mut() {
out.push(posting.positions.clone());
}
}
let skip_levels = build_skip_levels(&doc_ids);
DecodedPostingList {
term: list.term.clone(),
doc_ids,
frequencies,
weights,
positions,
skip_levels,
total_frequency: list.total_frequency,
doc_frequency: list.doc_frequency,
}
}
pub fn into_posting_list(self) -> PostingList {
let n = self.doc_ids.len();
let positions_iter: Box<dyn Iterator<Item = Option<Vec<u32>>>> = match self.positions {
Some(v) => Box::new(v.into_iter()),
None => Box::new(std::iter::repeat_with(|| None).take(n)),
};
let postings: Vec<Posting> = self
.doc_ids
.into_iter()
.zip(self.frequencies)
.zip(self.weights)
.zip(positions_iter)
.map(|(((did, freq), w), pos)| Posting {
doc_id: did as u64,
frequency: freq,
positions: pos,
weight: w,
})
.collect();
PostingList {
term: self.term,
postings,
total_frequency: self.total_frequency,
doc_frequency: self.doc_frequency,
}
}
}
impl PostingList {
pub fn new(term: String) -> Self {
PostingList {
term,
postings: Vec::new(),
total_frequency: 0,
doc_frequency: 0,
}
}
pub fn add_posting(&mut self, posting: Posting) {
match self
.postings
.binary_search_by_key(&posting.doc_id, |p| p.doc_id)
{
Ok(pos) => {
let existing = &mut self.postings[pos];
existing.frequency += posting.frequency;
self.total_frequency += posting.frequency as u64;
if let Some(new_positions) = posting.positions {
match &mut existing.positions {
Some(positions) => positions.extend(new_positions),
None => existing.positions = Some(new_positions),
}
}
}
Err(pos) => {
self.total_frequency += posting.frequency as u64;
self.doc_frequency += 1;
self.postings.insert(pos, posting);
}
}
}
pub fn len(&self) -> usize {
self.postings.len()
}
pub fn is_empty(&self) -> bool {
self.postings.is_empty()
}
pub fn iter(&'_ self) -> std::slice::Iter<'_, Posting> {
self.postings.iter()
}
pub fn optimize(&mut self) {
self.postings.sort_by_key(|p| p.doc_id);
self.postings.dedup_by_key(|p| p.doc_id);
}
pub fn to_compact(&self) -> Vec<CompactPosting> {
self.postings
.iter()
.map(|p| CompactPosting {
doc_id: p.doc_id,
frequency: p.frequency,
weight: p.weight,
})
.collect()
}
pub fn to_soa(&self) -> SoAPostingList {
let len = self.postings.len();
let mut doc_ids = Vec::with_capacity(len);
let mut frequencies = Vec::with_capacity(len);
let mut weights = Vec::with_capacity(len);
for p in &self.postings {
doc_ids.push(p.doc_id);
frequencies.push(p.frequency);
weights.push(p.weight);
}
SoAPostingList {
term: self.term.clone(),
doc_ids,
frequencies,
weights,
total_frequency: self.total_frequency,
doc_frequency: self.doc_frequency,
}
}
pub fn encode<W: StorageOutput>(&self, writer: &mut StructWriter<W>) -> Result<()> {
self.encode_header_and_payload(writer, false)
}
pub fn encode_v2<W: StorageOutput>(&self, writer: &mut StructWriter<W>) -> Result<()> {
self.encode_header_and_payload(writer, true)
}
fn encode_header_and_payload<W: StorageOutput>(
&self,
writer: &mut StructWriter<W>,
with_skip_levels: bool,
) -> Result<()> {
writer.write_string(&self.term)?;
writer.write_varint(self.total_frequency)?;
writer.write_varint(self.doc_frequency)?;
let n = self.postings.len();
writer.write_varint(n as u64)?;
let any_positions = self.postings.iter().any(|p| p.positions.is_some());
writer.write_u8(u8::from(any_positions))?;
if with_skip_levels {
let mut doc_ids_u32 = Vec::with_capacity(n);
for posting in &self.postings {
let did = posting.doc_id;
doc_ids_u32.push(u32::try_from(did).map_err(|_| {
LaurusError::index(format!(
"doc_id {did} exceeds u32::MAX; segment is too large for bit-packed posting format"
))
})?);
}
let levels = build_skip_levels(&doc_ids_u32);
writer.write_u8(u8::try_from(levels.len()).map_err(|_| {
LaurusError::index(format!(
"skip level count {} exceeds u8::MAX; refusing to encode",
levels.len()
))
})?)?;
for level in &levels {
writer.write_varint(level.len() as u64)?;
for &did in level {
writer.write_u32(did)?;
}
}
}
if n == 0 {
return Ok(());
}
let bitpacker = BitPacker4x::new();
let full_blocks = n / POSTING_BLOCK_LEN;
let tail = n % POSTING_BLOCK_LEN;
let mut doc_buf = [0u32; POSTING_BLOCK_LEN];
let mut packed = vec![0u8; 32 * POSTING_BLOCK_LEN / 8];
let mut initial: u32 = 0;
for b in 0..full_blocks {
for (i, slot) in doc_buf.iter_mut().enumerate() {
let did = self.postings[b * POSTING_BLOCK_LEN + i].doc_id;
*slot = u32::try_from(did).map_err(|_| {
LaurusError::index(format!(
"doc_id {did} exceeds u32::MAX; segment is too large for bit-packed posting format"
))
})?;
}
let num_bits = bitpacker.num_bits_sorted(initial, &doc_buf);
let bytes = num_bits as usize * POSTING_BLOCK_LEN / 8;
bitpacker.compress_sorted(initial, &doc_buf, &mut packed[..bytes], num_bits);
writer.write_u8(num_bits)?;
writer.write_raw(&packed[..bytes])?;
initial = doc_buf[POSTING_BLOCK_LEN - 1];
}
let mut prev_did: u64 = initial as u64;
for i in 0..tail {
let did = self.postings[full_blocks * POSTING_BLOCK_LEN + i].doc_id;
u32::try_from(did).map_err(|_| {
LaurusError::index(format!(
"doc_id {did} exceeds u32::MAX; segment is too large for bit-packed posting format"
))
})?;
writer.write_varint(did - prev_did)?;
prev_did = did;
}
let mut freq_buf = [0u32; POSTING_BLOCK_LEN];
for b in 0..full_blocks {
for (i, slot) in freq_buf.iter_mut().enumerate() {
*slot = self.postings[b * POSTING_BLOCK_LEN + i].frequency;
}
let num_bits = bitpacker.num_bits(&freq_buf);
let bytes = num_bits as usize * POSTING_BLOCK_LEN / 8;
bitpacker.compress(&freq_buf, &mut packed[..bytes], num_bits);
writer.write_u8(num_bits)?;
writer.write_raw(&packed[..bytes])?;
}
for i in 0..tail {
let freq = self.postings[full_blocks * POSTING_BLOCK_LEN + i].frequency;
writer.write_varint(freq as u64)?;
}
for posting in &self.postings {
writer.write_f32(posting.weight)?;
}
if any_positions {
for posting in &self.postings {
if let Some(positions) = &posting.positions {
writer.write_u8(1)?;
writer.write_varint(positions.len() as u64)?;
let mut prev_pos = 0u32;
for &pos in positions {
let delta = pos.saturating_sub(prev_pos);
writer.write_varint(delta as u64)?;
prev_pos = pos;
}
} else {
writer.write_u8(0)?;
}
}
}
Ok(())
}
pub fn decode_soa<R: StorageInput>(reader: &mut StructReader<R>) -> Result<DecodedPostingList> {
Self::decode_soa_inner(reader, false)
}
pub fn decode_soa_v2<R: StorageInput>(
reader: &mut StructReader<R>,
) -> Result<DecodedPostingList> {
Self::decode_soa_inner(reader, true)
}
fn decode_soa_inner<R: StorageInput>(
reader: &mut StructReader<R>,
with_skip_levels: bool,
) -> Result<DecodedPostingList> {
let term = reader.read_string()?;
let total_frequency = reader.read_varint()?;
let doc_frequency = reader.read_varint()?;
let n = reader.read_varint()? as usize;
let any_positions = reader.read_u8()? != 0;
let mut disk_skip_levels: Vec<Vec<u32>> = Vec::new();
if with_skip_levels {
let num_levels = reader.read_u8()? as usize;
disk_skip_levels.reserve(num_levels);
for _ in 0..num_levels {
let level_len = reader.read_varint()? as usize;
let mut level = Vec::with_capacity(level_len);
for _ in 0..level_len {
level.push(reader.read_u32()?);
}
disk_skip_levels.push(level);
}
}
if n == 0 {
return Ok(DecodedPostingList {
term,
doc_ids: Vec::new(),
frequencies: Vec::new(),
weights: Vec::new(),
positions: if any_positions {
Some(Vec::new())
} else {
None
},
skip_levels: disk_skip_levels,
total_frequency,
doc_frequency,
});
}
let bitpacker = BitPacker4x::new();
let full_blocks = n / POSTING_BLOCK_LEN;
let tail = n % POSTING_BLOCK_LEN;
let mut doc_ids: Vec<u32> = Vec::with_capacity(n);
let mut buf = [0u32; POSTING_BLOCK_LEN];
let mut initial: u32 = 0;
for _ in 0..full_blocks {
let num_bits = reader.read_u8()?;
let bytes = num_bits as usize * POSTING_BLOCK_LEN / 8;
reader.read_raw_with(bytes, |compressed| {
bitpacker.decompress_sorted(initial, compressed, &mut buf, num_bits);
})?;
doc_ids.extend_from_slice(&buf);
initial = buf[POSTING_BLOCK_LEN - 1];
}
let mut prev_did: u64 = initial as u64;
for _ in 0..tail {
let delta = reader.read_varint()?;
let did = prev_did + delta;
doc_ids.push(u32::try_from(did).map_err(|_| {
LaurusError::index(format!(
"decoded doc_id {did} exceeds u32::MAX; corrupted posting list"
))
})?);
prev_did = did;
}
let mut frequencies: Vec<u32> = Vec::with_capacity(n);
for _ in 0..full_blocks {
let num_bits = reader.read_u8()?;
let bytes = num_bits as usize * POSTING_BLOCK_LEN / 8;
reader.read_raw_with(bytes, |compressed| {
bitpacker.decompress(compressed, &mut buf, num_bits);
})?;
frequencies.extend_from_slice(&buf);
}
for _ in 0..tail {
frequencies.push(reader.read_varint()? as u32);
}
let mut weights: Vec<f32> = Vec::with_capacity(n);
for _ in 0..n {
weights.push(reader.read_f32()?);
}
let positions = if any_positions {
let mut out: Vec<Option<Vec<u32>>> = Vec::with_capacity(n);
for _ in 0..n {
let has = reader.read_u8()? != 0;
if has {
let count = reader.read_varint()? as usize;
let mut p = Vec::with_capacity(count);
let mut prev_pos = 0u32;
for _ in 0..count {
let delta = reader.read_varint()? as u32;
let pos = prev_pos + delta;
p.push(pos);
prev_pos = pos;
}
out.push(Some(p));
} else {
out.push(None);
}
}
Some(out)
} else {
None
};
let skip_levels = if with_skip_levels {
disk_skip_levels
} else {
build_skip_levels(&doc_ids)
};
Ok(DecodedPostingList {
term,
doc_ids,
frequencies,
weights,
positions,
skip_levels,
total_frequency,
doc_frequency,
})
}
pub fn decode<R: StorageInput>(reader: &mut StructReader<R>) -> Result<Self> {
Ok(Self::decode_soa(reader)?.into_posting_list())
}
pub fn decode_v2<R: StorageInput>(reader: &mut StructReader<R>) -> Result<Self> {
Ok(Self::decode_soa_v2(reader)?.into_posting_list())
}
}
pub struct PostingIterator {
postings: Vec<Posting>,
position: usize,
}
impl PostingIterator {
pub fn new(postings: Vec<Posting>) -> Self {
PostingIterator {
postings,
position: 0,
}
}
pub fn empty() -> Self {
PostingIterator {
postings: Vec::new(),
position: 0,
}
}
pub fn current(&self) -> Option<&Posting> {
self.postings.get(self.position)
}
#[allow(clippy::should_implement_trait)]
pub fn next(&mut self) -> Option<&Posting> {
if self.position < self.postings.len() {
let posting = &self.postings[self.position];
self.position += 1;
Some(posting)
} else {
None
}
}
pub fn skip_to(&mut self, target_doc_id: u64) -> bool {
while self.position < self.postings.len() {
if self.postings[self.position].doc_id >= target_doc_id {
return true;
}
self.position += 1;
}
false
}
pub fn is_exhausted(&self) -> bool {
self.position >= self.postings.len()
}
pub fn len(&self) -> usize {
self.postings.len()
}
pub fn is_empty(&self) -> bool {
self.postings.is_empty()
}
}
impl Iterator for PostingIterator {
type Item = Posting;
fn next(&mut self) -> Option<Self::Item> {
if self.position < self.postings.len() {
let posting = self.postings[self.position].clone();
self.position += 1;
Some(posting)
} else {
None
}
}
}
#[derive(Debug)]
pub struct TermPostingIndex {
terms: AHashMap<String, PostingList>,
doc_count: u64,
term_count: u64,
}
impl TermPostingIndex {
pub fn new() -> Self {
TermPostingIndex {
terms: AHashMap::new(),
doc_count: 0,
term_count: 0,
}
}
pub fn add_posting(&mut self, term: String, posting: Posting) {
let posting_list = self.terms.entry(term.clone()).or_insert_with(|| {
self.term_count += 1;
PostingList::new(term)
});
posting_list.add_posting(posting);
}
pub fn add_document(&mut self, doc_id: u64, terms: Vec<(String, u32, Option<Vec<u32>>)>) {
for (term, frequency, positions) in terms {
let posting = if let Some(positions) = positions {
Posting::with_positions(doc_id, positions)
} else {
Posting::with_frequency(doc_id, frequency)
};
self.add_posting(term, posting);
}
self.doc_count = self.doc_count.max(doc_id + 1);
}
pub fn get_posting_list(&self, term: &str) -> Option<&PostingList> {
self.terms.get(term)
}
pub fn get_posting_iterator(&self, term: &str) -> PostingIterator {
match self.terms.get(term) {
Some(posting_list) => PostingIterator::new(posting_list.postings.clone()),
None => PostingIterator::empty(),
}
}
pub fn doc_count(&self) -> u64 {
self.doc_count
}
pub fn term_count(&self) -> u64 {
self.term_count
}
pub fn terms(&self) -> impl Iterator<Item = &String> {
self.terms.keys()
}
pub fn optimize(&mut self) {
for posting_list in self.terms.values_mut() {
posting_list.optimize();
}
}
const ON_DISK_VERSION: u32 = 2;
pub fn write_to_storage<W: StorageOutput>(&self, writer: &mut StructWriter<W>) -> Result<()> {
writer.write_u32(0x494E5658)?; writer.write_u32(Self::ON_DISK_VERSION)?;
writer.write_varint(self.doc_count)?;
writer.write_varint(self.term_count)?;
writer.write_varint(self.terms.len() as u64)?;
let mut sorted_terms: Vec<_> = self.terms.iter().collect();
sorted_terms.sort_by_key(|(term, _)| *term);
for (_, posting_list) in sorted_terms {
posting_list.encode_v2(writer)?;
}
Ok(())
}
pub fn read_from_storage<R: StorageInput>(reader: &mut StructReader<R>) -> Result<Self> {
let magic = reader.read_u32()?;
if magic != 0x494E5658 {
return Err(LaurusError::index("Invalid inverted index file format"));
}
let version = reader.read_u32()?;
if version != 1 && version != 2 {
return Err(LaurusError::index(format!(
"Unsupported index version: {version}"
)));
}
let doc_count = reader.read_varint()?;
let term_count = reader.read_varint()?;
let posting_list_count = reader.read_varint()? as usize;
let mut terms = AHashMap::with_capacity(posting_list_count);
for _ in 0..posting_list_count {
let posting_list = if version == 1 {
PostingList::decode(reader)?
} else {
PostingList::decode_v2(reader)?
};
terms.insert(posting_list.term.clone(), posting_list);
}
Ok(TermPostingIndex {
terms,
doc_count,
term_count,
})
}
}
impl Default for TermPostingIndex {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug, Clone)]
pub struct PostingStats {
pub posting_list_count: usize,
pub total_postings: usize,
pub avg_postings_per_list: f64,
pub max_posting_list_size: usize,
pub compressed_size: usize,
}
impl TermPostingIndex {
pub fn stats(&self) -> PostingStats {
let posting_list_count = self.terms.len();
let total_postings: usize = self.terms.values().map(|pl| pl.postings.len()).sum();
let avg_postings_per_list = if posting_list_count > 0 {
total_postings as f64 / posting_list_count as f64
} else {
0.0
};
let max_posting_list_size = self
.terms
.values()
.map(|pl| pl.postings.len())
.max()
.unwrap_or(0);
PostingStats {
posting_list_count,
total_postings,
avg_postings_per_list,
max_posting_list_size,
compressed_size: 0, }
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::storage::Storage;
use crate::storage::memory::MemoryStorage;
use crate::storage::memory::MemoryStorageConfig;
use std::sync::Arc;
#[test]
fn test_posting_creation() {
let posting = Posting::new(1);
assert_eq!(posting.doc_id, 1);
assert_eq!(posting.frequency, 1);
assert_eq!(posting.positions, None);
assert_eq!(posting.weight, 1.0);
let posting = Posting::with_frequency(2, 5);
assert_eq!(posting.doc_id, 2);
assert_eq!(posting.frequency, 5);
let posting = Posting::with_positions(3, vec![10, 20, 30]);
assert_eq!(posting.doc_id, 3);
assert_eq!(posting.frequency, 3);
assert_eq!(posting.positions, Some(vec![10, 20, 30]));
}
#[test]
fn test_posting_list() {
let mut list = PostingList::new("test".to_string());
assert!(list.is_empty());
list.add_posting(Posting::new(1));
list.add_posting(Posting::new(3));
list.add_posting(Posting::new(2));
assert_eq!(list.len(), 3);
assert_eq!(list.doc_frequency, 3);
let doc_ids: Vec<u64> = list.postings.iter().map(|p| p.doc_id).collect();
assert_eq!(doc_ids, vec![1, 2, 3]);
}
#[test]
fn test_posting_iterator() {
let postings = vec![
Posting::new(1),
Posting::new(3),
Posting::new(5),
Posting::new(7),
];
let mut iter = PostingIterator::new(postings);
assert_eq!(iter.current().unwrap().doc_id, 1);
assert_eq!(iter.next().unwrap().doc_id, 1);
assert_eq!(iter.current().unwrap().doc_id, 3);
assert!(iter.skip_to(5));
assert_eq!(iter.current().map(|p| p.doc_id), Some(5));
assert_eq!(iter.current().unwrap().doc_id, 5);
assert!(!iter.skip_to(10));
assert!(iter.is_exhausted());
}
#[test]
fn test_inverted_index() {
let mut index = TermPostingIndex::new();
index.add_document(
1,
vec![
("hello".to_string(), 1, Some(vec![0])),
("world".to_string(), 1, Some(vec![1])),
],
);
index.add_document(
2,
vec![
("hello".to_string(), 1, Some(vec![0])),
("rust".to_string(), 1, Some(vec![1])),
("world".to_string(), 1, Some(vec![2])),
],
);
assert_eq!(index.doc_count(), 3); assert_eq!(index.term_count(), 3);
let hello_list = index.get_posting_list("hello").unwrap();
assert_eq!(hello_list.postings.len(), 2);
assert_eq!(hello_list.doc_frequency, 2);
let rust_list = index.get_posting_list("rust").unwrap();
assert_eq!(rust_list.postings.len(), 1);
assert_eq!(rust_list.doc_frequency, 1);
assert!(index.get_posting_list("nonexistent").is_none());
}
#[test]
fn test_posting_list_encoding() {
let storage = Arc::new(MemoryStorage::new(MemoryStorageConfig::default()));
let mut original_list = PostingList::new("test".to_string());
original_list.add_posting(Posting::with_positions(1, vec![0, 5, 10]));
original_list.add_posting(Posting::with_frequency(3, 2));
original_list.add_posting(Posting::new(5));
{
let output = storage.create_output("test_posting.bin").unwrap();
let mut writer = StructWriter::new(output);
original_list.encode(&mut writer).unwrap();
writer.close().unwrap();
}
{
let input = storage.open_input("test_posting.bin").unwrap();
let mut reader = StructReader::new(input).unwrap();
let decoded_list = PostingList::decode(&mut reader).unwrap();
assert_eq!(decoded_list.term, original_list.term);
assert_eq!(decoded_list.postings.len(), original_list.postings.len());
assert_eq!(decoded_list.doc_frequency, original_list.doc_frequency);
assert_eq!(decoded_list.total_frequency, original_list.total_frequency);
for (orig, decoded) in original_list
.postings
.iter()
.zip(decoded_list.postings.iter())
{
assert_eq!(orig.doc_id, decoded.doc_id);
assert_eq!(orig.frequency, decoded.frequency);
assert_eq!(orig.positions, decoded.positions);
}
}
}
#[test]
fn test_inverted_index_serialization() {
let storage = Arc::new(MemoryStorage::new(MemoryStorageConfig::default()));
let mut original_index = TermPostingIndex::new();
original_index.add_document(
1,
vec![
("hello".to_string(), 2, Some(vec![0, 5])),
("world".to_string(), 1, Some(vec![1])),
],
);
original_index.add_document(
2,
vec![
("hello".to_string(), 1, Some(vec![2])),
("rust".to_string(), 3, Some(vec![0, 3, 6])),
],
);
{
let output = storage.create_output("test_index.bin").unwrap();
let mut writer = StructWriter::new(output);
original_index.write_to_storage(&mut writer).unwrap();
writer.close().unwrap();
}
{
let input = storage.open_input("test_index.bin").unwrap();
let mut reader = StructReader::new(input).unwrap();
let loaded_index = TermPostingIndex::read_from_storage(&mut reader).unwrap();
assert_eq!(loaded_index.doc_count(), original_index.doc_count());
assert_eq!(loaded_index.term_count(), original_index.term_count());
for term in ["hello", "world", "rust"] {
let orig_list = original_index.get_posting_list(term);
let loaded_list = loaded_index.get_posting_list(term);
match (orig_list, loaded_list) {
(Some(orig), Some(loaded)) => {
assert_eq!(orig.postings.len(), loaded.postings.len());
assert_eq!(orig.doc_frequency, loaded.doc_frequency);
}
(None, None) => {}
_ => panic!("Mismatch in term existence: {term}"),
}
}
}
}
#[test]
fn test_posting_stats() {
let mut index = TermPostingIndex::new();
for doc_id in 0..100 {
index.add_document(
doc_id,
vec![
("common".to_string(), 1, None),
(format!("term_{}", doc_id % 10), 1, None),
],
);
}
let stats = index.stats();
assert!(stats.posting_list_count > 0);
assert!(stats.total_postings > 0);
assert!(stats.avg_postings_per_list > 0.0);
assert!(stats.max_posting_list_size > 0);
}
#[test]
fn test_soa_posting_list() {
let mut list = PostingList::new("hello".to_string());
list.add_posting(Posting::with_frequency(1, 3).with_weight(1.0));
list.add_posting(Posting::with_frequency(5, 1).with_weight(2.0));
list.add_posting(Posting::with_frequency(9, 2).with_weight(0.5));
let soa = list.to_soa();
assert_eq!(soa.len(), 3);
assert_eq!(soa.doc_ids, &[1, 5, 9]);
assert_eq!(soa.frequencies, &[3, 1, 2]);
assert_eq!(soa.weights, &[1.0, 2.0, 0.5]);
assert_eq!(soa.term, "hello");
assert_eq!(soa.total_frequency, list.total_frequency);
assert_eq!(soa.doc_frequency, list.doc_frequency);
let mut iter = soa.iter();
let first = iter.next().unwrap();
assert_eq!(first, (1, 3, 1.0));
let second = iter.next().unwrap();
assert_eq!(second, (5, 1, 2.0));
let third = iter.next().unwrap();
assert_eq!(third, (9, 2, 0.5));
assert!(iter.next().is_none());
let mut iter = soa.iter();
assert!(iter.skip_to(5));
assert_eq!(iter.next().unwrap(), (5, 1, 2.0));
assert!(iter.skip_to(100));
assert!(iter.next().is_none());
}
#[test]
fn test_compact_posting() {
let posting = CompactPosting::new(42, 3, 1.5);
assert_eq!(posting.doc_id, 42);
assert_eq!(posting.frequency, 3);
assert_eq!(posting.weight, 1.5);
}
#[test]
fn test_posting_list_to_compact() {
let mut list = PostingList::new("test".to_string());
list.add_posting(Posting::with_positions(1, vec![0, 5, 10]).with_weight(1.0));
list.add_posting(Posting::with_positions(2, vec![3, 7]).with_weight(2.0));
let compact = list.to_compact();
assert_eq!(compact.len(), 2);
assert_eq!(compact[0].doc_id, 1);
assert_eq!(compact[0].frequency, 3);
assert_eq!(compact[0].weight, 1.0);
assert_eq!(compact[1].doc_id, 2);
assert_eq!(compact[1].frequency, 2);
assert_eq!(compact[1].weight, 2.0);
}
#[test]
fn test_compact_posting_size() {
assert_eq!(std::mem::size_of::<CompactPosting>(), 16);
}
fn round_trip_n(n: usize, with_positions: bool) {
let storage = Arc::new(MemoryStorage::new(MemoryStorageConfig::default()));
let mut original = PostingList::new(format!("term_n{n}"));
for i in 0..n {
let did = (i as u64) * 3 + 1;
let freq = ((i % 7) + 1) as u32;
let weight = 0.25 + (i % 4) as f32 * 0.5;
let posting = if with_positions {
let mut positions = Vec::with_capacity(freq as usize);
let mut p = (i % 11) as u32;
for _ in 0..freq {
positions.push(p);
p += 2;
}
Posting::with_positions(did, positions).with_weight(weight)
} else {
Posting::with_frequency(did, freq).with_weight(weight)
};
original.add_posting(posting);
}
let path = format!("rt_n{n}_pos{with_positions}.bin");
{
let output = storage.create_output(&path).unwrap();
let mut writer = StructWriter::new(output);
original.encode(&mut writer).unwrap();
writer.close().unwrap();
}
let input = storage.open_input(&path).unwrap();
let mut reader = StructReader::new(input).unwrap();
let decoded = PostingList::decode(&mut reader).unwrap();
assert_eq!(decoded.term, original.term, "term mismatch (n={n})");
assert_eq!(
decoded.total_frequency, original.total_frequency,
"total_frequency mismatch (n={n})"
);
assert_eq!(
decoded.doc_frequency, original.doc_frequency,
"doc_frequency mismatch (n={n})"
);
assert_eq!(
decoded.postings.len(),
original.postings.len(),
"len mismatch (n={n})"
);
for (i, (orig, dec)) in original
.postings
.iter()
.zip(decoded.postings.iter())
.enumerate()
{
assert_eq!(orig.doc_id, dec.doc_id, "doc_id mismatch at i={i} (n={n})");
assert_eq!(
orig.frequency, dec.frequency,
"frequency mismatch at i={i} (n={n})"
);
assert_eq!(orig.weight, dec.weight, "weight mismatch at i={i} (n={n})");
assert_eq!(
orig.positions, dec.positions,
"positions mismatch at i={i} (n={n})"
);
}
}
#[test]
fn test_round_trip_empty() {
round_trip_n(0, false);
round_trip_n(0, true);
}
#[test]
fn test_round_trip_single() {
round_trip_n(1, false);
round_trip_n(1, true);
}
#[test]
fn test_round_trip_below_block() {
round_trip_n(127, false);
round_trip_n(127, true);
}
#[test]
fn test_round_trip_exact_block() {
round_trip_n(128, false);
round_trip_n(128, true);
}
#[test]
fn test_round_trip_block_plus_one() {
round_trip_n(129, false);
round_trip_n(129, true);
}
#[test]
fn test_round_trip_two_blocks() {
round_trip_n(256, false);
round_trip_n(256, true);
}
#[test]
fn test_round_trip_many_blocks() {
round_trip_n(1000, false);
round_trip_n(1000, true);
}
#[test]
fn test_round_trip_mixed_positions() {
let storage = Arc::new(MemoryStorage::new(MemoryStorageConfig::default()));
let mut original = PostingList::new("mixed".to_string());
for i in 0..200u64 {
let mut posting = Posting::with_frequency(i * 2, ((i % 5) + 1) as u32);
if i % 3 == 0 {
posting.add_position((i % 17) as u32);
posting.add_position(((i % 17) + 5) as u32);
}
original.add_posting(posting);
}
let path = "rt_mixed.bin";
{
let output = storage.create_output(path).unwrap();
let mut writer = StructWriter::new(output);
original.encode(&mut writer).unwrap();
writer.close().unwrap();
}
let input = storage.open_input(path).unwrap();
let mut reader = StructReader::new(input).unwrap();
let decoded = PostingList::decode(&mut reader).unwrap();
assert_eq!(decoded.postings.len(), original.postings.len());
for (orig, dec) in original.postings.iter().zip(decoded.postings.iter()) {
assert_eq!(orig, dec);
}
}
#[test]
fn test_decode_soa_matches_decode_aos() {
let storage = Arc::new(MemoryStorage::new(MemoryStorageConfig::default()));
for &(n, with_pos) in &[
(0usize, false),
(1, false),
(1, true),
(127, false),
(128, true),
(129, false),
(256, true),
(1000, false),
] {
let mut original = PostingList::new(format!("term_n{n}_p{with_pos}"));
for i in 0..n {
let did = (i as u64) * 3 + 1;
let freq = ((i % 7) + 1) as u32;
let weight = 0.25 + (i % 4) as f32 * 0.5;
let p = if with_pos {
let mut positions = Vec::with_capacity(freq as usize);
let mut p = (i % 11) as u32;
for _ in 0..freq {
positions.push(p);
p += 2;
}
Posting::with_positions(did, positions).with_weight(weight)
} else {
Posting::with_frequency(did, freq).with_weight(weight)
};
original.add_posting(p);
}
let path = format!("soa_match_n{n}_p{with_pos}.bin");
{
let output = storage.create_output(&path).unwrap();
let mut writer = StructWriter::new(output);
original.encode(&mut writer).unwrap();
writer.close().unwrap();
}
let aos_decoded = {
let input = storage.open_input(&path).unwrap();
let mut reader = StructReader::new(input).unwrap();
PostingList::decode(&mut reader).unwrap()
};
let soa_decoded = {
let input = storage.open_input(&path).unwrap();
let mut reader = StructReader::new(input).unwrap();
PostingList::decode_soa(&mut reader).unwrap()
};
assert_eq!(aos_decoded.term, soa_decoded.term);
assert_eq!(aos_decoded.total_frequency, soa_decoded.total_frequency);
assert_eq!(aos_decoded.doc_frequency, soa_decoded.doc_frequency);
assert_eq!(aos_decoded.postings.len(), soa_decoded.len());
for (i, p) in aos_decoded.postings.iter().enumerate() {
assert_eq!(p.doc_id as u32, soa_decoded.doc_ids[i], "doc_id at {i}");
assert_eq!(p.frequency, soa_decoded.frequencies[i], "freq at {i}");
assert_eq!(p.weight, soa_decoded.weights[i], "weight at {i}");
let soa_pos = soa_decoded.positions.as_ref().and_then(|v| v[i].clone());
assert_eq!(p.positions, soa_pos, "positions at {i}");
}
}
}
#[test]
fn test_decoded_posting_list_aos_soa_roundtrip() {
let mut list = PostingList::new("term".to_string());
for i in 0..200u64 {
let mut p = Posting::with_frequency(i * 2 + 7, ((i % 5) + 1) as u32)
.with_weight(0.5 + (i % 3) as f32);
if i % 4 == 0 {
p.add_position((i % 13) as u32);
p.add_position(((i % 13) + 4) as u32);
}
list.add_posting(p);
}
let soa = DecodedPostingList::from_posting_list(&list);
assert_eq!(soa.len(), list.postings.len());
let rebuilt = soa.into_posting_list();
assert_eq!(rebuilt.term, list.term);
assert_eq!(rebuilt.total_frequency, list.total_frequency);
assert_eq!(rebuilt.doc_frequency, list.doc_frequency);
for (orig, dec) in list.postings.iter().zip(rebuilt.postings.iter()) {
assert_eq!(orig, dec);
}
}
#[test]
fn test_build_skip_levels_below_interval() {
for n in 0..SKIP_INTERVAL {
let doc_ids: Vec<u32> = (0..n as u32).collect();
let levels = build_skip_levels(&doc_ids);
assert!(
levels.is_empty(),
"expected empty skip levels at n={n}, got {levels:?}"
);
}
}
#[test]
fn test_build_skip_levels_single_block() {
let doc_ids: Vec<u32> = (0..SKIP_INTERVAL as u32).collect();
let levels = build_skip_levels(&doc_ids);
assert_eq!(levels.len(), 1, "{levels:?}");
assert_eq!(levels[0], vec![SKIP_INTERVAL as u32 - 1]);
}
#[test]
fn test_build_skip_levels_two_levels() {
let n = SKIP_INTERVAL * SKIP_INTERVAL;
let doc_ids: Vec<u32> = (0..n as u32).collect();
let levels = build_skip_levels(&doc_ids);
assert_eq!(levels.len(), 2);
assert_eq!(levels[0].len(), SKIP_INTERVAL);
for (i, &v) in levels[0].iter().enumerate() {
assert_eq!(v, ((i + 1) * SKIP_INTERVAL - 1) as u32);
}
assert_eq!(levels[1], vec![(n - 1) as u32]);
}
#[test]
fn test_build_skip_levels_5k_dense() {
let n: usize = 5_000;
let doc_ids: Vec<u32> = (0..n as u32).collect();
let levels = build_skip_levels(&doc_ids);
let mut step = SKIP_INTERVAL;
for level in &levels {
let expected_len = n / step;
assert_eq!(level.len(), expected_len, "step={step}");
for (i, &v) in level.iter().enumerate() {
assert_eq!(v, ((i + 1) * step - 1) as u32, "step={step} i={i}");
}
step *= SKIP_INTERVAL;
}
assert!(
levels.last().unwrap().len() <= SKIP_INTERVAL,
"top level should fit in a single skip window: {levels:?}"
);
}
#[test]
fn test_round_trip_v2_preserves_skip_levels() {
let storage = Arc::new(MemoryStorage::new(MemoryStorageConfig::default()));
let n: usize = 2_000;
let mut original = PostingList::new("v2_round_trip".to_string());
for i in 0..n {
let did = (i as u64) * 3 + 1;
let freq = ((i % 7) + 1) as u32;
let weight = 0.25 + (i % 4) as f32 * 0.5;
original.add_posting(Posting::with_frequency(did, freq).with_weight(weight));
}
let path = "v2_round_trip.bin";
{
let output = storage.create_output(path).unwrap();
let mut writer = StructWriter::new(output);
original.encode_v2(&mut writer).unwrap();
writer.close().unwrap();
}
let input = storage.open_input(path).unwrap();
let mut reader = StructReader::new(input).unwrap();
let decoded = PostingList::decode_soa_v2(&mut reader).unwrap();
assert_eq!(decoded.len(), n);
for (i, posting) in original.postings.iter().enumerate() {
assert_eq!(decoded.doc_ids[i], posting.doc_id as u32, "doc at {i}");
assert_eq!(decoded.frequencies[i], posting.frequency, "freq at {i}");
}
let expected_levels = build_skip_levels(&decoded.doc_ids);
assert_eq!(decoded.skip_levels.len(), expected_levels.len());
for (i, (got, want)) in decoded
.skip_levels
.iter()
.zip(expected_levels.iter())
.enumerate()
{
assert_eq!(got, want, "level {i} mismatch");
}
}
#[test]
fn test_v1_decode_populates_skip_levels_fallback() {
let storage = Arc::new(MemoryStorage::new(MemoryStorageConfig::default()));
let n: usize = 1_000;
let mut original = PostingList::new("v1_compat".to_string());
for i in 0..n {
let did = (i as u64) * 2;
original.add_posting(Posting::with_frequency(did, 1));
}
let path = "v1_compat.bin";
{
let output = storage.create_output(path).unwrap();
let mut writer = StructWriter::new(output);
original.encode(&mut writer).unwrap();
writer.close().unwrap();
}
let input = storage.open_input(path).unwrap();
let mut reader = StructReader::new(input).unwrap();
let decoded = PostingList::decode_soa(&mut reader).unwrap();
assert_eq!(decoded.len(), n);
let expected_levels = build_skip_levels(&decoded.doc_ids);
assert_eq!(decoded.skip_levels, expected_levels);
}
#[test]
fn test_term_posting_index_v1_back_compat() {
let storage = Arc::new(MemoryStorage::new(MemoryStorageConfig::default()));
let mut original = TermPostingIndex::new();
original.add_document(
1,
vec![
("hello".to_string(), 2, Some(vec![0, 5])),
("world".to_string(), 1, Some(vec![1])),
],
);
original.add_document(
2,
vec![
("hello".to_string(), 1, Some(vec![2])),
("rust".to_string(), 3, Some(vec![0, 3, 6])),
],
);
let path = "tpi_v1.bin";
{
let output = storage.create_output(path).unwrap();
let mut writer = StructWriter::new(output);
writer.write_u32(0x494E5658).unwrap(); writer.write_u32(1).unwrap(); writer.write_varint(original.doc_count()).unwrap();
writer.write_varint(original.term_count()).unwrap();
writer.write_varint(3).unwrap(); let mut terms: Vec<_> = original.terms.iter().collect();
terms.sort_by_key(|(t, _)| *t);
for (_, posting_list) in terms {
posting_list.encode(&mut writer).unwrap();
}
writer.close().unwrap();
}
let input = storage.open_input(path).unwrap();
let mut reader = StructReader::new(input).unwrap();
let loaded = TermPostingIndex::read_from_storage(&mut reader).unwrap();
assert_eq!(loaded.doc_count(), original.doc_count());
assert_eq!(loaded.term_count(), original.term_count());
for term in ["hello", "world", "rust"] {
let want = original.get_posting_list(term).expect("term exists");
let got = loaded.get_posting_list(term).expect("term loaded");
assert_eq!(got.postings.len(), want.postings.len(), "term={term}");
}
}
#[test]
fn test_encode_rejects_u64_doc_id_overflow() {
let storage = Arc::new(MemoryStorage::new(MemoryStorageConfig::default()));
let mut list = PostingList::new("overflow".to_string());
for i in 0..127u64 {
list.add_posting(Posting::new(i));
}
list.add_posting(Posting::new(u64::from(u32::MAX) + 1));
let output = storage.create_output("overflow.bin").unwrap();
let mut writer = StructWriter::new(output);
let err = list
.encode(&mut writer)
.expect_err("expected overflow error");
let msg = format!("{err}");
assert!(
msg.contains("exceeds u32::MAX"),
"unexpected error message: {msg}"
);
}
}