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
# 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