Skip to main content

simi/algo/
minhash.rs

1//! MinHash — probabilistic data structure for estimating Jaccard similarity
2//! between large documents. Uses k hash functions to create compact
3//! fingerprints.
4//!
5//! ## Performance
6//! O(k·n) time where k = number of hash functions, n = shingle count.
7//! Fingerprint size is O(k) memory per document.
8
9use std::collections::HashSet;
10
11const DEFAULT_NUM_HASHES: usize = 128;
12const DEFAULT_SHINGLE_SIZE: usize = 3;
13
14/// A MinHash fingerprint for estimating Jaccard similarity between documents.
15///
16/// The fingerprint is a vector of minimum hash values computed over the
17/// document's shingles. Two documents with similar content will have similar
18/// fingerprints.
19#[derive(Clone, Debug)]
20pub struct MinHash {
21    /// The computed minimum hash values (the fingerprint).
22    pub signatures: Vec<u64>,
23    /// Number of hash functions used.
24    pub num_hashes: usize,
25}
26
27/// Pre-computed random coefficients for the universal hash family.
28///
29/// Uses (a * x + b) mod P with randomly chosen a, b values to create
30/// k independent hash functions.
31fn generate_hash_coeffs(num_hashes: usize) -> (Vec<u64>, Vec<u64>) {
32    // Use deterministic seeds derived from a fixed base; in production
33    // you'd persist these across MinHash runs for consistency.
34    let seed: u64 = 0x9e3779b97f4a7c15;
35    let mut a_coeffs = Vec::with_capacity(num_hashes);
36    let mut b_coeffs = Vec::with_capacity(num_hashes);
37
38    for i in 0..num_hashes {
39        // Simple LCG-style coefficient generation
40        let a = seed.wrapping_mul(i as u64 + 1).wrapping_add(0xABCD);
41        let b = seed.wrapping_mul(i as u64 + 1).wrapping_add(0x1234);
42        a_coeffs.push(a | 1); // ensure odd (coprime with power of 2 modulus)
43        b_coeffs.push(b);
44    }
45
46    (a_coeffs, b_coeffs)
47}
48
49/// Compute a MinHash signature for a string.
50///
51/// # Arguments
52/// * `text` - The input text.
53/// * `shingle_size` - Size of character-level shingles (default: 3).
54/// * `num_hashes` - Number of hash functions (default: 128).
55///
56/// Returns a `MinHash` fingerprint.
57#[inline]
58pub fn signature(text: &str, shingle_size: usize, num_hashes: usize) -> MinHash {
59    // Generate shingles
60    let chars: Vec<char> = text.chars().collect();
61    let mut shingles = HashSet::new();
62
63    if chars.len() < shingle_size {
64        shingles.insert(text.to_string());
65    } else {
66        for w in chars.windows(shingle_size) {
67            shingles.insert(w.iter().collect::<String>());
68        }
69    }
70
71    let (a_coeffs, b_coeffs) = generate_hash_coeffs(num_hashes);
72    // Large prime for universal hashing
73    const P: u64 = (1 << 61) - 1;
74
75    let mut signatures = vec![u64::MAX; num_hashes];
76
77    for shingle in &shingles {
78        let shingle_hash = fxhash(shingle);
79
80        for i in 0..num_hashes {
81            let hash_val = a_coeffs[i]
82                .wrapping_mul(shingle_hash)
83                .wrapping_add(b_coeffs[i])
84                % P;
85            if hash_val < signatures[i] {
86                signatures[i] = hash_val;
87            }
88        }
89    }
90
91    MinHash {
92        signatures,
93        num_hashes,
94    }
95}
96
97/// A simple fast hash for short strings (FNV-1a like).
98fn fxhash(s: &str) -> u64 {
99    let mut hash: u64 = 0xcbf29ce484222325;
100    for b in s.bytes() {
101        hash ^= b as u64;
102        hash = hash.wrapping_mul(0x100000001b3);
103    }
104    hash
105}
106
107/// Estimate Jaccard similarity between two MinHash signatures.
108///
109/// Returns a value in `[0.0, 1.0]`.
110#[inline]
111pub fn similarity(a: &MinHash, b: &MinHash) -> f64 {
112    let min_len = std::cmp::min(a.signatures.len(), b.signatures.len());
113    let matches = a.signatures[..min_len]
114        .iter()
115        .zip(b.signatures[..min_len].iter())
116        .filter(|(sa, sb)| sa == sb)
117        .count();
118
119    matches as f64 / min_len as f64
120}
121
122/// Convenience: shingle + signature + similarity in one call.
123#[inline]
124pub fn compare(a: &str, b: &str, shingle_size: usize, num_hashes: usize) -> f64 {
125    let sig_a = signature(a, shingle_size, num_hashes);
126    let sig_b = signature(b, shingle_size, num_hashes);
127    similarity(&sig_a, &sig_b)
128}
129
130/// Default convenience: trigram shingles, 128 hashes.
131#[inline]
132pub fn compare_default(a: &str, b: &str) -> f64 {
133    compare(a, b, DEFAULT_SHINGLE_SIZE, DEFAULT_NUM_HASHES)
134}
135
136#[cfg(test)]
137mod tests {
138    use super::*;
139
140    #[test]
141    fn identical_documents() {
142        let sig_a = signature("hello world this is a test", 3, 128);
143        let sig_b = signature("hello world this is a test", 3, 128);
144        let s = similarity(&sig_a, &sig_b);
145        assert!((s - 1.0).abs() < 0.01, "identical should be ~1.0, got {s}");
146    }
147
148    #[test]
149    fn similar_documents() {
150        let s = compare_default(
151            "the quick brown fox jumps over the lazy dog",
152            "the quick brown fox jumps over the lazy cat",
153        );
154        assert!(s > 0.5, "expected high similarity, got {s}");
155    }
156
157    #[test]
158    fn different_documents() {
159        let s = compare_default(
160            "hello world this is a test",
161            "completely unrelated content nothing similar",
162        );
163        assert!(s < 0.3, "expected low similarity, got {s}");
164    }
165
166    #[test]
167    fn symmetric() {
168        let a = compare_default("abc def ghi", "abc xyz");
169        let b = compare_default("abc xyz", "abc def ghi");
170        assert!((a - b).abs() < f64::EPSILON);
171    }
172
173    #[test]
174    fn consistent_signature_length() {
175        let sig = signature("test", 3, 128);
176        assert_eq!(sig.signatures.len(), 128);
177        assert_eq!(sig.num_hashes, 128);
178    }
179}