Skip to content
The Lumenary

Math laboratory

Church–Rosser on every small λ-term: an exhaustive check

Enumerates all 38,438 λ-terms with at most one free variable up to size 10, builds each term's full β-reduction graph (all redexes, all orders; graphs over 200 terms are set aside) and checks that any two reducts have a common reduct and that no term reaches two normal forms. Also counts terms that normalise yet have infinite reductions. Exhaustive for these finitely many terms only; the theorem is proved in Lean.

Back to the labExhaustive finite checkExact computation
Church–Rosser on every small λ-term: an exhaustive checkPythonExhaustive finite checkExact computationComputationally tested
"""Testing the Church-Rosser theorem on every small λ-term.

We enumerate every λ-term (de Bruijn indices, at most one free variable) up
to a given size, build its complete β-reduction graph (all redexes, all
orders) when that graph is small, and check two consequences of confluence:
every two reducts of a term have a common reduct, and no term reaches two
different normal forms. We also count terms that have a normal form but also
an infinite reduction (weakly but not strongly normalising, like K I Omega),
and compare the shortest and longest routes to a normal form. This is
exhaustive evidence for finitely many terms; the theorem is proved in the
lesson and in Lean.
"""

from collections import deque

# Terms: ("var", i), ("app", s, t), ("lam", t).

def terms_of_size(size, depth, free=1, _memo=None):
    """All terms of exactly ``size`` nodes whose indices are < depth + free."""
    memo = {} if _memo is None else _memo
    key = (size, depth)
    if key in memo:
        return memo[key]
    out = []
    if size == 1:
        out = [("var", i) for i in range(depth + free)]
    elif size >= 2:
        out += [("lam", t) for t in terms_of_size(size - 1, depth + 1, free, memo)]
        for left in range(1, size - 1):
            for s in terms_of_size(left, depth, free, memo):
                for t in terms_of_size(size - 1 - left, depth, free, memo):
                    out.append(("app", s, t))
    memo[key] = out
    return out

def shift(t, d, cutoff=0):
    """Add ``d`` to every free index (those >= ``cutoff``)."""
    if t[0] == "var":
        return ("var", t[1] + d) if t[1] >= cutoff else t
    if t[0] == "app":
        return ("app", shift(t[1], d, cutoff), shift(t[2], d, cutoff))
    return ("lam", shift(t[1], d, cutoff + 1))

def subst(t, j, s):
    """Capture-avoiding substitution t[s/j]."""
    if t[0] == "var":
        return s if t[1] == j else t
    if t[0] == "app":
        return ("app", subst(t[1], j, s), subst(t[2], j, s))
    return ("lam", subst(t[1], j + 1, shift(s, 1)))

def reducts(t, _memo={}):
    """The set of all one-step β-reducts of ``t`` (every redex position)."""
    if t in _memo:
        return _memo[t]
    out = set()
    if t[0] == "lam":
        out = {("lam", b) for b in reducts(t[1])}
    elif t[0] == "app":
        f, a = t[1], t[2]
        if f[0] == "lam":
            out.add(shift(subst(f[1], 0, shift(a, 1)), -1))
        out |= {("app", f2, a) for f2 in reducts(f)}
        out |= {("app", f, a2) for a2 in reducts(a)}
    _memo[t] = frozenset(out)
    return _memo[t]

def size_of(t):
    """Number of nodes of a term."""
    if t[0] == "var":
        return 1
    if t[0] == "lam":
        return 1 + size_of(t[1])
    return 1 + size_of(t[1]) + size_of(t[2])

def reduction_graph(t, cap=200):
    """All terms reachable from ``t`` with their reducts, or None if more
    than ``cap`` terms are reachable."""
    graph, queue = {}, deque([t])
    while queue:
        u = queue.popleft()
        if u in graph:
            continue
        graph[u] = reducts(u)
        if len(graph) > cap:
            return None
        queue.extend(v for v in graph[u] if v not in graph)
    return graph

def reachable(graph, start):
    """Terms reachable from ``start`` (including itself) in a finite graph."""
    seen, stack = {start}, [start]
    while stack:
        for v in graph[stack.pop()]:
            if v not in seen:
                seen.add(v)
                stack.append(v)
    return seen

def has_cycle(graph):
    """Whether the finite reduction graph contains a cycle."""
    colour = {}
    for root in graph:
        if root in colour:
            continue
        colour[root] = 1
        stack = [(root, iter(graph[root]))]
        while stack:
            node, it = stack[-1]
            nxt = next(it, None)
            if nxt is None:
                colour[node] = 2
                stack.pop()
            elif colour.get(nxt) == 1:
                return True
            elif nxt not in colour:
                colour[nxt] = 1
                stack.append((nxt, iter(graph[nxt])))
    return False

