Skip to main content

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}