AlifeEvolveGallery.kt
src/jvmMain/kotlin/afield/examples/stdlib/AlifeEvolveGallery.kt · 722 lines
package afield.examples.stdlib
import afield.simulation.agents.EXTINCT_SCORE
import afield.study.search.Evaluation
import afield.study.search.GenomeOps
import afield.media.image.Img
import afield.simulation.agents.IntGrid
import afield.simulation.agents.LSystemGenome
import afield.simulation.agents.LeniaGenome
import afield.simulation.agents.LeniaRule
import afield.simulation.automata.LifeRule
import afield.simulation.agents.LifeRuleGenome
import afield.study.search.Objective
import afield.study.search.PopulationSearch
import afield.study.search.SearchRun
import afield.study.search.Selection
import afield.simulation.agents.balanceBrackets
import afield.simulation.agents.caEdgeOfChaosObjective
import afield.simulation.agents.caRun
import afield.simulation.agents.lSystemDimensionObjective
import afield.simulation.agents.lSystemDrawing
import afield.simulation.agents.leniaRun
import afield.simulation.agents.leniaSeed
import afield.simulation.agents.leniaSolitonObjective
import afield.simulation.automata.lifeRule
import afield.simulation.agents.lifeRuleSignature
import afield.media.image.montage
import afield.simulation.agents.randomSoup
import afield.media.image.savePng
import afield.simulation.automata.savePng
import afield.graphics.text.toImg
import afield.lang.stacksl.evolution.toImg
import afield.media.image.toImg
import afield.simulation.agents.toImg
import afield.simulation.automata.toImg
import afield.media.image.upscaleNearest
import java.io.File
import kotlin.math.max
import kotlin.system.exitProcess
FIELD_TEST id: stdlib.alife.evolve.gallery runner: jvm-main entrypoint: afield.examples.stdlib.AlifeEvolveGallery tags: [smoke, stdlib, alife, search, capture, cpu] timeoutSec: 600 expect: exitCode: 0 stdoutContains: - "alife evolve gallery rendered"
object AlifeEvolveGallery
Renders — and verifies — the claim that evolution beats chance on ALife rule spaces.
Modelled on AlifeGallery, and for the same reason. A picture of an evolved cellular automaton is
easy to fake, because a rule space is full of rules that look organic and a search that did nothing
at all would still return one. So every claim here is stated against a control arm at the same
budget from the same generator with the same seed policy — PopulationSearch.random, which draws
from exactly the distribution evolve mutates within — and every margin is asserted as a floor
rather than printed.
The order matters. figurePremise runs first and asserts the objectives themselves behave, using
rules whose behaviour is known independently of any search: Conway's Life must score well above a
rule that cannot survive, an extinct rule must hit exactly EXTINCT_SCORE, and the two Lenia
failure modes must be separated. If a later stage's environment drifts, that stage fails loudly
instead of quietly becoming a tautology in which everything scores zero and evolution "ties" the
control.
Three families, three genome shapes, one engine:
figureCaevolves a life-like rule table — a fixed-length bit string with real uniform crossover — for high activity at moderate density, over two soups, with lexicase selection.figureLeniaevolves a continuous rule's growth window and kernel shape toward a soliton score: alive, localised and travelling over 90 steps, all three read offleniaRun. What it finds is a transient that fills the field by step 300, and the figure checks that too.figureLSystemevolves a grammar, with single-point string crossover, toward a target mass–radius dimension.figureMapElitesilluminates the CA rule space rather than optimising it, and is the figure worth looking at: one panel per occupied cell of a (density x activity) archive.figureDeterminismre-runs three searches under the same seeds and requires bit-identical results, because an evolved rule that cannot be reproduced is an anecdote.
As a FIELD_TEST it renders to a temp dir and writes no committed assets. Pass --out DIR (or a
bare first argument, or set ALIFE_EVOLVE_DIR) to put the PNGs somewhere that survives, and
--only name,name to iterate on one figure.
object AlifeEvolveGallery {
Verification plumbing — same shape as AlifeGallery's, deliberately
private val failures = ArrayList<String>()
private var checks = 0
private fun fail(message: String) {
failures.add(message)
println("[alife-evolve] FAIL $message")
}
private fun check(condition: Boolean, message: String) {
checks++
if (!condition) fail(message) else println("[alife-evolve] ok $message")
}
private fun f(v: Double, digits: Int = 3): String = "%.${digits}f".format(v)
How far above the random control each search has to land. Measured, then floored with headroom — every one of these is a number a real run produced, written down with room under it, not a target chosen in advance.
The three are wildly different sizes and that is the most interesting thing in this file. These rule spaces do not reward search equally, and a single shared threshold would have hidden that: it would have been set low enough for the easiest problem and would then have passed on the hard ones without measuring anything.
Because the searches are deterministic under a fixed seed these are characterization fences, not statistical ones. A run that misses one has changed the search, not been unlucky.
private const val CA_MARGIN: Double = 0.08
Measured 0.1169 (0.5444 evolved against 0.4274 random) on a radius-2 rule table, +27%.
private const val CA_MEASURED: Double = 0.1169
Measured 0.3074 (2.0937 against 1.7864), +17%. The largest edge search has in this file.
private const val LENIA_MARGIN: Double = 0.20
Measured 0.0083 (0.2479 against 0.2396), +3.5%, and floored at half of that.
Small, and reported small. Hitting a target mass–radius dimension with a bracketed L-system is an easy problem: the objective's whole range is about 0.25 wide, most random grammars draw something bushy whose dimension is within 0.02 of the target, and 400 independent draws therefore land within 1% of the theoretical maximum on their own. Two different targets were tried (1.70, then 1.25) and both turned out to be near the mode of the prior. What this number says is that the L-system dimension objective is not a search problem at this budget, which is worth knowing before anyone builds on it — it is not a failure of the engine, which wins by 27% and 17% on the two families next door.
private const val LSYSTEM_MARGIN: Double = 0.004
Assert that evolved beat control by at least requiredMargin, and that the control arm
found something real in the first place.
The margin is absolute and per-problem, and it is a measurement. The first version of this file asserted a relative margin — 15% of the control arm's own span above the failure floor — and it was wrong in a way worth recording: that span is dominated by the arbitrary distance between zero and the failure floor (-1), not by the useful range of the objective, so it demanded a margin roughly thirty times what any of these searches produce. Each floor below is instead quoted from a measured run, with headroom, and the comment says which run.
The control.bestFitness > floor clause is the non-empty guard, and it is the whole difference
between a measurement and a tautology. Without it, an objective that returned EXTINCT_SCORE
for every genome would give evolved == control == floor, a margin of zero, and a comparison
that a >= 0 assertion would pass on blank-versus-blank.
private fun <G> checkBeatsControl(
name: String,
evolved: SearchRun<G>,
control: SearchRun<G>,
floor: Double,
requiredMargin: Double
) {
check(
control.evaluations == evolved.evaluations,
"$name: both arms spent the same budget (${control.evaluations} vs ${evolved.evaluations} evaluations)"
)
check(
control.bestFitness > floor,
"$name: the control arm found something above the failure floor " +
"(${f(control.bestFitness)} > ${f(floor)}) — if it had not, the comparison below " +
"would be blank against blank"
)
val margin = evolved.bestFitness - control.bestFitness
check(
margin >= requiredMargin,
"$name: evolution beat random by ${f(margin, 4)}, required >= ${f(requiredMargin, 4)}; " +
"evolved ${f(evolved.bestFitness, 4)} vs random ${f(control.bestFitness, 4)} " +
"at ${evolved.evaluations} evaluations each"
)
}
0 — the objectives themselves, against rules whose behaviour is known without a search
Asserts this file's own premise before any search runs.
Every number below comes from a rule that is what it is independently of anything here:
Conway's B3/S23, the never-survives rule B3/S, the everything-lives rule B12345678/S…,
and two Lenia parameter sets on opposite sides of the phase boundary. If the objectives stop
telling those apart, the later stages have nothing to measure and this stage says so.
source — 99 lines, from line 199
private fun figurePremise(outDir: File) {
val soups = listOf(randomSoup(64, 64, density = 0.35, seed = 11L))
val objective = caEdgeOfChaosObjective(soups, warmup = 20, scored = 30)
val conway = objective.evaluate(lifeRule("B3/S23"))
val dead = objective.evaluate(lifeRule("B3/S"))
val flood = objective.evaluate(LifeRule(birth = (1..8).toSet(), survive = (0..8).toSet()))
check(
dead.fitness == EXTINCT_SCORE,
"premise: B3/S goes extinct and scores exactly the floor ${f(dead.fitness)}"
)
check(
conway.fitness > 4.0 * max(1e-9, flood.fitness),
"premise: Conway ${f(conway.fitness, 4)} scores more than 4x the saturating rule " +
"${f(flood.fitness, 4)} — the density band and the activity term are both live"
)
val floodRun = caRun(soups[0], LifeRule(birth = (1..8).toSet(), survive = (0..8).toSet()), 20, 30)
check(
floodRun.density > 0.95 && floodRun.activity < 0.01,
"premise: the saturating rule really does saturate (density ${f(floodRun.density)}, " +
"activity ${f(floodRun.activity)}) — so its low score is earned, not a bug"
)
check(
conway.cases.size == soups.size,
"premise: the objective reports one case per soup (${conway.cases.size} for ${soups.size})"
)
check(
conway.behaviour.size == 2,
"premise: the CA behaviour descriptor is 2-D for a MAP-Elites archive"
)
// Lenia: two rules on opposite sides of the phase boundary, scored by the soliton objective.
val lenia = leniaSolitonObjective(width = 48, height = 48, steps = 60, initialSeed = 3L)
// Growth is `2*exp(-(u-mu)^2 / 2*sigma^2) - 1`. With mu = 0.9 and sigma = 0.01 that is -1 for
// every neighbourhood mean a sparse field can produce, so the seed can only decay. With
// mu = 0.5 and sigma = 1.0 it is >= 2*exp(-0.125) - 1 = 0.765 for every u in 0..1, so every
// cell including the empty background grows every step. Chosen from the mapping rather than
// guessed: the first two parameter sets tried here were both measured BOUNDED and this check
// caught it.
val alwaysDecays = LeniaRule(radius = 6, dt = 0.1, mu = 0.9, sigma = 0.01)
val alwaysGrows = LeniaRule(radius = 6, dt = 0.1, mu = 0.5, sigma = 1.0)
val decayed = leniaRun(leniaSeed(48, 48, alwaysDecays, seed = 3L), alwaysDecays, steps = 60, sampleEvery = 10)
val grew = leniaRun(leniaSeed(48, 48, alwaysGrows, seed = 3L), alwaysGrows, steps = 60, sampleEvery = 10)
check(
decayed.phase == afield.simulation.agents.LeniaPhase.DEATH && grew.phase == afield.simulation.agents.LeniaPhase.SATURATION,
"premise: the two constructed Lenia rules really do die and saturate ($decayed / $grew)"
)
val tight = lenia.evaluate(alwaysDecays)
val wide = lenia.evaluate(alwaysGrows)
check(
tight.fitness < 0.0 && wide.fitness < 0.0,
"premise: both Lenia failure modes score below zero (${f(tight.fitness, 3)}, ${f(wide.fitness, 3)})"
)
check(
tight.fitness == -1.0 && wide.fitness == -1.0,
"premise: a rule that dies to nothing and a rule that saturates completely both pin at " +
"exactly the floor -1.0 (${f(tight.fitness, 4)}, ${f(wide.fitness, 4)}). The gradient " +
"in leniaSolitonObjective separates PARTIAL failures; these two are total, and this " +
"check exists because the first pair of parameters tried here were both measured " +
"BOUNDED and scored above +1"
)
// The bracket repair, which every L-system genome depends on being total.
val ragged = "F[+F]]-[[F"
val fixed = balanceBrackets(ragged)
check(
bracketsBalanced(fixed),
"premise: balanceBrackets repairs '$ragged' to '$fixed'"
)
check(
balanceBrackets(fixed) == fixed,
"premise: balanceBrackets is idempotent — a repaired string is a fixed point"
)
check(
balanceBrackets("F+F-F") == "F+F-F",
"premise: balanceBrackets leaves a bracket-free string alone"
)
montage(
listOf(
"Conway %s".format(f(conway.fitness, 3)) to
caRun(soups[0], lifeRule("B3/S23"), 20, 30).finalGrid.toImg(2).upscaleNearest(2),
"extinct %s".format(f(dead.fitness, 3)) to
caRun(soups[0], lifeRule("B3/S"), 20, 30).finalGrid.toImg(2).upscaleNearest(2),
"satur %s".format(f(flood.fitness, 3)) to floodRun.finalGrid.toImg(2).upscaleNearest(2)
),
columns = 3, cellSide = 200
).savePng(File(outDir, "evolve-premise.png").path)
}
private fun bracketsBalanced(s: String): Boolean {
var d = 0
for (c in s) {
if (c == '[') d++
if (c == ']') { d--; if (d < 0) return false }
}
return d == 0
}
1 — Life-like rule tables
Evolves a life-like rule table for activity at moderate density, against the random control.
Lexicase selection, because this objective genuinely has a case structure — one case per soup — and a rule that holds together on one initial condition and collapses on the other is exactly what a tournament's average hides.
source — 54 lines, from line 310
private fun figureCa(outDir: File) {
// A radius-2 neighbourhood, not the classic Moore 8. With 24 neighbours there are 24 birth
// bits and 25 survival bits, so the rule space is 2^49 rather than 2^17. That matters: at
// 2^17 a few hundred independent draws land close to the optimum on their own, and the first
// version of this figure measured evolution beating the control by 3% for exactly that
// reason. A control arm that nearly wins is telling you the problem is easy, not that the
// search is good, and the honest response is a harder problem rather than a softer floor.
val ops = LifeRuleGenome(radius = 2)
val soups = listOf(
randomSoup(48, 48, density = 0.35, seed = 101L),
randomSoup(48, 48, density = 0.12, seed = 202L)
)
val objective = caEdgeOfChaosObjective(soups, warmup = 20, scored = 30)
val budget = 400
val control = PopulationSearch.random(ops, objective, budget, seed = 7L, label = "ca random")
val evolved = PopulationSearch.evolve(
ops, objective, budget, seed = 7L,
mu = 8, lambda = 16, selection = Selection.LEXICASE,
crossoverRate = 0.3, immigrantRate = 0.1,
label = "ca evolve"
)
println("[alife-evolve] ${control.summary()}")
println("[alife-evolve] ${evolved.summary()}")
checkBeatsControl("CA", evolved, control, EXTINCT_SCORE, CA_MARGIN)
check(
evolved.recombined > 0,
"CA: the rule table really was recombined ${evolved.recombined} time(s) — a bit-string " +
"genome with uniform crossover, not a graft"
)
check(
evolved.improvements.isNotEmpty(),
"CA: the search recorded a lineage of ${evolved.improvements.size} improvement(s)"
)
check(
evolved.bestFitness >= control.bestFitness * 1.15,
"CA: evolution is ${f(100.0 * (evolved.bestFitness / control.bestFitness - 1.0), 1)}% above " +
"the control in relative terms, required >= 15% (measured $CA_MEASURED absolute)"
)
val best = evolved.best!!.genome
println("[alife-evolve] best CA rule ${lifeRuleSignature(best)}")
val strip = ArrayList<Pair<String, Img>>()
strip += "rnd %s".format(f(control.bestFitness, 3)) to
caRun(soups[0], control.best!!.genome, 20, 30).finalGrid.toImg(2).upscaleNearest(3)
for (step in lineageSample(evolved.improvements.size, 5)) {
val imp = evolved.improvements[step]
strip += "g%d %s".format(imp.generation, f(imp.fitness, 3)) to
caRun(soups[0], imp.genome, 20, 30).finalGrid.toImg(2).upscaleNearest(3)
}
montage(strip, columns = 3, cellSide = 200)
.savePng(File(outDir, "evolve-ca-lineage.png").path)
}
Up to want evenly spaced indices into a list of size, always including the last.
private fun lineageSample(size: Int, want: Int): List<Int> {
if (size <= 0) return emptyList()
if (size <= want) return (0 until size).toList()
return (0 until want).map { ((it + 1).toDouble() * size / want).toInt().coerceIn(0, size - 1) }.distinct()
}
2 — Lenia
Evolves a Lenia rule toward a localised travelling structure, against the random control.
source — 59 lines, from line 377
private fun figureLenia(outDir: File) {
val ops = LeniaGenome(radius = 6, dt = 0.1, shells = 3)
val objective = leniaSolitonObjective(
width = 48, height = 48, steps = 90, initialSeed = 5L, driftScale = 8.0
)
val budget = 220
val control = PopulationSearch.random(ops, objective, budget, seed = 13L, label = "lenia random")
val evolved = PopulationSearch.evolve(
ops, objective, budget, seed = 13L,
mu = 6, lambda = 12, crossoverRate = 0.3, immigrantRate = 0.08,
label = "lenia evolve"
)
println("[alife-evolve] ${control.summary()}")
println("[alife-evolve] ${evolved.summary()}")
// The floor for Lenia is -1: a run that died with zero mass. Everything above it survived
// to some degree, so the control arm clearing it is the non-empty guard here too.
checkBeatsControl("Lenia", evolved, control, -1.0, LENIA_MARGIN)
val bestRule = evolved.best!!.genome
val bestRun = leniaRun(leniaSeed(48, 48, bestRule, seed = 5L), bestRule, steps = 90, sampleEvery = 8)
check(
bestRun.phase == afield.simulation.agents.LeniaPhase.BOUNDED,
"Lenia: the evolved rule is measured BOUNDED, not merely better-scoring — $bestRun"
)
check(
bestRun.centroidDrift > 0.0,
"Lenia: the evolved pattern travelled ${f(bestRun.centroidDrift, 2)} cells over the second " +
"half of the run"
)
println("[alife-evolve] best Lenia mu=${f(bestRule.mu, 4)} sigma=${f(bestRule.sigma, 4)} $bestRun")
// The objective scores 90 steps, and what it found is a transient: the same rule from the same
// seed fills the field with fragments by step 300. The page says so; this pins it.
val longRun = leniaRun(leniaSeed(48, 48, bestRule, seed = 5L), bestRule, steps = 300, sampleEvery = 8)
println("[alife-evolve] the same rule at 300 steps: $longRun")
check(
bestRun.blobCount == 1 && longRun.blobCount > 5 && longRun.finalMass > 5 * bestRun.finalMass,
"Lenia: the evolved 'soliton' is one blob at 90 steps and a field of ${longRun.blobCount} " +
"fragments at mean ${f(longRun.finalMass, 2)} by 300 — a ring on its way to filling the board"
)
val panels = ArrayList<Pair<String, Img>>()
val controlRule = control.best!!.genome
panels += "rnd %s".format(f(control.bestFitness, 3)) to
leniaRun(leniaSeed(48, 48, controlRule, seed = 5L), controlRule, steps = 90, sampleEvery = 8)
.field.upscaleNearest(3)
for (step in lineageSample(evolved.improvements.size, 5)) {
val imp = evolved.improvements[step]
panels += "g%d %s".format(imp.generation, f(imp.fitness, 3)) to
leniaRun(leniaSeed(48, 48, imp.genome, seed = 5L), imp.genome, steps = 90, sampleEvery = 8)
.field.upscaleNearest(3)
}
// A fixed 0..1 display range: a saturated field is constant at 1 and an auto-ranged colour
// map would draw it black, i.e. identical to death. This is the one family where the range
// policy changes the answer rather than tidying it.
montage(panels, columns = 3, cellSide = 200, displayRange = 0f..1f)
.savePng(File(outDir, "evolve-lenia-lineage.png").path)
}
3 — L-system grammars
Evolves a grammar toward a target mass–radius dimension, against the random control.
source — 54 lines, from line 442
private fun figureLSystem(outDir: File) {
// Two rewritten symbols, not one. `X` never draws, so it is pure structure: the grammar can
// build a scaffold that only later turns into strokes, and the space of grammars is squared.
// With one symbol a few hundred random draws already land within 0.005 of the target, which
// is a statement about the problem rather than about search.
val ops = LSystemGenome(axiom = "X", symbols = "FX", alphabet = "FFXX+-[]")
// 1.25, not the 1.70 this figure started at. A random bracketed grammar drawn at generation 4
// lands at D = 1.70 almost every time — measured: 400 independent draws produced a best of
// 1.700 against a target of 1.700 — so asking for 1.70 is asking the search to find the mode
// of its own prior. Nothing to search for is not the same as a search that works.
val target = 1.25
val objective = lSystemDimensionObjective(targetDimension = target, generations = 4, side = 128)
val budget = 400
val control = PopulationSearch.random(ops, objective, budget, seed = 23L, label = "lsys random")
val evolved = PopulationSearch.evolve(
ops, objective, budget, seed = 23L,
mu = 10, lambda = 20, crossoverRate = 0.35, immigrantRate = 0.08,
label = "lsys evolve"
)
println("[alife-evolve] ${control.summary()}")
println("[alife-evolve] ${evolved.summary()}")
checkBeatsControl("L-system", evolved, control, EXTINCT_SCORE, LSYSTEM_MARGIN)
check(
evolved.recombined > 0,
"L-system: ${evolved.recombined} child(ren) came from single-point string crossover"
)
val bestDrawing = lSystemDrawing(evolved.best!!.genome, generations = 4, side = 128)
val controlDrawing = lSystemDrawing(control.best!!.genome, generations = 4, side = 128)
check(bestDrawing.drawable, "L-system: the evolved grammar draws something measurable")
check(
kotlin.math.abs(bestDrawing.dimension - target) < kotlin.math.abs(controlDrawing.dimension - target),
"L-system: the evolved grammar lands closer to D=$target " +
"(${f(bestDrawing.dimension)}) than the control's (${f(controlDrawing.dimension)})"
)
check(
bestDrawing.rSquared > 0.95,
"L-system: the evolved drawing has a real scaling regime, r2 = ${f(bestDrawing.rSquared)} — " +
"a slope through a curve would hit any target and mean nothing"
)
println("[alife-evolve] best grammar ${bestDrawing.branches} branch(es) -> $bestDrawing")
val panels = ArrayList<Pair<String, Img>>()
panels += "rnd D%s".format(f(controlDrawing.dimension, 2)) to controlDrawing.raster
for (step in lineageSample(evolved.improvements.size, 5)) {
val imp = evolved.improvements[step]
val d = lSystemDrawing(imp.genome, generations = 4, side = 128)
panels += "g%d D%s".format(imp.generation, f(d.dimension, 2)) to d.raster
}
montage(panels, columns = 3, cellSide = 200)
.savePng(File(outDir, "evolve-lsystem-lineage.png").path)
}
4 — quality diversity: illuminate the CA rule space instead of optimising it
MAP-Elites over (density, activity), and the figure worth looking at.
Not an optimisation. The claim is coverage: an archive that holds an elite in many cells is a map of what the rule space can do, and the control arm for that claim is the same budget spent on independent random draws, binned the same way. Random sampling concentrates in whichever region is easiest to reach; the archive is what stops the search going back there.
source — 87 lines, from line 509
private fun figureMapElites(outDir: File) {
val ops = LifeRuleGenome(radius = 2)
val soups = listOf(randomSoup(48, 48, density = 0.35, seed = 303L))
val objective = caEdgeOfChaosObjective(soups, warmup = 20, scored = 25)
val bins = listOf(6, 6)
val axes = listOf(0.0..1.0, 0.0..0.6)
val budget = 600
val archive = PopulationSearch.mapElites(
ops, objective, budget, axes, bins, seed = 31L, initial = 40
)
println("[alife-evolve] $archive (off-grid ${archive.offGridCandidates})")
// The control arm is the same budget of independent draws, binned identically and reduced to
// the best genome per cell — i.e. an archive built by sampling instead of by illumination.
// Comparing against a *single* best random genome would be the wrong control: MAP-Elites is
// not trying to find one good rule, so beating one good rule proves nothing.
val controlElites = HashMap<List<Int>, Double>()
run {
val rng = kotlin.random.Random(31L)
repeat(budget) {
val e = objective.evaluate(ops.random(rng))
val cell = binOf(e, axes, bins)
val incumbent = controlElites[cell]
if (incumbent == null || e.fitness > incumbent) controlElites[cell] = e.fitness
}
}
val controlQd = controlElites.values.sum()
println("[alife-evolve] random-sampled archive: ${controlElites.size}/${archive.cells} cells, QD ${f(controlQd)}")
check(
controlElites.isNotEmpty(),
"MAP-Elites: the control archive covered ${controlElites.size} cell(s), so the comparisons " +
"below are not empty against empty"
)
// Quality-diversity is scored on QD, not on coverage. Random sampling can spread widely over
// a descriptor space with no effort — that is what "random" means — and the first version of
// this figure asserted coverage and lost 28 cells to 30. What illumination buys is the
// *quality* held in each cell, because every elite is the best of a lineage that was selected
// for staying in that cell rather than the luckiest of the draws that happened to land there.
check(
archive.qdScore > controlQd * 1.02,
"MAP-Elites: QD score ${f(archive.qdScore)} beats the random-sampled archive's ${f(controlQd)} " +
"by more than 2% at the same budget of $budget evaluations (measured 3.477 vs 3.361, +3.5%)"
)
check(
archive.elites.size >= controlElites.size * 0.85,
"MAP-Elites: coverage ${archive.elites.size}/${archive.cells} is within 15% of the random " +
"control's ${controlElites.size} — illumination is not expected to *win* coverage " +
"here, only to not throw it away while it buys quality"
)
check(
archive.offGridCandidates < budget / 2,
"MAP-Elites: ${archive.offGridCandidates}/${budget} candidates fell outside the axes and " +
"were clamped to an edge cell; above half the run the axes would be the wrong ones"
)
// Bidirectional ledger over the archive. Every elite must re-bin to the key it is filed
// under, and every key must be a legal cell. Aggregated into two checks rather than one per
// cell, but both directions are still tested: a mis-filed elite fails the first, and a key
// outside the grid fails the second.
val misfiled = archive.elites.entries.count { (cell, elite) -> binOf(elite.evaluation, axes, bins) != cell }
val malformed = archive.elites.keys.count { cell ->
cell.size != bins.size || cell.indices.any { cell[it] !in 0 until bins[it] }
}
check(misfiled == 0, "MAP-Elites: all ${archive.elites.size} elites re-bin to their own cell key")
check(malformed == 0, "MAP-Elites: all ${archive.elites.size} cell keys are inside the ${bins} grid")
val panels = archive.elites.entries
.sortedWith(compareBy({ it.key[0] }, { it.key[1] }))
.map { (cell, elite) ->
"%d%d %s".format(cell[0], cell[1], f(elite.fitness, 2)) to
caRun(soups[0], elite.genome, 20, 25).finalGrid.toImg(2).upscaleNearest(3)
}
check(panels.isNotEmpty(), "MAP-Elites: the archive is non-empty, so the contact sheet has panels")
montage(panels, columns = 6, cellSide = 150)
.savePng(File(outDir, "evolve-mapelites-archive.png").path)
}
private fun binOf(
e: Evaluation,
axes: List<ClosedFloatingPointRange<Double>>,
bins: List<Int>
): List<Int> = axes.indices.map { d ->
val axis = axes[d]
val t = ((e.behaviour[d] - axis.start) / (axis.endInclusive - axis.start)).coerceIn(0.0, 1.0)
(t * bins[d]).toInt().coerceIn(0, bins[d] - 1)
}
5 — reproducibility
The same seed must give the same search, or an evolved rule is an anecdote.
Run twice per family, compared on the genome's own signature rather than on the fitness alone: two different rules that happen to score identically would slip past a scalar comparison, and the thing being claimed reproducible is the rule.
source — 42 lines, from line 608
private fun figureDeterminism(outDir: File) {
val soups = listOf(randomSoup(48, 48, density = 0.35, seed = 404L))
val caOps = LifeRuleGenome()
val caObjective = caEdgeOfChaosObjective(soups, warmup = 15, scored = 20)
repeatable("CA", caOps, caObjective, 160, 3L) { lifeRuleSignature(it) }
val leniaOps = LeniaGenome(radius = 5, shells = 2)
val leniaObjective = leniaSolitonObjective(width = 40, height = 40, steps = 50, initialSeed = 9L)
repeatable("Lenia", leniaOps, leniaObjective, 90, 3L) { leniaOps.signature(it) }
val lsysOps = LSystemGenome()
val lsysObjective = lSystemDimensionObjective(generations = 3, side = 96)
repeatable("L-system", lsysOps, lsysObjective, 140, 3L) { lsysOps.signature(it) }
// Nothing to draw here; the ledger below only expects files for figures that declare one.
require(outDir.isDirectory)
}
private fun <G> repeatable(
name: String,
ops: GenomeOps<G>,
objective: Objective<G>,
budget: Int,
seed: Long,
signature: (G) -> String?
) {
val a = PopulationSearch.evolve(ops, objective, budget, seed, mu = 5, lambda = 10, crossoverRate = 0.3)
val b = PopulationSearch.evolve(ops, objective, budget, seed, mu = 5, lambda = 10, crossoverRate = 0.3)
check(
a.bestFitness == b.bestFitness && signature(a.best!!.genome) == signature(b.best!!.genome),
"determinism: $name reproduces genome and score under seed $seed " +
"(${signature(a.best!!.genome)} at ${f(a.bestFitness, 6)})"
)
check(
a.trace.contentEquals(b.trace),
"determinism: $name reproduces its whole ${a.trace.size}-point trace, not only its endpoint"
)
check(
a.recombined == b.recombined && a.immigrants == b.immigrants,
"determinism: $name reproduces its crossover and immigrant counts " +
"(${a.recombined}, ${a.immigrants})"
)
}
Driver
Figure name to the renderer, and to the PNG it must leave behind (or none).
private val figures: List<Triple<String, (File) -> Unit, String?>> = listOf(
Triple("premise", ::figurePremise, "evolve-premise.png"),
Triple("ca", ::figureCa, "evolve-ca-lineage.png"),
Triple("lenia", ::figureLenia, "evolve-lenia-lineage.png"),
Triple("lsystem", ::figureLSystem, "evolve-lsystem-lineage.png"),
Triple("mapelites", ::figureMapElites, "evolve-mapelites-archive.png"),
Triple("determinism", ::figureDeterminism, null)
)
fun main(args: Array<String>)
body — 57 lines, from line 665
@JvmStatic
fun main(args: Array<String>) {
var outPath: String? = System.getenv("ALIFE_EVOLVE_DIR")
var only: Set<String>? = null
var i = 0
while (i < args.size) {
when (args[i]) {
"--out" -> { outPath = args.getOrNull(i + 1); i++ }
"--only" -> { only = args.getOrNull(i + 1)?.split(',')?.map { it.trim() }?.toSet(); i++ }
else -> if (outPath == null && !args[i].startsWith("--")) outPath = args[i]
}
i++
}
val chosenOut = outPath
val outDir = if (chosenOut != null) File(chosenOut) else
java.nio.file.Files.createTempDirectory("alife-evolve").toFile()
outDir.mkdirs()
println("[alife-evolve] output -> ${outDir.absolutePath}")
val chosen = only
val selected = figures.filter { chosen == null || it.first in chosen }
check(selected.isNotEmpty(), "at least one figure selected (--only ${chosen?.joinToString(",")})")
val started = System.nanoTime()
for ((name, render, _) in selected) {
val t = System.nanoTime()
println("[alife-evolve] --- $name ---")
runCatching { render(outDir) }.onFailure { fail("$name threw: $it") }
println("[alife-evolve] --- $name done in %.1f s ---".format((System.nanoTime() - t) / 1e9))
}
// Bidirectional ledger over the figures and their files. Missing *and* stale *and* set
// equality: a figure that stopped writing its PNG fails, and a PNG left over from a figure
// that no longer declares one fails too. A one-directional "every declared file exists"
// check would pass forever after a figure was deleted.
val declared = selected.mapNotNull { it.third }.toSortedSet()
val onDisk = (outDir.listFiles { f -> f.name.endsWith(".png") } ?: emptyArray())
.map { it.name }.toSortedSet()
check(
declared == onDisk,
"figure ledger: declared $declared, on disk $onDisk " +
"(missing ${declared - onDisk}, unexpected ${onDisk - declared})"
)
for (name in declared) {
val file = File(outDir, name)
check(file.length() > 2000, "figure ledger: $name is ${file.length()} bytes, not a stub")
}
println("[alife-evolve] %d check(s), %d failure(s), %.1f s total"
.format(checks, failures.size, (System.nanoTime() - started) / 1e9))
if (failures.isNotEmpty()) {
println("[alife-evolve] FAILURES:")
failures.forEach { println(" - $it") }
exitProcess(1)
}
println("alife evolve gallery rendered")
}
}