Skip to content

growing.kt

src/jvmMain/kotlin/afield/data/graph/growing.kt · 368 lines

@file:afield.runtime.completion.CompletionCategory("Data")

package afield.data.graph

import afield.data.collections.get
import afield.data.collections.invoke
import afield.data.collections.times
import afield.document.graph.minusAssign
import afield.document.graph.plusAssign
import afield.document.properties.compareTo
import afield.document.workspace.file
import afield.graphics.render.rangeTo
import afield.graphics.shader.compute
import afield.math.autodiff.compareTo
import afield.math.autodiff.minus
import afield.math.autodiff.plus
import afield.math.autodiff.times
import afield.math.functions.rewrite
import afield.math.linalg.invoke
import afield.math.linalg.remAssign
import afield.math.linalg.set
import afield.math.linalg.times
import afield.math.statistics.noise
import afield.math.units.minus
import afield.math.units.plus
import afield.math.units.times
import afield.runtime.execution.start

import afield.audio.synth.*
import afield.data.collections.r
import afield.data.collections.s
import afield.math.autodiff.div
import afield.math.linalg.i
import afield.math.linalg.x
import afield.math.units.div
import afield.simulation.agents.step
import afield.simulation.automata.step
import kotlin.math.max

A 3-regular graph that grows by local rewriting. Every node in a GrowingGraph has exactly three neighbours and one bit of state, and GrowingGraph.grow alternates two phases: compute each node's next state and a "dividing" flag by indexing an integer rule with neighbour-count + 4 × state, then apply a division to each flagged node that adds two nodes and rewires their edges.

Seed the adjacency with GrowingGraph.setNeighbors (it must be a consistent 3-regular graph or the rewrite throws), set states with GrowingGraph.setState or GrowingGraph.randomizeStates, then call grow() per step and use the returned GrowingGraph.GrowDelta together with GrowingGraph.newEdgeU / GrowingGraph.newEdgeV to draw only what changed. Storage is flat IntArray/ByteArray with no per-node allocation and no allocation in the inner loops except when capacity doubles; pure Kotlin, single-threaded, with a built-in xorshift64* generator for the optional flipProb noise. main is a runnable 10-node demo.

class GrowingGraph

Self-contained Kotlin port of the "GrowingGraph" algorithm: - 3-regular graph: each node has exactly 3 neighbors - Binary state per node (0/1) - Two-phase grow(): Phase 0: compute next state + "dividing" flag from local neighborhood Phase 1: apply local rewrite (division) that adds 2 nodes and rewires edges

"Garbage-lite" notes: - Neighbors stored in one flat IntArray (3 * capacity), no per-node arrays. - States/dividing stored in ByteArray (0/1). - No allocations in the inner loops of grow(), except when capacity must expand.

