Skip to content

PopulationSearch.kt

src/jvmMain/kotlin/afield/study/search/PopulationSearch.kt · 829 lines

package and imports — 53 lines, from line 1
@file:afield.runtime.completion.CompletionCategory("Search")

package afield.study.search

import afield.data.collections.get
import afield.data.collections.times
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.document.workspace.path
import afield.geometry.mesh.quality
import afield.graphics.render.rangeTo
import afield.graphics.vulkan.buffers.add
import afield.math.autodiff.compareTo
import afield.math.autodiff.div
import afield.math.autodiff.mean
import afield.math.autodiff.minus
import afield.math.autodiff.plus
import afield.math.autodiff.times
import afield.math.functions.evolve
import afield.math.linalg.remAssign
import afield.math.linalg.set
import afield.math.linalg.times
import afield.math.units.div
import afield.math.units.minus
import afield.math.units.plus
import afield.math.units.times
import afield.simulation.agents.step
import afield.simulation.automata.step
import afield.study.ui.numbers

import afield.audio.synth.*
import afield.data.collections.invoke
import afield.data.collections.s
import afield.document.properties.clear
import afield.geometry.mesh.summary
import afield.graphics.render.label
import afield.math.autodiff.maxOf
import afield.math.autodiff.minOf
import afield.math.autodiff.sqrt
import afield.math.autodiff.sum
import afield.math.functions.behaviour
import afield.math.linalg.d
import afield.math.linalg.i
import afield.math.linalg.invoke
import afield.math.statistics.random
import afield.runtime.execution.cell
import afield.runtime.execution.start
import afield.runtime.lifecycle.cached
import kotlin.random.Random

Population search over any genome type — the generic drivers that alife_evolve.kt, FunctionEvolve.kt and afield.simulation.kratos.KratosStudy all run on. (afield.lang.morpho.MorphoSearch keeps its own.)

A caller supplies two things and nothing else. GenomeOps says how to make a genome, how to vary one, and (optionally) how to recombine two; Objective says what a genome scored. The five drivers in PopulationSearch then spend a budget of evaluations against that pair: PopulationSearch.random draws independently and keeps the best, PopulationSearch.hillClimb walks one point, PopulationSearch.evolve runs a (µ+λ) generational loop with tournament or lexicase selection, PopulationSearch.novelty replaces the objective's own fitness with distance from an archive of behaviours, and PopulationSearch.mapElites keeps the best genome per cell of a grid over the behaviour space rather than one winner.

PopulationSearch.random is the control arm. It draws from exactly the distribution the other four mutate within, so a difference between their results is a difference in selection rather than in the generator. Reporting an evolutionary result without it says nothing: a generator good enough to stumble onto a decent genome occasionally looks like a successful search. MorphoSearch reports the same pair for the same reason.

Budget is evaluations, not generations. A population of 24 and a hill climb of 1 do very different amounts of work per generation, so a per-generation budget would compare budgets rather than methods. SearchRun.trace is likewise best-so-far per evaluation.

What determinism means here. Given the same GenomeOps, the same Objective and the same seed, a driver returns the same SearchRun: the same best genome, the same trace, the same evaluation count, every time. It is one seeded Random threaded through every draw, and evaluation is sequential, because parallel evaluation would reorder those draws and throw the property away. It says nothing about thread safety, and it holds only if the caller's mutate and evaluate are themselves deterministic functions of their arguments.

