Skip to main content

brepkit_math/
det_hash.rs

1//! Deterministic hashing primitives.
2//!
3//! The standard-library `HashMap`/`HashSet` seed their hasher from a
4//! process-global source, so iteration order — and therefore any algorithm
5//! whose output depends on the order it visits map entries — varies between
6//! runs (and even between successive map instances within one process, since
7//! the seed counter advances per instance). For geometry kernels this surfaces
8//! as nondeterministic mesh welding and triangulation: the same input can
9//! produce different vertex merges and face counts run-to-run.
10//!
11//! [`DetHashMap`] / [`DetHashSet`] use a fixed-seed FNV-1a hasher so iteration
12//! order is fully reproducible for a given set of keys. Use them anywhere map
13//! iteration order feeds geometry construction (vertex welds, triangulation,
14//! shell assembly).
15//!
16//! # Security
17//!
18//! This is a deterministic, fixed-seed FNV-1a hasher. It is **not**
19//! DoS-resistant: an adversary who controls the keys can trivially force
20//! collisions. Use it only for internal geometry processing where keys are
21//! derived from trusted topology, never for attacker-controlled input.
22
23use std::hash::{BuildHasher, Hasher};
24
25const FNV_OFFSET: u64 = 0xcbf2_9ce4_8422_2325;
26const FNV_PRIME: u64 = 0x0000_0100_0000_01b3;
27
28/// Fixed-seed FNV-1a hasher. Deterministic across processes and instances.
29///
30/// Not DoS-resistant — see the [module docs](self#security). For internal
31/// geometry processing only.
32#[derive(Clone)]
33pub struct DetHasher(u64);
34
35impl DetHasher {
36    /// Create a hasher seeded with the FNV-1a offset basis.
37    #[must_use]
38    pub const fn new() -> Self {
39        Self(FNV_OFFSET)
40    }
41}
42
43impl Default for DetHasher {
44    fn default() -> Self {
45        Self::new()
46    }
47}
48
49impl Hasher for DetHasher {
50    fn finish(&self) -> u64 {
51        self.0
52    }
53
54    fn write(&mut self, bytes: &[u8]) {
55        let mut h = self.0;
56        for &b in bytes {
57            h ^= u64::from(b);
58            h = h.wrapping_mul(FNV_PRIME);
59        }
60        self.0 = h;
61    }
62}
63
64/// `BuildHasher` for [`DetHasher`], seeding each hasher at the FNV-1a offset.
65#[derive(Default, Clone)]
66pub struct DetState;
67
68impl BuildHasher for DetState {
69    type Hasher = DetHasher;
70
71    fn build_hasher(&self) -> Self::Hasher {
72        DetHasher::new()
73    }
74}
75
76/// A `HashMap` with deterministic, reproducible iteration order.
77pub type DetHashMap<K, V> = std::collections::HashMap<K, V, DetState>;
78
79/// A `HashSet` with deterministic, reproducible iteration order.
80pub type DetHashSet<K> = std::collections::HashSet<K, DetState>;
81
82#[cfg(test)]
83mod tests {
84    #![allow(clippy::unwrap_used, clippy::expect_used)]
85    use super::*;
86
87    fn hash_writes(writes: &[&[u8]]) -> u64 {
88        let mut h = DetHasher::new();
89        for w in writes {
90            h.write(w);
91        }
92        h.finish()
93    }
94
95    #[test]
96    fn empty_hash_is_offset_basis() {
97        assert_eq!(DetHasher::new().finish(), FNV_OFFSET);
98    }
99
100    #[test]
101    fn distinct_sequences_differ() {
102        assert_ne!(hash_writes(&[b"abc"]), hash_writes(&[b"abd"]));
103    }
104
105    #[test]
106    fn write_is_order_sensitive() {
107        assert_ne!(hash_writes(&[b"ab", b"cd"]), hash_writes(&[b"cd", b"ab"]),);
108    }
109
110    #[test]
111    fn zero_state_does_not_restart() {
112        // A running state that reaches exactly zero must keep folding in
113        // subsequent bytes rather than silently reverting to the offset
114        // basis. Find a single byte that drives the state to 0, then verify
115        // that appending more bytes keeps the two distinct streams distinct.
116        let target = (0u16..=u16::from(u8::MAX)).map(|b| b as u8).find(|&b| {
117            let mut h = DetHasher::new();
118            h.write(&[b]);
119            h.finish() == 0
120        });
121        if let Some(zeroing) = target {
122            let mut a = DetHasher::new();
123            a.write(&[zeroing]);
124            a.write(&[0x01]);
125
126            // A hasher that began life at zero would, under the old buggy
127            // logic, hash [0x01] identically. Build that comparison stream.
128            let mut b = DetHasher::new();
129            b.write(&[0x01]);
130            assert_ne!(a.finish(), b.finish());
131        }
132
133        // Regardless of whether a zeroing byte exists for this FNV constant,
134        // assert the invariant that distinct streams stay distinct even when
135        // one of them is the empty (offset-basis) stream.
136        assert_ne!(hash_writes(&[]), hash_writes(&[b"x"]));
137    }
138}