Skip to content

MorphoGraal.kt

src/jvmMain/kotlin/afield/lang/morpho/MorphoGraal.kt · 308 lines

package afield.lang.morpho

import org.graalvm.polyglot.Context
import org.graalvm.polyglot.HostAccess
import org.graalvm.polyglot.PolyglotAccess
import org.graalvm.polyglot.PolyglotException
import org.graalvm.polyglot.Source
import org.graalvm.polyglot.Value
import org.graalvm.polyglot.io.IOAccess
import java.io.Closeable
import java.nio.file.Files
import java.nio.file.Path
import java.nio.file.Paths
import java.nio.file.StandardCopyOption
import java.util.concurrent.locks.ReentrantLock
import kotlin.concurrent.withLock
import kotlin.io.path.exists

Hosting MorphoHDL's own compiler: one GraalJS context, one lock, and a growth handle per circuit.

Nothing about the language is reimplemented on this side. MorphoRuntime evaluates morpho's parser, compiler and expansion as ES modules, at the upstream commit recorded in morpho/PROVENANCE.md, and MorphoSession holds one compiled design that is expanded a budget at a time. The rest of the package consumes what comes back out: MorphoNetlist is the snapshot, MorphoSim evaluates it, MorphoLayout3D places it, MorphoFLines.kt draws it.

Crossing the boundary is the expensive part, so both guest scripts exist to make each crossing carry more. morpho-field-driver.mjs flattens the compiler's graph into typed arrays; morpho-field-layout.mjs drives the Barnes–Hut kernel from JavaScript, where its buffers can be wrapped in views rather than read an element at a time. A guest failure arrives as MorphoException, carrying morpho's own source line whenever its parser supplied one.

object MorphoRuntime

Hosts MorphoHDL inside Field by running morpho's own compiler on GraalJS.

Field already depends on org.graalvm.polyglot:js and :wasm (see build.gradle.kts, and afield.audio.synth.Dough for the established idiom), so bridging morpho costs a driver script and this file rather than a port. That matters beyond convenience: a port would be Field's opinion of what MorphoHDL means, and would drift. This is upstream's parser, upstream's width inference and upstream's expansion, byte-identical to the tag recorded in morpho/PROVENANCE.md, with Field supplying only simulation, layout and drawing.

Everything guest-side is funnelled through withJs under one lock. A Graal Context is not safe for concurrent access, and Field will happily execute two boxes at once.

object MorphoRuntime {

    private val lock = ReentrantLock()

val root: Path by lazy { materialize() }

Where the vendored tree lives as real files.

ES module imports need a filesystem, so a packaged run unpacks the resources to a temp directory once. A dev checkout skips that and reads src/jvmMain/resources in place, so editing the driver and re-running picks the change up without a rebuild.

internal val VENDORED

Every resource the guest needs as a real file, plus the attribution that travels with it.

Two uses, and both of them have to be complete: a missing entry makes materialize accept a dev checkout it should have rejected, and makes a packaged run unpack a tree with a hole in it. Anything reached by module or root.resolve belongs here — including the two .mjs entry points, the four js/ files they import, tiny_morpho.py (the standard library) and main.wasm (read by MorphoLayout3D).

internal only so that MorphoVendoredResourcesTest can hold the real list — not the regex's idea of it — against the paths the host and the guest actually ask for.

body — 55 lines, from line 73
    internal val VENDORED = listOf(
        "morpho-field-driver.mjs",
        "morpho-field-layout.mjs",
        "morpho/js/parser.js",
        "morpho/js/primitives.js",
        "morpho/js/compiler.js",
        "morpho/js/utils.js",
        "morpho/tiny_morpho.py",
        "morpho/graphs_engine/main.wasm",
        // Nothing loads these two. The jar carries them regardless, so the Apache-2 obligation is
        // met without them; they are here so the unpacked tree — the copy someone actually looks
        // at, or copies out — says whose code it is.
        "morpho/LICENSE",
        "morpho/PROVENANCE.md",
    )

    private fun materialize(): Path {
        val dev = Paths.get("./src/jvmMain/resources").toAbsolutePath().normalize()
        if (VENDORED.all { dev.resolve(it).exists() }) return dev

        val tmp = Files.createTempDirectory("field-morpho")
        tmp.toFile().deleteOnExit()
        VENDORED.forEach { rel ->
            val stream = MorphoRuntime::class.java.getResourceAsStream("/$rel")
                ?: error("morpho resource missing from the classpath: /$rel")
            val target = tmp.resolve(rel)
            Files.createDirectories(target.parent)
            stream.use { Files.copy(it, target, StandardCopyOption.REPLACE_EXISTING) }
        }
        return tmp
    }

