Algorithm Search⌘K

Knuth-Bendix completion in Python

Turns a set of equations into a confluent, terminating rewrite system by adding critical-pair rules.

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

"""Knuth-Bendix completion with simplification, collapse and composition.

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",). An equation or a rule is a pair (s, t) of terms. Positions are tuples
of 1-based argument indexes.

complete(equations, greater, max_steps) returns one of
    ("success", rules)    rules is convergent and has the same equational
                          theory as the equations,
    ("fail", (s, t))      the normalized equation s = t cannot be oriented,
    ("give up", rules)    max_steps iterations of the main loop were used.

greater(s, t) must be a reduction ordering; lpo(precedence) builds the
lexicographic path ordering. All functions use explicit stacks, so deep terms
do not hit the recursion limit.
"""

import heapq
from itertools import count
from typing import Callable, Optional, Union

Term = Union[str, tuple]
Pair = tuple[Term, Term]
Order = Callable[[Term, Term], bool]


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 size(t: Term) -> int:
    n = 0
    stack = [t]
    while stack:
        u = stack.pop()
        n += 1
        if not is_var(u):
            stack.extend(u[1:])
    return n


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 match(pattern: Term, term: Term) -> Optional[dict[str, Term]]:
    """Return sigma with pattern sigma = term, or None."""
    sigma: dict[str, Term] = {}
    stack = [(pattern, term)]
    while stack:
        p, t = stack.pop()
        if is_var(p):
            if p in sigma:
                if not term_eq(sigma[p], t):
                    return None
            else:
                sigma[p] = t
        elif is_var(t) or len(p) != len(t) or p[0] != t[0]:
            return None
        else:
            stack.extend(zip(p[1:], t[1:]))
    return sigma


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 rename(t: Term, names: dict[str, str]) -> Term:
    return substitute(t, {x: y for x, y in names.items()})


_NAMES = ["x", "y", "z", "u", "v", "w"]


def canonical(pair: Pair) -> Pair:
    """Rename the variables of a pair to x, y, z, u, v, w, x7, x8, ..."""
    s, t = pair
    seen = variables(("=", s, t))
    names = {v: (_NAMES[i] if i < len(_NAMES) else f"x{i + 1}") for i, v in enumerate(seen)}
    return rename(s, names), rename(t, names)


# ---------- rewriting ----------

def reducible(t: Term, rules: list[Pair]) -> bool:
    for _, u in subterms(t):
        if not is_var(u):
            for l, _ in rules:
                if match(l, u) is not None:
                    return True
    return False


def normalize(t: Term, rules: list[Pair]) -> Term:
    """Innermost normal form of t. The rules must terminate."""
    by_root: dict[str, list[Pair]] = {}
    for l, r in rules:
        by_root.setdefault(l[0], []).append((l, r))
    tasks: list[tuple] = [(0, t, None)]
    vals: list[Term] = []
    while tasks:
        kind, u, env = tasks.pop()
        if kind == 0:  # evaluate u under env, whose values are normal forms
            if is_var(u):
                vals.append(env[u] if env is not None and u in env else u)
            else:
                tasks.append((1, u, len(u) - 1))
                for a in reversed(u[1:]):
                    tasks.append((0, a, env))
        else:  # build u[0] over the top values and try the rules at the root
            n = env
            args = vals[len(vals) - n:] if n else []
            del vals[len(vals) - n:]
            s = (u[0],) + tuple(args)
            for l, r in by_root.get(s[0], ()):
                tau = match(l, s)
                if tau is not None:
                    tasks.append((0, r, tau))
                    break
            else:
                vals.append(s)
    return vals[0]


def critical_pairs(rule1: Pair, rule2: Pair, same: bool = False) -> list[Pair]:
    """Critical pairs from overlapping rule2 into non-variable positions of
    rule1. If same is true, the root overlap is skipped."""
    v1, v2 = variables(rule1[0]), variables(rule2[0])
    l1 = rename(rule1[0], {x: f"_a{i}" for i, x in enumerate(v1)})
    r1 = rename(rule1[1], {x: f"_a{i}" for i, x in enumerate(v1)})
    l2 = rename(rule2[0], {x: f"_b{i}" for i, x in enumerate(v2)})
    r2 = rename(rule2[1], {x: f"_b{i}" for i, x in enumerate(v2)})
    out = []
    for p, u in subterms(l1):
        if is_var(u) or (same and p == ()):
            continue
        sigma = unify(u, l2)
        if sigma is None:
            continue
        top = substitute(l1, sigma)
        out.append(canonical((replace(top, p, substitute(r2, sigma)), substitute(r1, sigma))))
    return out


