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
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 #[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 #[must_use]
82 pub fn band_hash(&self, start: usize, length: usize) -> u64 {
83 let band = self.band(start, length);
84
85 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 #[must_use]
96 pub fn len(&self) -> usize {
97 self.values.len()
98 }
99
100 #[must_use]
102 pub fn is_empty(&self) -> bool {
103 self.values.is_empty()
104 }
105}
106
107pub struct MinHasher {
112 hasher: FastHasher,
114 shingle_size: usize,
116 signature_size: usize,
118}
119
120impl MinHasher {
121 #[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 #[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 let mut signature = vec![u32::MAX; self.signature_size];
149
150 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 #[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 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 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 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 #[must_use]
217 pub const fn signature_size(&self) -> usize {
218 self.signature_size
219 }
220
221 #[must_use]
223 pub const fn shingle_size(&self) -> usize {
224 self.shingle_size
225 }
226}
227
228#[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#[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 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 #[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); }
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 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 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(); 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 assert!((exact_jaccard_similarity(&a, &b) - 0.5).abs() < f64::EPSILON);
440 }
441
442 #[test]
443 fn expected_error_bounds() {
444 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 let hasher = create_hasher();
466
467 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 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 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 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}