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//! 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}