Algorithm Search⌘K

Unfailing completion in Python

Completion that works on equations without orienting them first.

File
unfailing_completion.py, 351 lines
Level
I0 not checked yet
Toolchain
Python 3.14, pytest 9
Test framework
pytest
Run the tests
python3 -m pytest
unfailing_completion.pyRawGitHub
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/unfailing-completion/. The authors are listed in prov/FECHPF.jsonl.

"""Unfailing (ordered) completion with the lexicographic path ordering.

Terms: a variable is a str, an application is a tuple (f, arg1, ..., argk),
and a constant is a 1-tuple such as ("a",). An equation is a pair (s, t) of
terms. The order is the lexicographic path ordering (LPO) for a precedence,
given as a dict from symbols to integers (larger is bigger); with distinct
ranks for all symbols, the LPO is total on ground terms.

Rewriting and the main loop use explicit stacks; the LPO comparison is
recursive, with depth at most the sum of the depths of the two terms.
"""

import heapq
from dataclasses import dataclass, field
from typing import Union

Term = Union[str, tuple]
Equation = tuple[Term, Term]


def is_var(t: Term) -> bool:
    return isinstance(t, str)


def variables(t: Term) -> set[str]:
    out: set[str] = set()
    stack = [t]
    while stack:
        u = stack.pop()
        if isinstance(u, str):
            out.add(u)
        else:
            stack.extend(u[1:])
    return out


def occurs(v: str, t: Term) -> bool:
    return v in variables(t)


def lpo_gt(s: Term, t: Term, prec: dict[str, int]) -> bool:
    """s >_lpo t. Memoized on pairs of subterm occurrences, so there are at
    most |s| |t| distinct calls, each with O(|s| + |t|) work for the
    equality and occurrence tests: O(|s| |t| (|s| + |t|)) time in all."""
    memo: dict[tuple[int, int], bool] = {}

    def gt(s: Term, t: Term) -> bool:
        key = (id(s), id(t))
        if key in memo:
            return memo[key]
        if isinstance(t, str):
            r = not isinstance(s, str) and occurs(t, s)
        elif isinstance(s, str):
            r = False
        elif any(si == t or gt(si, t) for si in s[1:]):
            r = True
        elif prec[s[0]] > prec[t[0]]:
            r = all(gt(s, tj) for tj in t[1:])
        elif s[0] == t[0] and len(s) == len(t) and all(gt(s, tj) for tj in t[1:]):
            r = False
            for a, b in zip(s[1:], t[1:]):
                if a != b:
                    r = gt(a, b)
                    break
        else:
            r = False
        memo[key] = r
        return r

    return gt(s, t)


def apply(t: Term, sigma: dict[str, Term]) -> Term:
    """t sigma, where the values of sigma are already fully applied. O(|t sigma|)."""
    out: list[Term] = []
    stack: list[tuple[Term, bool]] = [(t, False)]
    while stack:
        u, done = stack.pop()
        if isinstance(u, str):
            out.append(sigma.get(u, 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))
            stack.extend((a, False) for a in reversed(u[1:]))
    return out[0]


def match(p: Term, t: Term) -> dict[str, Term] | None:
    """A substitution sigma with p sigma = t, or None; variables of t are constants."""
    sigma: dict[str, Term] = {}
    stack = [(p, t)]
    while stack:
        a, b = stack.pop()
        if isinstance(a, str):
            if a in sigma:
                if sigma[a] != b:
                    return None
            else:
                sigma[a] = b
        elif isinstance(b, str) or a[0] != b[0] or len(a) != len(b):
            return None
        else:
            stack.extend(zip(a[1:], b[1:]))
    return sigma


