Skip to main content

sqz_engine/
minhash_lsh.rs

1//! MinHash + LSH — Locality-Sensitive Hashing for O(1) near-duplicate detection.
2//!
3//! Uses MinHash signature generation from character-level 3-gram shingles with
4//! 64 hash functions, and LSH banding (8 bands × 8 rows) for fast candidate
5//! retrieval. Documents with high Jaccard similarity will hash to the same
6//! bucket in at least one band with high probability.
7//!
8//! # Example
9//! ```
10//! use sqz_engine::minhash_lsh::MinHashLsh;
11//!
12//! let mut lsh = MinHashLsh::new();
13//! lsh.insert(1, "the quick brown fox jumps over the lazy dog");
14//! lsh.insert(2, "the quick brown fox leaps over the lazy dog");
15//! lsh.insert(3, "completely unrelated content about quantum physics");
16//!
17//! let candidates = lsh.query("the quick brown fox jumps over the lazy dog");
18//! assert!(candidates.contains(&1));
19//! ```
20
21use std::collections::hash_map::DefaultHasher;
22use std::collections::{HashMap, HashSet};
23use std::hash::{Hash, Hasher};
24
25/// Number of hash functions in the MinHash signature.
26const NUM_HASHES: usize = 64;
27/// Number of bands for LSH banding.
28const NUM_BANDS: usize = 8;
29/// Number of rows per band (NUM_HASHES / NUM_BANDS).
30const ROWS_PER_BAND: usize = NUM_HASHES / NUM_BANDS;
31/// Default shingle size (character n-grams).
32const SHINGLE_SIZE: usize = 3;
33
34/// A MinHash signature — a vector of minimum hash values.
35#[derive(Debug, Clone, PartialEq, Eq)]
36pub struct MinHashSignature {
37    /// The minimum hash values, one per hash function.
38    pub values: Vec<u64>,
39}
40
41impl MinHashSignature {
42    /// Estimate Jaccard similarity between two signatures.
43    ///
44    /// Returns a value in `[0.0, 1.0]` where 1.0 means identical signatures.
45    pub fn jaccard_similarity(&self, other: &MinHashSignature) -> f64 {
46        if self.values.len() != other.values.len() || self.values.is_empty() {
47            return 0.0;
48        }
49        let matches = self
50            .values
51            .iter()
52            .zip(other.values.iter())
53            .filter(|(a, b)| a == b)
54            .count();
55        matches as f64 / self.values.len() as f64
56    }
57}
58
59/// MinHash + LSH index for near-duplicate detection.
60///
61/// Supports `insert` to add documents and `query` to find candidates
62/// that are likely near-duplicates of a given text.
63pub struct MinHashLsh {
64    /// Band → bucket → set of document IDs.
65    bands: Vec<HashMap<u64, HashSet<u64>>>,
66    /// Document ID → MinHash signature (for optional similarity refinement).
67    signatures: HashMap<u64, MinHashSignature>,
68}
69
70impl Default for MinHashLsh {
71    fn default() -> Self {
72        Self::new()
73    }
74}
75
76impl MinHashLsh {
77    /// Create a new empty MinHash LSH index.
78    pub fn new() -> Self {
79        let bands = (0..NUM_BANDS).map(|_| HashMap::new()).collect();
80        Self {
81            bands,
82            signatures: HashMap::new(),
83        }
84    }
85
86    /// Insert a document into the index.
87    ///
88    /// `doc_id` is a unique identifier for the document.
89    /// `text` is the document content to be shingled and hashed.
90    pub fn insert(&mut self, doc_id: u64, text: &str) {
91        let sig = Self::compute_signature(text);
92        self.insert_into_bands(doc_id, &sig);
93        self.signatures.insert(doc_id, sig);
94    }
95
96    /// Query the index for candidate near-duplicates of `text`.
97    ///
98    /// Returns a list of document IDs that share at least one LSH band
99    /// bucket with the query. These are *candidates* — false positives
100    /// are possible but false negatives are unlikely for high-similarity
101    /// pairs.
102    pub fn query(&self, text: &str) -> Vec<u64> {
103        let sig = Self::compute_signature(text);
104        let mut candidates = HashSet::new();
105
106        for (band_idx, band_map) in self.bands.iter().enumerate() {
107            let band_hash = Self::hash_band(&sig, band_idx);
108            if let Some(doc_ids) = band_map.get(&band_hash) {
109                for &id in doc_ids {
110                    candidates.insert(id);
111                }
112            }
113        }
114
115        let mut result: Vec<u64> = candidates.into_iter().collect();
116        result.sort_unstable();
117        result
118    }
119
120    /// Compute the MinHash signature for a text string.
121    pub fn compute_signature(text: &str) -> MinHashSignature {
122        let shingles = Self::shingle(text);
123        if shingles.is_empty() {
124            return MinHashSignature {
125                values: vec![u64::MAX; NUM_HASHES],
126            };
127        }
128
129        let mut min_hashes = vec![u64::MAX; NUM_HASHES];
130
131        for shingle in &shingles {
132            for i in 0..NUM_HASHES {
133                let h = Self::hash_with_seed(shingle, i as u64);
134                if h < min_hashes[i] {
135                    min_hashes[i] = h;
136                }
137            }
138        }
139
140        MinHashSignature { values: min_hashes }
141    }
142
143    /// Get the stored signature for a document, if it exists.
144    pub fn get_signature(&self, doc_id: u64) -> Option<&MinHashSignature> {
145        self.signatures.get(&doc_id)
146    }
147
148    /// Return the number of documents in the index.
149    pub fn len(&self) -> usize {
150        self.signatures.len()
151    }
152
153    /// Return true if the index is empty.
154    pub fn is_empty(&self) -> bool {
155        self.signatures.is_empty()
156    }
157
158    // ── Internal helpers ──────────────────────────────────────────────────
159
160    /// Generate character-level n-gram shingles from text.
161    fn shingle(text: &str) -> HashSet<&str> {
162        let starts = text.char_indices().map(|(i, _)| i);
163        let ends = text.char_indices().map(|(i, _)| i).chain([text.len()]).skip(SHINGLE_SIZE);
164        let mut set = HashSet::new();
165        for (start, end) in starts.zip(ends) {
166            set.insert(&text[start..end]);
167        }
168        if set.is_empty() && !text.is_empty() {
169            set.insert(text);
170        }
171        set
172    }
173
174    /// Hash a shingle with a seed to produce one of the NUM_HASHES hash values.
175    fn hash_with_seed(shingle: &str, seed: u64) -> u64 {
176        let mut hasher = DefaultHasher::new();
177        seed.hash(&mut hasher);
178        shingle.hash(&mut hasher);
179        hasher.finish()
180    }
181
182    /// Hash a band (a slice of the signature) to a single bucket key.
183    fn hash_band(sig: &MinHashSignature, band_idx: usize) -> u64 {
184        let start = band_idx * ROWS_PER_BAND;
185        let end = start + ROWS_PER_BAND;
186        let mut hasher = DefaultHasher::new();
187        for &val in &sig.values[start..end] {
188            val.hash(&mut hasher);
189        }
190        hasher.finish()
191    }
192
193    /// Insert a document's signature into all band buckets.
194    fn insert_into_bands(&mut self, doc_id: u64, sig: &MinHashSignature) {
195        for band_idx in 0..NUM_BANDS {
196            let band_hash = Self::hash_band(sig, band_idx);
197            self.bands[band_idx]
198                .entry(band_hash)
199                .or_default()
200                .insert(doc_id);
201        }
202    }
203}
204
205// ── Tests ─────────────────────────────────────────────────────────────────────
206
207#[cfg(test)]
208mod tests {
209    use super::*;
210
211    #[test]
212    fn test_identical_documents_are_candidates() {
213        let mut lsh = MinHashLsh::new();
214        lsh.insert(1, "the quick brown fox jumps over the lazy dog");
215        let candidates = lsh.query("the quick brown fox jumps over the lazy dog");
216        assert!(
217            candidates.contains(&1),
218            "identical document should be a candidate"
219        );
220    }
221
222    #[test]
223    fn test_similar_documents_are_candidates() {
224        let mut lsh = MinHashLsh::new();
225        lsh.insert(1, "the quick brown fox jumps over the lazy dog and more text here");
226        lsh.insert(2, "the quick brown fox leaps over the lazy dog and more text here");
227        // Query with text very similar to doc 1
228        let candidates = lsh.query("the quick brown fox jumps over the lazy dog and more text here");
229        assert!(
230            candidates.contains(&1),
231            "identical document should always be a candidate"
232        );
233    }
234
235    #[test]
236    fn test_empty_index_returns_no_candidates() {
237        let lsh = MinHashLsh::new();
238        let candidates = lsh.query("anything");
239        assert!(candidates.is_empty());
240    }
241
242    #[test]
243    fn test_empty_text_insert_and_query() {
244        let mut lsh = MinHashLsh::new();
245        lsh.insert(1, "");
246        let candidates = lsh.query("");
247        // Empty text should still match itself (both produce the same max-value signature)
248        assert!(candidates.contains(&1));
249    }
250
251    #[test]
252    fn test_signature_jaccard_identical() {
253        let sig = MinHashLsh::compute_signature("hello world foo bar baz");
254        let similarity = sig.jaccard_similarity(&sig);
255        assert!(
256            (similarity - 1.0).abs() < f64::EPSILON,
257            "identical signatures should have Jaccard similarity 1.0"
258        );
259    }
260
261    #[test]
262    fn test_signature_jaccard_different() {
263        let sig_a = MinHashLsh::compute_signature(
264            "the quick brown fox jumps over the lazy dog and some extra padding text",
265        );
266        let sig_b = MinHashLsh::compute_signature(
267            "completely different content about quantum physics and mathematics research",
268        );
269        let similarity = sig_a.jaccard_similarity(&sig_b);
270        assert!(
271            similarity < 1.0,
272            "different texts should have Jaccard similarity < 1.0, got {similarity}"
273        );
274    }
275
276    #[test]
277    fn test_multiple_inserts_and_query() {
278        let mut lsh = MinHashLsh::new();
279        for i in 0..10 {
280            lsh.insert(i, &format!("document number {i} with some shared content here"));
281        }
282        assert_eq!(lsh.len(), 10);
283        assert!(!lsh.is_empty());
284
285        let candidates = lsh.query("document number 5 with some shared content here");
286        assert!(
287            candidates.contains(&5),
288            "exact match should be a candidate"
289        );
290    }
291
292    #[test]
293    fn test_get_signature() {
294        let mut lsh = MinHashLsh::new();
295        lsh.insert(42, "test document");
296        assert!(lsh.get_signature(42).is_some());
297        assert!(lsh.get_signature(99).is_none());
298    }
299
300    #[test]
301    fn test_shingles_hold_only_distinct_windows() {
302        let repeated = "ab".repeat(4096);
303        let shingles = MinHashLsh::shingle(&repeated);
304        assert_eq!(shingles.len(), 2);
305        assert!(shingles.capacity() < 64, "capacity {}", shingles.capacity());
306        for text in ["", "a", "日本", "abc", "héllo wörld", "日本語のテキスト、句読点"] {
307            let chars: Vec<char> = text.chars().collect();
308            let mut want: HashSet<String> =
309                chars.windows(SHINGLE_SIZE).map(|w| w.iter().collect()).collect();
310            if want.is_empty() && !text.is_empty() {
311                want.insert(text.to_owned());
312            }
313            let got: HashSet<String> =
314                MinHashLsh::shingle(text).iter().map(|s| s.to_string()).collect();
315            assert_eq!(got, want, "{text:?}");
316        }
317    }
318
319    // ── Property-based tests ──────────────────────────────────────────────
320
321    use proptest::prelude::*;
322
323    proptest! {
324        /// Identical texts must always produce the same MinHash signature.
325        #[test]
326        fn prop_identical_texts_same_signature(text in "[a-z ]{5,100}") {
327            let sig_a = MinHashLsh::compute_signature(&text);
328            let sig_b = MinHashLsh::compute_signature(&text);
329            prop_assert_eq!(sig_a, sig_b);
330        }
331
332        /// Jaccard similarity of identical signatures is always 1.0.
333        #[test]
334        fn prop_self_similarity_is_one(text in "[a-z ]{5,100}") {
335            let sig = MinHashLsh::compute_signature(&text);
336            let sim = sig.jaccard_similarity(&sig);
337            prop_assert!((sim - 1.0).abs() < f64::EPSILON);
338        }
339
340        /// Jaccard similarity is always in [0.0, 1.0].
341        #[test]
342        fn prop_jaccard_bounded(a in "[a-z ]{5,100}", b in "[a-z ]{5,100}") {
343            let sig_a = MinHashLsh::compute_signature(&a);
344            let sig_b = MinHashLsh::compute_signature(&b);
345            let sim = sig_a.jaccard_similarity(&sig_b);
346            prop_assert!(sim >= 0.0 && sim <= 1.0, "similarity out of bounds: {sim}");
347        }
348
349        /// An inserted document is always returned as a candidate for its own text.
350        #[test]
351        fn prop_self_query_returns_self(text in "[a-z ]{5,100}", id in 1u64..10000) {
352            let mut lsh = MinHashLsh::new();
353            lsh.insert(id, &text);
354            let candidates = lsh.query(&text);
355            prop_assert!(
356                candidates.contains(&id),
357                "document {id} should be a candidate for its own text"
358            );
359        }
360    }
361}