polydat_core/numeric/permute.rs
1// Copyright 2024-2026 Jonathan Shook
2// SPDX-License-Identifier: Apache-2.0
3
4//! The Galois LFSR permutation: one body for the `shuffle` node and for
5//! `jit_shuffle`, the helper native code calls.
6//!
7//! They share one function so they cannot drift: a guard written into
8//! one copy and not the other would make the engines disagree about
9//! the same program.
10//!
11//! The feedback polynomials come with it. A shuffle whose polynomial
12//! does not match its register width is not a permutation, so the table
13//! and the body are one thing to keep honest, not two; splitting them
14//! across the crates is what let the bodies diverge in the first place.
15//! `polydat-nodes` re-exports the selection helpers under their old
16//! paths.
17
18/// Number of banks (feedback polynomials) stored per register width.
19const BANKS_PER_WIDTH: usize = 8;
20
21/// Galois LFSR feedback polynomials, 8 banks per register width 4..64.
22/// Indexed as FEEDBACK_BANKS[(width - 4) * 8 + bank].
23/// Widths with fewer than 8 known polynomials repeat the last one.
24const FEEDBACK_BANKS: [u64; 61 * BANKS_PER_WIDTH] = include!("metashift_banks.inc");
25
26/// Return the feedback polynomial for a given register width and bank.
27///
28/// `width` must be 4..=64. `bank` selects among different polynomials
29/// for the same width (modulo the number of available banks). Different
30/// banks produce different permutation orderings over the same range.
31pub fn feedback_for_width_and_bank(width: u32, bank: usize) -> u64 {
32 assert!(
33 (4..=64).contains(&width),
34 "LFSR width must be 4..64, got {width}"
35 );
36 let base = (width as usize - 4) * BANKS_PER_WIDTH;
37 FEEDBACK_BANKS[base + (bank % BANKS_PER_WIDTH)]
38}
39
40/// Return the default (bank 0) feedback polynomial for a given width.
41pub fn feedback_for_width(width: u32) -> u64 {
42 feedback_for_width_and_bank(width, 0)
43}
44
45/// Return the minimum register width needed to represent `period` values.
46pub fn width_for_period(period: u64) -> u32 {
47 assert!(period > 0, "period must be positive");
48 let bits = 64 - period.leading_zeros();
49 bits.max(4) // minimum 4-bit LFSR
50}
51
52/// Convenience: derive a bank-0 feedback polynomial directly from a
53/// shuffle `size`. Callers building a `Shuffle` node from an outer
54/// "size" parameter use this rather than tracking width / bank
55/// manually.
56pub fn feedback_for_size(size: u64) -> u64 {
57 feedback_for_width_and_bank(width_for_period(size), 0)
58}
59
60/// One step of the Galois LFSR: shift right, and fold in the feedback
61/// polynomial when the bit shifted out was set.
62///
63/// Bijective on the non-zero registers, which is what makes the shuffle
64/// above it a permutation rather than a hash. Zero is a fixed point and
65/// is kept out of range by the 1-based normalization.
66#[inline]
67pub fn lfsr_step(register: u64, feedback: u64) -> u64 {
68 let lsb = register & 1;
69 let shifted = register >> 1;
70 // The `-lsb` trick: lsb=1 gives all ones, so the mask passes the
71 // feedback; lsb=0 gives zero, so it blocks it. Branch-free, and the
72 // same instruction sequence native code would have emitted inline.
73 shifted ^ (lsb.wrapping_neg() & feedback)
74}
75
76/// Map `input` into `[min, min + size)`, visiting every value of that
77/// range exactly once per cycle.
78///
79/// The LFSR cannot produce zero, so the range is worked 1-based and
80/// denormalized on the way out. A register that steps past `size`
81/// is rejected and stepped again, which is what makes a range that is
82/// not a power of two come out whole.
83///
84/// # Degenerate pairs
85///
86/// Three pairs describe no permutation, and all three answer `min`, the
87/// range's floor: the empty range below, a `feedback` that walks the
88/// register to zero, and one that cycles without ever landing in range.
89/// Each is reachable with the node's own defaults or with the arbitrary
90/// constants a fuzzer supplies, so each is answered in bounded time
91/// rather than trapped. The result is always inside `[min, min + size)`,
92/// or `min` when that interval is empty.
93///
94/// `size == 0` is the empty range `[min, min)`, which has no value to
95/// permute onto, and it is reachable without being asked for: the
96/// node's `size` defaults to zero, so `shuffle(x)` and
97/// `shuffle(x, feedback)` both land here. It answers `min`, the range's
98/// own floor, as `hash_range` answers `0` for the same degenerate
99/// bound. Left unanswered it is not merely undefined but a trap:
100/// `input % size` divides by zero, and were that defined the rejection
101/// loop could never terminate, since the register starts at 1 and the
102/// exit condition wants `register <= 0`.
103#[inline]
104pub fn shuffle_bounded(input: u64, feedback: u64, size: u64, min: u64) -> u64 {
105 if size == 0 {
106 return min;
107 }
108
109 // Normalize to the 1-based LFSR range (the LFSR cannot produce 0).
110 let mut register = (input % size) + 1;
111
112 // A maximal polynomial of width `w` walks every non-zero register
113 // below `2^w` before it repeats, and `width_for_period` picks `w`
114 // so that `size < 2^w`. A well-formed pair therefore lands in
115 // `[1, size]` within `2^w` steps. Spending more proves the step is
116 // cycling in a subset that misses the range, and a deterministic
117 // map over finite state that has repeated will repeat forever — so
118 // the budget is not a timeout but the point past which "has not
119 // landed" and "cannot land" are the same statement.
120 let width = width_for_period(size);
121 let budget = if width >= 63 { u64::MAX } else { 1u64 << width };
122 let mut steps = 0u64;
123
124 // Rejection sampling: step until the register lands in range.
125 loop {
126 register = lfsr_step(register, feedback);
127 if register == 0 {
128 // The sequence collapsed. Zero is the LFSR's fixed point
129 // and sits outside the 1-based range, so there is nothing
130 // to denormalize — `register - 1` would wrap. It is
131 // reachable whenever `feedback` is not a polynomial for
132 // this width, and above all when it is zero, which is the
133 // node's default: the step degenerates to a plain shift and
134 // walks to zero. Small ranges reach it at once, `size == 1`
135 // for every input. Answer the floor, as the empty range
136 // does.
137 return min;
138 }
139 if register <= size {
140 break;
141 }
142 steps += 1;
143 if steps >= budget {
144 // Not a polynomial for this width. Same answer as the
145 // other degenerate pairs, and reached in bounded time.
146 return min;
147 }
148 }
149
150 // Denormalize back into [min, min + size).
151 (register - 1) + min
152}
153
154#[cfg(test)]
155mod tests {
156 use super::*;
157
158 /// The property the node exists for: every value of the range is
159 /// visited exactly once.
160 #[test]
161 fn a_bounded_shuffle_is_a_permutation() {
162 let size = 1000u64;
163 let feedback = feedback_for_size(size);
164 let mut seen = vec![false; size as usize];
165 for input in 0..size {
166 let out = shuffle_bounded(input, feedback, size, 0);
167 assert!(out < size, "{out} out of range for size {size}");
168 assert!(!seen[out as usize], "{out} visited twice");
169 seen[out as usize] = true;
170 }
171 assert!(seen.iter().all(|b| *b), "not every value was visited");
172 }
173
174 /// The degenerate bound, answered rather than trapped.
175 #[test]
176 fn an_empty_range_answers_its_floor() {
177 for min in [0u64, 7, u64::MAX] {
178 for input in [0u64, 1, 42, u64::MAX] {
179 assert_eq!(shuffle_bounded(input, 0, 0, min), min);
180 }
181 }
182 }
183
184 /// A one-value range is the only value, whatever the input.
185 #[test]
186 fn a_single_value_range_is_that_value() {
187 let feedback = feedback_for_size(1);
188 for input in [0u64, 1, 42, u64::MAX] {
189 assert_eq!(shuffle_bounded(input, feedback, 1, 5), 5);
190 }
191 }
192
193 /// Zero is the `feedback` default, and it is not a polynomial: the
194 /// step degenerates to a plain shift that walks the register to
195 /// zero, which is outside the 1-based range. Answered, not wrapped
196 /// — `register - 1` underflowed here before.
197 #[test]
198 fn a_collapsed_sequence_answers_the_floor() {
199 for size in [1u64, 2, 3, 16, 1000] {
200 for input in [0u64, 1, 42, u64::MAX] {
201 let out = shuffle_bounded(input, 0, size, 9);
202 assert!(
203 (9..9 + size).contains(&out),
204 "size={size} input={input} left the range with {out}"
205 );
206 }
207 }
208 }
209
210 /// The budget must never cut a well-formed pair short. Every bank
211 /// is a polynomial for its width, so every one of them still walks
212 /// the whole range: if the bound were too tight this is what would
213 /// break, and it would break as a lost value rather than as a
214 /// panic.
215 #[test]
216 fn a_valid_polynomial_never_exhausts_the_budget() {
217 for size in [1u64, 2, 5, 16, 17, 100, 255, 256, 1000] {
218 let width = width_for_period(size);
219 for bank in 0..8 {
220 let feedback = feedback_for_width_and_bank(width, bank);
221 let mut seen = vec![false; size as usize];
222 for input in 0..size {
223 let out = shuffle_bounded(input, feedback, size, 0);
224 assert!(out < size, "size={size} bank={bank} left range: {out}");
225 assert!(
226 !seen[out as usize],
227 "size={size} bank={bank} repeated {out}"
228 );
229 seen[out as usize] = true;
230 }
231 assert!(
232 seen.iter().all(|b| *b),
233 "size={size} bank={bank}: the budget truncated a valid permutation"
234 );
235 }
236 }
237 }
238
239 /// A feedback that is not a polynomial for the width may cycle
240 /// among registers that all miss the range. The step is
241 /// deterministic over finite state, so once it has repeated it
242 /// never lands — the bound is where that becomes provable, and the
243 /// answer is the floor. Terminating is the assertion.
244 #[test]
245 fn a_feedback_that_cannot_land_terminates() {
246 for size in [1u64, 2, 3, 7, 64, 1000] {
247 for feedback in [
248 u64::MAX,
249 1u64 << 63,
250 (1u64 << 63) | 1,
251 0xFFFF_0000_FFFF_0000,
252 ] {
253 for input in [0u64, 1, 42] {
254 let out = shuffle_bounded(input, feedback, size, 11);
255 assert!(
256 (11..11 + size).contains(&out),
257 "size={size} feedback={feedback:#x} input={input} gave {out}"
258 );
259 }
260 }
261 }
262 }
263
264 /// Whatever the constants, the answer is in range and there is no
265 /// panic: the fuzzer reaches this node with arbitrary ones.
266 #[test]
267 fn arbitrary_constants_stay_in_range() {
268 for feedback in [0u64, 1, 2, 7, 0xB400, u64::MAX] {
269 for size in [0u64, 1, 2, 5, 64] {
270 for input in [0u64, 1, 9, u64::MAX] {
271 let out = shuffle_bounded(input, feedback, size, 3);
272 let upper = 3 + size.max(1);
273 assert!(
274 (3..upper).contains(&out),
275 "feedback={feedback} size={size} input={input} gave {out}"
276 );
277 }
278 }
279 }
280 }
281
282 #[test]
283 fn lfsr_step_leaves_zero_alone() {
284 assert_eq!(lfsr_step(0, 0xB400), 0);
285 }
286}