def unify(s: Term, t: Term) -> dict[str, Term] | None:
    """A most general unifier, fully applied, or None."""
    sigma: dict[str, Term] = {}
    stack = [(s, t)]
    while stack:
        a, b = stack.pop()
        a, b = apply(a, sigma), apply(b, sigma)
        if a == b:
            continue
        if isinstance(b, str) and not isinstance(a, str):
            a, b = b, a
        if isinstance(a, str):
            if occurs(a, b):
                return None
            sigma = {x: apply(u, {a: b}) for x, u in sigma.items()}
            sigma[a] = b
        elif a[0] != b[0] or len(a) != len(b):
            return None
        else:
            stack.extend(zip(a[1:], b[1:]))
    return sigma


def positions(t: Term) -> list[tuple[int, ...]]:
    """The non-variable positions of t, in preorder."""
    out: list[tuple[int, ...]] = []
    stack: list[tuple[Term, tuple[int, ...]]] = [(t, ())]
    while stack:
        u, p = stack.pop()
        if isinstance(u, str):
            continue
        out.append(p)
        for i in range(len(u) - 1, 0, -1):
            stack.append((u[i], p + (i,)))
    return out


def subterm(t: Term, p: tuple[int, ...]) -> Term:
    for i in p:
        t = t[i]
    return t


def replace(t: Term, p: tuple[int, ...], r: Term) -> Term:
    path = []
    for i in p:
        path.append(t)
        t = t[i]
    for u, i in zip(reversed(path), reversed(p)):
        r = u[:i] + (r,) + u[i + 1:]
    return r


def orientations(e: Equation, prec: dict[str, int]) -> list[Equation]:
    """The directions in which an equation may be used: one if it is
    oriented by the LPO, else both."""
    s, t = e
    if lpo_gt(s, t, prec):
        return [(s, t)]
    if lpo_gt(t, s, prec):
        return [(t, s)]
    return [(s, t), (t, s)]


def usable(eqs: list[Equation], prec: dict[str, int]) -> list[tuple[Term, Term, bool]]:
    """The directions u -> v of the equations that ordered rewriting may use,
    with a flag telling whether u > v holds already, so no test is needed.
    A direction is skipped if u is a variable or v has a variable not in u."""
    out = []
    for e in eqs:
        dirs = orientations(e, prec)
        for u, v in dirs:
            if not is_var(u) and variables(v) <= variables(u):
                out.append((u, v, len(dirs) == 1))
    return out


def rewrite_once(t: Term, dirs: list[tuple[Term, Term, bool]], prec: dict[str, int]) -> Term | None:
    """One ordered rewrite step: at the first position p in preorder and with the
    first usable direction u -> v, if t|p = u sigma and u sigma > v sigma."""
    for p in positions(t):
        sub = subterm(t, p)
        for u, v, oriented in dirs:
            sigma = match(u, sub)
            if sigma is None:
                continue
            r = apply(v, sigma)
            if oriented or lpo_gt(sub, r, prec):
                return replace(t, p, r)
    return None


def normal_form(t: Term, eqs: list[Equation], prec: dict[str, int]) -> Term:
    """Ordered rewriting to a normal form; terminates since each step decreases t in the LPO."""
    dirs = usable(eqs, prec)
    while True:
        r = rewrite_once(t, dirs, prec)
        if r is None:
            return t
        t = r


def rename(e: Equation, suffix: str) -> Equation:
    names: dict[str, Term] = {}
    for v in variables(e[0]) | variables(e[1]):
        names[v] = v + suffix
    return apply(e[0], names), apply(e[1], names)


def ordered_critical_pairs(e1: Equation, e2: Equation, prec: dict[str, int]) -> list[Equation]:
    """The ordered critical pairs of e2 into e1: for each usable direction
    u1 = v1 of e1 and u2 = v2 of e2 (renamed apart), each non-variable position
    p of u1 and mgu sigma of u1|p and u2, the pair (u1 sigma[v2 sigma]_p, v1 sigma),
    unless v1 sigma > u1 sigma or v2 sigma > u2 sigma."""
    out: list[Equation] = []
    for u1, v1 in orientations(e1, prec):
        for u2, v2 in orientations(rename(e2, "'"), prec):
            for p in positions(u1):
                sigma = unify(subterm(u1, p), u2)
                if sigma is None:
                    continue
                a, b = apply(u1, sigma), apply(v1, sigma)
                c = apply(v2, sigma)
                if lpo_gt(b, a, prec) or lpo_gt(c, apply(u2, sigma), prec):
                    continue
                out.append((replace(a, p, c), b))
    return out