def all_critical_pairs(rules: list[Pair]) -> list[Pair]:
    out = []
    for i, a in enumerate(rules):
        for j, b in enumerate(rules):
            out.extend(critical_pairs(a, b, same=(i == j)))
    return out


# ---------- the lexicographic path ordering ----------

def lpo(precedence: dict[str, int]) -> Order:
    """s >lpo t for the precedence f > g iff precedence[f] > precedence[g].
    Symbols missing from the precedence are incomparable with all others.
    Pairs of subterms are memoized, so the time is polynomial."""

    def greater(s: Term, t: Term) -> bool:
        ids: dict[tuple, int] = {}
        nodes: list[tuple] = []
        varsets: list[frozenset] = []

        def intern(term: Term) -> int:
            out: list[int] = []
            stack: list[tuple[bool, Term]] = [(False, term)]
            while stack:
                done, u = stack.pop()
                if is_var(u):
                    key: tuple = ("?", u)
                    vs = frozenset([u])
                elif not done:
                    stack.append((True, u))
                    for a in reversed(u[1:]):
                        stack.append((False, a))
                    continue
                else:
                    n = len(u) - 1
                    kids = tuple(out[len(out) - n:]) if n else ()
                    del out[len(out) - n:]
                    key = (u[0], kids)
                    vs = frozenset().union(*(varsets[k] for k in kids)) if kids else frozenset()
                if key not in ids:
                    ids[key] = len(nodes)
                    nodes.append(key)
                    varsets.append(vs)
                out.append(ids[key])
            return out[0]

        a0, b0 = intern(s), intern(t)
        memo: dict[tuple[int, int], bool] = {}

        def frame(a: int, b: int):
            fa, ka = nodes[a]
            fb, kb = nodes[b]
            if fa == "?":
                return False
            if fb == "?":
                return kb in varsets[a] and a != b
            for c in ka:
                if c == b:
                    return True
                if (yield (c, b)):
                    return True
            pf, pg = precedence.get(fa), precedence.get(fb)
            if fa != fb and pf is not None and pg is not None and pf > pg:
                for c in kb:
                    if not (yield (a, c)):
                        return False
                return True
            if fa == fb and len(ka) == len(kb):
                k = 0
                while k < len(ka) and ka[k] == kb[k]:
                    k += 1
                if k == len(ka) or not (yield (ka[k], kb[k])):
                    return False
                for c in kb:
                    if not (yield (a, c)):
                        return False
                return True
            return False

        stack = [((a0, b0), frame(a0, b0))]
        val = None
        while stack:
            key, gen = stack[-1]
            try:
                q = gen.send(val)
            except StopIteration as stop:
                memo[key] = bool(stop.value)
                val = memo[key]
                stack.pop()
                continue
            if q in memo:
                val = memo[q]
            else:
                stack.append((q, frame(*q)))
                val = None
        return memo[(a0, b0)]

    return greater


# ---------- completion ----------

def complete(equations: list[Pair], greater: Order, max_steps: int = 1000):
    """Knuth-Bendix completion; see the module docstring for the result."""
    tick = count()
    pending: list = []

    def push(s: Term, t: Term) -> None:
        heapq.heappush(pending, (size(s) + size(t), next(tick), s, t))

    for s, t in equations:  # @line L1
        push(s, t)
    rules: list[Pair] = []
    steps = 0
    while True:  # @line L2
        if not pending:  # @line L3
            for u, v in all_critical_pairs(rules):
                if not term_eq(normalize(u, rules), normalize(v, rules)):
                    push(u, v)
            if not pending:
                return "success", rules
        steps += 1  # @line L4
        if steps > max_steps:
            return "give up", rules
        _, _, s, t = heapq.heappop(pending)  # @line L5
        s, t = normalize(s, rules), normalize(t, rules)
        if term_eq(s, t):  # @line L6
            continue
        if greater(s, t):  # @line L7
            rho = canonical((s, t))
        elif greater(t, s):
            rho = canonical((t, s))
        else:
            return "fail", (s, t)
        kept = []  # @line L8
        for g, d in rules:
            if reducible(g, [rho]):
                push(g, d)
            else:
                kept.append((g, d))
        for i, (g, d) in enumerate(kept):  # @line L9
            others = kept[:i] + kept[i + 1:] + [rho]
            kept[i] = (g, normalize(d, others))
        rules = kept + [rho]  # @line L10
        for rule in rules:
            for u, v in critical_pairs(rho, rule, same=rule is rho):
                push(u, v)
            if rule is not rho:
                for u, v in critical_pairs(rule, rho):
                    push(u, v)
