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