1use crate::normalize::structural;
10use crate::simhash::{simhash_weighted, Hash256};
11
12pub const MATCH_THRESHOLD: u32 = 24;
14
15const W_SENTENCE: i64 = 3;
18const W_PARAGRAPH: i64 = 2;
19const W_PUNCT: i64 = 1;
20const W_SKELETON: i64 = 2;
21
22const FUNCTION_WORDS: &[&str] = &[
25 "the", "a", "an", "and", "or", "but", "nor", "so", "yet", "for", "of", "to", "in", "on", "at",
26 "by", "as", "is", "are", "was", "were", "be", "been", "being", "am", "do", "does", "did",
27 "have", "has", "had", "will", "would", "shall", "should", "can", "could", "may", "might",
28 "must", "with", "from", "into", "onto", "upon", "over", "under", "above", "below", "between",
29 "through", "during", "before", "after", "about", "against", "among", "around", "because", "if",
30 "then", "than", "that", "this", "these", "those", "it", "its", "he", "she", "they", "them",
31 "his", "her", "their", "our", "your", "my", "we", "you", "i", "me", "us", "who", "whom",
32 "whose", "which", "what", "when", "where", "why", "how", "not", "no", "yes", "all", "any",
33 "some", "each", "every", "both", "few", "many", "more", "most", "other", "such", "only", "own",
34 "same", "too", "very", "just", "also", "here", "there", "up", "down", "out", "off", "again",
35 "once", "while", "until",
36];
37
38pub fn compute(text: &str) -> Hash256 {
40 let norm = structural(text);
41 let paragraphs = split_paragraphs(&norm);
42
43 let mut sentence_lengths: Vec<usize> = Vec::new();
44 let mut paragraph_lengths: Vec<usize> = Vec::new();
45 let mut skeleton_tokens: Vec<String> = Vec::new();
46
47 for para in ¶graphs {
48 let sentences = split_sentences(para);
49 paragraph_lengths.push(sentences.len());
50 for sent in &sentences {
51 let words = word_tokens(sent);
52 sentence_lengths.push(words.len());
53 for w in &words {
54 if FUNCTION_WORDS.contains(&w.as_str()) {
55 skeleton_tokens.push(w.clone());
56 } else {
57 skeleton_tokens.push("_".to_string());
58 }
59 }
60 }
61 }
62
63 let mut features: Vec<(String, i64)> = Vec::new();
65
66 for len in &sentence_lengths {
68 features.push((format!("SL1:{len}"), W_SENTENCE));
69 }
70 for pair in sentence_lengths.windows(2) {
71 features.push((format!("SL2:{},{}", pair[0], pair[1]), W_SENTENCE));
72 }
73
74 for len in ¶graph_lengths {
76 features.push((format!("PL:{len}"), W_PARAGRAPH));
77 }
78
79 for (class, count) in punctuation_profile(&norm) {
81 features.push((format!("PUNCT:{class}"), W_PUNCT * count as i64));
82 }
83
84 if skeleton_tokens.len() >= 3 {
86 for w in skeleton_tokens.windows(3) {
87 features.push((format!("FW:{} {} {}", w[0], w[1], w[2]), W_SKELETON));
88 }
89 } else if !skeleton_tokens.is_empty() {
90 features.push((format!("FW:{}", skeleton_tokens.join(" ")), W_SKELETON));
91 }
92
93 simhash_weighted(features.iter().map(|(k, w)| (k.as_bytes(), *w)))
94}
95
96pub fn matches(a: &Hash256, b: &Hash256) -> bool {
99 a.hamming(b) <= MATCH_THRESHOLD
100}
101
102fn split_paragraphs(text: &str) -> Vec<String> {
103 let mut paras = Vec::new();
104 let mut current = String::new();
105 let mut blank_run = 0;
106 for line in text.lines() {
107 if line.trim().is_empty() {
108 blank_run += 1;
109 if blank_run >= 1 && !current.trim().is_empty() {
110 paras.push(std::mem::take(&mut current));
111 }
112 } else {
113 blank_run = 0;
114 current.push_str(line);
115 current.push(' ');
116 }
117 }
118 if !current.trim().is_empty() {
119 paras.push(current);
120 }
121 if paras.is_empty() {
122 paras.push(text.to_string());
123 }
124 paras
125}
126
127fn split_sentences(para: &str) -> Vec<String> {
128 let mut sentences = Vec::new();
129 let mut current = String::new();
130 for c in para.chars() {
131 current.push(c);
132 if matches!(c, '.' | '!' | '?') && current.trim().len() > 1 {
133 sentences.push(std::mem::take(&mut current));
134 }
135 }
136 if !current.trim().is_empty() {
137 sentences.push(current);
138 }
139 if sentences.is_empty() && !para.trim().is_empty() {
140 sentences.push(para.to_string());
141 }
142 sentences
143}
144
145fn word_tokens(sentence: &str) -> Vec<String> {
146 sentence
147 .split(|c: char| !c.is_alphanumeric())
148 .filter(|w| !w.is_empty())
149 .map(|w| w.to_lowercase())
150 .collect()
151}
152
153fn punctuation_profile(text: &str) -> Vec<(char, usize)> {
155 let mut counts: std::collections::BTreeMap<char, usize> = std::collections::BTreeMap::new();
156 for c in text.chars() {
157 if let Some(class) = punctuation_class(c) {
158 *counts.entry(class).or_insert(0) += 1;
159 }
160 }
161 counts.into_iter().collect()
162}
163
164fn punctuation_class(c: char) -> Option<char> {
166 match c {
167 '.' | '\u{2026}' => Some('.'), ',' => Some(','),
169 ';' => Some(';'),
170 ':' => Some(':'),
171 '?' => Some('?'),
172 '!' => Some('!'),
173 '-' | '\u{2013}' | '\u{2014}' => Some('-'), '(' | ')' | '[' | ']' | '{' | '}' => Some('('),
175 '"' | '\'' | '\u{201C}' | '\u{201D}' | '\u{2018}' | '\u{2019}' => Some('"'),
176 _ => None,
177 }
178}
179
180#[cfg(test)]
181mod tests {
182 use super::*;
183
184 const SRC: &str = "Provenance must survive editing. A soft binding derives a durable value \
185 from the words themselves. When the embedded manifest is stripped, a resolver \
186 recomputes the value and finds the manifest again.\n\nText is mutable, so no single \
187 technique wins. A family of algorithms with different fragility profiles is combined \
188 by the verifier. Casual redistribution stays fully recoverable.";
189
190 #[test]
191 fn identical_text_zero_distance() {
192 let a = compute(SRC);
193 let b = compute(SRC);
194 assert_eq!(a.hamming(&b), 0);
195 }
196
197 #[test]
198 fn synonym_paraphrase_stays_within_threshold() {
199 let a = compute(SRC);
200 let para = SRC
203 .replace("survive", "outlast")
204 .replace("durable", "lasting")
205 .replace("stripped", "removed")
206 .replace("recomputes", "recalculates")
207 .replace("mutable", "changeable")
208 .replace("technique", "method")
209 .replace("fragility", "brittleness")
210 .replace("redistribution", "resharing");
211 let b = compute(¶);
212 let d = a.hamming(&b);
213 assert!(
214 d <= MATCH_THRESHOLD,
215 "synonym-level paraphrase distance {d} exceeded structural threshold"
216 );
217 }
218
219 #[test]
220 fn reformatting_survives() {
221 let a = compute(SRC);
222 let b = compute(&SRC.replace(". ", ". \u{200B}"));
223 assert!(matches(&a, &b));
224 }
225
226 #[test]
227 fn different_structure_diverges() {
228 let a = compute(SRC);
229 let b = compute(
230 "One short line. Another. And a third! Then a question? Yes. No. Maybe. \
231 Terse fragments everywhere, with commas, dashes — and semicolons; many of them.",
232 );
233 assert!(
234 a.hamming(&b) > MATCH_THRESHOLD,
235 "a structurally different document must exceed the threshold"
236 );
237 }
238
239 #[test]
241 fn vector_structure() {
242 let h = compute("First sentence here. Second one follows. Third to close it.");
243 assert_eq!(
244 h.to_hex(),
245 "1e7b9ab9dabce9dfe4461a259434f09be52147161a0228234838f86e7dc60a62",
246 "PIN: recompute and update on any intentional algorithm change"
247 );
248 }
249}