docs: plan the lowering phase (RFC-0029), and settle the spec questions it opened #22

Merged
crowmaki merged 3 commits from rfc/0029-mir into main 2026-08-26 18:18:16 +00:00
Collaborator

The front end is finished and the next phase had no design of record. This adds
the phase-opening RFC for lowering, fixes the spec's phase list to match what the
compiler will actually do, and then settles every spec question that plan opened.

0-overview.md § Phases of Compilation

Phase 4 was Optimization. On a compiler whose back end is LLVM that is the
wrong name — inlining, GVN, loop transforms and register allocation come free
with the IR, and naming the phase after them invites writing passes that
duplicate them. Phase 4 is now Lowering, and the section says plainly that
machine-level optimization is the back end's and not a phase of this compiler.

RFC-0029 — Lowering: overview

What the phase is for: drop placement on every exit path, monomorphization and
reification, the ?. / ?? / ! desugars RFC-0016 decision 3 deferred,
conformance and vtable layout (RFC-0016 question 6), and devirtualization —
the one optimization the phase keeps, because the types that justify it are gone
by the time LLVM sees a table-slot fetch.

It also opens five spec questions it cannot start without, each carrying a
recommendation and none decided in the RFC. The load-bearing one: the language
had no runtime-failure model at all. § Optionals said a force-unwrap "throws" in
a language with no exceptions, and whether failure unwinds decides whether MIR
has cleanup edges — the single largest structural question about the IR.

The spec settles all five, and the five they depended on

Per RFC-0001, a gap is resolved in docs/prd/ first and in its own commit.
Each answer turned out to have a follow-on the spec also had to hold:

Question Ruling Where it lives
What does a failed ! do? Unwinds, running every destructor on the way up § Runtime Failure (new)
Can that be caught? No — no try, no catch, no landing pad § Runtime Failure
Out-of-range subscript? Checked under debug, undefined under release § Arrays ▸ Bounds (new)
Which profiles are those? debug and release, optimization bundled in § Targets ▸ Build Profiles (new)
Does a lambda capture? Yes, by value — @n recovers by-reference § Functions ▸ Captures (new)
May a capturing lambda escape? Only via new, yielding an owned function type § Functions ▸ Escaping the Enclosing Scope (new)
Is #[SOA] layout or addressing? Addressing, and lowering owns it § Structs
Then what is @arr[i]? Refused in sema § Structs
What is a per-type descriptor? (interface, table) pairs, identity by address § Object Model ▸ What a descriptor holds (new)
What reads it? is, interface-to-interface narrowing, a fallible as § Type Conversions ▸ Fallible Conversions (new)

Two of those deserve their reasoning stated rather than tabulated:

  • Captures are by value because that is the direction that can be undone.
    @n recovers by-reference behaviour; nothing recovers a private copy from a
    language that captures by reference. The environment lives in the creating
    frame, so a capturing lambda may be called and passed down but not returned or
    stored — and one that must outlive its scope is written new (x) -> ..., which
    allocates with the word that means allocation everywhere else. That makes
    owned a type modifier as well as a field one, on function types and
    nothing else.
  • The descriptor stores tables, not just identities. A search that only says
    yes leaves the caller holding a reference it still cannot dispatch through.
    Storing the table means asking s is IShape and using the answer are one
    lookup — which is what pays for interface-to-interface narrowing and for the
    fallible as, whose rule is one sentence: x as T is fallible exactly when
    x is T is a legal test.

The grammar carries two new forms: owned before a function-type as its own
type-atom arm (deliberately not a type-modifier, which would admit
owned int), and new before a func-value.

RFC-0029 catches up

Four of the ten rulings went against the RFC's own recommendation, so the
phase is larger than planned:

  • Terminator::Trap is gone. New decision 8 fences unwinding instead: a
    Call gains an optional unwind successor, a failure site terminates in
    Unwind, a cleanup chain ends in Resume, and a cleanup block may never
    branch back into the normal CFG — which verify() now checks, because
    uncatchable is the property that keeps the IR this small.
  • Decisions 3 and 4 are re-checked rather than re-asserted. Drop stays a
    statement, and can, because Catlang has no user-written destructor — teardown
    is generated frees, which cannot fail. No drop flags survives too: a cleanup
    path is entered from one program point, not a join, so liveness there is still
    static. The cost lands in duplicated cleanup chains instead.
  • Decision 9 takes #[SOA] out of the no-layout fence, and records that
    de-structuring costs a lowering rule and zero IR — no strided place, no second
    pointer form. Decision 10 makes the build profile a lowering input, and the
    fixture contract grows a fourth point pinning fixtures to debug.
  • IsInterface becomes FindTable; a fourth HIR-diff note records that lambdas
    need no Closure rvalue either.

