Probably on Koru: The Judge Is a Phantom Type
Two weeks ago TypeSafe AI came out of stealth with Jev, a “System One” model. It cannot write. It cannot chat. It answers typed questions over a closed answer space — noul (yes/no, returns a probability), choice (pick among declared labels, returns a distribution), score (ordered levels, returns a continuous value). ~70ms, all questions evaluated in one parallel pass.
On top of Jev sits Probably, a tiny workflow language. Its whole vocabulary is feels (ask a noul), match (a choice), with confidence 80% (gate on the probability), otherwise maybe (the sub-threshold path), chaos { } (sample instead of argmax), and hard budgets — twelve model calls, five iterations, enforced.
Probably’s core ports to Koru with zero new syntax — every keyword in its vocabulary was already a construct with a compile-time semantics here.
A tor already is a Jev judgment
Jev’s contract — typed question in, typed answer with a probability out — is the shape of a Koru tor with branches. The branch set is the closed answer space. The payload is the probability.
// feels — Jev "noul": argmax yes/no over a value.
pub tor feels { j: *Judge<greedy>, text: string, question: string }
| yes f64
| no f64
| exhausted Asked at a call site, the whole Probably idiom falls out of constructs that already exist:
| Probably | Koru |
|---|---|
if x feels "q" | a feels tor with | yes f64 | no f64 |
match x { "a" => ... } | a tor whose branch set is the label set |
with confidence 80% | when p >= 0.8 — a guard on the payload |
otherwise maybe | the un-guarded fallthrough arms |
chaos { } | a <chaotic> phantom state on the judge |
| 12 calls / 5 iterations, hard | a fuel field + an | exhausted branch you must route |
The last row deserves a beat. Probably enforces budgets in the runtime. In Koru the judge’s fuel is just a field its procs decrement, and running dry produces exhausted — a named branch the caller is obliged to handle, not an exception. The budget is a value; the obligation to route its exhaustion is in the type.
The mode lives in the type system
Probably scopes chaos { } dynamically — inside the block, judgments sample; outside, they argmax. Koru has no ambient scope for that. It has something better: the judge’s mode is a state on a phantom type, and the two modes are different types.
pub tor feels { j: *Judge<greedy>, text: string, question: string } ...
pub tor cfeels { j: *Judge<chaotic>, text: string, question: string } ...
pub tor chaos { j: *Judge<!greedy> } -> *Judge<chaotic!>
pub tor settle { j: *Judge<!chaotic> } -> *Judge<greedy!>
pub tor retire { j: *Judge<!greedy> } feels borrows a <greedy> judge. cfeels borrows a <chaotic> one. A greedy judge cannot sample — asking it to is a compile error, not a mode flag you forgot to check. chaos and settle consume the state and re-issue the other; they are pure type-level transitions with no runtime mode field at all. And retire is the discharge — except you never write it. It is the sole void disposer of <greedy> (chaos and settle return fresh obligations, so they are never spliceable candidates), and the emitter inserts the call at every scope exit itself. Probably’s judge can leak. This one’s destructor isn’t even in the source.
The department
polish — Probably’s while draft feels "full of corporate jargon" — is a #loop fold living inside the judge module. One round asks the judge, spends a generation if the draft is still jargon, and loops; the judge is borrowed through every back-edge, and fuel — not a counter — is the honest bound:
polish = #loop polish-round(j, draft)
| again d |> @loop(j, draft: d)
| done out => polished out
| exhausted => exhausted The app is an inbox department. Three mails, one route subflow, all three outcomes exercised:
route = koru/odds:judge(budget: 8): j |> koru/odds:triage(j, text: m)
| [bug: Bug reports] p |> std/io:print.ln("route: bug p={{ p:f }}")
|> koru/odds:feels(j, text: m, question: "genuinely urgent")
| yes q when q >= 0.8 |> ... |> koru/odds:polish(j, draft: m)
| polished d |> ...
| exhausted |> ...
...
| [feature: Feature requests] p |> ... |> koru/odds:chaos(j): cj |> koru/odds:ctriage(j: cj, text: m)
| [other: Other messages] q |> ... |> koru/odds:settle(j: cj)
...
| [other: Other messages] p |> ...
| exhausted |> ... Not one retire — the emitter splices it at every scope exit because no other event qualifies. The one discharge-adjacent call that is spelled is settle, and for the honest reason: it is a transition that mints a fresh <greedy!> (which then disposes itself), never a terminal, so <chaotic> has no candidate to auto-splice. You write settle because leaving sampled mode is a decision; you never write retire because cleanup isn’t one. The phantom checker verifies the whole lifecycle before the program ever runs. But a mock judge only proves the flows typecheck. The question that matters is whether the shape holds when the probabilities are real.
The branch names are the criteria
koru/odds ships two backends. judge issues a live judge — every judgment is a real POST to the OpenRouter Decisions API against ~typesafe/jev-latest — and judge.mock issues the same *Judge<greedy> resource on a deterministic hash for tests. The flows do not change; only the probabilities’ provenance does.
The part worth staring at is how the request is built. Jev’s choice question takes a criteria map — option name to description. The proc never spells either half: it enumerates the union’s f64-payload fields at comptime, via @typeInfo on the handler’s declared Output, and splits each branch name on :. A pattern-named branch spells [tag: description] — the tag is the key Jev answers with, the text after the colon is the description it reads:
| [bug: Bug reports] f64 ┐
| [feature: Feature requests] f64 ├─→ "criteria": { "bug": "Bug reports",
| [other: Other messages] f64 ┘ "feature": "Feature requests",
| exhausted "other": "Other messages" }
| err Err → control, not criteria — excluded by payload *type* The trick is that the emitter quotes a bracketed branch name verbatim into the union field (@"bug: Bug reports"), so the whole string round-trips through @typeInfo — key and description come out of the one declared name. The continuation matches it by equality and binds the probability: | [bug: Bug reports] p when p >= 0.8 |>.
Rename a branch and the wire question changes — the answer space is declared once, in the event signature, and the API call reflects it. Jev’s “the model cannot hallucinate” and Koru’s “a continuation must name a declared branch” are the same constraint, viewed from opposite sides of the wire.
One honest asymmetry: gen is not a Jev call and cannot be — the model gives up strings entirely. So revision goes to a chat-completions model on the same key, and the polish loop composes two model categories in one fuel-bounded flow: Jev judges, an LLM rewrites. The System One thesis stops being a claim in this post and becomes a type-level fact in the code.
Real output — calibrated probabilities, about $0.0001 for the whole run:
route: bug p=1
drop everything p=0.99
polished: Subject: URGENT - app crashes on login ...
route: feature p=0.99
chaos reroute: feature p=0.99
route: other p=1 The urgent bug triaged at p=1 and cleared the 0.8 gate — then feels("full of corporate jargon") said no, correctly, so polish handed the draft back without spending a generation. The gate declining to spend is the budget doing its job. And the feature request sampled feature at p=0.99 — the distribution was nearly degenerate, so this time chaos agreed with argmax. Sampling only shows its point when the model is genuinely uncertain, which is the honest thing about calibrated probabilities: most real mails aren’t.
Writing the example broke the compiler four times
The demo kept hitting walls that turned out to be real defects, and each one is now a pinned regression test.
A |> ending a line silently vanished. In branch position, a dangling pipe-forward is refused with Step must follow '|>'. At a flow head it was swallowed — the chain parsed as one step, the next line became an unrelated flow, and the error arrived as unused binding on a binding the author did use. The refusal was real only where it was pinned; the sibling position failed as a wrong tree a later checker described faithfully.
A phantom state forgot which module it lived in. Inside an imported .kz, a *Judge<greedy> parameter was tracked under the file stem (jev:greedy) while call sites canonicalized to the logical name (app.jev:greedy). Same file, same spelling, two state identities — so a module-internal subflow could never re-pass its own judge, and a #loop over a phantom arg could not typecheck. The anchor is now the module key the event resolved under; polish exists because of this fix.
A comment asserted Zig semantics that don’t exist. The fold emitter’s pre-label path claimed Zig narrows a union(enum) through a while condition — it doesn’t — and emitted a post-loop switch missing the looping arms. The mid-chain path already marked them unreachable; now the pre-label path shares the code.
A jump’s arguments were trusted, never checked. @loop args re-seed loop state by name — the <label>_<name> vars — but nothing validated the names against the target event. A wrong label emitted an undeclared variable; an under-filled jump emitted a partial struct. Both arrived as raw Zig errors, one indirection from the fault. The checker now refuses non-field args with KORU043, and the emitter fills every parameter on re-entry — an omitted param carries its state forward, which is what the vars are var for.
Four fixes. The first three are green under the full board — 1779/1779; the fourth is pinned and green across the label family. The example that found them is committed at examples/probably/.
What it shows
Probably is a good language. It is also, in a precise sense, a subset of this one: everything its keywords name was already a construct with a compile-time semantics here. The interesting question is the direction Jev points — models that answer typed questions instead of generating text. A compiler that can hold the answer space in its type system can hold the question protocol there too — and now does: the criteria map is read off the branch set itself, the calibrated probabilities fill the payloads, and the judge’s mode, budget, and obligation to be retired are all checked — the retirement itself emitted — before the first request leaves the process.
The module lives at koru/odds — named for the medium, not the model. Jev is the first System One model; it will not be the last judgment backend, and the branch-set shape outlives any of them. That is what a phantom type is for.