Algorithm Search⌘K

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

Read the full page about Straight-line program