Data enumeration
The one-point rule, and its dynamic gap
Closing a sum/∃/∀ variable means picking concrete constructor values
until the guard decides. The cheapest possible case needs no search at all:
if the guard contains a conjunct x == e for a bound variable x and a term
e that doesn't mention it, x can only ever be e — bind it directly and
drop it from the search. This is the one-point rule, the degenerate,
single-step case of narrowing (no unifier to compute, just reading a
conjunct). Note that reading it off a conjunct is the existential form:
∃x. x == e && φ(x) is φ(e), whereas the corresponding ∀ rule reads an
implication, ∀x. x == e ⟹ φ(x). Applying the conjunct form under a ∀
narrows the domain to the single point e rather than preserving the
quantifier's meaning. mCRL2 ships it as a separate linearisation pre-pass
(lps/one_point_rule_rewrite.h); merc_enumerate applies it as part of the
enumerator's own goal preprocessing instead, so it also benefits sum
exploration and nested quantifiers, not just the LPS's top-level guard.
What merc does today: a static pass
merc_enumerate runs the one-point rule once per goal, before search starts:
split the (already-rewritten) body into its top-level &&-conjuncts, repeatedly
find one of shape x == e / e == x for a still-unbound x, substitute, and
re-split the residual — a fixpoint, so a chain like
n == 5 && m == n
resolves in one pass: binding n first turns the second conjunct into
m == 5, itself a fresh one-point conjunct the same loop picks up before
returning.
The gap: a one-point conjunct hidden behind if
The fixpoint only ever looks at the original body's top-level conjuncts. A
conjunct that only becomes x == e shaped after some other variable has
been bound by ordinary constructor expansion — because it was sitting inside
an if whose condition only just collapsed — is invisible to it. Take:
map goal: Nat # Nat -> Bool;
var n, m: Nat;
eqn goal(n, m) = (n < 2) && if(n == 0, m == 50, m == 60);
Nothing here is a top-level x == e conjunct — the m == 50 / m == 60
branches are arguments of if, not conjuncts of the goal. So the static pass
leaves both n and m in the search. n gets bound by ordinary expansion
almost immediately (n = 0 is its smallest constructor value), and at that
point the guard rewrites to the plain conjunct m == 50 — a one-point
opportunity that has just appeared mid-search, one work-item expansion after
the goal was first split. Nothing revisits it: the search instead walks m
up from 0 one successor at a time until it happens to hit 50.
Measured directly (merc_enumerate::Enumerator::find_witness, InnermostRewriter,
counting calls to rewrite_with — one per branch the search actually
expands):
| Goal | rewrite_with calls to find the witness |
|---|---|
| the guard above, searched as written | 58 |
the same guard with n already fixed to 0 (if(0 == 0, m == 50, m == 60)), so the exposed m == 50 conjunct is one-point from the start |
1 |
Both numbers come from the real enumerator, not an estimate — the second column is exactly what a dynamic one-point pass (re-splitting the body into conjuncts after every work-item expansion, not just once up front) would achieve on this goal, simulated by handing the enumerator the post-collapse goal directly instead of implementing the re-scan.
Why merc doesn't do this yet
The static fixpoint already resolves every conjunct-chain case for free
(the n == 5 && m == n example above), so a dynamic pass would only earn its
keep on the narrower "hidden behind a non-&& connective, exposed by an
unrelated variable's binding" pattern this page demonstrates. It also isn't
free: re-splitting a potentially large body into conjuncts on every queue pop,
instead of once per goal, is real per-step overhead that has to be paid on
every search, including the overwhelming majority that never hit this
pattern.
Tracked as deferred work in docs/enumeration-crate-plan.md (§6.3/§8.4,
Phase 5) in the merc repository: worth implementing once a real
specification's guards are shown to hit this pattern often enough for the
57-call gap above to matter in aggregate — not before.
The same static split has a second blind spot. Both the one-point rule and
the variable ordering below work from split_conjuncts, which only flattens
top-level &&. A goal whose body is a top-level || — say
∃x,y . (x == 1 && P(y)) || (x == 2 && Q(y)) — hides its per-disjunct
one-point conjuncts behind that ||, and the search falls back to blind
joint enumeration of both disjuncts at once. Fixing it means splitting the
goal by quantifier polarity first (on || for ∃, on && for ∀) and
running the preprocessing per branch, which turns a single body into a
worklist of sub-goals — a bigger structural change than the heuristic it
would improve.
Which variable to expand first
Once the one-point rule has taken out everything it can, whatever is left has
to be searched, and the order matters. merc_enumerate ranks the surviving
variables once per goal by, first, how many top-level conjuncts each one is
the sole remaining free variable of, then by raw mention count, with ties
broken by the caller's declaration order.
The sole-survivor rule is the useful half: binding a variable that is the
only unbound one left in a conjunct makes that conjunct ground immediately,
so the search's reject test can decide it and prune the branch one step
earlier than it otherwise would. Measured on a goal with one vacuous variable
and one directly-constrained variable, declared in the "wrong" order,
find_witness needs max_items >= 18 with the ordering applied against
>= 22 without it. Real, but modest — the ranking runs once per goal and
never re-ranks the fresh variables that a constructor expansion introduces
at each level, which are appended behind whatever was already queued.
Like the one-point pass, this is a static approximation: it counts how many of the bound variables a conjunct still mentions, never what the conjunct evaluates to.
Bindings along a branch
The search is breadth-first, so many partially-instantiated branches are
outstanding at once, and each one extends a shared prefix of the bindings
chosen so far. Representing a branch's bindings as an owned map per work item
would copy that prefix on every expansion, so merc_enumerate keeps them as
a linked list of (variable, value) nodes in one arena owned by the
enumerator: extending a branch is a push, and every sibling that shares the
prefix stays valid. The arena is cleared, not dropped, between searches, so a
caller driving many searches through one enumerator — LPS sum-successor
generation is the motivating case — reuses its capacity.
A chain is deliberately not a general substitution. A variable's image may
mention variables bound later in the same chain (v ↦ c(y1, y2) where y1
and y2 are fresh variables introduced to expand v), so splicing a chain
into a term with other free variables outstanding would leave them behind.
It is only used that way to normalise the search's own goal body, where the
enumerator knows exactly which variable is being replaced; resolving a
finished branch to ground values instead walks the chain recursively.
That resolution has to rewrite as it goes, not only at the leaves.
Substituting already-normal arguments into a constructor can still leave the
composite reducible — under the machine-word Nat encoding, @succ_nat
applied to a normalised digit still needs its carry-propagating equation to
fire — and the values handed back to a caller are spliced straight into
further rewrite_with calls as substitution images, which requires them to
be normal forms.
Testing the enumerator against a second implementation
merc_enumerate ships a NaiveEnumerator alongside the real one, in the
same role merc_sabre::NaiveRewriter plays for the rewrite engines:
materialise every ground term of each variable's sort up to a size bound,
substitute all variables at once, rewrite once per combination. No
incremental normalisation, no pruning, no one-point rule, no fairness scheme
to get wrong. Random goals are then run through both and their solution
sets compared.
Two things make that suite cheap enough to run 200 goals per invocation.
Goals are built through the merc_data/merc_sabre API rather than
generated as source text, so neither the parser nor the typechecker runs per
goal. And the rewriters are built once, outside the loop: constructing an
InnermostRewriter compiles a SetAutomaton over every equation the
specification carries, which includes the whole lowered built-in library
(331 equations even for a specification declaring one small custom sort), so
rebuilding one per goal dominated the runtime of an earlier version of the
test — over a CPU-minute for 200 goals, against well under a second with the
rewriters shared. A real caller builds its rewriter once per run for exactly
the same reason.