Hoffmann-O'Donnell tree pattern matching in Python
Compiles a set of tree patterns into a matching automaton for linear-time-per-node matching.
- File
hoffmann_odonnell_matching.py, 210 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/hoffmann-odonnell-matching/. The authors are listed in prov/R5TC80.jsonl.
"""Hoffmann-O'Donnell bottom-up tree pattern matching.
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",). Positions are tuples of 1-based argument indexes.
Preprocessing builds the pattern forest PF: every subterm of every pattern,
with all variables identified with one wildcard v (id 0). A match set is the
set of members of PF that match one subject node. The preprocessing computes
every reachable match set and, for each symbol f of arity k, the table
T[f/k][(M1, ..., Mk)] of the match set of f(t1, ..., tk) when ti has the match
set Mi. This is a deterministic bottom-up tree automaton. Matching is then one
table lookup for each node of the subject.
The wildcard forgets which variables are equal, so the automaton decides
matching for linear patterns. A non-linear pattern is reported only after an
equality check of the subterms that its repeated variables take. Variables of
the subject are treated as constants. All functions use explicit stacks.
"""
from itertools import product
from typing import Optional, Union
Term = Union[str, tuple]
State = int
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 match(pattern: Term, t: Term) -> Optional[dict[str, Term]]:
"""The matcher sigma with pattern sigma = t, or None."""
sigma: dict[str, Term] = {}
stack = [(pattern, t)]
while stack:
p, u = stack.pop()
if is_var(p):
if p in sigma:
if not term_eq(sigma[p], u):
return None
else:
sigma[p] = u
elif is_var(u) or len(p) != len(u) or p[0] != u[0]:
return None
else:
stack.extend(zip(p[1:], u[1:]))
return sigma
def is_linear(t: Term) -> bool:
seen: set[str] = set()
stack = [t]
while stack:
u = stack.pop()
if is_var(u):
if u in seen:
return False
seen.add(u)
else:
stack.extend(u[1:])
return True
class Matcher:
"""The bottom-up matching automaton of a list of patterns."""
def __init__(self, patterns: list[Term], max_states: int = 100000) -> None:
self.patterns = list(patterns)
# PF by hash-consing: id 0 is the wildcard, other ids are (f, k, child ids)
self.node_id: dict[tuple, int] = {}
self.by_symbol: dict[tuple, list[tuple[int, tuple[int, ...]]]] = {}
self.root_id: list[int] = []
for p in self.patterns: # @line L1
self.root_id.append(self._intern(p))
self.linear = [is_linear(p) for p in self.patterns]
self.states: list[frozenset[int]] = [] # @line L2
self.state_of: dict[frozenset[int], State] = {}
self.table: dict[tuple, dict[tuple[State, ...], State]] = {sym: {} for sym in self.by_symbol}
self.wild = self._add(frozenset([0])) # the match set of a node whose symbol is in no pattern
changed = True
while changed: # @line L3
changed = False
for sym, members in self.by_symbol.items(): # @line L4
k = sym[1]
tab = self.table[sym]
for tup in product(range(len(self.states)), repeat=k):
if tup in tab:
continue
m = {0} # @line L5
for pid, kids in members:
if all(kids[i] in self.states[tup[i]] for i in range(k)):
m.add(pid)
s = self._add(frozenset(m)) # @line L6
if s == len(self.states) - 1 and len(self.states) > max_states:
raise RuntimeError("too many match sets")
tab[tup] = s
changed = True
# for each state, the patterns it reports
self.reports: list[list[int]] = [
[j for j, rid in enumerate(self.root_id) if rid in m] for m in self.states
] # @line L7
def _add(self, m: frozenset[int]) -> State:
s = self.state_of.get(m)
if s is None:
s = len(self.states)
self.states.append(m)
self.state_of[m] = s
return s
def _intern(self, p: Term) -> int:
ids: list[int] = []
stack: list[tuple[bool, Term]] = [(False, p)]
while stack:
done, u = stack.pop()
if is_var(u):
ids.append(0)
elif done or len(u) == 1:
k = len(u) - 1
kids = tuple(ids[len(ids) - k:]) if k else ()
del ids[len(ids) - k:]
key = (u[0], k) + kids
pid = self.node_id.get(key)
if pid is None:
pid = len(self.node_id) + 1
self.node_id[key] = pid
self.by_symbol.setdefault((u[0], k), []).append((pid, kids))
ids.append(pid)
else:
stack.append((True, u))
for a in reversed(u[1:]):
stack.append((False, a))
return ids[0]
def num_states(self) -> int:
return len(self.states)
def match_sets(self, t: Term) -> tuple[list[Term], list[int], list[int], list[State]]:
"""Number the nodes of t and compute their states bottom-up.
Returns the subterms, the parent and argument index of each node (for
positions), and the state of each node.
"""
nodes: list[Term] = [t]
parent: list[int] = [-1]
argi: list[int] = [0]
kids: list[list[int]] = [[]]
state: list[State] = [self.wild]
stack: list[tuple[bool, int]] = [(False, 0)]
while stack: # @line L8
done, i = stack.pop()
u = nodes[i]
if is_var(u):
state[i] = self.wild # @line L9
elif done or len(u) == 1:
tab = self.table.get((u[0], len(u) - 1))
key = tuple(state[c] for c in kids[i])
state[i] = self.wild if tab is None else tab[key] # @line L10
else:
stack.append((True, i))
for a in range(1, len(u)):
nodes.append(u[a])
parent.append(i)
argi.append(a)
kids.append([])
state.append(self.wild)
kids[i].append(len(nodes) - 1)
for c in reversed(kids[i]):
stack.append((False, c))
return nodes, parent, argi, state
def find_all(self, t: Term) -> list[tuple[tuple[int, ...], int]]:
"""All (position, pattern index) with the pattern matching t at the position.
The list is sorted by position, then by pattern index.
"""
nodes, parent, argi, state = self.match_sets(t)
out: list[tuple[tuple[int, ...], int]] = []
for i, u in enumerate(nodes):
for j in self.reports[state[i]]: # @line L11
if self.linear[j] or match(self.patterns[j], u) is not None: # @line L12
pos: list[int] = []
k = i
while parent[k] >= 0:
pos.append(argi[k])
k = parent[k]
out.append((tuple(reversed(pos)), j))
out.sort()
return out # @line L13
test_hoffmann_odonnell_matching.py 170 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/hoffmann-odonnell-matching/. The authors are listed in prov/R5TC80.jsonl.
import random
import pytest
from hoffmann_odonnell_matching import Matcher
A, B = ("a",), ("b",)
def f(s, t):
return ("f", s, t)
def g(t):
return ("g", t)
# ---------- an independent, naive oracle (recursive, for small terms) ----------
def o_var(t):
return isinstance(t, str)
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_positions(t):
out = [((), t)]
if not o_var(t):
for i in range(1, len(t)):
out += [((i,) + p, u) for p, u in o_positions(t[i])]
return out
def o_find_all(patterns, t):
return sorted((p, j) for p, u in o_positions(t) for j, q in enumerate(patterns) if o_match(q, u, {}))
def find_all(m, t):
return sorted(m.find_all(t))
# ---------- the examples of the page ----------
def test_page_example():
m = Matcher([f(A, "x"), f("x", g(A))])
assert m.num_states() == 6
assert len(m.table[("f", 2)]) == 36 and len(m.table[("g", 1)]) == 6
t = f(f(A, B), g(A))
assert find_all(m, t) == [((), 1), ((1,), 0)]
assert find_all(m, f(A, g(A))) == [((), 0), ((), 1)]
nodes, parent, argi, state = m.match_sets(t)
names = [sorted(m.states[s]) for s in state]
assert len(names) == 6 and names[nodes.index(B)] == [0] # b is in no pattern: {v}
def test_exponential_match_sets():
# h(x, ..., a, ..., x) with a at place i, for i = 1..k: 2^k match sets
for k in range(1, 4):
pats = [("h",) + tuple(A if j == i else "x%d" % j for j in range(k)) for i in range(k)]
m = Matcher(pats)
# {v} (no pattern), {v, a}, and one set for each nonempty subset of the patterns
assert m.num_states() == 2 ** k + 1
assert len(m.table[("h", k)]) == (2 ** k + 1) ** k
def test_edge_cases():
m = Matcher([])
assert m.find_all(f(A, B)) == [] and m.num_states() == 1
m = Matcher(["x"]) # a variable matches everywhere
assert find_all(m, f(A, "y")) == [((), 0), ((1,), 0), ((2,), 0)]
m = Matcher([("f", A), f(A, A)]) # same name, two arities
assert find_all(m, ("f", A)) == [((), 0)]
assert find_all(m, f(A, A)) == [((), 1)]
m = Matcher([g(A), g(A)]) # a pattern given twice
assert find_all(m, g(A)) == [((), 0), ((), 1)]
m = Matcher([g("x")]) # subject variables are constants
assert find_all(m, g("x")) == [((), 0)]
assert find_all(m, "x") == []
with pytest.raises(RuntimeError):
Matcher([("h",) + tuple(A if j == i else "x%d" % j for j in range(5)) for i in range(5)], max_states=10)
def test_non_linear_patterns():
m = Matcher([f("x", "x"), f("x", "y")])
assert find_all(m, f(g(A), g(A))) == [((), 0), ((), 1)]
assert find_all(m, f(g(A), g(B))) == [((), 1)]
m = Matcher([("h", "x", g("x"), "y")])
assert find_all(m, ("h", A, g(A), B)) == [((), 0)]
assert find_all(m, ("h", A, g(B), B)) == []
def test_deep_terms():
n = 50000
t = A
for _ in range(n):
t = g(t)
m = Matcher([g(g(A)), f("x", "x")])
assert find_all(m, t) == [((1,) * (n - 2), 0)]
assert m.find_all(f(t, t))[0] == ((), 1) and len(m.find_all(f(t, t))) == 3
assert [j for _, j in m.find_all(f(t, g(t)))] == [0, 0]
# ---------- random tests against the oracle ----------
SIG = {"f": 2, "g": 1, "h": 3, "a": 0, "b": 0}
def rand_term(rng, vs, depth):
if depth == 0 or rng.random() < 0.3:
if vs and rng.random() < 0.5:
return rng.choice(vs)
return (rng.choice(["a", "b"]),)
name = rng.choice(["f", "g", "h", "f", "g"])
return (name,) + tuple(rand_term(rng, vs, depth - 1) for _ in range(SIG[name]))
def test_random_against_brute_force():
rng = random.Random(5)
hits = 0
for case in range(700):
pats = [rand_term(rng, ["x", "y", "z"], rng.randint(0, 3)) for _ in range(rng.randint(0, 5))]
m = Matcher(pats)
for _ in range(4):
t = rand_term(rng, ["x", "u"] if rng.random() < 0.3 else [], rng.randint(0, 5))
want = o_find_all(pats, t)
assert find_all(m, t) == want, (pats, t)
hits += len(want)
assert hits > 2000
def test_random_match_sets_are_exact():
"""Each state is exactly the set of subpatterns that match the node."""
rng = random.Random(6)
for case in range(300):
pats = [rand_term(rng, ["x", "y"], rng.randint(0, 3)) for _ in range(rng.randint(1, 4))]
m = Matcher(pats)
inv = {pid: key for key, pid in m.node_id.items()}
def build(pid):
if pid == 0:
return "v"
key = inv[pid]
return (key[0],) + tuple(build(c) for c in key[2:])
t = rand_term(rng, [], rng.randint(0, 4))
nodes, parent, argi, state = m.match_sets(t)
for u, s in zip(nodes, state):
want = {0} | {pid for pid in inv if _lin_match(build(pid), u)}
assert set(m.states[s]) == want
def _lin_match(p, t):
"""Match with every occurrence of v a separate variable."""
if o_var(p):
return True
if o_var(t) or p[0] != t[0] or len(p) != len(t):
return False
return all(_lin_match(a, b) for a, b in zip(p[1:], t[1:]))
What the next level needs
- the tests did not run in this build
Read the full page about Hoffmann-O'Donnell tree pattern matching