Building a CAS in OCaml Part 3
2026-04-20 · 31 min
Part 2 ended with an expression tree that simplifies but does not compute: no expand, no idea that x^4 - 1 and x^3 - 1 share a factor. Everything a CAS spends its time on happens in a different representation. Sparse multivariate polynomials, exact and pseudo division, resultants, square-free decomposition, and the GCD done twice.
Two representations
The expression tree holds anything and computes nothing. A polynomial holds almost nothing and computes everything. The whole part is about moving between them.
expression tree polynomial----------------------- -----------------------------------sin(x) + 1/x + (a+b)^7 a sorted list of (exponents, coefficient)arbitrary nesting no nesting at allsimplify is a rewrite add is a merge, mul is a double loopequality needs a canon- equality is list equalityical formexpand, gcd, resultant, coeff, degree, diffall live on the right and are exported on the left
Sparse, because a polynomial in six variables of degree ten has a few dozen terms and a dense array would have a few million slots. Distributed, because the recursive alternative, poly in x whose coefficients are polys in y, makes monomial orders impossible to state.
Monomials, and three orders
A monomial is an exponent vector indexed against the polynomial's own variable array. Everything about ordering is a comparison on those vectors.
type monom = int arraytype term = monom * Rational.ttype t = {vars : string array; (* sorted, no duplicates *)terms : term list; (* descending lex, no zero coefficients *)}
The three standard orders. Lex is what the terms are stored in, because the head of a lex-sorted list is the leading term in the main variable, which is what every division and GCD loop below wants. The graded orders are here because a Groebner basis computation is far cheaper in grevlex, and that computation is coming.
type order = Lex | Grlex | Grevlexlet total_deg (m : monom) : int = Array.fold_left ( + ) 0 mlet cmp_lex (a : monom) (b : monom) : int =let n = Array.length a inlet rec go i = if i >= n then 0 else if a.(i) <> b.(i) then compare a.(i) b.(i) else go (i + 1) ingo 0let cmp_grlex (a : monom) (b : monom) : int =let c = compare (total_deg a) (total_deg b) inif c <> 0 then c else cmp_lex a b(* Graded reverse lex: equal degree is broken by the LAST variable inwhich the exponents differ, with the smaller exponent winning. It isthe order Groebner basis computations are fastest in, which is whyit is here before anything needs it. *)let cmp_grevlex (a : monom) (b : monom) : int =let c = compare (total_deg a) (total_deg b) inif c <> 0 then celse beginlet n = Array.length a inlet rec go i =if i < 0 then 0 else if a.(i) <> b.(i) then compare b.(i) a.(i) else go (i - 1)ingo (n - 1)endlet cmp_order (o : order) : monom -> monom -> int =match o with Lex -> cmp_lex | Grlex -> cmp_grlex | Grevlex -> cmp_grevlex
Multiplication adds exponents. Division subtracts them and fails if any goes negative, and that single test is the entirety of multivariate divisibility.
let mono_mul (a : monom) (b : monom) : monom = Array.init (Array.length a) (fun i -> a.(i) + b.(i))(* Some (a / b) when b divides a, that is when no exponent goesnegative. This single test is the whole of multivariate division. *)let mono_div (a : monom) (b : monom) : monom option =let n = Array.length a inlet r = Array.make n 0 inlet ok = ref true infor i = 0 to n - 1 dolet d = a.(i) - b.(i) inif d < 0 then ok := false else r.(i) <- ddone;if !ok then Some r else None
Where the graded orders disagree, from the tests. Same total degree, opposite verdicts, which is the whole reason there is more than one.
(* The textbook case that separates grlex from grevlex: same degree,and they disagree. *)let a = [| 1; 2; 0 |] and b = [| 0; 0; 3 |] incheck "grlex prefers the earlier variable" (cmp_grlex a b > 0);check "grevlex prefers the later variable being absent" (cmp_grevlex a b > 0);let a = [| 1; 1; 1 |] and b = [| 2; 0; 1 |] incheck "grlex: x^2*z beats x*y*z" (cmp_grlex a b < 0);check "grevlex: x^2*z beats x*y*z too" (cmp_grevlex a b < 0)
Construction and alignment
Building a polynomial normalizes it: sort descending, merge equal monomials, drop the zeros. After that the representation is canonical, so equality is structural.
(* Sort descending, add up equal monomials, and drop the zeros. *)let make (vars : string array) (terms : term list) : t =let sorted = List.sort (fun (m1, _) (m2, _) -> cmp_lex m2 m1) terms inlet rec collect = function| (m1, c1) :: (m2, c2) :: rest when cmp_lex m1 m2 = 0 ->collect ((m1, Rational.add c1 c2) :: rest)| x :: rest -> x :: collect rest| [] -> []in{ vars; terms = List.filter (fun (_, c) -> not (Rational.is_zero c)) (collect sorted) }let zero : t = { vars = [||]; terms = [] }let of_rational (r : Rational.t) : t =if Rational.is_zero r then zero else { vars = [||]; terms = [ ([||], r) ] }let of_int (n : int) : t = of_rational (Rational.of_int n)let one : t = of_int 1let var (name : string) : t = { vars = [| name |]; terms = [ ([| 1 |], Rational.one) ] }
Two polynomials in different variables have exponent vectors of different lengths and different meanings, so every binary operation aligns them first. The index map is built once per call and then applied to every monomial.
(* Re-express p over a superset of its own variables. The index map iscomputed once and then applied to every monomial. *)let remap (p : t) (vars : string array) : t =if p.vars = vars then pelse beginlet n = Array.length vars inlet idx =Array.map(fun v ->let rec find i = if i >= n then -1 else if vars.(i) = v then i else find (i + 1) infind 0)p.varsinArray.iter (fun i -> if i < 0 then invalid_arg "Poly.remap: missing variable") idx;let terms =List.map(fun (m, c) ->let m' = Array.make n 0 inArray.iteri (fun j e -> m'.(idx.(j)) <- e) m;(m', c))p.termsinmake vars termsendlet union_vars (a : string array) (b : string array) : string array =let l = List.sort_uniq String.compare (Array.to_list a @ Array.to_list b) inArray.of_list llet unify (a : t) (b : t) : t * t =if a.vars = b.vars then (a, b)else beginlet v = union_vars a.vars b.vars in(remap a v, remap b v)end
A deliberate omission, and the reason for it.
(* Note there is no pruning step: a polynomial's variable array isallowed to be a superset of the variables it actually uses, which iswhat happens whenever a term cancels. Keeping it that way meansevery monomial in a polynomial is indexed the same, so the divisionloop below can hand monomials between two polynomials withoutre-aligning them mid-loop. `degree_in` reports 0 for a variable theterms never mention, which is the right answer anyway. *)
Arithmetic
Addition is a concatenation followed by the normalizing constructor. Multiplication is the obvious double loop, which is quadratic in the term count and will be the first thing replaced when the series turns to performance.
let neg (p : t) : t = { p with terms = List.map (fun (m, c) -> (m, Rational.neg c)) p.terms }let add (a : t) (b : t) : t =let a, b = unify a b inmake a.vars (a.terms @ b.terms)let sub (a : t) (b : t) : t = add a (neg b)let scale (p : t) (c : Rational.t) : t =if Rational.is_zero c then zeroelse { p with terms = List.map (fun (m, x) -> (m, Rational.mul c x)) p.terms }let mul (a : t) (b : t) : t =if is_zero a || is_zero b then zeroelse beginlet a, b = unify a b inlet acc = ref [] inList.iter(fun (m1, c1) ->List.iter (fun (m2, c2) -> acc := (mono_mul m1 m2, Rational.mul c1 c2) :: !acc) b.terms)a.terms;make a.vars !accend
Exponentiation by squaring, so that (x + y)^64 is six multiplications rather than sixty-three.
let pow (p : t) (n : int) : t =if n < 0 then invalid_arg "Poly.pow: negative exponent";let rec go acc b n =if n = 0 then acc else if n land 1 = 1 then go (mul acc b) (mul b b) (n lsr 1) else go acc (mul b b) (n lsr 1)ingo one p n
The univariate view
Every algorithm from here on treats a multivariate polynomial as a univariate one in a chosen variable, whose coefficients are polynomials in the rest. These four functions are that view, and they are the only place the switch happens.
(* Degree in one variable; -1 for the zero polynomial, 0 for anythingthat does not mention the variable at all. *)let degree_in (p : t) (v : string) : int =if is_zero p then -1elsematch var_index p v with| None -> 0| Some i -> List.fold_left (fun d (m, _) -> max d m.(i)) 0 p.termslet total_degree (p : t) : int =if is_zero p then -1 else List.fold_left (fun d (m, _) -> max d (total_deg m)) 0 p.terms(* The coefficient of v^k, itself a polynomial in the other variables. *)let coeff_in (p : t) (v : string) (k : int) : t =if is_zero p then zeroelsematch var_index p v with| None -> if k = 0 then p else zero| Some i ->let terms =List.filter_map(fun (m, c) ->if m.(i) <> k then Noneelse beginlet m' = Array.copy m inm'.(i) <- 0;Some (m', c)end)p.termsinmake p.vars terms(* Coefficients by ascending degree in v: index k holds the coefficientof v^k. This is the view every univariate algorithm below works in. *)let coeffs_in (p : t) (v : string) : t list =let d = degree_in p v inif d < 0 then [] else List.init (d + 1) (fun k -> coeff_in p v k)let of_coeffs (v : string) (cs : t list) : t =let x = var v inlet rec go k acc = function| [] -> acc| c :: rest -> go (k + 1) (add acc (mul c (pow x k))) restingo 0 zero cs(* Leading coefficient with respect to v, as a polynomial. *)let lc_in (p : t) (v : string) : t =let d = degree_in p v inif d < 0 then zero else coeff_in p v d
Verbatim, from the test suite.
degree_in ((x + y)^3) x = 3coeffs_in ((x + y)^3) x = y^3 | 3*y^2 | 3*y | 1of_coeffs x (coeffs_in p x) = pdegree_in zero x = -1degree_in (y^5) x = 0
Exact division
Over a field, division by the leading term can never fail on the coefficient, only on the monomial. And if b really divides a then it never fails at all, because the quotient's leading term has to be lt(a)/lt(b). So the first failure is a proof that the division is not exact, and there is no remainder to accumulate.
exception Not_exact(* Exact division. Because the coefficients form a field, the only waya step can fail is a leading monomial that does not divide, and ifb really divides a that never happens - the quotient's leading termhas to be lt(a)/lt(b). So the first failure is a proof that thedivision is not exact, and there is no need to accumulate aremainder. *)let divide (a : t) (b : t) : t option =if is_zero b then raise Division_by_zeroelse if is_zero a then Some zeroelse beginlet a, b = unify a b inlet bm, bc = List.hd b.terms inlet quo = ref [] and rem = ref a intrywhile not (is_zero !rem) dolet rm, rc = List.hd !rem.terms inmatch mono_div rm bm with| None -> raise Not_exact| Some m ->let c = Rational.div rc bc inquo := (m, c) :: !quo;rem := sub !rem (mul { vars = a.vars; terms = [ (m, c) ] } b)done;Some (make a.vars !quo)with Not_exact -> Noneend
Which makes divisibility a one-liner.
let divide_exn (a : t) (b : t) : t =match divide a b with Some q -> q | None -> failwith "Poly.divide_exn: not an exact division"let divides (b : t) (a : t) : bool = match divide a b with Some _ -> true | None -> false
Pseudo-division, and why
Over any polynomial divides any other. Over , or over the ring of polynomials in the remaining variables, not: dividing by produces a third. Multiply the dividend by first and every coefficient stays in the ring.
The multivariate version, with respect to a chosen variable.
(* Pseudo-division with respect to v, treating both arguments asunivariate in v over the ring of polynomials in the othervariables. Exactly the Z[x] story from Upoly, one level up. *)let pseudo_div (v : string) (a : t) (b : t) : t * t =let db = degree_in b v inif is_zero b then raise Division_by_zero;let da = degree_in a v inif da < db then (zero, a)else beginlet lcb = lc_in b v inlet x = var v inlet e = ref (da - db + 1) inlet q = ref zero and r = ref a inwhile (not (is_zero !r)) && degree_in !r v >= db dolet d = degree_in !r v - db inlet c = lc_in !r v inlet term = mul c (pow x d) inq := add (mul !q lcb) term;r := sub (mul !r lcb) (mul term b);decr edone;let f = pow lcb !e in(mul !q f, mul !r f)end
The identity it guarantees, asserted in the tests rather than assumed.
(* Pseudo-division identity, one variable at a time. *)let a = add (mul (pow x 3) y) (add (mul x y) (n 5)) inlet b = add (mul (pow x 2) y) (n 1) inlet q, r = pseudo_div "x" a b inlet e = degree_in a "x" - degree_in b "x" + 1 incheck "pseudo division identity"(equal (add (mul q b) r) (mul (pow (lc_in b "x") e) a));check "pseudo remainder degree drops" (degree_in r "x" < degree_in b "x")
The coefficient explosion
Staying in the ring is the whole problem with doing it repeatedly. Each remainder is multiplied by a power of the previous leading coefficient, so coefficients square at every step. Two coprime polynomials of degree 8 and 6, coefficients under 21:This is Knuth’s example from Seminumerical Algorithms 4.6.1, and it is chosen well: the two polynomials are coprime, so the whole sequence is computed and thrown away. The answer is 1.
./_out/main growth
./_out/main growth
Verbatim. Five remainders, and the last one has 35 digits. The inputs had two.
a = x^8 + x^6 - 3*x^4 - 3*x^3 + 8*x^2 + 2*x - 5b = 3*x^6 + 5*x^4 - 4*x^2 - 9*x + 21prem 1: degree 6, max coefficient 21prem 2: degree 4, max coefficient 15prem 3: degree 2, max coefficient 59535prem 4: degree 1, max coefficient 1654608338437500prem 5: degree 0, max coefficient 12593338795500743100931141992187500subresultant gcd = 1
GCD the first way: the subresultant PRS
The growth is not real. Each remainder is divisible by a factor predictable from the degrees and leading coefficients of the previous two, and dividing it out every step holds the coefficients near the true subresultants. That factor is what g and h below track.
Knuth's Algorithm C, on the dense univariate integer polynomials in lib/upoly.ml.
(* Knuth's Algorithm C. The plain pseudo-remainder sequence is correctbut its coefficients grow doubly exponentially; the g and h belowtrack a factor that provably divides the next remainder exactly, anddividing it out holds the growth to something linear in the degree.`steps` counts the remainders taken, for the benchmark. *)let steps = ref 0let gcd_prs (a : t) (b : t) : t =if is_zero a then primitive_part belse if is_zero b then primitive_part aelse beginlet d = Bigint.gcd (content a) (content b) inlet u = ref (primitive_part a) and v = ref (primitive_part b) inif degree !u < degree !v then beginlet t = !u inu := !v;v := tend;let g = ref Bigint.one and h = ref Bigint.one inlet result = ref zero and finished = ref false inwhile not !finished doincr steps;let delta = degree !u - degree !v inlet r = prem !u !v inif is_zero r then beginresult := !v;finished := trueendelse if degree r = 0 then beginresult := one;finished := trueendelse beginu := !v;v := divexact_int r (Bigint.mul !g (Bigint.pow !h delta));g := lc !u;h :=if delta = 0 then !helse Bigint.div (Bigint.pow !g delta) (Bigint.pow !h (delta - 1))enddone;scale (primitive_part !result) dend
The pseudo-division it runs on, which is the Z version of the multivariate one above.
(* Pseudo-division: returns (q, r) with lc(v)^(deg u - deg v + 1) * u =q*v + r and deg r < deg v. Multiplying through by that power of theleading coefficient is what keeps every intermediate value in Zwithout ever needing a fraction. *)let pseudo_div (u : t) (v : t) : t * t =if is_zero v then raise Division_by_zero;let dv = degree v and lcv = lc v inif degree u < dv then (zero, u)else beginlet e = ref (degree u - dv + 1) inlet q = ref zero and r = ref u inwhile (not (is_zero !r)) && degree !r >= dv dolet d = degree !r - dv inlet c = lc !r inlet term = shift (const c) d inq := add (scale !q lcv) term;r := sub (scale !r lcv) (mul term v);decr edone;(* The loop consumed one factor of lc(v) per step; top up so thatthe identity holds with exactly deg u - deg v + 1 factors. *)let f = Bigint.pow lcv !e in(scale !q f, scale !r f)end
It is correct, and on the example above it returns 1 without any 35-digit intermediate. It is also still slow, because the subresultants themselves grow linearly in the degree, so a degree-50 input has coefficients hundreds of digits wide and every one of the arithmetic operations is on bignums.
gcd_prs (x^2 - 1) (x^2 + 2x + 1) = x + 1gcd_prs (x^4 - 1) (x^3 - 1) = x - 1gcd_prs (6x^2 + 18x + 12) (4x^2 + 12x + 8)= 2x^2 + 6x + 4the integer content is part of the answer: gcd(6, 4) = 2
GCD the second way: small primes
The answer is small even when the road to it is not, so there is no reason to ever hold a 3000-digit number. Take the GCD modulo a machine-word prime, where every coefficient is an int and plain Euclid applies because the coefficients form a field. Repeat with another prime, reconstruct by the Chinese remainder theorem, and stop once the modulus provably exceeds twice the largest coefficient the answer could have.The final trial division is not optional. The bound guarantees that the lift is correct once the modulus is large enough, but the modulus is compared against a bound on the true GCD, and an unlucky sequence of primes can converge on a proper multiple of it. Dividing into both inputs is cheap and settles the question.
Arithmetic in F_p, with primes small enough that a product of two residues fits in an int with room to spare.
module Zp = structlet add p a b =let s = a + b inif s >= p then s - p else slet sub p a b =let s = a - b inif s < 0 then s + p else slet mul p a b = a * b mod plet neg p a = if a = 0 then 0 else p - a(* Extended Euclid, iterative. *)let inv p a =if a = 0 then invalid_arg "Zp.inv: zero";let r0 = ref p and r1 = ref (a mod p) inlet t0 = ref 0 and t1 = ref 1 inwhile !r1 <> 0 dolet q = !r0 / !r1 inlet r = !r0 - (q * !r1) inr0 := !r1;r1 := r;let t = !t0 - (q * !t1) int0 := !t1;t1 := tdone;let t = !t0 mod p inif t < 0 then t + p else tend
Polynomials over F_p. Over a field there is no pseudo-division and no content: the remainder sequence is the plain Euclidean one, and the GCD is normalized monic.
module Pmod = structtype t = int arraylet normalize (a : t) : t =let n = ref (Array.length a) inwhile !n > 0 && a.(!n - 1) = 0 dodecr ndone;if !n = Array.length a then a else Array.sub a 0 !nlet degree (a : t) : int = Array.length a - 1let is_zero (a : t) : bool = Array.length a = 0let lc (a : t) : int = if is_zero a then 0 else a.(Array.length a - 1)let scale p (a : t) (c : int) : t =if c = 0 then [||] else Array.map (fun x -> Zp.mul p x c) alet monic p (a : t) : t = if is_zero a then a else scale p a (Zp.inv p (lc a))let sub p (a : t) (b : t) : t =let n = max (Array.length a) (Array.length b) innormalize(Array.init n (fun i ->let x = if i < Array.length a then a.(i) else 0 inlet y = if i < Array.length b then b.(i) else 0 inZp.sub p x y))(* Remainder of a divided by b, both over the field F_p, so nopseudo-division is needed. *)let rem p (a : t) (b : t) : t =if is_zero b then raise Division_by_zero;let db = degree b and ib = Zp.inv p (lc b) inlet r = ref a inwhile (not (is_zero !r)) && degree !r >= db dolet d = degree !r - db inlet c = Zp.mul p (lc !r) ib inlet shifted = Array.make (Array.length b + d) 0 inArray.iteri (fun i x -> shifted.(i + d) <- Zp.mul p x c) b;r := sub p !r shifteddone;!rlet gcd p (a : t) (b : t) : t =let u = ref a and v = ref b inwhile not (is_zero !v) dolet r = rem p !u !v inu := !v;v := rdone;monic p !ulet of_upoly p (a : Bigint.t array) : t =normalize (Array.map (fun c -> bigint_mod_int c p) a)end
How far to go. A divisor of a polynomial over Z cannot have coefficients larger than 2^deg times its max norm, which bounds what the CRT lift has to resolve. Once the product of the primes used exceeds twice that, the symmetric representative is the answer rather than merely congruent to it.
(* Every coefficient of a divisor of a is bounded by2^deg(a) * ||a||_inf, so a GCD scaled to have leading coefficientgcd(lc a, lc b) is bounded by that times the scaling. Once theproduct of the primes used exceeds twice the bound, the symmetricCRT lift is the answer and not merely congruent to it. *)let landau_mignotte (a : t) (b : t) (g : Bigint.t) : Bigint.t =let m = min (degree a) (degree b) inlet na = max_norm a and nb = max_norm b inlet smaller = if Bigint.compare na nb <= 0 then na else nb inBigint.mul (Bigint.mul (Bigint.pow (Bigint.of_int 2) m) g) smaller(* Symmetric representative of r modulo m: the one in (-m/2, m/2]. *)let symmetric (r : Bigint.t) (m : Bigint.t) : Bigint.t =let two_r = Bigint.mul (Bigint.of_int 2) r inif Bigint.compare two_r m > 0 then Bigint.sub r m else r(* One coefficient of the Chinese remainder step: find the valuecongruent to r1 mod m1 and to r2 mod p. *)let crt_coeff (r1 : Bigint.t) (m1 : Bigint.t) (r2 : int) (p : int) : Bigint.t =let r1p = bigint_mod_int r1 p inlet m1p = bigint_mod_int m1 p inlet delta = Zp.mul p (Zp.sub p r2 r1p) (Zp.inv p m1p) inBigint.add r1 (Bigint.mul m1 (Bigint.of_int delta))
And the loop. Three things can go wrong with a prime and all three are handled: it can divide a leading coefficient, it can make the degree drop, and it can be unlucky in the sense that the image GCD is too big. The last one is detected by comparing degrees against the accumulation so far, and an image of smaller degree invalidates everything collected before it.
let gcd_modular (a : t) (b : t) : t =if is_zero a then primitive_part belse if is_zero b then primitive_part aelse beginlet d = Bigint.gcd (content a) (content b) inlet aa = primitive_part a and bb = primitive_part b inlet g = Bigint.gcd (lc aa) (lc bb) inlet bound = Bigint.mul (Bigint.of_int 2) (landau_mignotte aa bb g) in(* v holds the CRT lift so far, m the product of the primes used. *)let v = ref [||] and m = ref Bigint.one and have = ref false inlet p = ref (1 lsl 29) inlet result = ref zero and finished = ref false inwhile not !finished dop := next_prime (!p + 1);let pi = !p in(* Skip a prime that divides the leading coefficients: modulosuch a prime the degree drops and the image is not the imageof the GCD. *)if bigint_mod_int g pi <> 0 then beginlet ap = Pmod.of_upoly pi aa and bp = Pmod.of_upoly pi bb inif Pmod.degree ap = degree aa && Pmod.degree bp = degree bb then beginincr primes_used;let gp = Pmod.gcd pi ap bp inif Pmod.degree gp = 0 then begin(* Coprime mod one prime means coprime over Z. *)result := scale one d;finished := trueendelse begin(* Impose the known leading coefficient instead of leavingthe image monic, so the images agree across primes. *)let gp = Pmod.scale pi gp (bigint_mod_int g pi) inlet cur_deg = if !have then Array.length !v - 1 else max_int inif (not !have) || Pmod.degree gp < cur_deg then begin(* A smaller degree means every earlier prime wasunlucky; throw the accumulation away and restart. *)have := true;m := Bigint.of_int pi;v :=Array.map(fun c -> symmetric (Bigint.of_int c) (Bigint.of_int pi))gpendelse if Pmod.degree gp = cur_deg then beginlet m' = Bigint.mul !m (Bigint.of_int pi) inlet lifted =Array.init (Array.length !v) (fun i ->symmetric (crt_coeff !v.(i) !m gp.(i) pi) m')inv := lifted;m := m'end;(* else this prime is unlucky: its GCD is too big, drop it *)if Bigint.compare !m bound > 0 then beginlet cand = primitive_part (normalize (Array.copy !v)) inmatch (divides cand aa, divides cand bb) with| Some _, Some _ ->result := scale cand d;finished := true| _ -> () (* bound was not enough yet; keep going *)endendendenddone;!resultend
The two, timed
./_out/main bench
./_out/main bench
Verbatim. Random polynomials with a planted common factor of the stated degree, coefficients around 60 bits, both algorithms returning the same answer, which the benchmark checks. The last column is the number of decimal digits in the largest coefficient of the result, and it does not move.
deg(g) deg(a) prs (s) modular (s) max digits4 8 0.0004 0.0006 198 16 0.0042 0.0006 1816 32 0.0539 0.0008 1924 48 0.2697 0.0011 1932 64 0.7688 0.0016 1940 80 1.7734 0.0023 1948 96 3.5756 0.0029 19
At degree 96 the modular algorithm is roughly 1200 times faster and the gap is still widening. The subresultant column grows faster than the degree cubed; the modular column grows linearly, because the number of primes needed tracks the bits in the answer, and the answer is not getting bigger.
Multivariate GCD, by recursion
The multivariate case reduces to the univariate one. Pick a main variable, split each polynomial into its content, which is a GCD of polynomials in fewer variables, and its primitive part. Recurse on the contents. Run the subresultant PRS on the primitive parts, this time over the ring of polynomials in the remaining variables rather than over Z. Multiply the two results.
The recursion. A variable handled at one level never reappears below it, which is what makes the recursion terminate.
let rec gcd (a : t) (b : t) : t =if is_zero a then primitive belse if is_zero b then primitive aelse beginlet a, b = unify (primitive a) (primitive b) in(* Recurse on the first variable either side actually uses. Avariable handled here never reappears further down, so therecursion is bounded by the number of variables. *)let n = Array.length a.vars inlet rec pick i =if i >= n then Noneelse if degree_in a a.vars.(i) > 0 || degree_in b a.vars.(i) > 0 then Some a.vars.(i)else pick (i + 1)inmatch pick 0 with| None -> one (* both are nonzero constants, and over Q every one of those is a unit *)| Some v ->let ca = content_in v a and cb = content_in v b inlet cg = gcd ca cb inlet pa = divide_exn a ca and pb = divide_exn b cb inif degree_in pa v = 0 || degree_in pb v = 0 then primitive cgelse primitive (mul cg (gcd_prs v pa pb))endand content_in (v : string) (p : t) : t =List.fold_left (fun g c -> gcd g c) zero (coeffs_in p v)and primitive_part_in (v : string) (p : t) : t = divide_exn p (content_in v p)(* Knuth's Algorithm C again, this time over the ring of polynomials inthe variables other than v. Both arguments must already be primitivewith respect to v. *)and gcd_prs (v : string) (a : t) (b : t) : t =let u = ref a and w = ref b inif degree_in !u v < degree_in !w v then beginlet t = !u inu := !w;w := tend;let g = ref one and h = ref one inlet result = ref one and finished = ref false inwhile not !finished dolet delta = degree_in !u v - degree_in !w v inlet r = pseudo_rem v !u !w inif is_zero r then beginresult := primitive_part_in v !w;finished := trueendelse if degree_in r v = 0 then beginresult := one;finished := trueendelse beginu := !w;w := divide_exn r (mul !g (pow !h delta));g := lc_in !u v;h := (if delta = 0 then !h else divide_exn (pow !g delta) (pow !h (delta - 1)))enddone;!result
A GCD is defined up to a unit, and over Q every nonzero rational is a unit, so a normalization convention is what turns gcd from a relation into a function. This one clears denominators, divides out the integer content, and makes the lex-leading coefficient positive.
(* Scale by the unique positive rational that clears all denominators,divides out the integer content, and makes the lex-leadingcoefficient positive. A GCD is only defined up to a unit, and over Qevery nonzero rational is a unit, so a convention like this is whatmakes `gcd` a function rather than a relation. *)let primitive (p : t) : t =if is_zero p then pelse beginlet den_lcm =List.fold_left(fun l (_, c) ->let d = Rational.den c inBigint.div (Bigint.mul l d) (Bigint.gcd l d))Bigint.one p.termsinlet scaled = scale p (Rational.of_bigint den_lcm) inlet num_gcd =List.fold_left (fun g (_, c) -> Bigint.gcd g (Rational.num c)) Bigint.zero scaled.termsinlet r = scale scaled (Rational.make Bigint.one num_gcd) inmatch lead_term r with| Some (_, c) when Rational.sign c < 0 -> neg r| _ -> rend
Verbatim, from the REPL.
> gcd x^6 - 1, x^4 - 1-1 + x^2> gcd x^2*y - y^3, x^2 - 2*x*y + y^2x - y> gcd x + 1, x + 21
Resultants
The resultant of two polynomials in x is a polynomial in everything else that vanishes exactly when the two have a common root. That makes it the tool for eliminating a variable from a system, and it is the reason a CAS can solve x^2 + y^2 = 1, x = y^2 without numerics.
Computed by the same remainder sequence, with the bookkeeping the two identities require: res(a,b) = (-1)^(mn) res(b,a), and res(b,a) = lc(b)^(m-k) res(b,r) for a remainder r of degree k. Pseudo-division scales a by lc(b)^(m-n+1) first, and res(b, c*a) = c^n res(b,a), so that factor has to come back out. Every division here is exact.
let rec resultant (v : string) (a : t) (b : t) : t =if is_zero a || is_zero b then zeroelse beginlet m = degree_in a v and n = degree_in b v inif m = 0 && n = 0 then oneelse if m < n thenlet s = resultant v b a inif m * n mod 2 = 1 then neg s else selse if n = 0 then pow b melse beginlet r = pseudo_rem v a b inif is_zero r then zeroelse beginlet k = degree_in r v inlet lcb = lc_in b v inlet num = mul (resultant v b r) (pow lcb (m - k)) inlet den = pow lcb (n * (m - n + 1)) inlet s = divide_exn num den inif m * n mod 2 = 1 then neg s else sendendend
Checked against things that are known independently. The second is the discriminant of a quadratic, which is res(p, p') divided by minus the leading coefficient.
(* res(a, b) = 0 exactly when a and b have a common factor. *)let test_resultant () =check_str "resultant eliminates x" "y^2 - 7*y + 9"(s (resultant "x" (sub (pow x 2) y) (add (sub (pow x 2) (n 3)) x)));check_str "resultant with a linear argument" "-y + z^2"(s (resultant "x" (sub (pow x 2) y) (sub x z)));check "a shared root makes the resultant vanish"(is_zero (resultant "x" (mul (add x (n 1)) (add x (n 2))) (mul (add x (n 1)) (add x (n 3)))));check "no shared root makes it nonzero"(not (is_zero (resultant "x" (add x (n 1)) (add x (n 2)))));(* The discriminant of a x^2 + b x + c is b^2 - 4ac, and it isres(p, p') up to the leading coefficient. *)(* For p = z*x^2 + y*x + c the discriminant is y^2 - 4*z*c, andres(p, p') = -z * (y^2 - 4*z*c), so dividing by -4 gives(1/4)*y^2*z - c*z^2. *)let p = add (add (mul z (pow x 2)) (mul y x)) (var "c") inlet r = resultant "x" p (diff p "x") incheck_str "discriminant of a quadratic" "-c*z^2 + 1/4*y^2*z"(s (scale r (Rational.make (Bigint.of_int (-1)) (Bigint.of_int 4))));(* res(a, b) = (-1)^(deg a * deg b) res(b, a): both degrees are 3here, so the two differ by a sign. *)let a = add (pow x 3) (n 1) and b = add (pow x 3) x incheck "resultant is antisymmetric in odd degrees"(equal (resultant "x" a b) (neg (resultant "x" b a)))
And the classical one, from the REPL: the resultant of two monic quadratics.
> resultant x^2 + a*x + b, x^2 + c*x + d, xb^2 + d^2 - 2*b*d - a*b*c - a*c*d + b*c^2 + d*a^2which is (b - d)^2 + (a - c)*(a*d - b*c), as it should be
Square-free decomposition
A polynomial and its derivative share exactly the repeated factors, each multiplicity dropped by one. Yun turns that into a full split into square-free pieces, one GCD per multiplicity level rather than one per factor. Every factoriser starts here.
Characteristic zero only: the argument rests on the derivative of x^n being nonzero.
(* Returns factors paired with their multiplicities, whose product isthe primitive part of p, each factor square-free and the factorspairwise coprime. The trick is that p and p' share exactly therepeated factors, each with its multiplicity dropped by one, so oneGCD peels off a whole layer at a time. Valid in characteristiczero only. *)let square_free (p : t) (v : string) : (t * int) list =if is_zero p || degree_in p v <= 0 then [ (primitive p, 1) ]else beginlet a = primitive p inlet b = diff a v inlet d = gcd a b inlet w = ref (divide_exn a d) inlet y = ref (divide_exn b d) inlet z = ref (sub !y (diff !w v)) inlet out = ref [] and i = ref 1 inwhile degree_in !w v > 0 dolet g = gcd !w !z inif degree_in g v > 0 then out := (g, !i) :: !out;w := divide_exn !w g;y := divide_exn !z g;z := sub !y (diff !w v);incr idone;List.rev !outend
Verbatim.
> sqfree (x - 1)^2*(x + 2)^3, x(-1 + x)^2 * (2 + x)^3> sqfree (x^2 - 1)^3*(x + 5)^2, x(5 + x)^2 * (-1 + x^2)^3
Crossing back to expressions
A converter that gave up on anything non-polynomial would be useless: sin(x)*(x + 1)^2 has a perfectly good expansion. Take the maximal non-polynomial subexpressions, call them kernels, treat each as an opaque variable, and expand with respect to those.
Kernel names are the printed form of the subexpression, which is injective on simplified expressions, so equal names mean equal subexpressions.
exception Not_polynomial of stringtype ctx = { mutable kernels : (string * Expr.t) list }let new_ctx () : ctx = { kernels = [] }let kernel (c : ctx) (e : Expr.t) : string =let name = Expr.to_string e inif not (List.mem_assoc name c.kernels) then c.kernels <- (name, e) :: c.kernels;name(* ------------------------------------------------------------------ *)(* Expr -> Poly *)(* ------------------------------------------------------------------ *)let rec to_poly (c : ctx) (e : Expr.t) : Poly.t =match e with| Expr.Num r -> Poly.of_rational r| Expr.Sym s ->ignore (kernel c (Expr.Sym s));Poly.var s| Expr.Add xs -> List.fold_left (fun acc x -> Poly.add acc (to_poly c x)) Poly.zero xs| Expr.Mul xs -> List.fold_left (fun acc x -> Poly.mul acc (to_poly c x)) Poly.one xs| Expr.Pow (b, Expr.Num n) when Rational.is_integer n && Rational.sign n >= 0 -> (match Rational.to_int_opt n with| Some k -> Poly.pow (to_poly c b) k| None -> raise (Not_polynomial "exponent too large to expand"))| Expr.Pow _ | Expr.Fun _ ->(* A negative or fractional power, or an unknown function: opaque. *)Poly.var (kernel c e)
And back. The variable name is looked up in the kernel table, so sin(x) comes out as the function call it started as rather than as a symbol named sin(x).
let of_poly (c : ctx) (p : Poly.t) : Expr.t =let vars = Array.of_list (Poly.vars_of p) inlet var_expr i =let name = vars.(i) inmatch List.assoc_opt name c.kernels with Some e -> e | None -> Expr.Sym nameinlet terms =List.map(fun (m, coeff) ->let factors = ref [] inArray.iteri(fun i k ->if k = 1 then factors := var_expr i :: !factorselse if k > 1 then factors := Expr.Pow (var_expr i, Expr.int k) :: !factors)m;let body = match !factors with [] -> Expr.num_one | [ f ] -> f | fs -> Expr.Mul fs inif Rational.is_one coeff then body else Expr.Mul [ Expr.Num coeff; body ])(Poly.terms_of p)inmatch terms with [] -> Expr.num_zero | [ t ] -> Expr.simplify t | ts -> Expr.simplify (Expr.Add ts)(* Round-trip an expression through the polynomial representation.This is `expand`: the polynomial form has no nesting left todistribute, so converting back is what performs the distribution. *)let expand (e : Expr.t) : Expr.t =let c = new_ctx () inof_poly c (to_poly c (Expr.simplify e))
Which makes expand a round trip: the polynomial form has no nesting left, so converting back is what performs the distribution.
(* Round-trip an expression through the polynomial representation.This is `expand`: the polynomial form has no nesting left todistribute, so converting back is what performs the distribution. *)let expand (e : Expr.t) : Expr.t =let c = new_ctx () inof_poly c (to_poly c (Expr.simplify e))
Verbatim. The last three are the kernels doing their job.
> expand (x + y)^3x^3 + y^3 + 3*x*y^2 + 3*y*x^2> expand (x + 1)*(x - 1)*(x^2 + 1)-1 + x^4> expand (x + sin(y))^2x^2 + sin(y)^2 + 2*x*sin(y)> expand (x^(1/2) + 1)^21 + x + 2*x^(1/2)> expand (1/x + 1)^21 + x^(-2) + 2*x^(-1)
Degree, coefficient, collect
Three operations that are trivial on a polynomial and awkward on a tree.
let degree (e : Expr.t) (v : string) : int =let c = new_ctx () inPoly.degree_in (to_poly c (Expr.simplify e)) vlet total_degree (e : Expr.t) : int =let c = new_ctx () inPoly.total_degree (to_poly c (Expr.simplify e))(* The coefficient of v^k, as an expression. *)let coeff (e : Expr.t) (v : string) (k : int) : Expr.t =let c = new_ctx () inof_poly c (Poly.coeff_in (to_poly c (Expr.simplify e)) v k)(* Rewrite as a sum of coeff * v^k with the coefficients grouped. Thisis what `expand` is not: it keeps the structure the user asked forrather than flattening everything. *)let collect (e : Expr.t) (v : string) : Expr.t =let c = new_ctx () inlet p = to_poly c (Expr.simplify e) inlet cs = Poly.coeffs_in p v inlet x = Expr.Sym v inlet terms =List.mapi (fun k ck -> (k, ck)) cs|> List.filter (fun (_, ck) -> not (Poly.is_zero ck))|> List.map (fun (k, ck) ->let ce = of_poly c ck inif k = 0 then ceelse beginlet xp = if k = 1 then x else Expr.Pow (x, Expr.int k) inif Expr.is_num_one ce then xp else Expr.Mul [ ce; xp ]end)inmatch terms with [] -> Expr.num_zero | [ t ] -> t | ts -> Expr.Add (List.rev ts)
collect is precisely what expand is not: it keeps the structure the caller asked for instead of flattening everything.
> collect x^2*y + x^2 + 3*x*y + 7, x(1 + y)*x^2 + 3*y*x + 7> coeff (1 + x)^20, x, 10184756> degree (x*y + z)^7, z7> degree (x + y)^5 * (x - 1), x6
Differentiation
Sums, products, and powers with a constant exponent, which covers everything the polynomial layer produces plus the negative and fractional powers it treats as kernels. A symbolic exponent needs a logarithm and a Fun node needs a derivative table, so both are refused by name rather than guessed at.
exception Not_differentiable of string(* Sums, products, and powers with a constant exponent - which iseverything the polynomial layer can produce, plus the negative andfractional powers it treats as kernels. A power with a symbolicexponent needs a logarithm to differentiate, and a Fun node needs aderivative table; both wait for the calculus part. *)let rec diff (e : Expr.t) (v : string) : Expr.t =Expr.simplify (diff_raw e v)and diff_raw (e : Expr.t) (v : string) : Expr.t =match e with| Expr.Num _ -> Expr.num_zero| Expr.Sym s -> if s = v then Expr.num_one else Expr.num_zero| Expr.Add xs -> Expr.Add (List.map (fun x -> diff_raw x v) xs)| Expr.Mul xs ->(* Product rule, n-ary: one summand per factor differentiated. *)Expr.Add(List.mapi(fun i _ ->Expr.Mul (List.mapi (fun j x -> if i = j then diff_raw x v else x) xs))xs)| Expr.Pow (b, Expr.Num n) ->(* n * b^(n-1) * b' *)let n1 = Expr.Num (Rational.sub n Rational.one) inExpr.Mul [ Expr.Num n; Expr.Pow (b, n1); diff_raw b v ]| Expr.Pow _ -> raise (Not_differentiable "power with a symbolic exponent")| Expr.Fun (f, _) -> raise (Not_differentiable ("no derivative rule for " ^ f))
Verbatim, including the two refusals.
> diff (x^2 + 3*x + 1)^3, x3*(1 + x^2 + 3*x)^2*(3 + 2*x)> diff (x^3 + 1)^4, x12*x^2*(1 + x^3)^3> diff 1/x, x-x^(-2)> diff (x^2 + 1)^(1/2), xx*(1 + x^2)^(-1/2)> diff cos(x), xcannot differentiate: no derivative rule for cos> diff x^y, xcannot differentiate: power with a symbolic exponent
The product rule is checked against itself on random polynomials rather than against a table of expected answers: differentiate a product, differentiate the two factors and combine them by hand, expand both, demand that they are identical. Two hundred cases per run.
An input language
Everything above is reachable only through constructors, which is fine for a library and useless for a demonstration. The grammar needed is small enough that a table of binding powers and one recursive function cover it.
The tokenizer. Numbers are exact integers, so 1/3 is a division of two integers and stays a rational forever.
let is_digit c = c >= '0' && c <= '9'let is_alpha c = (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || c = '_'let is_alnum c = is_alpha c || is_digit clet tokenize (s : string) : token list =let n = String.length s inlet out = ref [] inlet i = ref 0 inwhile !i < n dolet c = s.[!i] inif c = ' ' || c = '\t' || c = '\n' || c = '\r' then incr ielse if is_digit c then beginlet j = ref !i inwhile !j < n && is_digit s.[!j] doincr jdone;out := TNum (Rational.of_string (String.sub s !i (!j - !i))) :: !out;i := !jendelse if is_alpha c then beginlet j = ref !i inwhile !j < n && is_alnum s.[!j] doincr jdone;out := TIdent (String.sub s !i (!j - !i)) :: !out;i := !jendelse beginlet t =match c with| '+' -> TPlus| '-' -> TMinus| '*' -> TStar| '/' -> TSlash| '^' -> TCaret| '(' -> TLParen| ')' -> TRParen| ',' -> TComma| _ -> raise (Parse_error (Printf.sprintf "unexpected character %C" c))inout := t :: !out;incr ienddone;List.rev (TEnd :: !out)
Binding powers. Parsing at power bp means consume everything that binds tighter than bp, which turns precedence and associativity into two numbers instead of five grammar rules.
(* Left binding power. Higher binds tighter. A caret is given a lowerright binding power than its left one when it recurses, which iswhat makes it right associative. *)let lbp (t : token) : int =match t with| TPlus | TMinus -> 10| TStar | TSlash -> 20| TCaret -> 30| _ -> 0let rec parse_expr (st : state) (bp : int) : Expr.t =let left = ref (parse_prefix st) inlet continue_ = ref true inwhile !continue_ dolet t = peek st inif lbp t <= bp then continue_ := falseelse beginignore (advance st);left := parse_infix st !left tenddone;!leftand parse_prefix (st : state) : Expr.t =match advance st with| TNum r -> Expr.Num r| TIdent name ->if peek st = TLParen then beginignore (advance st);let args = parse_args st inExpr.Fun (name, args)endelse Expr.Sym name(* Unary minus binds tighter than + and * but looser than ^, so that-x^2 parses as -(x^2) and -x*y as (-x)*y. *)| TMinus -> Expr.Mul [ Expr.Num Rational.minus_one; parse_expr st 25 ]| TPlus -> parse_expr st 25| TLParen ->let e = parse_expr st 0 inexpect st TRParen ")";e| TEnd -> raise (Parse_error "unexpected end of input")| _ -> raise (Parse_error "expected an expression")
Argument lists and the infix table. Subtraction and division do not exist as constructors, so they are built here out of the ones that do, exactly as Part 2 defined them.
and parse_args (st : state) : Expr.t list =if peek st = TRParen then beginignore (advance st);[]endelse beginlet args = ref [ parse_expr st 0 ] inwhile peek st = TComma doignore (advance st);args := parse_expr st 0 :: !argsdone;expect st TRParen ")";List.rev !argsendand parse_infix (st : state) (left : Expr.t) (t : token) : Expr.t =match t with| TPlus -> Expr.Add [ left; parse_expr st 10 ]| TMinus -> Expr.Add [ left; Expr.Mul [ Expr.Num Rational.minus_one; parse_expr st 10 ] ]| TStar -> Expr.Mul [ left; parse_expr st 20 ]| TSlash -> Expr.Mul [ left; Expr.Pow (parse_expr st 20, Expr.Num Rational.minus_one) ]| TCaret -> Expr.Pow (left, parse_expr st 29) (* 29 < 30: right associative *)| _ -> raise (Parse_error "not an infix operator")
The precedence cases that matter, asserted on the raw tree before simplification, because after simplification the ordering hides them.
let test_precedence () =check_tree "product binds tighter than sum" "(+ 2 (* 3 x))" "2 + 3*x";check_tree "power binds tighter than product" "(* 3 (^ x 2))" "3*x^2";check_tree "parentheses override" "(^ (+ x 1) 2)" "(x + 1)^2";check_tree "left associative subtraction" "(+ (+ a (* -1 b)) (* -1 c))" "a - b - c";check_tree "right associative power" "(^ 2 (^ 3 2))" "2^3^2";check_parse "and that means 512" "512" "2^3^2";check_tree "unary minus is looser than power" "(* -1 (^ x 2))" "-x^2";check_parse "so -x^2 at x=2 is -4" "-4" "-(2^2)";check_tree "unary minus is tighter than product" "(* (* -1 x) y)" "-x*y";check_tree "division is a negative power" "(* a (^ b -1))" "a/b";check_tree "chained division is left associative" "(* (* a (^ b -1)) (^ c -1))" "a/b/c"
And a round trip: printing a simplified expression and parsing it back has to land on the same expression, which is the only real check that the printer and the parser agree.
(* Printing a simplified expression and parsing it back must land onthe same expression. That is the check that the printer and theparser agree about precedence. *)let test_round_trip () =let inputs =["(x + y)^2";"1/2*x + 2/3*y";"x*y*z - 3";"x^2/(y + 1)";"-x - y";"2^x*3^y";"(x + 1)^(1/2)";"f(x, y)*g(z)";"x - y - z";"1/(x*y)";]inList.iter(fun input ->let e = Parser.parse_simplified input inlet printed = Expr.to_string e inincr checks;match Parser.parse_simplified printed with| exception Parser.Parse_error m ->incr failures;Printf.printf "FAIL: reparsing %s failed: %s\n" printed m| e2 ->if Expr.compare_expr e e2 <> 0 then beginincr failures;Printf.printf "FAIL: round trip changed %s\n printed %s\n reparsed %s\n" input printed(Expr.to_string e2)end)inputs;check "parse is total on its own output" true
The REPL
Command, comma-separated arguments, and a fall-through that treats anything else as an expression to simplify.
let eval_command (line : string) : string =let line = String.trim line inlet cmd, rest =match String.index_opt line ' ' with| None -> (line, "")| Some i -> (String.sub line 0 i, String.sub line (i + 1) (String.length line - i - 1))inlet args = split_args rest inlet arg n = List.nth args n inmatch (cmd, List.length args) with| "expand", 1 -> show (Algebra.expand (parse (arg 0)))| "simplify", 1 -> show (parse (arg 0))| "collect", 2 -> show (Algebra.collect (parse (arg 0)) (sym_name (arg 1)))| "degree", 2 -> string_of_int (Algebra.degree (parse (arg 0)) (sym_name (arg 1)))| "coeff", 3 ->show (Algebra.coeff (parse (arg 0)) (sym_name (arg 1)) (int_of_string (String.trim (arg 2))))| "diff", 2 -> show (Algebra.diff (parse (arg 0)) (sym_name (arg 1)))| "gcd", 2 -> show (Algebra.gcd (parse (arg 0)) (parse (arg 1)))| "lcm", 2 -> show (Algebra.lcm (parse (arg 0)) (parse (arg 1)))| "resultant", 3 ->show (Algebra.resultant (parse (arg 0)) (parse (arg 1)) (sym_name (arg 2)))| "sqfree", 2 ->Algebra.square_free (parse (arg 0)) (sym_name (arg 1))|> List.map (fun (f, m) -> Printf.sprintf "(%s)^%d" (show f) m)|> String.concat " * "| "tree", 1 -> Expr.to_sexp (parse (arg 0))| "help", _ ->"expand E | simplify E | collect E, v | degree E, v | coeff E, v, k\n\diff E, v | gcd E, F | lcm E, F | resultant E, F, v | sqfree E, v | tree E"(* Anything that is not a command word is treated as an expression tosimplify, so the REPL can be used as a calculator. *)| _ -> show (parse line)
Splitting on commas has to respect parentheses, or coeff f(x, y), x, 2 falls apart.
(* Split on commas that are not inside parentheses, so that"coeff (x+1)^3, x, 2" separates into three arguments. *)let split_args (s : string) : string list =let out = ref [] and buf = Buffer.create 32 and depth = ref 0 inString.iter(fun c ->if c = '(' then beginincr depth;Buffer.add_char buf cendelse if c = ')' then begindecr depth;Buffer.add_char buf cendelse if c = ',' && !depth = 0 then beginout := Buffer.contents buf :: !out;Buffer.clear bufendelse Buffer.add_char buf c)s;out := Buffer.contents buf :: !out;List.rev_map String.trim !out
A session, verbatim.
$ ./_out/main replcas part 3 - type `help`, or an expression to simplify> 2^64 + 118446744073709551617> 1/3 + 1/61/2> expand (x + y)^3x^3 + y^3 + 3*x*y^2 + 3*y*x^2> gcd x^6 - 1, x^4 - 1-1 + x^2> resultant x^2 + a*x + b, x^2 + c*x + d, xb^2 + d^2 - 2*b*d - a*b*c - a*c*d + b*c^2 + d*a^2> sqfree (x^2 - 1)^3*(x + 5)^2, x(5 + x)^2 * (-1 + x^2)^3> coeff (1 + x)^20, x, 10184756> foo xparse error: trailing input> quit
Tests
The expensive properties are the ones stated without reference to an expected answer, because those are the ones that can be run on random input a few hundred times per suite.
Whatever the GCD routines return, it has to divide both inputs, contain the planted factor, and leave coprime cofactors. That is the definition, and it is checkable without knowing the answer.
(* The property that matters: whatever the two algorithms return, itmust divide both inputs, and the quotients must be coprime. That isthe definition of a GCD, and it is checkable without knowing theanswer in advance. *)let random_poly deg =Upoly.of_list (List.init (deg + 1) (fun i -> if i = deg then 1 + Random.int 9 else Random.int 21 - 10))let test_gcd_property () =Random.init 20260420;for _ = 1 to 200 dolet g = random_poly (1 + Random.int 4) inlet a = Upoly.mul g (random_poly (1 + Random.int 4)) inlet b = Upoly.mul g (random_poly (1 + Random.int 4)) inlet d1 = Upoly.gcd_prs a b and d2 = Upoly.gcd_modular a b incheck "the two algorithms agree" (Upoly.equal d1 d2);check "the gcd divides a" (Upoly.divides d1 a <> None);check "the gcd divides b" (Upoly.divides d1 b <> None);check "the gcd is a multiple of the planted factor"(Upoly.divides (Upoly.primitive_part g) d1 <> None);match (Upoly.divides d1 a, Upoly.divides d1 b) with| Some qa, Some qb ->check "the cofactors are coprime" (Upoly.degree (Upoly.gcd_prs qa qb) = 0)| _ -> ()done
The ring laws, plus the two facts every division-based algorithm above depends on: a product divides back exactly, and degrees add.
let test_ring_laws () =Random.init 424242;for _ = 1 to 300 dolet a = random_poly 2 and b = random_poly 2 and c = random_poly 2 incheck "addition commutes" (equal (add a b) (add b a));check "multiplication commutes" (equal (mul a b) (mul b a));check "multiplication associates" (equal (mul (mul a b) c) (mul a (mul b c)));check "multiplication distributes" (equal (mul a (add b c)) (add (mul a b) (mul a c)));check "subtraction inverts addition" (equal (sub (add a b) b) a);(* A product always divides back exactly. *)if not (is_zero b) thencheck "exact division undoes multiplication" (equal (divide_exn (mul a b) b) a);(* Degrees add under multiplication. *)if (not (is_zero a)) && not (is_zero b) thencheck "degrees add" (total_degree (mul a b) = total_degree a + total_degree b)done
And the one that catches an expansion bug immediately: expanding must not change what an expression evaluates to.
(* The property that catches an expansion bug immediately: expandingmust not change what an expression evaluates to. *)let test_expand_preserves_value () =Random.init 31415;let rec random_expr d =if d <= 0 thenmatch Random.int 4 with| 0 -> Expr.int (Random.int 5 - 2)| 1 -> Expr.rat (Random.int 5 - 2) (1 + Random.int 3)| 2 -> Expr.Sym "x"| _ -> Expr.Sym "y"elsematch Random.int 4 with| 0 -> Expr.Add [ random_expr (d - 1); random_expr (d - 1) ]| 1 -> Expr.Mul [ random_expr (d - 1); random_expr (d - 1) ]| 2 -> Expr.Add [ random_expr (d - 1); random_expr (d - 1); random_expr (d - 1) ]| _ -> Expr.Pow (random_expr (d - 1), Expr.int (Random.int 4))infor _ = 1 to 400 dolet e = random_expr 3 inlet xv = Expr.int (1 + Random.int 4) and yv = Expr.int (1 + Random.int 4) inlet value g = Expr.to_rational (Expr.substitute "y" yv (Expr.substitute "x" xv g)) inmatch (value e, value (Algebra.expand e)) with| exception Expr.Undefined _ -> ()| exception Division_by_zero -> ()| Some a, Some b ->check "expansion preserves the value" (Rational.equal a b)| _ -> ()done
What is deliberately not here
Two gaps, both large, both next.
no factorization expand ((x+1)*(x+2)) gives x^2 + 3x + 2, and nothingturns it back. square_free splits multiplicities,not factors.no rational functions 1/(x^2 - 1) + 1/(x + 1) stays as written. Addingthem needs a common denominator and cancellingthe result needs the GCD this part just built.Part 4: factorization over F_p, Hensel lifting to Z, multivariatefactorization, and the rational function field on top of it.
Download
The snapshot at the end of this part. New in Part 3: poly.ml, upoly.ml, algebra.ml, parser.ml, main.ml, and the tests test_poly.ml, test_upoly.ml, test_algebra.ml, test_parser.ml. Carried over unchanged from Part 2: bigint.ml, rational.ml, expr.ml, test_rational.ml, test_expr.ml, test_all.ml, test_bigint.ml.