Skip to content

FunctionRewriteKotlin.kt

src/jvmMain/kotlin/afield/math/functions/FunctionRewriteKotlin.kt · 420 lines

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

package afield.math.functions

import afield.app.commands.action
import afield.app.commands.actions
import afield.data.collections.s
import afield.data.collections.times
import afield.data.graph.rules
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.graphics.render.rangeTo
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.sin
import afield.math.autodiff.times
import afield.math.linalg.f
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.media.image.close
import afield.media.image.open
import afield.runtime.lifecycle.cached
import afield.runtime.lifecycle.once
import afield.study.source.SourceBackedBlock
import afield.study.source.SourceBackedBlockSelfBoxReentry
import afield.study.source.sourceBlock

import afield.audio.synth.*
import afield.data.collections.get
import afield.data.collections.invoke
import afield.document.properties.clear
import afield.geometry.mesh.expanded
import afield.graphics.vulkan.buffers.add
import afield.math.linalg.invoke
import afield.study.source.hydratingBoxes
import afield.study.source.sourceBackedBlockBox
import afield.document.graph.Box
import afield.runtime.kotlin.PreparedSnippet
import afield.app.diagnostics.logging.lprintln
import java.util.concurrent.ConcurrentHashMap
import kotlin.script.experimental.api.ResultValue

The bridge that gives the string rewrite DSL a real Kotlin right-hand side.

FunctionRewriteDsl.kt parses @{...} expressions but never compiles them; this file supplies the FnRewriteOptions.kotlinNumberContext hook it leaves open, compiling each expression as a FnRewriteNumberContext -> Any? lambda inside a live box's coding context so a snippet can read value, op, parentOp, depth and the rest as plain properties. SourceBackedFnRewriteNumberExpression stores only source plus a box id and hydrates on first use; SourceBackedFnRewriteNumberEvaluator caches one per distinct expression string. The entry points are Functions.Fn.rewriteKotlin, Functions.Fn.previewRewriteKotlin, Functions.Fn.rewriteKotlinChecked and the Box.rewriteFn / Box.previewRewriteFn receiver forms — each is the corresponding plain-DSL call with that evaluator wired in.

Hydration requires the originating box to still be loaded and the compiled lambda to return a Number?; anything else throws, naming the box or the offending source. When there is no box in scope no evaluator is installed at all, so Kotlin right-hand sides are left unapplied; the compiler-free rewrite(...) path never needs one.

Do not call rewriteKotlin (or thisBox.rewriteFn) from the body of the box the snippets compile in. Hydration evaluates on that box's own engine, which re-instantiates its snippet chain and so re-runs the body that is already running — once per matched node, until the stack goes. It is guarded now: the second hydration for a box on one thread throws FnRewriteSelfBoxReentry, whose message names the ways out. Same hazard as afield.study.source.SourceBackedBlock, reached by a second route; see dev_docs/FN_REWRITE_SELF_BOX_REENTRY.md.

class FnRewriteSelfBoxReentry(box: Box, source: String) : IllegalStateException

Thrown when a @{...} right-hand side would be compiled in a box whose body is already running — the failure that used to be 172 nested body runs and a StackOverflowError. The in-flight set it consults is afield.study.source.hydratingBoxes, shared with SourceBackedBlock's identical guard.

rewriteKotlin compiles each @{...} in a box's live engine, and takes that box from Box.context: itself. Evaluating a new snippet re-instantiates the engine's snippet chain, the box body is in that chain, so the body runs again — and calls rewriteKotlin again, once per level, until the stack goes. Detected on the second hydration for the box rather than the first, which costs one extra body run and in exchange never refuses the legitimate cases (a frame loop or a control callback, where the body has finished and the hydration is not nested inside another).

The message names the three ways out, because none of them is guessable from a stack trace. See dev_docs/FN_REWRITE_SELF_BOX_REENTRY.md. SourceBackedBlockSelfBoxReentry is the same guard on SourceBackedBlock's route, and shares this one's in-flight set.

class FnRewriteSelfBoxReentry(box: Box, source: String) : IllegalStateException(
    "This rewrite is compiling '@{$source}' in box '${box.name}' (${box.id}) — the box that is running it, " +
        "whose body it therefore re-runs, once per matched node, until the stack overflows. " +
        "Run the rewrite somewhere that is not this box's own body: " +
        "(1) from another box — `other.rewriteFn(fn, spec)`, e.g. `parents.last().rewriteFn(fn, spec)`, " +
        "where `other` is idle; " +
        "(2) from a frame loop or a control callback, after this body has finished; or " +
        "(3) with a typed evaluator instead of a source string — `fnRewriteNumber { value * 2.0 }` " +
        "carries a live lambda and never compiles anything. " +
        "See dev_docs/FN_REWRITE_SELF_BOX_REENTRY.md."
)

