Four defects in how Mut is typed: message polarity, an unclosable refinement, and op= as a rebind - #63
Merged
Conversation
Contributor
Author
This was referenced Aug 5, 2026
dpmills
force-pushed
the
dmills/mut-typing-fixes
branch
from
August 5, 2026 04:05
583bc10 to
9cb14ba
Compare
dpmills
force-pushed
the
dmills/mut-typing-fixes
branch
from
August 5, 2026 18:41
9cb14ba to
8b597ce
Compare
dpmills
force-pushed
the
dmills/mut-typing-fixes
branch
from
August 5, 2026 19:46
8b597ce to
4c774c6
Compare
dpmills
force-pushed
the
dmills/mut-typing-fixes
branch
from
August 5, 2026 20:09
4c774c6 to
e8473c9
Compare
dpmills
marked this pull request as ready for review
August 5, 2026 20:12
dpmills
force-pushed
the
dmills/mut-typing-fixes
branch
from
August 5, 2026 21:06
e8473c9 to
04bf41f
Compare
dpmills
force-pushed
the
dmills/mut-typing-fixes
branch
from
August 5, 2026 22:34
04bf41f to
9a05bde
Compare
dpmills
force-pushed
the
dmills/mut-typing-fixes
branch
from
August 6, 2026 22:41
9a05bde to
8361dbc
Compare
dpmills
force-pushed
the
dmills/mut-typing-fixes
branch
from
August 6, 2026 22:47
8361dbc to
3133b6a
Compare
dpmills
force-pushed
the
dmills/mut-typing-fixes
branch
from
August 7, 2026 00:00
3133b6a to
a38cf10
Compare
dpmills
force-pushed
the
dmills/mut-typing-fixes
branch
from
August 7, 2026 00:05
a38cf10 to
c656179
Compare
dpmills
force-pushed
the
dmills/mut-typing-fixes
branch
from
August 12, 2026 00:09
e3a61ea to
db6e37d
Compare
dpmills
force-pushed
the
dmills/mut-typing-fixes
branch
from
August 12, 2026 20:08
db6e37d to
1f0e7ae
Compare
dpmills
force-pushed
the
dmills/mut-typing-fixes
branch
from
August 12, 2026 20:32
1f0e7ae to
ecbdb6f
Compare
This was referenced Aug 13, 2026
dpmills
force-pushed
the
dmills/mut-typing-fixes
branch
from
August 13, 2026 05:39
ecbdb6f to
f4da15b
Compare
dpmills
force-pushed
the
dmills/mut-typing-fixes
branch
from
August 13, 2026 19:18
f4da15b to
f515dda
Compare
dpmills
force-pushed
the
dmills/mut-typing-fixes
branch
from
August 13, 2026 23:42
f515dda to
4ebbe9a
Compare
sortalongo
approved these changes
Aug 14, 2026
dpmills
force-pushed
the
dmills/mut-typing-fixes
branch
from
August 14, 2026 19:49
4ebbe9a to
15b00cf
Compare
…efinement, and `op=` as a rebind
Four defects in how `Mut` is typed, found by reviewing the base PR and probing what it left. Each is independently reviewable; none changes the lattice — that is the follow-up, which this PR pins the ground for.
`x: Mut(Int) := "s"` reported *expected String, found Int* — the sides printed the wrong way round. `constrain_subtype(lhs, rhs)` means `lhs <: rhs`, so the left side is the value that flowed in and the right is the demand it failed; `map_constrain_err` stored the value in `type_a` and the formatter printed `expected {type_a}`.
The neutral field names are why this survived: nothing about `type_a` / `type_b` says which is which, so the mapping and the formatter could disagree and still read as deliberate. They are `found` and `expected` now.
`expected` is an `Option<Type>`, because two of the four mismatches do not relate two types at all. A missing record field and an unaccepted variant tag are faults in a *single* type — the `ctx` already says what is wrong with it — and they had been borrowing the second slot with a `Type::Hole`, rendering as a bare `_` on whichever side the formatter put it.
Nothing tested it. Narrowing is the unsound direction and the whole reason `Type::History` is invariant in its value: if `Mut({a: Int, b: Int})` could flow into a `Mut({a: Int})` parameter, the callee's `r := (a=5)` would drop a field the caller's declaration still promises, and a later `x.b` would type-check against a value that no longer has it.
Worth pinning now because **the invariance rule is not what enforces this today.** At an argument position the deref arm fires first — `Typing::apply` records `arg <: ?d` against a fresh variable, so a register meets an `Infer` and reads through — so the `(History, History)` arm never runs there. Measured: on one pass-by-reference call every argument edge is `Mut(Int, D) <: ?N`, and the 438 invariance firings across the suite are 338 × `Int vs Int` plus 85 × `?N vs ?N`, i.e. already-equal pairs. The property is assembled instead from the application edge (`caller <: callee`) and `contribute_pbr_writes` (`callee <: caller`) — equal in strength to the rule, spread across two mechanisms.
Recorded on `Type::History`, with why the obvious narrowing of the rule is a dead end: "invariant only where the value type is declared" cannot be expressed, because declaredness is *provenance*, not a property of a type, and a variance rule sees two types without being able to ask where either came from.
`x := 2; ys = [i for i in [1, 2, 3] if i < x]; ys` did not compile — it tripped `check_scope_valid`, whose own doc says it never fires on a well-typed program. That check is `#[cfg(debug_assertions)]`, so a **release** build had no check at all and carried the ill-scoped type to the pre-desugar wall, where it panicked on a surviving mutable type.
A comprehension filter's predicate rides the domain type as a refinement, so filtering on a register produces a type that mentions it. A `let` binder can be discharged into the type it is lifted out of, because the binder *is* its bound expression; a register has no such term **at the point closure is demanded** — and that is the whole obstacle. Closure is required during coalesce, while `mut_elim`, several passes later, is what compiles a write-free register into a `let` and a written one into trailing `let x_final = final_or_default(…)` bindings. The naming exists; it arrives too late to discharge with.
So this is a **staging** limitation, and the comment records it as scoped work rather than a dead end: let a predicate reference a register through inference (registers are enumerable — `MutDecl` binders and pass-by-reference params), stage the scope invariant across `mut_elim`, and have that pass rewrite reads inside predicate terms as it already does in the term tree. One sub-case stays genuinely hard — a comprehension *inside* the loop that writes the register needs the refinement to depend on the sequencing position, and a predicate rides a type, which carries no position. And it is worth asking first whether a filter belongs in a type at all: it is there as a *planning channel*, not as a proof obligation the way an index-in-range refinement is.
It is now reported where the closing would have happened, as `InferError::MutableInRefinedType` — release-visible, with the introduction's source span and the offending predicate. The message says it is a limitation rather than a mistake, and deliberately **offers no workaround**, because the obvious one does not work: reading the register into an immutable and refining on that discharges `[k ↦ x]`, which puts the register's name straight back into the predicate.
**The lifted type now follows every spine link.** Getting the `k = x` case to report correctly exposed a second gap: the `Let` arm composes to fixpoint by reading its *body's* already-coalesced type, but `ExprStmt` did not propagate at all, so a discharge below one never reached the binder above it. A register written in a loop puts the `for` between the two, and the `MutDecl` above saw an undischarged `k` instead of the `x` it could report on.
Both loop-lowering paths fell back to a per-iteration shadowing `let` when a `x op= e` target was not a pre-loop accumulator. Wrong twice over: the update is discarded at the iteration boundary, and because `op=` reads the old value, each iteration reads the binding's **initial** value. `y = 0; y += i` in a loop body did not accumulate `y`; it recomputed `0 + i` every time.
`op=` now has one outcome per path — the `MutWrite` the phase threads, or a rejection. In the generator path that leaves no fallback at all, since nothing in that body is mutable by construction. This is the `op=` half of the hole whose `:=` half the base PR closed.
`./ci.sh` green. New tests cover both mismatch directions and the absence of an invented demand; narrowing, widening, and equal-width through a `Mut` parameter; the refinement rejection directly, through a copy, and after writes; and `op=` rejection in both loop paths.
Every program this branch adds with `\n` becomes an `indoc!` block, per `CLAUDE.md`.
dpmills
force-pushed
the
dmills/mut-typing-fixes
branch
from
August 14, 2026 21:34
15b00cf to
f648152
Compare
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.
Four defects in how
Mutis typed, found by probing what the base PR left. None changes the lattice; each is independently reviewable.A mismatch named the demand as
found—x: Mut(Int) := "s"reported expected String, found Int.constrain_subtype(lhs, rhs)meanslhs <: rhs, so the fields arefoundandexpectednow, withexpectedanOption: a missing field and an unaccepted tag are faults in a single type, and had been borrowing the second slot with aType::Holethat rendered as a bare_.A refinement depending on a mutable variable is a rejection, not a compiler bug.
x := 2; ys = [i for i in [1, 2, 3] if i < x]tripped the debug-onlycheck_scope_valid, so release builds panicked at the pre-desugar wall instead. It isInferError::MutableInRefinedTypenow, whose docs record why this is staging — the term that would discharge the binder is minted bymut_elim, passes after closure is demanded. Reporting it neededExprStmtto propagate the lifted type, which it did not.op=inside a loop body is a mutable write. Both loop-lowering paths fell back to a per-iteration shadowingletfor a target that was not a pre-loop accumulator, and sinceop=reads the old value,y = 0; y += irecomputed0 + irather than accumulating. Each path now threads theMutWriteor rejects — theop=half of the hole whose:=half the base PR closed.A mutable variable's value type is invariant across a
Mutparameter — now pinned. Narrowing is the unsound direction and nothing tested it. Both directions are rejected today but not by the invariance rule, since the deref arm fires first at an argument position, soType::History's docs name the two edges that assemble the property instead.