Purity Is the Wrong Boundary
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.txt — when 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.