Skip to content

LambdaNetFigure.kt

src/jvmMain/kotlin/afield/examples/stdlib/LambdaNetFigure.kt · 210 lines

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

import afield.geometry.path.FLine
import afield.ui.visualization.drawToSVG
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 java.io.File
import kotlin.math.max
import kotlin.math.min

object LambdaNetFigure

Writes the figure for dev_docs/INTERACTION_NETS_LAMBDA.md: one reduction, one panel per step, left to right.

(λx. x x) (λy. y) is the term, because it is the smallest one that shows the whole story — a β against the abstraction, a fan built by LambdaTerms.compile for the variable used twice, dup-lam duplicating an abstraction whose binder is wired straight to its own body (the case that used to throw), and fan-fan annihilating the two halves of that duplication.

Run it through the smoke harness's own launcher:

python3 tools/field_backdoor.py --port 9101 run-jvm-main \
    afield.examples.stdlib.LambdaNetFigure --arg <absolute output path>.svg

Each panel is fitted independently, so a panel's scale says nothing about the next one's — what a panel shows is the shape of the net at that step, and the agent count in the caption is what says how big it was.

object LambdaNetFigure {

    private const val TERM = "(\\x. x x) (\\y. y)"

    private const val PANEL_WIDTH = 300.0
    private const val PANEL_HEIGHT = 300.0
    private const val GUTTER = 14.0
    private const val PADDING = 26.0

fun main(args: Array<String>)

body — 58 lines, from line 92
    @JvmStatic
    fun main(args: Array<String>) {
        val target = args.firstOrNull()
        check(target != null) { "usage: LambdaNetFigure <output .svg path>" }

        val term = LambdaTerms.parse(TERM)
        val compiled = LambdaTerms.compile(term)
        // 8 relax iterations per step, unlike the headless default of 0: this one *is* the picture.
        val reducer = Reducer(Lambda.rules(), relaxIterations = 8)

        val stages = ArrayList<Pair<String, String>>()
        compiled.net.relax(iterations = 60)
        stages += "compiled" to snapshotDescription(compiled.net)

        var step = 0
        while (true) {
            val detail = reducer.stepDetailed(compiled.net) ?: break
            step++
            compiled.net.relax(iterations = 24)
            stages += "$step. ${detail.ruleName}" to snapshotDescription(compiled.net)
            check(step < 40) { "Reduction did not settle in 40 steps" }
        }

        val readback = LambdaTerms.readback(compiled)
        val oracle = LambdaTerms.referenceNormalOrder(term)

        // Panels are drawn from the *live* net at each stage, so the net has to be re-reduced once per panel: the
        // reducer mutates in place and there is no snapshotting. Cheap at this size, and it keeps the panels honest
        // about what the reducer actually did rather than reconstructing it.
        val panelLines = ArrayList<FLine>()
        for ((index, stage) in stages.withIndex()) {
            val net = replay(index)
            val x0 = index * (PANEL_WIDTH + GUTTER)
            panelLines += frame(x0)
            panelLines += fit(net, x0)
            check(stage.second.isNotEmpty()) { "empty stage description" }
        }

        val width = stages.size * PANEL_WIDTH + (stages.size - 1) * GUTTER

        // NO BACKGROUND PLATE. This used to prepend an opaque white rectangle over the whole figure,
        // and it was the only one of the 30 committed SVGs under public/docs to paint a background at
        // all -- so on the dark documentation site it showed up as a white slab with the page dark
        // around it. Every sibling figure is transparent and inherits the page, and now so is this.
        // The strokes and discs are pale (#BDEBEE, #C7C5E4, #FFF5E6 and friends), which is why they
        // read fine either way; the plate was never doing any work.
        val svg = drawToSVG(panelLines, width, PANEL_HEIGHT)
        File(target).writeText(svg)

        println("term        : $TERM")
        println("panels      : ${stages.size} (${stages.joinToString(" | ") { it.first }})")
        for ((label, description) in stages) println("  ${label.padEnd(18)} $description")
        println("readback    : ${LambdaTerms.format(readback)}")
        println("oracle      : ${LambdaTerms.format(oracle)}")
        println("agree       : ${LambdaTerms.alphaEquivalent(readback, oracle)}")
        println("wrote       : $target (${File(target).length()} bytes, ${width.toInt()}x${PANEL_HEIGHT.toInt()})")
        check(LambdaTerms.alphaEquivalent(readback, oracle)) { "Figure term did not reduce correctly" }
    }

Re-runs the reduction from scratch and stops after steps reactions, so each panel gets its own net.

    private fun replay(steps: Int): Net {
        val compiled = LambdaTerms.compile(LambdaTerms.parse(TERM))
        val reducer = Reducer(Lambda.rules(), relaxIterations = 8)
        compiled.net.relax(iterations = 60)
        repeat(steps) {
            reducer.stepDetailed(compiled.net)
            compiled.net.relax(iterations = 24)
        }
        return compiled.net
    }

    private fun snapshotDescription(net: Net): String {
        val byType = net.nodes.groupingBy { it.type.name }.eachCount().toSortedMap()
        return "${net.nodes.size} agents: " + byType.entries.joinToString(", ") { "${it.key} x${it.value}" }
    }

A hairline box around a panel, so the reader can see where one net stops and the next begins.

    private fun frame(x0: Double): List<FLine> = listOf(
        fline {
            moveTo(x0 + 2.0, 2.0)
            lineTo(x0 + PANEL_WIDTH - 2.0, 2.0)
            lineTo(x0 + PANEL_WIDTH - 2.0, PANEL_HEIGHT - 2.0)
            lineTo(x0 + 2.0, PANEL_HEIGHT - 2.0)
            close()
            filled = false
            stroked = true
            thickness = 1.0
            color = vec(0.78, 0.80, 0.84, 1.0)
        }
    )

The net's lines, scaled and centred into the panel at x0.

Fitted per panel rather than once for the whole figure: the nets differ in extent by several times over a reduction, and a single scale makes the last panels a dot. The cost is that panel scales are not comparable, which the KDoc above says out loud.

    private fun fit(net: Net, x0: Double): List<FLine> {
        val lines = net.toFLines()
        if (lines.isEmpty() || net.nodes.isEmpty()) return emptyList()
        val points = net.nodes.map { net.positionOf(it) }
        // The node radius is about 20 and the layout positions are centres, so the extent has to allow for it.
        val minX = points.minOf { it.x.toDouble() } - 34.0
        val maxX = points.maxOf { it.x.toDouble() } + 34.0
        val minY = points.minOf { it.y.toDouble() } - 34.0
        val maxY = points.maxOf { it.y.toDouble() } + 34.0
        val scale = min(
            (PANEL_WIDTH - 2 * PADDING) / max(1.0, maxX - minX),
            (PANEL_HEIGHT - 2 * PADDING) / max(1.0, maxY - minY)
        )
        val offsetX = x0 + PANEL_WIDTH / 2.0 - (minX + maxX) / 2.0 * scale
        val offsetY = PANEL_HEIGHT / 2.0 - (minY + maxY) / 2.0 * scale
        return lines.map { line ->
            line.transform { p -> vec(p.x * scale + offsetX, p.y * scale + offsetY, p.z.toDouble()) }
        }
    }
}