Skip to content

Boolean networks to FLine examples

A Boolean network is a set of nodes that are each on or off, plus a rule that computes every node's next value from the current values of the others. Because there are only finitely many states, every run ends up in a loop or at a fixed point (an attractor), and Field can list them all exactly. Each example below draws the network's wiring next to what it does over time.

Wiring and state-space, side by side

toggleNetwork() is two nodes, A = NOT B and B = A, and that is already enough to separate the two views the rest of the page relies on: the causal wiring graph, which has one arrow per dependency, and the induced state-transition graph, which has one arrow per state.

:::static
val net = toggleNetwork()
val attrs = net.findAttractors()

:::fold helpers: offset a drawing, fill a background panel
fun shift(lines: List<FLine>, dx: Double, dy: Double) = lines.map { it + vec(dx, dy, 0) }

fun panel(x: Double, y: Double, w: Double, h: Double, color: Vec4) = fline {
    rect(x, y, w, h)
    filled = true
    stroked = true
    thickness = 1.0
    this.color = color
}

:::endfold

lines(
    [
        panel(30.0, 40.0, 430.0, 430.0, vec(0.08, 0.13, 0.18, 0.56)),
        panel(500.0, 40.0, 500.0, 430.0, vec(0.16, 0.11, 0.08, 0.54)),
    ] +
        shift(net.drawNetworkGraph(radius = 120.0, nodeR = 18.0), 80.0, 80.0) +
        shift(net.drawStateTransitionGraph(radius = 150.0), 560.0, 55.0),
    channel = "boolean-toggle",
    w = 1060.0,
    h = 520.0,
    fitToSize = true,
)

The left image tells you what depends on what; the right image tells you what that dependency does to every possible state. Here the two coincide: with four states and both nodes reading the other, the whole state space is a single period-4 loop, 00 -> 10 -> 11 -> 01 and back.

two panels side by side. The blue-tinted left panel holds two blue discs one above the other with a single line between them. The brown-tinted right panel holds four orange discs at the corners of a diamond, joined by a closed four-step loop drawn as two crossing chords, so every disc has exactly one line in and one out

Segment-polarity basins and a transient

segmentPolarityNetwork() has four nodes, so sixteen states, and unlike the toggle it does not spend all of them on one cycle. There is more than one attractor, so there is more than one basin: the set of starting states that end on a given attractor.

:::static
val net = segmentPolarityNetwork()
val attrs = net.findAttractors().sortedByDescending { it.basin.size }
val largest = attrs.first()
val cycleStates = largest.cycle.map { net.stateToInt(it) }.toSet()
val start = largest.basin.firstOrNull { it !in cycleStates } ?: largest.basin.first()
val trajectory = net.trajectoryUntilCycle(net.intToState(start))
val shown = attrs.take(2)

:::fold helpers: offset a drawing, fill a background panel
fun shift(lines: List<FLine>, dx: Double, dy: Double) = lines.map { it + vec(dx, dy, 0) }

fun panel(x: Double, y: Double, w: Double, h: Double, color: Vec4) = fline {
    rect(x, y, w, h)
    filled = true
    stroked = true
    thickness = 1.0
    this.color = color
}

:::endfold

lines(
    [
        panel(30.0, 40.0, 220.0, 420.0, vec(0.08, 0.13, 0.18, 0.56)),
        panel(290.0, 40.0, 380.0, 420.0, vec(0.10, 0.10, 0.14, 0.56)),
        panel(710.0, 40.0, 620.0, 420.0, vec(0.16, 0.11, 0.08, 0.54)),
    ] +
        shift(net.drawUnrolled(trajectory, cellSize = 34.0), 72.0, 128.0) +
        shift(net.drawStateTransitionGraph(radius = 125.0), 347.0, 83.0) +
        shift(net.drawAttractorBasins(shown, samplesPerAttractor = 3, cellSize = 18.0), 724.0, 154.0),
    channel = "boolean-segment",
    w = 1360.0,
    h = 520.0,
    fitToSize = true,
)

The left panel is one concrete transient, the middle panel is the whole 16-state transition graph, and the right panel samples the two largest basins as miniature spacetime strips. The transient is the path from a starting state to its attractor: which of the sixteen states you start from decides which attractor you end on, and how many steps it takes.

