use crate::normalize::structural;
use crate::simhash::{simhash_weighted, Hash256};
pub const MATCH_THRESHOLD: u32 = 24;
const W_SENTENCE: i64 = 3;
const W_PARAGRAPH: i64 = 2;
const W_PUNCT: i64 = 1;
const W_SKELETON: i64 = 2;
const FUNCTION_WORDS: &[&str] = &[
"the", "a", "an", "and", "or", "but", "nor", "so", "yet", "for", "of", "to", "in", "on", "at",
"by", "as", "is", "are", "was", "were", "be", "been", "being", "am", "do", "does", "did",
"have", "has", "had", "will", "would", "shall", "should", "can", "could", "may", "might",
"must", "with", "from", "into", "onto", "upon", "over", "under", "above", "below", "between",
"through", "during", "before", "after", "about", "against", "among", "around", "because", "if",
"then", "than", "that", "this", "these", "those", "it", "its", "he", "she", "they", "them",
"his", "her", "their", "our", "your", "my", "we", "you", "i", "me", "us", "who", "whom",
"whose", "which", "what", "when", "where", "why", "how", "not", "no", "yes", "all", "any",
"some", "each", "every", "both", "few", "many", "more", "most", "other", "such", "only", "own",
"same", "too", "very", "just", "also", "here", "there", "up", "down", "out", "off", "again",
"once", "while", "until",
];
pub fn compute(text: &str) -> Hash256 {
let norm = structural(text);
let paragraphs = split_paragraphs(&norm);
let mut sentence_lengths: Vec<usize> = Vec::new();
let mut paragraph_lengths: Vec<usize> = Vec::new();
let mut skeleton_tokens: Vec<String> = Vec::new();
for para in ¶graphs {
let sentences = split_sentences(para);
paragraph_lengths.push(sentences.len());
for sent in &sentences {
let words = word_tokens(sent);
sentence_lengths.push(words.len());
for w in &words {
if FUNCTION_WORDS.contains(&w.as_str()) {
skeleton_tokens.push(w.clone());
} else {
skeleton_tokens.push("_".to_string());
}
}
}
}
let mut features: Vec<(String, i64)> = Vec::new();
for len in &sentence_lengths {
features.push((format!("SL1:{len}"), W_SENTENCE));
}
for pair in sentence_lengths.windows(2) {
features.push((format!("SL2:{},{}", pair[0], pair[1]), W_SENTENCE));
}
for len in ¶graph_lengths {
features.push((format!("PL:{len}"), W_PARAGRAPH));
}
for (class, count) in punctuation_profile(&norm) {
features.push((format!("PUNCT:{class}"), W_PUNCT * count as i64));
}
if skeleton_tokens.len() >= 3 {
for w in skeleton_tokens.windows(3) {
features.push((format!("FW:{} {} {}", w[0], w[1], w[2]), W_SKELETON));
}
} else if !skeleton_tokens.is_empty() {
features.push((format!("FW:{}", skeleton_tokens.join(" ")), W_SKELETON));
}
simhash_weighted(features.iter().map(|(k, w)| (k.as_bytes(), *w)))
}
pub fn matches(a: &Hash256, b: &Hash256) -> bool {
a.hamming(b) <= MATCH_THRESHOLD
}
fn split_paragraphs(text: &str) -> Vec<String> {
let mut paras = Vec::new();
let mut current = String::new();
let mut blank_run = 0;
for line in text.lines() {
if line.trim().is_empty() {
blank_run += 1;
if blank_run >= 1 && !current.trim().is_empty() {
paras.push(std::mem::take(&mut current));
}
} else {
blank_run = 0;
current.push_str(line);
current.push(' ');
}
}
if !current.trim().is_empty() {
paras.push(current);
}
if paras.is_empty() {
paras.push(text.to_string());
}
paras
}
fn split_sentences(para: &str) -> Vec<String> {
let mut sentences = Vec::new();
let mut current = String::new();
for c in para.chars() {
current.push(c);
if matches!(c, '.' | '!' | '?') && current.trim().len() > 1 {
sentences.push(std::mem::take(&mut current));
}
}
if !current.trim().is_empty() {
sentences.push(current);
}
if sentences.is_empty() && !para.trim().is_empty() {
sentences.push(para.to_string());
}
sentences
}
fn word_tokens(sentence: &str) -> Vec<String> {
sentence
.split(|c: char| !c.is_alphanumeric())
.filter(|w| !w.is_empty())
.map(|w| w.to_lowercase())
.collect()
}
fn punctuation_profile(text: &str) -> Vec<(char, usize)> {
let mut counts: std::collections::BTreeMap<char, usize> = std::collections::BTreeMap::new();
for c in text.chars() {
if let Some(class) = punctuation_class(c) {
*counts.entry(class).or_insert(0) += 1;
}
}
counts.into_iter().collect()
}
fn punctuation_class(c: char) -> Option<char> {
match c {
'.' | '\u{2026}' => Some('.'), ',' => Some(','),
';' => Some(';'),
':' => Some(':'),
'?' => Some('?'),
'!' => Some('!'),
'-' | '\u{2013}' | '\u{2014}' => Some('-'), '(' | ')' | '[' | ']' | '{' | '}' => Some('('),
'"' | '\'' | '\u{201C}' | '\u{201D}' | '\u{2018}' | '\u{2019}' => Some('"'),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
const SRC: &str = "Provenance must survive editing. A soft binding derives a durable value \
from the words themselves. When the embedded manifest is stripped, a resolver \
recomputes the value and finds the manifest again.\n\nText is mutable, so no single \
technique wins. A family of algorithms with different fragility profiles is combined \
by the verifier. Casual redistribution stays fully recoverable.";
#[test]
fn identical_text_zero_distance() {
let a = compute(SRC);
let b = compute(SRC);
assert_eq!(a.hamming(&b), 0);
}
#[test]
fn synonym_paraphrase_stays_within_threshold() {
let a = compute(SRC);
let para = SRC
.replace("survive", "outlast")
.replace("durable", "lasting")
.replace("stripped", "removed")
.replace("recomputes", "recalculates")
.replace("mutable", "changeable")
.replace("technique", "method")
.replace("fragility", "brittleness")
.replace("redistribution", "resharing");
let b = compute(¶);
let d = a.hamming(&b);
assert!(
d <= MATCH_THRESHOLD,
"synonym-level paraphrase distance {d} exceeded structural threshold"
);
}
#[test]
fn reformatting_survives() {
let a = compute(SRC);
let b = compute(&SRC.replace(". ", ". \u{200B}"));
assert!(matches(&a, &b));
}
#[test]
fn different_structure_diverges() {
let a = compute(SRC);
let b = compute(
"One short line. Another. And a third! Then a question? Yes. No. Maybe. \
Terse fragments everywhere, with commas, dashes — and semicolons; many of them.",
);
assert!(
a.hamming(&b) > MATCH_THRESHOLD,
"a structurally different document must exceed the threshold"
);
}
#[test]
fn vector_structure() {
let h = compute("First sentence here. Second one follows. Third to close it.");
assert_eq!(
h.to_hex(),
"1e7b9ab9dabce9dfe4461a259434f09be52147161a0228234838f86e7dc60a62",
"PIN: recompute and update on any intentional algorithm change"
);
}
}