    private val context: Context by lazy {
        Context.newBuilder("js", "wasm")
            .allowIO(IOAccess.ALL)
            .allowHostAccess(HostAccess.ALL)
            // Required for js.webassembly: the JS WebAssembly API is implemented on top of the
            // "wasm" language, so JS must be allowed to reach it.
            .allowPolyglotAccess(PolyglotAccess.ALL)
            .allowExperimentalOptions(true)
            .option("js.esm-eval-returns-exports", "true")
            .option("js.webassembly", "true")
            .build()
    }

    private fun module(fileName: String): Value = withJs {
        context.eval(
            Source.newBuilder("js", root.resolve(fileName).toFile())
                .mimeType("application/javascript+module")
                .build()
        )
    }

    private val driver: Value by lazy { module("morpho-field-driver.mjs") }
    private val layoutModule: Value by lazy { module("morpho-field-layout.mjs") }

internal fun <T> withJs(block: () -> T): T = lock.withLock {

    internal fun <T> withJs(block: () -> T): T = lock.withLock {
        context.enter()
        try {
            block()
        } finally {
            context.leave()
        }
    }

internal fun call(member: String, vararg args: Any?): Value

    internal fun call(member: String, vararg args: Any?): Value =
        withJs { driver.getMember(member).execute(*args) }

internal fun interruptGuest(timeout: java.time.Duration = java.time.Duration.ofSeconds(2)): Boolean

Ask whatever the guest is currently doing to stop, from another thread.

Hand-written MorphoHDL is written by someone who wants a circuit, so nothing in the box language needs this. Generated MorphoHDL is not: materialize recurses once per cell expansion (compiler.js:1088) and only bottoms out when a SPLIT runs out of bits, so a synthesised body whose self-call does not narrow its arguments never returns. afield.lang.morpho.MorphoEvaluator arms this before every compile so a bad candidate costs one timeout instead of the process.

Interrupting is safe here and closing would not be: a Context survives an interrupt and stays usable, which matters because every other morpho box in the document shares this one. It must not be called from a thread that is inside withJs — hence "another thread".

@return true if the guest acknowledged within timeout.

    internal fun interruptGuest(timeout: java.time.Duration = java.time.Duration.ofSeconds(2)): Boolean =
        runCatching { context.interrupt(timeout); true }.getOrDefault(false)

internal fun callLayout(member: String, vararg args: Any?): Value

    internal fun callLayout(member: String, vararg args: Any?): Value =
        withJs { layoutModule.getMember(member).execute(*args) }

val standardLibrary: String by lazy

The MorphoHDL standard library — 8 LUTs and 26 cells covering adders, multipliers, shifters, muxes and the growth shapes.

It is read out of tiny_morpho.py between the # MORPHO_BEGIN / # MORPHO_END markers, because that is where upstream keeps it: the JavaScript parses the very bytes the Python executes. One copy, two implementations — which is exactly what makes the differential test meaningful.

    val standardLibrary: String by lazy {
        val text = Files.readString(root.resolve("morpho/tiny_morpho.py"))
        val begin = text.indexOf(BEGIN_MARKER)
        val end = text.indexOf(END_MARKER)
        require(begin >= 0 && end > begin) {
            "tiny_morpho.py is missing its $BEGIN_MARKER / $END_MARKER markers"
        }
        text.substring(begin + BEGIN_MARKER.length, end).trim()
    }

    private const val BEGIN_MARKER = "# MORPHO_BEGIN"
    private const val END_MARKER = "# MORPHO_END"

fun inspect(source: String): String = morphoErrors { call("inspect", source).asString() }

Parse source and report what it defines, without compiling anything.

fun cellNames(source: String): List<String>

Names of the cells a source defines, in declaration order.

    fun cellNames(source: String): List<String> =
        Regex("\"name\":\"([A-Za-z_]\\w*)\"").findAll(inspect(source)).map { it.groupValues[1] }.toList()

fun compile

Compile design at signature and return a handle that has not been expanded yet.

@param optimize run morpho's constant propagation and dead-code elimination during expansion. Upstream's demo defaults this on; leaving it off shows the unoptimised structure, which is usually the more legible thing to watch grow. @param maxFanout insert buffer trees above this fanout, or 0 for unlimited.

    fun compile(
        source: String = standardLibrary,
        design: String,
        signature: IntArray,
        optimize: Boolean = true,
        maxFanout: Int = 0,
    ): MorphoSession = morphoErrors {
        val state = call("create", source, design, signature.joinToString(","), optimize, maxFanout)
        MorphoSession(design, signature, state)
    }

internal fun <T> morphoErrors(block: () -> T): T = try {

Translate a guest failure into something a box can show on the right line.

morpho's parser attaches a line to syntax errors; its compiler mostly throws plain Errors, so the line is often absent and the message is all there is.

    internal fun <T> morphoErrors(block: () -> T): T = try {
        block()
    } catch (e: PolyglotException) {
        if (!e.isGuestException) throw e
        val line = runCatching {
            e.guestObject?.takeIf { it.hasMember("line") }?.getMember("line")?.asInt()
        }.getOrNull() ?: e.sourceLocation?.startLine?.takeIf { it > 0 }
        throw MorphoException(e.message ?: "morpho error", line, e)
    }
}

class MorphoException

A MorphoHDL parse, compile or expansion failure, with a source line where morpho supplied one.

class MorphoException(
    message: String,
    val line: Int?,
    cause: Throwable? = null,
) : RuntimeException(message, cause)

class MorphoSession

One circuit being grown.

A session starts as a single unexpanded cell and is expanded a budget at a time by grow, which is what makes growth watchable rather than a thing that has already happened by the time the box finishes running. Snapshot with netlist whenever you want to draw.

class MorphoSession(
    val design: String,
    val signature: IntArray,
    /** The guest-side growth handle. Shared with [MorphoLayout3D], which lays out the same graph. */
    internal val state: Value,
) : Closeable {

    private var closed = false

fun grow(budget: Int = Int.MAX_VALUE, method: String = "largest"): Int

Expand up to budget cells and return how many actually expanded; 0 means fully grown.

@param method "largest" expands the biggest estimated subtree first, so the circuit's overall shape appears early and fills in. "bfs" walks cell order, so it grows outward from the inputs. Upstream defaults to largest-first and so do we.

    fun grow(budget: Int = Int.MAX_VALUE, method: String = "largest"): Int {
        check(!closed) { "session for $design has been closed" }
        return MorphoRuntime.morphoErrors {
            MorphoRuntime.call("grow", state, budget, method).asInt()
        }
    }

fun growFully(method: String = "largest"): Int

Expand to completion, returning the total number of expansions.

    fun growFully(method: String = "largest"): Int {
        var total = 0
        while (true) {
            val n = grow(Int.MAX_VALUE, method)
            if (n == 0) break
            total += n
        }
        return total
    }

fun isFullyGrown(): Boolean

    fun isFullyGrown(): Boolean =
        MorphoRuntime.call("isFullyGrown", state).asBoolean()

fun netlist(): MorphoNetlist

Copy the current circuit out of the guest. Safe to call between grow steps.

    fun netlist(): MorphoNetlist = MorphoRuntime.withJs {
        val ex = MorphoRuntime.call("extract", state)
        val names = ex.getMember("names").asString()
        MorphoNetlist(
            nCells = ex.getMember("nCells").asInt(),
            nNets = ex.getMember("nNets").asInt(),
            nPins = ex.getMember("nPins").asInt(),
            steps = ex.getMember("steps").asInt(),
            meta = ex.getMember("cellMeta").`as`(IntArray::class.java),
            cellPinCount = ex.getMember("cellPinCount").`as`(IntArray::class.java),
            pinNet = ex.getMember("pinNet").`as`(IntArray::class.java),
            netDriver = ex.getMember("netDriver").`as`(IntArray::class.java),
            inputCells = ex.getMember("inputCells").`as`(IntArray::class.java),
            outputCells = ex.getMember("outputCells").`as`(IntArray::class.java),
            names = if (names.isEmpty()) emptyList() else names.split('\n'),
        )
    }

fun timing(): MorphoTiming

Signal arrival times from morpho's own updateTimes().

    fun timing(): MorphoTiming = MorphoRuntime.withJs {
        val arr = MorphoRuntime.call("timing", state).`as`(FloatArray::class.java)
        MorphoTiming(arr, arr.size / 2)
    }

override fun close()

    override fun close() {
        closed = true
    }
}