Skip to content

Make a mathematical operation a value, and put the 1.x entry points on it (#746) - #816

Merged
Rafael-SOWNet merged 3 commits into
masterfrom
feat/transformation-layer-core
Aug 8, 2026
Merged

Make a mathematical operation a value, and put the 1.x entry points on it (#746)#816
Rafael-SOWNet merged 3 commits into
masterfrom
feat/transformation-layer-core

Conversation

@Rafael-SOWNet

Copy link
Copy Markdown
Collaborator

Closes nothing on its own; this is the first layer of #746, with consumers in the same change.

What was wrong

The top-level mathematical operations are procedures you invoke. There is nowhere to say what one of them claims about its output, how well justified the claim is, or that it could not settle the question — and nowhere to compose two of them, because a Func<Entity, Entity> carries none of that. #746's argument is that this interface is the ceiling on everything built above it.

What this adds

AngouriMath.Core.Transformations, public and documented as experimental:

Transformation Name, Relation, Soundness, Apply — plus Then, Repeat(n), UntilStable(max)
TransformationResult input, output-or-nothing, which transformation ran. A struct, so routing an ordinary call allocates nothing
RewriteRuleSet a named, attributed group of rewrites
RewriteRules the registry: ten shipped sets, explicitly listed, enumerable in a fixed order

Three things it insists on, each the honesty rule in a different place:

  • TransformationRelation is Equivalence or Derivation. "Sound" is only a statement about some relation, and a derivative is not another way of writing its integrand — so the property test that subtracts output from input only runs against the transformations that claim equivalence.
  • Soundness is declared, not checked. Every shipped rule set is SoundUnderAssumptions, and a test over RewriteRules.All holds it there: promoting one means changing that test and saying why in the same change. Nothing in the catalogue claims Heuristic — that is a statement about what is registered so far, not about the library.
  • No answer is null. This is the one place the new layer is more honest than the method it backs: Transformation.Integration("x") has no answer for e^(x^2), where Entity.Integrate returns an unevaluated Integralf. Same claim, different shape; neither is NaN.

UntilStable reports hitting its bound as no answer rather than as whatever value it was holding, so a rule set that does not converge is visible instead of silently truncated.

What now uses it

Entity.Simplify(level)     ->  Transformation.SimplificationAtLevel(level)  ->  Simplificator.Simplify
Entity.Expand(level)       ->  Transformation.ExpansionAtLevel(level)       ->  Entity.ExpandOverSum
Entity.Factorize(level)    ->  Transformation.FactorizationAtLevel(level)   ->  RewriteRules, composed
Entity.Differentiate(x)    ->  Transformation.Differentiation(x)            ->  Entity.DifferentiateOnce
Entity.Integrate(x)        ->  Transformation.Integration(x)                ->  Integration.ComputeIndefiniteIntegral
Entity.Limit(x, to, side)  ->  Transformation.LimitAt(x, to, side)          ->  LimitFunctional.ComputeLimit
Simplificator.SimplifyChildren  ->  a chain composed from four registry entries

Two of these are real ports. Factorize no longer names its own rules — it is PerfectSquare, then Factorization, then a tidying pass, repeated level times, built out of the registry. SimplifyChildren, which every stage of the simplification pipeline runs, is a chain composed once, statically, from four registry entries instead of a hand-written run of Replace calls. The rest are thin adapters. Nothing that worked was rewritten to make the architecture tidier.

What is deliberately not here

  • Solve is not a transformation. It consumes a goal and produces a solution set, and belongs in a tactic layer that does not exist yet. Entity.Set being an Entity means Solve would type-check as Entity -> Entity — which is the reason to keep it out, since it would compile while saying nothing true.
  • No inverse machinery. Expand and Factor are not inverses and Unsolve is not well defined.
  • Most of the pattern table is untouched and still reachable only from Simplificator. Ten sets are registered — the ones the catalogue is built from.

Extensibility constraints

Registration is static and explicit — no assembly scanning, no Activator — so the layer stays trimmable and NativeAOT-publishable, and RewriteRules.All is in an order that does not depend on hashing or on which type loaded first.

One trap is worth naming, because it cost a full-suite abort during development: RewriteRuleSet builds its Transformation on demand. Doing it in the constructor makes the registry and the catalogue depend on each other's static initialisation, and whichever type is touched second reads the other's fields before they are set — so the failure is a null field in whichever order the process happened to load them, not a failing assertion. There is a comment on the field and a smoke test pinning it.

Measurements

Release, net7.0, against master 21f0d16f, two samples each, same expressions as DotnetBenchmark/CommonFunctionsInterVersion. Every timing range overlaps the baseline's. Allocation is deterministic and is the reliable comparison:

master this branch
ParseEasy 17.6 KB 17.6 KB
ParseHard 3405.8 KB 3405.4 KB
SimplifyEasy 158.6 KB 157.6 KB
SimplifyCommon 6976.8 KB 6973.6 KB
SimplifyHard 3616232 KB 3616197 KB
Differentiate 51.6 KB 51.6 KB
SolveEasy 19789.6 KB 19789.6 KB
Limit 7664.8 KB 7660.8 KB

Identical or slightly lower. Patterns.SortRules(level) returns a fresh closure on every call and SimplifyChildren used to build one per invocation; it is now built once, which is where the difference comes from.

Tests

dotnet test Sources/Tests/UnitTests5699 passed, 0 failed, 14 skipped (5551 + 149 new, the same 14 skips as master).
dotnet test Sources/Tests/FSharpWrapperUnitTests130 passed, 0 failed.

The new tests cover the abstraction; that each 1.x method answers exactly what its transformation answers, including Factorize at levels 0–3; determinism; that an equivalence transformation does not change the value of the expression, checked by simplifying the difference and stripping the domain condition rather than by comparing printed forms; that unsupported integrals and limits stay honest; and that no registered rule set rewrites in a cycle.

Compatibility

No behavioural change, so no BREAKING-CHANGES.md entry — the suite is byte-identical in outcome and the public signatures are untouched. The new surface is additive.

Sources/.editorconfig gains a path-scoped file_header_template so the new directories carry the current year; the existing 367 files keep the header they were written with rather than having a year bump folded into this diff.

Documentation: Docs/Contributing/Transformations.md, linked from the contributor index, with a section in AGENTS.md on the three habits the layer asks for.

What comes next

The unit here is the rule set, not the single pattern -> replacement line, because every rewrite in this library is a case of one switch and the compiler turns that into a type test and a jump — splitting each case into an object would trade one dispatch per node for one delegate call per rule per node, on the hottest path there is. Making individual rewrites addressable without paying that is the next piece of work, and nothing here forecloses it. After that: the goal/tactic layer Solve belongs in, then derivations.

🤖 Generated with Claude Code

Rafael-SOWNet and others added 3 commits August 8, 2026 12:45
…n it

The top-level operations are procedures you invoke: `Simplify`, `Expand`,
`Factorize`, `Differentiate`, `Integrate`, `Limit`. There is nowhere to say what
one of them claims about its output, how well justified the claim is, or that it
could not settle the question -- and nowhere to compose two of them, because a
`Func<Entity, Entity>` carries none of that. That is the first layer #746 asks
for, and this adds the smallest version of it that has real consumers.

`AngouriMath.Core.Transformations` is:

  Transformation        Name, Relation, Soundness, Apply -- plus Then, Repeat and
                        UntilStable, all bounded by the caller
  TransformationResult  input, output-or-nothing, which transformation ran; a
                        struct, so routing an ordinary call allocates nothing
  RewriteRuleSet        a named, attributed group of rewrites
  RewriteRules          the registry: ten shipped sets, explicitly listed,
                        enumerable in a fixed order

Relation is `Equivalence` or `Derivation`, because "sound" is only a statement
about some relation and a derivative is not another way of writing its
integrand. Soundness is declared, not checked, so every shipped rule set is
`SoundUnderAssumptions` and a test over `RewriteRules.All` holds it there. No
answer is `null`, the same distinction AGENTS.md draws between an unevaluated
node and NaN -- which is where this layer is more honest than the method it
backs: `Transformation.Integration` has no answer for `e^(x^2)` where
`Entity.Integrate` returns an unevaluated `Integralf`.

Two real ports, not wrappers. `Factorize` no longer names its own rules: it is
PerfectSquare, then Factorization, then a tidying pass, repeated `level` times,
composed out of the registry. `SimplifyChildren` -- run by every stage of the
simplification pipeline -- is a chain built once, statically, from four registry
entries. The other five entry points are thin adapters over the algorithm that
was already there; nothing that worked was rewritten.

`Solve` is deliberately absent. It consumes a goal and produces a solution set,
and belongs in a tactic layer that does not exist yet; `Entity.Set` being an
`Entity` means it would type-check here, which is the reason to keep it out. No
inverse machinery either -- Expand and Factor are not inverses.

Registration is static and explicit, so the layer stays trimmable and
NativeAOT-publishable, and `RewriteRules.All` is in an order that does not depend
on hashing or type-load order. `RewriteRuleSet` builds its transformation on
demand: doing it eagerly makes the registry and the catalogue depend on each
other's static initialisation, and whichever is touched second reads null fields.

Measured on net7.0/Release against master 21f0d16, two samples each, same
expressions as DotnetBenchmark/CommonFunctionsInterVersion: every timing range
overlaps the baseline's, and allocation -- which is deterministic -- is identical
or lower (SimplifyEasy 158.6 -> 157.6 KB, SimplifyCommon 6976.8 -> 6973.6 KB,
SimplifyHard 3616232 -> 3616197 KB). `Patterns.SortRules` used to build a fresh
closure on every `SimplifyChildren` call and is now built once, which is where
the difference comes from.

Tests: 5551 -> 5699 passing, 0 failed, the same 14 skipped; F# 130 passing. The
149 new tests cover the abstraction, that each 1.x method answers what its
transformation answers, determinism, that an equivalence transformation does not
change the value of the expression, that unsupported cases stay honest, and that
no registered rule set rewrites in a cycle.

No behavioural change, so no BREAKING-CHANGES.md entry. The new surface is
additive and marked experimental in its own documentation.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <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