Comptime Evaluation: The Fold Without the Tag

· 6 min read

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.