c2pa_text_binding/
minhash.rs1use crate::normalize::words;
10
11pub const NUM_PERM: usize = 128;
13pub const BANDS: usize = 32;
15pub const ROWS: usize = 4;
17pub const SHINGLE_K: usize = 5;
19pub const MATCH_JACCARD: f64 = 0.70;
21
22const MERSENNE_61: u64 = (1 << 61) - 1;
24const PERM_SEED: u64 = 0x0123_4567_89AB_CDEF;
26
27#[derive(Clone, Debug, PartialEq, Eq)]
29pub struct MinHash {
30 pub sig: [u64; NUM_PERM],
31 pub bands: [u64; BANDS],
32 pub shingle_count: usize,
33}
34
35fn splitmix64(state: &mut u64) -> u64 {
37 *state = state.wrapping_add(0x9E37_79B9_7F4A_7C15);
38 let mut z = *state;
39 z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
40 z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
41 z ^ (z >> 31)
42}
43
44fn permutation_params() -> ([u64; NUM_PERM], [u64; NUM_PERM]) {
46 let mut state = PERM_SEED;
47 let mut a = [0u64; NUM_PERM];
48 let mut b = [0u64; NUM_PERM];
49 for i in 0..NUM_PERM {
50 a[i] = (splitmix64(&mut state) % (MERSENNE_61 - 1)) + 1;
52 b[i] = splitmix64(&mut state) % MERSENNE_61;
53 }
54 (a, b)
55}
56
57fn base_hash(shingle: &str) -> u64 {
59 const OFFSET: u64 = 0xcbf2_9ce4_8422_2325;
60 const PRIME: u64 = 0x0000_0100_0000_01b3;
61 let mut h = OFFSET;
62 for &byte in shingle.as_bytes() {
63 h ^= byte as u64;
64 h = h.wrapping_mul(PRIME);
65 }
66 h % MERSENNE_61
67}
68
69fn band_hash(rows: &[u64]) -> u64 {
71 const OFFSET: u64 = 0xcbf2_9ce4_8422_2325;
72 const PRIME: u64 = 0x0000_0100_0000_01b3;
73 let mut h = OFFSET;
74 for &v in rows {
75 for byte in v.to_le_bytes() {
76 h ^= byte as u64;
77 h = h.wrapping_mul(PRIME);
78 }
79 }
80 h
81}
82
83impl MinHash {
84 pub fn compute(text: &str) -> Self {
86 let toks = words(text);
87 let shingles = shingles(&toks);
88 let (a, b) = permutation_params();
89 let mut sig = [u64::MAX; NUM_PERM];
90 for s in &shingles {
91 let h = base_hash(s);
92 for i in 0..NUM_PERM {
93 let v = mul_mod(a[i], h).wrapping_add(b[i]) % MERSENNE_61;
94 if v < sig[i] {
95 sig[i] = v;
96 }
97 }
98 }
99 if shingles.is_empty() {
101 sig = [0u64; NUM_PERM];
102 }
103 let mut bands = [0u64; BANDS];
104 for (band, slot) in bands.iter_mut().enumerate() {
105 let start = band * ROWS;
106 *slot = band_hash(&sig[start..start + ROWS]);
107 }
108 MinHash {
109 sig,
110 bands,
111 shingle_count: shingles.len(),
112 }
113 }
114
115 pub fn from_signature(sig: [u64; NUM_PERM]) -> Self {
120 let mut bands = [0u64; BANDS];
121 for (band, slot) in bands.iter_mut().enumerate() {
122 let start = band * ROWS;
123 *slot = band_hash(&sig[start..start + ROWS]);
124 }
125 MinHash {
126 sig,
127 bands,
128 shingle_count: 0,
129 }
130 }
131
132 pub fn jaccard(&self, other: &Self) -> f64 {
134 let equal = self
135 .sig
136 .iter()
137 .zip(other.sig.iter())
138 .filter(|(x, y)| x == y)
139 .count();
140 equal as f64 / NUM_PERM as f64
141 }
142
143 pub fn shares_band(&self, other: &Self) -> bool {
145 self.bands.iter().any(|x| other.bands.contains(x))
146 }
147
148 pub fn matches(&self, other: &Self) -> bool {
151 self.jaccard(other) >= MATCH_JACCARD
152 }
153}
154
155fn shingles(toks: &[String]) -> Vec<String> {
158 if toks.is_empty() {
159 return Vec::new();
160 }
161 if toks.len() < SHINGLE_K {
162 return vec![toks.join(" ")];
163 }
164 toks.windows(SHINGLE_K).map(|w| w.join(" ")).collect()
165}
166
167fn mul_mod(x: u64, y: u64) -> u64 {
169 ((x as u128 * y as u128) % MERSENNE_61 as u128) as u64
170}
171
172#[cfg(test)]
173mod tests {
174 use super::*;
175
176 const SRC: &str = "the honest limit is that text which is both heavily paraphrased and \
177 retyped defeats every layer except an approximate semantic match while everything \
178 short of that degrades gracefully across the fingerprint family of algorithms";
179
180 #[test]
181 fn identical_text_jaccard_one() {
182 let a = MinHash::compute(SRC);
183 let b = MinHash::compute(SRC);
184 assert_eq!(a.jaccard(&b), 1.0);
185 assert_eq!(a, b);
186 }
187
188 #[test]
189 fn near_duplicate_shares_band() {
190 let a = MinHash::compute(SRC);
195 let b = MinHash::compute(&SRC.replacen("gracefully", "smoothly", 1));
196 assert!(
197 a.matches(&b),
198 "near-duplicate must match at Jaccard >= 0.70"
199 );
200 assert!(
201 a.shares_band(&b),
202 "near-duplicates should share an LSH band"
203 );
204 }
205
206 #[test]
207 fn reordering_and_deletion_stay_high() {
208 let a = MinHash::compute(SRC);
209 let edited = SRC.replace("heavily paraphrased and ", "");
211 let b = MinHash::compute(&edited);
212 assert!(
213 a.jaccard(&b) > 0.5,
214 "minor deletion should keep Jaccard high"
215 );
216 }
217
218 #[test]
219 fn unrelated_text_low_jaccard() {
220 let a = MinHash::compute(SRC);
221 let b = MinHash::compute(
222 "a recipe for shortbread needs only butter sugar and flour combined by hand \
223 then chilled and baked until the edges are barely golden at the rim",
224 );
225 assert!(a.jaccard(&b) < MATCH_JACCARD);
226 assert!(!a.matches(&b));
227 }
228
229 #[test]
230 fn casefold_and_zero_width_ignored() {
231 let a = MinHash::compute(SRC);
232 let b = MinHash::compute(&SRC.to_uppercase().replace(' ', "\u{200B} "));
233 assert_eq!(a, b);
234 }
235
236 #[test]
237 fn bands_derived_from_signature() {
238 let a = MinHash::compute(SRC);
239 assert_eq!(a.bands.len(), BANDS);
240 assert_eq!(BANDS * ROWS, NUM_PERM);
241 }
242
243 #[test]
245 fn vector_signature_prefix() {
246 let mh = MinHash::compute("the quick brown fox jumps over the lazy dog again");
247 assert_eq!(
248 &mh.sig[..4],
249 &[
250 111552349047830145,
251 188277353147072251,
252 213821157973154889,
253 100998052165424870
254 ],
255 "PIN: recompute and update on any intentional algorithm change"
256 );
257 }
258}