koruc explain: The Compiler Explains Your Program
In most languages a library is code you call. In Koru a module is a compile-time machine: it reads the whole program tree, decides, and emits. std/store walks every std/store:* site in your program to expand your protos into columns and mint the accessors. std/pump composes participants it never sees inside — it joins names. std/refine meets your bounds and leaves a typed facet in the tree for whoever wants to enforce it. These are not APIs with documentation — they are self-driving transforms, and they already hold *const Program.
koruc explain asks that machinery to report. Every module that owns a vocabulary can ship an [explainer] — a comptime tor that folds the same sites its transform reads — and the command gathers every report into one catalog. SQL’s EXPLAIN reports the planner’s decision about your query; here the libraries are the planners, and each reports what it decided about your program’s use of it.
Not generic docs. Docs describe the library; this is the ledger — “given this config and these call sites, here is what I decided about THIS code.” It runs instead of compilation, on the freshly-parsed AST, in three formats:
koruc main.k explain # text
koruc main.k explain json # machine-readable catalog
koruc main.k explain html # rich page What it reads back
Here is a program — a Server record with a refined port, a list that pushes them, a store that holds two:
std/proto:i64(Port)
std/proto(Server) {
port: Port
host: string
}
std/refine(Server) {
port: Port & >1024 & <=65535
}
std/list:new(Server)
| list xs |> _
std/store:new(fleet, capacity: 8) { web: Server, db: Server }
std/store:insert(fleet) { web.port: 8080, web.host: "web-01" }
| row _ |> _
| full f |> _ And koruc demo.k explain — verbatim, minus the boilerplate rows:
📖 std/refine
demo:Server
contributors = 1
port: Port & >1024 & <=65535 → i64 — refuse
port.meet = Port & >1024 & <=65535
port.effect = refuse
📖 std/list
list(demo:Server)
element = demo:Server
port: Port & >1024 & <=65535 — push refuse
facets_enforced = 1
push declares | ?ok | ?!violated | ?!oom — an unhandled violation panics
📖 std/store
demo:fleet
capacity = 8 [pwmsj]
columns = 2 [pwmsj]
web: Server
db: Server
inserts = 1 [pwm2]
facets_declared = 2
insert enforces them — every generated insert event declares `?!violated <column>`; unhandled it panics, `| violated f |>` supervises Three libraries report three different decisions about the same field: refine says what the meet resolved to (Port → i64, effect refuse), list says what it will enforce at its own boundary (push refuses a bad port — or panics unhandled), and store says what it enforces at its boundary — two facets land on its member columns, and every generated insert event declares ?!violated for them. Two days ago that last row read “insert does NOT enforce them”, and it was true: no consumer read the fold at insert yet. The row flipped the day the enforcement landed, because the explainer asks the same metFields fold the insert guard is built from. Declared-versus-enforced is a first-class fact in the catalog either way; silence would pass for “no facets exist.”
The bracketed tokens are addresses — the sites each row counted. More on those below.
No module had to know about the others to produce this. Each explainer reads the program’s own sites and reports what its transform would do; the catalog joins them on demo:Server because that’s what the program actually says.
The explainer runs the transform’s fold, not a second reading
The honesty anchor is older than these three providers. The first explainer was std/runtime’s: a register block declares the verbs a scope mounts — which mint obligations and which consume them — and the transform builds a manifest record that is then served as the scope’s JSON contract endpoint, rendered into the model’s prompt, and read back by explain. One record, three views — no hand-written manifest exists to drift from the enforcement.
That was the easy case: a record to re-render. The harder case is a module whose decisions are procedures — a constraint meet, a participant’s legality check, a store’s column expansion. There is no manifest; there is a fold that decides.
So the explainers run the fold — and this is why the self-driving angle holds literally: the explainer and the transform are the same machine at two sinks. std/refine’s report of what meets on a field calls the same metFields resolution that builds std/list’s push guard — the explanation cannot drift from the enforcement because they are the same code. std/pump’s explainer folds create/join/run sites in document order under the same legality checks the transforms apply — step required, ! wait needs ! live, the callee contract. And when a program refuses, the report’s status rows and the compiler’s diagnostics format the same shared message consts — one string, two sinks, wording drift impossible by construction.
A refused program explains itself
Explain runs on the pre-transform tree, which inverts the usual tooling rule: the report still works on a program that fails to compile. That makes the highest-value query — why did my program refuse? — the one explain answers best. Give a string terminal a bound and a nonexistent proto a refinement, and a program that dies with two KORU205 diagnostics still explains:
email: Email & >5 — refused: 'Email' resolves to 'string' — bounds and clamps refine integer scalars only
port: Port & >1024 — refused: no declaration named 'Ghost' in home 'demo' to refine Those rows are the diagnostics — verbatim, because the refusal text is shared, not mirrored. The debugging surface works precisely where the build is broken.
The planner reports its plan
SQL’s EXPLAIN earns its name by telling you whether a query walks the table or hits an index. std/store now answers the same question, for the same reason: it is the thing that decides. A std/indexes:store(players, key) declaration is pure AST-data — it never runs — and the store’s transforms discover it on their program walk and route a ! first whose when names the indexed column to a map lookup instead of a sweep. The explainer reports that decision per site:
import std/io
import std/store
import std/indexes
import std/explain
std/store:new(players, capacity: 8) { key: i64, val: i64 }
std/indexes:store(players, key)
std/store:insert(players) { key: 10, val: 100 }
| row a |> std/store:insert(players) { key: 20, val: 200 }
| row b |> std/store:insert(players) { key: 30, val: 300 }
|> std/io:print.ln("seeded")
|> std/store:query(players)
! first p when p.key == 30 |> std/io:print.ln("nested {{ p.val:d }}")
| none |> std/io:print.ln("nested none")
// routed: the guard's equality is on the indexed column
std/store:query(players)
! first p when p.key == 30 |> std/io:print.ln("first {{ p.val:d }}")
| none |> std/io:print.ln("first none")
// sweep: visits every row
std/store:query(players)
! query q |> std/io:print.ln("row {{ q.key:d }}")
// scan: the predicate names a non-indexed column — same `! first`
// spelling, no index to route through
std/store:query(players)
! first p when p.val == 100 |> std/io:print.ln("scan {{ p.val:d }}")
| none |> std/io:print.ln("scan none")
// a [tree] store: its `parent` self-FK is synthesized by the annotation
// and invisible to the query surface — plans for it read the same column
// list the transform does
[tree]std/store:new(tree, capacity: 4) { val: i64 }
std/store:insert(tree) { val: 10 }
| row r |> std/store:insert(tree) { val: 20 }
|> std/store:query(tree)
! first n when n.val == 20 |> std/io:print.ln("tree nested {{ n.val:d }}")
| none |> std/io:print.ln("tree none")
std/store:query(tree)
! first n when n.val == 10 |> std/io:print.ln("root {{ n.val:d }}")
| none |> std/io:print.ln("no root") 📖 std/store
input:players
capacity = 8 [vjdw]
columns = 2 [vjdw]
key: i64
val: i64
index = key [vjda9]
inserts = 3 [vjdxjp] [vjdxjp64z] [vjdxjp64zgyg]
queries = 4 [vjdxjp64zgygjcfc9v] [vjdp82] [vjd9p2] [vjdgd1]
query[0] = ! first p — index lookup on key [vjdxjp64zgygjcfc9v]
query[1] = ! first p — index lookup on key [vjdp82]
query[2] = ! query q — sweep [vjd9p2]
query[3] = ! first p — sweep, stops at first match [vjdgd1]
watches = 0
input:tree
capacity = 4 [vjdf]
columns = 1 [vjdf]
val: i64
inserts = 2 [vjda5p] [vjda5p75q]
queries = 2 [vjda5p75qsga] [vjd3v1]
query[0] = ! first n — sweep, stops at first match [vjda5p75qsga]
query[1] = ! first n — sweep, stops at first match [vjd3v1]
watches = 0 Same ! first spelling four times; two route, two sweep, and the report says which. Nothing in the source distinguishes them — the index is an organ decision, not a surface — so the only honest way to report the plan is to ask the organ. The explainer does not read the when text. It runs the query transform’s own chain — arm resolution, the request-block rules, the guard lowering, the routing call — and prints what came out; wherever that chain would refuse, the row prints the refusal in the compiler’s own words instead of a plan.
The [tree] store is there for the trap. Its annotation synthesizes a parent column into the cell, and the obvious “completeness” move is to let the explainer see it. But query cannot see it — a guard on n.parent refuses at compile time — so an explainer that knew about parent would print a plan for a program the compiler rejects. The fold’s input has to be the transform’s read surface, not the organ’s storage, or the report lies in the direction nobody checks.
Every counted row carries its addresses
inserts = 3 [vjdxjp] [vjdxjp64z] [vjdxjp64zgyg] is not a count with decoration. The count is the length of the witness list — the row cannot say 3 and show 2 — and each token is a coordinate over the program’s semantic path: module, item, site, and one more segment per level of nesting. Chop the right end and you zoom out to the enclosing site; the three inserts share vjdxjp because the second and third are chained under the first. Nothing is registered and nothing rots: the hash is a pure function of the tree, minted fresh each time a report runs.
koruc <file> at <hash> is the drill. Hand it the deep query’s coordinate and it lands on that site and lists the children whose hashes extend it:
📍 at vjdxjp64zgygjcfc9v — 3 site(s)
site std.store:query [vjdxjp64zgygjcfc9v] input.k:28
↳ [vjdxjp64zgygjcfc9v9] std.io:print.ln
↳ [vjdxjp64zgygjcfc9vg] std.io:print.ln
site std.io:print.ln [vjdxjp64zgygjcfc9v9fa] input.k:29
site std.io:print.ln [vjdxjp64zgygjcfc9vg69] input.k:30 A stale hash — one whose tail no longer exists because the program moved — lands on the deepest cell that still does and says so (tail drifted; nearest cell is depth 18) rather than missing. Text renders the shortest unique prefix, so a shallow program’s addresses stay four or five characters; glance prints the same coordinates beside declarations, and the JSON catalog carries them as "inserts$at" arrays. One enumerator serves the map, the ledger, and the drill — same tree, same pointers, same hashes — which is what lets std/pump’s report and std/store’s report below agree on a coordinate neither could have told the other.
The catalog corroborates what no file contains
The deepest use so far is a design that scatters truth on purpose — and a channel that exists only at compile time. A store armed with ! step mints a tasks-step unit mid-transform: a name in no source tree, in no file you can grep. When a pump joins it, the two reports cross-check the channel from opposite ends:
📖 std/store
demo:tasks
interceptors = step, discharge, wait
pump participant — exposes tasks-step / tasks-live / tasks-wait
📖 std/pump
main
drains = true
j0.step = demo:tasks-step ← synthesized by std/store:new(tasks)
j0.live = demo:tasks-live ← synthesized by std/store:new(tasks)
j0.wait = demo:tasks-wait ← synthesized by std/store:new(tasks) Store’s report claims the verb; pump’s report resolves the callee to its generator — “synthesized by std/store:new(tasks)” instead of the false “undeclared tor” a naive reader would conclude. The catalog traces a name through a wire that only exists at compile time, reported by the module that writes it on one side and the module that reads it on the other.
Opt-in, library-owned
explain is the only caller of explainers; libraries provide them, never invoke them — the reporting verb rides on the same machine that already drives your program at comptime. koruc itself knows nothing about stores, pumps or facets: it generates one gather routine per program that calls every [explainer] it found in the imported modules and hands the typed reports to the renderer. std/runtime shipped the first; std/refine, std/list, std/pump, and std/store followed, each folding its own sites with its transform’s own checks — five today, and a user library gets the same door by shipping an explainer of its own. A module that can see your whole program can describe what it saw — and the moment it does, “here is what I decided about YOUR program” stops being a promise and becomes a command.
The constraint story that produced most of these exhibits is its own post: std/refine: Constraints That Meet, Boundaries That Enforce.