StackSLEvolution.kt
src/jvmMain/kotlin/afield/lang/stacksl/evolution/StackSLEvolution.kt · 1105 lines
package and imports — 84 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.graph.minusAssign
import afield.document.graph.plusAssign
import afield.document.properties.compareTo
import afield.document.properties.saved
import afield.document.workspace.files
import afield.geometry.mesh.gradient
import afield.graphics.render.rangeTo
import afield.graphics.text.layout
import afield.math.autodiff.compareTo
import afield.math.autodiff.div
import afield.math.autodiff.mean
import afield.math.autodiff.minus
import afield.math.autodiff.times
import afield.math.functions.evolve
import afield.math.linalg.d
import afield.math.linalg.invoke
import afield.math.linalg.remAssign
import afield.math.linalg.set
import afield.math.linalg.times
import afield.math.linalg.x
import afield.math.units.div
import afield.math.units.minus
import afield.math.units.times
import afield.media.image.showImg
import afield.media.image.threshold
import afield.media.video.image
import afield.runtime.execution.end
import afield.runtime.lifecycle.cached
import afield.runtime.lifecycle.once
import afield.simulation.agents.step
import afield.simulation.automata.step
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.document.context.why
import afield.document.properties.clear
import afield.document.workspace.file
import afield.geometry.mesh.summary
import afield.graphics.render.label
import afield.graphics.text.resample
import afield.graphics.text.toImg
import afield.graphics.vulkan.buffers.add
import afield.math.autodiff.plus
import afield.math.autodiff.round
import afield.math.linalg.f
import afield.math.linalg.i
import afield.math.statistics.random
import afield.math.units.plus
import afield.media.image.Img
import afield.media.image.montage
import afield.media.image.resize
import afield.media.image.toImg
import afield.media.image.toPngBytes
import afield.media.image.upscaleNearest
import afield.runtime.execution.start
import afield.simulation.agents.toImg
import afield.simulation.automata.history
import afield.simulation.automata.toImg
import afield.study.search.Individual
import afield.study.search.PopulationSearch
import afield.study.search.Selection
import afield.lang.stacksl.cpu.AdAnnealing
import afield.lang.stacksl.cpu.StackSLCpu
import afield.lang.stacksl.cpu.StackSLFit
import afield.lang.stacksl.cpu.StackSLFitLoss
import afield.lang.stacksl.cpu.StackSLWires
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.fasterxml.jackson.module.kotlin.readValue
import java.io.File
import java.util.Base64
import java.util.concurrent.Executors
import java.util.concurrent.Future
import kotlin.math.max
import kotlin.math.min
import kotlin.random.Random
The resumable StackSL search: StackSLEvolution holds the island populations, the stage schedule and the per-origin credit table between generations, and saves them as a StackSLEvolutionSnapshot.
The settings (StackSLEvolveSettings, StackSLStage), the history records and the StackSLPopulationArchive that files snapshots on disk live here too.
class StackSLEvolution
A StackSL search you can stop, look at, steer and resume: evolveStackSLTowards taken apart.
evolveStackSLTowards is one call that runs to its budget and returns. This is the same search held as an object, so that something outside the loop can take part in it between generations:
- wider: several islands evolving apart, with the best few migrating round a ring every
StackSLEvolveSettings.migrateEverygenerations, and a larger µ and λ; - deeper: programs up to
StackSLGenome.maxTokenslong (48 here, against 24), and proposals may carry: name … ;definitions, checked for balance before they cost a render; - a schedule (StackSLStage): the resolution, the metric and the LPIPS region grid can change as the search goes, coarse and cheap first, native 64 px LPIPS later. The population is re-scored when a stage begins, since a score from another stage is not comparable;
- a gradient step (fit): a round of Adam on a program's own numeric literals, through
StackSLFit's exact reverse-mode gradient, re-scored under the search's metric; - a watchmaker (watchmaker): a language model shown the target and the leaders' renders, asked for edits. See StackSLWatchmaker;
- proposals from anyone (inject): a person, or the AI4 chat through
stackSLEvolveStudio; - crossbreeding (crossbreed): survivors of another saved population, and recombinants of them with this one's leaders.
Every candidate carries its origin, and credit counts, per origin, how many were offered, how many survived into the population and how many took the lead. That table is what says whether the watchmaker or the gradient step is doing anything, at the same render budget the blind operators get.
The state is a snapshot: settings, every island's programs, the history of the best score and the event notes, as JSON. restore puts it back. The AI4 studio saves one before and after every chat turn, which is how rewinding the conversation rewinds the population.
As in evolveStackSLTowards, the held-out check StackSLLayout.layoutAgreement is never shown to the search, to the watchmaker or to the chat: layoutOf is for reports.
class StackSLEvolution(
/** The target at a given fitness resolution. Asked once per resolution and kept. */
private val targetAt: (dim: Int) -> StackSLTarget,
settings: StackSLEvolveSettings = StackSLEvolveSettings(),
/** The one seed every draw, selection and mutation is threaded from; a snapshot carries it. */
val seed: Long = 1L,
/** Split each render's rows across cores. */
val parallel: Boolean = true,
/** The render time every candidate is drawn at; `StackSLCpu.stillTemplate`'s default. */
val time: Float = 1.75f
) {
constructor(target: StackSLTarget, settings: StackSLEvolveSettings = StackSLEvolveSettings(), seed: Long = 1L, parallel: Boolean = true) :
A search toward one fixed target; other resolutions are area-averaged from it.
constructor(target: StackSLTarget, settings: StackSLEvolveSettings = StackSLEvolveSettings(), seed: Long = 1L, parallel: Boolean = true) :
this(resample(target), settings, seed, parallel)
var settings: StackSLEvolveSettings
The current settings. Change them with configure, which validates them first.
var settings: StackSLEvolveSettings = settings.also { it.validate() }
private set
val targetName: String get() = targetAt(stage.dim).name
The target's name, the same at every resolution.
var generation: Int
Generations run so far, over every island.
var generation: Int = 0
private set
var renders: Int
Programs rendered for fitness so far: the budget. Cache hits, thumbnails and fits are not in it.
var renders: Int = 0
private set
val millis: MutableMap<String, Double> = linkedMapOf()
Wall time spent in step, fit and the watchmaker, by what spent it.
var watchmaker: StackSLProposer? = null
The language model, when one takes part. null is the blind search.
val islands: List<List<Individual<StackSLProgram>>> get() = pools.map { it.toList() }
Each island's population, best first.
val ranked: List<Individual<StackSLProgram>>
Every island's members, best first, programs distinct.
val ranked: List<Individual<StackSLProgram>>
get() = pools.flatten().sortedByDescending { it.fitness }.distinctBy { it.genome.text }
val started: Boolean get() = pools.any { it.isNotEmpty() }
Whether there is a population yet. Looking at a search that has not started does not start it.
val best: Individual<StackSLProgram>? get() = ranked.firstOrNull()
The best program in any island.
val history: MutableList<StackSLGenerationRecord> = ArrayList()
One line per generation: the leader's score and where it came from.
val improvements: MutableList<StackSLImprovement> = ArrayList()
Every time the overall leader improved, with the program, so the lineage can be drawn.
val notes: MutableList<String> = ArrayList()
What happened besides ordinary generations: stage changes, watchmaker rounds, fits, crossbreeds.
val credit: MutableMap<String, StackSLOriginCredit> = sortedMapOf()
Per origin: offered, survived into a population, took the overall lead.
var usage: StackSLModelUsage
What the watchmaker has cost so far.
var usage: StackSLModelUsage = StackSLModelUsage()
private set
The last watchmaker round's outcome per proposal, shown to it next time.
private var lastRound: List<StackSLProposalOutcome> = emptyList()
private var pools: MutableList<MutableList<Individual<StackSLProgram>>> = ArrayList()
private var dealt = 0
private val targets = HashMap<Int, StackSLTarget>()
private val objectives = HashMap<String, StackSLImageObjective>()
private var stageKey: String? = null
private var sinceImprovement = 0
private var lastAsked = Int.MIN_VALUE / 2
private var pending: Future<StackSLProposalReply>? = null
private var pendingFor: List<Individual<StackSLProgram>> = emptyList()
private val asker = Executors.newSingleThreadExecutor { r -> Thread(r, "stacksl-watchmaker").apply { isDaemon = true } }
val genome: StackSLGenome get()
The genome the blind operators use: the default vocabulary at StackSLEvolveSettings.maxTokens.
val genome: StackSLGenome get() = StackSLGenome(maxTokens = settings.maxTokens, maxRandomTokens = min(32, settings.maxTokens))
val stage: StackSLStage get() = settings.stageAt(generation)
The stage the next generation runs under.
val objective: StackSLImageObjective get() = objectiveFor(stage)
The objective of the current StackSLEvolution.stage.
val target: StackSLTarget get() = targetOf(stage.dim)
The current stage's target.
fun skill(fitness: Double): Double = objective.skill(fitness)
fitness rescaled so 0 is a flat fill of the target's mean colour and 1 is exact.
running
fun step(generations: Int = 1, maxRenders: Int = Int.MAX_VALUE): StackSLEvolution
Run generations generations, or fewer if maxRenders is reached first.
fun step(generations: Int = 1, maxRenders: Int = Int.MAX_VALUE): StackSLEvolution {
require(generations >= 0) { "generations must be >= 0, got $generations" }
timed("evolve") { ensureStartedQuietly() }
repeat(generations) {
if (renders >= maxRenders) return this
generationOnce(maxRenders)
}
return this
}
fun runTo(budget: Int): StackSLEvolution
Run until budget programs have been rendered for fitness.
body — 66 lines, from line 238
fun runTo(budget: Int): StackSLEvolution {
timed("evolve") { ensureStartedQuietly() }
var stuck = 0
while (renders < budget && stuck < 50) {
val before = renders
generationOnce(budget)
stuck = if (renders == before) stuck + 1 else 0
}
return this
}
private fun ensureStarted() {
enterStage()
if (pools.size != settings.islands) {
while (pools.size < settings.islands) pools.add(ArrayList())
while (pools.size > settings.islands) pools.last().let { last -> pools.removeAt(pools.size - 1); pools[0] += last }
}
for ((i, pool) in pools.withIndex()) {
val rng = rngFor(-1, i)
var tries = 0
while (pool.size < settings.mu && tries++ < settings.mu * PopulationSearch.PROPOSAL_LIMIT) {
consider(genome.random(rng), "random")?.let { pool += it }
}
trim(pool)
}
if (leaderFitness == Double.NEGATIVE_INFINITY) noteLeader("random")
}
private fun generationOnce(maxRenders: Int) {
timed("evolve") {
enterStage()
val s = settings
val genome = genome
val tournamentSelection = if (s.selection == Selection.LEXICASE && !casesAvailable()) Selection.TOURNAMENT else s.selection
for ((i, pool) in pools.withIndex()) {
if (pool.isEmpty()) continue
val rng = rngFor(generation, i)
val children = ArrayList<Individual<StackSLProgram>>(s.lambda)
val present = pool.mapTo(HashSet()) { it.genome.text }
var offered = 0
while (offered < s.lambda * PopulationSearch.PROPOSAL_LIMIT && children.size < s.lambda && renders < maxRenders) {
offered++
val child: Pair<StackSLProgram, String> = if (s.immigrantRate > 0.0 && rng.nextDouble() < s.immigrantRate) {
genome.random(rng) to "immigrant"
} else {
val parent = PopulationSearch.select(pool, s.tournament, tournamentSelection, rng)
val recombine = s.crossoverRate > 0.0 && pool.size > 1 && rng.nextDouble() < s.crossoverRate
val base = if (recombine) genome.crossover(parent.genome, PopulationSearch.select(pool, s.tournament, tournamentSelection, rng).genome, rng) else parent.genome
val v = genome.mutations[rng.nextInt(genome.mutations.size)]
(runCatching { v.apply(base, rng) }.getOrDefault(base)) to ((if (recombine) "crossover+" else "") + v.name)
}
if (!present.add(child.first.text)) continue
consider(child.first, child.second)?.let { children += it }
}
pool += children
trim(pool)
}
generation++
if (s.islands > 1 && s.migrateEvery > 0 && generation % s.migrateEvery == 0) migrate()
}
if (settings.fitEvery > 0 && generation % settings.fitEvery == 0 && renders < maxRenders) {
best?.let { leader -> fit(leader.genome, iterations = settings.fitIterations, origin = "fit") }
}
watchmakerTurn()
noteLeader(null)
}
Whether the current stage's objective gives lexicase anything to select on.
private fun casesAvailable(): Boolean = stage.metric != StackSLMetric.LPIPS || stage.lpipsGrid >= 2
private fun migrate() {
val movers = pools.map { pool -> pool.take(settings.migrants) }
for (i in pools.indices) {
val into = pools[(i + 1) % pools.size]
val have = into.map { it.genome.text }.toSet()
movers[i].filter { it.genome.text !in have }.forEach { into += Individual(it.genome, it.evaluation, "migrant") }
trim(into)
}
}
candidates from outside the blind loop
fun inject(programs: List<String>, origin: String = "proposed", island: Int = 0): List<StackSLProposalOutcome>
Offer programs to the population, as origin. Each is checked (StackSLProposals.check),
rendered, scored and put into island (0 by default; -1 deals them round the islands),
where truncation decides whether it stays. Costs one render each, like any candidate.
fun inject(programs: List<String>, origin: String = "proposed", island: Int = 0): List<StackSLProposalOutcome> {
ensureStartedQuietly()
val out = programs.map { text ->
val problem = StackSLProposals.check(text, settings.maxTokens)
if (problem != null) return@map StackSLProposalOutcome(text, origin, null, null, null, problem)
val program = StackSLProposals.program(text)
val individual = consider(program, origin)
?: return@map StackSLProposalOutcome(program.text, origin, null, null, null, "already in the cache: scored before")
if (!individual.evaluation.fitness.isFinite() || individual.fitness <= StackSLImageObjective.INVALID_SCORE) {
return@map StackSLProposalOutcome(program.text, origin, individual.fitness, null, null, "renders a blank or flat picture, or throws")
}
val pool = pools[if (island < 0) dealt++ % pools.size else island.coerceIn(0, pools.size - 1)]
pool += individual
trim(pool)
val rank = ranked.indexOfFirst { it.genome.text == program.text }.takeIf { it >= 0 }?.plus(1)
StackSLProposalOutcome(program.text, origin, individual.fitness, skill(individual.fitness), rank, null)
}
noteLeader(null)
return out
}
fun fit
A round of Adam on program's numeric literals, toward the current stage's target, then
scored under the search's own metric and offered to the population as origin.
The descent's loss follows the stage's metric: the mean squared error of the clamped render
under PIXEL, and under LPIPS and LPIPS_PIXEL the mean of LPIPS (through its exported
gradient, the stage's region grid averaged in) and that squared error. Not pure LPIPS under
LPIPS: measured on 2026-10-07 (StackSLLpipsFitMeasurements), a pure-LPIPS descent never
turned an inverted seed the right way round and on a bands seed with the wrong stripe
frequency drifted from polarity +0.25 to -0.13, while the mean gained the most skill under
LPIPS of the three and never ended inverted. loss overrides it. Always with step-family
annealing, so a threshold or select edge can move. A fit can still
lower its own loss and score worse under the search's; the population decides. literals
picks which literals may move by token index (0-based), all of them when null.
fun fit(
program: StackSLProgram,
literals: List<Int>? = null,
iterations: Int = settings.fitIterations,
learningRate: Double = settings.fitLearningRate,
origin: String = "fit",
loss: StackSLFitLoss? = null
): StackSLFitOutcome = timed("fit") {
ensureStartedQuietly()
val dim = stage.dim
val descent = loss ?: when (stage.metric) {
StackSLMetric.PIXEL -> StackSLFitLoss.MSE
StackSLMetric.LPIPS, StackSLMetric.LPIPS_PIXEL -> StackSLFitLoss.LPIPS_PIXEL
}
val result = runCatching {
val wired = StackSLWires.lift(program.text, "program")
val wires = wired.literalWires
val columns = StackSLProposals.tokenColumns(program.text)
val chosen = if (literals == null) wires.map { it.index } else {
val wanted = literals.mapNotNull { columns.getOrNull(it) }.toSet()
wires.filter { it.column in wanted }.map { it.index }
}
require(chosen.isNotEmpty()) { "no literal to move" + if (literals != null) " at token(s) $literals" else "" }
StackSLFit.fit(
program.text, targetOf(dim).raster, template = StackSLCpu.stillTemplate(time), dim = dim,
iterations = iterations, learningRate = learningRate, indices = chosen, parallel = parallel,
annealing = AdAnnealing.stepOnly(iterations), loss = descent, lpipsSide = stage.lpipsSide(),
lpipsGrid = if (descent != StackSLFitLoss.MSE && stage.lpipsGrid >= 2) stage.lpipsGrid else 0
)
}
val fitted = result.getOrElse { e ->
return@timed StackSLFitOutcome(program.text, null, null, null, null, null, e.message ?: e.toString())
}
val before = objective.evaluate(program).fitness
val outcome = inject(listOf(fitted.fittedProgram), origin).single()
notes += "gen $generation: fit ${fitted.fittedIndices.size} literal(s) of a %.3f program, ${descent.label} %.4f -> %.4f, skill %s".format(
skill(before), fitted.seedLoss, fitted.fittedLoss, outcome.skill?.let { "%.3f".format(it) } ?: outcome.problem
)
StackSLFitOutcome(program.text, fitted.fittedProgram, fitted.seedLoss, fitted.fittedLoss, skill(before), outcome.skill, outcome.problem)
}
fun crossbreed(other: StackSLEvolutionSnapshot, count: Int = max(2, settings.mu / 2)): List<StackSLProposalOutcome>
Bring in other's survivors: its top count programs as they are, and count one-point
recombinants of its leaders with this population's. A rewound conversation can breed with
the branch it left this way.
fun crossbreed(other: StackSLEvolutionSnapshot, count: Int = max(2, settings.mu / 2)): List<StackSLProposalOutcome> {
ensureStartedQuietly()
val theirs = other.islands.flatten().sortedByDescending { it.fitness }.map { it.program }.distinct().take(count)
val mine = ranked.take(count).map { it.genome }
val rng = rngFor(generation, 997)
val recombinants = if (mine.isEmpty() || theirs.isEmpty()) emptyList() else (0 until count).map {
val a = mine[rng.nextInt(mine.size)]
val b = StackSLProgram.of(theirs[rng.nextInt(theirs.size)])
(if (rng.nextBoolean()) genome.crossover(a, b, rng) else genome.crossover(b, a, rng)).text
}
val label = other.label.ifBlank { "another population" }
val out = inject(theirs, "crossbreed", island = -1) + inject(recombinants, "crossbreed+crossover")
notes += "gen $generation: crossbred with '$label': ${out.count { it.rank != null }} of ${out.size} entered"
return out
}
the watchmaker
fun askWatchmaker(proposals: Int = settings.proposals, parents: Int = settings.parents): List<StackSLProposalOutcome>
One watchmaker round now: show the model the target and the top parents, take proposals
programs back, and offer them as watchmaker. Waits for the reply.
body — 46 lines, from line 433
fun askWatchmaker(proposals: Int = settings.proposals, parents: Int = settings.parents): List<StackSLProposalOutcome> {
val model = watchmaker ?: throw IllegalStateException("no watchmaker: set evolution.watchmaker first")
ensureStartedQuietly()
val leaders = distinctLeaders(parents)
val reply = timed("watchmaker") { model.propose(request(leaders, proposals)) }
return absorb(reply, leaders)
}
private fun watchmakerTurn() {
val model = watchmaker ?: return
val s = settings
pending?.let { f ->
if (f.isDone || s.watchmakerWait) {
pending = null
val reply = timed("watchmaker") { runCatching { f.get() } }
reply.onSuccess { absorb(it, pendingFor) }
.onFailure { notes += "gen $generation: watchmaker failed: ${(it.cause ?: it).message}" }
}
}
if (pending != null || lastAsked == generation) return
val due = (s.watchmakerEvery > 0 && generation % s.watchmakerEvery == 0) ||
(s.watchmakerOnStall > 0 && sinceImprovement >= s.watchmakerOnStall && generation - lastAsked >= s.watchmakerOnStall)
if (!due) return
lastAsked = generation
val leaders = distinctLeaders(s.parents)
val request = timed("watchmaker") { request(leaders, s.proposals) }
pendingFor = leaders
pending = asker.submit<StackSLProposalReply> { model.propose(request) }
if (s.watchmakerWait) watchmakerTurn()
}
private fun absorb(reply: StackSLProposalReply, leaders: List<Individual<StackSLProgram>>): List<StackSLProposalOutcome> {
usage = usage + reply.usage
val outcomes = reply.proposals.map { p ->
val parent = p.parent?.let { leaders.getOrNull(it - 1) }
inject(listOf(p.program), if (parent == null) "watchmaker:new" else "watchmaker:edit", island = -1).single().copy(why = p.why)
}
lastRound = outcomes
val entered = outcomes.count { it.rank != null }
val bestSkill = outcomes.mapNotNull { it.skill }.maxOrNull()
notes += "gen $generation: watchmaker (${reply.usage.model}) proposed ${reply.proposals.size}, $entered entered" +
(bestSkill?.let { ", best skill %.3f".format(it) } ?: "") +
(reply.problem?.let { "; $it" } ?: "") +
"; ${reply.usage.inputTokens} in (${reply.usage.cachedTokens} cached), ${reply.usage.outputTokens} out, %.1fs".format(reply.usage.millis / 1000.0)
return outcomes
}
fun distinctLeaders(n: Int): List<Individual<StackSLProgram>>
The best n programs that score differently, for the watchmaker to see.
Not simply the top n: a converged island's top four are one program and its neutral
variants at an identical score, which is one picture shown four times. The first live rounds
on rings did exactly that.
fun distinctLeaders(n: Int): List<Individual<StackSLProgram>> {
val all = ranked
val picked = ArrayList<Individual<StackSLProgram>>()
val scores = HashSet<Long>()
for (ind in all) {
if (picked.size >= n) break
if (scores.add(Math.round(ind.fitness * 1e4))) picked += ind
}
for (ind in all) if (picked.size < n && ind !in picked) picked += ind
return picked
}
fun request(leaders: List<Individual<StackSLProgram>> = distinctLeaders(settings.parents), proposals: Int = settings.proposals): StackSLProposalRequest
What the watchmaker is shown. Public so a person can read exactly what a model would see.
fun request(leaders: List<Individual<StackSLProgram>> = distinctLeaders(settings.parents), proposals: Int = settings.proposals): StackSLProposalRequest {
ensureStartedQuietly()
val t = target
val st = stage
val sheet = thumbnailSheet(leaders.map { it.genome })
return StackSLProposalRequest(
targetName = t.name,
targetColours = StackSLProposals.describeColours(t),
metric = st.describe(),
caseLabels = caseLabels(st),
generation = generation,
stalled = sinceImprovement,
parents = leaders.mapIndexed { i, ind ->
StackSLParentView(i + 1, ind.genome.text, ind.fitness, skill(ind.fitness), ind.evaluation.cases.toList(), ind.origin)
},
proposals = proposals,
maxTokens = settings.maxTokens,
lastRound = lastRound,
sheetPng = sheet.toPngBytes(),
preview = if (settings.watchmakerRevise) ::preview else null
)
}
fun preview(programs: List<String>): StackSLPreview
What programs draw, for the watchmaker's second pass: each at 64 px beside the target, with
whether the search would refuse it and why. Thumbnails, not fitness renders: not in renders.
fun preview(programs: List<String>): StackSLPreview {
val panels = ArrayList<Pair<String, Img>>()
panels += "T" to target.raster.toImg().upscaleNearest(max(1, THUMBNAIL / target.raster.width)).let {
if (it.width == THUMBNAIL) it else it.resize(THUMBNAIL, THUMBNAIL)
}
val status = programs.mapIndexed { i, text ->
val problem = StackSLProposals.check(text, settings.maxTokens)
val raster = if (problem != null) null else runCatching {
StackSLCpu.renderStill(StackSLProposals.program(text).text, dim = THUMBNAIL, time = time, parallel = parallel)
}.getOrNull()
panels += "P${i + 1}" to (raster?.toImg() ?: Img(THUMBNAIL, THUMBNAIL, 3, FloatArray(THUMBNAIL * THUMBNAIL * 3)))
when {
problem != null -> "refused: $problem"
raster == null -> "refused: it throws when rendered"
RasterStructure.of(raster).let { it.spread < StackSLImageObjective.MIN_SPREAD || it.distinctColours < StackSLImageObjective.MIN_DISTINCT_COLOURS } ->
"flat: one colour, which the search refuses"
else -> "draws the picture shown"
}
}
return StackSLPreview(montage(panels, columns = min(panels.size, 5), cellSide = THUMBNAIL + 4, gap = 6).toPngBytes(), status)
}
private fun caseLabels(st: StackSLStage): List<String> {
val lpips = if (st.lpipsGrid >= 2 && st.metric != StackSLMetric.PIXEL) gridLabels(st.lpipsGrid).map { "lpips $it" } else emptyList()
val pixel = if (st.metric != StackSLMetric.LPIPS) gridLabels(4).map { "pixel $it" } else emptyList()
return lpips + pixel
}
looking
fun contactSheet(top: Int = 6, side: Int = 128): Img
The target and the top programs that score differently (distinctLeaders), rendered at
side, labelled by rank and skill. Only the target before started.
fun contactSheet(top: Int = 6, side: Int = 128): Img {
if (!started) return montage(listOf("target" to target.raster.toImg().upscaleNearest(max(1, side / target.raster.width))), columns = 1, cellSide = side + 8)
val panels = ArrayList<Pair<String, Img>>()
panels += "target" to target.raster.toImg().upscaleNearest(max(1, side / target.raster.width))
// Leaders that score differently: the top few by text are often one picture and its variants.
distinctLeaders(top).forEachIndexed { i, ind ->
panels += "#${i + 1} %.2f".format(skill(ind.fitness)) to StackSLCpu.renderStill(ind.genome.text, dim = side, time = time, parallel = parallel).toImg()
}
return montage(panels, columns = min(panels.size, 4), cellSide = side + 8)
}
fun thumbnailSheet(programs: List<StackSLProgram>, side: Int = THUMBNAIL): Img
What the watchmaker sees: the target and programs at 64 px, labelled T, #1, #2...
fun thumbnailSheet(programs: List<StackSLProgram>, side: Int = THUMBNAIL): Img {
val panels = ArrayList<Pair<String, Img>>()
panels += "T" to target.raster.toImg().upscaleNearest(max(1, side / target.raster.width)).let {
if (it.width == side) it else it.resize(side, side)
}
programs.forEachIndexed { i, p ->
val img = runCatching { StackSLCpu.renderStill(p.text, dim = side, time = time, parallel = parallel).toImg() }
.getOrElse { Img(side, side, 3, FloatArray(side * side * 3)) }
panels += "#${i + 1}" to img
}
return montage(panels, columns = min(panels.size, 5), cellSide = side + 4, gap = 6)
}
fun layoutOf(program: StackSLProgram, dim: Int = stage.dim): Double
The held-out check: StackSLLayout.layoutAgreement of program against the target at dim.
For reports only; nothing in the search reads it.
fun layoutOf(program: StackSLProgram, dim: Int = stage.dim): Double =
StackSLLayout.layoutAgreement(StackSLCpu.renderStill(program.text, dim = dim, time = time, parallel = parallel), targetOf(dim).raster)
fun summary(): String
A few lines: where the search is, its leader, and which origins have earned their keep.
fun summary(): String = buildString {
if (!started) {
appendLine("$targetName: no population yet; ${stage.describe()} first, ${settings.islands} island(s) of ${settings.mu}")
return@buildString
}
val b = best
appendLine("%s: generation %d, %d renders, stage %s, %d island(s) of %d".format(
targetName, generation, renders, stage.describe(), pools.size, settings.mu))
if (b != null) {
appendLine("best skill %.3f (fitness %.4f) via %s, layout %.2f".format(skill(b.fitness), b.fitness, b.origin, layoutOf(b.genome)))
appendLine(" ${b.genome.text}")
}
val usageLine = usage.takeIf { it.calls > 0 }?.toString()
if (usageLine != null) appendLine("watchmaker: $usageLine")
appendLine("origin offered survived leads")
credit.forEach { (k, c) -> appendLine("%-16s %8d %8d %7d".format(k, c.offered, c.survived, c.leads)) }
}
override fun toString(): String = summary()
The search's summary block.
settings, snapshot and restore
fun configure(change: (StackSLEvolveSettings) -> StackSLEvolveSettings): StackSLEvolution {
Change the settings between generations. A changed stage re-scores the population.
fun configure(change: (StackSLEvolveSettings) -> StackSLEvolveSettings): StackSLEvolution {
val next = change(settings)
next.validate()
settings = next
notes += "gen $generation: settings changed"
return this
}
fun snapshot(label: String = ""): StackSLEvolutionSnapshot
Everything needed to put this search back as it is now, as plain data.
fun snapshot(label: String = ""): StackSLEvolutionSnapshot = StackSLEvolutionSnapshot(
label = label,
target = targetName,
seed = seed,
generation = generation,
renders = renders,
settings = settings,
islands = pools.map { pool -> pool.map { StackSLSnapshotMember(it.genome.text, it.origin, it.fitness, skill(it.fitness)) } },
history = history.toList(),
improvements = improvements.toList(),
notes = notes.toList(),
credit = credit.mapValues { it.value.copy() },
usage = usage,
lastRound = lastRound,
savedAt = System.currentTimeMillis()
)
fun restore(snapshot: StackSLEvolutionSnapshot): StackSLEvolution
Put the search back to snapshot. Programs are re-scored, which costs nothing for one already seen.
fun restore(snapshot: StackSLEvolutionSnapshot): StackSLEvolution {
pending = null
settings = snapshot.settings.also { it.validate() }
generation = snapshot.generation
renders = snapshot.renders
history.clear(); history += snapshot.history
improvements.clear(); improvements += snapshot.improvements
notes.clear(); notes += snapshot.notes
credit.clear(); credit += snapshot.credit.mapValues { it.value.copy() }
usage = snapshot.usage
lastRound = snapshot.lastRound
pools = ArrayList()
stageKey = null
enterStage()
pools = snapshot.islands.map { members ->
members.mapNotNull { m -> score(StackSLProposals.program(m.program))?.let { Individual(it.genome, it.evaluation, m.origin) } }
.sortedByDescending { it.fitness }.toMutableList()
}.toMutableList()
survivedCounted.clear()
survivedCounted += pools.flatten()
leaderFitness = best?.fitness ?: Double.NEGATIVE_INFINITY
rebase = false
sinceImprovement = history.reversed().takeWhile { it.leaderOrigin == null }.size
return this
}
fun reset(): StackSLEvolution
The search as it would be before its first generation: nothing in it, the same settings.
fun reset(): StackSLEvolution = restore(StackSLEvolutionSnapshot(label = "fresh", target = targetName, seed = seed, settings = settings))
bookkeeping
private fun ensureStartedQuietly() {
if (pools.size != settings.islands || pools.any { it.size < min(settings.mu, 2) }) ensureStarted() else enterStage()
}
private fun targetOf(dim: Int): StackSLTarget = targets.getOrPut(dim) { targetAt(dim) }
private fun objectiveFor(st: StackSLStage): StackSLImageObjective = objectives.getOrPut(st.key) {
val t = targetOf(st.dim)
StackSLImageObjective(
t, dim = st.dim, time = time, parallel = parallel, metric = st.metric, cache = StackSLScoreCache(),
lpipsSide = st.lpipsSide(), lpipsRegions = st.lpipsGrid >= 2 && st.metric != StackSLMetric.PIXEL,
lpipsGrid = max(2, st.lpipsGrid)
)
}
Re-score every member when the stage has changed since the last generation; costs renders.
private fun enterStage() {
val st = stage
if (stageKey == st.key) return
val first = stageKey == null
stageKey = st.key
if (pools.isEmpty()) return
if (!first) notes += "gen $generation: stage ${st.describe()}"
for (i in pools.indices) {
pools[i] = pools[i].mapNotNull { m ->
consider(m.genome, m.origin, countOrigin = false)?.let { Individual(it.genome, it.evaluation, m.origin) }
}.sortedByDescending { it.fitness }.toMutableList()
}
survivedCounted.clear()
survivedCounted += pools.flatten()
// A new stage's scores are on a new scale: whoever leads now leads, and earns no credit for it.
rebase = true
}
private var leaderFitness = Double.NEGATIVE_INFINITY
private var rebase = false
Score without spending budget: for a restore, where the render already happened once.
private fun score(program: StackSLProgram): Individual<StackSLProgram>? =
runCatching { Individual(program, objective.evaluate(program), "restored") }.getOrNull()
Score a candidate, charging a render when it was not in the cache. null when evaluation threw.
private fun consider(program: StackSLProgram, origin: String, countOrigin: Boolean = true): Individual<StackSLProgram>? {
val o = objective
val cache = o.cache!!
val before = cache.size
val evaluation = runCatching { o.evaluate(program) }.getOrNull() ?: return null
renders += cache.size - before
if (countOrigin) creditOf(origin).offered++
return Individual(program, evaluation, origin)
}
Sort, drop duplicates, keep µ, and count each newcomer that stays, once, by origin.
private fun trim(pool: MutableList<Individual<StackSLProgram>>) {
pool.sortWith(compareByDescending { it.fitness })
val kept = PopulationSearch.truncate(genome, pool, settings.mu)
pool.clear()
pool += kept
kept.forEach { if (survivedCounted.add(it) && it.origin !in UNCREDITED) creditOf(it.origin).survived++ }
if (survivedCounted.size > 4 * settings.mu * pools.size + 64) survivedCounted.retainAll(pools.flatten().toSet())
}
Identity, not equality: Individual is not a data class, so a re-scored member is a newcomer only if it was never counted, which enterStage and restore see to.
private val survivedCounted = HashSet<Individual<StackSLProgram>>()
private fun noteLeader(initialOrigin: String?) {
val b = best ?: return
if (rebase) {
rebase = false
leaderFitness = b.fitness
improvements += StackSLImprovement(generation, renders, b.fitness, skill(b.fitness), "stage ${stage.key}", b.genome.text)
return
}
val improved = b.fitness > leaderFitness + 1e-12
if (improved) {
leaderFitness = b.fitness
sinceImprovement = 0
if (initialOrigin == null) creditOf(b.origin).leads++
improvements += StackSLImprovement(generation, renders, b.fitness, skill(b.fitness), b.origin, b.genome.text)
} else sinceImprovement++
if (initialOrigin == null) {
history += StackSLGenerationRecord(generation, renders, stage.key, b.fitness, skill(b.fitness), if (improved) b.origin else null)
}
}
private fun creditOf(origin: String): StackSLOriginCredit = credit.getOrPut(originFamily(origin)) { StackSLOriginCredit() }
private fun rngFor(generation: Int, island: Int): Random = Random(seed * 1_000_003L + generation * 7_919L + island)
private inline fun <T> timed(what: String, block: () -> T): T {
val t0 = System.nanoTime()
try {
return block()
} finally {
millis[what] = (millis[what] ?: 0.0) + (System.nanoTime() - t0) / 1e6
}
}
companion object
companion object {
const val THUMBNAIL: Int = 64
The side the watchmaker sees each render at.
Origins that are bookkeeping rather than variation, and so earn no survival credit.
private val UNCREDITED = setOf("random", "restored", "migrant")
fun originFamily(origin: String): String
Group origins the way credit reports them: crossover+point is crossover.
fun originFamily(origin: String): String = when {
origin.startsWith("crossover+") -> "crossover"
else -> origin
}
fun gridLabels(n: Int): List<String> = (0 until n * n).map { "r${it / n + 1}c${it % n + 1}" }
Labels for an n by n grid of cases, row by row from the top left: r1c1, r1c2...
fun resample(base: StackSLTarget): (Int) -> StackSLTarget = { dim ->
A target at other resolutions: area-averaged from base, or base itself at its own size.
fun resample(base: StackSLTarget): (Int) -> StackSLTarget = { dim ->
if (dim == base.raster.width) base
else StackSLTargets.byName(base.name, dim) ?: StackSLTarget.of(base.name, base.raster.toImg(), dim)
}
}
}
data class StackSLStage
One stage of a schedule: how many generations it lasts and what fitness means during it.
data class StackSLStage(
/** How many generations it lasts. The schedule's last stage runs on whatever this says. */
val generations: Int = Int.MAX_VALUE,
/** The square side candidates are rendered at while it runs. A multiple of 4 in 8..256. */
val dim: Int = DEFAULT_FITNESS_DIM,
/** What "close to the target" means during it. */
val metric: StackSLMetric = StackSLMetric.LPIPS,
/**
* Regions per side scored as lexicase cases under LPIPS: 2 is the quadrants, 3 the ninths, 0
* none. Ignored under [StackSLMetric.PIXEL], whose cases are its own 4 by 4 grid.
*/
val lpipsGrid: Int = 2
) {
val key: String get() = "${metric.name.lowercase()}@$dim/g$lpipsGrid"
What identifies the stage's objective and score cache.
fun lpipsSide(): Int
The side LPIPS sees: the smallest multiple of dim that fits the network and the grid.
fun lpipsSide(): Int {
var side = dim
while (side < StackSLImageObjective.DEFAULT_LPIPS_SIDE || (lpipsGrid >= 2 && (side < lpipsGrid * afield.ai.inference.onnx.Lpips.MIN_SIDE || side % lpipsGrid != 0))) side += dim
return side
}
fun describe(): String
The stage in words: the metric, the resolution, and the region grid when it has one.
fun describe(): String = buildString {
append(metric.name)
append(" at ${dim}px")
if (metric != StackSLMetric.PIXEL && lpipsGrid >= 2) append(", ${lpipsGrid}x$lpipsGrid region cases")
}
fun validate()
Throw if any of the stage's numbers is out of range.
fun validate() {
require(generations >= 1) { "a stage lasts at least one generation, got $generations" }
require(dim in 8..256 && dim % 4 == 0) { "stage dim must be a multiple of 4 in 8..256, got $dim" }
require(lpipsGrid == 0 || lpipsGrid in 2..4) { "lpipsGrid must be 0 or 2..4, got $lpipsGrid" }
}
}
data class StackSLEvolveSettings
Everything about how a StackSLEvolution searches. Plain data, so a snapshot carries it.
declaration — 45 lines, from line 847
data class StackSLEvolveSettings(
/** Survivors kept per island each generation. */
val mu: Int = 12,
/** Children produced per island each generation. */
val lambda: Int = 24,
/** How many populations evolve side by side, exchanging programs through [migrateEvery]. */
val islands: Int = 1,
/** Every this many generations, each island sends its best to the next one; 0 never. */
val migrateEvery: Int = 8,
/** How many of an island's best travel on a migration. A program the destination already has is not copied. */
val migrants: Int = 2,
/**
* How a parent is chosen. [Selection.LEXICASE] falls back to [Selection.TOURNAMENT] for any
* stage whose objective offers no cases, rather than degenerating into a random draw.
*/
val selection: Selection = Selection.LEXICASE,
/** How many candidates a tournament compares. Unused under lexicase selection. */
val tournament: Int = 3,
/** The chance a child is recombined from two parents rather than mutated from one. */
val crossoverRate: Double = 0.25,
/** The chance a child is a fresh random draw instead, which is how an island keeps new words arriving. */
val immigrantRate: Double = 0.05,
/** Longest program, in words, definitions included; proposals over it are refused, never cut. */
val maxTokens: Int = 48,
/** The schedule. The last stage runs for ever, whatever its [StackSLStage.generations] says. */
val stages: List<StackSLStage> = listOf(StackSLStage()),
/** Every this many generations, a round of Adam on the leader's literals; 0 never. */
val fitEvery: Int = 0,
/** Adam steps per [fitEvery] round. */
val fitIterations: Int = 16,
/** Adam's step size over the literals, which are in the program's own units. */
val fitLearningRate: Double = 0.05,
/** Every this many generations, a watchmaker round; 0 never (unless [watchmakerOnStall]). */
val watchmakerEvery: Int = 0,
/** A watchmaker round once the leader has not improved for this many generations; 0 never. */
val watchmakerOnStall: Int = 0,
/** Show the watchmaker what its programs draw and take its corrections (a second call per round). */
val watchmakerRevise: Boolean = true,
/** Wait for the watchmaker's reply before the next generation (else it lands when it lands). */
val watchmakerWait: Boolean = true,
/** Programs asked of the watchmaker per round. */
val proposals: Int = 8,
/** Leaders it is shown per round. */
val parents: Int = 4
) {
fun stageAt(g: Int): StackSLStage
The stage generation g runs under.
fun stageAt(g: Int): StackSLStage {
var start = 0
for ((i, st) in stages.withIndex()) {
if (i == stages.size - 1 || g < start + st.generations) return st
start += st.generations
}
return stages.last()
}
fun validate()
Throw if any of the settings is out of range, each stage included.
fun validate() {
require(mu >= 1 && lambda >= 1) { "mu and lambda must be >= 1" }
require(islands in 1..16) { "islands must be in 1..16, got $islands" }
require(crossoverRate in 0.0..1.0 && immigrantRate in 0.0..1.0) { "rates must be in 0..1" }
require(maxTokens in 3..96) { "maxTokens must be in 3..96, got $maxTokens" }
require(stages.isNotEmpty()) { "a schedule needs at least one stage" }
stages.forEach { it.validate() }
require(proposals in 1..32 && parents in 1..9) { "proposals must be in 1..32 and parents in 1..9" }
}
companion object
companion object {
fun wide(): StackSLEvolveSettings
Wider and deeper than evolveStackSLTowards: three islands of 12, programs up to 48 words, a coarse 16 px stage, then LPIPS on the quadrants, then LPIPS plus pixel error on ninths.
The last stage adds pixel error because LPIPS barely tells a picture from its negative: scored by LPIPS alone, one run kept concentric rings in inverted colours as its best (layout −0.98). Measured with the watchmaker on, 29 runs over twelve targets, the pixel term gave the same mean layout (0.90 against 0.91) and more runs above 0.9 on the seven targets the prompt was never tuned on (10 of 14 against 6 of 14).
fun wide(): StackSLEvolveSettings = StackSLEvolveSettings(
islands = 3,
stages = listOf(
StackSLStage(generations = 6, dim = 16, metric = StackSLMetric.LPIPS_PIXEL, lpipsGrid = 2),
StackSLStage(generations = 20, dim = 32, metric = StackSLMetric.LPIPS, lpipsGrid = 2),
StackSLStage(dim = 32, metric = StackSLMetric.LPIPS_PIXEL, lpipsGrid = 3)
)
)
fun watchmaker(): StackSLEvolveSettings
wide with the watchmaker taking a round every 4 generations and after 2 without improvement, each round with its second pass. The settings that drew all twelve test targets.
fun watchmaker(): StackSLEvolveSettings = wide().copy(watchmakerEvery = 4, watchmakerOnStall = 2)
}
}
data class StackSLGenerationRecord
One generation's leader. leaderOrigin is set only in a generation where the leader improved.
data class StackSLGenerationRecord(
/** Which generation this is, from 1. */
val generation: Int = 0,
/** Renders spent by the end of it — the budget axis every trace is read against. */
val renders: Int = 0,
/** The stage it ran under, as [StackSLStage.key] writes it. */
val stage: String = "",
/** The leader's fitness at the end of it. */
val bestFitness: Double = 0.0,
/** The same score in skill units: 0 is a flat fill of the target's mean colour, 1 is exact. */
val bestSkill: Double = 0.0,
/** What produced the new leader, or `null` in a generation where the leader did not change. */
val leaderOrigin: String? = null
)
data class StackSLImprovement
A new overall leader.
data class StackSLImprovement(
/** The generation it arrived in. */
val generation: Int = 0,
/** Renders spent when it did, so improvements can be plotted against budget rather than time. */
val renders: Int = 0,
/** What it scored. */
val fitness: Double = 0.0,
/** The same score in skill units; see [StackSLGenerationRecord.bestSkill]. */
val skill: Double = 0.0,
/** What produced it: a named mutation, a crossover, a migrant, or a watchmaker round. */
val origin: String = "",
/** The program itself, so the list of improvements is a readable lineage. */
val program: String = ""
)
data class StackSLFitOutcome
What a StackSLEvolution.fit did: the loss the descent ran on before and after, and the skill
under the search's own metric before and after. problem says why there is no fitted program.
data class StackSLFitOutcome(
/** The program the descent started from. */
val program: String,
/** The program with its literals moved, or `null` when there is a [problem]. */
val fitted: String?,
/** The descent's own loss before it ran, which is not the search's metric. */
val lossBefore: Double?,
/** The same loss after. A descent can lower this and still lose skill; both are reported. */
val lossAfter: Double?,
/** Skill under the search's metric before the descent. */
val skillBefore: Double?,
/**
* Skill under the search's metric after it, or `null` when the fitted program was refused. The
* fitted program is offered to the population either way: this says whether it was worth it.
*/
val skillAfter: Double?,
/** Why there is no fitted program, or `null` when there is one. */
val problem: String?
)
data class StackSLOriginCredit
How one origin of candidates has done.
data class StackSLOriginCredit(
/** Candidates this origin produced. */
var offered: Int = 0,
/** How many of them survived a trim into the next generation. */
var survived: Int = 0,
/** How many of them became the overall leader, which is the column a claim about an operator rests on. */
var leads: Int = 0,
)
data class StackSLSnapshotMember
A member of a saved island.
data class StackSLSnapshotMember(
/** The program as source text. */
val program: String = "",
/** What produced it. */
val origin: String = "",
/** What it scored under the stage it was saved from. */
val fitness: Double = 0.0,
/** The same score in skill units; see [StackSLGenerationRecord.bestSkill]. */
val skill: Double = 0.0,
)
data class StackSLEvolutionSnapshot
A StackSLEvolution as plain data. StackSLEvolutionSnapshot.write and read are its JSON.
data class StackSLEvolutionSnapshot(
/** The format version. Reading ignores fields it does not know, so a newer file still loads. */
val version: Int = 1,
/** A name for this saved population, which the archive files it under. */
val label: String = "",
/** The target's name, which is how a restored search finds the picture again. */
val target: String = "",
/** The search's seed, so a restored run continues the same stream rather than a fresh one. */
val seed: Long = 1L,
/** The generation it was saved at. */
val generation: Int = 0,
/** Renders spent by then. */
val renders: Int = 0,
/** The settings it was running under, saved whole because they are plain data. */
val settings: StackSLEvolveSettings = StackSLEvolveSettings(),
/** Each island's members, best first. */
val islands: List<List<StackSLSnapshotMember>> = emptyList(),
/** One record per generation. */
val history: List<StackSLGenerationRecord> = emptyList(),
/** Every generation in which the overall leader improved. */
val improvements: List<StackSLImprovement> = emptyList(),
/** The run's own log lines, in order. */
val notes: List<String> = emptyList(),
/** What each origin has produced, survived and led with, keyed by origin name. */
val credit: Map<String, StackSLOriginCredit> = emptyMap(),
/** What the watchmaker has cost the run so far. */
val usage: StackSLModelUsage = StackSLModelUsage(),
/** The last watchmaker round's outcomes, which the next round's prompt reports back. */
val lastRound: List<StackSLProposalOutcome> = emptyList(),
/** When it was written, as epoch milliseconds. */
val savedAt: Long = 0L
) {
val best: StackSLSnapshotMember? get() = islands.flatten().maxByOrNull { it.fitness }
The best saved program and its skill, or null for an empty snapshot.
fun write(file: File)
Write the snapshot as JSON, through a temporary file so a reader never sees a half-written one.
fun write(file: File) {
file.parentFile?.mkdirs()
val tmp = File(file.parentFile, file.name + ".tmp")
mapper.writerWithDefaultPrettyPrinter().writeValue(tmp, this)
if (!tmp.renameTo(file)) { file.delete(); tmp.renameTo(file) }
}
companion object
companion object {
private val mapper = jacksonObjectMapper()
.configure(com.fasterxml.jackson.databind.DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
fun read(file: File): StackSLEvolutionSnapshot = mapper.readValue(file)
fun fromJson(json: String): StackSLEvolutionSnapshot = mapper.readValue(json)
fun toJson(s: StackSLEvolutionSnapshot): String = mapper.writeValueAsString(s)
class StackSLPopulationArchive(val directory: File)
Saved populations by id, append-only, in a directory: what StackSLEvolution.crossbreed reads.
The AI4 studio files a snapshot here at the end of every chat turn, so a population from a branch the conversation has since left, or from before a rewind, is still here to breed with.
class StackSLPopulationArchive(val directory: File) {
fun add(snapshot: StackSLEvolutionSnapshot): String
File snapshot under a new id and return the id.
fun add(snapshot: StackSLEvolutionSnapshot): String {
directory.mkdirs()
var n = (directory.list()?.count { it.endsWith(".json") } ?: 0) + 1
var id: String
do { id = "p%03d".format(n++) } while (File(directory, "$id.json").exists())
snapshot.copy(label = snapshot.label.ifBlank { id }).write(File(directory, "$id.json"))
return id
}
fun get(id: String): StackSLEvolutionSnapshot?
The snapshot filed as id, or null.
fun get(id: String): StackSLEvolutionSnapshot? =
File(directory, "${id.removeSuffix(".json")}.json").takeIf { it.isFile }?.let { runCatching { StackSLEvolutionSnapshot.read(it) }.getOrNull() }
fun list(): List<Pair<String, StackSLEvolutionSnapshot>>
Every id, oldest first, with its label, generation and best skill.
fun list(): List<Pair<String, StackSLEvolutionSnapshot>> =
directory.listFiles { f -> f.name.endsWith(".json") }.orEmpty().sortedBy { it.name }
.mapNotNull { f -> runCatching { f.nameWithoutExtension to StackSLEvolutionSnapshot.read(f) }.getOrNull() }
}
fun StackSLProgram.render(side: Int = 160, time: Float = 1.75f): Img
What this program draws, at side pixels square and render time time: for showImg and montage.
fun StackSLProgram.render(side: Int = 160, time: Float = 1.75f): Img = StackSLCpu.renderStill(text, dim = side, time = time).toImg()
fun Img.toPngDataUrl(): String
An Img as a PNG data URL, for a model or an <img>.
fun Img.toPngDataUrl(): String = "data:image/png;base64," + Base64.getEncoder().encodeToString(toPngBytes())