FunctionOps.kt
src/jvmMain/kotlin/afield/math/functions/FunctionOps.kt · 337 lines
package and imports — 65 lines, from line 1
@file:afield.runtime.completion.CompletionCategory("Function")
package afield.math.functions
import afield.data.collections.get
import afield.data.collections.invoke
import afield.data.collections.times
import afield.document.graph.minusAssign
import afield.document.graph.plusAssign
import afield.document.properties.compareTo
import afield.document.workspace.file
import afield.document.workspace.files
import afield.geometry.mesh.gradient
import afield.graphics.render.label
import afield.graphics.render.rangeTo
import afield.graphics.render.rect
import afield.graphics.vulkan.buffers.add
import afield.math.autodiff.FnFormulas
import afield.math.autodiff.FnNonDifferentiable
import afield.math.autodiff.compareTo
import afield.math.autodiff.derivative
import afield.math.autodiff.div
import afield.math.autodiff.max
import afield.math.autodiff.mean
import afield.math.autodiff.min
import afield.math.autodiff.minus
import afield.math.autodiff.plus
import afield.math.autodiff.times
import afield.math.linalg.invoke
import afield.math.linalg.remAssign
import afield.math.linalg.set
import afield.math.linalg.times
import afield.math.linalg.x
import afield.math.statistics.noise
import afield.math.units.div
import afield.math.units.minus
import afield.math.units.plus
import afield.math.units.times
import afield.media.image.pad
import afield.media.image.threshold
import afield.runtime.execution.end
import afield.runtime.execution.start
import afield.audio.synth.*
import afield.data.collections.s
import afield.math.autodiff.abs
import afield.math.autodiff.atan
import afield.math.autodiff.atan2
import afield.math.autodiff.clamp
import afield.math.autodiff.cos
import afield.math.autodiff.exp
import afield.math.autodiff.floor
import afield.math.autodiff.hypot
import afield.math.autodiff.pow
import afield.math.autodiff.reciprocal
import afield.math.autodiff.sign
import afield.math.autodiff.sin
import afield.math.linalg.f
import afield.math.statistics.perlinNoise
import afield.simulation.agents.step
import afield.simulation.automata.step
import afield.study.ui.numbers
import afield.math.functions.Functions.Easing
import afield.math.functions.Functions.PulseEdge
import afield.math.functions.Functions.PulseProfile
The op vocabulary for the whole Fn graph family, held in one object, OpRegistry: which op
names the stdlib actually emits into a graph node, which of them are aliases of each other, and
which can be rebuilt from an arbitrary node's role-addressed children — those rebuildable ones
each carrying a family label (oscillator, noise, transform, …).
Everything that has to reason about ops consults it instead of keeping a private list — the
rewrite DSL's op= validator and its hint text, the pattern matcher's alias-aware op
comparison, rebuildAsOp, and the "another op in the same family" draw behind the
substituteOp mutation. The Monaco fnrewrite.js extension mirrors it, and a JVM test asserts
that mirror never drifts.
object OpRegistry
Single source of truth for the Fn graph op vocabulary and op= transmutation.
The stdlib in Functions attaches a structural FnGraphNode to almost every
function it builds, each with a stable op string and role-tagged children.
Three places used to hardcode their own idea of "which ops exist": the
rebuildAsOp transmuter, the rewrite-DSL validator, and the Monaco language
extension (fnrewrite.js). They drifted. This registry is the one place that
knows the vocabulary; the others consult it (and a JVM test asserts the JS
mirror never drifts).
Transmutation ("rebuild this node as a different op") is explicit about what is
possible. Generators rebuild from role-addressed children plus scalar metadata
and always succeed, degrading missing roles to the factory defaults. Value
transforms rebuild only when the source node already has a source child, so
clamp => op=softClip works while sin => op=clamp is a reported no-op rather
than a lie. Data-carrying ops (piecewise, timeline, rbf, sequencing, …) are
known — selectors and validation recognise them — but are not transmutation
targets, because their identity lives in baked point/item data a foreign node
cannot supply.
object OpRegistry {
data class OpSpec
A transmutation target: how to rebuild canonical from an arbitrary matched node.
data class OpSpec(
val canonical: String,
val family: String,
val aliases: Set<String> = emptySet(),
val build: (OpSource) -> Functions.Fn?
)
class OpSource internal constructor(val self: Functions.Fn)
Read-only view of the node being transmuted: children by role, plus scalar metadata.
val node: FnGraphNode = self.graphNode()
val domain: Functions.Domain get() = self.domain
val numbers: Map<String, Double> get() = node.numbers
val strings: Map<String, String> get() = node.strings
val booleans: Map<String, Boolean> get() = node.booleans
val longs: Map<String, Long> get() = node.longs
private val byRole: Map<String, Functions.Fn> =
node.children.associate { it.role to it.fn }
fun child(vararg roles: String): Functions.Fn? = roles.firstNotNullOfOrNull { byRole[it] }
fun childOr(default: Double, vararg roles: String): Functions.Fn = child(*roles) ?: p(default)
fun source(): Functions.Fn? = child("source")
fun long(key: String, default: Long = 0L): Long = longs[key] ?: default
fun int(key: String, default: Int): Int = numbers[key]?.toInt() ?: default
fun bool(key: String, default: Boolean): Boolean = booleans[key] ?: default
fun str(key: String): String? = strings[key]
fun frequencyLike(default: Double = 1.0): Functions.Fn
frequency, or the reciprocal of period, or the default — bridges oscillator families.
fun frequencyLike(default: Double = 1.0): Functions.Fn =
child("frequency") ?: child("period")?.let(::reciprocal) ?: p(default)
fun periodLike(default: Double = 1.0): Functions.Fn
period, or the reciprocal of frequency, or the default.
fun periodLike(default: Double = 1.0): Functions.Fn =
child("period") ?: child("frequency")?.let(::reciprocal) ?: p(default)
}
transmutation builders (one per op; delegate to the real Functions factory)
source — 43 lines, from line 143
private fun buildSin(s: OpSource) = Functions.sin(s.frequencyLike(), s.childOr(0.0, "phase"), s.childOr(1.0, "amplitude"), s.childOr(0.0, "offset"), s.domain)
private fun buildCos(s: OpSource) = Functions.cos(s.frequencyLike(), s.childOr(0.0, "phase"), s.childOr(1.0, "amplitude"), s.childOr(0.0, "offset"), s.domain)
private fun buildSaw(s: OpSource) = Functions.saw(s.periodLike(), s.childOr(0.0, "phase"), s.childOr(1.0, "amplitude"), s.childOr(0.0, "offset"), s.domain)
private fun buildTriangle(s: OpSource) = Functions.triangle(s.periodLike(), s.childOr(0.0, "phase"), s.childOr(1.0, "amplitude"), s.childOr(0.0, "offset"), s.domain)
private fun buildSquare(s: OpSource) = Functions.square(s.periodLike(), s.childOr(0.0, "phase"), s.childOr(0.5, "duty"), s.childOr(1.0, "amplitude"), s.childOr(0.0, "offset"), s.domain)
private fun buildPulseTrain(s: OpSource) = Functions.pulseTrain(
period = s.periodLike(),
phase = s.childOr(0.0, "phase"),
duty = s.childOr(0.5, "duty"),
amplitude = s.childOr(1.0, "amplitude"),
offset = s.childOr(0.0, "offset"),
edge = parseEnum(s.str("edge"), PulseEdge.SMOOTH),
profile = parseEnum(s.str("profile"), PulseProfile.RECT),
seed = s.long("seed"),
bipolar = s.bool("bipolar", false),
domain = s.domain
)
private fun buildGaussian(s: OpSource) = Functions.gaussian(s.childOr(0.0, "mean"), s.childOr(1.0, "sigma"), s.childOr(1.0, "amplitude"), s.domain)
private fun buildWhiteNoise(s: OpSource) = Functions.whiteNoise(s.long("seed"), s.frequencyLike(), s.childOr(1.0, "amplitude"), s.domain)
private fun buildValueNoise(s: OpSource) = Functions.valueNoise(s.long("seed"), s.frequencyLike(), s.childOr(1.0, "amplitude"), s.domain)
private fun buildPerlinNoise(s: OpSource) = Functions.perlinNoise(s.long("seed"), s.frequencyLike(), s.childOr(1.0, "amplitude"), s.domain)
private fun buildFbm(s: OpSource) = Functions.fbm(s.long("seed"), s.frequencyLike(), s.childOr(1.0, "amplitude"), s.int("octaves", 5), s.childOr(2.0, "lacunarity"), s.childOr(0.5, "gain"), s.domain)
private fun buildFbmPerlin(s: OpSource) = Functions.fbmPerlin(s.long("seed"), s.frequencyLike(), s.childOr(1.0, "amplitude"), s.int("octaves", 5), s.childOr(2.0, "lacunarity"), s.childOr(0.5, "gain"), s.domain)
private fun buildRandomWalk(s: OpSource) = Functions.randomWalk(s.long("seed"), s.childOr(1.0, "step"), s.childOr(1.0, "amplitude"), s.domain)
private fun buildRamp(s: OpSource) = Functions.ramp(s.childOr(0.0, "start"), s.childOr(1.0, "end"), s.domain)
private fun buildRemap(s: OpSource) = Functions.remap(s.childOr(0.0, "from"), s.childOr(1.0, "to"), s.domain)
private fun buildStep(s: OpSource) = Functions.step(s.childOr(0.0, "edge"), s.domain)
private fun buildSmoothStep(s: OpSource) = Functions.smoothStep(s.childOr(0.0, "edge0"), s.childOr(1.0, "edge1"), s.domain)
private fun buildHann(s: OpSource) = Functions.hannWindow(s.domain)
private fun buildHamming(s: OpSource) = Functions.hammingWindow(s.domain)
private fun buildBlackman(s: OpSource) = Functions.blackmanWindow(s.domain)
private fun buildGaussianWindow(s: OpSource) = Functions.gaussianWindow(s.domain, s.childOr(0.4, "sigma"))
private fun buildTween(s: OpSource) = Functions.tween(s.childOr(0.0, "from"), s.childOr(1.0, "to"), s.domain, parseEnum(s.str("easing"), Easing.SMOOTHSTEP))
private fun buildEase(s: OpSource): Functions.Fn? {
val src = s.source() ?: return null
return with(Functions) { src.ease(parseEnum(s.str("easing"), Easing.SMOOTHSTEP), s.bool("clampInput", true)) }
}
algebra — needs both operands
private fun buildBinary(s: OpSource, combine: Functions.(Functions.Fn, Functions.Fn) -> Functions.Fn): Functions.Fn? {
val left = s.child("left") ?: return null
val right = s.child("right") ?: return null
return combine(Functions, left, right)
}
value transforms — need an existing source child (so transform => transform works, generator => transform is a no-op)
source — 80 lines, from line 195
private fun buildUnary(s: OpSource, wrap: Functions.(Functions.Fn) -> Functions.Fn): Functions.Fn? {
val src = s.source() ?: return null
return wrap(Functions, src)
}
private val specs: List<OpSpec> = listOf(
OpSpec("sin", "oscillator", setOf("sine"), ::buildSin),
OpSpec("cos", "oscillator", setOf("cosine"), ::buildCos),
OpSpec("saw", "oscillator", setOf("sawtooth", "ramp-wave"), ::buildSaw),
OpSpec("triangle", "oscillator", setOf("tri"), ::buildTriangle),
OpSpec("square", "oscillator", setOf("rect"), ::buildSquare),
OpSpec("pulsetrain", "pulse", setOf("pulseTrain"), ::buildPulseTrain),
OpSpec("gaussian", "shape", emptySet(), ::buildGaussian),
OpSpec("ramp", "shape", emptySet(), ::buildRamp),
OpSpec("remap", "shape", emptySet(), ::buildRemap),
OpSpec("step", "shape", emptySet(), ::buildStep),
OpSpec("smoothstep", "shape", setOf("smoothStep"), ::buildSmoothStep),
OpSpec("tween", "motion", emptySet(), ::buildTween),
OpSpec("ease", "motion", emptySet(), ::buildEase),
OpSpec("whiteNoise", "noise", setOf("white", "whitenoise"), ::buildWhiteNoise),
OpSpec("valueNoise", "noise", setOf("valuenoise", "noise"), ::buildValueNoise),
OpSpec("perlinNoise", "noise", setOf("perlin", "perlinnoise"), ::buildPerlinNoise),
OpSpec("fbm", "noise", emptySet(), ::buildFbm),
OpSpec("fbmPerlin", "noise", setOf("fbmperlin"), ::buildFbmPerlin),
OpSpec("randomWalk", "noise", setOf("randomwalk"), ::buildRandomWalk),
OpSpec("hannWindow", "window", setOf("hann"), ::buildHann),
OpSpec("hammingWindow", "window", setOf("hamming"), ::buildHamming),
OpSpec("blackmanWindow", "window", setOf("blackman"), ::buildBlackman),
OpSpec("gaussianWindow", "window", setOf("gausswin"), ::buildGaussianWindow),
OpSpec("add", "algebra", setOf("+", "plus")) { buildBinary(it) { a, b -> a + b } },
OpSpec("subtract", "algebra", setOf("-", "minus")) { buildBinary(it) { a, b -> a - b } },
OpSpec("multiply", "algebra", setOf("*", "times")) { buildBinary(it) { a, b -> a * b } },
OpSpec("divide", "algebra", setOf("/")) { buildBinary(it) { a, b -> a / b } },
OpSpec("shift", "transform", setOf("delay")) { buildUnary(it) { f -> f.shift(it.childOr(0.0, "dx", "delay")) } },
OpSpec("scaleX", "transform", emptySet()) { buildUnary(it) { f -> f.scaleX(it.childOr(1.0, "scale"), it.childOr(0.0, "about")) } },
OpSpec("scaleY", "transform", setOf("gain")) { buildUnary(it) { f -> f.scaleY(it.childOr(1.0, "scale")) } },
OpSpec("offsetY", "transform", setOf("bias")) { buildUnary(it) { f -> f.offsetY(it.childOr(0.0, "offset")) } },
OpSpec("pow", "transform", emptySet()) { buildUnary(it) { f -> f.pow(it.childOr(1.0, "exponent")) } },
OpSpec("log", "transform", emptySet()) { buildUnary(it) { f -> f.log(it.childOr(kotlin.math.E, "base")) } },
OpSpec("clamp", "transform", emptySet()) { buildUnary(it) { f -> f.clamp(it.childOr(-1.0, "min"), it.childOr(1.0, "max")) } },
OpSpec("clip", "transform", emptySet()) { buildUnary(it) { f -> f.clip(it.childOr(1.0, "threshold")) } },
OpSpec("softClip", "transform", emptySet()) { buildUnary(it) { f -> f.softClip(it.childOr(1.0, "amount")) } },
OpSpec("fold", "transform", emptySet()) { buildUnary(it) { f -> f.fold(it.childOr(1.0, "threshold")) } },
OpSpec("chebyshev", "transform", setOf("cheby")) { buildUnary(it) { f -> f.chebyshev(it.childOr(2.0, "order")) } },
OpSpec("wrapY", "transform", emptySet()) { buildUnary(it) { f -> f.wrapY(it.childOr(-1.0, "min"), it.childOr(1.0, "max")) } },
OpSpec("rectify", "transform", setOf("rectifier")) { buildUnary(it) { f -> f.rectify(parseEnum(it.str("mode"), Functions.RectifyMode.FULL)) } },
OpSpec("quantize", "transform", setOf("quant")) { buildUnary(it) { f -> f.quantize(it.childOr(8.0, "levels")) } },
OpSpec("crush", "transform", setOf("bitcrush", "bitCrush")) { buildUnary(it) { f -> f.bitCrush(it.childOr(8.0, "bits")) } },
// The four ex-`mapY` shapers. They used to hide inside an opaque lambda node labelled with
// their *child's* name, so the graph did not record that an abs (or an exp) had happened at
// all — invisible to `op=`, to the rewrite/evolve layer and to the exact gradient alike.
// `exp` is here so it is the transmutable partner of `log` rather than an opaque lambda
// beside it. None carries scalar metadata, so each rebuilds from its `source` child alone.
// `abs`, `flip` and `exp` also have derivatives (see `FnFormulas`); `sign` deliberately does
// not — its derivative is zero wherever it exists, so `FnNonDifferentiable` files it with a
// reason rather than letting a fit descend a gradient that is always exactly zero.
OpSpec("exp", "transform", emptySet()) { buildUnary(it) { f -> f.exp() } },
OpSpec("abs", "transform", setOf("absoluteValue")) { buildUnary(it) { f -> f.abs() } },
OpSpec("floor", "transform", setOf("floorY")) { buildUnary(it) { f -> f.floor() } },
OpSpec("flip", "transform", setOf("negate")) { buildUnary(it) { f -> f.flip() } },
OpSpec("sign", "transform", setOf("signum")) { buildUnary(it) { f -> f.sign() } },
// The angle family, which arrived with RectSL's differentiable heading. `atan2` and `hypot`
// read their second operand as a child so a transmutation can put a signal there, and the
// defaults are the ones that make each degenerate into something recognisable: `atan2(y, 1)`
// is `atan(y)` up to the quadrant, and `hypot(a, 0)` is `|a|`.
OpSpec("atan", "transform", setOf("arctan")) { buildUnary(it) { f -> f.atan() } },
OpSpec("atan2", "transform", setOf("arctan2")) {
buildUnary(it) { f -> f.atan2(it.childOr(1.0, "x")) }
},
OpSpec("hypot", "transform", setOf("length")) {
buildUnary(it) { f -> f.hypot(it.childOr(0.0, "y")) }
}
)
private val specByName: Map<String, OpSpec> = buildMap {
for (spec in specs) {
put(spec.canonical.lowercase(), spec)
for (alias in spec.aliases) put(alias.lowercase(), spec)
}
}
val knownOps: Set<String>
Every op string the stdlib actually emits (from node(...)/graphFn(...)),
so validation and selectors never treat a real op as unknown even when it is
not a transmutation target. Kept in sync with Functions.kt by
FunctionsGraphRewriteTest.
val knownOps: Set<String> = (specByName.keys + setOf(
"const", "param", "identity", "name", "over", "lambda", "opaque", "array",
"amplitudeMod", "ringMod", "warp", "sampleHold", "quantizeX", "jitterX", "saturate",
"mapX", "mapY", "stretch", "reverse", "crossfade", "cut", "replace", "withEdge",
"repeat", "mirror", "mirrorRepeat", "tile", "pad", "echo", "copyPaste", "randomCopyPaste",
"randomRepeat", "gateHold",
"derivative", "integral", "differentiate", "convolve", "movingAverage", "blur",
"gaussianSmooth", "autoScale", "zNormalize", "whitenCdf",
"piecewise", "piecewiseLinear", "catmullRom", "monotoneCubic", "hermite",
"bSplineQuadratic", "cosineInterp", "smoothStepInterp", "smootherStepInterp",
"stepHold", "nearestValue", "rbf", "granular",
"pat",
"timeline", "timelineTap", "spliceWindow", "sequence", "sequenceLoop",
"randomSequence", "weightedSequence", "randomPermute", "randomPermuteLoop",
"palindrome", "roundRobin", "stack", "average", "select", "morph",
"pulseburst", "pulsepattern", "dutyFromWidth", "periodFromFrequency", "period"
).map { it.lowercase() }).toSet()
val transmutableOps: List<String> get() = specs.map { it.canonical }
Canonical op names that op= can rebuild to, grouped by family, for docs and diagnostics.
fun knows(name: String): Boolean
fun knows(name: String): Boolean {
val key = name.trim().trim('"', '\'').lowercase()
return key in specByName || key in knownOps
}
fun isTransmutable(name: String): Boolean
fun isTransmutable(name: String): Boolean =
name.trim().trim('"', '\'').lowercase() in specByName
fun canonical(name: String): String?
fun canonical(name: String): String? =
specByName[name.trim().trim('"', '\'').lowercase()]?.canonical
fun family(name: String): String?
The family of a transmutable op (e.g. "oscillator", "noise", "transform").
fun family(name: String): String? =
specByName[name.trim().trim('"', '\'').lowercase()]?.family
fun opsInFamily(family: String): List<String>
Canonical transmutable ops in family, useful for within-family mutation.
fun opsInFamily(family: String): List<String> =
specs.filter { it.family == family }.map { it.canonical }
fun rebuild(fn: Functions.Fn, targetOp: String): Functions.Fn?
Rebuild fn as targetOp, or null if the target is not a viable transmutation of this node.
fun rebuild(fn: Functions.Fn, targetOp: String): Functions.Fn? {
val spec = specByName[targetOp.trim().trim('"', '\'').lowercase()] ?: return null
return spec.build(OpSource(fn))
}
fun transmutableHint(): String = "transmutable: " + transmutableOps.joinToString(", ")
One-line description of the transmutation vocabulary, for validator hints.
private fun p(v: Double): Functions.Fn = Functions.c(v, Functions.Domain.ALL)
private fun reciprocal(f: Functions.Fn): Functions.Fn = with(Functions) { p(1.0) / f }
private inline fun <reified E : Enum<E>> parseEnum(value: String?, default: E): E =
value?.let { raw -> enumValues<E>().firstOrNull { it.name.equals(raw, ignoreCase = true) } } ?: default
}