Algorithm Search⌘K

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

Read the full page about XL algorithm