Narrowing in Python
Rewriting with unification in place of matching, to solve equations and goals.
- File
narrowing.py, 236 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/narrowing/. The authors are listed in prov/4TKTY0.jsonl.
"""Breadth-first narrowing: solve s = t modulo a term rewriting system.
A term is a variable, written as a str, or an application, written as a tuple
(f, t1, ..., tn) whose first item is the function symbol; a constant a is
("a",). A rule is a pair (l, r) of terms. Positions are tuples of 1-based
argument indexes. Variable names that start with "_" are reserved for the
fresh copies of rule variables.
narrow(s, t, rules, depth) returns the answers of all narrowing derivations of
length at most depth from the goal s = t, in breadth-first order, without
repetitions. An answer is a dict from each variable of s and t to a term.
Every answer theta is sound: s theta and t theta rewrite to a common term. If
the rules are confluent and terminating, the answers are also complete: every
normalized solution is an instance of an answer found with a large enough
depth (Hullot 1980). All functions use explicit stacks.
"""
from collections import deque
from itertools import count
from typing import Optional, Union
Term = Union[str, tuple]
Rule = tuple[Term, Term]
def is_var(t: Term) -> bool:
return isinstance(t, str)
def term_eq(s: Term, t: Term) -> bool:
"""Syntactic equality, without recursion."""
stack = [(s, t)]
while stack:
u, v = stack.pop()
if u is v:
continue
if is_var(u) or is_var(v):
if u != v:
return False
continue
if len(u) != len(v) or u[0] != v[0]:
return False
stack.extend(zip(u[1:], v[1:]))
return True
def variables(t: Term) -> list[str]:
"""The variables of t in left-to-right order of first occurrence."""
out: list[str] = []
seen: set[str] = set()
stack = [t]
while stack:
u = stack.pop()
if is_var(u):
if u not in seen:
seen.add(u)
out.append(u)
else:
stack.extend(reversed(u[1:]))
return out
def substitute(t: Term, sigma: dict[str, Term]) -> Term:
"""t sigma. Bound values are inserted as they are, without copying."""
out: list[Term] = []
stack: list[tuple[bool, Term]] = [(False, t)]
while stack:
done, u = stack.pop()
if done:
n = len(u) - 1
args = out[len(out) - n:] if n else []
del out[len(out) - n:]
out.append((u[0],) + tuple(args))
elif is_var(u):
out.append(sigma.get(u, u))
else:
stack.append((True, u))
for a in reversed(u[1:]):
stack.append((False, a))
return out[0]
def _walk(t: Term, sigma: dict[str, Term]) -> Term:
while is_var(t) and t in sigma:
t = sigma[t]
return t
def _occurs(x: str, t: Term, sigma: dict[str, Term]) -> bool:
stack = [t]
while stack:
u = _walk(stack.pop(), sigma)
if is_var(u):
if u == x:
return True
else:
stack.extend(u[1:])
return False
def _resolve(t: Term, sigma: dict[str, Term]) -> Term:
"""Apply a triangular substitution completely."""
out: list[Term] = []
stack: list[tuple[bool, Term]] = [(False, t)]
while stack:
done, u = stack.pop()
if done:
n = len(u) - 1
args = out[len(out) - n:] if n else []
del out[len(out) - n:]
out.append((u[0],) + tuple(args))
continue
u = _walk(u, sigma)
if is_var(u):
out.append(u)
else:
stack.append((True, u))
for a in reversed(u[1:]):
stack.append((False, a))
return out[0]
def unify(s: Term, t: Term) -> Optional[dict[str, Term]]:
"""A most general unifier of s and t, fully applied, or None."""
sigma: dict[str, Term] = {}
stack = [(s, t)]
while stack:
a, b = stack.pop()
a, b = _walk(a, sigma), _walk(b, sigma)
if is_var(a) and is_var(b) and a == b:
continue
if is_var(b) and not is_var(a):
a, b = b, a
if is_var(a):
if _occurs(a, b, sigma):
return None
sigma[a] = b
continue
if len(a) != len(b) or a[0] != b[0]:
return None
stack.extend(zip(a[1:], b[1:]))
return {x: _resolve(x, sigma) for x in sigma}
def subterms(t: Term) -> list[tuple[tuple[int, ...], Term]]:
"""All (position, subterm) pairs of t in preorder."""
out = []
stack: list[tuple[tuple[int, ...], Term]] = [((), t)]
while stack:
p, u = stack.pop()
out.append((p, u))
if not is_var(u):
for i in range(len(u) - 1, 0, -1):
stack.append((p + (i,), u[i]))
return out
def replace(t: Term, p: tuple[int, ...], s: Term) -> Term:
"""t[s]_p, rebuilding only the path to p."""
path = []
u = t
for i in p:
path.append((u, i))
u = u[i]
for node, i in reversed(path):
s = node[:i] + (s,) + node[i + 1:]
return s
def show(t: Term) -> str:
"""A string form of t, built without recursion."""
out: list[str] = []
stack: list[Union[str, Term]] = [t]
while stack:
u = stack.pop()
if isinstance(u, str):
out.append(u)
elif len(u) == 1:
out.append(u[0])
else:
stack.append(")")
for i in range(len(u) - 1, 0, -1):
stack.append(u[i])
if i > 1:
stack.append(",")
stack.append(u[0] + "(")
return "".join(out)
def _tidy(answer: dict[str, Term]) -> dict[str, Term]:
"""Rename the fresh variables of an answer to _1, _2, ... in order."""
names: dict[str, str] = {}
for x in answer:
for v in variables(answer[x]):
if v.startswith("_") and v not in names:
names[v] = "_" + str(len(names) + 1)
return {x: substitute(u, names) for x, u in answer.items()}
def narrow(s: Term, t: Term, rules: list[Rule], depth: int,
max_answers: Optional[int] = None) -> list[dict[str, Term]]:
"""Answers of the narrowing derivations from s = t of length <= depth."""
goal_vars = variables(("=", s, t))
queue = deque([(("=", s, t), {x: x for x in goal_vars}, 0)]) # @line L1
answers: list[dict[str, Term]] = []
seen: set[str] = set()
fresh = count(1)
while queue: # @line L2
goal, sigma, d = queue.popleft() # @line L3
mu = unify(goal[1], goal[2]) # @line L4
if mu is not None:
answer = _tidy({x: substitute(sigma[x], mu) for x in goal_vars})
key = "|".join(show(answer[x]) for x in goal_vars)
if key not in seen:
seen.add(key)
answers.append(answer)
if max_answers is not None and len(answers) >= max_answers:
return answers
if d >= depth: # @line L5
continue
for p, u in subterms(goal): # @line L6
if not p or is_var(u):
continue
for l, r in rules: # @line L7
k = next(fresh)
names = {x: "_" + str(k) + x for x in variables(l)}
l2, r2 = substitute(l, names), substitute(r, names)
tau = unify(u, l2) # @line L8
if tau is None:
continue
child = substitute(replace(goal, p, r2), tau) # @line L9
queue.append((child, {x: substitute(v, tau) for x, v in sigma.items()}, d + 1))
return answers # @line L10
test_narrowing.py 233 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/narrowing/. The authors are listed in prov/4TKTY0.jsonl.
import random
from narrowing import narrow, show
Z = ("0",)
def S(t):
return ("s", t)
def num(k, base=Z):
t = base
for _ in range(k):
t = S(t)
return t
ADD = [(("add", Z, "y"), "y"), (("add", S("x"), "y"), S(("add", "x", "y")))]
MUL = ADD + [(("mul", Z, "y"), Z), (("mul", S("x"), "y"), ("add", ("mul", "x", "y"), "y"))]
NIL = ("nil",)
APP = [(("app", NIL, "y"), "y"), (("app", ("cons", "x", "y"), "z"), ("cons", "x", ("app", "y", "z")))]
BOOL = [
(("not", ("true",)), ("false",)), (("not", ("false",)), ("true",)),
(("and", ("true",), "x"), "x"), (("and", ("false",), "x"), ("false",)),
]
def shown(answers):
return [{x: show(v) for x, v in a.items()} for a in answers]
# ---------- an independent, naive oracle (recursive, for small terms) ----------
def o_var(t):
return isinstance(t, str)
def o_vars(t):
if o_var(t):
return {t}
out = set()
for a in t[1:]:
out |= o_vars(a)
return out
def o_sub(t, s):
return s.get(t, t) if o_var(t) else (t[0],) + tuple(o_sub(a, s) for a in t[1:])
def o_match(p, t, s):
if o_var(p):
if p in s:
return s[p] == t
s[p] = t
return True
if o_var(t) 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_steps(t, rules):
out = []
for l, r in rules:
s = {}
if o_match(l, t, s):
out.append(o_sub(r, s))
if not o_var(t):
for i in range(1, len(t)):
for u in o_steps(t[i], rules):
out.append(t[:i] + (u,) + t[i + 1:])
return out
def o_reach(t, rules, k):
seen = {t}
frontier = [t]
for _ in range(k):
frontier = [v for u in frontier for v in o_steps(u, rules) if v not in seen]
seen.update(frontier)
return seen
def o_innermost(t, rules):
"""(normal form, number of innermost steps) for a terminating system."""
if o_var(t):
return t, 0
n = 0
args = []
for a in t[1:]:
v, k = o_innermost(a, rules)
args.append(v)
n += k
u = (t[0],) + tuple(args)
for l, r in rules:
s = {}
if o_match(l, u, s):
v, k = o_innermost(o_sub(r, s), rules)
return v, n + 1 + k
return u, n
def instance_of(answer, theta, xs):
"""Is theta(x) an instance of answer(x), with one matcher for all x in xs?"""
s = {}
return all(o_match(answer[x], theta[x], s) for x in xs)
# ---------- the examples of the page ----------
def test_page_example():
goal = (("add", "x", S(Z)), num(2))
assert narrow(*goal, ADD, 1) == []
assert narrow(*goal, ADD, 2) == [{"x": S(Z)}]
assert narrow(*goal, ADD, 5) == [{"x": S(Z)}]
def test_all_solutions_of_a_sum():
assert shown(narrow(("add", "x", "y"), num(2), ADD, 4)) == [
{"x": "0", "y": "s(s(0))"}, {"x": "s(0)", "y": "s(0)"}, {"x": "s(s(0))", "y": "0"},
]
assert len(narrow(("add", "x", "y"), num(2), ADD, 4, max_answers=2)) == 2
def test_partial_answers_keep_variables():
# add(x, y) = s(z): x = 0 and y = s(z), or x = s(x') and z = add(x', y) for any x', y
answers = shown(narrow(("add", "x", "y"), S("z"), ADD, 1))
assert answers == [{"x": "0", "y": "s(z)", "z": "z"}, {"x": "s(_1)", "y": "_2", "z": "add(_1,_2)"}]
def test_empty_system_is_syntactic_unification():
assert narrow(("f", "x", ("b",)), ("f", ("a",), "y"), [], 3) == [{"x": ("a",), "y": ("b",)}]
assert narrow(("f", "x"), "x", [], 3) == []
assert narrow("x", "x", [], 0) == [{"x": "x"}]
def test_shared_variable_and_non_linear_rule():
assert narrow(("add", "x", "x"), num(2), ADD, 4) == [{"x": S(Z)}]
rules = [(("eq", "x", "x"), ("true",))]
assert narrow(("eq", ("f", "y"), ("f", ("a",))), ("true",), rules, 1) == [{"y": ("a",)}]
def test_lists():
xs = ("cons", ("a",), ("cons", ("b",), NIL))
answers = shown(narrow(("app", "x", "y"), xs, APP, 3))
assert answers == [
{"x": "nil", "y": "cons(a,cons(b,nil))"},
{"x": "cons(a,nil)", "y": "cons(b,nil)"},
{"x": "cons(a,cons(b,nil))", "y": "nil"},
]
def test_non_terminating_system_with_a_bound():
rules = [(("f", "x"), ("f", S("x")))]
assert narrow(("f", Z), ("g", "y"), rules, 6) == []
# sound even here: f(x) = f(s(s(0))) by x = s(s(0)), or by x = 0 and two steps
answers = shown(narrow(("f", "x"), ("f", num(2)), rules, 2))
assert {"x": "s(s(0))"} in answers and {"x": "0"} in answers and {"x": "s(0)"} in answers
def test_deep_terms():
n = 800
assert narrow(("add", "x", num(n)), num(n + 1), ADD, 2) == [{"x": S(Z)}]
assert narrow(("add", num(3), "y"), num(n), ADD, 4) == [{"y": num(n - 3)}]
# ---------- random tests against the oracle ----------
def rand_term(rng, sig, vs, depth):
if depth == 0 or rng.random() < 0.3:
consts = [f for f, k in sig.items() if k == 0]
if vs and (not consts or rng.random() < 0.5):
return rng.choice(vs)
return (rng.choice(consts),)
f = rng.choice(sorted(sig))
return (f,) + tuple(rand_term(rng, sig, vs, depth - 1) for _ in range(sig[f]))
def test_random_soundness_on_any_system():
"""Every answer joins both sides within depth steps, for any rules at all."""
rng = random.Random(11)
sig = {"f": 1, "g": 2, "a": 0, "b": 0}
total = 0
for case in range(600):
rules = []
for _ in range(rng.randint(0, 3)):
l = rand_term(rng, sig, ["x", "y"], 2)
if o_var(l):
l = ("f", l)
r = rand_term(rng, sig, sorted(o_vars(l)), 2)
rules.append((l, r))
s = rand_term(rng, sig, ["x", "y"], 2)
t = rand_term(rng, sig, ["x", "y"], 2)
depth = rng.randint(0, 2)
for ans in narrow(s, t, rules, depth):
ss, tt = o_sub(s, ans), o_sub(t, ans)
assert o_reach(ss, rules, depth) & o_reach(tt, rules, depth), (rules, s, t, ans)
total += 1
assert total >= 300
def test_random_completeness_on_convergent_systems():
"""Every normalized solution is an instance of an answer (Hullot)."""
rng = random.Random(12)
systems = [
(ADD, {"add": 2, "s": 1, "0": 0}, {"s": 1, "0": 0}),
(MUL, {"mul": 2, "add": 2, "s": 1, "0": 0}, {"s": 1, "0": 0}),
(APP, {"app": 2, "cons": 2, "nil": 0, "a": 0}, {"cons": 2, "nil": 0, "a": 0}),
(BOOL, {"not": 1, "and": 2, "true": 0, "false": 0}, {"true": 0, "false": 0}),
]
cases = 0
while cases < 500:
rules, sig, cons = systems[rng.randrange(len(systems))]
s = rand_term(rng, sig, ["x", "y"], 3 if rng.random() < 0.3 else 2)
xs = sorted(o_vars(s))
theta = {x: rand_term(rng, cons, [], 2) for x in xs}
if rng.random() < 0.7:
t = o_innermost(o_sub(s, theta), rules)[0]
else:
t = rand_term(rng, sig, [], 2)
left, n1 = o_innermost(o_sub(s, theta), rules)
right, n2 = o_innermost(t, rules)
if left != right or n1 + n2 > 4 or (n1 + n2 == 0 and rng.random() < 0.9):
continue
answers = narrow(s, t, rules, n1 + n2)
assert any(instance_of(a, theta, xs) for a in answers), (s, t, theta, answers)
for a in answers: # and they are all sound
assert o_innermost(o_sub(s, a), rules)[0] == o_innermost(o_sub(t, a), rules)[0]
cases += 1
What the next level needs
- the tests did not run in this build