MorphoGenome.kt
src/jvmMain/kotlin/afield/lang/morpho/MorphoGenome.kt · 376 lines
package afield.lang.morpho
import kotlin.random.Random
The thing a search actually mutates: a MorphoHDL program, as text.
A generator could build netlists directly and skip the compiler, and it would be faster. It
would also optimise against Field's idea of what morpho compiles a design into rather than
against MorphoHDL. So a genome renders to source, upstream's parse() reads it, upstream's
compileModule() compiles it, and anything the search finds can be pasted into a morpho box and
will still be there tomorrow.
The grammar this has to hit
parser.js is 190 lines and its accepted language is small (parser.js:23-188):
- top-level
Name = LUT(arity, value); @morphoor@morpho(fallback=X), thendef name(a, b, c):;- SSA lines
t0, t1 = OP(x, y), one per line; return x, y.
That is all MorphoGenome.render emits, and it is why the rendered text is legible: it is not a serialisation format, it is the language.
Where the widths come from
MorphoHDL infers bus widths and never declares them, so a generator that ignores widths produces
mostly-invalid programs. MorphoWidthModel is Field's model of that inference — it mirrors
primitives.js's SPLIT/CAT and compiler.js's materializeLut broadcast rule — and it is
used only to steer generation. The compiler remains the judge; the model's accuracy shows up as
MorphoSearchRun.failureRate and nowhere else.
---------------------------------------------------------------- vocabulary
data class MorphoLut(val name: String, val arity: Int, val value: Int)
A LUT standard cell: a truth table and the number of inputs it reads.
data class MorphoLut(val name: String, val arity: Int, val value: Int) {
fun declaration(): String
As parser.js:165 wants to read it back.
fun declaration(): String {
val bits = 1 shl arity
var binary = value.toUInt().toString(2).padStart(bits, '0').takeLast(bits)
if (arity >= 3) binary = binary.chunked(4).joinToString("_")
return "%-6s = LUT(%d, 0b%s)".format(name, arity, binary)
}
}
enum class MorphoOpKind
What a generated SSA line can be.
enum class MorphoOpKind {
Apply a standard cell from the palette.
LUT,
SPLIT(x) — halve a bus. Returns null below width 2, which is how recursion terminates.
SPLIT,
CAT(x, y) — join two buses.
CAT,
Call the cell being generated. Only legal when the arguments strictly narrow.
SELF,
}
data class MorphoOpcode(val name: String, val arity: Int, val results: Int, val kind: MorphoOpKind)
One callable thing, with its shape.
data class MorphoOpcode(val name: String, val arity: Int, val results: Int, val kind: MorphoOpKind)
class MorphoPalette(val luts: List<MorphoLut>)
The standard cells a search is allowed to build out of.
standard lifts every LUT(...) declaration straight out of morpho's own standard library
rather than copying the truth tables into Kotlin, because a copy drifts and this one cannot: it
is the same bytes tiny_morpho.py executes and parser.js parses.
val ops: List<MorphoOpcode> = luts.map { MorphoOpcode(it.name, it.arity, 1, MorphoOpKind.LUT) }
fun declarations(): String = luts.joinToString("\n") { it.declaration() }
The palette's declarations, ready to head a rendered program.
fun withArities(arities: Set<Int>): MorphoPalette
Only the cells with the given arities — a 1-bit base case has no use for a 4-input mux.
fun withArities(arities: Set<Int>): MorphoPalette = MorphoPalette(luts.filter { it.arity in arities })
fun only(vararg names: String): MorphoPalette
Only the named cells, in the order named.
fun only(vararg names: String): MorphoPalette =
MorphoPalette(names.mapNotNull { n -> luts.firstOrNull { it.name == n } })
companion object
companion object {
private val DECL = Regex("""^\s*([A-Za-z_]\w*)\s*=\s*LUT\(\s*(\d+)\s*,\s*(\S+?)\s*\)""", RegexOption.MULTILINE)
fun from(source: String = MorphoRuntime.standardLibrary): MorphoPalette
Every LUT declared in source; defaults to morpho's own standard library.
fun from(source: String = MorphoRuntime.standardLibrary): MorphoPalette = MorphoPalette(
DECL.findAll(source).map { m ->
val raw = m.groupValues[3].replace("_", "")
val value = when {
raw.startsWith("0b") -> raw.substring(2).toInt(2)
raw.startsWith("0x") -> raw.substring(2).toInt(16)
else -> raw.toInt()
}
MorphoLut(m.groupValues[1], m.groupValues[2].toInt(), value)
}.toList()
)
val standard: MorphoPalette by lazy { from() }
morpho's own eight standard cells.
---------------------------------------------------------------- the program
data class MorphoStatement(val targets: List<String>, val op: String, val args: List<String>)
One SSA line: targets = op(args).
fun render(): String = " ${targets.joinToString(", ")} = $op(${args.joinToString(", ")})"
data class MorphoCellGenome
One @morpho def — a cell body plus the ports it presents.
data class MorphoCellGenome(
val name: String,
val inputs: List<String>,
val statements: List<MorphoStatement>,
val outputs: List<String>,
val fallback: String?,
) {
fun render(): String
fun render(): String = buildString {
append(if (fallback == null) "@morpho\n" else "@morpho(fallback=$fallback)\n")
append("def ").append(name).append('(').append(inputs.joinToString(", ")).append("):\n")
statements.forEach { append(it.render()).append('\n') }
append(" return ").append(outputs.joinToString(", ")).append('\n')
}
fun pruned(): MorphoCellGenome
Drop statements nothing reachable from outputs consumes.
Not cosmetic. A dead SPLIT whose operand is one bit wide still returns null, and
compiler.js:1053 throws the whole body away when any instruction does — so an unused
statement can silently turn a working design into its own fallback. Pruning before compiling
is the difference between a search that works and one that mostly evaluates base cases.
fun pruned(): MorphoCellGenome {
val live = HashSet(outputs)
val keep = BooleanArray(statements.size)
for (i in statements.indices.reversed()) {
val s = statements[i]
if (s.targets.none { it in live }) continue
keep[i] = true
live.addAll(s.args)
}
return copy(statements = statements.filterIndexed { i, _ -> keep[i] })
}
fun normalized(): MorphoCellGenome
Rename every target to t0, t1, … in order, so two structurally identical cells render identically.
fun normalized(): MorphoCellGenome {
val map = HashMap<String, String>()
var n = 0
val out = statements.map { s ->
val args = s.args.map { map[it] ?: it }
val targets = s.targets.map { t -> "t${n++}".also { map[t] = it } }
MorphoStatement(targets, s.op, args)
}
return copy(statements = out, outputs = outputs.map { map[it] ?: it })
}
}
data class MorphoGenome
A whole searchable program: one or more cells plus whatever fixed text they are compiled against.
The first cell is the root — the one MorphoEvaluator builds — and later cells are helpers it may fall back into. Two cells is the interesting case: a recursion and its base, evolved together.
data class MorphoGenome(
val cells: List<MorphoCellGenome>,
/** LUT declarations and any fixed helper cells, emitted verbatim above the evolved ones. */
val preamble: String,
) {
init
init {
require(cells.isNotEmpty()) { "a genome needs at least one cell" }
}
val rootName: String get() = cells[0].name
fun render(): String
The program as the compiler sees it: dead statements dropped, wires renumbered.
Genotype and phenotype are deliberately not the same object here. Statements nothing reads
are kept in cells and removed only on the way out, which sounds like bookkeeping and is
actually the difference between a search that works and one that stops.
The first version pruned the genome itself, and hill climbing reliably walked into a cell
with no statements at all — return a, ZERO — and then could not leave. Every operator
became a no-op: there was nothing to rewire, nothing to delete, and an inserted statement was
dead the instant it appeared, so pruning deleted it again and the mutation was identity.
Keeping introns means an inserted statement waits, silently, until some later mutation points
an output at it. That is what neutral drift is for.
fun render(): String = phenotype().let { p ->
buildString {
if (preamble.isNotBlank()) append(preamble.trimEnd()).append("\n\n")
p.cells.forEach { append(it.render()).append('\n') }
}.trimEnd() + "\n"
}
fun phenotype(): MorphoGenome = copy(cells = cells.map { it.pruned().normalized() })
The live part of the program, which is what render emits and what gets compiled.
fun pruned(): MorphoGenome = copy(cells = cells.map { it.pruned() })
fun normalized(): MorphoGenome = copy(cells = cells.map { it.normalized() })
val statementCount: Int get() = cells.sumOf { it.statements.size }
Statements across every cell, introns included — what the genome costs.
val liveStatementCount: Int get() = cells.sumOf { it.pruned().statements.size }
Statements that survive into the compiled circuit.
---------------------------------------------------------------- the width model
object MorphoWidthModel
Field's model of MorphoHDL's width inference, used to steer generation and never to judge it.
Bus widths in morpho are lists of net indices, so every rule here is a length rule taken straight from upstream:
| construct | rule | upstream |
|---|---|---|
SPLIT(x) |
floor(w/2), w - floor(w/2); invalid below w = 2 |
primitives.js:39-44 |
CAT(a, b) |
w(a) + w(b) |
primitives.js:45-47 |
| LUT | max(w), with width-1 operands broadcast |
compiler.js:962-973 |
| self-call | the spec's own output shape at the argument widths | — |
The last row is the one that is a model rather than a transcription, because the compiler works a recursive call's output widths out by compiling it. Getting it wrong costs a failed candidate, which is counted.
object MorphoWidthModel {
val CONSTANTS = mapOf("ZERO" to 1, "ONE" to 1)
Widths morpho gives its two built-in constants — one net each (compiler.js:949).
fun trace
Walk cell with inputWidths and report the width of every wire it defines.
@param selfShape output widths of a recursive call, given its argument widths. @return wire widths, and the reasons (if any) the body would not survive the compiler.
body — 67 lines, from line 244
fun trace(
cell: MorphoCellGenome,
inputWidths: IntArray,
palette: MorphoPalette,
selfShape: (IntArray) -> IntArray,
): Trace {
val widths = HashMap<String, Int>(CONSTANTS)
cell.inputs.forEachIndexed { i, n -> widths[n] = inputWidths.getOrElse(i) { 1 } }
val problems = ArrayList<String>()
val lut = palette.luts.associateBy { it.name }
val inputTotal = inputWidths.sum()
var boundary = false
for (s in cell.statements) {
val args = s.args.map { widths[it] }
if (args.any { it == null }) {
problems += "${s.op} reads an undefined wire"
s.targets.forEach { widths[it] = 1 }
continue
}
@Suppress("UNCHECKED_CAST")
val w = (args as List<Int>)
when {
s.op == "SPLIT" -> {
if (w.size != 1) problems += "SPLIT takes one argument, got ${w.size}"
val x = w.firstOrNull() ?: 1
if (x < 2) {
problems += "SPLIT of a $x-bit bus discards the body"
boundary = true
}
val half = x / 2
s.targets.getOrNull(0)?.let { widths[it] = half }
s.targets.getOrNull(1)?.let { widths[it] = x - half }
}
s.op == "CAT" -> s.targets.getOrNull(0)?.let { widths[it] = w.sum() }
s.op == cell.name -> {
if (w.size != cell.inputs.size) problems += "recursive call takes ${cell.inputs.size} arguments, got ${w.size}"
val total = w.sum()
if (total >= inputTotal) {
problems += "recursive call does not narrow ($total bits in, $inputTotal available): it would not terminate"
}
val shape = selfShape(w.toIntArray())
s.targets.forEachIndexed { i, t -> widths[t] = shape.getOrElse(i) { 1 } }
}
lut.containsKey(s.op) -> {
val def = lut.getValue(s.op)
if (w.size != def.arity) problems += "${s.op} takes ${def.arity} inputs, got ${w.size}"
val wide = w.maxOrNull() ?: 1
if (w.any { it != 1 && it != wide }) {
problems += "${s.op} mixes widths ${w.joinToString("/")}; morpho only broadcasts width 1"
}
s.targets.getOrNull(0)?.let { widths[it] = wide }
}
else -> {
problems += "unknown operation ${s.op}"
s.targets.forEach { widths[it] = 1 }
}
}
}
cell.outputs.forEach { if (it !in widths) problems += "returns an undefined wire '$it'" }
return Trace(widths, problems, boundary)
}
class Trace(val widths: Map<String, Int>, val problems: List<String>, val hitsBoundary: Boolean = false)
val ok: Boolean get() = problems.isEmpty()
fun width(wire: String): Int = widths[wire] ?: 1
fun terminates
Does this cell's recursion stop?
The check that mattered most in practice. MorphoHDL terminates by running out of bits: a
SPLIT below width 2 returns null (primitives.js:41), the compiler throws the body away
(compiler.js:1053) and the fallback answers instead. A generated body has no such courtesy,
and the first version of this file learned it the slow way — evolved(ONE, t0, ONE), where
t0 is as wide as b, calls itself at the same signature forever.
A local rule ("the arguments are narrower than the inputs") is not enough, because it is only checked at the top and the body computes different widths at every level. So this walks the signature graph instead: each distinct signature is traced, the self-calls in it give the next signatures, and a signature revisited on the current path is a cycle and therefore a hang. That is exact with respect to the width model, and the model is all the generator has.
@param widthCap a signature wider than this is treated as divergent. A recursion whose bus grows is technically well-founded only in the sense that it eventually exhausts memory.
body — 41 lines, from line 335
fun terminates(
cell: MorphoCellGenome,
inputWidths: IntArray,
palette: MorphoPalette,
selfShape: (IntArray) -> IntArray,
widthCap: Int = 64,
signatureBudget: Int = 256,
): Boolean {
if (cell.statements.none { it.op == cell.name }) return true
val onPath = HashSet<List<Int>>()
val settled = HashSet<List<Int>>()
var visits = 0
fun visit(sig: IntArray): Boolean {
if (++visits > signatureBudget) return false
if (sig.any { it > widthCap || it < 0 }) return false
val key = sig.toList()
if (key in settled) return true
if (!onPath.add(key)) return false
val t = trace(cell, sig, palette, selfShape)
var ok = true
// A body that hits a boundary never runs; its fallback does, and a fallback cannot
// recurse into this cell. So this signature is a leaf and the walk stops here.
if (!t.hitsBoundary) {
for (s in cell.statements) {
if (s.op != cell.name) continue
if (!visit(s.args.map { t.width(it) }.toIntArray())) {
ok = false
break
}
}
}
onPath.remove(key)
if (ok) settled += key
return ok
}
return visit(inputWidths)
}
}