Modes get a solver: joins are deferred, and a boundary solves only what it owns
The type checker is graded CBPV. The CBPV half — equi-recursive unification,
rows by Rémy rewrite, cycle-snapshotting schemes — has been quiet since June.
The grading half has not: six fixes in ten weeks (852be2ac, 14654474,
c3f0bb0b, 27e84d3f, 9cd86640, 9c5c8d3c), every one of them in the same
fragment, the mode joins over branch arms, sequences, and scopes.
What was wrong
The cause was structural, not a run of careless patches.
The mode lattice ∅ ⊑ Bytes semantically requires a join. The solver only
had equality (Unifier::unify_mode). So every join site improvised the
join in its own way, by resolving the unifier’s current state and casing on
what happened to be ground: join_byte_mode, union_mode,
join_arm_results’s open-arm protocol, merge_branches’ all-or-nothing fold,
lift_modes’ two bool flags. Each improvisation is an eager decision taken
against a partially-solved store, and is therefore order-sensitive by
construction — the answer depends on when the checker happened to visit the
constraint relative to when some arm’s mode got grounded.
The characteristic victim is an arm whose mode is its own binding group’s,
still under inference: a recursive call in a branch. Read too early it looks
silent, and the decision taken against that reading is never revisited.
852be2ac (“still-open arms stay open and ground at the binding group’s fixed
point”) is lazy constraint solving, hand-implemented for one mode, in one
construct family.
The wiki’s principality claim — “no typing decision depends on the order the solver visits constraints” — was proven only for result-mode variables, via the slot restriction. The input and output joins had no such property and no one had claimed otherwise.
What we decided
One constraint store, in core/src/typecheck/mode_solver.rs, owning the
only logic in the checker that computes a join by casing on a mode’s
groundness. Four reads stay outside as shape verdicts, not joins —
infer_pipeline’s byte-tail decision, Bind’s result pin,
consumes_value_arg, and lift_channels’ tail-shape verdict (below) — each
inspecting settled state to apply its own introduction rule.
Three operations, named apart. Equality stays equality — pipeline
adjacency is genuine unification, ground ∅ never meets ground Bytes
(T0012), and unify_mode is untouched. Beside it now stand two others:
Join(⊔, bytes-dominant) — a form’s channel end is the least upper bound of its parts’ ends: aSeq’s channels over its statements, a scope’s over its arms, a branch’s over its arms.∅is the identity,Bytesis absorbing, and the join constrains the target only — it never writes back into an end.Alt— arms of which only one runs. Ground and equal ends agree; ground and disagreeing ends leave the target free, because a clash between arms that never both run is an unknown a downstream stage can pin, not a contradiction.ArmResults— the arms of anif/?/case/tryagreeing on which conduit carries the payload, under the one subsumption instance∅@Unit ⊑ Bytes@Unit. Unlike the channel join, this one does discipline the arms: it pins open results and ties byte-side values toUnit.
Narrowed by the payload-route change: Join and Alt are gone; only
ArmResults remains.
[[decisions/260809_pipes-are-positional-byte-wires|Pipes are positional byte
wires]] deletes input/output outright, leaving one payload route per
computation type. Join and Alt existed to merge channel ends — the
input/output a Seq’s statements, a scope’s arms, or a branch’s arms
carried independently of their value — and that is exactly the notion the
route collapse removes: stdout is no longer typed per node at all, so there
are no channel ends left for a form’s parts to join. route_solver.rs
(renamed from mode_solver.rs) carries no Join or Alt today; the
constraint store is route_constraints: Vec<ArmResults> — a plain struct, not
an enum of three — and ArmResults is unchanged in kind: it is exactly this
page’s payload-boundary join, over the one thing still worth deferring, which
side of the route an if/case/try’s arms collapse to. So the verdict is
narrowed, not reversed: the architecture this page decided — emit an
already-determined conclusion immediately, defer the rest as a constraint,
drain only what a boundary is about to quantify at solve_at_boundary,
collapse everything at solve_and_finalize, ground-directed collapse before
equation, principality up to variables shared across one binding — is exactly
what join_arm_results still does, unchanged in shape, for the one join that
survived. Two of the three constraint kinds this page named are gone
because their subject matter is gone, not because the solver built for them
was wrong.
Conclude, store, solve-what-you-own. Emission applies whatever conclusion
is already determined and stores the rest. Applying early is sound because a
mode only ever moves Var → ground, never back, so an early conclusion cannot
be invalidated. From there the store drains at two kinds of point.
solve_at_boundary(env) runs at every in-inference point that produces a
scheme — the Bind let-generalisation, infer_letrec’s group fixpoint,
handler_comp_scheme — and solves exactly the constraints touching a mode
variable not free in env: the variables generalize is about to quantify,
and quantification is what a constraint must not outlive. A constraint whose
every writable variable is still free in the environment belongs to an
enclosing binding and is left entirely untouched — not collapsed, and not
retried either. The bookkeeping is the generalisation criterion itself,
computed by env_free_vars only when the store is non-empty. It has to gate
retry as well as collapse: a conclusion’s target is order-invariant, but the
byte side’s side effects pin arms still under inference elsewhere, so a
conclusion run at a boundary a syntactic accident placed — any inner let,
the elaborator’s hoisted binds included — can foreclose a sibling’s ∅@Unit
subsumption and reject a program the owning boundary accepts, or move the
join’s error onto the group unification. solve_and_finalize is the terminal
drain — end of check before annotate, and the empty-environment scheme
builders alias_arm_scheme and binding_value_scheme — where nothing
encloses the store and everything collapses. Propagation to quiescence is a
worklist over a height-1 lattice, trivially terminating.
Collapse is directed by the target, then equates; it never defaults. A
residual constraint whose target a neighbour grounded from outside is settled
by that target, not equated through it. A Join/Alt whose target reads
Bytes is satisfied and drops — a form’s end may exceed its parts’ use, and
the join never writes back into an end; a ∅ target pins the open ends ∅,
the one forced direction; an ArmResults whose result grounded Bytes or ∅
lands on that side with the side’s full protocol — byte-side value-to-Unit
tying included, which a bare equation would skip. These ground-writing
collapses (every ArmResults among them, since its value unifications ground
types whichever side it lands) run one constraint at a time with the worklist
re-run between, so a write that determines a sibling reaches the sibling’s own
rule instead of being raced by an equation. Only then does the all-open
residue equate — open ends with each other and with the target, pure
union-find merges no order can observe. A collapsed variable can still ground
Bytes later, and the target rides it. Defaulting stays exactly where it
already lived, in InferCtx::ground, at annotation time.
This keeps the invariant that keeps schemes simple, stated over ownership:
no constraint outlives the generalisation of its variables. Schemes
quantify plain mode variables exactly as before, nothing new serialises, and
session-scheme-continuity is
untouched. The ownership criterion counts writable positions only —
Join/Alt targets and ends, ArmResults results and outputs, never an
arm’s input, which no rule writes — and modes only: a kept constraint whose
value types mention a locally quantified type variable would let an inner
scheme’s instantiations escape the value agreement, a corner reachable only
through contrived nesting and bounded by the owning boundary’s collapse.
The sequence tail is a shape verdict, not a join. A Seq’s statements
run for their effect; the tail gives the sequence its value and may be a
function. lift_channels keeps a Fun tail untouched, joins a Return
tail’s ends with the statements’, and forces a still-unknown tail into stage
shape only when some statement’s end has settled Bytes — a demand needs a
spec to live on — leaving it otherwise free to resolve Fun at its call site
({ |f| return (); !$f } applied to a lambda). This is a state-inspection
beside consumes_value_arg, with the laxity that entails, stated: a
statement whose byte demand settles only after the sequence closes does not
reach a still-free tail, and an opaque statement (force $t) contributes
fresh unattached ends rather than t’s own, since attaching them would force
t into stage shape and reject a lambda argument. HM offers no disjunction
over a tail’s eventual shape; the alternatives — always forcing, which
rejects the higher-order idioms, or never forcing, which drops a settled
stdin demand on the floor, a golden-rule hole — are both worse.
What it buys
Each of these is a bug the old shape had, or would have had again:
- An open arm that grounds
Bytesbefore the boundary re-evaluates the whole join on the byte side with the subsumption check intact. Previously the no-bytes path had already unified the values and the check never re-fired. - Two open sibling arms that ground differently before the boundary get the
join’s own error under the join’s own provenance. Previously
union_modehad already equated them, so the clash surfaced at whatever unrelated site touched the shared variable next — or silently mis-moded a silent sibling as byte-emitting. - A
Seqholdingforce $twith the statement’s ends still open generalises with its channel open and quantified — a variable a later grounding can still move. The variable is the sequence’s own, nott’s: an opaque statement contributes no attachment tot’s channels, because tying them down would forcetinto stage shape and reject a lambda argument. Previouslylift_modes’ eagerboolreadfalseand stamped theSeqsilent for good. - The byte side ties every arm’s value to
Unit, pinned-open arms included. Previously the byte path pinned aVararm’s result and never touched its value — a WF-2 gap the declarative reading closes for free.
WF-1 and WF-2 move into the solver, asserted and enforced per arm where an
ArmResults lands on the byte side, rather than debug-asserted at each
constructing site. infer_pipeline keeps its own two assertions — it decides a
byte tail by reading settled modes, not by joining — and builtins.rs keeps
its, which check hand-written signature tables at construction and have
nothing to do with joins.
Principality, scoped and true. For mode constraints, typing verdicts do
not depend on the order the solver emits, retries, or collapses constraints:
conclusions are monotone on a height-1 lattice, ground-directed collapses are
serialised through the worklist, the all-open residue is pure equation, and
ownership fixes which boundary equates. Two limits are part of the claim.
Still-open ends joined under one binding equate — monomorphise — at the
boundary that owns their variables: the deliberate incompleteness relative to
qualified schemes, priced below, tied to variable ownership rather than to
whichever inner let the elaborator happened to hoist. And two still-open
ArmResults sharing value type variables at one boundary could in
principle observe each other’s value unifications in collapse order — a
corner no source program has been made to reach, left open because closing it
needs payload-deferral machinery out of proportion to it. The shape verdicts
listed above sit outside the claim entirely; they are introduction-rule
choices HM cannot defer, consumes_value_arg and the tail verdict foremost.
The slot restriction is restated rather than abandoned. A deferred
ArmResults does mint a fresh result-mode variable as its target, which the
old rule (“no source typing rule mints a free result-mode variable”) forbade.
The honest form of the invariant is over lifetime: no result-mode variable
outlives its owning drain except as an alias of a declared slot variable or
of a ground mode. The target is either concluded to a ground mode or
collapsed onto the arms’ own variables; nothing downstream ever sees an
unattached one.
The price, stated
Two open ends joined under one binding equate at the collapse of the boundary
that owns them, so mode polymorphism holds up to joined ends sharing a
variable: {|t,u| if $c { force $t } else { force $u }} comes out with one
shared μ rather than two independent ones. This is the known completeness
frontier of the grading, not a regression — the old code equated them too,
only earlier and less predictably. A mixed constraint, one foot on an
enclosing binding’s variable and one on a local one, is the frontier’s edge
case: it collapses where the local variable generalises, monomorphising the
local end onto the enclosing one — the same equation qualified schemes would
avoid, taken at the innermost point that must take it.
Alternatives rejected
- Qualified schemes carrying mode constraints (HM(X)). The principal fix for the shared-variable corner above: schemes would quantify constrained variables and constraints would survive generalisation. Rejected for now — it costs the “no constraint outlives generalisation” invariant, every serialised scheme, and session-scheme continuity, to buy a corner no real program has hit. Revisit only if one does.
Payload = Value(Ty) | Bytesinside theReturntype, making WF-2 true by construction. Rejected: it churns the IR, every scheme, and serde, for a representation win that does not remove the order-sensitivity — which is the actual disease.
Deliberately not decided here
Whether branch inputs should join. Alt reproduces today’s leniency: arms
disagreeing on stdin yield an unknown for a downstream stage to pin. The
alternative — inputs join too, so a form with any byte-reading arm demands a
byte upstream — closes a real hole: if $c { from-json } else { return 5 } fed
by a value edge passes statically today and hands from-json a channel that is
not there. It also tightens existing programs and churns goldens. That is its
own decision, to be taken in the vocabulary this one creates; the constraint
language supports either by swapping the emission.
Two neighbours stay queued behind as well: the checker’s re-parsing of
Exec("alias", …) IR inside infer_seq_with_alias_bindings, which wants a
dedicated IR node rather than alias_statement_shape; and infer_map_val’s
"plugins" special case, already promised to the rc static-schema layer.