Skip to main content

codehelion_core/engine/
fingerprint.rs

1//! Deterministic content hashing: token hashes, rolling k-grams, winnowing.
2//!
3//! Every hash here is a pure function of token content (kind tags and
4//! normalized or raw text). No process randomness, no position, no file
5//! identity enters any hash, so runs are reproducible and equal content always
6//! collides intentionally. The 64-bit FNV hashes are used only for candidate
7//! indexing. Grouping uses a domain-separated 128-bit BLAKE3 digest, so an
8//! attacker-controlled FNV collision cannot combine unrelated findings.
9
10use 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/// Collision-resistant identity of one matched token sequence for grouping.
18///
19/// This is deliberately separate from the 64-bit FNV candidate key. The
20/// latter keeps the index compact; this digest protects the user-visible
21/// equivalence relation after candidates have been verified.
22#[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/// Incremental FNV-1a over bytes.
33#[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/// Hash one raw token: kind tag plus raw text.
60#[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/// Hash one normalized token: kind tag, atom discriminant, atom payload.
69#[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/// Content key of a raw token sequence: the fold of its per-token hashes.
81#[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/// Content key of a normalized token sequence.
90#[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/// Collision-resistant grouping identity of a raw token sequence.
101#[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/// Collision-resistant grouping identity of a normalized token sequence.
112#[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/// Rolling polynomial hashes of every k-gram of `units` (mod 2^64):
153/// `gram(i) = units[i]·B^(k-1) + … + units[i+k-1]`.
154///
155/// Returns an empty vector when the input is shorter than `k`.
156#[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/// Winnowing: select fingerprints from k-gram hashes.
180///
181/// Over every window of `w` consecutive hashes the minimum is selected
182/// (rightmost on ties). Inputs shorter than one window select their global
183/// minimum, so short segments are still fingerprinted.
184///
185/// Returns deduplicated `(hash, gram index)` pairs in ascending index order.
186/// The selection guarantees that any shared token run of at least `w + k - 1`
187/// tokens produces at least one shared fingerprint.
188#[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        // Remove equal values too: a later equal minimum is the required
209        // rightmost representative and outlives every earlier one.
210        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        // Each rolled hash equals a direct recomputation over its window.
245        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        // Every window of w consecutive grams contains at least one pick.
265        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        // Deterministic: the same input always hashes the same.
340        assert_eq!(norm_sequence_hash(&a), norm_sequence_hash(&a));
341    }
342}