#![cfg_attr(not(feature = "std"), no_std)]
#![deny(unsafe_code)]
#![deny(missing_docs)]
extern crate alloc;
use alloc::string::String;
use alloc::vec::Vec;
use pith_digest::{SplitMix64, fnv1a64};
pub mod ffi;
#[cfg(all(not(test), feature = "std"))]
mod ffi_jni;
pub mod reference;
const SHINGLE_WORDS: usize = 3;
pub const SIGNATURE_WORDS: usize = 128;
const SEED_STREAM: u64 = 0x5445_5854_4D48_3634;
fn sm64_mix(mut z: u64) -> u64 {
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
z ^ (z >> 31)
}
#[must_use]
pub fn canonicalize(input: &str) -> String {
let nfc = pith_unicode::nfc(input);
let lowered = nfc.to_lowercase();
let mut out = String::with_capacity(lowered.len() + 1);
out.push_str(lowered.trim_end());
out.push('\n');
out
}
fn shingle_hash(shingle: &[&str]) -> u64 {
fnv1a64(shingle.join(" ").as_bytes())
}
fn seeds() -> [u64; SIGNATURE_WORDS] {
let mut rng = SplitMix64::new(SEED_STREAM);
let mut out = [0u64; SIGNATURE_WORDS];
for slot in &mut out {
*slot = rng.next_u64();
}
out
}
fn minhash(elements: &[u64]) -> [u64; SIGNATURE_WORDS] {
let seeds = seeds();
let mut out = [u64::MAX; SIGNATURE_WORDS];
for &e in elements {
for (slot, &seed) in out.iter_mut().zip(seeds.iter()) {
let h = sm64_mix(e ^ seed);
if h < *slot {
*slot = h;
}
}
}
out
}
#[must_use]
pub fn signature(input: &str) -> Vec<u64> {
let canonical = canonicalize(input);
let words: Vec<&str> = canonical.split_whitespace().collect();
let k = words.len().min(SHINGLE_WORDS);
let shingles: Vec<u64> = if k == 0 {
Vec::new()
} else {
words.windows(k).map(shingle_hash).collect()
};
minhash(&shingles).to_vec()
}
#[must_use]
pub fn jaccard_estimate(a: &[u64], b: &[u64]) -> f64 {
let n = a.len().min(b.len());
if n == 0 {
return 1.0;
}
let equal = a
.iter()
.zip(b.iter())
.take(n)
.filter(|(x, y)| x == y)
.count();
equal as f64 / n as f64
}
#[cfg(test)]
mod tests {
use super::*;
use alloc::vec;
#[test]
fn minhash_estimates_jaccard() {
let mut rng = SplitMix64::new(0xC0FF_EE11_2233_4455);
const PAIRS: usize = 200;
let mut out_of_tolerance = 0usize;
let mut err_sum = 0.0f64;
for _ in 0..PAIRS {
let sa = 30 + (rng.next_u64() % 90) as usize;
let sb = 30 + (rng.next_u64() % 90) as usize;
let overlap = (rng.next_u64() as usize) % (sa.min(sb) + 1);
let mut a = Vec::with_capacity(sa);
let mut b = Vec::with_capacity(sb);
for _ in 0..overlap {
let v = rng.next_u64();
a.push(v);
b.push(v);
}
while a.len() < sa {
a.push(rng.next_u64());
}
while b.len() < sb {
b.push(rng.next_u64());
}
let true_j = overlap as f64 / (sa + sb - overlap) as f64;
let est = jaccard_estimate(&minhash(&a), &minhash(&b));
let err = (est - true_j).abs();
err_sum += err;
if err > 0.1 {
out_of_tolerance += 1;
}
}
assert!(
out_of_tolerance <= 4,
"{out_of_tolerance}/{PAIRS} pairs outside |err| <= 0.1 (95% bound allows 10)"
);
assert!(
err_sum / PAIRS as f64 <= 0.04,
"mean |err| {} exceeds the 0.04 bound",
err_sum / PAIRS as f64
);
}
#[test]
fn minhash_empty_set_sentinel() {
assert_eq!(minhash(&[]), [u64::MAX; SIGNATURE_WORDS]);
let one = minhash(&[0]);
assert_ne!(one, [u64::MAX; SIGNATURE_WORDS]);
assert!(one.iter().any(|&w| w != u64::MAX));
}
#[test]
fn minhash_disjoint_sets_estimate_zero() {
let mut rng = SplitMix64::new(0xDEAD_BEEF_CAFE_F00D);
for _ in 0..50 {
let a: Vec<u64> = (0..40).map(|_| rng.next_u64()).collect();
let b: Vec<u64> = (0..40).map(|_| rng.next_u64()).collect();
assert!(jaccard_estimate(&minhash(&a), &minhash(&b)) <= 0.1);
}
let s: Vec<u64> = (0..40).map(|_| rng.next_u64()).collect();
assert_eq!(minhash(&s), minhash(&s));
assert_eq!(
vec![u64::MAX; SIGNATURE_WORDS].as_slice(),
minhash(&[]).as_slice()
);
}
}