Skip to content

LambdaTermsSmokeTest.kt

src/jvmMain/kotlin/afield/examples/stdlib/LambdaTermsSmokeTest.kt · 356 lines

package afield.examples.stdlib

import afield.data.graph.ActivationOrder
import afield.data.graph.Lambda
import afield.lang.lambda.LambdaTerms
import afield.data.graph.NodeState
import afield.data.graph.Reducer
import afield.lang.lambda.Term
import afield.data.graph.net
import afield.math.linalg.vec

FIELD_TEST id: stdlib.lambda-terms.smoke runner: jvm-main entrypoint: afield.examples.stdlib.LambdaTermsSmokeTest tags: [smoke, stdlib, interaction-nets, lambda] timeoutSec: 240 expect: exitCode: 0 stdoutContains: - "Lambda terms smoke tests passed"

object LambdaTermsSmokeTest

The oracle harness for afield.lang.lambda.LambdaTerms.

The load-bearing assertion is testCorpusAgreesWithTheOracle: for every named term in LambdaTerms.corpus(), readback(reduce(compile(t))) is α-equivalent to referenceNormalOrder(t) — a net reduction compared against an ordinary capture-avoiding substitution evaluator that shares nothing. It is asserted as set equality on the corpus names, so a corpus that quietly loses an entry fails rather than passing more easily, and the disagreements are printed by name before the failure rather than hidden behind it.

testSchedulersAgree is the other half. The ActivationOrder is a free choice and the answer must not depend on it, so four of the six orders are run over the whole corpus and their de Bruijn keys compared.

object LambdaTermsSmokeTest {

Every order except RANDOM (not reproducible in a failure message) and LOWEST_PAIR_ENERGY (see below).

    private val ORDERS = listOf(
        ActivationOrder.FIRST_FOUND,
        ActivationOrder.LEFTMOST_BY_ID,
        ActivationOrder.RIGHTMOST_BY_ID,
        ActivationOrder.HIGHEST_PAIR_ENERGY
    )

    private const val MAX_STEPS = 200_000

fun runTests(): Boolean

    fun runTests(): Boolean {
        return try {
            println("Running lambda terms smoke tests...")
            testCorpusIsNamedAndClosed()
            testParserRoundTrips()
            testAlphaEquivalence()
            testSubstitutionAvoidsCapture()
            testIdentityUnderDuplicationReducesAtAll()
            testCorpusAgreesWithTheOracle()
            testSchedulersAgree()
            testStepCountTable()
            println("Lambda terms smoke tests passed")
            true
        } catch (t: Throwable) {
            println("Lambda terms smoke tests failed: ${t.message}")
            t.printStackTrace()
            false
        }
    }

fun main(args: Array<String>)

    @JvmStatic
    fun main(args: Array<String>) {
        check(runTests()) { "Lambda terms smoke tests failed" }
    }

The corpus itself

The corpus is asserted by the set of its names, so it cannot shrink without this failing, and every entry is asserted closed — an open term is outside what the readback promises.

    private fun testCorpusIsNamedAndClosed() {
        val expected = setOf(
            "identity",
            "k-applied",
            "skk",
            "skk-applied",
            "sks-applied",
            "church-zero",
            "church-succ-zero",
            "church-succ-two",
            "church-add-1-2",
            "church-add-2-3",
            "church-mul-2-3",
            "church-mul-3-3",
            "church-pred-one",
            "church-pred-three",
            "church-exp-two-cubed",
            "bool-true",
            "bool-if-true",
            "bool-if-false",
            "bool-and-applied",
            "self-apply-identity",
            "self-apply-redex",
            "duplicate-a-redex",
            "share-under-lambda",
            "erase-argument",
            "erase-nested"
        )
        val actual = LambdaTerms.corpus().keys
        require(actual == expected) {
            "Corpus names changed. Missing: ${expected - actual}; unexpected: ${actual - expected}"
        }
        require(actual.size == 25) { "Expected a corpus of 25 named terms, found ${actual.size}" }
        for ((name, source) in LambdaTerms.corpus()) {
            val free = LambdaTerms.freeVariablesOf(LambdaTerms.parse(source))
            require(free.isEmpty()) { "Corpus entry '$name' is not closed: free variables $free" }
        }
        println("  corpus: ${actual.size} named closed terms")
    }

parse(format(t)) == t exactly — not merely α-equivalent — over all 25 corpus entries.

    private fun testParserRoundTrips() {
        for ((name, source) in LambdaTerms.corpus()) {
            val once = LambdaTerms.parse(source)
            val printed = LambdaTerms.format(once)
            val twice = LambdaTerms.parse(printed)
            require(once == twice) { "Round-trip changed '$name': $source -> $printed" }
        }
        // The two spellings of a binder, and the multi-binder sugar, are one term.
        require(LambdaTerms.parse("\\x y. y x") == LambdaTerms.parse("λx. λy. y x")) {
            "Multi-binder sugar and nested λ should parse identically"
        }
        require(LambdaTerms.format(LambdaTerms.parse("(\\x. x) (\\y. y) z")) == "(λx. x) (λy. y) z") {
            "Printer parenthesised wrongly: ${LambdaTerms.format(LambdaTerms.parse("(\\x. x) (\\y. y) z"))}"
        }
        println("  parser round-trips over 25 corpus entries plus 2 hand cases")
    }

