Skip to content

Thirty-five arms become thirty: Power is data (#746) - #1076

Merged
Rafael-SOWNet merged 1 commit into
masterfrom
feat/power-is-data
Aug 26, 2026
Merged

Thirty-five arms become thirty: Power is data (#746)#1076
Rafael-SOWNet merged 1 commit into
masterfrom
feat/power-is-data

Conversation

@Rafael-SOWNet

@Rafael-SOWNet Rafael-SOWNet commented Aug 26, 2026

Copy link
Copy Markdown
Collaborator

The largest switch in the transformation layer, and the one carrying the most
conditions. Patterns.PowerRules has thirty-five arms; five separate issues — #752,
#801, #802, #902 and #721 — each put a branch-cut guard on one rewrite in it. As data
it is thirty rules.

The guards are asked, not restated. MayTakeLogOfPower, MayGatherLogarithms and
ReduceRadical stay in Patterns.Power.cs and the rules call them. Copying a branch-cut
condition into a second file is how the two copies come to disagree, and one of these has
already been wrong in both directions at once (log(-3, -3) is 1 where a written-out
a > 0 calls it undefined; log(1, 1) is NaN where that guard calls it 1).

Two rules read what a Bindings cannot carry. log_b(b) = 1 takes the overload
handed the matched node, so the condition it attaches is the node's own DomainCondition.
The radical reduction asks ReduceRadical in both its when and its replacement, because
whether it applies and what it produces are one computation.

Six arms become two commutative patterns — a power times its own base, and a power
times a product containing its base. They fire exactly where the pair and the quadruple
did, since the switch writes every orientation out; firesWhereTheSwitchDoesNot is empty.

What this PR cost, and what it produced

The kernel gate failed the first version of this branch on allocation — SolveHard
+4.4%, SolveMediumHard +5.0%, past its 3% band. Not time, and not noise: allocation
reproduces to 0.033%.

The cause was the two commutative patterns. A commutative pattern is not
IsDeterministic, so TryApply enumerated its two candidates through an iterator state
machine per pattern node, at every node of every pass. My own pre-push measurement had
looked at Simplify, whose allocation is identical to the byte on every build here — so
the regression was invisible to it. The gate measures eighteen entry points because one of
them is not the others.

Writing the six arms out as node patterns fixed it, at the cost of the collapse. That was
a bad trade for a rule set language, so it became #1079#1080: a bounded pattern is
now walked by index rather than enumerated. With that in, the commutative form is the cheap
one:

SolveMediumHard, allocated
master (2c082da4) 163,425,360 B
this branch, two commutative rules 164,959,936 B — +0.94%
the same, before #1080 171,367,976 B — +3.8%, gate red

SimplifyEasy is 129,648 B on both, to the byte.

What the differential run caught. Two arms bind their operands as Number, so 1 - c
folds to a literal in the switch where Entity arithmetic builds a Minusf. Both
disagreed on the first run of PowerAsDataMatchesTheSwitch over 3,464 generated
expressions, and neither is a case anyone would have written down by hand.

One test made stronger. EveryDataRuleIsClassifiedAsWritten now asserts that a rule
name never stands for two classifications. Power and Factorization deliberately carry
the same guarded a^b * c^b; without the assertion, a name meaning two things would have
listed the rule twice rather than failed.

Part of #746 tier 1.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Bjumi5K7fg8yx6UK1mZTQd

Rafael-SOWNet added a commit that referenced this pull request Aug 26, 2026
`MatchedRule.TryApply` had two paths: a pattern that `IsDeterministic` is asked for a
single match and allocates nothing, and anything else is enumerated through `Match`,
which is a chain of iterator state machines, one per pattern node, built at every node
of the tree a pass visits.

Between the two sits a case that is neither and is common. A `Commutative<T>` node of
deterministic children offers the written order and the swapped one and nothing else --
two candidates, known before it is asked. Enumerating those two costs the whole iterator
chain, and on a set that runs on every pass it is measurable: `RewriteRules.Power` as
data with two commutative rules took `SolveMediumHard` from 165.05 MB to 171.37 MB,
+3.8%, past the kernel gate's 3% band, and had to be written out as six node patterns to
land (#1076).

So a pattern now says how many candidates it can offer at most -- `ChoiceCount`, or
`Unbounded` where it cannot say -- and can be asked for the nth of them without an
iterator. A node's count is the product over its children, doubled when commutative;
`Gathered` is unbounded, because how many ways k parts sit among n operands is a
property of the expression rather than of the pattern, and anything containing one is
unbounded with it.

The index is a mixed radix over the children with the first the most significant digit,
because `MatchInOrder` makes it the outermost loop -- so indexing yields the candidates
in the order enumeration yields them, which matters: `TryApply` takes the first that
satisfies the rule's `when`, so two implementations agreeing on the set of matches and
differing on the order are two different rewriters. The count is an upper bound rather
than a count, since a child whose name is already bound offers one candidate or none
depending on what it is asked to match; an index that does not exist answers false and
is skipped, exactly as an enumeration omits it.

`BoundedMatchingAgreesWithEnumeration` holds the two implementations together over every
rule in `MatchedRules` and the corpus already there, asserting count, order and bindings,
and asserting that at least one pattern did match more than one way -- or it would be the
deterministic test again under another name.

Measured on master, with no new rule set: `SolveMediumHard` 165,054,016 B to
163,400,736 B, -1.00%. The sets already converted carry commutative patterns, so the
saving is there to collect before anything else is exchanged.

The set list in `DeterministicMatchingTest` was five names, written when there were five
and still five while `MatchedRules` grew past twenty -- so the test whose subject is
"every rule" was looking at a tenth of them. It reads the class now.


Claude-Session: https://claude.ai/code/session_01Bjumi5K7fg8yx6UK1mZTQd

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…e most guarded

`Patterns.PowerRules` is the biggest `switch` in the transformation layer and the one
carrying the most conditions: five separate issues -- #752, #801, #802, #902 and #721 --
each put a branch-cut guard on one rewrite in it. Written as data, its thirty-five arms
are thirty rules, and every one of those guards is *asked of the same helper it was
written in* rather than restated here. A branch cut copied into a second file is how the
two copies come to disagree, and one of these has already been wrong in both directions
at once.

Two rules needed something a `Bindings` cannot carry. `log_b(b) = 1` holds wherever the
node is defined at all, so it takes the overload handed the matched node and reads the
node's own `DomainCondition`; and the radical reduction asks `ReduceRadical` in both its
`when` and its replacement, because whether it applies and what it produces are one
computation.

Six `switch` arms collapse into two commutative patterns -- a power times its own base,
and a power times a product containing its base -- which fire exactly where the pair and
the quadruple did, since the `switch` writes every orientation out.

**That collapse is affordable only because of #1080.** A commutative pattern is not
`IsDeterministic`, so before bounded matching it enumerated its two candidates through an
iterator state machine per pattern node, at every node of every pass. The kernel gate
failed this branch on allocation for exactly that: `SolveHard` +4.4% and `SolveMediumHard`
+5.0%, reproduced locally as 165,054,016 B to 171,367,976 B. Writing the six arms out
fixed it at the cost of the collapse; walking the candidates by index fixes it without,
and the same two rules now measure 164,959,936 B against master's 163,425,360 B, +0.94%
and inside the band. `SimplifyEasy` is 129,648 B either way, to the byte.

Two arms bind their operands as `Number` rather than as `Entity`, so `1 - c` folds to a
literal in the `switch` where `Entity` arithmetic would build a `Minusf`. The agreement
run over 3,464 generated expressions found both; the data form casts and folds the same
way. This is the whole value of running the two forms over generated expressions rather
than over cases someone thought of.

`EveryDataRuleIsClassifiedAsWritten` takes a name's classification as distinct rather than
merely distinct-by-pair: `Power` and `Factorization` deliberately carry the same guarded
`a^b * c^b`, and one name standing for two classifications would have listed the rule
twice below rather than failed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bjumi5K7fg8yx6UK1mZTQd
@Rafael-SOWNet
Rafael-SOWNet merged commit 8c5157b into master Aug 26, 2026
31 checks passed
@Rafael-SOWNet
Rafael-SOWNet deleted the feat/power-is-data branch August 26, 2026 20:15
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant