Skip to main content

ocas_poly/factor/
hensel.rs

1//! Hensel lifting and Zassenhaus factor combination over $\mathbb{Z}$.
2//!
3//! This module lifts factorizations found modulo a prime back to the integers.
4//! The finite-field factorization in [`super::finite_field`] is the foundation
5//! it builds upon.
6//!
7//! Pipeline:
8//! 1. Pick a prime $p$ not dividing the leading coefficient, with $f \bmod p$
9//!    square-free.
10//! 2. Factor $f \bmod p$ into monic irreducibles.
11//! 3. Compute the Mignotte bound $B$; lift $p \to p^k$ with $p^k > 2B$ via
12//!    linear Hensel lifting.
13//! 4. Recombine the lifted factors by trial division (Zassenhaus).
14//!
15//! References: Zassenhaus (1969); Geddes, Czapor, Labahn, *Algorithms for
16//! Computer Algebra*; Knuth, TAOCP vol. 2 §4.6.2.
17
18use ocas_domain::{Domain, FiniteField, Integer, IntegerDomain, number_theory};
19
20use crate::dense::DenseUnivariatePolynomial;
21use crate::factor::finite_field::{self, FpPoly};
22
23/// Univariate polynomial over the integers.
24pub type ZPoly = DenseUnivariatePolynomial<IntegerDomain>;
25
26/// Convert a $\mathbb{Z}[x]$ polynomial to $\mathbb{F}_p[x]$ by reducing each
27/// coefficient modulo $p$.
28fn to_finite_field(f: &ZPoly, p: &Integer) -> FpPoly {
29    let field = FiniteField::new(p.to_bigint());
30    let coeffs = f
31        .coeffs()
32        .iter()
33        .map(|c| field.element(c.to_bigint()))
34        .collect();
35    FpPoly::from_coeffs(field, coeffs)
36}
37
38/// Convert an $\mathbb{F}_p[x]$ polynomial back to $\mathbb{Z}[x]$ using the
39/// symmetric representative of each coefficient (range $(-p/2, p/2]$).
40fn from_finite_field_symmetric(g: &FpPoly) -> ZPoly {
41    let domain = IntegerDomain;
42    let p = g.domain().prime();
43    let p_int = Integer::from(p.clone());
44    let coeffs = g
45        .coeffs()
46        .iter()
47        .map(|c| {
48            let c_int = Integer::from(c.value().clone());
49            number_theory::symmetric_mod(&c_int, &p_int)
50        })
51        .collect();
52    ZPoly::from_coeffs(domain, coeffs)
53}
54
55/// Normalize an $\mathbb{F}_p[x]$ polynomial to monic form.
56fn monic_fp(f: &FpPoly) -> FpPoly {
57    if f.is_zero() {
58        return f.zero();
59    }
60    let lc = f.leading_coeff().unwrap();
61    if f.domain().is_one(lc) {
62        return f.clone();
63    }
64    let inv = f.domain().inv(lc).expect("nonzero lc");
65    f.mul_scalar(&inv)
66}
67
68/// Landau–Mignotte bound: an upper bound on the absolute value of any
69/// coefficient of a factor of $f$ in $\mathbb{Z}[x]$.
70///
71/// For a degree-$n$ polynomial $f$ with coefficient 2-norm $\|f\|_2$, every
72/// factor $g$ satisfies $\|g\|_\infty \le 2^n \|f\|_2$.
73pub(crate) fn mignotte_bound(f: &ZPoly) -> Integer {
74    let n = f.degree().unwrap_or(0);
75    let mut sum_sq = Integer::from(0);
76    for c in f.coeffs() {
77        let v = c.abs();
78        sum_sq += &(&v * &v);
79    }
80    let norm = sum_sq.sqrt() + &Integer::from(1);
81    &Integer::from(2).pow_u32(n as u32) * &norm
82}
83
84/// Bézout coefficients over $\mathbb{F}_p$ for coprime `g`, `h`: returns
85/// `(s, t)` with $s g + t h \equiv 1 \pmod p$.
86///
87/// Over a field the GCD is only defined up to a unit, so the raw extended
88/// Euclid yields `s·g + t·h = c` for some nonzero constant `c`; we rescale by
89/// `1/c` to obtain the normalized identity.
90fn bezout_mod_p(g: &FpPoly, h: &FpPoly) -> (FpPoly, FpPoly) {
91    let field = g.domain().clone();
92    let one = FpPoly::from_coeffs(field.clone(), vec![field.one()]);
93    let zero = FpPoly::from_coeffs(field.clone(), vec![field.zero()]);
94    let (mut old_r, mut r) = (g.clone(), h.clone());
95    let (mut old_s, mut s) = (one.clone(), zero.clone());
96    let (mut old_t, mut t) = (zero, one);
97    while !r.is_zero() {
98        let (q, rem) = old_r.div_rem(&r).unwrap_or_else(|| (r.zero(), r.zero()));
99        old_r = r;
100        r = rem;
101        let new_s = old_s.sub(&q.mul(&s));
102        let new_t = old_t.sub(&q.mul(&t));
103        old_s = s;
104        s = new_s;
105        old_t = t;
106        t = new_t;
107    }
108    // Normalize so that s·g + t·h = 1 (old_r may be any nonzero constant c).
109    if let Some(c) = old_r.leading_coeff()
110        && !field.is_one(c)
111    {
112        let c_inv = field.inv(c).expect("gcd constant is nonzero");
113        old_s = old_s.mul_scalar(&c_inv);
114        old_t = old_t.mul_scalar(&c_inv);
115    }
116    (old_s, old_t)
117}
118
119/// Lift a two-factor factorization $f \equiv g \cdot h \pmod p$ (with
120/// $\gcd(g, h) = 1$ over $\mathbb{F}_p$) towards $f = g \cdot h$ over
121/// $\mathbb{Z}$, for monic $f$ with monic true factors.
122///
123/// Lifts modulo $p^k$ until $p^k > \text{bound}$, then returns the lifted
124/// pair. Returns `None` if the lift is inconsistent.
125fn hensel_lift_pair(
126    f: &ZPoly,
127    g0: &FpPoly,
128    h0: &FpPoly,
129    p: &Integer,
130    bound: &Integer,
131) -> Option<(ZPoly, ZPoly)> {
132    // Bézout coefficient t for h0 in 1 = s·g0 + t·h0 (mod p); only t is used.
133    let (s, t) = bezout_mod_p(g0, h0);
134    debug_assert!(
135        {
136            let one = g0.one();
137            let id = s.mul(g0).add(&t.mul(h0));
138            id.sub(&one).is_zero()
139        },
140        "Bézout identity s·g0 + t·h0 = 1 failed"
141    );
142    let mut g = from_finite_field_symmetric(g0);
143    let mut h = from_finite_field_symmetric(h0);
144    let mut m = p.clone();
145
146    loop {
147        let gh = g.mul(&h);
148        let e = f.sub(&gh);
149        if e.is_zero() {
150            return Some((g, h));
151        }
152        // e must be divisible by m coefficientwise.
153        let mut e_over_m = Vec::new();
154        for c in e.coeffs() {
155            let (q, r) = c.div_rem(&m);
156            if r.is_zero() {
157                e_over_m.push(q);
158            } else {
159                return None;
160            }
161        }
162        let e_over_m = ZPoly::from_coeffs(IntegerDomain, e_over_m);
163        if e_over_m.is_zero() {
164            return Some((g, h));
165        }
166        let e_bar = to_finite_field(&e_over_m, p);
167        // Δg = (t·ē) mod g0  (degree < deg g0).
168        let (_q1, dg) = t.mul(&e_bar).div_rem(g0)?;
169        // Δh = (ē − Δg·h0) / g0  (exact over the field, degree < deg h0).
170        // This direct division avoids the large-intermediate cancellation
171        // that the q·h0 + s·ē form suffers when deg(h0) ≫ deg(g0).
172        let dividend = e_bar.sub(&dg.mul(h0));
173        let (dh, dh_rem) = dividend.div_rem(g0)?;
174        debug_assert!(
175            dh_rem.is_zero(),
176            "Δh division not exact; Bézout identity may be broken"
177        );
178        let dg_z = from_finite_field_symmetric(&dg);
179        let dh_z = from_finite_field_symmetric(&dh);
180        let m_int = m.clone();
181        g = g.add(&dg_z.mul_scalar(&m_int));
182        h = h.add(&dh_z.mul_scalar(&m_int));
183        m *= p;
184        if &m > bound {
185            return Some((g, h));
186        }
187    }
188}
189
190/// Lift many monic mod-$p$ factors back to $\mathbb{Z}$ by peeling off one
191/// factor at a time (each step is a pairwise Hensel lift of `g0` against the
192/// product of the remaining factors). `bound` is a power of $p$ exceeding
193/// $2\,\mathrm{Mignotte}(f)$; the returned factors are reduced into the
194/// symmetric range of `bound` for subsequent Zassenhaus recombination.
195fn hensel_lift_multi(
196    f: &ZPoly,
197    factors: &[FpPoly],
198    p: &Integer,
199    bound: &Integer,
200) -> Option<Vec<ZPoly>> {
201    if factors.len() <= 1 {
202        return Some(vec![f.clone()]);
203    }
204    let mut lifted: Vec<ZPoly> = Vec::new();
205    let mut work = factors.to_vec();
206    let mut f_current = f.clone();
207    while work.len() > 1 {
208        let g0 = monic_fp(&work[0].clone());
209        let h0 = monic_fp(&work[1..].iter().cloned().reduce(|a, b| a.mul(&b)).unwrap());
210        let (g, h) = hensel_lift_pair(&f_current, &g0, &h0, p, bound)?;
211        // f_current must be kept mod-p faithful, so reduce only the emitted
212        // factor g; carry h forward unreduced.
213        lifted.push(reduce_symmetric(&g, bound));
214        f_current = h;
215        work = work[1..].to_vec();
216    }
217    lifted.push(reduce_symmetric(&f_current, bound));
218    Some(lifted)
219}
220
221/// Reduce each coefficient of a $\mathbb{Z}[x]$ polynomial into the symmetric
222/// range $(-M/2, M/2]$ modulo $M$. Used after Hensel lifting to recover the
223/// true integer factors from their modular images.
224fn reduce_symmetric(f: &ZPoly, modulus: &Integer) -> ZPoly {
225    let coeffs = f
226        .coeffs()
227        .iter()
228        .map(|c| number_theory::symmetric_mod(c, modulus))
229        .collect();
230    ZPoly::from_coeffs(IntegerDomain, coeffs)
231}
232
233/// Generate all index combinations of `k` elements from `0..n`.
234fn combinations(n: usize, k: usize) -> Vec<Vec<usize>> {
235    let mut out = Vec::new();
236    let mut cur = Vec::new();
237    combos(0, n, k, &mut cur, &mut out);
238    out
239}
240
241fn combos(start: usize, n: usize, k: usize, cur: &mut Vec<usize>, out: &mut Vec<Vec<usize>>) {
242    if cur.len() == k {
243        out.push(cur.clone());
244        return;
245    }
246    for i in start..n {
247        cur.push(i);
248        combos(i + 1, n, k, cur, out);
249        cur.pop();
250    }
251}
252
253/// Zassenhaus factor combination: enumerate subsets of the lifted factors in
254/// order of increasing size and accept the first that divides $f$ in
255/// $\mathbb{Z}[x]$. Each candidate's coefficients are reduced into the symmetric
256/// range of the lifting modulus before trial-division, since a true
257/// $\mathbb{Z}$-factor $h$ (with $\|h\|_\infty < B/2$) equals the subset
258/// product modulo $B$. If `f` is not monic, an integer divisor of the leading
259/// coefficient is attached to the candidate before testing. Returns the monic
260/// irreducible factors.
261fn zassenhaus_combine(f: &ZPoly, lifted: &[ZPoly], modulus: &Integer) -> Vec<ZPoly> {
262    if lifted.is_empty() {
263        return Vec::new();
264    }
265    let one = f.one();
266    let lc_f = f
267        .leading_coeff()
268        .cloned()
269        .unwrap_or_else(|| Integer::from(1));
270    let lc_f_abs = lc_f.abs();
271    let mut remaining: Vec<ZPoly> = lifted.to_vec();
272    let mut result = Vec::new();
273    let mut size = 1usize;
274    while size <= remaining.len() && !remaining.is_empty() {
275        let n = remaining.len();
276        let mut found = false;
277        for combo in combinations(n, size) {
278            let mut prod = one.clone();
279            for &idx in &combo {
280                prod = prod.mul(&remaining[idx]);
281            }
282            // Reduce into the symmetric range so that the candidate equals the
283            // true integer factor (whose coefficients fit in (-B/2, B/2]).
284            let candidate = reduce_symmetric(&prod, modulus);
285            // Try attaching each divisor of the leading coefficient in case the
286            // true factor is not monic.
287            for d in divisors(&lc_f_abs) {
288                let scaled = candidate.mul_scalar(&d);
289                if let Some((_, r)) = f.div_rem(&scaled)
290                    && r.is_zero()
291                    && !scaled.is_one()
292                {
293                    result.push(scaled);
294                    let mut nr = Vec::new();
295                    for (i, fac) in remaining.iter().enumerate() {
296                        if !combo.contains(&i) {
297                            nr.push(fac.clone());
298                        }
299                    }
300                    remaining = nr;
301                    found = true;
302                    size = 1;
303                    break;
304                }
305            }
306            if found {
307                break;
308            }
309        }
310        if !found {
311            size += 1;
312        }
313    }
314    for fac in remaining {
315        if !fac.is_one() {
316            result.push(fac);
317        }
318    }
319    result
320}
321
322/// All positive divisors of a positive integer in ascending order.
323fn divisors(n: &Integer) -> Vec<Integer> {
324    if n <= &Integer::from(0) {
325        return Vec::new();
326    }
327    let mut divs = vec![Integer::from(1)];
328    let mut remaining = n.clone();
329    let mut p = Integer::from(2);
330    while &p * &p <= remaining {
331        let mut count = 0u32;
332        while (&remaining % &p).is_zero() {
333            remaining /= &p;
334            count += 1;
335        }
336        if count > 0 {
337            let current = divs.clone();
338            for d in current {
339                for e in 1..=count {
340                    let factor = &d * &p.pow_u32(e);
341                    divs.push(factor);
342                }
343            }
344        }
345        p += &Integer::from(1);
346    }
347    if remaining > Integer::from(1) {
348        let current = divs.clone();
349        for d in current {
350            divs.push(&d * &remaining);
351        }
352    }
353    divs.sort();
354    divs
355}
356
357/// Factor a monic square-free polynomial over $\mathbb{Z}$ into monic
358/// irreducible factors.
359pub fn factor_square_free_monic(f: &ZPoly) -> Vec<ZPoly> {
360    if f.degree().unwrap_or(0) == 0 {
361        return Vec::new();
362    }
363    if f.degree() == Some(1) {
364        return vec![f.clone()];
365    }
366    let bound = mignotte_bound(f);
367    let two_bound = &Integer::from(2) * &bound;
368    let lc = f.leading_coeff().unwrap().abs();
369
370    let mut prime_count = 0usize;
371    for p in number_theory::primes_from(&Integer::from(2)) {
372        prime_count += 1;
373        if prime_count > 30 {
374            break;
375        }
376        if (&lc % &p).is_zero() {
377            continue;
378        }
379        let fp = to_finite_field(f, &p);
380        let fpp = fp.derivative();
381        if fpp.is_zero() || fp.gcd(&fpp).degree().unwrap_or(0) > 0 {
382            continue; // not square-free mod p
383        }
384        let factors_p = finite_field::cantor_zassenhaus(&monic_fp(&fp));
385        if factors_p.len() <= 1 {
386            return vec![f.clone()]; // irreducible over Z
387        }
388        let mut lift_mod = p.clone();
389        while lift_mod <= two_bound {
390            lift_mod *= &p;
391        }
392        if let Some(lifted) = hensel_lift_multi(f, &factors_p, &p, &lift_mod) {
393            let irreducibles = zassenhaus_combine(f, &lifted, &lift_mod);
394            if !irreducibles.is_empty() {
395                return irreducibles;
396            }
397        }
398    }
399    vec![f.clone()]
400}
401
402/// Factor a primitive polynomial over $\mathbb{Z}$ into irreducible factors
403/// with multiplicities, using square-free factorization (characteristic 0,
404/// so the generic Yun algorithm applies) followed by
405/// [`factor_square_free_monic`] on each square-free component.
406pub fn factor_primitive(f: &ZPoly) -> Vec<(ZPoly, usize)> {
407    if f.degree().unwrap_or(0) == 0 {
408        return Vec::new();
409    }
410    // Square-free factorization over Z (generic Yun works in characteristic 0).
411    let sqfree = f.square_free_factorization();
412    let mut result = Vec::new();
413    for (g, mult) in sqfree {
414        // Normalize sign so the factor is monic-ish: if leading coeff is
415        // negative, negate the polynomial (absorbed into the content/sign).
416        let lc = g.leading_coeff().cloned().unwrap();
417        let sign = if lc.is_negative() {
418            Integer::from(-1i64)
419        } else {
420            Integer::from(1i64)
421        };
422        let g_pos = g.mul_scalar(&sign);
423        for irr in factor_square_free_monic(&g_pos.primitive_part()) {
424            result.push((irr, mult));
425        }
426    }
427    result
428}
429
430#[cfg(test)]
431mod tests {
432    use super::*;
433
434    fn zpoly(coeffs: &[i64]) -> ZPoly {
435        ZPoly::from_coeffs(
436            IntegerDomain,
437            coeffs.iter().map(|&c| Integer::from(c)).collect(),
438        )
439    }
440
441    fn reconstruct(f: &ZPoly, factors: &[ZPoly]) -> bool {
442        let mut acc = f.one();
443        for g in factors {
444            acc = acc.mul(g);
445        }
446        let (q, r) = f.div_rem(&acc).unwrap_or((f.zero(), f.clone()));
447        r.is_zero() && q.degree() == Some(0)
448    }
449
450    #[test]
451    fn factor_x100_minus_1_over_z() {
452        let mut coeffs = vec![Integer::from(-1i64)];
453        coeffs.resize(101, Integer::from(0));
454        coeffs[100] = Integer::from(1);
455        let f = ZPoly::from_coeffs(IntegerDomain, coeffs);
456        let factors = factor_square_free_monic(&f);
457        assert!(reconstruct(&f, &factors));
458        // 9 cyclotomic factors for d | 100.
459        assert_eq!(factors.len(), 9);
460    }
461
462    #[test]
463    fn factor_quadratic_split() {
464        let f = zpoly(&[6, 5, 1]); // (x+2)(x+3)
465        let factors = factor_square_free_monic(&f);
466        assert!(reconstruct(&f, &factors));
467        assert_eq!(factors.len(), 2);
468    }
469
470    #[test]
471    fn factor_irreducible_quadratic() {
472        let f = zpoly(&[1, 0, 1]); // x^2+1
473        let factors = factor_square_free_monic(&f);
474        assert!(reconstruct(&f, &factors));
475        assert_eq!(factors.len(), 1);
476    }
477
478    #[test]
479    fn factor_three_linear() {
480        let f = zpoly(&[-6, 11, -6, 1]); // (x-1)(x-2)(x-3)
481        let factors = factor_square_free_monic(&f);
482        assert!(reconstruct(&f, &factors));
483        assert_eq!(factors.len(), 3);
484    }
485
486    #[test]
487    fn factor_three_mixed() {
488        // (x^2+1)(x^2+x+1)(x+1)
489        let a = zpoly(&[1, 0, 1]);
490        let b = zpoly(&[1, 1, 1]);
491        let c = zpoly(&[1, 1]);
492        let f = a.mul(&b).mul(&c);
493        let factors = factor_square_free_monic(&f);
494        assert!(reconstruct(&f, &factors));
495        assert_eq!(factors.len(), 3);
496    }
497
498    #[test]
499    fn mignotte_bound_sanity() {
500        let f = zpoly(&[1, 0, 1]); // x^2 + 1, ||f||_2 = sqrt(2)
501        let b = mignotte_bound(&f);
502        // True bound = 2^2 * sqrt(2) ≈ 5.66; conservative integer version
503        // rounds up and is therefore >= 6.
504        assert!(
505            b >= Integer::from(6) && b <= Integer::from(10),
506            "mignotte(x^2+1) = {b}, expected ~6-10"
507        );
508    }
509
510    #[test]
511    fn factor_cyclotomic_matches_sympy_over_z() {
512        // Ground-truth degree histograms from SymPy 1.14
513        // `Poly(x^n-1, x, domain='ZZ').factor_list()` for the cyclotomic
514        // decomposition of x^n - 1 over Z.
515        let cases: &[(usize, &[(usize, usize)])] = &[
516            (12, &[(1, 2), (2, 3), (4, 1)]),
517            (30, &[(1, 2), (2, 2), (4, 2), (8, 2)]),
518            (60, &[(1, 2), (2, 3), (4, 3), (8, 3), (16, 1)]),
519            (100, &[(1, 2), (2, 1), (4, 2), (8, 1), (20, 2), (40, 1)]),
520        ];
521        for &(n, expected) in cases {
522            let mut coeffs = vec![Integer::from(-1i64)];
523            coeffs.resize(n + 1, Integer::from(0));
524            coeffs[n] = Integer::from(1);
525            let f = ZPoly::from_coeffs(IntegerDomain, coeffs);
526            let factors = factor_square_free_monic(&f);
527            assert!(
528                reconstruct(&f, &factors),
529                "x^{n}-1: factors do not reconstruct"
530            );
531            let mut obs: std::collections::BTreeMap<usize, usize> = Default::default();
532            for g in &factors {
533                *obs.entry(g.degree().unwrap()).or_insert(0) += 1;
534            }
535            let observed: Vec<(usize, usize)> = obs.into_iter().collect();
536            assert_eq!(
537                observed.as_slice(),
538                expected,
539                "x^{n}-1 over Z: degree histogram mismatch"
540            );
541        }
542    }
543
544    #[test]
545    fn factor_x30_minus_1_over_z() {
546        // x^30 - 1 has 8 cyclotomic factors (d | 30: 1,2,3,5,6,10,15,30).
547        let mut coeffs = vec![Integer::from(-1i64)];
548        coeffs.resize(31, Integer::from(0));
549        coeffs[30] = Integer::from(1);
550        let f = ZPoly::from_coeffs(IntegerDomain, coeffs);
551        let factors = factor_square_free_monic(&f);
552        assert!(reconstruct(&f, &factors));
553        assert_eq!(factors.len(), 8, "expected 8 cyclotomic factors");
554    }
555}
556
557#[cfg(test)]
558mod proptests {
559    use super::*;
560    use proptest::prelude::*;
561
562    fn any_int_poly(max_degree: usize) -> impl Strategy<Value = ZPoly> {
563        (0..=max_degree)
564            .prop_flat_map(move |deg| {
565                let n = deg + 1;
566                prop::collection::vec(i64_range(), n)
567            })
568            .prop_map(|coeffs| {
569                let c: Vec<Integer> = coeffs.into_iter().map(Integer::from).collect();
570                ZPoly::from_coeffs(IntegerDomain, c)
571            })
572    }
573
574    fn i64_range() -> impl Strategy<Value = i64> {
575        -100i64..=100i64
576    }
577
578    proptest! {
579        #![proptest_config(ProptestConfig::with_cases(500))]
580
581        #[test]
582        fn factor_then_multiply_roundtrip(p in any_int_poly(6)) {
583            // The input may have content > 1; we factor the primitive part.
584            let f = p.primitive_part();
585            if f.degree().unwrap_or(0) == 0 {
586                return Ok(());
587            }
588            let factors = factor_primitive(&f);
589            let mut acc = f.one();
590            for (g, m) in &factors {
591                for _ in 0..*m {
592                    acc = acc.mul(g);
593                }
594            }
595            // acc and f should be equal up to a constant factor.
596            if let Some((q, r)) = f.div_rem(&acc) {
597                prop_assert!(r.is_zero());
598                prop_assert_eq!(q.degree(), Some(0));
599            }
600        }
601    }
602}