A mutable variable read is an explicit operation, not a subtyping rule - #64
Merged
Merged
Conversation
Contributor
Author
This was referenced Aug 5, 2026
dpmills
force-pushed
the
dmills/explicit-register-reads
branch
from
August 5, 2026 03:59
de2ad61 to
30565e3
Compare
dpmills
force-pushed
the
dmills/explicit-register-reads
branch
from
August 5, 2026 04:05
30565e3 to
f3437b9
Compare
dpmills
force-pushed
the
dmills/explicit-register-reads
branch
from
August 5, 2026 18:41
f3437b9 to
546b980
Compare
dpmills
force-pushed
the
dmills/explicit-register-reads
branch
from
August 5, 2026 19:46
546b980 to
bff5b87
Compare
dpmills
force-pushed
the
dmills/explicit-register-reads
branch
from
August 5, 2026 20:09
bff5b87 to
6b6ef72
Compare
dpmills
marked this pull request as ready for review
August 5, 2026 20:13
dpmills
force-pushed
the
dmills/explicit-register-reads
branch
from
August 5, 2026 21:06
6b6ef72 to
4ab4faa
Compare
dpmills
force-pushed
the
dmills/explicit-register-reads
branch
from
August 5, 2026 22:34
4ab4faa to
c4e4776
Compare
dpmills
force-pushed
the
dmills/explicit-register-reads
branch
from
August 6, 2026 22:41
c4e4776 to
f318fe3
Compare
dpmills
force-pushed
the
dmills/explicit-register-reads
branch
from
August 6, 2026 22:47
f318fe3 to
596782c
Compare
dpmills
force-pushed
the
dmills/explicit-register-reads
branch
from
August 7, 2026 00:00
596782c to
9c52ea6
Compare
This was referenced Aug 11, 2026
dpmills
force-pushed
the
dmills/explicit-register-reads
branch
2 times, most recently
from
August 12, 2026 00:38
d9ebb4e to
a50daa0
Compare
dpmills
force-pushed
the
dmills/explicit-register-reads
branch
from
August 12, 2026 20:08
a50daa0 to
f2e1228
Compare
dpmills
force-pushed
the
dmills/explicit-register-reads
branch
2 times, most recently
from
August 12, 2026 22:45
b21c2c6 to
e6ac140
Compare
This was referenced Aug 13, 2026
dpmills
force-pushed
the
dmills/explicit-register-reads
branch
from
August 13, 2026 05:39
e6ac140 to
b9a1966
Compare
dpmills
force-pushed
the
dmills/explicit-register-reads
branch
from
August 13, 2026 19:18
b9a1966 to
1b5485c
Compare
dpmills
force-pushed
the
dmills/explicit-register-reads
branch
from
August 13, 2026 23:42
1b5485c to
81e1714
Compare
sortalongo
approved these changes
Aug 14, 2026
dpmills
force-pushed
the
dmills/explicit-register-reads
branch
from
August 14, 2026 19:49
81e1714 to
938851e
Compare
`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`.
This was referenced Aug 17, 2026
Close a commit store's keys individually, so an await waits only for its own variable's writers
#102
Open
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
A mutable variable was related to its value by subtyping, in both directions, and neither is a lattice fact:
Mut(V) <: Vputs a mutable variable below its value,V <: Mut(V, D)puts it above, andMutis invariant inV. Both fire against a fresh inference variable —cnt + 1emitsMut(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_writesis deleted.The three positions that made the upward coercion look necessary
Each is a place the rule should have read through the handle:
a := a + 5; aderivedIntand recordedMut(Int, D). 394 of the arm's 395 firings acrosscompilation_pipelinewere the wall reconciling that;Casearm was not a value position, so a conditional over two mutable variables denoted aMutwhose writer cannot be traced — the case rule 2 exists to prevent;Mutparameter given something that is not one had no value edge, leaving an argument the discipline check rejects under-determined.mut_var_value_typeasserted the old behaviour and now pins the corrected one, two tests cover the fixed tail andCasepositions, and the arm's unit test becomes the statement that a value does not satisfy aMutdemand.