1use logicaffeine_base::BigInt;
19use std::f64::consts::PI;
20
21#[inline]
22fn zero() -> BigInt {
23 BigInt::from_i64(0)
24}
25
26#[derive(Clone, Debug, PartialEq, Eq)]
29pub struct Cyclo {
30 pub n: usize,
31 pub coeffs: Vec<BigInt>,
32}
33
34impl Cyclo {
35 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 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 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 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); } else {
90 c[k - n] = c[k - n].sub(&prod); }
92 }
93 }
94 Cyclo { n, coeffs: c }
95 }
96
97 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 pub fn conjugate(&self) -> Cyclo {
115 self.galois((2 * self.n - 1) as u64)
116 }
117
118 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 pub fn norm(&self) -> BigInt {
126 self.galois_product().coeffs[0].clone()
127 }
128
129 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 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 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 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
167fn 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
188fn 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
203fn 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
229pub 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
236pub 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 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 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
289fn 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#[derive(Clone, Debug)]
308pub struct LogUnitAudit {
309 pub n: usize,
310 pub rank: usize,
312 pub gs_lengths: Vec<f64>,
314 pub covering_radius_bound: f64,
317}
318
319pub 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
329pub 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
358pub 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
370pub 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 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 assert_eq!(Cyclo::monomial(n, n + 2, bi(1)), Cyclo::monomial(n, 2, bi(-1)));
392 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 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 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; let group = galois_group(n);
420 assert_eq!(group.len(), n, "there are exactly φ(2n) = n automorphisms");
421
422 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 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 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 assert_eq!(a.conjugate().conjugate(), a, "σ_{{-1}}² = id");
471 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 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 assert!(a.galois_product().coeffs[1..].iter().all(|c| c.is_zero()), "N(a) is a rational integer");
485 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 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 for &t in &galois_group(n) {
501 assert!(u.galois(t).is_unit(), "σ_t(u) is again a unit");
502 }
503 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]); 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 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 let g = Cyclo::from_ints(n, &[1, -1, 0, 0, 0, 0, 0, 0]); let units = cyclotomic_units(n);
526 let u = units[0].mul(&units[1]); 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 let rec = recover_short_generator(&h).expect("CDPR recovery succeeds");
533
534 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 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 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 assert!(recovery_margin(&Cyclo::one(n)) < 1e-6, "Log(1) = 0 ⟹ margin 0");
567
568 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 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 }
582
583 #[test]
584 fn the_upstream_wall_the_approximation_scale_grows_with_n() {
585 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 assert!(scales[3] > scales[0], "the approximation scale grows with n: {scales:?}");
595 }
596}