test_knuth_bendix_completion.py 405 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/knuth-bendix-completion/. The authors are listed in prov/WM035M.jsonl.

import itertools
import random

from knuth_bendix_completion import (
    canonical,
    complete,
    critical_pairs,
    lpo,
    normalize,
    term_eq,
    unify,
)


def P(text):
    """Parse 'f(x, g(a))' into a term; names starting with u..z are variables."""
    pos = 0

    def term():
        nonlocal pos
        start = pos
        while pos < len(text) and (text[pos].isalnum() or text[pos] == "_"):
            pos += 1
        name = text[start:pos]
        if pos < len(text) and text[pos] == "(":
            pos += 1
            args = [term()]
            while text[pos] == ",":
                pos += 1
                while text[pos] == " ":
                    pos += 1
                args.append(term())
            pos += 1
            return (name,) + tuple(args)
        return name if name[0] in "uvwxyz" else (name,)

    text = text.replace(" ", "")
    return term()


def E(*pairs):
    return [(P(a), P(b)) for a, b in pairs]


GROUP = E(("m(e, x)", "x"), ("m(i(x), x)", "e"), ("m(m(x, y), z)", "m(x, m(y, z))"))
GROUP_RULES = E(
    ("m(e, x)", "x"), ("m(x, e)", "x"), ("m(i(x), x)", "e"), ("m(x, i(x))", "e"),
    ("m(m(x, y), z)", "m(x, m(y, z))"), ("i(e)", "e"), ("i(i(x))", "x"),
    ("m(i(x), m(x, y))", "y"), ("m(x, m(i(x), y))", "y"), ("i(m(x, y))", "m(i(y), i(x))"),
)
GROUP_PREC = {"i": 3, "m": 2, "e": 1}


def as_set(rules):
    return {canonical(r) for r in rules}


# ---------- an independent, naive oracle (recursive, for small terms) ----------

def o_var(t):
    return isinstance(t, str)


def o_vars(t):
    return {t} if o_var(t) else set().union(*[o_vars(a) for a in t[1:]]) if len(t) > 1 else set()


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_nf(t, rules):
    while True:
        nxt = o_steps(t, rules)
        if not nxt:
            return t
        t = nxt[-1]  # any strategy: the rules are convergent when this is used


def o_unify(eqs):
    s = {}
    eqs = list(eqs)
    while eqs:
        a, b = eqs.pop()
        a, b = o_sub(a, s), o_sub(b, s)
        if a == b:
            continue
        if not o_var(a) and o_var(b):
            a, b = b, a
        if o_var(a):
            if a in o_vars(b):
                return None
            s = {x: o_sub(v, {a: b}) for x, v in s.items()}
            s[a] = b
            continue
        if a[0] != b[0] or len(a) != len(b):
            return None
        eqs.extend(zip(a[1:], b[1:]))
    return s


def o_positions(t, p=()):
    yield p, t
    if not o_var(t):
        for i in range(1, len(t)):
            yield from o_positions(t[i], p + (i,))


def o_put(t, p, s):
    if not p:
        return s
    i = p[0]
    return t[:i] + (o_put(t[i], p[1:], s),) + t[i + 1:]


def o_cps(rules):
    out = []
    for i, (l1, r1) in enumerate(rules):
        for j, (l2, r2) in enumerate(rules):
            ren = {x: x + "'" for x in o_vars(l2)}
            l2r, r2r = o_sub(l2, ren), o_sub(r2, ren)
            for p, u in o_positions(l1):
                if o_var(u) or (i == j and p == ()):
                    continue
                s = o_unify([(u, l2r)])
                if s is not None:
                    out.append((o_put(o_sub(l1, s), p, o_sub(r2r, s)), o_sub(r1, s)))
    return out


def o_lpo(s, t, prec):
    if o_var(s):
        return False
    if o_var(t):
        return t in o_vars(s) and s != t
    if any(a == t or o_lpo(a, t, prec) for a in s[1:]):
        return True
    f, g = s[0], t[0]
    if f != g and f in prec and g in prec and prec[f] > prec[g]:
        return all(o_lpo(s, b, prec) for b in t[1:])
    if f == g and len(s) == len(t):
        for a, b in zip(s[1:], t[1:]):
            if a != b:
                return o_lpo(a, b, prec) and all(o_lpo(s, c, prec) for c in t[1:])
        return False
    return False


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


