Skip to content

The e-graph moves from a harness into the kernel, behind an explicit opt-in - #1101

Open
Rafael-SOWNet wants to merge 3 commits into
masterfrom
tier2-inverse-pair-table
Open

The e-graph moves from a harness into the kernel, behind an explicit opt-in#1101
Rafael-SOWNet wants to merge 3 commits into
masterfrom
tier2-inverse-pair-table

Conversation

@Rafael-SOWNet

Copy link
Copy Markdown
Collaborator

Summary

#746 tier 2's row names two things remaining "in dependency order": no production caller for the reversible-rule mechanism, and folding on insertion. This closes the second (already proven in the work/egraph measurement harness — 7/16 → 15/16 corpus expressions saturating under the full rule set, neutral-element churn 13–34% → 0%) and gives the first a real, narrowly-scoped answer.

  • EGraph — the harness's e-graph design (union-find, hash-consed e-nodes, congruence rebuild, neutral-element folding on insertion) moved into the kernel unchanged, as an internal type.
  • MatchPattern.ConstructConstructNode — widened from private to internal (as ConstructNode) so extraction rebuilds a node's type from the same registry the pattern-matching layer already trusts, instead of a second, independently-drifting operator list.
  • Transformation.EqualitySaturation(WorkBudget, CostModel) — the production caller. Builds an e-graph from the input, fires only rules whose RewriteRuleGrowth is Collects or Rearranges (withholding Expands and Unknown — unproven isn't the same as safe), bounded by AngouriMath.Core.Budgets.BudgetLedger (the same budget type Gröbner elimination already answers to), and extracts the cheapest candidate under the given CostModel.

Nothing runs this by default — same standing as RationalCanonicalization/Canonicalization. Simplify applies a rule set once and moves on; equality saturation deletes exactly that ordering, which is why it needs a budget rather than a pass count.

What this is not, stated in the doc comment rather than left implicit: the harness this is built from enumerates a class's terms and rewrites each — it finds what e-matching would, but by materialising terms a real e-matcher never builds. That instrument moved here unchanged; a production e-matcher over MatchPattern is not this, and tier 2 still names it as the production caller's other missing half. Nor does a 16-expression textbook corpus settle whether this generalises to what Simplify is actually asked to handle — the budget parameter is the honest acknowledgment of that, not a solved problem's formality.

A flake chased down, not papered over

a / b / c rewritten to a / (b * c) occasionally failed a numeric equality check under dotnet test, and never once across 2,300+ raw concurrent calls to the same code in an isolated process. Both chains are the same value reached by differently-ordered complex divisions; comparing them with the exact equality ExpressionNumerical.AreEqual uses is comparing two floating-point rounding paths, not the value each settles on. Fixed by verifying against Entity.EqualsImprecisely — the tolerance this library already has for exactly that comparison — rather than loosening what the transformation itself promises.

Test plan

  • TDD throughout: EGraph's union-find, hash-consing, congruence rebuild, and neutral folding (all 5 operator cases, including the 2 — 0 - x, 1 / x — that must not fold) each have a test that failed for the right reason before the fix.
  • Transformation.EqualitySaturation tested for its stated relation/soundness, that it declines to change an already-cheapest expression, that it never throws under a starved budget, and — across the existing TransformationTest.Corpus — that it never changes the value it claims to preserve (numerically, at several real check points).
  • Full suite: 8838 tests, 8824 passed, 14 skipped (pre-existing), 0 failed.
  • PublicApi.txt regenerated for the one new public member (Transformation.EqualitySaturation) — diff is exactly that one line.

🤖 Generated with Claude Code

…opt-in

#746 tier 2's own row names two things as remaining "in dependency order":
no production caller for the reversible-rule mechanism, and folding on
insertion. The second is done (this workspace's `work/egraph` harness,
proven against a 16-expression corpus: 7 of 16 saturating under the full
rule set became 15 of 16, neutral-element churn from 13-34% to 0%). This
gives the first a real, if narrowly-scoped, answer.

EGraph is that harness's design moved here unchanged: e-classes over a
union-find, e-nodes hash-consed by operator and child class, congruence
restored by Rebuild, neutral elements folded on insertion rather than
discovered later by a rule. `MatchPattern.Construct` -- until now `private`,
used to build a rule's right-hand side from bindings -- is exposed as
`ConstructNode` so extraction rebuilds a node's type from the same registry
the rest of the pattern-matching layer already trusts, rather than a second,
independently-drifting list of the same operators.

`Transformation.EqualitySaturation(WorkBudget, CostModel)` is the caller:
builds an e-graph from the input, fires every rule whose `RewriteRuleGrowth`
is `Collects` or `Rearranges` -- withholding `Expands` and `Unknown` for the
same reason, unproven is not the same as safe -- against
`AngouriMath.Core.Budgets.BudgetLedger`, the same budget type Gröbner
elimination already answers to, and extracts the cheapest candidate under
the given `CostModel`. Nothing runs this by default, the same standing as
`RationalCanonicalization` and `Canonicalization`: `Simplify` applies a rule
set once and moves on, so an expanding rule and a collecting one never meet,
and equality saturation deletes exactly that ordering.

What this is not, stated in its own doc comment rather than left implicit:
the harness this is built from enumerates a class's terms and rewrites each,
which finds what e-matching would but by materialising terms a real
e-matcher never builds. That instrument moved here unchanged. A production
e-matcher over `MatchPattern` is not this, and tier 2 still names it as the
production caller's other missing half. Nor does a 16-expression textbook
corpus settle whether this generalises to what `Simplify` is actually asked
to handle -- the budget is the honest acknowledgment of that, not a solved
problem's formality.

One test flake chased down rather than papered over: `a / b / c` rewritten
to `a / (b * c)` occasionally failed a numeric equality check under
`dotnet test` and never once under 2,300+ raw concurrent calls to the same
code in an isolated process. The two chains are the same value reached by
differently-ordered complex divisions, and comparing them by the exact
equality `ExpressionNumerical.AreEqual` uses is comparing two floating-point
rounding paths, not the value each settles on. Fixed by verifying against
`Entity.EqualsImprecisely` -- the tolerance this library already has for
exactly that comparison -- not by loosening what the transformation itself
promises.

TDD throughout: EGraph's union-find, hash-consing, congruence rebuild and
neutral folding (all five operator cases, including the two -- `0 - x`,
`1 / x` -- that must not fold) each have a test that failed for the right
reason before the line that makes it pass. `PublicApi.txt` regenerated for
the one new public member.
Tried the obvious move: give RewriteRule a Reversed the same way AsAddressable()
already gives it a Growth. Measured against the live registry rather than
assumed to work -- grep says AsAddressable() is called exactly once, for
RationalizeDenominator, and every other set's addressable Rules still comes
from RuleRegistryGenerator reading a switch's arms, kept around after the
exchange purely for that. Wiring Reversed into AsAddressable() alone therefore
changes nothing for 29 of 30 sets, and the one it does reach has two
code-built, non-reversible rules -- so the change measured zero reversible
rules registry-wide and was reverted rather than shipped speculative.

Recorded here rather than in a commit message that stops being read: what a
real fix costs (extending the generator to compute reversibility from syntax,
or re-deriving Rules from AsAddressable() for every converted set and
reconciling two independent renderings that have never been compared), and
why RationalizeDenominator specifically is the wrong set to prototype against.
Not fixed here -- estimating which option is worth its cost is a separate
question from measuring that the cheap option does not exist.
@Rafael-SOWNet

Copy link
Copy Markdown
Collaborator Author

Added a follow-up commit: while working on this, tried extending Reversed/IsReversible onto the public RewriteRule the same way Growth is exposed there (tier 2's own text names the inverse-pair table as needing exactly that). Measured against the live registry before shipping it, found it changes nothing for 29 of 30 sets (only RationalizeDenominator flows through AsAddressable(), and its rules are non-reversible anyway), and reverted the code rather than ship something inert. Docs/Contributing/InversePairTable.md records what a real fix would need, so the next attempt starts from there instead of re-discovering it.

…erve

EGraph.Extract rebuilt every node through a bare constructor, which
restores neither Entity.Codomain nor the reference identity that keeps
EulerIntrinsic out of a binder over the name e -- both confirmed
wrong-answer bugs, both fixed with a regression test at the EGraph level
and at the public Transformation.EqualitySaturation level.

The other thirteen findings from the same review are recorded in
EqualitySaturationReviewFindings.md rather than fixed here: several
point at the same underlying gap (the e-graph's node model has nowhere
to carry metadata beyond raw tree shape), which is a design question
worth its own pass rather than a patch alongside these two.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@Rafael-SOWNet

Copy link
Copy Markdown
Collaborator Author

A code review of this PR and #1102 found fifteen issues before either had a human reviewer. Two were confirmed wrong-answer bugs and are fixed in the latest commit, each with a regression test at the EGraph level and at this PR's own Transformation.EqualitySaturation level:

  • Codomain loss on every reconstructed node. EGraph.Extract rebuilt through a bare constructor (MatchPattern.ConstructNode), which restores nothing — the rest of the codebase copies Codomain forward on every Replace via a New(...) helper. A Codomain-narrowed subexpression (sqrt(-1) restricted to the reals evaluates to NaN, unrestricted to i) silently reverted to its type's default codomain, unconditionally, whether or not any rule fired.
  • EulerIntrinsic identity loss. The e-graph keys a leaf by its printed form, and Entity.Constant.EulerIntrinsic prints identically to the ordinary named constant e — the two are Equals-equal by design but only EulerIntrinsic is meant to stay outside what a binder over the name e can capture. Re-parsing the printed form silently substituted the named constant, invisible to every equality check and only wrong at a binder (sum(ln(x), e, 1, 2) after a round trip through the graph).

The other thirteen findings are recorded in EqualitySaturationReviewFindings.md rather than fixed here — several (a Providedf-wrapped result unioned onto a dead end, Extract's 14-type reconstruction whitelist, a NaN CostModel corrupting comparison) point at the same underlying gap: the e-graph's node model has nowhere to carry anything beyond raw tree shape. That's a design question worth its own pass rather than a patch alongside these two. The rest (budget-charge timing, an unused NodeTypes pre-filter, a duplicated operator-type list, RewriteRecording blindness, SimplifiedRate cache staleness on #1102) are each small and independent, and are listed there for whoever picks one up next.

Full suite green: 8828 passed, 14 skipped (pre-existing), 0 failed.

Rafael-SOWNet added a commit that referenced this pull request Aug 29, 2026
11 TDD tasks covering EBindings, NodeCount/Growth on MatchPattern and
MatchedRule, the EGraph helpers e-matching needs, CanEMatch/EMatch/
ETryBuild on all four pattern kinds, MatchedRule.TryEMatchApply, and
rewiring EqualitySaturationTransformation to source from
Matching.MatchedRules. Also rebases this branch onto
tier2-inverse-pair-table (#1101), which EGraph.cs and
EqualitySaturationTransformation only exist on.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
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