What this leaves for sema

Three rulings landed upstream of the phase that asked for them. They are not
amendments — RFC-0019, 0021, 0023 and 0026 are implemented and stay
implemented. RFC-0037–0039 are reserved for the new front-end work: captures
(the one with reach — an owned function type is a new shape in RFC-0018's
Type, and an owned local is the first binding outside a struct field whose
Live/Empty state RFC-0026 has to follow), the fallible as, and the #[SOA]
addressing refusal.

None of the three blocks RFC-0030 or RFC-0031. All three block RFC-0036,
because the manifest flip asserts every fixture lowers, and a fixture lowers only
if it type-checked first.


Docs only — no code, no fixtures, no manifest rows; those land with the RFCs that
implement each ruling. Suite untouched, .known-red still empty. Numbers
0030–0036 are reserved by the plan, as RFC-0016 reserved 0017–0028.

The front end is finished and the next phase had no design of record. This adds the phase-opening RFC for lowering, fixes the spec's phase list to match what the compiler will actually do, and then settles every spec question that plan opened. ## `0-overview.md` § Phases of Compilation Phase 4 was **Optimization**. On a compiler whose back end is LLVM that is the wrong name — inlining, GVN, loop transforms and register allocation come free with the IR, and naming the phase after them invites writing passes that duplicate them. Phase 4 is now **Lowering**, and the section says plainly that machine-level optimization is the back end's and not a phase of this compiler. ## RFC-0029 — Lowering: overview What the phase is for: drop placement on every exit path, monomorphization and reification, the `?.` / `??` / `!` desugars `RFC-0016` decision 3 deferred, conformance and vtable layout (`RFC-0016` question 6), and devirtualization — the one optimization the phase keeps, because the types that justify it are gone by the time LLVM sees a table-slot fetch. It also opens five spec questions it cannot start without, each carrying a recommendation and none decided in the RFC. The load-bearing one: the language had no runtime-failure model at all. § Optionals said a force-unwrap "throws" in a language with no exceptions, and whether failure unwinds decides whether MIR has cleanup edges — the single largest structural question about the IR. ## The spec settles all five, and the five they depended on Per `RFC-0001`, a gap is resolved in `docs/prd/` first and in its own commit. Each answer turned out to have a follow-on the spec also had to hold: | Question | Ruling | Where it lives | |---|---|---| | What does a failed `!` do? | Unwinds, running every destructor on the way up | § Runtime Failure *(new)* | | Can that be caught? | No — no `try`, no `catch`, no landing pad | § Runtime Failure | | Out-of-range subscript? | Checked under `debug`, undefined under `release` | § Arrays ▸ Bounds *(new)* | | Which profiles are those? | `debug` and `release`, optimization bundled in | § Targets ▸ Build Profiles *(new)* | | Does a lambda capture? | Yes, by value — `@n` recovers by-reference | § Functions ▸ Captures *(new)* | | May a capturing lambda escape? | Only via `new`, yielding an `owned` function type | § Functions ▸ Escaping the Enclosing Scope *(new)* | | Is `#[SOA]` layout or addressing? | Addressing, and lowering owns it | § Structs | | Then what is `@arr[i]`? | Refused in `sema` | § Structs | | What is a per-type descriptor? | `(interface, table)` pairs, identity by address | § Object Model ▸ What a descriptor holds *(new)* | | What reads it? | `is`, interface-to-interface narrowing, a fallible `as` | § Type Conversions ▸ Fallible Conversions *(new)* | Two of those deserve their reasoning stated rather than tabulated: - **Captures are by value** because that is the direction that can be undone. `@n` recovers by-reference behaviour; nothing recovers a private copy from a language that captures by reference. The environment lives in the creating frame, so a capturing lambda may be called and passed down but not returned or stored — and one that must outlive its scope is written `new (x) -> ...`, which allocates with the word that means allocation everywhere else. That makes `owned` a **type** modifier as well as a field one, on function types and nothing else. - **The descriptor stores tables, not just identities.** A search that only says *yes* leaves the caller holding a reference it still cannot dispatch through. Storing the table means asking `s is IShape` and using the answer are one lookup — which is what pays for interface-to-interface narrowing and for the fallible `as`, whose rule is one sentence: `x as T` is fallible exactly when `x is T` is a legal test. The grammar carries two new forms: `owned` before a function-type as its own `type-atom` arm (deliberately not a `type-modifier`, which would admit `owned int`), and `new` before a `func-value`. ## RFC-0029 catches up Four of the ten rulings went **against** the RFC's own recommendation, so the phase is larger than planned: - `Terminator::Trap` is gone. New **decision 8** fences unwinding instead: a `Call` gains an optional unwind successor, a failure site terminates in `Unwind`, a cleanup chain ends in `Resume`, and a cleanup block may never branch back into the normal CFG — which `verify()` now checks, because *uncatchable* is the property that keeps the IR this small. - **Decisions 3 and 4 are re-checked rather than re-asserted.** `Drop` stays a statement, and can, because Catlang has no user-written destructor — teardown is generated frees, which cannot fail. *No drop flags* survives too: a cleanup path is entered from one program point, not a join, so liveness there is still static. The cost lands in duplicated cleanup chains instead. - **Decision 9** takes `#[SOA]` out of the no-layout fence, and records that de-structuring costs a lowering rule and zero IR — no strided place, no second pointer form. **Decision 10** makes the build profile a lowering input, and the fixture contract grows a fourth point pinning fixtures to `debug`. - `IsInterface` becomes `FindTable`; a fourth HIR-diff note records that lambdas need no `Closure` rvalue either. ## What this leaves for `sema` Three rulings landed upstream of the phase that asked for them. They are **not** amendments — `RFC-0019`, `0021`, `0023` and `0026` are implemented and stay implemented. `RFC-0037`–`0039` are reserved for the new front-end work: captures (the one with reach — an `owned` function type is a new shape in `RFC-0018`'s `Type`, and an `owned` local is the first binding outside a struct field whose Live/Empty state `RFC-0026` has to follow), the fallible `as`, and the `#[SOA]` addressing refusal. None of the three blocks `RFC-0030` or `RFC-0031`. All three block `RFC-0036`, because the manifest flip asserts every fixture lowers, and a fixture lowers only if it type-checked first. --- Docs only — no code, no fixtures, no manifest rows; those land with the RFCs that implement each ruling. Suite untouched, `.known-red` still empty. Numbers 0030–0036 are reserved by the plan, as `RFC-0016` reserved 0017–0028.
The front end is finished and the next phase had no design of record. This is
the phase-opening RFC for lowering, in the shape RFC-0016 used for semantics.

