Skip to main content

logicaffeine_proof/
cyclotomic.rs

1//! # Power-of-two cyclotomic ring `R = ℤ[X]/(Xⁿ + 1)`, `n = 2ᵏ`
2//!
3//! When `n` is a power of two, `Xⁿ + 1 = Φ_{2n}(X)` is the `2n`-th cyclotomic polynomial, so `R` is the ring
4//! of integers `𝒪_K` of the cyclotomic field `K = ℚ(ζ_{2n})` (`ζ_{2n}` a primitive `2n`-th root of unity,
5//! played by `X`). This is the algebraic substrate of **Module-LWE** — ML-KEM/Kyber live over `R_q` with
6//! `n = 256`. Multiplication is **negacyclic** (`Xⁿ ≡ −1`).
7//!
8//! The field's **Galois group** is `Gal(K/ℚ) ≅ (ℤ/2n)^×` — the ring automorphisms `σ_t : X ↦ X^t` for odd
9//! `t`, of which there are exactly `φ(2n) = n`. This rigid, fully-known symmetry group is precisely what
10//! structure-exploiting cryptanalysis (the log-unit lattice, the Principal Ideal Problem) rides on: two
11//! generators of an ideal differ by a unit, and the units are governed by these automorphisms. It is the
12//! lattice analogue of the `Aut(E₀)`-orbit collapse in the isogeny keyspace — **symmetry = compression**.
13//!
14//! This module builds the ring and its Galois action honestly. It does **not** break Module-LWE: it is the
15//! substrate on which the (short-generator) attacks that *do* work are expressed, and the lens that measures
16//! where they stop.
17
18use logicaffeine_base::BigInt;
19use std::f64::consts::PI;
20
21#[inline]
22fn zero() -> BigInt {
23    BigInt::from_i64(0)
24}
25
26/// An element of `R = ℤ[X]/(Xⁿ + 1)`: the coefficient vector `[a₀, …, a_{n−1}]` of `Σ aᵢ Xⁱ`, with `n` a
27/// power of two.
28#[derive(Clone, Debug, PartialEq, Eq)]
29pub struct Cyclo {
30    pub n: usize,
31    pub coeffs: Vec<BigInt>,
32}
33
34impl Cyclo {
35    /// An element from a coefficient vector (padded/verified to length `n`). `n` must be a power of two.
36    pub fn new(n: usize, mut coeffs: Vec<BigInt>) -> Cyclo {
37        assert!(n.is_power_of_two(), "R = ℤ[X]/(Xⁿ+1) requires n a power of two");
38        assert!(coeffs.len() <= n, "an element of R has degree < n");
39        coeffs.resize(n, zero());
40        Cyclo { n, coeffs }
41    }
42
43    /// Convenience constructor from small integers.
44    pub fn from_ints(n: usize, v: &[i64]) -> Cyclo {
45        Cyclo::new(n, v.iter().map(|&x| BigInt::from_i64(x)).collect())
46    }
47
48    pub fn zero(n: usize) -> Cyclo {
49        Cyclo::new(n, vec![])
50    }
51
52    pub fn one(n: usize) -> Cyclo {
53        Cyclo::from_ints(n, &[1])
54    }
55
56    /// `coeff · Xⁱ` reduced into `R` (any `i ≥ 0`): `Xⁱ = (−1)^{⌊i/n⌋} · X^{i mod n}`.
57    pub fn monomial(n: usize, i: usize, coeff: BigInt) -> Cyclo {
58        let mut c = vec![zero(); n];
59        let signed = if (i / n) % 2 == 0 { coeff } else { zero().sub(&coeff) };
60        c[i % n] = signed;
61        Cyclo { n, coeffs: c }
62    }
63
64    pub fn add(&self, o: &Cyclo) -> Cyclo {
65        Cyclo { n: self.n, coeffs: (0..self.n).map(|i| self.coeffs[i].add(&o.coeffs[i])).collect() }
66    }
67
68    pub fn sub(&self, o: &Cyclo) -> Cyclo {
69        Cyclo { n: self.n, coeffs: (0..self.n).map(|i| self.coeffs[i].sub(&o.coeffs[i])).collect() }
70    }
71
72    pub fn neg(&self) -> Cyclo {
73        Cyclo { n: self.n, coeffs: self.coeffs.iter().map(|c| zero().sub(c)).collect() }
74    }
75
76    /// Negacyclic product: multiply as polynomials, then reduce `Xⁿ ≡ −1`.
77    pub fn mul(&self, o: &Cyclo) -> Cyclo {
78        let n = self.n;
79        let mut c = vec![zero(); n];
80        for i in 0..n {
81            if self.coeffs[i].is_zero() {
82                continue;
83            }
84            for j in 0..n {
85                let prod = self.coeffs[i].mul(&o.coeffs[j]);
86                let k = i + j;
87                if k < n {
88                    c[k] = c[k].add(&prod); // Xᵏ, k < n
89                } else {
90                    c[k - n] = c[k - n].sub(&prod); // Xᵏ = −X^{k−n}
91                }
92            }
93        }
94        Cyclo { n, coeffs: c }
95    }
96
97    /// The Galois automorphism `σ_t : X ↦ X^t` (`t` odd, i.e. a unit of `ℤ/2n`). On monomials
98    /// `σ_t(Xⁱ) = X^{it}`, reduced by `Xⁿ ≡ −1`. A ring automorphism of `R`.
99    pub fn galois(&self, t: u64) -> Cyclo {
100        let n = self.n;
101        let twon = 2 * n as u64;
102        let t = t % twon;
103        assert!(t % 2 == 1, "Galois automorphisms σ_t require t odd (a unit mod 2n)");
104        let mut c = vec![zero(); n];
105        for i in 0..n {
106            let m = ((i as u64) * t) % twon;
107            let (idx, neg) = if (m as usize) < n { (m as usize, false) } else { (m as usize - n, true) };
108            c[idx] = if neg { c[idx].sub(&self.coeffs[i]) } else { c[idx].add(&self.coeffs[i]) };
109        }
110        Cyclo { n, coeffs: c }
111    }
112
113    /// Complex conjugation `σ_{−1} : X ↦ X^{−1} = X^{2n−1}` — the order-two element of the Galois group.
114    pub fn conjugate(&self) -> Cyclo {
115        self.galois((2 * self.n - 1) as u64)
116    }
117
118    /// The product `∏_{σ ∈ Gal} σ(a)` as a ring element — Galois-invariant, hence a rational integer times
119    /// `1`. Used by [`norm`](Cyclo::norm); exposed so callers can confirm the higher coefficients vanish.
120    fn galois_product(&self) -> Cyclo {
121        galois_group(self.n).into_iter().fold(Cyclo::one(self.n), |acc, t| acc.mul(&self.galois(t)))
122    }
123
124    /// The field **norm** `N(a) = ∏_{σ ∈ Gal} σ(a) ∈ ℤ` — multiplicative, and `±1` exactly on the units.
125    pub fn norm(&self) -> BigInt {
126        self.galois_product().coeffs[0].clone()
127    }
128
129    /// The field **trace** `Tr(a) = Σ_{σ ∈ Gal} σ(a) ∈ ℤ` — additive; `Tr(1) = n`.
130    pub fn trace(&self) -> BigInt {
131        let sum = galois_group(self.n).into_iter().fold(Cyclo::zero(self.n), |acc, t| acc.add(&self.galois(t)));
132        sum.coeffs[0].clone()
133    }
134
135    /// Whether `a` is a unit of `R` — equivalently `N(a) = ±1`.
136    pub fn is_unit(&self) -> bool {
137        let nrm = self.norm();
138        nrm == BigInt::from_i64(1) || nrm == BigInt::from_i64(-1)
139    }
140
141    /// The exact ring inverse of a **unit**, with no field division: since `N(u) = u·∏_{t≠1}σ_t(u) = ±1`, we
142    /// have `u⁻¹ = N(u)·∏_{t≠1}σ_t(u)`. `None` for a non-unit.
143    pub fn unit_inverse(&self) -> Option<Cyclo> {
144        let nrm = self.norm();
145        let one = BigInt::from_i64(1);
146        let neg = BigInt::from_i64(-1);
147        let sign_neg = if nrm == one {
148            false
149        } else if nrm == neg {
150            true
151        } else {
152            return None;
153        };
154        let prod = galois_group(self.n)
155            .into_iter()
156            .filter(|&t| t != 1)
157            .fold(Cyclo::one(self.n), |acc, t| acc.mul(&self.galois(t)));
158        Some(if sign_neg { prod.neg() } else { prod })
159    }
160
161    /// The sum of absolute values of the coefficients — a coarse length used to recognize a *short* generator.
162    pub fn coeff_norm(&self) -> i64 {
163        self.coeffs.iter().map(|c| c.to_i64().unwrap_or(i64::MAX / 2).abs()).sum()
164    }
165}
166
167// ---- The log-unit lattice and CDPR short-generator recovery -----------------------------------------
168//
169// The canonical embedding sends a ∈ K to (a(ζ^j))_{j odd} ∈ ℂⁿ (the n complex places), and the log embedding
170// to (log|a(ζ^j)|)_j ∈ ℝⁿ. The units map to a lattice Λ = Log(units); the cyclotomic units b_j = (Xʲ−1)/(X−1)
171// give an explicit basis. A short generator g of a principal ideal sits *close to the origin* in this picture,
172// so a generator h = g·u differs from it by the lattice vector Log(u); rounding Log(h) into Λ strips the unit.
173// This is the AutOrbitClosure move — quotient out the units, collapse the space — for structured lattices.
174
175/// `a(ζ^j)` in ℂ, `ζ = exp(2πi/2n)` — the `j`-th canonical embedding.
176fn embed_at(a: &Cyclo, j: usize) -> (f64, f64) {
177    let twon = 2.0 * a.n as f64;
178    let (mut re, mut im) = (0.0f64, 0.0f64);
179    for (i, c) in a.coeffs.iter().enumerate() {
180        let ci = c.to_i64().expect("embedding assumes coefficients fit i64") as f64;
181        let ang = 2.0 * PI * (j as f64) * (i as f64) / twon;
182        re += ci * ang.cos();
183        im += ci * ang.sin();
184    }
185    (re, im)
186}
187
188/// The log embedding `Log(a) = (log|a(ζ^j)|)_{j odd} ∈ ℝⁿ`.
189fn log_embedding(a: &Cyclo) -> Vec<f64> {
190    (1..2 * a.n)
191        .step_by(2)
192        .map(|j| {
193            let (re, im) = embed_at(a, j);
194            0.5 * (re * re + im * im).ln()
195        })
196        .collect()
197}
198
199fn dot(u: &[f64], v: &[f64]) -> f64 {
200    u.iter().zip(v).map(|(a, b)| a * b).sum()
201}
202
203/// Solve `A x = b` (small dense system) by Gaussian elimination with partial pivoting. `None` if singular.
204fn solve_linear(mut a: Vec<Vec<f64>>, mut b: Vec<f64>) -> Option<Vec<f64>> {
205    let n = b.len();
206    for col in 0..n {
207        let piv = (col..n).max_by(|&r1, &r2| {
208            a[r1][col].abs().partial_cmp(&a[r2][col].abs()).unwrap_or(std::cmp::Ordering::Equal)
209        })?;
210        if a[piv][col].abs() < 1e-9 {
211            return None;
212        }
213        a.swap(col, piv);
214        b.swap(col, piv);
215        for r in 0..n {
216            if r == col {
217                continue;
218            }
219            let f = a[r][col] / a[col][col];
220            for k in col..n {
221                a[r][k] -= f * a[col][k];
222            }
223            b[r] -= f * b[col];
224        }
225    }
226    Some((0..n).map(|i| b[i] / a[i][i]).collect())
227}
228
229/// The cyclotomic units `b_j = 1 + X + ⋯ + X^{j−1} = (Xʲ−1)/(X−1)` for `j` an odd residue in `[3, n−1]`. These
230/// are `n/2 − 1` independent units — a basis of a finite-index subgroup of the unit group, and the basis of
231/// the log-unit lattice used by CDPR.
232pub fn cyclotomic_units(n: usize) -> Vec<Cyclo> {
233    (3..n).step_by(2).map(|j| Cyclo::from_ints(n, &vec![1i64; j])).collect()
234}
235
236/// **CDPR short-generator recovery.** Given a generator `h` of a principal ideal that has an *unusually short*
237/// generator `g` (so `h = g·u` for a unit `u`), recover a short generator: project `Log(h)` onto the log-unit
238/// lattice against the cyclotomic-unit basis to strip the unit, divide it out **exactly** (via the
239/// Galois-conjugate inverse), and pick the exact generator of smallest coefficient-norm over a local search
240/// around the rounded lattice point. Returns `g` up to a root of unity `±Xᵐ`. This is the genuine break on
241/// schemes that expose a short principal-ideal generator (Soliloquy, Smart–Vercauteren); it does **not** touch
242/// Module-LWE, where the secret is not such a generator.
243pub fn recover_short_generator(h: &Cyclo) -> Option<Cyclo> {
244    let n = h.n;
245    let units = cyclotomic_units(n);
246    let r = units.len();
247    let blogs: Vec<Vec<f64>> = units.iter().map(log_embedding).collect();
248    let target = log_embedding(h);
249
250    // Least-squares coordinates of Log(h) in the unit-log basis: (BᵀB) c = Bᵀ Log(h).
251    let mut btb = vec![vec![0.0; r]; r];
252    let mut bt = vec![0.0; r];
253    for i in 0..r {
254        for k in 0..r {
255            btb[i][k] = dot(&blogs[i], &blogs[k]);
256        }
257        bt[i] = dot(&blogs[i], &target);
258    }
259    let c = solve_linear(btb, bt)?;
260    let base: Vec<i64> = c.iter().map(|&x| x.round() as i64).collect();
261
262    // Local search over the rounded exponents: the true short generator is the exact divisor of least norm.
263    let inverses: Vec<Cyclo> = units.iter().filter_map(|u| u.unit_inverse()).collect();
264    if inverses.len() != r {
265        return None;
266    }
267    let mut best: Option<(i64, Cyclo)> = None;
268    for mask in 0..3usize.pow(r as u32) {
269        let mut m = mask;
270        let mut uprime = Cyclo::one(n);
271        for i in 0..r {
272            let e = base[i] + (m % 3) as i64 - 1;
273            m /= 3;
274            let base_elt = if e >= 0 { &units[i] } else { &inverses[i] };
275            for _ in 0..e.unsigned_abs() {
276                uprime = uprime.mul(base_elt);
277            }
278        }
279        let Some(uinv) = uprime.unit_inverse() else { continue };
280        let g = h.mul(&uinv);
281        let norm = g.coeff_norm();
282        if best.as_ref().is_none_or(|(bn, _)| norm < *bn) {
283            best = Some((norm, g));
284        }
285    }
286    best.map(|(_, g)| g)
287}
288
289/// Gram–Schmidt orthogonalization of a set of `ℝ^m` vectors (returns the orthogonal `bᵢ*`).
290fn gram_schmidt(vs: &[Vec<f64>]) -> Vec<Vec<f64>> {
291    let mut out: Vec<Vec<f64>> = Vec::new();
292    for v in vs {
293        let mut u = v.clone();
294        for w in &out {
295            let coef = dot(v, w) / dot(w, w);
296            for j in 0..u.len() {
297                u[j] -= coef * w[j];
298            }
299        }
300        out.push(u);
301    }
302    out
303}
304
305/// An audit of the **log-unit lattice** geometry `Λ = Log(units)` that governs CDPR recovery — the decoding
306/// scale of the symmetry-quotient.
307#[derive(Clone, Debug)]
308pub struct LogUnitAudit {
309    pub n: usize,
310    /// The unit rank `r₁ + r₂ − 1 = n/2 − 1` (Dirichlet).
311    pub rank: usize,
312    /// Gram–Schmidt lengths `‖bᵢ*‖` of the cyclotomic-unit log-basis.
313    pub gs_lengths: Vec<f64>,
314    /// Covering-radius upper bound `μ ≤ ½·(Σ‖bᵢ*‖²)^½` — the scale below which a short generator is
315    /// decodable by rounding in `Λ`.
316    pub covering_radius_bound: f64,
317}
318
319/// Measure the log-unit lattice geometry for `R = ℤ[X]/(Xⁿ+1)`.
320pub fn audit_log_unit(n: usize) -> LogUnitAudit {
321    let units = cyclotomic_units(n);
322    let blogs: Vec<Vec<f64>> = units.iter().map(log_embedding).collect();
323    let gs = gram_schmidt(&blogs);
324    let gs_lengths: Vec<f64> = gs.iter().map(|v| dot(v, v).sqrt()).collect();
325    let cov = 0.5 * gs_lengths.iter().map(|l| l * l).sum::<f64>().sqrt();
326    LogUnitAudit { n, rank: units.len(), gs_lengths, covering_radius_bound: cov }
327}
328
329/// The **CDPR recovery margin** of a candidate generator `g`: `‖proj_{span Λ} Log(g)‖ / μ(Λ)`. A generator
330/// whose log projects to well within the covering radius (`margin < 1`) is decodable by stripping the unit;
331/// `margin ≫ 1` is beyond the decoding region. This measures *where the short-generator wall is* — and,
332/// honestly, it is **small even for short (ML-KEM-shaped) secrets**, showing the log-unit collapse is *not*
333/// where Module-LWE's hardness lives: that sits upstream (no principal-ideal-generator to hand the attacker,
334/// module rank ≥ 2, and the `2^Õ(√n)` Ideal-SVP approximation gap).
335pub fn recovery_margin(g: &Cyclo) -> f64 {
336    let n = g.n;
337    let blogs: Vec<Vec<f64>> = cyclotomic_units(n).iter().map(log_embedding).collect();
338    let r = blogs.len();
339    let lg = log_embedding(g);
340    let mut btb = vec![vec![0.0; r]; r];
341    let mut bt = vec![0.0; r];
342    for i in 0..r {
343        for k in 0..r {
344            btb[i][k] = dot(&blogs[i], &blogs[k]);
345        }
346        bt[i] = dot(&blogs[i], &lg);
347    }
348    let Some(c) = solve_linear(btb, bt) else { return f64::INFINITY };
349    let mut proj = vec![0.0; n];
350    for i in 0..r {
351        for (j, pj) in proj.iter_mut().enumerate() {
352            *pj += c[i] * blogs[i][j];
353        }
354    }
355    dot(&proj, &proj).sqrt() / audit_log_unit(n).covering_radius_bound
356}
357
358/// **The upstream wall, measured.** The approximation scale of the log-unit collapse at dimension `n`: the
359/// covering radius `μ(Λ)` against the shortest basis log-length — a proxy for the factor by which a
360/// log-unit-decoded generator can exceed the true shortest vector. To *threaten* Module-LWE this factor would
361/// have to stay **polynomial**; instead it grows super-polynomially with `n` (asymptotically `2^{Õ(√n)}`,
362/// Cramer–Ducas–Wesolowski 2017). This is the real wall — not the symmetry-collapse rung (which is cheap, as
363/// [`recovery_margin`] shows) but the *quality* of what the collapse can decode as the field grows.
364pub fn approximation_scale(n: usize) -> f64 {
365    let a = audit_log_unit(n);
366    let shortest = a.gs_lengths.iter().cloned().fold(f64::INFINITY, f64::min);
367    a.covering_radius_bound / shortest
368}
369
370/// The `n = φ(2n)` Galois automorphisms of `R = ℤ[X]/(Xⁿ+1)`, as the odd residues mod `2n` that index them.
371pub fn galois_group(n: usize) -> Vec<u64> {
372    (1..(2 * n as u64)).step_by(2).collect()
373}
374
375#[cfg(test)]
376mod tests {
377    use super::*;
378
379    fn bi(x: i64) -> BigInt {
380        BigInt::from_i64(x)
381    }
382
383    #[test]
384    fn negacyclic_multiplication_reduces_x_to_the_n_as_minus_one() {
385        let n = 8;
386        // X · X^{n-1} = Xⁿ = −1.
387        let x = Cyclo::monomial(n, 1, bi(1));
388        let xnm1 = Cyclo::monomial(n, n - 1, bi(1));
389        assert_eq!(x.mul(&xnm1), Cyclo::one(n).neg(), "Xⁿ ≡ −1 (negacyclic)");
390        // X^{n+2} folds to −X².
391        assert_eq!(Cyclo::monomial(n, n + 2, bi(1)), Cyclo::monomial(n, 2, bi(-1)));
392        // One is the multiplicative identity; multiplication commutes and distributes.
393        let a = Cyclo::from_ints(n, &[1, -2, 3, 0, -1, 5, 0, 2]);
394        let b = Cyclo::from_ints(n, &[0, 1, -1, 4, 2, 0, -3, 1]);
395        let c = Cyclo::from_ints(n, &[2, 0, 1, -1, 0, 3, 1, -2]);
396        assert_eq!(a.mul(&Cyclo::one(n)), a, "1 is the identity");
397        assert_eq!(a.mul(&b), b.mul(&a), "multiplication commutes");
398        assert_eq!(a.mul(&b.add(&c)), a.mul(&b).add(&a.mul(&c)), "distributivity");
399        assert_eq!(a.mul(&b).mul(&c), a.mul(&b.mul(&c)), "associativity");
400    }
401
402    #[test]
403    fn every_galois_map_is_a_ring_automorphism() {
404        let n = 8;
405        let a = Cyclo::from_ints(n, &[1, -2, 3, 0, -1, 5, 0, 2]);
406        let b = Cyclo::from_ints(n, &[0, 1, -1, 4, 2, 0, -3, 1]);
407        for &t in &galois_group(n) {
408            // σ_t preserves + and ·, so it is a ring homomorphism (and, being invertible, an automorphism).
409            assert_eq!(a.add(&b).galois(t), a.galois(t).add(&b.galois(t)), "σ_{t} preserves addition");
410            assert_eq!(a.mul(&b).galois(t), a.galois(t).mul(&b.galois(t)), "σ_{t} preserves multiplication");
411            // σ_t fixes the rational constant ℤ ⊂ R.
412            assert_eq!(Cyclo::one(n).galois(t), Cyclo::one(n), "σ_{t} fixes 1");
413        }
414    }
415
416    #[test]
417    fn the_galois_group_is_z_mod_2n_star_of_order_n() {
418        let n = 8; // 2n = 16, (ℤ/16)^× has order φ(16) = 8 = n
419        let group = galois_group(n);
420        assert_eq!(group.len(), n, "there are exactly φ(2n) = n automorphisms");
421
422        // σ_1 = id, and the group law σ_s ∘ σ_t = σ_{st mod 2n} holds on a witness element.
423        let a = Cyclo::from_ints(n, &[3, 1, -2, 0, 4, -1, 2, 5]);
424        assert_eq!(a.galois(1), a, "σ_1 is the identity");
425        let twon = (2 * n) as u64;
426        for &s in &group {
427            for &t in &group {
428                assert_eq!(a.galois(t).galois(s), a.galois((s * t) % twon), "σ_s ∘ σ_t = σ_{{st}}");
429            }
430        }
431
432        // The automorphisms are pairwise distinct (their action on X already separates them).
433        let x = Cyclo::monomial(n, 1, bi(1));
434        let images: Vec<Cyclo> = group.iter().map(|&t| x.galois(t)).collect();
435        for i in 0..images.len() {
436            for j in (i + 1)..images.len() {
437                assert_ne!(images[i], images[j], "distinct t ⟹ distinct automorphism");
438            }
439        }
440
441        // {σ_{-1}, σ_3} generate the whole group — the closure of {2n−1, 3} under × mod 2n is all n residues.
442        let mut closure = vec![1u64];
443        let gens = [twon - 1, 3];
444        loop {
445            let mut grew = false;
446            for &g in &gens {
447                for k in 0..closure.len() {
448                    let p = (closure[k] * g) % twon;
449                    if !closure.contains(&p) {
450                        closure.push(p);
451                        grew = true;
452                    }
453                }
454            }
455            if !grew {
456                break;
457            }
458        }
459        closure.sort_unstable();
460        let mut all = group.clone();
461        all.sort_unstable();
462        assert_eq!(closure, all, "⟨σ_{{-1}}, σ_3⟩ generates the full Galois group");
463    }
464
465    #[test]
466    fn conjugation_is_the_order_two_automorphism() {
467        let n = 8;
468        let a = Cyclo::from_ints(n, &[3, 1, -2, 0, 4, -1, 2, 5]);
469        // σ_{-1} is an involution.
470        assert_eq!(a.conjugate().conjugate(), a, "σ_{{-1}}² = id");
471        // σ_{-1}(X) = X^{-1} = X^{2n-1} = −X^{n-1}.
472        let x = Cyclo::monomial(n, 1, bi(1));
473        assert_eq!(x.conjugate(), Cyclo::monomial(n, n - 1, bi(-1)), "σ_{{-1}}(X) = −X^{{n-1}}");
474        // Conjugation fixes the constant term and negates the "imaginary" part, as complex conjugation must.
475        assert_eq!(a.conjugate().coeffs[0], a.coeffs[0], "the rational trace part is fixed");
476    }
477
478    #[test]
479    fn norm_and_trace_are_rational_integers() {
480        let n = 8;
481        let a = Cyclo::from_ints(n, &[3, 1, -2, 0, 4, -1, 2, 5]);
482        let b = Cyclo::from_ints(n, &[1, 0, 1, -1, 2, 1, 0, -2]);
483        // The Galois product/sum land in ℤ ⊂ R — every higher coefficient vanishes.
484        assert!(a.galois_product().coeffs[1..].iter().all(|c| c.is_zero()), "N(a) is a rational integer");
485        // Norm is multiplicative, trace is additive — the defining properties.
486        assert_eq!(a.mul(&b).norm(), a.norm().mul(&b.norm()), "N(ab) = N(a)·N(b)");
487        assert_eq!(a.add(&b).trace(), a.trace().add(&b.trace()), "Tr(a+b) = Tr(a)+Tr(b)");
488        assert_eq!(Cyclo::one(n).norm(), bi(1), "N(1) = 1");
489        assert_eq!(Cyclo::one(n).trace(), bi(n as i64), "Tr(1) = n");
490    }
491
492    #[test]
493    fn cyclotomic_units_have_norm_plus_minus_one() {
494        let n = 8;
495        // u = 1 + X + X² = (1 − X³)/(1 − X) is a cyclotomic unit: N(1−X³) = N(1−X) = 2, so N(u) = 1.
496        let u = Cyclo::from_ints(n, &[1, 1, 1]);
497        assert_eq!(u.norm(), bi(1), "the cyclotomic unit 1+X+X² has norm 1");
498        assert!(u.is_unit(), "hence it is a unit of R");
499        // Every Galois image of a unit is a unit (the automorphisms permute the units).
500        for &t in &galois_group(n) {
501            assert!(u.galois(t).is_unit(), "σ_t(u) is again a unit");
502        }
503        // 1 + X is NOT a unit: N(1+X) = ∏(1+ζ) = 2. Ramified prime above 2, not a unit.
504        let non_unit = Cyclo::from_ints(n, &[1, 1]);
505        assert_eq!(non_unit.norm(), bi(2), "N(1+X) = 2");
506        assert!(!non_unit.is_unit(), "1+X is not a unit (it generates the ramified prime above 2)");
507    }
508
509    #[test]
510    fn unit_inverse_is_an_exact_ring_inverse() {
511        let n = 8;
512        let u = Cyclo::from_ints(n, &[1, 1, 1]); // the cyclotomic unit 1+X+X²
513        let inv = u.unit_inverse().expect("a unit has a ring inverse");
514        assert_eq!(u.mul(&inv), Cyclo::one(n), "u · u⁻¹ = 1 exactly in R");
515        // A non-unit has no ring inverse.
516        assert!(Cyclo::from_ints(n, &[1, 1]).unit_inverse().is_none(), "1+X is not invertible in R");
517    }
518
519    #[test]
520    fn cdpr_strips_the_unit_and_recovers_the_short_generator() {
521        let n = 8;
522        // The Soliloquy-style secret: a SHORT generator g of a principal ideal.
523        let g = Cyclo::from_ints(n, &[1, -1, 0, 0, 0, 0, 0, 0]); // 1 − X
524        // The adversary publishes h = g·u, hiding g behind a large cyclotomic unit u.
525        let units = cyclotomic_units(n);
526        let u = units[0].mul(&units[1]); // b₃ · b₅ — a genuine, nontrivial unit
527        assert!(u.is_unit(), "the mask is a unit");
528        let h = g.mul(&u);
529        assert!(h.coeff_norm() > g.coeff_norm(), "the public generator h is long (unit-masked)");
530
531        // Recover: strip the unit in the log-unit lattice, divide it out exactly.
532        let rec = recover_short_generator(&h).expect("CDPR recovery succeeds");
533
534        // The recovery lands on a generator as short as the planted secret — the unit is stripped. By
535        // construction rec = h·u'⁻¹ with u' a unit, so (rec) = (h) = (g): it is a genuine short generator of
536        // the secret ideal (differing from g by a unit — CDPR recovers *a* short generator, the break).
537        assert_eq!(rec.coeff_norm(), g.coeff_norm(), "recovered a generator as short as the secret");
538        let recn = rec.norm();
539        assert!(recn == bi(2) || recn == bi(-2), "|N(rec)| = |N(g)| — a genuine generator of the secret ideal");
540        assert!(h.coeff_norm() >= 2 * rec.coeff_norm(), "and far shorter than the unit-masked public h");
541        // Sanity: the recovered generator is not a unit — it generates the proper secret ideal, not all of R.
542        assert!(!rec.is_unit(), "rec generates the secret ideal (norm 2), not R");
543    }
544
545    #[test]
546    fn the_log_unit_geometry_audit_is_sane_and_the_scale_grows_with_n() {
547        for n in [8usize, 16, 32] {
548            let a = audit_log_unit(n);
549            assert_eq!(a.rank, n / 2 - 1, "unit rank = n/2 − 1 (Dirichlet unit theorem)");
550            assert!(a.gs_lengths.iter().all(|&l| l > 1e-9), "the cyclotomic-unit log-basis is nondegenerate");
551            assert!(a.covering_radius_bound > 0.0, "a positive decoding scale");
552        }
553        // The decoding scale (the log-unit covering radius) grows with n — the geometry the wall lives in.
554        let (m8, m16, m32) = (
555            audit_log_unit(8).covering_radius_bound,
556            audit_log_unit(16).covering_radius_bound,
557            audit_log_unit(32).covering_radius_bound,
558        );
559        assert!(m8 < m16 && m16 < m32, "μ(Λ) grows with n: {m8} < {m16} < {m32}");
560    }
561
562    #[test]
563    fn recovery_margin_locates_the_short_generator_wall() {
564        let n = 8;
565        // 1 (trivial generator of R) has Log(1) = 0 ⟹ margin 0.
566        assert!(recovery_margin(&Cyclo::one(n)) < 1e-6, "Log(1) = 0 ⟹ margin 0");
567
568        // The short secret 1 − X we actually recovered sits INSIDE the decoding region.
569        let g = Cyclo::from_ints(n, &[1, -1, 0, 0, 0, 0, 0, 0]);
570        let m_g = recovery_margin(&g);
571        assert!(m_g < 1.0, "the short principal-ideal generator is inside the wall (margin {m_g} < 1)");
572
573        // A cyclotomic unit's whole log lies in span(Λ), so it sits far out along exactly the directions the
574        // recovery quotients away — its margin exceeds the short generator's.
575        let u = cyclotomic_units(n)[1].clone();
576        assert!(recovery_margin(&u) > m_g, "a unit sits farther out along the log-unit directions than g");
577
578        // The honest headline, as a number: short generators are INSIDE the wall (recoverable). So the
579        // log-unit collapse is not Module-LWE's defense — that is upstream (no generator handed over, module
580        // rank ≥ 2, the Ideal-SVP approximation gap). The auditor measures the rung and points past it.
581    }
582
583    #[test]
584    fn the_upstream_wall_the_approximation_scale_grows_with_n() {
585        // The decoding factor the log-unit collapse achieves would have to stay POLYNOMIAL to threaten
586        // Module-LWE; measured directly, it grows with the field dimension n — the real wall.
587        let ns = [8usize, 16, 32, 64];
588        let scales: Vec<f64> = ns.iter().map(|&n| approximation_scale(n)).collect();
589        for (n, s) in ns.iter().zip(&scales) {
590            eprintln!("approximation_scale(n={n}) = {s:.4}");
591            assert!(s.is_finite() && *s > 0.0, "a finite positive decoding scale");
592        }
593        // Over the measured range the scale clearly grows — the gap to a polynomial factor widens with n.
594        assert!(scales[3] > scales[0], "the approximation scale grows with n: {scales:?}");
595    }
596}