Purity Is the Wrong Boundary

· 9 min read

Every language with compile-time execution draws a line around what comptime code is allowed to do, and almost everyone draws it in the same place: purity. Zig’s comptime can compute anything but touch nothing — no syscalls, no IO. C++‘s constexpr started as pure expressions in 2011 and has been relaxed release after release, each standard conceding a little more of the world into the sanctuary. The shared assumption underneath: compile time is a clean room, and side effects are contamination.

Koru just drew the line somewhere else entirely. But first, the thing that made the question concrete: the compiler now runs Koru at compile time.

The smallest fold

This is a passing regression test, verbatim:

~import std/io

~[comptime] pub event answer {} -> i64
~answer -> 40 + 2

~[comptime] answer(): r |> std/io:print.ln("{{ r:d }}")

The [comptime] head invocation answer() is evaluated during backend compilation — a tree-walking interpreter descends into the implementation -> 40 + 2 and produces the value. The flow’s continuation is residue: it emits as ordinary runtime code, with r spliced in as the Koru literal 42. The binary prints 42. Nothing about answer survives to runtime — not the event, not the impl, not a function call. The runtime program never knew the question.

This is partial evaluation in the classic sense: split the program into a static part and a dynamic part, execute the static part now, emit the dynamic part with the static results baked in. In Koru the split falls naturally out of the flow structure — the [comptime] head is the static part, the continuation chain is the dynamic part, and the seam between them is a value.

Two disciplines keep the seam honest. Values splice as Koru literal text, never host text — the residue is still Koru, still owned by the rest of the pipeline. And the interpreter’s output vocabulary is values and Koru AST, full stop. Lowering to host code remains the emitter’s monopoly, which means folding never bypasses the machinery that makes runtime code fast. The interpreter cannot produce a slow path, because it cannot produce a path at all — only the program the emitter would have compiled anyway, minus the parts that already happened.

One more walker

The comptime interpreter parses nothing. By the time it runs, the backend already holds the full program AST — the metacircular pipeline is itself a chain of AST walkers (frontend, analysis, optimizer, emission), and the evaluator is simply one more walker standing beside them, a sibling of the transform passes rather than a new subsystem. The fold pass slots into the frontend sequence of compiler.kz like any other step. It lives inside the compiler, where the parsing already happened and correctness is the only axis.

The thunk law

Now the capability question. The evaluator walks pure-Koru expressions itself. What happens when comptime code calls an event whose implementation is a proc — a handler written in host Zig?

The ruling: callable at comptime = thunked into the backend at Stage A. Koru’s compilation runs in four stages — Stage A parses your program and emits the backend’s source; Stage B compiles that backend natively; Stage C runs it (this is where the metacircular pipeline and now the interpreter live); Stage D compiles the final binary. So for events in the thunk policy, Stage A emits a wrapper marshalling interpreter values to the handler’s input and output structs, Stage B compiles handler and wrapper to machine code, and the Stage C interpreter calls through the table. Comptime Koru calling compiled native code — print, file IO, arbitrary Zig — one dispatch away.

This is where the purity doctrine dissolves. The pipeline that hosts the interpreter reads your source files, writes generated programs, shells out to the host compiler. The compiler pipeline IS IO. A comptime that banned side effects couldn’t even be the compiler — and in Koru, the compiler is comptime Koru. Excluding IO from compile time would exclude the one program that has been running there all along. Purity was never the real boundary; it was a proxy for a different fear.

Jai — the language that made compile-time execution famous — runs comptime code in a bytecode VM inside the compiler, interpreting everything, IO included. Koru’s four-stage architecture pays an unexpected dividend here: because Stage B exists, the native half comes pre-compiled. The interpreter doesn’t emulate handlers; it calls them, at native speed, marshalled through a table that Stage A generated. The metacircular structure — adopted so the compiler could be written in its own language — turns out to be exactly the shape a comptime FFI wants.

And the real boundary, the one hard wall: calls to procs born during comptime evaluation. A proc that comes into existence while the interpreter runs was never seen by Stage A, so no thunk was compiled for it — there is no compiler in the loop to give it a native body. We cannot pretend to know what calls will be generated. Comptime-born code is runtime output only: the interpreter can emit it into the residue, but it cannot call it. Knowable-at-emission versus born-at-comptime — that is the line, and unlike purity, it is a line the architecture itself enforces.

The countdown runs inside the compiler

Rung two — the flow walker and the thunk bridge — landed while this post was being written. This is its regression test, verbatim, green:

~[comptime] event tick { n: i64 }
| go i64

~proc tick|zig {
    return .{ .go = n };
}

~[comptime] event report { v: i64 }

~proc report|zig {
    std.debug.print("{}\n", .{v});
}

~[comptime] pub event countdown {}

~[comptime] countdown = #loop tick(n: 5)
| go i when i > 0 |> @loop(n: i - 1)
| go i |> report(v: i)

~[comptime] countdown()

The interpreter walks countdown’s labeled loop: it invokes tick — a Zig proc, compiled at Stage B, called through the thunk table at native speed — dispatches the go branch, binds i, evaluates the when-guard, and re-enters the #loop head through @loop with fresh arguments. Five, four, three, two, one, zero. When the guard fails, the terminal arm’s report fires and prints — during compilation. The build log contains a 0. The compiled binary contains nothing: no tick, no report, no countdown, no loop. The program happened before the program existed.

Pinning that needed one honest addition to the test suite: alongside expected.txt (“when this program runs, it must print these lines”) there is now expected_comptime.txtwhen this program compiles, the compiler must print these lines. Compile-time behavior is now a first-class thing a test can assert, which is exactly what a language with a comptime interpreter owes its test suite.

The roadmap is red and public

The next rung already exists as a regression test that fails today, on purpose. The comptime producers rung teaches comptime contexts to produce structured AST instead of rendered text. Koru’s for and if are templates over the effect-branch engine — perfect for emission, opaque to an evaluator. Comptime-selective lowering gives the interpreter real foreach and conditional nodes to walk (runtime code keeps the templates and loses nothing), and with it, a pure-Koru capture-fold — sum the numbers one through nine — computed entirely at compile time.