StackSLEvolve.kt
src/jvmMain/kotlin/afield/lang/stacksl/evolution/StackSLEvolve.kt · 1707 lines
package and imports — 122 lines, from line 1
@file:afield.runtime.completion.CompletionCategory("Search")
package afield.lang.stacksl.evolution
import afield.data.collections.invoke
import afield.data.collections.times
import afield.document.context.Why
import afield.document.context.why
import afield.document.graph.minusAssign
import afield.document.graph.named
import afield.document.graph.plusAssign
import afield.document.properties.compareTo
import afield.document.workspace.file
import afield.geometry.mesh.bounds
import afield.geometry.mesh.expanded
import afield.graphics.render.rangeTo
import afield.graphics.shader.task
import afield.graphics.text.layout
import afield.math.autodiff.atan2
import afield.math.autodiff.compareTo
import afield.math.autodiff.cos
import afield.math.autodiff.div
import afield.math.autodiff.dot
import afield.math.autodiff.minus
import afield.math.autodiff.plus
import afield.math.autodiff.pow
import afield.math.autodiff.sigmoid
import afield.math.autodiff.sign
import afield.math.autodiff.times
import afield.math.functions.FnMutations
import afield.math.functions.atan2
import afield.math.functions.cos
import afield.math.linalg.bar
import afield.math.linalg.invoke
import afield.math.linalg.median
import afield.math.linalg.remAssign
import afield.math.linalg.set
import afield.math.linalg.times
import afield.math.linalg.xx
import afield.math.linalg.xy
import afield.math.linalg.xyz
import afield.math.linalg.yx
import afield.math.statistics.noise
import afield.math.units.div
import afield.math.units.minus
import afield.math.units.plus
import afield.math.units.times
import afield.media.image.channel
import afield.media.image.close
import afield.media.image.crop
import afield.media.image.median
import afield.media.image.rotate
import afield.media.image.savePng
import afield.media.image.showImg
import afield.media.image.threshold
import afield.media.video.image
import afield.runtime.execution.start
import afield.runtime.lifecycle.once
import afield.simulation.agents.LSystemGenome
import afield.simulation.agents.step
import afield.simulation.automata.savePng
import afield.simulation.automata.step
import afield.animation.keyframes.h
import afield.audio.synth.*
import afield.data.collections.get
import afield.data.collections.m
import afield.data.collections.r
import afield.data.collections.s
import afield.geometry.mesh.gradient
import afield.geometry.mesh.rounded
import afield.geometry.mesh.scaled
import afield.geometry.mesh.summary
import afield.graphics.color.rgb
import afield.graphics.color.rgba
import afield.graphics.render.label
import afield.graphics.vulkan.buffers.add
import afield.math.autodiff.floor
import afield.math.autodiff.mean
import afield.math.autodiff.norm
import afield.math.autodiff.round
import afield.math.autodiff.sin
import afield.math.autodiff.sum
import afield.math.functions.behaviour
import afield.math.functions.evolve
import afield.math.functions.sin
import afield.math.linalg.argb
import afield.math.linalg.d
import afield.math.linalg.i
import afield.math.linalg.isNaN
import afield.math.linalg.l
import afield.math.linalg.rgb
import afield.math.linalg.rgba
import afield.math.linalg.x
import afield.math.statistics.random
import afield.media.image.Img
import afield.media.image.luma
import afield.media.image.montage
import afield.media.image.resize
import afield.media.image.split
import afield.media.image.toRGB
import afield.media.image.upscaleNearest
import afield.runtime.execution.cell
import afield.study.search.Evaluation
import afield.study.search.GenomeOps
import afield.study.search.Objective
import afield.study.search.PopulationSearch
import afield.study.search.SearchRun
import afield.study.search.Selection
import afield.study.search.Variation
import afield.study.ui.numbers
import afield.ui.output.h
import afield.ai.inference.onnx.Lpips
import afield.lang.stacksl.StackSLCompiler
import afield.lang.stacksl.StackSLSeverity
import afield.lang.stacksl.cpu.CpuRaster
import afield.lang.stacksl.cpu.StackSLCpu
import kotlin.math.abs
import kotlin.math.max
import kotlin.math.min
import kotlin.math.sqrt
import kotlin.random.Random
Genetic programming over StackSL token sequences, scored by rendering the program and comparing it to a target image.
This is a third shape of consumer for PopulationSearch.kt. The two that were there before it
evolve numbers or rule tables — FunctionEvolve.kt mutates an Fn expression graph and
alife_evolve.kt mutates rule tables, growth windows and grammar bodies — and neither has a
phenotype you can look at. Here the genome is a program in a real language, the phenotype is a
picture, and the thing in between is the production emitter plus afield.lang.stacksl.cpu's
interpreter. Nothing about what a StackSL word means is restated in this file.
What is new here and what is not
Being precise about this, because three of the four ingredients already existed:
- Not new: linear-genome GP with this operator set.
LSystemGenomeinalife_evolve.ktalready haspoint,insert,delete,duplicate,swap, single-point crossover and asignature, over a string of turtle symbols. StackSLGenome's operators are that set, lifted to a token list, and the resemblance is deliberate. - Not new: synthesising a program as text and letting a real compiler judge it.
afield.lang.morpho.MorphoGenomedoes exactly that for MorphoHDL, and already reports its invalid fraction rather than hiding it. It predatesPopulationSearchand runs on a private search core. - New: the phenotype is an image, produced by the same emitter a GPU reads, so fitness is a measurement of a rendered picture rather than of a scalar or a netlist.
- New: a program-text genome on the generic core, so PopulationSearch.random,
PopulationSearch.evolve,PopulationSearch.novelty,PopulationSearch.mapElitesandSelection.LEXICASEare all available to it without any of them being written again.
Arity: there was nothing to repair and nothing to reject
The plan this file was written against asked for a deliberate choice between repairing a token
sequence into a legal stack effect and rejecting it as invalid. Measured against the real
compiler, that is a false dichotomy for a straight-line program: StackSL has no invalid
straight-line programs. StackSLCompileOptions.defaultValues fills any operand the stack does
not have, and it does so without an ERROR diagnostic even at tolerant = false — + on its
own compiles, ok, and leaves the stack at depth 1. The silent-pruning hazard that
finalStackDepth exists to expose belongs to definitions (: name … ;), whose arity has to
work out for the emitted function to be called at all; this generator emits no definitions, so
it cannot hit it.
So StackSLGenome does not repair for validity and does not reject. It carries a model of
each word's stack effect (StackSLWord.consumes) and uses it only to steer generation — the
same relationship MorphoWidthModel has to MorphoHDL's real width inference, where "the compiler
remains the judge; the model's accuracy shows up as failureRate and nowhere else". Here the
model's accuracy shows up as StackSLEvolveRun.danglingValues, which counts values a program
pushed and never consumed, read off the compiler's own analysis.
The gate that actually matters is on the image, not on the program
A program that compiles perfectly and renders a flat grey rectangle is the single most likely way a search like this produces a fake result: two blanks agree with each other everywhere, and a plain error metric will happily rank one of them first. StackSLImageObjective therefore scores a constant-colour render as invalid, inside the fitness function, and refuses a target that is itself blank.
Beating chance is not resembling the target
The first version of this file scored 1 - mean absolute RGB error, now StackSLMetric.PIXEL.
Evolution beat the random control on four of five targets under it, and the pictures mostly did
not look like their targets: a yellow disc evolved into dark diagonal streaks, four quadrants
into a vertical gradient. An absolute error is satisfied first by matching the target's overall
colour balance, and the search found that rather than the layout. StackSLMetric.LPIPS, the
default, scores with a learned perceptual distance instead (afield.ai.inference.onnx.Lpips).
StackSLLayout.layoutAgreement is the independent check on whether a result resembles its
target, and StackSLEvolveGallery.figureTargets runs both metrics against each other and asserts
on it.
Cost, so the budget is chosen rather than discovered
One 32x32 candidate is about 1,000 pixel-shader invocations through afield.lang.stacksl.cpu's
interpreter, which runs 1.3–1.6 x 10^4 a second per core (dev_docs/STACKSL_CPU_BACKEND.md):
30 ms with parallel = true on ten cores, 70 ms to 130 ms on one, depending on how many fbm
octaves the program has and what else the machine is doing. Compiling and expanding the
template costs 0.35 ms and is noise by comparison. A 600-evaluation search is therefore about
twenty seconds, and the two-arm comparison evolveStackSLTowards runs about forty.
evolveStackSLTowards is the one-call entry point a box wants; see public/docs/stacksl_evolve.md.
1 — the genome: a StackSL program as a list of tokens
enum class StackSLWordKind
What a word does to the stack's depth, in the model this file steers generation with.
enum class StackSLWordKind {
Pushes a value and consumes none: coordinates, constants, colours, numeric literals.
PUSH,
Consumes one and pushes one: sin, fbm, length, a swizzle, a ]-driver suffix.
UNARY,
Consumes two and pushes one: +, mix's neighbours, threshold, twist.
BINARY,
Consumes three and pushes one: mix, smoothstep, select, pulse.
TERNARY
}
class StackSLWord(val token: String, val kind: StackSLWordKind)
One word in the generator's vocabulary, with the stack effect this file believes it has.
consumes is a model, and it is measured rather than read off the cheatsheet: every entry in
StackSLPalette.DEFAULT was checked by compiling input input input input input <word> and
reading finalStackDepth back off StackSLAnalysis. Words whose real arity depends on how deep
the stack happens to be — palette eats five values when five are there and one when one is,
grade eats four, vec4 eats four — are modelled at the arity they are used at in the gallery,
which is the arity the generator should aim for. Being wrong here costs a dangling value, not a
broken program.
class StackSLWord(val token: String, val kind: StackSLWordKind) {
val consumes: Int get()
Values the word takes off the stack, in the model.
val consumes: Int get() = when (kind) {
StackSLWordKind.PUSH -> 0
StackSLWordKind.UNARY -> 1
StackSLWordKind.BINARY -> 2
StackSLWordKind.TERNARY -> 3
}
override fun toString(): String = token
The word's token, so a palette prints as its vocabulary.
class StackSLProgram(val tokens: List<String>)
A StackSL program as a token list — the genome.
A list rather than a string because every variation operator in StackSLGenome is positional, and splitting a string on whitespace at the top of six operators is six chances to disagree about what a token is. text is the program as StackSL reads it, and is also the signature the search memoises on.
class StackSLProgram(val tokens: List<String>) {
val text: String get() = tokens.joinToString(" ")
The program as source text, one space between tokens.
val size: Int get() = tokens.size
How many tokens; the parsimony axis and one of the MAP-Elites axes.
override fun toString(): String = text
The program as text, which is also what the search memoises on.
companion object
companion object {
fun of(text: String): StackSLProgram
Split source text into tokens the way the generator's operators expect.
fun of(text: String): StackSLProgram =
StackSLProgram(text.split(Regex("""\s+""")).filter { it.isNotBlank() })
}
}
class StackSLPalette
The vocabulary a search draws from, and the numeric-literal distribution beside it.
Deliberately not every StackSL word. Three exclusions, each for a reason:
sample,sample-abs,gather,blurand the other texture readers are out because they need a previous frame, and a still render has none —GlslSamplers.REFUSEwould throw and the candidate would be scored as a crash rather than as a picture.: … ;definitions are out, because a definition whose arity does not work out is pruned silently and this whole file's honesty rests on nothing being silent.?namehost reads are out because they name GLSL that only exists inside a live module.
Everything else in the cheatsheet's arithmetic, swizzle, reducer, domain-transform, colour and
shaping tables is fair game, plus the ]-driver suffix forms, which are where StackSL's
modulation vocabulary actually lives.
class StackSLPalette(
/** The words a draw may choose from, each with the arity it is modelled at. One of every kind is required. */
val words: List<StackSLWord>,
/** Literal values the `number` draw picks from before jitter widens them. */
val numbers: List<Double> = DEFAULT_NUMBERS,
/** Probability that a `PUSH` draw yields a fresh numeric literal rather than a named word. */
val numberShare: Double = 0.45
) {
init
init {
require(words.isNotEmpty()) { "a palette needs at least one word" }
require(numbers.isNotEmpty()) { "a palette needs at least one number" }
require(numberShare in 0.0..1.0) { "numberShare must be in 0..1, got $numberShare" }
StackSLWordKind.entries.forEach { kind ->
require(words.any { it.kind == kind }) { "palette has no $kind word" }
}
}
private val byKind: Map<StackSLWordKind, List<StackSLWord>> = words.groupBy { it.kind }
private val kindOfToken: Map<String, StackSLWordKind> = words.associate { it.token to it.kind }
fun kindOf(token: String): StackSLWordKind
The modelled kind of token; a token that parses as a number, or a vector literal such as
[0.9,0.1,0.1] (which a proposed program may carry), is a StackSLWordKind.PUSH.
fun kindOf(token: String): StackSLWordKind =
kindOfToken[token] ?: if (isNumber(token) || VECTOR_LITERAL.matches(token)) StackSLWordKind.PUSH else StackSLWordKind.UNARY
fun consumes(token: String): Int
How many values token is modelled to consume.
fun consumes(token: String): Int = when (kindOf(token)) {
StackSLWordKind.PUSH -> 0
StackSLWordKind.UNARY -> 1
StackSLWordKind.BINARY -> 2
StackSLWordKind.TERNARY -> 3
}
fun draw(kind: StackSLWordKind, rng: Random): String
A fresh token of kind; a PUSH is a numeric literal with probability numberShare.
fun draw(kind: StackSLWordKind, rng: Random): String {
if (kind == StackSLWordKind.PUSH && rng.nextDouble() < numberShare) return drawNumber(rng)
val pool = byKind.getValue(kind)
return pool[rng.nextInt(pool.size)].token
}
fun substitute(token: String, rng: Random): String
A token of the same modelled kind as token.
This is what makes point substitution structure-preserving: swapping sin for fbm leaves
every downstream token reading the depth it read before, so a point mutation explores meaning
without disturbing shape. An insertion or a deletion is the operator that changes shape.
fun substitute(token: String, rng: Random): String {
val kind = kindOf(token)
if (kind == StackSLWordKind.PUSH && isNumber(token)) return drawNumber(rng)
val pool = byKind.getValue(kind).filter { it.token != token }
if (pool.isEmpty()) return token
return pool[rng.nextInt(pool.size)].token
}
fun drawNumber(rng: Random): String
A numeric literal, occasionally with a StackSL suffix that turns it into a modulator.
fun drawNumber(rng: Random): String {
val base = numbers[rng.nextInt(numbers.size)]
return when (rng.nextInt(8)) {
0 -> format(base) + "t"
1 -> format(base) + "s" + (1 + rng.nextInt(6))
2 -> format(base) + "c" + (1 + rng.nextInt(6))
else -> format(base)
}
}
companion object
companion object {
val DEFAULT_NUMBERS: List<Double>
A round set of magnitudes shader constants actually take, spanning four decades.
val DEFAULT_NUMBERS: List<Double> = listOf(
0.05, 0.1, 0.15, 0.2, 0.25, 0.33, 0.5, 0.62, 0.75, 0.9,
1.0, 1.5, 2.0, 3.0, 4.0, 5.0, 6.0, 8.0, 12.0, 18.0
)
fun isNumber(token: String): Boolean = NUMBER.matches(token)
true when token is a plain decimal literal, with or without a StackSL suffix.
fun numberOf(token: String): Double?
The numeric part of a literal token, or null when there is none.
fun numberOf(token: String): Double? = NUMBER.matchEntire(token)?.groupValues?.get(1)?.toDoubleOrNull()
fun withNumber(token: String, value: Double): String
Rewrite a literal token's numeric part, keeping any suffix.
fun withNumber(token: String, value: Double): String {
val m = NUMBER.matchEntire(token) ?: return token
return format(value) + m.groupValues[2]
}
fun format(v: Double): String
Three decimals, no exponent, no trailing .0 surprises — StackSL reads this back.
fun format(v: Double): String {
val rounded = Math.round(v * 1000.0) / 1000.0
return if (rounded == Math.rint(rounded) && abs(rounded) < 1e6) "%.1f".format(rounded)
else "%.3f".format(rounded).trimEnd('0')
}
A literal, group 1 the number and group 2 the suffix (t, s3, c2, …).
private val NUMBER = Regex("""(\d+(?:\.\d+)?)([a-z]\d*)?""")
A bracketed numeric vector literal, x to [x,y,z,w]; not a [2z,1y] stack selector.
private val VECTOR_LITERAL = Regex("""\[-?\d*\.?\d+(?:,-?\d*\.?\d+){0,3}]""")
val DEFAULT: StackSLPalette
The default vocabulary: 96 words across the four kinds.
Every arity here was measured, not read: input input input input input <word> compiled
and finalStackDepth compared against the 6 the five pushes alone leave.
val DEFAULT: StackSLPalette = StackSLPalette(
listOf(
// --- PUSH: coordinates, time, constants, colours -----------------------------
"uv", "p", "input", "time", "hash", "pi", "tau",
"white", "black", "red", "green", "blue", "cyan", "magenta", "yellow", "orange", "gray"
).map { StackSLWord(it, StackSLWordKind.PUSH) } + listOf(
// --- UNARY: maths, swizzles, reducers, noise, colour spaces, domain flips -----
"abs", "sin", "cos", "fract", "floor", "sqrt", "neg", "1-", "not", "normalize",
"length", "luma", "average", "sum", "vmin", "vmax", "median",
"x", "y", "xy", "rgb", "xyz", "yx", "xx",
"fbm", "noise", "palette", "saturate", "tonemap",
"polar", "cart", "mirror", "repeat",
"rgb2oklab", "oklab2rgb", "ryb",
"]wr8", "]wa6", "]wx12", "]wy12", "]sh10", "]fbm4", "]c3", "]s5", "]tri4", "]saw3"
).map { StackSLWord(it, StackSLWordKind.UNARY) } + listOf(
// --- BINARY: arithmetic, comparison, shaping, domain transforms ---------------
"+", "-", "*", "/", "min", "max", "mod", "pow", "atan2", "dot", "distance", "step",
"threshold", "posterize", "contrast", "sigmoid", "bias", "bitcrush",
"kaleido", "tile", "twist", "spiral", "rotate", ">x", ">y", "vec2"
).map { StackSLWord(it, StackSLWordKind.BINARY) } + listOf(
// --- TERNARY -----------------------------------------------------------------
"mix", "smoothstep", "select", "ramp", "band", "pulse", "vec3"
).map { StackSLWord(it, StackSLWordKind.TERNARY) }
)
}
}
class StackSLGenome
A StackSL program presented to PopulationSearch as a genome.
All six variation operators are named, so SearchRun.improvements says which one produced
each improvement and the lineage can be drawn rather than asserted. crossover is genuine
one-point recombination on the token list, so SearchRun.recombined is a real count.
signature is StackSLProgram.text, and it is the most valuable line in the class. A point
mutation that lands on the only sin and puts a sin back, a jitter that rounds to the same
three decimals, a delete on a token past maxTokens that was going to be clipped anyway — all
of those are neutral, all of them are common, and the core charges no budget for a cache hit
because the evaluator was not called. At 30 ms a render that is not a micro-optimisation.
class StackSLGenome(
/** The words and literals every draw and substitution comes from. */
val palette: StackSLPalette = StackSLPalette.DEFAULT,
/** Shortest program the generator or `delete` will produce. */
val minTokens: Int = 3,
/**
* Longest; every operator clamps to it, so bloat has a hard ceiling rather than a parsimony
* penalty that would have to be weighed against image error in units nobody has.
*/
val maxTokens: Int = 24,
/** Longest program [random] draws; [maxTokens] still bounds what mutation may grow to. */
val maxRandomTokens: Int = maxTokens
) : GenomeOps<StackSLProgram> {
init
init {
require(minTokens >= 1) { "minTokens must be >= 1, got $minTokens" }
require(maxTokens >= minTokens) { "need maxTokens >= minTokens, got $maxTokens < $minTokens" }
require(maxRandomTokens in minTokens..maxTokens) { "need minTokens <= maxRandomTokens <= maxTokens, got $maxRandomTokens" }
}
override fun random(rng: Random): StackSLProgram
An independent draw — and therefore exactly what PopulationSearch.random samples.
The walk tracks the modelled stack depth and only offers words the depth can pay for, which
is the whole use the arity model gets. It starts at depth 1 because StackSL's stack starts
holding stack_in; that is why an empty program is not a blank frame but the input's own
rgb, and why this search has no cold-start problem.
override fun random(rng: Random): StackSLProgram {
val n = minTokens + rng.nextInt(maxRandomTokens - minTokens + 1)
val out = ArrayList<String>(n)
var depth = 1
repeat(n) {
val kind = drawKind(depth, rng)
val token = palette.draw(kind, rng)
out += token
depth = depth - palette.consumes(token) + 1
}
return canonical(out)
}
override val mutations: List<Variation<StackSLProgram>>
The six positional operators, by the names a run reports against each improvement: point
substitutes one token, insert and delete change the length, duplicate copies a slice,
jitter moves a numeric literal, and swap exchanges two positions. Each clamps to
minTokens``..``maxTokens, so none of them can produce a program the search would refuse.
body — 45 lines, from line 484
override val mutations: List<Variation<StackSLProgram>> = listOf(
Variation("point") { g, rng ->
if (g.size == 0) g else {
val i = rng.nextInt(g.size)
canonical(g.tokens.toMutableList().also { it[i] = palette.substitute(it[i], rng) })
}
},
Variation("insert") { g, rng ->
if (g.size >= maxTokens) g else {
val at = rng.nextInt(g.size + 1)
val depth = modelledDepthAt(g.tokens, at)
canonical(g.tokens.toMutableList().also { it.add(at, palette.draw(drawKind(depth, rng), rng)) })
}
},
Variation("delete") { g, rng ->
if (g.size <= minTokens) g else canonical(g.tokens.toMutableList().also { it.removeAt(rng.nextInt(it.size)) })
},
Variation("duplicate") { g, rng ->
if (g.size == 0 || g.size >= maxTokens) g else {
val from = rng.nextInt(g.size)
val to = min(g.size, from + 1 + rng.nextInt(4))
canonical(g.tokens.toMutableList().also { it.addAll(to, g.tokens.subList(from, to)) })
}
},
Variation("jitter") { g, rng ->
val sites = g.tokens.indices.filter { StackSLPalette.numberOf(g.tokens[it]) != null }
if (sites.isEmpty()) g else {
val i = sites[rng.nextInt(sites.size)]
val base = StackSLPalette.numberOf(g.tokens[i])!!
val scaled = base * (1.0 + (rng.nextDouble() * 2.0 - 1.0) * JITTER)
canonical(g.tokens.toMutableList().also { it[i] = StackSLPalette.withNumber(it[i], scaled) })
}
},
Variation("swap") { g, rng ->
if (g.size < 2) g else {
val i = rng.nextInt(g.size)
val j = rng.nextInt(g.size)
canonical(
g.tokens.toMutableList().also {
val t = it[i]; it[i] = it[j]; it[j] = t
}
)
}
}
)
override fun crossover(a: StackSLProgram, b: StackSLProgram, rng: Random): StackSLProgram
One-point crossover: a prefix of a and a suffix of b.
The textbook recombination for a linear genome, and the same one LSystemGenome uses on a
grammar body. It cuts each parent independently rather than at a shared index, because the
two programs are not aligned and pretending they are would make crossover a length-preserving
shuffle.
override fun crossover(a: StackSLProgram, b: StackSLProgram, rng: Random): StackSLProgram {
val cutA = rng.nextInt(a.size + 1)
val cutB = rng.nextInt(b.size + 1)
return canonical(a.tokens.take(cutA) + b.tokens.drop(cutB))
}
override fun signature(g: StackSLProgram): String = g.text
The program text. See the class KDoc for why this is worth having.
fun canonical(tokens: List<String>): StackSLProgram
Clamp to minTokens..maxTokens. This is not a validity repair — see the file KDoc: a
straight-line StackSL program cannot be arity-invalid, because the compiler fills missing
operands from defaultValues and does not complain. It is a bloat ceiling and nothing else.
fun canonical(tokens: List<String>): StackSLProgram {
var t = tokens
if (t.size > maxTokens) t = t.take(maxTokens)
while (t.size < minTokens) t = t + "uv"
return StackSLProgram(t)
}
Pick a kind the modelled stack depth can pay for, biased toward pushes when it is shallow.
Not a uniform draw: a uniform draw over four kinds on a shallow stack spends most of its
tokens on words whose operands are all defaultValues fillers, and a program made of
fillers is a program that ignores its own coordinates.
private fun drawKind(depth: Int, rng: Random): StackSLWordKind {
val r = rng.nextDouble()
return when {
depth <= 1 -> if (r < 0.55) StackSLWordKind.PUSH else StackSLWordKind.UNARY
depth == 2 -> when {
r < 0.30 -> StackSLWordKind.PUSH
r < 0.62 -> StackSLWordKind.UNARY
else -> StackSLWordKind.BINARY
}
else -> when {
r < 0.24 -> StackSLWordKind.PUSH
r < 0.54 -> StackSLWordKind.UNARY
r < 0.86 -> StackSLWordKind.BINARY
else -> StackSLWordKind.TERNARY
}
}
}
fun modelledDepthAt(tokens: List<String>, index: Int): Int
The modelled stack depth just before token index, starting from StackSL's own depth of 1.
fun modelledDepthAt(tokens: List<String>, index: Int): Int {
var depth = 1
for (i in 0 until min(index, tokens.size)) depth = depth - palette.consumes(tokens[i]) + 1
return max(1, depth)
}
private companion object {
/** Multiplicative half-width of a constant jitter, matching `FnMutations.jitterNumbers`. */
const val JITTER: Double = 0.35
}
}
2 — what a render looks like, and the guard that a render is a picture at all
class RasterStructure
The three numbers that say whether a raster is a picture or a rectangle of one colour.
Reported rather than merely thresholded, because "this render was rejected as blank" is only believable next to the numbers that rejected it.
class RasterStructure(
/**
* The largest per-channel standard deviation over the image, in `0..1` units. A flat fill is
* exactly 0; the StackSL gallery's stills sit between 0.08 and 0.35.
*/
val spread: Double,
/**
* How many distinct 8-bit RGB triples appear, capped at [DISTINCT_CAP] so a busy image does not
* pay for counting them all.
*/
val distinctColours: Int,
/**
* Mean absolute luma gradient, scaled into `0..1`. Independent of [spread] in the way that
* matters: a smooth left-to-right ramp has a large spread and a tiny spatial frequency, and a
* checkerboard has both.
*/
val spatialFrequency: Double,
/** Mean per-pixel `max(rgb) - min(rgb)` — how far the image is from grey. */
val colourfulness: Double
) {
override fun toString(): String
All four numbers on one line, which is how a refusal reports what it measured.
override fun toString(): String =
"spread %.4f, %d colours, freq %.4f, colour %.4f".format(spread, distinctColours, spatialFrequency, colourfulness)
companion object
companion object {
const val DISTINCT_CAP: Int = 4096
Counting past this tells you nothing more about whether the image is blank.
fun of(raster: CpuRaster): RasterStructure
Measure raster's structure. Alpha is ignored: every template here writes a constant 1.
body — 51 lines, from line 636
fun of(raster: CpuRaster): RasterStructure {
val n = raster.width * raster.height
require(n > 0) { "cannot measure an empty raster" }
var maxVariance = 0.0
for (ch in 0 until 3) {
var sum = 0.0
var sumSq = 0.0
for (i in 0 until n) {
val v = raster.rgba[i * 4 + ch].toDouble()
sum += v
sumSq += v * v
}
val mean = sum / n
maxVariance = max(maxVariance, max(0.0, sumSq / n - mean * mean))
}
val seen = HashSet<Int>()
val argb = raster.toArgb()
for (px in argb) {
if (seen.size >= DISTINCT_CAP) break
seen += px and 0xFFFFFF
}
var colour = 0.0
for (i in 0 until n) {
val r = raster.rgba[i * 4]
val g = raster.rgba[i * 4 + 1]
val b = raster.rgba[i * 4 + 2]
colour += (max(r, max(g, b)) - min(r, min(g, b))).toDouble()
}
var gradient = 0.0
var edges = 0
for (y in 0 until raster.height) for (x in 0 until raster.width) {
val here = luma(raster, x, y)
if (x + 1 < raster.width) { gradient += abs(luma(raster, x + 1, y) - here); edges++ }
if (y + 1 < raster.height) { gradient += abs(luma(raster, x, y + 1) - here); edges++ }
}
return RasterStructure(
spread = sqrt(maxVariance),
distinctColours = seen.size,
// x4 so an ordinary shader lands mid-scale rather than in the bottom eighth: a
// mean neighbour-to-neighbour luma step of 0.25 is already a very busy image.
spatialFrequency = if (edges == 0) 0.0 else (gradient / edges * 4.0).coerceIn(0.0, 1.0),
colourfulness = (colour / n).coerceIn(0.0, 1.0)
)
}
private fun luma(r: CpuRaster, x: Int, y: Int): Double {
val i = (y * r.width + x) * 4
return 0.2126 * r.rgba[i] + 0.7152 * r.rgba[i + 1] + 0.0722 * r.rgba[i + 2]
}
}
}
object StackSLLayout
Does a picture have its target's layout? The check this file uses on a search's results, kept apart from anything a search optimises.
object StackSLLayout {
fun layoutAgreement(candidate: CpuRaster, target: CpuRaster): Double
Would someone looking at candidate see target's layout? Cohen's kappa: 1 exact, 0 no
better than chance, negative for the target's negative.
A check on a search, not part of its fitness, and a different kind of measurement from LPIPS:
it classifies rather than compares features. When the target is drawn in at most PALETTE_LIMIT
colours (every built-in target is), each candidate pixel is labelled with the target colour
it is nearest in RGB, and kappa compares those labels with the target's own. So a white
disc on black counts as the yellow disc on navy it imitates, while faint texture in the
background colour counts as background, however well it happens to correlate with the disc.
A rank-based version of this measure was tried first and credited exactly that: the
2026-09-06 "dark streaks" answer for spot scored 0.65 because its streaks were very slightly
brighter in the middle.
For a target with more colours than that (a photograph), pixels are instead placed along the target's principal colour axis and cut into four equal-population bands, the target's and the candidate's at the same cut points.
body — 52 lines, from line 712
fun layoutAgreement(candidate: CpuRaster, target: CpuRaster): Double {
require(candidate.width == target.width && candidate.height == target.height) {
"cannot compare ${candidate.width}x${candidate.height} against ${target.width}x${target.height}"
}
val n = target.width * target.height
val keyOf = { r: CpuRaster, i: Int ->
(Math.round(r.rgba[i * 4] * 255f) shl 16) or (Math.round(r.rgba[i * 4 + 1] * 255f) shl 8) or
Math.round(r.rgba[i * 4 + 2] * 255f)
}
val palette = (0 until n).map { keyOf(target, it) }.distinct()
val targetLabel = IntArray(n)
val candidateLabel = IntArray(n)
val k: Int
if (palette.size <= PALETTE_LIMIT) {
k = palette.size
val index = palette.withIndex().associate { it.value to it.index }
val colours = palette.map { doubleArrayOf((it shr 16 and 255) / 255.0, (it shr 8 and 255) / 255.0, (it and 255) / 255.0) }
for (i in 0 until n) {
targetLabel[i] = index.getValue(keyOf(target, i))
var best = 0
var bestD = Double.MAX_VALUE
for ((l, c) in colours.withIndex()) {
val dr = candidate.rgba[i * 4] - c[0]
val dg = candidate.rgba[i * 4 + 1] - c[1]
val db = candidate.rgba[i * 4 + 2] - c[2]
val d = dr * dr + dg * dg + db * db
if (d < bestD) { bestD = d; best = l }
}
candidateLabel[i] = best
}
} else {
k = 4
val axis = principalAxis(target)
val t = project(target, axis)
val c = project(candidate, axis)
val cuts = t.sorted().let { sorted -> (1 until k).map { sorted[it * n / k] } }
for (i in 0 until n) {
targetLabel[i] = cuts.count { t[i] >= it }
candidateLabel[i] = cuts.count { c[i] >= it }
}
}
val targetCount = IntArray(k)
val candidateCount = IntArray(k)
var agree = 0
for (i in 0 until n) {
targetCount[targetLabel[i]]++
candidateCount[candidateLabel[i]]++
if (targetLabel[i] == candidateLabel[i]) agree++
}
val chance = (0 until k).sumOf { targetCount[it].toDouble() * candidateCount[it] } / (n.toDouble() * n)
return if (chance >= 1.0) 0.0 else (agree.toDouble() / n - chance) / (1.0 - chance)
}
const val PALETTE_LIMIT: Int = 8
Most colours a target may have for layoutAgreement to label by nearest colour.
fun principalAxis(raster: CpuRaster): DoubleArray
The unit RGB direction of greatest variance over raster, by power iteration.
fun principalAxis(raster: CpuRaster): DoubleArray {
val n = raster.width * raster.height
val mean = DoubleArray(3)
for (i in 0 until n) for (ch in 0 until 3) mean[ch] += raster.rgba[i * 4 + ch]
for (ch in 0 until 3) mean[ch] /= n
val cov = Array(3) { DoubleArray(3) }
for (i in 0 until n) for (a in 0 until 3) for (b in 0 until 3) {
cov[a][b] += (raster.rgba[i * 4 + a] - mean[a]) * (raster.rgba[i * 4 + b] - mean[b])
}
// Start from luma's direction, so a grey target's axis is (1,1,1)/sqrt(3) with its sign.
var v = doubleArrayOf(0.2126, 0.7152, 0.0722)
repeat(64) {
val w = DoubleArray(3) { a -> (0 until 3).sumOf { b -> cov[a][b] * v[b] } }
val norm = sqrt(w.sumOf { it * it })
if (norm < 1e-12) return doubleArrayOf(0.2126, 0.7152, 0.0722)
v = DoubleArray(3) { w[it] / norm }
}
// Orient it so that "higher" still means "brighter".
if (v[0] * 0.2126 + v[1] * 0.7152 + v[2] * 0.0722 < 0) v = DoubleArray(3) { -v[it] }
return v
}
private fun project(raster: CpuRaster, axis: DoubleArray): DoubleArray =
DoubleArray(raster.width * raster.height) { i ->
axis[0] * raster.rgba[i * 4] + axis[1] * raster.rgba[i * 4 + 1] + axis[2] * raster.rgba[i * 4 + 2]
}
}
class StackSLTarget(val name: String, val raster: CpuRaster)
A named target image a search is aimed at.
class StackSLTarget(val name: String, val raster: CpuRaster) {
val structure: RasterStructure = RasterStructure.of(raster)
The target's own structure — measured, so a blank target can be refused.
val flatColour: FloatArray
The target's mean RGB — the colour of the best possible flat answer.
This is what StackSLImageObjective.flatFloor is built from, and it is the anchor every
number in this file is reported against. A raw 1 - error is close to unreadable on its own:
almost every program that renders anything mid-toned scores between 0.66 and 0.78 against
these targets, so a margin of 0.03 could be either a rounding artefact or a large fraction of
the whole attainable range, and the reader cannot tell which. Against the flat floor it is
legible immediately.
val flatColour: FloatArray = run {
val n = raster.width * raster.height
val out = FloatArray(3)
for (i in 0 until n) for (ch in 0 until 3) out[ch] += raster.rgba[i * 4 + ch]
FloatArray(3) { out[it] / n }
}
fun flatRaster(): CpuRaster
A raster of nothing but flatColour — the trivial answer, at the same size.
fun flatRaster(): CpuRaster {
val n = raster.width * raster.height
val data = FloatArray(n * 4)
for (i in 0 until n) {
data[i * 4] = flatColour[0]
data[i * 4 + 1] = flatColour[1]
data[i * 4 + 2] = flatColour[2]
data[i * 4 + 3] = 1f
}
return CpuRaster(raster.width, raster.height, data)
}
override fun toString(): String = "$name (${raster.width}x${raster.height}, $structure)"
The target's name, the raster's size and its RasterStructure.
companion object
companion object {
fun of(name: String, img: Img, dim: Int = DEFAULT_FITNESS_DIM): StackSLTarget
A target from any picture: area-averaged down to dim square, grey expanded to RGB.
Row 0 of img is its top row, as a PNG's is. A non-square picture is squashed, not cropped; crop it first if the aspect matters.
fun of(name: String, img: Img, dim: Int = DEFAULT_FITNESS_DIM): StackSLTarget {
val small = img.resize(dim, dim).toRGB()
val data = FloatArray(dim * dim * 4)
for (p in 0 until dim * dim) {
for (c in 0 until 3) data[p * 4 + c] = small.data[p * 3 + c].coerceIn(0f, 1f)
data[p * 4 + 3] = 1f
}
return StackSLTarget(name, CpuRaster(dim, dim, data))
}
}
}
object StackSLTargets
Five synthetic targets, generated in plain Kotlin.
Generated rather than borrowed from the StackSL gallery on purpose. A gallery still is a picture some StackSL program already makes, so aiming a StackSL search at one is a program-recovery task, and a good score would be ambiguous between "the search works" and "the search rediscovered a short program that was in its own vocabulary". These are made of arithmetic this language has no single word for, so a match has to be built.
Why they are all high-contrast, and why they were not at first
The first version of this set had a smooth two-axis colour ramp in it, and evolve lost to
random on it at every budget tried. That is not a defect in the search; it is what an easy
problem looks like. Against a smooth mid-toned target, almost anything the generator produces is
within 0.25 mean absolute error, so the whole attainable range is a few hundredths wide and the
control's max-of-N sits at the top of it. AlifeEvolveGallery had already learned the same
lesson on a radius-1 rule table and wrote down the response: "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."
So every target here is saturated and piecewise or high-frequency, which makes the flat-fill answer bad (see StackSLTarget.flatColour) and opens up the range. And the set still spans structures: spot is radial, rings is radial and periodic, bands is one-dimensional, checker is two-dimensional and periodic, quadrants is aperiodic and piecewise. A search that only handled one kind of structure would show up as four of the five failing.
object StackSLTargets {
fun spot(dim: Int): StackSLTarget
A bright warm disc on near-black: radial, one hard edge.
fun spot(dim: Int): StackSLTarget = generate("spot", dim) { u, v ->
val r = sqrt((u - 0.5) * (u - 0.5) + (v - 0.5) * (v - 0.5))
if (r < 0.28) floatArrayOf(1.0f, 0.82f, 0.25f) else floatArrayOf(0.03f, 0.04f, 0.12f)
}
fun rings(dim: Int): StackSLTarget
Concentric black-and-white rings: radial and periodic.
fun rings(dim: Int): StackSLTarget = generate("rings", dim) { u, v ->
val r = sqrt((u - 0.5) * (u - 0.5) + (v - 0.5) * (v - 0.5))
val s = if (kotlin.math.sin(r * 40.0) > 0.0) 1.0f else 0.0f
floatArrayOf(s, s, s)
}
fun bands(dim: Int): StackSLTarget
Six hard vertical stripes, yellow against deep blue: high frequency in x, none in y.
fun bands(dim: Int): StackSLTarget = generate("bands", dim) { u, _ ->
if (kotlin.math.floor(u * 6.0).toInt() % 2 == 0) floatArrayOf(0.98f, 0.9f, 0.1f)
else floatArrayOf(0.05f, 0.1f, 0.55f)
}
fun checker(dim: Int): StackSLTarget
A 4x4 magenta-and-teal checkerboard: periodic in both axes.
fun checker(dim: Int): StackSLTarget = generate("checker", dim) { u, v ->
val cell = kotlin.math.floor(u * 4.0).toInt() + kotlin.math.floor(v * 4.0).toInt()
if (cell % 2 == 0) floatArrayOf(0.9f, 0.1f, 0.7f) else floatArrayOf(0.05f, 0.45f, 0.4f)
}
fun quadrants(dim: Int): StackSLTarget
Four flat quadrants: piecewise constant, hard edges, four saturated colours.
fun quadrants(dim: Int): StackSLTarget = generate("quadrants", dim) { u, v ->
when {
u < 0.5 && v < 0.5 -> floatArrayOf(0.9f, 0.1f, 0.1f)
u >= 0.5 && v < 0.5 -> floatArrayOf(0.1f, 0.5f, 0.95f)
u < 0.5 -> floatArrayOf(0.98f, 0.9f, 0.1f)
else -> floatArrayOf(0.05f, 0.06f, 0.15f)
}
}
fun byName(name: String, dim: Int): StackSLTarget?
The built-in target called name at dim, or null for a name that is not one of the five.
fun byName(name: String, dim: Int): StackSLTarget? = when (name) {
"spot" -> spot(dim)
"rings" -> rings(dim)
"bands" -> bands(dim)
"checker" -> checker(dim)
"quadrants" -> quadrants(dim)
else -> null
}
fun all(dim: Int): List<StackSLTarget>
All five at dim square, in the order the contact sheet shows them.
fun all(dim: Int): List<StackSLTarget> =
listOf(spot(dim), rings(dim), bands(dim), checker(dim), quadrants(dim))
fun generate(name: String, dim: Int, rgb: (u: Double, v: Double) -> FloatArray): StackSLTarget {
Build a target from a (u, v) -> rgb function.
v runs downwards, matching CpuRaster's row 0 = top. That is deliberately not
StackSL's own convention — StackSLCpu.FRAG_UV_ORIGIN_IS_TOP_LEFT is false, so fragUV.y
grows upwards — and the mismatch is fine: both sides of the comparison are rasters, indexed
the same way, and a program that needs 1 - y can find it.
fun generate(name: String, dim: Int, rgb: (u: Double, v: Double) -> FloatArray): StackSLTarget {
require(dim >= 2) { "a target must be at least 2x2, got $dim" }
val data = FloatArray(dim * dim * 4)
for (y in 0 until dim) for (x in 0 until dim) {
val c = rgb((x + 0.5) / dim, (y + 0.5) / dim)
val i = (y * dim + x) * 4
data[i] = c[0].coerceIn(0f, 1f)
data[i + 1] = c[1].coerceIn(0f, 1f)
data[i + 2] = c[2].coerceIn(0f, 1f)
data[i + 3] = 1f
}
return StackSLTarget(name, CpuRaster(dim, dim, data))
}
}
3 — the objective
enum class StackSLMetric
What StackSLImageObjective counts as "close to the target".
enum class StackSLMetric {
1 - mean absolute RGB error (optionally over a pyramid). Rewards getting each pixel's
colour right on average, which a search satisfies first by matching the target's overall
colour balance: against a disc that is mostly background, "mostly background colour, with
texture anywhere" scores well whether or not there is a disc.
PIXEL,
1 - the LPIPS perceptual distance (afield.ai.inference.onnx.Lpips): AlexNet features compared where
they are, weighted by a layer trained on human judgements of which images look alike. The
default.
LPIPS,
The mean of LPIPS and PIXEL. LPIPS compares features wherever they are and barely
notices a picture's negative; the pixel error pins colours and positions. Its lexicase cases
are both sets: the LPIPS quadrants (when StackSLImageObjective.lpipsRegions is on) and the
pixel-error grid.
LPIPS_PIXEL
}
class StackSLScores
One render's scores under both metrics, so one render can serve several objectives.
class StackSLScores(
/**
* `false` for a render the blank guard refused or a program that threw; every score is then
* [StackSLImageObjective.INVALID_SCORE].
*/
val valid: Boolean,
/** The whole-picture pixel-error score, higher better. */
val pixelFitness: Double,
/** One pixel-error case per cell of the objective's grid, for lexicase selection. */
val pixelCases: DoubleArray,
/** The whole-picture LPIPS score, higher better. */
val lpipsFitness: Double,
/**
* One LPIPS case per region, or empty when the objective was not asked for them
* ([StackSLImageObjective.lpipsRegions]).
*/
val lpipsCases: DoubleArray,
/** The behavioural descriptor, which MAP-Elites bins on and novelty search measures against. */
val behaviour: DoubleArray
) {
fun fitness(metric: StackSLMetric): Double
The scalar fitness under metric.
fun fitness(metric: StackSLMetric): Double = when (metric) {
StackSLMetric.PIXEL -> pixelFitness
StackSLMetric.LPIPS -> lpipsFitness
StackSLMetric.LPIPS_PIXEL -> 0.5 * (pixelFitness + lpipsFitness)
}
fun evaluation(metric: StackSLMetric): Evaluation
The search-facing Evaluation under metric.
fun evaluation(metric: StackSLMetric): Evaluation = when (metric) {
StackSLMetric.PIXEL -> Evaluation(pixelFitness, pixelCases, behaviour)
StackSLMetric.LPIPS -> Evaluation(lpipsFitness, lpipsCases, behaviour)
StackSLMetric.LPIPS_PIXEL -> Evaluation(fitness(metric), lpipsCases + pixelCases, behaviour)
}
companion object
companion object {
fun blank(caseGrid: Int, lpipsCases: Int, behaviour: DoubleArray): StackSLScores
A render the blank guard refused; its behaviour is still reported for MAP-Elites.
fun blank(caseGrid: Int, lpipsCases: Int, behaviour: DoubleArray): StackSLScores {
val floor = StackSLImageObjective.INVALID_SCORE
return StackSLScores(
false, floor, DoubleArray(caseGrid * caseGrid) { floor },
floor, DoubleArray(lpipsCases) { floor }, behaviour
)
}
}
}
class StackSLScoreCache
Program text to StackSLScores, for objectives that share one target.
Thread-safe, so targets can be searched concurrently; but one cache serves one target at one set of objective settings (resolution, supersampling, render time, pyramid depth, case grid, LPIPS size and regions), and the first objective to use it fixes them. A second objective that disagrees is refused rather than handed another target's scores.
class StackSLScoreCache {
private val scores = java.util.concurrent.ConcurrentHashMap<String, StackSLScores>()
private val hitCount = java.util.concurrent.atomic.AtomicInteger()
private var owner: String? = null
val hits: Int get() = hitCount.get()
Evaluations served from the cache rather than the interpreter.
val size: Int get() = scores.size
Distinct programs scored.
internal fun claim
@Synchronized
internal fun claim(
target: StackSLTarget, dim: Int, time: Float, pyramidLevels: Int, caseGrid: Int, supersample: Int,
lpipsSide: Int, lpipsRegions: Boolean, lpipsGrid: Int = 2
) {
val key = "${target.name}#${System.identityHashCode(target)} @$dim x$supersample t=$time pyramid=$pyramidLevels " +
"cases=$caseGrid lpips=$lpipsSide regions=${if (lpipsRegions) lpipsGrid else 0}"
val current = owner
require(current == null || current == key) { "this cache belongs to $current, not $key" }
owner = key
}
internal fun get(text: String): StackSLScores?
internal fun get(text: String): StackSLScores? = scores[text]?.also { hitCount.incrementAndGet() }
internal fun put(text: String, s: StackSLScores)
internal fun put(text: String, s: StackSLScores) {
scores[text] = s
}
}
class StackSLImageObjective
Score a StackSL program by rendering it and comparing it to target.
Fitness is in 0..1 for anything that renders a picture, higher is better, and 1.0 is a
pixel-exact match. What "close" means is metric; see StackSLMetric. cases are the same
measure over a caseGrid by caseGrid tiling of the image, which is what makes
Selection.LEXICASE mean something here: a program that nails one corner and misses everything
else is invisible to a mean and is selected by lexicase whenever that corner's case comes up
first.
skill rescales a fitness so that 0 is a flat fill of the target's mean colour and 1 is exact, which is the number to read: raw fitness for almost anything mid-toned sits in a band a few hundredths wide.
pyramidLevels > 1 averages the PIXEL error over a box-filtered pyramid. Measured on 2026-09-06
over four targets at budget 800, three levels against one gave a summed margin over chance of
+0.108 against +0.149: neutral to slightly worse, so the default is one level.
behaviour is [spatialFrequency, colourfulness] of the render, both in 0..1, for
PopulationSearch.mapElites.
The blank guard
A render whose RasterStructure.spread is below MIN_SPREAD, or which shows fewer than
MIN_DISTINCT_COLOURS distinct 8-bit colours, scores INVALID_SCORE — a large negative, not a
merely poor error — and every one of its cases scores the same. So:
- the search can never climb toward a blank, however well a blank happens to match;
- a target that is itself blank is refused in
init, because the guard is meaningless if both sides are flat; - and because the guard is in the fitness function rather than in the report, nothing downstream has to remember to apply it.
A program that throws — a sample word with no texture bound, a NaN that becomes an exception in
the interpreter — also lands on INVALID_SCORE rather than propagating, which is the same
treatment and for the same reason. (PopulationSearch's Session.consider would have caught the
throw anyway, at Double.NEGATIVE_INFINITY; catching it here keeps every rejection on one scale
and lets invalidRate count it.)
declaration — 49 lines, from line 1110
class StackSLImageObjective(
/** The picture to get close to. A target that is itself blank is refused in `init`. */
val target: StackSLTarget,
/** The square side each candidate is rendered at; the target's own width by default. */
val dim: Int = target.raster.width,
/** The `time` value every still is rendered at, so an animated program is judged on one frame. */
val time: Float = 1.75f,
/** The [StackSLMetric.PIXEL] lexicase cases: one per cell of this grid. */
val caseGrid: Int = 4,
/** Scales the [StackSLMetric.PIXEL] error is averaged over; 1 is a plain full-resolution mean. */
val pyramidLevels: Int = DEFAULT_PYRAMID_LEVELS,
/** `false` renders each candidate on one thread — see [evolveStackSLTowards] for when to. */
val parallel: Boolean = true,
/** What "close to the target" means. See [StackSLMetric]. */
val metric: StackSLMetric = StackSLMetric.LPIPS,
/**
* Scores already computed for this target, keyed by program text, shared between objectives.
*
* Every evaluation computes *both* metrics from its one render (the comparison costs a few
* milliseconds, the render tens) and stores them here. So a random control scored under both
* metrics, or two arms that happen to draw the same program, pay for the render once.
*/
val cache: StackSLScoreCache? = null,
/**
* Render each candidate at this multiple of [dim] and average down: 1 (no supersampling) or a
* power of two. Without it a program can score by aliasing: stripes finer than a fitness pixel,
* sampled at pixel centres, can land on a pattern that resembles the target at 32 pixels and
* nothing at 128. Costs `supersample^2` times the render.
*/
val supersample: Int = 1,
/**
* The side LPIPS sees. A render smaller than this is enlarged pixel by pixel (and the target
* with it) before comparison, because AlexNet's deeper stages are 1x1 at 32 pixels. Enlarging
* adds no detail, it gives the network room to look at the detail the render has.
*/
val lpipsSide: Int = DEFAULT_LPIPS_SIDE,
/**
* Also score LPIPS on each region of an [lpipsGrid] by [lpipsGrid] tiling of the [lpipsSide]
* image, as lexicase cases. That is `lpipsGrid^2` more (smaller) LPIPS evaluations per
* candidate, so off unless you select by lexicase.
*/
val lpipsRegions: Boolean = false,
/**
* Regions per side when [lpipsRegions] is on: 2 is the four quadrants. Each region must be at
* least [Lpips.MIN_SIDE] square, so 3 needs an [lpipsSide] of 96. More regions pin *where* and
* *how many* more tightly; `bands` evolving the right stripes out of phase is what they are for.
*/
val lpipsGrid: Int = 2
) : Objective<StackSLProgram> {
init
init {
require(dim >= 2) { "fitness resolution must be at least 2, got $dim" }
require(caseGrid >= 1) { "caseGrid must be >= 1, got $caseGrid" }
require(pyramidLevels >= 1) { "pyramidLevels must be >= 1, got $pyramidLevels" }
require(supersample >= 1 && supersample and (supersample - 1) == 0) {
"supersample must be a power of two, got $supersample"
}
require(lpipsSide >= Lpips.MIN_SIDE && lpipsSide % dim == 0) {
"lpipsSide must be at least ${Lpips.MIN_SIDE} and a multiple of dim $dim, got $lpipsSide"
}
require(lpipsGrid >= 2) { "lpipsGrid must be >= 2, got $lpipsGrid" }
require(!lpipsRegions || (lpipsSide >= lpipsGrid * Lpips.MIN_SIDE && lpipsSide % lpipsGrid == 0)) {
"lpipsRegions on a $lpipsGrid x $lpipsGrid grid needs lpipsSide a multiple of $lpipsGrid and at least " +
"${lpipsGrid * Lpips.MIN_SIDE}, got $lpipsSide"
}
require(target.raster.width == dim && target.raster.height == dim) {
"target '${target.name}' is ${target.raster.width}x${target.raster.height} but fitness runs at ${dim}x$dim"
}
require(target.structure.spread >= MIN_SPREAD) {
"target '${target.name}' has spread ${"%.5f".format(target.structure.spread)}, below the " +
"$MIN_SPREAD blank floor — matching a blank target is not a measurement of anything"
}
}
The target's pyramid and its LPIPS input, built once.
private val targetPyramid: List<CpuRaster> = pyramidOf(target.raster, pyramidLevels)
private val targetPlanar: FloatArray = lpipsPlanar(target.raster, lpipsSide)
private val lpipsCaseCount: Int = if (lpipsRegions) lpipsGrid * lpipsGrid else 0
private val targetQuadrants: FloatArray? = if (lpipsRegions) regions(targetPlanar, lpipsSide, lpipsGrid) else null
init
init {
cache?.claim(target, dim, time, pyramidLevels, caseGrid, supersample, lpipsSide, lpipsRegions, lpipsGrid)
}
val flatFloor: Double
What a flat fill of the target's own mean colour would score — the trivial answer's fitness.
The anchor skill is measured from. Note that it is not attainable by any candidate this objective will accept: a flat fill has zero spread and the blank guard rejects it. It is a reference point, not a competitor, and that is exactly what makes it a good one — it is the score a search gets for understanding nothing about where the target's structure is.
val flatFloor: Double =
scoresAgainst(target.flatRaster(), target.raster, targetPyramid, targetPlanar, targetQuadrants, allowFlat = true)
.fitness(metric)
fun skill(fitness: Double): Double
A fitness rescaled so 0 is the flat answer and 1 is exact.
(fitness - flatFloor) / (1 - flatFloor). Monotone in fitness, so it never changes a ranking
or which arm wins — it only makes the margin readable. Negative for a candidate that did worse
than a flat fill, which is a thing that happens and should look bad.
fun skill(fitness: Double): Double =
if (flatFloor >= 1.0) 0.0 else (fitness - flatFloor) / (1.0 - flatFloor)
var considered: Int
Candidates offered so far, including ones this objective rejected.
var considered: Int = 0
private set
var invalid: Int
Candidates rejected as blank or as a crash. The failureRate of MorphoSearchRun, here.
var invalid: Int = 0
private set
val invalidRate: Double get() = if (considered == 0) 0.0 else invalid.toDouble() / considered
Fraction of candidates that were not pictures. Reported, never hidden.
override fun evaluate(genome: StackSLProgram): Evaluation
Render genome once, score it under both metrics and return the one metric asks for. A
render that throws, or that the blank guard refuses, scores INVALID_SCORE on every case
rather than propagating, and is counted in invalid. A cache hit skips the render.
override fun evaluate(genome: StackSLProgram): Evaluation {
considered++
val s = cache?.get(genome.text) ?: run {
val raster = try {
renderForFitness(genome)
} catch (t: Throwable) {
null
}
val computed = if (raster == null) StackSLScores.blank(caseGrid, lpipsCaseCount, doubleArrayOf(0.0, 0.0))
else scores(raster)
cache?.put(genome.text, computed)
computed
}
if (!s.valid) invalid++
return s.evaluation(metric)
}
fun render(genome: StackSLProgram, dim: Int = this.dim): CpuRaster
Render genome at dim — the same call evaluate makes, for a caller that wants the pixels.
fun render(genome: StackSLProgram, dim: Int = this.dim): CpuRaster =
StackSLCpu.renderStill(genome.text, dim = dim, time = time, parallel = parallel)
fun renderForFitness(genome: StackSLProgram): CpuRaster
What evaluate scores: genome at dim, rendered at dim * supersample and box-filtered
down, so detail finer than a fitness pixel averages out instead of aliasing.
fun renderForFitness(genome: StackSLProgram): CpuRaster {
var r = StackSLCpu.renderStill(genome.text, dim = dim * supersample, time = time, parallel = parallel)
while (r.width > dim) r = halve(r)
return r
}
fun scoreAt(genome: StackSLProgram, other: StackSLTarget): Double
Fitness of a program against a target at that target's resolution, under the same metric.
The proxy question is asked with this: score the same programs against a large target and see whether the small target's ranking survives. LPIPS then sees the large image at its own size.
fun scoreAt(genome: StackSLProgram, other: StackSLTarget): Double {
val raster = render(genome, other.raster.width)
val side = max(Lpips.MIN_SIDE, other.raster.width)
val want = lpipsPlanar(other.raster, side)
return scoresAgainst(raster, other.raster, pyramidOf(other.raster, pyramidLevels), want,
if (lpipsRegions && side % lpipsGrid == 0 && side >= lpipsGrid * Lpips.MIN_SIDE) regions(want, side, lpipsGrid) else null
).fitness(metric)
}
fun scores(raster: CpuRaster): StackSLScores
Both metrics' scores for a raster already rendered at dim.
body — 46 lines, from line 1279
fun scores(raster: CpuRaster): StackSLScores =
scoresAgainst(raster, target.raster, targetPyramid, targetPlanar, targetQuadrants)
private fun scoresAgainst(
raster: CpuRaster,
want: CpuRaster,
wantPyramid: List<CpuRaster>,
wantPlanar: FloatArray,
wantQuadrants: FloatArray?,
allowFlat: Boolean = false
): StackSLScores {
val structure = RasterStructure.of(raster)
val behaviour = doubleArrayOf(structure.spatialFrequency, structure.colourfulness)
val flat = structure.spread < MIN_SPREAD || structure.distinctColours < MIN_DISTINCT_COLOURS
// allowFlat is for the flat-fill reference alone: the one flat raster that must get a
// number rather than the blank guard's floor, because [skill] is measured from it.
if (flat && !allowFlat) return StackSLScores.blank(caseGrid, lpipsCaseCount, behaviour)
val size = raster.width
val pixelFitness = 1.0 - pyramidError(raster, wantPyramid)
val pixelCases = DoubleArray(caseGrid * caseGrid)
for (ry in 0 until caseGrid) for (rx in 0 until caseGrid) {
pixelCases[ry * caseGrid + rx] = 1.0 - meanAbsoluteError(
raster, want, rx * size / caseGrid, ry * size / caseGrid, (rx + 1) * size / caseGrid, (ry + 1) * size / caseGrid
)
}
val side = wantPlanar.size / 3
val lpipsSideHere = kotlin.math.sqrt(side.toDouble()).toInt()
val planar = lpipsPlanar(raster, lpipsSideHere)
val lpipsFitness = 1.0 - Lpips.distancesPlanar(planar, 1, lpipsSideHere, lpipsSideHere, wantPlanar)[0]
val lpipsCases = if (wantQuadrants == null) DoubleArray(0) else {
val grid = lpipsGrid
val cell = lpipsSideHere / grid
val mine = regions(planar, lpipsSideHere, grid)
val plane = 3 * cell * cell
DoubleArray(grid * grid) { q ->
val d = Lpips.distancesPlanar(
mine.copyOfRange(q * plane, (q + 1) * plane), 1, cell, cell,
wantQuadrants.copyOfRange(q * plane, (q + 1) * plane)
)[0]
1.0 - d
}
}
return StackSLScores(true, pixelFitness, pixelCases, lpipsFitness, lpipsCases, behaviour)
}
companion object
companion object {
const val INVALID_SCORE: Double = -1.0
What a program that did not render a picture scores.
Below every attainable fitness (which lives in 0..1) by a wide margin, and finite, so
SearchRun.trace stays plottable and a run whose first candidates were all blank does not
start at negative infinity.
const val MIN_SPREAD: Double = 0.02
Smallest per-channel standard deviation a render may have and still count as a picture.
0.02 in 0..1 units is about five 8-bit levels. Chosen from the other side: the 29
non-feedback stills in the StackSL gallery have spreads from 0.08 upward, so this floor is
a factor of four below the least structured thing the language is known to make on purpose.
const val MIN_DISTINCT_COLOURS: Int = 2
Fewer distinct 8-bit colours than this is a flat fill.
Two, not more. This was 4 until 2026-10-07, on the theory that two colours is "a dither,
not an image" — which refused every hard-edged two-colour picture, including four of the
five targets themselves and the shortest programs that draw them (p length 0.28
threshold renders exactly two colours). The search could approach a target only through
programs that were not its exact answer. MIN_SPREAD is what tells a picture from a
flat fill; this only stops a single colour slipping under it.
const val DEFAULT_PYRAMID_LEVELS: Int = 1
Scales the default error averages over — one, i.e. a plain full-resolution mean.
The simplest metric that could work, and the one that measured best. See the class KDoc for the pyramid that was built as an alternative and did not earn the default.
const val PYRAMID_LEVELS_MEASURED: Int = 3
The pyramid depth PYRAMID_LEVELS_MEASURED refers to: full, half, quarter.
Three rather than "as many as fit". A fourth level at DEFAULT_FITNESS_DIM would be 4x4 pixels, at which point the level is measuring the image's average colour and would put a quarter of the metric's weight on a single number.
const val DEFAULT_LPIPS_SIDE: Int = 64
The side LPIPS sees by default: 64, twice DEFAULT_FITNESS_DIM. At 32 AlexNet's three deepest stages are 1x1 and the distance scale is compressed (a disc against a ramp is 0.42 at 32 pixels and 0.68 at 64); at 64 they are 3x3.
fun lpipsPlanar(raster: CpuRaster, side: Int): FloatArray
raster as LPIPS input: planar RGB, 3 x side x side, enlarged pixel by pixel when
side is a multiple of its size. A raster already at side is copied as is.
fun lpipsPlanar(raster: CpuRaster, side: Int): FloatArray {
require(side % raster.width == 0 && raster.width == raster.height) {
"cannot enlarge a ${raster.width}x${raster.height} raster to $side"
}
val k = side / raster.width
val plane = side * side
val out = FloatArray(3 * plane)
for (y in 0 until side) for (x in 0 until side) {
val src = ((y / k) * raster.width + x / k) * 4
val dst = y * side + x
out[dst] = raster.rgba[src].coerceIn(0f, 1f)
out[plane + dst] = raster.rgba[src + 1].coerceIn(0f, 1f)
out[2 * plane + dst] = raster.rgba[src + 2].coerceIn(0f, 1f)
}
return out
}
fun quadrants(planar: FloatArray, side: Int): FloatArray = regions(planar, side, 2)
The four quadrants of a planar 3 x side x side image, each planar, top-left first.
fun regions(planar: FloatArray, side: Int, grid: Int): FloatArray
The cells of a grid by grid tiling of a planar 3 x side x side image, each planar,
row by row from the top left.
fun regions(planar: FloatArray, side: Int, grid: Int): FloatArray {
require(side % grid == 0) { "cannot cut a $side-pixel image into $grid equal regions" }
val cell = side / grid
val plane = side * side
val cPlane = cell * cell
val out = FloatArray(grid * grid * 3 * cPlane)
for (q in 0 until grid * grid) {
val ox = (q % grid) * cell
val oy = (q / grid) * cell
for (c in 0 until 3) for (y in 0 until cell) for (x in 0 until cell) {
out[q * 3 * cPlane + c * cPlane + y * cell + x] = planar[c * plane + (oy + y) * side + ox + x]
}
}
return out
}
fun pyramidOf(raster: CpuRaster, levels: Int): List<CpuRaster>
levels box-filtered halvings of raster, level 0 being raster itself.
Stops early rather than going below 2x2, so a small target silently gets fewer levels instead of a degenerate one; pyramidError weights by the levels it is given.
fun pyramidOf(raster: CpuRaster, levels: Int): List<CpuRaster> {
require(levels >= 1) { "a pyramid needs at least one level, got $levels" }
val out = ArrayList<CpuRaster>(levels)
var current = raster
out += current
while (out.size < levels && current.width >= 4 && current.height >= 4) {
current = halve(current)
out += current
}
return out
}
fun pyramidError(raster: CpuRaster, targetPyramid: List<CpuRaster>): Double
Mean over levels of the absolute RGB error, each level against its own target level.
fun pyramidError(raster: CpuRaster, targetPyramid: List<CpuRaster>): Double {
var sum = 0.0
var current = raster
for ((level, want) in targetPyramid.withIndex()) {
if (level > 0) current = halve(current)
sum += meanAbsoluteError(current, want, 0, 0, want.width, want.height)
}
return sum / targetPyramid.size
}
fun halve(raster: CpuRaster): CpuRaster
A 2x2 box downsample. Odd sizes drop the last row/column; every raster here is a power of two.
fun halve(raster: CpuRaster): CpuRaster {
val w = raster.width / 2
val h = raster.height / 2
require(w >= 1 && h >= 1) { "cannot halve a ${raster.width}x${raster.height} raster" }
val out = FloatArray(w * h * 4)
for (y in 0 until h) for (x in 0 until w) {
for (ch in 0 until 4) {
val a = raster.rgba[((2 * y) * raster.width + 2 * x) * 4 + ch]
val b = raster.rgba[((2 * y) * raster.width + 2 * x + 1) * 4 + ch]
val c = raster.rgba[((2 * y + 1) * raster.width + 2 * x) * 4 + ch]
val d = raster.rgba[((2 * y + 1) * raster.width + 2 * x + 1) * 4 + ch]
out[(y * w + x) * 4 + ch] = (a + b + c + d) * 0.25f
}
}
return CpuRaster(w, h, out)
}
fun meanAbsoluteError(a: CpuRaster, b: CpuRaster, x0: Int, y0: Int, x1: Int, y1: Int): Double
Mean absolute RGB difference over a rectangle of two same-sized rasters, in 0..1.
fun meanAbsoluteError(a: CpuRaster, b: CpuRaster, x0: Int, y0: Int, x1: Int, y1: Int): Double {
require(a.width == b.width && a.height == b.height) {
"cannot compare ${a.width}x${a.height} against ${b.width}x${b.height}"
}
var sum = 0.0
var n = 0
for (y in y0 until y1) for (x in x0 until x1) {
val i = (y * a.width + x) * 4
sum += abs(a.rgba[i] - b.rgba[i]).toDouble()
sum += abs(a.rgba[i + 1] - b.rgba[i + 1]).toDouble()
sum += abs(a.rgba[i + 2] - b.rgba[i + 2]).toDouble()
n += 3
}
return if (n == 0) 0.0 else sum / n
}
}
}
4 — the box-facing entry point
const val DEFAULT_FITNESS_DIM: Int = 32
Fitness resolution that costs about 30 ms a candidate on ten cores. See the file KDoc.
class StackSLEvolveRun
One search, its control arm, and everything needed to say whether the result is real.
control is not optional and not a courtesy. It is PopulationSearch.random over the same
StackSLGenome at the same budget with the same seed, which is the only thing that turns
"evolution found this" from a sentence into a claim: a generator good enough to stumble onto a
decent shader occasionally looks exactly like a successful search.
class StackSLEvolveRun(
/** The picture both arms were aimed at. */
val target: StackSLTarget,
/** The search arm. */
val evolved: SearchRun<StackSLProgram>,
/** The control arm: the same genome and budget, drawn at random rather than selected. */
val control: SearchRun<StackSLProgram>,
/** The evolved arm's [StackSLImageObjective.invalidRate]: candidates that were not pictures. */
val invalidRate: Double,
/**
* Values the best program pushed and never consumed, off the compiler's own `StackSLAnalysis`.
* The arity model's error bar, in the only units it has.
*/
val danglingValues: Int,
/** The objective the evolved arm ran against — kept so [skill] and re-rendering are available. */
val objective: StackSLImageObjective
) {
val best: StackSLProgram? get() = evolved.best?.genome
The evolved program, or null if every candidate was rejected.
val margin: Double get() = evolved.bestFitness - control.bestFitness
Evolved fitness minus control fitness at equal budget. The number a claim is made of.
val evolvedSkill: Double get() = objective.skill(evolved.bestFitness)
Evolved fitness in StackSLImageObjective.skill units: 0 is a flat fill, 1 is exact.
val controlSkill: Double get() = objective.skill(control.bestFitness)
The control's fitness in the same units.
val skillRatio: Double get()
How many times further above the trivial flat answer evolve got than random did.
The headline number, and the one that is legible: a raw margin of 0.03 on a scale whose whole
useful range is 0.1 wide reads as nothing and is in fact a third of everything there was to
win. NaN when the control did not beat a flat fill at all, which would make the ratio
meaningless rather than infinite.
val skillRatio: Double get() = if (controlSkill <= 0.0) Double.NaN else evolvedSkill / controlSkill
val evolvedLayout: Double by lazy { layoutOf(evolved) }
StackSLLayout.layoutAgreement of the evolved program against the target, at the search's own resolution: does it put light and dark where the target does? Not what either arm optimised, which is why it is worth reporting beside their fitness.
val controlLayout: Double by lazy { layoutOf(control) }
The control's evolvedLayout.
private fun layoutOf(run: SearchRun<StackSLProgram>): Double {
val g = run.best?.genome ?: return 0.0
if (run.bestFitness <= StackSLImageObjective.INVALID_SCORE) return 0.0
return StackSLLayout.layoutAgreement(objective.render(g), objective.target.raster)
}
fun contactSheet(side: Int = 160, also: List<Pair<String, StackSLProgram>> = emptyList()): Img
Target, evolved best and the control's best side by side at side pixels, for showImg.
The target is enlarged pixel by pixel; both programs are re-rendered at side, so
what you see is the program, not an enlarged thumbnail of what the search scored.
also adds more programs after them, each labelled with its name alone (a long name and a
score do not fit under a panel): a program from another search, or one written by hand, for
comparison. skillOf gives its score under this run's objective.
fun contactSheet(side: Int = 160, also: List<Pair<String, StackSLProgram>> = emptyList()): Img {
val panels = ArrayList<Pair<String, Img>>()
panels += "target" to target.raster.toImg().upscaleNearest(max(1, side / target.raster.width))
evolved.best?.genome?.let { panels += "evolved %.2f".format(evolvedSkill) to objective.render(it, side).toImg() }
control.best?.genome?.let { panels += "random %.2f".format(controlSkill) to objective.render(it, side).toImg() }
for ((name, program) in also) panels += name to objective.render(program, side).toImg()
return montage(panels, columns = panels.size, cellSide = side + 10)
}
fun skillOf(program: StackSLProgram): Double
program's skill under this run's own objective, the units evolvedSkill is in.
fun skillOf(program: StackSLProgram): Double = objective.skill(objective.evaluate(program).fitness)
fun summary(): String
One aligned line comparing the two arms: both fitnesses and skills, the margin between them, the layout check, the invalid rate, the dangling values and the time.
fun summary(): String = buildString {
append("%-10s evolved %.4f (skill %+.3f) random %.4f (skill %+.3f)".format(
target.name, evolved.bestFitness, evolvedSkill, control.bestFitness, controlSkill
))
append(" margin %+.4f".format(margin))
if (!skillRatio.isNaN()) append(" ratio %.2fx".format(skillRatio))
append(" layout %.2f vs %.2f".format(evolvedLayout, controlLayout))
append(" invalid %.1f%%".format(invalidRate * 100))
append(" dangling %d".format(danglingValues))
append(" %.1fs".format((evolved.millis + control.millis) / 1000.0))
}
override fun toString(): String = summary()
The run's summary line.
fun evolveStackSLTowards
Evolve a StackSL program toward target, with the random control arm run beside it.
The one call a box needs:
val run = evolveStackSLTowards(StackSLTargets.spot(32), budget = 600)
print(run.summary())
showImg(run.contactSheet())
parallel = true (the default) splits each render's rows across cores, which composes with
PopulationSearch's sequential loop and is worth about 2.5x at 32 rows on ten cores. Turn it off
when you run several searches on their own threads.
@param metric what counts as close; StackSLMetric.LPIPS unless you are reproducing the
pixel-error version
@param cache shared by both arms, so a program they both draw is rendered once; pass null to
render every evaluation
@param supersample see StackSLImageObjective.supersample
@param lpipsSide see StackSLImageObjective.lpipsSide
@param budget evaluations per arm; the evolved and control arms each get this many, so the
comparison is at equal budget by construction rather than by the caller remembering to
@param crossoverRate probability a child is recombined before mutation. Non-zero by default
because StackSLGenome declares a real GenomeOps.crossover and SearchRun.recombined should
have something in it
body — 52 lines, from line 1630
fun evolveStackSLTowards(
target: StackSLTarget,
budget: Int = 600,
seed: Long = 1L,
genome: StackSLGenome = StackSLGenome(),
dim: Int = target.raster.width,
mu: Int = 6,
lambda: Int = 12,
selection: Selection = Selection.TOURNAMENT,
tournament: Int = 3,
crossoverRate: Double = 0.25,
immigrantRate: Double = 0.05,
pyramidLevels: Int = StackSLImageObjective.DEFAULT_PYRAMID_LEVELS,
parallel: Boolean = true,
metric: StackSLMetric = StackSLMetric.LPIPS,
cache: StackSLScoreCache? = StackSLScoreCache(),
supersample: Int = 1,
lpipsSide: Int = StackSLImageObjective.DEFAULT_LPIPS_SIDE,
label: String = "stacksl ${target.name}"
): StackSLEvolveRun {
require(budget >= 1) { "budget must be >= 1, got $budget" }
// Lexicase under LPIPS selects on the four quadrant distances; nothing else needs them.
val regions = selection == Selection.LEXICASE && metric != StackSLMetric.PIXEL
fun objective() = StackSLImageObjective(
target, dim = dim, pyramidLevels = pyramidLevels, parallel = parallel, metric = metric, cache = cache,
supersample = supersample, lpipsSide = lpipsSide, lpipsRegions = regions
)
val evolvedObjective = objective()
val controlObjective = objective()
val evolved = PopulationSearch.evolve(
ops = genome,
objective = evolvedObjective,
budget = budget,
seed = seed,
mu = mu,
lambda = lambda,
tournament = tournament,
selection = selection,
crossoverRate = crossoverRate,
immigrantRate = immigrantRate,
label = label
)
val control = PopulationSearch.random(genome, controlObjective, budget, seed = seed, label = "$label random")
return StackSLEvolveRun(
target = target,
evolved = evolved,
control = control,
invalidRate = evolvedObjective.invalidRate,
danglingValues = evolved.best?.genome?.let { danglingValuesOf(it) } ?: 0,
objective = evolvedObjective
)
}
fun danglingValuesOf(program: StackSLProgram): Int
How many values program pushes and never consumes, read off the compiler's own analysis.
The one place StackSLAnalysis is consulted for its own sake, and the honest error bar on
StackSLWord.consumes: a dangling value is a token the model thought would be used and that was
not. Returns -1 if the program does not compile at all, which for a straight-line program
without definitions should never happen — see the file KDoc.
fun danglingValuesOf(program: StackSLProgram): Int {
val result = StackSLCompiler.compile(program.text, StackSLCpu.stackAutoOptions("program"))
if (result.diagnostics.any { it.severity == StackSLSeverity.ERROR }) return -1
return result.analysis.danglingWires.size
}
fun CpuRaster.toImg(): Img
A CpuRaster as an RGB Img, for montage and savePng. Alpha is dropped, not premultiplied.
fun CpuRaster.toImg(): Img {
val out = FloatArray(width * height * 3)
for (p in 0 until width * height) {
out[p * 3] = rgba[p * 4].coerceIn(0f, 1f)
out[p * 3 + 1] = rgba[p * 4 + 1].coerceIn(0f, 1f)
out[p * 3 + 2] = rgba[p * 4 + 2].coerceIn(0f, 1f)
}
return Img(width, height, 3, out)
}