Skip to content

A mutable variable read is an explicit operation, not a subtyping rule - #64

Merged
dpmills merged 4 commits into
dmills/mut-typing-fixesfrom
dmills/explicit-register-reads
Aug 14, 2026
Merged

A mutable variable read is an explicit operation, not a subtyping rule#64
dpmills merged 4 commits into
dmills/mut-typing-fixesfrom
dmills/explicit-register-reads

Conversation

@dpmills

@dpmills dpmills commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

A mutable variable was related to its value by subtyping, in both directions, and neither is a lattice fact: Mut(V) <: V puts a mutable variable below its value, V <: Mut(V, D) puts it above, and Mut is invariant in V. Both fire against a fresh inference variable — cnt + 1 emits Mut(Int, D) <: ?α — so the relation cannot tell a read from a handle being passed along, since a pass-by-reference argument meets a fresh variable too. The deref moves to the rule that emits the operand (emit::emit_value_read), both arms are deleted, and the relation relates a mutable variable only to another mutable variable.

Where a handle survives

Rules 1 and 2 of the second-class discipline make the handle positions enumerable — a pass-by-reference argument and a write's target — so a parent knows without inspecting the operand. Everything else derefs, tail positions included; a lambda's result deliberately does not, since that is where rule 2 catches an escape. See mutability.md. Invariance now supplies both directions at a call, so contribute_pbr_writes is deleted.

The three positions that made the upward coercion look necessary

Each is a place the rule should have read through the handle:

  • the coalesce-time lifted type copied a tail's continuation type verbatim, re-stamping a statement or a mutable-variable introduction with the handle the read had just looked through: a := a + 5; a derived Int and recorded Mut(Int, D). 394 of the arm's 395 firings across compilation_pipeline were the wall reconciling that;
  • a Case arm was not a value position, so a conditional over two mutable variables denoted a Mut whose writer cannot be traced — the case rule 2 exists to prevent;
  • a Mut parameter given something that is not one had no value edge, leaving an argument the discipline check rejects under-determined.

mut_var_value_type asserted the old behaviour and now pins the corrected one, two tests cover the fixed tail and Case positions, and the arm's unit test becomes the statement that a value does not satisfy a Mut demand.

@dpmills

dpmills commented Aug 5, 2026

Copy link
Copy Markdown
Contributor Author

This change is part of the following stack:

Change managed by git-spice.

@dpmills
dpmills force-pushed the dmills/explicit-register-reads branch from de2ad61 to 30565e3 Compare August 5, 2026 03:59
@dpmills
dpmills force-pushed the dmills/explicit-register-reads branch from 30565e3 to f3437b9 Compare August 5, 2026 04:05
@dpmills
dpmills force-pushed the dmills/explicit-register-reads branch from f3437b9 to 546b980 Compare August 5, 2026 18:41
@dpmills
dpmills force-pushed the dmills/explicit-register-reads branch from 546b980 to bff5b87 Compare August 5, 2026 19:46
@dpmills
dpmills force-pushed the dmills/explicit-register-reads branch from bff5b87 to 6b6ef72 Compare August 5, 2026 20:09
@dpmills
dpmills marked this pull request as ready for review August 5, 2026 20:13
@dpmills
dpmills requested a review from a team as a code owner August 5, 2026 20:13
@dpmills
dpmills requested review from sortalongo and removed request for a team August 5, 2026 20:13
@dpmills
dpmills force-pushed the dmills/explicit-register-reads branch from 6b6ef72 to 4ab4faa Compare August 5, 2026 21:06
@dpmills
dpmills force-pushed the dmills/explicit-register-reads branch from 4ab4faa to c4e4776 Compare August 5, 2026 22:34
@dpmills
dpmills force-pushed the dmills/explicit-register-reads branch from c4e4776 to f318fe3 Compare August 6, 2026 22:41
@dpmills
dpmills force-pushed the dmills/explicit-register-reads branch from f318fe3 to 596782c Compare August 6, 2026 22:47
@dpmills
dpmills force-pushed the dmills/explicit-register-reads branch from 596782c to 9c52ea6 Compare August 7, 2026 00:00
Comment thread src/ccl/design/mutability.md Outdated
Comment thread src/ccl/infer/emit.rs Outdated
@dpmills
dpmills force-pushed the dmills/explicit-register-reads branch from 81e1714 to 938851e Compare August 14, 2026 19:49
@dpmills dpmills changed the title A register read is an explicit operation, not a subtyping rule A mutable variable read is an explicit operation, not a subtyping rule Aug 14, 2026
`Mut(T) <: T` held for any non-`History` demand, which made a register a *subtype of its value*. As a lattice fact that is incoherent: `Mut(V)` sat **below** `V` while `Mut` is invariant in `V`. It was a coercion wearing a subtyping rule's clothes, and it fired 796 times across the suite — **769 of them against a bare inference variable**, because `+`/`<` are `∀α. α → α → α`, so `cnt + 1` emits `Mut(Int, D) <: ?α`.

