rfc/0029-lowering #23

Merged
crowmaki merged 12 commits from rfc/0029-lowering into main 2026-08-27 04:35:07 +00:00
Owner
No description provided.
§ Type Union Propagation's `sum1`/`sum2` wrote `res += num as float;` with
`num: int | float`. § Fallible Conversions says `x as T` is fallible exactly
when `x is T` is a legal test, and `num is float` is one — so that cast is a
`?float` and the example does not compile. The two sections contradicted each
other, and RFC-0038 hit it while being written.

The cast was incidental: the section is about *implicit* casting to type
unions, so `res += num;` says what it meant and type-checks today, inserting
the `(conv f32 ...)` the surrounding prose is about.

The fallibility rule keeps no totality exception, and § Fallible Conversions
now says so outright. Exempting the total case reads well on `int | float`
and fails on `bool | int` — the spec's own union from § Unions, and the one
§ Type Tests uses as its worked example — where every member reaches `int`,
so `r as int` would be `int` rather than `?int` and a `false` would arrive
as `0` with nothing said. The exception is invisible exactly where it fires.

Spec-only, in its own commit, per RFC-0001.
§ Type Tests admits exactly two operands — a union, which carries a tag, and
an interface reference, whose (pointer, table) pair names the implementation
behind it — and refuses everything else on the grounds that the answer is
already known. `any` falls outside both halves of that: the answer is not
known, it is unknowable. § Any reinterprets bits in both directions without
checking, so there is no discriminant to read.

The front end accepts `raw is int` today and has no way to answer it, which
RFC-0038 hit while working out what each form of `is` lowers to. Recording the
refusal in the spec is what lets that RFC name a lowering for every form it
admits, rather than carrying one it cannot emit.

No fixture tests `is` on an `any`, so nothing changes colour; `any-type.cat`
uses `as`, which stays unchecked and stays legal.

Spec-only, in its own commit, per RFC-0001.
§ Object Model ▸ What a descriptor holds specified a descriptor as "a count
and a run of (interface, table) pairs". The count cannot be written by the
compiler. § Object Model's external-implementations rule lets a struct
defined in one unit gain an interface implementation registered in another,
and a count emitted by the defining unit is wrong the moment that happens —
which is the one case the descriptor exists to answer, since `s is IShape`
on an externally-added implementation is the only operation in the language
that performs a runtime lookup.

So the run is delimited by the linker: each unit emits its pairs into the
type's section, the linker concatenates them and supplies the bounds. This
also settles which of two linker models the phase uses. Concatenation keeps
both units' contributions; the alternative — one weak symbol per descriptor,
collapsed to a single copy — keeps one unit's pairs and silently answers
"no" for an interface the type demonstrably implements.

Found while writing RFC-0033 and RFC-0034, which had each already dropped
the count and had specified the two opposite linker models. Recording the
ruling here rather than in either RFC, per RFC-0001: a spec noun is not
redefined in passing by an implementation note.

Spec-only, in its own commit.
§ Memory Model promised that "owned data cannot simply memory-leak or
double-free by forgetting a manual teardown" and then left copying
unspecified. Copying is where the promise fails, and it fails without any
`delete` in the program:

    struct M { owned d: ?*T; }
    function mk() -> M { let m: M; m.d = new T; return m; }
    function f() -> void { let q = mk(); }

`mk`'s scope exit destroys `m`, freeing the `T`; `f` receives a struct whose
owned field is dangling, and `f`'s scope exit frees it again. Verified: the
front end accepts all of it silently today. Destruction follows scope, so
reading an owning value out of a place makes two owners of one target, and
neither carries anything that says the other exists.

There is no reading of that copy which leaves exactly one owner, because
Catlang has no move semantics — RFC-0029 decision 7 records that `release`
is the explicit way to hand a target on and everything else copies. So a
value whose type transitively owns may not be read out of a place: binding
it to a second name, passing it by value, returning a binding that holds
one, and destructuring a tuple holding one are static errors.

The rule is about the **read**, not about an owning type in value position.
`return new (f: f32) -> f * by;` from § Escaping the Enclosing Scope stays
legal — a fresh value handed straight on has no first owner for a second to
sit beside — while `const f = new …; return f;` does not. Getting that
distinction wrong would have contradicted the spec's own example. The rule is
on the type rather than the value's state, so it is decidable from a
declaration.

The natural relaxation is a move rule, letting a `return` empty its source.
It is deliberately not adopted: a move needs use-after-move tracking, which
is the class of analysis § Deliberate scope rejects, and this refusal is what
the existing text already requires.

