Skip to content

Lowering

Phase 4, the final phase of the pipeline, walks the typed representation produced by sort inference 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.

For a polymorphic container/function-update operation, lowering recovers the concrete operation from the operator name together with the sort that inference assigned the occurrence — see the polymorphic signature for why inference itself never resolves these to a concrete overload.

Binary-aterm compatibility

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.

Known divergences from the mCRL2 toolset

merc's lowering matches the mCRL2 toolset's own C++ type checker term-for-term for the overwhelming majority of specifications. This is checked mechanically by tools/mcrl2/crates/mcrl2/tests/lowering_conformance.rs, which type checks and lowers the same specification with both checkers and asserts structural (address) equality of the resulting aterms in the shared, maximally-shared aterm pool. Three cases are known to diverge, each in exactly one user_defined_* section; the round-trip test for each such specification is narrowed to the sections that do conform, rather than being dropped, so everything else about the case stays guarded.

Structured sorts

merc's desugar_structured_sorts turns sort D = struct …; into an abstract sort D plus its constructor/recogniser/projection declarations, so D lands in the sorts section. The toolset instead keeps the declaration as an alias D = SortStruct(…) and leaves its sorts section empty. Every symbol the struct declares, and any equation using it, do conform — only the sorts section differs.

sort D = struct c1(pr1: Nat, pr2: Bool)?is_c1 | c2?is_c2;
map f: D -> Bool;
var d: D;
eqn f(d) = is_c1(d);

The recursive case (sort Tree = struct leaf | node(left: Tree, right: Tree);) is checked the same way, since the recursion is what makes the constructor and equation terms worth checking separately.

Canonical sort representatives

normalize_sorts erases alias names, and not only in the alias section (sort B = A; becomes B = Nat once A = Nat) — every use of the alias is expanded too, so a mapping declared as C -> Bool lowers with List(Nat) where the toolset keeps the name C:

sort A = Nat; B = A; C = List(B);
sort D;
cons d: D;
map f: C -> Bool; g: D -> Bool;
var x: D;
eqn g(x) = true;

Only the sections that never mention an alias round-trip: the abstract sort D and its own constructor and equation. The alias declaration itself is fine when there is no indirection to expand.

Expected-sort propagation

Consider a where binding with no expected sort:

map f: Nat -> Nat;
var x: Nat;
eqn f(x) = y + y whr y = x + 1 end;

Both checkers pick the exact overload +: Nat # Pos -> Pos for x + 1, binding y: Pos. The body y + y then has expected sort Nat: the toolset pushes that expectation down into the choice of +, picking +: Nat # Nat -> Nat and wrapping both operands in Pos2Nat. merc instead types the body at its own minimal sort, Pos, and widens the result once at the end. Both readings are well-sorted — only the toolset's is what the binary aterm form holds, so it is the one lowering must match.

The minimal reproduction drops the where entirely:

map f: Nat -> Nat;
var x: Nat;
eqn f(x) = x + 1;

With x: Nat and the equation's expected sort Nat, the toolset resolves + at its result sort — +: Nat # Nat -> Nat, retyping the literal 1 at Nat — while merc's ranked search prefers the overload needing no widening at all, +: Nat # Pos -> Pos, and widens the result to Nat afterwards. Only the equations section can see the difference; sorts, aliases, constructors and mappings are untouched.