Skip to content

FunctionRewritePattern.kt

src/jvmMain/kotlin/afield/math/functions/FunctionRewritePattern.kt · 373 lines

@file:afield.runtime.completion.CompletionCategory("Function")

package afield.math.functions

import afield.app.commands.action
import afield.data.collections.invoke
import afield.data.collections.times
import afield.data.graph.rules
import afield.document.graph.minusAssign
import afield.document.graph.plusAssign
import afield.document.properties.compareTo
import afield.document.workspace.file
import afield.geometry.mesh.repeated
import afield.graphics.render.rangeTo
import afield.graphics.vulkan.buffers.add
import afield.math.autodiff.compareTo
import afield.math.autodiff.cos
import afield.math.autodiff.div
import afield.math.autodiff.minus
import afield.math.autodiff.plus
import afield.math.autodiff.sin
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.units.div
import afield.math.units.minus
import afield.math.units.plus
import afield.math.units.times

import afield.audio.synth.*
import afield.data.collections.get
import afield.data.collections.s
import afield.graphics.render.arrow
import afield.math.linalg.i
import afield.runtime.execution.start
import afield.study.ui.numbers
import kotlin.math.abs

The third rewriting surface in the family, and the algebraic one. FunctionRewriteDsl.kt is selector => action and edits one matched node in place; FnGraphRule in FunctionGraphRules.kt is a hand-written predicate plus a hand-written replacement; here both sides of a rule are graph shapes with metavariables (FnPattern), so add(?x, 0) => ?x reads as an identity rather than an instruction. fnRule compiles a spec into an ordinary FnGraphRule, which is what lets pattern rules reuse the existing traced, bounded, cycle-detecting runners; Functions.Fn.rewritePatterns is the fixed-point convenience over a set of specs.

Matching and the parser are both here. A ?name binds a subtree and, if repeated, must bind a structurally equal one (compared by graphSignature()); a bare number matches a numeric node's value to within 1e-12; op names are compared through OpRegistry's alias table; op(a, b, …) constrains only the leading children. The string template side is narrower than the pattern side: a bare op transmutes the matched node, only add/subtract/multiply/divide accept an explicit child list, and any other op(...) template throws — the fnRule overload taking a Kotlin builder over FnPatternMatch is the general escape.

sealed interface FnPattern

Structural pattern → template rewriting: the term-rewriting surface on top of the graph copier.

The string DSL in Functions.Fn.rewrite is selector => action: it edits one matched node in place. This is the algebraic sibling — pattern => template — where both sides are graph shapes with metavariables. It is what a future optimizer or evolving-function system rewrites with:

add(?x, 0)       => ?x            // additive identity
multiply(?x, 1)  => ?x            // multiplicative identity
multiply(?x, 0)  => 0            // annihilation
multiply(?a, ?b) => multiply(?b, ?a)   // commute
sin              => cos           // transmute the matched node, keeping its children

A metavariable ?x binds a whole subtree; repeated names must bind structurally equal subtrees (compared via graphSignature). A bare number matches a numeric node (const/param) with that value. A bare op name matches by op (respecting the OpRegistry alias table); op(a, b, …) additionally requires the leading child subgraphs to match, leaving any trailing children free.

fnRule compiles a spec into a plain FnGraphRule, so pattern rules inherit the existing traced, bounded, cycle-detecting machinery (Functions.Fn.rewriteWithRules, Functions.Fn.rewriteUntilStable) for free. For an RHS the string form cannot express, use the programmatic overload with a Kotlin builder over the bindings.

sealed interface FnPattern {

data class Var(val name: String) : FnPattern

?name — binds (or, if already bound, must structurally re-match) a subtree.

data object Wild : FnPattern

_ — matches anything without binding.

data class Num(val value: Double) : FnPattern

A numeric literal — matches a numbers["value"] node (const/param).

data class Op(val op: String, val children: List<FnPattern>, val constrained: Boolean) : FnPattern

op (children unconstrained) or op(child, …) (leading children must match).

