use std::collections::HashMap;
use std::path::{Path, PathBuf};
use sparse_vector::handle::SparseHandle;
use sparse_vector::index::{SparseIndex, SparseVector};
struct TempDir(PathBuf);
impl TempDir {
fn new(name: &str) -> Self {
let dir = std::env::temp_dir().join(format!("lucivy_segments_{name}"));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
Self(dir)
}
fn str(&self) -> &str { self.0.to_str().unwrap() }
}
impl Drop for TempDir {
fn drop(&mut self) { let _ = std::fs::remove_dir_all(&self.0); }
}
fn segment_count(base: &Path) -> usize {
std::fs::read_dir(base).unwrap().flatten()
.filter(|e| {
let n = e.file_name().to_string_lossy().into_owned();
n.starts_with("seg_") && n.ends_with(".mmap")
})
.count()
}
fn vector(id: u64, weight: f32) -> SparseVector {
let indices: Vec<u32> = (0..6).map(|k| ((id * 7 + k * 1301) % 5_000) as u32).collect();
SparseVector { indices, values: vec![weight; 6] }
}
fn queries() -> Vec<SparseVector> {
(0..12u64).map(|i| vector(i * 13, 1.0)).collect()
}
fn reference(docs: &[(u64, SparseVector)]) -> SparseIndex {
let mut index = SparseIndex::new();
for (id, v) in docs { index.insert(*id, v); }
index
}
#[test]
fn a_commit_writes_one_segment_and_the_answers_do_not_change() {
let dir = TempDir::new("many_commits");
let handle = SparseHandle::create(dir.str()).unwrap();
let mut docs: Vec<(u64, SparseVector)> = Vec::new();
for round in 0..5u64 {
for i in 0..40u64 {
let id = round * 40 + i;
let v = vector(id, 1.0 + (id % 4) as f32);
handle.insert(id, &v).unwrap();
docs.push((id, v));
}
handle.commit_inner().unwrap();
assert_eq!(segment_count(&dir.0), round as usize + 1,
"each commit writes one segment, and leaves the earlier ones alone");
assert_eq!(handle.len(), docs.len(), "after {} commits", round + 1);
}
let reference = reference(&docs);
for q in queries() {
assert_eq!(handle.search(&q, 20), reference.search(&q, 20),
"five segments disagree with one index");
}
}
#[test]
fn an_update_hides_the_older_copy() {
let dir = TempDir::new("update");
let handle = SparseHandle::create(dir.str()).unwrap();
for id in 0..30u64 { handle.insert(id, &vector(id, 1.0)).unwrap(); }
handle.commit_inner().unwrap();
let mut docs: Vec<(u64, SparseVector)> = (0..30u64).map(|id| (id, vector(id, 1.0))).collect();
for id in (0..30u64).step_by(3) {
let v = vector(id, 9.0);
handle.insert(id, &v).unwrap();
docs[id as usize] = (id, v);
}
handle.commit_inner().unwrap();
assert_eq!(handle.len(), 30, "an update is not a second document");
let reference = reference(&docs);
for q in queries() {
let got = handle.search(&q, 30);
assert_eq!(got, reference.search(&q, 30), "the updated weight must be the one that answers");
let ids: Vec<u64> = got.iter().map(|(id, _)| *id).collect();
let mut unique = ids.clone();
unique.sort_unstable();
unique.dedup();
assert_eq!(ids.len(), unique.len(), "a document answered twice: {ids:?}");
}
}
#[test]
fn a_deletion_holds_across_a_reopen() {
let dir = TempDir::new("delete");
let handle = SparseHandle::create(dir.str()).unwrap();
for id in 0..50u64 { handle.insert(id, &vector(id, 1.0)).unwrap(); }
handle.commit_inner().unwrap();
let gone: Vec<u64> = (0..50u64).step_by(5).collect();
for &id in &gone {
assert!(handle.remove(id).unwrap(), "removing a committed document must report it");
}
assert!(!handle.remove(4242).unwrap(), "removing what is not there reports nothing");
handle.commit_inner().unwrap();
assert_eq!(handle.len(), 40);
let kept: Vec<(u64, SparseVector)> = (0..50u64)
.filter(|id| !gone.contains(id))
.map(|id| (id, vector(id, 1.0)))
.collect();
let reference = reference(&kept);
for handle in [handle, SparseHandle::open(dir.str()).unwrap()] {
assert_eq!(handle.len(), 40);
for q in queries() {
let got = handle.search(&q, 50);
assert!(got.iter().all(|(id, _)| !gone.contains(id)), "a deleted document answered");
assert_eq!(got, reference.search(&q, 50));
}
}
}
#[test]
fn a_filtered_search_over_segments_is_the_search_intersected() {
let dir = TempDir::new("filtered");
let handle = SparseHandle::create(dir.str()).unwrap();
let mut docs = Vec::new();
for round in 0..3u64 {
for i in 0..25u64 {
let id = round * 25 + i;
let v = vector(id, 1.0 + (id % 3) as f32);
handle.insert(id, &v).unwrap();
docs.push((id, v));
}
handle.commit_inner().unwrap();
}
let allowed: Vec<u64> = (0..75u64).step_by(4).collect();
for q in queries() {
let full: HashMap<u64, f32> = handle.search(&q, 75).into_iter().collect();
let filtered = handle.search_filtered(&q, 75, &allowed);
let mut expected: Vec<(u64, f32)> = full.into_iter()
.filter(|(id, _)| allowed.contains(id))
.collect();
expected.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap().then(a.0.cmp(&b.0)));
assert_eq!(filtered, expected);
}
}
#[test]
fn an_index_from_before_segments_is_converted_by_its_next_commit() {
let dir = TempDir::new("convert");
let docs: Vec<(u64, SparseVector)> = (0..60u64).map(|id| (id, vector(id, 1.0 + (id % 5) as f32))).collect();
{
let handle = SparseHandle::create(dir.str()).unwrap();
for (id, v) in &docs { handle.insert(*id, v).unwrap(); }
handle.commit_inner().unwrap();
}
let seg: PathBuf = std::fs::read_dir(&dir.0).unwrap().flatten()
.map(|e| e.path())
.find(|p| p.file_name().unwrap().to_string_lossy().ends_with(".mmap"))
.expect("a segment was written");
std::fs::rename(&seg, dir.0.join("sparse.mmap")).unwrap();
let _ = std::fs::remove_file(dir.0.join("meta.json"));
for e in std::fs::read_dir(&dir.0).unwrap().flatten() {
let n = e.file_name().to_string_lossy().into_owned();
if n.ends_with(".ids") { let _ = std::fs::remove_file(e.path()); }
}
let handle = SparseHandle::open(dir.str()).unwrap();
assert_eq!(handle.len(), 60, "the old layout still opens");
let before = reference(&docs);
for q in queries() {
assert_eq!(handle.search(&q, 20), before.search(&q, 20), "before conversion");
}
handle.insert(999, &vector(999, 3.0)).unwrap();
handle.commit_inner().unwrap();
assert!(!dir.0.join("sparse.mmap").exists());
assert!(!dir.0.join("sparse_dims.bin").exists());
assert!(!dir.0.join("sparse_vectors.bin").exists());
assert!(dir.0.join("meta.json").exists());
assert_eq!(handle.len(), 61);
let mut with_extra = docs.clone();
with_extra.push((999, vector(999, 3.0)));
let after = reference(&with_extra);
for q in queries() {
assert_eq!(handle.search(&q, 20), after.search(&q, 20), "after conversion");
}
}
#[test]
fn a_merge_keeps_the_answers_and_applies_the_tombstones() {
let dir = TempDir::new("merge");
let handle = SparseHandle::create(dir.str()).unwrap();
let mut docs: HashMap<u64, SparseVector> = HashMap::new();
for round in 0..4u64 {
for i in 0..30u64 {
let id = round * 30 + i;
let v = vector(id, 1.0 + (id % 4) as f32);
handle.insert(id, &v).unwrap();
docs.insert(id, v);
}
if round == 2 {
for id in (0..30u64).step_by(6) {
let v = vector(id, 8.0);
handle.insert(id, &v).unwrap();
docs.insert(id, v);
}
}
handle.commit_inner().unwrap();
}
for id in (5..120u64).step_by(11) {
handle.remove(id).unwrap();
docs.remove(&id);
}
handle.commit_inner().unwrap();
assert_eq!(handle.num_segments(), 4);
let before: Vec<Vec<(u64, f32)>> = queries().iter().map(|q| handle.search(q, 40)).collect();
let live = handle.len();
assert_eq!(live, docs.len());
let bytes_before: u64 = std::fs::read_dir(&dir.0).unwrap().flatten()
.filter_map(|e| e.metadata().ok().map(|m| m.len())).sum();
handle.compact().unwrap();
assert_eq!(handle.num_segments(), 1, "a merge leaves one segment");
assert_eq!(segment_count(&dir.0), 1, "and removes the files nothing names any more");
assert_eq!(handle.len(), live, "a merge does not change what is in the index");
let after: Vec<Vec<(u64, f32)>> = queries().iter().map(|q| handle.search(q, 40)).collect();
assert_eq!(after, before, "a merge does not move an answer");
let bytes_after: u64 = std::fs::read_dir(&dir.0).unwrap().flatten()
.filter_map(|e| e.metadata().ok().map(|m| m.len())).sum();
assert!(bytes_after < bytes_before, "{bytes_after} is not smaller than {bytes_before}");
let reopened = SparseHandle::open(dir.str()).unwrap();
assert_eq!(reopened.len(), live);
let reference: Vec<(u64, SparseVector)> = docs.into_iter().collect();
let reference = reference_index(&reference);
for (q, want) in queries().iter().zip(&before) {
assert_eq!(&reopened.search(q, 40), want);
assert_eq!(reopened.search(q, 40), reference.search(q, 40));
}
}
fn reference_index(docs: &[(u64, SparseVector)]) -> SparseIndex {
reference(docs)
}
#[test]
fn segments_are_merged_once_they_pile_up() {
let dir = TempDir::new("policy");
let handle = SparseHandle::create(dir.str()).unwrap();
let cap: usize = std::env::var("LUCIVY_SPARSE_MAX_SEGMENTS").ok()
.and_then(|v| v.parse().ok()).unwrap_or(8);
let mut docs: Vec<(u64, SparseVector)> = Vec::new();
for round in 0..(cap as u64 + 4) {
for i in 0..10u64 {
let id = round * 10 + i;
let v = vector(id, 1.0 + (id % 3) as f32);
handle.insert(id, &v).unwrap();
docs.push((id, v));
}
handle.commit_inner().unwrap();
assert!(handle.num_segments() <= cap,
"commit {round}: {} segments, cap is {cap}", handle.num_segments());
}
assert_eq!(handle.len(), docs.len());
let want = reference(&docs);
for q in queries() {
assert_eq!(handle.search(&q, 30), want.search(&q, 30), "merging changed an answer");
}
}
#[test]
fn a_filtered_search_is_the_same_before_and_after_a_merge() {
let dir = TempDir::new("filter_merge");
let handle = SparseHandle::create(dir.str()).unwrap();
let mut docs: Vec<(u64, SparseVector)> = Vec::new();
for round in 0..5u64 {
for i in 0..24u64 {
let id = round * 24 + i;
let v = vector(id, 1.0 + (id % 4) as f32);
handle.insert(id, &v).unwrap();
docs.push((id, v));
}
handle.commit_inner().unwrap();
}
for id in (3..120u64).step_by(17) {
handle.remove(id).unwrap();
docs.retain(|(d, _)| *d != id);
}
handle.commit_inner().unwrap();
assert!(handle.num_segments() >= 5, "the point is to search several segments");
let sets: Vec<Vec<u64>> = vec![
(0..120).step_by(37).collect(), (0..120).step_by(7).collect(), (0..120).step_by(2).collect(), (0..120).collect(), vec![9_999, 10_000], ];
let before: Vec<Vec<Vec<(u64, f32)>>> = sets.iter()
.map(|ids| queries().iter().map(|q| handle.search_filtered(q, 40, ids)).collect())
.collect();
let reference = reference(&docs);
for (ids, per_query) in sets.iter().zip(&before) {
for (q, got) in queries().iter().zip(per_query) {
let mut want: Vec<(u64, f32)> = reference.search(q, 200).into_iter()
.filter(|(id, _)| ids.contains(id))
.collect();
want.truncate(40);
assert_eq!(got, &want, "a filtered search over segments is the search intersected");
}
}
handle.compact().unwrap();
assert_eq!(handle.num_segments(), 1);
for (ids, per_query) in sets.iter().zip(&before) {
for (q, want) in queries().iter().zip(per_query) {
assert_eq!(&handle.search_filtered(q, 40, ids), want,
"the merge moved a filtered answer");
}
}
}