Algorithm Search⌘K

Rewriting induction in Python

A proof method for inductive theorems over terminating rewrite systems.

File
rewriting_induction.py, 291 lines
Level
I0 not checked yet
Toolchain
Python 3.14, pytest 9
Test framework
pytest
Run the tests
python3 -m pytest
rewriting_induction.pyRawGitHub
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/rewriting-induction/. The authors are listed in prov/35E48J.jsonl.

"""Rewriting induction (Reddy 1990) with the lexicographic path ordering.

Terms: a variable is a str, an application is a tuple (f, arg1, ..., argk),
and a constant is a 1-tuple such as ("0",). A rule or an equation is a pair
of terms. The rewrite system R must be a constructor system: the root of each
left side is a defined symbol, and its arguments are constructor terms.

Preconditions, as on the page: R is sufficiently complete (every ground term
f(c1, ..., cn) with f defined and ci ground constructor terms is reducible),
and its rules are oriented by the LPO for the precedence (this is checked).
A "disproved" answer also needs R to be ground confluent, which holds for
orthogonal constructor systems.

Matching, unification, rewriting and renaming use explicit stacks; the LPO
comparison is a memoized recursion of depth at most the sum of the depths.
"""

from dataclasses import dataclass, field
from itertools import count, product
from typing import Union

Term = Union[str, tuple]
Rule = tuple[Term, Term]


def variables(t: Term) -> set[str]:
    out: set[str] = set()
    stack = [t]
    while stack:
        u = stack.pop()
        if isinstance(u, str):
            out.add(u)
        else:
            stack.extend(u[1:])
    return out


def symbols(t: Term) -> set[str]:
    out: set[str] = set()
    stack = [t]
    while stack:
        u = stack.pop()
        if not isinstance(u, str):
            out.add(u[0])
            stack.extend(u[1:])
    return out


def lpo_gt(s: Term, t: Term, prec: dict[str, int]) -> bool:
    """s >_lpo t, memoized on pairs of subterm occurrences: O(|s| |t| (|s| + |t|))."""
    memo: dict[tuple[int, int], bool] = {}

    def gt(s: Term, t: Term) -> bool:
        key = (id(s), id(t))
        if key in memo:
            return memo[key]
        if isinstance(t, str):
            r = not isinstance(s, str) and t in variables(s)
        elif isinstance(s, str):
            r = False
        elif any(si == t or gt(si, t) for si in s[1:]):
            r = True
        elif prec[s[0]] > prec[t[0]]:
            r = all(gt(s, tj) for tj in t[1:])
        elif s[0] == t[0] and len(s) == len(t) and all(gt(s, tj) for tj in t[1:]):
            r = False
            for a, b in zip(s[1:], t[1:]):
                if a != b:
                    r = gt(a, b)
                    break
        else:
            r = False
        memo[key] = r
        return r

    return gt(s, t)


def apply(t: Term, sigma: dict[str, Term]) -> Term:
    """t sigma, for a sigma whose values are already fully applied. O(|t sigma|)."""
    out: list[Term] = []
    stack: list[tuple[Term, bool]] = [(t, False)]
    while stack:
        u, done = stack.pop()
        if isinstance(u, str):
            out.append(sigma.get(u, u))
        elif done:
            k = len(u) - 1
            args = out[len(out) - k:] if k else []
            del out[len(out) - k:]
            out.append((u[0], *args))
        else:
            stack.append((u, True))
            stack.extend((a, False) for a in reversed(u[1:]))
    return out[0]


def match(p: Term, t: Term) -> dict[str, Term] | None:
    """A sigma with p sigma = t, or None; the variables of t are treated as constants."""
    sigma: dict[str, Term] = {}
    stack = [(p, t)]
    while stack:
        a, b = stack.pop()
        if isinstance(a, str):
            if a in sigma:
                if sigma[a] != b:
                    return None
            else:
                sigma[a] = b
        elif isinstance(b, str) or a[0] != b[0] or len(a) != len(b):
            return None
        else:
            stack.extend(zip(a[1:], b[1:]))
    return sigma


def unify(s: Term, t: Term) -> dict[str, Term] | None:
    """A most general unifier, fully applied, or None."""
    sigma: dict[str, Term] = {}
    stack = [(s, t)]
    while stack:
        a, b = stack.pop()
        a, b = apply(a, sigma), apply(b, sigma)
        if a == b:
            continue
        if isinstance(b, str) and not isinstance(a, str):
            a, b = b, a
        if isinstance(a, str):
            if a in variables(b):
                return None
            sigma = {x: apply(u, {a: b}) for x, u in sigma.items()}
            sigma[a] = b
        elif a[0] != b[0] or len(a) != len(b):
            return None
        else:
            stack.extend(zip(a[1:], b[1:]))
    return sigma


