Skip to main content

polydat_nodes/
pcg.rs

1// Copyright 2024-2026 Jonathan Shook
2// SPDX-License-Identifier: Apache-2.0
3
4//! PCG-RXS-M-XS 64/64 random number generator nodes.
5//!
6//! These nodes implement the PCG (Permuted Congruential Generator) family
7//! algorithm with the RXS-M-XS output permutation. The key property is
8//! O(log N) seek: any position in the sequence can be computed directly
9//! without iterating from the beginning. This makes it ideal for
10//! deterministic parallel workloads where each thread jumps to its own
11//! region of the sequence.
12//!
13//! Three nodes are provided:
14//!
15//! - [`Pcg`] — fixed seed and stream, position is the wire input
16//! - [`PcgStream`] — fixed seed, both position and stream are wire inputs
17//! - [`CycleWalk`] — bijective permutation of `[0, range)` via cycle-walking
18//!
19//! `CycleWalk` uses multi-source `#[poly_const]` to derive a
20//! `CycleWalkState` from `(range, seed, stream)` at construction
21//! time, plus `compiled_u64 = ...` / `jit_constants = ...` overrides
22//! that capture the cached Feistel state by Copy and publish
23//! `[range, seed, inc]` to the JIT classifier.
24
25use polydat::ast::CompiledU64Op;
26#[cfg(test)]
27use polydat::ast::{PolydatNode, Value};
28
29// =================================================================
30// PCG-RXS-M-XS 64/64 core algorithm
31// =================================================================
32
33pub use polydat::numeric::pcg::{
34    CycleWalkState, FEISTEL_ROUNDS, MULT, build_cycle_walk_state, cycle_walk_inner, pcg_output,
35    pcg_seek,
36};
37
38/// PCG-RXS-M-XS 64/64 random number generator with fixed seed and stream.
39///
40/// Signature: `pcg(input: u64, seed: u64, stream: u64) -> u64`
41///
42/// The `seed` and `stream` are init-time constants baked into the node.
43/// The `input` wire selects which element of the sequence to return.
44/// Seeking is O(log N) so any position can be accessed directly.
45///
46/// Use this when every thread/cycle needs an independent, deterministic
47/// random value from the same generator. The output is a full 64-bit
48/// pseudo-random value suitable for feeding into range reduction,
49/// unit-interval mapping, or distribution sampling.
50///
51/// JIT level: P2 (auto-emitted `compiled_u64` closure with captured
52/// `seed` and `stream`; the body recomputes `inc = 2*stream + 1`
53/// per call — a single mul+add, negligible vs the seek work).
54/// `jit_constants`: `[seed, stream]` in declaration order.
55#[polydat::polydat_node(category = Permutation)]
56fn pcg(
57    input: u64,
58    #[poly_default(0u64)] seed: Const<u64>,
59    #[poly_default(0u64)] stream: Const<u64>,
60) -> u64 {
61    let inc = 2u64.wrapping_mul(*stream).wrapping_add(1);
62    pcg_seek(*seed, inc, input)
63}
64
65/// PCG-RXS-M-XS 64/64 with runtime stream selection.
66///
67/// Signature: `pcg_stream(input: u64, stream: u64, seed: u64) -> u64`
68///
69/// Like [`Pcg`], but the stream is a wire input rather than a constant.
70/// This allows each row or partition to use a different stream while
71/// sharing the same seed, producing independent sequences that are
72/// statistically uncorrelated.
73///
74/// Use this when the stream identity is data-dependent (e.g., derived
75/// from a partition key) and cannot be fixed at assembly time.
76///
77/// JIT level: P2 (auto-emitted `compiled_u64` closure with captured
78/// `seed`; `inc` derives from the wire-fed `stream` each call).
79#[polydat::polydat_node(category = Permutation)]
80fn pcg_stream(input: u64, stream: u64, #[poly_default(0u64)] seed: Const<u64>) -> u64 {
81    let inc = 2u64.wrapping_mul(stream).wrapping_add(1);
82    pcg_seek(*seed, inc, input)
83}
84
85/// `compiled_u64` override — captures the pre-computed Feistel
86/// state from `&Self` by Copy and returns a closure that walks
87/// the input through the bijection. The override receives `&Self`
88/// so setup-derived state is reachable without exposing the
89/// macro-internal struct shape to user code.
90fn cycle_walk_jit(node: &CycleWalk) -> CompiledU64Op {
91    let range = node.range;
92    let half_bits = node.state.half_bits;
93    let half_mask = node.state.half_mask;
94    let round_keys = node.state.round_keys;
95    Box::new(move |inputs, outputs| {
96        outputs[0] = cycle_walk_inner(inputs[0], range, half_bits, half_mask, &round_keys);
97    })
98}
99
100fn cycle_walk_jit_constants(node: &CycleWalk) -> Vec<u64> {
101    vec![node.range, node.seed, node.state.inc]
102}
103
104/// Bijective permutation of `[0, range)` via cycle-walking over PCG.
105///
106/// Signature: `cycle_walk(position: u64, range: u64, seed: u64, stream: u64) -> u64`
107///
108/// Maps every integer in `[0, range)` to a unique integer in `[0, range)`
109/// (a permutation). Internally uses a 6-round Feistel network operating
110/// on the bit-width of range, with PCG-derived round keys, then
111/// cycle-walks: if the Feistel output is >= range, it is fed back as
112/// input. Because the Feistel cipher is a bijection on the power-of-two
113/// domain and the mask is at most 2x range, each cycle-walk iteration
114/// has >= 50% chance of landing in range, giving fast expected
115/// termination (~2 iterations).
116///
117/// Use this when you need a shuffle or bijective mapping: e.g., visiting
118/// every row in a table exactly once in a pseudo-random order, or
119/// generating unique IDs without a tracking structure.
120///
121/// The `range`, `seed`, and `stream` are init-time constants.
122///
123/// JIT level: P2 — macro-authored via `compiled_u64 = ...` /
124/// `jit_constants = ...` overrides that capture the pre-computed
125/// `CycleWalkState`. Exposes `jit_constants`: `[range, seed, inc]`.
126#[polydat::polydat_node(
127    category = Permutation,
128    compiled_u64 = cycle_walk_jit,
129    jit_constants = cycle_walk_jit_constants,
130)]
131fn cycle_walk(
132    position: u64,
133    range: Const<u64>,
134    #[poly_default(0u64)] seed: Const<u64>,
135    #[poly_default(0u64)] stream: Const<u64>,
136    #[poly_const(build_cycle_walk_state, from = (range, seed, stream))] state: &CycleWalkState,
137) -> u64 {
138    let _ = seed;
139    let _ = stream;
140    cycle_walk_inner(
141        position,
142        *range,
143        state.half_bits,
144        state.half_mask,
145        &state.round_keys,
146    )
147}
148
149#[cfg(test)]
150mod tests {
151    use super::*;
152    use std::collections::HashSet;
153
154    // ----- pcg_seek / pcg_output unit tests -----
155
156    #[test]
157    fn pcg_output_deterministic() {
158        // Same state must always produce the same output.
159        let a = pcg_output(123456789);
160        let b = pcg_output(123456789);
161        assert_eq!(a, b);
162    }
163
164    #[test]
165    fn pcg_seek_position_zero_vs_one() {
166        let seed = 42u64;
167        let inc = 1u64; // stream 0
168        let v0 = pcg_seek(seed, inc, 0);
169        let v1 = pcg_seek(seed, inc, 1);
170        assert_ne!(v0, v1, "different positions must produce different values");
171    }
172
173    #[test]
174    fn pcg_seek_deterministic() {
175        let seed = 0xDEAD_BEEF;
176        let inc = 3;
177        let a = pcg_seek(seed, inc, 1000);
178        let b = pcg_seek(seed, inc, 1000);
179        assert_eq!(a, b);
180    }
181
182    #[test]
183    fn pcg_seek_sequential_matches_step() {
184        // Verify that seek(N) produces the same result as stepping
185        // through the LCG N times.
186        let seed = 77u64;
187        let inc = 5u64;
188        let n = 50u64;
189
190        // Step through manually
191        let mut state = seed;
192        for _ in 0..n {
193            state = state.wrapping_mul(MULT).wrapping_add(inc);
194        }
195        let stepped = pcg_output(state);
196
197        let seeked = pcg_seek(seed, inc, n);
198        assert_eq!(
199            stepped, seeked,
200            "seek({n}) must match {n} sequential LCG steps"
201        );
202    }
203
204    // ----- Pcg node tests -----
205
206    #[test]
207    fn pcg_node_deterministic() {
208        let node = Pcg::new(42, 0);
209        let mut out = [Value::None];
210        node.eval(&[Value::U64(100)], &mut out);
211        let first = out[0].as_u64();
212        node.eval(&[Value::U64(100)], &mut out);
213        assert_eq!(
214            first,
215            out[0].as_u64(),
216            "same position must give same result"
217        );
218    }
219
220    #[test]
221    fn pcg_node_different_positions() {
222        let node = Pcg::new(42, 0);
223        let mut out1 = [Value::None];
224        let mut out2 = [Value::None];
225        node.eval(&[Value::U64(0)], &mut out1);
226        node.eval(&[Value::U64(1)], &mut out2);
227        assert_ne!(out1[0].as_u64(), out2[0].as_u64());
228    }
229
230    #[test]
231    fn pcg_node_different_seeds() {
232        let a = Pcg::new(1, 0);
233        let b = Pcg::new(2, 0);
234        let mut out_a = [Value::None];
235        let mut out_b = [Value::None];
236        a.eval(&[Value::U64(50)], &mut out_a);
237        b.eval(&[Value::U64(50)], &mut out_b);
238        assert_ne!(
239            out_a[0].as_u64(),
240            out_b[0].as_u64(),
241            "different seeds should produce different values"
242        );
243    }
244
245    #[test]
246    fn pcg_node_different_streams() {
247        let a = Pcg::new(42, 0);
248        let b = Pcg::new(42, 1);
249        let mut out_a = [Value::None];
250        let mut out_b = [Value::None];
251        a.eval(&[Value::U64(50)], &mut out_a);
252        b.eval(&[Value::U64(50)], &mut out_b);
253        assert_ne!(
254            out_a[0].as_u64(),
255            out_b[0].as_u64(),
256            "different streams should produce different values"
257        );
258    }
259
260    #[test]
261    fn pcg_compiled_matches_eval() {
262        let node = Pcg::new(99, 7);
263        let compiled = node.compiled_u64().expect("Pcg must provide compiled_u64");
264        for pos in 0..100u64 {
265            let mut eval_out = [Value::None];
266            node.eval(&[Value::U64(pos)], &mut eval_out);
267            let mut comp_out = [0u64];
268            compiled(&[pos], &mut comp_out);
269            assert_eq!(
270                eval_out[0].as_u64(),
271                comp_out[0],
272                "compiled and eval must agree at position {pos}"
273            );
274        }
275    }
276
277    #[test]
278    fn pcg_jit_constants() {
279        // Macro auto-emits jit_constants in declaration order:
280        // [seed, stream]. Phase-3 classifier doesn't special-case
281        // pcg (it rides the Fallback path), so the precise layout
282        // is informational; the contract is "values the closure
283        // depends on", and the body recomputes inc from stream.
284        let node = Pcg::new(42, 7);
285        let consts = node.jit_constants();
286        assert_eq!(consts.len(), 2);
287        assert_eq!(consts[0], 42, "first constant is seed");
288        assert_eq!(
289            consts[1], 7,
290            "second constant is stream (inc = 2*stream+1 derived in body)"
291        );
292    }
293
294    // ----- PcgStream node tests -----
295
296    #[test]
297    fn pcg_stream_deterministic() {
298        let node = PcgStream::new(42);
299        let mut out = [Value::None];
300        node.eval(&[Value::U64(100), Value::U64(3)], &mut out);
301        let first = out[0].as_u64();
302        node.eval(&[Value::U64(100), Value::U64(3)], &mut out);
303        assert_eq!(first, out[0].as_u64());
304    }
305
306    #[test]
307    fn pcg_stream_independence() {
308        let node = PcgStream::new(42);
309        let mut out_a = [Value::None];
310        let mut out_b = [Value::None];
311        node.eval(&[Value::U64(50), Value::U64(0)], &mut out_a);
312        node.eval(&[Value::U64(50), Value::U64(1)], &mut out_b);
313        assert_ne!(
314            out_a[0].as_u64(),
315            out_b[0].as_u64(),
316            "different stream_ids should produce different values"
317        );
318    }
319
320    #[test]
321    fn pcg_stream_matches_fixed_pcg() {
322        // PcgStream with a fixed stream_id should produce the same
323        // output as Pcg constructed with that stream.
324        let fixed = Pcg::new(42, 5);
325        let dynamic = PcgStream::new(42);
326        for pos in 0..50u64 {
327            let mut f_out = [Value::None];
328            let mut d_out = [Value::None];
329            fixed.eval(&[Value::U64(pos)], &mut f_out);
330            dynamic.eval(&[Value::U64(pos), Value::U64(5)], &mut d_out);
331            assert_eq!(
332                f_out[0].as_u64(),
333                d_out[0].as_u64(),
334                "PcgStream must match Pcg for same seed/stream at position {pos}"
335            );
336        }
337    }
338
339    #[test]
340    fn pcg_stream_compiled_matches_eval() {
341        let node = PcgStream::new(99);
342        let compiled = node
343            .compiled_u64()
344            .expect("PcgStream must provide compiled_u64");
345        for pos in 0..50u64 {
346            for stream in 0..5u64 {
347                let mut eval_out = [Value::None];
348                node.eval(&[Value::U64(pos), Value::U64(stream)], &mut eval_out);
349                let mut comp_out = [0u64];
350                compiled(&[pos, stream], &mut comp_out);
351                assert_eq!(
352                    eval_out[0].as_u64(),
353                    comp_out[0],
354                    "compiled and eval must agree at pos={pos}, stream={stream}"
355                );
356            }
357        }
358    }
359
360    // ----- CycleWalk node tests -----
361
362    #[test]
363    fn cycle_walk_bounded() {
364        let node = CycleWalk::new(100, 42, 0);
365        let mut out = [Value::None];
366        for i in 0..200u64 {
367            node.eval(&[Value::U64(i)], &mut out);
368            assert!(
369                out[0].as_u64() < 100,
370                "output {} >= range 100",
371                out[0].as_u64()
372            );
373        }
374    }
375
376    #[test]
377    fn cycle_walk_deterministic() {
378        let node = CycleWalk::new(1000, 42, 0);
379        let mut out = [Value::None];
380        node.eval(&[Value::U64(77)], &mut out);
381        let first = out[0].as_u64();
382        node.eval(&[Value::U64(77)], &mut out);
383        assert_eq!(first, out[0].as_u64());
384    }
385
386    #[test]
387    fn cycle_walk_bijective_small() {
388        // For inputs [0, range), the mapping must be a permutation:
389        // every output is unique and within [0, range).
390        let range = 50u64;
391        let node = CycleWalk::new(range, 42, 0);
392        let mut seen = HashSet::new();
393        let mut out = [Value::None];
394        for i in 0..range {
395            node.eval(&[Value::U64(i)], &mut out);
396            let v = out[0].as_u64();
397            assert!(v < range, "output {v} out of range [0, {range})");
398            assert!(seen.insert(v), "duplicate output {v} at position {i}");
399        }
400        assert_eq!(
401            seen.len(),
402            range as usize,
403            "must produce exactly {range} distinct values"
404        );
405    }
406
407    #[test]
408    fn cycle_walk_bijective_power_of_two() {
409        // Powers of two are a common edge case.
410        let range = 64u64;
411        let node = CycleWalk::new(range, 123, 7);
412        let mut seen = HashSet::new();
413        let mut out = [Value::None];
414        for i in 0..range {
415            node.eval(&[Value::U64(i)], &mut out);
416            let v = out[0].as_u64();
417            assert!(v < range);
418            assert!(seen.insert(v), "duplicate at {i}");
419        }
420        assert_eq!(seen.len(), range as usize);
421    }
422
423    #[test]
424    fn cycle_walk_compiled_matches_eval() {
425        let node = CycleWalk::new(200, 42, 3);
426        let compiled = node
427            .compiled_u64()
428            .expect("CycleWalk must provide compiled_u64");
429        for pos in 0..200u64 {
430            let mut eval_out = [Value::None];
431            node.eval(&[Value::U64(pos)], &mut eval_out);
432            let mut comp_out = [0u64];
433            compiled(&[pos], &mut comp_out);
434            assert_eq!(
435                eval_out[0].as_u64(),
436                comp_out[0],
437                "compiled and eval must agree at position {pos}"
438            );
439        }
440    }
441
442    #[test]
443    fn cycle_walk_jit_constants() {
444        let node = CycleWalk::new(500, 42, 7);
445        let consts = node.jit_constants();
446        assert_eq!(consts.len(), 3);
447        assert_eq!(consts[0], 500, "first constant is range");
448        assert_eq!(consts[1], 42, "second constant is seed");
449        assert_eq!(consts[2], 2 * 7 + 1, "third constant is inc");
450    }
451
452    #[test]
453    #[should_panic(expected = "range must be > 0")]
454    fn cycle_walk_zero_range_panics() {
455        CycleWalk::new(0, 42, 0);
456    }
457
458    #[test]
459    fn cycle_walk_range_one() {
460        // With range=1, every input must map to 0.
461        let node = CycleWalk::new(1, 42, 0);
462        let mut out = [Value::None];
463        for i in 0..10u64 {
464            node.eval(&[Value::U64(i)], &mut out);
465            assert_eq!(out[0].as_u64(), 0);
466        }
467    }
468}