Skip to main content

dedup/lsh/
mod.rs

1//! Locality Sensitive Hashing (LSH) for efficient candidate pair detection.
2//!
3//! LSH bands the MinHash signature such that similar documents are likely to
4//! collide in at least one bucket. This reduces the O(n²) pairwise comparison
5//! to O(n) bucket lookups.
6
7use 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
16/// LSH index for finding candidate similar document pairs.
17///
18/// Documents are inserted into buckets based on their band hashes.
19/// Documents in the same bucket are candidate pairs for similarity checking.
20pub struct LshIndex {
21    /// Number of bands.
22    num_bands: usize,
23    /// Rows per band.
24    rows_per_band: usize,
25    /// Similarity threshold.
26    threshold: f64,
27    /// Bucket storage: band_index -> bucket_hash -> doc_indices
28    buckets: Vec<HashMap<u64, Vec<usize>>>,
29    /// All signatures stored for verification.
30    signatures: std::collections::BTreeMap<usize, MinHashSignature>,
31    /// Document count.
32    doc_count: usize,
33    /// Duplicate clusters found.
34    clusters: Vec<DuplicateCluster>,
35    /// Map from doc index to cluster id.
36    doc_to_cluster: HashMap<usize, usize>,
37    /// Next cluster ID.
38    next_cluster_id: usize,
39    /// Maximum document ID seen (tracks range of document IDs).
40    max_doc_id: usize,
41}
42
43impl LshIndex {
44    /// Create a new LSH index from configuration.
45    ///
46    /// # Errors
47    ///
48    /// Returns an error if the configuration has incompatible parameters.
49    #[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    /// Clear all indexed documents and derived cluster state, keeping the band
85    /// and row structure and the similarity threshold.
86    ///
87    /// This is the infallible way to empty an index for reuse. Unlike rebuilding
88    /// via [`Self::new`] (which is fallible and, when a caller swallowed its
89    /// error, could leave a stale populated index while surrounding state was
90    /// reset), `clear()` cannot fail and cannot leave the index half-reset.
91    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    /// Insert a signature into the LSH index.
104    ///
105    /// Returns a list of candidate similar document indices that collided
106    /// in at least one bucket.
107    ///
108    /// # Errors
109    ///
110    /// Returns [`Error::InvalidConfig`] when `doc_id` exceeds the configured maximum
111    /// (prevents unbounded allocation from adversarial identifiers).
112    #[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        // Guard against adversarial doc_ids that would cause unbounded allocation.
117        // 100M documents × ~1KB per signature = ~100GB, which is the practical limit.
118        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        // Reject malformed signatures. band_hash slices [start, start+rows) and
127        // silently CLAMPS an out-of-range end, so a signature shorter than
128        // num_bands * rows_per_band would be indexed under clamped/overlapping
129        // band hashes, corrupting candidate recall without any error. Validate the
130        // length up front instead.
131        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        // Determine whether a signature was present before
146        let had_signature = self.signatures.contains_key(&doc_id);
147
148        // If there was an old signature, remove this doc_id from its buckets
149        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        // Store the new signature and update counts
165        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        // Index changed: invalidate any cached clusters
172        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 each band, compute bucket hash and find collisions
181        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                    // Add all existing documents as candidates
190                    for &existing_id in entry.get() {
191                        if existing_id != doc_id {
192                            candidates.insert(existing_id);
193                        }
194                    }
195                    // Cap at 10K entries per bucket to prevent OOM from hash collisions.
196                    //
197                    // The old `!entry.get().contains(&doc_id)` guard was a linear
198                    // O(bucket) scan on EVERY insert, making insertion into large
199                    // colliding buckets O(N^2). It was also redundant: `doc_id`
200                    // cannot already be in this bucket here - a fresh doc was never
201                    // inserted, and a re-inserted doc_id had its old band entries
202                    // removed by the `had_signature` cleanup above (lines ~130-143),
203                    // so within one band it is pushed at most once per insert. Only
204                    // the size cap remains, keeping the push O(1).
205                    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    /// Query for candidate similar documents.
220    ///
221    /// Returns document indices that collided with the given signature
222    /// in at least one LSH bucket.
223    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    /// Verify similarity between two documents using their signatures.
252    #[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    /// Find all duplicate clusters in the index.
260    ///
261    /// This performs pairwise verification of all candidate pairs
262    /// and groups documents into clusters.
263    #[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        // Path-compressed union-find over doc ids. Skipping verify_similarity
270        // for pairs already in the same component prunes the redundant all-pairs
271        // work in dense buckets (toward near-linear vs O(n^2)) while producing
272        // identical connected components: every component-MERGING edge is still
273        // verified (find differs -> verify -> union); only redundant intra-
274        // component edges are skipped, which cannot change the components.
275        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            // Get candidates from LSH
282            let candidates = self.query(signature);
283
284            for &candidate_id in &candidates {
285                if candidate_id <= doc_id {
286                    continue; // Avoid duplicate checking
287                }
288
289                // Already in the same component: this edge is redundant for
290                // connected components, so skip the similarity computation.
291                if uf_find(&mut parent, doc_id) == uf_find(&mut parent, candidate_id) {
292                    continue;
293                }
294
295                // Verify actual similarity, and union on a real edge.
296                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        // Group doc ids by their component root. Sort each group and order the
305        // groups by their minimum member so cluster ids are assigned
306        // deterministically (the previous HashMap-seeded BFS numbered clusters
307        // in nondeterministic order; the partition itself is unchanged).
308        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                // Minimum doc_id is the representative (deterministic).
323                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    /// Get the cluster for a document index.
338    #[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    /// Check if a document is a duplicate (belongs to any cluster).
345    #[must_use]
346    pub fn is_duplicate(&self, doc_id: usize) -> bool {
347        self.doc_to_cluster.contains_key(&doc_id)
348    }
349
350    /// Get all unique documents (first in each cluster + non-duplicate documents).
351    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        // Add representatives from clusters
356        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        // Add non-clustered documents (only those that actually have signatures)
364        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    /// Get the total number of documents indexed.
375    #[must_use]
376    pub const fn doc_count(&self) -> usize {
377        self.doc_count
378    }
379
380    /// Get the number of duplicate clusters.
381    #[must_use]
382    pub fn cluster_count(&self) -> usize {
383        self.clusters.len()
384    }
385
386    /// Get the number of duplicate documents (documents in clusters, excluding representatives).
387    #[must_use]
388    pub fn duplicate_count(&self) -> usize {
389        self.clusters.iter().map(|c| c.len().saturating_sub(1)).sum()
390    }
391
392    /// Get statistics about the LSH index.
393    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    /// Estimate memory usage in bytes.
415    #[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); // Approximate BTreeMap overhead
418        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
430/// Path-compressed union-find `find` over a doc-id -> parent map.
431///
432/// Returns the component root of `x`, compressing the path so future lookups
433/// are near-constant. A doc id absent from `parent` is treated as its own root
434/// (it never panics on a missing key).
435fn 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    // Point every node on the path directly at the root.
444    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
455/// Union the components of `a` and `b`, keeping the smaller root as the
456/// representative so component roots stay deterministic across runs.
457fn 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;