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