Algorithm Search⌘K

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
substitution_tree.pyRawGitHub
# 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

Read the full page about Substitution tree