MorphoSpec.kt
src/jvmMain/kotlin/afield/lang/morpho/MorphoSpec.kt · 491 lines
package afield.lang.morpho
import java.util.concurrent.Executors
import java.util.concurrent.ScheduledExecutorService
import java.util.concurrent.TimeUnit
import kotlin.random.Random
What a MorphoHDL design is supposed to do, and how much it is allowed to cost — the half of a search that morpho itself has no opinion about.
Everything in this file evaluates candidate designs by running them: MorphoRuntime compiles the
candidate's source with upstream's parser and upstream's compiler, MorphoSim runs the resulting
netlist, and MorphoTiming — morpho's own updateTimes() — supplies the depth. Nothing here
models a circuit. A search whose fitness function was Field's idea of what a netlist would look
like would be searching Field's imagination, and the point of the bridge is that it does not have
to.
enum class MorphoPortRole { BUS, BIT }
The role a port plays in a width-parametric design.
MorphoHDL cells do not declare bus widths — a design is a claim quantified over all N — so a
specification has to say which ports scale with N and which are single wires. ripple_adder's
a and b are BUS; its carry in and carry out are BIT.
class MorphoSpec
A functional oracle plus the widths it must hold at.
The width family is the part that matters and the part that is specific to this language. A
combinational-logic search normally checks one circuit against one truth table; a MorphoHDL
design is a family, so widths lists several and MorphoEvaluator compiles the candidate once
per width and scores the worst of them. An individual that adds correctly at four bits and not at
sixteen has not found an adder, and this is where that gets noticed rather than in the write-up.
@param oracle (inputs, busWidth) -> outputs, both indexed by port, values as unsigned integers.
@param exhaustiveLimit compile-time input-space size below which every vector is tried. Above it
the evaluator falls back to sampleCount seeded-random vectors, which is a weaker check and is
reported as such by MorphoEvaluator.exhaustiveAt.
class MorphoSpec(
val name: String,
val inputRoles: List<MorphoPortRole>,
val outputRoles: List<MorphoPortRole>,
val widths: List<Int>,
val exhaustiveLimit: Int = 1 shl 13,
val sampleCount: Int = 384,
val seed: Int = 20260801,
val oracle: (LongArray, Int) -> LongArray,
) {
init
init {
require(inputRoles.isNotEmpty()) { "a spec needs at least one input port" }
require(outputRoles.isNotEmpty()) { "a spec needs at least one output port" }
require(widths.isNotEmpty()) { "a spec needs at least one width to check at" }
require(widths.all { it >= 1 }) { "widths must be >= 1, got $widths" }
}
fun signature(width: Int): IntArray
The signature create() wants: MorphoPortRole.BUS ports at width, MorphoPortRole.BIT at 1.
fun signature(width: Int): IntArray =
IntArray(inputRoles.size) { if (inputRoles[it] == MorphoPortRole.BUS) width else 1 }
fun outputWidths(width: Int): IntArray
The output widths a correct design must produce at width.
fun outputWidths(width: Int): IntArray =
IntArray(outputRoles.size) { if (outputRoles[it] == MorphoPortRole.BUS) width else 1 }
fun inputSpace(width: Int): Long
How many distinct input vectors exist at width, saturating rather than overflowing.
fun inputSpace(width: Int): Long {
var n = 1L
signature(width).forEach {
if (n > Long.MAX_VALUE / (1L shl it)) return Long.MAX_VALUE
n *= (1L shl it)
}
return n
}
fun isExhaustiveAt(width: Int): Boolean = inputSpace(width) <= exhaustiveLimit
True when samples at width is the whole input space rather than a sample of it.
fun samples(width: Int): Array<LongArray> = sampleCache.getOrPut(width) { buildSamples(width) }
samples[port](../../../../../../reference/source/jvmMain/afield/audio/synth/Subharmonic.md#L240), cached: a search asks for the same vectors thousands of times.
fun expected(width: Int): Array<LongArray>
expected[port](../../../../../../reference/source/jvmMain/afield/audio/synth/Subharmonic.md#L240), the oracle's answer for samples.
fun expected(width: Int): Array<LongArray> = expectedCache.getOrPut(width) {
val ins = samples(width)
val n = ins[0].size
val out = Array(outputRoles.size) { LongArray(n) }
val one = LongArray(ins.size)
for (i in 0 until n) {
for (p in ins.indices) one[p] = ins[p][i]
val got = oracle(one, width)
require(got.size == outputRoles.size) {
"oracle for '$name' returned ${got.size} outputs, spec declares ${outputRoles.size}"
}
for (p in got.indices) out[p][i] = got[p]
}
out
}
fun at(widths: List<Int>): MorphoSpec
The same specification checked at a different width family.
The one operation a curriculum needs: search at at(listOf(2)), then re-score what was found
at at(listOf(2, 4, 8, 16)) and see whether it was an adder or an accident.
fun at(widths: List<Int>): MorphoSpec = MorphoSpec(
name, inputRoles, outputRoles, widths, exhaustiveLimit, sampleCount, seed, oracle
)
fun outputBits(width: Int): Int = outputWidths(width).sum() * samples(width)[0].size
Total oracle output bits per width — the denominator of the bit-error rate.
val caseCount: Int by lazy { widths.sumOf { outputWidths(it).sum() } }
One case per (width, output port, bit position) — the granularity lexicase selection needs.
Aggregating an adder's error into one number throws away the only structure the problem has: bit 0 needs no carry, bit 1 needs one, bit 7 needs seven. A per-bit case vector lets a selection operator notice an individual that is uniquely good at the high bits, which is exactly the individual that has started to use the recursion.
fun caseName(index: Int): String
Human-readable name of case i, for reporting.
body — 53 lines, from line 125
fun caseName(index: Int): String {
var k = index
for (w in widths) {
val ow = outputWidths(w)
for (port in ow.indices) for (bit in 0 until ow[port]) {
if (k == 0) return "w$w:p$port:b$bit"
k--
}
}
return "case$index"
}
private val sampleCache = HashMap<Int, Array<LongArray>>()
private val expectedCache = HashMap<Int, Array<LongArray>>()
private fun buildSamples(width: Int): Array<LongArray> {
val sig = signature(width)
val space = inputSpace(width)
if (space <= exhaustiveLimit) {
val n = space.toInt()
val out = Array(sig.size) { LongArray(n) }
for (i in 0 until n) {
var rest = i.toLong()
for (p in sig.indices) {
val span = 1L shl sig[p]
out[p][i] = rest % span
rest /= span
}
}
return out
}
// Seeded so two search runs are comparable, and edge-heavy on purpose: all-ones and
// all-zeros are where a carry chain and an off-by-one disagree, and uniform random draws
// hit them with probability 2^-N.
val rng = Random(seed + width)
val n = sampleCount
val out = Array(sig.size) { LongArray(n) }
for (i in 0 until n) {
for (p in sig.indices) {
// Masked draws rather than nextLong(span): at 63 bits `1L shl 63` is Long.MIN_VALUE
// and the bounded overload throws "Random range is empty", which is a crash that
// only appears when a design is retested at a width nothing was searched at.
val mask = if (sig[p] >= 63) Long.MAX_VALUE else (1L shl sig[p]) - 1
out[p][i] = when (i) {
0 -> 0L
1 -> mask
2 -> if (p == 0) mask else 1L
else -> rng.nextLong() and mask
}
}
}
return out
}
companion object
companion object {
fun adder(widths: List<Int> = listOf(2, 4, 8), name: String = "adder"): MorphoSpec
sum, c_out = a + b + c_in — the specification ripple_adder and brent_kung_adder are
two different answers to.
Two ports in, three, both roles present, and an oracle whose hard cases (a full-width carry propagation) are exactly the ones a nearly-right circuit gets wrong. It is the standard benchmark for this kind of search for good reasons.
fun adder(widths: List<Int> = listOf(2, 4, 8), name: String = "adder"): MorphoSpec =
MorphoSpec(
name = name,
inputRoles = listOf(MorphoPortRole.BUS, MorphoPortRole.BUS, MorphoPortRole.BIT),
outputRoles = listOf(MorphoPortRole.BUS, MorphoPortRole.BIT),
widths = widths,
) { ins, w ->
val total = ins[0] + ins[1] + ins[2]
longArrayOf(total and ((1L shl w) - 1), total ushr w)
}
fun bitwiseXor(widths: List<Int> = listOf(2, 4, 8), name: String = "bitxor"): MorphoSpec
y = a XOR b bitwise — the easiest parametric target there is, and so the pipeline's canary.
fun bitwiseXor(widths: List<Int> = listOf(2, 4, 8), name: String = "bitxor"): MorphoSpec =
MorphoSpec(
name = name,
inputRoles = listOf(MorphoPortRole.BUS, MorphoPortRole.BUS),
outputRoles = listOf(MorphoPortRole.BUS),
widths = widths,
) { ins, _ -> longArrayOf(ins[0] xor ins[1]) }
fun orReduce(widths: List<Int> = listOf(2, 4, 8), name: String = "orreduce"): MorphoSpec
y = (x != 0) — an N-bit bus reduced to one bit.
The target that separates "can this search find a recursion" from "can this search find an adder", and the reason is the shape of the landscape rather than the size of the program.
A reduction cannot be done without recursion in this vocabulary. Morpho's LUTs are
broadcast — compiler.js:964 sizes a LUT cell to its widest operand — so no amount of
Or applied to an eight-bit bus produces one bit. The only route from N wires down to
one is to keep SPLITting, which means every correct answer here is genuinely
divide-and-conquer rather than a broadcast that happened to fit.
And unlike parity, it has a gradient. Or-reducing only the low half is wrong exactly
when the low half is zero and the high half is not — about 6% of eight-bit vectors — so a
half-built recursion scores 0.06 where a half-built parity scores the 0.5 of a coin toss.
Parity is the textbook deceptive function for exactly that reason.
fun orReduce(widths: List<Int> = listOf(2, 4, 8), name: String = "orreduce"): MorphoSpec =
MorphoSpec(
name = name,
inputRoles = listOf(MorphoPortRole.BUS),
outputRoles = listOf(MorphoPortRole.BIT),
widths = widths,
) { ins, _ -> longArrayOf(if (ins[0] != 0L) 1L else 0L) }
fun andReduce(widths: List<Int> = listOf(2, 4, 8), name: String = "andreduce"): MorphoSpec
y = (x == all ones) — the same shape as orReduce with the dual gate.
fun andReduce(widths: List<Int> = listOf(2, 4, 8), name: String = "andreduce"): MorphoSpec =
MorphoSpec(
name = name,
inputRoles = listOf(MorphoPortRole.BUS),
outputRoles = listOf(MorphoPortRole.BIT),
widths = widths,
) { ins, w -> longArrayOf(if (ins[0] == (1L shl w) - 1) 1L else 0L) }
fun increment(widths: List<Int> = listOf(2, 4, 8), name: String = "increment"): MorphoSpec
y = x + 1 — an increment, which is an adder with one operand nailed to a constant and
therefore a genuinely easier carry chain to find.
fun increment(widths: List<Int> = listOf(2, 4, 8), name: String = "increment"): MorphoSpec =
MorphoSpec(
name = name,
inputRoles = listOf(MorphoPortRole.BUS, MorphoPortRole.BIT),
outputRoles = listOf(MorphoPortRole.BUS, MorphoPortRole.BIT),
widths = widths,
) { ins, w ->
val total = ins[0] + ins[1]
longArrayOf(total and ((1L shl w) - 1), total ushr w)
}
}
}
data class MorphoCost
What a compiled candidate costs, entirely in numbers the real netlist and morpho's own timing produced.
gates rather than cells is the size to optimise: a LUT cell is broadcast across output
slots (see MorphoNetlist.slots), so an 8-bit XOR is one cell and eight gates, and cell count
would reward a design for being wide rather than for being small.
data class MorphoCost(
val cells: Int,
val luts: Int,
val gates: Int,
val depth: Int,
val maxFanout: Int,
) {
override fun toString(): String = "$gates gates, depth $depth, fanout $maxFanout"
companion object
fun of(net: MorphoNetlist, timing: MorphoTiming): MorphoCost
fun of(net: MorphoNetlist, timing: MorphoTiming): MorphoCost = MorphoCost(
cells = net.activeCount,
luts = net.lutCount,
gates = net.gateCount,
depth = timing.depth(net).toInt(),
maxFanout = maxFanoutOf(net),
)
fun maxFanoutOf(net: MorphoNetlist): Int
The largest number of active input pins any one net feeds.
Not a cost morpho charges — its compiler only inserts buffer trees when a maxFanout is
asked for — but a design that drives forty gates off one wire is not free in silicon, and
a search that is only told about gates and depth will happily find one.
fun maxFanoutOf(net: MorphoNetlist): Int {
val counts = IntArray(net.nNets)
for (cell in net.activeCells) {
val start = net.pinStart(cell)
for (p in 0 until net.pinCount(cell)) {
val n = net.pinNet[start + p]
if (n in 0 until net.nNets) counts[n]++
}
}
return counts.maxOrNull() ?: 0
}
}
}
class MorphoFitness
How a candidate scored: how wrong it is, what it costs, and — when it did not compile at all — why.
error is the fraction of output bits that disagreed with the oracle, not a pass/fail. A
pass/fail landscape is flat everywhere except at the answer, which makes hill climbing exactly
as good as random sampling; a bit-error rate at least tells a search that getting the low bits of
a sum right is progress.
Mean to search by, worst to believe
error is the mean across the spec's widths and worstError is the maximum. The search
climbs the mean and correct demands that every width is exact, which are two different jobs.
An earlier version scored on the maximum alone and searched much worse: a candidate that has
genuinely learnt to add two bits, and has not yet learnt to carry across eight, is progress, and
a max-only score cannot see it. Nothing is claimed on the strength of the mean — a design is
correct here only if it is correct at every width in the family.
class MorphoFitness(
val error: Double,
/** Bit-error rate at each of [MorphoSpec.widths], in order. */
val perWidthError: DoubleArray,
val cost: MorphoCost?,
/** Non-null when the candidate did not compile, did not simulate, or was rejected as too big. */
val failure: String?,
val millis: Double = 0.0,
private val caseErrors: DoubleArray = DoubleArray(0),
) {
val valid: Boolean get() = failure == null
val cases: DoubleArray get() = caseErrors
Error rate per (width, port, bit) case — see MorphoSpec.caseCount.
val worstError: Double get() = perWidthError.maxOrNull() ?: 1.0
The width that went worst — the number a claim of correctness has to answer to.
val correct: Boolean get() = failure == null && perWidthError.all { it == 0.0 }
override fun toString(): String
override fun toString(): String = when {
failure != null -> "invalid ($failure)"
correct -> "correct, ${cost}"
else -> "err %.4f (worst %.4f), %s".format(error, worstError, cost ?: "no cost")
}
companion object
companion object {
fun failed(reason: String, widths: Int, cases: Int, millis: Double = 0.0): MorphoFitness
The score a candidate gets for not compiling: maximally wrong, and no cost to trade against.
fun failed(reason: String, widths: Int, cases: Int, millis: Double = 0.0): MorphoFitness =
MorphoFitness(1.0, DoubleArray(widths) { 1.0 }, null, reason, millis, DoubleArray(cases) { 1.0 })
}
}
class MorphoEvaluator
Compiles a candidate at every width in the spec and scores it.
Why there is a watchdog
MorphoHDL recursion terminates when a SPLIT runs out of bits — primitives.js:41 returns
null, compiler.js:1053 discards the graph, and the fallback takes over. A generated cell
body has no such guarantee: a self-call whose arguments are no narrower than its inputs recurses
forever, and one whose arguments are wider does so while doubling the bus each level, which
exhausts memory long before it exhausts the JS stack. MorphoProgramGenerator will not emit
either, but a mutation operator is not a proof, so every evaluation runs under a timeout that
calls MorphoRuntime.interruptGuest. A candidate that trips it is scored as a failure and the
search carries on.
class MorphoEvaluator(
val spec: MorphoSpec,
val optimize: Boolean = true,
/** Candidates whose netlist exceeds this many active cells are rejected unsimulated. */
val cellBudget: Int = 40_000,
val timeoutMillis: Long = 5_000,
) {
var evaluations: Int
var evaluations: Int = 0
private set
var failures: Int
var failures: Int = 0
private set
var totalMillis: Double
var totalMillis: Double = 0.0
private set
fun exhaustiveAt(width: Int): Boolean = spec.isExhaustiveAt(width)
True when the width's check was the whole input space rather than a sample of it.
fun evaluate(source: String, design: String = spec.name): MorphoFitness
Compile source at every spec width, simulate it, and score it.
@param design the cell name to build; defaults to the spec's name, which is what MorphoGenome names the cell it renders.
fun evaluate(source: String, design: String = spec.name): MorphoFitness {
val t0 = System.nanoTime()
evaluations++
val perWidth = DoubleArray(spec.widths.size)
val cases = ArrayList<Double>(spec.caseCount)
var cost: MorphoCost? = null
for ((i, width) in spec.widths.withIndex()) {
val outcome = runCatching { compileAndScore(source, design, width) }
val value = outcome.getOrElse { e ->
failures++
val ms = (System.nanoTime() - t0) / 1e6
totalMillis += ms
return MorphoFitness.failed(describe(e), spec.widths.size, spec.caseCount, ms)
}
perWidth[i] = value.perBit.average()
cases.addAll(value.perBit.toList())
cost = value.cost
}
val ms = (System.nanoTime() - t0) / 1e6
totalMillis += ms
return MorphoFitness(perWidth.average(), perWidth, cost, null, ms, cases.toDoubleArray())
}
Per-bit error rates at one width, and the cost of the circuit that produced them.
private class WidthResult(val perBit: DoubleArray, val cost: MorphoCost)
private fun compileAndScore(source: String, design: String, width: Int): WidthResult {
val guard = watchdog.schedule({ MorphoRuntime.interruptGuest() }, timeoutMillis, TimeUnit.MILLISECONDS)
val net: MorphoNetlist
val cost: MorphoCost
try {
MorphoRuntime.compile(source, design, spec.signature(width), optimize).use { session ->
session.growFully()
net = session.netlist()
if (net.activeCount > cellBudget) {
throw MorphoException("netlist of ${net.activeCount} cells exceeds the $cellBudget budget", null)
}
cost = MorphoCost.of(net, session.timing())
}
} finally {
guard.cancel(false)
}
if (net.inputCells.size != spec.inputRoles.size) {
throw MorphoException(
"design has ${net.inputCells.size} input ports, spec declares ${spec.inputRoles.size}", null
)
}
if (net.outputCells.size != spec.outputRoles.size) {
throw MorphoException(
"design has ${net.outputCells.size} output ports, spec declares ${spec.outputRoles.size}", null
)
}
val got = MorphoSim.evaluate(net, spec.samples(width))
return WidthResult(bitErrors(got, spec.expected(width), spec.outputWidths(width)), cost)
}
Fraction of expected output bits the circuit got wrong.
A port the design made too narrow contributes its missing bits as errors rather than being
quietly dropped — otherwise a one-bit sum would score better than an eight-bit one that is
merely wrong somewhere, and the search would evolve stumps.
private fun bitErrors(got: Array<LongArray>, want: Array<LongArray>, widths: IntArray): DoubleArray {
val out = DoubleArray(widths.sum())
var at = 0
for (port in widths.indices) {
val g = got[port]
val e = want[port]
for (bit in 0 until widths[port]) {
var wrong = 0
val mask = 1L shl bit
for (i in e.indices) if (((g[i] xor e[i]) and mask) != 0L) wrong++
out[at++] = if (e.isEmpty()) 1.0 else wrong.toDouble() / e.size
}
}
return out
}
private fun describe(e: Throwable): String = when (e) {
is MorphoException -> e.message?.substringBefore('\n')?.take(160) ?: "morpho error"
is StackOverflowError -> "stack overflow"
else -> (e.message?.substringBefore('\n')?.take(160) ?: e::class.simpleName ?: "error")
}
companion object
companion object {
private val watchdog: ScheduledExecutorService =
Executors.newSingleThreadScheduledExecutor { r ->
Thread(r, "morpho-search-watchdog").apply { isDaemon = true }
}
}
}