Algorithm Search⌘K

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

Read the full page about Narrowing