use std::collections::HashSet;
use crate::config::Config;
use crate::error::{Error, Result};
use crate::shingle::HashedShingleIterator;
use crate::fast_hash::FastHasher;
use tracing::{instrument, warn};
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct MinHashSignature {
pub values: Vec<u32>,
pub doc_id: usize,
}
impl MinHashSignature {
pub fn new(values: Vec<u32>, doc_id: usize) -> Self {
Self { values, doc_id }
}
#[must_use]
pub fn similarity(&self, other: &Self) -> f64 {
if self.values.len() != other.values.len() || self.values.is_empty() {
return 0.0;
}
let matches = self
.values
.iter()
.zip(&other.values)
.filter(|(a, b)| a == b)
.count();
matches as f64 / self.values.len() as f64
}
#[must_use]
pub fn band(&self, start: usize, length: usize) -> &[u32] {
let end = start.saturating_add(length).min(self.values.len());
&self.values[start.min(self.values.len())..end]
}
#[must_use]
pub fn band_hash(&self, start: usize, length: usize) -> u64 {
let band = self.band(start, length);
let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
for &value in band {
hash ^= u64::from(value);
hash = hash.wrapping_mul(0x0100_0000_01b3);
}
hash
}
#[must_use]
pub fn len(&self) -> usize {
self.values.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.values.is_empty()
}
}
pub struct MinHasher {
hasher: FastHasher,
shingle_size: usize,
signature_size: usize,
}
impl MinHasher {
#[instrument(skip(config), level = "debug")]
pub fn new(config: &Config) -> Result<Self> {
Ok(Self {
hasher: FastHasher::new(config.signature_size, config.seed),
shingle_size: config.shingle_size,
signature_size: config.signature_size,
})
}
#[instrument(skip(self, data), fields(doc_id, data_len = data.len()), level = "debug")]
pub fn compute(&self, data: &[u8], doc_id: usize) -> Result<MinHashSignature> {
if data.is_empty() {
warn!(doc_id, "empty document");
return Err(Error::EmptyDocument { index: doc_id });
}
let mut signature = vec![u32::MAX; self.signature_size];
let shingle_iter = HashedShingleIterator::new(data, self.shingle_size);
if shingle_iter.len() == 0 {
warn!(doc_id, shingle_size = self.shingle_size, "document too short for shingle size");
return Err(Error::EmptyDocument { index: doc_id });
}
for shingle_hash in shingle_iter {
self.hasher.update_signature(&mut signature, shingle_hash);
}
Ok(MinHashSignature::new(signature, doc_id))
}
#[instrument(skip(self, text), fields(doc_id, text_len = text.len()), level = "debug")]
pub fn compute_str(&self, text: &str, doc_id: usize) -> Result<MinHashSignature> {
self.compute(text.as_bytes(), doc_id)
}
pub fn compute_batch(&self, documents: &[&[u8]], start_id: usize) -> Vec<Result<MinHashSignature>> {
documents
.iter()
.enumerate()
.map(|(idx, doc)| match start_id.checked_add(idx) {
Some(doc_id) => self.compute(doc, doc_id),
None => Err(Error::InvalidConfig {
reason: format!(
"doc_id overflow: start_id {start_id} plus batch index {idx} exceeds usize::MAX"
),
fix: "use a smaller start_id or split the batch".to_string(),
}),
})
.collect()
}
pub fn compute_from_hashed_shingles(
&self,
shingle_hashes: &[u64],
doc_id: usize,
) -> MinHashSignature {
let mut signature = vec![u32::MAX; self.signature_size];
for &shingle_hash in shingle_hashes {
self.hasher.update_signature(&mut signature, shingle_hash);
}
MinHashSignature::new(signature, doc_id)
}
#[must_use]
pub const fn signature_size(&self) -> usize {
self.signature_size
}
#[must_use]
pub const fn shingle_size(&self) -> usize {
self.shingle_size
}
}
#[must_use]
#[allow(dead_code)]
pub fn exact_jaccard_similarity<T: Ord + Clone + std::hash::Hash>(a: &[T], b: &[T]) -> f64 {
if a.is_empty() && b.is_empty() {
return 1.0;
}
if a.is_empty() || b.is_empty() {
return 0.0;
}
let set_a: HashSet<_> = a.iter().cloned().collect();
let set_b: HashSet<_> = b.iter().cloned().collect();
let intersection: HashSet<_> = set_a.intersection(&set_b).collect();
let union: HashSet<_> = set_a.union(&set_b).collect();
intersection.len() as f64 / union.len() as f64
}
#[must_use]
#[allow(dead_code)]
pub fn expected_error(similarity: f64, signature_size: usize) -> f64 {
let s = similarity.clamp(0.0, 1.0);
let k = signature_size as f64;
(s * (1.0 - s) / k).sqrt()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::Config;
fn create_hasher() -> MinHasher {
let config = Config::default();
MinHasher::new(&config).unwrap()
}
#[test]
fn minhash_signature_similarity_perfect() {
let sig1 = MinHashSignature::new(vec![1, 2, 3, 4, 5], 0);
let sig2 = MinHashSignature::new(vec![1, 2, 3, 4, 5], 1);
assert!((sig1.similarity(&sig2) - 1.0).abs() < f64::EPSILON);
}
#[test]
fn minhash_signature_similarity_zero() {
let sig1 = MinHashSignature::new(vec![1, 2, 3, 4, 5], 0);
let sig2 = MinHashSignature::new(vec![6, 7, 8, 9, 10], 1);
assert!((sig1.similarity(&sig2) - 0.0).abs() < f64::EPSILON);
}
#[test]
fn minhash_signature_similarity_partial() {
let sig1 = MinHashSignature::new(vec![1, 2, 3, 4, 5], 0);
let sig2 = MinHashSignature::new(vec![1, 2, 8, 9, 10], 1);
assert!((sig1.similarity(&sig2) - 0.4).abs() < f64::EPSILON);
}
#[test]
fn band_extraction() {
let sig = MinHashSignature::new(vec![1, 2, 3, 4, 5, 6, 7, 8], 0);
let band = sig.band(2, 3);
assert_eq!(band, &[3, 4, 5]);
}
#[test]
fn band_hash_deterministic() {
let sig = MinHashSignature::new(vec![1, 2, 3, 4, 5], 0);
let h1 = sig.band_hash(0, 3);
let h2 = sig.band_hash(0, 3);
assert_eq!(h1, h2);
}
#[test]
fn compute_batch_reports_doc_id_overflow() {
let hasher = create_hasher();
let docs: &[&[u8]] = &[b"first document", b"second document"];
let results = hasher.compute_batch(docs, usize::MAX);
assert_eq!(results.len(), 2);
let first = results[0].as_ref().expect("index 0 fits at usize::MAX");
assert_eq!(first.doc_id, usize::MAX);
let err = results[1].as_ref().expect_err("index 1 must overflow");
assert!(
err.to_string().contains("doc_id overflow"),
"error names the overflow: {err}"
);
}
#[test]
fn band_hash_different_bands_different() {
let sig = MinHashSignature::new(vec![1, 2, 3, 4, 5, 6], 0);
let h1 = sig.band_hash(0, 3);
let h2 = sig.band_hash(3, 3);
assert_ne!(h1, h2);
}
#[test]
fn compute_signature_for_document() {
let hasher = create_hasher();
let doc = b"hello world this is a test document";
let sig = hasher.compute(doc, 0).unwrap();
assert_eq!(sig.len(), 128); }
#[test]
fn similar_documents_have_similar_signatures() {
let hasher = create_hasher();
let doc1 = b"hello world this is a test document";
let doc2 = b"hello world this is a test document with extra words";
let sig1 = hasher.compute(doc1, 0).unwrap();
let sig2 = hasher.compute(doc2, 1).unwrap();
let similarity = sig1.similarity(&sig2);
assert!(similarity > 0.5, "similarity was {}", similarity);
}
#[test]
fn different_documents_have_low_similarity() {
let hasher = create_hasher();
let doc1 = b"the quick brown fox jumps over the lazy dog";
let doc2 = b"completely different content about various topics";
let sig1 = hasher.compute(doc1, 0).unwrap();
let sig2 = hasher.compute(doc2, 1).unwrap();
let similarity = sig1.similarity(&sig2);
assert!(similarity < 0.3, "similarity was {}", similarity);
}
#[test]
fn empty_document_errors() {
let hasher = create_hasher();
let result = hasher.compute(b"", 0);
assert!(result.is_err());
}
#[test]
fn document_too_short_for_shingle_size() {
let hasher = create_hasher(); let result = hasher.compute(b"hi", 0);
assert!(result.is_err());
}
#[test]
fn compute_str_works() {
let hasher = create_hasher();
let sig = hasher.compute_str("hello world", 0).unwrap();
assert_eq!(sig.len(), 128);
}
#[test]
fn batch_compute() {
let hasher = create_hasher();
let docs: Vec<&[u8]> = vec![
b"document one content",
b"document two content",
b"document three content",
];
let results = hasher.compute_batch(&docs, 0);
assert_eq!(results.len(), 3);
assert!(results.iter().all(|r| r.is_ok()));
}
#[test]
fn compute_from_hashed_shingles() {
let hasher = create_hasher();
let shingles = vec![1_u64, 2, 3, 4, 5];
let sig = hasher.compute_from_hashed_shingles(&shingles, 0);
assert_eq!(sig.len(), 128);
}
#[test]
fn exact_jaccard_identical_sets() {
let a = vec![1, 2, 3];
let b = vec![1, 2, 3];
assert!((exact_jaccard_similarity(&a, &b) - 1.0).abs() < f64::EPSILON);
}
#[test]
fn exact_jaccard_disjoint_sets() {
let a = vec![1, 2, 3];
let b = vec![4, 5, 6];
assert!((exact_jaccard_similarity(&a, &b) - 0.0).abs() < f64::EPSILON);
}
#[test]
fn exact_jaccard_overlapping_sets() {
let a = vec![1, 2, 3];
let b = vec![2, 3, 4];
assert!((exact_jaccard_similarity(&a, &b) - 0.5).abs() < f64::EPSILON);
}
#[test]
fn expected_error_bounds() {
let err_mid = expected_error(0.5, 100);
let err_low = expected_error(0.1, 100);
let err_high = expected_error(0.9, 100);
assert!(err_mid > err_low);
assert!(err_mid > err_high);
}
#[test]
fn signature_is_empty() {
let sig = MinHashSignature::new(vec![], 0);
assert!(sig.is_empty());
let sig = MinHashSignature::new(vec![1, 2, 3], 0);
assert!(!sig.is_empty());
}
#[test]
fn minhash_preserves_similarity() {
let hasher = create_hasher();
let doc1 = "the quick brown fox jumps over the lazy dog";
let doc2 = "the quick brown fox jumps over the lazy cat";
let sig1 = hasher.compute_str(doc1, 0).unwrap();
let sig2 = hasher.compute_str(doc2, 1).unwrap();
let estimated_sim = sig1.similarity(&sig2);
assert!(estimated_sim > 0.5 && estimated_sim < 1.0);
}
}