use super::*;
use crate::cluster::DuplicateCluster;
use crate::config::Config;
use crate::minhash::MinHashSignature;
fn create_index() -> LshIndex {
let config = Config::default();
LshIndex::new(&config).unwrap()
}
fn create_signature(values: Vec<u32>, doc_id: usize) -> MinHashSignature {
MinHashSignature::new(values, doc_id)
}
#[test]
fn lsh_index_creation() {
let index = create_index();
assert_eq!(index.doc_count(), 0);
assert_eq!(index.num_bands, 16);
}
#[test]
fn insert_and_query() {
let mut index = create_index();
let sig = create_signature((0..128).map(|i| i as u32).collect(), 0);
let candidates = index.insert(sig.clone()).unwrap();
assert!(candidates.is_empty());
let sig2 = create_signature((0..128).map(|i| i as u32).collect(), 1);
let candidates = index.insert(sig2).unwrap();
assert!(candidates.contains(&0));
}
#[test]
fn query_returns_candidates() {
let mut index = create_index();
let sig1 = create_signature(vec![1; 128], 0);
index.insert(sig1.clone()).unwrap();
let sig2 = create_signature(vec![1; 128], 1);
let candidates = index.query(&sig2);
assert!(candidates.contains(&0));
}
#[test]
fn verify_similarity_exact() {
let mut index = create_index();
let sig1 = create_signature(vec![1; 128], 0);
index.insert(sig1).unwrap();
let sig2 = create_signature(vec![1; 128], 1);
index.insert(sig2).unwrap();
let sim = index.verify_similarity(0, 1).unwrap();
assert!((sim - 1.0).abs() < f64::EPSILON);
}
#[test]
fn find_clusters_detects_duplicates() {
let mut index = create_index();
for i in 0..5 {
let sig = create_signature(vec![42; 128], i);
index.insert(sig).unwrap();
}
let clusters = index.find_clusters();
assert_eq!(clusters.len(), 1);
assert_eq!(clusters[0].len(), 5);
}
#[test]
fn get_unique_indices() {
let mut index = create_index();
for i in 0..3 {
let sig = create_signature(vec![42; 128], i);
index.insert(sig).unwrap();
}
let unique_sig = create_signature((0..128).map(|i| i as u32).collect(), 3);
index.insert(unique_sig).unwrap();
index.find_clusters();
let unique = index.get_unique_indices();
assert_eq!(unique.len(), 2);
assert!(unique.contains(&0)); assert!(unique.contains(&3)); }
#[test]
fn cluster_info() {
let mut cluster = DuplicateCluster::new(0, 0);
cluster.add(1);
cluster.add(2);
assert_eq!(cluster.len(), 3);
assert!(cluster.contains(1));
assert!(!cluster.contains(99));
}
#[test]
fn duplicate_cluster() {
let mut cluster = DuplicateCluster::new(0, 5);
cluster.add(6);
cluster.add(7);
assert_eq!(cluster.len(), 3);
assert!(cluster.is_duplicate());
assert!(cluster.contains(6));
}
#[test]
fn stats_calculation() {
let mut index = create_index();
for i in 0..10 {
let sig = create_signature(vec![i as u32; 128], i);
index.insert(sig).unwrap();
}
let stats = index.stats();
assert_eq!(stats.doc_count, 10);
assert!(stats.total_buckets > 0);
}
#[test]
fn estimated_recall_at_threshold() {
let config = Config::default();
let index = LshIndex::new(&config).unwrap();
let stats = index.stats();
let recall = stats.estimated_recall();
assert!(recall > 0.9);
}
#[test]
fn invalid_config_rejected() {
let config = Config::new(100, 16, 5, 0.9).unwrap_err();
assert!(config.to_string().contains("divisible"));
}
#[test]
fn memory_usage_reported() {
let mut index = create_index();
for i in 0..100 {
let sig = create_signature(vec![i as u32; 128], i);
index.insert(sig).unwrap();
}
let mem = index.memory_usage();
assert!(mem > 0);
}
#[test]
fn duplicate_count_correct() {
let mut index = create_index();
for i in 0..4 {
let sig = create_signature(vec![42; 128], i);
index.insert(sig).unwrap();
}
index.find_clusters();
assert_eq!(index.duplicate_count(), 3);
}
#[test]
fn insert_rejects_wrong_length_signature() {
let mut index = create_index();
let bad = create_signature((0..64).map(|i| i as u32).collect(), 0);
let result = index.insert(bad);
assert!(
matches!(result, Err(crate::error::Error::InvalidConfig { .. })),
"a signature whose length != num_bands*rows_per_band must be rejected, got {result:?}"
);
let good = create_signature((0..128).map(|i| i as u32).collect(), 1);
assert!(index.insert(good).is_ok());
}
#[test]
fn dense_component_forms_one_cluster_without_losing_nodes() {
let mut index = create_index();
for i in 0..20 {
index.insert(create_signature(vec![7; 128], i)).unwrap();
}
let clusters = index.find_clusters();
assert_eq!(clusters.len(), 1, "all identical docs form one cluster");
assert_eq!(clusters[0].len(), 20, "no node dropped or duplicated");
}
#[test]
fn mixed_corpus_partitions_into_exact_connected_components() {
let mut index = create_index();
for i in 0..3 {
index.insert(create_signature(vec![7; 128], i)).unwrap();
}
for i in 3..5 {
index.insert(create_signature(vec![9; 128], i)).unwrap();
}
index.insert(create_signature(vec![42; 128], 5)).unwrap();
let clusters = index.find_clusters();
assert_eq!(clusters.len(), 2, "two duplicate groups, singleton excluded");
assert_eq!(clusters[0].len(), 3);
assert!(clusters[0].contains(0) && clusters[0].contains(1) && clusters[0].contains(2));
assert!(!clusters[0].contains(3) && !clusters[0].contains(5));
assert_eq!(clusters[1].len(), 2);
assert!(clusters[1].contains(3) && clusters[1].contains(4));
assert!(!clusters[1].contains(0) && !clusters[1].contains(5));
assert!(
!clusters.iter().any(|c| c.contains(5)),
"unique doc 5 must not be clustered"
);
}
#[test]
fn reinserting_same_doc_id_never_duplicates_it_in_a_bucket() {
let mut index = create_index();
for round in 0..5u32 {
index
.insert(create_signature(vec![round; 128], 7))
.unwrap();
}
for band in &index.buckets {
for ids in band.values() {
let count = ids.iter().filter(|&&id| id == 7).count();
assert!(
count <= 1,
"doc_id 7 must appear at most once per bucket, found {count}"
);
}
}
assert_eq!(index.doc_count(), 1, "re-insert must not inflate doc_count");
let candidates = index.query(&create_signature(vec![4u32; 128], 99));
assert_eq!(
candidates,
vec![7],
"the latest signature must resolve back to doc_id 7"
);
}
#[test]
fn clear_empties_the_index_leaving_no_stale_entries() {
let mut index = create_index();
for i in 0..50 {
index.insert(create_signature(vec![7u32; 128], i)).unwrap();
}
index.find_clusters(); assert_eq!(index.doc_count(), 50);
index.clear();
assert_eq!(index.doc_count(), 0, "doc_count must be zeroed");
assert!(
index.buckets.iter().all(std::collections::HashMap::is_empty),
"every band bucket must be emptied"
);
assert!(index.signatures.is_empty(), "signatures must be emptied");
assert!(index.clusters.is_empty(), "cluster cache must be emptied");
assert_eq!(index.num_bands, 16, "band structure is preserved");
index.insert(create_signature(vec![7u32; 128], 0)).unwrap();
let candidates = index.query(&create_signature(vec![7u32; 128], 99));
assert_eq!(
candidates,
vec![0],
"after clear, only the one re-inserted doc collides - no stale entries"
);
}
#[test]
fn inserting_100k_similar_documents_completes_under_two_seconds() {
const GROUPS: u32 = 25_000;
const COPIES: usize = 4;
let mut index = create_index();
let start = std::time::Instant::now();
for g in 0..GROUPS {
for c in 0..COPIES {
let doc_id = g as usize * COPIES + c;
index
.insert(create_signature(vec![g; 128], doc_id))
.unwrap();
}
}
let elapsed = start.elapsed();
assert_eq!(index.doc_count(), GROUPS as usize * COPIES, "all 100k indexed");
assert!(
elapsed < std::time::Duration::from_secs(2),
"inserting 100k similar documents took {elapsed:?}; the O(N) contains scan \
(now removed) made this O(N^2)"
);
let candidates = index.query(&create_signature(vec![0u32; 128], 0));
assert_eq!(
candidates.len(),
COPIES - 1,
"each copy must collide with the other 99 in its group"
);
}