A Pyramid Is a Pipeline, Hand-Unrolled

· 7 min read

Here is a real compilation pass, verbatim from koru_std/compiler.kz — the analysis stage, six checks run in a row over one compiler context:

~analysis = check-release-gate(ctx)
| ctx cg |> check-structure(ctx: cg)
  | ctx c1 |> pass-auto-discharge(ctx: c1)
    | ctx c2 |> check-flow(ctx: c2)
      | ctx c3 |> check-phantom-semantic(ctx: c3)
        | ctx c4 |> check-purity(ctx: c4)
          | ctx c5 => ctx c5
          | failed f => failed { f.ctx, f.message }
        | failed f => failed { f.ctx, f.message }
      | failed f => failed { f.ctx, f.message }
    | failed f => failed { f.ctx, f.message }
  | failed f => failed { f.ctx, f.message }
| failed f => failed { f.ctx, f.message }

It works, it ships, it compiles the compiler. It is also a pyramid — leaning right, one level deeper per check, and carrying that | failed f => failed { f.ctx, f.message } line six times, byte for byte. The question worth sitting with isn’t “how do we shorten this.” It’s: why does it have to look like this at all?

Two taxes, stacked

Read the pyramid slowly and it’s paying two unrelated tolls.

The first is the repeated failed. Every level re-raises the same branch by hand, because propagation isn’t automatic — nothing carries an unhandled branch out of the chain on its own, so each nesting level writes its own copy. (There is nothing special about failed here — Koru has no privileged success or failure path. It’s just a branch, named like any other.)

The second is quieter, and it’s the tell. Look at any rung:

| ctx cg |> check-structure(ctx: cg)

The branch is named ctx. The next stage’s input is named ctx. And cg is a binder that exists for one reason: to carry the first to the second. The names already line up. cg is pure ceremony — a courier for a value that knows its own destination.

That symmetry is the whole story. It means the compiler already has everything it needs to thread the value itself.

What it wants to be

Remove both tolls and the pyramid falls flat:

~analysis = check-release-gate
  |> check-structure
  |> pass-auto-discharge
  |> check-flow
  |> check-phantom-semantic
  |> check-purity
| failed f => failed { f.ctx, f.message }

Six re-raises collapse to one. The threading binders vanish. The identity terminus — | ctx c5 => ctx c5, which was return x when x was already the last expression — vanishes too, because the final stage’s ctx matches the flow’s declared | ctx branch and emits by the same name-match that threaded it.

What’s left is a single rule, and it’s worth stating without the lie most languages tell here. Koru has no success path and no failure path — a distinction it refuses on purpose, and one of the load-bearing decisions in the language. So the rule is not “thread the good branch, catch the bad one.” It is: thread the one branch that’s left. The trailing | failed arm claims failed at every stage; once it’s claimed, exactly one branch — ctx — is unclaimed, so that’s the one that threads. Not because ctx is success. Because it’s the sole survivor. Two branches left and you’re forced to handle; zero and it terminates. The thread follows arity, never polarity.

The dedented | failed reads the way catch reads after a try: everything indented above it is the guarded region, the arm at the dedent handles it. And that isn’t a new rule — indentation has always delimited reach in Koru; it’s how the pyramid nested six deep in the first place. The flat form just uses the mechanism at one level instead of six.

Nothing here is new

This is the part that matters, and it’s why the flat form reads as inevitable rather than clever. The point-free pipeline adds no feature. It leans on three things Koru already believes and had simply been spelling out longhand:

  • Events are monads. So |> is their bind, and an unhandled branch propagating to the nearest matching handler is just what bind does. That’s the choke.
  • Punning is mandatory. So when a branch named ctx meets an input named ctx, the thread knows which argument it fills. That’s the auto-binding — and it’s why this is safe where general point-free style isn’t. Ordinary partial application is ambiguous: which argument did you leave off? Koru’s isn’t, because punning names the hole. The thread fills exactly one slot, the one the language already required you to name.
  • Indentation delimits reach. So the dedented arm closes the block above it. That’s where the choke lands.

The pyramid was those three principles, hand-unrolled. The pipeline is the surface finally admitting what the semantics already were.

Where the honesty lives

A flat pipeline threads only where each stage leaves exactly one branch unclaimed — the survivor. And the claiming that produces that survivor is constrained on both sides, which is what keeps the implicitness honest. It is total: every branch a stage produces is either claimed by a handler or is the survivor that threads — a branch left unclaimed and unthreaded is a hard error, never a silent drop. And it aims to be exact: a claim should hit a branch that actually exists, so a mistyped faild is caught as a dead claim rather than a real failed going quietly unhandled. (More on how much of that exactness the compiler enforces today below — the guarantee is total, the tailored message isn’t there yet.)

Two more checks fit the thread itself. The survivor has to pun into the next stage’s input, and a stage may be written bare only when the thread is its whole input. Need more — extra arguments, a value reused later — and it keeps its parentheses. In the neighbouring coordinate pass, frontend and analysis go bare while metrics-format(…) and inter:start(contexts: [c0…c5]) stay explicit, because the latter reach for values the thread can’t provide. The gradient is per stage: a chain collapses exactly as far as the rules allow and stays explicit at the one rung where they don’t.

What actually landed

The surface is built. The parser accepts the flat form — a leading |> continues the chain, a bare stage after it is an event call with its input supplied by the thread, and the dedented arm attaches as the choke — and a desugar pass rewrites the point-free chain back into the canonical pyramid before any checker runs. So everything downstream — flow-check, purity, exhaustiveness, emission — sees exactly the tree it always saw. Nothing about the type system moved; the pipeline is pure surface, unrolled back into the shape it has always compiled.

Between them the two tests cover both halves: the value flowing forward through the thread, and an unclaimed branch bubbling to the single handler at the dedent.

Then we pointed it at the hardest target we had: the analysis pass that opens this post — the compiler’s own six-deep pyramid. Rewritten point-free, it compiles, and the compiler compiles itself through it. That one move surfaced a bug the toy chains never could: the real choke body is a record, failed { f.ctx, f.message }, and it was being replicated with its field values dropped — leaving f bound but unused. A small clone fix, and the metacircular pipeline now runs on the flat form. The strongest test of a language feature is the compiler depending on it to build at all, and this one now does.

What isn’t done yet is the tailored diagnostics. When a stage leaves two branches unclaimed, or a claim names a branch that doesn’t exist, the compiler today falls back to its general coverage wall — it still refuses to guess, so nothing threads wrongly, but it doesn’t yet say “handle these two” or “did you mean failed?” at the exact spot. A dead claim currently surfaces as the real branch going unhandled a few lines away, not as a “did you mean” at the typo. Closing that gap — the message at the site — is the next rung, and it’s the difference between a wall that stops you and a wall that also points the way.

The pyramid was a pipeline all along. Now we can write it that way — and the compiler quietly unrolls it back into the shape it always compiled.