Skip to content

Mutable-variable typing: an honest seed, a fixed history read-through, and a polarity-directed base - #52

Merged
dpmills merged 4 commits into
dmills/traitsfrom
dmills/mut-typing
Aug 14, 2026
Merged

Mutable-variable typing: an honest seed, a fixed history read-through, and a polarity-directed base#52
dpmills merged 4 commits into
dmills/traitsfrom
dmills/mut-typing

Conversation

@dpmills

@dpmills dpmills commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

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 a Transact key'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 a Mut parameter never contributed to it at all. This records that contribution, deletes all three strips, and fixes both checks, so x := 5 with no other writes types as Mut(5) and reads as 5, while x := 0 written only through a Mut(Int) parameter joins to Int instead of being rejected against that parameter.

The missing contribution

Typing::apply records arg <: d against a fresh variable, so a Mut argument meets an Infer and takes the deliberate deref arm — right for a bare read like cnt + 1, wrong here: the handle is gone before the invariance rule can relate the two value types, and the parameter's V arrives only as an upper bound. emit_apply records 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 bare Infer for 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_unread counted 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 as histories_agree.
  • inline's refinement_discharged_by compared the parameter's demand against the argument's Mut stamp — which the mutability phase needs in order to find the read — so it asks what the argument denotes instead.

MutWrite also stopped relaxing its target: the constraint is skipped outright when the target is not a mutable variable, leaving that diagnosis to check_mut_write_targets, so Mut(Int) genuinely demands Int of its writes.

Mechanical, and one known gap

Type::peel_refinements / mut_value_type / as_feed / is_handle replace the hand-rolled peel-then-matches! at every site dispatching on a handle shape, retiring peel_refinements_outer and peel_mut with 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.

@dpmills

dpmills commented Jul 31, 2026

Copy link
Copy Markdown
Contributor Author

This change is part of the following stack:

Change managed by git-spice.

@dpmills dpmills changed the title A register's seed contributes its base as a type operator, not a syntactic peel Mutable-register typing: an honest seed, a fixed history read-through, and a polarity-directed base Jul 31, 2026
@dpmills
dpmills force-pushed the dmills/mut-typing branch from b7ccc82 to a1a8e3b Compare July 31, 2026 20:49
@dpmills
dpmills force-pushed the dmills/mut-typing branch 5 times, most recently from a1b1348 to b14e304 Compare August 1, 2026 01:10
@dpmills
dpmills marked this pull request as ready for review August 3, 2026 17:09
@dpmills
dpmills requested a review from a team as a code owner August 3, 2026 17:09
@dpmills
dpmills requested review from groundlar and removed request for a team August 3, 2026 17:09
@dpmills
dpmills force-pushed the dmills/mut-typing branch from b14e304 to f541e19 Compare August 3, 2026 21:47
@dpmills
dpmills force-pushed the dmills/mut-typing branch from f541e19 to bd20fb9 Compare August 4, 2026 22:00
@dpmills
dpmills force-pushed the dmills/mut-typing branch from bd20fb9 to d7fe4ff Compare August 5, 2026 18:41
@dpmills
dpmills force-pushed the dmills/mut-typing branch from d7fe4ff to f635c4d Compare August 5, 2026 19:46
@dpmills
dpmills force-pushed the dmills/mut-typing branch from f635c4d to 7e20261 Compare August 5, 2026 21:06
…, 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
dpmills force-pushed the dmills/mut-typing branch from fa14fe9 to 19390c6 Compare August 14, 2026 19:49
@dpmills dpmills changed the title Mutable-register typing: an honest seed, a fixed history read-through, and a polarity-directed base Mutable-variable typing: an honest seed, a fixed history read-through, and a polarity-directed base Aug 14, 2026
"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.
@dpmills
dpmills merged commit ce60fd0 into main Aug 14, 2026
1 check passed
@dpmills
dpmills deleted the dmills/mut-typing 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