`0-overview.md` § Phases of Compilation called phase 4 "Optimization". On a
compiler whose back end is LLVM that is the wrong name: inlining, GVN, loop
transforms and register allocation come free with the IR, and naming the phase
after them invites writing passes that duplicate them. Phase 4 is now
**Lowering**, and the spec says plainly that machine-level optimization is the
back end's and not a phase of this compiler.

RFC-0029 records what the phase is for — drop placement on every exit path,
monomorphization and reification, the `?.` / `??` / `!` desugars RFC-0016
decision 3 deferred, conformance and vtable layout (RFC-0016 question 6), and
devirtualization — plus the seven locked decisions the RFCs under it will cite.
Two worth naming here: MIR keeps addressable locals rather than constructing
SSA, and it needs **no drop flags**, because RFC-0026 diagnoses a branch join
whose ownership states differ instead of compiling it.

It also opens what the phase cannot start without:

- The fixture contract needs a fourth phase (`lower`, `tests/fixtures/lowering/`)
  and a fourth driver. The snippet rule survives — a fixture that type-checks
  lowers — but the driver must refuse to pass vacuously on a declaration-only
  fixture.
- Five spec questions, each with a recommendation and none decided here. The
  load-bearing one is that the language has no runtime-failure model: § Optionals
  says a force-unwrap "throws" in a language with no exceptions, and whether
  failure unwinds decides whether MIR has cleanup edges at all.

Docs only. Numbers 0030–0036 are reserved by the plan, as RFC-0016 reserved
0017–0028.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012xwjuW4s8kpNdCqkxespiR
RFC-0029 could not start without answers to five gaps in docs/prd/, and
each answer opened one more. This lands all ten in the spec, where
RFC-0001 says a gap is resolved, so the lowering RFCs can cite rather
than decide.

