Skip to main content

vecq_core/
rhdh.rs

1//! Randomized Hadamard Transform (RHDH): R = (1/sqrt(d')) · H · D.
2//!
3//! H is the Walsh-Hadamard matrix (d' = next power of two >= d), D is a
4//! diagonal matrix of random +/-1 signs derived deterministically from a seed.
5//! After RHDH, coordinates of a unit vector are approximately N(0, 1),
6//! which makes the precomputed Lloyd-Max N(0,1) tables valid without any
7//! training pass.
8//!
9//! The transform runs in O(d log d) via the fast Walsh-Hadamard transform.
10
11/// Deterministic pseudo-random +/-1 signs from a u64 seed (xorshift64*).
12/// No external RNG dependency: the sign sequence must be reproducible from
13/// the seed stored in the file header across platforms and builds.
14struct SignStream {
15    state: u64,
16}
17
18impl SignStream {
19    fn new(seed: u64) -> Self {
20        // Avoid all-zero state; mix the seed once.
21        let state = if seed == 0 {
22            0x9E37_79B9_7F4A_7C15
23        } else {
24            seed
25        };
26        Self { state }
27    }
28
29    #[inline]
30    fn next_f64(&mut self) -> f64 {
31        // xorshift64* — deterministic, portable.
32        let mut x = self.state;
33        debug_assert_ne!(x, 0);
34        x ^= x >> 12;
35        x ^= x << 25;
36        x ^= x >> 27;
37        self.state = x;
38        let v = x.wrapping_mul(0x2545_F491_4F6C_DD1D);
39        // Map to [0, 1)
40        (v >> 11) as f64 / (1u64 << 53) as f64
41    }
42
43    #[inline]
44    fn next_sign(&mut self) -> f32 {
45        if self.next_f64() < 0.5 {
46            -1.0
47        } else {
48            1.0
49        }
50    }
51}
52
53/// Fast in-place Walsh-Hadamard transform (unnormalized).
54/// `v.len()` must be a power of two.
55pub fn fwht(v: &mut [f32]) {
56    let n = v.len();
57    debug_assert!(n.is_power_of_two());
58    let mut h = 1;
59    while h < n {
60        let mut i = 0;
61        while i < n {
62            for j in i..i + h {
63                let x = v[j];
64                let y = v[j + h];
65                v[j] = x + y;
66                v[j + h] = x - y;
67            }
68            i += h * 2;
69        }
70        h *= 2;
71    }
72}
73
74/// Pad dimension up to the next power of two.
75pub fn padded_dim(dim: usize) -> usize {
76    dim.max(1).next_power_of_two()
77}
78
79/// A reusable RHDH context: the random sign diagonal for a padded dimension.
80pub struct Rhdh {
81    pub padded: usize,
82    signs: Vec<f32>,
83}
84
85impl Rhdh {
86    /// Build the transform for `dim` dimensions from a deterministic seed.
87    pub fn new(dim: usize, seed: u64) -> Self {
88        let padded = padded_dim(dim);
89        let mut s = SignStream::new(seed);
90        let signs: Vec<f32> = (0..padded).map(|_| s.next_sign()).collect();
91        Self { padded, signs }
92    }
93
94    /// Apply the randomized Hadamard transform to a unit-normalized input of
95    /// `dim` values. Returns a vector of `padded` values, approximately
96    /// N(0, 1) per coordinate. The padding zeros receive random signs too,
97    /// which keeps energy spread uniformly.
98    pub fn apply(&self, v: &[f32], out: &mut Vec<f32>) {
99        debug_assert!(v.len() <= self.padded);
100        out.clear();
101        out.resize(self.padded, 0.0);
102        out[..v.len()].copy_from_slice(v);
103        // Apply random signs then the (unnormalized) FWHT. For a unit input,
104        // unnormalized FWHT coordinates are approximately N(0,1) each —
105        // exactly the distribution the Lloyd-Max N(0,1) tables assume.
106        for (x, &s) in out.iter_mut().zip(self.signs.iter()).take(self.padded) {
107            *x *= s;
108        }
109        fwht(out);
110    }
111}
112
113#[cfg(test)]
114mod tests {
115    use super::*;
116
117    #[test]
118    fn padded_dims() {
119        assert_eq!(padded_dim(7), 8);
120        assert_eq!(padded_dim(8), 8);
121        assert_eq!(padded_dim(384), 512);
122        assert_eq!(padded_dim(1024), 1024);
123    }
124
125    #[test]
126    fn transform_scales_norm_by_sqrt_padded() {
127        // The unnormalized FWHT scales a vector's norm by sqrt(padded);
128        // callers normalize afterwards.
129        let t = Rhdh::new(8, 42);
130        let v: Vec<f32> = vec![0.3, -0.5, 0.2, 0.8, -0.1, 0.4, 0.6, -0.7];
131        let mut out = Vec::new();
132        t.apply(&v, &mut out);
133        let n_in: f32 = v.iter().map(|x| x * x).sum::<f32>().sqrt();
134        let n_out: f32 = out.iter().map(|x| x * x).sum::<f32>().sqrt();
135        let scale = n_out / n_in;
136        assert!(
137            (scale - (8f32).sqrt()).abs() < 1e-3,
138            "scale {scale} expected sqrt(8)"
139        );
140    }
141
142    #[test]
143    fn deterministic_from_seed() {
144        let a = Rhdh::new(16, 7);
145        let b = Rhdh::new(16, 7);
146        let c = Rhdh::new(16, 8);
147        let v: Vec<f32> = (0..16).map(|i| (i as f32 * 0.13 - 1.0).sin()).collect();
148        let (mut oa, mut ob, mut oc) = (Vec::new(), Vec::new(), Vec::new());
149        a.apply(&v, &mut oa);
150        b.apply(&v, &mut ob);
151        c.apply(&v, &mut oc);
152        assert_eq!(oa, ob, "same seed must be byte-identical");
153        assert_ne!(oa, oc, "different seed must differ");
154    }
155
156    #[test]
157    fn unit_vector_coords_approach_normal() {
158        // A randomly rotated unit vector's coordinates should look N(0,1/d'):
159        // after 1/sqrt(d') scaling they are approx N(0,1) with variance ~1.
160        let d = 512;
161        let t = Rhdh::new(d, 1234);
162        let mut v = vec![0.0f32; d];
163        for x in v.iter_mut() {
164            *x = rand_std_normal();
165        }
166        let norm: f32 = v.iter().map(|x| x * x).sum::<f32>().sqrt();
167        for x in v.iter_mut() {
168            *x /= norm;
169        }
170        let mut out = Vec::new();
171        t.apply(&v, &mut out);
172        let var: f32 = out.iter().map(|x| x * x).sum::<f32>() / out.len() as f32;
173        // For a unit input, unnormalized FWHT spreads total energy (1) over
174        // the padded dims: per-coordinate variance ~ 1 ... no — energy sums
175        // to 1, so per-coordinate variance ~ 1/padded * padded = 1 only for
176        // coords in the original span. Total ||out||^2 = ||v||^2 = 1 means
177        // mean variance = 1/padded.
178        let expected = 1.0f32;
179        assert!(
180            (var / expected - 1.0).abs() < 0.3,
181            "var {var} expected ~{expected}"
182        );
183    }
184
185    fn rand_std_normal() -> f32 {
186        // Box-Muller from two uniforms via a cheap LCG (test-only).
187        use std::cell::Cell;
188        thread_local! {
189            static S: Cell<u64> = const { Cell::new(0x853C_49E6_748F_EA9B) };
190        }
191        S.with(|s| {
192            let mut x = s.get();
193            x ^= x << 13;
194            x ^= x >> 7;
195            x ^= x << 17;
196            s.set(x);
197            let u1 = (x >> 11) as f64 / (1u64 << 53) as f64 + 1e-12;
198            let u2 = ((x >> 21) as f64) / 4294967296.0;
199            (-2.0 * u1.ln()).sqrt() * (std::f64::consts::TAU * u2).cos()
200        }) as f32
201    }
202}