XL algorithm in Python
Solves polynomial systems by multiplying equations by monomials and linearizing, mainly over small finite fields.
- File
xl_algorithm.py, 155 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/xl-algorithm/. The authors are listed in prov/JSBYXR.jsonl.
"""XL (extended linearization) over the prime field GF(p), with field equations.
A polynomial in GF(p)[x_0, ..., x_{n-1}] is a dict {exponent tuple: int mod p}.
All monomials are reduced by x_i^p = x_i, so exponents lie in 0..p-1 and we
look only for solutions in GF(p)^n. Cost model: operations in GF(p).
xl_step(F, n, p, D) -> ("none", None) | ("univariate", [g, ...]) | ("fail", None)
xl_solve(F, n, p) -> the sorted list of all solutions in GF(p)^n
"""
from __future__ import annotations
from itertools import product
def red_exp(e, p):
return 0 if e == 0 else 1 + (e - 1) % (p - 1)
def clean(f, p):
return {e: c % p for e, c in f.items() if c % p}
def reduce_poly(f, p):
out = {}
for e, c in f.items():
k = tuple(red_exp(a, p) for a in e)
out[k] = (out.get(k, 0) + c) % p
return clean(out, p)
def mul_mono(f, u, p):
out = {}
for e, c in f.items():
k = tuple(red_exp(a + b, p) for a, b in zip(e, u))
out[k] = (out.get(k, 0) + c) % p
return clean(out, p)
def tdeg(f):
return max((sum(e) for e in f), default=-1)
def monomials(n, p, D):
"""All reduced monomials of total degree at most D."""
return [e for e in product(range(p), repeat=n) if sum(e) <= D]
def is_univariate(e):
return all(a == 0 for a in e[1:])
def rref(rows, ncols, p):
"""Reduced row echelon form over GF(p); rows are lists, changed in place."""
r = 0
for c in range(ncols):
piv = next((i for i in range(r, len(rows)) if rows[i][c]), None)
if piv is None:
continue
rows[r], rows[piv] = rows[piv], rows[r]
inv = pow(rows[r][c], p - 2, p)
rows[r] = [v * inv % p for v in rows[r]]
for i in range(len(rows)):
if i != r and rows[i][c]:
f = rows[i][c]
rows[i] = [(a - f * b) % p for a, b in zip(rows[i], rows[r])]
r += 1
return rows[:r]
def xl_step(F, n, p, D):
"""One XL step at degree D. Univariate results are lists of coefficients
of x_0^0, x_0^1, ... (low degree first)."""
F = [reduce_poly(f, p) for f in F]
F = [f for f in F if f]
rows_p = []
for f in F: # @line L1
for u in monomials(n, p, D - tdeg(f)):
rows_p.append(mul_mono(f, u, p))
cols = monomials(n, p, D) # @line L2
cols.sort(key=lambda e: (is_univariate(e), -sum(e), [-a for a in e]))
index = {e: j for j, e in enumerate(cols)}
M = []
for g in rows_p:
row = [0] * len(cols)
for e, c in g.items():
row[index[e]] = c
M.append(row)
E = rref(M, len(cols), p) # @line L3
first_uni = next((j for j, e in enumerate(cols) if is_univariate(e)), len(cols))
G = []
for row in E: # @line L4
if not any(row[:first_uni]):
g = [0] * p
for j in range(first_uni, len(cols)):
if row[j]:
g[cols[j][0]] = row[j]
G.append(g)
if any(not any(g[1:]) for g in G): # @line L5
return "none", None
if G: # @line L6
return "univariate", G
return "fail", None # @line L7
def ev_uni(g, a, p):
s = 0
for c in reversed(g):
s = (s * a + c) % p
return s
def substitute_first(f, a, p):
out = {}
for e, c in f.items():
k = e[1:]
out[k] = (out.get(k, 0) + c * pow(a, e[0], p)) % p
return clean(out, p)
def xl_solve(F, n, p, trace=None):
"""All solutions in GF(p)^n, by XL on x_0 and substitution."""
sols = []
stack = [(tuple(), [reduce_poly(f, p) for f in F], n)]
while stack:
prefix, G, k = stack.pop()
G = [g for g in G if g]
if k == 0: # @line S4
if not G:
sols.append(prefix)
continue
if not G:
for rest in product(range(p), repeat=k):
sols.append(prefix + rest)
continue
D_full = k * (p - 1) + max(tdeg(g) for g in G)
D = max(tdeg(g) for g in G)
while True: # @line S1
status, U = xl_step(G, k, p, D)
if status != "fail" or D >= D_full:
break
D += 1
if trace is not None:
trace.append((k, D, status))
if status == "none":
continue
if status == "univariate": # @line S2
roots = [a for a in range(p) if all(ev_uni(g, a, p) == 0 for g in U)]
else:
roots = list(range(p))
for a in roots: # @line S3
stack.append((prefix + (a,), [substitute_first(g, a, p) for g in G], k - 1))
return sorted(sols)
test_xl_algorithm.py 128 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/xl-algorithm/. The authors are listed in prov/JSBYXR.jsonl.
"""XL against brute-force enumeration of GF(p)^n."""
import random
from itertools import product
from xl_algorithm import xl_step, xl_solve, reduce_poly, ev_uni, tdeg
def ev(f, pt, p):
s = 0
for e, c in f.items():
t = c
for v, k in zip(pt, e):
t = t * pow(v, k, p)
s += t
return s % p
def brute(F, n, p):
return sorted(pt for pt in product(range(p), repeat=n) if all(ev(f, pt, p) == 0 for f in F))
def rand_quad(rng, n, p, terms):
f = {}
for _ in range(terms):
e = [0] * n
for _ in range(rng.randint(0, 2)):
e[rng.randrange(n)] += 1
e = tuple(e)
f[e] = (f.get(e, 0) + rng.randrange(1, p)) % p
return {e: c for e, c in f.items() if c}
def planted(rng, n, p, m, terms=4):
"""m random quadratics that vanish at a random point (when wanted)."""
pt = tuple(rng.randrange(p) for _ in range(n))
F = []
for _ in range(m):
f = rand_quad(rng, n, p, terms)
c = ev(f, pt, p)
z = (0,) * n
f[z] = (f.get(z, 0) - c) % p
F.append({e: v for e, v in f.items() if v})
return F, pt
def test_random_against_brute_force():
rng = random.Random(21)
cases = [(2, 5), (2, 6), (3, 3), (3, 4), (5, 2), (5, 3)]
count = 0
for _ in range(12):
for p, n in cases:
m = rng.randint(1, n + 3)
if rng.random() < 0.7:
F, pt = planted(rng, n, p, m)
assert pt in brute(F, n, p)
else:
F = [rand_quad(rng, n, p, 4) for _ in range(m)]
assert xl_solve(F, n, p) == brute(F, n, p)
count += 1
assert count == 72
def test_univariate_rows_vanish_on_solutions():
rng = random.Random(22)
found = 0
for _ in range(40):
p, n = rng.choice([(2, 4), (3, 3), (5, 2)])
F, _ = planted(rng, n, p, n + 2)
sols = brute(F, n, p)
proj = {s[0] for s in sols}
for D in range(2, n * (p - 1) + 3):
st, U = xl_step(F, n, p, D)
if st == "univariate":
found += 1
for g in U:
assert all(ev_uni(g, a, p) == 0 for a in proj)
break
assert st != "none" # a planted solution exists
# at the full degree the univariate rows cut out the projection exactly
st, U = xl_step(F, n, p, n * (p - 1) + 2)
if st == "univariate":
roots = {a for a in range(p) if all(ev_uni(g, a, p) == 0 for g in U)}
assert roots == proj
else:
assert st == "fail" and proj == set(range(p))
assert found >= 20
def test_inconsistent_detected():
p, n = 3, 2
F = [{(1, 0): 1}, {(1, 0): 1, (0, 0): 1}] # x0 = 0 and x0 = -1
assert xl_step(F, n, p, 1)[0] == "none"
assert xl_solve(F, n, p) == []
assert xl_solve([{(0, 0): 2}], n, p) == [] # the unit ideal
def test_edges():
p = 2
assert xl_solve([], 2, p) == brute([], 2, p) # no equations
assert xl_solve([{}], 2, p) == brute([], 2, p) # the zero polynomial
f = {(1, 1): 1, (0, 0): 1} # x0 x1 = 1 over GF(2)
assert xl_solve([f], 2, p) == [(1, 1)] # a single generator
# x^3 - x vanishes on GF(3): it reduces to 0 by the field equations
assert reduce_poly({(3,): 1, (1,): 2}, 3) == {}
assert xl_solve([{(3,): 1, (1,): 2}], 1, 3) == [(0,), (1,), (2,)]
# a chain that needs many substitution levels: x_{i+1} = x_i^2 + 1 over GF(5)
n = 4
F = [{(1, 0, 0, 0): 1, (0, 0, 0, 0): 3}] # x0 = 2
for i in range(n - 1):
e2 = [0] * n
e2[i] = 2
e1 = [0] * n
e1[i + 1] = 1
F.append({tuple(e1): 1, tuple(e2): 4, (0,) * n: 4})
assert xl_solve(F, n, 5) == brute(F, n, 5) == [(2, 0, 1, 2)]
def test_worked_example():
# GF(2), x0 x1 + x2 = 0, x0 + x1 x2 + 1 = 0, x0 x2 + x1 + x2 = 0
F = [{(1, 1, 0): 1, (0, 0, 1): 1},
{(1, 0, 0): 1, (0, 1, 1): 1, (0, 0, 0): 1},
{(1, 0, 1): 1, (0, 1, 0): 1, (0, 0, 1): 1}]
tr = []
assert xl_solve(F, 3, 2, tr) == brute(F, 3, 2)
assert tr[0][0] == 3
What the next level needs
- the tests did not run in this build