Skip to main content

logicaffeine_proof/
lattice.rs

1//! LLL lattice reduction — the geometric lens of the compression campaign.
2//!
3//! Every rung so far reads structure through a finite field: linear/algebraic/correlation attacks are
4//! linear algebra or statistics over GF(2), GF(p), GF(2ⁿ). Lattice reduction reads it through the
5//! GEOMETRY OF NUMBERS instead — an orthogonal lens. A lattice is the integer span of a basis; its short
6//! vectors are its most compressed representatives, and a SHORT vector *is* a hidden small relation among
7//! the basis rows. So "find the shortest vector" is exactly "find the maximal compression," and the
8//! symmetry being broken is the lattice's own geometry rather than a field's algebra.
9//!
10//! This is the lens that reaches the cryptographic weaknesses the field-based rungs cannot express: an
11//! RSA key that is *structured but not factorable by Fermat* — small hidden roots (Coppersmith),
12//! partial key exposure, a private exponent below Wiener's bound (Boneh–Durfee `d < N^0.292`) — leaks a
13//! short lattice vector even when no factoring shortcut exists. It is a different game than factoring:
14//! the compression game, played on the reals. [`lll_reduce`] is that lens; Coppersmith's small-roots
15//! method is built on top of it.
16//!
17//! The reduction is EXACT — Gram–Schmidt over [`Rational`], no floating point — so its output is a
18//! certified reduced basis, not a numerical approximation.
19
20use logicaffeine_base::numeric::{BigInt, Rational};
21
22fn zero() -> Rational {
23    Rational::zero()
24}
25
26fn rat(n: i64, d: i64) -> Rational {
27    Rational::new(BigInt::from_i64(n), BigInt::from_i64(d)).expect("nonzero denominator")
28}
29
30/// The inner product of two rational vectors.
31fn dot(a: &[Rational], b: &[Rational]) -> Rational {
32    a.iter().zip(b).fold(zero(), |s, (x, y)| s.add(&x.mul(y)))
33}
34
35/// Gram–Schmidt orthogonalization: the orthogonal vectors `b*ᵢ` and the coefficients
36/// `μᵢⱼ = ⟨bᵢ, b*ⱼ⟩ / ⟨b*ⱼ, b*ⱼ⟩`, all exact over the rationals.
37fn gram_schmidt(b: &[Vec<Rational>]) -> (Vec<Vec<Rational>>, Vec<Vec<Rational>>) {
38    let n = b.len();
39    let mut bstar: Vec<Vec<Rational>> = Vec::with_capacity(n);
40    let mut mu = vec![vec![zero(); n]; n];
41    for i in 0..n {
42        let mut v = b[i].clone();
43        for j in 0..i {
44            let m = dot(&b[i], &bstar[j]).div(&dot(&bstar[j], &bstar[j])).expect("independent basis");
45            for d in 0..v.len() {
46                v[d] = v[d].sub(&m.mul(&bstar[j][d]));
47            }
48            mu[i][j] = m;
49        }
50        bstar.push(v);
51    }
52    (bstar, mu)
53}
54
55/// The LLL algorithm proper, on rational vectors: returns a reduced basis of the same lattice.
56fn lll_core(mut b: Vec<Vec<Rational>>) -> Vec<Vec<Rational>> {
57    let n = b.len();
58    if n < 2 {
59        return b;
60    }
61    let delta = rat(3, 4);
62    let half = rat(1, 2);
63    let (mut bstar, mut mu) = gram_schmidt(&b);
64    let mut norm: Vec<Rational> = (0..n).map(|i| dot(&bstar[i], &bstar[i])).collect();
65
66    let mut k = 1;
67    while k < n {
68        // Size-reduce bₖ against b_{k-1}, …, b₀.
69        for j in (0..k).rev() {
70            if mu[k][j].abs() > half {
71                let q = mu[k][j].round(); // nearest integer
72                let qr = Rational::from_bigint(q);
73                for d in 0..b[k].len() {
74                    b[k][d] = b[k][d].sub(&qr.mul(&b[j][d]));
75                }
76                mu[k][j] = mu[k][j].sub(&qr);
77                for l in 0..j {
78                    mu[k][l] = mu[k][l].sub(&qr.mul(&mu[j][l]));
79                }
80            }
81        }
82        // Lovász condition: ‖b*ₖ‖² ≥ (δ − μ²ₖ,ₖ₋₁)·‖b*ₖ₋₁‖².
83        let rhs = delta.sub(&mu[k][k - 1].mul(&mu[k][k - 1])).mul(&norm[k - 1]);
84        if norm[k] >= rhs {
85            k += 1;
86        } else {
87            b.swap(k, k - 1);
88            let (bs, m) = gram_schmidt(&b);
89            bstar = bs;
90            mu = m;
91            norm = (0..n).map(|i| dot(&bstar[i], &bstar[i])).collect();
92            k = if k >= 2 { k - 1 } else { 1 };
93        }
94    }
95    b
96}
97
98/// LLL-reduce an integer lattice basis (the rows of `basis`), returning a reduced basis of the same
99/// lattice with short, nearly-orthogonal vectors. Exact arithmetic throughout (Gram–Schmidt over the
100/// rationals, size reduction with `|μ| ≤ ½`, the Lovász swap at `δ = ¾`), so the result is a certified
101/// LLL-reduced basis. The first row is a short vector — for a lattice built to encode a cryptographic
102/// weakness, that short vector is the leaked secret.
103pub fn lll_reduce(basis: &[Vec<i64>]) -> Vec<Vec<BigInt>> {
104    let b = basis.iter().map(|row| row.iter().map(|&x| Rational::from_i64(x)).collect()).collect();
105    lll_core(b).iter().map(|row| row.iter().map(|r| r.round()).collect()).collect()
106}
107
108/// The bit length of `|x|`.
109fn bit_len(x: &BigInt) -> usize {
110    let (_, bytes) = x.to_le_bytes();
111    for (i, &byte) in bytes.iter().enumerate().rev() {
112        if byte != 0 {
113            return i * 8 + (8 - byte.leading_zeros() as usize);
114        }
115    }
116    0
117}
118
119/// Floating-point Gram–Schmidt of an EXACT integer basis, uniformly down-scaled so the doubles stay
120/// finite (the scale cancels in `μ` and in the Lovász ratio, both scale-invariant). Returns `(μ, ‖b*‖²)`.
121fn gram_schmidt_f64(b: &[Vec<BigInt>]) -> (Vec<Vec<f64>>, Vec<f64>) {
122    let (n, dim) = (b.len(), b[0].len());
123    let maxbits = b.iter().flatten().map(bit_len).max().unwrap_or(0);
124    let shift = maxbits.saturating_sub(900);
125    let mut scale = BigInt::from_i64(1);
126    for _ in 0..shift {
127        scale = scale.mul(&BigInt::from_i64(2));
128    }
129    let cf: Vec<Vec<f64>> = b
130        .iter()
131        .map(|row| row.iter().map(|x| Rational::new(x.clone(), scale.clone()).map(|r| r.to_f64()).unwrap_or(0.0)).collect())
132        .collect();
133    let mut bstar = vec![vec![0f64; dim]; n];
134    let mut mu = vec![vec![0f64; n]; n];
135    for i in 0..n {
136        bstar[i].clone_from(&cf[i]);
137        for j in 0..i {
138            let dp: f64 = cf[i].iter().zip(&bstar[j]).map(|(a, c)| a * c).sum();
139            let nj: f64 = bstar[j].iter().map(|c| c * c).sum();
140            let m = if nj != 0.0 { dp / nj } else { 0.0 };
141            mu[i][j] = m;
142            for d in 0..dim {
143                bstar[i][d] -= m * bstar[j][d];
144            }
145        }
146    }
147    let norm = bstar.iter().map(|v| v.iter().map(|c| c * c).sum()).collect();
148    (mu, norm)
149}
150
151fn vdot(u: &[BigInt], v: &[BigInt]) -> BigInt {
152    u.iter().zip(v).fold(BigInt::zero(), |a, (x, y)| a.add(&x.mul(y)))
153}
154
155// ---- BigFloat: fixed-precision binary floating point for the L² reduction (fpLLL) --------------------
156//
157// The f64 Gram–Schmidt loses precision on Boneh–Durfee's huge dynamic range; the exact integer LLL is
158// correct but its sub-determinants grow to ~2^(dim·bits) and crawl. BigFloat splits the difference: a
159// `BigInt` mantissa kept to a FIXED number of significant bits (`FP_PREC`) times `2^exp`. It behaves like
160// floating point (fast, bounded size — no blow-up) but with enough bits to survive the dynamic range.
161
162const FP_PREC: u64 = 2048;
163
164fn bit_length(x: &BigInt) -> u64 {
165    let (_, bytes) = x.to_le_bytes();
166    for (i, &b) in bytes.iter().enumerate().rev() {
167        if b != 0 {
168            return i as u64 * 8 + (8 - b.leading_zeros() as u64);
169        }
170    }
171    0
172}
173
174fn two_pow(k: u64) -> BigInt {
175    BigInt::from_i64(2).pow(k as u32)
176}
177
178fn shl(x: &BigInt, k: u64) -> BigInt {
179    if k == 0 {
180        x.clone()
181    } else {
182        x.mul(&two_pow(k))
183    }
184}
185
186fn shr(x: &BigInt, k: u64) -> BigInt {
187    if k == 0 {
188        x.clone()
189    } else {
190        x.div_rem(&two_pow(k)).expect("nonzero").0
191    }
192}
193
194#[derive(Clone)]
195struct BigFloat {
196    m: BigInt,
197    e: i64,
198}
199
200impl BigFloat {
201    fn normalized(m: BigInt, e: i64) -> Self {
202        if m.is_zero() {
203            return Self { m, e: 0 };
204        }
205        let bl = bit_length(&m);
206        if bl > FP_PREC {
207            let s = bl - FP_PREC;
208            Self { m: shr(&m, s), e: e + s as i64 }
209        } else {
210            Self { m, e }
211        }
212    }
213    fn zero() -> Self {
214        Self { m: BigInt::zero(), e: 0 }
215    }
216    fn from_bigint(n: &BigInt) -> Self {
217        Self::normalized(n.clone(), 0)
218    }
219    fn from_i64(n: i64) -> Self {
220        Self::from_bigint(&BigInt::from_i64(n))
221    }
222    fn add(&self, o: &Self) -> Self {
223        if self.m.is_zero() {
224            return o.clone();
225        }
226        if o.m.is_zero() {
227            return self.clone();
228        }
229        // Align to the MINIMUM exponent (shift mantissas UP, losing nothing); if one operand is more than
230        // FP_PREC bits smaller in magnitude, it is below precision and the larger one is returned.
231        let mag_s = self.e + bit_length(&self.m) as i64;
232        let mag_o = o.e + bit_length(&o.m) as i64;
233        if mag_s > mag_o + FP_PREC as i64 + 8 {
234            return self.clone();
235        }
236        if mag_o > mag_s + FP_PREC as i64 + 8 {
237            return o.clone();
238        }
239        let e = self.e.min(o.e);
240        let ma = shl(&self.m, (self.e - e) as u64);
241        let mb = shl(&o.m, (o.e - e) as u64);
242        Self::normalized(ma.add(&mb), e)
243    }
244    fn neg(&self) -> Self {
245        Self { m: self.m.negated(), e: self.e }
246    }
247    fn sub(&self, o: &Self) -> Self {
248        self.add(&o.neg())
249    }
250    fn mul(&self, o: &Self) -> Self {
251        Self::normalized(self.m.mul(&o.m), self.e + o.e)
252    }
253    fn div(&self, o: &Self) -> Self {
254        let num = shl(&self.m, FP_PREC);
255        Self::normalized(num.div_rem(&o.m).expect("nonzero div").0, self.e - o.e - FP_PREC as i64)
256    }
257    fn abs(&self) -> Self {
258        Self { m: self.m.abs(), e: self.e }
259    }
260    fn cmp(&self, o: &Self) -> std::cmp::Ordering {
261        let d = self.sub(o).m;
262        if d.is_zero() {
263            std::cmp::Ordering::Equal
264        } else if d.is_negative() {
265            std::cmp::Ordering::Less
266        } else {
267            std::cmp::Ordering::Greater
268        }
269    }
270    /// Nearest integer (ties away from zero).
271    fn round(&self) -> BigInt {
272        if self.e >= 0 {
273            shl(&self.m, self.e as u64)
274        } else {
275            let k = (-self.e) as u64;
276            let half = two_pow(k - 1);
277            let adj = if self.m.is_negative() { self.m.sub(&half) } else { self.m.add(&half) };
278            adj.div_rem(&two_pow(k)).expect("nonzero").0
279        }
280    }
281}
282
283fn dot_bf(u: &[BigFloat], v: &[BigFloat]) -> BigFloat {
284    u.iter().zip(v).fold(BigFloat::zero(), |a, (x, y)| a.add(&x.mul(y)))
285}
286
287fn gram_schmidt_bf(b: &[Vec<BigInt>]) -> (Vec<Vec<BigFloat>>, Vec<BigFloat>) {
288    let (n, dim) = (b.len(), b[0].len());
289    let cf: Vec<Vec<BigFloat>> = b.iter().map(|r| r.iter().map(BigFloat::from_bigint).collect()).collect();
290    let mut bstar = vec![vec![BigFloat::zero(); dim]; n];
291    let mut mu = vec![vec![BigFloat::zero(); n]; n];
292    for i in 0..n {
293        bstar[i].clone_from(&cf[i]);
294        for j in 0..i {
295            let m = dot_bf(&cf[i], &bstar[j]).div(&dot_bf(&bstar[j], &bstar[j]));
296            for d in 0..dim {
297                bstar[i][d] = bstar[i][d].sub(&m.mul(&bstar[j][d]));
298            }
299            mu[i][j] = m;
300        }
301    }
302    let norm = bstar.iter().map(|v| dot_bf(v, v)).collect();
303    (mu, norm)
304}
305
306/// Incrementally update the Gram–Schmidt data `(mu, norm)` when swapping basis rows `k` and `k−1` — the
307/// standard LLL swap formulas, so a swap is `O(n)` rather than a full `O(n²·dim)` recomputation.
308fn swap_gso(k: usize, n: usize, b: &mut [Vec<BigInt>], mu: &mut [Vec<BigFloat>], norm: &mut [BigFloat]) {
309    b.swap(k, k - 1);
310    let nu = mu[k][k - 1].clone();
311    let db = norm[k].add(&nu.mul(&nu).mul(&norm[k - 1])); // new ‖b*_{k-1}‖²
312    let new_mu = nu.mul(&norm[k - 1]).div(&db);
313    let new_norm_k = norm[k - 1].mul(&norm[k]).div(&db);
314    {
315        let (lower, upper) = mu.split_at_mut(k);
316        let (row_km1, row_k) = (&mut lower[k - 1], &mut upper[0]);
317        for j in 0..(k - 1) {
318            std::mem::swap(&mut row_km1[j], &mut row_k[j]);
319        }
320    }
321    mu[k][k - 1] = new_mu.clone();
322    norm[k] = new_norm_k;
323    norm[k - 1] = db;
324    for i in (k + 1)..n {
325        let t = mu[i][k].clone();
326        mu[i][k] = mu[i][k - 1].sub(&nu.mul(&t));
327        mu[i][k - 1] = t.add(&new_mu.mul(&mu[i][k]));
328    }
329}
330
331/// The L² lattice reduction (fpLLL): LLL with a BigFloat Gram–Schmidt — fast like floating point, but
332/// with `FP_PREC` bits of precision so it survives the high dynamic range of Coppersmith/Boneh–Durfee
333/// lattices where f64 fails, and without the determinant blow-up of the exact integer LLL. The basis
334/// stays exact integer; only the steering is in BigFloat, and swaps update the Gram–Schmidt incrementally
335/// (`O(n³)` overall rather than the `O(n⁵)` of recompute-on-swap).
336pub fn lll_reduce_bigint_fp(basis: &[Vec<BigInt>]) -> Vec<Vec<BigInt>> {
337    let n = basis.len();
338    if n < 2 {
339        return basis.to_vec();
340    }
341    let dim = basis[0].len();
342    let mut b = basis.to_vec();
343    let delta = BigFloat::from_i64(3).div(&BigFloat::from_i64(4));
344    let half = BigFloat::from_i64(1).div(&BigFloat::from_i64(2));
345    let (mut mu, mut norm) = gram_schmidt_bf(&b);
346    let max_bits = b.iter().flatten().map(bit_length).max().unwrap_or(1) as usize;
347    let cap = (max_bits + 16) * n * n * 4;
348    let mut k = 1;
349    let mut guard = 0usize;
350    while k < n && guard < cap {
351        guard += 1;
352        for j in (0..k).rev() {
353            if mu[k][j].abs().cmp(&half) == std::cmp::Ordering::Greater {
354                let q = mu[k][j].round();
355                if !q.is_zero() {
356                    let qf = BigFloat::from_bigint(&q);
357                    for d in 0..dim {
358                        b[k][d] = b[k][d].sub(&q.mul(&b[j][d]));
359                    }
360                    mu[k][j] = mu[k][j].sub(&qf);
361                    for l in 0..j {
362                        mu[k][l] = mu[k][l].sub(&qf.mul(&mu[j][l]));
363                    }
364                }
365            }
366        }
367        let rhs = delta.sub(&mu[k][k - 1].mul(&mu[k][k - 1])).mul(&norm[k - 1]);
368        if norm[k].cmp(&rhs) != std::cmp::Ordering::Less {
369            k += 1;
370        } else {
371            swap_gso(k, n, &mut b, &mut mu, &mut norm);
372            k = if k >= 2 { k - 1 } else { 1 };
373        }
374    }
375    b
376}
377
378/// Nearest integer to `a/b` for `b > 0`.
379fn round_div(a: &BigInt, b: &BigInt) -> BigInt {
380    let (q, r) = a.div_rem(b).expect("nonzero");
381    if BigInt::from_i64(2).mul(&r.abs()) > *b {
382        if a.is_negative() {
383            q.sub(&BigInt::from_i64(1))
384        } else {
385            q.add(&BigInt::from_i64(1))
386        }
387    } else {
388        q
389    }
390}
391
392fn redi(k: usize, l: usize, b: &mut [Vec<BigInt>], d: &[BigInt], lam: &mut [Vec<BigInt>]) {
393    if BigInt::from_i64(2).mul(&lam[k][l].abs()) <= d[l] {
394        return;
395    }
396    let q = round_div(&lam[k][l], &d[l]);
397    let bl = b[l].clone();
398    for c in 0..bl.len() {
399        b[k][c] = b[k][c].sub(&q.mul(&bl[c]));
400    }
401    lam[k][l] = lam[k][l].sub(&q.mul(&d[l]));
402    let laml = lam[l].clone();
403    for i in 1..l {
404        lam[k][i] = lam[k][i].sub(&q.mul(&laml[i]));
405    }
406}
407
408fn swapi(k: usize, n: usize, b: &mut [Vec<BigInt>], d: &mut [BigInt], lam: &mut [Vec<BigInt>]) {
409    b.swap(k, k - 1);
410    for j in 1..=k.saturating_sub(2) {
411        let t = lam[k][j].clone();
412        lam[k][j] = lam[k - 1][j].clone();
413        lam[k - 1][j] = t;
414    }
415    let lambda = lam[k][k - 1].clone();
416    let bval = d[k - 2].mul(&d[k]).add(&lambda.mul(&lambda)).div_rem(&d[k - 1]).expect("exact").0;
417    for i in (k + 1)..=n {
418        let t = lam[i][k].clone();
419        let new_ik = d[k].mul(&lam[i][k - 1]).sub(&lambda.mul(&t)).div_rem(&d[k - 1]).expect("exact").0;
420        let new_ik1 = bval.mul(&t).add(&lambda.mul(&new_ik)).div_rem(&d[k]).expect("exact").0;
421        lam[i][k] = new_ik;
422        lam[i][k - 1] = new_ik1;
423    }
424    d[k - 1] = bval;
425}
426
427/// Exact integer LLL (Cohen, *A Course in Computational Algebraic Number Theory*, Algorithm 2.6.3): the
428/// Gram–Schmidt quantities are tracked as EXACT integers (the sub-determinants `dᵢ` and the scaled
429/// coefficients `λᵢⱼ = dⱼ·μᵢⱼ`), with Bareiss-style exact division — no floating point (so no precision
430/// loss on high-dynamic-range lattices) and no rationals (so no coefficient blow-up). This is the
431/// trustworthy reduction the Boneh–Durfee independence diagnosis needs.
432pub fn lll_reduce_bigint_exact(basis: &[Vec<BigInt>]) -> Vec<Vec<BigInt>> {
433    let n = basis.len();
434    if n < 2 {
435        return basis.to_vec();
436    }
437    let mut b: Vec<Vec<BigInt>> = vec![Vec::new()]; // 1-indexed; b[0] is a dummy
438    b.extend(basis.iter().cloned());
439    let mut d = vec![BigInt::zero(); n + 2];
440    d[0] = BigInt::from_i64(1);
441    let mut lam = vec![vec![BigInt::zero(); n + 2]; n + 2];
442
443    d[1] = vdot(&b[1], &b[1]);
444    let mut k = 2;
445    let mut kmax = 1;
446    while k <= n {
447        if k > kmax {
448            kmax = k;
449            for j in 1..=k {
450                let mut u = vdot(&b[k], &b[j]);
451                for i in 1..j {
452                    u = d[i].mul(&u).sub(&lam[k][i].mul(&lam[j][i])).div_rem(&d[i - 1]).expect("exact").0;
453                }
454                if j < k {
455                    lam[k][j] = u;
456                } else {
457                    d[k] = u;
458                }
459            }
460        }
461        redi(k, k - 1, &mut b, &d, &mut lam);
462        let lhs = BigInt::from_i64(4).mul(&d[k]).mul(&d[k - 2]);
463        let rhs = BigInt::from_i64(3)
464            .mul(&d[k - 1].mul(&d[k - 1]))
465            .sub(&BigInt::from_i64(4).mul(&lam[k][k - 1].mul(&lam[k][k - 1])));
466        if lhs < rhs {
467            swapi(k, n, &mut b, &mut d, &mut lam);
468            k = if k - 1 >= 2 { k - 1 } else { 2 };
469        } else {
470            for l in (1..=k.saturating_sub(2)).rev() {
471                redi(k, l, &mut b, &d, &mut lam);
472            }
473            k += 1;
474        }
475    }
476    b.into_iter().skip(1).collect()
477}
478
479/// LLL-reduce a `BigInt` lattice basis — the entry point for lattices with huge entries, such as the
480/// `N^m`-scaled rows of Coppersmith's method. The symmetry that makes this fast: only the BASIS need be
481/// exact; the Gram–Schmidt that steers the size-reductions and Lovász swaps runs in floating point, so
482/// there is no rational coefficient blow-up. The reduced basis is still exact integer, and correctness is
483/// re-checked downstream (a Coppersmith root either divides `N` or it does not).
484pub fn lll_reduce_bigint(basis: &[Vec<BigInt>]) -> Vec<Vec<BigInt>> {
485    let n = basis.len();
486    if n < 2 {
487        return basis.to_vec();
488    }
489    let dim = basis[0].len();
490    let mut b = basis.to_vec();
491    let (mut mu, mut norm) = gram_schmidt_f64(&b);
492    let mut k = 1;
493    let mut guard = 0usize;
494    let cap = 2000 * n * n; // precision backstop against float-induced non-termination
495    while k < n && guard < cap {
496        guard += 1;
497        for j in (0..k).rev() {
498            if mu[k][j].abs() > 0.5 {
499                let q = mu[k][j].round();
500                if q != 0.0 {
501                    let qb = BigInt::parse_decimal(&format!("{q:.0}")).unwrap_or_else(BigInt::zero);
502                    for d in 0..dim {
503                        b[k][d] = b[k][d].sub(&qb.mul(&b[j][d]));
504                    }
505                    mu[k][j] -= q;
506                    for l in 0..j {
507                        mu[k][l] -= q * mu[j][l];
508                    }
509                }
510            }
511        }
512        if norm[k] >= (0.75 - mu[k][k - 1] * mu[k][k - 1]) * norm[k - 1] {
513            k += 1;
514        } else {
515            b.swap(k, k - 1);
516            let (m, nn) = gram_schmidt_f64(&b);
517            mu = m;
518            norm = nn;
519            k = if k >= 2 { k - 1 } else { 1 };
520        }
521    }
522    b
523}
524
525#[cfg(test)]
526mod tests {
527    use super::*;
528
529    fn as_i64(v: &[Vec<BigInt>]) -> Vec<Vec<i64>> {
530        v.iter().map(|row| row.iter().map(|x| x.to_i64().expect("small entry")).collect()).collect()
531    }
532
533    fn norm_sq(row: &[i64]) -> i64 {
534        row.iter().map(|&x| x * x).sum()
535    }
536
537    #[test]
538    fn lll_reduces_a_skewed_basis_to_the_standard_one() {
539        // The lattice ℤ² given by a badly skewed basis: [10,1] is long, but 10·[1,0] shears it to [0,1].
540        let reduced = as_i64(&lll_reduce(&[vec![1, 0], vec![10, 1]]));
541        assert_eq!(reduced, vec![vec![1, 0], vec![0, 1]], "LLL recovers the standard basis");
542    }
543
544    #[test]
545    fn lll_finds_the_shortest_vector_of_a_flat_lattice() {
546        // det = 15·17 − 23·11 = 2, so this lattice is very flat and its shortest vector is tiny ([1,1],
547        // norm² = 2); both input rows have norm² in the hundreds. LLL surfaces it.
548        let reduced = as_i64(&lll_reduce(&[vec![15, 23], vec![11, 17]]));
549        let shortest = reduced.iter().map(|r| norm_sq(r)).min().unwrap();
550        assert_eq!(shortest, 2, "LLL finds the shortest vector, norm² = 2");
551        assert!(reduced.iter().any(|r| r == &[1, 1] || r == &[-1, -1]), "and it is ±[1,1]");
552    }
553
554    #[test]
555    fn exact_integer_lll_matches_the_known_reductions() {
556        let big = |rows: &[&[i64]]| -> Vec<Vec<BigInt>> {
557            rows.iter().map(|r| r.iter().map(|&x| BigInt::from_i64(x)).collect()).collect()
558        };
559        let out = as_i64(&lll_reduce_bigint_exact(&big(&[&[1, 0], &[10, 1]])));
560        assert_eq!(out, vec![vec![1, 0], vec![0, 1]], "exact LLL recovers the standard basis");
561
562        let out = as_i64(&lll_reduce_bigint_exact(&big(&[&[15, 23], &[11, 17]])));
563        let shortest = out.iter().map(|r| norm_sq(r)).min().unwrap();
564        assert_eq!(shortest, 2, "exact LLL finds the shortest vector, norm² = 2");
565    }
566
567    #[test]
568    fn l2_fplll_matches_the_known_reductions() {
569        let big = |rows: &[&[i64]]| -> Vec<Vec<BigInt>> {
570            rows.iter().map(|r| r.iter().map(|&x| BigInt::from_i64(x)).collect()).collect()
571        };
572        let out = as_i64(&lll_reduce_bigint_fp(&big(&[&[1, 0], &[10, 1]])));
573        assert_eq!(out, vec![vec![1, 0], vec![0, 1]], "L² recovers the standard basis");
574
575        let out = as_i64(&lll_reduce_bigint_fp(&big(&[&[15, 23], &[11, 17]])));
576        let shortest = out.iter().map(|r| norm_sq(r)).min().unwrap();
577        assert_eq!(shortest, 2, "L² finds the shortest vector, norm² = 2");
578    }
579
580    #[test]
581    fn lll_output_is_size_reduced() {
582        // A 3-D lattice: after reduction every off-diagonal Gram–Schmidt coefficient satisfies |μ| ≤ ½.
583        let reduced = lll_reduce(&[vec![1, 2, 3], vec![4, 5, 6], vec![7, 8, 10]]);
584        let b: Vec<Vec<Rational>> =
585            reduced.iter().map(|row| row.iter().map(|x| Rational::from_bigint(x.clone())).collect()).collect();
586        let (_, mu) = gram_schmidt(&b);
587        for i in 0..b.len() {
588            for j in 0..i {
589                assert!(mu[i][j].abs() <= rat(1, 2), "size-reduced: |μ[{i}][{j}]| ≤ ½");
590            }
591        }
592    }
593}