def positions(t: Term) -> list[tuple[int, ...]]:
    """The non-variable positions of t, in preorder."""
    out: list[tuple[int, ...]] = []
    stack: list[tuple[Term, tuple[int, ...]]] = [(t, ())]
    while stack:
        u, p = stack.pop()
        if isinstance(u, str):
            continue
        out.append(p)
        for i in range(len(u) - 1, 0, -1):
            stack.append((u[i], p + (i,)))
    return out


def subterm(t: Term, p: tuple[int, ...]) -> Term:
    for i in p:
        t = t[i]
    return t


def replace(t: Term, p: tuple[int, ...], r: Term) -> Term:
    path = []
    for i in p:
        path.append(t)
        t = t[i]
    for u, i in zip(reversed(path), reversed(p)):
        r = u[:i] + (r,) + u[i + 1:]
    return r


def normal_form(t: Term, rules: list[Rule]) -> Term:
    """Rewrite at the first redex in preorder until none is left. Each step
    tries O(|t|) positions and all rules, with an O(|t|) match: O(|rules| |t|^2)."""
    while True:
        for p in positions(t):
            sub = subterm(t, p)
            for l, r in rules:
                sigma = match(l, sub)
                if sigma is not None:
                    t = replace(t, p, apply(r, sigma))
                    break
            else:
                continue
            break
        else:
            return t


def basic_position(t: Term, defined: set[str]) -> tuple[int, ...] | None:
    """The first position in preorder of a subterm f(c1, ..., cn) with f
    defined and every ci a constructor term, or None."""
    for p in positions(t):
        u = subterm(t, p)
        if u[0] in defined and not any(symbols(a) & defined for a in u[1:]):
            return p
    return None


_fresh = count()


def rename(rule: Rule) -> Rule:
    k = next(_fresh)
    names = {v: f"{v}_{k}" for v in variables(rule[0]) | variables(rule[1])}
    return apply(rule[0], names), apply(rule[1], names)


def ground_constructor_terms(constructors: dict[str, int], max_size: int) -> list[Term]:
    """All ground constructor terms with at most max_size symbols."""
    by: dict[int, list[Term]] = {}
    for n in range(1, max_size + 1):
        terms: list[Term] = []
        for c, k in constructors.items():
            if k == 0:
                if n == 1:
                    terms.append((c,))
                continue
            # split n - 1 symbols among k arguments
            def splits(total: int, parts: int):
                if parts == 1:
                    if total >= 1:
                        yield (total,)
                    return
                for first in range(1, total - parts + 2):
                    for rest in splits(total - first, parts - 1):
                        yield (first,) + rest
            for sizes in splits(n - 1, k):
                for args in product(*(by[s] for s in sizes)):
                    terms.append((c, *args))
        by[n] = terms
    return [t for n in by for t in by[n]]


@dataclass
class Result:
    """verdict is "proved", "disproved" or "unknown". For "disproved",
    witness is a ground equation, derived from the conjectures, whose sides
    are different constructor terms."""

    verdict: str
    hypotheses: list[Rule] = field(default_factory=list)
    steps: int = 0
    witness: Rule | None = None


def rewriting_induction(R: list[Rule], conjectures: list[Rule], constructors: dict[str, int],
                        prec: dict[str, int], max_steps: int = 200) -> Result:
    """Try to prove that every conjecture s = t holds for all ground instances,
    modulo R. Each step simplifies one equation with R and the hypotheses H,
    deletes it, or orients it into H and expands it at a basic subterm with
    every rule of R that unifies there. The cost of the run is not bounded in
    terms of the input; see the page for the cost of one step."""
    for l, r in R:
        if not lpo_gt(l, r, prec):
            raise ValueError(f"rule {l} -> {r} is not oriented by the LPO")
    defined = {l[0] for l, _ in R}
    E = list(conjectures)  # @line L1
    H: list[Rule] = []
    steps = 0
    while E:  # @line L2
        if steps == max_steps:  # @line L3
            return Result("unknown", H, steps)
        s, t = E.pop(0)  # @line L4
        steps += 1
        s, t = normal_form(s, R + H), normal_form(t, R + H)  # @line L5
        if s == t:  # @line L6
            continue
        if not (symbols(s) | symbols(t)) & defined:  # @line L7
            vs = sorted(variables(s) | variables(t))
            ground = ground_constructor_terms(constructors, 4)
            for vals in product(ground, repeat=len(vs)):
                theta = dict(zip(vs, vals))
                gs, gt_ = apply(s, theta), apply(t, theta)
                if gs != gt_:
                    return Result("disproved", H, steps, (gs, gt_))
            return Result("unknown", H, steps)
        if lpo_gt(t, s, prec):  # @line L8
            s, t = t, s
        p = basic_position(s, defined)
        if p is None or not lpo_gt(s, t, prec):  # @line L9
            return Result("unknown", H, steps)
        u = subterm(s, p)  # @line L10
        H.append((s, t))
        for rule in R:  # @line L11
            l, r = rename(rule)
            tau = unify(u, l)
            if tau is not None:
                E.append((apply(replace(s, p, r), tau), apply(t, tau)))
    return Result("proved", H, steps)  # @line L12
