Evaluator.kt
src/jvmMain/kotlin/afield/lang/fieldml/core/Evaluator.kt · 735 lines
package afield.lang.fieldml.core
import afield.lang.fieldml.ast.BinaryOp
import afield.math.linalg.Vector2f
import afield.math.linalg.Vector3f
import afield.math.linalg.Vector4f
The FieldML runtime: it walks a lowered CoreExpr tree and returns the FieldMLValue the tree evaluates to.
This is the last stage of the pipeline. Lowerer hands over a tree whose lambdas are already
curried and whose source ranges are already gone, so nothing here can report a position and a
box blames the fragment it ran rather than the subexpression that failed. An Evaluator holds
the prelude it was constructed with and builds a fresh root scope for every evaluate, so two
runs of the same box share no state.
Most of the value shapes are what their names say. Two are not. HostValue wraps an arbitrary
JVM object, which is how a FieldML box reaches Field at all: FieldMLHostBridge both produces
and consumes these, and Vec2, Vec3 and Vec4 are HostValues holding the mutable linalg
vector types rather than a FieldML shape of their own. Arithmetic on them and access to their
x, y, z and w components are therefore special-cased here, component-wise between
vectors of equal width and broadcast against a scalar, and always into a freshly allocated
vector, because the linalg types are mutable and a FieldML value is not. FactoryValue is the
other: RuntimeEnv calls its create on every lookup, so each mention of the name is a
separate value while a let-bound name is one value. The prelude uses this once, for fline,
and needs to, because moveTo and lineTo mutate the line they are handed and return it.
hostToFieldMLValue and fieldMLValueToHost are the conversion pair at the JVM boundary, and
they are not inverses. A tuple leaves as a plain List and comes back a list, so a round trip
through the host loses the distinction the type checker made; records, unit and the numeric
shapes survive one. Closures, native functions, partially applied constructors and ADT values
leave as themselves rather than as anything JVM-shaped, which is what lets the bridge's SAM
proxy apply one later.
Scope is a chain of RuntimeEnvs with one parent each. A let always makes a child; a
recursive one evaluates its own body in the scope that will hold the name, and a plain one
evaluates in the parent, so let x = x + 1 reads the outer x. A match makes one child per
case it tries, and a value matching no case fails at this point: nothing earlier checks
exhaustiveness, and this runtime error is the only mention of it in FieldML.
Two failures here have their cause somewhere else. Tail positions loop, but every other call
recurses, so deep non-tail FieldML recursion arrives as a JVM StackOverflowError and not as a FieldML
diagnostic. And an unknown runtime name means the two halves of the prelude have drifted apart:
Inferencer already rejects any name FieldMLTypePrelude.schemes does not list, so a name that
type-checked and then went missing is one FieldMLRuntimePrelude.values does not define.
sealed interface FieldMLValue
Everything a FieldML expression can evaluate to.
The literal shapes fix FieldML's numeric width: its Int is a JVM Long and its Float a JVM
Double, one of each, which is why calling into Kotlin needs the conversion ladder
FieldMLHostBridge scores with.
sealed interface FieldMLValue {
data class IntValue(val value: Long) : FieldMLValue
A FieldML Int. There is only one integer width, and it is 64-bit.
data class FloatValue(val value: Double) : FieldMLValue
A FieldML Float. There is only one floating-point width, and it is 64-bit.
data class BoolValue(val value: Boolean) : FieldMLValue
A FieldML Bool.
data class StringValue(val value: String) : FieldMLValue
A FieldML String.
data class TupleValue(val elements: List<FieldMLValue>) : FieldMLValue
A fixed-width tuple, whose elements may have different types.
Structurally identical to ListValue, and separate from it because the type checker treats
them differently. Nothing downstream of fieldMLValueToHost can tell them apart.
data class ListValue(val elements: List<FieldMLValue>) : FieldMLValue
A list of any length, whose elements all have one type.
data class RecordValue(val fields: Map<String, FieldMLValue>) : FieldMLValue
A record, keyed by field name. This is also what a JVM Map arrives as.
data class AdtValue(val tag: String, val arguments: List<FieldMLValue>) : FieldMLValue
A fully applied data constructor: the constructor's name and its arguments.
A zero-arity constructor is one of these immediately; anything wider becomes a ConstructorValue first and turns into this once its last argument arrives.
data object UnitValue : FieldMLValue
The one value of FieldML's unit type, and what a Kotlin Unit or a null arrives as.
data class HostValue(val value: Any?) : FieldMLValue
An arbitrary JVM object, opaque to FieldML itself.
This is the whole of FieldML's reach into Field: a Box, an FLine, an Img, a linalg
vector. hostGet reads members off one, hostCall calls methods on one, and the vector
widths get component arithmetic on top. The type checker knows such a value only by the host
type it was given.
data class NativeFunctionValue
A prelude function, implemented in Kotlin, taking exactly one argument.
Multi-argument prelude entries are curried into nested ones, so they compose with FieldML's own one-argument application and partial application works on them unchanged.
data class NativeFunctionValue(
val name: String,
val apply: (FieldMLValue) -> FieldMLValue,
/**
* Present only on a prelude entry that may *suspend* — `hostAwait`'s last argument. The
* suspending walk ([applyFieldMLFunctionSuspending]) calls this; the ordinary walk calls
* [apply], which for such an entry refuses with the reason. See "Suspendable bodies" in
* `dev_docs/FIELDML_HOST_BRIDGE.md`.
*/
val suspendApply: (suspend (FieldMLValue) -> FieldMLValue)? = null,
) : FieldMLValue
data class FactoryValue(val name: String, val create: () -> FieldMLValue) : FieldMLValue
A prelude entry that is a new value on every mention rather than a shared one.
RuntimeEnv resolves it by calling create, so a factory is never itself the result of
looking a name up. This exists for prelude values whose members mutate them in place.
data class ClosureValue(val parameter: String, val body: CoreExpr, val env: RuntimeEnv) : FieldMLValue
A FieldML function: one parameter, an unevaluated body, and the scope it was written in.
Every FieldML function has exactly one parameter by the time it gets here, because lowering curries the multi-parameter ones.
data class ClosureValue(val parameter: String, val body: CoreExpr, val env: RuntimeEnv) : FieldMLValue
data class ConstructorValue(val tag: String, val arity: Int, val arguments: List<FieldMLValue> = emptyList()) : FieldMLValue
A data constructor part-way through being applied, holding the arguments it has so far.
Applying the last one yields an AdtValue; applying fewer yields another of these, so a constructor can be passed around half-filled like any other function.
data class ConstructorValue(val tag: String, val arity: Int, val arguments: List<FieldMLValue> = emptyList()) : FieldMLValue
}
class RuntimeEnv
One FieldML scope: the names bound here, and the scope to fall back on for the rest.
Scopes form a chain, one link per let, per function call and per match case tried, and
shadowing is what falling back up the chain gives for free. A FieldMLValue.ClosureValue keeps a
reference to the scope it was written in, which is what makes a FieldML function a closure and
what makes a recursive let able to find itself.
class RuntimeEnv(
private val parent: RuntimeEnv? = null,
private val values: MutableMap<String, FieldMLValue> = mutableMapOf(),
/** The step budget this scope's evaluation draws on; every child scope shares its parent's. */
val fuel: StepFuel? = parent?.fuel,
) {
fun child(): RuntimeEnv = RuntimeEnv(this)
A new scope that falls back on this one.
fun define(name: String, value: FieldMLValue)
Binds a name in this scope, shadowing any binding of it further up the chain.
fun define(name: String, value: FieldMLValue) {
values[name] = value
}
fun lookup(name: String): FieldMLValue
The value of a name, searching up the chain, resolving a FieldMLValue.FactoryValue as it goes.
Failing here is a prelude problem rather than a program problem: Inferencer has already rejected every name the type prelude does not list, so a name that reaches this point and is missing is one the runtime prelude does not define.
fun lookup(name: String): FieldMLValue {
val value = values[name] ?: return parent?.lookup(name) ?: error("Unknown FieldML runtime name '$name'")
return if (value is FieldMLValue.FactoryValue) value.create() else value
}
}
class StepFuel(val limit: Long)
A budget of evaluator steps, shared by every scope of one evaluation. Both walks spend one step per node they visit (a tail call that loops counts as a visit), so a program runs out at the same point on either walk.
class StepFuel(val limit: Long) {
private var spent = 0L
fun spend()
Spends one step, failing with a FieldML error once limit steps have been spent.
fun spend() {
if (++spent > limit) {
error("FieldML step limit reached: $limit evaluation steps. The program may loop forever; a loop that never ends is not an error FieldML can see otherwise.")
}
}
}
class Evaluator
Evaluates lowered FieldML trees against one prelude.
Cheap to construct and safe to reuse: the prelude is read per call, not held as mutable state.
class Evaluator(
private val prelude: Map<String, FieldMLValue> = emptyMap(),
/**
* When set, an evaluation that visits more than this many nodes stops with a FieldML error
* instead of running on; null (the default) is no limit. A `suspend` body Kotlin calls later
* shares the budget of the evaluation that made its closure.
*/
private val stepLimit: Long? = null,
) {
fun evaluate(expr: CoreExpr): FieldMLValue
Walks expr and returns what it evaluates to, starting from a fresh scope holding the
prelude.
Every call starts clean, so nothing a previous call bound is visible to this one. Failures
arrive as an IllegalStateException with no source position, because lowering has already
dropped the ranges; a deep enough recursion arrives as a StackOverflowError instead, since
the walk is the JVM stack. Calls in tail position (a let or type body, an if branch, a
match case, the closure a call enters) loop instead of recursing, so tail recursion does not.
fun evaluate(expr: CoreExpr): FieldMLValue {
val env = RuntimeEnv(fuel = stepLimit?.let(::StepFuel))
prelude.forEach { (name, value) -> env.define(name, value) }
val observe = onTopLevelLet ?: return evalFieldML(expr, env)
// The program's spine of top-level lets, walked here rather than by recursion so each binding's
// value can be reported as it is made — exactly as the CLet case below evaluates one.
var scope = env
var rest = expr
var index = 0
while (rest is CLet) {
val local = scope.child()
val value = evalFieldML(rest.value, if (rest.recursive) local else scope)
local.define(rest.name, value)
observe(index++, rest.name, value)
scope = local
rest = rest.body
}
return evalFieldML(rest, scope)
}
var onTopLevelLet: ((index: Int, name: String, value: FieldMLValue) -> Unit)? = null
Called with each top-level let binding's position in the program's spine of lets (0 first), its
name and the value it bound, as evaluate makes it; the box records these for its line notes.
source — 93 lines, from line 250
private fun evalFieldML(expr0: CoreExpr, env0: RuntimeEnv): FieldMLValue {
var expr = expr0
var env = env0
while (true) {
env.fuel?.spend()
return when (expr) {
is CInt -> FieldMLValue.IntValue(expr.value)
is CFloat -> FieldMLValue.FloatValue(expr.value)
is CBool -> FieldMLValue.BoolValue(expr.value)
is CString -> FieldMLValue.StringValue(expr.value)
is CVar -> env.lookup(expr.name)
is CTuple -> FieldMLValue.TupleValue(expr.elements.map { evalFieldML(it, env) })
is CList -> FieldMLValue.ListValue(expr.elements.map { evalFieldML(it, env) })
is CTypeDecl -> {
val local = env.child()
expr.constructors.forEach { constructor ->
local.define(
constructor.name,
if (constructor.arity == 0) {
FieldMLValue.AdtValue(constructor.name, emptyList())
} else {
FieldMLValue.ConstructorValue(constructor.name, constructor.arity)
},
)
}
env = local
expr = expr.body
continue
}
is CLet -> {
val local = env.child()
// A recursive `let f x = ...` evaluates its own body in the scope that will hold `f`, so the
// closure can find itself by the time it is called. Plain bindings evaluate in the parent
// scope, which keeps `let x = x + 1` referring to the outer `x`.
local.define(expr.name, evalFieldML(expr.value, if (expr.recursive) local else env))
env = local
expr = expr.body
continue
}
is CLam -> FieldMLValue.ClosureValue(expr.parameter, expr.body, env)
is CApp -> {
val function = evalFieldML(expr.function, env)
val argument = evalFieldML(expr.argument, env)
// A closure called in tail position is entered here rather than by recursion, so a FieldML
// loop written as tail recursion runs in constant JVM stack (and, on the suspending walk,
// a constant continuation chain however many times it has waited).
if (function !is FieldMLValue.ClosureValue) return applyFieldMLFunction(function, argument)
env = function.env.child().also { it.define(function.parameter, argument) }
expr = function.body
continue
}
is CIf -> {
expr = if (evalFieldML(expr.condition, env).asBool()) expr.thenBranch else expr.elseBranch
continue
}
is CMatch -> {
val value = evalFieldML(expr.scrutinee, env)
val chosen = expr.cases.firstNotNullOfOrNull { case ->
env.child().takeIf { local -> matchPattern(case.pattern, value, local) }?.let { local -> local to case.body }
} ?: error("Non-exhaustive FieldML match")
env = chosen.first
expr = chosen.second
continue
}
is CRecord -> FieldMLValue.RecordValue(expr.fields.associate { (name, value) -> name to evalFieldML(value, env) })
is CField -> when (val target = evalFieldML(expr.target, env)) {
is FieldMLValue.RecordValue -> target.fields[expr.field] ?: error("No such field: ${expr.field}")
// Vector components first: they are the hot path and read straight off the value. Anything
// else on a host value is a JVM member, which is what makes `img.width` work.
is FieldMLValue.HostValue -> readVectorComponent(target.value, expr.field)
?: target.value?.let { FieldMLHostBridge.readMember(expr.field, it) }
?: error("No such field: ${expr.field}")
else -> error("Field access on non-record value")
}
is CBinary -> when (expr.op) {
// Short circuiting: the right-hand side is only evaluated when it can change the answer.
BinaryOp.AND ->
if (!evalFieldML(expr.left, env).asBool()) FieldMLValue.BoolValue(false)
else FieldMLValue.BoolValue(evalFieldML(expr.right, env).asBool())
BinaryOp.OR ->
if (evalFieldML(expr.left, env).asBool()) FieldMLValue.BoolValue(true)
else FieldMLValue.BoolValue(evalFieldML(expr.right, env).asBool())
else -> evalBinary(expr.op, evalFieldML(expr.left, env), evalFieldML(expr.right, env))
}
is CNegate -> when (val value = evalFieldML(expr.value, env)) {
is FieldMLValue.IntValue -> FieldMLValue.IntValue(-value.value)
is FieldMLValue.FloatValue -> FieldMLValue.FloatValue(-value.value)
else -> vectorComponents(value)?.let { parts -> vectorValue(FloatArray(parts.size) { -parts[it] }) }
?: error("Cannot negate ${renderFieldMLValue(value)}")
}
}
}
}
internal suspend fun evalFieldMLSuspending(expr0: CoreExpr, env0: RuntimeEnv): FieldMLValue
The same walk as evalFieldML, as a Kotlin suspend function: a FieldML body run this way can
stop half-way — at a hostAwait — hand COROUTINE_SUSPENDED back to the Kotlin that called it,
and carry on from that point when the host resumes it. Kotlin's own CPS transform of this
function is the evaluator continuation; nothing here saves or restores a FieldML stack by hand.
Kept as a line-for-line mirror rather than the only walk because a suspend call allocates a
continuation per frame of recursion, which the hot synchronous path (every map callback, every
vertex) should not pay. FieldMLWalkParityTest (a corpus through both walks) and
FieldMLSuspendableBodyTest.bothWalksAgreeOnEveryCoreShape keep the two
honest. Only closures are walked suspendingly: a FieldMLValue.NativeFunctionValue runs its
synchronous Kotlin, so a FieldML function called back from inside one (map, Mesh.write's
builder) is back on the ordinary walk and cannot suspend there — hostAwait refuses, by name.
body — 93 lines, from line 358
internal suspend fun evalFieldMLSuspending(expr0: CoreExpr, env0: RuntimeEnv): FieldMLValue {
var expr = expr0
var env = env0
while (true) {
env.fuel?.spend()
return when (expr) {
is CInt -> FieldMLValue.IntValue(expr.value)
is CFloat -> FieldMLValue.FloatValue(expr.value)
is CBool -> FieldMLValue.BoolValue(expr.value)
is CString -> FieldMLValue.StringValue(expr.value)
is CVar -> env.lookup(expr.name)
is CTuple -> FieldMLValue.TupleValue(expr.elements.map { evalFieldMLSuspending(it, env) })
is CList -> FieldMLValue.ListValue(expr.elements.map { evalFieldMLSuspending(it, env) })
is CTypeDecl -> {
val local = env.child()
expr.constructors.forEach { constructor ->
local.define(
constructor.name,
if (constructor.arity == 0) {
FieldMLValue.AdtValue(constructor.name, emptyList())
} else {
FieldMLValue.ConstructorValue(constructor.name, constructor.arity)
},
)
}
env = local
expr = expr.body
continue
}
is CLet -> {
val local = env.child()
// A recursive `let f x = ...` evaluates its own body in the scope that will hold `f`, so the
// closure can find itself by the time it is called. Plain bindings evaluate in the parent
// scope, which keeps `let x = x + 1` referring to the outer `x`.
local.define(expr.name, evalFieldMLSuspending(expr.value, if (expr.recursive) local else env))
env = local
expr = expr.body
continue
}
is CLam -> FieldMLValue.ClosureValue(expr.parameter, expr.body, env)
is CApp -> {
val function = evalFieldMLSuspending(expr.function, env)
val argument = evalFieldMLSuspending(expr.argument, env)
// A closure called in tail position is entered here rather than by recursion, so a FieldML
// loop written as tail recursion runs in constant JVM stack (and, on the suspending walk,
// a constant continuation chain however many times it has waited).
if (function !is FieldMLValue.ClosureValue) return applyFieldMLFunctionSuspending(function, argument)
env = function.env.child().also { it.define(function.parameter, argument) }
expr = function.body
continue
}
is CIf -> {
expr = if (evalFieldMLSuspending(expr.condition, env).asBool()) expr.thenBranch else expr.elseBranch
continue
}
is CMatch -> {
val value = evalFieldMLSuspending(expr.scrutinee, env)
val chosen = expr.cases.firstNotNullOfOrNull { case ->
env.child().takeIf { local -> matchPattern(case.pattern, value, local) }?.let { local -> local to case.body }
} ?: error("Non-exhaustive FieldML match")
env = chosen.first
expr = chosen.second
continue
}
is CRecord -> FieldMLValue.RecordValue(expr.fields.associate { (name, value) -> name to evalFieldMLSuspending(value, env) })
is CField -> when (val target = evalFieldMLSuspending(expr.target, env)) {
is FieldMLValue.RecordValue -> target.fields[expr.field] ?: error("No such field: ${expr.field}")
// Vector components first: they are the hot path and read straight off the value. Anything
// else on a host value is a JVM member, which is what makes `img.width` work.
is FieldMLValue.HostValue -> readVectorComponent(target.value, expr.field)
?: target.value?.let { FieldMLHostBridge.readMember(expr.field, it) }
?: error("No such field: ${expr.field}")
else -> error("Field access on non-record value")
}
is CBinary -> when (expr.op) {
// Short circuiting: the right-hand side is only evaluated when it can change the answer.
BinaryOp.AND ->
if (!evalFieldMLSuspending(expr.left, env).asBool()) FieldMLValue.BoolValue(false)
else FieldMLValue.BoolValue(evalFieldMLSuspending(expr.right, env).asBool())
BinaryOp.OR ->
if (evalFieldMLSuspending(expr.left, env).asBool()) FieldMLValue.BoolValue(true)
else FieldMLValue.BoolValue(evalFieldMLSuspending(expr.right, env).asBool())
else -> evalBinary(expr.op, evalFieldMLSuspending(expr.left, env), evalFieldMLSuspending(expr.right, env))
}
is CNegate -> when (val value = evalFieldMLSuspending(expr.value, env)) {
is FieldMLValue.IntValue -> FieldMLValue.IntValue(-value.value)
is FieldMLValue.FloatValue -> FieldMLValue.FloatValue(-value.value)
else -> vectorComponents(value)?.let { parts -> vectorValue(FloatArray(parts.size) { -parts[it] }) }
?: error("Cannot negate ${renderFieldMLValue(value)}")
}
}
}
}
suspend fun applyFieldMLFunctionSuspending(function: FieldMLValue, argument: FieldMLValue): FieldMLValue
applyFieldMLFunction for the suspending walk: a closure's body is walked by
evalFieldMLSuspending, and a native that can suspend (FieldMLValue.NativeFunctionValue.suspendApply)
is called as one. Everything else is the synchronous application.
suspend fun applyFieldMLFunctionSuspending(function: FieldMLValue, argument: FieldMLValue): FieldMLValue =
when (function) {
is FieldMLValue.ClosureValue -> {
val local = function.env.child()
local.define(function.parameter, argument)
evalFieldMLSuspending(function.body, local)
}
is FieldMLValue.NativeFunctionValue -> function.suspendApply?.invoke(argument) ?: function.apply(argument)
else -> applyFieldMLFunction(function, argument)
}
fun applyFieldMLFunction(function: FieldMLValue, argument: FieldMLValue): FieldMLValue
Applies one argument to one callable value, which is all application FieldML has.
A closure binds its parameter in a child of the scope it captured and evaluates its body. A native function runs its Kotlin. A constructor collects the argument and yields either a finished FieldMLValue.AdtValue or a wider partial one. Anything else is not callable and fails here.
Public because the host bridge needs it: a FieldML function handed to a Kotlin parameter becomes a proxy, and the proxy feeds its arguments in one at a time through this.
body — 75 lines, from line 479
fun applyFieldMLFunction(function: FieldMLValue, argument: FieldMLValue): FieldMLValue =
when (function) {
is FieldMLValue.ClosureValue -> {
val local = function.env.child()
local.define(function.parameter, argument)
evalFieldML(function.body, local)
}
is FieldMLValue.NativeFunctionValue -> function.apply(argument)
is FieldMLValue.ConstructorValue -> {
val args = function.arguments + argument
if (args.size == function.arity) {
FieldMLValue.AdtValue(function.tag, args)
} else {
function.copy(arguments = args)
}
}
else -> error("Attempted to call ${renderFieldMLValue(function)}")
}
private fun matchPattern(pattern: CorePattern, value: FieldMLValue, env: RuntimeEnv): Boolean =
when (pattern) {
is CPWildcard -> true
is CPVar -> {
env.define(pattern.name, value)
true
}
is CPInt -> value is FieldMLValue.IntValue && value.value == pattern.value
is CPBool -> value is FieldMLValue.BoolValue && value.value == pattern.value
is CPString -> value is FieldMLValue.StringValue && value.value == pattern.value
is CPTuple -> {
value is FieldMLValue.TupleValue &&
value.elements.size == pattern.elements.size &&
pattern.elements.zip(value.elements).all { (subPattern, subValue) -> matchPattern(subPattern, subValue, env) }
}
is CPList -> {
value is FieldMLValue.ListValue &&
value.elements.size == pattern.elements.size &&
pattern.elements.zip(value.elements).all { (subPattern, subValue) -> matchPattern(subPattern, subValue, env) }
}
is CPConstructor -> {
value is FieldMLValue.AdtValue &&
value.tag == pattern.name &&
value.arguments.size == pattern.arguments.size &&
pattern.arguments.zip(value.arguments).all { (subPattern, subValue) -> matchPattern(subPattern, subValue, env) }
}
is CPCons -> {
value is FieldMLValue.ListValue &&
value.elements.isNotEmpty() &&
matchPattern(pattern.head, value.elements.first(), env) &&
matchPattern(pattern.tail, FieldMLValue.ListValue(value.elements.drop(1)), env)
}
}
private fun evalBinary(op: BinaryOp, left: FieldMLValue, right: FieldMLValue): FieldMLValue {
evalVectorBinary(op, left, right)?.let { return it }
return when (op) {
BinaryOp.ADD -> numeric(left, right, Long::plus, Double::plus)
BinaryOp.SUB -> numeric(left, right, Long::minus, Double::minus)
BinaryOp.MUL -> numeric(left, right, Long::times, Double::times)
BinaryOp.DIV -> FieldMLValue.FloatValue(left.asDouble() / right.asDouble())
BinaryOp.CONCAT -> FieldMLValue.StringValue(left.asStringValue() + right.asStringValue())
BinaryOp.CONS -> {
val tail = right as? FieldMLValue.ListValue ?: error("':: expects a list on the right")
FieldMLValue.ListValue(listOf(left) + tail.elements)
}
BinaryOp.LT -> FieldMLValue.BoolValue(left.asDouble() < right.asDouble())
BinaryOp.LTE -> FieldMLValue.BoolValue(left.asDouble() <= right.asDouble())
BinaryOp.GT -> FieldMLValue.BoolValue(left.asDouble() > right.asDouble())
BinaryOp.GTE -> FieldMLValue.BoolValue(left.asDouble() >= right.asDouble())
BinaryOp.EQ -> FieldMLValue.BoolValue(left == right)
BinaryOp.NEQ -> FieldMLValue.BoolValue(left != right)
BinaryOp.AND -> FieldMLValue.BoolValue(left.asBool() && right.asBool())
BinaryOp.OR -> FieldMLValue.BoolValue(left.asBool() || right.asBool())
}
}
Arithmetic over Vec2/Vec3/Vec4: component by component between two vectors of the same
width, or broadcast when one side is a scalar. Returns null when neither side is a vector, so the
caller can fall through to the ordinary numeric rules. Results are always freshly allocated —
the underlying linalg vectors are mutable, and FieldML values are not.
private fun evalVectorBinary(op: BinaryOp, left: FieldMLValue, right: FieldMLValue): FieldMLValue? {
val leftParts = vectorComponents(left)
val rightParts = vectorComponents(right)
if (leftParts == null && rightParts == null) return null
if (op !in setOf(BinaryOp.ADD, BinaryOp.SUB, BinaryOp.MUL, BinaryOp.DIV)) {
if (op == BinaryOp.EQ) return FieldMLValue.BoolValue(leftParts?.toList() == rightParts?.toList())
if (op == BinaryOp.NEQ) return FieldMLValue.BoolValue(leftParts?.toList() != rightParts?.toList())
error("Operator '${op.text}' is not defined for vectors")
}
val width = leftParts?.size ?: rightParts!!.size
if (leftParts != null && rightParts != null && leftParts.size != rightParts.size) {
error("Cannot combine a Vec${leftParts.size} and a Vec${rightParts.size}")
}
val leftAt = componentReader(leftParts, left)
val rightAt = componentReader(rightParts, right)
val out = FloatArray(width) { index ->
val a = leftAt(index)
val b = rightAt(index)
when (op) {
BinaryOp.ADD -> a + b
BinaryOp.SUB -> a - b
BinaryOp.MUL -> a * b
else -> a / b
}
}
return vectorValue(out)
}
private fun componentReader(parts: FloatArray?, value: FieldMLValue): (Int) -> Float {
if (parts != null) return { index -> parts[index] }
val scalar = value.asDouble().toFloat()
return { scalar }
}
internal fun vectorComponents(value: FieldMLValue): FloatArray?
internal fun vectorComponents(value: FieldMLValue): FloatArray? =
(value as? FieldMLValue.HostValue)?.let { vectorComponents(it.value) }
private fun vectorComponents(host: Any?): FloatArray? =
when (host) {
is Vector2f -> floatArrayOf(host.x, host.y)
is Vector3f -> floatArrayOf(host.x, host.y, host.z)
is Vector4f -> floatArrayOf(host.x, host.y, host.z, host.w)
else -> null
}
internal fun readVectorComponent(host: Any?, field: String): FieldMLValue?
internal fun readVectorComponent(host: Any?, field: String): FieldMLValue? {
val parts = vectorComponents(host) ?: return null
val index = listOf("x", "y", "z", "w").indexOf(field)
if (index < 0 || index >= parts.size) return null
return FieldMLValue.FloatValue(parts[index].toDouble())
}
internal fun vectorValue(parts: FloatArray): FieldMLValue
internal fun vectorValue(parts: FloatArray): FieldMLValue =
FieldMLValue.HostValue(
when (parts.size) {
2 -> Vector2f(parts[0], parts[1])
3 -> Vector3f(parts[0], parts[1], parts[2])
4 -> Vector4f(parts[0], parts[1], parts[2], parts[3])
else -> error("Unsupported vector width ${parts.size}")
},
)
private fun numeric(
left: FieldMLValue,
right: FieldMLValue,
ints: (Long, Long) -> Long,
floats: (Double, Double) -> Double,
): FieldMLValue =
when {
left is FieldMLValue.IntValue && right is FieldMLValue.IntValue -> FieldMLValue.IntValue(ints(left.value, right.value))
left is FieldMLValue.FloatValue && right is FieldMLValue.FloatValue -> FieldMLValue.FloatValue(floats(left.value, right.value))
else -> error("Mismatched numeric runtime values")
}
private fun FieldMLValue.asBool(): Boolean =
(this as? FieldMLValue.BoolValue)?.value ?: error("Expected Bool")
private fun FieldMLValue.asStringValue(): String =
(this as? FieldMLValue.StringValue)?.value ?: error("Expected String")
private fun FieldMLValue.asDouble(): Double =
when (this) {
is FieldMLValue.IntValue -> value.toDouble()
is FieldMLValue.FloatValue -> value
else -> error("Expected number")
}
fun fieldMLValueToHost(value: FieldMLValue): Any?
Unwraps a FieldML value into the JVM value a Kotlin caller should see.
Literals unwrap to their boxed primitives, a record to a Map, a HostValue to whatever it was
holding, and both tuples and lists to a List — which is where the round trip with
hostToFieldMLValue stops being faithful, since a tuple comes back a list. The callable shapes
and a finished ADT value are returned as themselves, so that the host bridge can still recognise
a function it is expected to adapt.
fun fieldMLValueToHost(value: FieldMLValue): Any? =
when (value) {
is FieldMLValue.IntValue -> value.value
is FieldMLValue.FloatValue -> value.value
is FieldMLValue.BoolValue -> value.value
is FieldMLValue.StringValue -> value.value
is FieldMLValue.TupleValue -> value.elements.map(::fieldMLValueToHost)
is FieldMLValue.ListValue -> value.elements.map(::fieldMLValueToHost)
is FieldMLValue.RecordValue -> value.fields.mapValues { fieldMLValueToHost(it.value) }
is FieldMLValue.AdtValue -> value
FieldMLValue.UnitValue -> Unit
is FieldMLValue.HostValue -> value.value
is FieldMLValue.NativeFunctionValue -> value
is FieldMLValue.FactoryValue -> value.create().let(::fieldMLValueToHost)
is FieldMLValue.ClosureValue -> value
is FieldMLValue.ConstructorValue -> value
}
fun hostToFieldMLValue(value: Any?): FieldMLValue
Wraps a JVM value as the nearest FieldML value, falling back to an opaque FieldMLValue.HostValue.
Every integral width collapses to FieldML's one Int and both floating widths to its one
Float, so the width a Kotlin method declared does not survive the return trip. A null and a
Unit both arrive as unit, a List as a list and a Map as a record with its keys stringified.
Anything else stays a JVM object that FieldML can only pass along or reach into with hostGet.
fun hostToFieldMLValue(value: Any?): FieldMLValue =
when (value) {
null -> FieldMLValue.UnitValue
Unit -> FieldMLValue.UnitValue
is FieldMLValue -> value
is Int -> FieldMLValue.IntValue(value.toLong())
is Long -> FieldMLValue.IntValue(value)
is Short -> FieldMLValue.IntValue(value.toLong())
is Byte -> FieldMLValue.IntValue(value.toLong())
is Float -> FieldMLValue.FloatValue(value.toDouble())
is Double -> FieldMLValue.FloatValue(value)
is Boolean -> FieldMLValue.BoolValue(value)
is String -> FieldMLValue.StringValue(value)
is List<*> -> FieldMLValue.ListValue(value.map(::hostToFieldMLValue))
is Map<*, *> -> FieldMLValue.RecordValue(
value.entries.associate { (key, field) -> key.toString() to hostToFieldMLValue(field) },
)
else -> FieldMLValue.HostValue(value)
}
fun renderFieldMLValue(value: FieldMLValue): String
Renders a value the way FieldML source would spell it, for a box's output and for show.
Tuples print in parentheses and lists in brackets, so this is the one path out of the runtime
that keeps them apart. A HostValue prints its object's toString, so what a box
shows for one is whatever Kotlin decided. The callable shapes have no source spelling and print
as an angle-bracketed placeholder naming the kind.
fun renderFieldMLValue(value: FieldMLValue): String =
when (value) {
is FieldMLValue.IntValue -> value.value.toString()
is FieldMLValue.FloatValue -> value.value.toString()
is FieldMLValue.BoolValue -> value.value.toString()
is FieldMLValue.StringValue -> "\"${value.value}\""
is FieldMLValue.TupleValue -> value.elements.joinToString(prefix = "(", postfix = ")") { renderFieldMLValue(it) }
is FieldMLValue.ListValue -> value.elements.joinToString(prefix = "[", postfix = "]") { renderFieldMLValue(it) }
is FieldMLValue.RecordValue -> value.fields.entries.joinToString(prefix = "{", postfix = "}") { (name, field) ->
"$name = ${renderFieldMLValue(field)}"
}
is FieldMLValue.AdtValue -> if (value.arguments.isEmpty()) {
value.tag
} else {
value.arguments.joinToString(prefix = "${value.tag} ") { renderFieldMLValue(it) }
}
FieldMLValue.UnitValue -> "()"
is FieldMLValue.HostValue -> value.value?.toString() ?: "null"
is FieldMLValue.NativeFunctionValue -> "<native ${value.name}>"
is FieldMLValue.FactoryValue -> "<factory ${value.name}>"
is FieldMLValue.ClosureValue -> "<function>"
is FieldMLValue.ConstructorValue -> "<constructor ${value.tag}>"
}