Skip to content
The Lumenary

Math laboratory

A toy proof checker for natural-number addition

Implements a checker for equational proofs from the rules a + 0 = a and a + S(b) = S(a + b): it accepts 2 + 2 = 4, rejects a wrong step, finds that 0 + n is stuck when n is a variable (so induction is needed), checks 0 + k = k by computation for k up to 100, and checks both cases of the induction proof.

Back to the labExact computationExhaustive finite check
A toy proof checker for natural-number additionPythonExact computationExhaustive finite checkComputationally tested
"""A tiny proof checker for addition of natural numbers.

Lean's kernel is a small program that checks every step of a proof. This
experiment builds a toy version for one theory: natural numbers written with
0 and S (successor, "plus one"), and the two defining rules of addition,
  A1:  a + 0 = a          A2:  a + S(b) = S(a + b).
A proof is a chain of terms; each step must rewrite exactly one subterm with
one rule (or with a hypothesis, such as an induction hypothesis). The checker
accepts a proof of 2 + 2 = 4, rejects a bogus one, shows why n + 0 = n is
"true by computation" while 0 + n = n gets stuck, and checks both cases of
the induction proof of 0 + n = n. Every verdict is exact; the toy shows how
checking works, but is far simpler than Lean's kernel.

Runs unchanged in the browser (Pyodide) using only the standard library.
"""

import textwrap

ZERO = ("0",)

def S(t):
    """The successor S(t), meaning t + 1."""
    return ("S", t)

def add(a, b):
    """The term a + b."""
    return ("+", a, b)

def var(name):
    """A variable such as n (an unknown natural number)."""
    return ("var", name)

def num(k):
    """The numeral k written as S(S(...S(0)...))."""
    t = ZERO
    for _ in range(k):
        t = S(t)
    return t

def show(t, top=True):
    """Write a term readably: numerals as digits, S(x) for other successors."""
    k, u = 0, t
    while u[0] == "S":
        k, u = k + 1, u[1]
    if u == ZERO:
        return str(k)
    if t[0] == "S":
        return f"S({show(t[1])})"
    if t[0] == "+":
        text = f"{show(t[1], False)} + {show(t[2], False)}"
        return text if top else f"({text})"
    return t[1]

def rewrite_root(t, rules):
    """All (name, result) for rules whose left side matches t itself."""
    out = []
    if t[0] == "+" and t[2] == ZERO:
        out.append(("A1", t[1]))
    if t[0] == "+" and t[2][0] == "S":
        out.append(("A2", S(add(t[1], t[2][1]))))
    for name, (lhs, rhs) in rules.items():
        if t == lhs:
            out.append((name, rhs))
    return out

def one_step(t, rules):
    """All (name, term) reachable from t by rewriting exactly one subterm."""
    results = rewrite_root(t, rules)
    for i in range(1, len(t)):
        if isinstance(t[i], tuple):
            for name, sub in one_step(t[i], rules):
                results.append((name, t[:i] + (sub,) + t[i + 1:]))
    return results

def check_chain(chain, rules=None):
    """Check a proof chain; return (True, rule names) or (False, bad step)."""
    rules = rules or {}
    names = []
    for k, (t, u) in enumerate(zip(chain, chain[1:]), start=1):
        found = [n for n, v in one_step(t, rules) if v == u]
        found += [n + " backwards" for n, v in one_step(u, rules) if v == t]
        if not found:
            return False, k
        names.append(found[0])
    return True, names

def first_step(t):
    """The result of rewriting the outermost, leftmost A1/A2 match, or None."""
    found = rewrite_root(t, {})
    if found:
        return found[0][1]
    for i in range(1, len(t)):
        if isinstance(t[i], tuple):
            sub = first_step(t[i])
            if sub is not None:
                return t[:i] + (sub,) + t[i + 1:]
    return None

def normalize(t, limit=10_000):
    """Apply A1/A2 until no rule applies; return (normal form, steps)."""
    steps = 0
    while steps < limit:
        nxt = first_step(t)
        if nxt is None:
            return t, steps
        t, steps = nxt, steps + 1
    raise RuntimeError("no normal form within the step limit")

def verdict(result):
    """Describe the result of check_chain in words."""
    ok, info = result
    if ok:
        return f"accepted (rules: {', '.join(info)})"
    return f"REJECTED at step {info}"

def main():
    two, n = num(2), var("n")
    proof = [add(two, two), S(add(two, num(1))), S(S(add(two, ZERO))), num(4)]
    ok, names = check_chain(proof)
    print("Proof of 2 + 2 = 4:")
    for t, why in zip(proof, ["start"] + names):
        print(f"  {show(t):<16} [{why}]")
    print(f"  accepted: {ok}")
    bogus = [add(two, two), S(add(two, num(1))), num(5)]
    print(f"Bogus chain 2 + 2 -> S(2 + 1) -> 5: {verdict(check_chain(bogus))}")
    print("  (step 2 is not a single application of a rule)")
    print()
    for t in (add(n, ZERO), add(ZERO, n)):
        nf, steps = normalize(t)
        print(f"Computing {show(t)}: reaches {show(nf)} after {steps} step(s)")
    print("  0 + n is stuck: n is neither 0 nor S(something), so no rule fits.")
    good = all(normalize(add(ZERO, num(k)))[0] == num(k) for k in range(101))
    print(f"0 + k computes to k for every numeral k = 0..100: {good}")
    print()
    print("Induction proof of 0 + n = n:")
    base = check_chain([add(ZERO, ZERO), ZERO])
    print(f"  base case 0 + 0 -> 0: {verdict(base)}")
    k = var("k")
    ih = {"IH": (add(ZERO, k), k)}
    step = [add(ZERO, S(k)), S(add(ZERO, k)), S(k)]
    print("  step case, assuming IH: 0 + k = k")
    print(f"    0 + S(k) -> S(0 + k) -> S(k): {verdict(check_chain(step, ih))}")
    print()
    print(textwrap.fill(
        "Epistemic status: exact. Each verdict is a complete mechanical check "
        "of the given chain, and the 101 numeral cases were all computed. The "
        "checker trusts the induction principle and its two rules without "
        "question, and it is a toy: Lean's kernel checks a far richer logic. "
        "Checking 0 + k for 101 numerals is evidence about 0 + n; the "
        "induction proof is what covers every n.",
        width=78,
    ))

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

Used in