Comptime Evaluation: The Fold Without the Tag
A June post from Inferara — “Zero-Cost ‘Tagless Final’ in Rust with
GADT-style Enums” —
builds an embedded expression language in Rust: IntConst, Lambda, Apply, Add, encoded as an enum where the type parameters make exactly
one variant constructible per node and the rest are the never type !.
With no discriminant to dispatch on, eval folds a tree of constants,
lambdas, and applications into eleven instructions — leaq, addq, retq. David Barbour read it and called it a good fit for Koru.
He is right, and the reason is worth stating precisely: the thing that encoding buys is a thing Koru already has, reached by a different mechanism.
What the encoding is paying for
A GADT’s Expr a ties each node’s type to the value it produces, so an
ill-typed DSL program fails before any interpreter runs. The !-disabled
variants turn “which constructor is this” into a type-level fact, which
makes the tag provably unreachable, which lets the optimizer delete the
tree. The cost is the scaffolding — Cursor and Attic traits, NGuard, vu_cast — type-level plumbing whose entire job is to smuggle staging
information through the type checker so the optimizer can see it.
Buried in that plumbing is the claim that matters: a DSL program is compile-time data wearing runtime clothes. Rust’s trick makes the clothes transparent. Koru never dresses the program in them.
The split the pattern wants is the language’s basic move
Tagless-final asks for one syntax and many interpreters — a signature
held apart from everything that implements it. In Koru that split is the
ordinary event/impl pair, not a technique: a tor declares the
vocabulary and a subflow supplies one interpretation of it. Branch names
are inert identifiers; their meaning lives entirely in the subflow that
resolves them — return, break, and continue mean nothing to the
compiler, the same way tagless-final’s repr means nothing until an
interpreter is chosen:
run = step()
| return => stopped
| break => stopped
| continue => iterated The fold that already lands
The other half of the article — the tree that disappears — is the part
Koru does by execution rather than encoding. A [comptime] head is
evaluated at Stage C by the comptime evaluator (src/comptime_eval.zig,
a tree-walking interpreter over the AST, a sibling of the pipeline
passes), and its result is spliced into the residue as a literal:
import std/io
[comptime] pub tor answer {} -> i64
answer -> 40 + 2
[comptime] answer(): r |> std/io:print.ln("{{ r:d }}") The emitted Zig for that program contains the folded answer inline — .__kw("{d}\n", .{42}), a literal 42 at the print call. No answer,
no eval, no tag. Not because an optimizer proved a discriminant
unreachable, but because the value was computed while the program was
still the compiler’s data. Staging, not persuasion.
Worth being precise about when each part runs, because there are three
executions here, not one. The evaluator runs 40 + 2 during compilation
— that is what the [comptime] on the head means, and it is genuinely
execution, inside the backend process at Stage C. print.ln is itself a [comptime|transform], so it also runs during compilation — but as an
elaborator whose job is emitting the runtime print call, not printing.
And the .{42} it emits is what the binary prints at program runtime:
the test’s expected output is captured from the compiled program’s
stdout, not the compiler’s. Running happens twice at compile time, for
two different jobs; what distinguishes the fold is that answer leaves
no runtime code at all.
Notice what the program does not contain: no source: Source parameter,
no AST anywhere. That is a ruling, not an omission. An Expression or Source parameter is a representation choice — “capture this
argument’s source text” — while [comptime] is a timing choice — “run
this during compilation” — and the language holds the two axes apart on
purpose. The coupling was severed 2026-07-07 after the parser’s
auto-comptime stamp on expression parameters spent months filtering a
flow’s own lowered code — a for loop’s process/finalize branch
tors — out of the runtime module. Capturing syntax had been conflated
with running early, and they are different things: the if/for keywords consume an expression at compile time to emit runtime code.
Comptime Koru is supposed to look like ordinary Koru, because that is
what it is: the same code, executed early.
Where the rungs stop today
The honest boundary: the article’s DSL composes — apply feeds lambda, add nests inside add — and a composed comptime flow is the
rung Koru has not reached. A scratch program chaining two comptime calls
(program = mul(l: 3, r: 4): m |> add(l: 2, r: m): v -> v) is refused
with a diagnostic that names the missing rung rather than papering over
it:
comptime fold failed: comptime walk: residue continuations on a walked
flow are a later rung — the flow must be fully comptime The evaluator’s source names the whole ladder. Rung one — expression
trees — is landed. Flow walking for subflow-implemented heads is landed.
Residue continuations inside a walked flow, shape-destructure arms, the
source-time call bridge, and AST-as-value fragments are each declared
as a later rung, and each refusal is loud — no approximation, no
fallback. The aspiration itself is already pinned in the suite, sitting
at todo until its rung arrives:
One rung on that list deserves a second look, because it is where the
two axes rejoin. Value.fragment — AST as a first-class comptime value —
is already declared in the evaluator: metaprograms return program. When it lands, a [comptime] tor whose return is a fragment is a
transform — the source: Source shape the stdlib’s keyword transforms
take today, written as evaluation instead of AST surgery. Representation
becomes a value like any other, and “run early” and “emit program” stop
being different mechanisms.
The difference in kind
The article is honest about the encoding’s fragility: #[inline(always)] approximates the fold only until a crate boundary, because the whole
effect is a proof the optimizer happens to be able to see. Koru’s fold is
not an optimization at all — it is execution at Stage C, splicing a
computed value, so it cannot be defeated by a module boundary. When the
remaining rungs land, a composed Koru DSL folds for a stronger reason
than the Rust tree does: it ran.
And the architecture is already that shape at full scale. The pipeline — coordinate, elaborate, analysis, emission, plus the check passes —
is a family of interpreters over one Program, each reading the same
syntax a different way. Tagless-final is the “one tree, many
interpreters” trick; koruc is built out of it. The fit Barbour saw is
not “port the encoding.” It is that the language already stands where the
encoding is trying to arrive.