Skip to main content

polydat_nodes/
hash.rs

1// Copyright 2024-2026 Jonathan Shook
2// SPDX-License-Identifier: Apache-2.0
3
4//! Hash function nodes.
5//!
6//! Every node here is JIT-eligible: each arg and return maps to a
7//! `JitType`, so the macro emits `compiled_u64()` and
8//! `jit_constants()` (carrying the captured `Const<...>` field
9//! values) alongside `eval()`.
10
11use polydat::compile::fusion::{DecomposedGraph, DecomposedWire, FusedNode};
12use xxhash_rust::xxh3::xxh3_64;
13
14pub use polydat::numeric::hash::splitmix64_u64;
15
16/// 64-bit hash using SplitMix64 (high-speed scalar integer mixer).
17///
18/// Signature: `hash(input: u64) -> (u64)`
19///
20/// The fundamental entropy source for deterministic data generation.
21/// Place at the head of nearly every pipeline to scatter sequential
22/// cycle counters into uniformly distributed u64 values. Fully inlined
23/// into native Cranelift ALU instructions in Phase 3 JIT.
24///
25/// JIT level: P3 (Fully inlined Cranelift native IR).
26#[polydat::polydat_node(category = Hashing)]
27fn hash(input: u64) -> u64 {
28    splitmix64_u64(input)
29}
30
31/// Explicit SplitMix64 integer permutation node.
32///
33/// Signature: `splitmix64(input: u64) -> (u64)`
34#[polydat::polydat_node(category = Hashing)]
35fn splitmix64(input: u64) -> u64 {
36    splitmix64_u64(input)
37}
38
39/// Scatter sequential cycle counters across the 64-bit integer space.
40///
41/// Signature: `scatter(input: u64) -> (u64)`
42#[polydat::polydat_node(category = Hashing)]
43fn scatter(input: u64) -> u64 {
44    splitmix64_u64(input)
45}
46
47/// Canonical 64-bit xxHash3 digest.
48///
49/// Signature: `xxhash3(input: u64) -> (u64)`
50///
51/// Use when exact compatibility with the xxHash3 algorithm is required.
52#[polydat::polydat_node(category = Hashing)]
53fn xxhash3(input: u64) -> u64 {
54    xxh3_64(&input.to_le_bytes())
55}
56
57/// Canonical 64-bit xxHash3 digest (short alias).
58///
59/// Signature: `xxh3(input: u64) -> (u64)`
60#[polydat::polydat_node(category = Hashing)]
61fn xxh3(input: u64) -> u64 {
62    xxh3_64(&input.to_le_bytes())
63}
64
65/// Hash a u64 into a bounded range `[0, max)`.
66///
67/// Signature: `hash_range(input: u64, max: u64) -> (u64)`
68///
69/// Combines hashing and modular reduction in a single node. Use when
70/// you need a bounded integer directly, for example selecting a row
71/// index: `hash_range(cycle, 1_000_000)` gives a uniformly distributed
72/// key in [0, 1M).
73///
74/// JIT level: P3 (Fully inlined Cranelift native IR).
75#[polydat::polydat_node(category = Hashing)]
76fn hash_range(input: u64, max: Const<u64>) -> u64 {
77    if *max == 0 {
78        0
79    } else {
80        splitmix64_u64(input) % *max
81    }
82}
83
84impl FusedNode for HashRange {
85    /// `hash_range(x, K)` decomposes to `mod(hash(x), K)`.
86    fn decomposed(&self) -> DecomposedGraph {
87        use crate::arithmetic::Mod;
88        let mut g = DecomposedGraph::new(1);
89        let h = g.add_node(Box::new(Hash::new()), vec![DecomposedWire::Input(0)]);
90        let m = g.add_node(
91            Box::new(Mod::new(self.max)),
92            vec![DecomposedWire::Node(h, 0)],
93        );
94        g.set_outputs(vec![DecomposedWire::Node(m, 0)]);
95        g
96    }
97}
98
99/// Hash a u64 into a float interval `[min, max)`.
100///
101/// Signature: `hash_interval(input: u64, min: f64, max: f64) -> (f64)`
102///
103/// Convenience node that hashes, normalizes to [0,1), and scales in one
104/// step. Useful when a uniform f64 in a specific range is needed without
105/// wiring separate `hash` + `unit_interval` + `lerp` nodes.
106///
107/// JIT level: P3 (Fully inlined Cranelift native IR).
108#[polydat::polydat_node(category = Hashing)]
109fn hash_interval(input: u64, min: Const<f64>, max: Const<f64>) -> f64 {
110    let h = splitmix64_u64(input);
111    let unit = (h as f64) / (u64::MAX as f64);
112    *min + unit * (*max - *min)
113}
114
115impl FusedNode for HashInterval {
116    /// `hash_interval(x, lo, hi)` decomposes to `lerp(unit_interval(hash(x)), lo, hi)`.
117    fn decomposed(&self) -> DecomposedGraph {
118        use crate::lerp::Lerp;
119        use crate::sampling::icd::UnitInterval;
120        let mut g = DecomposedGraph::new(1);
121        let h = g.add_node(Box::new(Hash::new()), vec![DecomposedWire::Input(0)]);
122        let ui = g.add_node(
123            Box::new(UnitInterval::new()),
124            vec![DecomposedWire::Node(h, 0)],
125        );
126        let lerp = g.add_node(
127            Box::new(Lerp::new(self.min, self.max)),
128            vec![DecomposedWire::Node(ui, 0)],
129        );
130        g.set_outputs(vec![DecomposedWire::Node(lerp, 0)]);
131        g
132    }
133}
134
135#[cfg(test)]
136mod tests {
137    use super::*;
138    use polydat::ast::{PolydatNode, Value};
139
140    #[test]
141    fn hash_deterministic() {
142        let node = Hash::new();
143        let mut out = [Value::None];
144        node.eval(&[Value::U64(42)], &mut out);
145        let first = out[0].as_u64();
146        node.eval(&[Value::U64(42)], &mut out);
147        assert_eq!(
148            first,
149            out[0].as_u64(),
150            "same input must produce same output"
151        );
152    }
153
154    #[test]
155    fn hash_different_inputs_differ() {
156        let node = Hash::new();
157        let mut out1 = [Value::None];
158        let mut out2 = [Value::None];
159        node.eval(&[Value::U64(0)], &mut out1);
160        node.eval(&[Value::U64(1)], &mut out2);
161        assert_ne!(out1[0].as_u64(), out2[0].as_u64());
162    }
163
164    #[test]
165    fn hash_range_bounded() {
166        let node = HashRange::new(100);
167        let mut out = [Value::None];
168        for i in 0..1000 {
169            node.eval(&[Value::U64(i)], &mut out);
170            assert!(out[0].as_u64() < 100);
171        }
172    }
173
174    #[test]
175    fn hash_interval_bounded() {
176        let node = HashInterval::new(10.0, 20.0);
177        let mut out = [Value::None];
178        for i in 0..1000 {
179            node.eval(&[Value::U64(i)], &mut out);
180            let v = out[0].as_f64();
181            assert!((10.0..20.0).contains(&v), "got {v}");
182        }
183    }
184
185    #[test]
186    fn splitmix64_and_xxhash3_distinguishable() {
187        let sm = Splitmix64::new();
188        let xh = Xxhash3::new();
189        let mut out_sm = [Value::None];
190        let mut out_xh = [Value::None];
191        sm.eval(&[Value::U64(12345)], &mut out_sm);
192        xh.eval(&[Value::U64(12345)], &mut out_xh);
193        assert_ne!(out_sm[0].as_u64(), 0);
194        assert_ne!(out_xh[0].as_u64(), 0);
195        assert_ne!(out_sm[0].as_u64(), out_xh[0].as_u64());
196    }
197}
198
199// ── Fusion rules ───────────────────────────────────────────────
200//
201// The compiler knows no node by name: the rules that fuse a hash
202// with its consumers are registered here, beside the nodes they
203// build (compile::fusion::FusionRuleRegistration).
204
205use polydat::compile::fusion::{FusionPattern, FusionRule, FusionRuleRegistration};
206
207/// `mod(hash(x), K)` → `hash_range(x, K)`: hashing and bounded
208/// reduction in one node, with no buffer slot for the hash between.
209fn hash_mod_to_hash_range() -> FusionRule {
210    FusionRule {
211        name: "hash_mod_to_hash_range",
212        pattern: FusionPattern::node(
213            "mod",
214            vec![FusionPattern::node(
215                "hash",
216                vec![FusionPattern::any("x")],
217                "hash_node",
218            )],
219            "mod_node",
220        ),
221        replacement: |m| {
222            let max = m.const_u64("mod_node");
223            Box::new(HashRange::new(max))
224        },
225        input_bindings: &["x"],
226    }
227}
228
229/// `lerp(unit_interval(hash(x)), lo, hi)` → `hash_interval(x, lo, hi)`:
230/// one hash and one scaled float in one step.
231fn hash_unit_lerp_to_hash_interval() -> FusionRule {
232    FusionRule {
233        name: "hash_unit_lerp_to_hash_interval",
234        pattern: FusionPattern::node(
235            "lerp",
236            vec![FusionPattern::node(
237                "unit_interval",
238                vec![FusionPattern::node(
239                    "hash",
240                    vec![FusionPattern::any("x")],
241                    "hash_node",
242                )],
243                "ui_node",
244            )],
245            "lerp_node",
246        ),
247        replacement: |m| {
248            let consts = m.const_vec("lerp_node");
249            let lo = f64::from_bits(consts[0]);
250            let hi = f64::from_bits(consts[1]);
251            Box::new(HashInterval::new(lo, hi))
252        },
253        input_bindings: &["x"],
254    }
255}
256
257polydat::inventory::submit! {
258    FusionRuleRegistration { priority: 10, build: hash_mod_to_hash_range }
259}
260polydat::inventory::submit! {
261    FusionRuleRegistration { priority: 20, build: hash_unit_lerp_to_hash_interval }
262}