Skip to content

InteractionNetsSmokeTest.kt

src/jvmMain/kotlin/afield/examples/stdlib/InteractionNetsSmokeTest.kt · 444 lines

package and imports — 62 lines, from line 1
package afield.examples.stdlib

import afield.document.graph.Box
import afield.app.protocol.Rect
import afield.geometry.path.FLine
import afield.ai.learning.*
import afield.ai.retrieval.*
import afield.animation.interpolation.*
import afield.animation.keyframes.*
import afield.animation.motion.*
import afield.app.commands.*
import afield.app.diagnostics.*
import afield.audio.analysis.*
import afield.audio.synth.*
import afield.data.collections.*
import afield.data.formats.*
import afield.data.graph.*
import afield.data.sorting.*
import afield.document.context.*
import afield.document.graph.*
import afield.document.properties.*
import afield.document.workspace.*
import afield.geometry.mesh.*
import afield.geometry.path.*
import afield.geometry.spatial.*
import afield.graphics.color.*
import afield.graphics.interaction.*
import afield.graphics.render.*
import afield.graphics.shader.*
import afield.graphics.text.*
import afield.graphics.vulkan.buffers.*
import afield.io.osc.*
import afield.io.process.*
import afield.lang.lambda.*
import afield.lang.python.*
import afield.lang.remotejs.*
import afield.lang.stacksl.*
import afield.math.autodiff.*
import afield.math.control.*
import afield.math.estimation.*
import afield.math.functions.*
import afield.math.linalg.*
import afield.math.optimization.*
import afield.math.statistics.*
import afield.math.units.*
import afield.media.image.*
import afield.media.video.*
import afield.runtime.execution.*
import afield.runtime.languages.*
import afield.runtime.lifecycle.*
import afield.simulation.agents.*
import afield.simulation.automata.*
import afield.simulation.particles.*
import afield.study.build.*
import afield.study.evidence.*
import afield.study.search.*
import afield.study.source.*
import afield.study.ui.*
import afield.ui.controls.*
import afield.ui.output.*
import afield.ui.visualization.*
import kotlin.math.abs

object InteractionNetsSmokeTest

FIELD_TEST id: stdlib.interaction-nets.smoke runner: jvm-main entrypoint: afield.examples.stdlib.InteractionNetsSmokeTest tags: [smoke, stdlib, interaction-nets] timeoutSec: 20 expect: exitCode: 0 stdoutContains: - "Interaction nets smoke tests passed"

object InteractionNetsSmokeTest {

fun runTests(): Boolean

    fun runTests(): Boolean {
        return try {
            println("Running interaction nets smoke tests...")
            testBetaRuleHandlesSwappedPairOrientation()
            testEnergyBiasedSchedulerPrefersHotterPair()
            testDuplicationSplitsStateAndReportsEvent()
            testEraseRuleCreatesDecayAgents()
            testEraseCascadeRunsToAFixedPoint()
            testDifferentlyLabelledFansCommuteAndThenKeepReducing()
            testErasingTheIdentityReachesTheEraseEraseRule()
            testDrawingHelpersProvideReusableFLinePrimitives()
            testFLineRendererFeedsOutputAreas()
            println("Interaction nets smoke tests passed")
            true
        } catch (t: Throwable) {
            println("Interaction nets smoke tests failed: ${t.message}")
            t.printStackTrace()
            false
        }
    }

fun main(args: Array<String>)

body — 105 lines, from line 99
    @JvmStatic
    fun main(args: Array<String>) {
        check(runTests()) { "Interaction nets smoke tests failed" }
    }

