Rothstein-Trager algorithm in Python
Computes the logarithmic part of a rational integral from the resultant of the denominator and a log-derivative equation.
- File
rothstein_trager.py, 251 lines- Level
- I0 not checked yet
- Toolchain
- Python 3.14, pytest 9
- Test framework
- pytest
- Run the tests
python3 -m pytest
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/rothstein-trager/. The authors are listed in prov/2HTAE6.jsonl.
"""The Rothstein-Trager algorithm for the logarithmic part of a rational integral.
Polynomials are lists of Fractions from the constant term up (index = degree);
[] is the zero polynomial. Input: A, D in Q[x], D squarefree, deg A < deg D.
Output: the resultant R(z) = res_x(D, A - z D') and a list of pairs (q, S),
q in Q[z] monic squarefree and S in (Q[z]/q)[x] monic in x, such that
integral of A/D dx = sum over pairs, sum over roots c of q, of c log S(c, x).
Rational roots of R come out as pairs with deg q = 1. The other factors are
handled in Q[z]/(q) by the Euclidean algorithm, splitting q when a leading
coefficient is a zero divisor, so R is never factored. Cost model: operations
in Q, with n = deg D. The resultant takes O(n^4) operations (n + 1 elimination
determinants of order 2n - 1), each gcd attempt over Q[z]/(q) takes O(n^2 k^2)
with k = deg q, and there are at most 2n attempts, so O(n^5) in all. The
rational root search (line L2) is not a field operation: it tries the divisors
of two integer coefficients, which is exponential in their length.
"""
from __future__ import annotations
from fractions import Fraction
from math import gcd as igcd
Poly = list # list of Fraction, low degree first
def norm(p: Poly) -> Poly:
p = [Fraction(c) for c in p]
while p and p[-1] == 0:
p.pop()
return p
def padd(a: Poly, b: Poly) -> Poly:
n = max(len(a), len(b))
return norm([(a[i] if i < len(a) else 0) + (b[i] if i < len(b) else 0) for i in range(n)])
def pscale(a: Poly, c) -> Poly:
return norm([c * x for x in a])
def pmul(a: Poly, b: Poly) -> Poly:
if not a or not b:
return []
out = [Fraction(0)] * (len(a) + len(b) - 1)
for i, x in enumerate(a):
for j, y in enumerate(b):
out[i + j] += x * y
return norm(out)
def pdivmod(a: Poly, b: Poly) -> tuple[Poly, Poly]:
a, b = norm(a), norm(b)
q = [Fraction(0)] * max(len(a) - len(b) + 1, 0)
while a and len(a) >= len(b):
k, c = len(a) - len(b), a[-1] / b[-1]
q[k] = c
a = norm([a[i] - (c * b[i - k] if 0 <= i - k < len(b) else 0) for i in range(len(a))])
return norm(q), a
def pgcd(a: Poly, b: Poly) -> Poly:
a, b = norm(a), norm(b)
while b:
a, b = b, pdivmod(a, b)[1]
return pscale(a, 1 / a[-1]) if a else []
def deriv(a: Poly) -> Poly:
return norm([i * a[i] for i in range(1, len(a))])
def peval(a: Poly, x):
r = Fraction(0)
for c in reversed(a):
r = r * x + c
return r
def _det(M: list[list[Fraction]]) -> Fraction:
M = [row[:] for row in M]
n, d = len(M), Fraction(1)
for k in range(n):
p = next((r for r in range(k, n) if M[r][k] != 0), None)
if p is None:
return Fraction(0)
if p != k:
M[k], M[p] = M[p], M[k]
d = -d
d *= M[k][k]
for i in range(k + 1, n):
f = M[i][k] / M[k][k]
if f:
for j in range(k, n):
M[i][j] -= f * M[k][j]
return d
def resultant_formal(f: Poly, g: Poly, m: int, n: int) -> Fraction:
"""Sylvester determinant for formal degrees m and n (f has degree m)."""
N = m + n
if N == 0:
return Fraction(1)
fc = [f[m - k] if m - k < len(f) else Fraction(0) for k in range(m + 1)] # high first
gc = [g[n - k] if n - k < len(g) else Fraction(0) for k in range(n + 1)]
M = [[Fraction(0)] * N for _ in range(N)]
for i in range(n):
for k in range(m + 1):
M[i][i + k] = fc[k]
for j in range(m):
for k in range(n + 1):
M[n + j][j + k] = gc[k]
return _det(M)
def rt_resultant(A: Poly, D: Poly) -> Poly:
"""R(z) = res_x(D, A - z D') with formal degrees (n, n - 1), by interpolation in z."""
n = len(D) - 1
Dp = deriv(D)
pts = list(range(n + 1))
vals = [resultant_formal(D, padd(A, pscale(Dp, -z)), n, n - 1) for z in pts]
R: Poly = []
for i, zi in enumerate(pts): # Lagrange interpolation
basis, den = [Fraction(1)], Fraction(1)
for j, zj in enumerate(pts):
if j != i:
basis = pmul(basis, [Fraction(-zj), Fraction(1)])
den *= zi - zj
R = padd(R, pscale(basis, vals[i] / den))
return R
def rational_roots(R: Poly) -> list[Fraction]:
"""All rational roots of R, by the rational root test on the integer form."""
R = norm(R)
if not R:
return []
L = 1
for c in R:
L = L * c.denominator // igcd(L, c.denominator)
Z = [int(c * L) for c in R]
while Z and Z[0] == 0:
Z = Z[1:]
roots = [Fraction(0)] if len(Z) < len(R) else []
g = 0
for c in Z:
g = igcd(g, c)
Z = [c // g for c in Z]
a0, an = abs(Z[0]), abs(Z[-1])
def divs(v: int) -> list[int]:
small, d = [], 1
while d * d <= v:
if v % d == 0:
small.append(d)
d += 1
return small + [v // d for d in reversed(small) if d * d != v]
for p in divs(a0):
for q in divs(an):
if igcd(p, q) != 1:
continue
for s in (1, -1):
c = Fraction(s * p, q)
# q^k R(c) in integers, k = deg R, so that the test needs no fractions
k = len(Z) - 1
val = sum(z * (s * p) ** i * q ** (k - i) for i, z in enumerate(Z))
if val == 0 and c not in roots:
roots.append(c)
return roots
# Arithmetic in Q[z]/(q): elements are Polys of length < deg q.
class ZeroDivisor(Exception):
def __init__(self, factor: Poly):
self.factor = factor
def amul(a: Poly, b: Poly, q: Poly) -> Poly:
return pdivmod(pmul(a, b), q)[1]
def ainv(a: Poly, q: Poly) -> Poly:
"""Inverse of a mod q, or raise ZeroDivisor with a proper factor of q."""
r0, r1, s0, s1 = q, a, [], [Fraction(1)]
while r1:
quo, rem = pdivmod(r0, r1)
r0, r1 = r1, rem
s0, s1 = s1, padd(s0, pscale(pmul(quo, s1), -1))
if len(r0) > 1:
raise ZeroDivisor(pscale(r0, 1 / r0[-1]))
return pdivmod(pscale(s0, 1 / r0[0]), q)[1]
def xgcd_mod(F: list[Poly], G: list[Poly], q: Poly) -> list[Poly]:
"""Monic gcd in (Q[z]/q)[x]; F, G are lists of algebra elements, low degree in x first."""
def strip(P):
P = [pdivmod(c, q)[1] for c in P]
while P and not P[-1]:
P.pop()
return P
a, b = strip(F), strip(G)
while b:
inv = ainv(b[-1], q)
while a and len(a) >= len(b):
k, c = len(a) - len(b), amul(a[-1], inv, q)
a = strip([padd(a[i], pscale(amul(c, b[i - k], q), -1)) if 0 <= i - k < len(b) else a[i]
for i in range(len(a))])
a, b = b, a
inv = ainv(a[-1], q)
return [amul(c, inv, q) for c in a]
def rothstein_trager(A: Poly, D: Poly) -> tuple[Poly, list[tuple[Poly, list[Poly]]]]:
"""Return (R, pairs) with integral A/D = sum over pairs (q, S) of sum_{q(c)=0} c log S(c, x)."""
A, D = norm(A), norm(D)
if len(D) < 2 or len(A) >= len(D):
raise ValueError("need deg A < deg D and deg D >= 1")
Dp = deriv(D)
R = rt_resultant(A, D) # @line L1
pairs: list[tuple[Poly, list[Poly]]] = []
if not A:
return R, pairs
rest = pscale(R, 1 / R[-1])
sqf = pdivmod(rest, pgcd(rest, deriv(rest)))[0] # same roots as R, smaller coefficients
for c in rational_roots(sqf): # @line L2
rest = pdivmod(rest, [-c, Fraction(1)])[0]
while not pdivmod(rest, [-c, Fraction(1)])[1]:
rest = pdivmod(rest, [-c, Fraction(1)])[0]
if c != 0:
v = pgcd(D, padd(A, pscale(Dp, -c))) # @line L3
pairs.append(([-c, Fraction(1)], [[x] for x in v]))
q0 = pdivmod(rest, pgcd(rest, deriv(rest)))[0] if len(rest) > 1 else [] # @line L4
stack = [pscale(q0, 1 / q0[-1])] if len(q0) > 1 else []
while stack: # @line L5
q = stack.pop()
Fz = [[c] for c in D]
Gz = [padd([a], [Fraction(0), -d]) for a, d in
zip(A + [Fraction(0)] * (len(Dp) - len(A)), Dp + [Fraction(0)] * (len(A) - len(Dp)))]
try:
S = xgcd_mod(Fz, Gz, q) # @line L6
except ZeroDivisor as zd: # @line L7
g = zd.factor
stack.append(g)
stack.append(pdivmod(q, g)[0])
continue
pairs.append((q, S)) # @line L8
return R, pairs
test_rothstein_trager.py 147 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/rothstein-trager/. The authors are listed in prov/2HTAE6.jsonl.
import random
from fractions import Fraction as F
import pytest
from rothstein_trager import (deriv, norm, padd, pdivmod, pgcd, pmul, pscale,
resultant_formal, rothstein_trager, rt_resultant)
def trace(b, q):
"""Trace of multiplication by b on Q[z]/(q), with the basis 1, z, ..., z^(k-1)."""
k = len(q) - 1
t = F(0)
for i in range(k):
col = pdivmod(pmul(b, [F(0)] * i + [F(1)]), q)[1]
t += col[i] if i < len(col) else 0
return t
def amul(a, b, q):
return pdivmod(pmul(a, b), q)[1]
def derivative_numerator(pairs, D):
"""Sum over pairs of Tr( z * S'(z, x) * (D / S)(z, x) ): the numerator over D of d/dx of the answer."""
total = []
for q, S in pairs:
# exact division D / S in (Q[z]/q)[x]; S is monic in x
a = [[c] for c in D]
quo = [[] for _ in range(max(len(a) - len(S) + 1, 0))]
while len(a) >= len(S) and any(a):
k, c = len(a) - len(S), pdivmod(a[-1], q)[1]
quo[k] = c
a = [padd(a[i], pscale(amul(c, S[i - k], q), -1)) if 0 <= i - k < len(S) else a[i]
for i in range(len(a))]
a = [pdivmod(x, q)[1] for x in a]
while a and not a[-1]:
a.pop()
assert not any(a), "S does not divide D"
Sp = [pscale(S[i], i) for i in range(1, len(S))]
prod = [[] for _ in range(len(Sp) + len(quo))]
for i, x in enumerate(Sp):
for j, y in enumerate(quo):
prod[i + j] = padd(prod[i + j], amul(x, y, q))
z = [F(0), F(1)] if len(q) > 2 else [-q[0]]
coeffs = [trace(amul(z, c, q), q) for c in prod]
total = padd(total, coeffs)
return total
def check(A, D):
R, pairs = rothstein_trager(A, D)
assert derivative_numerator(pairs, D) == norm(A)
# the q of the pairs multiply to the squarefree part of R without the root 0
prod = [F(1)]
for q, _ in pairs:
prod = pmul(prod, q)
Rs = pdivmod(R, pgcd(R, deriv(R)))[0]
if Rs and Rs[0] == 0:
Rs = Rs[1:]
assert pscale(prod, 1) == pscale(Rs, 1 / Rs[-1])
return R, pairs
def test_examples():
R, pairs = check([F(1)], [F(-2), F(0), F(1)])
assert R == [F(1), F(0), F(-8)]
assert pairs == [([F(-1, 8), F(0), F(1)], [[F(0), F(-4)], [F(1)]])]
D = [F(-1), F(1), F(-1), F(1)] # (x - 1)(x^2 + 1)
R, pairs = check([F(3, 2), F(-3, 2), F(2)], D)
assert R == [F(5), F(-21), F(32), F(-16)]
assert pairs[0] == ([F(-1), F(1)], [[F(-1)], [F(1)]])
assert pairs[1] == ([F(5, 16), F(-1), F(1)], [[F(2), F(-4)], [F(1)]])
def test_edges():
with pytest.raises(ValueError):
rothstein_trager([F(1)], [F(3)])
with pytest.raises(ValueError):
rothstein_trager([F(1), F(1)], [F(1), F(1)])
assert rothstein_trager([], [F(1), F(1)])[1] == []
check([F(5)], [F(-3), F(2)]) # 5/(2x - 3)
check([F(1)], [F(0), F(1), F(0), F(1)]) # 1/(x^3 + x)
check([F(1)], [F(1), F(0), F(0), F(0), F(1)]) # 1/(x^4 + 1), one irreducible quartic in z
check([F(1), F(2)], [F(1), F(0), F(1)]) # (2x + 1)/(x^2 + 1)
# residues sqrt(2) twice and sqrt(3) once: q = (z^2 - 2)(z^2 - 3) must split at line L7
D = pmul([F(-2), F(0), F(0), F(0), F(1)], [F(-3), F(0), F(1)])
R, pairs = check([F(-12), F(-24), F(0), F(8), F(6)], D)
assert sorted(pairs) == [([F(-3), F(0), F(1)], [[F(0), F(-1)], [F(1)]]),
([F(-2), F(0), F(1)], [[F(0), F(-1)], [], [F(1)]])]
def test_resultant_matches_definition():
rng = random.Random(3)
for _ in range(400):
n = rng.randint(1, 4)
D = [F(rng.randint(-3, 3)) for _ in range(n)] + [F(rng.choice([1, 2]))]
A = [F(rng.randint(-3, 3)) for _ in range(n)]
R = rt_resultant(A, D)
for z in (F(1, 3), F(-2), F(7, 5)):
r = F(0)
for c in reversed(R):
r = r * z + c
assert r == resultant_formal(D, padd(A, pscale(deriv(D), -z)), n, n - 1)
def test_random_by_differentiation():
rng = random.Random(1976)
done = 0
while done < 80:
# D as a product of distinct small factors, which forces splittings and rational roots
factors = rng.sample([[F(-1), F(1)], [F(1), F(1)], [F(-2), F(1)], [F(1), F(0), F(1)],
[F(-2), F(0), F(1)], [F(-3), F(0), F(1)], [F(1), F(1), F(1)],
[F(0), F(1)], [F(2), F(0), F(1)]], rng.randint(1, 3))
D = [F(1)]
for f in factors:
D = pmul(D, f)
if len(pgcd(D, deriv(D))) > 1 or len(D) > 6:
continue
A = norm([F(rng.randint(-4, 4)) for _ in range(len(D) - 1)])
if not A:
continue
check(A, pscale(D, rng.choice([1, 2, -3])))
done += 1
def test_random_algebraic_residues():
# A/D = sum over two quadratic fields of sum_{c^2 = a} c S'/S with S = x^k + e + b c,
# so the residues are irrational and the gcd degrees can differ, which forces splittings
rng = random.Random(1984)
done = 0
while done < 20:
D, A = [F(1)], []
for a in rng.sample([2, 3, 5, 6, 7], 2):
k, b, e = rng.randint(1, 2), rng.choice([-2, -1, 1, 2]), rng.randint(-2, 2)
y = [F(e)] + [F(0)] * (k - 1) + [F(1)] # x^k + e
N = padd(pmul(y, y), [F(-b * b * a)]) # S(c) S(-c)
num = [F(0)] * (k - 1) + [F(-2 * a * b * k)] # -2abk x^(k-1)
A = padd(pmul(A, N), pmul(num, D))
D = pmul(D, N)
if len(pgcd(D, deriv(D))) > 1:
continue
_, pairs = check(A, D)
assert sum(len(q) - 1 for q, _ in pairs) >= 4
done += 1
What the next level needs
- the tests did not run in this build