def size(t: Term) -> int:
    n = 0
    stack = [t]
    while stack:
        u = stack.pop()
        n += 1
        if not isinstance(u, str):
            stack.extend(u[1:])
    return n


def canonical(e: Equation) -> Equation:
    """e with its variables renamed v1, v2, ... by first occurrence, so that
    two equations are variants iff their canonical forms are equal."""
    names: dict[str, Term] = {}
    stack = [e[1], e[0]]
    while stack:
        u = stack.pop()
        if isinstance(u, str):
            if u not in names:
                names[u] = "v" + str(len(names) + 1)
        else:
            stack.extend(reversed(u[1:]))
    return apply(e[0], names), apply(e[1], names)


@dataclass
class Result:
    """saturated is True if the final equations were checked to have no
    ordered critical pair with different normal forms; then ordered rewriting
    with them decides the ground word problem of E."""

    saturated: bool
    equations: list[Equation] = field(default_factory=list)
    steps: int = 0


def unfailing_completion(E: list[Equation], prec: dict[str, int], max_steps: int = 500) -> Result:
    """Unfailing completion with the LPO for prec, in the given-equation style.

    Each step takes a smallest pending equation (oldest first among equal
    sizes), simplifies it by ordered rewriting with the active equations, and,
    unless it becomes trivial or a variant of an active equation, adds it,
    sends back to pending the active equations that it simplifies, and adds
    its ordered critical pairs with the active equations. When nothing is
    pending, all ordered critical pairs of the active set are recomputed; if
    they all have equal normal forms the result is saturated. Gives up after
    max_steps steps. Each step costs a critical pair computation against all
    active equations, so the time is not bounded in terms of |E|."""
    active: list[Equation] = []  # @line L1
    heap: list[tuple[int, int, Equation]] = []
    count = 0

    def push(e: Equation) -> None:
        nonlocal count
        heapq.heappush(heap, (size(e[0]) + size(e[1]), count, e))
        count += 1

    dirs_cache: list = [None]

    def nf(t: Term) -> Term:
        if dirs_cache[0] is None:
            dirs_cache[0] = usable(active, prec)
        while True:
            r = rewrite_once(t, dirs_cache[0], prec)
            if r is None:
                return t
            t = r

    for e in E:
        push(e)
    steps = 0
    while steps < max_steps:  # @line L2
        if not heap:  # @line L3
            open_pairs = [cp for a in active for b in active
                          for cp in ordered_critical_pairs(a, b, prec)
                          if nf(cp[0]) != nf(cp[1])]
            if not open_pairs:
                return Result(True, active, steps)  # @line L4
            for cp in open_pairs:
                push(cp)
            continue
        _, _, (s, t) = heapq.heappop(heap)  # @line L5
        steps += 1
        s, t = nf(s), nf(t)  # @line L6
        c = canonical((s, t))
        if s == t or any(c == canonical(a) or c == canonical((a[1], a[0])) for a in active):  # @line L7
            continue
        if lpo_gt(t, s, prec):  # @line L8
            s, t = t, s
        e = (s, t)
        keep: list[Equation] = []
        dirs = usable([e], prec)
        for a in active:  # @line L9
            if rewrite_once(a[0], dirs, prec) is not None or rewrite_once(a[1], dirs, prec) is not None:
                push(a)
            else:
                keep.append(a)
        active = keep + [e]  # @line L10
        dirs_cache[0] = None
        for other in active:  # @line L11
            for cp in ordered_critical_pairs(e, other, prec) + ordered_critical_pairs(other, e, prec):
                push(cp)
    return Result(False, active, steps)  # @line L12