    private fun testBetaRuleHandlesSwappedPairOrientation() {
        val rules = Lambda.rules()
        val reducer = Reducer(rules)
        val net = net {
            val root = node(Lambda.ROOT, id = "root", at = vec(-180.0, -80.0), state = NodeState(energy = 0.7))
            val app = node(Lambda.APP, id = "app", at = vec(-40.0, 0.0), state = NodeState(energy = 0.8))
            val lam = node(Lambda.LAM, id = "lam", at = vec(40.0, 0.0), state = NodeState(energy = 1.4))
            val arg = node(Lambda.CONST, id = "arg", at = vec(170.0, 80.0), state = NodeState(energy = 1.1))
            val binder = node(Lambda.VAR, id = "binder", at = vec(180.0, -70.0), state = NodeState(energy = 0.9))
            val body = node(Lambda.VAR, id = "body", at = vec(170.0, 10.0), state = NodeState(energy = 1.0))

            // Intentionally connect in LAM -> APP order to prove signature normalization.
            lam.principal to app.principal
            app.aux(0) to root.principal
            app.aux(1) to arg.principal
            lam.aux(0) to binder.principal
            lam.aux(1) to body.principal
        }

        val detail = reducer.stepDetailed(net) ?: error("Expected beta step")
        require(detail.ruleName == "beta-linear") { "Expected beta-linear, got ${detail.ruleName}" }
        require(detail.swapped) { "Expected swapped=true when the pair is connected as LAM -> APP" }
        // Lambdascope: the body reaches the result, and the argument the binder, each through a level-0 scope.
        require(detail.createdNodeIds.size == 2 && detail.createdNodeIds.all { net.node(it).type == Lambda.DELIM && Lambda.levelOf(net.node(it)) == 0.0 }) {
            "beta-linear should create two level-0 delimiters, created ${detail.createdNodeIds.map { net.node(it).type.name }}"
        }
        require(detail.events.singleOrNull()?.kind == "beta") { "beta-linear should emit a beta event" }
        require(net.nodes.none { it.id == "app" || it.id == "lam" }) { "APP/LAM nodes should be removed" }
        // Both delimiters face an atom (the root, the constant), so they go, and the plain wiring is left.
        reducer.reduce(net, maxSteps = 8)
        require(net.nodes.none { it.type == Lambda.DELIM }) { "the delimiters should leave against the root and the constant" }
        requireConnected(net, net.nodes.single { it.type == Lambda.ROOT }, net.node("body"))
        requireConnected(net, net.node("binder"), net.nodes.single { it.type == Lambda.CONST })
    }

    private fun testEnergyBiasedSchedulerPrefersHotterPair() {
        val rules = Lambda.rules()
        val reducer = Reducer(rules, order = ActivationOrder.HIGHEST_PAIR_ENERGY)
        val net = net {
            val lowL = node(Lambda.ROOT, id = "lowL", at = vec(-320.0, -40.0), state = NodeState(energy = 0.2))
            val lowR = node(Lambda.ROOT, id = "lowR", at = vec(-320.0, 50.0), state = NodeState(energy = 0.2))
            val dupLow = node(Lambda.DUP, id = "dupLow", at = vec(-160.0, 0.0), state = NodeState(energy = 0.6))
            val constLow = node(Lambda.CONST, id = "constLow", at = vec(-40.0, 0.0), state = NodeState(energy = 0.5))

            val highL = node(Lambda.ROOT, id = "highL", at = vec(80.0, -40.0), state = NodeState(energy = 0.2))
            val highR = node(Lambda.ROOT, id = "highR", at = vec(80.0, 50.0), state = NodeState(energy = 0.2))
            val dupHigh = node(Lambda.DUP, id = "dupHigh", at = vec(220.0, 0.0), state = NodeState(energy = 1.2))
            val constHigh = node(Lambda.CONST, id = "constHigh", at = vec(340.0, 0.0), state = NodeState(energy = 1.8, hue = 0.14))

            dupLow.principal to constLow.principal
            dupLow.aux(0) to lowL.principal
            dupLow.aux(1) to lowR.principal

            dupHigh.principal to constHigh.principal
            dupHigh.aux(0) to highL.principal
            dupHigh.aux(1) to highR.principal
        }

        val detail = reducer.stepDetailed(net) ?: error("Expected a DUP/CONST rewrite")
        require(detail.ruleName == "dup-const") { "Expected dup-const, got ${detail.ruleName}" }
        require(detail.removedNodeIds.toSet() == setOf("dupHigh", "constHigh")) {
            "Expected the hotter pair to fire first, got ${detail.removedNodeIds}"
        }
        require(net.nodes.any { it.id == "dupLow" }) { "The colder pair should still remain after one step" }
    }