class GrowingGraph(
    initialNodeCount: Int,
    rule: Int = 2182,
    flipProb: Double = 0.0,
    seed: Long = 1L
) {
    private val NN = 3
    private val CASE_N = 8

var nodeCount: Int

Graph size (active nodes) and capacity (allocated size)

    var nodeCount: Int = initialNodeCount
        private set
    private var capacity: Int = max(4, initialNodeCount)

Flat neighbors: neighbors[node*3 + k] is the k-th neighbor of node

    private var neighbors: IntArray = IntArray(NN * capacity)

State buffers; swapped each phase-0

    private var states: ByteArray = ByteArray(capacity)     // current
    private var nextStates: ByteArray = ByteArray(capacity) // scratch

Dividing flags (computed in phase 0, consumed in phase 1)

    private var dividing: ByteArray = ByteArray(capacity)

var rule: Int = rule

Rule and noise

var flipProb: Double = flipProb

Phase toggles: false -> phase 0, true -> phase 1

    private var phase: Boolean = false

data class GrowDelta

    data class GrowDelta(
        val newNodeStart: Int,
        val newNodeCount: Int,
        val newEdgeStart: Int,
        val newEdgeCount: Int
    )

    private var edgeU: IntArray = IntArray(16)
    private var edgeV: IntArray = IntArray(16)
    private var edgeCount: Int = 0

val newEdgeU: IntArray get() = edgeU

val newEdgeV: IntArray get() = edgeV

    private val rng = XorShift64Star(seed)

fun setNeighbors(node: Int, a: Int, b: Int, c: Int)

Set neighbor list for a node (must be exactly 3 neighbors).

    fun setNeighbors(node: Int, a: Int, b: Int, c: Int) {
        val base = node * NN
        neighbors[base] = a
        neighbors[base + 1] = b
        neighbors[base + 2] = c
    }

fun neighborAt(node: Int, slot: Int): Int

Get the neighbor at slot 0..2 for a node.

    fun neighborAt(node: Int, slot: Int): Int {
        require(slot in 0 until NN) { "slot must be 0..2" }
        return neighbors[node * NN + slot]
    }

fun stateOf(node: Int): Int = (states[node].toInt() and 1)

Get state (0/1).

fun setState(node: Int, s: Int)

Set state (0/1).

    fun setState(node: Int, s: Int) {
        states[node] = (s and 1).toByte()
    }

fun randomizeStates(p: Double)

Randomize initial states with probability p of being 1.

    fun randomizeStates(p: Double) {
        for (i in 0 until nodeCount) {
            states[i] = if (rng.nextDouble01() < p) 1 else 0
        }
    }

fun grow(): GrowDelta

One step. Alternates between: - Phase 0: compute next states + dividing flags from current graph - Phase 1: apply division rewrites for nodes with dividing=1

    fun grow(): GrowDelta {
        phase = !phase
        if (!phase) {
            // Phase 0: compute cases -> nextStates + dividing, then swap state buffers
            computeNextStatesAndDividing()
            val tmp = states
            states = nextStates
            nextStates = tmp
            edgeCount = 0
            return GrowDelta(0, 0, 0, 0)
        } else {
            // Phase 1: apply rewrites in increasing node order
            val newNodeStart = nodeCount
            edgeCount = 0
            var i = 0
            while (i < nodeCount) {
                if ((dividing[i].toInt() and 1) != 0) {
                    divideAt(i)
                    dividing[i] = 0
                }
                i++
            }
            return GrowDelta(newNodeStart, nodeCount - newNodeStart, 0, edgeCount)
        }
    }

internals

    private fun computeNextStatesAndDividing() {
        val r0 = rule
        val fp = flipProb

        for (i in 0 until nodeCount) {
            val base = i * NN
            val n0 = neighbors[base]
            val n1 = neighbors[base + 1]
            val n2 = neighbors[base + 2]

            val s0 = states[n0].toInt() and 1
            val s1 = states[n1].toInt() and 1
            val s2 = states[n2].toInt() and 1
            val nc = s0 + s1 + s2

            val si = states[i].toInt() and 1
            val r = nc + (si shl 2) // nc + state*4 (since NN=3 => NN+1=4)

            var ns = (r0 ushr r) and 1
            if (fp > 0.0 && rng.nextDouble01() < fp) ns = ns xor 1

            val div = (r0 ushr (r + CASE_N)) and 1

            nextStates[i] = ns.toByte()
            dividing[i] = div.toByte()
        }
    }

Local rewrite (division) for node i with neighbors [a,b,c]:

Before: i -- a, i -- b, i -- c After: i -> [a, j, k] j -> [i, b, k] k -> [i, j, c] b replaces neighbor i with j c replaces neighbor i with k statesj = statesi, statesk = statesi

source — 100 lines, from line 218
    private fun divideAt(i: Int) {
        val base = i * NN
        val a = neighbors[base]
        val b = neighbors[base + 1]
        val c = neighbors[base + 2]

        val j = nodeCount
        val k = nodeCount + 1

        ensureCapacity(nodeCount + 2)

        // Rewire i
        neighbors[base] = a
        neighbors[base + 1] = j
        neighbors[base + 2] = k

        // Create j
        setNeighbors(j, i, b, k)

        // Create k
        setNeighbors(k, i, j, c)

        // Reconnect b: replace i with j
        replaceNeighbor(b, oldNeighbor = i, newNeighbor = j)

        // Reconnect c: replace i with k
        replaceNeighbor(c, oldNeighbor = i, newNeighbor = k)

        // Track new edges (undirected pairs)
        appendEdge(i, j)
        appendEdge(i, k)
        appendEdge(j, b)
        appendEdge(j, k)
        appendEdge(k, c)

        // Copy state from parent
        states[j] = states[i]
        states[k] = states[i]

        // New nodes shouldn't immediately divide unless later computed in phase 0
        dividing[j] = 0
        dividing[k] = 0

        nodeCount += 2
    }

    private fun replaceNeighbor(node: Int, oldNeighbor: Int, newNeighbor: Int) {
        val base = node * NN
        // Degree is fixed at 3, so linear scan is tiny and allocation-free.
        if (neighbors[base] == oldNeighbor) {
            neighbors[base] = newNeighbor
            return
        }
        if (neighbors[base + 1] == oldNeighbor) {
            neighbors[base + 1] = newNeighbor
            return
        }
        if (neighbors[base + 2] == oldNeighbor) {
            neighbors[base + 2] = newNeighbor
            return
        }
        // If this triggers, your graph isn't consistent 3-regular adjacency.
        throw IllegalStateException("Neighbor $oldNeighbor not found in node $node")
    }

    private fun ensureCapacity(minCapacity: Int) {
        if (minCapacity <= capacity) return
        var newCap = capacity
        while (newCap < minCapacity) newCap = newCap * 2

        // Expand arrays (rare; not in steady-state inner loops)
        val newNeighbors = IntArray(NN * newCap)
        neighbors.copyInto(newNeighbors, endIndex = NN * capacity)
        neighbors = newNeighbors

        val newStates = ByteArray(newCap)
        states.copyInto(newStates, endIndex = capacity)
        states = newStates

        val newNextStates = ByteArray(newCap)
        nextStates.copyInto(newNextStates, endIndex = capacity)
        nextStates = newNextStates

        val newDividing = ByteArray(newCap)
        dividing.copyInto(newDividing, endIndex = capacity)
        dividing = newDividing

        capacity = newCap
    }

    private fun appendEdge(a: Int, b: Int) {
        if (edgeCount >= edgeU.size) {
            val newCap = max(4, edgeU.size * 2)
            edgeU = edgeU.copyOf(newCap)
            edgeV = edgeV.copyOf(newCap)
        }
        edgeU[edgeCount] = a
        edgeV[edgeCount] = b
        edgeCount++
    }

Simple fast RNG: xorshift64* (not crypto-secure)

    private class XorShift64Star(seed: Long) {
        private var x: Long = if (seed != 0L) seed else 0x9E3779B97FA7C15L

        private fun nextLong(): Long {
            var z = x
            z = z xor (z ushr 12)
            z = z xor (z shl 25)
            z = z xor (z ushr 27)
            x = z
            return z * 2685821657736338717L
        }

        fun nextDouble01(): Double {
            // Use top 53 bits to make a double in [0,1)
            val v = nextLong() ushr 11
            return v.toDouble() * (1.0 / (1L shl 53).toDouble())
        }
    }
}

