Unfailing completion in Python
Completion that works on equations without orienting them first.
- File
unfailing_completion.py, 351 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/unfailing-completion/. The authors are listed in prov/FECHPF.jsonl.
"""Unfailing (ordered) completion 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 ("a",). An equation is a pair (s, t) of
terms. The order is the lexicographic path ordering (LPO) for a precedence,
given as a dict from symbols to integers (larger is bigger); with distinct
ranks for all symbols, the LPO is total on ground terms.
Rewriting and the main loop use explicit stacks; the LPO comparison is
recursive, with depth at most the sum of the depths of the two terms.
"""
import heapq
from dataclasses import dataclass, field
from typing import Union
Term = Union[str, tuple]
Equation = tuple[Term, Term]
def is_var(t: Term) -> bool:
return isinstance(t, str)
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 occurs(v: str, t: Term) -> bool:
return v in variables(t)
def lpo_gt(s: Term, t: Term, prec: dict[str, int]) -> bool:
"""s >_lpo t. Memoized on pairs of subterm occurrences, so there are at
most |s| |t| distinct calls, each with O(|s| + |t|) work for the
equality and occurrence tests: O(|s| |t| (|s| + |t|)) time in all."""
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 occurs(t, 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, where the values of sigma 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 substitution sigma with p sigma = t, or None; variables of t are 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 occurs(a, 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 orientations(e: Equation, prec: dict[str, int]) -> list[Equation]:
"""The directions in which an equation may be used: one if it is
oriented by the LPO, else both."""
s, t = e
if lpo_gt(s, t, prec):
return [(s, t)]
if lpo_gt(t, s, prec):
return [(t, s)]
return [(s, t), (t, s)]
def usable(eqs: list[Equation], prec: dict[str, int]) -> list[tuple[Term, Term, bool]]:
"""The directions u -> v of the equations that ordered rewriting may use,
with a flag telling whether u > v holds already, so no test is needed.
A direction is skipped if u is a variable or v has a variable not in u."""
out = []
for e in eqs:
dirs = orientations(e, prec)
for u, v in dirs:
if not is_var(u) and variables(v) <= variables(u):
out.append((u, v, len(dirs) == 1))
return out
def rewrite_once(t: Term, dirs: list[tuple[Term, Term, bool]], prec: dict[str, int]) -> Term | None:
"""One ordered rewrite step: at the first position p in preorder and with the
first usable direction u -> v, if t|p = u sigma and u sigma > v sigma."""
for p in positions(t):
sub = subterm(t, p)
for u, v, oriented in dirs:
sigma = match(u, sub)
if sigma is None:
continue
r = apply(v, sigma)
if oriented or lpo_gt(sub, r, prec):
return replace(t, p, r)
return None
def normal_form(t: Term, eqs: list[Equation], prec: dict[str, int]) -> Term:
"""Ordered rewriting to a normal form; terminates since each step decreases t in the LPO."""
dirs = usable(eqs, prec)
while True:
r = rewrite_once(t, dirs, prec)
if r is None:
return t
t = r
def rename(e: Equation, suffix: str) -> Equation:
names: dict[str, Term] = {}
for v in variables(e[0]) | variables(e[1]):
names[v] = v + suffix
return apply(e[0], names), apply(e[1], names)
def ordered_critical_pairs(e1: Equation, e2: Equation, prec: dict[str, int]) -> list[Equation]:
"""The ordered critical pairs of e2 into e1: for each usable direction
u1 = v1 of e1 and u2 = v2 of e2 (renamed apart), each non-variable position
p of u1 and mgu sigma of u1|p and u2, the pair (u1 sigma[v2 sigma]_p, v1 sigma),
unless v1 sigma > u1 sigma or v2 sigma > u2 sigma."""
out: list[Equation] = []
for u1, v1 in orientations(e1, prec):
for u2, v2 in orientations(rename(e2, "'"), prec):
for p in positions(u1):
sigma = unify(subterm(u1, p), u2)
if sigma is None:
continue
a, b = apply(u1, sigma), apply(v1, sigma)
c = apply(v2, sigma)
if lpo_gt(b, a, prec) or lpo_gt(c, apply(u2, sigma), prec):
continue
out.append((replace(a, p, c), b))
return out
def size(t: Term) -> int:
n = 0
stack = [t]
while stack:
u = stack.pop()
n += 1
if not isinstance(u, str):
stack.extend(u[1:])
return n
def canonical(e: Equation) -> Equation:
"""e with its variables renamed v1, v2, ... by first occurrence, so that
two equations are variants iff their canonical forms are equal."""
names: dict[str, Term] = {}
stack = [e[1], e[0]]
while stack:
u = stack.pop()
if isinstance(u, str):
if u not in names:
names[u] = "v" + str(len(names) + 1)
else:
stack.extend(reversed(u[1:]))
return apply(e[0], names), apply(e[1], names)
@dataclass
class Result:
"""saturated is True if the final equations were checked to have no
ordered critical pair with different normal forms; then ordered rewriting
with them decides the ground word problem of E."""
saturated: bool
equations: list[Equation] = field(default_factory=list)
steps: int = 0
def unfailing_completion(E: list[Equation], prec: dict[str, int], max_steps: int = 500) -> Result:
"""Unfailing completion with the LPO for prec, in the given-equation style.
Each step takes a smallest pending equation (oldest first among equal
sizes), simplifies it by ordered rewriting with the active equations, and,
unless it becomes trivial or a variant of an active equation, adds it,
sends back to pending the active equations that it simplifies, and adds
its ordered critical pairs with the active equations. When nothing is
pending, all ordered critical pairs of the active set are recomputed; if
they all have equal normal forms the result is saturated. Gives up after
max_steps steps. Each step costs a critical pair computation against all
active equations, so the time is not bounded in terms of |E|."""
active: list[Equation] = [] # @line L1
heap: list[tuple[int, int, Equation]] = []
count = 0
def push(e: Equation) -> None:
nonlocal count
heapq.heappush(heap, (size(e[0]) + size(e[1]), count, e))
count += 1
dirs_cache: list = [None]
def nf(t: Term) -> Term:
if dirs_cache[0] is None:
dirs_cache[0] = usable(active, prec)
while True:
r = rewrite_once(t, dirs_cache[0], prec)
if r is None:
return t
t = r
for e in E:
push(e)
steps = 0
while steps < max_steps: # @line L2
if not heap: # @line L3
open_pairs = [cp for a in active for b in active
for cp in ordered_critical_pairs(a, b, prec)
if nf(cp[0]) != nf(cp[1])]
if not open_pairs:
return Result(True, active, steps) # @line L4
for cp in open_pairs:
push(cp)
continue
_, _, (s, t) = heapq.heappop(heap) # @line L5
steps += 1
s, t = nf(s), nf(t) # @line L6
c = canonical((s, t))
if s == t or any(c == canonical(a) or c == canonical((a[1], a[0])) for a in active): # @line L7
continue
if lpo_gt(t, s, prec): # @line L8
s, t = t, s
e = (s, t)
keep: list[Equation] = []
dirs = usable([e], prec)
for a in active: # @line L9
if rewrite_once(a[0], dirs, prec) is not None or rewrite_once(a[1], dirs, prec) is not None:
push(a)
else:
keep.append(a)
active = keep + [e] # @line L10
dirs_cache[0] = None
for other in active: # @line L11
for cp in ordered_critical_pairs(e, other, prec) + ordered_critical_pairs(other, e, prec):
push(cp)
return Result(False, active, steps) # @line L12
def equal(s: Term, t: Term, result: Result, prec: dict[str, int]) -> bool:
"""Decide s = t modulo E for ground s and t, if result is saturated."""
return normal_form(s, result.equations, prec) == normal_form(t, result.equations, prec)
test_unfailing_completion.py 217 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/unfailing-completion/. The authors are listed in prov/FECHPF.jsonl.
import itertools
import random
from unfailing_completion import canonical, equal, lpo_gt, normal_form, unfailing_completion
A, B = ("a",), ("b",)
def f(s, t):
return ("f", s, t)
def g(t):
return ("g", t)
# ---------- independent oracles (recursive, for small terms) ----------
def o_match(p, t, s):
if isinstance(p, str):
if p in s:
return s[p] == t
s[p] = t
return True
if isinstance(t, str) or p[0] != t[0] or len(p) != len(t):
return False
return all(o_match(a, b, s) for a, b in zip(p[1:], t[1:]))
def o_apply(t, s):
if isinstance(t, str):
return s.get(t, t)
return (t[0],) + tuple(o_apply(a, s) for a in t[1:])
def o_size(t):
return 1 if isinstance(t, str) else 1 + sum(o_size(a) for a in t[1:])
def o_steps(t, E):
"""All terms reachable from t by one replacement step with E, in either direction."""
out = []
stack = [(t, lambda r: r)]
while stack:
u, ctx = stack.pop()
for l, r in E:
for x, y in ((l, r), (r, l)):
s = {}
if o_match(x, u, s):
out.append(ctx(o_apply(y, s)))
if not isinstance(u, str):
for i in range(1, len(u)):
stack.append((u[i], lambda r, u=u, i=i, ctx=ctx: ctx(u[:i] + (r,) + u[i + 1:])))
return out
def o_class(t, E, max_size, rounds):
"""The ground terms of size at most max_size that are E-equal to t by at most rounds steps."""
seen = {t}
frontier = [t]
for _ in range(rounds):
nxt = []
for u in frontier:
for v in o_steps(u, E):
if v not in seen and o_size(v) <= max_size:
seen.add(v)
nxt.append(v)
frontier = nxt
return seen
def o_eval(t, alg, env):
if isinstance(t, str):
return env[t]
return alg[t[0]][tuple(o_eval(a, alg, env) for a in t[1:])]
def o_vars(t, acc):
if isinstance(t, str):
acc.add(t)
else:
for a in t[1:]:
o_vars(a, acc)
return acc
def holds(alg, eq, dom):
vs = sorted(o_vars(eq[0], set()) | o_vars(eq[1], set()))
for vals in itertools.product(dom, repeat=len(vs)):
env = dict(zip(vs, vals))
if o_eval(eq[0], alg, env) != o_eval(eq[1], alg, env):
return False
return True
ARITY = {"a": 0, "b": 0, "g": 1, "f": 2}
def random_algebra(rng, dom):
return {s: {args: rng.choice(dom) for args in itertools.product(dom, repeat=k)} for s, k in ARITY.items()}
def rand_term(rng, depth, vars_):
if depth == 0 or rng.random() < 0.3:
if vars_ and rng.random() < 0.5:
return rng.choice(vars_)
return (rng.choice(["a", "b"]),)
name = rng.choice(["g", "f", "f"])
return (name,) + tuple(rand_term(rng, depth - 1, vars_) for _ in range(ARITY[name]))
def ground_terms(max_size):
"""All ground terms of size at most max_size."""
by = {1: [A, B]}
for n in range(2, max_size + 1):
by[n] = [g(t) for t in by[n - 1]]
for i in range(1, n - 1):
by[n] += [f(s, t) for s in by[i] for t in by[n - 1 - i]]
return [t for n in by for t in by[n]]
# ---------- tests ----------
def test_commutativity():
"""Knuth-Bendix fails on commutativity; unfailing completion saturates at once."""
prec = {"f": 3, "b": 2, "a": 1}
r = unfailing_completion([(f("x", "y"), f("y", "x"))], prec)
assert r.saturated and len(r.equations) == 1
assert normal_form(f(B, A), r.equations, prec) == f(A, B)
assert equal(f(f(B, A), A), f(A, f(A, B)), r, prec)
assert not equal(f(f(A, B), A), f(A, A), r, prec)
def test_page_example():
prec = {"f": 3, "b": 2, "a": 1}
E = [(f("x", "y"), f("y", "x")), (f("x", A), "x")]
r = unfailing_completion(E, prec)
assert r.saturated
assert sorted(map(repr, r.equations)) == sorted(map(repr, [
(f("x", "y"), f("y", "x")), (f("x", A), "x"), (f(A, "x'"), "x'")]))
assert normal_form(f(f(A, B), f(B, A)), r.equations, prec) == f(B, B)
def test_groups():
m = lambda x, y: ("*", x, y)
i = lambda x: ("i", x)
e = ("e",)
prec = {"i": 3, "*": 2, "e": 1}
G = [(m(e, "x"), "x"), (m(i("x"), "x"), e), (m(m("x", "y"), "z"), m("x", m("y", "z")))]
r = unfailing_completion(G, prec)
assert r.saturated and len(r.equations) == 10
assert all(lpo_gt(s, t, prec) for s, t in r.equations) # all oriented: a convergent system
assert normal_form(i(m("a1", i("a2"))), r.equations, {**prec, "a1": 0, "a2": 0}) == m("a2", i("a1"))
def test_empty_system():
r = unfailing_completion([], {"f": 2, "a": 1})
assert r.saturated and r.equations == []
assert not equal(f(A, A), A, r, {"f": 2, "a": 1})
def test_non_terminating_with_bound():
"""h(g(h(x))) = h(g(x)) makes the run add h(g^n(h(x))) = h(g^n(x)) for n = 1, 2, ...;
the step bound stops it."""
h = lambda t: ("h", t)
def gn(n, t):
for _ in range(n):
t = g(t)
return t
r = unfailing_completion([(h(g(h("x"))), h(g("x")))], {"h": 2, "g": 1}, max_steps=25)
assert not r.saturated and r.steps == 25
want = {(h(gn(n, h("v1"))), h(gn(n, "v1"))) for n in range(1, 7)}
assert {canonical(e) for e in r.equations} == want
def test_random_against_oracles():
rng = random.Random(5)
ground = ground_terms(5)
dom = (0, 1)
saturated = 0
for case in range(500):
E = []
for _ in range(rng.randint(1, 2)):
while True:
s = rand_term(rng, 2, ["x", "y"])
t = rand_term(rng, 2, ["x", "y"])
if s != t and o_vars(s, set()) == o_vars(t, set()):
break
E.append((s, t))
syms = ["a", "b", "g", "f"]
rng.shuffle(syms)
prec = {s: k for k, s in enumerate(syms)}
r = unfailing_completion(E, prec, max_steps=25)
# soundness: every final equation holds in every 2-element model of E
models = [alg for alg in (random_algebra(rng, dom) for _ in range(40)) if all(holds(alg, e, dom) for e in E)]
for alg in models:
for eq in r.equations:
assert holds(alg, eq, dom), (E, eq)
sample = rng.sample(ground, 6)
for t in sample:
nf = normal_form(t, r.equations, prec)
for alg in models:
assert o_eval(nf, alg, {}) == o_eval(t, alg, {})
if not r.saturated:
continue
saturated += 1
# completeness on ground terms: E-equal terms found by search have equal normal forms
for t in sample:
nf = normal_form(t, r.equations, prec)
for u in o_class(t, E, 7, 3):
assert normal_form(u, r.equations, prec) == nf, (E, t, u)
assert saturated >= 400
What the next level needs
- the tests did not run in this build