def o_holds(eq, alg, dom):
    s, t = eq
    vs = sorted(o_vars(s) | o_vars(t))
    for vals in itertools.product(dom, repeat=len(vs)):
        env = dict(zip(vs, vals))
        if o_eval(s, alg, env) != o_eval(t, alg, env):
            return False
    return True


def all_algebras(sig, dom):
    """Every algebra over the domain for the signature {symbol: arity}."""
    names = sorted(sig)
    choices = []
    for f in names:
        keys = list(itertools.product(dom, repeat=sig[f]))
        choices.append([dict(zip(keys, vals)) for vals in itertools.product(dom, repeat=len(keys))])
    for combo in itertools.product(*choices):
        yield dict(zip(names, combo))


def o_bfs(t, eqs, depth, max_size):
    """Terms reachable from t by at most depth replacements with eqs, both ways."""
    both = eqs + [(b, a) for a, b in eqs if not o_var(b) and o_vars(a) <= o_vars(b)]
    seen = {t}
    frontier = [t]
    for _ in range(depth):
        nxt = []
        for u in frontier:
            for v in o_steps(u, both):
                if v not in seen and len(repr(v)) <= max_size:
                    seen.add(v)
                    nxt.append(v)
        frontier = nxt
    return seen


# ---------- the examples of the page ----------

def test_page_example():
    status, rules = complete(E(("f(f(x))", "g(x)")), lpo({"f": 2, "g": 1}))
    assert status == "success"
    assert rules == E(("f(f(x))", "g(x)"), ("f(g(x))", "g(f(x))"))


def test_groups():
    status, rules = complete(GROUP, lpo(GROUP_PREC))
    assert status == "success"
    assert as_set(rules) == as_set(GROUP_RULES)


def test_groups_any_input_order_gives_the_same_reduced_system():
    for perm in itertools.permutations(GROUP):
        status, rules = complete(list(perm), lpo(GROUP_PREC))
        assert status == "success" and as_set(rules) == as_set(GROUP_RULES)


def test_divergence_gives_up():
    status, rules = complete(E(("f(g(f(x)))", "g(f(x))")), lpo({"f": 2, "g": 1}), max_steps=20)
    assert status == "give up"
    assert canonical((P("f(g(g(g(f(x)))))"), P("g(g(g(f(x))))"))) in as_set(rules)


def test_commutativity_fails():
    status, eq = complete(E(("p(x, y)", "p(y, x)")), lpo({"p": 1}))
    assert status == "fail" and as_set([eq]) == as_set(E(("p(x, y)", "p(y, x)")))


def test_empty_and_trivial():
    assert complete([], lpo({})) == ("success", [])
    assert complete([("x", "x")], lpo({})) == ("success", [])
    assert complete([(P("f(x)"), P("f(x)"))], lpo({"f": 1})) == ("success", [])
    assert complete([("x", "y")], lpo({}))[0] == "fail"


def test_non_linear_and_shared_variables():
    status, rules = complete(E(("f(x, x)", "x")), lpo({"f": 1}))
    assert status == "success" and rules == E(("f(x, x)", "x"))
    # x occurs on both sides; the right side has fewer variables
    status, rules = complete(E(("h(x, y)", "g(x)")), lpo({"h": 2, "g": 1}))
    assert status == "success" and rules == E(("h(x, y)", "g(x)"))
    # the other orientation would introduce a variable
    status, eq = complete(E(("h(x, y)", "g(x)")), lpo({"h": 1, "g": 2}))
    assert status == "fail"


def test_collapse_and_compose():
    # a = b, f(a) = c, then b = c makes f(a) -> c's left side reducible
    status, rules = complete(E(("f(a)", "c"), ("a", "b")), lpo({"f": 4, "a": 3, "b": 2, "c": 1}))
    assert status == "success"
    assert as_set(rules) == as_set(E(("a", "b"), ("f(b)", "c")))


def test_deep_terms():
    n = 2000
    t = ("a",)
    for _ in range(n):
        t = ("g", t)
    status, rules = complete([(t, ("a",))], lpo({"g": 2, "a": 1}))
    assert status == "success" and len(rules) == 1 and term_eq(rules[0][0], t)
    u = ("b",)
    for _ in range(n):
        u = ("g", u)
    assert unify(t, u) is None
    deep = ("a",)
    for _ in range(3 * n):
        deep = ("g", deep)
    assert term_eq(normalize(deep, rules), ("a",))  # g^6000(a) -> g^4000(a) -> g^2000(a) -> a
    deep = ("g", deep)
    assert term_eq(normalize(deep, rules), ("g", ("a",)))


