Skip to content

RectSLSimplify.kt

src/jvmMain/kotlin/afield/lang/rectsl/RectSLSimplify.kt · 164 lines

package afield.lang.rectsl

import kotlin.math.abs

An algebraic simplifier over the track IR, under a requirement that every rewrite be bit-exact.

The IR is data classes, so two tracks built by the same array body compare ==. The GLSL emitter's function deduplication rests on that, keying emitted functions by the track itself so that a hundred array copies share one function, and so does the min(x, x) rule below, which compares two subtrees directly. The narrower rewrites over the same IR are describe(), which prints a track, and substituteParameters, which sets named free parameters to numbers; this is the general one.

Nothing in afield.lang.rectsl writes a track out or reads one back, so a track cannot be serialized, although being data rather than closures would allow it.

What it is for

Not speed, primarily. RectSL's array words build per-copy channels by offsetting a constant, so a hundred-copy array arrives at a backend as a hundred BinaryDoubleTrack(const, const, ADD) nodes, and each backend has had to fold that itself: trackConstantValue exists because the GLSL emitter needed it, and the Fn lowering calls the same function for the same reason. A fold is the smallest rewrite there is, and both emitters had to reach for it themselves because there was nowhere over the IR to apply it once.

The bigger reason is that a simplified tree is a smaller Fn graph, and an Fn graph's size is the cost of both a sample and a gradient — Fn.eval walks every node, and gradientAt records every node on a tape. A fit runs that thousands of times.

The contract: every rule is bit-exact

A simplified track must sample bit-identically to the original, not merely equal-to-tolerance, which rules out several rewrites everyone writes by reflex. The gallery corpus caught the first one: 0 - x rewritten to -x disagrees at x = 0, because 0.0 - 0.0 is +0.0 and -(0.0) is -0.0.

A signed zero is not a curiosity in this IR. HeadingTrack takes atan2 of a velocity, and atan2(+0.0, -1.0) is +pi while atan2(-0.0, -1.0) is -pi — half a turn apart. A rewrite that "only" changes the sign of a zero can therefore spin a rectangle through 180 degrees. So the rules kept here are the ones exact for every double including both zeros, and the reflexive ones are dropped with their reasons:

rewrite kept? why
constant folding yes exact by construction — the folded value is what the chain evaluated to
x - 0 -> x yes x - (+0.0) == x for every x, both zeros included
x * 1, x / 1, x ^ 1 yes multiplicative identity preserves sign
min(x, x), max(x, x) -> x yes structural equality; exact whatever x is
-(-x) -> x, abs abs, saturate saturate yes involution / idempotence, sign-exact
clamp(x, v, v) -> v yes coerceIn(x, v, v) is v
shift 0, scale 1, degenerate loop/pingpong yes the lookup is t unchanged
x + 0 -> x no (-0.0) + 0.0 is +0.0, so this flips a signed zero
0 - x -> -x no the rule the corpus caught, above
mix(a, b, 1) -> b no a + (b - a) is not b in floating point at all
mix(a, b, 0) -> a, mix(a, a, t) -> a no both end in a + (+/-0.0), signed-zero unsafe
x * 0 -> 0 no only valid for finite x, and nothing here proves x is finite

Losing the additive identity costs less than it looks like it should: RectSL's array words build per-copy channels by offsetting a constant, and the constant fold takes those whole.

Free parameters are never folded, for the same reason trackConstantValue takes a flag: a FreeParamTrack holds a number but is not a number, and folding it away would delete the thing a fit is meant to solve for.

How it is checked

RectSLSimplifyTest runs it over the gallery and requires every rewritten channel to sample bit-identically to the original at 41 times each, and to be a fixed point of a second pass. The node-count reduction it prints is reported rather than asserted at any figure, because that figure moves with the gallery and with the IR — a key value became a node when TcbKey began holding a track, for instance. Run the test to read the current one.

A plausible-looking rule that is wrong in one corner is this file's characteristic failure, and bit-identity over the corpus already assembled for the differentiable backend is what catches it.

fun Track<Double>.nodeCount(): Int = 1 + trackChildren(this).sumOf { it.nodeCount() }

Total nodes in this track's tree, counting a shared subtree once per path — the size a backend pays.

