Skip to content

The Set Automaton

merc_sabre — the Set Automaton Based Rewrite engine — finds every left-hand side that matches anywhere in a term in a single automaton walk, rather than trying each rewrite rule against each subterm in turn. The construction is based on

Erkens, R., Groote, J.F. (2021). A Set Automaton to Locate All Pattern Matches in a Term. In: Theoretical Aspects of Computing – ICTAC 2021. DOI

Goals, obligations, announcements

Every state of the automaton carries a set of MatchGoals — one per rewrite rule that could still fire from here:

pub struct MatchGoal {
    pub obligations: Vec<MatchObligation>,
    pub announcement: MatchAnnouncement,
}

pub struct MatchObligation {
    pub pattern: DataExpression,
    pub position: DataPosition,
}

pub struct MatchAnnouncement {
    pub rule: Rule,
    pub position: DataPosition,
    pub symbols_seen: usize,
}
  • An obligation is a sub-pattern of the rule's left-hand side that still needs checking, pinned to the position in the term it must hold at. Every goal starts with exactly one obligation — the whole left-hand side at the root, position ε.
  • The announcement position is where the goal itself is anchored — the position at which the rule fires once every obligation is satisfied. It also starts at ε, and only ever moves by having a common prefix stripped off once the automaton has actually distinguished this rule from its neighbours (see below).

Building the automaton is a fixpoint computation over states. For a state and a head symbol, State::compute_derivative sorts each of the state's goals into:

  • completed — the one remaining obligation is the symbol at this position, with only variables below it. The goal becomes an output (Transition::announcements) rather than part of any destination state.
  • discarded — an obligation at this position expects a different head symbol. Dead end, dropped.
  • unchanged — no obligation is pinned to this position at all; the goal carries forward untouched.
  • reduced — an obligation is pinned here and its symbol matches; it's replaced by one fresh obligation per non-variable argument, one level deeper.

The unchanged and reduced goals become the raw material for whatever state(s) this transition leads to.

Partitioning goals into destination states

A single transition doesn't have to lead to one destination. Goals whose remaining obligations sit under disjoint subtrees can be split across several independent destinations — doing so lets MatchGoal::greatest_common_prefix factor a longer common position prefix out of each group, which keeps that destination's label (and thus how much lookahead is needed before the automaton can say anything useful) tighter.

MatchGoal::partition groups goals by whether their announcement positions are "comparable" — one a prefix of the other:

/// Checks for two positions whether one is a subposition of the other.
/// For example 2.2.3 and 2 are comparable. 2.2.3 and 1 are not.
pub fn pos_comparable(p1: &DataPosition, p2: &DataPosition) -> bool

pos_comparable treats the empty position as comparable to anything — it returns true the moment either position runs out of indices to compare. At the very start of a match, before any rule has been distinguished from its siblings, every goal's announcement position is still ε; partition special-cases this ("if one of the goals has a root position, all goals are related") and puts everything in one group. That's correct and cheap: it happens once, doesn't recurse, and doesn't grow.

Separately, a transition also introduces fresh subtrees — the argument positions the head symbol's arity adds — each of which gets one brand-new goal per rewrite rule, since that rule's pattern hasn't been looked at down there yet. State::derive_transition has to decide, for each fresh position, whether it belongs with one of partition's existing groups or needs a destination of its own. This decision is where a real bug lived, fixed after being exposed by the machine-word work.

The partition-merge bug

Until this fix, the fresh-subtree decision reused the same announcement-position comparison partition uses internally — testing a fresh position against the deduplicated announcement positions of each group. That's unsound as a repeated test, because an announcement position only shortens once every member of its group shares a longer common prefix, and greatest_common_prefix forces that common length to 0 the instant any member — announcement or obligation — is already sitting at ε. So a group containing even one still-open, root-anchored goal keeps announcement position ε pinned indefinitely, at every state reachable while that goal survives, not only at the very first step. Since pos_comparable(ε, _) is always true, such a group absorbed every fresh subtree from then on, splicing in a fresh copy of every rewrite rule each time — so the state it produced never matched one already built, the construction's worklist never drained, and SetAutomaton::new never returned.

A worked example: + on machine-word Pos

merc's machine-word encoding gives Pos addition six equations:

(@most_significant_digit(w1) + @most_significant_digit(w2)) = ...;
(@concat_digit(p1,w1)        + @most_significant_digit(w2)) = ...;
(@most_significant_digit(w1) + @concat_digit(p2,w2))        = ...;
(@concat_digit(p1,w1)        + @concat_digit(p2,w2))        = ...;
(@succ_pos(p1) + p2) = @succ_pos((p1 + p2));
(p1 + @succ_pos(p2)) = @succ_pos((p1 + p2));

The first four match on the digit mappings @most_significant_digit/ @concat_digit, one case per combination of one-digit/multi-digit operand. The last two exist because, in this encoding, Pos's actual constructors are @c1 and @succ_pos — the digit mappings are only ever produced by equations, so an unreduced @succ_pos-headed term can still legitimately reach +, and these two equations are its home. Note their shape: each constrains only one argument (@succ_pos(p1), @succ_pos(p2)) and leaves the other (p2, p1) a completely unconstrained variable.

Building the automaton starts by matching the root + symbol, which turns each equation into a goal with one obligation per non-variable argument:

equation obligation at position 1 obligation at position 2
digit + digit @most_significant_digit(w1) @most_significant_digit(w2)
chain + digit @concat_digit(p1,w1) @most_significant_digit(w2)
digit + chain @most_significant_digit(w1) @concat_digit(p2,w2)
chain + chain @concat_digit(p1,w1) @concat_digit(p2,w2)
succ + * @succ_pos(p1) (none — p2 is a bare variable)
* + succ (none — p1 is a bare variable) @succ_pos(p2)

