Cylindrical algebraic decomposition in Python
Decomposes R^n into cells compatible with a polynomial family, enabling quantifier elimination.
- File
cylindrical_algebraic_decomposition.py, 726 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/cylindrical-algebraic-decomposition/. The authors are listed in prov/P06M5D.jsonl.
"""Cylindrical algebraic decomposition of the plane, exact over Q.
cad(F) takes polynomials in Q[x, y] (dicts from (i, j) to Fractions, meaning
c x^i y^j) and returns an F-sign-invariant cylindrical decomposition of R^2:
a list of columns over the cells of the x-axis, each with its stack of cells
in y, a sample point and the sign vector of F at it.
Algebraic numbers are given by a squarefree defining polynomial over Q and an
isolating interval with rational endpoints. Arithmetic in Q(alpha) uses
dynamic evaluation: when a gcd with the defining polynomial is nontrivial, the
defining polynomial is replaced by the factor that vanishes at alpha.
Cost model: operations in Q on numbers whose size grows with the degrees.
For a product S of total degree D, the projection P has degree O(D^2) and
each stack at most 2D + 1 cells, so there are O(D^3) cells; the number of
field operations is polynomial in D. The bound for n variables, doubly
exponential in n, does not arise here since n = 2.
"""
from fractions import Fraction
from typing import Dict, List, Optional, Sequence, Tuple
UPoly = List[Fraction] # low degree first
BiPoly = Dict[Tuple[int, int], Fraction]
YPoly = List[UPoly] # polynomial in y with coefficients in Q[x]
# ---------------------------------------------------------------- univariate
def trim(p: Sequence[Fraction]) -> UPoly:
p = list(p)
while p and p[-1] == 0:
p.pop()
return p
def uadd(p: UPoly, q: UPoly) -> UPoly:
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 uscale(p: UPoly, c: Fraction) -> UPoly:
return trim([c * a for a in p])
def umul(p: UPoly, q: UPoly) -> UPoly:
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 udivmod(p: UPoly, q: UPoly) -> Tuple[UPoly, UPoly]:
p, q = trim(p), trim(q)
if len(p) < len(q):
return [], p
r = list(p)
out = [Fraction(0)] * (len(p) - len(q) + 1)
inv = 1 / q[-1]
for k in range(len(p) - len(q), -1, -1):
c = r[k + len(q) - 1] * inv
out[k] = c
if c:
for i, b in enumerate(q):
r[k + i] -= c * b
return trim(out), trim(r[: len(q) - 1])
def umonic(p: UPoly) -> UPoly:
p = trim(p)
return [a / p[-1] for a in p] if p else []
def ugcd(p: UPoly, q: UPoly) -> UPoly:
p, q = trim(p), trim(q)
while q:
p, q = q, udivmod(p, q)[1]
return umonic(p)
def uderiv(p: UPoly) -> UPoly:
return trim([i * p[i] for i in range(1, len(p))])
def ueval(p: UPoly, x: Fraction) -> Fraction:
v = Fraction(0)
for a in reversed(p):
v = v * x + a
return v
def usqfree(p: UPoly) -> UPoly:
p = trim(p)
if len(p) <= 1:
return umonic(p)
return umonic(udivmod(p, ugcd(p, uderiv(p)))[0])
def sign(v: Fraction) -> int:
return (v > 0) - (v < 0)
def sturm(p: UPoly) -> List[UPoly]:
seq = [trim(p), uderiv(p)]
while seq[-1]:
seq.append(uscale(udivmod(seq[-2], seq[-1])[1], Fraction(-1)))
return seq[:-1]
def variations(signs: List[int]) -> int:
s = [v for v in signs if v]
return sum(1 for a, b in zip(s, s[1:]) if a != b)
def int_poly(p: UPoly) -> List[int]:
"""A positive multiple of p with coprime integer coefficients (same signs everywhere)."""
den = 1
for c in p:
den = den * c.denominator // gcd_int(den, c.denominator)
q = [int(c * den) for c in p]
g = 0
for c in q:
g = gcd_int(g, c)
return [c // g for c in q] if g else q
def gcd_int(a: int, b: int) -> int:
a, b = abs(a), abs(b)
while b:
a, b = b, a % b
return a
def isign(q: List[int], x: Fraction) -> int:
"""Sign of q(x) for x = n/e, from the integer e^k q(n/e) with e > 0."""
n, e = x.numerator, x.denominator
k = len(q) - 1
ep = [1] * (k + 1)
for i in range(1, k + 1):
ep[i] = ep[i - 1] * e
v = 0
for i in range(k, -1, -1):
v = v * n + q[i] * ep[k - i]
return (v > 0) - (v < 0)
class SturmCounter:
"""Sturm sequence of a squarefree p, with sign variations cached by point."""
def __init__(self, p: UPoly):
self.seq = [int_poly(q) for q in sturm(p)]
self.cache: Dict[Fraction, int] = {}
def var(self, x: Fraction) -> int:
if x not in self.cache:
self.cache[x] = variations([isign(q, x) for q in self.seq])
return self.cache[x]
def count(self, a: Fraction, b: Fraction) -> int:
"""Distinct real roots in (a, b); a and b are not roots."""
return self.var(a) - self.var(b)
def isolate(p: UPoly) -> List[Tuple[Fraction, Fraction]]:
"""Disjoint open intervals with non-root rational endpoints, one per real root of squarefree p."""
p = trim(p)
if len(p) <= 1:
return []
sc = SturmCounter(p)
B = 1 + max(abs(c / p[-1]) for c in p[:-1])
out, stack = [], [(-B, B)]
while stack:
a, b = stack.pop()
k = sc.count(a, b)
if k == 0:
continue
if k == 1:
out.append((a, b))
continue
m, w = (a + b) / 2, (b - a) / 4
while ueval(p, m) == 0:
m, w = m + w, w / 2
stack += [(a, m), (m, b)]
return sorted(out)
def interpolate(xs: List[Fraction], ys: List[Fraction]) -> UPoly:
"""Newton interpolation through the points (xs[i], ys[i])."""
coef = list(ys)
n = len(xs)
for j in range(1, n):
for i in range(n - 1, j - 1, -1):
coef[i] = (coef[i] - coef[i - 1]) / (xs[i] - xs[i - j])
p: UPoly = []
for i in range(n - 1, -1, -1):
p = uadd(umul(p, [-xs[i], Fraction(1)]), [coef[i]])
return p
def det_int(M: List[List[int]]) -> int:
"""Determinant of an integer matrix by Bareiss fraction-free elimination."""
M = [row[:] for row in M]
n, sgn, prev = len(M), 1, 1
for k in range(n - 1):
if M[k][k] == 0:
piv = next((r for r in range(k + 1, n) if M[r][k] != 0), None)
if piv is None:
return 0
M[k], M[piv] = M[piv], M[k]
sgn = -sgn
for i in range(k + 1, n):
for j in range(k + 1, n):
M[i][j] = (M[i][j] * M[k][k] - M[i][k] * M[k][j]) // prev
prev = M[k][k]
return sgn * M[n - 1][n - 1]
def sylvester_res(a: List[Fraction], b: List[Fraction]) -> Fraction:
"""Resultant of a and b with formal degrees len(a)-1 and len(b)-1."""
m, n = len(a) - 1, len(b) - 1
if m == 0:
return a[0] ** n
if n == 0:
return b[0] ** m
la = lb = 1
for c in a:
la = la * c.denominator // gcd_int(la, c.denominator)
for c in b:
lb = lb * c.denominator // gcd_int(lb, c.denominator)
ai, bi = [int(c * la) for c in reversed(a)], [int(c * lb) for c in reversed(b)]
N = m + n
rows = [[0] * i + ai + [0] * (N - m - 1 - i) for i in range(n)]
rows += [[0] * i + bi + [0] * (N - n - 1 - i) for i in range(m)]
return Fraction(det_int(rows), la ** n * lb ** m)
# ---------------------------------------------------------------- bivariate
def to_ypoly(f: BiPoly) -> YPoly:
if not f:
return []
dy = max(j for _, j in f)
out: YPoly = [[] for _ in range(dy + 1)]
for (i, j), c in f.items():
col = out[j] + [Fraction(0)] * (i + 1 - len(out[j]))
col[i] += c
out[j] = trim(col)
while out and not out[-1]:
out.pop()
return out
def ymul(A: YPoly, B: YPoly) -> YPoly:
if not A or not B:
return []
out: YPoly = [[] for _ in range(len(A) + len(B) - 1)]
for i, a in enumerate(A):
for j, b in enumerate(B):
out[i + j] = uadd(out[i + j], umul(a, b))
return out
def content(A: YPoly) -> UPoly:
g: UPoly = []
for c in A:
g = ugcd(g, c)
return g
def primitive(A: YPoly) -> YPoly:
g = content(A)
return [udivmod(c, g)[0] for c in A] if A else []
def yderiv(A: YPoly) -> YPoly:
out = [uscale(A[j], Fraction(j)) for j in range(1, len(A))]
while out and not out[-1]:
out.pop()
return out
def prem(A: YPoly, B: YPoly) -> YPoly:
"""Pseudo-remainder of A by B in Q[x][y]."""
R = [list(c) for c in A]
lb, db = B[-1], len(B) - 1
while len(R) - 1 >= db and R:
lr, shift = R[-1], len(R) - 1 - db
R = [umul(c, lb) for c in R]
for j, c in enumerate(B):
R[j + shift] = uadd(R[j + shift], uscale(umul(c, lr), Fraction(-1)))
while R and not R[-1]:
R.pop()
return R
def ygcd(A: YPoly, B: YPoly) -> YPoly:
"""Primitive gcd in Q[x][y] of primitive A and B, by the primitive remainder sequence."""
A, B = primitive(A), primitive(B)
while B and len(B) > 1:
A, B = B, primitive(prem(A, B))
if B: # a nonzero constant in y: the gcd is 1
return [[Fraction(1)]]
return A
def yeval_x(A: YPoly, x0: Fraction) -> List[Fraction]:
return [ueval(c, x0) for c in A]
def res_y(A: YPoly, B: YPoly) -> UPoly:
"""res_y(A, B) in Q[x], by evaluation at x = 0, 1, ... and interpolation."""
dA = max(len(c) for c in A) - 1
dB = max(len(c) for c in B) - 1
D = (len(A) - 1) * dB + (len(B) - 1) * dA
xs = [Fraction(k) for k in range(D + 1)]
return interpolate(xs, [sylvester_res(yeval_x(A, x0), yeval_x(B, x0)) for x0 in xs])
# ---------------------------------------------------------------- algebraic points
class RealAlg:
"""A real algebraic number: squarefree p over Q with one root alpha in (a, b), or exact."""
def __init__(self, p: UPoly, a: Fraction, b: Fraction, exact: Optional[Fraction] = None):
self.p, self.a, self.b, self.exact = umonic(p), a, b, exact
self._src: Optional[UPoly] = None
if exact is not None:
self.p, self.a, self.b = [-exact, Fraction(1)], exact, exact
@staticmethod
def rational(r: Fraction) -> "RealAlg":
return RealAlg([-r, Fraction(1)], r, r, r)
def refine(self, steps: int = 1) -> None:
"""Halve the isolating interval `steps` times, with integer sign evaluation."""
for _ in range(steps):
if self.exact is not None:
return
if self._src is not self.p:
self._src, self._pi = self.p, int_poly(self.p)
self._sa = isign(self._pi, self.a)
m = (self.a + self.b) / 2
v = isign(self._pi, m)
if v == 0:
self.__init__(self.p, m, m, m)
return
if v == self._sa:
self.a = m
else:
self.b = m
def has_root(self, g: UPoly) -> bool:
"""For a divisor g of p: does g vanish at alpha?"""
if self.exact is not None:
return ueval(g, self.exact) == 0
return sign(ueval(g, self.a)) * sign(ueval(g, self.b)) < 0
def reduce(self, q: UPoly) -> UPoly:
q = trim(q)
return q if len(q) < len(self.p) else udivmod(q, self.p)[1]
def is_zero(self, q: UPoly) -> bool:
q = trim(q)
if not q:
return True
if self.exact is not None:
return ueval(q, self.exact) == 0
lo, hi = interval_eval(q, self.a, self.b)
if lo > 0 or hi < 0: # the interval already proves q(alpha) != 0
return False
g = ugcd(self.p, q)
if len(g) <= 1:
return False
if self.has_root(g):
self.p = g
return True
self.p = udivmod(self.p, g)[0]
return False
def inverse(self, q: UPoly) -> UPoly:
"""Inverse of q modulo p; q must be nonzero at alpha (checked first with is_zero)."""
g = ugcd(self.p, q)
if len(g) > 1: # q vanishes at other roots of p only: drop them
self.p = udivmod(self.p, g)[0]
r0, r1, s0, s1 = self.p, self.reduce(q), [], [Fraction(1)]
while len(r1) > 1:
quo, rem = udivmod(r0, r1)
r0, r1, s0, s1 = r1, rem, s1, uadd(s0, uscale(umul(quo, s1), Fraction(-1)))
return self.reduce(uscale(s1, 1 / r1[0]))
def sign_of(self, q: UPoly) -> int:
q = trim(q)
if self.exact is not None:
return sign(ueval(q, self.exact))
if self.is_zero(q):
return 0
while True:
lo, hi = interval_eval(q, self.a, self.b)
if lo > 0 or hi < 0:
return 1 if lo > 0 else -1
self.refine(4)
def ipow(lo: int, hi: int, k: int) -> Tuple[int, int]:
"""Bounds of x^k for x in [lo, hi]."""
if k == 0:
return 1, 1
a, b = lo ** k, hi ** k
if k % 2 == 0 and lo < 0 < hi:
return 0, max(a, b)
return min(a, b), max(a, b)
def scaled(a: Fraction, b: Fraction) -> Tuple[int, int, int]:
"""[a, b] = [A, B] / D with integers A, B and D > 0."""
D = a.denominator * b.denominator // gcd_int(a.denominator, b.denominator)
return int(a * D), int(b * D), D
def interval_eval(q: UPoly, a: Fraction, b: Fraction) -> Tuple[int, int]:
"""A positive multiple of an enclosure of q over [a, b]; only its sign pattern is used."""
qi = int_poly(trim(q))
A, B, D = scaled(a, b)
k = len(qi) - 1
lo = hi = 0
for i, c in enumerate(qi):
if c:
pl, ph = ipow(A, B, i)
w = D ** (k - i)
lo, hi = (lo + c * pl * w, hi + c * ph * w) if c > 0 else (lo + c * ph * w, hi + c * pl * w)
return lo, hi
def interval_eval2(f: BiPoly, X: Tuple[Fraction, Fraction], Y: Tuple[Fraction, Fraction]) -> Tuple[int, int]:
"""A positive multiple of an enclosure of f over the box X x Y."""
den = 1
for c in f.values():
den = den * c.denominator // gcd_int(den, c.denominator)
A1, B1, D1 = scaled(*X)
A2, B2, D2 = scaled(*Y)
dx = max(i for i, _ in f)
dy = max(j for _, j in f)
lo = hi = 0
for (i, j), c in f.items():
c = int(c * den)
xl, xh = ipow(A1, B1, i)
yl, yh = ipow(A2, B2, j)
ps = [xl * yl, xl * yh, xh * yl, xh * yh]
pl, ph = min(ps), max(ps)
w = D1 ** (dx - i) * D2 ** (dy - j)
lo, hi = (lo + c * pl * w, hi + c * ph * w) if c > 0 else (lo + c * ph * w, hi + c * pl * w)
return lo, hi
# ---------------------------------------------------------------- polynomials over Q(alpha)
def a_trim(al: RealAlg, g: List[UPoly]) -> List[UPoly]:
g = [al.reduce(c) for c in g]
while g and (not g[-1] or al.is_zero(g[-1])):
g.pop()
return [al.reduce(c) for c in g]
def a_monic(al: RealAlg, g: List[UPoly]) -> List[UPoly]:
inv = al.inverse(g[-1])
return [al.reduce(umul(c, inv)) for c in g]
def a_divmod(al: RealAlg, A: List[UPoly], B: List[UPoly]) -> Tuple[List[UPoly], List[UPoly]]:
"""Division in Q(alpha)[y]; B is trimmed, so its leading coefficient is nonzero at alpha."""
A = a_trim(al, A)
B = a_monic(al, a_trim(al, B))
R = list(A)
Q: List[UPoly] = [[] for _ in range(max(len(A) - len(B) + 1, 0))]
while len(R) >= len(B) and R:
c, k = R[-1], len(R) - len(B)
Q[k] = c
for i, b in enumerate(B):
R[i + k] = al.reduce(uadd(R[i + k], uscale(umul(c, b), Fraction(-1))))
R = a_trim(al, R)
return Q, R
def a_gcd(al: RealAlg, A: List[UPoly], B: List[UPoly]) -> List[UPoly]:
A, B = a_trim(al, A), a_trim(al, B)
while B:
A, B = B, a_divmod(al, A, B)[1]
return a_monic(al, A) if A else []
def a_deriv(al: RealAlg, A: List[UPoly]) -> List[UPoly]:
return a_trim(al, [uscale(A[j], Fraction(j)) for j in range(1, len(A))])
def a_eval(al: RealAlg, A: List[UPoly], y0: Fraction) -> UPoly:
v: UPoly = []
for c in reversed(A):
v = al.reduce(uadd(uscale(v, y0), c))
return v
class AlgSturm:
"""Sturm sequence over Q(alpha) of a squarefree g, with variations cached by point."""
def __init__(self, al: RealAlg, g: List[UPoly]):
self.al = al
self.seq = [a_trim(al, g), a_deriv(al, g)]
while self.seq[-1]:
self.seq.append([uscale(x, Fraction(-1)) for x in a_divmod(al, self.seq[-2], self.seq[-1])[1]])
self.seq = self.seq[:-1]
self.cache: Dict[Fraction, int] = {}
def var(self, y0: Fraction) -> int:
if y0 not in self.cache:
self.cache[y0] = variations([self.al.sign_of(a_eval(self.al, q, y0)) for q in self.seq])
return self.cache[y0]
def count(self, c: Fraction, d: Fraction) -> int:
"""Distinct roots in (c, d) of g over Q(alpha); c, d are not roots."""
return self.var(c) - self.var(d)
def norm_in_y(al: RealAlg, g: List[UPoly]) -> UPoly:
"""N(y) = res_x(p(x), g(x, y)): its roots contain the roots of g over every root of p."""
ex = max(len(c) for c in g)
D = (len(al.p) - 1) * (len(g) - 1)
ys = [Fraction(k) for k in range(D + 1)]
vals = []
for y0 in ys:
col = [Fraction(0)] * ex
for j, c in enumerate(g):
for i, v in enumerate(c):
col[i] += v * y0 ** j
vals.append(sylvester_res(list(al.p), col))
return interpolate(ys, vals)
# ---------------------------------------------------------------- the decomposition
def yun(f: UPoly) -> List[UPoly]:
"""Yun's squarefree decomposition: the nonconstant a_i with f = c * prod a_i^i."""
f = umonic(f)
if len(f) <= 1:
return []
g = ugcd(f, uderiv(f))
c = udivmod(f, g)[0]
d = uadd(udivmod(uderiv(f), g)[0], uscale(uderiv(c), Fraction(-1)))
out = []
while len(c) > 1:
a = ugcd(c, d)
if len(a) > 1:
out.append(a)
c = udivmod(c, a)[0]
d = uadd(udivmod(d, a)[0], uscale(uderiv(c), Fraction(-1)))
return out
def projection(F: List[BiPoly]) -> Tuple[YPoly, UPoly, List[UPoly]]:
"""S = squarefree part of the primitive part of prod F, P = the squarefree projection in Q[x],
and the squarefree factors of the projection polynomials, used as small defining polynomials."""
prod: YPoly = [[Fraction(1)]]
factors: List[UPoly] = []
for f in F:
A = to_ypoly(f)
if not A:
continue
factors.append(content(A))
prod = ymul(prod, primitive(A))
S = primitive(prod)
if len(S) > 1:
G = ygcd(S, yderiv(S))
if len(G) > 1:
S = primitive(ydivexact(S, G))
factors.append(S[-1])
factors.append(res_y(S, yderiv(S)))
P: UPoly = [Fraction(1)]
basis: List[UPoly] = []
for q in factors:
if len(trim(q)) > 1:
P = umul(P, q)
basis += yun(q)
return S, usqfree(P), sorted(basis, key=len)
def ydivexact(A: YPoly, B: YPoly) -> YPoly:
"""Exact quotient A / B in Q[x][y] when B divides A."""
R = [list(c) for c in A]
Q: YPoly = [[] for _ in range(len(A) - len(B) + 1)]
lb = B[-1]
for k in range(len(A) - len(B), -1, -1):
c, r = udivmod(R[k + len(B) - 1], lb)
assert not r
Q[k] = c
for j, b in enumerate(B):
R[j + k] = uadd(R[j + k], uscale(umul(c, b), Fraction(-1)))
return Q
class Cell:
def __init__(self, kind: Tuple[int, int], x, y, signs: Tuple[int, ...]):
self.kind, self.x, self.y, self.signs = kind, x, y, signs
def __repr__(self) -> str:
return f"Cell({self.kind}, {self.signs})"
def sign_at(f: BiPoly, al: RealAlg, y) -> int:
"""Sign of f(alpha, y), for y rational or y = (Ns, c, d, g) a section of the stack."""
fa = a_trim(al, to_ypoly(f)) if f else []
if not fa:
return 0
if isinstance(y, Fraction):
return al.sign_of(a_eval(al, fa, y))
return section_sign(f, fa, al, *y)
def section_sign(f: BiPoly, fa: List[UPoly], al: RealAlg, Ns: UPoly, c: Fraction, d: Fraction,
g: List[UPoly]) -> int:
"""Sign of f at (alpha, beta), beta the only root of Ns and of g(alpha, y) in (c, d)."""
if al.exact is not None: # rational x: work with f(x0, y) in Q[y]
q = trim([c0[0] if c0 else Fraction(0) for c0 in fa])
G = ugcd(q, Ns)
if len(G) > 1 and SturmCounter(G).count(c, d) >= 1:
return 0
while True:
lo, hi = interval_eval(q, c, d)
if lo > 0 or hi < 0:
return 1 if lo > 0 else -1
m = (c + d) / 2
v = ueval(Ns, m)
if v == 0:
return sign(ueval(q, m))
if sign(v) == sign(ueval(Ns, c)):
c = m
else:
d = m
lo, hi = interval_eval2(f, (al.a, al.b), (c, d))
if lo > 0 or hi < 0:
return 1 if lo > 0 else -1
G = a_gcd(al, fa, g)
if len(G) > 1 and AlgSturm(al, G).count(c, d) >= 1:
return 0
yl, yh = c, d
while True:
lo, hi = interval_eval2(f, (al.a, al.b), (yl, yh))
if lo > 0 or hi < 0:
return 1 if lo > 0 else -1
al.refine()
if yl != yh:
m = (yl + yh) / 2
v = ueval(Ns, m)
if v == 0:
yl = yh = m
elif sign(v) == sign(ueval(Ns, yl)):
yl = m
else:
yh = m
def stack_over(F: List[BiPoly], S: YPoly, al: RealAlg) -> List[Tuple[str, object]]:
"""The y-cells over the point alpha: sectors with rational samples, sections as (Ns, c, d)."""
if len(S) <= 1:
return [("sector", Fraction(0))]
if al.exact is not None: # rational x: the stack of S(x0, y) in Q[y]
Ns = usqfree([ueval(c, al.exact) for c in S])
sections = isolate(Ns)
g = [[v] if v else [] for v in Ns]
else:
Ns, g, sections = algebraic_sections(S, al)
if not sections:
return [("sector", Fraction(0))]
out: List[Tuple[str, object]] = [("sector", sections[0][0] - 1)]
for c, d in sections:
out.append(("section", (Ns, c, d, g)))
out.append(("sector", d))
out[-1] = ("sector", sections[-1][1] + 1)
return out
def algebraic_sections(S: YPoly, al: RealAlg) -> Tuple[UPoly, List[UPoly], List[Tuple[Fraction, Fraction]]]:
"""Roots of S(alpha, y) for irrational alpha: the norm, the squarefree stack polynomial, the intervals."""
g = a_trim(al, S)
g = a_divmod(al, g, a_gcd(al, g, a_deriv(al, g)))[0]
g = a_monic(al, a_trim(al, g))
if len(g) <= 1:
return [Fraction(1)], g, []
Ns = usqfree(norm_in_y(al, g))
st = AlgSturm(al, g)
return Ns, g, [(c, d) for c, d in isolate(Ns) if st.count(c, d) == 1]
def cad(F: List[BiPoly]) -> Tuple[List[Tuple[str, object, List[Cell]]], UPoly, YPoly]:
"""Cylindrical F-sign-invariant decomposition of R^2.
Returns the columns (kind, x sample, cells), the projection polynomial P(x)
and the lifting polynomial S(x, y). Precondition: F is a list of
polynomials over Q. Cost: polynomial in the degrees, for fixed n = 2.
"""
S, P, basis = projection(F) # @line L1
xs: List[Tuple[str, RealAlg]] = []
roots = isolate(P) # @line L2
if not roots:
xs.append(("sector", RealAlg.rational(Fraction(0))))
else:
xs.append(("sector", RealAlg.rational(roots[0][0] - 1)))
for k, (a, b) in enumerate(roots): # @line L3
# the smallest squarefree projection factor with a root in (a, b) defines alpha
p = next(q for q in basis if sign(ueval(q, a)) * sign(ueval(q, b)) < 0)
al = RealAlg(p, a, b)
al.refine(24)
xs.append(("section", al))
nxt = roots[k + 1][0] if k + 1 < len(roots) else b + 2
xs.append(("sector", RealAlg.rational((b + nxt) / 2)))
columns = []
for i, (kx, al) in enumerate(xs): # @line L4
ys = stack_over(F, S, al) # @line L5
cells = []
for j, (ky, y) in enumerate(ys): # @line L6
signs = tuple(sign_at(f, al, y) for f in F) # @line L7
cells.append(Cell((i, j), al, y, signs))
columns.append((kx, al, cells))
return columns, P, S # @line L8
test_cylindrical_algebraic_decomposition.py 366 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/cylindrical-algebraic-decomposition/. The authors are listed in prov/P06M5D.jsonl.
"""Tests for the plane CAD against an exact line-by-line oracle.
On a vertical line x = x0 with x0 rational, the oracle restricts each f to a
univariate polynomial, isolates the real roots of their product with its own
Sturm sequences, and lists the sign vectors along the line from bottom to top.
Sign-invariance and cylindricity say that over every x0 in a sector column this
list equals the column's list of cell sign vectors.
"""
import random
from fractions import Fraction as Fr
from typing import Dict, List, Tuple
from cylindrical_algebraic_decomposition import cad
# ------------------------------------------------------------ independent oracle
def tr(p):
p = list(p)
while p and p[-1] == 0:
p.pop()
return p
def ev(p, x):
v = Fr(0)
for a in reversed(p):
v = v * x + a
return v
def mul(p, q):
if not p or not q:
return []
r = [Fr(0)] * (len(p) + len(q) - 1)
for i, a in enumerate(p):
for j, b in enumerate(q):
r[i + j] += a * b
return r
def rem(p, q):
r = tr(p)
while len(r) >= len(q):
c, k = r[-1] / q[-1], len(r) - len(q)
for i, b in enumerate(q):
r[k + i] -= c * b
r = tr(r)
return r
def quo(p, q):
r, out = tr(p), [Fr(0)] * max(len(p) - len(q) + 1, 1)
while len(r) >= len(q):
c, k = r[-1] / q[-1], len(r) - len(q)
out[k] = c
for i, b in enumerate(q):
r[k + i] -= c * b
r = tr(r)
return tr(out)
def gcd(p, q):
p, q = tr(p), tr(q)
while q:
p, q = q, rem(p, q)
return p
def der(p):
return tr([i * p[i] for i in range(1, len(p))])
def sqf(p):
p = tr(p)
return quo(p, gcd(p, der(p))) if len(p) > 1 else p
class Sturm:
"""Sturm sequence of the squarefree part of p, for counting roots in (a, b]."""
def __init__(self, p):
p = sqf(p)
self.seq = [p, der(p)] if len(p) > 1 else []
while self.seq:
r = [-c for c in rem(self.seq[-2], self.seq[-1])]
if not r:
break
self.seq.append(r)
def var(self, x):
s = [v for v in (ev(q, x) for q in self.seq) if v != 0]
return sum(1 for u, w in zip(s, s[1:]) if (u > 0) != (w > 0))
def count(self, a, b):
return self.var(a) - self.var(b) if self.seq else 0
def nroots(p, a, b):
"""Distinct real roots of p in (a, b]."""
return Sturm(p).count(a, b)
def roots_of(h):
"""Isolating intervals (c, d) of the real roots of squarefree h, with h(c), h(d) nonzero."""
if len(h) <= 1:
return []
B = 1 + sum(abs(c) for c in h[:-1]) / abs(h[-1])
st = Sturm(h)
out, todo = [], [(-B, B)]
while todo:
a, b = todo.pop()
k = st.count(a, b)
if k == 1:
out.append((a, b))
elif k > 1:
m = (a + b) / 2
if ev(h, m) == 0:
out.append((m, m))
e = (b - a) / 1024
while ev(h, m - e) == 0 or ev(h, m + e) == 0 or st.count(m - e, m + e) > 1:
e /= 2
out[-1] = (m - e, m + e)
todo += [(a, m - e), (m + e, b)]
else:
todo += [(a, m), (m, b)]
return sorted(out)
def sign_at_root(q, h, c, d):
"""Sign of q at the unique root of squarefree h in (c, d)."""
q = tr(q)
if not q:
return 0
g = gcd(q, h)
if len(g) > 1 and nroots(g, c, d) > 0:
return 0
while True: # q(beta) != 0: shrink (c, d) until q keeps one sign on it
lo, hi = hull(q, c, d)
if lo > 0 or hi < 0:
return 1 if lo > 0 else -1
m = (c + d) / 2
if ev(h, m) == 0:
v = ev(q, m)
return (v > 0) - (v < 0)
if (ev(h, m) > 0) == (ev(h, c) > 0):
c = m
else:
d = m
def hull(q, c, d):
"""Bounds of q on [c, d] by interval Horner evaluation."""
lo = hi = Fr(0)
for a in reversed(q):
ps = [lo * c, lo * d, hi * c, hi * d]
lo, hi = min(ps) + a, max(ps) + a
return lo, hi
def restrict(f, x0):
deg = max((j for _, j in f), default=0)
out = [Fr(0)] * (deg + 1)
for (i, j), c in f.items():
out[j] += c * x0 ** i
return tr(out)
def line_signs(F, x0) -> List[Tuple[int, ...]]:
qs = [restrict(f, x0) for f in F]
h = [Fr(1)]
for q in qs:
if q:
h = mul(h, q)
h = sqf(h)
iv = roots_of(h)
sg = lambda y: tuple((ev(q, y) > 0) - (ev(q, y) < 0) for q in qs)
if not iv:
return [sg(Fr(0))]
out = [sg(iv[0][0] - 1)]
for k, (c, d) in enumerate(iv):
out.append(tuple(sign_at_root(q, h, c, d) for q in qs))
nxt = iv[k + 1][0] if k + 1 < len(iv) else d + 2
out.append(sg((d + nxt) / 2))
return out
def sign_at_alg(q, P, a, b):
"""Sign of the univariate q at the unique root of squarefree P in (a, b)."""
return sign_at_root(q, sqf(P), a, b)
def restrict_y(f, y0):
deg = max((i for i, _ in f), default=0)
out = [Fr(0)] * (deg + 1)
for (i, j), c in f.items():
out[i] += c * y0 ** j
return tr(out)
# ------------------------------------------------------------ checks
def simple_in(lo, hi, x):
"""A rational with a small denominator in the open interval (lo, hi), near x."""
n = 1
while True:
y = x.limit_denominator(n)
if lo < y < hi:
return y
n *= 4
def runs(seq):
return [v for i, v in enumerate(seq) if i == 0 or seq[i - 1] != v]
def column_ranges(cols):
"""Rational open x-ranges (lo, hi) inside each sector column; None means unbounded."""
secs = [(al.a, al.b) for k, al, _ in cols if k == "section"]
ranges = []
for i in range(len(secs) + 1):
lo = secs[i - 1][1] if i > 0 else None
hi = secs[i][0] if i < len(secs) else None
ranges.append((lo, hi))
return ranges
def check(F, rng, probes=1, xs=()):
cols, P, S = cad(F)
kinds = [k for k, _, _ in cols]
assert kinds[0] == "sector" and kinds[-1] == "sector"
assert all(kinds[i] != kinds[i + 1] for i in range(len(kinds) - 1))
sectors = [c for c in cols if c[0] == "sector"]
for (lo, hi), (_, al, cells) in zip(column_ranges(cols), sectors):
sigs = [c.signs for c in cells]
assert line_signs(F, al.exact) == sigs
for _ in range(probes):
if lo is None and hi is None:
x0 = Fr(rng.randint(-50, 50), rng.randint(1, 9))
elif lo is None:
x0 = hi - Fr(rng.randint(1, 400), rng.randint(1, 40))
elif hi is None:
x0 = lo + Fr(rng.randint(1, 400), rng.randint(1, 40))
elif lo < hi:
x0 = simple_in(lo, hi, lo + (hi - lo) * Fr(rng.randint(1, 99), 100))
else:
continue
assert line_signs(F, x0) == sigs, (F, x0)
for k, al, cells in cols:
if k != "section":
continue
assert len(cells) % 2 == 1
known = [r for r in xs if al.a <= r <= al.b and ev(P, r) == 0]
if al.exact is not None or known:
x0 = al.exact if al.exact is not None else known[0]
# where some f vanishes on the whole line the stack may keep extra
# sections of S there, so runs of equal sign vectors are merged
assert runs(line_signs(F, x0)) == runs([c.signs for c in cells])
continue
for j, c in enumerate(cells):
if j % 2 == 0:
expect = tuple(sign_at_alg(restrict_y(f, c.y), P, al.a, al.b) for f in F)
assert c.signs == expect
else:
assert 0 in c.signs
for i, s in enumerate(c.signs):
if s != 0:
assert cells[j - 1].signs[i] == s == cells[j + 1].signs[i]
return cols
def rand_poly(rng, deg, terms) -> Dict[Tuple[int, int], Fr]:
mons = [(i, j) for i in range(deg + 1) for j in range(deg + 1 - i)]
f = {}
for m in rng.sample(mons, min(terms, len(mons))):
c = rng.randint(-4, 4)
if c:
f[m] = Fr(c)
return f
# ------------------------------------------------------------ tests
def circle():
return {(2, 0): Fr(1), (0, 2): Fr(1), (0, 0): Fr(-1)}
def test_worked_example():
F = [circle(), {(1, 0): Fr(1), (0, 1): Fr(-1)}]
cols, P, S = cad(F)
assert P == [Fr(1, 2), Fr(0), Fr(-3, 2), Fr(0), Fr(1)] # (x^2 - 1)(x^2 - 1/2)
assert [len(c) for _, _, c in cols] == [3, 5, 7, 5, 7, 5, 7, 5, 3]
assert sum(len(c) for _, _, c in cols) == 47
mid = cols[4][2]
assert [c.signs for c in mid] == [(1, 1), (0, 1), (-1, 1), (-1, 0), (-1, -1), (0, -1), (1, -1)]
check(F, random.Random(1))
def test_circle_alone():
cols, P, S = cad([circle()])
assert P == [Fr(-1), Fr(0), Fr(1)]
assert [len(c) for _, _, c in cols] == [1, 3, 5, 3, 1]
assert sum(len(c) for _, _, c in cols) == 13
def test_edge_cases():
assert [c.signs for _, _, cs in cad([])[0] for c in cs] == [()]
cols, _, _ = cad([{(0, 0): Fr(-3)}])
assert [c.signs for _, _, cs in cols for c in cs] == [(-1,)]
cols, _, _ = cad([{}])
assert [c.signs for _, _, cs in cols for c in cs] == [(0,)]
cols, P, _ = cad([{(1, 0): Fr(1)}]) # x: one vertical line
assert P == [Fr(0), Fr(1)] and [len(c) for _, _, c in cols] == [1, 1, 1]
assert [cs[0].signs for _, _, cs in cols] == [(-1,), (0,), (1,)]
cols, P, _ = cad([{(0, 1): Fr(1)}]) # y: one horizontal line
assert len(cols) == 1 and [c.signs for c in cols[0][2]] == [(-1,), (0,), (1,)]
check([{(1, 1): Fr(1), (0, 0): Fr(-1)}], random.Random(2)) # hyperbola xy = 1
check([{(1, 1): Fr(1)}], random.Random(3)) # xy: content x times y
check([{(0, 2): Fr(1), (3, 0): Fr(-1), (2, 0): Fr(-1)}], random.Random(4)) # nodal cubic
check([{(0, 2): Fr(1), (3, 0): Fr(-1)}], random.Random(5)) # cusp
check([{(0, 2): Fr(1), (0, 0): Fr(-2)}], random.Random(6)) # y^2 = 2, two lines
check([{(2, 1): Fr(1), (0, 0): Fr(-1)}], random.Random(7)) # x^2 y = 1, vanishing lc
check([circle(), circle()], random.Random(8)) # repeated input
check([{(2, 0): Fr(1), (0, 2): Fr(1)}], random.Random(9)) # a single point
def test_lines_rational_sections():
rng = random.Random(10)
for _ in range(230):
F = []
for _ in range(rng.randint(1, 3)):
a, b, c = rng.randint(-3, 3), rng.randint(-3, 3), rng.randint(-3, 3)
if a == 0 and b == 0:
continue
F.append({k: Fr(v) for k, v in {(1, 0): a, (0, 1): b, (0, 0): c}.items() if v})
xs = [] # every critical x of a family of lines is rational
for f in F:
if f.get((0, 1), 0) == 0:
xs.append(-f.get((0, 0), Fr(0)) / f[(1, 0)])
for g in F:
a1, b1, c1 = (f.get(m, Fr(0)) for m in [(1, 0), (0, 1), (0, 0)])
a2, b2, c2 = (g.get(m, Fr(0)) for m in [(1, 0), (0, 1), (0, 0)])
dd = a1 * b2 - a2 * b1
if dd:
xs.append((b1 * c2 - b2 * c1) / dd)
cols = check(F, rng, xs=xs)
for k, al, _ in cols:
if k == "section":
assert al.exact is not None or any(al.a <= r <= al.b for r in xs)
def test_random_families():
rng = random.Random(11)
count = 0
for _ in range(220):
F = [rand_poly(rng, 2, rng.randint(2, 4)) for _ in range(rng.randint(1, 2))]
check(F, rng)
count += 1
assert count == 220 # with 230 line families and 50 cubics: 500 random inputs
def test_random_cubics():
rng = random.Random(12)
for _ in range(50):
check([rand_poly(rng, 3, rng.randint(2, 4))], rng)
What the next level needs
- the tests did not run in this build
Read the full page about Cylindrical algebraic decomposition