Skip to main content

dedup/
minhash.rs

1//! MinHash signature computation.
2//!
3//! MinHash is a technique for quickly estimating how similar two sets are.
4//! It compresses large sets into small signatures while preserving Jaccard
5//! similarity, enabling efficient near-duplicate detection.
6
7use std::collections::HashSet;
8
9use crate::config::Config;
10use crate::error::{Error, Result};
11use crate::shingle::HashedShingleIterator;
12use crate::fast_hash::FastHasher;
13use tracing::{instrument, warn};
14
15/// A MinHash signature for a document.
16///
17/// The signature is a vector of hash values (typically 64-256 values).
18/// Similar documents will have similar signatures.
19#[derive(Debug, Clone, PartialEq, Eq, Hash)]
20pub struct MinHashSignature {
21    /// The hash values forming this signature.
22    pub values: Vec<u32>,
23    /// Document index (if part of a collection).
24    pub doc_id: usize,
25}
26
27impl MinHashSignature {
28    /// Create a new signature from raw values.
29    pub fn new(values: Vec<u32>, doc_id: usize) -> Self {
30        Self { values, doc_id }
31    }
32
33    /// Compute estimated Jaccard similarity with another signature.
34    ///
35    /// The similarity is the fraction of hash values that match between
36    /// the two signatures. This approximates the true Jaccard similarity
37    /// of the original sets.
38    #[must_use]
39    pub fn similarity(&self, other: &Self) -> f64 {
40
41        if self.values.len() != other.values.len() || self.values.is_empty() {
42            warn!(
43                self_len = self.values.len(),
44                other_len = other.values.len(),
45                "MinHashSignature::similarity called with mismatched or empty signature lengths"
46            );
47            return 0.0;
48        }
49
50        let matches = self
51            .values
52            .iter()
53            .zip(&other.values)
54            .filter(|(a, b)| a == b)
55            .count();
56
57        matches as f64 / self.values.len() as f64
58    }
59
60    /// Get a band of the signature for LSH.
61    ///
62    /// Returns the slice `values[start .. start + length]`, gracefully clamped
63    /// to the signature bounds: a `start` at or past the end yields an empty
64    /// slice, and an over-long `length` is truncated to the available tail. It
65    /// never panics.
66    ///
67    /// This clamp is safe (not a silent recall loss) because the callers that
68    /// require exact band tiling, the [`LshIndex`](crate::lsh::LshIndex),
69    /// validate `signature.len() == num_bands * rows_per_band` before slicing,
70    /// so a band is never silently shortened on the indexing path.
71    #[must_use]
72    pub fn band(&self, start: usize, length: usize) -> &[u32] {
73        let end = start.saturating_add(length).min(self.values.len());
74        &self.values[start.min(self.values.len())..end]
75    }
76
77    /// Compute a band hash for LSH bucketing.
78    ///
79    /// This combines all values in a band into a single hash value
80    /// that can be used as a bucket key.
81    #[must_use]
82    pub fn band_hash(&self, start: usize, length: usize) -> u64 {
83        let band = self.band(start, length);
84        
85        // FNV-1a inspired hash combination
86        let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
87        for &value in band {
88            hash ^= u64::from(value);
89            hash = hash.wrapping_mul(0x0100_0000_01b3);
90        }
91        hash
92    }
93
94    /// Get the number of hash values in this signature.
95    #[must_use]
96    pub fn len(&self) -> usize {
97        self.values.len()
98    }
99
100    /// Check if this signature is empty.
101    #[must_use]
102    pub fn is_empty(&self) -> bool {
103        self.values.is_empty()
104    }
105}
106
107/// MinHasher computes MinHash signatures from documents.
108///
109/// This struct maintains the hash function coefficients and provides
110/// methods to compute signatures from raw bytes or strings.
111pub struct MinHasher {
112    /// The fast hash function for MinHash computation.
113    hasher: FastHasher,
114    /// Shingle size (k-gram length).
115    shingle_size: usize,
116    /// Signature size (number of hash functions).
117    signature_size: usize,
118}
119
120impl MinHasher {
121    /// Create a new MinHasher from a configuration.
122    ///
123    /// # Errors
124    ///
125    /// Returns an error if the configuration is invalid.
126    #[instrument(skip(config), level = "debug")]
127    pub fn new(config: &Config) -> Result<Self> {
128        Ok(Self {
129            hasher: FastHasher::new(config.signature_size, config.seed),
130            shingle_size: config.shingle_size,
131            signature_size: config.signature_size,
132        })
133    }
134
135    /// Compute MinHash signature for a byte sequence.
136    ///
137    /// # Errors
138    ///
139    /// Returns an error if the document is empty or too large.
140    #[instrument(skip(self, data), fields(doc_id, data_len = data.len()), level = "debug")]
141    pub fn compute(&self, data: &[u8], doc_id: usize) -> Result<MinHashSignature> {
142        if data.is_empty() {
143            warn!(doc_id, "empty document");
144            return Err(Error::EmptyDocument { index: doc_id });
145        }
146
147        // Initialize signature with maximum values (we'll take minimum)
148        let mut signature = vec![u32::MAX; self.signature_size];
149
150        // Iterate over all shingles and update signature
151        let shingle_iter = HashedShingleIterator::new(data, self.shingle_size);
152        
153        if shingle_iter.len() == 0 {
154            warn!(doc_id, shingle_size = self.shingle_size, "document too short for shingle size");
155            return Err(Error::EmptyDocument { index: doc_id });
156        }
157
158        for shingle_hash in shingle_iter {
159            self.hasher.update_signature(&mut signature, shingle_hash);
160        }
161
162        Ok(MinHashSignature::new(signature, doc_id))
163    }
164
165    /// Compute signature for a string.
166    ///
167    /// # Errors
168    ///
169    /// Returns an error if the document is empty or too large.
170    #[instrument(skip(self, text), fields(doc_id, text_len = text.len()), level = "debug")]
171    pub fn compute_str(&self, text: &str, doc_id: usize) -> Result<MinHashSignature> {
172        self.compute(text.as_bytes(), doc_id)
173    }
174
175    /// Compute signatures for multiple documents in batch.
176    ///
177    /// This is more efficient than calling `compute` repeatedly
178    /// due to better cache utilization.
179    pub fn compute_batch(&self, documents: &[&[u8]], start_id: usize) -> Vec<Result<MinHashSignature>> {
180        documents
181            .iter()
182            .enumerate()
183            .map(|(idx, doc)| match start_id.checked_add(idx) {
184                Some(doc_id) => self.compute(doc, doc_id),
185                // Overflow used to panic in debug and silently wrap (aliasing
186                // doc 0) in release. Report the offending entry instead; the
187                // rest of the batch still computes.
188                None => Err(Error::InvalidConfig {
189                    reason: format!(
190                        "doc_id overflow: start_id {start_id} plus batch index {idx} exceeds usize::MAX"
191                    ),
192                    fix: "use a smaller start_id or split the batch".to_string(),
193                }),
194            })
195            .collect()
196    }
197
198    /// Compute signature from pre-hashed shingles.
199    ///
200    /// Useful when shingles are computed elsewhere or cached.
201    pub fn compute_from_hashed_shingles(
202        &self,
203        shingle_hashes: &[u64],
204        doc_id: usize,
205    ) -> MinHashSignature {
206        let mut signature = vec![u32::MAX; self.signature_size];
207
208        for &shingle_hash in shingle_hashes {
209            self.hasher.update_signature(&mut signature, shingle_hash);
210        }
211
212        MinHashSignature::new(signature, doc_id)
213    }
214
215    /// Get the signature size.
216    #[must_use]
217    pub const fn signature_size(&self) -> usize {
218        self.signature_size
219    }
220
221    /// Get the shingle size.
222    #[must_use]
223    pub const fn shingle_size(&self) -> usize {
224        self.shingle_size
225    }
226}
227
228/// Compute exact Jaccard similarity between two sets.
229#[must_use]
230pub fn exact_jaccard_similarity<T: Ord + Clone + std::hash::Hash>(a: &[T], b: &[T]) -> f64 {
231    if a.is_empty() && b.is_empty() {
232        return 1.0;
233    }
234    if a.is_empty() || b.is_empty() {
235        return 0.0;
236    }
237
238    let set_a: HashSet<_> = a.iter().cloned().collect();
239    let set_b: HashSet<_> = b.iter().cloned().collect();
240
241    let intersection: HashSet<_> = set_a.intersection(&set_b).collect();
242    let union: HashSet<_> = set_a.union(&set_b).collect();
243
244    intersection.len() as f64 / union.len() as f64
245}
246
247/// Estimate the expected error of MinHash estimation.
248///
249/// The variance of MinHash similarity estimation is approximately:
250/// Var(ŝ) ≈ s(1-s)/k where s is true similarity and k is signature size.
251#[must_use]
252pub fn expected_error(similarity: f64, signature_size: usize) -> f64 {
253    let s = similarity.clamp(0.0, 1.0);
254    let k = signature_size as f64;
255    (s * (1.0 - s) / k).sqrt()
256}
257
258#[cfg(test)]
259mod tests {
260    use super::*;
261    use crate::config::Config;
262
263    fn create_hasher() -> MinHasher {
264        let config = Config::default();
265        MinHasher::new(&config).unwrap()
266    }
267
268    #[test]
269    fn minhash_signature_similarity_perfect() {
270        let sig1 = MinHashSignature::new(vec![1, 2, 3, 4, 5], 0);
271        let sig2 = MinHashSignature::new(vec![1, 2, 3, 4, 5], 1);
272        
273        assert!((sig1.similarity(&sig2) - 1.0).abs() < f64::EPSILON);
274    }
275
276    #[test]
277    fn minhash_signature_similarity_zero() {
278        let sig1 = MinHashSignature::new(vec![1, 2, 3, 4, 5], 0);
279        let sig2 = MinHashSignature::new(vec![6, 7, 8, 9, 10], 1);
280        
281        assert!((sig1.similarity(&sig2) - 0.0).abs() < f64::EPSILON);
282    }
283
284    #[test]
285    fn minhash_signature_similarity_partial() {
286        let sig1 = MinHashSignature::new(vec![1, 2, 3, 4, 5], 0);
287        let sig2 = MinHashSignature::new(vec![1, 2, 8, 9, 10], 1);
288        
289        // 2 out of 5 match = 0.4
290        assert!((sig1.similarity(&sig2) - 0.4).abs() < f64::EPSILON);
291    }
292
293    #[test]
294    fn band_extraction() {
295        let sig = MinHashSignature::new(vec![1, 2, 3, 4, 5, 6, 7, 8], 0);
296        let band = sig.band(2, 3);
297        assert_eq!(band, &[3, 4, 5]);
298    }
299
300    #[test]
301    fn band_hash_deterministic() {
302        let sig = MinHashSignature::new(vec![1, 2, 3, 4, 5], 0);
303        let h1 = sig.band_hash(0, 3);
304        let h2 = sig.band_hash(0, 3);
305        assert_eq!(h1, h2);
306    }
307
308    /// Regression: `compute_batch` assigned doc ids with `start_id + idx`,
309    /// which panics in debug builds and silently wraps in release when
310    /// `start_id` is near usize::MAX, aliasing later documents onto doc id 0.
311    /// The overflowing entry must now surface as an error while the rest of
312    /// the batch still computes.
313    #[test]
314    fn compute_batch_reports_doc_id_overflow() {
315        let hasher = create_hasher();
316        let docs: &[&[u8]] = &[b"first document", b"second document"];
317        let results = hasher.compute_batch(docs, usize::MAX);
318
319        assert_eq!(results.len(), 2);
320        let first = results[0].as_ref().expect("index 0 fits at usize::MAX");
321        assert_eq!(first.doc_id, usize::MAX);
322        let err = results[1].as_ref().expect_err("index 1 must overflow");
323        assert!(
324            err.to_string().contains("doc_id overflow"),
325            "error names the overflow: {err}"
326        );
327    }
328
329    #[test]
330    fn band_hash_different_bands_different() {
331        let sig = MinHashSignature::new(vec![1, 2, 3, 4, 5, 6], 0);
332        let h1 = sig.band_hash(0, 3);
333        let h2 = sig.band_hash(3, 3);
334        assert_ne!(h1, h2);
335    }
336
337    #[test]
338    fn compute_signature_for_document() {
339        let hasher = create_hasher();
340        let doc = b"hello world this is a test document";
341        let sig = hasher.compute(doc, 0).unwrap();
342        
343        assert_eq!(sig.len(), 128); // Default signature size
344    }
345
346    #[test]
347    fn similar_documents_have_similar_signatures() {
348        let hasher = create_hasher();
349        
350        let doc1 = b"hello world this is a test document";
351        let doc2 = b"hello world this is a test document with extra words";
352        
353        let sig1 = hasher.compute(doc1, 0).unwrap();
354        let sig2 = hasher.compute(doc2, 1).unwrap();
355        
356        let similarity = sig1.similarity(&sig2);
357        // Similar documents should have > 0.5 estimated similarity
358        assert!(similarity > 0.5, "similarity was {}", similarity);
359    }
360
361    #[test]
362    fn different_documents_have_low_similarity() {
363        let hasher = create_hasher();
364        
365        let doc1 = b"the quick brown fox jumps over the lazy dog";
366        let doc2 = b"completely different content about various topics";
367        
368        let sig1 = hasher.compute(doc1, 0).unwrap();
369        let sig2 = hasher.compute(doc2, 1).unwrap();
370        
371        let similarity = sig1.similarity(&sig2);
372        // Different documents should have low similarity
373        assert!(similarity < 0.3, "similarity was {}", similarity);
374    }
375
376    #[test]
377    fn empty_document_errors() {
378        let hasher = create_hasher();
379        let result = hasher.compute(b"", 0);
380        assert!(result.is_err());
381    }
382
383    #[test]
384    fn document_too_short_for_shingle_size() {
385        let hasher = create_hasher(); // Default shingle_size = 5
386        let result = hasher.compute(b"hi", 0);
387        assert!(result.is_err());
388    }
389
390    #[test]
391    fn compute_str_works() {
392        let hasher = create_hasher();
393        let sig = hasher.compute_str("hello world", 0).unwrap();
394        assert_eq!(sig.len(), 128);
395    }
396
397    #[test]
398    fn batch_compute() {
399        let hasher = create_hasher();
400        let docs: Vec<&[u8]> = vec![
401            b"document one content",
402            b"document two content",
403            b"document three content",
404        ];
405        
406        let results = hasher.compute_batch(&docs, 0);
407        assert_eq!(results.len(), 3);
408        assert!(results.iter().all(|r| r.is_ok()));
409    }
410
411    #[test]
412    fn compute_from_hashed_shingles() {
413        let hasher = create_hasher();
414        let shingles = vec![1_u64, 2, 3, 4, 5];
415        let sig = hasher.compute_from_hashed_shingles(&shingles, 0);
416        
417        assert_eq!(sig.len(), 128);
418    }
419
420    #[test]
421    fn exact_jaccard_identical_sets() {
422        let a = vec![1, 2, 3];
423        let b = vec![1, 2, 3];
424        assert!((exact_jaccard_similarity(&a, &b) - 1.0).abs() < f64::EPSILON);
425    }
426
427    #[test]
428    fn exact_jaccard_disjoint_sets() {
429        let a = vec![1, 2, 3];
430        let b = vec![4, 5, 6];
431        assert!((exact_jaccard_similarity(&a, &b) - 0.0).abs() < f64::EPSILON);
432    }
433
434    #[test]
435    fn exact_jaccard_overlapping_sets() {
436        let a = vec![1, 2, 3];
437        let b = vec![2, 3, 4];
438        // Intersection = {2, 3}, Union = {1, 2, 3, 4}
439        assert!((exact_jaccard_similarity(&a, &b) - 0.5).abs() < f64::EPSILON);
440    }
441
442    #[test]
443    fn expected_error_bounds() {
444        // At similarity 0.5, error should be highest
445        let err_mid = expected_error(0.5, 100);
446        let err_low = expected_error(0.1, 100);
447        let err_high = expected_error(0.9, 100);
448        
449        assert!(err_mid > err_low);
450        assert!(err_mid > err_high);
451    }
452
453    #[test]
454    fn signature_is_empty() {
455        let sig = MinHashSignature::new(vec![], 0);
456        assert!(sig.is_empty());
457        
458        let sig = MinHashSignature::new(vec![1, 2, 3], 0);
459        assert!(!sig.is_empty());
460    }
461
462    #[test]
463    fn minhash_preserves_similarity() {
464        // Test that MinHash approximates Jaccard similarity
465        let hasher = create_hasher();
466        
467        // Create two documents with known overlap
468        let doc1 = "the quick brown fox jumps over the lazy dog";
469        let doc2 = "the quick brown fox jumps over the lazy cat";
470        
471        // They share most words (dog vs cat is the main difference)
472        let sig1 = hasher.compute_str(doc1, 0).unwrap();
473        let sig2 = hasher.compute_str(doc2, 1).unwrap();
474        
475        let estimated_sim = sig1.similarity(&sig2);
476        
477        // Should be high but not perfect
478        assert!(estimated_sim > 0.5 && estimated_sim < 1.0);
479    }
480    #[test]
481    fn test_exact_jaccard_similarity_and_expected_error() {
482        let set1 = vec!["apple", "banana", "cherry"];
483        let set2 = vec!["banana", "cherry", "date"];
484        // Intersection = 2, Union = 4 -> Jaccard = 0.5
485        let sim = exact_jaccard_similarity(&set1, &set2);
486        assert!((sim - 0.5).abs() < f64::EPSILON);
487
488        let err = expected_error(0.5, 100);
489        assert!((err - 0.05).abs() < 1e-4);
490
491        assert_eq!(exact_jaccard_similarity::<&str>(&[], &[]), 1.0);
492        assert_eq!(exact_jaccard_similarity(&["a"], &[]), 0.0);
493    }
494}