Skip to main content

ruvector_turboquant/
rotation.rs

1//! Deterministic randomized rotation for Turbo4 codes (ADR-296).
2//!
3//! Construction: `ROUNDS` rounds of
4//!
5//! ```text
6//!   v ← blockFWHT( P · (s ⊙ v) )
7//! ```
8//!
9//! where `s` is a ±1 sign diagonal, `P` a uniform permutation, and
10//! `blockFWHT` applies the Fast Walsh-Hadamard Transform independently on the
11//! power-of-two blocks of the binary decomposition of `D` (e.g. 1536 = 1024 +
12//! 512). Each factor is exactly orthogonal, so norms are preserved to floating
13//! point accuracy and the composition Gaussianizes coordinate marginals like a
14//! Haar-uniform rotation (TurboQuant arXiv:2504.19874 §3.2) — without
15//! zero-padding, so downstream code size stays `ceil(D/2)` bytes.
16//!
17//! Randomness comes from an in-crate SplitMix64 stream seeded by the caller.
18//! No `rand` dependency: encoded bytes are a *persisted storage format*, so
19//! the rotation must stay bit-identical across platforms, architectures, and
20//! dependency upgrades forever.
21
22/// Number of sign→permute→FWHT rounds. Three rounds is the standard
23/// HD₁·HD₂·HD₃ recipe that reaches the near-Haar regime.
24const ROUNDS: usize = 3;
25
26/// SplitMix64 — tiny, seedable, platform-stable PRNG (public domain
27/// construction, Steele et al. 2014). Used only at build time of a
28/// [`Rotation`]; never on the encode hot path.
29pub(crate) struct SplitMix64(pub u64);
30
31impl SplitMix64 {
32    #[inline]
33    pub fn next_u64(&mut self) -> u64 {
34        self.0 = self.0.wrapping_add(0x9E37_79B9_7F4A_7C15);
35        let mut z = self.0;
36        z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
37        z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
38        z ^ (z >> 31)
39    }
40
41    /// Uniform value in `0..bound` via Lemire-style rejection (bias-free).
42    #[inline]
43    fn next_below(&mut self, bound: u64) -> u64 {
44        debug_assert!(bound > 0);
45        loop {
46            let v = self.next_u64();
47            // Rejection zone keeps the mapping exactly uniform.
48            if v < u64::MAX - (u64::MAX % bound) {
49                return v % bound;
50            }
51        }
52    }
53}
54
55/// One round's parameters: sign bits (LSB-first packed), permutation, and the
56/// (shared) block decomposition of `dim`.
57struct Round {
58    /// ±1 signs packed 64 per word; bit set ⇒ negate.
59    sign_words: Vec<u64>,
60    /// `perm[i]` = source index for output slot `i` (gather form).
61    perm: Vec<u32>,
62}
63
64/// Deterministic randomized rotation. Build once per (dim, seed); apply many
65/// times. `apply` is `O(D log D)` with no matrix stored.
66pub struct Rotation {
67    dim: usize,
68    rounds: Vec<Round>,
69    /// Power-of-two block sizes covering `dim` (descending), from the binary
70    /// decomposition of `dim`. Blocks of size 1 are identity for the FWHT but
71    /// still mix through the permutations.
72    blocks: Vec<usize>,
73}
74
75impl Rotation {
76    /// Build the rotation for `dim` dimensions from `seed`.
77    pub fn new(dim: usize, seed: u64) -> Self {
78        assert!(dim >= 2, "Turbo4 rotation requires dim >= 2, got {dim}");
79        let mut rng = SplitMix64(seed ^ 0x5175_6472_616E_7434); // "QudranT4" domain sep
80        let n_words = dim.div_ceil(64);
81
82        let rounds = (0..ROUNDS)
83            .map(|_| {
84                let sign_words: Vec<u64> = (0..n_words).map(|_| rng.next_u64()).collect();
85                // Fisher–Yates with the bias-free sampler.
86                let mut perm: Vec<u32> = (0..dim as u32).collect();
87                for i in (1..dim).rev() {
88                    let j = rng.next_below(i as u64 + 1) as usize;
89                    perm.swap(i, j);
90                }
91                Round { sign_words, perm }
92            })
93            .collect();
94
95        // Binary decomposition: dim = Σ 2^k over set bits, descending.
96        let mut blocks = Vec::new();
97        let mut bit = usize::BITS - 1 - dim.leading_zeros();
98        loop {
99            if dim & (1 << bit) != 0 {
100                blocks.push(1usize << bit);
101            }
102            if bit == 0 {
103                break;
104            }
105            bit -= 1;
106        }
107
108        Self {
109            dim,
110            rounds,
111            blocks,
112        }
113    }
114
115    /// Dimensionality this rotation was built for.
116    #[inline]
117    pub fn dim(&self) -> usize {
118        self.dim
119    }
120
121    /// Rotate `v` in place. `scratch` must be `dim` long (used for the
122    /// permutation gather); contents are clobbered.
123    pub fn apply_in_place(&self, v: &mut [f32], scratch: &mut [f32]) {
124        assert_eq!(v.len(), self.dim);
125        assert_eq!(scratch.len(), self.dim);
126        for round in &self.rounds {
127            // Signs.
128            for (i, x) in v.iter_mut().enumerate() {
129                if round.sign_words[i / 64] >> (i % 64) & 1 != 0 {
130                    *x = -*x;
131                }
132            }
133            // Permutation (gather into scratch, swap back).
134            for (i, &src) in round.perm.iter().enumerate() {
135                scratch[i] = v[src as usize];
136            }
137            v.copy_from_slice(scratch);
138            // Blockwise FWHT with 1/sqrt(block) normalization (orthogonal).
139            let mut off = 0;
140            for &b in &self.blocks {
141                fwht_normalized(&mut v[off..off + b]);
142                off += b;
143            }
144        }
145    }
146
147    /// Rotate `v`, returning a new vector.
148    pub fn apply(&self, v: &[f32]) -> Vec<f32> {
149        let mut out = v.to_vec();
150        let mut scratch = vec![0.0f32; self.dim];
151        self.apply_in_place(&mut out, &mut scratch);
152        out
153    }
154
155    /// Inverse rotation (for tests/debugging; never needed on the search path).
156    pub fn apply_inverse(&self, v: &[f32]) -> Vec<f32> {
157        assert_eq!(v.len(), self.dim);
158        let mut out = v.to_vec();
159        let mut scratch = vec![0.0f32; self.dim];
160        for round in self.rounds.iter().rev() {
161            // Inverse blockwise FWHT (self-inverse when normalized).
162            let mut off = 0;
163            for &b in &self.blocks {
164                fwht_normalized(&mut out[off..off + b]);
165                off += b;
166            }
167            // Inverse permutation (scatter).
168            for (i, &src) in round.perm.iter().enumerate() {
169                scratch[src as usize] = out[i];
170            }
171            out.copy_from_slice(&scratch);
172            // Signs are self-inverse.
173            for (i, x) in out.iter_mut().enumerate() {
174                if round.sign_words[i / 64] >> (i % 64) & 1 != 0 {
175                    *x = -*x;
176                }
177            }
178        }
179        out
180    }
181}
182
183/// In-place Fast Walsh–Hadamard Transform, scaled by `1/sqrt(len)` so the
184/// transform is orthogonal (and self-inverse). `len` must be a power of two;
185/// `len == 1` is the identity.
186fn fwht_normalized(v: &mut [f32]) {
187    let n = v.len();
188    debug_assert!(n.is_power_of_two());
189    if n == 1 {
190        return;
191    }
192    let mut h = 1;
193    while h < n {
194        let mut i = 0;
195        while i < n {
196            for j in i..i + h {
197                let x = v[j];
198                let y = v[j + h];
199                v[j] = x + y;
200                v[j + h] = x - y;
201            }
202            i += h * 2;
203        }
204        h *= 2;
205    }
206    let scale = 1.0 / (n as f32).sqrt();
207    for x in v.iter_mut() {
208        *x *= scale;
209    }
210}
211
212#[cfg(test)]
213mod tests {
214    use super::*;
215
216    fn gauss_vec(dim: usize, seed: u64) -> Vec<f32> {
217        // Box–Muller over SplitMix64 — deterministic test vectors, no rand dep.
218        let mut rng = SplitMix64(seed);
219        let mut out = Vec::with_capacity(dim);
220        while out.len() < dim {
221            let u1 = (rng.next_u64() >> 11) as f64 / (1u64 << 53) as f64;
222            let u2 = (rng.next_u64() >> 11) as f64 / (1u64 << 53) as f64;
223            let r = (-2.0 * u1.max(1e-12).ln()).sqrt();
224            let (s, c) = (2.0 * std::f64::consts::PI * u2).sin_cos();
225            out.push((r * c) as f32);
226            if out.len() < dim {
227                out.push((r * s) as f32);
228            }
229        }
230        out
231    }
232
233    fn norm(v: &[f32]) -> f32 {
234        v.iter().map(|x| x * x).sum::<f32>().sqrt()
235    }
236
237    #[test]
238    fn preserves_norm_pow2_and_non_pow2() {
239        for dim in [64usize, 128, 96, 1536, 1000, 3] {
240            let rot = Rotation::new(dim, 42);
241            let v = gauss_vec(dim, 7);
242            let r = rot.apply(&v);
243            let (n0, n1) = (norm(&v), norm(&r));
244            assert!(
245                (n0 - n1).abs() < 1e-3 * n0.max(1.0),
246                "dim {dim}: norm {n0} -> {n1}"
247            );
248        }
249    }
250
251    #[test]
252    fn preserves_inner_products() {
253        let dim = 96; // 64 + 32: exercises cross-block mixing
254        let rot = Rotation::new(dim, 9);
255        let a = gauss_vec(dim, 1);
256        let b = gauss_vec(dim, 2);
257        let dot = |x: &[f32], y: &[f32]| x.iter().zip(y).map(|(p, q)| p * q).sum::<f32>();
258        let (ra, rb) = (rot.apply(&a), rot.apply(&b));
259        assert!((dot(&a, &b) - dot(&ra, &rb)).abs() < 1e-2 * dim as f32);
260    }
261
262    #[test]
263    fn inverse_roundtrips() {
264        let dim = 200; // 128+64+8
265        let rot = Rotation::new(dim, 5);
266        let v = gauss_vec(dim, 3);
267        let back = rot.apply_inverse(&rot.apply(&v));
268        for (x, y) in v.iter().zip(&back) {
269            assert!((x - y).abs() < 1e-4, "{x} vs {y}");
270        }
271    }
272
273    #[test]
274    fn deterministic_across_builds() {
275        let dim = 128;
276        let (r1, r2) = (Rotation::new(dim, 42), Rotation::new(dim, 42));
277        let v = gauss_vec(dim, 11);
278        assert_eq!(r1.apply(&v), r2.apply(&v));
279        // Different seed ⇒ different rotation.
280        let r3 = Rotation::new(dim, 43);
281        assert_ne!(r1.apply(&v), r3.apply(&v));
282    }
283
284    #[test]
285    fn spreads_spike_across_coordinates() {
286        // A one-hot vector must be spread out (max |coord| well below 1).
287        let dim = 1536;
288        let rot = Rotation::new(dim, 42);
289        let mut v = vec![0.0f32; dim];
290        v[17] = 1.0;
291        let r = rot.apply(&v);
292        let max = r.iter().fold(0.0f32, |m, x| m.max(x.abs()));
293        assert!(max < 0.25, "spike not spread: max coord {max}");
294    }
295}