1use crate::cluster::DuplicateCluster;
8use crate::config::Config;
9use crate::error::{Error, Result};
10use crate::minhash::MinHashSignature;
11
12use std::collections::hash_map::Entry;
13use std::collections::HashMap;
14use tracing::{instrument, warn};
15
16pub struct LshIndex {
21 num_bands: usize,
23 rows_per_band: usize,
25 threshold: f64,
27 buckets: Vec<HashMap<u64, Vec<usize>>>,
29 signatures: std::collections::BTreeMap<usize, MinHashSignature>,
31 doc_count: usize,
33 clusters: Vec<DuplicateCluster>,
35 doc_to_cluster: HashMap<usize, usize>,
37 next_cluster_id: usize,
39 max_doc_id: usize,
41}
42
43impl LshIndex {
44 #[instrument(skip(config), level = "debug")]
50 pub fn new(config: &Config) -> Result<Self> {
51 if config.signature_size % config.num_bands != 0 {
52 warn!(
53 signature_size = config.signature_size,
54 num_bands = config.num_bands,
55 "signature_size not divisible by num_bands"
56 );
57 return Err(Error::InvalidConfig {
58 reason: format!(
59 "signature_size ({}) not divisible by num_bands ({})",
60 config.signature_size, config.num_bands
61 ),
62 fix: "ensure signature_size = num_bands * rows_per_band".to_string(),
63 });
64 }
65
66 let rows_per_band = config.signature_size / config.num_bands;
67 let buckets: Vec<HashMap<u64, Vec<usize>>> =
68 (0..config.num_bands).map(|_| HashMap::new()).collect();
69
70 Ok(Self {
71 num_bands: config.num_bands,
72 rows_per_band,
73 threshold: config.similarity_threshold,
74 buckets,
75 signatures: std::collections::BTreeMap::new(),
76 doc_count: 0,
77 clusters: Vec::new(),
78 doc_to_cluster: HashMap::new(),
79 next_cluster_id: 0,
80 max_doc_id: 0,
81 })
82 }
83
84 pub fn clear(&mut self) {
92 for band in &mut self.buckets {
93 band.clear();
94 }
95 self.signatures.clear();
96 self.doc_count = 0;
97 self.clusters.clear();
98 self.doc_to_cluster.clear();
99 self.next_cluster_id = 0;
100 self.max_doc_id = 0;
101 }
102
103 #[instrument(skip(self, signature), fields(doc_id = signature.doc_id), level = "debug")]
113 pub fn insert(&mut self, signature: MinHashSignature) -> Result<Vec<usize>> {
114 let doc_id = signature.doc_id;
115
116 const MAX_DOC_ID: usize = 100_000_000;
119 if doc_id > MAX_DOC_ID {
120 return Err(Error::InvalidConfig {
121 reason: format!("doc_id {doc_id} exceeds maximum {MAX_DOC_ID}"),
122 fix: "use sequential doc_ids starting from 0".to_string(),
123 });
124 }
125
126 let expected_len = self.num_bands * self.rows_per_band;
132 if signature.len() != expected_len {
133 return Err(Error::InvalidConfig {
134 reason: format!(
135 "signature length {} does not match index configuration ({} bands x {} rows = {expected_len})",
136 signature.len(),
137 self.num_bands,
138 self.rows_per_band
139 ),
140 fix: "generate signatures with the signature_size the index was configured for"
141 .to_string(),
142 });
143 }
144
145 let had_signature = self.signatures.contains_key(&doc_id);
147
148 if had_signature {
150 if let Some(old_sig) = self.signatures.remove(&doc_id) {
151 for band_idx in 0..self.num_bands {
152 let start = band_idx * self.rows_per_band;
153 let old_hash = old_sig.band_hash(start, self.rows_per_band);
154 if let Some(vec) = self.buckets[band_idx].get_mut(&old_hash) {
155 vec.retain(|&id| id != doc_id);
156 if vec.is_empty() {
157 self.buckets[band_idx].remove(&old_hash);
158 }
159 }
160 }
161 }
162 }
163
164 self.signatures.insert(doc_id, signature.clone());
166 if !had_signature {
167 self.doc_count += 1;
168 }
169 self.max_doc_id = self.max_doc_id.max(doc_id);
170
171 if !self.clusters.is_empty() || !self.doc_to_cluster.is_empty() {
173 self.clusters.clear();
174 self.doc_to_cluster.clear();
175 self.next_cluster_id = 0;
176 }
177
178 let mut candidates = std::collections::HashSet::new();
179
180 for band_idx in 0..self.num_bands {
182 let start = band_idx * self.rows_per_band;
183 let band_hash = signature.band_hash(start, self.rows_per_band);
184
185 let bucket = &mut self.buckets[band_idx];
186
187 match bucket.entry(band_hash) {
188 Entry::Occupied(mut entry) => {
189 for &existing_id in entry.get() {
191 if existing_id != doc_id {
192 candidates.insert(existing_id);
193 }
194 }
195 const MAX_BUCKET_SIZE: usize = 10_000;
206 if entry.get().len() < MAX_BUCKET_SIZE {
207 entry.get_mut().push(doc_id);
208 }
209 }
210 Entry::Vacant(entry) => {
211 entry.insert(vec![doc_id]);
212 }
213 }
214 }
215
216 Ok(candidates.into_iter().collect())
217 }
218
219 pub fn query(&self, signature: &MinHashSignature) -> Vec<usize> {
224 let expected_len = self.num_bands * self.rows_per_band;
225 if signature.len() != expected_len {
226 warn!(
227 sig_len = signature.len(),
228 expected_len = expected_len,
229 "LshIndex::query called with signature length mismatched with index configuration"
230 );
231 return Vec::new();
232 }
233
234 let mut candidates = std::collections::HashSet::new();
235 for band_idx in 0..self.num_bands {
236 let start = band_idx * self.rows_per_band;
237 let band_hash = signature.band_hash(start, self.rows_per_band);
238
239 if let Some(bucket) = self.buckets[band_idx].get(&band_hash) {
240 for &doc_id in bucket {
241 if doc_id != signature.doc_id {
242 candidates.insert(doc_id);
243 }
244 }
245 }
246 }
247
248 candidates.into_iter().collect()
249 }
250
251 #[must_use]
253 pub fn verify_similarity(&self, doc_a: usize, doc_b: usize) -> Option<f64> {
254 let sig_a = self.signatures.get(&doc_a)?;
255 let sig_b = self.signatures.get(&doc_b)?;
256 Some(sig_a.similarity(sig_b))
257 }
258
259 #[instrument(skip(self), level = "debug")]
264 pub fn find_clusters(&mut self) -> &[DuplicateCluster] {
265 if !self.clusters.is_empty() {
266 return &self.clusters;
267 }
268
269 let mut parent: HashMap<usize, usize> =
276 self.signatures.keys().map(|&d| (d, d)).collect();
277
278 for (doc_id, signature) in &self.signatures {
279 let doc_id = *doc_id;
280
281 let candidates = self.query(signature);
283
284 for &candidate_id in &candidates {
285 if candidate_id <= doc_id {
286 continue; }
288
289 if uf_find(&mut parent, doc_id) == uf_find(&mut parent, candidate_id) {
292 continue;
293 }
294
295 if let Some(sim) = self.verify_similarity(doc_id, candidate_id) {
297 if sim >= self.threshold {
298 uf_union(&mut parent, doc_id, candidate_id);
299 }
300 }
301 }
302 }
303
304 let all_docs: Vec<usize> = self.signatures.keys().copied().collect();
309 let mut components: HashMap<usize, Vec<usize>> = HashMap::new();
310 for doc_id in all_docs {
311 let root = uf_find(&mut parent, doc_id);
312 components.entry(root).or_default().push(doc_id);
313 }
314 let mut groups: Vec<Vec<usize>> = components.into_values().collect();
315 for g in &mut groups {
316 g.sort_unstable();
317 }
318 groups.sort_unstable_by_key(|g| g[0]);
319
320 for cluster_docs in groups {
321 if cluster_docs.len() > 1 {
322 let mut cluster = DuplicateCluster::new(self.next_cluster_id, cluster_docs[0]);
324 self.doc_to_cluster.insert(cluster_docs[0], self.next_cluster_id);
325 for &doc in &cluster_docs[1..] {
326 cluster.add(doc);
327 self.doc_to_cluster.insert(doc, self.next_cluster_id);
328 }
329 self.clusters.push(cluster);
330 self.next_cluster_id += 1;
331 }
332 }
333
334 &self.clusters
335 }
336
337 #[must_use]
339 pub fn get_cluster_for_doc(&self, doc_id: usize) -> Option<&DuplicateCluster> {
340 let cluster_id = self.doc_to_cluster.get(&doc_id)?;
341 self.clusters.get(*cluster_id)
342 }
343
344 #[must_use]
346 pub fn is_duplicate(&self, doc_id: usize) -> bool {
347 self.doc_to_cluster.contains_key(&doc_id)
348 }
349
350 pub fn get_unique_indices(&self) -> Vec<usize> {
352 let mut unique: Vec<usize> = Vec::new();
353 let mut in_cluster = std::collections::HashSet::new();
354
355 for cluster in &self.clusters {
357 unique.push(cluster.representative);
358 for &idx in &cluster.indices {
359 in_cluster.insert(idx);
360 }
361 }
362
363 for &doc_id in self.signatures.keys() {
365 if !in_cluster.contains(&doc_id) {
366 unique.push(doc_id);
367 }
368 }
369
370 unique.sort_unstable();
371 unique
372 }
373
374 #[must_use]
376 pub const fn doc_count(&self) -> usize {
377 self.doc_count
378 }
379
380 #[must_use]
382 pub fn cluster_count(&self) -> usize {
383 self.clusters.len()
384 }
385
386 #[must_use]
388 pub fn duplicate_count(&self) -> usize {
389 self.clusters.iter().map(|c| c.len().saturating_sub(1)).sum()
390 }
391
392 pub fn stats(&self) -> LshStats {
394 let total_buckets: usize = self.buckets.iter().map(std::collections::HashMap::len).sum();
395 let total_entries: usize = self.buckets.iter().map(|b| b.values().map(std::vec::Vec::len).sum::<usize>()).sum();
396
397 LshStats {
398 num_bands: self.num_bands,
399 rows_per_band: self.rows_per_band,
400 threshold: self.threshold,
401 doc_count: self.doc_count,
402 total_buckets,
403 total_entries,
404 avg_bucket_size: if total_buckets > 0 {
405 total_entries as f64 / total_buckets as f64
406 } else {
407 0.0
408 },
409 cluster_count: self.clusters.len(),
410 duplicate_count: self.duplicate_count(),
411 }
412 }
413
414 #[must_use]
416 pub fn memory_usage(&self) -> usize {
417 let signature_bytes = self.signatures.len() * (std::mem::size_of::<usize>() + std::mem::size_of::<MinHashSignature>() + 32); let bucket_bytes: usize = self.buckets.iter()
419 .map(|b| {
420 b.capacity() * (std::mem::size_of::<u64>() + std::mem::size_of::<Vec<usize>>()) +
421 b.values().map(|v| v.capacity() * std::mem::size_of::<usize>()).sum::<usize>()
422 })
423 .sum();
424 let cluster_bytes = self.clusters.len() * std::mem::size_of::<DuplicateCluster>();
425
426 signature_bytes + bucket_bytes + cluster_bytes
427 }
428}
429
430fn uf_find(parent: &mut HashMap<usize, usize>, x: usize) -> usize {
436 let mut root = x;
437 while let Some(&p) = parent.get(&root) {
438 if p == root {
439 break;
440 }
441 root = p;
442 }
443 let mut cur = x;
445 while let Some(&p) = parent.get(&cur) {
446 if p == root {
447 break;
448 }
449 parent.insert(cur, root);
450 cur = p;
451 }
452 root
453}
454
455fn uf_union(parent: &mut HashMap<usize, usize>, a: usize, b: usize) {
458 let ra = uf_find(parent, a);
459 let rb = uf_find(parent, b);
460 if ra != rb {
461 let (keep, drop) = if ra < rb { (ra, rb) } else { (rb, ra) };
462 parent.insert(drop, keep);
463 }
464}
465
466pub mod stats;
467#[cfg(test)]
468mod tests;
469
470pub use stats::LshStats;