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}