Algorithm Search⌘K

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
kovacic_algorithm.pyRawGitHub
# 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

Read the full page about Kovacic's algorithm