Skip to main content

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}