Skip to main content

vyre_libs/intern/
perfect_hash.rs

1//! CHD perfect-hash over label-family strings (G9).
2//!
3//! # Algorithm
4//!
5//! CHD (Compress, Hash, Displace)  -  Belazzougui, Botelho &
6//! Dietzfelbinger 2009. Given `n` keys, produce a perfect hash table
7//! of size `~1.23n` with one level of per-bucket displacements so
8//! lookup is:
9//!
10//! ```text
11//!   h1 = hash1(key) mod n_buckets
12//!   disp = displacement[h1]
13//!   slot = hash2(key, disp) mod table_size
14//!   if key_hashes[slot] == verify_hash(key): return values[slot]
15//!   else: return None
16//! ```
17//!
18//! Two 64-bit hashes with independent seeds, plus a third
19//! independent verify hash stored alongside the value (catches
20//! false hits from keys that weren't in the input set). Lookup is
21//! O(1): three hashes + two loads.
22//!
23//! Construction is Rust-host only; the resulting `PerfectHash`
24//! exposes the three buffers (displacement, key_hashes, values)
25//! GPU consumers upload once and lookup via subgroup-parallel
26//! evaluation.
27
28use rustc_hash::FxHashSet;
29use vyre_primitives::hash::fnv1a::{fnv1a64_initial_state, fnv1a64_update_byte};
30/// Space-factor α: table size = ⌈n × α⌉. 1.23 is the CHD paper's
31/// recommended sweet spot for 1k..1M-entry corpora.
32const ALPHA: f64 = 1.23;
33
34/// Buckets-per-slot. The paper uses n/4 buckets so each bucket
35/// averages ~4 keys and displacement search stays cheap.
36const BUCKET_DIVISOR: usize = 4;
37
38/// Cap on displacement-search attempts per bucket. Real corpora
39/// find a fit in <100 tries; 1M caps pathological inputs and
40/// triggers a salt retry.
41const MAX_DISPLACEMENT_TRIES: u32 = 1_000_000;
42
43/// Maximum salt retries before failing construction. Each retry
44/// picks a fresh seed pair. Real inputs typically land on the
45/// first salt.
46const MAX_SALT_RETRIES: u32 = 16;
47
48/// A constructed perfect hash table.
49#[derive(Debug, Clone, Default)]
50pub struct PerfectHash {
51    seed1: u64,
52    seed2: u64,
53    displacement: Vec<u32>,
54    key_hashes: Vec<u64>,
55    values: Vec<u32>,
56    len: usize,
57}
58
59impl PerfectHash {
60    /// Look up a key. O(1): two primary hashes + one verify hash +
61    /// two array loads. Returns `None` if `key` was not in the
62    /// input set.
63    pub fn lookup(&self, key: &str) -> Option<u32> {
64        if self.displacement.is_empty() {
65            return None;
66        }
67        let bytes = key.as_bytes();
68        let h1 = hash_with_seed(bytes, self.seed1) as usize;
69        let bucket = h1 % self.displacement.len();
70        let disp = self.displacement[bucket];
71        let h2 = hash_with_seed(bytes, self.seed2.wrapping_add(disp as u64));
72        let slot = (h2 as usize) % self.key_hashes.len();
73        if self.key_hashes[slot] == hash_verify(bytes) {
74            Some(self.values[slot])
75        } else {
76            None
77        }
78    }
79
80    /// Number of entries inserted.
81    pub fn len(&self) -> usize {
82        self.len
83    }
84
85    /// Whether the hash is empty.
86    pub fn is_empty(&self) -> bool {
87        self.len == 0
88    }
89
90    /// Total slot count (≥ len(), ~1.23× len() after rounding).
91    pub fn slots(&self) -> usize {
92        self.key_hashes.len()
93    }
94
95    /// Displacement table  -  GPU ReadOnly buffer.
96    pub fn displacement(&self) -> &[u32] {
97        &self.displacement
98    }
99
100    /// Key-hash verification table  -  GPU ReadOnly buffer.
101    pub fn key_hashes(&self) -> &[u64] {
102        &self.key_hashes
103    }
104
105    /// Value table  -  GPU ReadOnly buffer.
106    pub fn values(&self) -> &[u32] {
107        &self.values
108    }
109
110    /// `(seed1, seed2)` used at construction. GPU consumers need
111    /// both to reproduce the bucket + slot hash.
112    pub fn seeds(&self) -> (u64, u64) {
113        (self.seed1, self.seed2)
114    }
115}
116
117/// Build a CHD perfect hash from `(key, value)` pairs.
118///
119/// Panics if construction fails. Use [`try_build_chd`] when the caller needs
120/// recoverable diagnostics for duplicate or adversarial keys.
121pub fn build_chd<I, S>(entries: I) -> PerfectHash
122where
123    I: IntoIterator<Item = (S, u32)>,
124    S: AsRef<str>,
125{
126    try_build_chd(entries).unwrap_or_default()
127}
128
129/// Fallible variant of [`build_chd`].
130pub fn try_build_chd<I, S>(entries: I) -> Result<PerfectHash, BuildError>
131where
132    I: IntoIterator<Item = (S, u32)>,
133    S: AsRef<str>,
134{
135    let pairs: Vec<(String, u32)> = entries
136        .into_iter()
137        .map(|(k, v)| (k.as_ref().to_owned(), v))
138        .collect();
139
140    if pairs.is_empty() {
141        return Ok(PerfectHash::default());
142    }
143
144    // Dedupe check.
145    let mut seen = FxHashSet::default();
146    seen.reserve(pairs.len());
147    for (k, _) in &pairs {
148        if !seen.insert(k.as_str()) {
149            return Err(BuildError::DuplicateKey(k.clone()));
150        }
151    }
152
153    for salt in 0..MAX_SALT_RETRIES {
154        if let Some(ph) = try_build_with_salt(&pairs, salt as u64) {
155            return Ok(ph);
156        }
157    }
158    Err(BuildError::ConstructionFailed(pairs.len()))
159}
160
161fn try_build_with_salt(pairs: &[(String, u32)], salt: u64) -> Option<PerfectHash> {
162    let n = pairs.len();
163    let table_size = (((n as f64) * ALPHA).ceil() as usize) | 1;
164    let n_buckets = ((n / BUCKET_DIVISOR).max(1)) | 1;
165
166    let seed1 = salt.wrapping_mul(0x9E37_79B9_7F4A_7C15).wrapping_add(1);
167    let seed2 = salt
168        .wrapping_mul(0xBF58_476D_1CE4_E5B9)
169        .wrapping_add(0xDEAD_BEEF_CAFE_BABE);
170
171    // Bucket each key by hash1 without allocating one Vec per bucket.
172    let mut bucket_offsets = vec![0usize; n_buckets + 1];
173    for (k, _) in pairs {
174        let h = hash_with_seed(k.as_bytes(), seed1) as usize;
175        bucket_offsets[h % n_buckets + 1] += 1;
176    }
177    for i in 1..bucket_offsets.len() {
178        bucket_offsets[i] += bucket_offsets[i - 1];
179    }
180    let mut bucket_cursor = bucket_offsets[..n_buckets].to_vec();
181    let mut bucket_items = vec![0usize; n];
182    for (i, (k, _)) in pairs.iter().enumerate() {
183        let h = hash_with_seed(k.as_bytes(), seed1) as usize;
184        let bucket = h % n_buckets;
185        let slot = bucket_cursor[bucket];
186        bucket_items[slot] = i;
187        bucket_cursor[bucket] += 1;
188    }
189
190    // Process buckets in descending-size order  -  hardest first.
191    let mut bucket_order: Vec<usize> = (0..n_buckets).collect();
192    bucket_order.sort_by_key(|&b| std::cmp::Reverse(bucket_offsets[b + 1] - bucket_offsets[b]));
193
194    let mut displacement = vec![0_u32; n_buckets];
195    let mut key_hashes = vec![0_u64; table_size];
196    let mut values = vec![0_u32; table_size];
197    let mut occupied = vec![false; table_size];
198    let mut candidate_scratch = vec![false; table_size];
199    let mut candidate_slots = Vec::new();
200
201    'bucket: for b in bucket_order {
202        let bucket = &bucket_items[bucket_offsets[b]..bucket_offsets[b + 1]];
203        if bucket.is_empty() {
204            continue;
205        }
206        // PHASE5_ASTWALK MEDIUM: previous `candidate_slots.contains`
207        // was O(bucket) per entry, which becomes O(bucket²) per
208        // displacement try under adversarial collisions. A
209        // scratchpad `Vec<bool>` occupancy table (also declared
210        // outside the displacement loop and cleared only on success)
211        // keeps the check O(1). The scratch vec is reused across
212        // displacement tries, which is why we zero the touched
213        // slots rather than reallocating.
214        for disp in 0..MAX_DISPLACEMENT_TRIES {
215            candidate_slots.clear();
216            candidate_slots.reserve(bucket.len());
217            let mut ok = true;
218            for &ki in bucket {
219                let key = pairs[ki].0.as_bytes();
220                let h2 = hash_with_seed(key, seed2.wrapping_add(disp as u64));
221                let slot = (h2 as usize) % table_size;
222                if occupied[slot] || candidate_scratch[slot] {
223                    ok = false;
224                    break;
225                }
226                candidate_scratch[slot] = true;
227                candidate_slots.push(slot);
228            }
229            // Always clear the scratch before the next iteration,
230            // whether the try succeeded or failed.
231            for slot in &candidate_slots {
232                candidate_scratch[*slot] = false;
233            }
234            if ok {
235                displacement[b] = disp;
236                for (ki, slot) in bucket.iter().zip(candidate_slots.iter()) {
237                    let key = pairs[*ki].0.as_bytes();
238                    key_hashes[*slot] = hash_verify(key);
239                    values[*slot] = pairs[*ki].1;
240                    occupied[*slot] = true;
241                }
242                continue 'bucket;
243            }
244        }
245        return None;
246    }
247
248    Some(PerfectHash {
249        seed1,
250        seed2,
251        displacement,
252        key_hashes,
253        values,
254        len: n,
255    })
256}
257
258/// CHD construction failure.
259#[derive(Debug, thiserror::Error)]
260pub enum BuildError {
261    /// Two entries share the same key string.
262    #[error("duplicate key: {0:?}")]
263    DuplicateKey(String),
264    /// Construction exhausted all salt retries without a fit.
265    #[error("CHD construction failed for {0} keys after all salt retries")]
266    ConstructionFailed(usize),
267}
268
269/// FNV-1a 64 with a seeded initialization vector. The seed makes
270/// independent hash families cheap (just feed a different salt).
271#[inline]
272fn hash_with_seed(data: &[u8], seed: u64) -> u64 {
273    let mut h = seed ^ fnv1a64_initial_state();
274    for &b in data {
275        h = fnv1a64_update_byte(h, b);
276    }
277    h
278}
279
280/// Independent verify hash. Different mix function and a final
281/// avalanche so verify collisions are independent of primary-hash
282/// collisions. Without this, a non-inserted key that happens to
283/// share the bucket+slot of a real key would look like a hit.
284#[inline]
285fn hash_verify(data: &[u8]) -> u64 {
286    let mut h: u64 = 0x517c_c1b7_2722_0a95;
287    for &b in data {
288        h = h.rotate_left(5) ^ (b as u64);
289        h = h.wrapping_mul(0x9e37_79b9_7f4a_7c15);
290    }
291    // Final avalanche (xxHash-style finalizer).
292    h ^= h >> 33;
293    h = h.wrapping_mul(0xff51_afd7_ed55_8ccd);
294    h ^= h >> 33;
295    h = h.wrapping_mul(0xc4ce_b9fe_1a85_ec53);
296    h ^= h >> 33;
297    h
298}
299
300#[cfg(test)]
301mod tests {
302    use super::*;
303
304    #[test]
305    fn empty_input_roundtrips() {
306        let ph = build_chd(Vec::<(&str, u32)>::new());
307        assert_eq!(ph.len(), 0);
308        assert!(ph.is_empty());
309        assert_eq!(ph.lookup("any"), None);
310    }
311
312    #[test]
313    fn single_entry() {
314        let ph = build_chd([("hello", 42_u32)]);
315        assert_eq!(ph.len(), 1);
316        assert_eq!(ph.lookup("hello"), Some(42));
317        assert_eq!(ph.lookup("world"), None);
318    }
319
320    #[test]
321    fn ten_keys_roundtrip() {
322        let entries: Vec<(String, u32)> = (0..10).map(|i| (format!("key_{i}"), i as u32)).collect();
323        let ph = build_chd(entries.clone());
324        assert_eq!(ph.len(), 10);
325        for (k, v) in &entries {
326            assert_eq!(ph.lookup(k), Some(*v), "key={k:?}");
327        }
328        assert_eq!(ph.lookup("unknown"), None);
329    }
330
331    #[test]
332    fn thousand_keys_roundtrip() {
333        let entries: Vec<(String, u32)> = (0..1000)
334            .map(|i| (format!("func_{i:04}"), i as u32))
335            .collect();
336        let ph = build_chd(entries.clone());
337        assert_eq!(ph.len(), 1000);
338        for (k, v) in &entries {
339            assert_eq!(ph.lookup(k), Some(*v), "key={k:?}");
340        }
341        for i in 1000..1100 {
342            assert_eq!(ph.lookup(&format!("func_{i:04}")), None);
343        }
344    }
345
346    #[test]
347    fn duplicate_keys_rejected() {
348        let err = try_build_chd([("dup", 1_u32), ("dup", 2_u32)]).unwrap_err();
349        assert!(matches!(err, BuildError::DuplicateKey(k) if k == "dup"));
350    }
351
352    #[test]
353    fn infallible_builder_returns_empty_on_duplicates() {
354        // `build_chd` folds construction errors into an empty table; the
355        // fallible `try_build_chd` surfaces them (see `duplicate_keys_rejected`).
356        let ph = build_chd([("dup", 1_u32), ("dup", 2_u32)]);
357        assert_eq!(ph.len(), 0);
358        assert!(ph.is_empty());
359        assert_eq!(ph.lookup("dup"), None);
360    }
361
362    #[test]
363    fn value_preserved_bitwise() {
364        let entries: Vec<(String, u32)> = (0..100)
365            .map(|i| (format!("k_{i}"), (i as u32).wrapping_mul(0xDEAD_BEEF)))
366            .collect();
367        let ph = build_chd(entries.clone());
368        for (k, v) in entries {
369            assert_eq!(ph.lookup(&k), Some(v));
370        }
371    }
372
373    #[test]
374    fn unicode_keys_work() {
375        let entries = vec![
376            ("naïve".to_string(), 1_u32),
377            ("咖啡".to_string(), 2),
378            ("über".to_string(), 3),
379            ("🎉".to_string(), 4),
380            ("test".to_string(), 5),
381        ];
382        let ph = build_chd(entries.clone());
383        for (k, v) in entries {
384            assert_eq!(ph.lookup(&k), Some(v));
385        }
386    }
387
388    #[test]
389    fn space_overhead_under_30_percent() {
390        let entries: Vec<(String, u32)> = (0..500).map(|i| (format!("k_{i}"), i as u32)).collect();
391        let n = entries.len();
392        let ph = build_chd(entries);
393        let ratio = ph.slots() as f64 / n as f64;
394        assert!(ratio < 1.30, "slots/len ratio {ratio} > 1.30 budget");
395    }
396
397    #[test]
398    fn seeds_and_tables_are_non_trivial_after_build() {
399        let entries: Vec<(String, u32)> = (0..50).map(|i| (format!("k_{i}"), i as u32)).collect();
400        let ph = build_chd(entries);
401        let (s1, s2) = ph.seeds();
402        assert_ne!(s1, 0);
403        assert_ne!(s2, 0);
404        assert!(!ph.displacement().is_empty());
405        assert!(!ph.key_hashes().is_empty());
406        assert!(!ph.values().is_empty());
407    }
408
409    #[test]
410    fn negative_lookups_are_rejected_by_verify_hash() {
411        let entries: Vec<(String, u32)> = (0..200).map(|i| (format!("k_{i}"), i as u32)).collect();
412        let ph = build_chd(entries);
413        // 500 strings that aren't in the set  -  all must miss.
414        for i in 1000..1500 {
415            assert_eq!(ph.lookup(&format!("q_{i}")), None, "false hit on q_{i}");
416        }
417    }
418
419    #[test]
420    fn hash_with_seed_is_deterministic() {
421        assert_eq!(hash_with_seed(b"hello", 42), hash_with_seed(b"hello", 42));
422        assert_ne!(hash_with_seed(b"hello", 42), hash_with_seed(b"hello", 43));
423        assert_ne!(hash_with_seed(b"hello", 42), hash_with_seed(b"world", 42));
424    }
425
426    #[test]
427    fn hash_verify_differs_from_seeded_hash() {
428        let key = b"hello";
429        assert_ne!(hash_with_seed(key, 0), hash_verify(key));
430    }
431
432    #[test]
433    fn real_label_family_names_build_and_lookup() {
434        // Simulate a Tier-B label family corpus: function names from
435        // the @filesystem_open_family TOML.
436        let funcs = [
437            "fopen",
438            "open",
439            "openat",
440            "CreateFileA",
441            "CreateFileW",
442            "std::fs::OpenOptions::open",
443            "std::fs::File::open",
444            "std::fs::File::create",
445            "tokio::fs::File::open",
446            "tokio::fs::File::create",
447            "rocket::response::NamedFile::open",
448        ];
449        let entries: Vec<(String, u32)> = funcs
450            .iter()
451            .enumerate()
452            .map(|(i, f)| (f.to_string(), i as u32))
453            .collect();
454
455        let ph = build_chd(entries.clone());
456        for (k, v) in entries {
457            assert_eq!(ph.lookup(&k), Some(v));
458        }
459        assert_eq!(ph.lookup("not_in_family"), None);
460        assert_eq!(ph.lookup("malloc"), None);
461    }
462
463    use proptest::prelude::*;
464
465    proptest! {
466        #[test]
467        fn proptest_roundtrip_random_keys(
468            entries in prop::collection::hash_map(
469                "[a-zA-Z0-9_]{1,32}",
470                0u32..10000u32,
471                1..256usize,
472            ),
473        ) {
474            let vec: Vec<(String, u32)> = entries.into_iter().collect();
475            let ph = build_chd(vec.clone());
476            for (k, v) in &vec {
477                prop_assert_eq!(ph.lookup(k), Some(*v), "key={}", k);
478            }
479        }
480
481        #[test]
482        fn proptest_negative_lookups_miss(
483            entries in prop::collection::vec(("[a-z]{1,16}", 0u32..100u32), 1..100usize),
484            queries in prop::collection::vec("[a-z]{1,16}", 1..50usize),
485        ) {
486            let deduped: std::collections::HashMap<String, u32> = entries.into_iter().collect();
487            prop_assume!(!deduped.is_empty());
488            let vec: Vec<(String, u32)> = deduped.clone().into_iter().collect();
489            let ph = build_chd(vec);
490            let key_set: std::collections::HashSet<&str> = deduped.keys().map(|k| k.as_str()).collect();
491            for q in &queries {
492                if key_set.contains(q.as_str()) {
493                    continue;
494                }
495                prop_assert_eq!(ph.lookup(q), None, "false hit on {}", q);
496            }
497        }
498
499        #[test]
500        fn proptest_space_overhead_under_35_percent(
501            entries in prop::collection::vec(("[a-zA-Z0-9_]{1,32}", 0u32..10000u32), 10..500usize),
502        ) {
503            let deduped: std::collections::HashMap<String, u32> = entries.into_iter().collect();
504            prop_assume!(deduped.len() >= 10);
505            let vec: Vec<(String, u32)> = deduped.into_iter().collect();
506            let ph = build_chd(vec.clone());
507            let n = vec.len();
508            let ratio = ph.slots() as f64 / n as f64;
509            // CHD overhead is tighter for larger tables; allow rounding slack for tiny sets.
510            let budget = if n < 20 { 1.5 } else { 1.35 };
511            prop_assert!(ratio < budget, "slots/len ratio {ratio} > {budget} budget for n={n}");
512        }
513    }
514}