Skip to content

Condition caching

a condition cache is a bounded, LRU-evicting map from a condition side's already-substituted term to its normal form, filled lazily as the computation runs. It exists because checking a conditional rewrite rule's if clause can end up normalising the same subterm more than once: two sibling rules whose conditions disagree on the same computed value:

  odd(s(N)) -> true if even(N) = true
  odd(s(N)) -> false if even(N) = false

Or a rule's condition getting re-checked, on the same concrete argument, from several unrelated call sites of a deep recursion (evalexpr.rec's succ17, see below). Without a cache, a specification whose conditions are cheap on their own can still blow up combinatorially once those conditions are checked repeatedly this way — oddeven.rec is the sharpest example: 2^(n+1)-1 rewrite steps uncached versus n+1 cached.

Both engines carry a cache unconditionally now (::new picks a default capacity; ::with_condition_cache_capacity picks another). Since the key is the substituted term rather than any rule's unsubstituted pattern, which variable name a rule happens to bind an argument to never enters into the lookup at all — merc_aterm's maximal sharing means two structurally equal substituted terms are the same interned value (structural equality is pointer equality), so two entirely unrelated rules that happen to compute the same concrete subterm still share the cache entry.

Static caching

evalexpr.rec's succ17 — successor on a 17-bit bounded natural, wrapping back to Z at the bound — is a sibling-conditioned rule:

succ17(r) -> Z    if eq(r, S(S(...16 S's...(Z)))) = true
succ17(r) -> S(r) if eq(r, S(S(...16 S's...(Z)))) = false

Both rules share the same left-hand side and the same condition subterm eq(r, S^16(Z)) — a static per-rule (or sibling-group) cache finds this one on its own: when the first rule's condition fails, the second rule's identical check reuses the already-computed result instead of recomputing it, no dynamic cache required.

What a static cache cannot see is how many different, unrelated places in the specification end up asking succ17 that same question. plus17, mult17 and exp17 all recurse through succ17:

plus17(r, S(t)) -> succ17(plus17(r, t))
mult17(r, S(t)) -> plus17(r, mult17(r, t))
exp17(r, S(t))  -> mult17(r, exp17(r, t))

and eval17 — which recursively evaluates an arithmetic expression tree — calls into all three, plus itself, at every node:

eval17(Exs(n))     -> succ17(eval17(n))
eval17(Explus(n,m)) -> plus17(eval17(n), eval17(m))
eval17(Exmult(n,m)) -> mult17(eval17(n), eval17(m))
eval17(Exexp(n,m))  -> exp17(eval17(n), eval17(m))

The spec's actual EVAL goal, lambda0(m) -> eq(eval17(m), evalexp17(m)) (with evalexp17(n) -> eval17(expand(n))), then runs two structurally different evaluations of related expressions side by side. Across that whole computation, succ17's condition ends up being checked on the same concrete SNat value from many separate, structurally unrelated call sites — not from two rules announced at the same match, which is the only shape a static analysis of the rule set could ever predict ahead of time, since it depends on which concrete numbers a particular run actually produces, not on how the rules are written. Measured: InnermostRewriter with only the (removed) static cache took 19,698,985 rewrite steps on evalexpr.rec's EVAL terms; with the dynamic cache, 8,265,493 — well under half, all from catching exactly this pattern.

Scope: condition sides only

The cache is only consulted where a condition side is normalised, not during ordinary matching. Two rules f(c) -> a and g(c) -> b, called on the same expensive-to-normalise c with no condition in sight (e.g. h(f(c), g(c))), still normalise c twice — that is matching-argument sharing, a different problem from condition-side sharing.

f(x) -> a if x = c
g(y) -> b if y = c

by contrast — c inside two different rules' own conditions rather than their arguments — the cache does reach: whichever of the two is checked second reuses the first's already-normalised c, the same way it reuses succ17's already-checked condition above, regardless of the rules being otherwise unrelated.

A real limitation: nested occurrences within one condition side

The cache is keyed on the result of evaluating one condition side's pattern as a whole — it is only ever consulted once per side, not once per subterm within that side. So not(E) = true and E = true — E nested inside the first condition's pattern, but the entirety of the second's — do not share E's computation: the first condition's cache entry is keyed on the whole not(E), never on E alone, so the second condition's lookup on bare E misses and recomputes it from scratch.

Measured directly with a synthetic slow(x) spec of exactly this shape (slow takes n steps to normalise): not(slow(x)) = true alongside slow(x) = true takes 403 steps (slow(x) computed twice); both conditions written as slow(x) in full (no nesting) takes 201 (computed once, one cache hit).

The removed static per-rule cache did not have this gap, but did slow down overall rewriting because it had to maintain and check a more complex cache structure for every subterm of every condition side.

Caching every subterm instead

The obvious way to close both gaps at once is to stop treating condition sides as special: cache a normal form for every subterm the rewriter settles, wherever in the stack machine that happens. Evaluating a condition side is just another rewrite job to the stack machine, so such a cache reaches condition sides, subterms nested inside them, and plain rule arguments — the f(c)/g(c) case above — with one mechanism.

That reach is not free. The condition cache does one lookup per condition side; a general term cache does a lookup, plus records a pending entry to fill in on a miss, once per application subterm. RewritingStatistics:: symbol_comparisons counts exactly the automaton-traversal work a hit skips, so it answers directly what a hit is worth:

Spec symbol comparisons avoided term cache hits avoided per hit
fibonacci05 1,084 4 271.0
bubblesort100 515,011 25,248 20.4
oddeven 575 140 4.1
tak18 2,462 222,732 0.011
evalexpr 615 6,229,866 0.0001

Where the cached subterm is large and structurally repeated — a memoised recursive call's result, a long already-sorted list suffix re-examined on every pass — a hit skips tens to hundreds of symbol comparisons. Where it is a small, already-cheap-to-reject leaf, as in the deep-arithmetic specs, a hit avoids essentially nothing while still paying the fixed per-candidate cost.

Over the 41-spec REC suite that split shows up as a net loss, because the suite's wall-clock mass sits in exactly the second shape:

Spec steps (default → term cache) ms (default → term cache) ratio
fibonacci05 480 → 160 0.086 → 0.034 0.40
bubblesort100 177,074 → 177,073 78.3 → 44.6 0.57
tautologyhard 1,035 → 370 0.127 → 0.082 0.64
evaltree 15,208,761 → 15,208,761 7,230 → 8,450 1.17
evalexpr 8,265,493 → 8,265,493 3,670 → 4,480 1.22
tak18 112,303 → 112,303 37.0 → 56.1 1.52
all 41, summed — 22,700 → 27,000 1.19
37 of 41 (excl. the 4 slowest) — 581 → 583 ~1.00

Every result matched its snapshot and no step count ever went up, so the mechanism is correct and does buy real sharing — closure drops 11.5% in steps yet is still marginally slower, which is the per-candidate overhead showing through even where the cache is genuinely earning something.

So the generalisation stays out of both engines: it has no way to tell the two shapes apart before paying to check. A cheap proxy for "worth watching" — skipping a subterm whose arguments are already machine numbers or bare symbols — is the concrete next step if this is revisited. The experiment's code is crates/sabre/src/matching/term_cache.rs in the merc repository; it deliberately knows nothing of the stack machine driving it, dealing only in configuration-stack depths, subterms and result slot indices.