def equal(s: Term, t: Term, result: Result, prec: dict[str, int]) -> bool:
    """Decide s = t modulo E for ground s and t, if result is saturated."""
    return normal_form(s, result.equations, prec) == normal_form(t, result.equations, prec)
test_unfailing_completion.py 217 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/unfailing-completion/. The authors are listed in prov/FECHPF.jsonl.

import itertools
import random

from unfailing_completion import canonical, equal, lpo_gt, normal_form, unfailing_completion

A, B = ("a",), ("b",)


def f(s, t):
    return ("f", s, t)


def g(t):
    return ("g", t)


# ---------- independent oracles (recursive, for small terms) ----------

def o_match(p, t, s):
    if isinstance(p, str):
        if p in s:
            return s[p] == t
        s[p] = t
        return True
    if isinstance(t, str) 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_apply(t, s):
    if isinstance(t, str):
        return s.get(t, t)
    return (t[0],) + tuple(o_apply(a, s) for a in t[1:])


def o_size(t):
    return 1 if isinstance(t, str) else 1 + sum(o_size(a) for a in t[1:])


def o_steps(t, E):
    """All terms reachable from t by one replacement step with E, in either direction."""
    out = []
    stack = [(t, lambda r: r)]
    while stack:
        u, ctx = stack.pop()
        for l, r in E:
            for x, y in ((l, r), (r, l)):
                s = {}
                if o_match(x, u, s):
                    out.append(ctx(o_apply(y, s)))
        if not isinstance(u, str):
            for i in range(1, len(u)):
                stack.append((u[i], lambda r, u=u, i=i, ctx=ctx: ctx(u[:i] + (r,) + u[i + 1:])))
    return out


def o_class(t, E, max_size, rounds):
    """The ground terms of size at most max_size that are E-equal to t by at most rounds steps."""
    seen = {t}
    frontier = [t]
    for _ in range(rounds):
        nxt = []
        for u in frontier:
            for v in o_steps(u, E):
                if v not in seen and o_size(v) <= max_size:
                    seen.add(v)
                    nxt.append(v)
        frontier = nxt
    return seen


def o_eval(t, alg, env):
    if isinstance(t, str):
        return env[t]
    return alg[t[0]][tuple(o_eval(a, alg, env) for a in t[1:])]


def o_vars(t, acc):
    if isinstance(t, str):
        acc.add(t)
    else:
        for a in t[1:]:
            o_vars(a, acc)
    return acc


def holds(alg, eq, dom):
    vs = sorted(o_vars(eq[0], set()) | o_vars(eq[1], set()))
    for vals in itertools.product(dom, repeat=len(vs)):
        env = dict(zip(vs, vals))
        if o_eval(eq[0], alg, env) != o_eval(eq[1], alg, env):
            return False
    return True


ARITY = {"a": 0, "b": 0, "g": 1, "f": 2}


def random_algebra(rng, dom):
    return {s: {args: rng.choice(dom) for args in itertools.product(dom, repeat=k)} for s, k in ARITY.items()}


def rand_term(rng, depth, vars_):
    if depth == 0 or rng.random() < 0.3:
        if vars_ and rng.random() < 0.5:
            return rng.choice(vars_)
        return (rng.choice(["a", "b"]),)
    name = rng.choice(["g", "f", "f"])
    return (name,) + tuple(rand_term(rng, depth - 1, vars_) for _ in range(ARITY[name]))


def ground_terms(max_size):
    """All ground terms of size at most max_size."""
    by = {1: [A, B]}
    for n in range(2, max_size + 1):
        by[n] = [g(t) for t in by[n - 1]]
        for i in range(1, n - 1):
            by[n] += [f(s, t) for s in by[i] for t in by[n - 1 - i]]
    return [t for n in by for t in by[n]]


# ---------- tests ----------

