c2pa_text_binding/
simhash.rs1use crate::normalize::canonical;
11use blake2::{Blake2b512, Digest};
12use std::collections::HashMap;
13
14pub const FP_BITS: usize = 256;
16pub const MATCH_THRESHOLD: u32 = 32;
18pub const WINDOW_CHARS: usize = 512;
20pub const WINDOW_STEP: usize = 256;
22
23#[derive(Clone, Copy, Debug, PartialEq, Eq)]
25pub struct Hash256(pub [u8; 32]);
26
27impl Hash256 {
28 pub fn hamming(&self, other: &Self) -> u32 {
30 self.0
31 .iter()
32 .zip(other.0.iter())
33 .map(|(a, b)| (a ^ b).count_ones())
34 .sum()
35 }
36
37 pub fn to_hex(&self) -> String {
39 hex::encode(self.0)
40 }
41
42 pub fn from_hex(s: &str) -> Option<Self> {
44 let bytes = hex::decode(s).ok()?;
45 if bytes.len() != 32 {
46 return None;
47 }
48 let mut out = [0u8; 32];
49 out.copy_from_slice(&bytes);
50 Some(Hash256(out))
51 }
52}
53
54pub(crate) fn blake2_256(bytes: &[u8]) -> [u8; 32] {
56 let mut h = Blake2b512::new();
57 h.update(bytes);
58 let digest = h.finalize();
59 let mut out = [0u8; 32];
60 out.copy_from_slice(&digest[..32]);
61 out
62}
63
64pub(crate) fn simhash_weighted<'a, I>(features: I) -> Hash256
67where
68 I: IntoIterator<Item = (&'a [u8], i64)>,
69{
70 let mut acc = [0i64; FP_BITS];
71 for (bytes, weight) in features {
72 let h = blake2_256(bytes);
73 for (bit, slot) in acc.iter_mut().enumerate() {
74 let set = (h[bit / 8] >> (7 - (bit % 8))) & 1 == 1;
75 *slot += if set { weight } else { -weight };
76 }
77 }
78 let mut out = [0u8; 32];
79 for (bit, &count) in acc.iter().enumerate() {
80 if count > 0 {
81 out[bit / 8] |= 1 << (7 - (bit % 8));
82 }
83 }
84 Hash256(out)
85}
86
87fn simhash_4grams(chars: &[char]) -> Hash256 {
89 if chars.is_empty() {
90 return Hash256([0u8; 32]);
91 }
92 let n = 4.min(chars.len());
93 let mut counts: HashMap<String, i64> = HashMap::new();
94 for gram in chars.windows(n) {
95 let key: String = gram.iter().collect();
96 *counts.entry(key).or_insert(0) += 1;
97 }
98 simhash_weighted(counts.iter().map(|(k, w)| (k.as_bytes(), *w)))
99}
100
101#[derive(Clone, Debug)]
103pub struct WindowFp {
104 pub start: usize,
105 pub len: usize,
106 pub hash: Hash256,
107}
108
109#[derive(Clone, Debug)]
111pub struct Fingerprint {
112 pub whole: Hash256,
113 pub windows: Vec<WindowFp>,
114}
115
116impl Fingerprint {
117 pub fn compute(text: &str) -> Self {
119 let norm = canonical(text);
120 let chars: Vec<char> = norm.chars().collect();
121 let whole = simhash_4grams(&chars);
122 let mut windows = Vec::new();
123 if chars.len() > WINDOW_CHARS {
124 let mut start = 0;
125 loop {
126 let end = (start + WINDOW_CHARS).min(chars.len());
127 windows.push(WindowFp {
128 start,
129 len: end - start,
130 hash: simhash_4grams(&chars[start..end]),
131 });
132 if end == chars.len() {
133 break;
134 }
135 start += WINDOW_STEP;
136 }
137 }
138 Fingerprint { whole, windows }
139 }
140
141 pub fn best_distance(&self, candidate: &Hash256) -> u32 {
144 let mut best = self.whole.hamming(candidate);
145 for w in &self.windows {
146 best = best.min(w.hash.hamming(candidate));
147 }
148 best
149 }
150
151 pub fn matches(&self, candidate: &Hash256) -> bool {
154 self.best_distance(candidate) <= MATCH_THRESHOLD
155 }
156}
157
158#[cfg(test)]
159mod tests {
160 use super::*;
161
162 const LONG: &str = "The principles of provenance require that a document's origin \
163 can be recovered even after it has been copied, reformatted, or lightly edited. \
164 A soft binding derives a durable value from the words themselves so that a \
165 manifest can be found again when the embedded one is stripped away by a careless \
166 copy-paste or an aggressive text pipeline that normalizes everything in sight.";
167
168 #[test]
169 fn identical_text_zero_distance() {
170 let a = Fingerprint::compute(LONG);
171 let b = Fingerprint::compute(LONG);
172 assert_eq!(a.whole.hamming(&b.whole), 0);
173 }
174
175 #[test]
176 fn reformatting_and_zero_width_survive() {
177 let a = Fingerprint::compute(LONG);
178 let reformatted = format!(" {} ", LONG.to_uppercase().replace(' ', "\u{200B} "));
179 let b = Fingerprint::compute(&reformatted);
180 assert_eq!(
181 a.whole.hamming(&b.whole),
182 0,
183 "case + whitespace + zero-width must not change the fingerprint"
184 );
185 }
186
187 #[test]
188 fn single_word_edit_stays_within_threshold() {
189 let a = Fingerprint::compute(LONG);
190 let edited = LONG.replacen("copied", "duplicated", 1);
191 let b = Fingerprint::compute(&edited);
192 let d = a.whole.hamming(&b.whole);
193 assert!(
194 d <= MATCH_THRESHOLD,
195 "single-word edit distance {d} exceeded threshold"
196 );
197 }
198
199 #[test]
200 fn different_documents_diverge() {
201 let a = Fingerprint::compute(LONG);
202 let other = Fingerprint::compute(
203 "Chocolate chip cookies require flour, sugar, butter, eggs, and vanilla; \
204 preheat the oven to three hundred and fifty degrees before mixing anything.",
205 );
206 assert!(
207 a.whole.hamming(&other.whole) > MATCH_THRESHOLD,
208 "unrelated documents must exceed the match threshold"
209 );
210 }
211
212 #[test]
213 fn windows_present_for_long_text() {
214 let big = LONG.repeat(4);
215 let fp = Fingerprint::compute(&big);
216 assert!(
217 !fp.windows.is_empty(),
218 "long text must produce window blocks"
219 );
220 assert!(fp.windows.iter().all(|w| w.len <= WINDOW_CHARS));
221 }
222
223 #[test]
224 fn excerpt_matches_via_window() {
225 let big = LONG.repeat(3);
226 let fp = Fingerprint::compute(&big);
227 let excerpt: String = big.chars().skip(400).take(500).collect();
229 let ex = Fingerprint::compute(&excerpt);
230 assert!(
231 fp.matches(&ex.whole),
232 "an excerpt should match one of the window fingerprints"
233 );
234 }
235
236 #[test]
237 fn hex_roundtrip() {
238 let fp = Fingerprint::compute(LONG);
239 let hex = fp.whole.to_hex();
240 assert_eq!(hex.len(), 64);
241 assert_eq!(Hash256::from_hex(&hex), Some(fp.whole));
242 }
243
244 #[test]
247 fn vector_whole_fingerprint() {
248 let fp = Fingerprint::compute("The quick brown fox jumps over the lazy dog.");
249 assert_eq!(
250 fp.whole.to_hex(),
251 "aa53230a3df3561f2f6d00bdd84907a5b628d2c694a445656620152bcad0d274",
252 "PIN: recompute and update this vector only on an intentional algorithm change"
253 );
254 }
255}