That last number is the actual defect. Firing against a fresh variable means the relation cannot distinguish a **read** from a **handle being passed along** — a pass-by-reference argument also meets a fresh variable, since `Typing::apply` records `arg <: ?d`. So the handle was dereffed before the invariance rule could see it, and passing a register to a `Mut(V)` parameter needed `contribute_pbr_writes` to hand-thread back the contribution that had just been thrown away.

Dereffing now happens in the rule that emits the operand (`emit::read_operand`), and the arm is gone. **`contribute_pbr_writes` is deleted** — with the handle intact at the argument position, invariance relates the two value types directly and supplies both directions itself. Two mechanisms collapse into one, and the rule the docs already claimed becomes the rule that runs.

Rule 1 forces a `Mut`-typed value to be a bare `Var` and rule 2 keeps `Mut` out of every composite, so a parent always knows whether the operand it is about to constrain is a handle position without inspecting it:

- a **pass-by-reference argument**, decided in `emit_apply` by reading the parameter off the head of the application spine (a call is curried, so the applied type says nothing about an argument past the first);
- a **write's target**, resolved by name rather than as a subexpression — the written *value* is an ordinary operand and reads.

Everything else derefs, including the continuation positions: a node's own type is a value, so a trailing bare register read (`a := a + 5; a`) yields `Int`.

Two placements are deliberate and were found by test rather than by reasoning:

- **A lambda's result is not dereffed.** That is where rule 2 catches a function returning a `Mut`, whose reference would escape to where its writer set is unknown. Dereffing turned the escape into a read and *accepted* the program — a language change, caught by `rule2_function_returning_mut_is_rejected`.
- **A `MutWrite`'s value is dereffed** while its target is not, which is what makes `b := a` between two registers write `a`'s current value.

The two `constrain` unit tests that asserted the arm's behaviour are removed, with a note pointing at where the property now lives — `cnt + 1` yielding `Int` rather than leaving a `Mut` on an inference variable is pinned in `tests/type_check.rs` and end to end by the `mutability` suite. The variance tests added in the base PR are what make deleting `contribute_pbr_writes` safe rather than merely green: they fail if narrowing or widening through a `Mut` parameter stops being rejected.

`./ci.sh` green.

Moving the deref to the emitting rule means asking, at each site, whether it *consumes* a value or *passes one along*. A `Let`'s body, a statement's continuation, and a register introduction's body all pass along: the node's type simply **is** its continuation's, and none of them is a place a handle stops being one. A `Let` in particular owns nothing — it cannot even bind a register — so its body reports whatever the body reports.

The register introduction and the effect statement are the exception, and only for the *type they report*: a program whose own tail reads its accumulator yields that accumulator's value, and after `inline` collapses `let b = a in b` to `a`, the statement's recorded type has to keep agreeing with what the node now derives. So those two deref.

That leaves a gap the deref would otherwise cover for. Rule 2 catches a function returning a `Mut` by looking at the lambda's codomain — but if a tail dereffed on the way out, the codomain says `Int` and the escape is invisible. Inserting one line before the escape then changed the verdict, and not to an acceptance: the lambda's stamped codomain disagreed with its body's own type at the post-inference consistency wall, so `def f(c: Mut(Int)): y = 1; c` **panicked the compiler** where `def f(c: Mut(Int)): c` is rejected.

So the check asks what the body *denotes* — walking the tails to the term that produces the value — rather than what its root node happens to be stamped with. Every tail is walked, the register introduction included: a register does not escape its own introduction either, so returning one declared inside the function is the same escape as returning a parameter, and both are rejected exactly as they were before the deref moved.