    data class Op(val op: String, val children: List<FnPattern>, val constrained: Boolean) : FnPattern

companion object

fun parse(text: String): FnPattern = FnPatternParser(text).parseWhole()

class FnPatternMatch internal constructor

The result of matching a pattern: metavariable bindings plus the matched node.

class FnPatternMatch internal constructor(
    val matched: Functions.Fn,
    private val bindings: Map<String, Functions.Fn>
) {

fun sub(name: String): Functions.Fn

The subtree bound to ?name.

    fun sub(name: String): Functions.Fn =
        bindings[name] ?: error("Pattern metavariable ?$name is not bound")

fun value(name: String): Double? = bindings[name]?.graphNode()?.numbers?.get("value")

The numeric value of ?name, if it bound a numeric node.

fun has(name: String): Boolean = name in bindings

fun fnRule(spec: String): FnGraphRule

Compile "lhs => rhs" into a traced FnGraphRule usable with the existing rewrite runners.

fun fnRule(spec: String): FnGraphRule {
    val (lhsText, rhsText) = splitRuleArrow(spec)
    val lhs = FnPattern.parse(lhsText)
    val rhs = FnPattern.parse(rhsText)
    return FnGraphRule(
        name = spec.trim(),
        matches = { context -> matchFnPattern(lhs, context.original, HashMap()) },
        replace = { _, copy ->
            val bindings = HashMap<String, Functions.Fn>()
            if (matchFnPattern(lhs, copy, bindings)) buildFnTemplate(rhs, bindings, copy) else copy
        }
    )
}

fun fnRule(name: String, lhs: String, build: Functions.(FnPatternMatch) -> Functions.Fn): FnGraphRule {

Compile a pattern LHS with a Kotlin builder RHS. This is the fully general surface: the pattern captures structure, the builder writes the replacement in ordinary Kotlin over the whole Functions stdlib.

fun fnRule(name: String, lhs: String, build: Functions.(FnPatternMatch) -> Functions.Fn): FnGraphRule {
    val pattern = FnPattern.parse(lhs)
    return FnGraphRule(
        name = name,
        matches = { context -> matchFnPattern(pattern, context.original, HashMap()) },
        replace = { _, copy ->
            val bindings = HashMap<String, Functions.Fn>()
            if (matchFnPattern(pattern, copy, bindings)) with(Functions) { build(FnPatternMatch(copy, bindings)) } else copy
        }
    )
}

fun Functions.Fn.rewritePatterns

Convenience: rewrite to a fixed point with a set of pattern rules.

fun Functions.Fn.rewritePatterns(
    vararg specs: String,
    maxPasses: Int = 16,
    preserveSharing: Boolean = false
): FnGraphRewriteRun = rewriteUntilStable(specs.map(::fnRule), maxPasses, preserveSharing)

matching

source — 43 lines, from line 162
private fun matchFnPattern(
    pattern: FnPattern,
    fn: Functions.Fn,
    bindings: MutableMap<String, Functions.Fn>
): Boolean = when (pattern) {
    is FnPattern.Wild -> true

    is FnPattern.Var -> {
        val existing = bindings[pattern.name]
        if (existing == null) {
            bindings[pattern.name] = fn
            true
        } else {
            existing.graphSignature() == fn.graphSignature()
        }
    }

    is FnPattern.Num -> {
        val value = fn.graphNode().numbers["value"]
        value != null && abs(value - pattern.value) < 1e-12
    }

    is FnPattern.Op -> {
        val node = fn.graphNode()
        if (!opNamesMatch(pattern.op, node.op)) {
            false
        } else if (!pattern.constrained) {
            true
        } else {
            pattern.children.withIndex().all { (index, childPattern) ->
                val child = node.children.getOrNull(index)?.fn
                child != null && matchFnPattern(childPattern, child, bindings)
            }
        }
    }
}

private fun opNamesMatch(patternOp: String, nodeOp: String): Boolean {
    if (patternOp.equals(nodeOp, ignoreCase = true)) return true
    val a = OpRegistry.canonical(patternOp) ?: patternOp
    val b = OpRegistry.canonical(nodeOp) ?: nodeOp
    return a.equals(b, ignoreCase = true)
}

template building

source — 42 lines, from line 208
private val ALGEBRA_OPS = setOf("add", "subtract", "multiply", "divide", "+", "-", "*", "/")

private fun buildFnTemplate(
    template: FnPattern,
    bindings: Map<String, Functions.Fn>,
    matched: Functions.Fn
): Functions.Fn = when (template) {
    is FnPattern.Wild -> matched

    is FnPattern.Var ->
        bindings[template.name] ?: error("Template references unbound metavariable ?${template.name}")

    is FnPattern.Num -> Functions.c(template.value, matched.domain)

    is FnPattern.Op -> when {
        // bare op (no child list): transmute the matched node, preserving its children.
        !template.constrained -> matched.rebuildAsOp(template.op) ?: matched

        // algebra ops build directly from two child templates.
        OpRegistry.canonical(template.op)?.lowercase() in ALGEBRA_OPS || template.op.lowercase() in ALGEBRA_OPS -> {
            require(template.children.size == 2) { "Algebra op `${template.op}` needs two operands in a template" }
            val left = buildFnTemplate(template.children[0], bindings, matched)
            val right = buildFnTemplate(template.children[1], bindings, matched)
            val canonical = OpRegistry.canonical(template.op)?.lowercase() ?: template.op.lowercase()
            with(Functions) {
                when (canonical) {
                    "add", "+" -> left + right
                    "subtract", "-" -> left - right
                    "multiply", "*" -> left * right
                    "divide", "/" -> left / right
                    else -> error("unreachable")
                }
            }
        }

        else -> error(
            "Template op `${template.op}(...)` with explicit children is limited to algebra " +
                "(add/subtract/multiply/divide) in the string DSL. Use a bare op to transmute the " +
                "matched node, or the programmatic fnRule(name, lhs) { ... } builder for full control."
        )
    }
}

parsing

source — 120 lines, from line 253
private fun splitRuleArrow(spec: String): Pair<String, String> {
    val trimmed = spec.trim().removePrefix("#fnrewrite").trim()
    val arrows = listOf("=>", "->", "→")
    var index = -1
    var length = 0
    for (arrow in arrows) {
        val at = trimmed.indexOf(arrow)
        if (at >= 0 && (index < 0 || at < index)) {
            index = at
            length = arrow.length
        }
    }
    require(index >= 0) { "Pattern rule needs `lhs => rhs`: `$spec`" }
    val lhs = trimmed.substring(0, index).trim()
    val rhs = trimmed.substring(index + length).trim()
    require(lhs.isNotEmpty()) { "Pattern rule has an empty left-hand side: `$spec`" }
    require(rhs.isNotEmpty()) { "Pattern rule has an empty right-hand side: `$spec`" }
    return lhs to rhs
}

private class FnPatternParser(text: String) {
    private val s = text.trim()
    private var i = 0

    fun parseWhole(): FnPattern {
        val term = parseTerm()
        skipWs()
        require(i >= s.length) { "Unexpected trailing input in pattern `$s` at $i" }
        return term
    }

    private fun parseTerm(): FnPattern {
        skipWs()
        require(i < s.length) { "Empty pattern term in `$s`" }
        val c = s[i]
        if (c == '?') {
            i++
            val name = readIdent()
            require(name.isNotEmpty()) { "Metavariable needs a name in `$s`" }
            return FnPattern.Var(name)
        }
        if (c == '_' && !isIdentPart(s.getOrNull(i + 1))) {
            i++
            return FnPattern.Wild
        }
        if (c == '-' || c == '+' || c == '.' || c.isDigit()) {
            readNumber()?.let { return FnPattern.Num(it) }
        }
        val op = readIdentOrSymbol()
        require(op.isNotEmpty()) { "Expected an op name or metavariable in `$s` at $i" }
        skipWs()
        if (i < s.length && s[i] == '(') {
            i++ // consume (
            val children = mutableListOf<FnPattern>()
            skipWs()
            if (i < s.length && s[i] != ')') {
                while (true) {
                    children += parseTerm()
                    skipWs()
                    if (i < s.length && s[i] == ',') {
                        i++
                        continue
                    }
                    break
                }
            }
            skipWs()
            require(i < s.length && s[i] == ')') { "Unbalanced parentheses in pattern `$s`" }
            i++ // consume )
            return FnPattern.Op(op, children, constrained = true)
        }
        return FnPattern.Op(op, emptyList(), constrained = false)
    }

    private fun readNumber(): Double? {
        val start = i
        if (i < s.length && (s[i] == '-' || s[i] == '+')) i++
        var digits = false
        while (i < s.length && (s[i].isDigit() || s[i] == '.')) {
            digits = true
            i++
        }
        if (i < s.length && (s[i] == 'e' || s[i] == 'E')) {
            i++
            if (i < s.length && (s[i] == '-' || s[i] == '+')) i++
            while (i < s.length && s[i].isDigit()) i++
        }
        if (!digits) {
            i = start
            return null
        }
        val token = s.substring(start, i)
        val parsed = token.toDoubleOrNull()
        if (parsed == null) {
            i = start
            return null
        }
        return parsed
    }

    private fun readIdent(): String {
        val start = i
        while (i < s.length && isIdentPart(s[i])) i++
        return s.substring(start, i)
    }

    private fun readIdentOrSymbol(): String {
        skipWs()
        if (i < s.length && s[i] in "+-*/") {
            return s[i].toString().also { i++ }
        }
        return readIdent()
    }

    private fun isIdentPart(c: Char?): Boolean = c != null && (c.isLetterOrDigit() || c == '_')

    private fun skipWs() {
        while (i < s.length && s[i].isWhitespace()) i++
    }
}