test_rewriting_induction.py 214 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/rewriting-induction/. The authors are listed in prov/35E48J.jsonl.

import itertools
import random

import pytest

from rewriting_induction import rewriting_induction

Z = ("0",)
NAT = {"0": 0, "s": 1}


def S(t):
    return ("s", t)


def P(a, b):
    return ("+", a, b)


def D(t):
    return ("d", t)


PLUS = [(P(Z, "y"), "y"), (P(S("x"), "y"), S(P("x", "y")))]
DOUBLE = PLUS + [(D(Z), Z), (D(S("x")), S(S(D("x"))))]
PREC = {"0": 0, "s": 1, "+": 2, "d": 3}


# ---------- an independent evaluator on Python integers ----------

class Budget(Exception):
    pass


def o_match(p, n, env):
    """Match a constructor pattern against the natural number n."""
    if isinstance(p, str):
        env[p] = n
        return True
    if p[0] == "0":
        return n == 0
    return n > 0 and o_match(p[1], n - 1, env)


def o_eval(t, R, env, budget):
    budget[0] -= 1
    if budget[0] < 0:
        raise Budget
    if isinstance(t, str):
        return env[t]
    if t[0] == "0":
        return 0
    if t[0] == "s":
        return o_eval(t[1], R, env, budget) + 1
    args = [o_eval(a, R, env, budget) for a in t[1:]]
    if max(args, default=0) > 200:
        raise Budget
    for l, r in R:
        e = {}
        if l[0] == t[0] and all(o_match(p, n, e) for p, n in zip(l[1:], args)):
            return o_eval(r, R, e, budget)
    raise AssertionError("not sufficiently complete")


def o_holds(eq, R, n):
    """Does eq hold for all values 0..n of its variables? None if too costly."""
    vs = sorted({v for side in eq for v in o_vars(side)})
    try:
        for vals in itertools.product(range(n + 1), repeat=len(vs)):
            env = dict(zip(vs, vals))
            b = [20000]
            if o_eval(eq[0], R, env, b) != o_eval(eq[1], R, env, b):
                return False
    except Budget:
        return None
    return True


def o_vars(t):
    if isinstance(t, str):
        return {t}
    return set().union(*(o_vars(a) for a in t[1:])) if len(t) > 1 else set()


# ---------- tests ----------

def test_page_examples():
    r = rewriting_induction(PLUS, [(P("x", Z), "x")], NAT, PREC)
    assert r.verdict == "proved" and r.steps == 3 and r.hypotheses == [(P("x", Z), "x")]
    assoc = (P(P("x", "y"), "z"), P("x", P("y", "z")))
    r = rewriting_induction(PLUS, [assoc], NAT, PREC)
    assert r.verdict == "proved" and r.steps == 3
    # commutativity: the two sides are incomparable in the LPO
    r = rewriting_induction(PLUS, [(P("x", "y"), P("y", "x"))], NAT, PREC)
    assert r.verdict == "unknown" and r.steps == 1
    r = rewriting_induction(PLUS, [(P("x", "y"), "x")], NAT, PREC)
    assert r.verdict == "disproved" and r.witness == (S(Z), Z)


def test_needs_a_lemma():
    """d(x) = x + x diverges alone and is proved with x + s(y) = s(x + y)."""
    goal = (D("x"), P("x", "x"))
    r = rewriting_induction(DOUBLE, [goal], NAT, PREC, max_steps=50)
    assert r.verdict == "unknown" and r.steps == 50
    lemma = (P("x", S("y")), S(P("x", "y")))
    for conj in ([goal, lemma], [lemma, goal]):
        r = rewriting_induction(DOUBLE, conj, NAT, PREC)
        assert r.verdict == "proved" and r.steps == 6