def test_deep_normalization_result():
    n = 1500
    t = ("a",)
    for _ in range(n):
        t = ("g", t)
    rules = [(("g", ("g", "x")), "x")]
    assert term_eq(normalize(t, rules), ("a",))
    assert lpo({"g": 1})(t, ("g", ("a",)))


# ---------- random systems against the oracle ----------

SIGS = [
    {"f": 2, "g": 1, "a": 0},
    {"m": 2, "i": 1, "e": 0},
    {"f": 1, "g": 1, "a": 0, "b": 0},
    {"h": 2, "a": 0, "b": 0},
]


def rand_term(rng, sig, vs, depth):
    if depth == 0 or rng.random() < 0.3:
        consts = [f for f, n in sig.items() if n == 0]
        if vs and (not consts or rng.random() < 0.6):
            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_against_oracle():
    rng = random.Random(20261106)
    models_cache = {}
    stats = {"success": 0, "fail": 0, "give up": 0, "bfs": 0}
    cases = 0
    while cases < 600:
        k = rng.randrange(len(SIGS))
        sig = SIGS[k]
        if k not in models_cache:
            models_cache[k] = list(all_algebras(sig, (0, 1)))
        eqs = [(rand_term(rng, sig, ["x", "y"], 2), rand_term(rng, sig, ["x", "y"], 2))
               for _ in range(rng.randint(1, 3))]
        order = sorted(sig)
        rng.shuffle(order)
        prec = {f: i for i, f in enumerate(order)}
        status, out = complete(eqs, lpo(prec), max_steps=40)
        cases += 1
        stats[status] += 1
        models = [a for a in models_cache[k] if all(o_holds(e, a, (0, 1)) for e in eqs)]
        if status == "fail":
            s, t = out
            assert s != t and not o_lpo(s, t, prec) and not o_lpo(t, s, prec)
            assert all(o_holds((s, t), a, (0, 1)) for a in models)
            continue
        rules = out
        for l, r in rules:
            assert o_lpo(l, r, prec)  # every rule is oriented, so R terminates
            assert all(o_holds((l, r), a, (0, 1)) for a in models)  # sound
        if status == "give up":
            continue
        for i, (l, r) in enumerate(rules):  # the result is reduced
            assert o_steps(r, rules) == []
            assert o_steps(l, rules[:i] + rules[i + 1:]) == []
        for u, v in o_cps(rules):  # local confluence
            assert o_nf(u, rules) == o_nf(v, rules)
        for s, t in eqs:  # the input equations are decided
            assert o_nf(s, rules) == o_nf(t, rules)
        for _ in range(3):  # bounded search for E-equal terms
            s = rand_term(rng, sig, ["x"], 2)
            nf = o_nf(s, rules)
            for t in o_bfs(s, eqs, 2, 60):
                assert o_nf(t, rules) == nf
                stats["bfs"] += 1
    assert cases >= 500
    assert stats["success"] >= 150 and stats["fail"] >= 30 and stats["bfs"] >= 1000, stats


def test_critical_pairs_against_oracle():
    rng = random.Random(7)
    checked = 0
    for _ in range(800):
        sig = SIGS[rng.randrange(len(SIGS))]
        l1 = rand_term(rng, sig, ["x", "y"], 3)
        l2 = rand_term(rng, sig, ["x", "y"], 2)
        if o_var(l1) or o_var(l2):
            continue
        r1 = rand_term(rng, sig, sorted(o_vars(l1)), 2)
        r2 = rand_term(rng, sig, sorted(o_vars(l2)), 2)
        mine = sorted(map(repr, (canonical(p) for p in critical_pairs((l1, r1), (l2, r2)))))
        ref = []
        for p, u in o_positions(l1):
            if o_var(u):
                continue
            ren = {x: x + "'" for x in o_vars(l2)}
            s = o_unify([(u, o_sub(l2, ren))])
            if s is not None:
                ref.append(canonical((o_put(o_sub(l1, s), p, o_sub(o_sub(r2, ren), s)), o_sub(r1, s))))
        assert mine == sorted(map(repr, ref))
        checked += 1
    assert checked >= 500


def test_lpo_against_oracle():
    rng = random.Random(3)
    for _ in range(3000):
        sig = SIGS[rng.randrange(len(SIGS))]
        order = sorted(sig)
        rng.shuffle(order)
        prec = {f: i for i, f in enumerate(order)}
        s = rand_term(rng, sig, ["x", "y"], 3)
        t = rand_term(rng, sig, ["x", "y"], 3)
        assert lpo(prec)(s, t) == o_lpo(s, t, prec)

What the next level needs

  • the tests did not run in this build

Read the full page about Knuth-Bendix completion