Mutable-variable typing: an honest seed, a fixed history read-through, and a polarity-directed base - #52
Merged
Merged
Conversation
Contributor
Author
This was referenced Jul 31, 2026
dpmills
force-pushed
the
dmills/mut-typing
branch
from
July 31, 2026 20:49
b7ccc82 to
a1a8e3b
Compare
dpmills
force-pushed
the
dmills/mut-typing
branch
5 times, most recently
from
August 1, 2026 01:10
a1b1348 to
b14e304
Compare
dpmills
marked this pull request as ready for review
August 3, 2026 17:09
dpmills
force-pushed
the
dmills/mut-typing
branch
from
August 3, 2026 21:47
b14e304 to
f541e19
Compare
dpmills
force-pushed
the
dmills/mut-typing
branch
from
August 4, 2026 22:00
f541e19 to
bd20fb9
Compare
This was referenced Aug 4, 2026
dpmills
force-pushed
the
dmills/mut-typing
branch
from
August 5, 2026 18:41
bd20fb9 to
d7fe4ff
Compare
dpmills
force-pushed
the
dmills/mut-typing
branch
from
August 5, 2026 19:46
d7fe4ff to
f635c4d
Compare
dpmills
force-pushed
the
dmills/mut-typing
branch
from
August 5, 2026 21:06
f635c4d to
7e20261
Compare
dpmills
force-pushed
the
dmills/mut-typing
branch
from
August 12, 2026 00:09
1dc1982 to
31f884a
Compare
dpmills
force-pushed
the
dmills/mut-typing
branch
from
August 12, 2026 20:08
31f884a to
3fa1470
Compare
dpmills
force-pushed
the
dmills/mut-typing
branch
from
August 12, 2026 20:32
3fa1470 to
2e3dfbe
Compare
This was referenced Aug 13, 2026
dpmills
force-pushed
the
dmills/mut-typing
branch
2 times, most recently
from
August 13, 2026 19:18
18e79f2 to
452b77c
Compare
dpmills
force-pushed
the
dmills/mut-typing
branch
from
August 13, 2026 23:42
452b77c to
fa14fe9
Compare
sortalongo
approved these changes
Aug 14, 2026
…, and a polarity-directed base
Three fixes to the mutable-register typing, all found by asking why a register may not keep a refinement its contributions agree on.
The three register-contribution sites — `MutWrite`, a mutable binding's initializer, and a `Transact` key's seed — stripped refinements with `strip_refinements` at emit. That is the same category error arithmetic had (see the parent PR) and it failed the same way: the peel is *syntactic*, so it returns a `Type::Infer` untouched and covered a **literal** seed and nothing else.
Every existing test seeds from a bare literal, which hid it. A computed seed is not covered:
```
r = (a=0, b=9)
x := r.a
x
```
A projection *selects*, so `r.a` carries the field's refinement, and with no write to intersect it away the register's value type claimed `{Int | __elem == 0}` — a mutable register typed as a constant. In debug builds that tripped `assert_reads_stable`; in release it typed silently. Pre-existing: `main` panics on this program too.
The fix is **not** a replacement for the join. The join is already the lattice's: every contribution is a *lower* bound of the register's value variable, and a positive-position read intersects refinement sets — exactly "a refinement survives only if every contribution establishes it". That is why the strip looked harmless: with two or more contributions the intersection does the whole job.
What the strip was really covering is the case the intersection cannot handle: a join with **one** member. A register seeded and never written has only its seed, so the intersection is the seed's own refinement.
So each contribution now flows in as `CommonBase(ty)` (`contributed_base`), which guarantees the intersection always has an unrefined member. Reduction runs on the *resolved* type, so a computed seed is stripped where a syntactic peel could not see one. And it **contributes** a base rather than **demanding** a stripped form, which keeps it variance-stable: relating a refined value to a stripped sibling is what manufactures the illegal `D <: {D | p}` obligation the design doc warns about for collection-typed registers, whose extent rides a contravariant `Fun` domain.
`types_agree_modulo_unread` peeled refinement layers *before* reading through a history handle, so it compared a refined value's layer count against the handle's own — one versus zero — and rejected a pair that is in fact the same type. That is why the program above panicked with "`x` was read as `Mut(0, ?46)` … but the final graph resolves it to `History { value: Refinement(Base(Int), …==0) }`": two spellings of one type.
A handle is transparent, so the read-through now runs first, in `histories_agree`. The kind-mismatch rule and the per-kind read views move with it unchanged; splitting them out is what makes the ordering explicit rather than incidental.
This matters beyond the one program: it was the check that made a refined register value *look* unsound, which is the evidence that would have argued against typing such a register precisely at all.
`MutWrite` stripped refinements from the *target* as well as the value. Its reason was diagnostic ordering — when the target is not a register at all (`x = 0; x += 1`) a type error here pre-empts the mutability-discipline error with a worse message — but relaxing a demand to buy ordering weakens a check that should hold: a register annotated `Mut({Int | p})` genuinely does demand `{Int | p}` of its writes.
The constraint is now **skipped** when the target is not an `Overwrite` register, and `check_mut_write_targets` owns the diagnosis. Same message, one fewer syntactic peel, and the demand is the register's real value type. `strip_refinements` now has no callers in `emit` at all.
Stripping at every depth is wrong for a collection. A refinement at a **covariant** position is a claim about the value produced, and that is what a computed or joined type must not inherit. At a **contravariant** position it is not a claim at all — on a collection domain it is the *extent*, which the interpreter compiles to a `Restrict` at the iteration boundary — so dropping it changes which collection the type denotes rather than relaxing an assertion.
Polarity is already the distinction the lattice draws, so the rule needs no new concept: strip covariantly, preserve contravariantly (`strip_value_claims`). A register seeded `c := [v for v in xs if v >= 2]` now keeps its extent; before, its binding slot said `({[0, 2] | v >= 2} ⇒ Int)` while the register's value type said `Mut(([0, 2] ⇒ Int), _)` — the value type had forgotten it was filtered.
`resolve_argument` installed its in-flight guard only when the argument was a bare `Type::Infer`, so a **structured** argument — a collection's `Fun`, a tuple, a refined type — was resolved with no guard at all and a cycle through its interior recursed until the stack ran out. It now guards on every variable the argument mentions: any cycle must revisit some variable, and it appears syntactically in the argument of whichever operator it is reached through, which is where it gets caught.
This is a mechanism-level bug rather than a mutability one, and it was wrong the moment an operator took a structured argument — which `CommonBase` on a register's collection-valued seed already did.
The missing contribution, and with it the reason a register could not keep a refinement.
A write reaching a register **through a `Mut` parameter** never joined into it, because `Typing::apply` records `arg <: d` against a *fresh variable*: a `Mut` argument therefore meets an `Infer`, which takes the deliberate deref arm — correct for a bare read, since `cnt + 1` must read through the handle, but it drops the handle here. The invariance rule that would relate the two value types never runs, and the parameter's `V` arrives only as an *upper* bound. So `def fw(c: Mut(Int)): c += 1` called as `fw(x)` on `x := 0` typed `x` as `Mut({Int | __elem == 0})` and was then rejected against `Mut(Int)`, since `Mut` is invariant.
`emit_apply` now records it (`contribute_pbr_writes`): passing a register to a `Mut(V)` parameter contributes `V`, because that is what the call means. The two types are walked in parallel so the uncurried multi-argument shape works, contributing only at the register positions. Reading the parameter's `Mut` syntactically is sound at this one site — the mutability discipline *requires* a pass-by-reference parameter to be annotated.
**With the join complete, none of the three sites needs to weaken its contribution.** `contributed_base` is gone; each flows in verbatim, and the register law is entirely the lattice's join. The precision that unlocks: `x := 1` with no other writes types as `Mut(1)` and reads as `1`, which is sound and consistent with a language where every literal carries a refinement — the register really does hold that value at every position. `x := 0` with real writes still joins to `Int`, whether the writes are lexical or reach it through a parameter. Checked against the hazard the `Transact` seed's comment named (`flag := False` written `True`), which joins to `Bool` and reads `true` with and without a `Mut(_, Txn)` annotation; a transactional register's writes are ordinary `MutWrite`s against the same variable, so that path's join was complete all along.
**A mutable collection register with a filtered seed types correctly and then does not terminate** in join planning's `insert_iterate_markers`. Everything before completes — inference, the post-letrec `typecheck`, group-by recognition, `simplify` — and it is the same unfinished area that makes a *write* to a collection register panic in `split_decision_compose`. Mutable collections do not work either way; what changed is that the seed-only case no longer limps through by silently dropping the extent. Two termination tripwires in planning were tried and neither fired, so it is few recursion entries each doing unbounded work, not a runaway node count. Recorded on the rule and in the design doc as a planning bug to fix with mutable-collection support — deliberately not encoded as a restriction on the reduction, since a downstream limitation does not belong in the type system.
`strip_refinements` has **no callers left in `emit`** — every remaining use in the tree is either a comparison of two already-resolved types or a post-inference pass, where the peel does exactly what it says. Its doc now records the invariant that makes it safe: it is syntactic, it returns a `Type::Infer` untouched, and so it cannot express a relation between types while those types are still variables. A `Type::App` over them is the pointer for that case — its rule runs on the *resolved* arguments.
A refinement never changes a type's **shape**: it is a claim about the value at a position, not part of the structure carrying it, so `{(D ⇒ V) | p}` is a function and `{Mut(V, D) | p}` is a register. Every rule that dispatches on or destructures a shape therefore has to look *through* the outer layers — and thirteen sites across `emit`, `check` and `solve` were each doing it by hand: a `peel_refinements_outer` call followed by a `matches!` or a `match` on `Type::History`, each with its own comment guessing at why the peel was needed.
Nine of the thirteen ask one question — *is this a handle, and what does a read of it yield?* — which the second-class `Mut` discipline had already named for itself as `peel_mut`. Two answers to one question, and the typing rules used neither.
The accessors now live on `Type`, where both callers reach them: `peel_refinements` (the borrowing look-through, carrying the shape-versus-claim rule that justifies every site, and pointing at `strip_refinements` as the all-depths *dropping* counterpart), `as_register`, `as_feed`, and `is_handle` — the kind-agnostic question, which is a *binding*'s, since naming a handle aliases the state behind it whichever kind it is. `peel_mut` retires into `as_register`: all six of its callers used it as a predicate and discarded the children it returned.
No behavior change. Two comments that made claims about what the peel sees are corrected — `emit_compose` asserted that in Emit "the morphism types are bare inference vars, so this peels to `None`" when they are always arrows in both modes, and the coalesce walk's `Compose` arm justified its peel by a refinement a morphism "acquired during solving" rather than by the rule that a refined function is still a function.
Two end-to-end cases for the computed seed (with writes, and with none — the single-contribution case where the law must come from the base contribution rather than the intersection).
The handle accessors' contract gets a unit test, because a handle type is built structurally rather than resolved from a variable, so no position accumulates a claim onto one and no program can exercise the refined-handle case: a refined register is still a register, a refined channel still a channel, and neither kind answers for the other.
Full `./ci.sh` passes.
Making the seed honest is what puts a refinement on an unwritten register's value type, and that reached `inline` as a panic: `def id(v): v` called with `x := 5; id(x)` asserted that parameter type `5` "does not entail" argument type `Mut(5, ?d)`.
Both types are right; they are recorded at different levels. The read derefs the *constraint*, so the parameter acquires the dereferenced value type — the seed's singleton, since nothing widens it. The argument node keeps its `Mut` stamp, and has to: that stamp is how the mutability phase finds the read. `refinement_discharged_by` peeled `Type::Refinement` off the stamp only, found nothing under the handle, and reported an undischarged precondition.
So the check asks the argument what it *denotes*. A register mention at a value operand is a read, and what it denotes is the value it holds, whose refinements are exactly the ones the parameter demands. The guard is unweakened, and a test says so — a register whose value carries a *different* refinement still trips the assert.
Both spellings that leave the parameter type to inference panicked, `def id(v)` and `def id(v: Int)`, for any register whose value type still carries its seed's singleton. Nothing in the suite passed a register to a UDF at a value parameter, which is why an outright panic on a three-line program was green.
The `solve` test module gained a `Lit` import from main, so the paths these tests spell in full are redundant and `unused_qualifications` rejects them.
The five programs this branch adds with `\n` become `indoc!` blocks, the convention `CLAUDE.md` records: an escaped newline hides the indentation an off-side-rule language depends on, which matters most for the loop bodies here. `mutability.rs` had no `indoc` import because its existing programs use column-0 raw strings. Those read fine and are left alone; the import is added rather than the new cases matching a style the guidance supersedes.
dpmills
force-pushed
the
dmills/mut-typing
branch
from
August 14, 2026 19:49
fa14fe9 to
19390c6
Compare
"Register" was this codebase's word for a mutable variable, and it earned
nothing: the concept has a name the language already uses, `Mut` is already the
type, and the borrowed hardware word only made the prose ambiguous. Two places
show the cost directly. `register_value_tys` parses as a verb phrase ("register
the value types") when it is a noun phrase; and `Type::as_register` returned the
*value* type of a mutable variable rather than the variable, so the name
described neither its subject nor its result.
The word is kept where it means **registration** — `register_source`,
`register_watch`, `SharedHttpServer::register`, `pre_register_txn_decls`, every
`registry`. That sense is untouched, and is why this is a rewrite rather than a
substitution: `-` is a word boundary, so a blanket rule turns `pre-register`
into prose about mutable variables.
Identifiers take a `mut_var` stem, matching `Mut` / `MutDecl` / `MutWrite` /
`mut_elim`: `txn_registers` becomes `txn_mut_vars`, `register_value_tys` becomes
`mut_var_value_tys`, `is_txn_register` becomes `is_txn_mut_var`.
Three names are chosen rather than translated:
- `Type::as_register` becomes `Type::mut_value_type`, named for what it returns.
It answers "is this a mutable variable, and what does a read of it yield" in
one call, and only the second half was ever in the name.
- `emit::deref_mut` becomes `emit::read_through`. The old name collided with
`DerefMut::deref_mut`, which this crate implements four times, and pointed the
opposite way: std's hands out a mutable borrow, this reads a value *out* of a
mutable variable. It is also total — identity on anything that is not one — so
it is named for the act, not for `Mut`. "Read through" is what the surrounding
prose already called it.
- `mut_elim`'s local `as_register_value` was `Type::as_register` rewritten, so
it is deleted rather than renamed.
In the interpreter, `register` was half of a **carry policy** contrast rather
than the surface concept: a `carry_forward: true` stream holds its latest write,
a reply tap is a per-tick event. That pair becomes carry-vs-tap, so no sense of
the word is left doing two jobs.
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 could not keep a refinement that every one of its contributions establishes. The three contribution sites —
MutWrite, a mutable binding's initializer, and aTransactkey's seed — stripped refinements at emit, and two later checks read a refined variable as drift. The strip was compensating for an incomplete join: a write reaching a mutable variable through aMutparameter never contributed to it at all. This records that contribution, deletes all three strips, and fixes both checks, sox := 5with no other writes types asMut(5)and reads as5, whilex := 0written only through aMut(Int)parameter joins toIntinstead of being rejected against that parameter.The missing contribution
Typing::applyrecordsarg <: dagainst a fresh variable, so aMutargument meets anInferand takes the deliberate deref arm — right for a bare read likecnt + 1, wrong here: the handle is gone before the invariance rule can relate the two value types, and the parameter'sVarrives only as an upper bound.emit_applyrecords it directly instead (contribute_pbr_writes), reading the parameter off the head of the application spine (parameter_type), since a curried call leaves the immediately-applied type a bareInferfor every argument past the first. Why the lattice's join is the whole rule:src/ccl/design/type-inference.md.Two checks that made a refined mutable variable look unsound
types_agree_modulo_unreadcounted refinement layers before reading through a handle, comparing a refined value's one layer against the handle's zero. The read-through runs first now, split out ashistories_agree.inline'srefinement_discharged_bycompared the parameter's demand against the argument'sMutstamp — which the mutability phase needs in order to find the read — so it asks what the argument denotes instead.MutWritealso stopped relaxing its target: the constraint is skipped outright when the target is not a mutable variable, leaving that diagnosis tocheck_mut_write_targets, soMut(Int)genuinely demandsIntof its writes.Mechanical, and one known gap
Type::peel_refinements/mut_value_type/as_feed/is_handlereplace the hand-rolled peel-then-matches!at every site dispatching on a handle shape, retiringpeel_refinements_outerandpeel_mutwith no behavior change.An unstripped seed also keeps a filtered comprehension's extent, which then hangs in join planning's
insert_iterate_markers— the same unfinished area where a write to a collection-valued mutable variable panics. Mutable collections are unsupported either way; recorded as a planning bug, not a restriction in the type system.New end-to-end cases cover the computed seed with writes and without, and the pass-by-reference contribution at a non-first argument and through a forwarding chain; the accessors and the read-through are unit-tested, since no program can refine a handle.