def test_commutativity():
    """Knuth-Bendix fails on commutativity; unfailing completion saturates at once."""
    prec = {"f": 3, "b": 2, "a": 1}
    r = unfailing_completion([(f("x", "y"), f("y", "x"))], prec)
    assert r.saturated and len(r.equations) == 1
    assert normal_form(f(B, A), r.equations, prec) == f(A, B)
    assert equal(f(f(B, A), A), f(A, f(A, B)), r, prec)
    assert not equal(f(f(A, B), A), f(A, A), r, prec)


def test_page_example():
    prec = {"f": 3, "b": 2, "a": 1}
    E = [(f("x", "y"), f("y", "x")), (f("x", A), "x")]
    r = unfailing_completion(E, prec)
    assert r.saturated
    assert sorted(map(repr, r.equations)) == sorted(map(repr, [
        (f("x", "y"), f("y", "x")), (f("x", A), "x"), (f(A, "x'"), "x'")]))
    assert normal_form(f(f(A, B), f(B, A)), r.equations, prec) == f(B, B)


def test_groups():
    m = lambda x, y: ("*", x, y)
    i = lambda x: ("i", x)
    e = ("e",)
    prec = {"i": 3, "*": 2, "e": 1}
    G = [(m(e, "x"), "x"), (m(i("x"), "x"), e), (m(m("x", "y"), "z"), m("x", m("y", "z")))]
    r = unfailing_completion(G, prec)
    assert r.saturated and len(r.equations) == 10
    assert all(lpo_gt(s, t, prec) for s, t in r.equations)  # all oriented: a convergent system
    assert normal_form(i(m("a1", i("a2"))), r.equations, {**prec, "a1": 0, "a2": 0}) == m("a2", i("a1"))


def test_empty_system():
    r = unfailing_completion([], {"f": 2, "a": 1})
    assert r.saturated and r.equations == []
    assert not equal(f(A, A), A, r, {"f": 2, "a": 1})


def test_non_terminating_with_bound():
    """h(g(h(x))) = h(g(x)) makes the run add h(g^n(h(x))) = h(g^n(x)) for n = 1, 2, ...;
    the step bound stops it."""
    h = lambda t: ("h", t)

    def gn(n, t):
        for _ in range(n):
            t = g(t)
        return t

    r = unfailing_completion([(h(g(h("x"))), h(g("x")))], {"h": 2, "g": 1}, max_steps=25)
    assert not r.saturated and r.steps == 25
    want = {(h(gn(n, h("v1"))), h(gn(n, "v1"))) for n in range(1, 7)}
    assert {canonical(e) for e in r.equations} == want


def test_random_against_oracles():
    rng = random.Random(5)
    ground = ground_terms(5)
    dom = (0, 1)
    saturated = 0
    for case in range(500):
        E = []
        for _ in range(rng.randint(1, 2)):
            while True:
                s = rand_term(rng, 2, ["x", "y"])
                t = rand_term(rng, 2, ["x", "y"])
                if s != t and o_vars(s, set()) == o_vars(t, set()):
                    break
            E.append((s, t))
        syms = ["a", "b", "g", "f"]
        rng.shuffle(syms)
        prec = {s: k for k, s in enumerate(syms)}
        r = unfailing_completion(E, prec, max_steps=25)
        # soundness: every final equation holds in every 2-element model of E
        models = [alg for alg in (random_algebra(rng, dom) for _ in range(40)) if all(holds(alg, e, dom) for e in E)]
        for alg in models:
            for eq in r.equations:
                assert holds(alg, eq, dom), (E, eq)
        sample = rng.sample(ground, 6)
        for t in sample:
            nf = normal_form(t, r.equations, prec)
            for alg in models:
                assert o_eval(nf, alg, {}) == o_eval(t, alg, {})
        if not r.saturated:
            continue
        saturated += 1
        # completeness on ground terms: E-equal terms found by search have equal normal forms
        for t in sample:
            nf = normal_form(t, r.equations, prec)
            for u in o_class(t, E, 7, 3):
                assert normal_form(u, r.equations, prec) == nf, (E, t, u)
    assert saturated >= 400

What the next level needs

  • the tests did not run in this build

Read the full page about Unfailing completion