Type inference: the algorithm
The type system states what is well-typed; this is how
core/src/typecheck/ infers it — constraint-based Hindley–Milner over the CBPV
IR after elaboration.
The Inferencer walks the typed IR. infer.rs traverses Val / Comp,
allocating fresh type, row, and mode variables and emitting unification
constraints as it goes; infer_case is kept whole at ~100 lines by decision
(infer-case-stays-whole). Builtin
signatures enter through per-builtin rules carried with the body
(builtins registry: builtin_arity,
builtin_type_hint), the one source of arity
(fixed-arity).
The Unifier solves three sorts at once (unify.rs):
- Value and computation types are equi-recursive — unified with no occurs
check, so cyclic types are admitted rather than rejected. Termination rests on
a co-inductive guard (
Pairs): re-entering an in-progress equality obligation is an immediate success — the cyclic fixed point. The guard memoizes symmetric {ty, comp}-var root pairs and one-sided obligations — a var root against a finite structural key of the other side — so the same equi-recursive type anchored at a ty-var on one side and a comp-var on the other still converges rather than overflowing the stack (unify-one-sided-obligations). - Rows unify by the Rémy rewrite (
unify_row): a row-spine occurs check guards the tail variable, then mismatched labels are permuted past one another into a shared fresh tail — which is what makes scoped-label shadowing coherent without a restriction operator (row-types). - Byte modes unify too, by equality (
unify_mode): the∅/Bytes/μchannel modes (PipeMode) onF[I,O] Aare first-class unification variables, but a ground∅and a groundBytesdo not unify — a value edge cannot meet a byte edge. The lattice (PipeMode,PipeSpec) lives incore/src/mode.rsand the equality rule isUnifier::unify_mode— a plain method now that the static checker is the sole mode engine (unconditional-mode-pass) — so a pipeline edge mismatch is a liveModeMismatch(T0012) at type-check time rather than a silent coercion (pipelines; modes-equality-constrained-shared).
Generalisation is at the binding boundary (generalize.rs). At each Bind
the inferencer takes the type’s free variables minus those still free in the
environment and closes over the difference into a Scheme; instantiate
refreshes a scheme’s bound variables at each use. The order follows the SCC
structure the elaborator found — a non-recursive group generalises at its binding
point, a mutually recursive group stays monomorphic until its fixed point — which
is what keeps generalisation sound. A type error aborts with a positioned
expected-vs-inferred message (fmt.rs).
The quantifier is the prefix, not a binder. A Scheme is the body’s
ordinary Ty under a ∀-prefix of four Vecs of variable ids — value
(ty_vars), computation (comp_ty_vars), mode (mode_vars), row (row_vars)
sorts, each a u32-tagged unifier root (scheme.rs). There is no binder node
and no de Bruijn index: a variable is bound iff it is listed.
- Elimination is substitution-with-freshening.
instantiatemints a fresh id in the current unifier for every listed variable and substitutes it through the body, so capture is impossible by construction — the fresh ids did not exist before the call. - Recursive types are not syntax but μ-equations attached to the prefix.
comp_ty_bindings/ty_bindingscarry(root, applied-binding)pairs for each cycle in the body;instantiatere-ties them in fresh union-find slots, so two uses of one recursive scheme never share a cycle root. - Because the prefix is nominal-by-listing, an open scheme leaving its minting unifier aliases another’s variables: see schemes-leave-closed.
The verdict survives into the next turn. The checker is a transformation:
on success annotate writes each top-level name-bind’s generalised Scheme onto
its Bind node — and the ground mode Wire onto every Pipeline stage and Bind
RHS (ir-pipespec-annotation) — the
scheme closed against the empty environment so it carries no residual variable that
could alias the next turn’s fresh ids. The next turn’s
check is seeded from the live session — one SessionSchemes (the scope’s
name→scheme map plus the alias arms’ schemes) — so a name bound in turn N is
checked at its inferred scheme in turn N+1; a name from an unchecked path (a
sourced file, a plugin) is None and infers afresh
(session-scheme-continuity).