def test_empty_system():
    assert rewriting_induction([], [("x", "x")], NAT, {"0": 0, "s": 1}).verdict == "proved"
    assert rewriting_induction([], [], NAT, {"0": 0, "s": 1}).verdict == "proved"
    r = rewriting_induction([], [("x", "y")], NAT, {"0": 0, "s": 1})
    assert r.verdict == "disproved" and r.witness[0] != r.witness[1]
    r = rewriting_induction([], [(S("x"), "x")], NAT, {"0": 0, "s": 1})
    assert r.verdict == "disproved"


def test_rejects_unoriented_rules():
    loop = [(("f", "x"), ("f", S("x")))]
    with pytest.raises(ValueError):
        rewriting_induction(loop, [(("f", Z), Z)], NAT, {"0": 0, "s": 1, "f": 2})


def test_non_linear_conjectures():
    # x + x = d(x) and d(x) + 0 = x + x share variables between and within sides
    lemma = (P("x", S("y")), S(P("x", "y")))
    r = rewriting_induction(DOUBLE, [lemma, (P("x", "x"), D("x")), (P(D("x"), Z), P("x", "x"))], NAT, PREC)
    assert r.verdict == "proved"
    r = rewriting_induction(DOUBLE, [(P("x", "x"), S(D("x")))], NAT, PREC)
    assert r.verdict == "disproved"


def rand_rhs(rng, depth, vars_, funcs, rec=None):
    """A random right side over vars_, 0, s and the functions funcs; rec, if
    given, is a recursive call head f(x, ...) that may appear once."""
    if depth == 0 or rng.random() < 0.3:
        if vars_ and rng.random() < 0.7:
            return rng.choice(vars_)
        return Z
    choice = rng.random()
    if rec is not None and choice < 0.35:
        name, k, x = rec
        return (name, x) + tuple(rand_rhs(rng, depth - 1, vars_, funcs) for _ in range(k - 1))
    if funcs and choice < 0.75:
        name, k = rng.choice(funcs)
        return (name,) + tuple(rand_rhs(rng, depth - 1, vars_, funcs, rec) for _ in range(k))
    return S(rand_rhs(rng, depth - 1, vars_, funcs, rec))


def random_system(rng):
    """A terminating, orthogonal, sufficiently complete constructor system
    over the naturals, with functions defined by recursion on the first argument."""
    R = []
    funcs = []
    prec = {"0": 0, "s": 1}
    for i in range(rng.randint(1, 3)):
        name, k = f"f{i}", rng.randint(1, 2)
        prec[name] = 2 + i
        ys = ["y"] if k == 2 else []
        if rng.random() < 0.8:
            R.append(((name, Z, *ys), rand_rhs(rng, 2, ys, funcs)))
            R.append(((name, S("x"), *ys), rand_rhs(rng, 3, ["x"] + ys, funcs, (name, k, "x"))))
        else:
            xs = ["x"] + ys
            R.append(((name, *xs), rand_rhs(rng, 3, xs, funcs)))
        funcs.append((name, k))
    return R, funcs, prec


def rand_term(rng, depth, funcs):
    if depth == 0 or rng.random() < 0.25:
        return rng.choice(["x", "y", "x", Z])
    if rng.random() < 0.75:
        name, k = rng.choice(funcs)
        return (name,) + tuple(rand_term(rng, depth - 1, funcs) for _ in range(k))
    return S(rand_term(rng, depth - 1, funcs))


def test_random_against_evaluation():
    rng = random.Random(7)
    counts = {"proved": 0, "disproved": 0, "unknown": 0}
    cases = 0
    while cases < 600:
        R, funcs, prec = random_system(rng)
        pool = [rand_term(rng, 2, funcs) for _ in range(30)]
        conj = []
        for _ in range(rng.randint(1, 2)):
            s, t = rng.choice(pool), rng.choice(pool)
            if rng.random() < 0.5:
                # bias toward true conjectures: pick t with the same values as s on 0..3
                same = [u for u in pool if u != s and o_holds((s, u), R, 3)]
                if same:
                    t = rng.choice(same)
            conj.append((s, t))
        truth = [o_holds(e, R, 4) for e in conj]
        if None in truth:
            continue
        cases += 1
        r = rewriting_induction(R, conj, NAT, prec, max_steps=40)
        counts[r.verdict] += 1
        if r.verdict == "proved":
            assert all(truth), (R, conj)
        elif r.verdict == "disproved":
            a, b = r.witness
            assert a != b and not o_vars(a) and not o_vars(b)
            assert o_holds(r.witness, R, 0) is False
            # some conjecture has a counterexample (searched over a larger range)
            assert any(o_holds(e, R, 12) is False for e in conj), (R, conj)
    assert counts["proved"] >= 100 and counts["disproved"] >= 100, counts

What the next level needs

  • the tests did not run in this build

Read the full page about Rewriting induction