Skip to content

Four defects in how Mut is typed: message polarity, an unclosable refinement, and op= as a rebind - #63

Merged
dpmills merged 2 commits into
dmills/annotation-fixupsfrom
dmills/mut-typing-fixes
Aug 14, 2026
Merged

Four defects in how Mut is typed: message polarity, an unclosable refinement, and op= as a rebind#63
dpmills merged 2 commits into
dmills/annotation-fixupsfrom
dmills/mut-typing-fixes

Conversation

@dpmills

@dpmills dpmills commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Four defects in how Mut is typed, found by probing what the base PR left. None changes the lattice; each is independently reviewable.

  • A mismatch named the demand as foundx: Mut(Int) := "s" reported expected String, found Int. constrain_subtype(lhs, rhs) means lhs <: rhs, so the fields are found and expected now, with expected an Option: a missing field and an unaccepted tag are faults in a single type, and had been borrowing the second slot with a Type::Hole that 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-only check_scope_valid, so release builds panicked at the pre-desugar wall instead. It is InferError::MutableInRefinedType now, whose docs record why this is staging — the term that would discharge the binder is minted by mut_elim, passes after closure is demanded. Reporting it needed ExprStmt to 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 shadowing let for a target that was not a pre-loop accumulator, and since op= reads the old value, y = 0; y += i recomputed 0 + i rather than accumulating. Each path now threads the MutWrite or rejects — the op= half of the hole whose := half the base PR closed.

  • A mutable variable's value type is invariant across a Mut parameter — 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, so Type::History's docs name the two edges that assemble the property instead.

@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/mut-typing-fixes branch from 583bc10 to 9cb14ba Compare August 5, 2026 04:05
@dpmills
dpmills force-pushed the dmills/mut-typing-fixes branch from 9cb14ba to 8b597ce Compare August 5, 2026 18:41
@dpmills
dpmills force-pushed the dmills/mut-typing-fixes branch from 8b597ce to 4c774c6 Compare August 5, 2026 19:46
@dpmills
dpmills force-pushed the dmills/mut-typing-fixes branch from 4c774c6 to e8473c9 Compare August 5, 2026 20:09
@dpmills
dpmills marked this pull request as ready for review August 5, 2026 20:12
@dpmills
dpmills requested a review from a team as a code owner August 5, 2026 20:12
@dpmills
dpmills requested review from groundlar and removed request for a team August 5, 2026 20:12
@dpmills
dpmills force-pushed the dmills/mut-typing-fixes branch from e8473c9 to 04bf41f Compare August 5, 2026 21:06
@dpmills
dpmills force-pushed the dmills/mut-typing-fixes branch from 04bf41f to 9a05bde Compare August 5, 2026 22:34
@dpmills
dpmills force-pushed the dmills/mut-typing-fixes branch from 9a05bde to 8361dbc Compare August 6, 2026 22:41
@dpmills
dpmills force-pushed the dmills/mut-typing-fixes branch from 8361dbc to 3133b6a Compare August 6, 2026 22:47
@dpmills
dpmills force-pushed the dmills/mut-typing-fixes branch from 3133b6a to a38cf10 Compare August 7, 2026 00:00
@dpmills
dpmills force-pushed the dmills/mut-typing-fixes branch from a38cf10 to c656179 Compare August 7, 2026 00:05
@dpmills
dpmills force-pushed the dmills/mut-typing-fixes branch from 4ebbe9a to 15b00cf Compare August 14, 2026 19:49
…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
dpmills force-pushed the dmills/mut-typing-fixes branch from 15b00cf to f648152 Compare August 14, 2026 21:34
@dpmills
dpmills merged commit 2074ed7 into main Aug 14, 2026
1 check passed
@dpmills
dpmills deleted the dmills/mut-typing-fixes branch August 14, 2026 21:41
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