Skip to content

ColorRpnGenome.kt

src/jvmMain/kotlin/afield/lang/rpn/ColorRpnGenome.kt · 265 lines

@file:afield.runtime.completion.CompletionCategory("Search")

package afield.lang.rpn

import afield.study.search.GenomeOps
import afield.study.search.Variation
import kotlin.math.max
import kotlin.math.min
import kotlin.math.roundToInt
import kotlin.random.Random

The breeding rules for ColorRpn programs: what a child of two programs is, and the eight mutations that make one.

ColorRpnBreeding is the knobs, with the madp GA demo's values as the defaults: how many mutations a child gets, the length it is nudged toward, the chance of splicing a slice out of the other parent, and how often each of the eight mutations is chosen. ColorRpnGenome applies them, and because it implements GenomeOps the same genome drives both halves of afield.study.search: the picture grid of EyeEvolution.kt, where you pick the parents, and the scored drivers of PopulationSearch.kt, where an Objective picks them.

A mutation can produce a program that makes no sense, and nothing rejects it. ColorRpn reads any token sequence as some picture, which is what lets a search of this space run without a validity test.

data class ColorRpnBreeding

How the GA demo breeds ColorRpn programs. The defaults are the demo's.

data class ColorRpnBreeding(
    /**
     * Scales how many mutations a child gets: about `mutationRate × targetLength × (0.45..0.85)`,
     * and at least one with probability `mutationRate × 2.5` when that rounds to none.
     */
    val mutationRate: Double = 0.12,
    /** The length a child is nudged toward, give or take two tokens (4..96). */
    val targetLength: Int = 18,
    /**
     * The chance that a child, before mutating, takes a slice of one to three tokens from the
     * other parent.
     */
    val seedSplice: Double = 0.42,
    /** The relative frequency of each mutation, by the names [ColorRpnGenome.mutations] uses. */
    val weights: Map<String, Double> = DEFAULT_WEIGHTS,
    /**
     * Whether a child is grown or trimmed toward [targetLength] after mutating; `false` leaves it
     * as long as its mutations made it.
     */
    val nudgeLength: Boolean = true,
) {

companion object

    companion object {

val DEFAULT_WEIGHTS: Map<String, Double>

The demo's mix: a number tweaked 33%, a number turned into a variable 19%, an operator swapped 20%, a slice spliced in from the other parent 10%, a slice of itself copied 6%, one or two tokens dropped 6%, a random token added 4.5%, two tokens swapped 1.5%.

        val DEFAULT_WEIGHTS: Map<String, Double> = linkedMapOf(
            "num" to 0.33, "var" to 0.19, "op" to 0.20, "splice" to 0.10,
            "copy" to 0.06, "drop" to 0.06, "add" to 0.045, "swap" to 0.015,
        )
    }
}

class ColorRpnGenome(val breeding: ColorRpnBreeding = ColorRpnBreeding()) : GenomeOps<List<String>>

ColorRpn programs as a genome: a list of tokens, bred the way the madp GA demo breeds them.

crossover is the demo's child: copy one parent at random; with chance ColorRpnBreeding.seedSplice splice in one to three tokens from the other; apply a few weighted mutations (each may borrow from the other parent); then nudge the length to ColorRpnBreeding.targetLength ± 2, at most 96 tokens. mutations are the same eight operators by name, each borrowing from the genome itself, so PopulationSearch can drive this genome with a computed objective as well as an eye.

Programs that fail to make sense are not rejected: the language reads anything (see ColorRpn).

class ColorRpnGenome(val breeding: ColorRpnBreeding = ColorRpnBreeding()) : GenomeOps<List<String>> {

override fun random(rng: Random): List<String> = randomProgram(breeding.targetLength, rng)

A randomProgram of ColorRpnBreeding.targetLength tokens.

override val mutations: List<Variation<List<String>>>

One Variation per mutation named in ColorRpnBreeding.weights, each applying only that mutation. The weights are not consulted here — evolve picks uniformly among the operators — so they shape crossover's children and not a mutation-only search.

    override val mutations: List<Variation<List<String>>> = breeding.weights.keys.map { name ->
        Variation(name) { g: List<String>, rng: Random -> g.toMutableList().also { apply(name, it, g, rng) } }
    }

override fun crossover(a: List<String>, b: List<String>, rng: Random): List<String>

The tokens of child, dropping the mutation names it also reports.