All six goals still announce at ε, so partition's root special case puts them in one group — correct so far, the same one-time special case described above. Now follow one branch: the construction tries symbol @succ_pos at position 1. For the four digit-based goals this is a mismatch (discarded). For "succ + " it's a complete match — @succ_pos(p1)'s only child is a variable, and that goal had no obligation at position 2 either, so the whole equation is completed right here and becomes an announcement, not a goal in the next state. For " + succ" there was never an obligation at position 1, so it carries forward unchanged, still holding only its position-2 obligation.

The resulting state has exactly one live goal, whose sole obligation sits at position 2 — the group's announcement position correctly shrinks away from ε, and picking @succ_pos at position 2 first instead makes no difference by symmetry. Six equations for +, on their own, settle in one step; that's not yet a bug.

What is enough to get stuck is for one of these lopsided goals — one operand pinned, the other a bare variable, its lone obligation left sitting at just position 1 or just position 2 — to still be in a group the next time the construction discovers a fresh subtree belonging to some other equation whose own obligation happens to sit at the sibling position. The old code decided whether to fold that fresh subtree into an existing group by testing it against the group's announcement positions, and a group holding one of these lopsided goals still announces at ε — because greatest_common_prefix can't produce a common prefix longer than ε while one member constrains position 1 and nothing else in that partition constrains position 1 at all. Since pos_comparable(ε, anything) is always true, such a group is a standing invitation: the next fresh subtree the construction meets, from whichever equation, gets folded in rather than started fresh, widening the group's positions again and leaving it just as open at ε for another round. Repeated across a whole specification where +, <, ==, *, … on Pos and Nat all repeat this same "succ + *" shape, there is always another candidate goal ready to keep some group open — exactly what the fix's own comment describes: such a partition "absorbed every subsequent fresh subtree without bound," so the construction's worklist never drained.

Why this doesn't happen with the ordinary binary rules

The Binary encoding's Pos addition — @addc in pos.mcrl2 — has equations with the same superficial shape (@addc(false,@c1,p) = succ(p) leaves p unconstrained, just like @succ_pos(p1) + p2 does above), so a transient ε-glued group can form there too. What it doesn't have is a second representation to keep re-triggering that shape. Pos's constructors in Binary mode are @c1 and @cDub — the same pair every equation is written against, with no generic-constructor counterpart layered on top: succ is only ever a plain mapping, fully defined by its own closed equations over @cDub, and it never appears as an argument pattern inside +'s own left-hand sides. So once @addc's handful of equations have been told apart, every surviving goal ends up needing the same one remaining position, the group's announcement position shrinks for good, and it stops being a magnet for unrelated subtrees.

Pos (and Nat) under MachineWord, by contrast, keep @succ_pos (@succ_nat) as a real constructor alongside the digit mappings precisely so that a not-yet-normalized term built the generic way still has somewhere to go — which means every arithmetic and comparison operator on these sorts (+, <, ==, *, …) needs its own "succ + " / " + succ" pair the way + does above. With one shared automaton covering the whole specification, one persistently ε-anchored group anywhere is enough: "absorbed every subsequent fresh subtree without bound," as the fix's own comment puts it, is exactly what happens when the merge test can't tell a group that has genuinely settled from one that's still open at the root.

The fix swaps the test to use each group's remaining obligation positions instead:

for goal in &group {
    for obligation in &goal.obligations {
        obligation_positions.push(obligation.position.clone());
    }
}

This is sound where the announcement-position version wasn't, because obligations reaching this point are never empty by construction — a goal with no obligations left is classified as completed and diverted to the transition's announcements before this code ever sees it (compute_derivative asserts exactly this). So ε only turns up among obligation positions when a rule's pattern is genuinely still anchored at the root — real, load-bearing overlap — never as a leftover from a rule that was simply matched once, long ago, and never revisited. A fresh subtree now only joins a group when one of that group's still-open obligations actually sits under (or over) it.

Note that this is a deviation from the original construction, which merges on announcement positions (and MatchGoal::partition itself still groups goals that way), not a restoration of it.

Effect on rewriting order

The fix is not purely a termination fix: it also changes the shape of the automaton and the order in which Sabre inspects subterms.

Every obligation of a goal sits at or below that goal's announcement position. If a fresh position is comparable to an obligation position it is therefore also comparable to the announcement position, so the obligation test only ever splits off fresh positions that the announcement test would have merged; it never merges anything new. The split-off case is a fresh position that lies below an announcement position but beside all remaining obligations, for example under a variable argument of the pattern. Such a position becomes a separate hypertransition destination to the initial state, so the automaton has more, smaller states.

Sabre explores the destinations of a hypertransition depth first in sorted position order: ConfigurationStack::grow follows the first destination and pushes the remainder as a side branch. This determines which redex is found, and applied, first.

  • Root matches still take priority. A state's label is chosen from its root-anchored goals only (State::new), so in the old merged state the fresh goals were not inspected while a root goal was alive. In the new version the group's greatest common prefix is a prefix of the split-off position, so the group sorts, and is explored, first. In both cases the root is matched before the split-off subterm is visited.
  • Sibling order changes once the root goals fail. Previously the fresh goals were re-partitioned together with the other fresh subtrees and visited in position order. Now the split-off subtree is only visited after the entire group branch has been explored.

This can go either way. Consider the rule f(y, g(x)) = x applied to f(t1, h(...)) where h(...) rewrites to a g-term. The new construction explores argument 2 first, whereas the old construction visits t1 first even though the rule discards it; here the new order is lazier. Whether there are cases where it is measurably less lazy has not been determined yet.