Skip to main content

kopis/arithmetic/
ntt.rs

1//! Multiplication in ℤ[X]/(X^256 + 1) via a negacyclic NTT over an auxiliary prime field.
2//!
3//! Every ring multiplication in Kopis has one operand with small coefficients: a CBD secret
4//! with coefficients in [-μ/2, μ/2]. The other operand has coefficients in [0, 2^13). The
5//! coefficients of the *exact integer* product of such polynomials — even accumulated over an
6//! ℓ-term matrix-vector product — are bounded in magnitude by
7//!     ℓ · 256 · (2^13 - 1) · (μ/2) ≤ 3 · 256 · 8191 · 4 = 25_162_752,
8//! (the maximum over all three parameter sets). This is less than p/2 for the prime
9//! p = 50330113. So we can compute the product exactly: do the arithmetic mod p, lift the
10//! result to the centered representative in (-p/2, p/2), and reduce mod 2^16. The result is
11//! bit-identical to schoolbook multiplication of the wrapping-u16 ring elements.
12//!
13//! p ≡ 1 (mod 512), so ℤ/p has a primitive 512th root of unity ψ and X^256 + 1 splits
14//! completely: a full 8-level negacyclic NTT applies, and products of transformed elements are
15//! plain pointwise products. This makes matrix products cheap: each entry of a matrix-vector
16//! product costs one pointwise multiply-accumulate instead of a full ring multiplication, and
17//! the transforms themselves are shared across rows/columns. Callers cache fixed operands
18//! (the matrix A, the public vector b, the secret s) in NTT form.
19//!
20//! The implementation follows the reference ML-DSA (Dilithium) NTT structure: an in-place
21//! Cooley-Tukey forward pass and Gentleman-Sande inverse pass over a table of ψ powers in
22//! bit-reversed order, with signed Montgomery reduction (R = 2^32). Unlike Dilithium's q,
23//! our p is large enough that Gentleman-Sande sums could overflow an i32 after a few levels,
24//! so the inverse butterfly Barrett-reduces the sum path every level. All arithmetic is
25//! branch-free and all loop bounds are public, so the code is constant-time.
26
27// The explicit `for i in 0..N` index loops that trigger this lint are deliberate: aeneas (the
28// Lean extractor) handles them better than the iterator patterns clippy suggests
29#![allow(clippy::needless_range_loop)]
30
31use crate::{
32    arithmetic::{Matrix, RingElem},
33    consts::RING_DEG,
34};
35
36use zeroize::Zeroize;
37
38/// The NTT prime modulus
39const P: i32 = 50330113;
40/// p^-1 mod 2^32 (as a wrapping u32)
41const P_INV: u32 = 3575907841;
42/// round(2^48 / p), for Barrett reduction
43const BARRETT_M: i64 = 5592576;
44/// The rounding addend for Barrett reduction, 2^47. Spelled as a literal rather than `1 << 47`
45/// so that the extracted Lean contains no shift operation to discharge.
46const BARRETT_ROUND: i64 = 140737488355328;
47/// ⌊p/2⌋, the centering threshold. A literal for the same reason (no division to discharge).
48const P_HALF: i32 = 25165056;
49/// 256^-1 · 2^64 mod p: the inverse-NTT output scale. One Montgomery reduction by this value
50/// undoes both the 1/256 of the inverse transform and the 2^-32 introduced by the Montgomery
51/// reduction in the pointwise multiplication step.
52const INVNTT_SCALE: i32 = 44652572;
53
54/// Powers of ψ = 49118445 (a primitive 512th root of unity mod p) in bit-reversed order and
55/// Montgomery form: ZETAS[k] = ψ^brv8(k) · 2^32 mod p. Generated by gen_ntt_consts.py; the
56/// `zetas_table_is_correct` test recomputes the table from ψ and checks every entry.
57#[rustfmt::skip]
58const ZETAS: [i32; 256] = [
59    16907691, 2667794, 25435945, 38041794, 4526213, 35360986, 30701667, 21078350,
60    36956843, 13677930, 22323069, 13735052, 16437056, 5691837, 29800035, 15764652,
61    45309494, 32237400, 24236486, 2001600, 1321324, 27016828, 15167304, 4366839,
62    28120194, 32421317, 30261273, 12292806, 22968316, 13702742, 937110, 2965954,
63    34335133, 44939767, 6512044, 26419553, 19973946, 49111849, 40847418, 20023056,
64    43261597, 41129446, 12627709, 45952874, 30150184, 36681813, 45532529, 39191671,
65    45414311, 2081056, 808565, 49693071, 31006016, 28961757, 15337390, 31683976,
66    4955339, 27911199, 29647313, 30189282, 40031443, 49298885, 46092656, 18408488,
67    245475, 31889855, 39534436, 41714666, 8292132, 21449251, 22309987, 8205570,
68    31096891, 47405297, 930348, 6971348, 10106150, 31035883, 46359140, 35263708,
69    25740623, 9884444, 542513, 16415589, 9876654, 44372807, 21881921, 19165831,
70    9227072, 2984297, 36824748, 43900147, 43917176, 9030513, 26543409, 34836022,
71    45307234, 35198677, 14645063, 2098347, 37061938, 3244828, 41096247, 32062813,
72    8918713, 47344810, 46288223, 21000897, 41046191, 20508021, 35242638, 21557502,
73    49459492, 3469647, 1704205, 29274982, 27408601, 6834376, 3860969, 50170866,
74    7253270, 26028155, 34019372, 9518412, 22713734, 14612739, 26018446, 43305093,
75    12047271, 21217146, 26069968, 4256850, 3639874, 48691800, 17320569, 3323937,
76    32443671, 42492417, 9308803, 33498296, 25236161, 17970048, 30529967, 11776789,
77    23284408, 23725761, 39814592, 32816444, 45216211, 34497791, 1878200, 45702238,
78    40014922, 39122569, 28779735, 44971151, 12142149, 25663462, 31479913, 19837380,
79    39143843, 21323231, 17806070, 36807664, 47595478, 49950288, 16588490, 32773564,
80    19315478, 33753056, 15093953, 23446825, 49044025, 41815538, 1288725, 23610193,
81    2754751, 40030005, 13643238, 20793088, 16114388, 33147218, 15927487, 41781494,
82    5795109, 11439966, 15064762, 35884894, 22645018, 10582966, 14900694, 21660678,
83    16765530, 20825350, 30320649, 42368153, 39131901, 42360159, 6363984, 35581825,
84    28072132, 11706419, 20970510, 44236552, 17934700, 14577479, 42770390, 44206858,
85    28865919, 40187227, 15964209, 15444696, 17019703, 32900174, 28423607, 42332883,
86    32435385, 40121202, 37968791, 33541227, 35393185, 34591081, 48852822, 8473545,
87    43827986, 23231608, 34405552, 26405625, 27381101, 33514230, 46158701, 27741938,
88    10409285, 21224207, 6796942, 35314909, 49625944, 20271219, 6354214, 38361969,
89    34767461, 14097694, 13212224, 47820051, 49316243, 20113174, 21747171, 39170867,
90    38847587, 20584729, 27124165, 40492847, 7742348, 43534070, 7422899, 12232461,
91];
92
93// Every arithmetic operation below is written with an explicit `wrapping_*` / `wrapping_shr`
94// even where the value bounds guarantee no overflow. This is deliberate: aeneas extracts
95// checked `+`/`-`/`*`/`>>` into the `Result` monad, so each one becomes a separate
96// panic-freedom obligation whose discharge needs the full magnitude analysis. The wrapping
97// forms extract as total functions, which keeps the generated Lean in plain (non-monadic)
98// arithmetic and leaves exactly one thing to prove: that the mathematical values are in range,
99// so the wrapping never actually wraps. That argument is made once, in the correctness proof,
100// instead of 36 times inline. Runtime behaviour is unchanged (the KATs pin it down).
101
102/// Signed Montgomery reduction: for |a| < 2^31 · p, returns t ≡ a · 2^-32 (mod p) with |t| < p
103#[inline(always)]
104fn mont_reduce(a: i64) -> i32 {
105    let t = (a as u32).wrapping_mul(P_INV) as i32 as i64;
106    a.wrapping_sub(t.wrapping_mul(P as i64)).wrapping_shr(32) as i32
107}
108
109/// Centered Barrett reduction: for any i32 input, returns r ≡ x (mod p) with |r| ≤ p/2 + 1
110#[inline(always)]
111fn barrett_reduce(x: i32) -> i32 {
112    let q = (x as i64)
113        .wrapping_mul(BARRETT_M)
114        .wrapping_add(BARRETT_ROUND)
115        .wrapping_shr(48) as i32;
116    x.wrapping_sub(q.wrapping_mul(P))
117}
118
119/// Reduces a value in (-p, p) to the canonical range [0, p), branch-free
120#[inline(always)]
121fn to_canonical(x: i32) -> i32 {
122    x.wrapping_add(x.wrapping_shr(31) & P)
123}
124
125/// Lifts a value in (-p, p) to its centered representative in (-p/2, p/2] and reduces it
126/// mod 2^16, branch-free. This is exact whenever the true integer value it represents lies in
127/// (-p/2, p/2], which the coefficient bound in the module docs guarantees.
128#[inline(always)]
129fn to_wrapping_u16(x: i32) -> u16 {
130    let x = to_canonical(x); // [0, p)
131    let x = x.wrapping_sub(P & P_HALF.wrapping_sub(x).wrapping_shr(31)); // (-p/2, p/2]
132    x as u16
133}
134
135/// In-place forward negacyclic NTT (Cooley-Tukey), then centering to |a[i]| ≤ p/2 + 1.
136///
137/// Input coefficients must satisfy |a[i]| < 2^13. Butterflies add at most p in magnitude per
138/// level, so intermediate values stay below 8p + 2^13 < 2^29 and never overflow. The output
139/// is the evaluation of the input polynomial at the odd powers of ψ, in bit-reversed order.
140fn ntt(a: &mut [i32; RING_DEG]) {
141    let mut k = 0;
142    let mut len = RING_DEG / 2;
143    while len > 0 {
144        let mut start = 0;
145        while start < RING_DEG {
146            k += 1;
147            let zeta = ZETAS[k] as i64;
148            for j in start..start + len {
149                let t = mont_reduce(zeta.wrapping_mul(a[j + len] as i64));
150                a[j + len] = a[j].wrapping_sub(t);
151                a[j] = a[j].wrapping_add(t);
152            }
153            start += 2 * len;
154        }
155        len /= 2;
156    }
157
158    for coeff in a.iter_mut() {
159        *coeff = barrett_reduce(*coeff);
160    }
161}
162
163/// In-place inverse negacyclic NTT (Gentleman-Sande). Input coefficients must satisfy
164/// |a[i]| < p. The final Montgomery multiplication by INVNTT_SCALE leaves plain
165/// (non-Montgomery) centered values with |a[i]| < p, *assuming* the pointwise step that
166/// produced the input performed exactly one Montgomery reduction (see module docs).
167///
168/// p is too large for Dilithium-style lazy growth to fit in an i32 across all 8 levels: the
169/// un-reduced sum path doubles per level. Starting below p, four levels reach 16p < 2^31;
170/// one Barrett pass then re-centers everything to ≤ p/2 + 1, and the remaining four levels
171/// reach at most 16(p/2 + 1) < 2^31 again. The Montgomery inputs also stay in range: the
172/// largest is ζ·(t - a) with |ζ| < p and |t - a| < 16p, and 16p² < 2^31 · p.
173fn invntt(a: &mut [i32; RING_DEG]) {
174    let mut k = RING_DEG;
175    let mut len = 1;
176    while len < RING_DEG {
177        let mut start = 0;
178        while start < RING_DEG {
179            k -= 1;
180            let neg_zeta = (ZETAS[k] as i64).wrapping_neg();
181            for j in start..start + len {
182                let t = a[j];
183                a[j] = t.wrapping_add(a[j + len]);
184                a[j + len] = mont_reduce(neg_zeta.wrapping_mul(t.wrapping_sub(a[j + len]) as i64));
185            }
186            start += 2 * len;
187        }
188        len *= 2;
189
190        // Halfway (after the len=8 level), re-center to keep sums within i32
191        if len == 16 {
192            for coeff in a.iter_mut() {
193                *coeff = barrett_reduce(*coeff);
194            }
195        }
196    }
197
198    for coeff in a.iter_mut() {
199        *coeff = mont_reduce((*coeff as i64).wrapping_mul(INVNTT_SCALE as i64));
200    }
201}
202
203/// A ring element in the NTT domain. Coefficients are centered mod-p values, |·| ≤ p/2 + 1.
204// The NTT is an invertible linear map, so a transformed secret is exactly as sensitive as the
205// coefficient-domain one. Zeroize accordingly, matching RingElem.
206#[derive(Clone, Copy, Zeroize)]
207pub(crate) struct NttElem(pub(crate) [i32; RING_DEG]);
208
209impl Default for NttElem {
210    fn default() -> Self {
211        NttElem([0i32; RING_DEG])
212    }
213}
214
215impl NttElem {
216    /// Forward-transforms a ring element whose coefficients are plain values in [0, 2^13),
217    /// e.g. an element of the uniform matrix A (13 bits) or a rounded vector (10 bits).
218    ///
219    /// The `< 2^13` bound on every coefficient is a precondition, not a checked one: it is
220    /// what keeps the forward transform's intermediate values inside an i32 and the final
221    /// product inside the exactness bound. It is discharged at each call site by the Lean
222    /// correspondence proof rather than by a per-coefficient runtime check.
223    pub(crate) fn from_uniform(elem: &RingElem) -> Self {
224        let mut a = [0i32; RING_DEG];
225        for i in 0..RING_DEG {
226            a[i] = elem.0[i] as i32;
227        }
228        ntt(&mut a);
229        NttElem(a)
230    }
231
232    /// Forward-transforms a CBD secret, interpreting each wrapping-u16 coefficient as the
233    /// signed value it represents (e.g. 0xFFFB is -5).
234    ///
235    /// As with [`NttElem::from_uniform`], the bound on the coefficients — here |·| ≤ μ/2 — is
236    /// a proof-side precondition rather than a runtime check.
237    pub(crate) fn from_secret(elem: &RingElem) -> Self {
238        let mut a = [0i32; RING_DEG];
239        for i in 0..RING_DEG {
240            a[i] = elem.0[i] as i16 as i32;
241        }
242        ntt(&mut a);
243        NttElem(a)
244    }
245}
246
247/// A matrix of NTT-domain ring elements, stored in row-major order like [`Matrix`]
248#[derive(Clone, Zeroize)]
249pub(crate) struct NttMatrix<const X: usize, const Y: usize>(pub(crate) [[NttElem; Y]; X]);
250
251impl<const X: usize, const Y: usize> Default for NttMatrix<X, Y> {
252    fn default() -> Self {
253        NttMatrix([[NttElem::default(); Y]; X])
254    }
255}
256
257/// Adds the pointwise product lhs ∘ rhs into the i64 accumulator, without reducing.
258///
259/// Products of centered values are below (p/2 + 1)² and callers accumulate at most 4 (= MAX_L)
260/// of them, so the accumulator stays (just) below p² < 2^52, within Montgomery reduction's
261/// valid input range.
262fn pointwise_mul_acc(acc: &mut [i64; RING_DEG], lhs: &NttElem, rhs: &NttElem) {
263    for i in 0..RING_DEG {
264        acc[i] = acc[i].wrapping_add((lhs.0[i] as i64).wrapping_mul(rhs.0[i] as i64));
265    }
266}
267
268/// Montgomery-reduces an accumulator of pointwise products, inverse-transforms it, and returns
269/// the coefficient-domain result as a wrapping-u16 ring element.
270fn reduce_invntt_to_ring_elem(acc: &[i64; RING_DEG]) -> RingElem {
271    let mut v = [0i32; RING_DEG];
272    for i in 0..RING_DEG {
273        v[i] = mont_reduce(acc[i]);
274    }
275    invntt(&mut v);
276
277    let mut out = RingElem::default();
278    for i in 0..RING_DEG {
279        out.0[i] = to_wrapping_u16(v[i]);
280    }
281    out
282}
283
284impl<const X: usize, const Y: usize> NttMatrix<X, Y> {
285    /// Forward-transforms a matrix of uniform (≤ 13-bit) ring elements, entry by entry
286    pub(crate) fn from_uniform_matrix(mat: &Matrix<X, Y>) -> Self {
287        let mut ret = NttMatrix::default();
288        for i in 0..X {
289            for j in 0..Y {
290                ret.0[i][j] = NttElem::from_uniform(&mat.0[i][j]);
291            }
292        }
293        ret
294    }
295
296    /// Forward-transforms a matrix of CBD secrets, entry by entry
297    pub(crate) fn from_secret_matrix(mat: &Matrix<X, Y>) -> Self {
298        let mut ret = NttMatrix::default();
299        for i in 0..X {
300            for j in 0..Y {
301                ret.0[i][j] = NttElem::from_secret(&mat.0[i][j]);
302            }
303        }
304        ret
305    }
306
307    /// Multiplies two NTT-domain matrices, returning the result in the coefficient domain.
308    /// Equivalent to [`Matrix::mul`] on the corresponding coefficient-domain matrices.
309    pub(crate) fn mul<const Z: usize>(&self, other: &NttMatrix<Y, Z>) -> Matrix<X, Z> {
310        // The inner dimension bounds the pointwise accumulator (see pointwise_mul_acc)
311        debug_assert!(Y <= crate::consts::MAX_L);
312
313        let mut result = Matrix::default();
314        for i in 0..X {
315            for k in 0..Z {
316                let mut acc = [0i64; RING_DEG];
317                for j in 0..Y {
318                    pointwise_mul_acc(&mut acc, &self.0[i][j], &other.0[j][k]);
319                }
320                result.0[i][k] = reduce_invntt_to_ring_elem(&acc);
321            }
322        }
323        result
324    }
325
326    /// Multiplies the transpose of this NTT-domain matrix by another, returning the result in
327    /// the coefficient domain. Equivalent to [`Matrix::mul_transpose`].
328    pub(crate) fn mul_transpose<const Z: usize>(&self, other: &NttMatrix<X, Z>) -> Matrix<Y, Z> {
329        // The inner dimension bounds the pointwise accumulator (see pointwise_mul_acc)
330        debug_assert!(X <= crate::consts::MAX_L);
331
332        let mut result = Matrix::default();
333        for j in 0..Y {
334            for k in 0..Z {
335                let mut acc = [0i64; RING_DEG];
336                for i in 0..X {
337                    pointwise_mul_acc(&mut acc, &self.0[i][j], &other.0[i][k]);
338                }
339                result.0[j][k] = reduce_invntt_to_ring_elem(&acc);
340            }
341        }
342        result
343    }
344}
345
346#[cfg(test)]
347mod test {
348    use super::*;
349
350    use rand::{Rng, rng};
351
352    /// ψ, the primitive 512th root of unity underlying ZETAS
353    const PSI: u64 = 49118445;
354
355    fn pow_mod(mut base: u64, mut exp: u64, modulus: u64) -> u64 {
356        let mut acc = 1u64;
357        base %= modulus;
358        while exp > 0 {
359            if exp & 1 == 1 {
360                acc = acc * base % modulus;
361            }
362            base = base * base % modulus;
363            exp >>= 1;
364        }
365        acc
366    }
367
368    // The constants spelled as literals (to keep shifts and divisions out of the extracted
369    // Lean) must equal the expressions they stand for
370    #[test]
371    fn literal_constants_are_correct() {
372        assert_eq!(BARRETT_ROUND, 1i64 << 47);
373        assert_eq!(P_HALF, P / 2);
374        assert_eq!(BARRETT_M, ((1i64 << 48) + (P as i64) / 2) / (P as i64));
375        // P_INV is p^-1 mod 2^32, i.e. p · P_INV ≡ 1
376        assert_eq!((P as u32).wrapping_mul(P_INV), 1);
377    }
378
379    // Recompute the ZETAS table from ψ and check every entry, plus ψ's defining properties
380    #[test]
381    fn zetas_table_is_correct() {
382        let p = P as u64;
383        // ψ is a 512th root of unity and ψ^256 = -1 (so the transform is negacyclic)
384        assert_eq!(pow_mod(PSI, 256, p), p - 1);
385        assert_eq!(pow_mod(PSI, 512, p), 1);
386
387        for (k, &z) in ZETAS.iter().enumerate() {
388            let brv = (k as u8).reverse_bits() as u64;
389            let expected = pow_mod(PSI, brv, p) * (1u64 << 32) % p;
390            assert_eq!(z as u64, expected, "ZETAS[{k}]");
391        }
392    }
393
394    /// A random CBD-like secret: wrapping-u16 coefficients in [-mu/2, mu/2]
395    fn rand_secret(rng: &mut impl Rng, half_mu: u16) -> RingElem {
396        let mut ret = RingElem::default();
397        for coeff in ret.0.iter_mut() {
398            *coeff = rng.random_range(0..=2 * half_mu).wrapping_sub(half_mu);
399        }
400        ret
401    }
402
403    /// A random uniform ring element with `bits`-bit coefficients
404    fn rand_uniform(rng: &mut impl Rng, bits: usize) -> RingElem {
405        let mut ret = RingElem::default();
406        for coeff in ret.0.iter_mut() {
407            *coeff = rng.random_range(0..(1u16 << bits));
408        }
409        ret
410    }
411
412    // Multiplying by the polynomial "1" via the NTT pipeline must return the input unchanged
413    #[test]
414    fn mul_by_one_roundtrip() {
415        let mut rng = rng();
416        for _ in 0..50 {
417            let a = rand_uniform(&mut rng, 13);
418            let mut one = RingElem::default();
419            one.0[0] = 1;
420
421            let a_ntt = NttMatrix::<1, 1>([[NttElem::from_uniform(&a)]]);
422            let one_ntt = NttMatrix::<1, 1>([[NttElem::from_secret(&one)]]);
423            let prod = a_ntt.mul(&one_ntt);
424            assert_eq!(prod.0[0][0], a);
425        }
426    }
427
428    // NTT-domain matrix products must match the schoolbook Matrix products exactly, for the
429    // shapes and operand ranges of all three parameter sets
430    #[test]
431    fn matches_schoolbook() {
432        fn check<const L: usize>(half_mu: u16) {
433            let mut rng = rng();
434            for _ in 0..20 {
435                // A is L×L uniform 13-bit, b is L×1 uniform 10-bit, s is an L×1 secret
436                let mut mat_a = Matrix::<L, L>::default();
437                for row in mat_a.0.iter_mut() {
438                    for entry in row.iter_mut() {
439                        *entry = rand_uniform(&mut rng, 13);
440                    }
441                }
442                let mut vec_b = Matrix::<L, 1>::default();
443                let mut vec_s = Matrix::<L, 1>::default();
444                for i in 0..L {
445                    vec_b.0[i][0] = rand_uniform(&mut rng, 10);
446                    vec_s.0[i][0] = rand_secret(&mut rng, half_mu);
447                }
448
449                let a_ntt = NttMatrix::from_uniform_matrix(&mat_a);
450                let b_ntt = NttMatrix::from_uniform_matrix(&vec_b);
451                let s_ntt = NttMatrix::from_secret_matrix(&vec_s);
452
453                assert_eq!(a_ntt.mul(&s_ntt), mat_a.mul(&vec_s));
454                assert_eq!(a_ntt.mul_transpose(&s_ntt), mat_a.mul_transpose(&vec_s));
455                assert_eq!(b_ntt.mul_transpose(&s_ntt), vec_b.mul_transpose(&vec_s));
456            }
457        }
458
459        check::<2>(5); // kopis512: L=2, mu=10
460        check::<3>(4); // kopis768: L=3, mu=8
461        check::<4>(3); // kopis1024: L=4, mu=6
462    }
463
464    // The worst-case accumulated product coefficient over all parameter sets is exactly
465    // L·256·8191·(μ/2) = 25_162_752 (L=3, μ=8), just under p/2. Hit it exactly: with
466    // a_i = 8191 for all i, s_0 = μ/2, and s_j = -μ/2 for j > 0, coefficient 0 of a·s is
467    // Σ_i 8191·(μ/2) = 256·8191·(μ/2), and the matrix product accumulates it L times.
468    #[test]
469    fn extremal_coefficients() {
470        const L: usize = 3;
471        const HALF_MU: u16 = 4;
472
473        let mut mat_a = Matrix::<L, L>::default();
474        for row in mat_a.0.iter_mut() {
475            for entry in row.iter_mut() {
476                *entry = RingElem([8191u16; RING_DEG]);
477            }
478        }
479        let mut vec_s = Matrix::<L, 1>::default();
480        for i in 0..L {
481            let mut s = RingElem([0u16.wrapping_sub(HALF_MU); RING_DEG]);
482            s.0[0] = HALF_MU;
483            vec_s.0[i][0] = s;
484        }
485
486        let a_ntt = NttMatrix::from_uniform_matrix(&mat_a);
487        let s_ntt = NttMatrix::from_secret_matrix(&vec_s);
488
489        assert_eq!(a_ntt.mul(&s_ntt), mat_a.mul(&vec_s));
490        assert_eq!(a_ntt.mul_transpose(&s_ntt), mat_a.mul_transpose(&vec_s));
491    }
492}