    override fun crossover(a: List<String>, b: List<String>, rng: Random): List<String> = child(a, b, rng).tokens

override fun signature(g: List<String>): String

The genome compiled and renormalised, so two token lists that draw the same picture share one evaluation. Tokens the language does not know are dropped by compiling, so they never distinguish two genomes.

    override fun signature(g: List<String>): String = ColorRpn.compile(g.joinToString(" ")).normalized

class Child(val tokens: List<String>, val notes: List<String>)

A child and the names of the mutations that made it (the demo's tile caption, e.g. seed+num+op).

    class Child(val tokens: List<String>, val notes: List<String>) {

val summary: String get() = notes.take(4).joinToString("+").ifEmpty { "mut" }

The first four notes joined by +, or mut when there were none.

fun child(parentA: List<String>, parentB: List<String>, rng: Random): Child

One child of parentA and parentB; see the class description.

    fun child(parentA: List<String>, parentB: List<String>, rng: Random): Child {
        val fromA = rng.nextDouble() < 0.5
        var base = (if (fromA) parentA else parentB).toMutableList()
        val donor = if (fromA) parentB else parentA
        val notes = mutableListOf<String>()
        if (base.isEmpty() && donor.isEmpty()) base = mutableListOf("0.5")
        if (rng.nextDouble() < breeding.seedSplice && donor.isNotEmpty()) {
            val slice = randomSlice(donor, rng)
            val start = rng.nextInt(base.size + 1)
            val remove = if (base.isNotEmpty()) rng.nextInt(min(2, base.size - min(start, base.size - 1) + 1)) else 0
            splice(base, start, remove, slice)
            notes += "seed"
        }
        val length = breeding.targetLength.coerceIn(4, 96)
        var count = (breeding.mutationRate * length * (0.45 + rng.nextDouble() * 0.4)).roundToInt()
        if (count < 1 && rng.nextDouble() < breeding.mutationRate * 2.5) count = 1
        repeat(count) { _ -> mutate(base, donor, rng, notes) }
        val target = max(4, (length + (rng.nextDouble() * 2 - 1) * 2).roundToInt())
        if (breeding.nudgeLength) nudgeLength(base, donor, target, rng)
        return Child(base.take(96), notes)
    }

fun children(parentA: List<String>, parentB: List<String>, count: Int, rng: Random): List<Child>

count children of the two parents, as the demo's grid has seven.

    fun children(parentA: List<String>, parentB: List<String>, count: Int, rng: Random): List<Child> =
        List(count) { _ -> child(parentA, parentB, rng) }

fun randomProgram(length: Int, rng: Random): List<String>

A random program of length tokens: a third numbers, a third variables, a third operators.

    fun randomProgram(length: Int, rng: Random): List<String> = List(length) { _ -> randomToken(rng) }

fun mutate(tokens: MutableList<String>, donor: List<String>, rng: Random, notes: MutableList<String>)

Apply one mutation, chosen by ColorRpnBreeding.weights, to tokens in place, borrowing slices from donor.

    fun mutate(tokens: MutableList<String>, donor: List<String>, rng: Random, notes: MutableList<String>) {
        val total = breeding.weights.values.sum()
        var roll = rng.nextDouble() * total
        var chosen = breeding.weights.keys.last()
        for ((name, w) in breeding.weights) {
            if (roll < w) { chosen = name; break }
            roll -= w
        }
        notes += apply(chosen, tokens, donor, rng)
    }

One named mutation; returns the note the demo records for it.

source — 115 lines, from line 150
    private fun apply(name: String, tokens: MutableList<String>, donor: List<String>, rng: Random): String {
        if (tokens.isEmpty()) tokens += randomToken(rng)
        when (name) {
            "num" -> {
                var idx = pickIndex(tokens, rng) { ColorRpn.number(it) != null }
                if (idx < 0) idx = rng.nextInt(tokens.size + 1)
                if (idx >= tokens.size) tokens.add(idx, randomNumber(rng)) else tokens[idx] = tweakNumber(tokens[idx], rng)
            }
            "var" -> {
                val idx = pickIndex(tokens, rng) { ColorRpn.number(it) != null }
                if (idx >= 0) tokens[idx] = randomVariable(rng) else tokens.add(rng.nextInt(tokens.size + 1), randomVariable(rng))
            }
            "op" -> {
                // as in the demo, "any word" includes the variables, so a variable can become an operator
                val idx = pickIndex(tokens, rng) { it in ColorRpn.operators || it in ColorRpn.variables }
                if (idx >= 0) tokens[idx] = randomOperator(rng) else tokens.add(rng.nextInt(tokens.size + 1), randomOperator(rng))
            }
            "splice" -> {
                val slice = if (donor.isNotEmpty()) randomSlice(donor, rng) else listOf(randomToken(rng))
                val at = rng.nextInt(tokens.size + 1)
                val remove = if (tokens.isNotEmpty()) rng.nextInt(min(2, tokens.size - at + 1)) else 0
                splice(tokens, at, remove, slice)
            }
            "copy" -> {
                val slice = randomSlice(tokens, rng)
                splice(tokens, rng.nextInt(tokens.size + 1), 0, slice)
            }
            "drop" -> {
                if (tokens.size > 1) {
                    val idx = rng.nextInt(tokens.size)
                    val remove = 1 + rng.nextInt(min(2, tokens.size - idx))
                    splice(tokens, idx, remove, emptyList())
                } else {
                    tokens[0] = randomToken(rng)
                }
            }
            "add" -> tokens.add(rng.nextInt(tokens.size + 1), randomToken(rng))
            "swap" -> {
                if (tokens.size > 1) {
                    val i = rng.nextInt(tokens.size)
                    val j = rng.nextInt(tokens.size)
                    val t = tokens[i]; tokens[i] = tokens[j]; tokens[j] = t
                } else {
                    tokens += randomToken(rng)
                    return "grow"
                }
            }
            else -> throw IllegalArgumentException("unknown mutation '$name'; the mutations are ${ColorRpnBreeding.DEFAULT_WEIGHTS.keys}")
        }
        return name
    }

    private fun nudgeLength(tokens: MutableList<String>, donor: List<String>, target: Int, rng: Random) {
        while (tokens.size < target) {
            if (rng.nextDouble() < 0.25 && donor.isNotEmpty()) splice(tokens, rng.nextInt(tokens.size + 1), 0, randomSlice(donor, rng))
            else tokens.add(rng.nextInt(tokens.size + 1), randomToken(rng))
            if (tokens.size > target + 4) break
        }
        while (tokens.size > target && tokens.size > 1) {
            val start = rng.nextInt(tokens.size)
            val count = 1 + rng.nextInt(min(3, tokens.size - start))
            splice(tokens, start, count, emptyList())
        }
        if (tokens.isEmpty()) tokens += randomToken(rng)
    }

    private fun splice(tokens: MutableList<String>, at: Int, remove: Int, insert: List<String>) {
        repeat(remove) { _ -> if (at < tokens.size) tokens.removeAt(at) }
        tokens.addAll(min(at, tokens.size), insert)
    }

    private fun randomSlice(tokens: List<String>, rng: Random): List<String> {
        if (tokens.isEmpty()) return emptyList()
        val start = rng.nextInt(tokens.size)
        val span = 1 + rng.nextInt(min(3, tokens.size))
        return tokens.subList(start, min(tokens.size, start + span)).toList()
    }

    private fun pickIndex(tokens: List<String>, rng: Random, predicate: (String) -> Boolean): Int {
        val matches = tokens.indices.filter { predicate(tokens[it]) }
        return if (matches.isEmpty()) -1 else matches[rng.nextInt(matches.size)]
    }

    private fun tweakNumber(token: String, rng: Random): String {
        var n = ColorRpn.number(token) ?: return randomNumber(rng)
        val mode = rng.nextDouble()
        n = when {
            mode < 0.34 -> n + (rng.nextDouble() * 2 - 1) * 0.18
            mode < 0.67 -> n * (0.55 + rng.nextDouble() * 1.4)
            rng.nextDouble() < 0.7 -> rng.nextDouble()
            else -> rng.nextDouble() * 4 - 2
        }
        return ColorRpn.format(n)
    }

    private fun randomNumber(rng: Random): String {
        val roll = rng.nextDouble()
        val v = when {
            roll < 0.55 -> rng.nextDouble()
            roll < 0.8 -> rng.nextDouble() * 4 - 2
            else -> (if (rng.nextDouble() < 0.5) -1 else 1) * (1 + rng.nextDouble() * 12)
        }
        return ColorRpn.format(v)
    }

    private fun randomVariable(rng: Random) = ColorRpn.variables[rng.nextInt(ColorRpn.variables.size)]

    private fun randomOperator(rng: Random) = ColorRpn.operators[rng.nextInt(ColorRpn.operators.size)]

    private fun randomToken(rng: Random): String = when (rng.nextInt(3)) {
        0 -> randomNumber(rng)
        1 -> randomVariable(rng)
        else -> randomOperator(rng)
    }
}