Found by the adversarial review of RFC-0032, which reached the shape from
drop placement rather than from types. No fixture goes red — no `semantics/`
fixture reads an owning value out of a place. The negative fixture and its
manifest row land with RFC-0043, which implements the refusal.

Spec-only, in its own commit, per RFC-0001.
RFC-0024, semantic analysis. `BodyCtx::member_of` handed a struct field's
declared type back verbatim:

    return Some(Member::Field(FieldId(index as u32), field.ty.clone()));

A field's declared type is written in the *struct's* placeholders, so it
only says what `base.member` is once the instantiation's arguments have
been put through it. This is one arm of a match getting a treatment its
sibling already gets: the `Member::Intrinsic` arm a few lines away
substitutes an array intrinsic's element type for exactly this reason. The
`Type::Named` binding above discarded the arguments (`let Type::Named(def,
_)`), so they were not even in scope to substitute with.

Before, all three accepted with zero diagnostics:

    struct Box<T> { v: T; } let b: Box<f32>; const x: bool = b.v;
      -> (global #10 (. (global #9):(#8 f32) .0):'0)
    struct Box<T> { v: T; } function f(b: Box<f32>) -> bool { return b.v; }
      -> (return (. (var %0):(#8 f32) .0):'0)
    struct Pair<A,B> { a: A; b: B; } let p: Pair<int,float>; const y: bool = p.a;
      -> (global #10 (. (global #9):(#8 s32 f32) .0):'0)

After, `b.v` is `f32` and each is refused with "expected `bool`, found
`f32`". Beyond the missing diagnostic, the stranded `Type::Var` is what
RFC-0030's verifier (rules 6 and 10) forbids anywhere in a lowered body.

RED first, at both levels:

- `tests/fixtures/semantics/generic-structs.cat`, lifted from
  language-spec.md's own `struct Span<T> { start: T; end: ?T; }`
  (Derived > Arrays > Slices and Ranges) — the spec's declaration of the
  compiler-provided `Span`, so the fixture supplies the seed rather than
  shadowing it. It was rejected with "`first` has no type: nothing worked
  out what its initializer produces". Registered in the feature manifest,
  which had no row exercising a generic struct beyond its declaration.
- Three inline tests in `body::tests`: positional substitution over one
  and two parameters, a field whose type only *mentions* a parameter
  (`[]T`, `*T`), a field with no parameter (unchanged), a nested
  instantiation, and the diagnostic that was being swallowed.

The third test is the sibling audit rather than a RED case: inside a member
function the receiver's arguments are its own placeholders, so `this.v` on
`Box<'0>` is still `'0` and the fix must not disturb it. `direct_call`
binds type arguments from the written argument types alone
(`generics::infer_type_args`); the receiver contributes none. That is
unobservable today because pass 1 does not scope a struct's type
parameters over its namespace's functions — `function Box::get(this) -> T`
is refused with "unknown type `T`" — so no generic method signature exists
to substitute into. Closing it is a collect change plus a `direct_call`
change, not this one-line shape, so it is left as it stands and pinned by
the test. `struct_literal` holds the same verbatim `field.ty.clone()`, but
is unreachable for an instantiated generic: the parser has no expression
form for `Box<f32> { ... }` and the un-argumented `new Box { ... }` is
already diagnosed.

No existing fixture changed behaviour: `slices.cat` is the only other
fixture declaring a generic struct and it never reads a field, and
`spans.cat` / `object-model-functions.cat` carry implicit generics over
the field-less intrinsic `Span`.
`let x: T;` had no meaning. § Default Initialization defines a *type
expression in value position* — `const v = Vector3;` — and says nothing
about a declaration that names a type and stops. The gap was load-bearing in
three places at once, and each found it independently:

  - RFC-0026 recorded that "there is no way to reach the live state on a
    stack owned field", because a struct with a non-optional owned pointer
    has no default and `new P { … }` yields a `*P` whose fields it cannot
    reach.
  - RFC-0032 found that drop placement emits a teardown over storage that
    never held a value.
  - `tests/fixtures/semantics/release.cat` — a green fixture lifted from
    § Structs — writes `let t: Transform;` and then reads `t.rotation`,
    putting a value on the stack that § Default Initialization says cannot
    go there and reading a field nothing wrote.

The ruling: a declaration without an initializer declares storage and does
not initialize it, and **a binding must be assigned before it is read**.
Assignment is tracked per field, so a struct with no default is built one
field at a time — which is what gives such a type a way to exist on the
stack, and what gives the owned-rebinding rule an empty state to start from.
An owned field that was never assigned owns nothing, so its first assignment
needs no `release`. The neighbouring claim that such a struct "cannot be
stack-allocated" is corrected: it cannot be *default*-initialized.

Two bounds keep the analysis small. A binding assigned on one path and not
another is not readable. And where the type owns, the paths must agree at
the join rather than at a later read — because destruction follows scope, so
a value whose existence depended on which path ran would need a run-time
flag, which is the one thing the destruction rules exist to avoid.

This is a forward pass over the control flow RFC-0026 already walks, not a
new analysis, and it is definite assignment rather than a lifetime or borrow
check — § Deliberate scope's fence is untouched.

`release.cat` goes red under this rule and is owed an edit: assign both
fields before releasing one. That edit lands with RFC-0043, which implements
the analysis. No other fixture is affected.

Spec-only, in its own commit, per RFC-0001.
The definite-assignment rule left one case open, and RFC-0032 found it by
asking what a scope exit emits for a struct that was only half assigned. Two
answers were available and they cost very differently.

Trimming the teardown to the assigned fields keeps every program legal, and
costs three interlocking things: `sema` must record per-field assignment
state at every exit, the HIR needs somewhere to put that record, and
RFC-0030's verifier rule 7 must relax from `order == destruction_order` to
`order` is a subsequence of it. Requiring completeness costs one sentence.

The ruling is completeness, and it follows from what the rest of the section
already promises. A teardown that frees some fields and skips others is a
destruction that depends on how far the program got, and not having to know
how far the program got is exactly what the ownership rules are arranged to
buy — it is the same argument that keeps drops unconditional and drop flags
out of the lowering phase.

Never-built stays legal, because a binding that was never assigned is
destroyed by nothing. Only half-built is refused, and leaving early with a
half-built value is written the way every other early decoupling is, with
`delete` or `release` on what was assigned.

The window is narrow: an `owned ?*T` has a default and is therefore assigned
from its declaration, so only a non-optional `owned *T` can be caught, and
only between its declaration and its one write. RFC-0032 reports the trim set
is empty in all six of its fixtures.

Spec-only, in its own commit, per RFC-0001. The refusal lands with RFC-0043
alongside the analysis it is part of.
Two corrections to the definite-assignment ruling in 7277846.

**`@x` on an unassigned binding is legal.** The ruling said a binding must be
assigned before it is read and did not say whether taking its address counts.
It does not. A pointer is how storage gets written through in the first
place — § Default Initialization's own `new Transform { @p, @r }` takes the
addresses of two bindings in order to build a third — and what happens at a
dereference is already the programmer's under § Raw pointers. Definite
assignment is a rule about values, not about locations.

**7277846's commit message was wrong about how many fixtures go red.** It
said "`release.cat` goes red ... No other fixture is affected." The real list
is six of the eight fixtures that declare without initializing:

  - `release.cat`            -- `release t.rotation`
  - `slices.cat`             -- `arr[0..3]` on a `let arr: []int;`
  - `spans-inclusive.cat`    -- same shape
  - `spans-unbounded.cat`    -- same shape
  - `generic-structs.cat`    -- `indices.start`, added in a5e5159
  - `type-test-statically-known.cat` -- `v is Vector3`

`owned-rebinding.cat` stays green: `p.next = new T;` is a write. And
`type-tests.cat` stays green only because of the ruling above -- its
`let tri: Triangle;` is read solely as `@tri`.

`type-test-statically-known.cat` is the one that matters most, and it is why
the miscount was worth a commit rather than a footnote. It is a *negative*
fixture whose marker sits on line 3; a second, unmarked error on line 2 makes
the reported error lines differ from the marked lines, and RFC-0001's driver
fails it for that. A rule that silently breaks the mechanism which proves
negative fixtures fail for the right reason is a rule whose blast radius has
to be counted, not estimated.

All six edits land with RFC-0043, alongside the analysis.

Spec-only, in its own commit, per RFC-0001.
Follow-up to a5e5159. The one-line substitution fix is unchanged and correct;
its adversarial review found that two things shipped beside it were not.

**The audit comment on `a_receivers_own_placeholders_substitute_to_themselves`
was wrong, and being wrong in a test comment is worse than being silent.** It
said `direct_call` failing to substitute from the receiver's arguments "is
unobservable today because a member function cannot name its owner's type
parameter at all". A member function cannot name it *by spelling* -- that
refusal is genuine and the test still pins it -- but it can acquire a
placeholder another way, and then the two collide:

    function Box::blit(this, s: Span) -> void { s.start = this.v; }
    function use(b: Box<f32>, s: Span<u8>) -> void { b.blit(s); }

A bare `Span` is an implicit generic; `lower_pending` resets `next_var` per
declaration and `collect_function` uses `Scope::empty()`, so the parameter's
placeholder is `Var(0)` -- the same index `receiver_type` hands the owner's
first parameter. The call records `Box::blit<u8>` against a `Box<f32>`
receiver, with no diagnostic. Under monomorphization that makes `this.v` a
`u8` and stores an `f32` into it.

The comment now states the hole and names where it is recorded. No test
asserts the broken shape: a test that pins a bug is how the bug stops being
looked at.

**`generic-structs.cat` was written on syntax that had no meaning, and would
have stopped being a RED test if fixed carelessly.** It read `indices.start`
from a `let indices: Span<size_t>;` that nothing assigned -- undefined at the
time, and a definite-assignment error under 7277846. Giving it an initializer
(`= 0..10`) fixes both. The naive repair -- making it a function parameter --
was tried and rejected: it passes *without* the substitution fix, because an
unsubstituted `Var` is assignable to anything, so only an unannotated binding
trips "has no type". Re-verified by reverting the fix: RED before, GREEN
after, 335 passing, fmt and clippy clean.

Also corrected: the fix's own comment said the intrinsic arm that substitutes
for the same reason is "below" it; it is above.
§ Default Initialization ▸ Declaring without initializing links to
"[Memory Model](#memory-model)" twice. § Memory Model is a heading in
`0-overview.md`, not in `language-spec.md`, so both anchors resolved to
nothing. They now point across the file.

Found by a cross-document consistency audit, which reports these as the only
broken links in the docs tree.
RFC-0029 reserved seven numbers for the lowering phase and three for the
front-end work it pushed upstream, the way RFC-0016 reserved RFC-0017..0028.
This writes all ten, amends RFC-0029 against what writing them found, and
indexes them.

Lowering (`mir`):

  0030  the IR -- blocks, terminators, places, `dump()`, `verify()`, the crate
  0031  HIR to a CFG -- the cursor, representation changes, narrowing,
        short-circuits, `switch` fall-through, bounds checks, `#[SOA]`
  0032  drops on every exit path, and the cleanup chain behind every unwind
  0033  interface conformance and table layout -- RFC-0016's question 6
  0034  monomorphization, reification, and the per-type descriptors
  0035  devirtualization -- the phase's one transformation
  0036  wiring, the driver, the manifest flip, and what still does not lower

Front-end work the phase asked for (`sema`):

  0037  captures, the escape refusal, and the `owned` function type
  0038  the fallible `as`, and narrowing to another interface
  0039  refusing the address of an `#[SOA]` element

Each was drafted, adversarially reviewed, revised against the findings, and
then checked twice for cross-document consistency. The reviews are the
reason the set is worth having: they found a use-after-free in RFC-0037's
first draft, an ordering in RFC-0038 that made the spec's own `bool | int`
union silently infallible, a predicate in RFC-0039 whose refusal leaked
through generic struct fields, and -- in RFC-0030 and RFC-0031, which are
mine -- fourteen defects, including a verifier rule that rejected five
currently-green fixtures and a narrowing lowering that silently copied a
two-word interface reference into a one-word pointer.

Eleven of RFC-0029's ten locked decisions survive; decision 9 gained the
scatter, the store-through-a-call and the `delete`, which it had not named.
Its IR sketch changed in eighteen places -- the sharpest being that
`Rvalue::InterfaceRef` could not express the spec's own interface-to-interface
narrowing, where the table comes back from a runtime lookup rather than being
a compile-time constant.

RFC-0029's table of what `sema` still owes grew from three rows to seven,
plus ten defects in code that is already implemented and two missing prelude
rows. That growth is the honest result of running the front end instead of
reading its documentation: `hir::Callee::Interface` is constructed nowhere,
`impl` blocks are collected nowhere, and the parser implements neither
`owned (f32) -> float` nor `new (f: f32) -> f * by` though the grammar
documents both.

Nothing here is built. RFC-0036 records what does not lower and why, and
RFC-0031 carries the row-by-row account -- including the two hard stops that
are language questions rather than compiler ones: a `Span<T>` whose `end: ?T`
cannot hold the end of the `0..256` its own derivation rule types as
`Span<u8>`, and an interpolation hole with no way to become a `string`.

The spec rulings these documents rest on landed separately, in their own
commits, per RFC-0001.
crowmaki deleted branch rfc/0029-lowering 2026-08-27 04:35:07 +00:00
crowmaki referenced this pull request from a commit 2026-08-27 04:35:09 +00:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
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!23
No description provided.