use std::collections::BTreeMap;
const BOUNDARY: char = '\u{0}';
pub fn shingles(s: &str, n: usize) -> std::collections::BTreeSet<String> {
use std::collections::BTreeSet;
if s.is_empty() || n == 0 {
return BTreeSet::new();
}
let pad = n.saturating_sub(1);
let chars: Vec<char> = std::iter::repeat_n(BOUNDARY, pad)
.chain(s.chars())
.chain(std::iter::repeat_n(BOUNDARY, pad))
.collect();
if chars.len() < n {
return BTreeSet::new();
}
(0..=chars.len() - n)
.map(|i| chars[i..i + n].iter().collect())
.collect()
}
pub fn jaccard(
a: &std::collections::BTreeSet<String>,
b: &std::collections::BTreeSet<String>,
) -> f64 {
let intersection = a.intersection(b).count();
let union = a.len() + b.len() - intersection;
if union == 0 {
return 0.0;
}
intersection as f64 / union as f64
}
pub fn shingle_entropy(sh: &std::collections::BTreeSet<String>) -> f64 {
let mut freq: BTreeMap<char, u64> = BTreeMap::new();
let mut total: u64 = 0;
for shingle in sh {
for c in shingle.chars().filter(|&c| c != BOUNDARY) {
*freq.entry(c).or_default() += 1;
total += 1;
}
}
if total == 0 {
return 0.0;
}
freq.values()
.map(|&count| {
let p = count as f64 / total as f64;
-p * p.log2()
})
.sum()
}
pub fn minhash(sh: &std::collections::BTreeSet<String>, perms: usize) -> Vec<u64> {
if sh.is_empty() {
return vec![u64::MAX; perms];
}
(0..perms)
.map(|i| {
let seed = (i as u64)
.wrapping_mul(0x9E37_79B9_7F4A_7C15)
.wrapping_add(0x517C_C1B7_2722_0A95);
sh.iter()
.map(|s| seeded_fnv1a(s, seed))
.min()
.unwrap_or(u64::MAX)
})
.collect()
}
pub fn lsh_bands(sig: &[u64], band_size: usize) -> Vec<u64> {
if sig.is_empty() || band_size == 0 {
return Vec::new();
}
sig.chunks(band_size)
.map(|band| {
let mut h = 0xcbf2_9ce4_8422_2325u64;
for &v in band {
h ^= v;
h = h.wrapping_mul(0x0000_0100_0000_01b3);
}
h
})
.collect()
}
fn seeded_fnv1a(s: &str, seed: u64) -> u64 {
let mut h = 0xcbf2_9ce4_8422_2325u64 ^ seed;
for b in s.as_bytes() {
h ^= *b as u64;
h = h.wrapping_mul(0x0000_0100_0000_01b3);
}
h
}
#[cfg(test)]
mod tests {
use super::*;
use proptest::prelude::*;
use std::collections::BTreeSet;
#[test]
fn shingles_basic_latin() {
let sh = shingles("hello", 3);
assert!(sh.contains("\u{0}\u{0}h"));
assert!(sh.contains("hel"));
assert!(sh.contains("ell"));
assert!(sh.contains("llo"));
assert!(sh.contains("o\u{0}\u{0}"));
assert_eq!(sh.len(), 7);
}
#[test]
fn shingles_short_string() {
let sh = shingles("a", 3);
assert!(!sh.is_empty());
assert!(sh.contains("\u{0}a\u{0}"));
}
#[test]
fn shingles_empty() {
assert!(shingles("", 3).is_empty());
assert!(shingles("abc", 0).is_empty());
}
#[test]
fn shingles_n1() {
let sh = shingles("abc", 1);
assert_eq!(sh.len(), 3); assert!(sh.contains("a") && sh.contains("b") && sh.contains("c"));
}
#[test]
fn shingles_cjk() {
let sh = shingles("张伟", 2);
assert_eq!(sh.len(), 3);
assert!(sh.contains("张伟"));
}
#[test]
fn jaccard_identity() {
let sh = shingles("hello", 3);
assert_eq!(jaccard(&sh, &sh), 1.0);
}
#[test]
fn jaccard_empty_set() {
let sh = shingles("hello", 3);
let empty = BTreeSet::new();
assert_eq!(jaccard(&sh, &empty), 0.0);
assert_eq!(jaccard(&empty, &sh), 0.0);
}
#[test]
fn jaccard_both_empty() {
let empty = BTreeSet::new();
assert_eq!(jaccard(&empty, &empty), 0.0);
}
#[test]
fn jaccard_symmetric() {
let a = shingles("hello", 3);
let b = shingles("world", 3);
assert!((jaccard(&a, &b) - jaccard(&b, &a)).abs() < 1e-12);
}
#[test]
fn jaccard_partial_overlap() {
let a = shingles("abcde", 3);
let b = shingles("acbde", 3); let j = jaccard(&a, &b);
assert!(j > 0.0 && j < 1.0);
assert!((j - 3.0 / 11.0).abs() < 1e-10, "got {j}");
}
#[test]
fn script_invariance_single_swap() {
let n = 3;
let cases: &[(&str, &str)] = &[
("abcde", "acbde"), ("가나다라마", "가다나라마"), ("金木水火土", "金水木火土"), ("ضصثقف", "ضثصقف"), ];
let jaccards: Vec<f64> = cases
.iter()
.map(|(a, b)| jaccard(&shingles(a, n), &shingles(b, n)))
.collect();
let max = jaccards.iter().cloned().fold(0.0f64, f64::max);
let min = jaccards.iter().cloned().fold(1.0f64, f64::min);
let spread = max - min;
assert!(
spread < 1e-10,
"script invariance violated: spread={spread}, values={jaccards:?}"
);
}
#[test]
fn entropy_low_for_repeated_chars() {
let sh = shingles("aaaa", 3);
let e = shingle_entropy(&sh);
assert!(e < 0.01, "expected ~0, got {e}");
}
#[test]
fn entropy_higher_for_diverse_chars() {
let low = shingle_entropy(&shingles("aaaa", 3));
let high = shingle_entropy(&shingles("abcd", 3));
assert!(high > low, "diverse should have higher entropy");
}
#[test]
fn entropy_zero_for_empty() {
assert_eq!(shingle_entropy(&BTreeSet::new()), 0.0);
}
#[test]
fn minhash_deterministic() {
let sh = shingles("hello world", 3);
let sig1 = minhash(&sh, 32);
let sig2 = minhash(&sh, 32);
assert_eq!(sig1, sig2);
}
#[test]
fn minhash_empty() {
let sig = minhash(&BTreeSet::new(), 16);
assert_eq!(sig.len(), 16);
assert!(sig.iter().all(|&v| v == u64::MAX));
}
#[test]
fn minhash_similar_sets_similar_signatures() {
let a = shingles("abcdefgh", 3);
let b = shingles("abcdefgh", 3);
let sig_a = minhash(&a, 128);
let sig_b = minhash(&b, 128);
let matches = sig_a.iter().zip(&sig_b).filter(|(x, y)| x == y).count();
assert_eq!(matches, 128);
let c = shingles("xyz12345", 3);
let sig_c = minhash(&c, 128);
let matches_c = sig_a.iter().zip(&sig_c).filter(|(x, y)| x == y).count();
assert!(
matches_c < 128,
"different sets should not match everywhere"
);
}
#[test]
fn lsh_bands_count() {
let sig = minhash(&shingles("hello", 3), 20);
let bands = lsh_bands(&sig, 5);
assert_eq!(bands.len(), 4); }
#[test]
fn lsh_bands_empty() {
assert!(lsh_bands(&[], 4).is_empty());
assert!(lsh_bands(&[1, 2, 3], 0).is_empty());
}
#[test]
fn lsh_bands_remainder() {
let sig: Vec<u64> = (0..22).collect();
let bands = lsh_bands(&sig, 5);
assert_eq!(bands.len(), 5);
}
proptest! {
#[test]
fn prop_jaccard_identity(s in "[a-z]{3,20}") {
let sh = shingles(&s, 3);
prop_assert_eq!(jaccard(&sh, &sh), 1.0);
}
#[test]
fn prop_jaccard_empty(s in "[a-z]{3,20}") {
let sh = shingles(&s, 3);
let empty = BTreeSet::new();
prop_assert_eq!(jaccard(&sh, &empty), 0.0);
}
#[test]
fn prop_jaccard_symmetry(a in "[a-z]{3,20}", b in "[a-z]{3,20}") {
let sa = shingles(&a, 3);
let sb = shingles(&b, 3);
prop_assert!((jaccard(&sa, &sb) - jaccard(&sb, &sa)).abs() < 1e-12);
}
#[test]
fn prop_jaccard_range(a in "[a-z]{3,20}", b in "[a-z]{3,20}") {
let sa = shingles(&a, 3);
let sb = shingles(&b, 3);
let j = jaccard(&sa, &sb);
prop_assert!((0.0..=1.0).contains(&j));
}
#[test]
fn prop_shingles_nonempty(s in ".{1,30}") {
let sh = shingles(&s, 3);
prop_assert!(!sh.is_empty(), "shingles must not be empty for non-empty input");
}
#[test]
fn prop_minhash_deterministic(s in "[a-z]{3,30}") {
let sh = shingles(&s, 3);
let sig1 = minhash(&sh, 16);
let sig2 = minhash(&sh, 16);
prop_assert_eq!(sig1, sig2);
}
#[test]
fn prop_minhash_length(sh in "[a-z]{3,30}", perms in 1usize..=64) {
let sh = shingles(&sh, 3);
let sig = minhash(&sh, perms);
prop_assert_eq!(sig.len(), perms);
}
}
}