Runtime failure (0-overview.md, new section). "An error will be thrown"
named nothing: the language has no exceptions, no panic, no abort. A
failure now unwinds, running every destructor on the way up, and cannot
be caught. Uncatchable is what keeps the cost to cleanup blocks and a
resume — no landing pad, no try/catch, and failure stays out of the
business of signalling anything.

Array bounds (language-spec.md, Arrays > Bounds). Checked under debug,
unchecked under release. An array already carries its length, which is
the whole reason it is not a C array, so the bound is at hand. That
needed a concept the spec did not have: Targets > Build Profiles, two
profiles, and bounds are the only semantics they change.

Lambda captures (Functions > Captures, Escaping the Enclosing Scope).
By value, because @n recovers by-reference and nothing recovers
by-value from the other default. The environment lives in the creating
frame, so a capturing lambda may be called and passed down but not
returned or stored; one that must outlive its scope is written
`new (x) -> ...`, which allocates and yields an `owned` function type.
`owned` therefore becomes a type modifier as well as a field one, on
function types and nothing else.

#[SOA] (Structs). Transparency holds except for the one operation that
cannot be given honestly: the address of a whole element. Reading an
element and addressing a field both stay legal; a pointer to the
element does not, because the alternative is a pointer to a temporary
whose writes go nowhere.

The per-type descriptor (0-overview.md, What a descriptor holds). A
count and a run of (interface, table) pairs, identified by address, so
separate compilation and externally-registered impls need no numbering.
Storing the table, not just the identity, is what makes `s is IShape`
usable: the lookup that answers the question also produces the
reference. That pays for interface-to-interface narrowing and for
Type Conversions > Fallible Conversions, whose rule is one sentence —
`x as T` is fallible exactly when `x is T` is a legal test.

The grammar carries two new forms: `owned` before a function-type as
its own type-atom arm rather than a type-modifier (which would admit
`owned int`), and `new` before a func-value, told apart from the other
new-value arms by the "(" that neither of them can start with.

No fixtures or manifest rows here; those land with the RFCs that
implement each ruling.
The plan opened five spec questions and recommended an answer to each.
The spec settled all five, plus the five they depended on, and went the
other way on four. This is the plan catching up.

Open spec questions becomes The spec questions this phase opened: a
table of ruling and citation, no restatement, and no reprint of the
recommendations that lost. The header cites the two sections that did
not exist when this was written.

Unwinding is the big one. New decision 8 fences it: a Call gains an
optional unwind successor, a failure site terminates in Unwind, a
cleanup chain ends in Resume, and a cleanup block may never branch back
into the normal CFG — which verify() now checks, because uncatchable is
the property that keeps the IR this small. Terminator::Trap is gone.

Decisions 3 and 4 needed re-checking rather than re-asserting. Drop
stays a statement, and can, because Catlang has no user-written
destructor: teardown is generated frees, which cannot fail. No drop
flags survives too — a cleanup path is entered from one program point,
not a join, so liveness there is still static; the cost lands in
duplicated cleanup chains instead, which RFC-0032 emits.

Decision 9 takes #[SOA] out of the no-layout fence, where the spec ruled
against it, and records that de-structuring costs a lowering rule and no
IR at all. Decision 10 makes the build profile an input, and the fixture
contract grows a fourth point pinning fixtures to debug, the superset
shape.

FindTable replaces IsInterface: the descriptor stores tables, not just
identities, so one rvalue serves is, narrowing and the fallible as. A
fourth note records that a lambda needs no Closure rvalue either — a
function value is an Aggregate of a code address and an environment
pointer.

What sema still owes this phase reserves RFC-0037 through RFC-0039 for
the three rulings that landed upstream. They are new front-end work, not
amendments: RFC-0019, 0021, 0023 and 0026 are implemented and stay
implemented. None blocks RFC-0030 or RFC-0031; all three block RFC-0036.
crowbot changed title from docs: plan the lowering phase (RFC-0029), retire "Optimization" to docs: plan the lowering phase (RFC-0029), and settle the spec questions it opened 2026-08-26 18:17:11 +00:00
crowmaki approved these changes 2026-08-26 18:18:13 +00:00
crowmaki deleted branch rfc/0029-mir 2026-08-26 18:18:16 +00:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
2 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
crowmaki/catlang!22
No description provided.