1use crate::frontend::Token;
11
12use super::normalize::{NormAtom, NormToken};
13
14const FNV_OFFSET: u64 = 0xcbf2_9ce4_8422_2325;
15const FNV_PRIME: u64 = 0x0000_0100_0000_01b3;
16
17#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
23pub(crate) struct ContentDigest([u8; 16]);
24
25impl ContentDigest {
26 #[cfg(test)]
27 pub(crate) const fn from_bytes(bytes: [u8; 16]) -> Self {
28 Self(bytes)
29 }
30}
31
32#[derive(Debug, Clone, Copy)]
34struct Fnv(u64);
35
36impl Fnv {
37 const fn new() -> Self {
38 Self(FNV_OFFSET)
39 }
40
41 const fn byte(mut self, b: u8) -> Self {
42 self.0 ^= b as u64;
43 self.0 = self.0.wrapping_mul(FNV_PRIME);
44 self
45 }
46
47 fn bytes(mut self, bytes: &[u8]) -> Self {
48 for &b in bytes {
49 self = self.byte(b);
50 }
51 self
52 }
53
54 const fn finish(self) -> u64 {
55 self.0
56 }
57}
58
59#[must_use]
61pub fn raw_token_hash(token: &Token) -> u64 {
62 Fnv::new()
63 .byte(token.kind.tag())
64 .bytes(token.text.as_bytes())
65 .finish()
66}
67
68#[must_use]
70pub fn norm_token_hash(token: &NormToken<'_>) -> u64 {
71 let h = Fnv::new().byte(token.tag);
72 match token.atom {
73 NormAtom::Renamed(n) => h.byte(1).bytes(&n.to_le_bytes()),
74 NormAtom::Text(text) => h.byte(2).bytes(text.as_bytes()),
75 NormAtom::Literal(class) => h.byte(3).byte(class),
76 }
77 .finish()
78}
79
80#[must_use]
82pub fn raw_sequence_hash(tokens: &[Token]) -> u64 {
83 tokens
84 .iter()
85 .fold(Fnv::new(), |h, t| h.bytes(&raw_token_hash(t).to_le_bytes()))
86 .finish()
87}
88
89#[must_use]
91pub fn norm_sequence_hash(tokens: &[NormToken<'_>]) -> u64 {
92 tokens
93 .iter()
94 .fold(Fnv::new(), |h, t| {
95 h.bytes(&norm_token_hash(t).to_le_bytes())
96 })
97 .finish()
98}
99
100#[must_use]
102pub(crate) fn raw_sequence_digest(tokens: &[Token]) -> ContentDigest {
103 let mut hasher = sequence_digest_hasher("codehelion/group/raw/v1", tokens.len());
104 for token in tokens {
105 hasher.update(&[token.kind.tag()]);
106 write_bytes(&mut hasher, token.text.as_bytes());
107 }
108 finish_digest(&hasher)
109}
110
111#[must_use]
113pub(crate) fn norm_sequence_digest(tokens: &[NormToken<'_>]) -> ContentDigest {
114 let mut hasher = sequence_digest_hasher("codehelion/group/normalized/v1", tokens.len());
115 for token in tokens {
116 hasher.update(&[token.tag]);
117 match token.atom {
118 NormAtom::Renamed(value) => {
119 hasher.update(&[1]);
120 hasher.update(&value.to_le_bytes());
121 }
122 NormAtom::Text(text) => {
123 hasher.update(&[2]);
124 write_bytes(&mut hasher, text.as_bytes());
125 }
126 NormAtom::Literal(class) => {
127 hasher.update(&[3, class]);
128 }
129 }
130 }
131 finish_digest(&hasher)
132}
133
134fn sequence_digest_hasher(domain: &str, token_count: usize) -> blake3::Hasher {
135 let mut hasher = blake3::Hasher::new();
136 write_bytes(&mut hasher, domain.as_bytes());
137 hasher.update(&u64::try_from(token_count).unwrap_or(u64::MAX).to_le_bytes());
138 hasher
139}
140
141fn write_bytes(hasher: &mut blake3::Hasher, bytes: &[u8]) {
142 hasher.update(&u64::try_from(bytes.len()).unwrap_or(u64::MAX).to_le_bytes());
143 hasher.update(bytes);
144}
145
146fn finish_digest(hasher: &blake3::Hasher) -> ContentDigest {
147 let mut bytes = [0; 16];
148 bytes.copy_from_slice(&hasher.finalize().as_bytes()[..16]);
149 ContentDigest(bytes)
150}
151
152#[must_use]
157pub fn kgram_hashes(units: &[u64], k: usize) -> Vec<u64> {
158 const B: u64 = FNV_PRIME;
159 if k == 0 || units.len() < k {
160 return Vec::new();
161 }
162 let pow = B.wrapping_pow(u32::try_from(k - 1).unwrap_or(u32::MAX));
163 let mut out = Vec::with_capacity(units.len() - k + 1);
164 let mut h: u64 = 0;
165 for &u in &units[..k] {
166 h = h.wrapping_mul(B).wrapping_add(u);
167 }
168 out.push(h);
169 for i in k..units.len() {
170 h = h
171 .wrapping_sub(units[i - k].wrapping_mul(pow))
172 .wrapping_mul(B)
173 .wrapping_add(units[i]);
174 out.push(h);
175 }
176 out
177}
178
179#[must_use]
189pub fn winnow(hashes: &[u64], w: usize) -> Vec<(u64, usize)> {
190 use std::collections::VecDeque;
191
192 if hashes.is_empty() || w == 0 {
193 return Vec::new();
194 }
195 if hashes.len() < w {
196 let mut best = 0usize;
197 for (i, &h) in hashes.iter().enumerate() {
198 if h <= hashes[best] {
199 best = i;
200 }
201 }
202 return vec![(hashes[best], best)];
203 }
204
205 let mut candidates = VecDeque::with_capacity(w);
206 let mut picks = Vec::with_capacity(hashes.len().div_ceil(w));
207 for (index, &hash) in hashes.iter().enumerate() {
208 while candidates
211 .back()
212 .is_some_and(|&previous| hashes[previous] >= hash)
213 {
214 candidates.pop_back();
215 }
216 candidates.push_back(index);
217
218 if index + 1 < w {
219 continue;
220 }
221 let start = index + 1 - w;
222 while candidates.front().is_some_and(|&previous| previous < start) {
223 candidates.pop_front();
224 }
225 let best = *candidates.front().unwrap_or(&index);
226 if picks.last().is_none_or(|&(_, previous)| previous != best) {
227 picks.push((hashes[best], best));
228 }
229 }
230 picks
231}
232
233#[cfg(test)]
234#[allow(clippy::expect_used, clippy::unwrap_used)]
235mod tests {
236 use super::*;
237
238 #[test]
239 fn kgram_count_and_rolling_consistency() {
240 let units: Vec<u64> = (0..40u64).map(|i| i.wrapping_mul(0x9e37_79b9)).collect();
241 let k = 5;
242 let hashes = kgram_hashes(&units, k);
243 assert_eq!(hashes.len(), units.len() - k + 1);
244 for (i, &h) in hashes.iter().enumerate() {
246 let direct = units[i..i + k]
247 .iter()
248 .fold(0u64, |acc, &u| acc.wrapping_mul(FNV_PRIME).wrapping_add(u));
249 assert_eq!(h, direct, "gram {i}");
250 }
251 }
252
253 #[test]
254 fn kgram_short_input_is_empty() {
255 assert!(kgram_hashes(&[1, 2, 3], 4).is_empty());
256 assert!(kgram_hashes(&[], 1).is_empty());
257 }
258
259 #[test]
260 fn winnow_covers_every_window() {
261 let hashes: Vec<u64> = (0..100u64).map(|i| i.wrapping_mul(0x517c_c1b7)).collect();
262 let w = 4;
263 let picks = winnow(&hashes, w);
264 let picked: std::collections::BTreeSet<usize> = picks.iter().map(|&(_, i)| i).collect();
266 for start in 0..=(hashes.len() - w) {
267 assert!(
268 (start..start + w).any(|i| picked.contains(&i)),
269 "window at {start} has no pick"
270 );
271 }
272 }
273
274 #[test]
275 fn winnow_short_input_selects_global_min() {
276 let hashes = [50u64, 10, 30];
277 let picks = winnow(&hashes, 8);
278 assert_eq!(picks, vec![(10, 1)]);
279 }
280
281 #[test]
282 fn winnow_is_deterministic() {
283 let hashes: Vec<u64> = (0..64u64).map(|i| i ^ (i << 3)).collect();
284 assert_eq!(winnow(&hashes, 4), winnow(&hashes, 4));
285 }
286
287 #[test]
288 fn winnow_matches_window_rescanning_for_ties_and_every_window_size() {
289 fn reference(hashes: &[u64], w: usize) -> Vec<(u64, usize)> {
290 use std::collections::BTreeSet;
291
292 if hashes.is_empty() || w == 0 {
293 return Vec::new();
294 }
295 let mut picks = BTreeSet::new();
296 for start in 0..hashes.len().saturating_sub(w).saturating_add(1) {
297 let end = (start + w).min(hashes.len());
298 let best = (start..end).min_by_key(|&index| (hashes[index], usize::MAX - index));
299 if let Some(best) = best {
300 picks.insert((best, hashes[best]));
301 }
302 }
303 picks
304 .into_iter()
305 .map(|(index, hash)| (hash, index))
306 .collect()
307 }
308
309 let hashes = [9, 4, 4, 7, 2, 2, 2, 5, 1, 1, 8, 3];
310 for w in 0..=hashes.len() + 2 {
311 assert_eq!(winnow(&hashes, w), reference(&hashes, w), "window {w}");
312 }
313 }
314
315 #[test]
316 fn sequence_hash_distinguishes_order_and_content() {
317 use crate::engine::normalize::{NormAtom, NormToken};
318 let a = [
319 NormToken {
320 tag: 1,
321 atom: NormAtom::Renamed(0),
322 },
323 NormToken {
324 tag: 4,
325 atom: NormAtom::Text("+"),
326 },
327 ];
328 let b = [
329 NormToken {
330 tag: 4,
331 atom: NormAtom::Text("+"),
332 },
333 NormToken {
334 tag: 1,
335 atom: NormAtom::Renamed(0),
336 },
337 ];
338 assert_ne!(norm_sequence_hash(&a), norm_sequence_hash(&b));
339 assert_eq!(norm_sequence_hash(&a), norm_sequence_hash(&a));
341 }
342}