Substitution tree in Python
A term index whose nodes store substitutions, supporting unification and matching queries.
- File
substitution_tree.py, 355 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/substitution-tree/. The authors are listed in prov/6X13J0.jsonl.
"""Substitution trees (Graf, 1995): a term index whose nodes store substitutions.
Terms: a variable is a str, an application is a tuple (f, arg1, ..., argk),
and a constant is a 1-tuple such as ("a",). The tree renames variables:
stored terms use "#1", "#2", ... (in order of first occurrence), query terms
use "?" + name, and indicator variables, which only occur inside the tree,
are "*0", "*1", .... A stored term is the composition of the substitutions
on the path from the top to its leaf, applied to "*0".
All functions use explicit stacks, so very deep terms are fine.
"""
from typing import Any, Callable, Hashable, Union
Term = Union[str, tuple]
Subst = dict[str, Term]
def is_var(t: Term) -> bool:
return isinstance(t, str)
def is_ivar(v: str) -> bool:
return v[0] == "*"
def term_eq(s: Term, t: Term) -> bool:
"""Syntactic equality without recursion. O(min(|s|, |t|))."""
stack = [(s, t)]
while stack:
a, b = stack.pop()
if a is b:
continue
if isinstance(a, str) or isinstance(b, str):
if a != b:
return False
continue
if a[0] != b[0] or len(a) != len(b):
return False
stack.extend(zip(a[1:], b[1:]))
return True
def rebuild(t: Term, leaf: Callable[[str], Term]) -> Term:
"""Copy t, replacing each variable v by leaf(v); variables are met left to right. O(|t|)."""
out: list[Term] = []
stack: list[tuple[Term, bool]] = [(t, False)]
while stack:
u, done = stack.pop()
if isinstance(u, str):
out.append(leaf(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))
for a in reversed(u[1:]):
stack.append((a, False))
return out[0]
def apply(t: Term, s: Subst) -> Term:
"""t with each variable v in s replaced by s[v], one pass. O(|t| + output)."""
return rebuild(t, lambda v: s.get(v, v))
def normalize(t: Term) -> Term:
"""Rename the variables of t to #1, #2, ... by first occurrence. O(|t|)."""
names: dict[str, str] = {}
def leaf(v: str) -> str:
if v not in names:
names[v] = "#" + str(len(names) + 1)
return names[v]
return rebuild(t, leaf)
def match_pairs(pairs: list[tuple[Term, Term]], is_mvar: Callable[[str], bool]) -> Subst | None:
"""One-sided matching: bind the variables v of the left terms with is_mvar(v)
so that each left term equals its right term; other variables are constants.
Returns the bindings or None. O(total size) plus the equality tests."""
b: Subst = {}
stack = list(pairs)
while stack:
p, u = stack.pop()
if isinstance(p, str):
if is_mvar(p):
if p in b:
if not term_eq(b[p], u):
return None
else:
b[p] = u
elif not (isinstance(u, str) and u == p):
return None
continue
if isinstance(u, str) or p[0] != u[0] or len(p) != len(u):
return None
stack.extend(zip(p[1:], u[1:]))
return b
def walk(t: Term, theta: Subst) -> Term:
while isinstance(t, str) and t in theta:
t = theta[t]
return t
def occurs(v: str, t: Term, theta: Subst) -> bool:
stack = [t]
while stack:
u = walk(stack.pop(), theta)
if isinstance(u, str):
if u == v:
return True
else:
stack.extend(u[1:])
return False
def unify(s: Term, t: Term, theta: Subst, trail: list[str], rigid: Callable[[str], bool]) -> bool:
"""Extend the triangular substitution theta to unify s and t, recording
new bindings on trail. Variables v with rigid(v) are never bound.
On failure, theta may hold partial bindings; the caller undoes the trail."""
stack = [(s, t)]
while stack:
a, b = stack.pop()
a = walk(a, theta)
b = walk(b, theta)
if isinstance(a, str) and isinstance(b, str) and a == b:
continue
if isinstance(a, str) and not rigid(a):
if occurs(a, b, theta):
return False
theta[a] = b
trail.append(a)
continue
if isinstance(b, str) and not rigid(b):
if occurs(b, a, theta):
return False
theta[b] = a
trail.append(b)
continue
if isinstance(a, str) or isinstance(b, str):
return False
if a[0] != b[0] or len(a) != len(b):
return False
stack.extend(zip(a[1:], b[1:]))
return True
class Node:
"""A node: a substitution, children, and at a leaf the stored entries."""
__slots__ = ("subst", "children", "entries")
def __init__(self, subst: Subst, children: list["Node"] | None = None,
entries: list[tuple[Term, Any]] | None = None) -> None:
self.subst = subst
self.children = children if children is not None else []
self.entries = entries if entries is not None else []
MODES = {
"generalizations": lambda v: v[0] == "?",
"instances": lambda v: v[0] == "#",
"unifiable": lambda v: False,
}
class SubstitutionTree:
"""A substitution tree storing entries (term, value)."""
def __init__(self) -> None:
self.top = Node({})
self.n = 0
self._next = 1
def _fresh(self) -> str:
v = "*" + str(self._next)
self._next += 1
return v
def _generalize(self, sigma: Subst, tau: Subst) -> tuple[Subst, Subst, Subst, bool]:
"""A common generalization mu of sigma and tau on dom(sigma), with
mu alpha = sigma and mu beta = tau there. Where the two terms differ,
mu keeps an indicator variable of sigma, or else gets a fresh one.
Also reports whether some mu(x) is not an indicator variable.
O(|sigma| + |tau|)."""
mu: Subst = {}
alpha: Subst = {}
beta: Subst = {}
nontrivial = False
for x, s in sigma.items():
u = tau[x]
out: list[Term] = []
stack: list[tuple[Term, Term, bool]] = [(s, u, False)]
while stack:
a, b, done = stack.pop()
if done:
k = len(a) - 1
args = out[len(out) - k:] if k else []
del out[len(out) - k:]
out.append((a[0], *args))
elif isinstance(a, str) and isinstance(b, str) and a == b:
out.append(a)
elif (not isinstance(a, str) and not isinstance(b, str)
and a[0] == b[0] and len(a) == len(b)):
stack.append((a, b, True))
for p, q in reversed(list(zip(a[1:], b[1:]))):
stack.append((p, q, False))
elif isinstance(a, str) and is_ivar(a):
beta[a] = b # keep the open variable of sigma, bound below
out.append(a)
else:
z = self._fresh()
alpha[z] = a
beta[z] = b
out.append(z)
mu[x] = out[0]
if not (isinstance(out[0], str) and is_ivar(out[0])):
nontrivial = True
return mu, alpha, beta, nontrivial
def insert(self, term: Term, value: Any) -> None:
"""Store the entry (term, value). Each child of each visited node is
tried once by matching and at most once by generalization, each in
time linear in its substitution, so the cost is O(S + |term|), where S
is the total size of the substitutions in the tree."""
tau: Subst = {"*0": normalize(term)} # @line L1
node = self.top
while tau: # @line L2
nxt = None
for c in node.children: # @line L3
rho = match_pairs([(c.subst[x], tau[x]) for x in c.subst], is_ivar)
if rho is not None:
nxt = (c, rho)
break
if nxt is None:
break
c, rho = nxt
tau = {x: u for x, u in tau.items() if x not in c.subst} # @line L4
tau.update(rho)
node = c
if not tau: # @line L5
node.entries.append((term, value))
self.n += 1
return
for i, c in enumerate(node.children): # @line L6
mu, alpha, beta, nontrivial = self._generalize(c.subst, tau)
if nontrivial and alpha: # @line L7
rest = {x: u for x, u in tau.items() if x not in mu}
rest.update(beta)
c.subst = alpha
node.children[i] = Node(mu, [c, Node(rest, entries=[(term, value)])]) # @line L8
self.n += 1
return
node.children.append(Node(dict(tau), entries=[(term, value)])) # @line L9
self.n += 1
def retrieve(self, query: Term, mode: str = "unifiable") -> list[tuple[Term, Any]]:
"""Return the stored entries (term, value) whose term
- is a generalization of query (term sigma = query), for mode "generalizations";
- is an instance of query (query sigma = term), for mode "instances";
- is unifiable with query after renaming apart, for mode "unifiable".
The cost is one unification for each visited node; for generalizations
every variable is bound to a subterm of the query, so a node with a
substitution of size s costs O(s n) and the total is O(S n)."""
rigid = MODES[mode]
q = rebuild(query, lambda v: "?" + v)
theta: Subst = {"*0": q} # @line L10
trail: list[str] = []
out: list[tuple[Term, Any]] = []
stack: list[tuple[str, Any]] = [("visit", c) for c in reversed(self.top.children)]
while stack: # @line L11
op, x = stack.pop()
if op == "undo": # @line L12
while len(trail) > x:
del theta[trail.pop()]
continue
mark = len(trail)
ok = all(unify(v, s, theta, trail, rigid) for v, s in x.subst.items()) # @line L13
if ok and x.children: # @line L14
stack.append(("undo", mark))
stack.extend(("visit", c) for c in reversed(x.children))
continue
if ok: # @line L15
out.extend(x.entries)
while len(trail) > mark: # @line L16
del theta[trail.pop()]
return out
def delete(self, term: Term, value: Any) -> bool:
"""Remove one copy of the entry (term, value); return whether it was there."""
t = normalize(term)
stack: list[tuple[Node, Subst, list[Node]]] = [(self.top, {"*0": t}, [self.top])]
while stack: # @line L17
node, tau, path = stack.pop()
if not tau:
for k, (s, v) in enumerate(node.entries): # @line L18
if v == value and term_eq(s, term):
del node.entries[k]
self.n -= 1
self._prune(path)
return True
continue
for c in node.children:
rho = match_pairs([(c.subst[x], tau[x]) for x in c.subst], is_ivar)
if rho is not None:
nt = {x: u for x, u in tau.items() if x not in c.subst}
nt.update(rho)
stack.append((c, nt, path + [c]))
return False
def _prune(self, path: list[Node]) -> None:
leaf = path[-1]
if leaf.entries:
return
parent = path[-2]
parent.children.remove(leaf) # @line L19
if parent is self.top or len(parent.children) != 1:
return
child = parent.children[0] # @line L20
introduced: set[str] = set()
for s in parent.subst.values():
stack = [s]
while stack:
u = stack.pop()
if isinstance(u, str):
if is_ivar(u):
introduced.add(u)
else:
stack.extend(u[1:])
merged = {x: apply(s, child.subst) for x, s in parent.subst.items()} # @line L21
merged.update({y: s for y, s in child.subst.items() if y not in introduced})
grand = path[-3]
grand.children[grand.children.index(parent)] = Node(merged, child.children, child.entries)
def entries(self) -> list[tuple[Term, Any]]:
"""All stored entries. O(number of nodes + n)."""
out: list[tuple[Term, Any]] = []
stack = [self.top]
while stack:
x = stack.pop()
out.extend(x.entries)
stack.extend(x.children)
return out
def __len__(self) -> int:
return self.n
test_substitution_tree.py 242 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/substitution-tree/. The authors are listed in prov/6X13J0.jsonl.
import random
from substitution_tree import SubstitutionTree, apply, normalize, term_eq
A, B = ("a",), ("b",)
def g(t):
return ("g", t)
def f(s, t):
return ("f", s, t)
PAGE = [f("x", A), f("x", B), f(g("x"), A), f("x", "x")]
def page_tree():
d = SubstitutionTree()
for i, p in enumerate(PAGE, 1):
d.insert(p, i)
return d
# ---------- a naive, independent oracle (recursive, for small terms) ----------
def o_var(t):
return isinstance(t, str)
def o_match(p, t, s):
"""Extend s so that p s = t; variables of t are constants."""
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_resolve(t, s):
while o_var(t) and t in s:
t = s[t]
return t
def o_occurs(v, t, s):
t = o_resolve(t, s)
if o_var(t):
return t == v
return any(o_occurs(v, a, s) for a in t[1:])
def o_unify(a, b, s):
a, b = o_resolve(a, s), o_resolve(b, s)
if a == b:
return True
if o_var(a):
if o_occurs(a, b, s):
return False
s[a] = b
return True
if o_var(b):
return o_unify(b, a, s)
if a[0] != b[0] or len(a) != len(b):
return False
return all(o_unify(x, y, s) for x, y in zip(a[1:], b[1:]))
def o_rename(t, prefix):
if o_var(t):
return prefix + t
return (t[0],) + tuple(o_rename(a, prefix) for a in t[1:])
def oracle(stored, q, mode):
out = []
for t, v in stored:
if mode == "generalizations":
ok = o_match(t, q, {})
elif mode == "instances":
ok = o_match(q, t, {})
else:
ok = o_unify(o_rename(t, "L"), o_rename(q, "R"), {})
if ok:
out.append((t, v))
return out
def key(entries):
return sorted((repr(t), v) for t, v in entries)
# ---------- structural invariants ----------
def check_invariants(d):
"""Each leaf's composed term is the normalized stored term, each indicator
variable is bound at most once on a path, inner nodes other than the top
have at least two children, and the count is right."""
count = 0
stack = [(c, {"*0": "*0"}, set()) for c in d.top.children]
while stack:
node, comp, bound = stack.pop()
assert node.subst, "empty substitution"
assert not (set(node.subst) & bound), "indicator variable bound twice"
new = {x: apply(t, node.subst) for x, t in comp.items()}
nb = bound | set(node.subst)
if node.children:
assert not node.entries
assert len(node.children) >= 2
for c in node.children:
stack.append((c, new, nb))
else:
assert node.entries
for t, _ in node.entries:
assert term_eq(new["*0"], normalize(t))
count += len(node.entries)
assert count == d.n == len(d.entries())
# ---------- tests ----------
def test_page_example():
d = page_tree()
check_invariants(d)
assert len(d.top.children) == 1
root = d.top.children[0]
t = root.subst["*0"]
assert t[0] == "f" and all(isinstance(a, str) and a[0] == "*" for a in t[1:])
assert sorted(len(c.children) for c in root.children) == [0, 3]
r = d.retrieve(f(A, A), "generalizations")
assert key(r) == key([(PAGE[0], 1), (PAGE[3], 4)])
r = d.retrieve(f(g(B), A), "generalizations")
assert key(r) == key([(PAGE[0], 1), (PAGE[2], 3)])
r = d.retrieve(f(A, "y"), "unifiable")
assert key(r) == key([(PAGE[0], 1), (PAGE[1], 2), (PAGE[3], 4)])
r = d.retrieve(f(g("z"), "y"), "instances")
assert key(r) == key([(PAGE[2], 3)])
# deleting f(g(x), a) leaves the root with one child, and the two merge
assert d.delete(f(g("x"), A), 3)
check_invariants(d)
root = d.top.children[0]
assert term_eq(root.subst["*0"], ("f", "#1", root.subst["*0"][2]))
assert len(root.children) == 3
assert not d.delete(f(g("x"), A), 3)
assert d.delete(f("x", B), 2)
check_invariants(d)
assert d.n == 2
def test_empty_and_single():
d = SubstitutionTree()
assert d.retrieve(A, "unifiable") == []
assert not d.delete(A, 1)
d.insert("x", 7)
check_invariants(d)
for mode in ("generalizations", "unifiable"):
assert d.retrieve(f(A, B), mode) == [("x", 7)]
assert d.retrieve(f(A, B), "instances") == []
assert d.retrieve("y", "instances") == [("x", 7)]
assert d.delete("x", 7)
assert d.n == 0 and d.top.children == []
def test_duplicates_and_variants():
d = SubstitutionTree()
d.insert(f("x", "y"), 1)
d.insert(f("u", "v"), 2)
d.insert(f("x", "y"), 1)
check_invariants(d)
assert len(d.top.children) == 1 and not d.top.children[0].children
assert len(d.retrieve(f(A, A), "generalizations")) == 3
assert d.delete(f("x", "y"), 1)
assert len(d.retrieve(f(A, A), "generalizations")) == 2
def test_occurs_check():
d = SubstitutionTree()
d.insert(f("x", g("x")), 1)
assert d.retrieve(f("y", "y"), "unifiable") == []
assert d.retrieve(f(A, g(A)), "generalizations") == [(f("x", g("x")), 1)]
def test_deep_terms():
d = SubstitutionTree()
t = A
for _ in range(5000):
t = g(t)
u = "x"
for _ in range(4999):
u = g(u)
d.insert(t, 1)
d.insert(u, 2)
check_invariants(d)
assert key(d.retrieve(t, "generalizations")) == key([(t, 1), (u, 2)])
assert key(d.retrieve(u, "instances")) == key([(t, 1), (u, 2)])
assert d.delete(t, 1)
check_invariants(d)
SIG = {"a": 0, "b": 0, "g": 1, "f": 2, "h": 3}
def rand_term(rng, depth, vars_):
if depth == 0 or rng.random() < 0.3:
if rng.random() < 0.45:
return rng.choice(vars_)
return (rng.choice(["a", "b"]),)
name = rng.choice(["g", "f", "f", "h"])
return (name,) + tuple(rand_term(rng, depth - 1, vars_) for _ in range(SIG[name]))
def test_random_against_oracle():
rng = random.Random(11)
modes = ("generalizations", "instances", "unifiable")
for case in range(600):
d = SubstitutionTree()
stored = []
vars_ = ["x", "y"] if case % 2 else ["x", "y", "z"]
for step in range(rng.randint(0, 14)):
if stored and rng.random() < 0.25:
t, v = stored.pop(rng.randrange(len(stored)))
assert d.delete(t, v)
else:
t = rand_term(rng, rng.randint(0, 3), vars_)
v = rng.randrange(4)
d.insert(t, v)
stored.append((t, v))
check_invariants(d)
for _ in range(4):
q = rand_term(rng, rng.randint(0, 3), ["x", "y", "w"])
for mode in modes:
assert key(d.retrieve(q, mode)) == key(oracle(stored, q, mode)), (stored, q, mode)
t = rand_term(rng, 2, vars_)
present = any(term_eq(s, t) and v == 0 for s, v in stored)
assert d.delete(t, 0) == present
What the next level needs
- the tests did not run in this build