Overview
merc_linearisation turns a type-checked merc_typecheck::ProcessSpecification
into a LinearProcessSpecification (LPS): a flat vector of process parameters
plus a set of sum d:D . c(d,p) -> a(f(d,p)) . P(g(d,p))-shaped summands — the
condition/action/effect form the rest of merc (merc_lts, merc_explore,
merc_symbolic) is built around. It is a native, from-scratch port of the
algorithm mCRL2's libraries/lps (source/linearise.cpp, ~10k lines)
implements. The full phased design — including the milestones not built yet —
lives in docs/linearisation-implementation-plan.md
in the merc repository; this page is about the design choices behind what
is built — Milestone 1 (a single, self-recursive, ||-free process),
Milestone 2 (compositional linearisation for tools/lts combine), and an
alphabet-based simplification pass added on top of both — and why they turned
out the way they did, several only after the corpus of real .mcrl2 examples
merc vendors (examples/mCRL2/) pushed back on the first attempt.
Why the data model stays at the merc_syntax level
The obvious choice would be to lower every condition/action-argument/
next-state expression into merc_data's aterm representation, the way
DataSpecification::lower_data_specification already does for data
equations — it's what the rest of merc (the rewriter, the LTS explorer)
eventually needs. Milestone 1 doesn't do this, for a mundane reason:
ProcessSpecification doesn't expose a way to. It type checks a process
body's expressions and records their sorts in a span-keyed TypingInfo side
table, but the expressions themselves stay as merc_syntax syntax trees —
there's no lower_process_specification alongside
lower_data_specification. Building one is real work (the process-body
scope includes sum/glob/proc-parameter variables lower_data_specification
never has to deal with) and belongs to merc_typecheck, not to a translator
that just rearranges the expressions it's handed. So LinearProcessSpecification
holds merc_syntax::DataExpr, and merc_linearisation::print renders it back
to mCRL2 concrete syntax — good enough to feed to the FFI-backed real mCRL2
library (tools/mcrl2) as an interchange format, until that lowering exists
and a native rewriter/enumerator makes a direct merc_explore::LPS adapter
worthwhile.
The translation is a structural fold, not a special case per operator
Reading linearise.cpp first suggests the algorithm is a large pile of
special cases — allow, block, hide, rename, communication all get their own
900-line header. In the fragment Milestone 1 covers (no ||, so no
communication combinatorics at all), it collapses to one idea: every process
expression denotes a set of summands, and every operator is a fold over
that set.
Choice— set union.Sequence— for each summand on the left that could still do more afterwards, splice the right operand's summands onto its tail; a summand that already reacheddeltaabsorbs nothing (delta . Qisdelta, neverQ).Sum— prepend the bound variables to every summand's own binder list.Condition— conjoin the guard into thethenbranch, its negation into theelsebranch (or a syntheticdeltabranch when there is noelse), then union the two summand sets.Rename/Hide/Block— a per-summand rewrite of the already-collected multi-action (substitute a name; drop a name; drop the whole summand if it performs a blocked name) — no recursion into "how was this summand built" needed at all.
This is why the translator's core (translate.rs) is one recursive function
matching on ProcessExprKind, each arm a few lines, rather than the kind of
special-purpose machinery linearise_communication.h needs once || is in
scope (Milestone 3).
Telling delta, "fell off the end", and "recurses" apart
The one piece of state that isn't just "the summand so far" is what a summand
does next. Three cases look interchangeable in isolation — delta,
tau . nil, and a bare recursive call P(e) — but Sequence has to treat
them differently: delta . Q never reaches Q, (a . nil) . Q does, and
P(e) . Q is a process call with something sequenced after it — not
tail-recursion, and Milestone 1 rejects it outright (NotInTailPosition,
mirroring mCRL2's own "regular" restriction). The translator tracks this
explicitly as a small Tail enum (Deadlock / Terminate / Recurse)
carried on every summand under construction, collapsed away once a summand is
finished — a finished summand's next-state vector is either the recursive
call's arguments, or (for Deadlock/Terminate) the parameters unchanged,
since a summand that never moves again can't be told apart from one that
just happens to revisit its current values.
A corpus-surfaced correctness bug: P() is not "wrong arity"
The crate's regression net for this milestone isn't a handful of hand-picked
snippets — it's every .mcrl2 file in examples/mCRL2/ (the same 167-file
corpus merc_typecheck's own example_tests.rs type checks), asserting only
that linearise never panics. Most of the corpus is full protocols built
from ||, so most cases correctly return UnsupportedConstruct or
MutualRecursionUnsupported for now — that's expected, and tracked as the
work later milestones do. But one failure the first version of the translator
produced, on examples/mCRL2/language/numbers.mcrl2, wasn't a scope
limitation: it rejected P() — a call to an 8-parameter process with no
assignments at all — as MissingParameterAssignment.
That's wrong. mCRL2's assignment notation (P(x = e, ...)) documents that an
omitted parameter keeps its current value — P() is legal shorthand for
"every parameter unchanged," used precisely so large-parameter processes
don't have to restate everything on every self-call. The fix replaced the
error with the documented default (an omitted parameter's next-state
expression is just a reference to the parameter itself) and deleted the
error variant — there was no longer a real condition left for it to report.
This is the kind of bug a curated set of hand-written test cases is unlikely
to catch (why would a test author write a call that omits an argument on
purpose?) but a large real-world corpus finds immediately, in the first file
that happens to use the feature. As of this fix, the corpus splits as:
| Outcome | Count | Why |
|---|---|---|
| Linearises successfully | 50 / 167 | Single self-recursive process, no \|\| |
MutualRecursionUnsupported |
12 / 167 | Two or more mutually-calling procs |
UnsupportedConstruct |
105 / 167 | \|\|/comm/at/dist, mostly (allow is supported now — see below) |
| Panics | 0 / 167 | — |
The zero-panics row is the point of running the whole corpus rather than a
sample: a recursive-descent translator has plenty of places an .expect() or
an index could go wrong on an input shape nobody thought to hand-write, and
this is the test that would catch it.
Why the compositional mode doesn't need to "cleave" anything
mCRL2 also ships lpscleave, which splits an already-linearised single LPS
back into two based on a heuristic partition of its parameters — a different
technique for a different problem (distributed state-space generation from
one flattened LPS with no source-level parallel structure left to exploit).
merc_linearisation's compositional mode (compositional::linearise_compositional,
Milestone 2) is not that. crates/explore::combine_lts (used by tools/lts
combine) already computes hide(H, allow(A, comm(C, L1 || ... || Ln))) at
the LTS level, given one already-built LTS per component plus the H/A/C sets
as plain merc_syntax values. So when init is literally
hide(H, allow(A, comm(C, P1(...) || ... || Pn(...)))), there is nothing to
reconstruct: each Pi already is its own independent process, linearisable
by exactly the Milestone 1 core described above, with no parallel composition
inside it to expand. Milestone 2 is a thin recognizer for that top-level
shape (peeling hide/allow/comm in that fixed order, then flattening the
|| chain underneath) plus a manifest tying the per-component LPS files to
the H/A/C sets, in the exact text form tools/lts combine already parses —
reuse, not new machinery. It doesn't even need its own "is this operand
parallel-free" check: a Pi that isn't gets rejected with the same
UnsupportedConstruct the shared per-process core (translate::translate)
already produces outside a compositional context, since that's the literal
same function call. Full monolithic linearisation (expanding ||/comm
anywhere, mCRL2's actual hardest ~half of linearise.cpp) stays a separate,
later milestone precisely because it's a strictly harder problem than the
compositional case.
Alphabet-based simplification, and the blow-up it caused before it was capped
The book (Groote & Mousavi §4) and mCRL2's process::alphabet_operations
give a standard set of axioms for eliminating a hide/block/allow/
rename/comm wrapper that provably has no effect on its operand — e.g.
hide(I, P) = P whenever none of I's names occur in P's alphabet (the
set of multi-actions, as name-multisets, P can ever perform). alphabet.rs
ports this as an unconditional pre-pass (alphabet::simplify) ahead of
translate::translate: mCRL2 gates the equivalent behind
t_lin_options::apply_alphabet_axioms, but since the pass only ever removes
a redundant node, there's no reason not to run it always.
The interesting design constraint is computing the alphabet cheaply enough.
alphabet(P || Q) needs interleaving and combination:
alphabet(P) ∪ alphabet(Q) ∪ {merge(a,b) | a ∈ alphabet(P), b ∈ alphabet(Q)}
— a cross product, multiplying set sizes at every || in a chain. The
first version of this module computed that exactly, and it hung
example_tests.rs (the whole-corpus regression test described above)
indefinitely — not on some contrived adversarial input, but on ordinary
real-world specifications with wide parallel compositions (dining-philosopher-style
N-way process networks, industrial protocols with dozens of components).
Two things make this worse than it might sound: first, simplify runs
before translate gets a chance to reject a || it doesn't support, so
even a specification Milestone 1 will ultimately refuse still pays for
computing its alphabet on the way there; second, the blow-up compounds across
a chain — each additional parallel component multiplies the running set size
again, so it's exponential in the number of components, not their
individual size.
The fix is the standard one for this shape of problem: give the alphabet
lattice an explicit top element. Alphabet is Finite(HashSet<MultiActionName>)
or Unknown ("could be any multiset of action names"). Every axiom check is
written to conservatively fail against Unknown (never prove a no-op that
isn't one), so bailing out to it is still sound by the same argument the rest
of the module leans on: an over-approximation can only cause a missed
simplification, never an incorrect one. Unknown is also contagious and
cheap — once one level of a wide || chain degrades to it, every level above
stays O(1) — so bounding one pairwise combination bounds the total cost
across an arbitrarily long chain.
The first fix that actually shipped checked the two operands' sizes before
multiplying them — above MAX_PARALLEL_ALPHABET (4096) computed entries,
give up without computing the product at all. That's correct, but needlessly
pessimistic: a size check on the inputs can't see that many merged pairs
are about to collide. Once several ||s are nested, merge(a, b) frequently
produces the same multiset from several different (a, b) pairs (multiset
union is commutative, and real specifications repeat the same handful of
synchronising actions across many components), so the true, deduplicated
alphabet is often far smaller than |alphabet(P)| × |alphabet(Q)| suggests —
exactly the compositions this analysis matters most for were the ones most
likely to get an unnecessary Unknown.
Alphabet::combined_with_limit fixes this by moving the bound onto the
output instead of the input: self's elements, then other's, then every
pairwise merge are chained into one lazy iterator — nothing eagerly
collected — and drained one item at a time into a HashSet by
collect_bounded, which bails to Unknown the moment the accumulated,
deduplicated result would exceed the limit, not before. Two things fall out
of doing it this way: a composition whose true alphabet is small stays exact
however many raw candidate pairs it takes to discover that (duplicates cost
one more HashSet::insert, nothing else), and a composition that genuinely
explodes stops pulling further pairs the instant the limit is crossed, rather
than only discovering the problem after building the full cross product.
Because it's a lazy iterator rather than a pair of nested loops materialising
a Vec first, "stop early" and "stay cheap when small" both fall out of the
same code path instead of needing separate handling.
Getting the bound-respecting rewrite right without silently breaking the
exact answer needed its own check: alphabet.rs's own #[cfg(test)] mod
tests runs combined_with_limit twice on the same inputs — once with the
production limit, once with usize::MAX — and compares the unbounded call
against a deliberately naive, nested-loop-with-no-early-exit reference
implementation of the same cross product. The naive version is obviously
correct by inspection (there is nothing to get subtly wrong about two nested
for loops), which is the point: it exists purely so the clever, lazy,
early-exiting version has something trustworthy to be checked against, not
because it's ever meant to run outside a test.
tests/alphabet_test.rs's test_alphabet_of_a_wide_parallel_composition_stays_bounded
constructs a 40-way parallel composition (2^40 multi-actions if computed
exactly) as the corresponding black-box regression test.
Comm's own contribution to the alphabet (Alphabet::with_communications)
took a different path to the same "port mCRL2, don't invent something new"
principle. The first version was a hand-rolled, single-round approximation:
for every multi-action already in the alphabet, try each communication
expression once. It's sound (another over-approximation) but not what mCRL2
actually computes, and diverges from it in an observable way — mCRL2's real
alphabet_operations::apply_comm keeps consuming a communication
expression's left-hand side from the same multi-action for as long as it
keeps matching (so a|a|b|b under comm(a|b -> c) yields c|c, not merely
a|b|c), and applies every expression in comm in order, so a later
expression can fire on a multi-action an earlier one just produced — no
separate fixed-point loop needed, one left-to-right pass over the
(statically fixed, finite) list of expressions already is one. apply_comm
now ports that algorithm directly rather than approximating it, so — unlike
combined, which is a genuine over-approximation by necessity — this part of
alphabet.rs computes exactly what mCRL2 would. The only remaining sense in
which it approximates the real per-summand comm operator is scope, not
precision: it runs over the whole static alphabet, not over which concrete
summand produced which multi-action.
One consequence worth calling out: this pass makes comm(C, P) linearisable
in a case translate::translate alone cannot — when C can be proven to
never actually fire against P's alphabet, simplify drops the wrapper
entirely and P's own summands are all that's left. Comm on its own is
still rejected as UnsupportedConstruct; it's specifically the provably
inert case that becomes accepted, as a natural side effect of an axiom that
was never about implementing comm, only about recognising when it does
nothing.
A design principle carried forward to Milestone 3
Milestone 3's binary parallel composition (not built yet) has to solve a
larger-scale version of exactly the problem Alphabet::combined_with_limit
just solved: given P's and Q's already-computed summand sets, most
(summand_p, summand_q) pairs are dead — blocked, not matching any comm
expression, filtered by allow — and a summand (condition, effect,
bound-variable substitutions and all) is far more expensive to build than a
MultiActionName is to compare. The implementation plan writes down the
consequence explicitly, ahead of writing the code: pair the two summand sets
as a lazy iterator chain that checks the cheap multi-action-name key first
and only constructs the expensive merged summand for a pair that survives —
build-first-then-retain is the wrong shape for the same reason a
size-product pre-check was the wrong shape for combined, and for the same
reason: it pays for work a cheap check upfront would have avoided entirely.