    private fun testDuplicationSplitsStateAndReportsEvent() {
        val rules = Lambda.rules()
        val reducer = Reducer(rules, order = ActivationOrder.HIGHEST_PAIR_ENERGY)
        val net = Lambda.duplicatedConstantDemo()

        val detail = reducer.stepDetailed(net) ?: error("Expected dup-const step")
        require(detail.ruleName == "dup-const") { "Expected dup-const, got ${detail.ruleName}" }
        require(detail.events.singleOrNull()?.kind == "duplicate") { "dup-const should emit duplicate" }

        val created = detail.createdNodeIds.map(net::node)
        require(created.size == 2) { "dup-const should create two copies, got ${created.size}" }
        require(created.all { it.type == Lambda.CONST }) { "dup-const should materialize two CONST nodes" }
        created.forEach {
            requireClose(it.state.energy, 0.81, message = "Duplicated constant should split energy with attenuation")
        }
        require(created[0].state.hue != created[1].state.hue) { "Duplicated branches should diverge in hue" }
    }

    private fun testEraseRuleCreatesDecayAgents() {
        val rules = Lambda.rules()
        val reducer = Reducer(rules)
        val net = Lambda.eraseApplicationDemo()

        val detail = reducer.stepDetailed(net) ?: error("Expected erase-app step")
        require(detail.ruleName == "erase-app") { "Expected erase-app, got ${detail.ruleName}" }
        require(detail.events.singleOrNull()?.kind == "prune") { "erase-app should emit prune" }

        val created = detail.createdNodeIds.map(net::node)
        require(created.size == 2) { "erase-app should spawn two ERASE agents" }
        require(created.all { it.type == Lambda.ERASE }) { "erase-app should only create ERASE agents" }
        created.forEach {
            requireClose(it.state.energy, 0.39375, message = "Decay agents should attenuate inherited energy")
        }
    }

The erase cascade, all the way down rather than one step.

Every other test in this file — and all four docs.interaction-nets.* boxes on public/docs/interaction_nets_examples.md — calls stepDetailed exactly once, which is why they passed unchanged on 2026-09-18 when erase-erase and fan-fan were added and (ERASE, ERASE) stopped being a normal form. A rule book gains a rule at the end of a reduction, so a single-step test cannot see it.

This drives eraseApplicationDemo to a fixed point and pins the whole sequence.