three panels. The narrow blue-tinted left panel is a tall four-column strip of cream and dark cells, one row per step. The middle panel is sixteen nodes arranged in a ring, alternately blue and orange, with straight chords running across the interior between them. The brown-tinted right panel holds two groups of three small spacetime strips separated by a thin orange rule, the left group visibly busier than the right

Random Kauffman regimes as a plotted sweep

The two networks above were hand-made. This one asks a population question instead: as in-degree k rises in random N-K Kauffman systems, what happens to the typical attractor count and period?

:::static
data class SweepRow(val k: Int, val attractors: Double, val period: Double, val fixed: Double)

val rows =
    (1..5).map { k ->
        val runs =
            (0 until 18).map { seed ->
                val attrs = kauffmanNetwork(n = 8, k = k, seed = seed.toLong()).findAttractors()
                Triple(
                    attrs.size.toDouble(),
                    if (attrs.isEmpty()) 0.0 else attrs.map { it.period.toDouble() }.average(),
                    attrs.count { it.period == 1 }.toDouble(),
                )
            }
        SweepRow(
            k = k,
            attractors = runs.map { it.first }.average(),
            period = runs.map { it.second }.average(),
            fixed = runs.map { it.third }.average(),
        )
    }

:::fold plot helpers: shared y scale, axes and grid, polylines, square markers
val maxY =
    [
            rows.maxOf { it.attractors },
            rows.maxOf { it.period },
            rows.maxOf { it.fixed },
        ]
        .max() + 0.5

fun guides() = [
    fline {
        moveTo(80, 580)
        lineTo(1100, 580)
        moveTo(80, 120)
        lineTo(80, 580)
        color = vec(1, 1, 1, 0.22)
        thickness = 1.1
    },
    fline {
        for (i in 0..8) {
            val y = 580.0 - i / 8.0 * 430.0
            moveTo(80, y)
            lineTo(1100, y)
        }
        for (i in rows.indices) {
            val x = 80.0 + i.toDouble() / (rows.size - 1).coerceAtLeast(1).toDouble() * 1020.0
            moveTo(x, 120.0)
            lineTo(x, 580.0)
        }
        color = vec(1, 1, 1, 0.08)
        thickness = 1.0
    },
]

fun curve(values: List<Double>, color: Vec4, thickness: Double): FLine = fline {
    breakNext()
    values.forEachIndexed { i, value ->
        val x = 80.0 + i.toDouble() / (values.size - 1).coerceAtLeast(1).toDouble() * 1020.0
        val y = 580.0 - value / maxY * 430.0
        lineTo(x, y)
    }
    this.color = color
    this.thickness = thickness
}

fun markers(values: List<Double>, color: Vec4, size: Double): FLine = fline {
    filled = true
    stroked = false
    values.forEachIndexed { i, value ->
        val x = 80.0 + i.toDouble() / (values.size - 1).coerceAtLeast(1).toDouble() * 1020.0
        val y = 580.0 - value / maxY * 430.0
        rect(x - size, y - size, 2 * size, 2 * size)
    }
    this.color = color
}

:::endfold

lines(
    guides() +
        listOf(
            curve(rows.map { it.attractors }, vec(0.56, 0.92, 1.0, 0.95), 3.0),
            curve(rows.map { it.period }, vec(1.0, 0.74, 0.38, 0.92), 3.0),
            curve(rows.map { it.fixed }, vec(0.58, 1.0, 0.68, 0.92), 3.0),
            markers(rows.map { it.attractors }, vec(0.56, 0.92, 1.0, 0.95), 5.0),
            markers(rows.map { it.period }, vec(1.0, 0.74, 0.38, 0.92), 5.0),
            markers(rows.map { it.fixed }, vec(0.58, 1.0, 0.68, 0.92), 5.0),
        ),
    channel = "boolean-kauffman",
    w = 1180.0,
    h = 660.0,
    fitToSize = true,
)

The plot has no legend, so: blue is the mean number of attractors, orange the mean period, green the mean number of fixed points, each averaged over 18 seeds at n = 8 for k from 1 to 5, and all three share one y scale. All three rise with k: more connections give more attractors and longer ones. Orange overtakes blue around the middle of the sweep and turns down at k = 5, and green stays under blue throughout, as it has to: a fixed point is a period-1 attractor and so is counted in both.

three five-point polylines with square markers on a faint grid, no axis labels or legend. Blue climbs steadily from low on the left and flattens at the fourth point. Orange starts just below blue, passes it immediately, meets it again at the middle point, then rises to the highest point on the plot at the fourth and falls back at the fifth. Green stays along the bottom, dipping slightly, then rising over the last two points to finish well below both

