Straight-line program in Python
Compact sequence of arithmetic instructions that computes a polynomial or other object.
- File
straight_line_program.py, 199 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/straight-line-program/. The authors are listed in prov/JGQ9GZ.jsonl.
"""Straight-line programs for polynomials in K[x_1, ..., x_n].
A program is a list of instructions. Instruction j is one of
("var", i) the input x_i, 0 <= i < n
("const", c) a constant of K
("+", a, b), ("-", a, b), ("*", a, b) with a, b < j
The value of the program is the value of its last instruction. The length L
is the number of instructions. Coefficients can be Python ints, Fractions,
or ints mod p (pass p to the functions that take it).
Cost model: operations in K. Evaluation costs L operations, the degree bound
O(L) integer operations, the gradient (reverse mode, Baur and Strassen) at
most 5 L operations, and expansion can cost exponential time in L because
the dense or sparse form can have exponentially many terms.
"""
from __future__ import annotations
import random
class SLP:
def __init__(self, n: int):
self.n = n
self.ins: list = []
def _push(self, ins) -> int:
self.ins.append(ins)
return len(self.ins) - 1
def var(self, i: int) -> int:
assert 0 <= i < self.n
return self._push(("var", i))
def const(self, c) -> int:
return self._push(("const", c))
def op(self, o: str, a: int, b: int) -> int:
assert o in "+-*" and 0 <= a < len(self.ins) and 0 <= b < len(self.ins)
return self._push((o, a, b))
def __len__(self) -> int:
return len(self.ins)
def evaluate(prog: SLP, point: list, p: int | None = None):
"""Value at point; one pass over the instructions."""
val = [] # @line L1
for ins in prog.ins: # @line L2
k = ins[0] # @line L3
if k == "var":
v = point[ins[1]]
elif k == "const":
v = ins[1]
else:
a, b = val[ins[1]], val[ins[2]]
v = a + b if k == "+" else a - b if k == "-" else a * b
if p is not None:
v %= p
val.append(v) # @line L4
return val[-1] if val else 0 # @line L5
def degree_bound(prog: SLP) -> list:
"""Upper bounds for the total degree of every instruction (-1 for zero constants)."""
deg = []
for ins in prog.ins:
k = ins[0]
if k == "var":
deg.append(1)
elif k == "const":
deg.append(0 if ins[1] != 0 else -1)
elif k == "*":
a, b = deg[ins[1]], deg[ins[2]]
deg.append(-1 if a < 0 or b < 0 else a + b)
else:
deg.append(max(deg[ins[1]], deg[ins[2]]))
return deg
def expand(prog: SLP, p: int | None = None, max_terms: int = 10**5) -> dict:
"""The polynomial as {exponent tuple: coefficient}; raises if it grows too big."""
n = prog.n
polys = []
for ins in prog.ins:
k = ins[0]
if k == "var":
e = [0] * n
e[ins[1]] = 1
f = {tuple(e): 1}
elif k == "const":
f = {(0,) * n: ins[1]} if ins[1] else {}
elif k in "+-":
f = dict(polys[ins[1]])
s = 1 if k == "+" else -1
for e, c in polys[ins[2]].items():
f[e] = f.get(e, 0) + s * c
else:
f = {}
for e1, c1 in polys[ins[1]].items():
for e2, c2 in polys[ins[2]].items():
e = tuple(x + y for x, y in zip(e1, e2))
f[e] = f.get(e, 0) + c1 * c2
if p is not None:
f = {e: c % p for e, c in f.items()}
f = {e: c for e, c in f.items() if c != 0}
if len(f) > max_terms:
raise OverflowError("expanded form too large")
polys.append(f)
return polys[-1] if polys else {}
def gradient(prog: SLP, point: list, p: int | None = None) -> list:
"""All partial derivatives at point, by the reverse mode (Baur and Strassen)."""
val = []
for ins in prog.ins: # forward pass # @line G1
k = ins[0]
if k == "var":
v = point[ins[1]]
elif k == "const":
v = ins[1]
else:
a, b = val[ins[1]], val[ins[2]]
v = a + b if k == "+" else a - b if k == "-" else a * b
val.append(v % p if p is not None else v)
L = len(prog.ins)
adj = [0] * L # adj[j] = d(output) / d(value of j)
grad = [0] * prog.n # @line G2
if L == 0:
return grad
adj[-1] = 1 # @line G3
for j in range(L - 1, -1, -1): # backward pass # @line G4
g = adj[j]
if p is not None:
g %= p
if g == 0:
continue
ins = prog.ins[j]
k = ins[0]
if k == "var":
grad[ins[1]] += g # @line G5
elif k == "+": # @line G6
adj[ins[1]] += g
adj[ins[2]] += g
elif k == "-":
adj[ins[1]] += g
adj[ins[2]] -= g
elif k == "*": # @line G7
adj[ins[1]] += g * val[ins[2]]
adj[ins[2]] += g * val[ins[1]]
return [x % p for x in grad] if p is not None else grad # @line G8
def is_zero(prog: SLP, p: int, rng: random.Random, trials: int = 20) -> bool:
"""Randomized zero test over Z/pZ (Schwartz-Zippel).
A nonzero program of degree at most D < p is reported zero with
probability at most (D / p)^trials. A zero program is always reported
zero.
"""
for _ in range(trials):
pt = [rng.randrange(p) for _ in range(prog.n)]
if evaluate(prog, pt, p) != 0:
return False
return True
def compose(outer: SLP, inner: list) -> SLP:
"""outer(g_1, ..., g_m) for programs g_i in the same n variables.
The length is at most the sum of the lengths plus 2.
"""
assert len(inner) == outer.n and inner and all(len(g) for g in inner)
n = inner[0].n
res = SLP(n)
outs = []
for g in inner:
base = len(res.ins)
for ins in g.ins:
if ins[0] in "+-*":
res.ins.append((ins[0], ins[1] + base, ins[2] + base))
else:
res.ins.append(ins)
outs.append(len(res.ins) - 1)
where = [] # index in res of each instruction of outer
for ins in outer.ins:
if ins[0] == "var":
where.append(outs[ins[1]]) # reuse g_i, no new instruction
elif ins[0] == "const":
where.append(res._push(ins))
else:
where.append(res._push((ins[0], where[ins[1]], where[ins[2]])))
if not where:
res.const(0)
elif where[-1] != len(res.ins) - 1: # the output must be last
res._push(("+", where[-1], res.const(0)))
return res
test_straight_line_program.py 161 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/straight-line-program/. The authors are listed in prov/JGQ9GZ.jsonl.
"""Tests for straight-line programs.
The reference is the expanded polynomial: values, degrees and derivatives of
the program must agree with those of its expansion, in exact integer,
rational and modular arithmetic.
"""
import random
from fractions import Fraction
import pytest
from straight_line_program import (SLP, compose, degree_bound, evaluate, expand,
gradient, is_zero)
P = 1_000_000_007
def random_slp(rng, n, L, consts=(-3, 3), max_deg=6):
"""A random program; products are kept to total degree max_deg so it expands fast."""
g = SLP(n)
for i in range(n):
g.var(i)
g.const(rng.randint(*consts))
deg = [1] * n + [0]
while len(g) < L:
a, b = rng.randrange(len(g)), rng.randrange(len(g))
o = rng.choice("+-*+*")
if o == "*" and deg[a] + deg[b] > max_deg:
o = "+"
g.op(o, a, b)
deg.append(deg[a] + deg[b] if o == "*" else max(deg[a], deg[b]))
return g
def peval(f, pt, p=None):
s = 0
for e, c in f.items():
v = c
for x, k in zip(pt, e):
v *= x ** k
s += v
return s % p if p is not None else s
def pdiff(f, i):
out = {}
for e, c in f.items():
if e[i]:
e2 = list(e)
e2[i] -= 1
out[tuple(e2)] = out.get(tuple(e2), 0) + c * e[i]
return out
def total_degree(f):
return max((sum(e) for e in f), default=-1)
@pytest.mark.parametrize("seed", range(8))
def test_against_expansion(seed):
rng = random.Random(seed)
for _ in range(25):
n = rng.randint(1, 3)
g = random_slp(rng, n, rng.randint(n + 1, n + 9))
f = expand(g)
assert degree_bound(g)[-1] >= total_degree(f)
for _ in range(5):
pt = [rng.randint(-5, 5) for _ in range(n)]
assert evaluate(g, pt) == peval(f, pt)
assert evaluate(g, pt, P) == peval(f, pt, P)
gr = gradient(g, pt)
assert gr == [peval(pdiff(f, i), pt) for i in range(n)]
assert gradient(g, pt, P) == [x % P for x in gr]
qt = [Fraction(rng.randint(-9, 9), rng.randint(1, 9)) for _ in range(n)]
assert evaluate(g, qt) == peval(f, qt)
assert gradient(g, qt) == [peval(pdiff(f, i), qt) for i in range(n)]
def test_repeated_squaring():
k = 60
g = SLP(1)
g.var(0)
for j in range(k):
g.op("*", j, j) # x^(2^(j+1))
assert len(g) == k + 1
assert degree_bound(g)[-1] == 2 ** k
rng = random.Random(5)
for _ in range(20):
x = rng.randrange(1, P)
assert evaluate(g, [x], P) == pow(x, 2 ** k, P)
assert gradient(g, [x], P) == [2 ** k % P * pow(x, 2 ** k - 1, P) % P]
with pytest.raises(OverflowError): # (x + 1)^(2^20) has 2^20 + 1 terms
h = SLP(1)
h.var(0)
h.const(1)
h.op("+", 0, 1)
for j in range(20):
h.op("*", 2 + j, 2 + j)
expand(h, max_terms=100)
def test_zero_test():
rng = random.Random(9)
g = SLP(2) # (x + y)^2 - x^2 - 2 x y - y^2
x, y = g.var(0), g.var(1)
s = g.op("+", x, y)
s2 = g.op("*", s, s)
xx, yy, xy = g.op("*", x, x), g.op("*", y, y), g.op("*", x, y)
t = g.op("-", s2, xx)
t = g.op("-", t, yy)
t = g.op("-", t, xy)
g.op("-", t, xy)
assert expand(g) == {}
assert is_zero(g, P, rng)
for seed in range(30):
r = random.Random(seed)
h = random_slp(r, 3, 12)
nonzero = expand(h, P) != {}
assert is_zero(h, P, rng) == (not nonzero)
# h - h is zero
last = len(h) - 1
h.op("-", last, last)
assert is_zero(h, P, rng)
def test_compose():
rng = random.Random(11)
for _ in range(20):
m, n = rng.randint(1, 3), rng.randint(1, 3)
outer = random_slp(rng, m, m + 6)
inner = [random_slp(rng, n, n + 5) for _ in range(m)]
c = compose(outer, inner)
assert len(c) <= len(outer) + sum(map(len, inner)) + 2
for _ in range(5):
pt = [rng.randint(-4, 4) for _ in range(n)]
vals = [evaluate(gi, pt) for gi in inner]
assert evaluate(c, pt) == evaluate(outer, vals)
def test_edges():
e = SLP(2)
assert evaluate(e, [1, 2]) == 0 and gradient(e, [1, 2]) == [0, 0]
assert expand(e) == {} and degree_bound(e) == []
c = SLP(2)
c.const(7)
assert evaluate(c, [3, 4]) == 7 and gradient(c, [3, 4]) == [0, 0]
assert expand(c) == {(0, 0): 7} and not is_zero(c, P, random.Random(1))
z = SLP(1)
z.const(0)
assert degree_bound(z) == [-1] and is_zero(z, P, random.Random(1))
v = SLP(3)
v.var(2)
assert gradient(v, [5, 6, 7]) == [0, 0, 1] and expand(v) == {(0, 0, 1): 1}
w = SLP(1) # x * 0 has degree bound -1
w.var(0)
w.const(0)
w.op("*", 0, 1)
assert degree_bound(w)[-1] == -1 and expand(w) == {}
What the next level needs
- the tests did not run in this build