Kovacic's algorithm in Python
Decides and solves second-order linear ODEs with rational coefficients in closed form.
- File
kovacic_algorithm.py, 567 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/kovacic-algorithm/. The authors are listed in prov/0QX1QC.jsonl.
"""Kovacic's algorithm for y'' = r y with r in Q(x), in exact rational arithmetic.
Polynomials are lists of Fractions, lowest degree first; rational functions
are pairs (num, den) in lowest terms with monic den. kovacic(s, t) looks at
r = s/t and returns (case, F), where F = [F_0, ..., F_m] lists the rational
coefficients of a polynomial F(w) = sum F_i w^i, monic in w, whose roots w
solve the Riccati equation w' + w^2 = r, so that y = exp(integral of w) is a
Liouvillian solution. It returns None when the equation has no Liouvillian
solution.
Scope: the poles of r must be rational (the denominator must split into
linear factors over Q), else ValueError. Case 1 may need square roots of
rational numbers; a family whose exponent sum is an integer only through
cancelling irrational square roots, or a pole of order 4 or more with an
irrational leading square root, raises NotImplementedError, since the
answer would need algebraic numbers.
"""
from fractions import Fraction
from itertools import product
from math import comb, factorial, isqrt
# ---------- polynomials over Q ----------
def trim(p):
p = list(p)
while p and p[-1] == 0:
p.pop()
return p
def add(p, q):
n = max(len(p), len(q))
return trim([(p[i] if i < len(p) else 0) + (q[i] if i < len(q) else 0) for i in range(n)])
def scale(p, c):
return trim([c * a for a in p])
def sub(p, q):
return add(p, scale(q, -1))
def mul(p, q):
if not p or not q:
return []
r = [Fraction(0)] * (len(p) + len(q) - 1)
for i, a in enumerate(p):
if a:
for j, b in enumerate(q):
r[i + j] += a * b
return trim(r)
def deriv(p):
return trim([i * p[i] for i in range(1, len(p))])
def shift(p, h):
"""p(x + h)."""
if not p or h == 0:
return list(p)
r = [Fraction(0)] * len(p)
for j, c in enumerate(p):
if c:
hp = Fraction(1)
for k in range(j, -1, -1):
r[k] += c * comb(j, k) * hp
hp *= h
return trim(r)
def divmod_poly(p, q):
p = trim(p)
quo = [Fraction(0)] * max(len(p) - len(q) + 1, 0)
while p and len(p) >= len(q):
c = Fraction(p[-1]) / q[-1]
k = len(p) - len(q)
quo[k] = c
p = sub(p, scale([0] * k + q, c))
return trim(quo), p
def gcd_poly(p, q):
p, q = trim(p), trim(q)
while q:
p, q = q, divmod_poly(p, q)[1]
return scale(p, Fraction(1) / p[-1]) if p else []
def _divisors(m):
m, out, d = abs(m), set(), 1
while d * d <= m:
if m % d == 0:
out.update((d, m // d))
d += 1
return sorted(out)
def _gcd(a, b):
while b:
a, b = b, a % b
return abs(a)
def rational_roots(p):
p = trim(p)
roots = []
if p and p[0] == 0:
roots.append(Fraction(0))
while p and p[0] == 0:
p = p[1:]
if len(p) <= 1:
return roots
den = 1
for c in p:
c = Fraction(c)
den = den * c.denominator // _gcd(den, c.denominator)
z = [int(Fraction(c) * den) for c in p]
g = 0
for c in z:
g = _gcd(g, c)
z = [c // g for c in z]
deg = len(z) - 1
for u in _divisors(z[0]):
for v in _divisors(z[-1]):
if _gcd(u, v) != 1:
continue
for su in (u, -u):
acc, up, vp = 0, 1, v ** deg
for c in z:
acc += c * up * vp
up *= su
vp //= v
if acc == 0:
roots.append(Fraction(su, v))
return roots
def poles(t):
"""{c: multiplicity} for the roots of t, which must split over Q."""
t = trim(t)
out = {}
for c in rational_roots(t):
lin = [-c, Fraction(1)]
while True:
q, rem = divmod_poly(t, lin)
if rem:
break
out[c] = out.get(c, 0) + 1
t = q
if len(t) != 1:
raise ValueError("the poles of r must be rational")
return out
# ---------- rational functions ----------
def rf(num, den=None):
num = trim([Fraction(c) for c in num])
den = [Fraction(1)] if den is None else trim([Fraction(c) for c in den])
if not num:
return ([], [Fraction(1)])
g = gcd_poly(num, den)
num, den = divmod_poly(num, g)[0], divmod_poly(den, g)[0]
c = den[-1]
return (scale(num, 1 / c), scale(den, 1 / c))
def rf_add(a, b):
return rf(add(mul(a[0], b[1]), mul(b[0], a[1])), mul(a[1], b[1]))
def rf_mul(a, b):
return rf(mul(a[0], b[0]), mul(a[1], b[1]))
def rf_scale(a, c):
return rf(scale(a[0], Fraction(c)), a[1])
def rf_der(a):
return rf(sub(mul(deriv(a[0]), a[1]), mul(a[0], deriv(a[1]))), mul(a[1], a[1]))
def rf_inv(a):
return rf(a[1], a[0])
def rf_sum(items):
out = rf([])
for a in items:
out = rf_add(out, a)
return out
def pole_term(coef, c, k):
"""coef / (x - c)^k."""
den = [Fraction(1)]
for _ in range(k):
den = mul(den, [-c, Fraction(1)])
return rf([coef], den)
ZERO = ([], [Fraction(1)])
# ---------- series ----------
def series_div(a, b, n):
a = [Fraction(c) for c in a] + [Fraction(0)] * n
out = []
for k in range(n):
v = a[k] - sum(out[i] * b[k - i] for i in range(max(0, k - len(b) + 1), k))
out.append(v / b[0])
return out
def rational_sqrt(q):
q = Fraction(q)
if q < 0:
return None
a, b = isqrt(q.numerator), isqrt(q.denominator)
return Fraction(a, b) if a * a == q.numerator and b * b == q.denominator else None
def sqrt_series(s, n):
a0 = rational_sqrt(s[0])
if a0 is None:
raise NotImplementedError("leading square root is irrational")
a = [a0]
for k in range(1, n):
a.append((s[k] - sum(a[i] * a[k - i] for i in range(1, k))) / (2 * a0))
return a
def laurent_at(s, t, c, m, n):
"""r = sum L[k] (x - c)^(k - m), for k < n."""
sc, tc = shift(s, c), shift(t, c)
return series_div(sc, tc[m:], n)
def laurent_at_infinity(s, t, n):
"""r = sum M[k] x^(-o - k) with o = deg t - deg s the order at infinity, for k < n."""
return series_div(list(reversed(s)), list(reversed(t)), n)
def squarefree_part(q):
"""(rational part, squarefree integer D) with sqrt(q) = part * sqrt(D)."""
q = Fraction(q)
if q == 0:
return Fraction(0), 1
num = q.numerator * q.denominator
sign = -1 if num < 0 else 1
num, D, f = abs(num), 1, 2
part = 1
while f * f <= num:
while num % (f * f) == 0:
num //= f * f
part *= f
f += 1
D = sign * num
return Fraction(part, q.denominator), D
# ---------- linear ODEs: polynomial solutions ----------
def nullspace(rows, ncols):
rows = [list(r) for r in rows if any(r)]
pivots, rank = [], 0
for col in range(ncols):
piv = next((i for i in range(rank, len(rows)) if rows[i][col] != 0), None)
if piv is None:
continue
rows[rank], rows[piv] = rows[piv], rows[rank]
inv = Fraction(1) / rows[rank][col]
rows[rank] = [a * inv for a in rows[rank]]
for i in range(len(rows)):
if i != rank and rows[i][col] != 0:
f = rows[i][col]
rows[i] = [a - f * b for a, b in zip(rows[i], rows[rank])]
pivots.append(col)
rank += 1
basis = []
for free in range(ncols):
if free in pivots:
continue
v = [Fraction(0)] * ncols
v[free] = Fraction(1)
for i, pc in enumerate(pivots):
v[pc] = -rows[i][free]
basis.append(v)
return basis
def solve_columns(cols):
height = max((len(c) for c in cols), default=0)
rows = [[c[e] if e < len(c) else Fraction(0) for c in cols] for e in range(height)]
basis = nullspace(rows, len(cols))
return trim(basis[0]) if basis else None
def poly_solution(coeffs, d):
"""A nonzero polynomial P of degree <= d with sum coeffs[k] P^(k) = 0, or None."""
den = [Fraction(1)]
for a in coeffs:
den = divmod_poly(mul(den, a[1]), gcd_poly(den, a[1]))[0]
nums = [divmod_poly(mul(a[0], den), a[1])[0] for a in coeffs]
cols = []
for j in range(d + 1):
mono, col = [Fraction(0)] * j + [Fraction(1)], []
for k, nk in enumerate(nums):
col = add(col, mul(nk, mono))
mono = deriv(mono)
cols.append(col)
return solve_columns(cols)
# ---------- the three cases ----------
def _alpha_pm(b):
"""1/2 +- sqrt(1 + 4b)/2 as surds (p, q, D) meaning p + q sqrt(D)."""
part, D = squarefree_part(1 + 4 * Fraction(b))
if D == 1:
return [(Fraction(1, 2) + part / 2, Fraction(0), 1), (Fraction(1, 2) - part / 2, Fraction(0), 1)]
if part == 0:
return [(Fraction(1, 2), Fraction(0), 1)] * 2
return [(Fraction(1, 2), part / 2, D), (Fraction(1, 2), -part / 2, D)]
def case1_local(s, t, P, o):
"""For each pole and for infinity: a list of (sqrt part, alpha) choices."""
local = []
for c, m in sorted(P.items()):
if m == 1:
local.append([(ZERO, (Fraction(1), Fraction(0), 1))])
elif m == 2:
b = laurent_at(s, t, c, m, 1)[0]
local.append([(ZERO, al) for al in _alpha_pm(b)])
else:
v = m // 2
L = laurent_at(s, t, c, m, v)
a = sqrt_series(L, v - 1) if v >= 2 else []
sq_poly = a[:v - 1]
sq = rf_sum([pole_term(a[k], c, v - k) for k in range(v - 1)])
b = L[v - 1] - sum(sq_poly[i] * sq_poly[v - 1 - i] for i in range(v)
if i < len(sq_poly) and v - 1 - i < len(sq_poly))
# leading coefficient a_0 of sqrt(r) itself
a0 = sqrt_series(L, 1)[0]
local.append([(sq, ((v + b / a0) / 2, Fraction(0), 1)),
(rf_scale(sq, -1), ((v - b / a0) / 2, Fraction(0), 1))])
if o > 2:
local.append([(ZERO, (Fraction(0), Fraction(0), 1)), (ZERO, (Fraction(1), Fraction(0), 1))])
elif o == 2:
b = Fraction(s[-1]) / t[-1]
local.append([(ZERO, al) for al in _alpha_pm(b)])
else:
v = -o // 2
M = laurent_at_infinity(s, t, v + 2)
a = sqrt_series(M, v + 1)
sq = rf([a[v - i] for i in range(v + 1)])
b = M[v + 1] - sum(a[i] * a[v + 1 - i] for i in range(1, v + 1))
local.append([(sq, ((b / a[0] - v) / 2, Fraction(0), 1)),
(rf_scale(sq, -1), ((-b / a[0] - v) / 2, Fraction(0), 1))])
return local
def case1(s, t, P, o, r, trace):
local = case1_local(s, t, P, o) # @line L4
for choice in product(*local): # @line L5
*at_poles, at_inf = choice
p = at_inf[1][0] - sum(al[0] for _, al in at_poles)
irr = {}
for sign, (_, al) in [(1, at_inf)] + [(-1, x) for x in at_poles]:
if al[1]:
irr[al[2]] = irr.get(al[2], 0) + sign * al[1]
if any(irr.values()):
continue
if p.denominator != 1 or p < 0:
continue
if irr:
raise NotImplementedError("family needs algebraic numbers")
d = int(p)
w = rf_sum( # @line L6
[sq for sq, _ in choice] +
[pole_term(al[0], c, 1) for (c, _), (_, al) in zip(sorted(P.items()), at_poles)])
rest = rf_add(rf_add(rf_der(w), rf_mul(w, w)), rf_scale(r, -1))
Pd = poly_solution([rest, rf_scale(w, 2), rf([1])], d) # @line L7
if trace is not None:
trace.append((1, d, w, Pd))
if Pd is not None:
w1 = rf_add(w, rf(deriv(Pd), Pd))
return [rf_scale(w1, -1), rf([1])]
return None
def _ints(values):
out = []
for v in values:
if v.denominator == 1 and v not in out:
out.append(v)
return out
def _sqrt_terms(b, mult):
"""[m * sqrt(1 + 4b) for m in mult], keeping only rational values."""
q = rational_sqrt(1 + 4 * Fraction(b))
if q is None:
return [Fraction(0)] if 0 in mult else []
return [m * q for m in mult]
def case2(s, t, P, o, r, trace):
local = []
for c, m in sorted(P.items()):
if m == 1:
local.append([Fraction(4)])
elif m == 2:
b = laurent_at(s, t, c, m, 1)[0]
local.append(_ints([2 + x for x in _sqrt_terms(b, [0, 2, -2])]))
else:
local.append([Fraction(m)])
if o > 2:
e_inf = [Fraction(0), Fraction(2), Fraction(4)]
elif o == 2:
e_inf = _ints([2 + x for x in _sqrt_terms(Fraction(s[-1]) / t[-1], [0, 2, -2])])
else:
e_inf = [Fraction(o)]
cs = sorted(P)
for ei in e_inf:
for es in product(*local):
d2 = ei - sum(es)
if d2 < 0 or d2 % 2 != 0:
continue
d = int(d2 // 2)
th = rf_sum([pole_term(e / 2, c, 1) for e, c in zip(es, cs)])
th1, th2 = rf_der(th), rf_der(rf_der(th))
c1 = rf_sum([rf_scale(rf_mul(th, th), 3), rf_scale(th1, 3), rf_scale(r, -4)])
c0 = rf_sum([th2, rf_scale(rf_mul(th, th1), 3), rf_mul(th, rf_mul(th, th)),
rf_scale(rf_mul(r, th), -4), rf_scale(rf_der(r), -2)])
Pd = poly_solution([c0, c1, rf_scale(th, 3), rf([1])], d)
if trace is not None:
trace.append((2, d, th, Pd))
if Pd is not None:
phi = rf_add(th, rf(deriv(Pd), Pd))
const = rf_sum([rf_scale(rf_der(phi), Fraction(1, 2)),
rf_scale(rf_mul(phi, phi), Fraction(1, 2)), rf_scale(r, -1)])
return [const, rf_scale(phi, -1), rf([1])]
return None
def case3(s, t, P, o, r, trace):
cs = sorted(P)
S = [Fraction(1)]
for c in cs:
S = mul(S, [-c, Fraction(1)])
S1 = deriv(S)
S2r = divmod_poly(mul(mul(S, S), r[0]), r[1])
assert not S2r[1]
S2r = S2r[0]
gamma = Fraction(s[-1]) / t[-1] if o == 2 else Fraction(0)
for n in (4, 6, 12): # @line L3
ks = [Fraction(12 * k, n) for k in range(-n // 2, n // 2 + 1)]
local = []
for c in cs:
if P[c] == 1:
local.append([Fraction(12)])
else:
b = laurent_at(s, t, c, 2, 1)[0]
local.append(_ints([6 + x for x in _sqrt_terms(b, ks)]))
e_inf = _ints([6 + x for x in _sqrt_terms(gamma, ks)])
for ei in e_inf:
for es in product(*local):
dd = Fraction(n, 12) * (ei - sum(es))
if dd < 0 or dd.denominator != 1:
continue
d = int(dd)
# S * theta as a polynomial
Sth = []
for e, c in zip(es, cs):
Sth = add(Sth, scale(divmod_poly(S, [-c, Fraction(1)])[0], Fraction(n, 12) * e))
seqs, cols = [], []
for j in range(d + 1):
Pi = {n + 1: [], n: [Fraction(0)] * j + [Fraction(-1)]}
for i in range(n, -1, -1):
Pi[i - 1] = add(add(scale(mul(S, deriv(Pi[i])), -1),
mul(sub(scale(S1, n - i), Sth), Pi[i])),
scale(mul(S2r, Pi[i + 1]), -(n - i) * (i + 1)))
seqs.append(Pi)
cols.append(Pi[-1])
v = solve_columns(cols)
if trace is not None:
trace.append((3, n, d, v))
if v is not None:
F = []
for i in range(n + 1):
Pi = []
for j, vj in enumerate(v):
Pi = add(Pi, scale(seqs[j][i], vj))
Sp = [Fraction(1)]
for _ in range(i):
Sp = mul(Sp, S)
F.append(rf(scale(mul(Sp, Pi), Fraction(1, factorial(n - i)))))
lead = rf_inv(F[-1])
return [rf_mul(f, lead) for f in F]
return None
def kovacic(s, t, trace=None):
"""Liouvillian solutions of y'' = (s/t) y: (case, F) or None."""
r = rf(s, t) # @line L1
s, t = r
P = poles(t)
o = len(t) - len(s) if s else 10 ** 9
cond1 = all(m == 1 or m % 2 == 0 for m in P.values()) and (o % 2 == 0 or o > 2)
cond2 = any(m == 2 or (m % 2 == 1 and m > 2) for m in P.values())
cond3 = all(m <= 2 for m in P.values()) and o >= 2
for case, ok, run in ((1, cond1, case1), (2, cond2, case2), (3, cond3, case3)): # @line L2
if not ok:
continue
F = run(s, t, P, o, r, trace) # lines L3 to L7, in case1, case2 and case3
if F is not None:
return case, F # @line L8
return None # @line L9
# ---------- an independent check ----------
def verify(s, t, F):
"""True if every root w of F solves w' + w^2 = s/t: F divides F_x + F_w (r - w^2).
Denominators are cleared first, so the test is a pseudo-division in w with
polynomial coefficients in x and needs no gcd computations.
"""
s = trim([Fraction(c) for c in s])
t = trim([Fraction(c) for c in t])
m = len(F) - 1
D = [Fraction(1)]
for f in F:
D = mul(D, divmod_poly(f[1], gcd_poly(D, f[1]))[0])
P = [mul(f[0], divmod_poly(D, f[1])[0]) for f in F]
dD = deriv(D)
def coef(k):
return P[k] if 0 <= k <= m else []
# G = D^2 t (F_x + F_w (s/t - w^2)), coefficient of w^k
G = [add(add(mul(t, sub(mul(deriv(coef(k)), D), mul(coef(k), dD))),
scale(mul(mul(coef(k + 1), D), s), k + 1)),
scale(mul(mul(coef(k - 1), D), t), -(k - 1)))
for k in range(m + 2)]
lead = P[m]
for k in range(len(G) - 1, m - 1, -1):
c = G[k]
if c:
G = [mul(g, lead) for g in G]
for i in range(m + 1):
G[k - m + i] = sub(G[k - m + i], mul(c, P[i]))
G = G[:k]
return all(not g for g in G)
test_kovacic_algorithm.py 127 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/kovacic-algorithm/. The authors are listed in prov/0QX1QC.jsonl.
import random
from fractions import Fraction as Q
import pytest
from kovacic_algorithm import kovacic, rf, rf_add, rf_der, rf_mul, rf_scale, pole_term, verify
def rsum(terms):
a = rf([])
for n, d in terms:
a = rf_add(a, rf(n, d))
return a
def riccati(w):
"""r = w' + w^2."""
return rf_add(rf_der(w), rf_mul(w, w))
def check(r, expect_case=None):
out = kovacic(*r)
if expect_case is None:
assert out is None
return out
assert out is not None
case, F = out
assert case == expect_case if isinstance(expect_case, int) else case in expect_case
assert verify(r[0], r[1], F)
return out
def test_worked_examples():
trace = []
case, F = kovacic([3, 0, 1], [1], trace) # r = x^2 + 3
assert case == 1 and len(trace) == 1 and trace[0][1] == 1
assert F == [rf([-1, 0, -1], [0, 1]), rf([1])] # w = x + 1/x, y = x e^(x^2/2)
case, F = kovacic([-3, 16], [0, 0, 16]) # r = 1/x - 3/(16 x^2)
assert case == 2
assert F == [rf([Q(1, 16), -1], [0, 0, 1]), rf([Q(-1, 2)], [0, 1]), rf([1])]
assert kovacic([0, 1], [1]) is None # Airy
assert not verify([3, 0, 1], [1], [rf([-1, 0, -1]), rf([1])]) # w = x is wrong
assert not verify([4, 0, 1], [1], [rf([-1, 0, -1], [0, 1]), rf([1])]) # wrong r
@pytest.mark.parametrize("lam,mu,nu,n", [
(Q(1, 2), Q(1, 3), Q(1, 3), 4), # tetrahedral
(Q(1, 2), Q(1, 3), Q(1, 4), 6), # octahedral
(Q(1, 2), Q(1, 3), Q(1, 5), 12), # icosahedral
])
def test_finite_primitive_groups(lam, mu, nu, n):
# Riemann equation with exponent differences lam, mu, nu at 0, 1, infinity
r = rsum([([-(1 - lam * lam) / 4], [0, 0, 1]), ([-(1 - mu * mu) / 4], [1, -2, 1]),
([-(lam * lam + mu * mu - nu * nu - 1) / 4], [0, -1, 1])])
case, F = check(r, 3)
assert len(F) - 1 == n
def test_weber_family():
# y'' = (x^2 + lam) y has a Liouvillian solution exactly when lam is an odd integer
for lam in range(-9, 10):
check(([lam, 0, 1], [1]), 1 if lam % 2 else None)
def test_modified_bessel_family():
# nu = k/2: Liouvillian exactly when nu + 1/2 is an integer, that is k odd
for k in range(0, 10):
r = rsum([([1], [1]), ([Q(k * k - 1, 4)], [0, 0, 1])])
check(r, (1, 2) if k % 2 else None)
def test_edge_cases():
assert kovacic([], [1])[0] == 1 # y'' = 0
assert kovacic([0, 0, 0, 1], [1]) is None # odd order at infinity
assert kovacic([1], [0, 1]) is None # y'' = y/x
with pytest.raises(ValueError):
kovacic([1], [-2, 0, 1]) # poles at +-sqrt(2)
def random_lin(rng):
return Q(rng.randint(-3, 3))
def test_planted_case_one():
rng = random.Random(1986)
halves = [Q(k) for k in range(-2, 4)] + [Q(1, 2), Q(3, 2), Q(-1, 2)]
for _ in range(500):
w = rf([random_lin(rng) for _ in range(rng.randint(0, 2))])
for c in rng.sample(range(-3, 4), rng.randint(0, 2)):
w = rf_add(w, pole_term(rng.choice(halves), Q(c), 1))
check(riccati(w), 1)
def test_planted_case_two():
rng = random.Random(1981)
for _ in range(40):
q = [Q(1)]
for c in rng.sample(range(-3, 4), rng.randint(1, 2)):
q = rf_mul(rf(q), rf([-c, 1]))[0]
B = rf([rng.choice([1, -1, 2, Q(1, 2)])])
for c in rng.sample(range(-3, 4), rng.randint(0, 2)):
B = rf_mul(B, rf([1], [-c, 1]) if rng.random() < 0.5 else rf([-c, 1]))
# w = A + B sqrt(q) with A = -B'/(2B) - q'/(4q) has w' + w^2 = A' + A^2 + q B^2
A = rf_add(rf_scale(rf_mul(rf_der(B), rf(B[1], B[0])), Q(-1, 2)),
rf_scale(rf_mul(rf_der(rf(q)), rf([1], q)), Q(-1, 4)))
r = rf_add(riccati(A), rf_mul(rf(q), rf_mul(B, B)))
check(r, (1, 2))
def test_random_inputs_are_sound():
rng = random.Random(4096)
found = 0
for _ in range(500):
t = [Q(1)]
for _ in range(rng.randint(0, 3)):
t = rf_mul(rf(t), rf([rng.randint(-2, 2), 1]))[0]
s = [Q(rng.randint(-4, 4)) for _ in range(rng.randint(1, 4))]
try:
out = kovacic(s, t)
except NotImplementedError:
continue
if out is not None:
found += 1
assert verify(s, t, out[1])
assert found > 0
What the next level needs
- the tests did not run in this build