EyeEvolution.kt
src/jvmMain/kotlin/afield/study/search/EyeEvolution.kt · 401 lines
@file:afield.runtime.completion.CompletionCategory("Search")
package afield.study.search
import afield.document.graph.Box
import afield.study.source.BoxStudies
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.doubleOrNull
import kotlinx.serialization.json.intOrNull
import kotlinx.serialization.json.jsonPrimitive
import afield.ui.output.registerUnownedStringCallback
import java.util.Base64
import java.util.UUID
import java.util.concurrent.Executors
import kotlin.random.Random
Interactive evolution: a grid of pictures in a box's output panel, where clicking the ones you like breeds the next generation. The fitness function is the person looking at it.
This is the half of afield.study.search that scores nothing itself. PopulationSearch.kt
spends a budget of evaluations against an Objective; this file spends your attention instead.
Both describe a genome with the same GenomeOps, so a genome written for one drives the other
unchanged, and breedFrom is the shared rule for turning a pair of parents into a child.
EyeEvolution holds the grid, the current parents and every earlier set; evolveByEye puts
one in a box and hands it back; EyeSetting is a number the grid's toolbar shows and lets you
turn between generations. Nothing here knows what a genome is beyond the four functions you
supply for breeding it, drawing it, naming it and labelling its tile. In this tree the genome it
is used with is afield.lang.rpn.ColorRpn, and ColorRpnEvolution.kt is the worked example of
wiring one up.
class EyeEvolution<G> internal constructor
Interactive evolution, where the eye is the fitness function: a grid of tiles in a box's output panel (the parents first, then their children), and clicking EyeEvolution.pick tiles makes them the next parents and breeds again. This is the loop of Karl Sims' Genetic Images and of Dawkins' biomorphs; afield.lang.rpn.ColorRpn programs (the madp GA demo) are one genome for it, and any genome with a picture works — StackSL programs, FLines, synth patches drawn as spectra.
val evolution = evolveByEye(
parents = listOf(a, b),
breed = { parents, rng -> Offspring(myCrossover(parents[0], parents[1], rng), "cross") },
picture = { g -> pngOf(g) },
text = { g -> g.toString() },
)
A genome that already has GenomeOps can pass them instead of breed (the other evolveByEye):
children come from GenomeOps.crossover, or a random one of GenomeOps.mutations when it
declines, and Fresh parents draws GenomeOps.random.
Box code drives the same loop the clicks do: pick, reroll, fresh and restart. The browser
half posts its clicks as JSON over Field's string-update control message to a callback this
object registers, the way the StackSL study viewer does; the grid is rebuilt on a background
thread, and onPick runs there too.
class EyeEvolution<G> internal constructor(
/** The box whose output panel holds the grid. */
val owner: Box,
/** The parents a run starts from, and [restart] returns to. */
val start: List<G>,
private val breed: (List<G>, Random) -> Offspring<G>,
private val picture: (G) -> ByteArray,
private val text: (G) -> String,
private val meta: (G) -> String,
private val freshParent: ((Random) -> G)?,
/** How many children each generation has, after the parents. */
val children: Int,
/** How many tiles a click selection takes. */
val pick: Int,
/** How many tiles a grid row holds. */
val columns: Int,
/** The output channel that holds the grid. */
val channel: String,
/** The word in the grid's title bar. */
val title: String,
/** The settings the toolbar shows, editable there. */
val settings: List<EyeSetting>,
seed: Long?,
) {
private val rng: Random = seed?.let { Random(it) } ?: Random.Default
private val actionId = "eye-evolution-${UUID.randomUUID()}"
private val worker = Executors.newSingleThreadExecutor { r -> Thread(r, "eye-evolution").apply { isDaemon = true } }
private val lock = Any()
var onPick: (List<G>) -> Unit = {}
Called with the new parents after every selection, on the grid's thread.
var parents: List<G>
The current parents.
var parents: List<G> = start
private set
var tiles: List<EyeTile<G>>
The current tiles, parents first.
var tiles: List<EyeTile<G>> = emptyList()
private set
val history: MutableList<List<G>> = mutableListOf(start)
Every set of parents so far, oldest first; the current one last.
var generation: Int
How many generations have been bred, counting the first.
var generation: Int = 0
private set
private var status = ""
private var log = "pick $pick tiles to breed the next generation"
init
init {
require(start.isNotEmpty()) { "evolveByEye needs at least one parent" }
require(pick >= 1) { "pick must be at least 1, was $pick" }
registerUnownedStringCallback(actionId) { json -> worker.execute { handle(json) } }
}
fun pick(vararg indices: Int)
Make tiles indices (0 is the first parent) the parents, breed, and show the new grid.
fun pick(vararg indices: Int) {
val chosen = synchronized(lock) {
require(indices.isNotEmpty()) { "pick at least one tile" }
indices.map { i ->
require(i in tiles.indices) { "there is no tile $i; the grid has ${tiles.size}" }
tiles[i].genome
}
}
synchronized(lock) {
parents = chosen
history += chosen
}
log = "new parents: tiles ${indices.joinToString(" and ")}"
breedAndShow("selection")
onPick(chosen)
}
fun reroll() = breedAndShow("reroll")
New children of the same parents.
fun fresh()
Random parents, when the genome can draw them; otherwise a reroll.
fun fresh() {
val f = freshParent ?: return reroll()
synchronized(lock) {
parents = parents.map { _ -> f(rng) }
history += parents
}
breedAndShow("fresh")
}
fun restart()
Back to the parents the code started from.
fun restart() {
synchronized(lock) {
parents = start
history += start
}
breedAndShow("restart")
}
fun breedAndShow(reason: String = "start")
Breed a generation from parents and show it. Runs on the calling thread.
body — 144 lines, from line 158
fun breedAndShow(reason: String = "start") {
val started = System.nanoTime()
val html = synchronized(lock) {
val labels = if (parents.size == 2) listOf("parent a", "parent b") else parents.indices.map { "parent ${it + 1}" }
val next = parents.mapIndexed { i, p -> EyeTile(p, labels[i], "seed", true) } +
List(children) { i -> breed(parents, rng).let { EyeTile(it.genome, "child ${i + 1}", it.note, false) } }
tiles = next
generation++
val cache = HashMap<String, String>()
val shown = next.map { tile ->
val key = text(tile.genome)
val cached = cache.containsKey(key)
val src = cache.getOrPut(key) { "data:image/png;base64," + Base64.getEncoder().encodeToString(picture(tile.genome)) }
Shown(tile, src, cached)
}
status = "ready in ${(System.nanoTime() - started) / 1_000_000}ms · $reason"
html(shown)
}
BoxStudies.show(owner, channel, html)
}
private class Shown<G>(val tile: EyeTile<G>, val src: String, val cached: Boolean)
private fun handle(json: String) {
runCatching {
val o = Json.parseToJsonElement(json) as JsonObject
when (o["act"]?.jsonPrimitive?.content) {
"pick" -> pick(*(o["tiles"] as JsonArray).mapNotNull { it.jsonPrimitive.intOrNull }.toIntArray())
"reroll" -> reroll()
"fresh" -> fresh()
"restart" -> restart()
"set" -> {
val name = o["name"]?.jsonPrimitive?.content
val value = o["value"]?.jsonPrimitive?.doubleOrNull
val s = settings.firstOrNull { it.name == name }
if (s != null && value != null) {
s.value = value.coerceIn(s.min, s.max)
s.onChange(s.value)
breedAndShow("${s.name} ${EyeSetting.format(s.value)}")
}
}
}
}.onFailure { e ->
log = "could not do that: ${e.message}"
runCatching { BoxStudies.show(owner, channel, synchronized(lock) { html(tiles.map { Shown(it, "", false) }) }) }
}
}
private fun html(shown: List<Shown<G>>): String {
val id = "eye-${UUID.randomUUID()}"
return buildString {
append("<div class=\"eye-evolution\" id=\"$id\"><style>")
append(CSS.replace("#ID", "#$id").replace("COLUMNS", "$columns"))
append("</style><div class=\"ee-bar\"><span class=\"ee-title\">").append(escape(title)).append("</span><span class=\"ee-sep\">|</span>")
append("<button data-act=\"reroll\">Reroll</button>")
if (freshParent != null) append("<button data-act=\"fresh\">Fresh Parents</button>")
append("<button data-act=\"restart\" title=\"back to the parents in the code\">Start over</button>")
if (settings.isNotEmpty()) append("<span class=\"ee-sep\">|</span>")
for (s in settings) {
append("<label>").append(escape(s.label)).append(' ')
if (s.choices.isNotEmpty()) {
append("<select data-setting=\"${escape(s.name)}\">")
for (c in s.choices) append("<option value=\"$c\"${if (c == s.value) " selected" else ""}>${EyeSetting.format(c)}</option>")
append("</select>")
} else {
append("<input data-setting=\"${escape(s.name)}\" type=\"number\" min=\"${s.min}\" max=\"${s.max}\" step=\"${s.step}\" value=\"${EyeSetting.format(s.value)}\">")
}
append("</label>")
}
append("<span class=\"ee-status\">").append(escape(status)).append("</span></div><div class=\"ee-grid\">")
shown.forEachIndexed { i, s ->
val t = s.tile
append("<article class=\"ee-tile${if (t.isParent) " ee-parent" else ""}\" data-index=\"$i\">")
append("<div class=\"ee-head\"><span class=\"ee-badge\">${if (t.isParent) "seed" else "mut"}</span>")
append("<span class=\"ee-name\">").append(escape("${t.label} ${t.note}")).append("</span>")
append("<span class=\"ee-meta\">").append(escape(meta(t.genome))).append("</span></div>")
append("<div class=\"ee-pic\">")
if (s.src.isNotEmpty()) append("<img src=\"${s.src}\" alt=\"").append(escape(text(t.genome))).append("\">")
append("<span class=\"ee-overlay\">${if (t.isParent) "seed" else if (s.cached) "cached" else "done"}</span></div>")
append("<div class=\"ee-code\">").append(escape(clip(text(t.genome).replace('\n', ' '), 108))).append("</div></article>")
}
append("</div><div class=\"ee-parents\">")
synchronized(lock) { parents }.forEachIndexed { i, p ->
append("<div><span class=\"ee-label\">${if (parents.size == 2) "parent ${"ab"[i]}" else "parent ${i + 1}"}</span><pre>")
append(escape(text(p))).append("</pre></div>")
}
append("</div><div class=\"ee-log\">").append(escape(log)).append(" · generation $generation</div>")
append("<script>(function(){const C={id:\"$id\",action:\"$actionId\",pick:$pick};").append(SCRIPT).append("})();</script></div>")
}
}
private companion object {
fun escape(s: String) = s.replace("&", "&").replace("<", "<").replace(">", ">").replace("\"", """)
fun clip(s: String, max: Int) = if (s.length <= max) s else s.take(max - 1) + "…"
/** The madp GA page's look: near-black ground, Iosevka, blue parents, amber selection. */
val CSS = """
#ID{background:#0a0a0a;color:#b8b8b8;font-family:'Iosevka Web','Iosevka',monospace;font-size:12px;padding:0 0 2px;width:max-content}
#ID .ee-bar{display:flex;align-items:center;gap:8px;padding:6px 10px;background:#111;border-bottom:1px solid #1e1e1e;flex-wrap:wrap}
#ID .ee-title{color:#4a4a4a;letter-spacing:2px;text-transform:uppercase;font-size:11px}
#ID .ee-sep{color:#2a2a2a}
#ID button,#ID input,#ID select{background:#171717;color:#909090;border:1px solid #272727;padding:3px 8px;font:inherit;font-size:12px}
#ID button{cursor:pointer}#ID button:hover{color:#d8d8d8;border-color:#3a3a3a;background:#1b1b1b}
#ID input,#ID select{width:64px;text-align:center}
#ID label{display:inline-flex;align-items:center;gap:4px;color:#7a7a7a}
#ID .ee-status{margin-left:auto;font-size:11px;color:#555}
#ID .ee-grid{display:grid;grid-template-columns:repeat(COLUMNS,200px);gap:10px;padding:10px}
#ID .ee-tile{background:rgba(12,12,12,.9);border:1px solid #1e1e1e;display:flex;flex-direction:column;overflow:hidden;cursor:pointer;transition:border-color .12s,transform .12s}
#ID .ee-tile:hover{border-color:#353535;transform:translateY(-1px)}
#ID .ee-tile.ee-parent{border-color:#244154}
#ID .ee-tile.ee-pending{border-color:#66502a;box-shadow:0 0 0 1px rgba(255,211,124,.22) inset}
#ID .ee-tile.ee-pending .ee-badge{color:#ffdf96;border-color:#5c4c26}
#ID .ee-head{display:flex;align-items:center;gap:8px;padding:6px 8px;border-bottom:1px solid #1e1e1e;background:rgba(16,16,16,.94);font-size:11px}
#ID .ee-badge{color:#7396a8;border:1px solid #253c49;padding:1px 5px;text-transform:uppercase;letter-spacing:.08em;flex-shrink:0}
#ID .ee-name{color:#8a8a8a;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
#ID .ee-meta{margin-left:auto;color:#434343;flex-shrink:0}
#ID .ee-pic{position:relative;background:#050505;width:200px;height:200px}
#ID .ee-pic img{width:200px;height:200px;display:block}
#ID .ee-overlay{position:absolute;right:8px;bottom:8px;background:rgba(8,8,8,.74);border:1px solid rgba(255,255,255,.06);color:#5d5d5d;padding:3px 6px;font-size:11px}
#ID .ee-code{padding:6px 8px 8px;color:#585858;font-size:11px;line-height:1.45;white-space:pre-wrap;word-break:break-word;min-height:56px;border-top:1px solid #1e1e1e;background:rgba(14,14,14,.84)}
#ID .ee-parents{display:flex;gap:10px;padding:0 10px 8px}
#ID .ee-parents div{flex:1;min-width:0;background:#0d0d0d;border:1px solid #1e1e1e;padding:6px 8px}
#ID .ee-parents .ee-label{color:#7396a8;font-size:11px;text-transform:uppercase;letter-spacing:.08em}
#ID .ee-parents pre{margin:4px 0 0;color:#9a9a9a;font:inherit;white-space:pre-wrap;user-select:text}
#ID .ee-log{padding:4px 10px;color:#484848;font-size:11px;border-top:1px solid #1e1e1e;background:#0e0e0e}
""".trimIndent().replace("\n", "")
/** The grid's browser half: tiles toggle until [pick] are chosen, then the choice goes to the box. */
val SCRIPT = """
const root=document.getElementById(C.id); if(!root) return;
const send=(o)=>{ if(window.handleStringInput) window.handleStringInput({id:C.action,value:JSON.stringify(o)}); };
const log=root.querySelector('.ee-log'); let chosen=[];
root.querySelectorAll('.ee-tile').forEach(t=>t.addEventListener('click',()=>{
const i=+t.dataset.index, at=chosen.indexOf(i);
if(at>=0){ chosen.splice(at,1); t.classList.remove('ee-pending'); }
else { chosen.push(i); t.classList.add('ee-pending'); }
if(chosen.length>=C.pick){ log.textContent='breeding from tiles '+chosen.join(' and ')+' …'; send({act:'pick',tiles:chosen}); chosen=[]; }
else log.textContent=chosen.length?('pick '+(C.pick-chosen.length)+' more'):('pick '+C.pick+' tiles to breed the next generation');
}));
root.querySelectorAll('button[data-act]').forEach(b=>b.addEventListener('click',()=>{ log.textContent=b.textContent+' …'; send({act:b.dataset.act}); }));
root.querySelectorAll('[data-setting]').forEach(e=>e.addEventListener('change',()=>send({act:'set',name:e.dataset.setting,value:parseFloat(e.value)})));
""".trimIndent()
}
}
class EyeTile<G>(val genome: G, val label: String, val note: String, val isParent: Boolean)
One tile of an EyeEvolution grid: a genome, its label (parent a, child 3), and how it was made.
class Offspring<G>(val genome: G, val note: String = "")
A child and a short note on how it was bred (shown in its tile's header, e.g. seed+num+op).
class EyeSetting
A number the EyeEvolution toolbar shows and lets you change; changing it calls onChange and
breeds a new generation from the same parents. choices, when given, makes it a menu.
class EyeSetting(
/** Identifies the control in the toolbar; the browser sends it back to say which one moved. */
val name: String,
/** The current value, which the toolbar shows and a change replaces. */
var value: Double,
/**
* The smallest value this accepts. A value arriving from the toolbar is clamped into
* [min]`..`[max], including when [choices] is a menu, so a menu entry outside the range is
* silently pulled back into it.
*/
val min: Double,
/** The largest value this accepts; see [min] for the clamp. */
val max: Double,
/** The number field's increment. Ignored when [choices] makes this a menu. */
val step: Double = 1.0,
/** When non-empty, the control is a menu of exactly these values rather than a number field. */
val choices: List<Double> = emptyList(),
/** The text shown beside the control; [name] by default. */
val label: String = name,
/** Called with the new value once it is clamped, before the next generation is bred. */
val onChange: (Double) -> Unit,
) {
internal companion object
fun format(v: Double): String = if (v == Math.rint(v)) v.toLong().toString() else v.toString()
fun <G> Box.evolveByEye
Show an EyeEvolution grid for parents in this box and return it; see EyeEvolution.
@param breed one child of the current parents
@param picture a genome's tile, as PNG bytes
@param text a genome as text, under its tile and in the parents list; also what makes two tiles the same picture
@param meta a short note in a tile's header (a size, a length)
@param fresh a random genome, for Fresh Parents; null hides the button
@param seed seeds every random draw the grid makes, for a reproducible run
fun <G> Box.evolveByEye(
parents: List<G>,
breed: (List<G>, Random) -> Offspring<G>,
picture: (G) -> ByteArray,
text: (G) -> String = { it.toString() },
meta: (G) -> String = { "" },
fresh: ((Random) -> G)? = null,
children: Int = 7,
pick: Int = 2,
columns: Int = 3,
channel: String = "evolve",
title: String = "evolve",
settings: List<EyeSetting> = emptyList(),
seed: Long? = null,
): EyeEvolution<G> = EyeEvolution(
this, parents, breed, picture, text, meta, fresh, children, pick, columns, channel, title, settings, seed,
).also { it.breedAndShow() }
fun <G> Box.evolveByEye
evolveByEye for a genome with GenomeOps: a child is GenomeOps.crossover of two of the
parents, or one of GenomeOps.mutations applied to one when crossover declines; Fresh Parents
draws GenomeOps.random.
fun <G> Box.evolveByEye(
ops: GenomeOps<G>,
parents: List<G>,
picture: (G) -> ByteArray,
text: (G) -> String = { it.toString() },
children: Int = 7,
pick: Int = 2,
columns: Int = 3,
channel: String = "evolve",
seed: Long? = null,
): EyeEvolution<G> = evolveByEye(
parents,
breed = { ps, rng -> breedFrom(ops, ps, rng) },
picture = picture, text = text, fresh = ops::random,
children = children, pick = pick, columns = columns, channel = channel, seed = seed,
)
fun <G> breedFrom(ops: GenomeOps<G>, parents: List<G>, rng: Random): Offspring<G>
One child of parents by ops: crossover of two (distinct when there are two), else a named mutation of one.
fun <G> breedFrom(ops: GenomeOps<G>, parents: List<G>, rng: Random): Offspring<G> {
val i = rng.nextInt(parents.size)
val a = parents[i]
if (parents.size > 1) {
val j = (i + 1 + rng.nextInt(parents.size - 1)) % parents.size
ops.crossover(a, parents[j], rng)?.let { return Offspring(it, "cross") }
}
val m = ops.mutations[rng.nextInt(ops.mutations.size)]
return Offspring(m.apply(a, rng), m.name)
}