Skip to content

Design document: individually addressable rewrite rules (#746 item 50) #825

Description

@Rafael-SOWNet

Design document for item 50 of #746 — "the rule registry: turn the pattern set into enumerable, attributable data without regressing Simplify performance".

The registry half landed in #818: all thirty rule sets the simplifier applies are named, attributed and enumerable, and Simplify got faster (allocation 157.6 → 125.8 KB). What remains is the finer grain — making the individual rewrites addressable, which is what #28 actually asks for and what #819 could only approximate at rule-set level.

The reason given for not doing it was a performance claim, and I made it repeatedly — in the code, in the docs, on #28 and on #746:

Splitting each case into its own object would replace one dispatch per node with one delegate call per rule per node on the hottest path in the library.

I measured it. It is not true. This document is mostly that measurement, because the whole decision turned on it.

What was measured

Fifteen rules transcribed from Patterns.CommonRules, in four shapes, applied through Entity.Replace over a 259-node expression (the one DotnetBenchmark uses for SimplifyHard). All four produce identical output, verified by structural equality, and identical allocation.

shape
A one switch, as the library writes it today
B a flat list of rules, each a delegate, tried in order
C the same rules bucketed by the node type they match, selected by Dictionary<Type, …>
D the same buckets, selected by a type switch instead of a dictionary

Method: 3000 warmup iterations, then the minimum of seven rounds of 3000 — the minimum rather than the mean because every source of error here adds time, so the fastest round is the least interfered with. net10.0, Release, x64.

Results

A switch (today)              0.0054 ms        1.27 KB
B flat list                   0.0086 ms        1.27 KB
C bucketed, dictionary        0.0036 ms        1.27 KB
D bucketed, type switch       0.0022 ms        1.27 KB

Reproduced across three separate process runs, within ±0.0004 ms.

  • The naive split (B) is ~1.6× slower — so the objection is not imaginary, it is just about the naive shape only.
  • Bucketing by node type (C, D) is 2.3× faster than the switch we have.

The mechanism is unsurprising once seen: most of the 259 nodes cannot match any rule in the set, and a type-indexed dispatch rejects those in one test, while a source-ordered switch works through the arms.

Does it hold as the set grows?

Fifteen rules is the small end; Patterns.CommonRules has about forty arms. Twenty further rules were added to both shapes, on node types this expression does not contain — so the answer is unchanged and only the dispatch grows:

A' switch, 35 rules           0.0025 ms        1.27 KB
D' bucketed, 35 rules         0.0024 ms        1.27 KB

The switch gets faster when it gets bigger — from 0.0054 to 0.0025 — and lands exactly on the bucketed registry. That is Roslyn: given enough arms over enough distinct types, it stops emitting sequential type tests and emits a real type dispatch.

Which is the most useful thing in this document. The compiler is already doing the bucketing. A registry that dispatches on node type is not a tax we would be paying for addressability — it is the same strategy, written down as data instead of inferred from the source order of a switch.

What follows

The performance argument against per-rule granularity does not survive measurement, at either size, in either direction. Nothing here is a reason not to proceed.

The real costs are elsewhere, and they are about people rather than machines:

  1. Transcription risk. Splitting forty arms by hand into forty objects is forty chances to change a pattern silently. This is the serious objection, and it is not addressed by any of the above.
  2. Per-rule metadata has to come from somewhere. Bucketing needs each rule's outer node type. Written by hand it is one more thing to get wrong and keep in sync.
  3. Order is semantics. First-match-wins within a bucket must preserve the source order of rules that can match the same node, or confluence changes. The measurement above verified identical output, which is evidence, not proof.

All three point at the same answer: a source generator over the existing switch bodies, rather than a hand transcription. The pattern already states the node type in its outermost position, and the arm order is already the priority order — so the generator has everything it needs, the switch stays the thing a human edits, and the registry becomes a build artefact that cannot drift from it. That also keeps #746's constraint that extensibility must not be bought with runtime reflection.

What I am not proposing

Not equality saturation, not an e-graph, not a rewrite of the rule sets. Only that the rewrites the library already has become individually named, so that #28 can be answered at the grain it asks for and a derivation can say which rule fired rather than which set.

Corrections owed

The claim this document refutes is currently written into the repository in four places — RewriteRuleSet.cs's remarks, Docs/Contributing/Transformations.md, and my comments on #28 and #746. I will correct all four regardless of what is decided here; an unmeasured performance claim sitting in the source as justification is worse than the design question being open.

Limits of the measurement

One rule set, one expression, one machine, one runtime. The filler rules in the scaling test never match, so it stresses dispatch and not within-bucket ordering; a set where many rules match the same node type would exercise that instead. The harness is small and I am happy to hand it over or extend it — in particular, running it against the real Patterns.CommonRules rather than a fifteen-rule transcription would settle the remaining doubt.

Activity

  1. Rafael-SOWNet commented on Aug 14, 2026

    @Rafael-SOWNet
    MemberAuthor

    Two findings from building rulecheck, a harness for #746 tier 2's "confluence and termination checked by tooling rather than asserted by authors". Both are input to the design this issue is about rather than defects, so they are here rather than in issues of their own.

    1. The declared metadata distinguishes nothing

    All 30 rule sets in RewriteRules declare TransformationRelation.Equivalence and Soundness.SoundUnderAssumptions. Every one, identically.

    So no tool can hold a set to a stronger claim than its neighbour, and the Soundness field carries no information at the moment it is read. Tier 2 asks for a "justification tier" as part of rules-as-data; for that to be worth having, it has to be data that varies — a set whose rules are unconditionally true over ℂ is a different thing from one whose rules need a domain assumption, and today they are spelled the same.

    The one thing that is checkable as written is Equivalence, and checking it found #936 — RewriteRules.NumericNeat answering 2 for -1 + -1, a wrong answer through public API that no Simplify-based harness could see, because the rule only fires on numerals and evaluation had already covered for it.

    2. A rule set is not a terminating rewrite system on its own

    Measured over 1365 applications on generated input, iterating each set to a fixed point two ways — alone, and with InnerSimplified between passes, which is how simplifyChildren actually composes them:

    0 never settle
    8 cycle when iterated alone, settle once the normalisation runs between passes
    0 settle alone but not composed

    The eight are in NumericNeat (3), Power (3) and Common (2). --x under NumericNeat grows -1 * 1 * 1 * 1 * ... for ever, because the rule keeps producing a unit factor that only the normalisation folds. (-x)^2 under Power alternates between (-1)^2 * x^2 and its regrouping.

    None of this is a defect in the simplifier — the pipeline composes them exactly the way that terminates, and I checked that before writing it down, having first recorded them as eight non-termination bugs and been wrong. But it matters here, because the premise of individually addressable rules is that a caller reaches for one on its own. Today:

    • nothing in RewriteRuleSet says whether iterating it terminates;
    • nothing says it is only half of a rewrite system whose other half is InnerSimplified;
    • and ApplyOnce is public, so a caller can already write the loop that does not end.

    Whatever shape the design takes, termination wants to be a property the type records and a tool checks, in the same way this issue wants provenance and cost recorded. The harness is in the analysis workspace (work/rulecheck) and its report names the eight, so the check exists and can be pointed at whatever the rules become.

  2. Rafael-SOWNet commented on Aug 14, 2026

    @Rafael-SOWNet
    MemberAuthor

    Third measurement, and the one most directly about priorities: 161 of the 435 rule-set pairs do not commute.

    rulecheck now applies A-then-B against B-then-A, normalising after each so the comparison is between settled trees rather than half-finished ones. Every one of the 435 pairs does something in at least one order; 161 of them land somewhere different depending on which order they ran in.

    first second example A then B B then A
    CanonicalOrder Common (1/2)^(1/2) sqrt(2) / 2 1/2 * sqrt(2)
    Common Power ln(1/2) ln(1/2) -ln(2)
    CanonicalOrder CollapseMultipleFractions 2 / x 2 / x 2 * 1 / x
    CanonicalOrder Factorization 0 ^ x 0 provided x / 2 * (...) > 0 0 provided 1/2 * (...) * x > 0

    Almost all of these are presentation rather than soundness — sqrt(2)/2 and 1/2 * sqrt(2) are the same number, ln(1/2) and -ln(2) the same value. That is what makes it a priorities question rather than a bug list: the order is not deciding whether the answer is right, it is deciding which of several right answers a user sees, and Simplify then breaks ties by generation order (see SimplifiedRate — differently-shaped forms rate equal far more often than one would hope).

    So the hardcoded sequence in Simplificator.simplifyChildren is load-bearing for output in a way nothing records. Two consequences for the design here:

    1. A pair that commutes may be reordered freely and a pair that does not may not. 274 of 435 commute; those are free. The other 161 are where "rule priorities" has to mean something, and they are now enumerated rather than guessed at.
    2. Priority cannot be a single number per rule if the goal is a predictable form, because the conflicts are pairwise and not obviously transitive. Whatever the design chooses, this matrix is the thing to check it against, and re-running it after a change says whether the change moved the conflict set.

    The harness and its report are in the analysis workspace (work/rulecheck, work/rulecheck.md); the matrix is regenerated by running it, so it can be diffed across a change rather than trusted from this comment.

  3. Rafael-SOWNet commented on Aug 14, 2026

    @Rafael-SOWNet
    MemberAuthor

    I tried to act on my own suggestion — audit the 30 sets and give the Soundness tier something to vary over — and the audit's result is that it cannot vary at set granularity, which is a sharper argument for this issue than the observation that it currently doesn't.

    Soundness.cs says the conservatism is deliberate: "the registry starts conservative on purpose: tightening a label needs an argument, loosening one does not." So the exercise is to produce the argument per set. I could not produce one for any of them, and the reason is the same every time.

    A set's tier is the minimum over its arms, and every set is a heterogeneous bag. Two worked examples:

    • NumericNeat is the best candidate there is — every arm is a sign rearrangement among real numerals, (-a) + (-b) = -(a + b) and so on, unconditional. Except the four that regroup a quotient: (-n·a)/c → -(n·(a/c)) holds only where c ≠ 0. The enum lists "wherever both sides are defined" as an assumption, so those four arms are SoundUnderAssumptions and they set the tier for all eighteen.
    • SetOperator is the next best — A ∩ A = A, A ∪ A = A, A \ A = ∅, distributivity over a union, all unconditional for sets. Except x ∈ [a, b] paraphrased into inequalities, which differs at complex x: i ∈ [0, 1] is false and 0 <= i and i <= 1 is NaN, the same branch that made p or not p unsound.

    Every other set touches division, a branch cut or a domain-sensitive rewrite somewhere in its switch.

    So the tier belongs on the rule, not on the set — which is exactly what this issue is for. And the reason it cannot be put there today is not that nobody has got round to it: a rule set is a switch expression, so its arms are not values, cannot be enumerated, cannot be counted, and cannot carry a tier each. Individually addressable rules are the prerequisite for the metadata being informative, rather than a nice-to-have alongside it.

    Two things done rather than proposed:

    1. rulecheck now enforces the declaration instead of merely reporting against it. A set declaring Sound that changes a value at a point where both sides are defined is an error; the same observation under SoundUnderAssumptions stays information, because that tier permits it and no tool can tell an assumption from a mistake. Today that reads 0 of 30 declare Sound and 0 violations, which is honest and uninteresting — the point is that the moment a label is tightened, something checks it, so tightening can be incremental and safe.
    2. The report records the tier distribution, so "the metadata still distinguishes nothing" is a number that moves rather than a remark someone has to notice.
  4. Rafael-SOWNet commented on Aug 15, 2026

    @Rafael-SOWNet
    MemberAuthor

    Implemented and merged in #951.

    What landed against this document:

    • A source generator over the existing switch bodies, as the "What follows" section argued for — 347 rules across 16 rule sets, each with its pattern, guard, replacement, source line, node types and direction.
    • RewriteStep now names which rewrite fired rather than which set, which is the grain Add a way to collect intermediate pattern replacements when simplifying #28 asked for.
    • The transcription objection — the one this document said the measurement did not dispose of — is answered by construction rather than by care: each arm is copied verbatim as syntax into the generated code, so a divergence is a compile error rather than a silent change.

    Two things worth recording here, since this document is the place they will be looked for.

    The generator found four unreachable arms. Three in Patterns.Common.cs, one in Patterns.Power.cs, each an exact duplicate — same pattern, same guard, same result — of an arm above it, and so unable to ever fire. The compiler cannot warn about them, because a guarded arm never marks a later arm unreachable. They are removed, no answer changes, and a test now fails the build if another appears. That is the "order is semantics" concern in this document turning up something real on the first run.

    The measurement that made item 51 depend on this one has now been re-run, and it holds. work/egraph was re-run with rules carrying their direction: withholding the 60 rules that expand ends the saturation blow-up inside the addressable half — every expression saturates, none above 19 e-nodes, where the same configuration with all rules reaches thousands — while still finding sin(x)^2 + cos(x)^2 = 1. Direction was the binding constraint.

    What is left, and what this document did not anticipate: the 14 sets that are not a switch — the sorts, polynomial division, the factorial rules — have no arms to generate from, so they can state nothing. They are now the binding constraint for item 51 rather than the rule grain is.

    Leaving this open rather than closing it: the design is implemented, but it was never marked Accepted, so whether it is the shape you want is still yours to say.

  5. 31 remaining items

  6. Rafael-SOWNet commented on Sep 7, 2026

    @Rafael-SOWNet
    MemberAuthor

    Closing as delivered. Since the comment above (27 of 30 sets), #1192 repointed the last three — the CanonicalOrder family — so every one of the 30 registered rule sets runs as data and describes the rules it runs; the switch spellings remain only because the agreement test holds the two to each other. A step names the arm that fired and carries the rule itself (pattern, guard, replacement, growth, source line), RewriteStep.Soundness is the rule's own tier rather than its set's, and every rule carries a name, an identity and a growth declaration or a reason beside it for not having one (#1195–#1197). What this document asked for under #746 item 50 is on master; what remains around the graph is tracked on #746 itself and on #1200.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Design documentFor issues representing detailed design of new API or feature

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions