polydat_core/numeric/pcg.rs
1// Copyright 2024-2026 Jonathan Shook
2// SPDX-License-Identifier: Apache-2.0
3
4//! The PCG-RXS-M-XS generator, its O(log N) seek, and the Feistel
5//! cycle walk: the bodies of `pcg`, `pcg_stream`, and `cycle_walk`,
6//! which the native lowerings and the comprehension strategies call.
7
8use crate::derive_support::PolydatSetup;
9
10/// The LCG multiplier of the 64-bit state.
11pub const MULT: u64 = 6364136223846793005;
12
13/// Apply the RXS-M-XS output permutation to an LCG state.
14///
15/// This is the bit-mixing function that turns correlated LCG state
16/// into high-quality pseudo-random output.
17#[inline]
18pub fn pcg_output(state: u64) -> u64 {
19 let word = ((state >> ((state >> 59) + 5)) ^ state).wrapping_mul(12605985483714917081);
20 (word >> 43) ^ word
21}
22
23/// Seek to an arbitrary position in the PCG sequence in O(log N) time.
24///
25/// Uses the "distance" algorithm that exponentiates the LCG recurrence
26/// via repeated squaring, equivalent to computing `state_N` directly
27/// from `seed` without iterating through positions 0..N.
28///
29/// - `seed`: initial LCG state
30/// - `inc`: LCG increment (must be odd; typically `2 * stream + 1`)
31/// - `position`: the sequence index to seek to
32#[inline]
33pub fn pcg_seek(seed: u64, inc: u64, position: u64) -> u64 {
34 let mut cur_mult = MULT;
35 let mut cur_plus = inc;
36 let mut acc_mult: u64 = 1;
37 let mut acc_plus: u64 = 0;
38 let mut delta = position;
39 while delta > 0 {
40 if delta & 1 != 0 {
41 acc_mult = acc_mult.wrapping_mul(cur_mult);
42 acc_plus = acc_plus.wrapping_mul(cur_mult).wrapping_add(cur_plus);
43 }
44 cur_plus = cur_mult.wrapping_add(1).wrapping_mul(cur_plus);
45 cur_mult = cur_mult.wrapping_mul(cur_mult);
46 delta >>= 1;
47 }
48 let state = acc_mult.wrapping_mul(seed).wrapping_add(acc_plus);
49 pcg_output(state)
50}
51
52// =================================================================
53// Polydat Nodes
54// =================================================================
55
56/// Number of Feistel rounds. 6 rounds provides good diffusion.
57pub const FEISTEL_ROUNDS: usize = 6;
58
59/// Pre-computed Feistel state for the `cycle_walk` node. Built once at
60/// construction from `(range, seed, stream)` via the multi-source
61/// `#[poly_const]` setup; consumed read-only by every cycle and by
62/// the `compiled_u64` override's captured closure.
63pub struct CycleWalkState {
64 /// Number of bits per Feistel half (total domain is 2^(2*half_bits)).
65 pub half_bits: u32,
66 /// Bitmask for each half: `(1 << half_bits) - 1`.
67 pub half_mask: u64,
68 /// PCG-derived LCG increment, `2 * stream + 1`. Published to
69 /// the JIT classifier as the third element of `jit_constants`.
70 pub inc: u64,
71 /// Pre-computed round keys derived from seed and stream.
72 pub round_keys: [u64; FEISTEL_ROUNDS],
73}
74
75impl PolydatSetup for CycleWalkState {}
76
77/// Joint Feistel-state derivation. Single-call construction-time
78/// invocation per node instance; the macro emits the call inside
79/// the generated `CycleWalk::new(range, seed, stream)`.
80///
81/// Panics if `range` is 0 — preserves the construction-time
82/// validation contract from the pre-Phase-E hand-written form.
83pub fn build_cycle_walk_state(range: u64, seed: u64, stream: u64) -> CycleWalkState {
84 assert!(range > 0, "CycleWalk range must be > 0");
85 let inc = 2u64.wrapping_mul(stream).wrapping_add(1);
86
87 // Compute the total bit width needed, then round up to even
88 // so the Feistel halves are balanced.
89 let min_bits = if range <= 1 {
90 2 // minimum 2 bits for a balanced Feistel
91 } else {
92 let b = 64 - (range - 1).leading_zeros();
93 if !b.is_multiple_of(2) {
94 b + 1
95 } else {
96 b.max(2)
97 }
98 };
99 let half_bits = min_bits / 2;
100 let half_mask = (1u64 << half_bits) - 1;
101
102 // Derive round keys from seed and inc using the PCG itself.
103 let mut round_keys = [0u64; FEISTEL_ROUNDS];
104 for (i, key) in round_keys.iter_mut().enumerate() {
105 *key = pcg_seek(seed, inc, i as u64 + 1_000_000_000);
106 }
107
108 CycleWalkState {
109 half_bits,
110 half_mask,
111 inc,
112 round_keys,
113 }
114}
115
116#[inline]
117fn feistel_round_fn(half: u64, round_key: u64) -> u64 {
118 let x = half
119 .wrapping_mul(0x9E3779B97F4A7C15)
120 .wrapping_add(round_key);
121 let x = ((x >> 32) ^ x).wrapping_mul(0xD6E8FEB86659FD93);
122 (x >> 32) ^ x
123}
124
125/// Apply a balanced Feistel network: a bijection on `[0, 2^total_bits)`.
126///
127/// The value is split into two halves of `half_bits` each (total_bits
128/// is always even -- we round up). Standard 6-round balanced Feistel
129/// with pre-computed round keys ensures bijectivity.
130#[inline]
131fn feistel_encrypt(
132 value: u64,
133 half_bits: u32,
134 half_mask: u64,
135 round_keys: &[u64; FEISTEL_ROUNDS],
136) -> u64 {
137 let mut left = (value >> half_bits) & half_mask;
138 let mut right = value & half_mask;
139
140 for key in round_keys.iter() {
141 let new_right = left ^ (feistel_round_fn(right, *key) & half_mask);
142 left = right;
143 right = new_right;
144 }
145
146 (left << half_bits) | right
147}
148
149/// Apply cycle-walking with the Feistel bijection.
150///
151/// The input `value` is first reduced to `[0, range)` via modular
152/// reduction so that out-of-range inputs are accepted gracefully.
153/// Starting from a value in `[0, range)`, cycle-walking is guaranteed
154/// to terminate because the Feistel permutation's cycle through that
155/// value must re-enter `[0, range)`.
156#[inline]
157pub fn cycle_walk_inner(
158 mut value: u64,
159 range: u64,
160 half_bits: u32,
161 half_mask: u64,
162 round_keys: &[u64; FEISTEL_ROUNDS],
163) -> u64 {
164 if range == 1 {
165 return 0;
166 }
167 // Ensure we start in [0, range) so cycle-walk terminates.
168 value %= range;
169 loop {
170 value = feistel_encrypt(value, half_bits, half_mask, round_keys);
171 if value < range {
172 return value;
173 }
174 }
175}