internal class FnRewriteCompilationFailure(message: String) : IllegalStateException(message)

A rejected source compile, as distinct from a missing box or a runtime evaluation failure.

internal class FnRewriteCompilationMemo<T : Any>

Memoize only compiler rejection; transient hydration/runtime failures remain retryable.

internal class FnRewriteCompilationMemo<T : Any> {
    @Volatile
    private var value: T? = null
    @Volatile
    private var failure: FnRewriteCompilationFailure? = null

fun get(compile: () -> T): T {

    fun get(compile: () -> T): T {
        value?.let { return it }
        failure?.let { throw it }
        return synchronized(this) {
            value?.let { return it }
            failure?.let { throw it }
            try {
                compile().also { value = it }
            } catch (rejected: FnRewriteCompilationFailure) {
                failure = rejected
                throw rejected
            }
        }
    }

fun clear()

    @Synchronized
    fun clear() {
        value = null
        failure = null
    }
}

class SourceBackedFnRewriteNumberExpression

Source-backed Kotlin evaluator for @{...} fragments inside the Fn rewrite DSL.

The evaluator mirrors SourceBackedBlock: it stores only source and a box id, then compiles the source in that box's live-coding context when first needed. The compiled value is a FnRewriteNumberContext -> Any? lambda, so snippets can refer to context properties directly:

sin.frequency => = @{value * 2.0}
role=amplitude => *= @{if (parentOp == "fbm") 0.5 else 1.0}
class SourceBackedFnRewriteNumberExpression(
    /**
     * The box whose live-coding context the source will be compiled in. Empty when there was no box in
     * scope at construction, which makes the first call fail rather than fall back to anything.
     */
    val boxId: String,
    /**
     * The Kotlin source of the right-hand side, already stripped of its `@{...}` wrapper. Compiled inside
     * a `with(context)` block, so it reads the [FnRewriteNumberContext] members as bare names, and its last
     * expression is the result.
     */
    val source: String,
    private val block: ((FnRewriteNumberContext) -> Any?)? = null
) : (FnRewriteNumberContext) -> Double? {
    private val compilation = FnRewriteCompilationMemo<(FnRewriteNumberContext) -> Any?>()

fun invalidate() = compilation.clear()

Forget a compiled lambda or rejected compile after changing the originating box's context.

override fun invoke(context: FnRewriteNumberContext): Double?

Evaluates the expression for one matched node, returning the new number or null to leave the value alone.

The first call compiles, under a lock, and later calls reuse the compiled lambda. Compiler rejection is also remembered until invalidate; missing boxes, cancellation and runtime errors are not. A result that is neither null nor a Number is a hard error naming the offending source — a snippet ending in a println or an assignment throws rather than being ignored in silence. A live block is called directly and its null is returned as-is, so returning null from one really does mean "leave this value alone"; only the absence of a block sends the call down the source-hydration path.

body — 97 lines, from line 185
    override fun invoke(context: FnRewriteNumberContext): Double? {
        // A live block returning null means "leave this value alone", so only the absence of a
        // block sends us to the source; an elvis here would hydrate an empty `source` instead.
        block?.let { return coerceResult(it.invoke(context)) }

        val delegate = compilation.get { hydrate() }
        return coerceResult(delegate(context))
    }

    private fun hydrate(): (FnRewriteNumberContext) -> Any? {
        val box = sourceBackedBlockBox(boxId)
            ?: throw IllegalStateException("Cannot hydrate Fn rewrite Kotlin; box '$boxId' is not loaded.")

        // Refuse to hydrate on this box's engine while its own body is running: it would run the body again.
        if (afield.runtime.execution.BoxBodyRun.isRunning(boxId))
            throw afield.study.source.SourceBackedBlockDuringOwnBody(box, source)
        // Refuse the second hydration for this box on this thread — see [FnRewriteSelfBoxReentry].
        // Logged as well as thrown: the throw lands inside a *re-entrant* run of the box body, and
        // whatever instantiates the snippet chain absorbs it without reporting, so the throw alone is
        // invisible to the person whose box just ran twice.
        if (!hydratingBoxes.get().add(boxId)) {
            val reentry = FnRewriteSelfBoxReentry(box, source)
            lprintln("error,fn-rewrite", reentry.message ?: "Fn rewrite re-entered its own box")
            throw reentry
        }
        try {
            return hydrateInBox(box)
        } finally {
            hydratingBoxes.get().remove(boxId)
        }
    }

    private fun hydrateInBox(box: Box): (FnRewriteNumberContext) -> Any? {
        val snippet = compileLambda(box)
        val result = snippet.evalAndWait(
            implicits = listOf(box),
            onEvalException = { throw it }
        )

        val value = when (result) {
            is ResultValue.Value -> result.value
            is ResultValue.Error -> throw result.error
            null -> throw IllegalStateException("Fn rewrite Kotlin in box '$boxId' produced no lambda.")
            else -> throw IllegalStateException("Fn rewrite Kotlin in box '$boxId' did not evaluate to a lambda: $result")
        }

        val function = value as? Function1<*, *>
            ?: throw IllegalStateException(
                "Fn rewrite Kotlin in box '$boxId' evaluated to ${value?.javaClass?.name ?: "null"}, not Function1."
            )

        @Suppress("UNCHECKED_CAST")
        return { context -> (function as Function1<Any?, *>).invoke(context) }
    }

    private fun compileLambda(box: Box): PreparedSnippet {
        val lambdaSource = """
            { ctx: afield.math.functions.FnRewriteNumberContext ->
                with(ctx) {
                    $source
                }
            }
        """.trimIndent()

        val expanded = runCatching { box.ki.applyMacros(box, lambdaSource, skipMacros = setOf("sourceBlock")) }
            .getOrElse {
                lprintln(
                    "warning,fn-rewrite",
                    "Macro expansion failed for Fn rewrite Kotlin in '${box.name}' (${box.id}): ${it.message}"
                )
                lambdaSource
            }

        val diagnosticsText = mutableListOf<String>()
        return box.ki.compileSnippetAndWait(
            expanded,
            implicits = listOf(box),
            onCompileError = { diagnostics ->
                diagnosticsText += diagnostics.map { "${it.message} ${it.location}" }
                diagnostics.forEach { lprintln("error,fn-rewrite", "compile: ${it.message} ${it.location}") }
            }
        ) ?: throw FnRewriteCompilationFailure(
            "Could not compile Fn rewrite Kotlin in box '${box.name}' (${box.id}). " +
                "Call invalidate() on the expression or clearCache() on the evaluator after repairing its context.\n" +
                diagnosticsText.joinToString("\n")
        )
    }

    private fun coerceResult(result: Any?): Double? =
        when (result) {
            null -> null
            is Number -> result.toDouble()
            else -> throw IllegalStateException(
                "Fn rewrite Kotlin expression '${source.take(80).replace('\n', ' ')}' returned " +
                    "${result::class.simpleName ?: result.javaClass.name}, not Number?"
            )
        }

override fun toString(): String

The box id and the first 80 characters of the source, newlines flattened — safe to log.

    override fun toString(): String =
        "SourceBackedFnRewriteNumberExpression(boxId=$boxId, source=${source.take(80).replace('\n', ' ')})"
}

class SourceBackedFnRewriteNumberEvaluator

The evaluator a whole rewrite runs through: one per box, compiling and caching a SourceBackedFnRewriteNumberExpression per distinct expression string it is asked for.

The cache is keyed on the source text alone — the node is passed in at call time — so one @{...} action is compiled once no matter how many occurrences it matches. It is not, however, shared between rewrites: rewriteKotlin and friends build a fresh evaluator per call, so a spec re-run in an animation loop recompiles its expressions every time. That compile is a real snippet compile in the box's engine, not a cheap parse; hoist the rewrite out of the loop, or keep one evaluator of your own and pass it through FnRewriteOptions. Rejected compiles are remembered as well; call clearCache after repairing imports/dependencies in that box. Runtime errors and temporarily missing boxes are not negatively cached.

class SourceBackedFnRewriteNumberEvaluator(
    /** The box every expression will be compiled in. */
    val boxId: String
) : (FnRewriteNumberContext) -> Double? {
    private val expressions = ConcurrentHashMap<String, SourceBackedFnRewriteNumberExpression>()

fun clearCache() = expressions.clear()

Forget cached successes and compiler rejections; the next rewrite hydrates against the current box.

override fun invoke(context: FnRewriteNumberContext): Double?

Compiles FnRewriteNumberContext.expression if this is the first sighting of it, then evaluates it.

    override fun invoke(context: FnRewriteNumberContext): Double? =
        expressions.computeIfAbsent(context.expression) {
            SourceBackedFnRewriteNumberExpression(boxId = boxId, source = it)
        }.invoke(context)
}

fun fnRewriteNumberExpression

Wraps a snippet of Kotlin source as a rewrite-number evaluator bound to box, compiled lazily on first use.

The way to build one evaluator by hand — to reuse across several rewrites, or to hand a persisted expression back its box. Nothing is compiled or even checked here, so a syntax error surfaces at the first matched node, and a null box produces an object that throws on first use rather than one that silently does nothing.

fun fnRewriteNumberExpression(
    source: String,
    box: Box? = Box.context.get()
): SourceBackedFnRewriteNumberExpression =
    SourceBackedFnRewriteNumberExpression(boxId = box?.id ?: "", source = source)

fun fnRewriteNumber(block: FnRewriteNumberContext.() -> Number?): SourceBackedFnRewriteNumberExpression =

A rewrite-number evaluator written as an ordinary Kotlin lambda over FnRewriteNumberContext, with no compilation involved — the typed alternative to a string of source.

The block runs directly, so it can close over anything in scope, and returning null from it means "leave this value alone" — the node is visited, nothing is written, and the walk carries on. Returning the current value instead is equivalent but records a change.

fun fnRewriteNumber(block: FnRewriteNumberContext.() -> Number?): SourceBackedFnRewriteNumberExpression =
    SourceBackedFnRewriteNumberExpression(
        boxId = Box.context.get()?.id ?: "",
        source = "",
        block = { context -> context.block() }
    )

fun Functions.Fn.rewriteKotlin

Functions.Fn.rewrite with the live box's Kotlin evaluator wired in — the version to use in Field code, and the only one where a @{...} right-hand side actually runs.

The snippets are compiled in box's coding context, so they see its imports, its top-level values and its helper functions. When no box can be found (the default looks one up in Box.context) no evaluator is installed at all and Kotlin actions are silently skipped, leaving the rest of the spec to apply — quiet by design, and worth remembering when a rewrite run outside a box appears to do nothing. As forgiving about malformed rules as Functions.Fn.rewrite; see rewriteKotlinChecked for the strict form.

fun Functions.Fn.rewriteKotlin(
    spec: String,
    preserveSharing: Boolean = false,
    box: Box? = Box.context.get()
): Functions.Fn = rewrite(spec, kotlinRewriteOptions(preserveSharing, box))

fun Functions.Fn.previewRewriteKotlin

Functions.Fn.previewRewrite with the live box's Kotlin evaluator wired in: the same rewrite rewriteKotlin performs, but keeping the diagnostics, matched paths and no-ops.

The one to read before committing, because it is the only place the quiet failures of this path become visible — an expression that returned the value unchanged shows up as matched/no-op, and an unmatched selector as an unmatched rule. Preview and commit compile in the same box, so they see the same live state.

fun Functions.Fn.previewRewriteKotlin(
    spec: String,
    preserveSharing: Boolean = false,
    box: Box? = Box.context.get()
): FnRewritePreview = previewRewrite(spec, kotlinRewriteOptions(preserveSharing, box))

fun Functions.Fn.rewriteKotlinChecked

Functions.Fn.rewriteChecked with the live box's Kotlin evaluator wired in — throws for a spec whose parse produced any error, rather than applying whatever rules happened to parse.

Throws IllegalArgumentException with the diagnostics attached. It checks the rewrite language only: the Kotlin inside @{...} is compiled lazily at the first node that matches, so a snippet that does not compile, or returns something that is not a number, throws from there and not from here.

fun Functions.Fn.rewriteKotlinChecked(
    spec: String,
    preserveSharing: Boolean = false,
    box: Box? = Box.context.get()
): Functions.Fn = rewriteChecked(spec, kotlinRewriteOptions(preserveSharing, box))

private fun kotlinRewriteOptions(preserveSharing: Boolean, box: Box?): FnRewriteOptions =
    FnRewriteOptions(
        preserveSharing = preserveSharing,
        kotlinNumberContext = box?.let { SourceBackedFnRewriteNumberEvaluator(it.id) }
    )

fun Box.rewriteFn

Functions.Fn.rewriteKotlin with the box named explicitly instead of looked up: box.rewriteFn(f, spec).

For code that is not running inside the box whose context the snippets should compile in — a helper on another thread, or an agent driving a specific box — where Box.context would be empty or wrong.

fun Box.rewriteFn(
    fn: Functions.Fn,
    spec: String,
    preserveSharing: Boolean = false
): Functions.Fn =
    fn.rewriteKotlin(spec, preserveSharing = preserveSharing, box = this)

fun Box.previewRewriteFn

Functions.Fn.previewRewriteKotlin with the box named explicitly: the reporting form of rewriteFn, for the same off-context callers.

fun Box.previewRewriteFn(
    fn: Functions.Fn,
    spec: String,
    preserveSharing: Boolean = false
): FnRewritePreview =
    fn.previewRewriteKotlin(spec, preserveSharing = preserveSharing, box = this)