Type Checking
The merc_typecheck crate type checks mCRL2 data specifications, following the
definitions in Modeling and Analysis of Communicating Systems (Groote &
Mousavi, MIT Press 2014). A type checker turns the loosely-structured syntax
tree produced by the parser into a fully typed specification: it resolves every
name, decides the sort of every expression, chooses between overloaded
operators, and inserts the implicit coercions that the surface language leaves
out (such as reading a natural number where a real number is expected).
Rather than performing all of this in one recursive traversal, merc splits type
checking into a pipeline of phases, each with a well-defined input and
output. The architecture is query-based in the style of the Rust compiler:
each derived fact — the signature of a specification, the sort denoted by a
declaration, the typing of an equation — is a memoized query on a shared
TypeckContext, so phases pull their dependencies lazily and every fact is
computed at most once. Cyclic definitions (a sort alias that refers to itself,
say) are detected through the memoization table's lock state instead of running
away into unbounded recursion.
Is the per-query memoization worth its complexity?
Not a settled question. Several passes already walk the full AST to perform other syntactic operations, so it is not yet clear how much the per-query memoization saves over simply recomputing facts during one of those existing traversals, versus what it costs in bookkeeping.
The entry point is DataSpecification::from_untyped, which takes the untyped
merc_syntax AST produced by the parser and runs the phases below.
Overview
UntypedDataSpecification (merc_syntax AST)
│ sort layer: flatten, name resolution, alias checks, normalization
│ desugaring and operator lowering
│ signature (S, C, M) + well-typedness checks
│ sort resolution onto the interned lattice
│ constraint-based sort inference (per equation, memoized)
▼
Typed specification + sort assignment (ExprId → ResolvedSort)
│ lowering
▼
merc_data::Mcrl2DataSpecification (aterm, fully typed)
The guiding idea is a split representation. During type checking, sorts are
not aterms: they are interned indices into a standalone arena, so equality is
a single integer comparison and typings live in compact side tables keyed by
expression id. This keeps the checker independent of the aterm term pool —
faster, testable in isolation, and free of garbage-collection concerns while a
fixed-point search runs. Only the final lowering phase produces the maximally
shared aterm representation that the rest of merc (merc_sabre,
merc_explore) consumes.
Phase 0 — The sort layer
The first phase establishes what sorts exist and rejects malformed sort declarations, operating directly on the AST:
- Name resolution assigns a definition id (
DefId) to everysortdeclaration and rewrites each sort reference to point at its definition. Duplicate and undefined sort names are rejected, while byte-identical duplicate declarations are silently accepted as one. - Alias checks reject circular sort aliases, including cycles that only close through a function or container sort. A recursion that passes through a list is inductively well-founded (a list can be empty), but one that passes through a function sort or an unbounded set/bag is not, and is rejected.
- Normalization expands aliases to a canonical form: non-structured
aliases are inlined, while aliases of structured (
struct) sorts stay as named representatives so that structurally identical structs share one sort.
Function sorts with product domains such as (A # B) -> C are also flattened
here into a single multi-argument form, so that later phases see one uniform
representation of a function sort.
Phase 1 — Desugaring and operator lowering
Structured sorts (struct declarations) are desugared into an abstract sort
plus its constructors, recognisers and projection functions; anonymous inline
structs are first hoisted into fresh named declarations, with structurally
identical structs sharing one. The defining equations (recognisers,
projections, and the ==/</<= orderings) are generated into a
system-defined specification that accompanies the user's specification.
Built-in operator syntax is then lowered into plain applications — x == y
becomes ==(x, y), the list cons [x] becomes an application of the cons
operator, and so on — so that sort inference has a single application code path
instead of a special case for every operator. Number and container literals are
kept as dedicated nodes, because their sort is chosen by inference rather than
declared.
Phase 2 — Signature and sort resolution
The signature query computes the book's triple — the declared sorts, constructors and mappings — as resolved overload sets per name. While computing it, the well-typedness conditions of Definition 15.1.7 are checked: constructors and mappings must be disjoint, basic and function sorts may not have constructors, and every sort with constructors must be non-empty. These checks run before alias expansion so that errors refer to sorts as the user wrote them; a syntactic safety net re-checks the normalized specification afterwards.
The system-defined specification is assembled: the standard data types of
Appendix B (Bool, Pos, Nat, Int, Real, lists, sets, bags) are injected
for exactly the sorts that occur in the specification, computed as a transitive
fixed point (using Set(S) pulls in FSet(S), and so on).
Finally, sort resolution maps every declaration-level sort expression onto
the interned ResolvedSort lattice described next.
The system-defined specification
The standard data types of Appendix B — Bool, Pos, Nat, Int, Real,
the List, Set, Bag, FSet, FBag containers, and function updates — are
not written by the user but are needed by almost every specification. merc keeps
them in a separate system-defined specification, assembled in Phase 2 for
exactly the sorts that occur (as the transitive fixed point above), alongside
the defining equations of the desugared structured sorts. It is trusted,
generated content: instantiated Appendix-B templates and generated struct
equations, not something a user typed.
Why it is not type-checked as a user specification
It might seem natural to instantiate this specification for every sort that occurs and then run the ordinary well-typedness checks and sort resolution over it, exactly as for the user's declarations. merc deliberately does not, for two reasons.
- It legitimately declares things a user may not. The Appendix-B templates
give the basic sorts their constructors (
@c0: Nat, thePos/Int/Realconstructor chains) and use reserved@-prefixed names throughout. The well-typedness conditions of Definition 15.1.7 — no constructors on basic or function sorts, constructor/mapping disjointness — are user-facing rules that this generated content is meant to violate. Running them over the system specification would reject it out of hand. - Instantiating it per sort into the searchable signature would create
ambiguity. The container and function-update operations (
in,#,|>,head, the function-update operators, …) exist for every element sort. Resolving their per-sort instantiations into the signature — soin: S # List(S) -> Boolbecomes one concrete overload for eachSthat occurs — while also keeping the polymorphic lookup below would list every such operation twice: once as the concrete overload and once polymorphically. A name with both a concrete and a polymorphic candidate for the same sort produces two tied disjuncts, which the solver reports as a spurious ambiguity. (Full instantiation on its own, without the polymorphic lookup, would be fine — see below.)
Instead, the system specification is trusted and checked separately, on its own terms, in two passes with different jobs.
Checking the system specification
check_system_specification runs first, unconditionally in every build — not
gated behind a debug_assert!, since silently trusting a malformed generated
spec in a release build would leave a rewrite specification quietly missing
rules. It is a cheap structural pass over the generated content exactly as
written, independent of any sort it happens to be instantiated for: every sort
reference is declared (catching an uninstantiated template variable like S),
product sorts occur only as function domains, no structured sort survives
desugaring, no var block declares a variable twice, every name in an
equation resolves (to a binder, an equation variable, a constructor or mapping,
or a builtin scheme), and the free variables of a condition and right-hand
side occur in the left-hand side, so every rule is executable by rewriting. It
exists to catch an editing mistake in a spec/*.mcrl2 template cheaply, before
spending a full inference pass on it.
One signature-level rule from Phase 2's well-typedness check is re-checked
here too: no constructor may target a function sort
(ConstructorForFunctionSort). Of Definition 15.1.7's constructor-related
checks, this is the only one the system specification does not legitimately
break — no template declares a function-sort constructor, so a hit here
catches a genuine editing mistake. The other checks are deliberately not
shared, because the system specification is designed to break them:
ConstructorForBasicSort— the templates declare@c0: Nat,@cNat: Pos -> Nat, and similar constructors for the basic sorts, pervasively;DuplicateConstantDifferentSort—[]: List(S)is a nullary polymorphic constructor, so a specification usingList(D)andList(E)legitimately declares[]at two sorts once instantiated;ConstructorAndMappingConflict— the same risk recurs across container instantiations.
ConstructorForFunctionSort is checked syntactically here — a raw walk over
the generated SortExpressions — rather than shared with build_signature's
version of the same rule: the system specification is nominal and alias-free,
so no interned sort lattice is needed for it, and merging the two code paths
behind an is_system flag would be the flag-argument anti-pattern, since the
resolution mechanisms differ entirely (memoized user queries versus a raw
resolve_system_sort walk).
check_system_equations then runs full Phase-3 (constraint-based) inference
over every system equation, the same way check_equations does for user
equations — both share the same ConstraintGenerator/Solver (see
EquationRole in inference.rs), differing only in where a name or an
equation-variable sort resolves from: a system equation resolves against its
own group's system_equation_signature_by_group entry (see
SystemEquationGroup) rather than the full signature, and its sorts via
resolve_system_sort rather than resolve_sort. Signatures are scoped per
group, not pooled, because two instantiations of the same container template
(Bag(Nat), Bag(D)) each carry a copy of its equations, and some of those
mention no argument pinning down which copy they belong to — one pooled
signature would make them ambiguous.
The polymorphic signature
Because of the above, the built-in operators are made available to Phase-3 inference in three different ways, according to how many sorts they range over:
- Basic-sort operators (
&&,+,-,*, the ordering comparisons on numbers, …) range over the five basic sorts only. Their declarations are resolved per-sort onto the lattice, giving inference an ordinary finite overload set — the system signature. - Comparison operators and
if(==,!=,<,<=,>,>=,if) exist for every sort and are never declared anywhere. They are typed as schemes —==as ,ifas — instantiated with a fresh unification variable per occurrence. - Container and function-update operations exist for every element sort.
Their template declarations are collected once into a polymorphic
signature, keyed by name, with the template sort variables (
S,T) left as unresolved references. Inference looks them up there and instantiates each overload with fresh unification variables per occurrence, exactly like the comparison schemes.
This mirrors mCRL2's built-in polymorphic symbol table. The per-sort instantiations of the polymorphic operations still exist in the system specification — they are needed for the defining equations and for Phase-4 lowering — but, as explained above, they are deliberately not resolved into the signature that inference searches. Phase-4 lowering recovers the concrete operation from the operator name together with the sort that inference assigned the occurrence.
Treating these operations polymorphically is ultimately an optimization, not
a necessity. merc could instead instantiate every polymorphic operation for
every element sort in the transitive fixed point — turning in: S # List(S) ->
Bool into concrete overloads in: Nat # List(Nat) -> Bool, in: Pos #
List(Pos) -> Bool, … — drop the polymorphic lookup, and resolve the results
into the ordinary signature like any user overload. That instantiation is
entirely possible and would accept exactly the same specifications. It is
avoided because it scales poorly: the set of element sorts grows with every
nested container, so each operation contributes one concrete overload per sort,
enlarging the disjunctions the solver must search at every use site. A single
template instantiated on demand with a fresh unification variable gives
inference one candidate — its element sort filled in from the arguments — where
full instantiation would give it many. The scheme also avoids pre-instantiating
an operation for an element sort that inference pins down only late (the element
of an empty [], or one supplied by a default), and it keeps merc aligned with
mCRL2's own polymorphic built-in table. The ambiguity noted above is what
forbids doing both — instantiating and keeping the polymorphic lookup — not
what forces the polymorphic route on its own.
One subtlety: arithmetic that is also a container operation
+, - and * are both number operators and the Set/Bag union,
difference and intersection operators. When no user overload shadows such a
name and it has no container reading at the use site, its numeric promotion
is resolved by a single direct lookup rather than a disjunction over the
basic-sort overloads — this keeps equations with many repeated arithmetic
sub-expressions from branching combinatorially.
When either condition fails — a user overload shadows the name, or the use
site does have a container reading — the operator simply reverts to an
ordinary Disjunction, exactly like any other overloaded name. The fast
path is a common-case optimization, not a correctness fence: the
branch-and-bound pruning and the
argument-before-callee ordering still keep that fallback tractable, only
without the direct-lookup savings.
System-internal sorts and the DefId offset
Desugaring and instantiation introduce a few nominal sorts that the user never
declared — for example @NatPair, used by the number templates. These
system-internal sorts need identifiers on the same footing as the user's
sorts, whose names are keyed by a DefId (an index into the user's sort
declarations assigned during name resolution).
Rather than a second namespace, merc simply continues the numbering: a
system-internal sort declared at position in the system specification gets
the DefId , where is the number
of user sort declarations. A DefId below user_len therefore indexes the
user declarations; one at or above it indexes the system-internal sorts, offset
by user_len. This keeps a resolved sort a single small index while letting a
name lookup fall through from the user table to the system table.
Because this offset is an encoding rather than a guaranteed contract, the two
directions of it live in one place: the assignment when the system signature is
resolved, and a single TypeckContext::sort_name accessor that performs the
reverse lookup for both debug rendering and Phase-4 lowering. No other pass
open-codes the DefId − user_len arithmetic, so a change to the scheme touches
exactly those two spots.
The sort lattice
Sort inference works over an interned lattice of resolved sorts, which is the
vocabulary shared by unification and the solver. A ResolvedSort is one of:
- a primitive sort (
Bool,Pos,Nat,Int,Real); - a container sort
op(S)such asList(S),Set(S)orFBag(S); - a function sort ;
- a nominal sort
Def(d), identified by the declarationdit resolves to; - a
Unitsort, used internally for the result of an action.
Because sorts are interned, each distinct sort is stored once and two sorts
are equal exactly when their indices are equal — a sort comparison is a single
integer comparison. Sub-sorts are stored as indices too, so a ResolvedSort is
small and structural equality never has to recurse.
Unlike the plain set of sorts in the book, these sorts form a lattice under the sub-sort ordering that the implicit coercions define:
- the number sorts form a chain ;
- the finite containers embed into their unbounded counterparts, and , when the element sorts are equal;
- all other distinct sorts are incomparable.
The lattice supplies a join (least common supersort) and meet (greatest
common subsort). A join is what lets two branches of an if, or the two sides
of an equation, meet at a single common sort: joining Nat and Int yields
Int, and joining FSet(Pos) and Set(Pos) yields Set(Pos). This directly
models the numeric up-casting and container widening that the surface language
performs silently, but as a clean lattice operation rather than a collection of
special cases.
Phase 3 — Sort inference
Sort inference runs per equation as a memoized query. Each equation is typed independently, in two steps: constraint generation, then a ranked backtracking search. The following three sections describe the machinery in detail, since it is the heart of the crate.
Constraint generation
The generator walks the lowered condition, left-hand side and right-hand side
of one equation and, for every sub-expression, allocates a sort node in the
unifier (see below) and emits constraints relating those nodes. Nodes are
numbered by an ExprId in a fixed order — parents before children, and within
an application the arguments before the applied function. This ordering
matters: by the time the solver reaches a function's overload choice, the
argument sorts are already known, so most overloads can be rejected immediately.
The constraint kinds are:
- Sub — the sort of one node must be a sub-sort of another, modelling an
implicit up-cast (a
Natargument passed whereIntis expected). Equality is the special case where no coercion is needed. - Lit — a number literal must take a number sort admitting its kind (
0is a natural, every other literal is positive). Literals prefer the most specific sort, so1is aPosbefore it is widened. - Disjunction — a name with several overloads must resolve to exactly one of them. The solver commits to one disjunct per solution.
- Comprehension — a set/bag comprehension
{ x: S | e }reads as aSet(S)when its body is boolean and as aBag(S)when its body is a number; the reading follows from the solved body sort. - Numeric — an application of an arithmetic operator (
+,-,*,/,div,mod,exp,max,min) with no user overload. Because the built-in overloads of these operators never overlap on their argument sorts, at most one can match a fully-known argument tuple, so this is resolved by a direct lookup rather than by branching. Treating them this way — instead of as a general disjunction — is what keeps equations with many repeated arithmetic sub-expressions from blowing up combinatorially. - Join — a group of
Subconstraints that all widen into the same shared sort variable (the operands of a comparison, the branches of anif, a set or bag element, the equation's two sides) is folded into one least-upper-bound over the lattice. Computing the common supersort in a single step avoids the order-sensitivity of solving the sub-constraints one at a time, where an early finite-container branch could otherwise fix the result prematurely and force the other branch to be re-explored.
Structural facts that must hold in every solution — that a callee has a function sort, that a condition is boolean — are unified eagerly at generation time, so a violation is reported as a direct error rather than a silent search failure.
Equations whose binders use a sort that inference does not model yet (an
anonymous struct, a bare product) are left untyped rather than rejected, so
the rest of the specification still type checks.
Unification with subtyping
Equality of sorts is decided by structural unification over a union-find
table. merc uses ena — the Rust compiler's
extracted unification-table crate — for the union-find, wrapped in a Unifier
that adds an arena of sort nodes and the sub-sort operations.
A sort node under inference is one of: a fully resolved (interned) sort, a
container op(subsort) whose element may still contain variables, a function
sort whose parts may contain variables, or a bare unification variable. A
node like List(?t) — a list whose element sort ?t is still unknown — is how
the generator represents an empty-list literal before the element sort is
pinned down.
Unification proceeds by the usual structural rules, with two additions specific to this checker:
- Interning makes the base case trivial. Two fully resolved sorts unify exactly when their indices are equal, so unification only ever spells out structure around the variables that remain.
- A resolved container or function sort unifies against a spelled-out one
by matching head constructors and recursing into the sub-sorts. This lets a
half-known
List(?t)unify with a fully resolvedList(Nat)by binding?t := Nat.
Binding a variable runs an occurs check first, which rejects the infinite
sort a binding like ?t := List(?t) would otherwise create.
Crucially, unification itself decides only equality, not sub-typing. The
sub-sort ordering is handled one level up, by the solver: unification never
silently widens Nat into Int. Instead, the Unifier exposes the strict
super-sorts and sub-sorts of a node — Pos yields [Nat, Int, Real], Real
yields [Int, Nat, Pos] — in ascending distance, and only the head
constructor is widened (Nat has supersorts; List(Nat) does not). The solver
enumerates these candidates explicitly when a plain equality does not hold. This
separation keeps unification simple and total, and confines every coercion
decision to the ranked search where it can be measured and compared.
Ranked backtracking search
The solver walks the constraints in generation order, and at each choice point it tries the alternatives and recurses. Because inference must pick not just a typing but the best one, every leaf of the search is scored by a lexicographic measure, and the solver keeps the single best leaf:
- each
Sub,LitandJoinsource contributes one measure component —0for an exact match, and a larger number for a wider coercion (the number of steps up the sub-sort chain); - components are ordered by generation position, earlier ones most significant, so a coercion high in the expression tree costs more than one deep inside it;
DisjunctionandComprehensioncontribute no component of their own but are explored exhaustively.
The minimum measure is the most specific typing: equality beats widening, nearer widenings beat farther ones, and literals take their smallest admissible sort. Each choice point does the same thing — try equality first, then the strict widenings in ascending distance — so the first solution found down any branch is already the locally cheapest.
Backtracking is implemented with the union-find table's native snapshot / rollback. Before trying an alternative the solver snapshots the variable bindings; if the branch dead-ends or is exhausted, it rolls back to free exactly the variables bound since the snapshot. The sort-node arena is append-only and is not rolled back — nodes created inside an abandoned branch simply remain as harmless garbage — which keeps rollback to the cheap union-find operation.
Two properties make the search both correct and tractable:
- Exhaustive disjunctions detect ambiguity. Because every overload and every comprehension reading is explored, two distinct solutions that tie at the same minimum measure are reported as a genuine ambiguity error rather than silently picking one.
- Branch-and-bound pruning keeps it fast. A partial branch whose measure prefix is already strictly worse, component for component, than the best leaf found so far can never win — earlier components dominate the lexicographic order — so it is cut immediately. Without this, an equation with many independent overloaded operators would explore every combination to its leaf; with it, the search stays practical. The pruning is exact: it changes only how much of the tree is visited, never which typing wins or which equations are ambiguous.
When the best leaf still leaves a sort variable free — an auxiliary sort that no constraint ever pinned down, such as the element sort of an empty container that is never used — the solver substitutes a default so the equation is accepted rather than reported as underdetermined.
The inferred sorts are recorded in side tables mapping each expression to its
resolved sort and each name occurrence to the chosen overload, keyed by the
same ExprId numbering the generator used, ready for the lowering phase to
re-walk.
Because this global ranked search considers the whole equation at once, it
accepts some specifications that a purely local algorithm rejects as ambiguous —
for example resolving an overloaded call by ranking an exact match strictly
above one that needs a numeric up-cast, or typing a where clause by solving
all of its bindings jointly instead of one at a time.
A worked example
Consider two overloads of the same name and a call that fits both:
map f: Nat -> Nat;
f: Int -> Int;
var n: Nat;
eqn f(n) = n;
Generation numbers the argument n before the callee f, so by the time f's
overload Disjunction is reached the argument sort is already known to be
Nat. Two disjuncts then unify:
f: Nat -> Nat— the argumentNatmatches the parameterNatexactly, so the argument'sSubcontributes measure component0;f: Int -> Int— the argumentNatmust widen toInt, one step up the number chain, so the sameSubcontributes1.
Both branches reach a leaf: the call type-checks either way. The measures differ
only in that argument component — […, 0, …] versus […, 1, …] — and because
0 < 1 the exact Nat -> Nat overload wins. A plain "disjunction handed to
unification" would have no reason to prefer it; the measure is exactly what
rules out the needless up-cast.
The same ranking governs literals. In f(n) = n's sibling map g: Real; eqn g
= 1;, the literal 1 is tried most-specific-first: Pos (generality 0)
before Nat, Int, Real. Pos is consistent — it widens to Real at the
equation's join — so the leaf that types the literal itself as Pos and pays
the widening at the coercion point has a smaller measure than one that starts
the literal at Real. The literal is therefore typed Pos and coerced,
matching the rule that literals take their smallest admissible sort.
For a container example, s == t with s: FSet(Pos) and t: Set(Pos) shares
one variable ?a between the operands. The Join computes the least upper
bound , charging one widening step to the
FSet(Pos) source and 0 to the already-Set(Pos) source — so both operands
agree on the least sort that admits them, Set(Pos), and the comparison is
typed there.
Phase 4 — Lowering
The final phase walks the typed representation and emits aterm
merc_data::DataExpressions, materializing the implicit coercions as explicit
function applications: numeric up-casts become the Appendix-B constructor
chains, and finite-to-unbounded container widenings become the corresponding
set/bag constructors. Number literals are lowered to their exact Appendix-B
constructor chains via arbitrary-precision binary encoding, and all binders
(lambda, forall/exists, set/bag comprehensions, where) are lowered too.
DataSpecification::lower_data_specification assembles the full
Mcrl2DataSpecification — user sorts, aliases, constructors, mappings and
equations, followed by the system-defined declarations and equations.
The output schema is fixed by binary-aterm compatibility. merc must load
mCRL2's already type-checked binary specifications, and because the aterm pool
is maximally shared, a symbol the checker constructs must be byte-for-byte
identical to the same symbol read from a binary file, so that the two share one
pooled term. Already-typed binary input therefore bypasses the checker
entirely: both routes converge on the same merc_data::Mcrl2DataSpecification,
and downstream code is oblivious to the provenance.
Lowering is invoked after from_untyped rather than inside it, so callers
that only need the typed intermediate representation pay nothing for the aterm
lowering.