use crate::normalize::canonical;
use blake2::{Blake2b512, Digest};
use std::collections::HashMap;
pub const FP_BITS: usize = 256;
pub const MATCH_THRESHOLD: u32 = 32;
pub const WINDOW_CHARS: usize = 512;
pub const WINDOW_STEP: usize = 256;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Hash256(pub [u8; 32]);
impl Hash256 {
pub fn hamming(&self, other: &Self) -> u32 {
self.0
.iter()
.zip(other.0.iter())
.map(|(a, b)| (a ^ b).count_ones())
.sum()
}
pub fn to_hex(&self) -> String {
hex::encode(self.0)
}
pub fn from_hex(s: &str) -> Option<Self> {
let bytes = hex::decode(s).ok()?;
if bytes.len() != 32 {
return None;
}
let mut out = [0u8; 32];
out.copy_from_slice(&bytes);
Some(Hash256(out))
}
}
pub(crate) fn blake2_256(bytes: &[u8]) -> [u8; 32] {
let mut h = Blake2b512::new();
h.update(bytes);
let digest = h.finalize();
let mut out = [0u8; 32];
out.copy_from_slice(&digest[..32]);
out
}
pub(crate) fn simhash_weighted<'a, I>(features: I) -> Hash256
where
I: IntoIterator<Item = (&'a [u8], i64)>,
{
let mut acc = [0i64; FP_BITS];
for (bytes, weight) in features {
let h = blake2_256(bytes);
for (bit, slot) in acc.iter_mut().enumerate() {
let set = (h[bit / 8] >> (7 - (bit % 8))) & 1 == 1;
*slot += if set { weight } else { -weight };
}
}
let mut out = [0u8; 32];
for (bit, &count) in acc.iter().enumerate() {
if count > 0 {
out[bit / 8] |= 1 << (7 - (bit % 8));
}
}
Hash256(out)
}
fn simhash_4grams(chars: &[char]) -> Hash256 {
if chars.is_empty() {
return Hash256([0u8; 32]);
}
let n = 4.min(chars.len());
let mut counts: HashMap<String, i64> = HashMap::new();
for gram in chars.windows(n) {
let key: String = gram.iter().collect();
*counts.entry(key).or_insert(0) += 1;
}
simhash_weighted(counts.iter().map(|(k, w)| (k.as_bytes(), *w)))
}
#[derive(Clone, Debug)]
pub struct WindowFp {
pub start: usize,
pub len: usize,
pub hash: Hash256,
}
#[derive(Clone, Debug)]
pub struct Fingerprint {
pub whole: Hash256,
pub windows: Vec<WindowFp>,
}
impl Fingerprint {
pub fn compute(text: &str) -> Self {
let norm = canonical(text);
let chars: Vec<char> = norm.chars().collect();
let whole = simhash_4grams(&chars);
let mut windows = Vec::new();
if chars.len() > WINDOW_CHARS {
let mut start = 0;
loop {
let end = (start + WINDOW_CHARS).min(chars.len());
windows.push(WindowFp {
start,
len: end - start,
hash: simhash_4grams(&chars[start..end]),
});
if end == chars.len() {
break;
}
start += WINDOW_STEP;
}
}
Fingerprint { whole, windows }
}
pub fn best_distance(&self, candidate: &Hash256) -> u32 {
let mut best = self.whole.hamming(candidate);
for w in &self.windows {
best = best.min(w.hash.hamming(candidate));
}
best
}
pub fn matches(&self, candidate: &Hash256) -> bool {
self.best_distance(candidate) <= MATCH_THRESHOLD
}
}
#[cfg(test)]
mod tests {
use super::*;
const LONG: &str = "The principles of provenance require that a document's origin \
can be recovered even after it has been copied, reformatted, or lightly edited. \
A soft binding derives a durable value from the words themselves so that a \
manifest can be found again when the embedded one is stripped away by a careless \
copy-paste or an aggressive text pipeline that normalizes everything in sight.";
#[test]
fn identical_text_zero_distance() {
let a = Fingerprint::compute(LONG);
let b = Fingerprint::compute(LONG);
assert_eq!(a.whole.hamming(&b.whole), 0);
}
#[test]
fn reformatting_and_zero_width_survive() {
let a = Fingerprint::compute(LONG);
let reformatted = format!(" {} ", LONG.to_uppercase().replace(' ', "\u{200B} "));
let b = Fingerprint::compute(&reformatted);
assert_eq!(
a.whole.hamming(&b.whole),
0,
"case + whitespace + zero-width must not change the fingerprint"
);
}
#[test]
fn single_word_edit_stays_within_threshold() {
let a = Fingerprint::compute(LONG);
let edited = LONG.replacen("copied", "duplicated", 1);
let b = Fingerprint::compute(&edited);
let d = a.whole.hamming(&b.whole);
assert!(
d <= MATCH_THRESHOLD,
"single-word edit distance {d} exceeded threshold"
);
}
#[test]
fn different_documents_diverge() {
let a = Fingerprint::compute(LONG);
let other = Fingerprint::compute(
"Chocolate chip cookies require flour, sugar, butter, eggs, and vanilla; \
preheat the oven to three hundred and fifty degrees before mixing anything.",
);
assert!(
a.whole.hamming(&other.whole) > MATCH_THRESHOLD,
"unrelated documents must exceed the match threshold"
);
}
#[test]
fn windows_present_for_long_text() {
let big = LONG.repeat(4);
let fp = Fingerprint::compute(&big);
assert!(
!fp.windows.is_empty(),
"long text must produce window blocks"
);
assert!(fp.windows.iter().all(|w| w.len <= WINDOW_CHARS));
}
#[test]
fn excerpt_matches_via_window() {
let big = LONG.repeat(3);
let fp = Fingerprint::compute(&big);
let excerpt: String = big.chars().skip(400).take(500).collect();
let ex = Fingerprint::compute(&excerpt);
assert!(
fp.matches(&ex.whole),
"an excerpt should match one of the window fingerprints"
);
}
#[test]
fn hex_roundtrip() {
let fp = Fingerprint::compute(LONG);
let hex = fp.whole.to_hex();
assert_eq!(hex.len(), 64);
assert_eq!(Hash256::from_hex(&hex), Some(fp.whole));
}
#[test]
fn vector_whole_fingerprint() {
let fp = Fingerprint::compute("The quick brown fox jumps over the lazy dog.");
assert_eq!(
fp.whole.to_hex(),
"aa53230a3df3561f2f6d00bdd84907a5b628d2c694a445656620152bcad0d274",
"PIN: recompute and update this vector only on an intentional algorithm change"
);
}
}