    private fun testAlphaEquivalence() {
        require(LambdaTerms.alphaEquivalent(LambdaTerms.parse("\\x. x"), LambdaTerms.parse("\\y. y")))
        require(!LambdaTerms.alphaEquivalent(LambdaTerms.parse("\\x y. x"), LambdaTerms.parse("\\x y. y")))
        require(LambdaTerms.deBruijnKey(LambdaTerms.parse("\\f x. f (f x)")) == "λλ(#1 (#1 #0))") {
            "de Bruijn key of Church two was ${LambdaTerms.deBruijnKey(LambdaTerms.parse("\\f x. f (f x)"))}"
        }
        // A free variable is keyed by name, so two different free variables are not α-equivalent.
        require(!LambdaTerms.alphaEquivalent(LambdaTerms.parse("\\x. y"), LambdaTerms.parse("\\x. z")))
        println("  alpha-equivalence: 4 cases")
    }

The one place a substitution evaluator is ever wrong.

    private fun testSubstitutionAvoidsCapture() {
        val substituted = LambdaTerms.substitute(LambdaTerms.parse("\\y. x y"), "x", LambdaTerms.parse("y"))
        // The binder must have been renamed, so the `y` that arrived is still free.
        require("y" in LambdaTerms.freeVariablesOf(substituted)) {
            "Substitution captured a free variable: $substituted"
        }
        require(substituted is Term.Lam && substituted.param != "y") {
            "Expected the capturing binder to be renamed, got $substituted"
        }
        println("  substitution avoids capture: $substituted")
    }

The substrate fix

The net that used to throw. λx. x compiled has a wire straight from the abstraction's binder to its own body, so duplicating it makes dup-lam reconnect to a port of a node the reducer has already removed — unless Reducer.apply chases the wire. Built by hand rather than through LambdaTerms.compile so the failure is pinned to the substrate and not to the compiler.

    private fun testIdentityUnderDuplicationReducesAtAll() {
        val reducer = Reducer(Lambda.rules(), relaxIterations = 0)
        val subject = net {
            val dup = node(Lambda.DUP, id = "dup", at = vec(-80.0, 0.0), state = Lambda.fan(1))
            val lam = node(Lambda.LAM, id = "lam", at = vec(80.0, 0.0), state = NodeState(energy = 1.0))
            val left = node(Lambda.ROOT, id = "left", at = vec(-160.0, -80.0))
            val right = node(Lambda.ROOT, id = "right", at = vec(-160.0, 80.0))
            dup.principal to lam.principal
            dup.aux(0) to left.principal
            dup.aux(1) to right.principal
            lam.aux(0) to lam.aux(1) // λx. x — the binder *is* the body
        }
        val steps = reducer.reduce(subject, maxSteps = 64)
        require(steps >= 2) { "Expected dup-lam then fan-fan, got $steps step(s)" }
        require(subject.nodes.none { it.type == Lambda.DUP }) {
            "Fans survived: ${subject.nodes.filter { it.type == Lambda.DUP }.map { it.id }}"
        }
        val lams = subject.nodes.filter { it.type == Lambda.LAM }
        require(lams.size == 2) { "Expected two copies of the abstraction, found ${lams.size}" }
        for (lam in lams) {
            require(subject.connected(lam.aux(0)) == lam.aux(1)) {
                "Copy ${lam.id} is not the identity: binder faces ${subject.connected(lam.aux(0))}"
            }
        }
        println("  dup-lam on the identity: $steps steps, 2 identity copies")
    }

The oracle

source — 42 lines, from line 205
    private class Outcome(
        val name: String,
        val netKey: String,
        val oracleKey: String,
        val steps: Int,
        val finished: Boolean,
        val printed: String,
        val fans: Int,
        val remaining: Int
    ) {
        val agrees: Boolean get() = finished && netKey == oracleKey
    }

    private fun reduceAndCompare(name: String, source: String, order: ActivationOrder): Outcome {
        val term = LambdaTerms.parse(source)
        val oracleKey = LambdaTerms.deBruijnKey(LambdaTerms.referenceNormalOrder(term))
        // A term that throws is a finding with a name, not a table that never prints.
        return try {
            val run = LambdaTerms.normalise(term, order = order, maxSteps = MAX_STEPS)
            Outcome(
                name = name,
                netKey = LambdaTerms.deBruijnKey(run.result),
                oracleKey = oracleKey,
                steps = run.steps,
                finished = run.finished,
                printed = run.printed,
                fans = run.fans,
                remaining = run.remainingNodes
            )
        } catch (t: Throwable) {
            Outcome(
                name = name,
                netKey = "threw ${t::class.simpleName}: ${t.message}",
                oracleKey = oracleKey,
                steps = -1,
                finished = false,
                printed = "threw ${t::class.simpleName}: ${t.message}",
                fans = -1,
                remaining = -1
            )
        }
    }

readback(reduce(compile(t))) ≡α referenceNormalOrder(t) over the whole corpus, asserted as set equality on the names so the corpus cannot shrink into a pass.

