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)