    private fun testEraseCascadeRunsToAFixedPoint() {
        val reducer = Reducer(Lambda.rules(), relaxIterations = 0)
        val net = Lambda.eraseApplicationDemo()
        val before = net.nodes.size
        val fired = ArrayList<String>()
        reducer.reduceDetailed(net, maxSteps = 32) { _, _, detail -> fired += detail.ruleName }

        require(fired == listOf("erase-app", "erase-const", "erase-var")) {
            "Expected the cascade erase-app, erase-const, erase-var; got $fired"
        }
        require(before == 4) { "eraseApplicationDemo should start with 4 agents, found $before" }
        require(net.nodes.isEmpty()) {
            "The cascade should consume the whole net; ${net.nodes.size} left: ${net.nodes.map { it.id }}"
        }
        // The fixed point is a fixed point: asking again does nothing at all.
        require(reducer.stepDetailed(net) == null) { "An empty net should offer no further rewrite" }
        println("  erase cascade: 4 agents -> 0 in ${fired.size} steps (${fired.joinToString(", ")})")
    }

Two fans with different labels, driven to a fixed point.

fan-fan's commuting branch is the half of the rule that no hand-wired net in this tree exercised: the demos all carry the default label 0, so they annihilate. Here the labels differ, so one fan-fan becomes four fans, and those four then have to go somewhere — which is the multi-step part, and the part a single stepDetailed cannot reach.

Four constants in, four copies out, and not one fan standing.

source — 46 lines, from line 244
    private fun testDifferentlyLabelledFansCommuteAndThenKeepReducing() {
        val reducer = Reducer(Lambda.rules(), relaxIterations = 0)
        val subject = net {
            val fanA = node(Lambda.DUP, id = "fanA", at = vec(-80.0, 0.0), state = Lambda.fan(1))
            val fanB = node(Lambda.DUP, id = "fanB", at = vec(80.0, 0.0), state = Lambda.fan(2))
            val c1 = node(Lambda.CONST, id = "c1", at = vec(-180.0, -70.0), state = NodeState(energy = 1.0))
            val c2 = node(Lambda.CONST, id = "c2", at = vec(-180.0, 70.0), state = NodeState(energy = 1.0))
            val c3 = node(Lambda.CONST, id = "c3", at = vec(180.0, -70.0), state = NodeState(energy = 1.0))
            val c4 = node(Lambda.CONST, id = "c4", at = vec(180.0, 70.0), state = NodeState(energy = 1.0))

            fanA.principal to fanB.principal
            fanA.aux(0) to c1.principal
            fanA.aux(1) to c2.principal
            fanB.aux(0) to c3.principal
            fanB.aux(1) to c4.principal
        }

        val fired = ArrayList<String>()
        val events = ArrayList<String>()
        reducer.reduceDetailed(subject, maxSteps = 64) { _, _, detail ->
            fired += detail.ruleName
            events += detail.events.map { it.kind }
        }

        require(fired.first() == "fan-fan") { "Expected fan-fan first, got ${fired.first()}" }
        require(events.first() == "branch") {
            "Different labels must commute, not annihilate — the first event was ${events.first()}"
        }
        require(fired == listOf("fan-fan", "dup-const", "dup-const", "dup-const", "dup-const")) {
            "A commuting fan-fan leaves four fans facing four constants, so the sequence is one branch and four " +
                "duplications; got $fired"
        }
        require(subject.nodes.size == 8) {
            "Four constants through a commuting pair of fans is eight copies, found ${subject.nodes.size}"
        }
        require(subject.nodes.none { it.type == Lambda.DUP }) {
            "Fans survived to the fixed point: ${subject.nodes.filter { it.type == Lambda.DUP }.map { it.id }}"
        }
        require(subject.nodes.all { it.type == Lambda.CONST }) {
            "Only constants should be left, found ${subject.nodes.map { it.type.name }.distinct()}"
        }
        println(
            "  labelled fan-fan commuting: ${fired.size} steps (${fired.joinToString(", ")}) " +
                "-> ${subject.nodes.size} constants"
        )
    }

erase-erase, reached the only way it can be reached: several steps in.

Erasing λx. x puts an ERASE on the binder and an ERASE on the body — and in the identity the binder is the body, so the two face each other down the wire that the vanishing LAM used to hold. Before 2026-09-18 that pair was a normal form and this net stopped with two agents standing that nothing could consume, which looks exactly like a finished reduction until you count the nodes.

Two things had to land for this to work and both are multi-step-only: the rule itself, and the wire chasing in Reducer.apply that lets erase-lam reattach through the pair it is deleting.

source — 142 lines, from line 302
    private fun testErasingTheIdentityReachesTheEraseEraseRule() {
        val reducer = Reducer(Lambda.rules(), relaxIterations = 0)
        val subject = net {
            val er = node(Lambda.ERASE, id = "er", at = vec(-90.0, 0.0), state = NodeState(energy = 0.9))
            val lam = node(Lambda.LAM, id = "lam", at = vec(90.0, 0.0), state = NodeState(energy = 1.1))
            er.principal to lam.principal
            lam.aux(0) to lam.aux(1) // λx. x — the binder is the body
        }

        val fired = ArrayList<String>()
        reducer.reduceDetailed(subject, maxSteps = 32) { _, _, detail -> fired += detail.ruleName }

        require(fired == listOf("erase-lam", "erase-erase")) {
            "Erasing the identity should be erase-lam then erase-erase; got $fired"
        }
        require(subject.nodes.isEmpty()) {
            "Nothing should survive erasing the identity; ${subject.nodes.size} left: " +
                subject.nodes.map { "${it.id}:${it.type.name}" }
        }
        println("  erasing the identity: ${fired.joinToString(" then ")} -> empty net")
    }

    private fun testDrawingHelpersProvideReusableFLinePrimitives() {
        val panel = Rect(10.0, 20.0, 30.0, 40.0).toFLine(
            color = vec(0.2, 0.3, 0.4, 0.5),
            filled = true
        )
        require(panel.nodes.size == 5) { "Rect.toFLine should create a closed rectangular path" }
        require(panel.nodes.last() is FLine.Close) { "Rect.toFLine should close the contour" }
        require(panel.filled && panel.stroked) { "Rect.toFLine should keep sensible stroke/fill defaults" }

        val fitted = listOf(panel).fitInto(Rect(0.0, 0.0, 100.0, 100.0), padding = 10.0)
        val fittedBounds = fitted.single().fast2DBounds()
        require(fittedBounds.left >= 10.0 - 1e-6 && fittedBounds.top >= 10.0 - 1e-6) {
            "fitInto should keep geometry inside the padded frame"
        }
        require(fittedBounds.right <= 90.0 + 1e-6 && fittedBounds.bottom <= 90.0 + 1e-6) {
            "fitInto should keep geometry inside the padded frame"
        }

        val arrowLine = arrow(0.0, 0.0, 120.0, 0.0)
        require(arrowLine.nodes.any { it is FLine.Close }) { "arrow should emit a real closed arrowhead" }
        val arrowBounds = arrowLine.fast2DBounds()
        require(arrowBounds.left >= -1e-6 && arrowBounds.right <= 120.0 + 1e-6) {
            "arrow bounds should stay anchored to the requested segment"
        }
    }