    private fun testCorpusAgreesWithTheOracle() {
        val corpus = LambdaTerms.corpus()
        println("  oracle identity over ${corpus.size} named terms, order=FIRST_FOUND:")
        val outcomes = ArrayList<Outcome>()
        for ((name, source) in corpus) {
            // Printed as it is computed, so a term that hangs or throws still names itself in the log.
            val o = reduceAndCompare(name, source, ActivationOrder.FIRST_FOUND)
            outcomes += o
            val mark = if (o.agrees) "ok  " else "DIFF"
            println(
                "    $mark ${o.name.padEnd(22)} steps=${o.steps.toString().padStart(6)} " +
                    "fans=${o.fans.toString().padStart(3)} left=${o.remaining.toString().padStart(4)} " +
                    "-> ${o.printed}"
            )
            if (!o.agrees) println("         oracle ${o.oracleKey}\n         net    ${o.netKey}")
        }
        val agreed = outcomes.filter { it.agrees }.map { it.name }.toSet()
        require(agreed == corpus.keys) {
            "Net and oracle disagree on ${(corpus.keys - agreed).size} of ${corpus.size}: ${corpus.keys - agreed}"
        }
    }

The scheduler is a free choice; the normal form is not.

    private fun testSchedulersAgree() {
        val corpus = LambdaTerms.corpus()
        val disagreements = ArrayList<String>()
        for ((name, source) in corpus) {
            val keys = LinkedHashMap<ActivationOrder, String>()
            for (order in ORDERS) keys[order] = reduceAndCompare(name, source, order).netKey
            if (keys.values.toSet().size != 1) disagreements += "$name: $keys"
        }
        require(disagreements.isEmpty()) {
            "Confluence failed for ${disagreements.size} of ${corpus.size} terms across ${ORDERS.size} orders: " +
                disagreements.joinToString("; ")
        }
        println("  confluence: ${corpus.size} terms x ${ORDERS.size} orders agree on the normal form")
    }

The step-count table, with its population stated and a band asserted around the total.

Two columns, in two different units. A net reaction is any rule firing — a β, a duplication, an erasure or a fan meeting — while an oracle step is a β-reduction and nothing else. They are not comparable as a ratio and neither number says "optimal". What the pair is good for is noticing a change: over the 25-term corpus at FIRST_FOUND, 1499 net reactions against 117 oracle β-steps, measured 2026-10-16 after the move to Lambdascope; most of the reactions are scope delimiters moving and closing. (228 with labelled fans.)

A floor as well as a ceiling, because a step count that suddenly drops is a reducer that has stopped doing the work rather than one that got clever, and the ceiling is what a rule firing forever trips. The oracle total is asserted exactly: it is pure term arithmetic with no scheduler in it, so it has no business drifting.

    private fun testStepCountTable() {
        val corpus = LambdaTerms.corpus()
        var total = 0
        var oracleTotal = 0
        var remaining = 0
        for ((_, source) in corpus) {
            val term = LambdaTerms.parse(source)
            val run = LambdaTerms.normalise(term, maxSteps = MAX_STEPS)
            total += run.steps
            remaining += run.remainingNodes
            oracleTotal += countOracleBetas(term)
        }
        println(
            "  step counts over ${corpus.size} terms, order=FIRST_FOUND: " +
                "$total net reactions against $oracleTotal oracle beta-steps, $remaining agents left standing"
        )
        require(total in STEP_FLOOR..STEP_CEILING) {
            "Corpus step total $total left the asserted band $STEP_FLOOR..$STEP_CEILING over ${corpus.size} terms"
        }
        require(oracleTotal == ORACLE_BETA_TOTAL) {
            "Oracle beta total moved from $ORACLE_BETA_TOTAL to $oracleTotal over ${corpus.size} terms"
        }
    }

β-steps the oracle takes, counted by instrumenting nothing — it just re-reduces and counts by bisection.

    private fun countOracleBetas(term: Term): Int {
        // `referenceNormalOrder` throws once the budget is short, so the smallest budget that succeeds is the count.
        var lo = 0
        var hi = 1
        while (hi < 1_000_000 && !oracleFitsIn(term, hi)) hi *= 2
        while (lo < hi) {
            val mid = (lo + hi) / 2
            if (oracleFitsIn(term, mid)) hi = mid else lo = mid + 1
        }
        return lo
    }

    private fun oracleFitsIn(term: Term, budget: Int): Boolean = try {
        LambdaTerms.referenceNormalOrder(term, maxSteps = budget)
        true
    } catch (e: LambdaTerms.ReductionBudgetExceeded) {
        false
    }

Measured 1499 at FIRST_FOUND on 2026-10-16 over the 25-term corpus (Lambdascope); widened by about 10% each way so that a re-layout or an id change does not churn the fence, and so that a collapse or an explosion still trips it.

    private const val STEP_FLOOR = 1350
    private const val STEP_CEILING = 1650

117 oracle beta-steps over the same 25 terms. Exact: no scheduler enters into it.

    private const val ORACLE_BETA_TOTAL = 117
}