fun main()

Small demo: start from K4 (tetrahedron), run a few steps, print size/state stats.

fun main() {
    // K4 is 3-regular: each of 4 nodes connects to the other 3.
    val g = GrowingGraph(initialNodeCount = 10, rule = 2182, flipProb = 0.0, seed = 1234L)
    g.setNeighbors(0, 9, 1, 2)
    g.setNeighbors(1, 0, 2, 4)
    g.setNeighbors(2, 1, 3, 0)
    g.setNeighbors(3, 2, 4, 6)
    g.setNeighbors(4, 3, 5, 1)
    g.setNeighbors(5, 4, 6, 8)
    g.setNeighbors(6, 5, 7, 3)
    g.setNeighbors(7, 6, 8, 9)
    g.setNeighbors(8, 7, 9, 5)
    g.setNeighbors(9, 8, 0, 7)

    // Same states as the JS example:
    // this.states = [0,0,0,1,0, 1,0,1,1,1];
    val initStates = intArrayOf(0, 0, 0, 1, 0, 1, 0, 1, 1, 1)
    for (i in 0 until 10) g.setState(i, initStates[i])

    repeat(100) { step ->
        g.grow()
        // Count live nodes (tiny loop; for demo only)
        var live = 0
        for (i in 0 until g.nodeCount) live += g.stateOf(i)
        println("step=${step + 1} phase=${(step + 1) % 2} nodes=${g.nodeCount} live=$live")
    }
}