class Variation<G>(val name: String, val apply: (G, Random) -> G) {

A named variation operator, so a run can report which mutation produced each improvement.

class Variation<G>(val name: String, val apply: (G, Random) -> G) {

override fun toString(): String = name

The operator's name, which is what a run records against each improvement.

interface GenomeOps<G>

Everything a population search needs to know about a genome type.

The three optional members default to declining rather than to faking: a genome with no recombination says so by leaving crossover alone, instead of returning one parent unchanged and letting evolve believe it recombined.

interface GenomeOps<G> {

fun random(rng: Random): G

An independent draw from the genome distribution. The control arm samples exactly this.

val mutations: List<Variation<G>>

The variation operators, at least one. evolve picks uniformly among them and records the name.

fun crossover(a: G, b: G, rng: Random): G? = null

Recombine two parents, or null — the default — to declare that this genome has no recombination.

null rather than "return a parent" because evolve then never spends its crossoverRate budget, and SearchRun.recombined reports zero. A default that returned one parent would make a mutation-only search look like a GA.

fun signature(g: G): String? = null

A stable string identifying a genome's phenotype, or null — the default — for no memoisation.

When non-null a repeat evaluation is served from cache and costs no budget, which is the right accounting when evaluation is the expensive resource and mutation is often neutral. The default is null so a caller who has not thought about it gets the strict reading, where every candidate costs one unit.

class Evaluation

What one candidate scored.

class Evaluation(
    /** The scalar, **higher is better**. Drives tournament selection and the trace. */
    val fitness: Double,
    /**
     * Per-test-case scores, higher better, for [Selection.LEXICASE]. Empty means the objective has
     * no case structure, and lexicase then degenerates to a uniform random draw — which is why
     * [evolve] rejects that combination instead of selecting at random.
     */
    val cases: DoubleArray = DoubleArray(0),
    /**
     * A fixed-length behavioural descriptor for [PopulationSearch.novelty]. Empty means the
     * objective offers none.
     */
    val behaviour: DoubleArray = DoubleArray(0)
) {

override fun toString(): String

The fitness and the sizes of cases and behaviour, for a log line.

    override fun toString(): String = "Evaluation(%.5f, %d case(s), %d descriptor(s))"
        .format(fitness, cases.size, behaviour.size)
}

fun interface Objective<G>

Scores a genome. Higher is better, always — a cost function is negated by its caller, not here.

fun interface Objective<G> {

fun evaluate(genome: G): Evaluation

Score one genome. A throw is caught and read as negative infinity, so a candidate the objective cannot evaluate costs its budget and loses rather than ending the run.

fun <G> objectiveOf(score: (G) -> Double): Objective<G> = Objective { Evaluation(score(it)) }

Build an Objective from a plain scalar score.

class Individual<G>(val genome: G, val evaluation: Evaluation, val origin: String)

A genome and what it scored.

class Individual<G>(val genome: G, val evaluation: Evaluation, val origin: String) {

val fitness: Double get() = evaluation.fitness

Shorthand for Evaluation.fitness, which is what selection and the trace compare.

override fun toString(): String = "%.5f via %s".format(fitness, origin)

The fitness and the origin that produced it, as the drivers' logs print it.

class Improvement<G>

One generation in which the population's leader improved.

class Improvement<G>(
    /** 1-based generation index. */
    val generation: Int,
    /** What the new leader scored. */
    val fitness: Double,
    /** The variation operator that produced it, or `"seed"`/`"random"`/`"immigrant"`. */
    val origin: String,
    /**
     * The new leader itself, so the list of improvements *is* a lineage and a figure can show what
     * the search looked like as it went rather than only where it ended up.
     */
    val genome: G
) {

override fun toString(): String = "gen $generation: %.5f via %s".format(fitness, origin)

The generation, the fitness and the origin, as a run's improvement log prints it.

enum class Selection

How PopulationSearch.evolve picks a parent.

enum class Selection {

Best of k by scalar fitness.

    TOURNAMENT,

Lexicase: shuffle the cases, keep only the individuals best on the first, then best on the second, until one is left.

It never averages, which is the point. An individual that is uniquely good on one hard case and hopeless on aggregate is invisible to a tournament and is selected by lexicase whenever that case comes up first. Requires Evaluation.cases to be non-empty; PopulationSearch.evolve rejects the combination otherwise rather than degenerating into a uniform random draw.

    LEXICASE
}

class SearchRun<G>

What one search run did.

declaration — 43 lines, from line 214
class SearchRun<G>(
    /** Which driver this was: `random`, `hill climb`, `evolve`, `novelty` or `map-elites`. */
    val label: String,
    /** The best genome the run evaluated, or `null` if it never evaluated one. */
    val best: Individual<G>?,
    /**
     * Times the objective was actually called. A cache hit costs none of this, so it is the budget
     * the run spent rather than the number of genomes it looked at.
     */
    val evaluations: Int,
    /**
     * Best-so-far fitness after each **evaluation**, so two drivers at the same budget are directly
     * comparable however differently they are shaped internally.
     */
    val trace: DoubleArray,
    /**
     * Genomes offered to the evaluator, including cache hits. Well above [evaluations] means the
     * driver is re-treading; see [GenomeOps.signature].
     */
    val proposals: Int,
    /**
     * How many children came from [GenomeOps.crossover]. Zero for a genome that declares no
     * recombination, and the number to look at before calling a run "a GA".
     */
    val recombined: Int,
    /**
     * How many children were fresh draws rather than offspring, from [PopulationSearch.evolve]'s
     * `immigrantRate`. Zero for every other driver.
     */
    val immigrants: Int,
    /** Generations the loop completed. Zero for the drivers that have no generation. */
    val generations: Int,
    /**
     * The population the run ended with — the last generation from [PopulationSearch.evolve], the
     * filled cells from [PopulationSearch.mapElites], the point [PopulationSearch.hillClimb]
     * stopped on. Always empty for [PopulationSearch.random], which keeps no population.
     */
    val survivors: List<Individual<G>>,
    /** Wall-clock milliseconds for the whole run, the caller's objective included. */
    val millis: Double,
    /** Every generation in which the leader improved, in order. Empty for a non-generational driver. */
    val improvements: List<Improvement<G>> = emptyList()
) {

val bestFitness: Double get() = best?.fitness ?: Double.NEGATIVE_INFINITY

The scalar the run reached, or negative infinity if it never evaluated anything.

val initialFitness: Double get() = if (trace.isEmpty()) Double.NEGATIVE_INFINITY else trace[0]

Best-so-far at the first evaluation — the one-shot baseline every later point improves on.

fun summary(): String

One aligned line for a comparison table: the label, the budget spent, the best fitness, the generations and the proposals, with crossover and immigrant counts only when non-zero.

    fun summary(): String = buildString {
        append("%-18s %5d evals".format(label, evaluations))
        append("  best %10.5f".format(bestFitness))
        append("  gens %3d".format(generations))
        append("  proposals %5d".format(proposals))
        if (recombined > 0) append("  crossover %d".format(recombined))
        if (immigrants > 0) append("  immigrants %d".format(immigrants))
        append("  %.1f s".format(millis / 1000.0))
    }

override fun toString(): String = summary()

The run's summary line.

object PopulationSearch

The drivers. All four take the same budget and return the same shape of result.

object PopulationSearch {

const val PROPOSAL_LIMIT: Int = 200

Proposals a driver may make per unit of budget before it is judged stuck and stopped.

fun <G> random

Draw budget independent genomes and keep the best. The control.

Anything hillClimb or evolve claims has to be claimed against this, at the same budget, from the same GenomeOps and with the same seed policy.

    fun <G> random(
        ops: GenomeOps<G>,
        objective: Objective<G>,
        budget: Int,
        seed: Long = 1L,
        label: String = "random"
    ): SearchRun<G> {
        val s = Session(ops, objective, seed, label)
        while (s.running(budget)) s.consider(ops.random(s.rng), "random")
        return s.finish()
    }

fun <G> hillClimb

Accept any variation that is not worse, restart when stuck.

"Not worse" rather than "better" is not a detail. Genome spaces are full of neutral changes, and a climber that refuses them sits on the first local optimum it meets. Neutral drift is how it walks along a plateau to somewhere a single mutation can improve.

@param patience non-improving steps before abandoning the current point for a fresh random one

    fun <G> hillClimb(
        ops: GenomeOps<G>,
        objective: Objective<G>,
        budget: Int,
        seed: Long = 1L,
        patience: Int = 40,
        start: G? = null,
        label: String = "hill climb"
    ): SearchRun<G> {
        require(ops.mutations.isNotEmpty()) { "hillClimb needs at least one mutation" }
        val s = Session(ops, objective, seed, label)
        if (!s.running(budget)) return s.finish()
        var current = s.consider(start ?: ops.random(s.rng), "seed")
        var sinceImprovement = 0
        while (s.running(budget)) {
            val v = ops.mutations[s.rng.nextInt(ops.mutations.size)]
            val candidate = s.consider(s.vary(v, current.genome), v.name)
            if (candidate.fitness > current.fitness) sinceImprovement = 0 else sinceImprovement++
            if (candidate.fitness >= current.fitness) current = candidate
            if (sinceImprovement >= patience && s.running(budget)) {
                current = s.consider(ops.random(s.rng), "restart")
                sinceImprovement = 0
            }
        }
        return s.finish(listOf(current))
    }

fun <G> evolve

A (µ+λ) generational search: elitist truncation, tournament or lexicase selection, optional recombination and optional random immigrants.

Elitist because parents survive into the truncation, so the best genome found can never be lost to a bad generation. λ children are produced per generation and the whole µ+λ pool is cut back to µ.

µ = 1 is a legal and meaningful configuration: a single incumbent, λ children, keep the best. That is the shape FunctionEvolve.evolve has always had, and it is why the parent draw is skipped outright when the pool holds one individual — a tournament over a population of one has no decision to make, so drawing for it would only burn entropy. For the same reason a crossoverRate or immigrantRate of exactly 0.0 skips its draw rather than making one whose comparison can never succeed.

@param mu parents kept after truncation, >= 1 @param lambda children per generation, >= 1 @param tournament tournament size k, ignored under Selection.LEXICASE @param crossoverRate probability a child comes from GenomeOps.crossover before mutation; silently inert for a genome that declares no recombination @param immigrantRate probability a child is a fresh random draw instead. Without immigrants an elitist truncation on a deceptive landscape converges early and then re-evaluates its converged point forever. @param elitistMutation when true (the default) a crossover child is also mutated @param seeds genomes to start the population from, e.g. the survivors of a previous stage

body — 109 lines, from line 367
    fun <G> evolve(
        ops: GenomeOps<G>,
        objective: Objective<G>,
        budget: Int,
        seed: Long = 1L,
        mu: Int = 8,
        lambda: Int = 16,
        tournament: Int = 2,
        selection: Selection = Selection.TOURNAMENT,
        crossoverRate: Double = 0.0,
        immigrantRate: Double = 0.0,
        elitistMutation: Boolean = true,
        seeds: List<G> = emptyList(),
        label: String = "evolve",
        onGeneration: ((generation: Int, evaluations: Int) -> Unit)? = null
    ): SearchRun<G> = evolveWithHook(
        ops, objective, budget, seed, mu, lambda, tournament, selection,
        crossoverRate, immigrantRate, elitistMutation, seeds, label, null, onGeneration
    )

    private fun <G> evolveWithHook(
        ops: GenomeOps<G>,
        objective: Objective<G>,
        budget: Int,
        seed: Long,
        mu: Int,
        lambda: Int,
        tournament: Int,
        selection: Selection,
        crossoverRate: Double,
        immigrantRate: Double,
        elitistMutation: Boolean,
        seeds: List<G>,
        label: String,
        hook: ((Individual<G>) -> Unit)?,
        onGeneration: ((generation: Int, evaluations: Int) -> Unit)? = null
    ): SearchRun<G> {
        require(mu >= 1) { "evolve needs mu >= 1, got $mu" }
        require(lambda >= 1) { "evolve needs lambda >= 1, got $lambda" }
        require(tournament >= 1) { "tournament size must be >= 1, got $tournament" }
        require(crossoverRate in 0.0..1.0) { "crossoverRate must be in 0..1, got $crossoverRate" }
        require(immigrantRate in 0.0..1.0) { "immigrantRate must be in 0..1, got $immigrantRate" }
        require(ops.mutations.isNotEmpty()) { "evolve needs at least one mutation" }

        val s = Session(ops, objective, seed, label, hook)
        val population = ArrayList<Individual<G>>(mu + lambda)
        for (g in seeds.take(mu)) {
            if (!s.running(budget)) break
            population += s.consider(g, "seed")
        }
        while (population.size < mu && s.running(budget)) {
            population += s.consider(ops.random(s.rng), "random")
        }
        // Lexicase over a population that carries no per-case scores is a uniform random draw
        // wearing a selection operator's name. Refuse rather than silently become the control arm.
        if (selection == Selection.LEXICASE) {
            require(population.any { it.evaluation.cases.isNotEmpty() }) {
                "Selection.LEXICASE needs Evaluation.cases; this objective supplies none, and " +
                    "lexicase over empty case vectors is a uniform random draw"
            }
        }
        var leaderFitness = population.maxOfOrNull { it.fitness } ?: Double.NEGATIVE_INFINITY

        while (s.running(budget)) {
            s.generations++
            val children = ArrayList<Individual<G>>(lambda)
            repeat(lambda) {
                if (!s.running(budget)) return@repeat
                val takeImmigrant = immigrantRate > 0.0 && s.rng.nextDouble() < immigrantRate
                if (takeImmigrant) {
                    s.immigrants++
                    children += s.consider(ops.random(s.rng), "immigrant")
                    return@repeat
                }
                val parent = select(population, tournament, selection, s.rng)
                val takeCrossover = crossoverRate > 0.0 && population.size > 1 &&
                    s.rng.nextDouble() < crossoverRate
                val recombinant: G? = if (!takeCrossover) null else {
                    val other = select(population, tournament, selection, s.rng)
                    ops.crossover(parent.genome, other.genome, s.rng)?.also { s.recombined++ }
                }
                val base = recombinant ?: parent.genome
                val prefix = if (recombinant == null) "" else "crossover+"
                if (recombinant != null && !elitistMutation) {
                    children += s.consider(base, "crossover")
                } else {
                    val v = ops.mutations[s.rng.nextInt(ops.mutations.size)]
                    children += s.consider(s.vary(v, base), prefix + v.name)
                }
            }
            population += children
            // Sort descending by fitness. `sortWith` on a list is TimSort and therefore stable, so
            // a child that merely *ties* the incumbent does not displace it — the strict-improvement
            // rule (1+λ) hill climbing has always had, generalised to any µ.
            population.sortWith(compareByDescending { it.fitness })
            if (population.size > mu) {
                val kept = truncate(ops, population, mu)
                population.clear()
                population.addAll(kept)
            }
            val leader = population.firstOrNull()
            if (leader != null && leader.fitness > leaderFitness) {
                leaderFitness = leader.fitness
                s.improvements += Improvement(s.generations, leader.fitness, leader.origin, leader.genome)
            }
            onGeneration?.invoke(s.generations, s.evaluations)
        }
        return s.finish(population.toList())
    }

fun <G> novelty

Novelty search: score a genome by how unlike everything already found it is, not by how good.

Each candidate's novelty is the mean behavioural distance to its neighbours nearest archive members — the canonical k-nearest formulation. An empty archive gives zero novelty, so the first generation is driven purely by validityWeight, a deliberate and deterministic cold start. The archive grows with every candidate whose novelty clears archiveThreshold, so the landscape this search climbs is one it is itself flattening.

The objective's own Evaluation.fitness is not used for selection. It is still evaluated and still recorded in SearchRun.trace, so a novelty run can be read against a fitness run on the same axes; NoveltyRun.archive is what the run is actually for.

@param validityWeight weight on the objective's fitness, squashed into 0..1 by validity, added to novelty. Zero is pure novelty. @param validity maps the objective's fitness onto 0..1; the default treats any finite fitness as fully valid and a non-finite one as worthless, which is the minimum a novelty search needs to avoid filling its archive with genomes that crashed.

body — 48 lines, from line 496
    fun <G> novelty(
        ops: GenomeOps<G>,
        objective: Objective<G>,
        budget: Int,
        seed: Long = 1L,
        mu: Int = 8,
        lambda: Int = 16,
        neighbours: Int = 3,
        validityWeight: Double = 0.0,
        archiveThreshold: Double = 0.0,
        validity: (Double) -> Double = { if (it.isFinite()) 1.0 else 0.0 },
        label: String = "novelty"
    ): NoveltyRun<G> {
        require(neighbours >= 1) { "neighbours must be >= 1, got $neighbours" }
        val archive = ArrayList<DoubleArray>()
        val archived = ArrayList<Individual<G>>()
        var descriptorLength = -1
        val wrapped = Objective<G> { genome ->
            val e = objective.evaluate(genome)
            require(e.behaviour.isNotEmpty()) {
                "PopulationSearch.novelty needs Evaluation.behaviour; this objective supplies none"
            }
            if (descriptorLength < 0) descriptorLength = e.behaviour.size
            require(e.behaviour.size == descriptorLength) {
                "behaviour descriptors must all be the same length, got ${e.behaviour.size} " +
                    "after $descriptorLength"
            }
            val n = meanDistanceToNearest(e.behaviour, archive, neighbours)
            Evaluation(n + validityWeight * validity(e.fitness), e.cases, e.behaviour)
        }
        // Novelty search IS [evolve] under a moving objective. It gets the archive-growing hook and
        // nothing else — no second population loop, because a second loop would be a second engine
        // and the two would drift.
        val run = evolveWithHook(
            ops, wrapped, budget, seed, mu, lambda,
            tournament = 2, selection = Selection.TOURNAMENT,
            crossoverRate = 0.0, immigrantRate = 0.0, elitistMutation = true,
            seeds = emptyList(), label = label,
            hook = { individual ->
                val b = individual.evaluation.behaviour
                if (b.isNotEmpty() && meanDistanceToNearest(b, archive, neighbours) >= archiveThreshold) {
                    archive += b
                    archived += individual
                }
            }
        )
        return NoveltyRun(run, archived.toList())
    }

fun <G> mapElites

MAP-Elites: keep the best genome found in each cell of a grid over the behaviour space.

A quality-diversity search rather than an optimisation. The archive is a grid over Evaluation.behaviour, one axis per axes entry, and a candidate is kept only if its cell is empty or its occupant is worse. Illumination rather than convergence: what comes out is not one answer but a map of "the best thing that behaves like this", which for these genomes is exactly the contact sheet a reader wants to look at.

The loop is the canonical one. initial random draws seed the archive, then each remaining unit of budget picks an occupied cell uniformly, mutates its elite, and offers the child back. Picking uniformly over occupied cells rather than over all evaluated genomes is what keeps the search from pouring its whole budget into whichever region is easiest to reach.

A candidate whose descriptor falls outside axes is clamped into the edge cell, not discarded. Discarding would silently make the reported MapElitesRun.coverage a fraction of a different denominator than the one the caller asked for.

Coverage depends on mutation step size against bin width, and it is easy to be disappointed here. After initial the only way to reach a new cell is for a mutation to move a descriptor across a bin boundary. If a typical mutation moves the descriptor by much less than one bin, the archive grows barely at all past its seed draws, and plain random sampling at the same budget will cover more cells — measured, twice, in this repository's own tests. That is not a defect in the search: what illumination buys is the quality held in each occupied cell, and MapElitesRun.qdScore rather than MapElitesRun.coverage is the number to compare. If coverage is what you want, widen the bins or add a coarser mutation.

@param axes the inclusive range of each behaviour dimension @param bins how many cells each axis is divided into; same length as axes @param initial random draws before the archive starts driving the search

body — 55 lines, from line 576
    fun <G> mapElites(
        ops: GenomeOps<G>,
        objective: Objective<G>,
        budget: Int,
        axes: List<ClosedFloatingPointRange<Double>>,
        bins: List<Int>,
        seed: Long = 1L,
        initial: Int = 32,
        label: String = "map-elites"
    ): MapElitesRun<G> {
        require(axes.isNotEmpty()) { "mapElites needs at least one axis" }
        require(axes.size == bins.size) { "axes and bins must be the same length, got ${axes.size} and ${bins.size}" }
        require(bins.all { it >= 1 }) { "every bin count must be >= 1, got $bins" }
        require(axes.all { it.endInclusive > it.start }) { "every axis must be increasing, got $axes" }
        require(initial >= 1) { "initial must be >= 1, got $initial" }
        require(ops.mutations.isNotEmpty()) { "mapElites needs at least one mutation" }

        val elites = LinkedHashMap<List<Int>, Individual<G>>()
        var offGrid = 0
        val s = Session(ops, objective, seed, label)

        fun offer(individual: Individual<G>) {
            val b = individual.evaluation.behaviour
            require(b.size == axes.size) {
                "mapElites expects a ${axes.size}-dimensional behaviour descriptor, got ${b.size}"
            }
            var clamped = false
            val cell = ArrayList<Int>(axes.size)
            for (d in axes.indices) {
                val axis = axes[d]
                if (b[d] < axis.start || b[d] > axis.endInclusive) clamped = true
                val t = ((b[d] - axis.start) / (axis.endInclusive - axis.start)).coerceIn(0.0, 1.0)
                cell += (t * bins[d]).toInt().coerceIn(0, bins[d] - 1)
            }
            if (clamped) offGrid++
            val incumbent = elites[cell]
            if (incumbent == null || individual.fitness > incumbent.fitness) elites[cell] = individual
        }

        repeat(initial) {
            if (!s.running(budget)) return@repeat
            offer(s.consider(ops.random(s.rng), "random"))
        }
        while (s.running(budget)) {
            if (elites.isEmpty()) {
                offer(s.consider(ops.random(s.rng), "random"))
                continue
            }
            val keys = elites.keys.toList()
            val parent = elites[keys[s.rng.nextInt(keys.size)]]!!
            val v = ops.mutations[s.rng.nextInt(ops.mutations.size)]
            offer(s.consider(s.vary(v, parent.genome), v.name))
        }
        return MapElitesRun(s.finish(elites.values.toList()), elites.toMap(), bins.toList(), axes.toList(), offGrid)
    }

class MapElitesRun<G>

mapElites's result: the archive, and the two numbers a QD result is reported as.

@property offGridCandidates how many candidates had a descriptor outside axes and were clamped into an edge cell. A large fraction means the axes are wrong for this genome, and it is reported rather than hidden because clamped candidates pile up on the border and make the edges of the map look richer than they are.

    class MapElitesRun<G>(
        val run: SearchRun<G>,
        val elites: Map<List<Int>, Individual<G>>,
        val bins: List<Int>,
        val axes: List<ClosedFloatingPointRange<Double>>,
        val offGridCandidates: Int
    ) {

val cells: Int get() = bins.fold(1) { a, b -> a * b }

Total cells in the archive grid, occupied or not.

val coverage: Double get() = elites.size / cells.toDouble()

Fraction of cells that hold an elite. The "diversity" half of quality-diversity.

val qdScore: Double get() = elites.values.sumOf { it.fitness }

Sum of elite fitnesses over occupied cells — the standard QD score.

override fun toString(): String

        override fun toString(): String =
            "MapElitesRun(${elites.size}/$cells cells, coverage %.3f, QD %.3f)".format(coverage, qdScore)
    }

class NoveltyRun<G>(val run: SearchRun<G>, val archive: List<Individual<G>>)

novelty's result: the underlying run plus the behavioural archive it grew.

    class NoveltyRun<G>(val run: SearchRun<G>, val archive: List<Individual<G>>) {

val meanPairwiseDistance: Double

Mean pairwise behavioural distance across the archive — the spread of the set.

        val meanPairwiseDistance: Double
            get() {
                if (archive.size < 2) return 0.0
                var sum = 0.0
                var pairs = 0
                for (i in archive.indices) for (j in i + 1 until archive.size) {
                    sum += euclidean(archive[i].evaluation.behaviour, archive[j].evaluation.behaviour)
                    pairs++
                }
                return sum / pairs
            }

override fun toString(): String

        override fun toString(): String =
            "NoveltyRun(${archive.size} archived, spread %.4f)".format(meanPairwiseDistance)
    }

internals

internal fun <G> truncate(ops: GenomeOps<G>, sorted: List<Individual<G>>, mu: Int): List<Individual<G>>

Truncate to µ on distinct phenotypes where GenomeOps.signature can tell them apart.

µ copies of one genome is a population of one that costs µ evaluations a generation, and that is the shape premature convergence takes when mutation is often neutral. Without a signature there is nothing to deduplicate on and this is a plain truncation.

    internal fun <G> truncate(ops: GenomeOps<G>, sorted: List<Individual<G>>, mu: Int): List<Individual<G>> {
        val first = sorted.firstOrNull() ?: return emptyList()
        if (ops.signature(first.genome) == null) return sorted.take(mu)
        val kept = LinkedHashMap<String, Individual<G>>()
        val unkeyed = ArrayList<Individual<G>>()
        for (ind in sorted) {
            if (kept.size + unkeyed.size >= mu) break
            val key = ops.signature(ind.genome)
            if (key == null) unkeyed += ind else kept.putIfAbsent(key, ind)
        }
        return (kept.values + unkeyed).take(mu)
    }

internal fun <G> select

    internal fun <G> select(
        pool: List<Individual<G>>,
        k: Int,
        how: Selection,
        rng: Random
    ): Individual<G> {
        // A tournament over a population of one has no decision to make. Skipping the draw keeps
        // the mu=1 configuration's random stream identical to a plain (1+lambda) hill climb's.
        if (pool.size == 1) return pool[0]
        return when (how) {
            Selection.TOURNAMENT -> {
                var best = pool[rng.nextInt(pool.size)]
                repeat(k - 1) {
                    val other = pool[rng.nextInt(pool.size)]
                    if (other.fitness > best.fitness) best = other
                }
                best
            }

            Selection.LEXICASE -> {
                var survivors = pool
                val cases = pool.maxOf { it.evaluation.cases.size }
                if (cases == 0) return pool[rng.nextInt(pool.size)]
                for (c in (0 until cases).shuffled(rng)) {
                    if (survivors.size <= 1) break
                    val ceiling = survivors.maxOf { it.evaluation.cases.getOrElse(c) { Double.NEGATIVE_INFINITY } }
                    survivors = survivors.filter {
                        it.evaluation.cases.getOrElse(c) { Double.NEGATIVE_INFINITY } >= ceiling - 1e-12
                    }
                }
                survivors[rng.nextInt(survivors.size)]
            }
        }
    }

internal fun meanDistanceToNearest(point: DoubleArray, archive: List<DoubleArray>, k: Int): Double

Mean distance from point to its k nearest members of archive; 0 for an empty archive.

    internal fun meanDistanceToNearest(point: DoubleArray, archive: List<DoubleArray>, k: Int): Double {
        if (archive.isEmpty()) return 0.0
        val distances = archive.map { euclidean(point, it) }.sorted()
        val n = minOf(k, distances.size)
        var sum = 0.0
        for (i in 0 until n) sum += distances[i]
        return sum / n
    }

internal fun euclidean(a: DoubleArray, b: DoubleArray): Double

Euclidean distance between two equal-length descriptors.

    internal fun euclidean(a: DoubleArray, b: DoubleArray): Double {
        require(a.size == b.size) { "descriptors must share a length, got ${a.size} and ${b.size}" }
        var sumSq = 0.0
        for (i in a.indices) {
            val d = a[i] - b[i]
            sumSq += d * d
        }
        return kotlin.math.sqrt(sumSq)
    }

The bookkeeping every driver shares: evaluate, trace, remember the best.

Keeping it in one place is what guarantees the four runs are comparable — there is exactly one code path that decides what counts as an evaluation.

source — 64 lines, from line 765
    private class Session<G>(
        val ops: GenomeOps<G>,
        val objective: Objective<G>,
        seed: Long,
        val label: String,
        val hook: ((Individual<G>) -> Unit)? = null
    ) {
        val rng = Random(seed)
        val trace = ArrayList<Double>()
        var best: Individual<G>? = null
        var proposals = 0
        var recombined = 0
        var immigrants = 0
        var generations = 0
        val improvements = ArrayList<Improvement<G>>()
        val started = System.nanoTime()
        private val seen = HashMap<String, Evaluation>()

        val evaluations: Int get() = trace.size

        /**
         * Apply a variation, treating a throw as "the variation declined" rather than as a run
         * failure. A structural mutation that cannot apply to a particular genome is ordinary, and
         * the alternative is a search that dies on its first awkward candidate.
         */
        fun vary(v: Variation<G>, g: G): G = runCatching { v.apply(g, rng) }.getOrDefault(g)

        fun consider(genome: G, origin: String): Individual<G> {
            proposals++
            val key = ops.signature(genome)
            val cached = if (key == null) null else seen[key]
            val evaluation = cached
                ?: runCatching { objective.evaluate(genome) }
                    .getOrElse { Evaluation(Double.NEGATIVE_INFINITY) }
                    .also { if (key != null) seen[key] = it }
            val individual = Individual(genome, evaluation, origin)
            val incumbent = best
            if (incumbent == null || individual.fitness > incumbent.fitness) best = individual
            // A cache hit costs no budget: the resource is the evaluator, and it was not called.
            if (cached != null) return individual
            trace += best!!.fitness
            hook?.invoke(individual)
            return individual
        }

        /** Budget left, and the search is still producing genomes the evaluator has not seen. */
        fun running(budget: Int): Boolean =
            evaluations < budget && proposals < budget.toLong() * PROPOSAL_LIMIT

        fun finish(survivors: List<Individual<G>> = emptyList()): SearchRun<G> = SearchRun(
            label = label,
            best = best,
            evaluations = evaluations,
            trace = trace.toDoubleArray(),
            proposals = proposals,
            recombined = recombined,
            immigrants = immigrants,
            generations = generations,
            survivors = survivors,
            millis = (System.nanoTime() - started) / 1e6,
            improvements = improvements.toList()
        )
    }
}