fun Track<Double>.simplify(): Track<Double> = mapTrack(this) { node -> rewriteNode(node) }

This track with constants folded and the algebraic identities applied, bottom-up.

A pure function returning a new tree; nothing mutates. Sampling is preserved bit-for-bit on the whole gallery corpus (2754 channels, 16.5% fewer nodes, 112,914 samples compared) — see this file's KDoc for how that is checked, and for the table of rewrites left out, with their reasons.

True when track is the constant value, exactly. A free parameter is never "a constant".

source — 73 lines, from line 91
private fun isConstant(track: Track<Double>, value: Double): Boolean =
    trackConstantValue(track, foldFreeParameters = false) == value

private fun rewriteNode(track: Track<Double>): Track<Double> {
    // The fold first: it subsumes every rule below for a subtree that does not vary with time, and it
    // is what turns a hundred-copy array's arithmetic into a hundred literals.
    trackConstantValue(track, foldFreeParameters = false)?.let { folded ->
        // A track that already *is* a bare constant is left alone rather than rebuilt, so simplify
        // is idempotent on its own output by identity and not merely by value.
        if (track is ConstantTrack<*>) return track
        return constantTrack(folded)
    }

    return when (track) {
        is BinaryDoubleTrack -> when (track.op) {
            // `x - 0` is the one additive identity that survives the signed-zero rule: subtracting
            // positive zero returns x untouched for every x, where *adding* it turns -0.0 into +0.0.
            TrackOp2.SUB -> if (isConstant(track.b, 0.0)) track.a else track
            TrackOp2.MUL -> when {
                isConstant(track.b, 1.0) -> track.a
                isConstant(track.a, 1.0) -> track.b
                else -> track
            }
            TrackOp2.DIV -> if (isConstant(track.b, 1.0)) track.a else track
            TrackOp2.POW -> if (isConstant(track.b, 1.0)) track.a else track
            // Structural equality doing real work: `min(x, x)` is `x` whatever x is.
            TrackOp2.MIN, TrackOp2.MAX -> if (track.a == track.b) track.a else track
            else -> track
        }

        is TernaryDoubleTrack -> when (track.op) {
            // No `mix` rule at all, which is the surprise. `mix(a, b, t)` evaluates as
            // `a + (b - a) * t`: at t = 1 that is `a + (b - a)`, which is simply not `b` in floating
            // point, and at t = 0 or with a == b it ends in `a + (+/-0.0)`, which is not `a` when a is
            // a negative zero. Every version of this rewrite is wrong somewhere.
            TrackOp3.CLAMP -> {
                val lo = trackConstantValue(track.b, foldFreeParameters = false)
                val hi = trackConstantValue(track.c, foldFreeParameters = false)
                // Equal bounds pin the value; and a clamp of something already inside its own bounds
                // cannot be recognised in general, so that case is left alone.
                if (lo != null && hi != null && lo == hi) constantTrack(lo) else track
            }
            else -> track
        }

        is MappedDoubleTrack -> {
            val inner = track.source
            when {
                // Involutions and idempotents. `neg neg` cancels; `abs` and `saturate` are already
                // themselves after one application.
                track.op == TrackOp1.NEG && inner is MappedDoubleTrack && inner.op == TrackOp1.NEG ->
                    inner.source
                track.op == TrackOp1.ABS && inner is MappedDoubleTrack && inner.op == TrackOp1.ABS ->
                    inner
                track.op == TrackOp1.SATURATE && inner is MappedDoubleTrack &&
                    inner.op == TrackOp1.SATURATE -> inner
                else -> track
            }
        }

        is TimeWarpTrack -> when (track.warp) {
            TimeWarpKind.SHIFT -> if (track.amount == 0.0) track.source else track
            TimeWarpKind.SCALE -> if (track.amount == 1.0) track.source else track
            // A degenerate period is the identity in both the CPU sampler and the GLSL, so the node
            // is not doing anything.
            TimeWarpKind.LOOP -> if (abs(track.amount) <= 1e-12) track.source else track
            TimeWarpKind.PINGPONG -> if (abs(track.amount * 2.0) <= 1e-12) track.source else track
            else -> track
        }

        else -> track
    }
}