Precedence
merc and mCRL2 implement the same documented DataExpr precedence table.
In particular, quantifiers and lambdas have low precedence, while operators
such as negation, unary minus, and cardinality have high precedence. They
usually therefore assign the same grouping to ordinary expressions.
- merc first recognizes an expression as a sequence of operands and operators, then applies a Pratt parser to build the precedence tree. Each prefix operator parses its operand according to that operator's own binding power.
- mCRL2 uses a grammar parser with precedence and associativity annotations on productions. It can consider multiple valid parse trees and select one using those annotations.
For example, both parse exists d: Bool . d && q as exists d: Bool. (d &&
q): the low-precedence quantifier is the outermost expression, and its body
includes the conjunction. The difference discussed below appears only in
more deeply nested combinations of prefix and infix operators.
Divergence
They disagree the moment a tighter-binding prefix operator (!) is placed
directly in front of a looser-binding one (exists/forall), with an
infix operator following the quantifier's body:
!exists d: D . X && Y
merc parses this as !(exists d: D . (X && Y)) — negation wrapping the whole
existential, && inside its scope. mCRL2's actual parser parses it as
(!exists d: D . X) && Y — negation truncated to just the bare existential,
&& outside both it and the negation. This isn't a cosmetic
pretty-printing difference: the two readings bind X/Y (and any variables
they share with d) under genuinely different scopes, so a specification
that relies on the implicit grouping means something different — or fails to
type check — depending on which tool reads it.
merc's Pratt parser
Pest's PrattParser::expr is a standard operator-precedence-climbing
algorithm (nud/led, see
pratt_parser.rs
in the pest crate): a prefix operator's nud recurses into its operand
with a right-binding-power of own_precedence - 1, and that recursive call
is the only thing bounding what the operand consumes.
Tracing ! exists d: D . X && Y (using merc's actual levels: ! is 130,
exists is 20, && is 50 — see precedence.rs:123-144):
- Top level sees
!(prec 130) and recurses for its operand withrbp = 129. - That recursion's
nudimmediately seesexists(prec 20, not 130 — each prefix uses its own precedence regardless of what's enclosing it) and recurses again for its operand withrbp = 19. - That innermost call parses
X, then loops: is19 < prec(&&) = 50? Yes — so it consumes&& Yright there, before ever returning to theexistsor!frames. The existential's operand is alreadyX && Yby the time anyone outside it gets a say. - Both outer frames just wrap what they were handed:
exists d:D. (X && Y), then!(...).
The general rule this illustrates: once a Pratt parser enters a prefix
operator's nud, how far that operator's operand extends is governed solely
by its own precedence — an enclosing, tighter-binding prefix cannot claw back
what a nested, looser-binding prefix already consumed. This is a structural
property of precedence-climbing for chained prefix operators with
non-monotonic precedence (tight-then-loose), not a merc-specific bug, and it
is exactly why !X && Y (no quantifier in between) behaves the "expected"
way — there !'s own rbp = 129 is what bounds the operand, 129 <
prec(&&) = 50 is false, so ! stops right after X and && ends up
outside it, giving (!X) && Y. The quantifier is what changes the outcome,
by handing control to its own, much lower, precedence partway through.
mCRL2's reading, verified against the real parser
dparser is a GLR (generalized LR) parser: on genuinely ambiguous input it builds a shared packed parse forest of every valid derivation and then disambiguates using the priority/associativity annotations — a different mechanism from a single bounded recursive descent. We verified the actual behavior directly against mCRL2:
| expression | mCRL2 parses it as |
|---|---|
exists d: Bool . d && q |
exists d: Bool. (d && q) |
!exists d: Bool . d && q |
(!(exists d: Bool. d)) && q |
!exists d: Bool . d \|\| q |
(!(exists d: Bool. d)) \|\| q |
!exists d: Bool . d => q |
(!(exists d: Bool. d)) => q |
!exists d: Bool . d == q |
(!(exists d: Bool. d)) == q |
!!exists d: Bool . d && q |
(!!(exists d: Bool. d)) && q |
!forall d: Bool . d && q |
(!(forall d: Bool. d)) && q |
The pattern is consistent with the following model: for a span with more than
one valid derivation, dparser picks the derivation whose outermost connective
has the lowest priority number among the outermost connectives actually
competing for that position — not "each prefix operator bounds its own reach
independently," which is what the Pratt parser does. For the bare exists d. d
&& q, the competing roots are exists (1) and && (4); exists wins (1 < 4)
and swallows the &&. For !exists d. d && q, the competing roots are ! (12,
wrapping the maximal exists-through-&& reading) and && (4, with a minimal
!(exists d. d) as its left operand); && wins (4 < 12), which forces the
!/exists pair into the smallest shape compatible with that — d alone.
exists's own very low priority (1) never enters the comparison, because in the
winning derivation exists isn't a candidate root at all; it's nested two
levels down.
Literature
Parsing-technology research distinguishes two classes of priority/associativity ambiguity:
- A shallow conflict is one a disambiguation filter can resolve by looking only at a parse node and its direct children — the ordinary case of "which of these two immediately-adjacent operators wins."
- A deep conflict needs a pattern check at unbounded depth, because a
low-precedence operator can be shadowed by a higher-precedence one several
levels down a subtree's left or right edge — exactly the
!/exists/&&shape here, where the conflict betweenexists(1) and&&(4) is mediated through an intervening!(12) rather than being direct siblings.
This terminology and the underlying analysis come from Eelco Visser's group
at TU Delft: Klint & Visser first formalized declarative disambiguation as
filters over parse trees ("Using Filters for the Disambiguation of
Context-free Grammars",
1994) — the semantics SDF2 implements, and the same general shape as
dparser's per-production right N/left N/binary_op_* priority and
associativity annotations: declare a priority on each production, then prune
parse-tree patterns that would violate it.
Afroozeh, van den Brand, Johnstone, Scott & Vinju ("Safe Specification of
Operator Precedence Rules", SLE
2013) showed that filters of this kind are provably unable to resolve deep
conflicts: the very case where a low-priority prefix operator (exists) is
separated from the higher-priority operator it should or shouldn't absorb (&&)
by an intervening node (!).
Amorim, Steindorfer & Visser ("Towards Zero-Overhead Disambiguation of Deep
Priority Conflicts", The Art, Science,
and Engineering of Programming 2(3), 2018, article 13) name and classify the
pattern as an "operator-style conflict" — their own minimal example is
if(e) e2 + e3 mixed with an outer +, structurally identical to
!(exists d. body) mixed with an outer &&: a lower-priority prefix
(if/exists) directly followed by a higher-priority infix (+/&&). They
state the interpretation everyone actually wants — "the addition to the left
of the conditional expression extends as far as possible," i.e. the
low-priority prefix's body swallows the high-priority infix — is the
supposedly correct one; the other reading, where the prefix's body gets
truncated and the infix escapes outward, is the one every disambiguation
technique they survey is trying to rule out.
Resolution
That "correct" reading is
precisely merc's !(exists d: D . (X && Y)) — and the "wrong" one their
paper is written to rule out is precisely what mCRL2's dparser produces here.
Their own related-work section singles out exactly the mechanism dparser
uses — a flat priority/associativity annotation per production, filtered by
checking only a node's direct parent-child relation, with no grammar
rewriting and no memory of what's structurally nested inside what (this is
the semantics Klint & Visser's filters give SDF2, and it's the same shape as
mcrl2_syntax.g's right N/binary_op_* annotations) — as provably
unable to solve deep conflicts: "[SDF2's filter semantics] only targets
conflicts by checking a parent-child relation in a tree, [so] this solution
is not able to solve deep priority conflicts." Getting an operator-style
conflict right instead requires either rewriting the grammar into new
non-terminals that track "what kind of thing can legally sit at this edge"
(the contextual grammars technique the 2018 paper builds on, and the
tree-automaton-restricted grammars of Adams & Might, "Restricting Grammars
with Tree Automata", OOPSLA 2017) or a
parser extended with genuine data-dependency to track that context at parse
time — machinery mcrl2_syntax.g doesn't have.
Precedence climbing — the family Pratt parsing and merc's PrattParser
belong to (traced back to Clarke, The Top-down Parsing of Expressions,
1986, and popularized for hand-written parsers by Pratt's 1973 "Top Down
Operator Precedence") — sidesteps the whole conflict class by construction,
not by explicitly handling it: a prefix operator's reach is always settled by
one bounded recursive call using its own precedence, so the algorithm never
builds an ambiguous forest that needs filtering afterward in the first place.
That's why merc lands on the literature's "correct" reading without any
special-casing for this shape — it's a parser architecture that the deep/
shallow conflict distinction doesn't apply to, not a grammar that happens to
get this one case right.