Boolean attractors as drum grids

A limit cycle repeats on a fixed period, so it is already a rhythm. You can treat each node as a lane directly, or gate the cycle with a beat phase such as step % 4 and turn the same binary motion into kick / snare / hat style triggers.

:::static
data class RhythmCandidate(val seed: Int, val net: BooleanNetwork, val attr: Attractor, val score: Double)

fun activity(attr: Attractor, net: BooleanNetwork): Double =
    attr.cycle.sumOf { state -> state.count { it } }.toDouble() / (attr.period * net.size).toDouble()

val candidate =
    (0..240)
        .mapNotNull { seed ->
            val net = kauffmanNetwork(n = 5, k = 2, seed = seed.toLong())
            net.findAttractors()
                .filter { it.period in 5..10 }
                .map { attr ->
                    val density = activity(attr, net)
                    val periodScore = 1.0 - kotlin.math.abs(attr.period - 8) / 8.0
                    val densityScore = 1.0 - kotlin.math.abs(density - 0.42)
                    val basinScore = attr.basin.size.toDouble() / (1 shl net.size).toDouble()
                    RhythmCandidate(seed, net, attr, periodScore * 1.6 + densityScore * 1.2 + basinScore)
                }
                .maxByOrNull { it.score }
        }
        .maxByOrNull { it.score }!!

val natural = candidate.attr.repeatedCycle(16)
val gated = natural.mapIndexed { step, state ->
    booleanArrayOf(
        state[0] && step % 4 == 0,
        state[1] && step % 4 == 2,
        state[2] && step % 2 == 0,
        state[3] && (step % 8 == 4 || step % 8 == 12),
        state[4] && (step % 4 == 1 || step % 4 == 3),
    )
}

:::fold helpers: offset a drawing, fill a background panel
fun shift(lines: List<FLine>, dx: Double, dy: Double) = lines.map { it + vec(dx, dy, 0) }

fun panel(x: Double, y: Double, w: Double, h: Double, color: Vec4) = fline {
    rect(x, y, w, h)
    filled = true
    stroked = true
    thickness = 1.0
    this.color = color
}

:::endfold

lines(
    [
        panel(30.0, 40.0, 280.0, 420.0, vec(0.08, 0.13, 0.18, 0.56)),
        panel(350.0, 40.0, 470.0, 420.0, vec(0.10, 0.10, 0.14, 0.56)),
        panel(860.0, 40.0, 470.0, 420.0, vec(0.16, 0.11, 0.08, 0.54)),
    ] +
        shift(candidate.net.drawNetworkGraph(radius = 96.0, nodeR = 17.0), 76.0, 118.0) +
        shift(
            drawRhythmGrid(natural, cellW = 21.0, cellH = 22.0, stepGap = 5.0, laneGap = 9.0, accentEvery = 4),
            390.0,
            150.0,
        ) +
        shift(
            drawRhythmGrid(gated, cellW = 21.0, cellH = 22.0, stepGap = 5.0, laneGap = 9.0, accentEvery = 4),
            900.0,
            150.0,
        ),
    channel = "boolean-rhythm",
    w = 1380.0,
    h = 520.0,
    fitToSize = true,
)

The middle grid is the raw attractor repeated over two bars. The right grid is the same cycle clock-gated into five kick / snare / hat / clap / percussion lanes, ready to hand to a sampler. The grids repeat exactly, so you can read them off the page.

three panels. The blue-tinted left panel holds five blue discs in a ring with six lines between them, two of them crossing the middle. The middle panel is a five-lane step grid with orange bar-lines every four steps, each lane in its own colour, and two bars' worth of the same pattern repeated. The brown-tinted right panel is the same five lanes much more sparsely filled, a handful of triggers per lane instead of a dense run

Here is the right-hand grid played through Field's Dirt-Samples, eight steps a second, kick, snare, hat, clap and percussion lanes as bd, sn, hh, cp and perc. The 16 steps play twice:

For the next step, where those grids become NoteProcess objects and live Dirt triggers, continue with Boolean network music examples. For related work where static structure and measured behavior are plotted together, continue with Values over time (interpolants, keyframes, correlations & estimation) and Sorting networks examples.

The public declarations used on this page are listed, with their KDoc, in the codebase reference: booleannetworks.kt. Start from what's already in the box.