    private fun testFLineRendererFeedsOutputAreas() {
        val outputs = linkedMapOf<String, Any?>()
        val box = Box("interaction-net-render-test").also { testBox ->
            testBox.output.displayProxy = { channel, message -> outputs[channel] = message }
        }

        val net = Lambda.duplicatedConstantDemo()
        val rendered = box.showInteractionNet(
            net,
            channel = "net",
            summaryChannel = "summary",
            title = "Duplicated constant",
            w = 360.0,
            h = 220.0
        )
        box.showInteractionSummary(
            channel = "cards",
            net.summaryHtml("dup"),
            ReductionStep(
                ruleName = "preview",
                context = PairContext(
                    first = net.node("dup"),
                    second = net.node("k"),
                    firstAuxPeers = listOf(net.connected(net.node("dup").aux(0)), net.connected(net.node("dup").aux(1))),
                    secondAuxPeers = emptyList(),
                    center = vec(0.0, 0.0),
                    radius = 1.0,
                    firstVelocity = vec(0.0, 0.0),
                    secondVelocity = vec(0.0, 0.0),
                    axis = vec(1.0, 0.0),
                    normal = vec(0.0, 1.0)
                ),
                swapped = false,
                removedNodeIds = listOf("dup", "k"),
                createdNodeIds = listOf("k_0", "k_1"),
                events = listOf(RewriteEvent("duplicate", vec(0.0, 0.0), 1.0))
            ).summaryHtml("step")
        )
        box.showInteractionOverview(
            graphicLines = rendered,
            cards = listOf(
                net.summaryHtml("dup"),
                ReductionStep(
                    ruleName = "preview",
                    context = PairContext(
                        first = net.node("dup"),
                        second = net.node("k"),
                        firstAuxPeers = listOf(net.connected(net.node("dup").aux(0)), net.connected(net.node("dup").aux(1))),
                        secondAuxPeers = emptyList(),
                        center = vec(0.0, 0.0),
                        radius = 1.0,
                        firstVelocity = vec(0.0, 0.0),
                        secondVelocity = vec(0.0, 0.0),
                        axis = vec(1.0, 0.0),
                        normal = vec(0.0, 1.0)
                    ),
                    swapped = false,
                    removedNodeIds = listOf("dup", "k"),
                    createdNodeIds = listOf("k_0", "k_1"),
                    events = listOf(RewriteEvent("duplicate", vec(0.0, 0.0), 1.0))
                ).summaryHtml("step")
            ),
            channel = "overview",
            graphicTitle = "Preview",
            graphicWidth = 360.0,
            graphicHeight = 220.0
        )

        require(rendered.isNotEmpty()) { "toFLines should produce geometry" }
        require(outputs["net"] is String && (outputs["net"] as String).contains("<svg")) {
            "showInteractionNet should render SVG into the output area"
        }
        require(outputs["summary"] is String && (outputs["summary"] as String).contains("Duplicated constant")) {
            "showInteractionNet should emit summary HTML"
        }
        require(outputs["cards"] is String && (outputs["cards"] as String).contains("display:grid")) {
            "showInteractionSummary should arrange cards in an HTML grid"
        }
        require(outputs["overview"] is String && (outputs["overview"] as String).contains("Preview")) {
            "showInteractionOverview should combine cards and geometry in one output disclosure"
        }
        val exported = net.toSvg(width = 360, height = 220)
        require(exported.contains("<svg")) { "SVG export should still be available via the FLine renderer" }
    }

    private fun requireConnected(net: Net, a: Node, b: Node) {
        require(net.connected(a.principal) == b.principal) { "Expected ${a.id} to connect to ${b.id}" }
        require(net.connected(b.principal) == a.principal) { "Expected ${b.id} to connect to ${a.id}" }
    }

    private fun requireClose(actual: Double, expected: Double, epsilon: Double = 1e-6, message: String) {
        require(abs(actual - expected) <= epsilon) { "$message: expected $expected, got $actual" }
    }
}