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