Skip to main content

logicaffeine_system/
ntt.rs

1//! ML-KEM (Kyber) negacyclic NTT runtime kernel — the verified scalar reference + AVX2 i16×16
2//! path that compiled LOGOS reaches through the `mlkemNtt` stdlib function. Ported from the
3//! validated `scripts/ntt_simd_proto.rs` (round-trip + AVX2==scalar bit-identical). q = 3329,
4//! R = 2¹⁶, the incomplete 7-level transform over ℤ_q\[X\]/(X²⁵⁶+1). The Montgomery reduction it
5//! uses is kernel-certified (`logicaffeine_kernel::field_algebra::montgomery_reduction_*`).
6//!
7//! Input/output are LOGOS `Int` (i64); coefficients are reduced into [0, q) at the boundary, the
8//! transform runs in i16, and the result is returned reduced into [0, q). On x86-64 with AVX2 the
9//! 16-wide kernel runs (≈49 ns/NTT on a modern core); otherwise the scalar path (≈147 ns).
10
11const Q: i32 = 3329;
12const QINV: i32 = -3327; // q⁻¹ mod 2¹⁶ (signed i16)
13
14/// Kyber zetas[128] — ζ^bitrev(i) · R mod q, signed, Montgomery form.
15const ZETAS: [i16; 128] = [
16    -1044, -758, -359, -1517, 1493, 1422, 287, 202, -171, 622, 1577, 182, 962, -1202, -1474, 1468,
17    573, -1325, 264, 383, -829, 1458, -1602, -130, -681, 1017, 732, 608, -1542, 411, -205, -1571,
18    1223, 652, -552, 1015, -1293, 1491, -282, -1544, 516, -8, -320, -666, -1618, -1162, 126, 1469,
19    -853, -90, -271, 830, 107, -1421, -247, -951, -398, 961, -1508, -725, 448, -1065, 677, -1275,
20    -1103, 430, 555, 843, -1251, 871, 1550, 105, 422, 587, 177, -235, -291, -460, 1574, 1653, -246,
21    778, 1159, -147, -777, 1483, -602, 1119, -1590, 644, -872, 349, 418, 329, -156, -75, 817, 1097,
22    603, 610, 1322, -1285, -1465, 384, -1215, -136, 1218, -1335, -874, 220, -1187, -1659, -1185,
23    -1530, -1278, 794, -1510, -854, -870, 478, -108, -308, 996, 991, 958, -1460, 1522, 1628,
24];
25
26#[inline]
27fn montgomery_reduce(a: i32) -> i16 {
28    let t = (a as i16).wrapping_mul(QINV as i16);
29    ((a - (t as i32) * Q) >> 16) as i16
30}
31
32/// Reduce a value already in the Montgomery range `(−q, q)` into `[0, q)` with a single conditional
33/// add — branchless (`x + (q & (x >> 15))`), auto-vectorizes 16-wide, and replaces the division-based
34/// `rem_euclid(Q)` in the coefficient hot path. Sound ONLY when `|x| < q` (the reduction invariant).
35#[inline]
36fn cadd_q(x: i16) -> u16 {
37    (x + (Q as i16 & (x >> 15))) as u16
38}
39#[inline]
40fn fqmul(a: i16, b: i16) -> i16 {
41    montgomery_reduce(a as i32 * b as i32)
42}
43
44/// Scalar forward negacyclic NTT (Kyber reference), in place.
45fn ntt_scalar(r: &mut [i16; 256]) {
46    let mut k = 1usize;
47    let mut len = 128usize;
48    while len >= 2 {
49        let mut start = 0usize;
50        while start < 256 {
51            let zeta = ZETAS[k];
52            k += 1;
53            for j in start..start + len {
54                let t = fqmul(zeta, r[j + len]);
55                r[j + len] = r[j].wrapping_sub(t);
56                r[j] = r[j].wrapping_add(t);
57            }
58            start += 2 * len;
59        }
60        len >>= 1;
61    }
62}
63
64/// AVX2 forward NTT — all 7 levels vectorized 16-wide. Bit-identical to `ntt_scalar`.
65#[cfg(target_arch = "x86_64")]
66#[target_feature(enable = "avx2")]
67unsafe fn ntt_avx2(r: &mut [i16; 256]) {
68    use std::arch::x86_64::*;
69    let qv = _mm256_set1_epi16(Q as i16);
70    let qinvv = _mm256_set1_epi16(QINV as i16);
71    let mut k = 1usize;
72    let mut len = 128usize;
73    // len >= 16: contiguous 16-wide butterflies, broadcast zeta.
74    while len >= 16 {
75        let mut start = 0usize;
76        while start < 256 {
77            let zeta = _mm256_set1_epi16(ZETAS[k]);
78            k += 1;
79            let mut j = start;
80            while j < start + len {
81                let aj = _mm256_loadu_si256(r.as_ptr().add(j) as *const __m256i);
82                let ajl = _mm256_loadu_si256(r.as_ptr().add(j + len) as *const __m256i);
83                let rlo = _mm256_mullo_epi16(zeta, ajl);
84                let rhi = _mm256_mulhi_epi16(zeta, ajl);
85                let tt = _mm256_mullo_epi16(rlo, qinvv);
86                let tt = _mm256_mulhi_epi16(tt, qv);
87                let t = _mm256_sub_epi16(rhi, tt);
88                _mm256_storeu_si256(r.as_mut_ptr().add(j) as *mut __m256i, _mm256_add_epi16(aj, t));
89                _mm256_storeu_si256(r.as_mut_ptr().add(j + len) as *mut __m256i, _mm256_sub_epi16(aj, t));
90                j += 16;
91            }
92            start += 2 * len;
93        }
94        len >>= 1;
95    }
96    // len = 8: one block per vector, 128-bit half-swap + blend.
97    {
98        let lo_mask = _mm256_set_epi64x(0, 0, -1, -1);
99        let mut start = 0usize;
100        while start < 256 {
101            let zeta = _mm256_set1_epi16(ZETAS[k]);
102            k += 1;
103            let v = _mm256_loadu_si256(r.as_ptr().add(start) as *const __m256i);
104            let hi = _mm256_permute2x128_si256(v, v, 0x01);
105            let rlo = _mm256_mullo_epi16(zeta, hi);
106            let rhi = _mm256_mulhi_epi16(zeta, hi);
107            let tt = _mm256_mullo_epi16(rlo, qinvv);
108            let tt = _mm256_mulhi_epi16(tt, qv);
109            let t = _mm256_sub_epi16(rhi, tt);
110            let add = _mm256_add_epi16(v, t);
111            let sub = _mm256_sub_epi16(v, t);
112            let sub_hi = _mm256_permute2x128_si256(sub, sub, 0x01);
113            let out = _mm256_blendv_epi8(sub_hi, add, lo_mask);
114            _mm256_storeu_si256(r.as_mut_ptr().add(start) as *mut __m256i, out);
115            start += 16;
116        }
117        len >>= 1;
118    }
119    // len = 4: two blocks per vector, per-block zeta + 64-bit swap.
120    {
121        let mask4 = _mm256_set_epi64x(0, -1, 0, -1);
122        let mut start = 0usize;
123        while start < 256 {
124            let z0 = ZETAS[k];
125            let z1 = ZETAS[k + 1];
126            k += 2;
127            let zv = _mm256_set_epi16(z1, z1, z1, z1, z1, z1, z1, z1, z0, z0, z0, z0, z0, z0, z0, z0);
128            let v = _mm256_loadu_si256(r.as_ptr().add(start) as *const __m256i);
129            let vhi = _mm256_shuffle_epi32(v, 0x4E);
130            let rlo = _mm256_mullo_epi16(zv, vhi);
131            let rhi = _mm256_mulhi_epi16(zv, vhi);
132            let tt = _mm256_mullo_epi16(rlo, qinvv);
133            let tt = _mm256_mulhi_epi16(tt, qv);
134            let t = _mm256_sub_epi16(rhi, tt);
135            let add = _mm256_add_epi16(v, t);
136            let sub = _mm256_sub_epi16(v, t);
137            let sub_s = _mm256_shuffle_epi32(sub, 0x4E);
138            let out = _mm256_blendv_epi8(sub_s, add, mask4);
139            _mm256_storeu_si256(r.as_mut_ptr().add(start) as *mut __m256i, out);
140            start += 16;
141        }
142        len >>= 1;
143    }
144    // len = 2: four blocks per vector, per-block zeta + 32-bit swap.
145    {
146        let mask2 = _mm256_set_epi32(0, -1, 0, -1, 0, -1, 0, -1);
147        let mut start = 0usize;
148        while start < 256 {
149            let z0 = ZETAS[k];
150            let z1 = ZETAS[k + 1];
151            let z2 = ZETAS[k + 2];
152            let z3 = ZETAS[k + 3];
153            k += 4;
154            let zv = _mm256_set_epi16(z3, z3, z3, z3, z2, z2, z2, z2, z1, z1, z1, z1, z0, z0, z0, z0);
155            let v = _mm256_loadu_si256(r.as_ptr().add(start) as *const __m256i);
156            let vhi = _mm256_shuffle_epi32(v, 0xB1);
157            let rlo = _mm256_mullo_epi16(zv, vhi);
158            let rhi = _mm256_mulhi_epi16(zv, vhi);
159            let tt = _mm256_mullo_epi16(rlo, qinvv);
160            let tt = _mm256_mulhi_epi16(tt, qv);
161            let t = _mm256_sub_epi16(rhi, tt);
162            let add = _mm256_add_epi16(v, t);
163            let sub = _mm256_sub_epi16(v, t);
164            let sub_s = _mm256_shuffle_epi32(sub, 0xB1);
165            let out = _mm256_blendv_epi8(sub_s, add, mask2);
166            _mm256_storeu_si256(r.as_mut_ptr().add(start) as *mut __m256i, out);
167            start += 16;
168        }
169    }
170}
171
172/// Montgomery multiply 16 i16 lanes: `montgomery_reduce(a·b)` — bit-identical to the scalar `fqmul`
173/// (low·qinv→t, then high − (t·q)high). The verified Kyber AVX2 reduction.
174#[cfg(target_arch = "x86_64")]
175#[target_feature(enable = "avx2")]
176#[inline]
177unsafe fn mont_mul_x16(
178    a: std::arch::x86_64::__m256i,
179    b: std::arch::x86_64::__m256i,
180) -> std::arch::x86_64::__m256i {
181    use std::arch::x86_64::*;
182    let q = _mm256_set1_epi16(Q as i16);
183    let qinv = _mm256_set1_epi16(QINV as i16);
184    let rlo = _mm256_mullo_epi16(b, a);
185    let rhi = _mm256_mulhi_epi16(b, a);
186    let tt = _mm256_mullo_epi16(rlo, qinv);
187    let tt = _mm256_mulhi_epi16(tt, q);
188    _mm256_sub_epi16(rhi, tt)
189}
190
191/// Barrett-reduce 16 i16 lanes, bit-identical to the scalar `barrett_reduce`. The scalar form
192/// `t = (V·a + 2²⁵) >> 26` (V = ⌊2²⁶/q⌉ = 20159, rounded) equals `t = (mulhi(a,V) + 512) >> 10`
193/// exactly: writing `V·a = hi·2¹⁶ + L` with `L ∈ [0,2¹⁶)`, the term `L/2²⁶ < 1/1024` can never
194/// move the floor, so the low half drops out and the rounding collapses to a +512 before the >>10.
195#[cfg(target_arch = "x86_64")]
196#[target_feature(enable = "avx2")]
197#[inline]
198unsafe fn barrett_x16(a: std::arch::x86_64::__m256i) -> std::arch::x86_64::__m256i {
199    use std::arch::x86_64::*;
200    const V: i16 = (((1 << 26) + Q / 2) / Q) as i16;
201    let q = _mm256_set1_epi16(Q as i16);
202    let hi = _mm256_mulhi_epi16(a, _mm256_set1_epi16(V));
203    let t = _mm256_srai_epi16(_mm256_add_epi16(hi, _mm256_set1_epi16(512)), 10);
204    _mm256_sub_epi16(a, _mm256_mullo_epi16(t, q))
205}
206
207/// AVX2 inverse NTT (`invntt_tomont`) — all 7 Gentleman-Sande levels vectorized 16-wide, then the
208/// final `f`-scaling. Bit-identical to `invntt_scalar`. Mirrors `ntt_avx2`'s lane routing in reverse
209/// level order: the GS butterfly writes `barrett(lo+hi)` to the low slot and `fqmul(ζ, hi−lo)` to the
210/// high slot (both already in position — no post-swap, unlike the forward Cooley–Tukey blend).
211#[cfg(target_arch = "x86_64")]
212#[target_feature(enable = "avx2")]
213unsafe fn invntt_avx2(r: &mut [i16; 256]) {
214    use std::arch::x86_64::*;
215    const F: i16 = 1441;
216    let mut k = 127usize;
217    // len = 2: four blocks per vector, per-block zeta, 32-bit swap.
218    {
219        let mask2 = _mm256_set_epi32(0, -1, 0, -1, 0, -1, 0, -1);
220        let mut start = 0usize;
221        while start < 256 {
222            let z0 = ZETAS[k];
223            let z1 = ZETAS[k - 1];
224            let z2 = ZETAS[k - 2];
225            let z3 = ZETAS[k - 3];
226            k -= 4;
227            let zv = _mm256_set_epi16(z3, z3, z3, z3, z2, z2, z2, z2, z1, z1, z1, z1, z0, z0, z0, z0);
228            let v = _mm256_loadu_si256(r.as_ptr().add(start) as *const __m256i);
229            let vsh = _mm256_shuffle_epi32(v, 0xB1);
230            let lopart = barrett_x16(_mm256_add_epi16(v, vsh));
231            let hipart = mont_mul_x16(zv, _mm256_sub_epi16(v, vsh));
232            let out = _mm256_blendv_epi8(hipart, lopart, mask2);
233            _mm256_storeu_si256(r.as_mut_ptr().add(start) as *mut __m256i, out);
234            start += 16;
235        }
236    }
237    // len = 4: two blocks per vector, per-block zeta, 64-bit swap.
238    {
239        let mask4 = _mm256_set_epi64x(0, -1, 0, -1);
240        let mut start = 0usize;
241        while start < 256 {
242            let z0 = ZETAS[k];
243            let z1 = ZETAS[k - 1];
244            k -= 2;
245            let zv = _mm256_set_epi16(z1, z1, z1, z1, z1, z1, z1, z1, z0, z0, z0, z0, z0, z0, z0, z0);
246            let v = _mm256_loadu_si256(r.as_ptr().add(start) as *const __m256i);
247            let vsh = _mm256_shuffle_epi32(v, 0x4E);
248            let lopart = barrett_x16(_mm256_add_epi16(v, vsh));
249            let hipart = mont_mul_x16(zv, _mm256_sub_epi16(v, vsh));
250            let out = _mm256_blendv_epi8(hipart, lopart, mask4);
251            _mm256_storeu_si256(r.as_mut_ptr().add(start) as *mut __m256i, out);
252            start += 16;
253        }
254    }
255    // len = 8: one block per vector, 128-bit half-swap.
256    {
257        let lo_mask = _mm256_set_epi64x(0, 0, -1, -1);
258        let mut start = 0usize;
259        while start < 256 {
260            let zeta = _mm256_set1_epi16(ZETAS[k]);
261            k -= 1;
262            let v = _mm256_loadu_si256(r.as_ptr().add(start) as *const __m256i);
263            let vsh = _mm256_permute2x128_si256(v, v, 0x01);
264            let lopart = barrett_x16(_mm256_add_epi16(v, vsh));
265            let hipart = mont_mul_x16(zeta, _mm256_sub_epi16(v, vsh));
266            let out = _mm256_blendv_epi8(hipart, lopart, lo_mask);
267            _mm256_storeu_si256(r.as_mut_ptr().add(start) as *mut __m256i, out);
268            start += 16;
269        }
270    }
271    // len >= 16: contiguous 16-wide butterflies, broadcast zeta.
272    let mut len = 16usize;
273    while len <= 128 {
274        let mut start = 0usize;
275        while start < 256 {
276            let zeta = _mm256_set1_epi16(ZETAS[k]);
277            k = k.wrapping_sub(1);
278            let mut j = start;
279            while j < start + len {
280                let lo = _mm256_loadu_si256(r.as_ptr().add(j) as *const __m256i);
281                let hi = _mm256_loadu_si256(r.as_ptr().add(j + len) as *const __m256i);
282                _mm256_storeu_si256(
283                    r.as_mut_ptr().add(j) as *mut __m256i,
284                    barrett_x16(_mm256_add_epi16(lo, hi)),
285                );
286                _mm256_storeu_si256(
287                    r.as_mut_ptr().add(j + len) as *mut __m256i,
288                    mont_mul_x16(zeta, _mm256_sub_epi16(hi, lo)),
289                );
290                j += 16;
291            }
292            start += 2 * len;
293        }
294        len <<= 1;
295    }
296    // Final f = mont²/128 scaling.
297    let fv = _mm256_set1_epi16(F);
298    let mut i = 0usize;
299    while i < 256 {
300        let v = _mm256_loadu_si256(r.as_ptr().add(i) as *const __m256i);
301        _mm256_storeu_si256(r.as_mut_ptr().add(i) as *mut __m256i, mont_mul_x16(fv, v));
302        i += 16;
303    }
304}
305
306/// Core forward ML-KEM NTT of 256 coefficients (mod q = 3329): AVX2 when the CPU has it, else
307/// scalar — same result. Coefficients are reduced into [0, q) at the boundary; output in [0, q).
308fn mlkem_ntt_raw(input: &[i64]) -> Vec<i64> {
309    assert_eq!(input.len(), 256, "mlkemNtt expects exactly 256 coefficients");
310    // mlkem_ntt's contract is to reduce ANY i64 input (see mlkem_ntt_reduces_and_matches_scalar),
311    // so the full rem_euclid stays here — unlike base_mul/inv_ntt/to_mont, which only ever see
312    // already-reduced NTT-domain coefficients.
313    let mut r = [0i16; 256];
314    for (slot, &x) in r.iter_mut().zip(input) {
315        *slot = x.rem_euclid(Q as i64) as i16;
316    }
317    #[cfg(target_arch = "x86_64")]
318    {
319        if std::is_x86_feature_detected!("avx2") {
320            unsafe { ntt_avx2(&mut r) };
321        } else {
322            ntt_scalar(&mut r);
323        }
324    }
325    #[cfg(not(target_arch = "x86_64"))]
326    ntt_scalar(&mut r);
327    r.iter().map(|&x| (x as i32).rem_euclid(Q) as i64).collect()
328}
329
330/// The compiled form of LOGOS `mlkemNtt(a)` — the runtime entry the generated Rust calls (and the
331/// interpreter dispatches to). Takes/returns the LOGOS `Seq of Int` carrier.
332pub fn mlkem_ntt(input: &[i64]) -> logicaffeine_data::LogosSeq<i64> {
333    let out = mlkem_ntt_raw(input);
334    logicaffeine_data::LogosSeq::from_vec(out)
335}
336
337/// `Word16`-native forward NTT — coefficients are carried as `Word16` (u16) in [0, q), so the
338/// boundary is a plain reinterpret (`w.0 as i16`, exact since q < 2¹⁵), no rem_euclid and no
339/// i64 round-trip. The experimental fast carrier for the Word16-representation crypto.
340pub fn mlkem_ntt_w16(input: &[logicaffeine_base::Word16]) -> Vec<logicaffeine_base::Word16> {
341    use logicaffeine_base::Word16;
342    assert_eq!(input.len(), 256);
343    let mut r = [0i16; 256];
344    for (slot, w) in r.iter_mut().zip(input) {
345        *slot = w.0 as i16;
346    }
347    #[cfg(target_arch = "x86_64")]
348    {
349        if std::is_x86_feature_detected!("avx2") {
350            unsafe { ntt_avx2(&mut r) };
351        } else {
352            ntt_scalar(&mut r);
353        }
354    }
355    #[cfg(not(target_arch = "x86_64"))]
356    ntt_scalar(&mut r);
357    r.iter().map(|&x| Word16((x as i32).rem_euclid(Q) as u16)).collect()
358}
359
360/// `Word16`-native inverse NTT, base multiply, and tomont — same zero-conversion carrier.
361pub fn mlkem_inv_ntt_w16(input: &[logicaffeine_base::Word16]) -> Vec<logicaffeine_base::Word16> {
362    use logicaffeine_base::Word16;
363    let mut r = [0i16; 256];
364    for (slot, w) in r.iter_mut().zip(input) {
365        *slot = w.0 as i16;
366    }
367    inv_ntt_inplace(&mut r);
368    r.iter().map(|&x| Word16((x as i32).rem_euclid(Q) as u16)).collect()
369}
370
371/// Inverse NTT in place: AVX2 when the CPU has it (bit-identical to the scalar reference), else scalar.
372#[inline]
373fn inv_ntt_inplace(r: &mut [i16; 256]) {
374    #[cfg(target_arch = "x86_64")]
375    {
376        if std::is_x86_feature_detected!("avx2") {
377            unsafe { invntt_avx2(r) };
378            return;
379        }
380    }
381    invntt_scalar(r);
382}
383pub fn mlkem_base_mul_w16(
384    a: &[logicaffeine_base::Word16],
385    b: &[logicaffeine_base::Word16],
386) -> Vec<logicaffeine_base::Word16> {
387    use logicaffeine_base::Word16;
388    let to16 = |v: &[Word16]| -> [i16; 256] {
389        let mut r = [0i16; 256];
390        for (slot, w) in r.iter_mut().zip(v) {
391            *slot = w.0 as i16;
392        }
393        r
394    };
395    let r = basemul_scalar(&to16(a), &to16(b));
396    // basemul_scalar sums two Montgomery products (each ∈ (−q, q)) ⇒ result ∈ (−2q, 2q): Barrett into
397    // (−q/2, q/2] then a conditional add into [0, q) — both branchless, no division.
398    r.iter().map(|&x| Word16(cadd_q(barrett_reduce(x)))).collect()
399}
400pub fn mlkem_to_mont_w16(coeffs: &[logicaffeine_base::Word16]) -> Vec<logicaffeine_base::Word16> {
401    use logicaffeine_base::Word16;
402    const F: i32 = 1353;
403    // montgomery_reduce returns (−q, q) ⇒ conditional add, no division.
404    coeffs.iter().map(|w| Word16(cadd_q(montgomery_reduce(w.0 as i16 as i32 * F)))).collect()
405}
406
407/// Native entry for the LOGOS `Seq of Word16` carrier — wraps the raw Word16 NTT in a `LogosSeq`.
408pub fn mlkem_ntt_w16_seq(
409    input: &[logicaffeine_base::Word16],
410) -> logicaffeine_data::LogosSeq<logicaffeine_base::Word16> {
411    logicaffeine_data::LogosSeq::from_vec(mlkem_ntt_w16(input))
412}
413
414// ── Word16/Word8 carrier: the rest of the ML-KEM pipeline (zero i64 round-trip) ───────────────
415use logicaffeine_base::{Word16, Word8};
416
417/// CBD noise sampled to [0, q) Word16 (the reduced form the Word16 NTT consumes directly).
418pub fn mlkem_cbd2_w16(buf: &[u8]) -> Vec<Word16> {
419    cbd_eta2(buf).iter().map(|&c| Word16((c as i32).rem_euclid(Q) as u16)).collect()
420}
421pub fn mlkem_cbd3_w16(buf: &[u8]) -> Vec<Word16> {
422    cbd_eta3(buf).iter().map(|&c| Word16((c as i32).rem_euclid(Q) as u16)).collect()
423}
424pub fn mlkem_compress_w16(coeffs: &[Word16], d: usize) -> Vec<Word16> {
425    let mask = (1u64 << d) - 1;
426    coeffs
427        .iter()
428        .map(|w| Word16(((((w.0 as u64) << d) + (Q as u64) / 2) / (Q as u64) & mask) as u16))
429        .collect()
430}
431pub fn mlkem_decompress_w16(coeffs: &[Word16], d: usize) -> Vec<Word16> {
432    let denom = 1u64 << d;
433    coeffs
434        .iter()
435        .map(|w| Word16((((w.0 as u64) * (Q as u64) + denom / 2) >> d) as u16))
436        .collect()
437}
438pub fn mlkem_byte_encode_w16(coeffs: &[Word16], d: usize) -> Vec<u8> {
439    let mask = (1u64 << d) - 1;
440    let mut out = Vec::with_capacity((coeffs.len() * d).div_ceil(8));
441    let (mut acc, mut nbits) = (0u64, 0u32);
442    for w in coeffs {
443        acc |= (w.0 as u64 & mask) << nbits;
444        nbits += d as u32;
445        while nbits >= 8 {
446            out.push((acc & 0xff) as u8);
447            acc >>= 8;
448            nbits -= 8;
449        }
450    }
451    if nbits > 0 {
452        out.push((acc & 0xff) as u8);
453    }
454    out
455}
456pub fn mlkem_byte_decode_w16(bytes: &[u8], d: usize) -> Vec<Word16> {
457    let n = (bytes.len() * 8) / d;
458    let mask = (1u64 << d) - 1;
459    let mut out = Vec::with_capacity(n);
460    let (mut acc, mut nbits, mut bi) = (0u64, 0u32, 0usize);
461    for _ in 0..n {
462        while nbits < d as u32 {
463            acc |= (bytes[bi] as u64) << nbits;
464            nbits += 8;
465            bi += 1;
466        }
467        let val = (acc & mask) as u16;
468        acc >>= d;
469        nbits -= d as u32;
470        out.push(Word16(if d == 12 { (val as i32 % Q) as u16 } else { val }));
471    }
472    out
473}
474/// FIPS-203 SampleNTT rejection over one 168-byte SHAKE128 block: parse 12-bit pairs, keep those
475/// `< q`, append to `out` (capped at 256). 168 = 56·3 so triples never straddle a block boundary.
476#[inline]
477fn reject_sample_block(buf: &[u8; 168], out: &mut Vec<Word16>) {
478    let q = Q as u32;
479    let mut k = 0;
480    while k + 3 <= 168 && out.len() < 256 {
481        let (b0, b1, b2) = (buf[k] as u32, buf[k + 1] as u32, buf[k + 2] as u32);
482        let d1 = b0 + 256 * (b1 % 16);
483        let d2 = (b1 / 16) + 16 * b2;
484        if d1 < q {
485            out.push(Word16(d1 as u16));
486        }
487        if d2 < q && out.len() < 256 {
488            out.push(Word16(d2 as u16));
489        }
490        k += 3;
491    }
492}
493
494/// The 256-entry byte-compaction shuffle table for the vectorized rejection sampler: `table[m]` is the
495/// `vpshufb` index that packs the accepted 16-bit lanes (accept bitmask `m` over 8 lanes) to the front
496/// of a 128-bit register. Built once (idempotent).
497#[cfg(target_arch = "x86_64")]
498fn rej_compact_table() -> &'static [[u8; 16]; 256] {
499    use std::sync::OnceLock;
500    static TABLE: OnceLock<[[u8; 16]; 256]> = OnceLock::new();
501    TABLE.get_or_init(|| {
502        let mut t = [[0xFFu8; 16]; 256];
503        for (m, entry) in t.iter_mut().enumerate() {
504            let mut pos = 0usize;
505            for lane in 0..8u8 {
506                if (m >> lane) & 1 == 1 {
507                    entry[pos * 2] = lane * 2;
508                    entry[pos * 2 + 1] = lane * 2 + 1;
509                    pos += 1;
510                }
511            }
512        }
513        t
514    })
515}
516
517/// AVX2 vectorized ML-KEM matrix rejection sampler — **bit-identical** to [`reject_sample_block`],
518/// several × faster: spread 24 bytes → 16 twelve-bit candidates per step (`vpshufb` + `vpsrlw` + blend),
519/// compare all sixteen to q at once (`vpcmpgtw`), then compact the accepted lanes with a 256-entry
520/// shuffle table (`vpshufb` + popcount advance). Appends accepted coefficients to `out` in the SAME
521/// order as the scalar sampler and stops at 256. This is the ExpandA hot loop (~85% of sampleA cost).
522#[cfg(target_arch = "x86_64")]
523#[target_feature(enable = "avx2")]
524unsafe fn reject_sample_block_avx2(buf: &[u8; 168], out: &mut Vec<Word16>) {
525    use std::arch::x86_64::*;
526    let table = rej_compact_table();
527    let qv = _mm256_set1_epi16(Q as i16);
528    let maskv = _mm256_set1_epi16(0x0fff);
529    // idx8: within each 128-bit half, 16-bit lane 2i ← bytes [3i, 3i+1], lane 2i+1 ← bytes [3i+1, 3i+2]
530    // (low half over its bytes 0..11, high half over its bytes 4..15 = the group's bytes 12..23).
531    let idx8 = _mm256_set_epi8(
532        15, 14, 14, 13, 12, 11, 11, 10, 9, 8, 8, 7, 6, 5, 5, 4, //
533        11, 10, 10, 9, 8, 7, 7, 6, 5, 4, 4, 3, 2, 1, 1, 0,
534    );
535    // Pad so every 32-byte load stays in bounds (168 = 7·24; the 7th load reads to byte 175).
536    let mut padded = [0u8; 192];
537    padded[..168].copy_from_slice(buf);
538
539    let mut off = 0usize;
540    while off + 24 <= 168 && out.len() < 256 {
541        let raw = _mm256_loadu_si256(padded.as_ptr().add(off) as *const __m256i);
542        let perm = _mm256_permute4x64_epi64(raw, 0x94); // lo=bytes0..15, hi=bytes8..23
543        let mut f = _mm256_shuffle_epi8(perm, idx8);
544        let g = _mm256_srli_epi16(f, 4);
545        f = _mm256_blend_epi16(f, g, 0xAA); // odd 16-bit lanes take the >>4 form (d2)
546        f = _mm256_and_si256(f, maskv);
547        let good = _mm256_cmpgt_epi16(qv, f); // 0xFFFF where f < q (accepted)
548
549        for half in 0..2 {
550            if out.len() >= 256 {
551                break;
552            }
553            let cand = if half == 0 { _mm256_castsi256_si128(f) } else { _mm256_extracti128_si256(f, 1) };
554            let gd = if half == 0 { _mm256_castsi256_si128(good) } else { _mm256_extracti128_si256(good, 1) };
555            let m = (_mm_movemask_epi8(_mm_packs_epi16(gd, gd)) & 0xff) as usize;
556            let sh = _mm_loadu_si128(table[m].as_ptr() as *const __m128i);
557            let packed = _mm_shuffle_epi8(cand, sh);
558            let mut tmp = [0i16; 8];
559            _mm_storeu_si128(tmp.as_mut_ptr() as *mut __m128i, packed);
560            let n = (m.count_ones() as usize).min(256 - out.len());
561            for &c in tmp.iter().take(n) {
562                out.push(Word16(c as u16));
563            }
564        }
565        off += 24;
566    }
567}
568
569/// The padded 168-byte SHAKE128 absorb block for matrix entry `Â[r][c]`: `seed‖r‖c`, the `0x1f`
570/// delimiter, and the `0x80` final bit — the single-block XOF input the rejection sampler streams.
571#[inline]
572fn sample_a_xof_block(seed: &[u8], r: u8, c: u8) -> [u8; 168] {
573    let mut blk = [0u8; 168];
574    blk[..32].copy_from_slice(&seed[..32]);
575    blk[32] = r;
576    blk[33] = c;
577    blk[34] = 0x1f;
578    blk[167] |= 0x80;
579    blk
580}
581
582/// Matrix entry Â\[i\]\[j\] sampled to [0, q) Word16 — streaming SHAKE128 rejection (scalar reference).
583pub fn mlkem_sample_a_w16(seed: &[u8], idx_i: i64, idx_j: i64) -> Vec<Word16> {
584    let mut xof_in = [0u8; 34];
585    xof_in[..32].copy_from_slice(&seed[..32]);
586    xof_in[32] = idx_i.rem_euclid(256) as u8;
587    xof_in[33] = idx_j.rem_euclid(256) as u8;
588    let mut st = crate::keccak::shake128_absorb(&xof_in);
589    let mut out: Vec<Word16> = Vec::with_capacity(256);
590    let mut buf = [0u8; 168];
591    loop {
592        for i in 0..21 {
593            buf[i * 8..i * 8 + 8].copy_from_slice(&st[i].to_le_bytes());
594        }
595        #[cfg(target_arch = "x86_64")]
596        {
597            if std::is_x86_feature_detected!("avx2") {
598                unsafe { reject_sample_block_avx2(&buf, &mut out) };
599            } else {
600                reject_sample_block(&buf, &mut out);
601            }
602        }
603        #[cfg(not(target_arch = "x86_64"))]
604        reject_sample_block(&buf, &mut out);
605        if out.len() >= 256 {
606            out.truncate(256);
607            return out;
608        }
609        crate::keccak::keccak_f1600(&mut st);
610    }
611}
612
613/// Sample the full ML-KEM-768 3×3 matrix  at once (9 × 256 Word16, entry Â\[r\]\[c\] at slot
614/// `(r·3+c)·256`), batching four rejection-sampling streams per 4-way AVX2 SHAKE128 permutation.
615/// Bit-identical to nine `mlkem_sample_a_w16` calls; falls back to scalar without AVX2. This is the
616/// matrix-expansion keystone — sampleA is ~60% of keygen and is `×9` again in encaps.
617pub fn mlkem_sample_matrix_w16(seed: &[u8]) -> Vec<Word16> {
618    const ENTRIES: [(u8, u8); 9] =
619        [(0, 0), (0, 1), (0, 2), (1, 0), (1, 1), (1, 2), (2, 0), (2, 1), (2, 2)];
620    let mut out = vec![Word16(0); 9 * 256];
621    let mut place = |e: usize, poly: &[Word16]| {
622        out[e * 256..e * 256 + 256].copy_from_slice(&poly[..256]);
623    };
624
625    #[cfg(target_arch = "x86_64")]
626    {
627        if std::is_x86_feature_detected!("avx2") {
628            let mut e = 0;
629            while e + 4 <= ENTRIES.len() {
630                let blocks: [[u8; 168]; 4] = std::array::from_fn(|l| {
631                    sample_a_xof_block(seed, ENTRIES[e + l].0, ENTRIES[e + l].1)
632                });
633                let mut accs: [Vec<Word16>; 4] =
634                    std::array::from_fn(|_| Vec::with_capacity(256));
635                let mut st = unsafe { crate::keccak::shake128_x4_absorb_once(&blocks) };
636                loop {
637                    let outb = unsafe { crate::keccak::shake128_x4_squeeze_block(&st) };
638                    let mut done = true;
639                    for (l, acc) in accs.iter_mut().enumerate() {
640                        if acc.len() < 256 {
641                            unsafe { reject_sample_block_avx2(&outb[l], acc) };
642                        }
643                        if acc.len() < 256 {
644                            done = false;
645                        }
646                    }
647                    if done {
648                        break;
649                    }
650                    unsafe { crate::keccak::keccak_f1600_x4(&mut st) };
651                }
652                for (l, acc) in accs.iter().enumerate() {
653                    place(e + l, acc);
654                }
655                e += 4;
656            }
657            while e < ENTRIES.len() {
658                let v = mlkem_sample_a_w16(seed, ENTRIES[e].0 as i64, ENTRIES[e].1 as i64);
659                place(e, &v);
660                e += 1;
661            }
662            return out;
663        }
664    }
665
666    for (e, &(r, c)) in ENTRIES.iter().enumerate() {
667        let v = mlkem_sample_a_w16(seed, r as i64, c as i64);
668        place(e, &v);
669    }
670    out
671}
672
673#[inline]
674fn barrett_reduce(a: i16) -> i16 {
675    const V: i32 = ((1 << 26) + Q / 2) / Q;
676    let t = (((V * a as i32) + (1 << 25)) >> 26) as i16;
677    a.wrapping_sub(t.wrapping_mul(Q as i16))
678}
679
680/// Scalar inverse NTT (Kyber reference, `invntt_tomont`), in place — Gentleman-Sande butterflies
681/// with Barrett reduction, then the `f = mont²/128` final scaling. invntt(ntt(p)) = p·MONT mod q.
682fn invntt_scalar(r: &mut [i16; 256]) {
683    const F: i16 = 1441;
684    let mut k = 127usize;
685    let mut len = 2usize;
686    while len <= 128 {
687        let mut start = 0usize;
688        while start < 256 {
689            let zeta = ZETAS[k];
690            k = k.wrapping_sub(1);
691            for j in start..start + len {
692                let t = r[j];
693                r[j] = barrett_reduce(t.wrapping_add(r[j + len]));
694                r[j + len] = r[j + len].wrapping_sub(t);
695                r[j + len] = fqmul(zeta, r[j + len]);
696            }
697            start += 2 * len;
698        }
699        len <<= 1;
700    }
701    for x in r.iter_mut() {
702        *x = fqmul(*x, F);
703    }
704}
705
706fn mlkem_inv_ntt_raw(input: &[i64]) -> Vec<i64> {
707    assert_eq!(input.len(), 256, "mlkemInvNtt expects exactly 256 coefficients");
708    // Inputs are NTT-domain coefficients in [0, q) — the truncating `as i16` cast is exact.
709    let mut r = [0i16; 256];
710    for (slot, &x) in r.iter_mut().zip(input) {
711        debug_assert!((0..Q as i64).contains(&x), "inv_ntt input out of [0,q)");
712        *slot = x as i16;
713    }
714    inv_ntt_inplace(&mut r);
715    r.iter().map(|&x| (x as i32).rem_euclid(Q) as i64).collect()
716}
717
718/// The compiled form of LOGOS `mlkemInvNtt(a)` — the inverse ML-KEM NTT (`tomont`), so
719/// `mlkemInvNtt(mlkemNtt(p)) = p·2285 mod q`. Together with `mlkemNtt` and a base-multiply this is
720/// the polynomial-multiplication primitive ML-KEM is built on.
721pub fn mlkem_inv_ntt(input: &[i64]) -> logicaffeine_data::LogosSeq<i64> {
722    let out = mlkem_inv_ntt_raw(input);
723    logicaffeine_data::LogosSeq::from_vec(out)
724}
725
726/// One degree-1 base multiply mod (X² − ζ): `r0 = a0·b0 + ζ·a1·b1`, `r1 = a0·b1 + a1·b0`. This is
727/// the gauss/Karatsuba bilinear form the kernel certifies (`field_algebra::gauss_three_multiply`).
728#[inline]
729fn bmul(a: &[i16], b: &[i16], zeta: i16) -> (i16, i16) {
730    let r0 = fqmul(fqmul(a[1], b[1]), zeta).wrapping_add(fqmul(a[0], b[0]));
731    let r1 = fqmul(a[0], b[1]).wrapping_add(fqmul(a[1], b[0]));
732    (r0, r1)
733}
734
735/// Scalar pointwise base-multiply of two NTT-domain polynomials (Kyber `poly_basemul`). The 128
736/// degree-1 pairs use ζ = ±zetas[64+i].
737fn basemul_scalar(a: &[i16; 256], b: &[i16; 256]) -> [i16; 256] {
738    let mut r = [0i16; 256];
739    for i in 0..64 {
740        let zeta = ZETAS[64 + i];
741        let (c0, c1) = bmul(&a[4 * i..4 * i + 2], &b[4 * i..4 * i + 2], zeta);
742        r[4 * i] = c0;
743        r[4 * i + 1] = c1;
744        let (d0, d1) = bmul(&a[4 * i + 2..4 * i + 4], &b[4 * i + 2..4 * i + 4], zeta.wrapping_neg());
745        r[4 * i + 2] = d0;
746        r[4 * i + 3] = d1;
747    }
748    r
749}
750
751fn mlkem_base_mul_raw(a: &[i64], b: &[i64]) -> Vec<i64> {
752    assert_eq!(a.len(), 256);
753    assert_eq!(b.len(), 256);
754    // Inputs are NTT-domain coefficients already reduced to [0, q) (q = 3329 < 2¹⁵), so the
755    // truncating `as i16` cast is exact — no per-element rem_euclid needed on the way in.
756    let to16 = |v: &[i64]| -> [i16; 256] {
757        let mut r = [0i16; 256];
758        for (slot, &x) in r.iter_mut().zip(v) {
759            debug_assert!((0..Q as i64).contains(&x), "base_mul input out of [0,q)");
760            *slot = x as i16;
761        }
762        r
763    };
764    let r = basemul_scalar(&to16(a), &to16(b));
765    r.iter().map(|&x| (x as i32).rem_euclid(Q) as i64).collect()
766}
767
768/// The compiled form of LOGOS `mlkemBaseMul(a, b)` — pointwise multiply of two NTT-domain
769/// polynomials. `mlkemInvNtt(mlkemBaseMul(mlkemNtt(a), mlkemNtt(b)))` is the ML-KEM polynomial
770/// product a·b in ℤ_q\[X\]/(X²⁵⁶+1).
771pub fn mlkem_base_mul(
772    a: &[i64],
773    b: &[i64],
774) -> logicaffeine_data::LogosSeq<i64> {
775    let out = mlkem_base_mul_raw(a, b);
776    logicaffeine_data::LogosSeq::from_vec(out)
777}
778
779/// poly_tomont (Kyber): bring coefficients into the Montgomery domain by multiplying by R = 2¹⁶
780/// (F = 2³² mod q = 1353, one montgomery_reduce). After accumulating `Σ_j basemul(Â[i][j], ŝ[j])`
781/// — which lands in the R⁻¹ domain — a single tomont returns t̂ to normal form for ByteEncode12.
782fn mlkem_to_mont_raw(coeffs: &[i64]) -> Vec<i64> {
783    const F: i32 = 1353; // 2³² mod q
784    coeffs
785        .iter()
786        .map(|&x| {
787            debug_assert!((0..Q as i64).contains(&x), "to_mont input out of [0,q)");
788            montgomery_reduce(x as i32 * F) as i32 % Q
789        })
790        .map(|x| x.rem_euclid(Q) as i64)
791        .collect()
792}
793
794/// The compiled form of LOGOS `toMont(coeffs)` — multiply a polynomial into the Montgomery domain.
795pub fn mlkem_to_mont(coeffs: &[i64]) -> logicaffeine_data::LogosSeq<i64> {
796    logicaffeine_data::LogosSeq::from_vec(mlkem_to_mont_raw(coeffs))
797}
798
799// ── CBD sampling: centered binomial noise, the Kyber bit-trick (η = 2, 3) ─────────────────────
800
801/// CBD_2 (ML-KEM η=2): 128 bytes → 256 coefficients in [−2, 2]. Each coefficient is
802/// popcount(2 bits) − popcount(2 bits); the `& 0x55..` trick sums the bit-pairs in parallel.
803fn cbd_eta2(buf: &[u8]) -> [i16; 256] {
804    assert_eq!(buf.len(), 128, "CBD_2 needs 64·η = 128 bytes");
805    let mut r = [0i16; 256];
806    for i in 0..32 {
807        let t = u32::from_le_bytes(buf[4 * i..4 * i + 4].try_into().unwrap());
808        let mut d = t & 0x5555_5555;
809        d += (t >> 1) & 0x5555_5555;
810        for j in 0..8 {
811            let a = ((d >> (4 * j)) & 0x3) as i16;
812            let b = ((d >> (4 * j + 2)) & 0x3) as i16;
813            r[8 * i + j] = a - b;
814        }
815    }
816    r
817}
818
819/// CBD_3 (ML-KEM η=3 / ML-KEM-512's `s`,`e`): 192 bytes → 256 coefficients in [−3, 3].
820fn cbd_eta3(buf: &[u8]) -> [i16; 256] {
821    assert_eq!(buf.len(), 192, "CBD_3 needs 64·η = 192 bytes");
822    let mut r = [0i16; 256];
823    for i in 0..64 {
824        let t =
825            buf[3 * i] as u32 | (buf[3 * i + 1] as u32) << 8 | (buf[3 * i + 2] as u32) << 16;
826        let mut d = t & 0x0024_9249;
827        d += (t >> 1) & 0x0024_9249;
828        d += (t >> 2) & 0x0024_9249;
829        for j in 0..4 {
830            let a = ((d >> (6 * j)) & 0x7) as i16;
831            let b = ((d >> (6 * j + 3)) & 0x7) as i16;
832            r[4 * i + j] = a - b;
833        }
834    }
835    r
836}
837
838fn mlkem_cbd_raw(buf: &[i64], eta: usize) -> Vec<i64> {
839    let bytes: Vec<u8> = buf.iter().map(|&x| x.rem_euclid(256) as u8).collect();
840    let r = match eta {
841        2 => cbd_eta2(&bytes),
842        3 => cbd_eta3(&bytes),
843        _ => panic!("ML-KEM CBD supports η ∈ {{2, 3}}"),
844    };
845    r.iter().map(|&x| x as i64).collect()
846}
847
848/// The compiled form of LOGOS `cbd2(buf)` — sample a noise polynomial (η=2) from 128 bytes of
849/// (SHAKE) randomness; 256 coefficients in [−2, 2].
850pub fn mlkem_cbd2(buf: &[i64]) -> logicaffeine_data::LogosSeq<i64> {
851    logicaffeine_data::LogosSeq::from_vec(mlkem_cbd_raw(buf, 2))
852}
853/// The compiled form of LOGOS `cbd3(buf)` — η=3 noise from 192 bytes; coefficients in [−3, 3].
854pub fn mlkem_cbd3(buf: &[i64]) -> logicaffeine_data::LogosSeq<i64> {
855    logicaffeine_data::LogosSeq::from_vec(mlkem_cbd_raw(buf, 3))
856}
857
858// ── Serialization: Compress/Decompress + ByteEncode/ByteDecode (FIPS 203 §4.2.1) ──────────────
859
860/// Compress_d (FIPS 203): map a coefficient in [0, q) into d bits, `round(2^d·x / q) mod 2^d`,
861/// rounding half up exactly as the Kyber reference (`(x·2^d + ⌊q/2⌋) / q`).
862fn mlkem_compress_raw(coeffs: &[i64], d: usize) -> Vec<i64> {
863    let mask = (1u64 << d) - 1;
864    coeffs
865        .iter()
866        .map(|&x| {
867            let x = x.rem_euclid(Q as i64) as u64;
868            (((x << d) + (Q as u64) / 2) / (Q as u64) & mask) as i64
869        })
870        .collect()
871}
872
873/// Decompress_d (FIPS 203): map d bits back toward [0, q), `round(q·y / 2^d)` (round half up).
874fn mlkem_decompress_raw(coeffs: &[i64], d: usize) -> Vec<i64> {
875    let denom = 1u64 << d;
876    coeffs
877        .iter()
878        .map(|&y| (((y as u64) * (Q as u64) + denom / 2) >> d) as i64)
879        .collect()
880}
881
882/// ByteEncode_d (FIPS 203 Alg. 5): pack the low d bits of each coefficient, least-significant bit
883/// first, into a byte string of length ⌈(len·d)/8⌉ (= 32·d for a 256-coefficient polynomial). A
884/// 64-bit bit-accumulator emits whole bytes (d ≤ 12, so at most 19 staged bits) — no per-bit loop.
885fn mlkem_byte_encode_raw(coeffs: &[i64], d: usize) -> Vec<i64> {
886    let mask = (1u64 << d) - 1;
887    let mut out = Vec::with_capacity((coeffs.len() * d).div_ceil(8));
888    let mut acc: u64 = 0;
889    let mut nbits = 0u32;
890    for &c in coeffs {
891        acc |= (c.rem_euclid(1i64 << d) as u64 & mask) << nbits;
892        nbits += d as u32;
893        while nbits >= 8 {
894            out.push((acc & 0xff) as i64);
895            acc >>= 8;
896            nbits -= 8;
897        }
898    }
899    if nbits > 0 {
900        out.push((acc & 0xff) as i64);
901    }
902    out
903}
904
905/// ByteDecode_d (FIPS 203 Alg. 6): the inverse of ByteEncode; for d = 12 the value is reduced
906/// mod q (12-bit fields can exceed q), otherwise it is a plain d-bit integer. Byte-batched: bytes
907/// feed a 64-bit accumulator from which d-bit values are sliced — no per-bit loop.
908fn mlkem_byte_decode_raw(bytes: &[i64], d: usize) -> Vec<i64> {
909    let n = (bytes.len() * 8) / d;
910    let mask = (1u64 << d) - 1;
911    let mut out = Vec::with_capacity(n);
912    let mut acc: u64 = 0;
913    let mut nbits = 0u32;
914    let mut bi = 0usize;
915    for _ in 0..n {
916        while nbits < d as u32 {
917            acc |= (bytes[bi].rem_euclid(256) as u64) << nbits;
918            nbits += 8;
919            bi += 1;
920        }
921        let val = (acc & mask) as i64;
922        acc >>= d;
923        nbits -= d as u32;
924        out.push(if d == 12 { val.rem_euclid(Q as i64) } else { val });
925    }
926    out
927}
928
929/// The compiled form of LOGOS `compress(coeffs, d)` — element-wise Compress_d.
930pub fn mlkem_compress(
931    coeffs: &[i64],
932    d: i64,
933) -> logicaffeine_data::LogosSeq<i64> {
934    logicaffeine_data::LogosSeq::from_vec(mlkem_compress_raw(coeffs, d as usize))
935}
936/// The compiled form of LOGOS `decompress(coeffs, d)` — element-wise Decompress_d.
937pub fn mlkem_decompress(
938    coeffs: &[i64],
939    d: i64,
940) -> logicaffeine_data::LogosSeq<i64> {
941    logicaffeine_data::LogosSeq::from_vec(mlkem_decompress_raw(coeffs, d as usize))
942}
943/// The compiled form of LOGOS `byteEncode(coeffs, d)` — pack coefficients to bytes.
944pub fn mlkem_byte_encode(
945    coeffs: &[i64],
946    d: i64,
947) -> logicaffeine_data::LogosSeq<i64> {
948    logicaffeine_data::LogosSeq::from_vec(mlkem_byte_encode_raw(coeffs, d as usize))
949}
950/// The compiled form of LOGOS `byteDecode(bytes, d)` — unpack bytes to coefficients.
951pub fn mlkem_byte_decode(
952    bytes: &[i64],
953    d: i64,
954) -> logicaffeine_data::LogosSeq<i64> {
955    logicaffeine_data::LogosSeq::from_vec(mlkem_byte_decode_raw(bytes, d as usize))
956}
957
958// ── Uniform sampling in the NTT domain: SampleNTT + matrix-entry expansion (FIPS 203 §4.2.2) ──
959
960/// Try to rejection-sample 256 coefficients (< q) from a byte stream of XOF output, three bytes
961/// yielding two 12-bit candidates. Returns `None` if the stream is exhausted first (the caller
962/// then squeezes more — SHAKE output is a stream, so a longer squeeze extends this one's prefix).
963fn try_sample_ntt(stream: &[u8], want: usize) -> Option<Vec<i64>> {
964    let q = Q as u32;
965    let mut a = Vec::with_capacity(want);
966    let mut i = 0usize;
967    while a.len() < want {
968        if i + 3 > stream.len() {
969            return None;
970        }
971        let (b0, b1, b2) = (stream[i] as u32, stream[i + 1] as u32, stream[i + 2] as u32);
972        let d1 = b0 + 256 * (b1 % 16);
973        let d2 = (b1 / 16) + 16 * b2;
974        if d1 < q {
975            a.push(d1 as i64);
976        }
977        if d2 < q && a.len() < want {
978            a.push(d2 as i64);
979        }
980        i += 3;
981    }
982    Some(a)
983}
984
985fn mlkem_sample_ntt_raw(stream: &[i64]) -> Vec<i64> {
986    let bytes: Vec<u8> = stream.iter().map(|&x| x.rem_euclid(256) as u8).collect();
987    try_sample_ntt(&bytes, 256).expect("SampleNTT: XOF stream exhausted before 256 coefficients")
988}
989
990/// Expand one NTT-domain matrix entry Â[i][j] (FIPS 203 §5.1): XOF = SHAKE128(ρ ‖ i ‖ j), then
991/// SampleNTT. Squeezes 168-byte SHAKE128 blocks until 256 coefficients are sampled — never
992/// truncates (the rejection rate makes one extra block astronomically rare, but the loop is exact).
993fn mlkem_sample_a_raw(seed: &[u8], idx_i: i64, idx_j: i64) -> Vec<i64> {
994    // Streaming SHAKE128 rejection sampling: absorb ρ‖i‖j once, then squeeze 168-byte blocks and
995    // reject directly from each block (168 = 56·3, so the 3-byte candidate triples never straddle a
996    // block boundary — bit-identical to sampling the concatenated stream). No heap stream, no
997    // re-squeeze-from-scratch.
998    let mut xof_in = [0u8; 34];
999    xof_in[..32].copy_from_slice(&seed[..32]);
1000    xof_in[32] = idx_i.rem_euclid(256) as u8;
1001    xof_in[33] = idx_j.rem_euclid(256) as u8;
1002    let mut st = crate::keccak::shake128_absorb(&xof_in);
1003
1004    let q = Q as u32;
1005    let mut out = Vec::with_capacity(256);
1006    let mut buf = [0u8; 168];
1007    loop {
1008        for i in 0..21 {
1009            buf[i * 8..i * 8 + 8].copy_from_slice(&st[i].to_le_bytes());
1010        }
1011        let mut k = 0;
1012        while k + 3 <= 168 && out.len() < 256 {
1013            let (b0, b1, b2) = (buf[k] as u32, buf[k + 1] as u32, buf[k + 2] as u32);
1014            let d1 = b0 + 256 * (b1 % 16);
1015            let d2 = (b1 / 16) + 16 * b2;
1016            if d1 < q {
1017                out.push(d1 as i64);
1018            }
1019            if d2 < q && out.len() < 256 {
1020                out.push(d2 as i64);
1021            }
1022            k += 3;
1023        }
1024        if out.len() >= 256 {
1025            return out;
1026        }
1027        crate::keccak::keccak_f1600(&mut st);
1028    }
1029}
1030
1031/// The compiled form of LOGOS `sampleNtt(stream)` — rejection-sample a uniform NTT-domain
1032/// polynomial directly from a caller-provided byte stream.
1033pub fn mlkem_sample_ntt(stream: &[i64]) -> logicaffeine_data::LogosSeq<i64> {
1034    logicaffeine_data::LogosSeq::from_vec(mlkem_sample_ntt_raw(stream))
1035}
1036
1037/// The compiled form of LOGOS `sampleA(seed, i, j)` — expand matrix entry Â\[i\]\[j\] from the
1038/// 32-byte seed ρ via SHAKE128, with the XOF and rejection handled in one verified step.
1039pub fn mlkem_sample_a(
1040    seed: &[i64],
1041    idx_i: i64,
1042    idx_j: i64,
1043) -> logicaffeine_data::LogosSeq<i64> {
1044    let seed_bytes: Vec<u8> = seed.iter().map(|&x| x.rem_euclid(256) as u8).collect();
1045    logicaffeine_data::LogosSeq::from_vec(mlkem_sample_a_raw(&seed_bytes, idx_i, idx_j))
1046}
1047
1048// ── Word16/Word8 LOGOS native entries (LogosSeq carrier) + native modular arithmetic ──────────
1049// The full Word16-representation ML-KEM rides on these: the SHIPPED `crypto.lg` calls them, the
1050// coefficient hot loop never touches i64, and the byte edges convert cheaply (≤1184 B).
1051
1052type Seq16 = logicaffeine_data::LogosSeq<Word16>;
1053type Seq8 = logicaffeine_data::LogosSeq<Word8>;
1054#[inline]
1055fn w8_to_u8(s: &[Word8]) -> Vec<u8> {
1056    s.iter().map(|w| w.0).collect()
1057}
1058#[inline]
1059fn u8_to_seq8(v: Vec<u8>) -> Seq8 {
1060    logicaffeine_data::LogosSeq::from_vec(v.into_iter().map(Word8).collect())
1061}
1062
1063pub fn mlkem_inv_ntt_w16_seq(input: &[Word16]) -> Seq16 {
1064    logicaffeine_data::LogosSeq::from_vec(mlkem_inv_ntt_w16(input))
1065}
1066pub fn mlkem_base_mul_w16_seq(a: &[Word16], b: &[Word16]) -> Seq16 {
1067    logicaffeine_data::LogosSeq::from_vec(mlkem_base_mul_w16(a, b))
1068}
1069pub fn mlkem_to_mont_w16_seq(c: &[Word16]) -> Seq16 {
1070    logicaffeine_data::LogosSeq::from_vec(mlkem_to_mont_w16(c))
1071}
1072pub fn mlkem_compress_w16_seq(c: &[Word16], d: i64) -> Seq16 {
1073    logicaffeine_data::LogosSeq::from_vec(mlkem_compress_w16(c, d as usize))
1074}
1075pub fn mlkem_decompress_w16_seq(c: &[Word16], d: i64) -> Seq16 {
1076    logicaffeine_data::LogosSeq::from_vec(mlkem_decompress_w16(c, d as usize))
1077}
1078pub fn mlkem_byte_encode_w16_seq(c: &[Word16], d: i64) -> Seq8 {
1079    u8_to_seq8(mlkem_byte_encode_w16(c, d as usize))
1080}
1081pub fn mlkem_byte_decode_w16_seq(b: &[Word8], d: i64) -> Seq16 {
1082    logicaffeine_data::LogosSeq::from_vec(mlkem_byte_decode_w16(&w8_to_u8(b), d as usize))
1083}
1084pub fn mlkem_cbd2_w16_seq(buf: &[Word8]) -> Seq16 {
1085    logicaffeine_data::LogosSeq::from_vec(mlkem_cbd2_w16(&w8_to_u8(buf)))
1086}
1087pub fn mlkem_cbd3_w16_seq(buf: &[Word8]) -> Seq16 {
1088    logicaffeine_data::LogosSeq::from_vec(mlkem_cbd3_w16(&w8_to_u8(buf)))
1089}
1090pub fn mlkem_sample_a_w16_seq(seed: &[Word8], i: i64, j: i64) -> Seq16 {
1091    logicaffeine_data::LogosSeq::from_vec(mlkem_sample_a_w16(&w8_to_u8(seed), i, j))
1092}
1093pub fn sha3_256_w8_seq(input: &[Word8]) -> Seq8 {
1094    u8_to_seq8(crate::keccak::sha3_256_bytes(&w8_to_u8(input)).to_vec())
1095}
1096pub fn sha3_512_w8_seq(input: &[Word8]) -> Seq8 {
1097    u8_to_seq8(crate::keccak::sha3_512_bytes(&w8_to_u8(input)).to_vec())
1098}
1099pub fn shake128_w8_seq(input: &[Word8], outlen: i64) -> Seq8 {
1100    u8_to_seq8(crate::keccak::shake128_bytes(&w8_to_u8(input), outlen.max(0) as usize))
1101}
1102pub fn shake256_w8_seq(input: &[Word8], outlen: i64) -> Seq8 {
1103    u8_to_seq8(crate::keccak::shake256_bytes(&w8_to_u8(input), outlen.max(0) as usize))
1104}
1105
1106/// `(a + b) mod q`, element-wise on Word16 [0, q) — the native modular-add the Logos matrix-vector
1107/// loop calls (so the orchestration never does Word16 arithmetic, only structure).
1108pub fn mlkem_add_mod_q_w16(a: &[Word16], b: &[Word16]) -> Seq16 {
1109    // Operands ∈ [0, q) ⇒ a + b ∈ [0, 2q): one conditional subtract (auto-vectorizes 16-wide), no
1110    // division — this is the Logos ML-KEM matrix-vector loop's inner reduction (called ~9×/keygen).
1111    let q = Q as u16;
1112    logicaffeine_data::LogosSeq::from_vec(
1113        a.iter()
1114            .zip(b)
1115            .map(|(x, y)| {
1116                let s = x.0 + y.0;
1117                Word16(if s >= q { s - q } else { s })
1118            })
1119            .collect(),
1120    )
1121}
1122/// `(a − b) mod q`, element-wise on Word16 [0, q).
1123pub fn mlkem_sub_mod_q_w16(a: &[Word16], b: &[Word16]) -> Seq16 {
1124    // Operands ∈ [0, q) ⇒ a − b ∈ (−q, q): one conditional add, no division.
1125    let q = Q as i32;
1126    logicaffeine_data::LogosSeq::from_vec(
1127        a.iter()
1128            .zip(b)
1129            .map(|(x, y)| {
1130                let d = x.0 as i32 - y.0 as i32;
1131                Word16((if d < 0 { d + q } else { d }) as u16)
1132            })
1133            .collect(),
1134    )
1135}
1136/// A run of `n` zero coefficients (Word16).
1137pub fn mlkem_zeros_w16(n: i64) -> Seq16 {
1138    logicaffeine_data::LogosSeq::from_vec(vec![Word16(0); n.max(0) as usize])
1139}
1140
1141// Int↔Word16 bridges at the byte edges: ML-KEM byte buffers stay LOGOS `Seq of Int` (so the AOT
1142// gates / hashing are unchanged), only the COEFFICIENT hot loop is Word16. Cheap (≤1184 bytes).
1143pub fn mlkem_cbd2_w16_from_int(buf: &[i64]) -> Seq16 {
1144    let bytes: Vec<u8> = buf.iter().map(|&x| x.rem_euclid(256) as u8).collect();
1145    logicaffeine_data::LogosSeq::from_vec(mlkem_cbd2_w16(&bytes))
1146}
1147pub fn mlkem_byte_encode_w16_to_int(c: &[Word16], d: i64) -> logicaffeine_data::LogosSeq<i64> {
1148    logicaffeine_data::LogosSeq::from_vec(
1149        mlkem_byte_encode_w16(c, d as usize).into_iter().map(|b| b as i64).collect(),
1150    )
1151}
1152pub fn mlkem_byte_decode_w16_from_int(b: &[i64], d: i64) -> Seq16 {
1153    let bytes: Vec<u8> = b.iter().map(|&x| x.rem_euclid(256) as u8).collect();
1154    logicaffeine_data::LogosSeq::from_vec(mlkem_byte_decode_w16(&bytes, d as usize))
1155}
1156pub fn mlkem_sample_a_w16_from_int(seed: &[i64], i: i64, j: i64) -> Seq16 {
1157    let bytes: Vec<u8> = seed.iter().map(|&x| x.rem_euclid(256) as u8).collect();
1158    logicaffeine_data::LogosSeq::from_vec(mlkem_sample_a_w16(&bytes, i, j))
1159}
1160/// Native entry for the full 3×3 matrix  (9 × 256 Word16, entry Â\[r\]\[c\] at slot `(r·3+c)·256`),
1161/// 4-way AVX2 SHAKE128 batched — the shipped-Logos `mlkemSampleMatrixW16(rho)`.
1162pub fn mlkem_sample_matrix_w16_from_int(seed: &[i64]) -> Seq16 {
1163    let bytes: Vec<u8> = seed.iter().map(|&x| x.rem_euclid(256) as u8).collect();
1164    logicaffeine_data::LogosSeq::from_vec(mlkem_sample_matrix_w16(&bytes))
1165}
1166
1167#[cfg(test)]
1168mod tests {
1169    use super::*;
1170
1171    #[test]
1172    #[cfg(target_arch = "x86_64")]
1173    fn reject_sample_avx2_bit_exact_vs_scalar() {
1174        if !std::is_x86_feature_detected!("avx2") {
1175            return;
1176        }
1177        // Fuzz many 168-byte blocks (incl. all-accept / all-reject boundaries) — the vectorized
1178        // rejection sampler MUST append exactly the coefficients the scalar reference does, in order.
1179        let mut s = 0x2545_F491_4F6C_DD1D_u64;
1180        let mut next = || {
1181            s ^= s << 13;
1182            s ^= s >> 7;
1183            s ^= s << 17;
1184            s
1185        };
1186        for iter in 0..2000 {
1187            let mut buf = [0u8; 168];
1188            match iter % 4 {
1189                0 => buf.iter_mut().for_each(|b| *b = (next() & 0xff) as u8), // random
1190                1 => buf.fill(0),                                            // all d=0 (accept)
1191                2 => buf.fill(0xff),                                         // all ≥ q (reject)
1192                _ => buf.iter_mut().enumerate().for_each(|(i, b)| *b = if i % 3 == 1 { 0x0d } else { (next() & 0xff) as u8 }),
1193            }
1194            let mut want: Vec<Word16> = Vec::with_capacity(256);
1195            reject_sample_block(&buf, &mut want);
1196            let mut got: Vec<Word16> = Vec::with_capacity(256);
1197            unsafe { reject_sample_block_avx2(&buf, &mut got) };
1198            assert_eq!(got, want, "AVX2 rejection sampler must be bit-identical to scalar (iter {iter})");
1199
1200            // Also validate the partial-fill path (out pre-loaded near the 256 cap).
1201            for pre in [0usize, 100, 250, 255] {
1202                let mut w = vec![Word16(1); pre];
1203                let mut g = vec![Word16(1); pre];
1204                reject_sample_block(&buf, &mut w);
1205                unsafe { reject_sample_block_avx2(&buf, &mut g) };
1206                assert_eq!(g, w, "AVX2 sampler must match scalar with {pre} pre-filled (iter {iter})");
1207            }
1208        }
1209    }
1210
1211    #[test]
1212    fn word16_native_layer_is_correct() {
1213        let q = Q as u32;
1214        let a: Vec<Word16> = (0..256).map(|i| Word16((i * 13 % 3329) as u16)).collect();
1215        let b: Vec<Word16> = (0..256).map(|i| Word16((i * 29 % 3329) as u16)).collect();
1216
1217        // modular arithmetic
1218        let add = mlkem_add_mod_q_w16(&a, &b);
1219        let sub = mlkem_sub_mod_q_w16(&a, &b);
1220        for i in 0..256 {
1221            assert_eq!(add.borrow()[i].0 as u32, (a[i].0 as u32 + b[i].0 as u32) % q);
1222            assert_eq!(sub.borrow()[i].0 as i32, (a[i].0 as i32 - b[i].0 as i32).rem_euclid(Q));
1223        }
1224        assert_eq!(mlkem_zeros_w16(256).borrow().len(), 256);
1225        assert!(mlkem_zeros_w16(256).borrow().iter().all(|w| w.0 == 0));
1226
1227        // ByteEncode∘ByteDecode round-trip (d=12) through the Word16/Word8 entries
1228        let enc = mlkem_byte_encode_w16_seq(&a, 12);
1229        let dec = mlkem_byte_decode_w16_seq(&enc.borrow(), 12);
1230        assert_eq!(dec.borrow().as_slice(), a.as_slice(), "byteDecode∘byteEncode = id (Word16)");
1231
1232        // cbd / sampleA Word16 entries match the i64 kernels (reduced into [0,q))
1233        let buf: Vec<Word8> = (0..128).map(|i| Word8((i * 7) as u8)).collect();
1234        let cbd_i64: Vec<i64> = mlkem_cbd2(&buf.iter().map(|w| w.0 as i64).collect::<Vec<_>>()).to_vec();
1235        let cbd_w16: Vec<i64> =
1236            mlkem_cbd2_w16_seq(&buf).borrow().iter().map(|w| (w.0 as i64).rem_euclid(Q as i64)).collect();
1237        let cbd_i64_red: Vec<i64> = cbd_i64.iter().map(|&c| c.rem_euclid(Q as i64)).collect();
1238        assert_eq!(cbd_w16, cbd_i64_red, "cbd2 Word16 == reduced i64 cbd2");
1239
1240        let seed: Vec<Word8> = (0..32).map(|i| Word8(i as u8)).collect();
1241        let sa_i64: Vec<i64> = mlkem_sample_a(&seed.iter().map(|w| w.0 as i64).collect::<Vec<_>>(), 1, 2).to_vec();
1242        let sa_w16: Vec<i64> = mlkem_sample_a_w16_seq(&seed, 1, 2).borrow().iter().map(|w| w.0 as i64).collect();
1243        assert_eq!(sa_w16, sa_i64, "sampleA Word16 == i64 sampleA");
1244    }
1245
1246    #[test]
1247    fn scalar_round_trip_and_avx2_matches_scalar() {
1248        let mut s = 0x1234_5678u64;
1249        let f: [i16; 256] = std::array::from_fn(|_| {
1250            s = s.wrapping_mul(1_103_515_245).wrapping_add(12_345);
1251            ((s >> 16) % Q as u64) as i16
1252        });
1253        // Round-trip: invntt(ntt(f)) == f·MONT mod q (MONT = R mod q = 2285; invntt is `tomont`).
1254        const MONT: i32 = 2285;
1255        let mut r = f;
1256        ntt_scalar(&mut r);
1257        invntt_scalar(&mut r);
1258        let norm = |x: i16| ((x as i32) % Q + Q) % Q;
1259        assert!(
1260            (0..256).all(|i| norm(r[i]) == (f[i] as i32 * MONT).rem_euclid(Q)),
1261            "scalar Kyber NTT round-trip must hold"
1262        );
1263
1264        #[cfg(target_arch = "x86_64")]
1265        if std::is_x86_feature_detected!("avx2") {
1266            let mut a = f;
1267            let mut b = f;
1268            ntt_scalar(&mut a);
1269            unsafe { ntt_avx2(&mut b) };
1270            assert_eq!(a, b, "AVX2 NTT must be bit-identical to scalar");
1271        }
1272    }
1273
1274    #[test]
1275    #[cfg(target_arch = "x86_64")]
1276    fn invntt_avx2_matches_scalar() {
1277        if !std::is_x86_feature_detected!("avx2") {
1278            return;
1279        }
1280        // Fuzz a wide spread of NTT-domain inputs; the inverse must be bit-identical to the scalar
1281        // reference at every level (Gentleman-Sande + rounded Barrett + the final f-scaling).
1282        let mut s = 0xC0FFEE_1234_5678u64;
1283        for _ in 0..2000 {
1284            let r0: [i16; 256] = std::array::from_fn(|_| {
1285                s = s.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
1286                ((s >> 33) % Q as u64) as i16
1287            });
1288            let mut a = r0;
1289            let mut b = r0;
1290            invntt_scalar(&mut a);
1291            unsafe { invntt_avx2(&mut b) };
1292            assert_eq!(a, b, "AVX2 inverse NTT must be bit-identical to scalar");
1293        }
1294    }
1295
1296    fn negacyclic_conv(a: &[i64], b: &[i64]) -> Vec<i64> {
1297        let q = Q as i64;
1298        let n = 256;
1299        let mut c = vec![0i64; n];
1300        for i in 0..n {
1301            for j in 0..n {
1302                let prod = (a[i] * b[j]).rem_euclid(q);
1303                let k = i + j;
1304                if k < n {
1305                    c[k] = (c[k] + prod).rem_euclid(q);
1306                } else {
1307                    c[k - n] = (c[k - n] - prod).rem_euclid(q);
1308                }
1309            }
1310        }
1311        c
1312    }
1313
1314    fn inv_mod_q(x: i64) -> i64 {
1315        let q = Q as i64;
1316        let (mut r, mut base, mut e) = (1i64, x.rem_euclid(q), q - 2);
1317        while e > 0 {
1318            if e & 1 == 1 {
1319                r = r * base % q;
1320            }
1321            base = base * base % q;
1322            e >>= 1;
1323        }
1324        r
1325    }
1326
1327    /// The CBD spec, bit by bit: coefficient k = Σ(η bits) − Σ(η bits) from bit offset 2·η·k.
1328    fn cbd_ref(buf: &[u8], eta: usize) -> [i16; 256] {
1329        let bit = |idx: usize| -> i16 { ((buf[idx / 8] >> (idx % 8)) & 1) as i16 };
1330        let mut r = [0i16; 256];
1331        for k in 0..256 {
1332            let base = 2 * eta * k;
1333            let (mut a, mut b) = (0i16, 0i16);
1334            for i in 0..eta {
1335                a += bit(base + i);
1336                b += bit(base + eta + i);
1337            }
1338            r[k] = a - b;
1339        }
1340        r
1341    }
1342
1343    #[test]
1344    fn sample_matrix_x4_matches_nine_scalar_entries() {
1345        // The 4-way batched matrix expander must be bit-identical to nine single-lane samples, for
1346        // several seeds (rejection consumes a data-dependent number of blocks per stream).
1347        for seed_byte in [0u8, 0x11, 0x5a, 0xff] {
1348            let seed: Vec<u8> = (0..32).map(|i| seed_byte ^ (i as u8 * 7)).collect();
1349            let matrix = mlkem_sample_matrix_w16(&seed);
1350            assert_eq!(matrix.len(), 9 * 256);
1351            for r in 0..3u8 {
1352                for c in 0..3u8 {
1353                    let want = mlkem_sample_a_w16(&seed, r as i64, c as i64);
1354                    let e = (r as usize * 3 + c as usize) * 256;
1355                    assert_eq!(
1356                        &matrix[e..e + 256],
1357                        &want[..],
1358                        "matrix entry ({r},{c}) must match the scalar sampleA"
1359                    );
1360                }
1361            }
1362        }
1363    }
1364
1365    #[test]
1366    fn cbd_bit_trick_matches_bit_by_bit_reference() {
1367        let mut s = 0xDEAD_BEEFu64;
1368        let mut rb = || {
1369            s = s.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
1370            (s >> 56) as u8
1371        };
1372        let buf2: Vec<u8> = (0..128).map(|_| rb()).collect();
1373        assert_eq!(cbd_eta2(&buf2), cbd_ref(&buf2, 2), "fast CBD_2 must equal the bit-by-bit definition");
1374        assert!(cbd_eta2(&buf2).iter().all(|&c| (-2..=2).contains(&c)), "CBD_2 ∈ [−2, 2]");
1375
1376        let buf3: Vec<u8> = (0..192).map(|_| rb()).collect();
1377        assert_eq!(cbd_eta3(&buf3), cbd_ref(&buf3, 3), "fast CBD_3 must equal the bit-by-bit definition");
1378        assert!(cbd_eta3(&buf3).iter().all(|&c| (-3..=3).contains(&c)), "CBD_3 ∈ [−3, 3]");
1379    }
1380
1381    /// The SampleNTT spec, written with a different bit layout (`d1 = b0 | (b1&0xF)<<8`) than the
1382    /// kernel's arithmetic form — they must agree.
1383    fn sample_ntt_reference(stream: &[u8]) -> Vec<i64> {
1384        let q = Q as u32;
1385        let mut a = Vec::new();
1386        let mut i = 0;
1387        while a.len() < 256 {
1388            let (b0, b1, b2) = (stream[i] as u32, stream[i + 1] as u32, stream[i + 2] as u32);
1389            let d1 = b0 | ((b1 & 0x0F) << 8);
1390            let d2 = (b1 >> 4) | (b2 << 4);
1391            if d1 < q && a.len() < 256 {
1392                a.push(d1 as i64);
1393            }
1394            if d2 < q && a.len() < 256 {
1395                a.push(d2 as i64);
1396            }
1397            i += 3;
1398        }
1399        a
1400    }
1401
1402    #[test]
1403    fn sample_ntt_matches_reference_and_stays_in_field() {
1404        let mut s = 0xCAFE_F00Du64;
1405        let mut rb = || {
1406            s = s.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
1407            (s >> 56) as u8
1408        };
1409        let stream: Vec<u8> = (0..2048).map(|_| rb()).collect();
1410        let stream_i: Vec<i64> = stream.iter().map(|&b| b as i64).collect();
1411
1412        let got = mlkem_sample_ntt_raw(&stream_i);
1413        assert_eq!(got.len(), 256, "SampleNTT yields exactly 256 coefficients");
1414        assert!(got.iter().all(|&c| (0..Q as i64).contains(&c)), "every coefficient is in [0, q)");
1415        assert_eq!(got, sample_ntt_reference(&stream), "kernel SampleNTT must match the reference");
1416
1417        // Hand check: bytes 0x01,0x20,0x03 ⇒ d1 = 1 + 256·0 = 1, d2 = 2 + 16·3 = 50.
1418        let hand = [0x01i64, 0x20, 0x03].iter().chain(std::iter::repeat(&0)).take(2048).copied().collect::<Vec<_>>();
1419        let hg = mlkem_sample_ntt_raw(&hand);
1420        assert_eq!(hg[0], 1, "first sampled coefficient");
1421        assert_eq!(hg[1], 50, "second sampled coefficient");
1422    }
1423
1424    #[test]
1425    fn sample_a_is_deterministic_and_consumes_its_own_xof() {
1426        let seed: Vec<u8> = (0..32u8).collect();
1427        let a1 = mlkem_sample_a_raw(&seed, 1, 2);
1428        let a2 = mlkem_sample_a_raw(&seed, 1, 2);
1429        assert_eq!(a1, a2, "SampleA is a deterministic function of (ρ, i, j)");
1430        assert_ne!(a1, mlkem_sample_a_raw(&seed, 2, 1), "index order matters (Â is not symmetric)");
1431        assert_eq!(a1.len(), 256);
1432        assert!(a1.iter().all(|&c| (0..Q as i64).contains(&c)));
1433
1434        // SampleA must equal SampleNTT over its own SHAKE128(ρ‖i‖j) stream.
1435        let mut xof_in = seed.clone();
1436        xof_in.push(1);
1437        xof_in.push(2);
1438        let stream = crate::keccak::shake128_bytes(&xof_in, 168 * 6);
1439        assert_eq!(a1, try_sample_ntt(&stream, 256).unwrap(), "SampleA = SampleNTT∘XOF");
1440    }
1441
1442    #[test]
1443    fn byte_encode_decode_round_trips_losslessly() {
1444        // For every d ∈ {1,4,5,10,11,12} a polynomial of d-bit coefficients survives the
1445        // pack/unpack cycle exactly, and the packed length is 32·d bytes.
1446        let mut s = 0x1234_5678u64;
1447        let mut rc = |bound: i64| -> i64 {
1448            s = s.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
1449            (s >> 40) as i64 % bound
1450        };
1451        for &d in &[1usize, 4, 5, 10, 11, 12] {
1452            let modulus = if d == 12 { Q as i64 } else { 1i64 << d };
1453            let coeffs: Vec<i64> = (0..256).map(|_| rc(modulus)).collect();
1454            let bytes = mlkem_byte_encode_raw(&coeffs, d);
1455            assert_eq!(bytes.len(), 32 * d, "ByteEncode_{d} length must be 32·d");
1456            assert!(bytes.iter().all(|&b| (0..256).contains(&b)), "encoded values are bytes");
1457            let back = mlkem_byte_decode_raw(&bytes, d);
1458            assert_eq!(back, coeffs, "ByteDecode_{d}∘ByteEncode_{d} must be the identity");
1459        }
1460    }
1461
1462    #[test]
1463    fn compress_decompress_meets_fips203_error_bound() {
1464        // FIPS 203 guarantees |Decompress_d(Compress_d(x)) − x mod ±q| ≤ round(q / 2^(d+1)).
1465        let q = Q as i64;
1466        let mod_pm_abs = |a: i64| -> i64 {
1467            let mut r = a.rem_euclid(q);
1468            if r > q / 2 {
1469                r -= q;
1470            }
1471            r.abs()
1472        };
1473        for &d in &[1usize, 4, 5, 10, 11] {
1474            let bound = (q + (1i64 << d)) / (1i64 << (d + 1)); // round(q / 2^(d+1))
1475            for x in 0..q {
1476                let c = mlkem_compress_raw(&[x], d)[0];
1477                assert!((0..(1i64 << d)).contains(&c), "Compress_{d}({x}) must be a d-bit value");
1478                let back = mlkem_decompress_raw(&[c], d)[0];
1479                assert!(
1480                    mod_pm_abs(back - x) <= bound,
1481                    "d={d} x={x}: error {} exceeds bound {bound}",
1482                    mod_pm_abs(back - x)
1483                );
1484            }
1485        }
1486    }
1487
1488    #[test]
1489    fn poly_multiply_via_ntt_matches_schoolbook_convolution() {
1490        let mut s = 0xCAFE_1234u64;
1491        let mut rand = || {
1492            s = s.wrapping_mul(1_103_515_245).wrapping_add(12_345);
1493            ((s >> 16) % Q as u64) as i64
1494        };
1495        let a: Vec<i64> = (0..256).map(|_| rand()).collect();
1496        let b: Vec<i64> = (0..256).map(|_| rand()).collect();
1497        // The full ML-KEM poly multiply: invntt(basemul(ntt(a), ntt(b))).
1498        let got = mlkem_inv_ntt_raw(&mlkem_base_mul_raw(&mlkem_ntt_raw(&a), &mlkem_ntt_raw(&b)));
1499        let conv = negacyclic_conv(&a, &b);
1500        // Determine the uniform Montgomery domain factor from the first non-zero coefficient.
1501        let k0 = (0..256).find(|&k| conv[k] != 0).expect("nonzero convolution");
1502        let factor = (got[k0] * inv_mod_q(conv[k0])).rem_euclid(Q as i64);
1503        assert!(
1504            (0..256).all(|k| got[k] == (conv[k] * factor).rem_euclid(Q as i64)),
1505            "invntt(basemul(ntt(a),ntt(b))) must equal the negacyclic convolution × a uniform factor (got factor {factor})"
1506        );
1507    }
1508
1509    #[test]
1510    fn mlkem_ntt_reduces_and_matches_scalar() {
1511        let input: Vec<i64> = (0..256).map(|i| (i * 37 % 5000) as i64 - 1000).collect();
1512        let got = mlkem_ntt_raw(&input);
1513        assert_eq!(got.len(), 256);
1514        assert!(got.iter().all(|&x| (0..Q as i64).contains(&x)), "output reduced into [0, q)");
1515        // Independent scalar reference through the same reduction boundary.
1516        let mut r = [0i16; 256];
1517        for (slot, &x) in r.iter_mut().zip(&input) {
1518            *slot = x.rem_euclid(Q as i64) as i16;
1519        }
1520        ntt_scalar(&mut r);
1521        let want: Vec<i64> = r.iter().map(|&x| (x as i32).rem_euclid(Q) as i64).collect();
1522        assert_eq!(got, want, "mlkem_ntt must equal the scalar reference (mod q)");
1523    }
1524
1525    /// Same-run A/B: the vectorized Kyber Montgomery butterfly (`fqmul` ×16) built from the
1526    /// `Lanes16Word16` lane API — the exact 5-op chain (`mullo·mulhi·mullo·mulhi·sub`) the compiled
1527    /// `crypto.lg` NTT lowers to — vs. the identical raw-`__m256i` intrinsic chain. The lane methods
1528    /// are `#[inline(always)]` + compile-time `cfg(target_feature="avx2")` (no runtime detect, no
1529    /// `#[target_feature]` call boundary), so under `+avx2` a dependent chain stays register-resident:
1530    /// LLVM forwards each op's store-to-`r` into the next op's load. This asserts (a) the lane fqmul is
1531    /// bit-identical to the scalar `fqmul` oracle lane-by-lane, and (b) a 200k-deep lane chain equals
1532    /// the raw-intrinsic chain bit-for-bit. It prints lane-vs-raw ns so register-residency is *measured*
1533    /// (the box is shared; timing is informational, not asserted) — proving the "hand-assembled NTT in
1534    /// Logos" lane ops hit the same roofline as raw intrinsics.
1535    #[test]
1536    #[cfg(target_arch = "x86_64")]
1537    fn lanes16_montgomery_butterfly_eq_scalar_and_ab() {
1538        use logicaffeine_base::{Lanes16Word16, Word16};
1539        if !std::is_x86_feature_detected!("avx2") {
1540            return; // scalar-only box: the lane path is the portable fallback (covered by base tests)
1541        }
1542        let coeffs: [i16; 16] = std::array::from_fn(|i| ((i as i32 * 211 - 1500) % Q) as i16);
1543        let tw: [i16; 16] = std::array::from_fn(|i| ZETAS[i]);
1544
1545        let qinv_v = Lanes16Word16::splat((QINV as i16) as u16);
1546        let q_v = Lanes16Word16::splat(Q as u16);
1547        let lane_fqmul = |a: Lanes16Word16, b: Lanes16Word16| -> Lanes16Word16 {
1548            let lo = a.mullo(b);
1549            let hi = a.mulhi(b);
1550            let m = lo.mullo(qinv_v);
1551            let th = m.mulhi(q_v);
1552            hi.sub(th)
1553        };
1554        let a = Lanes16Word16::from_words(&coeffs.map(|c| Word16(c as u16)));
1555        let b = Lanes16Word16::from_words(&tw.map(|c| Word16(c as u16)));
1556        let res = lane_fqmul(a, b);
1557
1558        for i in 0..16 {
1559            let want = fqmul(coeffs[i], tw[i]);
1560            assert_eq!(res.lane(i).0 as i16, want, "lane fqmul lane {i} must equal scalar fqmul");
1561        }
1562
1563        const K: usize = 200_000;
1564        let mut v = a;
1565        let t0 = std::time::Instant::now();
1566        for _ in 0..K {
1567            v = lane_fqmul(v, b);
1568        }
1569        let lane_ns = t0.elapsed().as_nanos() as f64 / K as f64;
1570        let lane_sink = v.lane(0).0;
1571
1572        let t1 = std::time::Instant::now();
1573        let raw_sink = unsafe { raw_fqmul_chain(coeffs, tw, K) };
1574        let raw_ns = t1.elapsed().as_nanos() as f64 / K as f64;
1575
1576        assert_eq!(lane_sink, raw_sink, "lane and raw fqmul chains must be bit-identical after {K} iters");
1577
1578        println!("\n=== Lanes16Word16 Montgomery butterfly (fqmul ×16) — 200k dependent chain, +avx2 ===");
1579        println!("  raw __m256i intrinsics : {raw_ns:>7.3} ns/fqmul");
1580        println!(
1581            "  Lanes16 lane API       : {lane_ns:>7.3} ns/fqmul   ({:.2}× of raw)",
1582            lane_ns / raw_ns.max(0.001)
1583        );
1584        println!("  (sink {lane_sink}; shared box — timing informational, correctness asserted)");
1585    }
1586
1587    #[cfg(target_arch = "x86_64")]
1588    #[target_feature(enable = "avx2")]
1589    unsafe fn raw_fqmul_chain(coeffs: [i16; 16], tw: [i16; 16], k: usize) -> u16 {
1590        use std::arch::x86_64::*;
1591        let mut v = _mm256_loadu_si256(coeffs.as_ptr() as *const __m256i);
1592        let b = _mm256_loadu_si256(tw.as_ptr() as *const __m256i);
1593        let qinv = _mm256_set1_epi16(QINV as i16);
1594        let q = _mm256_set1_epi16(Q as i16);
1595        for _ in 0..k {
1596            let lo = _mm256_mullo_epi16(v, b);
1597            let hi = _mm256_mulhi_epi16(v, b);
1598            let m = _mm256_mullo_epi16(lo, qinv);
1599            let th = _mm256_mulhi_epi16(m, q);
1600            v = _mm256_sub_epi16(hi, th);
1601        }
1602        let mut out = [0i16; 16];
1603        _mm256_storeu_si256(out.as_mut_ptr() as *mut __m256i, v);
1604        out[0] as u16
1605    }
1606}