FunctionEvolve.kt
src/jvmMain/kotlin/afield/math/functions/FunctionEvolve.kt · 529 lines
package and imports — 53 lines, from line 1
@file:afield.runtime.completion.CompletionCategory("Function")
package afield.math.functions
import afield.data.collections.get
import afield.data.collections.invoke
import afield.data.collections.s
import afield.data.collections.times
import afield.document.graph.minusAssign
import afield.document.graph.plusAssign
import afield.document.properties.compareTo
import afield.document.workspace.file
import afield.geometry.mesh.gradient
import afield.graphics.render.rangeTo
import afield.math.autodiff.compareTo
import afield.math.autodiff.div
import afield.math.autodiff.minus
import afield.math.autodiff.plus
import afield.math.autodiff.times
import afield.math.linalg.bar
import afield.math.linalg.invoke
import afield.math.linalg.remAssign
import afield.math.linalg.set
import afield.math.linalg.times
import afield.math.statistics.noise
import afield.math.statistics.random
import afield.math.units.div
import afield.math.units.minus
import afield.math.units.plus
import afield.math.units.times
import afield.media.image.close
import afield.runtime.execution.start
import afield.study.search.SearchRun
import afield.study.ui.numbers
import afield.audio.synth.*
import afield.data.collections.m
import afield.document.workspace.path
import afield.graphics.render.label
import afield.graphics.text.distanceTo
import afield.math.autodiff.mean
import afield.math.autodiff.min
import afield.math.autodiff.minOf
import afield.math.autodiff.sqrt
import afield.math.autodiff.sum
import afield.math.linalg.d
import afield.math.linalg.i
import afield.math.linalg.x
import afield.study.search.GenomeOps
import afield.study.search.PopulationSearch
import afield.study.search.Variation
import afield.study.search.objectiveOf
import kotlin.random.Random
Search over the structure of Fn graphs — the genetic counterpart to the gradient fitting of
a fixed structure's numbers in FunctionFit.kt.
FnMutations holds the seeded mutation operators (jitter and nudge numeric leaves, transmute an
op within its OpRegistry family, prune, duplicate or graft a subtree), each expressed over the
graph copy and query primitives rather than a private traversal. evolve runs them as a bounded
(µ+λ) hill-climb and returns an FnEvolveRun listing every accepted improvement. Fitness is the
caller's: matchFitness scores closeness to a target under any FnMetric, less an optional
complexity penalty from graphMetrics; noveltyFitness instead scores distance from an archive
of FnBehaviour descriptors — what a graph does when sampled, not how it is built — and
evolveVariety chains slots of novelty search into a behaviourally spread set of results rather
than one best match.
Both runners are bounded by an iteration count so a search cannot disappear into an animation
loop, and both are deterministic: traversal is stable and every draw comes from one seeded
Random, with each evolveVariety slot deriving rngSeed * 31 + slot. Set normalize on
Functions.Fn.behaviour (and hence on novelty search) to compare shape rather than amplitude.
fun interface FnMutation
Seeded mutation operators and a bounded, deterministic search harness.
This is the "rehearsal" the graph layer was built toward: graphMetrics gives a
cost, graphMatches locates occurrences, and the copy primitives
(Functions.Fn.mapGraphNumbers, Functions.Fn.pruneWhere,
Functions.Fn.graftWhere, plus OpRegistry transmutation) mutate structure.
evolve ties them together into a reproducible optimizer over Fn graphs.
Everything is deterministic given a seed: graph traversal is stable, and every
random draw goes through a single Random. The same seed and inputs produce the
same FnEvolveRun, which is what makes evolved gestures reproducible in live art.
fun mutate(fn: Functions.Fn, rng: Random): Functions.Fn
object FnMutations
Library of seeded structural mutations, each expressed over existing graph primitives.
object FnMutations {
fun jitterNumbers(scale: Double = 0.2): FnMutation
Perturb every numeric leaf by a bounded multiplicative jitter.
fun jitterNumbers(scale: Double = 0.2): FnMutation = FnMutation { fn, rng ->
fn.mapGraphNumbers { value, _, _ -> value * (1.0 + (rng.nextDouble() * 2.0 - 1.0) * scale) }
}
fun nudgeNumbers(amount: Double = 0.1): FnMutation
Additively nudge every numeric leaf by a bounded amount.
fun nudgeNumbers(amount: Double = 0.1): FnMutation = FnMutation { fn, rng ->
fn.mapGraphNumbers { value, _, _ -> value + (rng.nextDouble() * 2.0 - 1.0) * amount }
}
fun substituteOp(): FnMutation
Transmute one random node to another op in the same family (oscillator, noise, …).
fun substituteOp(): FnMutation = FnMutation { fn, rng ->
val candidates = fn.graphMatches { OpRegistry.isTransmutable(it.op) }
if (candidates.isEmpty()) return@FnMutation fn
val chosen = candidates[rng.nextInt(candidates.size)]
val family = OpRegistry.family(chosen.context.op) ?: return@FnMutation fn
val targets = OpRegistry.opsInFamily(family).filter { !it.equals(chosen.context.op, ignoreCase = true) }
if (targets.isEmpty()) return@FnMutation fn
val target = targets[rng.nextInt(targets.size)]
fn.rewriteGraph { context, copy ->
if (context.path == chosen.context.path) copy.rebuildAsOp(target) ?: copy else copy
}
}
fun pruneSubtree(): FnMutation
Replace one random non-root subtree with zero (parsimony pressure).
fun pruneSubtree(): FnMutation = FnMutation { fn, rng ->
val candidates = fn.graphMatches { it.path.isNotEmpty() }
if (candidates.isEmpty()) return@FnMutation fn
val chosen = candidates[rng.nextInt(candidates.size)]
fn.pruneWhere(predicate = { it.path == chosen.context.path })
}
fun duplicateSubtree(): FnMutation
Replace one random subtree with subtree + subtree, growing structure.
fun duplicateSubtree(): FnMutation = FnMutation { fn, rng ->
val candidates = fn.graphMatches { it.path.isNotEmpty() }
if (candidates.isEmpty()) return@FnMutation fn
val chosen = candidates[rng.nextInt(candidates.size)]
fn.rewriteGraph { context, copy ->
if (context.path == chosen.context.path) with(Functions) { copy + copy.deepCopy() } else copy
}
}
fun graftSubtree(donor: Functions.Fn): FnMutation
Graft a copy of donor onto one random non-root site (structural crossover).
fun graftSubtree(donor: Functions.Fn): FnMutation = FnMutation { fn, rng ->
val candidates = fn.graphMatches { it.path.isNotEmpty() }
if (candidates.isEmpty()) return@FnMutation fn
val chosen = candidates[rng.nextInt(candidates.size)]
fn.graftWhere(donor) { it.path == chosen.context.path }
}
fun defaults(): List<Pair<String, FnMutation>>
A reasonable default palette for evolve when the caller has no donor material.
fun defaults(): List<Pair<String, FnMutation>> = listOf(
"jitter" to jitterNumbers(),
"nudge" to nudgeNumbers(),
"substituteOp" to substituteOp(),
"prune" to pruneSubtree(),
"duplicate" to duplicateSubtree()
)
}
data class FnEvolveStep
One accepted improvement during a run.
data class FnEvolveStep(
val iteration: Int,
val mutation: String,
val score: Double
)
data class FnEvolveRun
Result and provenance of an evolve run, mirroring FnGraphRewriteRun's shape.
data class FnEvolveRun(
val best: Functions.Fn,
val bestScore: Double,
val initialScore: Double,
val steps: List<FnEvolveStep>,
val iterations: Int,
val evaluations: Int
) {
val improved: Boolean get() = bestScore > initialScore
class FnGenomeOps
The Fn graph, presented to the generic search core as a genome.
The one thing it cannot supply is GenomeOps.random: there is no distribution over Fn graphs
to draw from, so the control arm PopulationSearch.random is unavailable here and evolve never
asks for it. random throws rather than returning the seed, because a control arm that silently
returns the incumbent would make every comparison against it a foregone conclusion. That absence
is the honest difference between this genome and the ALife ones in alife_evolve.kt, which do
have a generator and therefore do get a control.
signature is deliberately null, so every candidate costs one unit of budget even when
mutation was neutral. That keeps evaluations equal to 1 + iterations * lambda, which is the
number FnEvolveRun has always reported.
class FnGenomeOps(
mutationList: List<Pair<String, FnMutation>> = FnMutations.defaults()
) : GenomeOps<Functions.Fn> {
override val mutations: List<Variation<Functions.Fn>>
override val mutations: List<Variation<Functions.Fn>> =
mutationList.map { (name, m) -> Variation(name) { fn, rng -> m.mutate(fn, rng) } }
override fun random(rng: Random): Functions.Fn
override fun random(rng: Random): Functions.Fn =
throw UnsupportedOperationException(
"there is no distribution over Fn graphs to draw from, so FnGenomeOps has no control " +
"arm; use PopulationSearch.evolve with an explicit seed"
)
}
fun evolve
A bounded (1+λ) hill-climb over Fn graphs.
Each iteration draws lambda mutated candidates from the current best (mutation
chosen by the seeded Random), scores them with fitness (higher is better),
and adopts the best candidate that beats the incumbent. Bounded by iterations
so it can never disappear into a live animation loop — the same discipline as
Functions.Fn.rewriteUntilStable.
(1+λ), not (µ+λ) — the KDoc here said µ+λ for as long as the function existed, and it was wrong: there is one incumbent, never a population of µ. The distinction became load-bearing when this body moved onto PopulationSearch.evolve, which is a genuine (µ+λ) and is run here at µ=1.
The search itself is no longer implemented in this file. It is
PopulationSearch.evolve(mu = 1, lambda = lambda, crossoverRate = 0, immigrantRate = 0) over an
FnGenomeOps, and every number FnEvolveRun reports is read back off the generic
SearchRun. The random stream is identical to the hand-rolled version's, because the core
skips the parent draw when the population holds one individual and skips the crossover and
immigrant draws when their rates are exactly zero — three decisions that are right on their own
terms and that together make µ=1 a plain (1+λ) climb. functions.evolve.worked-example is the
fence: it reproduces bestScore = -0.15676727177844857 from before the move.
body — 50 lines, from line 224
fun evolve(
seed: Functions.Fn,
iterations: Int = 64,
lambda: Int = 6,
rngSeed: Long = 0L,
mutations: List<Pair<String, FnMutation>> = FnMutations.defaults(),
onIteration: ((iteration: Int, total: Int) -> Unit)? = null,
fitness: (Functions.Fn) -> Double
): FnEvolveRun {
require(iterations >= 0) { "iterations must be non-negative" }
require(lambda > 0) { "lambda must be positive" }
require(mutations.isNotEmpty()) { "at least one mutation is required" }
// Politely offer a progress bar (with a tqdm-style ETA) unless the caller reports progress
// itself. Inert outside a box, globally disableable, and only surfaces if the search runs
// long enough to be worth mentioning — see afield.ui.output.ProgressReporter.
val reporter = if (onIteration == null) afield.ui.output.progressReporter("evolve") else null
val report: (Int, Int) -> Unit =
onIteration ?: { iteration, total -> reporter?.update(iteration.toLong(), total.toLong()) }
val run = try {
PopulationSearch.evolve(
ops = FnGenomeOps(mutations),
objective = objectiveOf(fitness),
// One evaluation for the seed plus lambda per iteration: the count this function has
// always reported, restated as the core's evaluation budget.
budget = 1 + iterations * lambda,
seed = rngSeed,
mu = 1,
lambda = lambda,
crossoverRate = 0.0,
immigrantRate = 0.0,
seeds = listOf(seed),
label = "fn evolve",
onGeneration = { generation, _ -> report(generation, iterations) }
)
} finally {
reporter?.finish()
}
val best = run.best ?: return FnEvolveRun(seed, Double.NEGATIVE_INFINITY, Double.NEGATIVE_INFINITY, emptyList(), iterations, 0)
return FnEvolveRun(
best = best.genome,
bestScore = best.fitness,
initialScore = run.initialFitness,
steps = run.improvements.map { FnEvolveStep(it.generation, it.origin, it.fitness) },
iterations = iterations,
evaluations = run.evaluations
)
}
fun matchFitness
A ready-made fitness: how closely a candidate matches target on a shared time-base,
minus an optional complexity penalty from graphMetrics. Higher is better.
The closeness term is -metric.distance(...); metric defaults to FnMetric.rmse, so an
omitted argument is the historic root-mean-square objective bit-for-bit (including the
NEGATIVE_INFINITY a candidate with no jointly-finite sample earns). Pass another FnMetric
to search — via evolve or fitHybrid — under a different notion of "close".
fun matchFitness(
target: Functions.Fn,
domain: Functions.Domain = Functions.Domain.DEFAULT,
samples: Int = 128,
complexityWeight: Double = 0.0,
metric: FnMetric = FnMetric.rmse
): (Functions.Fn) -> Double {
val count = samples.coerceIn(2, 8192)
val xs = DoubleArray(count) { index -> domain.min + domain.length * index / (count - 1).toDouble() }
val targetY = DoubleArray(count) { index -> target.evalSafe(xs[index], outside = Double.NaN) }
return { candidate ->
val candidateY = DoubleArray(count) { index -> candidate.evalSafe(xs[index], outside = Double.NaN) }
val distance = metric.distance(xs, targetY, candidateY)
// A non-finite distance means nothing was comparable (or the metric was degenerate):
// treat it as maximally unfit, matching the old valid==0 → NEGATIVE_INFINITY branch.
if (!distance.isFinite()) {
Double.NEGATIVE_INFINITY
} else {
-distance - complexityWeight * candidate.graphMetrics().cost
}
}
}
data class FnBehaviour
A fixed-length behavioural descriptor: what an Fn does, not how it is built.
samples holds Fn.evalSafe readings on an even grid across domain (non-finite
readings recorded as 0.0), and finiteFraction is the share of grid points that
evaluated to a finite value. Two graphs with different structure but the same sampled
signal are behaviourally identical — this is the coordinate novelty search explores,
the counterpart to the structural view given by graphMetrics.
data class FnBehaviour(
val samples: DoubleArray,
val domain: Functions.Domain,
val finiteFraction: Double
) {
fun distanceTo(other: FnBehaviour): Double
Root-mean-square difference between two equal-length descriptors.
fun distanceTo(other: FnBehaviour): Double {
require(samples.size == other.samples.size) { "behaviours must share a sample count" }
var sumSq = 0.0
for (index in samples.indices) {
val d = samples[index] - other.samples[index]
sumSq += d * d
}
return kotlin.math.sqrt(sumSq / samples.size)
}
override fun equals(other: Any?): Boolean
DoubleArray needs value-based equality/hash for FnBehaviour to behave in collections.
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is FnBehaviour) return false
return samples.contentEquals(other.samples) &&
domain == other.domain &&
finiteFraction == other.finiteFraction
}
override fun hashCode(): Int
override fun hashCode(): Int {
var result = samples.contentHashCode()
result = 31 * result + domain.hashCode()
result = 31 * result + finiteFraction.hashCode()
return result
}
}
fun Functions.Fn.behaviour
Characterizes what this does over domain into an FnBehaviour descriptor.
Uses the same even grid convention as matchFitness so descriptors and match objectives
line up. Non-finite readings are recorded as 0.0 in FnBehaviour.samples but counted
against FnBehaviour.finiteFraction, letting novelty search notice — and penalize —
candidates that mostly blow up.
With normalize the sampled signal is reduced to a z-score (subtract the mean, divide by
the RMS) so the descriptor captures shape independent of amplitude and offset. This
matters for novelty search: the raw coordinate measures distance in value-space, so a
search rewarded for novelty can "win" trivially by scaling an amplitude — behaviourally
distant, but the same gesture louder. Normalizing forces novelty to explore genuinely
different shapes (sine vs saw vs pulse) instead of the same shape rescaled. A flat signal
(zero variance) normalizes to zeros rather than dividing by ~0.
fun Functions.Fn.behaviour(
domain: Functions.Domain = Functions.Domain.DEFAULT,
samples: Int = 32,
normalize: Boolean = false
): FnBehaviour {
val count = samples.coerceIn(2, 8192)
val readings = DoubleArray(count)
var finite = 0
for (index in 0 until count) {
val x = domain.min + domain.length * index / (count - 1).toDouble()
val y = evalSafe(x, outside = Double.NaN)
if (y.isFinite()) {
readings[index] = y
finite++
}
}
if (normalize) {
var mean = 0.0
for (v in readings) mean += v
mean /= count
var sumSq = 0.0
for (index in readings.indices) {
readings[index] -= mean
sumSq += readings[index] * readings[index]
}
val rms = kotlin.math.sqrt(sumSq / count)
if (rms > 1e-12) {
for (index in readings.indices) readings[index] /= rms
}
}
return FnBehaviour(readings, domain, finite / count.toDouble())
}
fun noveltyFitness
A fitness that rewards behavioural novelty relative to an archive of already-seen
behaviours, plus a small validityWeight bonus for evaluating to finite values.
A candidate's novelty is the mean behavioural distance to its neighbours nearest
archive members (k-nearest novelty, the canonical formulation). An empty archive gives
zero novelty, so the first pass is driven purely by validity — a deliberate,
deterministic cold start. Higher is better, so it drops straight into evolve in place
of matchFitness.
fun noveltyFitness(
archive: List<FnBehaviour>,
domain: Functions.Domain = Functions.Domain.DEFAULT,
samples: Int = 32,
neighbours: Int = 3,
validityWeight: Double = 0.25,
normalize: Boolean = false
): (Functions.Fn) -> Double {
require(neighbours > 0) { "neighbours must be positive" }
val frozen = archive.toList()
return { candidate ->
val behaviour = candidate.behaviour(domain, samples, normalize)
val novelty = if (frozen.isEmpty()) {
0.0
} else {
val distances = frozen.map { behaviour.distanceTo(it) }.sorted()
val k = minOf(neighbours, distances.size)
distances.take(k).sum() / k
}
novelty + validityWeight * behaviour.finiteFraction
}
}
data class FnVarietyRun
Result and provenance of an evolveVariety run.
data class FnVarietyRun(
val results: List<Functions.Fn>,
val behaviours: List<FnBehaviour>,
val runs: List<FnEvolveRun>
) {
val meanPairwiseDistance: Double
Mean pairwise behavioural distance across behaviours — the spread of the set.
val meanPairwiseDistance: Double
get() {
if (behaviours.size < 2) return 0.0
var sum = 0.0
var pairs = 0
for (i in behaviours.indices) {
for (j in i + 1 until behaviours.size) {
sum += behaviours[i].distanceTo(behaviours[j])
pairs++
}
}
return sum / pairs
}
}
fun evolveVariety
Evolves a diverse set of Fn graphs instead of a single best match.
This is the variety-generation counterpart to evolve: rather than converging every
candidate onto one target, it grows count behaviourally distinct results by novelty
search. Each slot runs a full evolve against noveltyFitness over a growing
archive, adopts the winner, records its behaviour, then drifts the seed onto that
winner so the next slot has rich material to mutate away from.
Deterministic in the same disciplined way as evolve: each slot derives its own seed
(rngSeed * 31 + slot), so the same rngSeed and inputs yield an identical set — the
property that lets an evolved family of gestures be reproduced in live art.
Set normalize to explore shape rather than amplitude: without it, novelty distance is
measured in raw value-space and the search tends to drift toward ever-louder versions of
the same gesture; with it, descriptors are z-scored so the set spreads across genuinely
different waveforms. See Functions.Fn.behaviour.
body — 58 lines, from line 471
fun evolveVariety(
seed: Functions.Fn,
count: Int = 6,
iterations: Int = 48,
lambda: Int = 6,
rngSeed: Long = 0L,
domain: Functions.Domain = Functions.Domain.DEFAULT,
samples: Int = 32,
neighbours: Int = 3,
validityWeight: Double = 0.25,
normalize: Boolean = false,
mutations: List<Pair<String, FnMutation>> = FnMutations.defaults()
): FnVarietyRun {
require(count > 0) { "count must be positive" }
require(neighbours > 0) { "neighbours must be positive" }
val archive = mutableListOf<FnBehaviour>()
val results = mutableListOf<Functions.Fn>()
val runs = mutableListOf<FnEvolveRun>()
var current = seed
// Offer a transient progress bar to the box we're running in. Inert (a complete no-op)
// when there's no active box or when progress bars are globally disabled; removes itself
// from the output-area stack on completion. Total work = count slots x iterations each.
val progress = afield.ui.output.progressReporter("evolve variety")
val totalIterations = count.toLong() * iterations.toLong()
var completedIterations = 0L
try {
for (slot in 0 until count) {
val fitness = noveltyFitness(archive, domain, samples, neighbours, validityWeight, normalize)
val slotBase = completedIterations
val run = evolve(
seed = current,
iterations = iterations,
lambda = lambda,
rngSeed = rngSeed * 31 + slot,
mutations = mutations,
onIteration = { iteration, total ->
progress.update(
done = slotBase + iteration,
total = totalIterations,
details = "slot ${slot + 1}/$count · iter $iteration/$total"
)
},
fitness = fitness
)
completedIterations += iterations.toLong()
runs += run
results += run.best
archive += run.best.behaviour(domain, samples, normalize)
current = run.best
}
} finally {
progress.finish()
}
return FnVarietyRun(results, archive.toList(), runs)
}