def path_lengths(graph, start, target):
    """Shortest and longest path lengths from start to target (acyclic)."""
    best = {}

    def longest(u):
        if u == target:
            return 0
        if u not in best:
            options = [longest(v) for v in graph[u]]
            best[u] = 1 + max(x for x in options if x is not None) \
                if any(x is not None for x in options) else None
        return best[u]

    dist, queue = {start: 0}, deque([start])
    while queue:
        u = queue.popleft()
        for v in graph[u]:
            if v not in dist:
                dist[v] = dist[u] + 1
                queue.append(v)
    return dist.get(target), longest(start)

def analyse(t, cap=200):
    """Classify one term: returns a dict of facts about its reduction graph."""
    graph = reduction_graph(t, cap)
    if graph is None:
        return {"explored": False}
    normal = [u for u in graph if not graph[u]]
    reach = {u: reachable(graph, u) for u in graph}
    locally_confluent = all(
        reach[b] & reach[c]
        for u in graph for b in graph[u] for c in graph[u])
    return {"explored": True, "normal_forms": len(normal),
            "joinable": locally_confluent, "cycle": has_cycle(graph),
            "graph_size": len(graph)}

def survey(max_size=8, cap=200):
    """Analyse every term with at most one free variable up to ``max_size``."""
    stats = {"terms": 0, "explored": 0, "with_nf": 0, "two_nf": 0,
             "not_joinable": 0, "wn_not_sn": 0, "no_nf": 0, "largest": 0}
    memo = {}
    for size in range(1, max_size + 1):
        for t in terms_of_size(size, 0, 1, memo):
            stats["terms"] += 1
            facts = analyse(t, cap)
            if not facts["explored"]:
                continue
            stats["explored"] += 1
            stats["largest"] = max(stats["largest"], facts["graph_size"])
            stats["with_nf"] += facts["normal_forms"] >= 1
            stats["no_nf"] += facts["normal_forms"] == 0
            stats["two_nf"] += facts["normal_forms"] >= 2
            stats["not_joinable"] += not facts["joinable"]
            stats["wn_not_sn"] += facts["normal_forms"] >= 1 and facts["cycle"]
    return stats

NAMED = {
    "I": ("lam", ("var", 0)),
    "K": ("lam", ("lam", ("var", 1))),
    "omega": ("lam", ("app", ("var", 0), ("var", 0))),
    "omega3": ("lam", ("app", ("app", ("var", 0), ("var", 0)), ("var", 0))),
}

def examples():
    """Named terms illustrating the possible shapes of reduction graphs."""
    i, k, w, w3 = (NAMED[n] for n in ("I", "K", "omega", "omega3"))
    z = ("var", 0)
    return [
        ("(λx. x x)((λy. y) z)", ("app", w, ("app", i, z))),
        ("K I Omega", ("app", ("app", k, i), ("app", w, w))),
        ("Omega", ("app", w, w)),
        ("(λx. x x x)(λx. x x x)", ("app", w3, w3)),
    ]

def main():
    max_size = 10
    s = survey(max_size)
    print(f"All {s['terms']} λ-terms of size <= {max_size} with at most one"
          " free variable")
    print(f"  reduction graph built (<= 200 terms):        {s['explored']}")
    print(f"  largest graph built:                         {s['largest']}")
    print(f"  with a normal form:                          {s['with_nf']}")
    print(f"  with no normal form:                         {s['no_nf']}")
    print(f"  reaching two different normal forms:         {s['two_nf']}")
    print(f"  with two reducts that cannot be joined:      {s['not_joinable']}")
    print()
    print("Some larger terms:")
    for name, t in examples():
        g = reduction_graph(t, cap=200)
        if g is None:
            print(f"  {name:<24} more than 200 reducts: the graph keeps growing")
            continue
        nfs = [u for u in g if not g[u]]
        terms = f"{len(g)} term" + ("s" if len(g) != 1 else "")
        count = {0: "no normal form", 1: "one normal form"}.get(
            len(nfs), f"{len(nfs)} normal forms")
        line = f"  {name:<24} {terms}, {count}"
        if nfs and has_cycle(g):
            line += ", also an infinite reduction"
        elif nfs:
            short, long_ = path_lengths(g, t, nfs[0])
            line += f", routes of {short} to {long_} steps"
        else:
            line += ", every reduction is infinite"
        print(line)
    print()
    print("Epistemic status: an exhaustive check of finitely many terms (and "
          "only\nof those whose reduction graphs are small). It is evidence "
          "for the\nChurch-Rosser theorem, not a proof: the proof, by "
          "parallel reduction,\nis in the lesson and is machine-checked in "
          "Lean.")

if __name__ == "__main__":
    main()
Runs entirely in your browser, in an isolated worker. Nothing is sent to a server.

Used in