1use 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#[derive(Debug, Clone, PartialEq, Eq, Hash)]
20pub struct MinHashSignature {
21 pub values: Vec<u32>,
23 pub doc_id: usize,
25}
26
27impl MinHashSignature {
28 pub fn new(values: Vec<u32>, doc_id: usize) -> Self {
30 Self { values, doc_id }
31 }
32
33 #[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 #[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 #[must_use]
76 pub fn band_hash(&self, start: usize, length: usize) -> u64 {
77 let band = self.band(start, length);
78
79 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 #[must_use]
90 pub fn len(&self) -> usize {
91 self.values.len()
92 }
93
94 #[must_use]
96 pub fn is_empty(&self) -> bool {
97 self.values.is_empty()
98 }
99}
100
101pub struct MinHasher {
106 hasher: FastHasher,
108 shingle_size: usize,
110 signature_size: usize,
112}
113
114impl MinHasher {
115 #[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 #[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 let mut signature = vec![u32::MAX; self.signature_size];
143
144 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 #[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 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 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 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 #[must_use]
211 pub const fn signature_size(&self) -> usize {
212 self.signature_size
213 }
214
215 #[must_use]
217 pub const fn shingle_size(&self) -> usize {
218 self.shingle_size
219 }
220}
221
222#[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#[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 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 #[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); }
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 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 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(); 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 assert!((exact_jaccard_similarity(&a, &b) - 0.5).abs() < f64::EPSILON);
436 }
437
438 #[test]
439 fn expected_error_bounds() {
440 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 let hasher = create_hasher();
462
463 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 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 assert!(estimated_sim > 0.5 && estimated_sim < 1.0);
475 }
476}