pelicode_core/hash.rs
1//! Exact hashes, winnowing fingerprints, MinHash signatures and LSH buckets.
2
3use std::fmt;
4
5use serde::{Serialize, Serializer};
6use xxhash_rust::xxh3::{xxh3_64, xxh3_64_with_seed};
7
8/// 128-bit hash of a whole normalized representation.
9#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
10pub struct Hash128(pub [u8; 16]);
11
12impl Hash128 {
13 /// Hash of a token sequence: the first 128 bits of BLAKE3 over the tokens, each
14 /// followed by a separator, so that `["ab", "c"]` and `["a", "bc"]` differ.
15 ///
16 /// # Examples
17 ///
18 /// ```
19 /// use pelicode_core::Hash128;
20 ///
21 /// let tokens = |s: &str| s.split(' ').map(String::from).collect::<Vec<_>>();
22 /// assert_eq!(Hash128::of_tokens(&tokens("a b")), Hash128::of_tokens(&tokens("a b")));
23 /// assert_ne!(Hash128::of_tokens(&tokens("ab c")), Hash128::of_tokens(&tokens("a bc")));
24 /// ```
25 pub fn of_tokens(tokens: &[String]) -> Hash128 {
26 let mut hasher = blake3::Hasher::new();
27 for token in tokens {
28 hasher.update(token.as_bytes());
29 // A separator that never appears in source code tokens.
30 hasher.update(&[0x1f]);
31 }
32 let mut bytes = [0u8; 16];
33 bytes.copy_from_slice(&hasher.finalize().as_bytes()[..16]);
34 Hash128(bytes)
35 }
36
37 /// The hash as 32 lower-case hexadecimal digits.
38 ///
39 /// # Examples
40 ///
41 /// ```
42 /// use pelicode_core::Hash128;
43 ///
44 /// assert_eq!(Hash128([0xab; 16]).to_hex(), "ab".repeat(16));
45 /// ```
46 pub fn to_hex(&self) -> String {
47 to_hex(&self.0)
48 }
49
50 /// Parses 32 hexadecimal digits, as written by [`Hash128::to_hex`]; `None` for anything else.
51 ///
52 /// # Examples
53 ///
54 /// ```
55 /// use pelicode_core::Hash128;
56 ///
57 /// let hash = Hash128([0x01; 16]);
58 /// assert_eq!(Hash128::from_hex(&hash.to_hex()), Some(hash));
59 /// assert_eq!(Hash128::from_hex("not a hash"), None);
60 /// ```
61 pub fn from_hex(hex: &str) -> Option<Hash128> {
62 if hex.len() != 32 || !hex.is_ascii() {
63 return None;
64 }
65 let mut bytes = [0u8; 16];
66 for (i, byte) in bytes.iter_mut().enumerate() {
67 *byte = u8::from_str_radix(&hex[i * 2..i * 2 + 2], 16).ok()?;
68 }
69 Some(Hash128(bytes))
70 }
71}
72
73impl fmt::Display for Hash128 {
74 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
75 f.write_str(&self.to_hex())
76 }
77}
78
79impl Serialize for Hash128 {
80 fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
81 serializer.serialize_str(&self.to_hex())
82 }
83}
84
85/// Lower-case hexadecimal digits of `bytes`.
86///
87/// # Examples
88///
89/// ```
90/// use pelicode_core::hash::to_hex;
91///
92/// assert_eq!(to_hex(&[0x00, 0xff, 0x1a]), "00ff1a");
93/// ```
94pub fn to_hex(bytes: &[u8]) -> String {
95 bytes.iter().map(|b| format!("{b:02x}")).collect()
96}
97
98/// A k-gram hash selected by winnowing.
99#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
100pub struct Fingerprint {
101 /// Hash of the k-gram.
102 #[serde(serialize_with = "serialize_u64_hex")]
103 pub hash: u64,
104 /// Index of the first token of the k-gram.
105 pub position: u32,
106 /// Source lines covered by the k-gram (1-based, inclusive).
107 pub start_line: u32,
108 /// Last source line covered by the k-gram (1-based, inclusive).
109 pub end_line: u32,
110}
111
112pub(crate) fn serialize_u64_hex<S: Serializer>(
113 value: &u64,
114 serializer: S,
115) -> Result<S::Ok, S::Error> {
116 serializer.serialize_str(&format!("{value:016x}"))
117}
118
119pub(crate) fn serialize_u64_hex_vec<S: Serializer>(
120 values: &[u64],
121 serializer: S,
122) -> Result<S::Ok, S::Error> {
123 serializer.collect_seq(values.iter().map(|v| format!("{v:016x}")))
124}
125
126/// Hashes of all k-grams of `tokens`. A sequence shorter than `k` yields one k-gram.
127///
128/// # Examples
129///
130/// ```
131/// use pelicode_core::hash::kgram_hashes;
132///
133/// let tokens: Vec<String> = "a b c d".split(' ').map(String::from).collect();
134/// assert_eq!(kgram_hashes(&tokens, 2).len(), 3); // ab, bc, cd
135/// assert_eq!(kgram_hashes(&tokens, 10).len(), 1); // shorter than k: one k-gram
136/// assert!(kgram_hashes(&[], 2).is_empty());
137/// ```
138pub fn kgram_hashes(tokens: &[String], k: usize) -> Vec<u64> {
139 if tokens.is_empty() {
140 return Vec::new();
141 }
142 let token_hashes: Vec<u64> = tokens.iter().map(|t| xxh3_64(t.as_bytes())).collect();
143 let k = k.min(token_hashes.len());
144 token_hashes
145 .windows(k)
146 .map(|gram| {
147 let bytes: Vec<u8> = gram.iter().flat_map(|h| h.to_le_bytes()).collect();
148 xxh3_64(&bytes)
149 })
150 .collect()
151}
152
153/// Winnowing (Schleimer et al., 2003): the rightmost minimum of every window of `w` hashes,
154/// as `(position, hash)` pairs.
155///
156/// Every window of `w` consecutive hashes contains a selected position, so two
157/// sequences sharing a run of at least `w` hashes share a selected hash.
158///
159/// # Examples
160///
161/// ```
162/// use pelicode_core::hash::winnow;
163///
164/// let hashes = [77, 72, 42, 17, 98, 50, 17, 98, 8, 88, 67, 39, 77, 72, 42, 17, 98];
165/// let selected = winnow(&hashes, 4);
166/// assert_eq!(selected[0], (3, 17));
167/// for start in 0..=hashes.len() - 4 {
168/// assert!(selected.iter().any(|(pos, _)| (start..start + 4).contains(pos)));
169/// }
170/// ```
171pub fn winnow(hashes: &[u64], w: usize) -> Vec<(usize, u64)> {
172 let mut selected: Vec<(usize, u64)> = Vec::new();
173 if hashes.is_empty() {
174 return selected;
175 }
176 let w = w.min(hashes.len());
177 for start in 0..=hashes.len() - w {
178 let mut min_pos = start;
179 for pos in start..start + w {
180 if hashes[pos] <= hashes[min_pos] {
181 min_pos = pos;
182 }
183 }
184 if selected.last().is_none_or(|(pos, _)| *pos != min_pos) {
185 selected.push((min_pos, hashes[min_pos]));
186 }
187 }
188 selected
189}
190
191fn splitmix64(mut x: u64) -> u64 {
192 x = x.wrapping_add(0x9e37_79b9_7f4a_7c15);
193 x = (x ^ (x >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9);
194 x = (x ^ (x >> 27)).wrapping_mul(0x94d0_49bb_1331_11eb);
195 x ^ (x >> 31)
196}
197
198/// MinHash signature of a set of hashes; `None` for an empty set.
199///
200/// The signature does not depend on the order of the hashes or on duplicates.
201///
202/// # Examples
203///
204/// ```
205/// use pelicode_core::hash::minhash;
206///
207/// let a = minhash([1u64, 2, 3].into_iter(), 16).unwrap();
208/// let b = minhash([3u64, 2, 1, 1].into_iter(), 16).unwrap();
209/// assert_eq!(a, b);
210/// assert_eq!(a.len(), 16);
211/// assert_eq!(minhash(std::iter::empty(), 16), None);
212/// ```
213pub fn minhash(hashes: impl Iterator<Item = u64>, size: usize) -> Option<Vec<u64>> {
214 let mut signature = vec![u64::MAX; size];
215 let mut any = false;
216 for h in hashes {
217 any = true;
218 for (i, slot) in signature.iter_mut().enumerate() {
219 let seed = splitmix64(i as u64);
220 let v = splitmix64(h ^ seed);
221 if v < *slot {
222 *slot = v;
223 }
224 }
225 }
226 any.then_some(signature)
227}
228
229/// LSH bucket hashes: one per band of `signature`.
230///
231/// Two signatures share the bucket of a band when all the rows of that band agree,
232/// which is likely for similar sets and unlikely for dissimilar ones.
233///
234/// # Examples
235///
236/// ```
237/// use pelicode_core::hash::{lsh_buckets, minhash};
238///
239/// let a = minhash([1u64, 2, 3].into_iter(), 128).unwrap();
240/// let buckets = lsh_buckets(&a, 32);
241/// assert_eq!(buckets.len(), 32);
242/// assert_eq!(buckets, lsh_buckets(&a.clone(), 32));
243/// ```
244pub fn lsh_buckets(signature: &[u64], bands: usize) -> Vec<u64> {
245 let rows = signature.len() / bands;
246 signature
247 .chunks(rows)
248 .enumerate()
249 .map(|(band, chunk)| {
250 let bytes: Vec<u8> = chunk.iter().flat_map(|h| h.to_le_bytes()).collect();
251 xxh3_64_with_seed(&bytes, band as u64)
252 })
253 .collect()
254}
255
256/// Estimated Jaccard similarity of two MinHash signatures.
257///
258/// 0.0 when the signatures are empty or of different lengths.
259///
260/// # Examples
261///
262/// ```
263/// use pelicode_core::hash::{minhash, minhash_similarity};
264///
265/// let a = minhash((0u64..100).into_iter(), 128).unwrap();
266/// let b = minhash((0u64..100).into_iter(), 128).unwrap();
267/// let c = minhash((1000u64..1100).into_iter(), 128).unwrap();
268/// assert_eq!(minhash_similarity(&a, &b), 1.0);
269/// assert!(minhash_similarity(&a, &c) < 0.1);
270/// ```
271pub fn minhash_similarity(a: &[u64], b: &[u64]) -> f64 {
272 if a.is_empty() || a.len() != b.len() {
273 return 0.0;
274 }
275 let same = a.iter().zip(b).filter(|(x, y)| x == y).count();
276 same as f64 / a.len() as f64
277}
278
279#[cfg(test)]
280mod tests {
281 use super::*;
282
283 fn toks(s: &str) -> Vec<String> {
284 s.split_whitespace().map(String::from).collect()
285 }
286
287 #[test]
288 fn winnowing_covers_every_window() {
289 let hashes = kgram_hashes(&toks("a b c d e f g h i j k l m n"), 3);
290 let fps = winnow(&hashes, 4);
291 assert!(!fps.is_empty());
292 for start in 0..=hashes.len() - 4 {
293 assert!(fps.iter().any(|(pos, _)| (start..start + 4).contains(pos)));
294 }
295 }
296
297 #[test]
298 fn hex_round_trip() {
299 let h = Hash128::of_tokens(&toks("a b c"));
300 assert_eq!(Hash128::from_hex(&h.to_hex()), Some(h));
301 assert_eq!(Hash128::from_hex("zz"), None);
302 }
303
304 #[test]
305 fn short_sequences_still_have_a_fingerprint() {
306 let hashes = kgram_hashes(&toks("a b"), 5);
307 assert_eq!(hashes.len(), 1);
308 assert_eq!(winnow(&hashes, 4).len(), 1);
309 }
310
311 #[test]
312 fn minhash_of_identical_sets_is_identical() {
313 let a = minhash([1u64, 2, 3].into_iter(), 16).unwrap();
314 let b = minhash([3u64, 2, 1].into_iter(), 16).unwrap();
315 assert_eq!(minhash_similarity(&a, &b), 1.0);
316 assert_eq!(lsh_buckets(&a, 4), lsh_buckets(&b, 4));
317 assert!(minhash(std::iter::empty(), 16).is_none());
318 }
319}