Removing a mechanism leaves prose describing it, and here that prose had gone past stale into wrong. Nine sites across `ty.rs`, `emit.rs`, `constrain.rs`, `mutability.md` and `type-inference.md` still explained the coercion arm as live — several of them explaining *why* something else had to compensate for it.

The worst was inverted rather than merely out of date. `Type::History`'s docs said the invariance rule is **not** what enforces a register's value type across a function boundary, because the deref fired first at an argument position and the rule never ran; invariance was assembled from an application edge plus `contribute_pbr_writes`. With the handle reaching the parameter, the rule is exactly what enforces it, in both directions, and the compensating contribution no longer exists. `type-inference.md` carried the same claim at length.

The rest name a mechanism that is gone: a `(_, Mut)` "lenient coercion arm" that would deref a write's value anyway, a `(Mut, _)` deref arm that a feed payload had to be buried from, "the coercion arms in `constrain.rs`" as where a register read derefs, a cross-kind pair falling through to "the deref arms below", and a test contrast pointing at a test this change deletes. Each now says what the code does.

One behaviour worth recording because a comment claimed the arm caused it: a `<<` targeting a `:=` register still lands in `NotAFeed`. It used to get there by being dereffed to `(value, feed)`; it now arrives as the handle it is and matches the same `(_, Append)` arm, which accepts any left-hand shape.
Deleting `Mut(V) <: V` left its mirror standing. `V <: Mut(V, D)` held for any
value meeting a register demand, which puts a register *above* its value exactly
as the deleted arm put it below — and `Mut` is invariant in `V`, so neither is a
lattice fact. Both also fire against a fresh inference variable, so like the arm
this PR removed, this one cannot tell a read from a handle being passed along.

It survived because three positions relied on it, and each is a place the
*rule* should have read through the handle:

**A tail's recorded type contradicted the rule that derived it.** `read_operand`
derefs where the operand is emitted, but the coalesce-time lifted type
(`solve.rs`) copied the continuation's type verbatim, re-stamping a statement or
a register introduction with the handle the read had just looked through. So
`a := a + 5; a` derived `Int` and recorded `Mut(Int, D)`, and the post-inference
wall — which re-runs the same rule — needed the coercion to accept every read in
every program. Measured: 394 of the 395 firings across `compilation_pipeline`
were that reconcile. The lift now derefs for the two nodes whose rules report a
value; a `Let` deliberately does not, since it reports its body verbatim and that
is what leaves rule 2 an escape to catch.

**A `Case` arm was not a value position.** `emit_case_branch` ended in
`subexpr`, so an arm's handle flowed into the join and `x if c else y` over two
registers denoted a `Mut` — the untraceable-writer case rule 2 exists to
prevent — while the comment three lines above claimed arms "deref into the join
like any other". They do now. The design doc and the discipline check both
already described the fixed behaviour: rule 1 rejects `bump(x if c else y)` on
the argument *node* precisely because its type is a value.

**A `Mut` parameter given a non-register had no value edge.** That program is
rejected by the discipline check, which owns the diagnosis, but its argument
subtree still needs an upper bound — without one the argument is
under-determined and the wall fails on a narrower recorded type. `emit_apply`
now emits that edge itself, against the parameter's value, which is the same
move as every other read: the rule that knows the position reads through the
handle.

With those closed the arm is dead and deleted. The relation relates a register
only to another register, by invariance, in both directions.

`register_value_type` in `tests/type_check.rs` asserted the old behaviour — it
read the program's root expecting a `History` — and now pins the corrected one:
a handle escaping a tail is a failure. Two tests cover the fixed positions
directly, and the deleted arm's unit test becomes the statement that a value
does not satisfy a `Mut` demand.

The denied fact is also spelled `Mut(V) <: V` throughout now, in the docs and
comments this PR touches. It was written `Mut(V) <: τ` for a `τ` nothing
introduced, and the sentences carrying it all gloss it as "a subtype of its
value" — which is the `τ := V` instance, the one that makes the arm incoherent
and the one that mirrors `V <: Mut(V, D)`.

`./ci.sh` and `DEEP_TYPECHECK=1 ./ci.sh test` green.
The `solve` test module gained a `Lit` import from main, so the path this
test spells in full is redundant and `unused_qualifications` rejects it.
Every program this branch adds with `\n` becomes an `indoc!` block, per
`CLAUDE.md`.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants