Match across a chain, so a rule about two terms can find them among five (#248, #746 v1.0) - #942
Conversation
…ive (#248) #746 v1.0 asks for pattern matching "with commutative and n-ary matching handled by the engine". #938 built the commutative half and said plainly that the n-ary half was not there: a + b + c could not match x + y. This is that half. MatchPattern.Gathered<T>(restName, parts...) flattens an associative chain with the LinearChildren the library already has, finds the parts among the operands in any positions, and binds what is left to restName. Where nothing is left, restName is bound to the operator's identity -- 0 for a sum, 1 for a product. That is not a convenience. The empty sum is zero and the empty product is one, so one rule covers both sin(x)^2 + cos(x)^2 and the same pair buried in a longer sum, and no right-hand side has to ask which case it is in. Because the operands come from LinearChildren, the subtractive and divisive spellings are already normalised: a - b offers a and (-1) * b. A rule needs no second arm for them, which is one of the ways the switch sets multiplied. The search is bounded by measured work rather than by a reasoned size limit, as #921 settled for the resultant. Assigning k parts to n operands is n!/(n-k)! attempts; past the ceiling the enumeration stops yielding, so the rule does not apply rather than taking unbounded time. Declining is a legitimate answer where a hang is not. MatchedRules.PythagoreanIdentity is the demonstration, and it is the first set here with no switch to be checked against, because the switch cannot express it. Patterns.TrigonometricRules spends two arms on this identity -- one per operand order, since it has no commutative matching -- and both match only two children of one Sumf. So the pair is found in sin(x)^2 + cos(x)^2 and missed in a + sin(x)^2 + b + cos(x)^2. The library's answer is to sort the operands with CanonicalOrder before the rules run so the pair lands adjacent; that works, and it is the matcher's limitation showing through as a pipeline stage. One Gathered rule replaces both arms and handles the buried case. Nothing runs the new set, so no answer changes and there is no BREAKING-CHANGES entry. Everything here is internal; PublicApi.txt is unchanged. Measured: suite 7141 passed / 0 failed, 24 of them new. The bound is checked by counting solutions rather than by timing a run: three holes over eight terms enumerates exactly 8*7*6, and five holes over twenty stops well short of 20*19*18*17*16. #248 #746 Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…and close the gap (#248) #746 v2.0 says a slower Simplify for the common case is unacceptable and that the fast path must survive as a path. Nothing had measured whether a rule expressed as data can be that path, and the answer decides whether #248's migration is possible at all, so it is worth knowing before two thousand lines of rules are written against it. DotnetBenchmark.MatchingEngine asks it: the same rule as a switch arm and as data, on a node it rewrites and on a node it does not. The miss is the case that matters, because a rewrite pass asks every rule about every node and nearly every answer is no. As first written, the data form was disqualifying: miss 2.7 ns and no allocation -> 58.5 ns and 296 B hit 24.1 ns and 216 B -> 294.3 ns and 1696 B 296 bytes per rule per node is GC pressure on every node of every tree, which is not a factor to engineer around. Three things were responsible, and all three are fixed here. Bindings was a Dictionary copied on every bind -- a whole map allocated per hole per attempt, in a search that backtracks. It is a cons list sharing its tail now, so a bind is one small object. Immutability there is a correctness requirement rather than an optimisation: a branch that fails must leave nothing behind for the branch tried next. Match is now a non-iterator wrapper over a virtual MatchCore, guarded by the pattern's declared root node type. A type mismatch returns a shared empty sequence, so it allocates nothing and does not even construct the iterator state machine. The guard must never reject what the full match would accept, so RootType is null wherever the constraint is not exactly one type: Exact, because two entities can be equal without sharing a runtime type, and Gathered, because a sum chain is Sumf or Minusf. MatchedRuleSet walked its rules through the IReadOnlyList property, which boxes an enumerator. That was the last 32 bytes. It keeps the array and walks that; Rules still exposes the list, since being enumerable is the point. Measured after, anchored on DivisionSwitchMiss, which is 2.737 ns in the first run and 2.757 in the last: miss 58.5 ns and 296 B -> 14.1 ns and nothing hit 294.3 ns and 1696 B -> 232.4 ns and 1264 B A type-mismatched miss now allocates nothing at all, which is the switch's own figure. It is still about five times the switch in time, and a hit is still about twenty times and six times the allocation. Two things this does not claim. A miss that is *not* a type mismatch still does the work and still allocates -- PythagorasDataLongMiss is 351 ns and 1136 B, because the expression really is a sum and the assignments really are tried. And nothing here says Simplify is unaffected, because nothing runs these rules yet; what fraction of a pass is rule dispatch is not known, and the way to find out is to convert one real set, run it, and measure end to end. That experiment is worth doing now and was not before. The benchmark project is signed with the library's key, as UnitTests is, so it can name the internals the question is about. Measured: suite 7141 passed / 0 failed. No answer changes; nothing runs the data rules. #248 #746 Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
And then measured what it costs, because that decides whether #248 can happenA second commit. #746 v2.0 says a slower
As first written, the data form was disqualifying:
296 bytes per rule per node is GC pressure on every node of every tree. That is not a factor to engineer around; it ends the design. Three causes, all fixed here:
After
A type-mismatched miss now allocates nothing at all — the switch's own figure. On reading these numbers honestlyThe Two things this does not claim
The data form is still ~5× the switch on a miss and ~23× on a hit. That is a candidate to engineer with, where 296 B per miss was not. The benchmark project is signed with the library's key, as Suite 7141 passed / 0 failed. No answer changes. |
#248) The per-node numbers said the data form was five times the switch on a miss. The unit the pipeline actually spends is a pass -- every node of a real expression offered to a rule set -- so this measures that, with the same rules on both sides: the switch is Patterns.DivisionPreparingRules and the data set was proven to agree with it in #938. PassSwitch 135 ns 648 B PassData 1385 ns 4552 B Ten times the time and seven times the allocation, for one rule set on one modest tree, where Simplify runs about thirty of them repeatedly. That answers the question this benchmark was written for: the hot rule sets cannot be migrated to this form as it stands, which is what #746 v2.0 means when it says the fast path must survive as a path. An index was the obvious reply, and it is the one thing a switch cannot do: a switch tests its arms in order, while rules that are values can be grouped by the node type each one requires, so that a node is only offered the rules that stand a chance. It was built -- a per-runtime-type cache keyed on the node's type, with IsAssignableFrom so a rule requiring Number still sees an Integer, and filtering that preserves order so first-match-wins picks the same rule. It did not pay for itself, and it is recorded here rather than kept. per-node miss 0 B -> 24 B per pass 4552 B -> 5464 B Both figures reproduced exactly on a repeat run of the same build, so they are real and not noise -- while DivisionSwitchMiss, which nothing touched, moved from 2.85 ns to 7.99 ns between those same two runs. Allocation is the measurement here and the timings are not, which is the standing guidance in this repository and is why the time improvement the index appeared to give could not be claimed: it sat inside that drift. Two attempts at it. The first used the GetOrAdd overload that takes a factory, and the lambda closed over `this`, so it allocated a delegate on every call including every cache hit -- 64 bytes a node, to avoid work worth less than that. Looking the type up first fixed that and left 24 bytes a node which was never accounted for; a warm ConcurrentDictionary lookup should allocate nothing. An optimisation that makes the deterministic column worse for a reason nobody can name is not one to ship, so it is out, with a comment where it was so the next person measures rather than assumes. The finding underneath is that the index was aimed at the wrong half. Dispatch is not where the cost is -- the match attempt is, because a node that does match its type builds an iterator per child per level of nesting. The lever that would matter is a matcher that does not allocate: an explicit stack in place of yield return. That is a larger change and it now has a number to beat. Measured: suite 7141 passed / 0 failed. No answer changes; nothing runs the data rules. #248 #746 Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
The pass-level number, which is the one that decides the migrationPer-node figures said the data form was ~5× the switch on a miss. But the unit the pipeline spends is a pass — every node of a real expression offered to a rule set. Same rules on both sides (#938 proved the two agree), one modest tree:
10× the time and 7× the allocation, for one rule set. So the answer to the question this benchmark was written for is no: the hot rule sets cannot move to this form as it stands. That is exactly what #746 v2.0 means by "the fast path must survive as a path". An index was the obvious reply, and it did not pay for itselfIt is also the one thing a
Both reproduced exactly on a repeat of the same build. Meanwhile Two attempts. The first used the An optimisation that makes the deterministic column worse for a reason nobody can name is not one to ship. It is out, with a comment where it was so the next person measures instead of assuming it was never tried. What the failure actually showsThe index was aimed at the wrong half. Dispatch is not where the cost is — the match attempt is, because a node that does match its type builds an iterator per child per level of nesting. The lever that would matter is a non-allocating matcher: an explicit stack in place of None of this blocks what #248 was needed for. Per-rule soundness, addressable rules for #825, and e-matching all work at these speeds; it is only replacing the hot Suite 7141 passed / 0 failed. No answer changes. |
The pass measurement said the cost is in the match attempt rather than in the dispatch, and named the reason: MatchCore is an iterator, so a state machine is allocated for every node of the pattern at every attempt, and a rewrite pass makes an attempt at every node of the tree. Most rules do not need any of that. A pattern with no commutative node and no gathering in it can fit an expression in at most one way, so there is nothing to enumerate and nothing to backtrack over. MatchPattern.IsDeterministic says which patterns those are and TryMatchOnce matches them directly, threading the bindings left to right. Indeterminacy is inherited: a node containing a commutative child anywhere below it is itself a search. A flag that only looked at the node would take the fast path on a pattern with two answers and silently pick one of them. Allocation, which is the figure that reproduces on this machine: one pass over a tree 4552 B -> 888 B, against 648 B for the switch one hit 1288 B -> 296 B, against 216 B for the switch one miss 0 B -> 0 B, as before That is the whole of what disqualified this form. Seven times the switch's allocation on a pass is GC pressure at every node of every tree; 1.37 times is a cost to weigh. Time is not solved and is not claimed to be. The pass is around 1030 ns against 130 for the switch, so roughly eight times, and what remains is virtual dispatch and type tests rather than allocation -- the switch does inline what this does through the pattern tree. Whether eight times on one rule set matters to Simplify depends on what fraction of a pass is rule dispatch, which is still not measured. The Gathered path is untouched at 2712 B, because it really is a search. It throws if asked for a single match rather than guessing at one. Two implementations of matching now exist, so DeterministicMatchingTest holds them together: every rule in the registry over thirty expressions, requiring that a pattern claiming determinism never matches more than one way, that both matchers agree on whether it matches, and that they bind the same values. It asserts it checked more than a hundred pairs, because a test that silently checks nothing passes just as loudly. That the sets still rewrite the same thing end to end is left to MatchedRulesAgreeWithTheSwitchTest, which already ran both forms against each other and now reaches the fast path to do it. Measured: suite 7143 passed / 0 failed. No answer changes; nothing runs the data rules. #248 #746 Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
The allocation gap is closedThe pass measurement said the cost is the match attempt, not the dispatch, and named the reason: Most rules never needed it. A pattern with no commutative node and no gathering fits in at most one way, so there is nothing to enumerate and nothing to backtrack over.
Seven times the switch's allocation on a pass is GC pressure at every node of every tree — that was the disqualifying fact. 1.37× is a cost to weigh. What is not solved, and is not claimed to beTime. The pass is ~1,030 ns against ~130 for the switch, so roughly 8×. What remains is virtual dispatch and type tests, not allocation: the switch does inline what this does by walking a pattern tree. Whether 8× on one rule set matters to
Holding two matchers togetherThere are now two implementations of matching, which is how a matcher acquires a case where they differ. It also asserts it checked more than a hundred pairs — a test that silently checks nothing passes just as loudly, which is a mistake I made earlier in this same PR with the budget test. Indeterminacy is inherited: a node with a commutative child anywhere below it is itself a search. A flag that looked only at the node would take the fast path on a pattern with two answers and silently pick one. There is a test for that buried case specifically. That the sets still rewrite the same thing end to end is left to Suite 7143 passed / 0 failed. No answer changes. |
…nd (#248) A pass over a tree with one rule set as data costs about ten times what the switch costs. That was measured here and it is true, and on its own it says nothing about whether the migration is affordable, because it does not say what fraction of simplification is rule dispatch. So the exchange was made and measured. RewriteRules.DivisionPreparing was pointed at MatchedRules.DivisionPreparing.ApplyHere instead of Patterns.DivisionPreparingRules -- a set Simplificator runs twice per iteration -- and the library was measured through its own entry points. The suite passed, 7143 of 7143. That is a stronger statement than the agreement test which already covered these two forms: seven thousand real assertions went through the data matcher rather than thirty generated expressions, so the exchange is a behavioural non-event and not merely an agreeing one. Allocation did not move. Simplify 84,402 B -> 84,402 B SimplifyQuotient 7,325,772 B -> 7,328,796 B (+0.04%) Expand, Factorize, Differentiate, Rationalization identical Time moved a little: Simplify 46,819 ns -> 49,209 ns, about five percent, while SimplifyQuotient and Rationalization did not move at all and the isolated single pass moved eighteen percent. The ten-times figure does not reach the caller. Dispatch is a small part of what simplification spends, which is the thing that was unknown when the pass measurement was taken and is the reason a microbenchmark ratio was never enough to decide this. Five percent for one of some thirty sets is not free either. The conclusion is not "migrate everything" but "migrate where a set's rules need to be data", which is where a rule has to carry its own soundness, be named in a report, or be matched against an e-class. The exchange itself is reverted: a five percent cost buys nothing until something is built on it. What is kept is the number, in the place someone deciding this next will look. Measured on this tree, both columns on one machine, allocation read first because the timings on it move by more than this between runs of unchanged code. #248 #746 Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
The pass ratio does not reach the caller — measured, not arguedA pass with one rule set as data costs ~10× the
It is a behavioural non-eventThe suite passed, 7143 of 7143. That is a stronger statement than the agreement test that already covered these two forms: seven thousand real assertions went through the data matcher, rather than thirty generated expressions. Allocation did not move
Time moved a little
What this changesThe 10× does not reach the caller. Dispatch is a small part of what simplification spends — the thing that was unknown when I took the pass measurement, and the reason a microbenchmark ratio was never enough to decide this. I said earlier in this PR that the hot sets "cannot be migrated as it stands"; that was too strong, and this is the measurement that corrects it. But 5% for one of some thirty sets is not free either. The conclusion is not migrate everything. It is migrate where a set's rules need to be data — where a rule must carry its own soundness, be named in a report (#825), or be matched against an e-class. The exchange itself is reverted: 5% buys nothing until something is built on it. What is kept is the number, recorded on Allocation read first throughout, because the timings on this machine move by more than 5% between runs of unchanged code — |
#746 v1.0 asks for pattern matching "with commutative and n-ary matching handled by the engine". #938 built the commutative half and said plainly that the n-ary half was missing:
a + b + ccould not matchx + y. This is that half, and it is the last piece of #248's matching engine.Flattens the chain with the
LinearChildrenthe library already has, finds the parts among the operands in any positions, and binds the leftovers torest.The empty sum is zero
Where nothing is left over,
restis bound to the operator's identity —0for a sum,1for a product. Not a convenience: the empty sum is zero and the empty product is one. So one rule covers bothsin(x)^2 + cos(x)^2and the same pair buried in a longer sum, and no right-hand side has to ask which case it is in.Because the operands come from
LinearChildren, the subtractive and divisive spellings arrive normalised —a - boffersaand(-1) * b. A rule needs no second arm for them, which is one of the ways theswitchsets multiplied.What it buys, stated against the code rather than in the abstract
Patterns.TrigonometricRulesspends two arms on the Pythagorean identity:Two, because there is no commutative matching. And both match only two children of one
Sumf, so the pair is found insin(x)^2 + cos(x)^2and missed ina + sin(x)^2 + b + cos(x)^2.The library's answer is to sort the operands with
CanonicalOrderbefore the rules run, so the pair lands adjacent. That works — and it is the matcher's limitation showing through as a pipeline stage. OneGatheredrule replaces both arms and handles the buried case, andMatchedRules.PythagoreanIdentityis that rule.It is the first set in that file with no
switchto be checked against, because theswitchcannot express it. It is checked against the mathematics instead.Bounded, because this is where the explosion lives
Assigning k parts to n operands is n!/(n−k)! attempts. #746 warns that combinatorial explosion is the whole difficulty of the rewrite graph, so the search is bounded by measured work rather than by a reasoned size limit — the same choice #921 settled on for the resultant. Past the ceiling the enumeration stops yielding, so the rule does not apply rather than taking unbounded time. Declining is a legitimate answer where a hang is not. Two parts stay under the bound until 100 operands.
Measured
Suite 7141 passed / 0 failed, 24 of them new.
The bound is checked by counting solutions, not by timing a run — a timing assertion is flaky in CI, and this one is exact: three holes over eight terms enumerates exactly 8·7·6 = 336, and five holes over twenty stops well short of 1,860,480.
Worth recording, because the first version of that test was worthless: it used a pattern that failed at the first hole, so the search died at depth 1 having spent 60 of its 10,000 budget. It passed, and proved nothing. The replacement builds prefixes that all match and fail only at the last hole, which is the case the bound is actually for.
Nothing runs the new rule set, so no answer changes — no
BREAKING-CHANGES.mdentry. Everything here is internal;PublicApi.txtis unchanged.🤖 Generated with Claude Code