From 38f35668fdc271371dc4f9dfa4a794f82558a6b8 Mon Sep 17 00:00:00 2001 From: Rafael Vuijk Date: Sat, 8 Aug 2026 12:45:33 +0000 Subject: [PATCH 1/4] Make a mathematical operation a value, and put the 1.x entry points on 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` 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 21f0d16f, 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) --- AGENTS.md | 33 ++ Sources/.editorconfig | 10 + .../Core/Transformations/RewriteRuleSet.cs | 104 +++++ .../Core/Transformations/RewriteRules.cs | 161 +++++++ .../Core/Transformations/Soundness.cs | 43 ++ .../Transformation.Catalogue.cs | 297 +++++++++++++ .../Transformation.Combinators.cs | 141 ++++++ .../Core/Transformations/Transformation.cs | 105 +++++ .../Transformations/TransformationRelation.cs | 33 ++ .../Transformations/TransformationResult.cs | 65 +++ .../AngouriMath/Docs/Contributing/README.md | 4 +- .../Docs/Contributing/Transformations.md | 132 ++++++ .../Functions/Continuous/Differentiation.cs | 9 + .../Integration/Integration.Definition.cs | 5 +- .../Continuous/Limits/Limit.Definition.cs | 9 +- .../Evaluation/Evaluation.Definition.cs | 27 +- .../Functions/Simplification/Simplificator.cs | 31 +- .../Transformations/TransformationTest.cs | 416 ++++++++++++++++++ 18 files changed, 1605 insertions(+), 20 deletions(-) create mode 100644 Sources/AngouriMath/Core/Transformations/RewriteRuleSet.cs create mode 100644 Sources/AngouriMath/Core/Transformations/RewriteRules.cs create mode 100644 Sources/AngouriMath/Core/Transformations/Soundness.cs create mode 100644 Sources/AngouriMath/Core/Transformations/Transformation.Catalogue.cs create mode 100644 Sources/AngouriMath/Core/Transformations/Transformation.Combinators.cs create mode 100644 Sources/AngouriMath/Core/Transformations/Transformation.cs create mode 100644 Sources/AngouriMath/Core/Transformations/TransformationRelation.cs create mode 100644 Sources/AngouriMath/Core/Transformations/TransformationResult.cs create mode 100644 Sources/AngouriMath/Docs/Contributing/Transformations.md create mode 100644 Sources/Tests/UnitTests/Core/Transformations/TransformationTest.cs diff --git a/AGENTS.md b/AGENTS.md index 100f2a2a7..bf4fd6f2e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -201,6 +201,38 @@ with the measurements against it. product is contraction, not convolution), and operators or gates — those act *on* states rather than being states, and they belong to a quantum computing library rather than to a CAS. +## An operation is a value, not only a method + +`Simplify`, `Expand`, `Factorize`, `Differentiate`, `Integrate` and `Limit` are adapters over +`AngouriMath.Core.Transformations` — a `Transformation` is the operation itself, carrying a name, +what it claims about its output, and how well justified the claim is. The algorithms underneath are +untouched; what changed is that a step can be named, composed and enumerated rather than only +called. See [`Contributing/Transformations.md`](Sources/AngouriMath/Docs/Contributing/Transformations.md), +and [#746](https://github.com/asc-community/AngouriMath/issues/746) for where it is going. + +Three habits it asks for, and each is the honesty rule above in a different place: + +**Say which relation you are claiming.** `Equivalence` means the output is another way of writing the +input; `Derivation` means it is a different object. A derivative and an antiderivative are +`Derivation`, and a test that subtracts one from its input and asserts zero is testing nothing. + +**Do not label a rewrite `Sound` without an argument.** Every rule set shipped today is +`SoundUnderAssumptions`, and a test over `RewriteRules.All` holds it there. The tier is declared, not +verified — so promoting one means changing that test, and saying in the same change why the rewrite +needs no assumptions. Loosening a tier needs nothing. + +**Say "no answer" with `null`, here too.** `ApplyCore` returning `null` is the layer's way of saying +"I could not settle this", and it is the one place where the new layer is *more* honest than the 1.x +method it backs: `Transformation.Integration` has no answer where `Entity.Integrate` returns an +unevaluated `Integralf`. Both are the same claim; neither is `NaN`. + +And what not to do with it. `Solve` is not a transformation — it consumes a goal and produces a +solution set, and it belongs in a tactic layer that does not exist yet. `Entity.Set` being an +`Entity` means it would type-check as `Entity -> Entity`, which is the reason to keep it out rather +than a reason to put it in. Nor is there any inverse machinery: `Expand` and `Factor` are not +inverses and `Unsolve` is not well defined, so the API does not invent a symmetry the mathematics +does not have. + ## Keep up with the mathematics Algorithms here are decades of literature deep, and the good ones are written down. Before inventing @@ -294,6 +326,7 @@ are short, and a stale one is worse than none — if you change what a file desc | [`Contributing/General.md`](Sources/AngouriMath/Docs/Contributing/General.md) | the `Entity` hierarchy, in a paragraph | | [`Contributing/AddingNode.cs`](Sources/AngouriMath/Docs/Contributing/AddingNode.cs) | every place a new node has to be taught about. Read it *before* adding one | | [`Contributing/ImproveParser.md`](Sources/AngouriMath/Docs/Contributing/ImproveParser.md) | how to change the grammar and regenerate | +| [`Contributing/Transformations.md`](Sources/AngouriMath/Docs/Contributing/Transformations.md) | the transformation layer the 1.x entry points sit on, and how to add the next rule set | | [`Contributing/coding_rules.md`](Sources/AngouriMath/Docs/Contributing/coding_rules.md) | sealed-or-abstract, and immutability of `Entity` | | [`WhatsNew/version_performance_control.md`](Sources/AngouriMath/Docs/WhatsNew/version_performance_control.md) | the inter-version performance table, and how to add a column | | `Sources/Analyzers/` | the custom analyzers, including the static-field one behind `[ConstantField]` | diff --git a/Sources/.editorconfig b/Sources/.editorconfig index 6055088ce..876c81824 100644 --- a/Sources/.editorconfig +++ b/Sources/.editorconfig @@ -19,3 +19,13 @@ dotnet_diagnostic.IDE0090.severity = none # dotnet_diagnostic.CA2252.severity = none file_header_template=\nCopyright (c) 2019-2022 Angouri.\nAngouriMath is licensed under MIT.\nDetails: https://github.com/asc-community/AngouriMath/blob/master/LICENSE.md.\nWebsite: https://am.angouri.org.\n + +# Files written after 2022 say so. Bumping the year on the template above would make +# IDE0073 fail on all 367 files that already carry the old one, which is a rewrite of every +# header folded into whatever branch happened to need a new file -- so the year moves per +# directory as directories are added, not all at once. +[AngouriMath/Core/Transformations/*.cs] +file_header_template=\nCopyright (c) 2019-2026 Angouri.\nAngouriMath is licensed under MIT.\nDetails: https://github.com/asc-community/AngouriMath/blob/master/LICENSE.md.\nWebsite: https://am.angouri.org.\n + +[Tests/UnitTests/Core/Transformations/*.cs] +file_header_template=\nCopyright (c) 2019-2026 Angouri.\nAngouriMath is licensed under MIT.\nDetails: https://github.com/asc-community/AngouriMath/blob/master/LICENSE.md.\nWebsite: https://am.angouri.org.\n diff --git a/Sources/AngouriMath/Core/Transformations/RewriteRuleSet.cs b/Sources/AngouriMath/Core/Transformations/RewriteRuleSet.cs new file mode 100644 index 000000000..70e4f8248 --- /dev/null +++ b/Sources/AngouriMath/Core/Transformations/RewriteRuleSet.cs @@ -0,0 +1,104 @@ +// +// Copyright (c) 2019-2026 Angouri. +// AngouriMath is licensed under MIT. +// Details: https://github.com/asc-community/AngouriMath/blob/master/LICENSE.md. +// Website: https://am.angouri.org. +// + +using System; + +namespace AngouriMath.Core.Transformations +{ + /// + /// A named, attributable group of rewrites — the unit this library has always written + /// them in — carrying what it is called, what it claims and how well justified the + /// claim is, so that the set can be enumerated, tested and referred to by name instead + /// of only being called. + /// + /// + /// + /// The set, rather than the single pattern -> replacement line, is the unit + /// here on purpose. Every rewrite in this library is a case of one switch + /// matched against a node, and the C# compiler turns that switch into a type test and a + /// jump. 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, and buy + /// nothing that a caller can use today. What a caller can use today is the set: + /// is enumerable, each entry is applicable on its own, + /// and the tests iterate it. + /// + /// + /// The finer grain is the next step rather than the abandoned one — see + /// #746 on rules + /// as data — and nothing here forecloses it: a set whose rewrites become individually + /// addressable keeps the same name and the same entry in the registry. + /// + /// + public sealed class RewriteRuleSet + { + private readonly Func rules; + + internal RewriteRuleSet(string name, string description, TransformationRelation relation, Soundness soundness, Func rules) + => (Name, Description, Relation, Soundness, this.rules) = (name, description, relation, soundness, rules); + + /// A stable identity for this set. + public string Name { get; } + + /// What the set is for, in a sentence. + public string Description { get; } + + /// What the rewrites in this set claim about the expressions they produce. + public TransformationRelation Relation { get; } + + /// How well justified that claim is. See on what a tier here is and is not. + public Soundness Soundness { get; } + + /// + /// Applies the set once, bottom-up over every node, exactly as + /// does. One pass: a rewrite + /// that opens up an opportunity for another rewrite in the same set will not see it + /// taken until the next pass. + /// + /// The expression to rewrite. + public Entity ApplyOnce(Entity expression) + => expression is null + ? throw new ArgumentNullException(nameof(expression)) + : expression.Replace(rules); + + /// + /// This set as a , so that it composes with the rest of + /// the catalogue. + /// + /// + /// Built on demand, and it has to be. derives + /// from , so constructing one runs that type's static + /// initialiser -- which reads . Doing it in this + /// constructor instead would make the two types depend on each other's + /// initialisation and hand whichever one lost the race a null rule set. Two threads + /// arriving together may each build one; they are equivalent and immutable, so + /// whichever reference lands is the one everyone then uses. + /// + public Transformation AsTransformation() => asTransformation ??= new RewritingTransformation(this); + private Transformation? asTransformation; + + /// + public override string ToString() => Name; + + private sealed class RewritingTransformation : Transformation + { + private readonly RewriteRuleSet ruleSet; + + internal RewritingTransformation(RewriteRuleSet ruleSet) => this.ruleSet = ruleSet; + + public override string Name => $"rewrite[{ruleSet.Name}]"; + + public override TransformationRelation Relation => ruleSet.Relation; + + public override Soundness Soundness => ruleSet.Soundness; + + // A rewrite pass always has an answer: where nothing matched, the answer is the + // expression it was given. That is a fixed point, not a failure, and + // TransformationResult.Changed is what tells the two apart. + protected override Entity? ApplyCore(Entity input) => ruleSet.ApplyOnce(input); + } + } +} diff --git a/Sources/AngouriMath/Core/Transformations/RewriteRules.cs b/Sources/AngouriMath/Core/Transformations/RewriteRules.cs new file mode 100644 index 000000000..592b11cfa --- /dev/null +++ b/Sources/AngouriMath/Core/Transformations/RewriteRules.cs @@ -0,0 +1,161 @@ +// +// Copyright (c) 2019-2026 Angouri. +// AngouriMath is licensed under MIT. +// Details: https://github.com/asc-community/AngouriMath/blob/master/LICENSE.md. +// Website: https://am.angouri.org. +// + +using AngouriMath.Functions; + +namespace AngouriMath.Core.Transformations +{ + /// + /// The rewrite rule sets this library ships, as data: named, described, attributed with + /// what they claim, and enumerable through . + /// + /// + /// + /// Registration is explicit and static — there is no assembly scanning and no + /// Activator, so the registry survives trimming and NativeAOT, and + /// is in a fixed order that does not depend on hashing, reflection or + /// which type happened to be loaded first. + /// + /// + /// This is a slice, not the whole pattern table. The sets below are the ones the + /// transformations in are built from; the rest of + /// Functions/Simplification/Patterns is still reached only from + /// Simplificator. Adding one here is five lines and gets it enumeration, a + /// soundness label and the tests over for free. + /// + /// + public static class RewriteRules + { + /// + /// Puts the operands of commutative chains into a canonical order and groups equal + /// ones together, so that x + y and y + x stop being different trees. + /// + public static RewriteRuleSet CanonicalOrder { get; } = new( + nameof(CanonicalOrder), + "Sorts and groups the operands of sums, products, conjunctions, disjunctions and set operations.", + TransformationRelation.Equivalence, + // Regrouping reads a quotient as a product with a negative power, which is the + // same value wherever the divisor is not zero. + Soundness.SoundUnderAssumptions, + Patterns.SortRules(TreeAnalyzer.SortLevel.HIGH_LEVEL)); + + /// + /// Turns a negative power into a quotient: a * b ^ (-1) becomes a / b. + /// + public static RewriteRuleSet InvertNegativePowers { get; } = new( + nameof(InvertNegativePowers), + "Rewrites negative powers as quotients.", + TransformationRelation.Equivalence, + Soundness.SoundUnderAssumptions, + Patterns.InvertNegativePowers); + + /// + /// Brings a negative numeric factor out in front of the term it multiplies. + /// + public static RewriteRuleSet InvertNegativeMultipliers { get; } = new( + nameof(InvertNegativeMultipliers), + "Moves a negative numeric factor out of a product into the sign of the term.", + TransformationRelation.Equivalence, + Soundness.SoundUnderAssumptions, + Patterns.InvertNegativeMultipliers); + + /// + /// The arithmetic housekeeping rules — collecting like terms, flattening nested + /// quotients, moving numeric coefficients to the front. + /// + public static RewriteRuleSet Common { get; } = new( + nameof(Common), + "Collects like terms and normalises the arrangement of products and quotients.", + TransformationRelation.Equivalence, + Soundness.SoundUnderAssumptions, + Patterns.CommonRules); + + /// + /// Rules about powers, roots and logarithms. + /// + public static RewriteRuleSet Power { get; } = new( + nameof(Power), + "Gathers and splits powers, roots and logarithms.", + TransformationRelation.Equivalence, + // (a ^ b) ^ c is a ^ (b c) only on a branch; the rules guard for it, and the + // guard is what the tier is stating. + Soundness.SoundUnderAssumptions, + Patterns.PowerRules); + + /// + /// The trigonometric identities. + /// + public static RewriteRuleSet Trigonometric { get; } = new( + nameof(Trigonometric), + "Applies trigonometric identities to sines, cosines and their relatives.", + TransformationRelation.Equivalence, + // tan and cot bring poles with them, so an identity that introduces one holds + // away from those points rather than everywhere. + Soundness.SoundUnderAssumptions, + Patterns.TrigonometricRules); + + /// + /// Multiplies products over sums out. + /// + public static RewriteRuleSet Expansion { get; } = new( + nameof(Expansion), + "Distributes products and powers over sums.", + TransformationRelation.Equivalence, + Soundness.SoundUnderAssumptions, + Patterns.ExpandRules); + + /// + /// Takes common factors back out of a sum. + /// + public static RewriteRuleSet Factorization { get; } = new( + nameof(Factorization), + "Gathers common factors out of sums.", + TransformationRelation.Equivalence, + Soundness.SoundUnderAssumptions, + Patterns.FactorizeRules); + + /// + /// Recognises a perfect square written out, so that factorisation has something to + /// gather. + /// + public static RewriteRuleSet PerfectSquare { get; } = new( + nameof(PerfectSquare), + "Collapses a written-out perfect square into a squared binomial.", + TransformationRelation.Equivalence, + Soundness.SoundUnderAssumptions, + Patterns.PerfectSquareRules); + + /// + /// Clears a surd out of a two-term denominator. + /// + public static RewriteRuleSet RationaliseDenominator { get; } = new( + nameof(RationaliseDenominator), + "Multiplies a quotient by the conjugate of its denominator to clear a surd from it.", + TransformationRelation.Equivalence, + Soundness.SoundUnderAssumptions, + Patterns.RationaliseDenominator); + + /// + /// Every rule set registered above, in a fixed order. Enumerable so that a property + /// that should hold of all of them can be tested over all of them rather than over + /// whichever ones somebody remembered. + /// + public static IReadOnlyList All { get; } = new[] + { + CanonicalOrder, + InvertNegativePowers, + InvertNegativeMultipliers, + Common, + Power, + Trigonometric, + Expansion, + Factorization, + PerfectSquare, + RationaliseDenominator, + }; + } +} diff --git a/Sources/AngouriMath/Core/Transformations/Soundness.cs b/Sources/AngouriMath/Core/Transformations/Soundness.cs new file mode 100644 index 000000000..b9361747b --- /dev/null +++ b/Sources/AngouriMath/Core/Transformations/Soundness.cs @@ -0,0 +1,43 @@ +// +// Copyright (c) 2019-2026 Angouri. +// AngouriMath is licensed under MIT. +// Details: https://github.com/asc-community/AngouriMath/blob/master/LICENSE.md. +// Website: https://am.angouri.org. +// + +namespace AngouriMath.Core.Transformations +{ + /// + /// How well justified the relation a claims between its + /// input and its output is. The three tiers are never blurred: a heuristic labelled as + /// a proof is a wrong answer with a friendly face. + /// + /// + /// The tier is declared by whoever wrote the transformation, not derived from it. + /// Nothing in the library checks a declaration today, so a tier is a claim to be argued + /// with rather than a guarantee to be relied on, and the registry starts conservative + /// on purpose: tightening a label needs an argument, loosening one does not. + /// + public enum Soundness + { + /// + /// The claimed relation holds for every value of the free variables, with no side + /// conditions and no choice of branch. + /// + Sound, + + /// + /// The claimed relation holds only where the stated assumptions do: wherever both + /// sides are defined, under the conditions the output carries as + /// , or under a branch-cut convention. This is the + /// honest tier for most of the rewrite rules in this library. + /// + SoundUnderAssumptions, + + /// + /// Worth trying; proves nothing. A heuristic result has to be checked by something + /// else before it may be returned as an answer. + /// + Heuristic + } +} diff --git a/Sources/AngouriMath/Core/Transformations/Transformation.Catalogue.cs b/Sources/AngouriMath/Core/Transformations/Transformation.Catalogue.cs new file mode 100644 index 000000000..6e92959f9 --- /dev/null +++ b/Sources/AngouriMath/Core/Transformations/Transformation.Catalogue.cs @@ -0,0 +1,297 @@ +// +// Copyright (c) 2019-2026 Angouri. +// AngouriMath is licensed under MIT. +// Details: https://github.com/asc-community/AngouriMath/blob/master/LICENSE.md. +// Website: https://am.angouri.org. +// + +using System; +using AngouriMath.Functions; +using AngouriMath.Functions.Algebra; +using static AngouriMath.Entity; + +namespace AngouriMath.Core.Transformations +{ + partial class Transformation + { + /// + /// Applies a rewrite rule set once over every node. + /// + /// The set to apply; see . + public static Transformation Rewriting(RewriteRuleSet ruleSet) + => (ruleSet ?? throw new ArgumentNullException(nameof(ruleSet))).AsTransformation(); + + /// + /// One structural tidying pass — . Cheap, and + /// no pattern search. + /// + public static Transformation InnerSimplification { get; } = new InnerSimplificationTransformation(); + + /// + /// The full simplification pipeline at the default level, as + /// runs it. + /// + public static Transformation Simplification { get; } = SimplificationAtLevel(2); + + /// + /// The full simplification pipeline at a chosen level. + /// + /// + /// How hard to look; the same argument takes. + /// + public static Transformation SimplificationAtLevel(int level) + => LevelledCache.Simplification.For(level, static l => new SimplificationTransformation(l)); + + /// + /// Multiplies products over sums out, as does. + /// + public static Transformation Expansion { get; } = ExpansionAtLevel(2); + + /// + /// Multiplies products over sums out, for a chosen number of passes. + /// + /// The number of passes; the argument takes. + public static Transformation ExpansionAtLevel(int level) + => LevelledCache.Expansion.For(level, static l => new ExpansionTransformation(l)); + + /// + /// Gathers common factors back out, as does. + /// + public static Transformation Factorization { get; } = FactorizationAtLevel(2); + + /// + /// Gathers common factors back out, for a chosen number of passes. + /// + /// The number of passes; the argument takes. + /// + /// Built out of the registry rather than written again: one pass is the perfect + /// square rules, then the factorisation rules, then a tidying pass, and the level is + /// how many times that runs. + /// + public static Transformation FactorizationAtLevel(int level) + => LevelledCache.Factorization.For(level, static l => + Rewriting(RewriteRules.PerfectSquare) + .Then(Rewriting(RewriteRules.Factorization)) + .Then(InnerSimplification) + // Entity.Factorize has always run at least one pass, whatever it was asked for. + .Repeat(Math.Max(l, 1))); + + /// + /// Puts commutative chains into a canonical order, so that expressions which differ + /// only in the arrangement of their operands become the same tree. + /// + /// + /// Not a simplification: it makes an expression comparable, not shorter. It has + /// always been available inside Simplify and had no name of its own until + /// this layer gave the rule set one. + /// + public static Transformation Normalization { get; } + = Rewriting(RewriteRules.CanonicalOrder).Then(InnerSimplification); + + /// + /// Clears a surd out of a two-term denominator: 1 / (sqrt(3) + 5) becomes + /// (sqrt(3) - 5) / (-22). + /// + public static Transformation Rationalisation { get; } + = Rewriting(RewriteRules.RationaliseDenominator).Then(InnerSimplification); + + /// + /// Replaces every occurrence of with + /// , as does. + /// + /// The subexpression to replace. + /// What to put in its place. + public static Transformation Substitution(Entity what, Entity with) + => new SubstitutionTransformation( + what ?? throw new ArgumentNullException(nameof(what)), + with ?? throw new ArgumentNullException(nameof(with))); + + /// + /// The symbolic derivative over , as + /// computes it. + /// + /// The variable to differentiate over. + public static Transformation Differentiation(Variable variable) + => new DifferentiationTransformation(variable ?? throw new ArgumentNullException(nameof(variable))); + + /// + /// The antiderivative over , without the constant + /// of integration — is what adds it. + /// + /// The variable to integrate over. + /// + /// Where no antiderivative is found this says so, by having no answer. + /// hands back an unevaluated + /// instead, which is the same claim in the shape 1.x + /// callers expect. + /// + public static Transformation Integration(Variable variable) + => new IntegrationTransformation(variable ?? throw new ArgumentNullException(nameof(variable))); + + /// + /// The limit over as it approaches + /// from . + /// + /// The variable that approaches. + /// Where it approaches. + /// From which side. + /// + /// The limit as computed, not further simplified — + /// is what tidies it. Where the limit cannot be settled this has no answer, rather + /// than the unevaluated node the 1.x method returns; + /// neither is a claim that the limit does not exist. + /// + public static Transformation LimitAt(Variable variable, Entity destination, ApproachFrom side) + => new LimitTransformation( + variable ?? throw new ArgumentNullException(nameof(variable)), + destination ?? throw new ArgumentNullException(nameof(destination)), + side); + + /// + /// The transformations that differ only by a level, built once, so that an ordinary + /// Simplify() allocates nothing to reach its transformation. + /// + /// + /// Filled on demand rather than in the static initialiser, and it has to be. Building + /// the factorisation entry reads , whose sets in turn build + /// transformations of their own; doing that while this type is still initialising + /// makes the two types depend on each other's initialisation, and one of them then + /// reads the other's fields before they are set. Two threads arriving together may + /// each build the same entry; the entries are equivalent and immutable, so whichever + /// reference lands is the one everyone then uses. + /// + private sealed class LevelledCache + { + // The levels Simplify, Expand and Factorize are actually asked for: the default + // 2, the negated -level Simplify passes itself, and a little room either side. + private const int Lowest = -4; + private const int Highest = 4; + + [ConcurrentField] + internal static readonly LevelledCache Simplification = new(); + [ConcurrentField] + internal static readonly LevelledCache Expansion = new(); + [ConcurrentField] + internal static readonly LevelledCache Factorization = new(); + + private readonly Transformation?[] cached = new Transformation?[Highest - Lowest + 1]; + + internal Transformation For(int level, Func make) + => level < Lowest || level > Highest + ? make(level) + : cached[level - Lowest] ??= make(level); + } + + private sealed class InnerSimplificationTransformation : Transformation + { + public override string Name => "inner-simplify"; + + public override TransformationRelation Relation => TransformationRelation.Equivalence; + + public override Soundness Soundness => Soundness.SoundUnderAssumptions; + + protected override Entity? ApplyCore(Entity input) => input.InnerSimplified; + } + + private sealed class SimplificationTransformation : Transformation + { + private readonly int level; + + internal SimplificationTransformation(int level) => this.level = level; + + public override string Name => $"simplify[{level}]"; + + public override TransformationRelation Relation => TransformationRelation.Equivalence; + + public override Soundness Soundness => Soundness.SoundUnderAssumptions; + + protected override Entity? ApplyCore(Entity input) => Simplificator.Simplify(input, level); + } + + private sealed class ExpansionTransformation : Transformation + { + private readonly int level; + + internal ExpansionTransformation(int level) => this.level = level; + + public override string Name => $"expand[{level}]"; + + public override TransformationRelation Relation => TransformationRelation.Equivalence; + + public override Soundness Soundness => Soundness.SoundUnderAssumptions; + + protected override Entity? ApplyCore(Entity input) => input.ExpandOverSum(level); + } + + private sealed class SubstitutionTransformation : Transformation + { + private readonly Entity what, with; + + internal SubstitutionTransformation(Entity what, Entity with) => (this.what, this.with) = (what, with); + + public override string Name => $"substitute[{what.Stringize()} := {with.Stringize()}]"; + + // The output is a different expression from the input, not another way of + // writing it, so subtracting the two means nothing. + public override TransformationRelation Relation => TransformationRelation.Derivation; + + // Replacing every occurrence of a subexpression by another is valid whatever + // the two are; nothing about it is conditional. + public override Soundness Soundness => Soundness.Sound; + + protected override Entity? ApplyCore(Entity input) => input.Substitute(what, with); + } + + private sealed class DifferentiationTransformation : Transformation + { + private readonly Variable variable; + + internal DifferentiationTransformation(Variable variable) => this.variable = variable; + + public override string Name => $"differentiate[{variable.Name}]"; + + public override TransformationRelation Relation => TransformationRelation.Derivation; + + // The derivative is the derivative where the expression is differentiable, and + // an unevaluated Derivativef node where this library does not know a rule. + public override Soundness Soundness => Soundness.SoundUnderAssumptions; + + protected override Entity? ApplyCore(Entity input) => input.DifferentiateOnce(variable); + } + + private sealed class IntegrationTransformation : Transformation + { + private readonly Variable variable; + + internal IntegrationTransformation(Variable variable) => this.variable = variable; + + public override string Name => $"integrate[{variable.Name}]"; + + public override TransformationRelation Relation => TransformationRelation.Derivation; + + public override Soundness Soundness => Soundness.SoundUnderAssumptions; + + protected override Entity? ApplyCore(Entity input) + => Functions.Algebra.Integration.ComputeIndefiniteIntegral(input.InnerSimplified, variable)?.InnerSimplified; + } + + private sealed class LimitTransformation : Transformation + { + private readonly Variable variable; + private readonly Entity destination; + private readonly ApproachFrom side; + + internal LimitTransformation(Variable variable, Entity destination, ApproachFrom side) + => (this.variable, this.destination, this.side) = (variable, destination, side); + + public override string Name => $"limit[{variable.Name} -> {destination.Stringize()}, {side}]"; + + public override TransformationRelation Relation => TransformationRelation.Derivation; + + public override Soundness Soundness => Soundness.SoundUnderAssumptions; + + protected override Entity? ApplyCore(Entity input) + => LimitFunctional.ComputeLimit(input, variable, destination, side); + } + } +} diff --git a/Sources/AngouriMath/Core/Transformations/Transformation.Combinators.cs b/Sources/AngouriMath/Core/Transformations/Transformation.Combinators.cs new file mode 100644 index 000000000..e9375356c --- /dev/null +++ b/Sources/AngouriMath/Core/Transformations/Transformation.Combinators.cs @@ -0,0 +1,141 @@ +// +// Copyright (c) 2019-2026 Angouri. +// AngouriMath is licensed under MIT. +// Details: https://github.com/asc-community/AngouriMath/blob/master/LICENSE.md. +// Website: https://am.angouri.org. +// + +using System; + +namespace AngouriMath.Core.Transformations +{ + partial class Transformation + { + /// + /// Runs this transformation and then on what it produced. + /// Where either step has no answer, the composition has none: a step that could not + /// be settled is not silently skipped. + /// + /// The transformation to apply to this one's output. + public Transformation Then(Transformation next) + => new SequentialTransformation(this, next ?? throw new ArgumentNullException(nameof(next))); + + /// + /// Runs this transformation times in a row. The bound is + /// the caller's, which is what keeps repetition terminating by construction rather + /// than by the good behaviour of whatever is being repeated. + /// + /// How many applications; must not be negative. + public Transformation Repeat(int times) + => times < 0 + ? throw new ArgumentOutOfRangeException(nameof(times)) + : new RepeatedTransformation(this, times); + + /// + /// Runs this transformation until its output stops changing, giving up after + /// applications. + /// + /// The bound on applications; must be positive. + /// + /// Hitting the bound is reported as no answer rather than as the last value + /// reached. An unbounded rewrite loop is the failure mode this layer is supposed to + /// make visible, and handing back a value from the middle of one would hide exactly + /// the case worth seeing. It is also what lets a test assert that a rule set has a + /// fixed point on a given expression instead of assuming it. + /// + public Transformation UntilStable(int maxIterations) + => maxIterations < 1 + ? throw new ArgumentOutOfRangeException(nameof(maxIterations)) + : new StableTransformation(this, maxIterations); + + /// + /// The weaker of two justifications. Composing anything with a heuristic gives a + /// heuristic; nothing composes upwards into a stronger claim. + /// + private static Soundness Weaker(Soundness one, Soundness another) + => one > another ? one : another; + + /// + /// A chain is an equivalence only if every link is. One derivation anywhere in it + /// means the end no longer denotes the same value as the start. + /// + private static TransformationRelation Combine(TransformationRelation one, TransformationRelation another) + => one is TransformationRelation.Equivalence && another is TransformationRelation.Equivalence + ? TransformationRelation.Equivalence + : TransformationRelation.Derivation; + + private sealed class SequentialTransformation : Transformation + { + private readonly Transformation first, second; + + internal SequentialTransformation(Transformation first, Transformation second) + => (this.first, this.second) = (first, second); + + public override string Name => $"{first.Name} then {second.Name}"; + + public override TransformationRelation Relation => Combine(first.Relation, second.Relation); + + public override Soundness Soundness => Weaker(first.Soundness, second.Soundness); + + protected override Entity? ApplyCore(Entity input) + => first.Apply(input).Output is { } intermediate + ? second.Apply(intermediate).Output + : null; + } + + private sealed class RepeatedTransformation : Transformation + { + private readonly Transformation inner; + private readonly int times; + + internal RepeatedTransformation(Transformation inner, int times) + => (this.inner, this.times) = (inner, times); + + public override string Name => $"{inner.Name} x{times}"; + + public override TransformationRelation Relation => inner.Relation; + + public override Soundness Soundness => inner.Soundness; + + protected override Entity? ApplyCore(Entity input) + { + var current = input; + for (var i = 0; i < times; i++) + if (inner.Apply(current).Output is { } next) + current = next; + else + return null; + return current; + } + } + + private sealed class StableTransformation : Transformation + { + private readonly Transformation inner; + private readonly int maxIterations; + + internal StableTransformation(Transformation inner, int maxIterations) + => (this.inner, this.maxIterations) = (inner, maxIterations); + + public override string Name => $"{inner.Name} until stable (<={maxIterations})"; + + public override TransformationRelation Relation => inner.Relation; + + public override Soundness Soundness => inner.Soundness; + + protected override Entity? ApplyCore(Entity input) + { + var current = input; + for (var i = 0; i < maxIterations; i++) + { + if (inner.Apply(current).Output is not { } next) + return null; + if (next == current) + return current; + current = next; + } + return null; + } + } + } +} diff --git a/Sources/AngouriMath/Core/Transformations/Transformation.cs b/Sources/AngouriMath/Core/Transformations/Transformation.cs new file mode 100644 index 000000000..8d45b238a --- /dev/null +++ b/Sources/AngouriMath/Core/Transformations/Transformation.cs @@ -0,0 +1,105 @@ +// +// Copyright (c) 2019-2026 Angouri. +// AngouriMath is licensed under MIT. +// Details: https://github.com/asc-community/AngouriMath/blob/master/LICENSE.md. +// Website: https://am.angouri.org. +// + +using System; + +namespace AngouriMath.Core.Transformations +{ + /// + /// A named mathematical operation that consumes an and produces + /// one, together with enough about itself — what it claims, how well justified the + /// claim is — to be inspected and composed rather than only invoked. + /// + /// + /// + /// This is the layer the 1.x entry points sit on: + /// , and + /// are adapters over the transformations named + /// below, and the algorithms underneath are unchanged. Callers who only want an answer + /// should keep using those methods; this type is for callers who want to know which + /// operation produced it, or to build an operation out of others. + /// + /// + /// Deterministic: the same transformation applied to the same expression under the same + /// gives the same result. Composition is by explicit + /// ordering — there is no registry that decides what to run next, and nothing here + /// consults reflection, so the layer stays trimmable and AOT-publishable. + /// + /// + /// Experimental. The three concepts — a transformation, its relation, its + /// soundness tier — are meant to last; the catalogue of factories will grow and the + /// signatures here may still move. The stable surface is and the + /// methods on . + /// + /// + /// + /// + /// using System; + /// using AngouriMath; + /// using AngouriMath.Core.Transformations; + /// + /// Entity expr = "(x + 1) ^ 2"; + /// var result = Transformation.Expansion.Apply(expr); + /// Console.WriteLine(result.Output); + /// Console.WriteLine(result.Relation); + /// Console.WriteLine(result.Soundness); + /// + /// Prints + /// + /// 1 + 2 * x + x ^ 2 + /// Equivalence + /// SoundUnderAssumptions + /// + /// + public abstract partial class Transformation + { + /// + /// A stable identity for this operation, used in diagnostics and in the failure a + /// caller is handed. Composed transformations name their parts. + /// + public abstract string Name { get; } + + /// What this operation claims about its output relative to its input. + public abstract TransformationRelation Relation { get; } + + /// How well justified that claim is. + public abstract Soundness Soundness { get; } + + /// + /// Does the work. Returns to mean "I could not settle this" — + /// never an unevaluated node of the input, and never for a + /// question that merely went unanswered. + /// + /// The expression to transform. + protected abstract Entity? ApplyCore(Entity input); + + /// + /// Applies the transformation, reporting what happened rather than only the value. + /// + /// The expression to transform. + /// + /// The input, the output where there is one, and this transformation. See + /// for the case where there is none. + /// + public TransformationResult Apply(Entity input) + { + if (input is null) + throw new ArgumentNullException(nameof(input)); + return new TransformationResult(this, input, ApplyCore(input)); + } + + /// + /// Applies the transformation and hands back the input untouched where it produced + /// no answer — the convention the 1.x methods have always followed. + /// + /// The expression to transform. + public Entity ApplyOrKeep(Entity input) => Apply(input).OutputOrInput; + + /// + public override string ToString() => Name; + } +} diff --git a/Sources/AngouriMath/Core/Transformations/TransformationRelation.cs b/Sources/AngouriMath/Core/Transformations/TransformationRelation.cs new file mode 100644 index 000000000..99bee6c50 --- /dev/null +++ b/Sources/AngouriMath/Core/Transformations/TransformationRelation.cs @@ -0,0 +1,33 @@ +// +// Copyright (c) 2019-2026 Angouri. +// AngouriMath is licensed under MIT. +// Details: https://github.com/asc-community/AngouriMath/blob/master/LICENSE.md. +// Website: https://am.angouri.org. +// + +namespace AngouriMath.Core.Transformations +{ + /// + /// What a claims about its output relative to its input. + /// Without this, would be meaningless: "sound" is only a + /// statement about some relation, and the relation is not the same one for every + /// operation. + /// + public enum TransformationRelation + { + /// + /// The output denotes the same mathematical value as the input, so + /// input - output is zero wherever both are defined. Simplification, + /// expansion, factorisation and rewriting all claim this. + /// + Equivalence, + + /// + /// The output is a different mathematical object computed from the input — a + /// derivative, an antiderivative, a limit, an instance under a substitution. + /// Subtracting it from the input means nothing, and a test that does so is testing + /// nothing. + /// + Derivation + } +} diff --git a/Sources/AngouriMath/Core/Transformations/TransformationResult.cs b/Sources/AngouriMath/Core/Transformations/TransformationResult.cs new file mode 100644 index 000000000..c10dc2fa5 --- /dev/null +++ b/Sources/AngouriMath/Core/Transformations/TransformationResult.cs @@ -0,0 +1,65 @@ +// +// Copyright (c) 2019-2026 Angouri. +// AngouriMath is licensed under MIT. +// Details: https://github.com/asc-community/AngouriMath/blob/master/LICENSE.md. +// Website: https://am.angouri.org. +// + +namespace AngouriMath.Core.Transformations +{ + /// + /// What one application of a produced: the input, the + /// output if there was one, and which transformation was asked. + /// + /// + /// A struct, so that routing an ordinary Simplify or Differentiate call + /// through this layer costs no allocation. + /// + public readonly struct TransformationResult + { + internal TransformationResult(Transformation transformation, Entity input, Entity? output) + => (Transformation, Input, Output) = (transformation, input, output); + + /// The transformation that was applied. + public Transformation Transformation { get; } + + /// The expression it was applied to. + public Entity Input { get; } + + /// + /// The result, or where the transformation could not settle + /// the question. Null means "no answer" and nothing more — in particular it does not + /// mean the answer does not exist. + /// + public Entity? Output { get; } + + /// Whether there is an answer at all. + public bool Succeeded => Output is not null; + + /// + /// Whether there is an answer and it differs from the input. A transformation that + /// succeeded without changing anything has reached a fixed point, which is what + /// looks for. + /// + public bool Changed => Output is not null && Output != Input; + + /// + /// The answer where there is one, the untouched input otherwise. This is what the + /// 1.x API surface wants: those methods have always returned the original + /// expression rather than nothing when they could not improve on it. + /// + public Entity OutputOrInput => Output ?? Input; + + /// What the transformation claims relates to . + public TransformationRelation Relation => Transformation.Relation; + + /// How well justified that claim is. + public Soundness Soundness => Transformation.Soundness; + + /// + public override string ToString() + => Output is null + ? $"{Transformation.Name}: no answer for {Input.Stringize()}" + : $"{Transformation.Name}: {Input.Stringize()} -> {Output.Stringize()}"; + } +} diff --git a/Sources/AngouriMath/Docs/Contributing/README.md b/Sources/AngouriMath/Docs/Contributing/README.md index 31427ac7b..e3028dceb 100644 --- a/Sources/AngouriMath/Docs/Contributing/README.md +++ b/Sources/AngouriMath/Docs/Contributing/README.md @@ -10,7 +10,9 @@ If you aren't sure about what to add, you may want to check the current projects 1. General information 2. Adding a new node (function, operator) 3. Improve parser -4. Adding a public member — out of date; the `PublicApi.*.txt` files +4. Transformations — the layer the 1.x entry points sit on, and + how to add the next rule set or transformation +5. Adding a public member — out of date; the `PublicApi.*.txt` files and the analyzer that required them are no longer in the tree See also BREAKING-CHANGES.md, where a change that makes diff --git a/Sources/AngouriMath/Docs/Contributing/Transformations.md b/Sources/AngouriMath/Docs/Contributing/Transformations.md new file mode 100644 index 000000000..e9fec5a64 --- /dev/null +++ b/Sources/AngouriMath/Docs/Contributing/Transformations.md @@ -0,0 +1,132 @@ +# Transformations + +`AngouriMath.Core.Transformations` is the layer the 1.x mathematical entry points sit on. It exists +so that an operation is a **value** — something with a name, a stated claim, and a way to be +composed — instead of only a method you call. + +It is the first step of the rewrite-engine layer in +[#746](https://github.com/asc-community/AngouriMath/issues/746). It is deliberately small, and it is +**experimental**: the three concepts are meant to last, the catalogue will grow, and the signatures +may still move. The stable surface is `MathS` and the methods on `Entity`. + +## Why it is not just an interface with `Apply` + +Three things have to be said about a mathematical operation before it can be composed with another +one, and none of them fit in `Entity -> Entity`: + +**What it claims.** `Simplify` returns another way of writing the same value. `Differentiate` returns +a different object. Both are `Entity -> Entity` and confusing them is how you get a test that +subtracts a derivative from its integrand and asserts zero. `TransformationRelation` is `Equivalence` +or `Derivation`, and a chain is an equivalence only if every link is. + +**How well justified the claim is.** `Soundness` is `Sound`, `SoundUnderAssumptions` or `Heuristic`, +and composing takes the weaker of two — nothing composes upwards. The tier is **declared, not +checked**: it is a claim to argue with, not a guarantee, which is why the registry starts +conservative and why tightening a label needs an argument while loosening one does not. + +**Whether it answered at all.** `ApplyCore` returns `null` for "I could not settle this", which is +the same distinction [AGENTS.md](../../../../AGENTS.md) draws between an unevaluated node and `NaN`. +`Transformation.Integration("x")` applied to `e ^ (x ^ 2)` has no answer; `Entity.Integrate` makes the +same claim in the shape its callers expect, by handing back an unevaluated `Integralf`. + +## The pieces + +| | | +|---|---| +| `Transformation` | the operation: `Name`, `Relation`, `Soundness`, `Apply`, and the static catalogue | +| `TransformationResult` | input, output-or-nothing, and which transformation ran. A struct, so routing an ordinary call through this layer allocates nothing | +| `RewriteRuleSet` | a named, attributed group of rewrites — `Name`, `Description`, `Relation`, `Soundness`, `ApplyOnce` | +| `RewriteRules` | the registry: every shipped set, explicitly listed, enumerable through `All` in a fixed order | + +Composition is `Then`, `Repeat(n)` and `UntilStable(max)`. All three take their bound from the +caller: there is no unbounded rewrite loop anywhere in the layer, and `UntilStable` reports hitting +its bound as **no answer** rather than as the value it happened to be holding, so that a rule set +which does not converge is visible instead of silently truncated. + +Registration is static and explicit. No assembly scanning, no `Activator`, no reflection — 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. + +## What already 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 composed chain of four registry entries +``` + +Two of these are real ports rather than wrappers. `Factorize` is no longer a method that 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, out of four registry entries instead of a hand-written run of +`Replace` calls. Both produce exactly what they produced before. + +Everything else is a thin adapter over the algorithm that was already there. **Nothing that worked +was rewritten to make the architecture tidier.** + +## What is deliberately not here + +**`Solve` is not a transformation.** It consumes a *goal* — an equation, with a variable to solve for +— and produces a solution set, and the honest place for it is a tactic layer where a goal can become +subgoals. `Entity.Set` being an `Entity` means `Solve` would type-check as `Entity -> Entity`; that +is exactly why forcing it in would be a mistake, since it would compile while saying nothing true +about what the operation does. Same for `Isolate`, `Eliminate` and `Parametrize` when they arrive. + +**There are no inverse transformations.** `Expand` and `Factor` are not inverses, and `Unsolve` is not +a well-defined operation. Where an inverse is mathematically meaningful there is room to add one; the +API does not invent symmetry that the mathematics does not have. + +**`Heuristic` currently has no instance in the catalogue.** That is a statement about what is +registered so far, not a claim that nothing in the library guesses. + +**Most of the pattern table is still only reachable from `Simplificator`.** `RewriteRules` registers +the ten sets the catalogue is built from. The rest are unchanged and unregistered. + +## Adding the next one + +A new transformation built from rules that already exist is one line: + +```csharp +public static Transformation Rationalisation { get; } + = Rewriting(RewriteRules.RationaliseDenominator).Then(InnerSimplification); +``` + +A new rule set is five, and registering it gets it enumeration, an identity, a soundness label, and +the tests that run over `RewriteRules.All` — including the one that asserts it reaches a fixed point +rather than rewriting in a cycle: + +```csharp +public static RewriteRuleSet Power { get; } = new( + nameof(Power), + "Gathers and splits powers, roots and logarithms.", + TransformationRelation.Equivalence, + Soundness.SoundUnderAssumptions, + Patterns.PowerRules); +``` + +Add it to `RewriteRules.All` in the same change — the list is explicit so that its order is a +decision rather than an accident. + +Two things to get right: + +- **State the relation honestly.** If the output is not another way of writing the input, it is + `Derivation`, and the equivalence property test will correctly leave it alone. +- **Do not claim `Sound` without an argument.** Every rule set shipped today is + `SoundUnderAssumptions`, and the test over `RewriteRules.All` enforces that, so promoting one means + changing that test and saying why in the same change. + +## What the next step is + +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 the individual rewrites addressable without paying +that is the next piece of work, and nothing here forecloses it: a set whose rewrites become +individually reachable keeps its name and its entry in the registry. + +After that, in dependency order: the goal/tactic layer that `Solve` belongs in, and then derivations, +which want every step to be attributable — which is what naming the steps was for. diff --git a/Sources/AngouriMath/Functions/Continuous/Differentiation.cs b/Sources/AngouriMath/Functions/Continuous/Differentiation.cs index 6af8a1ae2..bef6ba8df 100644 --- a/Sources/AngouriMath/Functions/Continuous/Differentiation.cs +++ b/Sources/AngouriMath/Functions/Continuous/Differentiation.cs @@ -5,6 +5,7 @@ // Website: https://am.angouri.org. // +using AngouriMath.Core.Transformations; using PeterO.Numbers; namespace AngouriMath @@ -43,6 +44,14 @@ partial record Entity /// /// public Entity Differentiate(Variable variable) + => Transformation.Differentiation(variable).ApplyOrKeep(this); + + /// + /// What does, reachable by + /// without going back + /// through the public method and round again. + /// + internal Entity DifferentiateOnce(Variable variable) => InnerDifferentiate(variable).InnerSimplified; /// diff --git a/Sources/AngouriMath/Functions/Continuous/Integration/Integration.Definition.cs b/Sources/AngouriMath/Functions/Continuous/Integration/Integration.Definition.cs index 1431fa30d..ed61893a5 100644 --- a/Sources/AngouriMath/Functions/Continuous/Integration/Integration.Definition.cs +++ b/Sources/AngouriMath/Functions/Continuous/Integration/Integration.Definition.cs @@ -5,6 +5,7 @@ // Website: https://am.angouri.org. // +using AngouriMath.Core.Transformations; using AngouriMath.Extensions; using AngouriMath.Functions.Algebra; using PeterO.Numbers; @@ -31,7 +32,7 @@ partial record Entity /// https://github.com/asc-community/AngouriMath/issues/772 /// public Entity Integrate(Variable x) => - Integration.ComputeIndefiniteIntegral(InnerSimplified, x)?.InnerSimplified is { } antiderivative + Transformation.Integration(x).Apply(this).Output is { } antiderivative ? antiderivative + (antiderivative.VarsAndConsts.Contains("C") ? Variable.CreateUnique(antiderivative, "C") : "C") : new Integralf(this, x, null); /// @@ -45,7 +46,7 @@ public Entity Integrate(Variable x) => /// An integrated expression. It might remain the same or be transformed into nodes with no integrals. /// public Entity Integrate(Variable x, Entity from, Entity to) => - Integration.ComputeIndefiniteIntegral(InnerSimplified, x)?.InnerSimplified is { } antiderivative + Transformation.Integration(x).Apply(this).Output is { } antiderivative ? antiderivative.Substitute(x, to) - antiderivative.Substitute(x, from) : new Integralf(this, x, (from, to)); } diff --git a/Sources/AngouriMath/Functions/Continuous/Limits/Limit.Definition.cs b/Sources/AngouriMath/Functions/Continuous/Limits/Limit.Definition.cs index 8dc23ea0d..1754e1d61 100644 --- a/Sources/AngouriMath/Functions/Continuous/Limits/Limit.Definition.cs +++ b/Sources/AngouriMath/Functions/Continuous/Limits/Limit.Definition.cs @@ -34,6 +34,7 @@ namespace AngouriMath.Core namespace AngouriMath { using Core; + using Core.Transformations; using static Functions.Algebra.LimitFunctional; partial record Entity { @@ -58,8 +59,8 @@ partial record Entity /// cannot be determined /// public Entity Limit(Variable x, Entity destination, ApproachFrom side) - { - var res = ComputeLimit(this, x, destination, side); + { + var res = Transformation.LimitAt(x, destination, side).Apply(this).Output; if (res is null) return new Limitf(this, x, destination, side); return res.InnerSimplified; } @@ -118,7 +119,9 @@ public Entity Limit(Variable x, Entity destination, ApproachFrom side) /// public Entity Limit(Variable x, Entity destination) { - var res = ComputeLimit(this, x, destination, ApproachFrom.BothSides); + // The NaN test is on the limit as computed, before it is tidied: an expression + // that inner-simplifies to NaN is not the same claim as one that came back NaN. + var res = Transformation.LimitAt(x, destination, ApproachFrom.BothSides).Apply(this).Output; if (res is null || res == MathS.NaN) return new Limitf(this, x, destination, ApproachFrom.BothSides).InnerSimplified; return res.InnerSimplified; diff --git a/Sources/AngouriMath/Functions/Evaluation/Evaluation.Definition.cs b/Sources/AngouriMath/Functions/Evaluation/Evaluation.Definition.cs index 724c1bfec..8c8786c0d 100644 --- a/Sources/AngouriMath/Functions/Evaluation/Evaluation.Definition.cs +++ b/Sources/AngouriMath/Functions/Evaluation/Evaluation.Definition.cs @@ -5,6 +5,7 @@ // Website: https://am.angouri.org. // +using AngouriMath.Core.Transformations; using HonkSharp.Laziness; namespace AngouriMath @@ -204,6 +205,14 @@ private Entity InnerSimplifyWithCheck(bool isExact) /// public Entity Expand(int level = 2) + => Transformation.ExpansionAtLevel(level).ApplyOrKeep(this); + + /// + /// What does, reachable by + /// without going back through + /// the public method and round again. + /// + internal Entity ExpandOverSum(int level) { static Entity Expand_(Entity e, int level) => level <= 1 @@ -330,11 +339,16 @@ private static Entity CollectLikeTerms(Entity expanded) /// (1 + x) * (1 + y) /// /// - public Entity Factorize(int level = 2) => level <= 1 - // InnerSimplified, so that the factors come back finished: the rules leave - // x ^ 1 where they mean x, and sqrt(4) where they mean 2. - ? this.Replace(Patterns.PerfectSquareRules).Replace(Patterns.FactorizeRules).InnerSimplified - : this.Replace(Patterns.PerfectSquareRules).Replace(Patterns.FactorizeRules).InnerSimplified.Factorize(level - 1); + /// + /// One pass is the perfect-square rules, then the factorisation rules, then + /// -- so that the factors come back finished, since + /// the rules leave x ^ 1 where they mean x and sqrt(4) where + /// they mean 2 -- and is how many times that runs. + /// Built out of by + /// . + /// + public Entity Factorize(int level = 2) + => Transformation.FactorizationAtLevel(level).ApplyOrKeep(this); /// /// Simplifies an equation ( e.g. (x - y) * (x + y) -> x^2 - y^2, but 3 * x + y * x = (3 + y) * x ) @@ -386,7 +400,8 @@ public Entity Factorize(int level = 2) => level <= 1 /// cos((x * y) ^ 2 + x * y) * (2 * x * y ^ 2 + y) /// /// - public Entity Simplify(int level = 2) => Simplificator.Simplify(this, level); + public Entity Simplify(int level = 2) + => Transformation.SimplificationAtLevel(level).ApplyOrKeep(this); /// Finds all alternative forms of an expression sorted by their complexity /// diff --git a/Sources/AngouriMath/Functions/Simplification/Simplificator.cs b/Sources/AngouriMath/Functions/Simplification/Simplificator.cs index 2f0eaf900..695e22cb0 100644 --- a/Sources/AngouriMath/Functions/Simplification/Simplificator.cs +++ b/Sources/AngouriMath/Functions/Simplification/Simplificator.cs @@ -6,7 +6,9 @@ // using System; +using AngouriMath.Core; using AngouriMath.Core.Multithreading; +using AngouriMath.Core.Transformations; using PeterO.Numbers; namespace AngouriMath.Functions @@ -22,14 +24,27 @@ internal static Entity PickSimplest(Entity one, Entity another) /// See more details in internal static Entity Simplify(Entity expr, int level) => Alternate(expr, level).First().InnerSimplified; - internal static Entity SimplifyChildren(Entity expr) - { - return expr.Replace(Patterns.InvertNegativePowers) - .Replace(Patterns.InvertNegativeMultipliers).Replace( - Patterns.SortRules(TreeAnalyzer.SortLevel.HIGH_LEVEL) - ) - .InnerSimplified.Replace(Patterns.CommonRules).InnerSimplified; - } + /// + /// The tidying pass every stage of runs: get + /// the quotients and the signs into their usual shape, put the operands in order, + /// then collect like terms. + /// + /// + /// Composed once, statically, out of rather than written + /// out as a chain of Replace calls -- so the sequence is a value that can be + /// named, printed and tested, and so each stage of it is a registry entry other + /// code can reach on its own. The passes and their order are unchanged. + /// + [ConstantField] + private static readonly Transformation simplifyChildren = + Transformation.Rewriting(RewriteRules.InvertNegativePowers) + .Then(Transformation.Rewriting(RewriteRules.InvertNegativeMultipliers)) + .Then(Transformation.Rewriting(RewriteRules.CanonicalOrder)) + .Then(Transformation.InnerSimplification) + .Then(Transformation.Rewriting(RewriteRules.Common)) + .Then(Transformation.InnerSimplification); + + internal static Entity SimplifyChildren(Entity expr) => simplifyChildren.ApplyOrKeep(expr); /// Finds all alternative forms of an expression internal static IEnumerable Alternate(Entity src, int level) diff --git a/Sources/Tests/UnitTests/Core/Transformations/TransformationTest.cs b/Sources/Tests/UnitTests/Core/Transformations/TransformationTest.cs new file mode 100644 index 000000000..b88301f7e --- /dev/null +++ b/Sources/Tests/UnitTests/Core/Transformations/TransformationTest.cs @@ -0,0 +1,416 @@ +// +// Copyright (c) 2019-2026 Angouri. +// AngouriMath is licensed under MIT. +// Details: https://github.com/asc-community/AngouriMath/blob/master/LICENSE.md. +// Website: https://am.angouri.org. +// + +using System; +using System.Collections.Generic; +using System.Linq; +using AngouriMath; +using AngouriMath.Core; +using AngouriMath.Core.Transformations; +using Xunit; + +namespace AngouriMath.Tests.Core.Transformations +{ + /// + /// The transformation layer: that it does what it says, that the 1.x methods built on + /// it still answer what they answered, and that it is honest about what it could not do. + /// + [Trait("Area", "Transformations")] + public sealed class TransformationTest + { + /// + /// Ordinary textbook-sized expressions, of the kinds the rule sets below are about. + /// + public static readonly IEnumerable Corpus = new[] + { + "x + 0", + "x * 1", + "x - x", + "(x + 1) ^ 2", + "(x + y) * (x - y)", + "x * y + y + x + 1", + "sin(x) ^ 2 + cos(x) ^ 2", + "a / b / c", + "1 / (sqrt(3) + 5)", + "(x ^ 3 + 3 * x ^ 2 * y + 3 * x * y ^ 2 + y ^ 3) / (x + y)", + "2 * x + 3 * x", + "sqrt(12) + sqrt(27)", + }.Select(x => new object[] { x }).ToArray(); + + private static Entity Parse(string raw) => MathS.FromString(raw); + + #region The abstraction itself + + [Fact] + public void ATransformationReportsWhatItIsAndWhatItDid() + { + var result = Transformation.Expansion.Apply(Parse("(x + 1) ^ 2")); + + Assert.True(result.Succeeded); + Assert.True(result.Changed); + Assert.Equal(Transformation.Expansion, result.Transformation); + Assert.Equal(TransformationRelation.Equivalence, result.Relation); + Assert.Equal(Soundness.SoundUnderAssumptions, result.Soundness); + Assert.Equal(Parse("(x + 1) ^ 2"), result.Input); + } + + [Fact] + public void AFixedPointSucceedsWithoutChangingAnything() + { + // Nothing in the common rules matches a bare variable, so the pass answers with + // what it was given. That is a fixed point, not a failure. + var result = Transformation.Rewriting(RewriteRules.Common).Apply(Parse("x")); + + Assert.True(result.Succeeded); + Assert.False(result.Changed); + Assert.Equal(Parse("x"), result.Output); + } + + [Fact] + public void ApplyOrKeepHandsBackTheInputWhereThereIsNoAnswer() + { + var unanswerable = Transformation.Integration("x").Apply(Parse("e ^ (x ^ 2)")); + Assert.False(unanswerable.Succeeded); + Assert.Equal(Parse("e ^ (x ^ 2)"), unanswerable.OutputOrInput); + } + + [Fact] + public void ATransformationRefusesANullInput() + => Assert.Throws(() => Transformation.Simplification.Apply(null!)); + + #endregion + + #region Composition + + [Fact] + public void AChainRunsItsPartsInOrder() + { + var expandThenFactor = Transformation.Expansion.Then(Transformation.Factorization); + var factorThenExpand = Transformation.Factorization.Then(Transformation.Expansion); + + // Not an inverse pair, and the names say which way round each one is. + Assert.Equal("expand[2] then factorize", Shorten(expandThenFactor.Name)); + Assert.Equal("factorize then expand[2]", Shorten(factorThenExpand.Name)); + + static string Shorten(string name) + => name.Replace("rewrite[PerfectSquare] then rewrite[Factorization] then inner-simplify x2", "factorize"); + } + + [Fact] + public void AChainWithADerivationInItIsNotAnEquivalence() + { + var chain = Transformation.Expansion.Then(Transformation.Differentiation("x")); + + Assert.Equal(TransformationRelation.Equivalence, Transformation.Expansion.Relation); + Assert.Equal(TransformationRelation.Derivation, chain.Relation); + } + + [Fact] + public void AChainCarriesTheWeakerOfTheTwoJustifications() + { + // Substitution is unconditional; expansion is not. Composing them cannot make + // the pair unconditional again. + var chain = Transformation.Substitution("x", 3).Then(Transformation.Expansion); + + Assert.Equal(Soundness.Sound, Transformation.Substitution("x", 3).Soundness); + Assert.Equal(Soundness.SoundUnderAssumptions, chain.Soundness); + } + + [Fact] + public void ANoAnswerAnywhereInAChainIsANoAnswerForTheChain() + { + // The integral has no closed form, so nothing downstream of it can have run. + var chain = Transformation.Integration("x").Then(Transformation.Simplification); + + Assert.False(chain.Apply(Parse("e ^ (x ^ 2)")).Succeeded); + } + + [Fact] + public void RepeatingSomethingZeroTimesChangesNothing() + => Assert.Equal( + Parse("(x + 1) ^ 2"), + Transformation.Expansion.Repeat(0).Apply(Parse("(x + 1) ^ 2")).Output); + + [Fact] + public void HittingTheBoundBeforeStabilisingIsReportedAsNoAnswer() + { + // One iteration expands the square, so a single application cannot yet show + // that another one would change nothing. + var tooFewIterations = Transformation.Expansion.UntilStable(1); + Assert.False(tooFewIterations.Apply(Parse("(x + 1) ^ 2")).Succeeded); + + // Given room, it settles. + var enough = Transformation.Expansion.UntilStable(16); + Assert.True(enough.Apply(Parse("(x + 1) ^ 2")).Succeeded); + } + + [Theory] + [InlineData(0)] + [InlineData(-1)] + public void UntilStableRefusesAnUnboundedLoop(int bound) + => Assert.Throws(() => Transformation.Simplification.UntilStable(bound)); + + #endregion + + #region The rule registry + + [Fact] + public void TheRegistryIsEnumerableAndItsOrderIsFixed() + { + Assert.NotEmpty(RewriteRules.All); + Assert.Equal(RewriteRules.All.Select(r => r.Name), RewriteRules.All.Select(r => r.Name)); + Assert.Equal(RewriteRules.CanonicalOrder, RewriteRules.All[0]); + Assert.Equal(RewriteRules.All.Count, RewriteRules.All.Select(r => r.Name).Distinct().Count()); + } + + [Fact] + public void EveryRuleSetSaysWhatItIsAndWhatItClaims() + { + foreach (var ruleSet in RewriteRules.All) + { + Assert.False(string.IsNullOrWhiteSpace(ruleSet.Name)); + Assert.False(string.IsNullOrWhiteSpace(ruleSet.Description)); + // Nothing in the registry may quietly claim a proof it has not got. + Assert.NotEqual(Soundness.Sound, ruleSet.Soundness); + } + } + + [Fact] + public void TheRegistryAndTheCatalogueAreBothFullyBuilt() + { + // A smoke check, and it is here because the failure it catches is not a failing + // assertion: RewriteRuleSet builds its transformation on demand precisely so + // that constructing a rule set does not run Transformation's static + // initialiser, which reads the registry back. Tie those two together and + // whichever type is touched second reads the other's fields before they are + // set, so the entries below come out null and every call that reaches them + // dies -- in whichever order the process happened to load them. + foreach (var ruleSet in RewriteRules.All) + Assert.NotNull(ruleSet.AsTransformation()); + + Assert.NotNull(Transformation.Simplification); + Assert.NotNull(Transformation.Expansion); + Assert.NotNull(Transformation.Factorization); + Assert.NotNull(Transformation.Normalization); + Assert.NotNull(Transformation.Rationalisation); + Assert.NotNull(Transformation.InnerSimplification); + } + + [Theory] + [MemberData(nameof(Corpus))] + public void NoRuleSetRewritesInACycle(string raw) + { + var expr = Parse(raw); + foreach (var ruleSet in RewriteRules.All) + Assert.True( + ruleSet.AsTransformation().UntilStable(32).Apply(expr).Succeeded, + $"{ruleSet.Name} did not reach a fixed point on {raw} within 32 passes"); + } + + [Theory] + [MemberData(nameof(Corpus))] + public void ARuleSetGivesTheSameAnswerEveryTime(string raw) + { + var expr = Parse(raw); + foreach (var ruleSet in RewriteRules.All) + Assert.Equal(ruleSet.ApplyOnce(expr), ruleSet.ApplyOnce(expr)); + } + + #endregion + + #region The 1.x methods answer what they answered + + [Theory] + [MemberData(nameof(Corpus))] + public void SimplifyIsItsTransformation(string raw) + => Assert.Equal(Parse(raw).Simplify(), Transformation.Simplification.Apply(Parse(raw)).Output); + + [Theory] + [MemberData(nameof(Corpus))] + public void ExpandIsItsTransformation(string raw) + => Assert.Equal(Parse(raw).Expand(), Transformation.Expansion.Apply(Parse(raw)).Output); + + [Theory] + [MemberData(nameof(Corpus))] + public void FactorizeIsItsTransformation(string raw) + => Assert.Equal(Parse(raw).Factorize(), Transformation.Factorization.Apply(Parse(raw)).Output); + + [Theory] + [InlineData(0)] + [InlineData(1)] + [InlineData(2)] + [InlineData(3)] + public void FactorizeRunsThePassAsManyTimesAsItIsAsked(int level) + => Assert.Equal( + Parse("x * y + y + 1 + x").Factorize(level), + Transformation.FactorizationAtLevel(level).Apply(Parse("x * y + y + 1 + x")).Output); + + [Theory] + [MemberData(nameof(Corpus))] + public void DifferentiateIsItsTransformation(string raw) + => Assert.Equal( + Parse(raw).Differentiate("x"), + Transformation.Differentiation("x").Apply(Parse(raw)).Output); + + [Theory] + [InlineData("x")] + [InlineData("sin(x)")] + [InlineData("1 / x")] + public void IntegrateIsItsTransformationPlusTheConstant(string raw) + { + var antiderivative = Transformation.Integration("x").Apply(Parse(raw)).Output; + Assert.NotNull(antiderivative); + Assert.Equal(Parse(raw).Integrate("x"), antiderivative! + (Entity)"C"); + } + + [Theory] + [InlineData("sin(x) / x", "0")] + [InlineData("(1 + 1 / x) ^ x", "+oo")] + public void LimitIsItsTransformationTidiedUp(string raw, string destination) + { + var computed = Transformation.LimitAt("x", destination, ApproachFrom.BothSides).Apply(Parse(raw)).Output; + Assert.NotNull(computed); + Assert.Equal(Parse(raw).Limit("x", destination, ApproachFrom.BothSides), computed!.InnerSimplified); + } + + [Theory] + [MemberData(nameof(Corpus))] + public void SubstituteIsItsTransformation(string raw) + => Assert.Equal( + Parse(raw).Substitute("x", 3), + Transformation.Substitution("x", 3).Apply(Parse(raw)).Output); + + #endregion + + #region Honesty + + [Fact] + public void AnIntegralWithNoClosedFormHasNoAnswerRatherThanAWrongOne() + { + var result = Transformation.Integration("x").Apply(Parse("e ^ (x ^ 2)")); + + Assert.False(result.Succeeded); + Assert.Null(result.Output); + + // The 1.x method makes the same claim in the shape its callers expect: an + // unevaluated node, which is "I could not settle this" and not NaN. + var legacy = Parse("e ^ (x ^ 2)").Integrate("x"); + Assert.IsType(legacy); + Assert.False(legacy.IsNaN); + } + + [Fact] + public void ALimitThatCannotBeSettledHasNoAnswerRatherThanNaN() + { + // The documented unevaluated case: see the example on Entity.Limit(Variable, Entity). + var expr = Parse("sin(x * a) / x"); + var result = Transformation.LimitAt("x", "+oo", ApproachFrom.BothSides).Apply(expr); + + Assert.False(result.Succeeded); + + var legacy = expr.Limit("x", "+oo", ApproachFrom.BothSides); + Assert.IsType(legacy); + Assert.False(legacy.IsNaN); + } + + #endregion + + #region Determinism and the relation each transformation claims + + [Theory] + [MemberData(nameof(Corpus))] + public void TheSameTransformationOnTheSameInputGivesTheSameAnswer(string raw) + { + var expr = Parse(raw); + foreach (var transformation in new[] + { + Transformation.Simplification, + Transformation.Expansion, + Transformation.Factorization, + Transformation.Normalization, + Transformation.Rationalisation, + Transformation.InnerSimplification, + }) + Assert.Equal(transformation.Apply(expr).Output, transformation.Apply(expr).Output); + } + + [Theory] + [MemberData(nameof(Corpus))] + public void AnEquivalenceTransformationDoesNotChangeTheValue(string raw) + { + var expr = Parse(raw); + foreach (var transformation in new[] + { + Transformation.Expansion, + Transformation.Factorization, + Transformation.Normalization, + Transformation.Rationalisation, + Transformation.InnerSimplification, + }) + { + Assert.Equal(TransformationRelation.Equivalence, transformation.Relation); + if (transformation.Apply(expr).Output is not { } output) + continue; + // The property, not the printed form: subtract the two sides and simplify. + // A domain condition may survive that -- the two sides agree only where both + // are defined, which is exactly what SoundUnderAssumptions says -- so the + // condition is stripped before the value is read. + var difference = (expr - output).Simplify(); + if (difference is Entity.Providedf(var value, _)) + difference = value; + Assert.True( + difference == 0, + $"{transformation.Name} changed the value of {raw}: difference simplified to {difference}"); + } + } + + [Fact] + public void SubstitutionIsTheOneThingHereThatNeedsNoAssumptions() + { + var substitution = Transformation.Substitution("x", 3); + Assert.Equal(Soundness.Sound, substitution.Soundness); + // and it is a different object, not another way of writing the same one + Assert.Equal(TransformationRelation.Derivation, substitution.Relation); + } + + [Theory] + [MemberData(nameof(Corpus))] + public void SimplifyingAnAlreadySimplifiedExpressionChangesNothing(string raw) + { + var once = Parse(raw).Simplify(); + Assert.Equal(once, once.Simplify()); + } + + #endregion + + #region The transformations this layer added + + [Fact] + public void NormalizationMakesTwoArrangementsOfOneExpressionTheSameTree() + { + var oneWay = Transformation.Normalization.Apply(Parse("x + y + z")).Output; + var another = Transformation.Normalization.Apply(Parse("z + y + x")).Output; + + Assert.Equal(oneWay, another); + // and it is not simplification: the two were already as short as they get + Assert.NotEqual(Parse("x + y + z"), Parse("z + y + x")); + } + + [Fact] + public void RationalisationClearsASurdOutOfADenominator() + { + var result = Transformation.Rationalisation.Apply(Parse("1 / (sqrt(3) + 5)")); + + Assert.True(result.Succeeded); + Assert.DoesNotContain( + result.Output!.Nodes, + node => node is Entity.Divf(_, var denominator) && denominator.Nodes.Any(n => n is Entity.Powf)); + } + + #endregion + } +} From 5925fc2df7d06514c256981e390cebcf93f18d35 Mon Sep 17 00:00:00 2001 From: Rafael Vuijk Date: Sat, 8 Aug 2026 12:47:28 +0000 Subject: [PATCH 2/4] Say what the level cache does rather than when it does it Co-Authored-By: Claude Opus 5 (1M context) --- .../Core/Transformations/Transformation.Catalogue.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/AngouriMath/Core/Transformations/Transformation.Catalogue.cs b/Sources/AngouriMath/Core/Transformations/Transformation.Catalogue.cs index 6e92959f9..51358d855 100644 --- a/Sources/AngouriMath/Core/Transformations/Transformation.Catalogue.cs +++ b/Sources/AngouriMath/Core/Transformations/Transformation.Catalogue.cs @@ -148,8 +148,8 @@ public static Transformation LimitAt(Variable variable, Entity destination, Appr side); /// - /// The transformations that differ only by a level, built once, so that an ordinary - /// Simplify() allocates nothing to reach its transformation. + /// The transformations that differ only by a level, each built at most once, so that + /// an ordinary Simplify() allocates nothing to reach its transformation. /// /// /// Filled on demand rather than in the static initialiser, and it has to be. Building From b2e3c428bae36ba1625b6426c0a7e4895b7c2c85 Mon Sep 17 00:00:00 2001 From: Rafael Vuijk Date: Sat, 8 Aug 2026 14:19:43 +0000 Subject: [PATCH 3/4] Register every rule set the simplifier applies, and route it through them The registry held ten sets; `Simplificator` applied twenty-two, reaching most of them through `Patterns` directly. A set reachable only by its method has no name to report and nothing to attribute a step to, so any account of what `Simplify` did to an expression would have quietly omitted most of what actually fired -- which is the wrong kind of answer to give, and the reason this comes before #28 rather than after it. All twenty-two are registered now, and `Simplificator` reaches all of them through `RewriteRules`. Two are parameterised by sort level, so each is registered once per level with an internal chooser beside it (`CanonicalOrderAt`, `CommonDenominatorAt`) -- the alternative is an entry that cannot be enumerated, which defeats the list. `Entity.Expand` is routed too, so that it is not the one catalogue transformation still naming its own rules. Thirty entries in total; `Rewrite(ruleSet)` is an internal extension so a sequence of them still reads in the order it runs. This is a rename at every call site: `ApplyOnce` *is* `Replace(rules)`. Nothing about the order, the guards or the candidate selection moved. Measured against d1c4a4dd on net10.0/Release, two samples each. Allocation is deterministic and reproduces exactly: SimplifyEasy 157.6 -> 125.8 KB (-20%) SimplifyCommon 6971.1 -> 6867.8 KB Limit 7658.0 -> 7516.4 KB Integrate 1774.5 -> 1759.5 KB `Patterns.SortRules(level)` builds a fresh closure on every call and so does the common-denominator lambda; both ran once per pass inside the simplification loop and are now cached registry entries. Timings are unchanged within noise. The tests over `RewriteRules.All` now cover thirty sets rather than ten, and the corpus gained boolean, comparison, set, factorial and totient expressions so that those sets are exercised rather than passed over. Every registered set still reaches a fixed point within 32 passes and is deterministic. Two things the widened corpus turned up: `Expand` throws `AngouriBugException` on `(x + 1)! / x!` -- a public method crashing on an ordinary expression that `Simplify` answers as `1 + x`. It reproduces on d1c4a4dd, where `Expand`'s logic is untouched, so it is pre-existing: filed as #817, with a skipped regression test. The equivalence property test was wrong twice over, and both are fixed. Sets subtract elementwise, so the difference of two equal sets is the set of pairwise differences rather than zero; set-valued results are now compared by simplifying both sides. And simplifying a difference to zero proves agreement while failing to do so proves nothing -- `x! * (x + 1)` expands to `(x + 1)!`, correctly, and the difference does not reduce. The test now looks for an actual counterexample at sample points before reporting a defect, with a relative tolerance, and has its own test: it must separate `sqrt(x)` from `-sqrt(x)` and must not separate `x! * (x + 1)` from `(x + 1)!`. Tests: 5773 passing, 0 failed, 15 skipped (the new one is #817's); F# 130. Co-Authored-By: Claude Opus 5 (1M context) --- .../RewriteRuleSetExtensions.cs | 24 ++ .../Core/Transformations/RewriteRules.cs | 315 ++++++++++++++++-- .../Docs/Contributing/Transformations.md | 18 +- .../Evaluation/Evaluation.Definition.cs | 6 +- .../Functions/Simplification/Simplificator.cs | 66 ++-- .../Common/SimplificationRegressionTest.cs | 13 + .../Transformations/TransformationTest.cs | 104 +++++- 7 files changed, 484 insertions(+), 62 deletions(-) create mode 100644 Sources/AngouriMath/Core/Transformations/RewriteRuleSetExtensions.cs diff --git a/Sources/AngouriMath/Core/Transformations/RewriteRuleSetExtensions.cs b/Sources/AngouriMath/Core/Transformations/RewriteRuleSetExtensions.cs new file mode 100644 index 000000000..fa9d9706a --- /dev/null +++ b/Sources/AngouriMath/Core/Transformations/RewriteRuleSetExtensions.cs @@ -0,0 +1,24 @@ +// +// Copyright (c) 2019-2026 Angouri. +// AngouriMath is licensed under MIT. +// Details: https://github.com/asc-community/AngouriMath/blob/master/LICENSE.md. +// Website: https://am.angouri.org. +// + +namespace AngouriMath.Core.Transformations +{ + /// + /// Reading order for . + /// + /// + /// The simplifier applies a dozen rule sets in sequence, and written as calls the + /// sequence reads inside out. This is the same operation with the expression in front, + /// so that a pipeline still reads in the order it runs. + /// + internal static class RewriteRuleSetExtensions + { + /// Applies once over every node of . + internal static Entity Rewrite(this Entity expression, RewriteRuleSet ruleSet) + => ruleSet.ApplyOnce(expression); + } +} diff --git a/Sources/AngouriMath/Core/Transformations/RewriteRules.cs b/Sources/AngouriMath/Core/Transformations/RewriteRules.cs index 592b11cfa..b8d7e36b3 100644 --- a/Sources/AngouriMath/Core/Transformations/RewriteRules.cs +++ b/Sources/AngouriMath/Core/Transformations/RewriteRules.cs @@ -21,28 +21,59 @@ namespace AngouriMath.Core.Transformations /// which type happened to be loaded first. /// /// - /// This is a slice, not the whole pattern table. The sets below are the ones the - /// transformations in are built from; the rest of - /// Functions/Simplification/Patterns is still reached only from - /// Simplificator. Adding one here is five lines and gets it enumeration, a - /// soundness label and the tests over for free. + /// Every rule set the simplifier applies is registered here, and the simplifier reaches + /// them through this registry rather than through Patterns directly. That is what + /// lets an account of what the simplifier did to an expression be a complete one: a set + /// reachable only by its method has no name to report and nothing to attribute a step to, + /// so a derivation built while some sets were still unregistered would quietly omit + /// whatever they had done. + /// + /// + /// Every entry is declared . That is a claim + /// about what has been argued, not about what is true — nothing here checks a tier, so + /// the registry starts conservative and promoting an entry means making the case for it. /// /// public static class RewriteRules { + #region Arrangement + /// /// Puts the operands of commutative chains into a canonical order and groups equal /// ones together, so that x + y and y + x stop being different trees. + /// Looks at variables and functions only, ignoring constants and operators. /// public static RewriteRuleSet CanonicalOrder { get; } = new( nameof(CanonicalOrder), - "Sorts and groups the operands of sums, products, conjunctions, disjunctions and set operations.", + "Sorts and groups the operands of sums, products, conjunctions, disjunctions and set operations, by variables and functions alone.", TransformationRelation.Equivalence, // Regrouping reads a quotient as a product with a negative power, which is the // same value wherever the divisor is not zero. Soundness.SoundUnderAssumptions, Patterns.SortRules(TreeAnalyzer.SortLevel.HIGH_LEVEL)); + /// + /// , counting constants as well, so that terms differing + /// only by a numeric factor are no longer grouped together. + /// + public static RewriteRuleSet CanonicalOrderCountingConstants { get; } = new( + nameof(CanonicalOrderCountingConstants), + "Sorts and groups commutative operands, distinguishing terms by their constants too.", + TransformationRelation.Equivalence, + Soundness.SoundUnderAssumptions, + Patterns.SortRules(TreeAnalyzer.SortLevel.MIDDLE_LEVEL)); + + /// + /// over the whole subtree, so that only structurally + /// identical operands are grouped. + /// + public static RewriteRuleSet CanonicalOrderExact { get; } = new( + nameof(CanonicalOrderExact), + "Sorts and groups commutative operands by the whole subtree.", + TransformationRelation.Equivalence, + Soundness.SoundUnderAssumptions, + Patterns.SortRules(TreeAnalyzer.SortLevel.LOW_LEVEL)); + /// /// Turns a negative power into a quotient: a * b ^ (-1) becomes a / b. /// @@ -74,6 +105,30 @@ public static class RewriteRules Soundness.SoundUnderAssumptions, Patterns.CommonRules); + /// + /// Gets a quotient into the shape the division rules expect before they run. + /// + public static RewriteRuleSet DivisionPreparing { get; } = new( + nameof(DivisionPreparing), + "Lifts numeric factors out of a quotient so that the division rules can see it.", + TransformationRelation.Equivalence, + Soundness.SoundUnderAssumptions, + Patterns.DivisionPreparingRules); + + /// + /// Cosmetic arrangement of signs, so that adding a negative reads as a difference. + /// + public static RewriteRuleSet NumericNeat { get; } = new( + nameof(NumericNeat), + "Arranges signs so that adding a negative is written as subtracting a positive.", + TransformationRelation.Equivalence, + Soundness.SoundUnderAssumptions, + Patterns.NumericNeatRules); + + #endregion + + #region Powers, products and sums + /// /// Rules about powers, roots and logarithms. /// @@ -86,18 +141,6 @@ public static class RewriteRules Soundness.SoundUnderAssumptions, Patterns.PowerRules); - /// - /// The trigonometric identities. - /// - public static RewriteRuleSet Trigonometric { get; } = new( - nameof(Trigonometric), - "Applies trigonometric identities to sines, cosines and their relatives.", - TransformationRelation.Equivalence, - // tan and cot bring poles with them, so an identity that introduces one holds - // away from those points rather than everywhere. - Soundness.SoundUnderAssumptions, - Patterns.TrigonometricRules); - /// /// Multiplies products over sums out. /// @@ -129,6 +172,10 @@ public static class RewriteRules Soundness.SoundUnderAssumptions, Patterns.PerfectSquareRules); + #endregion + + #region Quotients + /// /// Clears a surd out of a two-term denominator. /// @@ -139,6 +186,193 @@ public static class RewriteRules Soundness.SoundUnderAssumptions, Patterns.RationaliseDenominator); + /// + /// Brings a quotient of quotients down to a single one. + /// + public static RewriteRuleSet CollapseMultipleFractions { get; } = new( + nameof(CollapseMultipleFractions), + "Collapses nested quotients into a single numerator over a single denominator.", + TransformationRelation.Equivalence, + Soundness.SoundUnderAssumptions, + Patterns.CollapseMultipleFractions); + + /// + /// Puts a sum of quotients over one denominator, grouping the terms by variables and + /// functions alone. + /// + public static RewriteRuleSet CommonDenominator { get; } = new( + nameof(CommonDenominator), + "Adds quotients by putting them over a common denominator.", + TransformationRelation.Equivalence, + Soundness.SoundUnderAssumptions, + expr => Patterns.FractionCommonDenominatorRules(expr, TreeAnalyzer.SortLevel.HIGH_LEVEL)); + + /// + /// , counting constants when it groups terms. + /// + public static RewriteRuleSet CommonDenominatorCountingConstants { get; } = new( + nameof(CommonDenominatorCountingConstants), + "Adds quotients over a common denominator, distinguishing terms by their constants too.", + TransformationRelation.Equivalence, + Soundness.SoundUnderAssumptions, + expr => Patterns.FractionCommonDenominatorRules(expr, TreeAnalyzer.SortLevel.MIDDLE_LEVEL)); + + /// + /// , grouping terms by the whole subtree. + /// + public static RewriteRuleSet CommonDenominatorExact { get; } = new( + nameof(CommonDenominatorExact), + "Adds quotients over a common denominator, grouping terms by the whole subtree.", + TransformationRelation.Equivalence, + Soundness.SoundUnderAssumptions, + expr => Patterns.FractionCommonDenominatorRules(expr, TreeAnalyzer.SortLevel.LOW_LEVEL)); + + /// + /// Divides one polynomial by another, leaving a quotient plus a remainder. + /// + public static RewriteRuleSet PolynomialLongDivision { get; } = new( + nameof(PolynomialLongDivision), + "Divides a polynomial by a polynomial, giving the quotient plus the remainder.", + TransformationRelation.Equivalence, + Soundness.SoundUnderAssumptions, + Patterns.PolynomialLongDivision); + + /// + /// Puts a quotient of polynomials into lowest terms. + /// + public static RewriteRuleSet PolynomialGcdCancellation { get; } = new( + nameof(PolynomialGcdCancellation), + "Cancels the greatest common divisor of a polynomial quotient's numerator and denominator.", + TransformationRelation.Equivalence, + Soundness.SoundUnderAssumptions, + Patterns.PolynomialGcdCancellation); + + #endregion + + #region Trigonometry + + /// + /// The trigonometric identities. + /// + public static RewriteRuleSet Trigonometric { get; } = new( + nameof(Trigonometric), + "Applies trigonometric identities to sines, cosines and their relatives.", + TransformationRelation.Equivalence, + // tan and cot bring poles with them, so an identity that introduces one holds + // away from those points rather than everywhere. + Soundness.SoundUnderAssumptions, + Patterns.TrigonometricRules); + + /// + /// Rewrites the derived trigonometric functions in terms of sine and cosine. + /// + public static RewriteRuleSet NormalTrigonometricForm { get; } = new( + nameof(NormalTrigonometricForm), + "Writes tangents, cotangents, secants and cosecants as sines and cosines.", + TransformationRelation.Equivalence, + Soundness.SoundUnderAssumptions, + Patterns.NormalTrigonometricForm); + + /// + /// Gathers sines and cosines back into the derived functions where that is shorter. + /// + public static RewriteRuleSet CollapseTrigonometricFunctions { get; } = new( + nameof(CollapseTrigonometricFunctions), + "Recognises a quotient or reciprocal of sines and cosines as a tangent, cotangent, secant or cosecant.", + TransformationRelation.Equivalence, + Soundness.SoundUnderAssumptions, + Patterns.CollapseTrigonometricFunctions); + + /// + /// Opens a trigonometric function of a sum into functions of its terms. + /// + public static RewriteRuleSet ExpandTrigonometric { get; } = new( + nameof(ExpandTrigonometric), + "Expands a sine or cosine of a sum into products of sines and cosines of its terms.", + TransformationRelation.Equivalence, + Soundness.SoundUnderAssumptions, + Patterns.ExpandTrigonometricRules); + + /// + /// Opens a trigonometric function of a multiplied angle. + /// + /// + /// Written out, sin(4x) is far longer than it started, which is why the + /// simplifier offers the result as a candidate rather than taking it. + /// + public static RewriteRuleSet ExpandMultipleAngle { get; } = new( + nameof(ExpandMultipleAngle), + "Expands a sine or cosine of an integer multiple of an angle.", + TransformationRelation.Equivalence, + Soundness.SoundUnderAssumptions, + Patterns.ExpandMultipleAngleRules); + + #endregion + + #region Statements, sets and number theory + + /// + /// The rules of boolean algebra. + /// + public static RewriteRuleSet Boolean { get; } = new( + nameof(Boolean), + "Applies the identities of boolean algebra to conjunctions, disjunctions and negations.", + TransformationRelation.Equivalence, + Soundness.SoundUnderAssumptions, + Patterns.BooleanRules); + + /// + /// Rules about equalities and inequalities. + /// + public static RewriteRuleSet InequalityEquality { get; } = new( + nameof(InequalityEquality), + "Rearranges equalities and inequalities into their usual form.", + TransformationRelation.Equivalence, + Soundness.SoundUnderAssumptions, + Patterns.InequalityEqualityRules); + + /// + /// Rules about unions, intersections and set differences. + /// + public static RewriteRuleSet SetOperator { get; } = new( + nameof(SetOperator), + "Applies the identities of set algebra to unions, intersections and set differences.", + TransformationRelation.Equivalence, + Soundness.SoundUnderAssumptions, + Patterns.SetOperatorRules); + + /// + /// Cancels a quotient of factorials down to the terms that survive. + /// + public static RewriteRuleSet ExpandFactorialDivisions { get; } = new( + nameof(ExpandFactorialDivisions), + "Cancels a quotient of factorials into the product of the terms that do not cancel.", + TransformationRelation.Equivalence, + Soundness.SoundUnderAssumptions, + Patterns.ExpandFactorialDivisions); + + /// + /// Recognises a product of consecutive terms as a factorial. + /// + public static RewriteRuleSet FactorizeFactorialMultiplications { get; } = new( + nameof(FactorizeFactorialMultiplications), + "Gathers a product of a factorial and its neighbouring terms back into one factorial.", + TransformationRelation.Equivalence, + Soundness.SoundUnderAssumptions, + Patterns.FactorizeFactorialMultiplications); + + /// + /// Rules about Euler's totient function. + /// + public static RewriteRuleSet PhiFunction { get; } = new( + nameof(PhiFunction), + "Applies the multiplicative identities of Euler's totient function.", + TransformationRelation.Equivalence, + Soundness.SoundUnderAssumptions, + Patterns.PhiFunctionRules); + + #endregion + /// /// Every rule set registered above, in a fixed order. Enumerable so that a property /// that should hold of all of them can be tested over all of them rather than over @@ -147,15 +381,58 @@ public static class RewriteRules public static IReadOnlyList All { get; } = new[] { CanonicalOrder, + CanonicalOrderCountingConstants, + CanonicalOrderExact, InvertNegativePowers, InvertNegativeMultipliers, Common, + DivisionPreparing, + NumericNeat, Power, - Trigonometric, Expansion, Factorization, PerfectSquare, RationaliseDenominator, + CollapseMultipleFractions, + CommonDenominator, + CommonDenominatorCountingConstants, + CommonDenominatorExact, + PolynomialLongDivision, + PolynomialGcdCancellation, + Trigonometric, + NormalTrigonometricForm, + CollapseTrigonometricFunctions, + ExpandTrigonometric, + ExpandMultipleAngle, + Boolean, + InequalityEquality, + SetOperator, + ExpandFactorialDivisions, + FactorizeFactorialMultiplications, + PhiFunction, }; + + /// + /// The family, chosen by how finely it distinguishes + /// operands. The simplifier picks the level from which pass it is on. + /// + internal static RewriteRuleSet CanonicalOrderAt(TreeAnalyzer.SortLevel level) + => level switch + { + TreeAnalyzer.SortLevel.MIDDLE_LEVEL => CanonicalOrderCountingConstants, + TreeAnalyzer.SortLevel.LOW_LEVEL => CanonicalOrderExact, + _ => CanonicalOrder + }; + + /// + /// The family, chosen the same way. + /// + internal static RewriteRuleSet CommonDenominatorAt(TreeAnalyzer.SortLevel level) + => level switch + { + TreeAnalyzer.SortLevel.MIDDLE_LEVEL => CommonDenominatorCountingConstants, + TreeAnalyzer.SortLevel.LOW_LEVEL => CommonDenominatorExact, + _ => CommonDenominator + }; } } diff --git a/Sources/AngouriMath/Docs/Contributing/Transformations.md b/Sources/AngouriMath/Docs/Contributing/Transformations.md index e9fec5a64..fba4f399e 100644 --- a/Sources/AngouriMath/Docs/Contributing/Transformations.md +++ b/Sources/AngouriMath/Docs/Contributing/Transformations.md @@ -36,7 +36,7 @@ same claim in the shape its callers expect, by handing back an unevaluated `Inte | `Transformation` | the operation: `Name`, `Relation`, `Soundness`, `Apply`, and the static catalogue | | `TransformationResult` | input, output-or-nothing, and which transformation ran. A struct, so routing an ordinary call through this layer allocates nothing | | `RewriteRuleSet` | a named, attributed group of rewrites — `Name`, `Description`, `Relation`, `Soundness`, `ApplyOnce` | -| `RewriteRules` | the registry: every shipped set, explicitly listed, enumerable through `All` in a fixed order | +| `RewriteRules` | the registry: every rule set the simplifier applies, explicitly listed, enumerable through `All` in a fixed order | Composition is `Then`, `Repeat(n)` and `UntilStable(max)`. All three take their bound from the caller: there is no unbounded rewrite loop anywhere in the layer, and `UntilStable` reports hitting @@ -83,8 +83,12 @@ API does not invent symmetry that the mathematics does not have. **`Heuristic` currently has no instance in the catalogue.** That is a statement about what is registered so far, not a claim that nothing in the library guesses. -**Most of the pattern table is still only reachable from `Simplificator`.** `RewriteRules` registers -the ten sets the catalogue is built from. The rest are unchanged and unregistered. +**Some rewrites still bypass the registry.** Every set the *simplifier* applies is registered, and +`Simplificator` reaches all of them through `RewriteRules` — that is what lets an account of what +`Simplify` did be a complete one, since a set reachable only by its method has no name to attribute a +step to. The equation and set solvers, the integrator and `TreeAnalyzer` still call `Patterns` +directly. `Patterns.TrigonometricToExponentialRules(from, to)` cannot become a registry entry as it +stands: it is parameterised by two variables, so it is a family of sets rather than one. ## Adding the next one @@ -109,7 +113,13 @@ public static RewriteRuleSet Power { get; } = new( ``` Add it to `RewriteRules.All` in the same change — the list is explicit so that its order is a -decision rather than an accident. +decision rather than an accident. Where a set is parameterised by something with a small fixed range, +register one entry per value and add an `internal` chooser beside them (`CanonicalOrderAt`, +`CommonDenominatorAt` are the two): the alternative is an entry that cannot be enumerated, which +defeats the point of the list. + +Inside the library, apply a set with `expression.Rewrite(RewriteRules.Power)` rather than +`RewriteRules.Power.ApplyOnce(expression)` — a sequence of rule sets otherwise reads inside out. Two things to get right: diff --git a/Sources/AngouriMath/Functions/Evaluation/Evaluation.Definition.cs b/Sources/AngouriMath/Functions/Evaluation/Evaluation.Definition.cs index 8c8786c0d..055d6fda2 100644 --- a/Sources/AngouriMath/Functions/Evaluation/Evaluation.Definition.cs +++ b/Sources/AngouriMath/Functions/Evaluation/Evaluation.Definition.cs @@ -216,8 +216,8 @@ internal Entity ExpandOverSum(int level) { static Entity Expand_(Entity e, int level) => level <= 1 - ? e.Replace(Patterns.ExpandRules) - : Expand_(e.Replace(Patterns.ExpandRules), level - 1); + ? e.Rewrite(RewriteRules.Expansion) + : Expand_(e.Rewrite(RewriteRules.Expansion), level - 1); var expChildren = new List(); foreach (var linChild in Sumf.LinearChildren(this)) if (TreeAnalyzer.SmartExpandOver(linChild, entity => true) is { } exp) @@ -265,7 +265,7 @@ private static Entity CollectLikeTerms(Entity expanded) { // PowerRules folds (x^2)^2 into x^4, without which the two would be // counted as different monomials. - var reduced = factor.Replace(Functions.Patterns.PowerRules).InnerSimplified; + var reduced = factor.Rewrite(RewriteRules.Power).InnerSimplified; if (reduced is Number) { coefficient = (coefficient * reduced).InnerSimplified; diff --git a/Sources/AngouriMath/Functions/Simplification/Simplificator.cs b/Sources/AngouriMath/Functions/Simplification/Simplificator.cs index 695e22cb0..4a4a63fa6 100644 --- a/Sources/AngouriMath/Functions/Simplification/Simplificator.cs +++ b/Sources/AngouriMath/Functions/Simplification/Simplificator.cs @@ -72,7 +72,7 @@ void AddHistory(Entity expr) #endif void __IterAddHistory(Entity expr) { - var refexpr = expr.Replace(Patterns.SortRules(TreeAnalyzer.SortLevel.HIGH_LEVEL)).InnerSimplified; + var refexpr = expr.Rewrite(RewriteRules.CanonicalOrder).InnerSimplified; var compl1 = refexpr.SimplifiedRate; var compl2 = expr.SimplifiedRate; var n = compl1 > compl2 ? expr : refexpr; @@ -83,7 +83,7 @@ void __IterAddHistory(Entity expr) history[ncompl] = new HashSet { n }; } __IterAddHistory(expr); - __IterAddHistory(expr.Replace(Patterns.InvertNegativePowers)); + __IterAddHistory(expr.Rewrite(RewriteRules.InvertNegativePowers)); MultithreadingFunctional.ExitIfCancelled(); } @@ -121,15 +121,15 @@ void __IterAddHistory(Entity expr) // is an accident rather than a preference. // https://github.com/asc-community/AngouriMath/issues/205 if (res.Nodes.Any(child => child is Divf)) - AddHistory(res = res.Replace(Patterns.RationaliseDenominator).InnerSimplified); + AddHistory(res = res.Rewrite(RewriteRules.RationaliseDenominator).InnerSimplified); - res = res.Replace(Patterns.SortRules(sortLevel)).InnerSimplified; + res = res.Rewrite(RewriteRules.CanonicalOrderAt(sortLevel)).InnerSimplified; if (res.Nodes.Any(child => child is Powf)) - AddHistory(res = res.Replace(Patterns.PowerRules).InnerSimplified); + AddHistory(res = res.Rewrite(RewriteRules.Power).InnerSimplified); AddHistory(res = SimplifyChildren(res)); - AddHistory(res = res.Replace(Patterns.InvertNegativePowers).Replace(Patterns.DivisionPreparingRules).InnerSimplified); + AddHistory(res = res.Rewrite(RewriteRules.InvertNegativePowers).Rewrite(RewriteRules.DivisionPreparing).InnerSimplified); // Lowest terms, offered as one more candidate rather than taken. Cancelling // means multiplying out, and a quotient that cancels down to a polynomial @@ -138,41 +138,41 @@ void __IterAddHistory(Entity expr) // stands, and u^2 + 4u + 4 only once this has expanded it. The complexity // metric is what should decide between them. if (res.Nodes.Any(child => child is Divf)) - AddHistory(res.Replace(Patterns.PolynomialGcdCancellation).InnerSimplified); + AddHistory(res.Rewrite(RewriteRules.PolynomialGcdCancellation).InnerSimplified); - AddHistory(res = res.Replace(Patterns.PolynomialLongDivision).InnerSimplified); + AddHistory(res = res.Rewrite(RewriteRules.PolynomialLongDivision).InnerSimplified); - AddHistory(res = res.Replace(Patterns.NormalTrigonometricForm).InnerSimplified); - AddHistory(res = res.Replace(Patterns.CollapseMultipleFractions).InnerSimplified); - AddHistory(res = res.Replace(e => Patterns.FractionCommonDenominatorRules(e, sortLevel)).InnerSimplified); - AddHistory(res = res.Replace(Patterns.InvertNegativePowers).Replace(Patterns.DivisionPreparingRules).InnerSimplified); - AddHistory(res = res.Replace(Patterns.PowerRules).InnerSimplified); - AddHistory(res = res.Replace(Patterns.TrigonometricRules).InnerSimplified); - AddHistory(res = res.Replace(Patterns.CollapseTrigonometricFunctions).InnerSimplified); + AddHistory(res = res.Rewrite(RewriteRules.NormalTrigonometricForm).InnerSimplified); + AddHistory(res = res.Rewrite(RewriteRules.CollapseMultipleFractions).InnerSimplified); + AddHistory(res = res.Rewrite(RewriteRules.CommonDenominatorAt(sortLevel)).InnerSimplified); + AddHistory(res = res.Rewrite(RewriteRules.InvertNegativePowers).Rewrite(RewriteRules.DivisionPreparing).InnerSimplified); + AddHistory(res = res.Rewrite(RewriteRules.Power).InnerSimplified); + AddHistory(res = res.Rewrite(RewriteRules.Trigonometric).InnerSimplified); + AddHistory(res = res.Rewrite(RewriteRules.CollapseTrigonometricFunctions).InnerSimplified); if (res.Nodes.Any(child => child is TrigonometricFunction)) { - var res1 = res.Replace(Patterns.ExpandTrigonometricRules).InnerSimplified; - AddHistory(res = res.Replace(Patterns.TrigonometricRules).Replace(Patterns.CommonRules).InnerSimplified); + var res1 = res.Rewrite(RewriteRules.ExpandTrigonometric).InnerSimplified; + AddHistory(res = res.Rewrite(RewriteRules.Trigonometric).Rewrite(RewriteRules.Common).InnerSimplified); AddHistory(res1); res = PickSimplest(res, res1); - AddHistory(res = res.Replace(Patterns.CollapseTrigonometricFunctions).Replace(Patterns.TrigonometricRules)); + AddHistory(res = res.Rewrite(RewriteRules.CollapseTrigonometricFunctions).Rewrite(RewriteRules.Trigonometric)); // Multiple angles opened up, then gathered again by the ordinary rules. // Offered as a candidate rather than taken: written out, sin(4x) is far // longer than it started, and only worth it where the pieces cancel -- // which is what the complexity metric is for. var expandedAngles = res - .Replace(Patterns.ExpandMultipleAngleRules) - .Replace(Patterns.NormalTrigonometricForm) + .Rewrite(RewriteRules.ExpandMultipleAngle) + .Rewrite(RewriteRules.NormalTrigonometricForm) .InnerSimplified; if (expandedAngles != res) { AddHistory(expandedAngles); AddHistory(SimplifyChildren(expandedAngles) - .Replace(Patterns.TrigonometricRules) - .Replace(Patterns.CommonRules) + .Rewrite(RewriteRules.Trigonometric) + .Rewrite(RewriteRules.Common) .InnerSimplified); } } @@ -180,35 +180,35 @@ void __IterAddHistory(Entity expr) if (res.Nodes.Any(child => child is Statement)) { - AddHistory(res = res.Replace(Patterns.BooleanRules).InnerSimplified); + AddHistory(res = res.Rewrite(RewriteRules.Boolean).InnerSimplified); } if (res.Nodes.Any(child => child is ComparisonSign)) { - AddHistory(res = res.Replace(Patterns.InequalityEqualityRules).InnerSimplified); + AddHistory(res = res.Rewrite(RewriteRules.InequalityEquality).InnerSimplified); } if (res.Nodes.Any(child => child is Factorialf)) { - AddHistory(res = res.Replace(Patterns.ExpandFactorialDivisions).InnerSimplified); - AddHistory(res = res.Replace(Patterns.FactorizeFactorialMultiplications).InnerSimplified); + AddHistory(res = res.Rewrite(RewriteRules.ExpandFactorialDivisions).InnerSimplified); + AddHistory(res = res.Rewrite(RewriteRules.FactorizeFactorialMultiplications).InnerSimplified); } if (res.Nodes.Any(child => child is Powf or Logf)) - AddHistory(res = res.Replace(Patterns.PowerRules).InnerSimplified); + AddHistory(res = res.Rewrite(RewriteRules.Power).InnerSimplified); if (res.Nodes.Any(child => child is Set)) { - var replaced = res.Replace(Patterns.SetOperatorRules); + var replaced = res.Rewrite(RewriteRules.SetOperator); AddHistory(res = replaced.InnerSimplified); } if (res.Nodes.Any(child => child is Phif)) - AddHistory(res = res.Replace(Patterns.PhiFunctionRules).InnerSimplified); + AddHistory(res = res.Rewrite(RewriteRules.PhiFunction).InnerSimplified); Entity? possiblePoly = null; foreach (var var in res.Vars) @@ -227,10 +227,10 @@ void __IterAddHistory(Entity expr) AddHistory(factoredPoly); - AddHistory(res = res.Replace(Patterns.CommonRules)); + AddHistory(res = res.Rewrite(RewriteRules.Common)); - AddHistory(res = res.Replace(Patterns.NumericNeatRules)); + AddHistory(res = res.Rewrite(RewriteRules.NumericNeat)); /* This was intended to simplify expressions as polynomials over nodes, some kind of @@ -259,8 +259,8 @@ not solve this issue completely and yet too slow to be accepted. // reporter's second expression is 0, and reaches 0 through // 2 sin(t) cos(t) and not through sin(2t). var openedAngles = res - .Replace(Patterns.ExpandMultipleAngleRules) - .Replace(Patterns.NormalTrigonometricForm) + .Rewrite(RewriteRules.ExpandMultipleAngle) + .Rewrite(RewriteRules.NormalTrigonometricForm) .InnerSimplified; if (openedAngles != res) // Expanded, and for the same reason res is expanded above: the diff --git a/Sources/Tests/UnitTests/Common/SimplificationRegressionTest.cs b/Sources/Tests/UnitTests/Common/SimplificationRegressionTest.cs index 0663d1f78..4fc3895f2 100644 --- a/Sources/Tests/UnitTests/Common/SimplificationRegressionTest.cs +++ b/Sources/Tests/UnitTests/Common/SimplificationRegressionTest.cs @@ -401,5 +401,18 @@ public void ANegativeFactorUnderTheLineStaysUnderIt(string input, double at, dou Assert.True(System.Math.Abs(((Entity.Number.Complex)value).RealPart.EDecimal.ToDouble() - expected) < 1e-9, $"{input} simplified to {simplified.Stringize()}, which is {value.Stringize()} at x = {at}"); } + + // Expand reaches SmartExpandOver with a sum and that method asserts it never gets + // one, so a public call throws AngouriBugException where the honest answer is the + // expression back unexpanded. Simplify handles the same input and gives 1 + x. + // https://github.com/asc-community/AngouriMath/issues/817 + [Theory(Skip = "https://github.com/asc-community/AngouriMath/issues/817")] + [InlineData("(x + 1)! / x!")] + [InlineData("(x + 2)! / x!")] + public void ExpandDoesNotThrowOnAQuotientOfFactorials(string input) + { + var expanded = input.ToEntity().Expand(); + Assert.NotNull(expanded); + } } } diff --git a/Sources/Tests/UnitTests/Core/Transformations/TransformationTest.cs b/Sources/Tests/UnitTests/Core/Transformations/TransformationTest.cs index b88301f7e..67f8f28fb 100644 --- a/Sources/Tests/UnitTests/Core/Transformations/TransformationTest.cs +++ b/Sources/Tests/UnitTests/Core/Transformations/TransformationTest.cs @@ -39,6 +39,16 @@ public sealed class TransformationTest "(x ^ 3 + 3 * x ^ 2 * y + 3 * x * y ^ 2 + y ^ 3) / (x + y)", "2 * x + 3 * x", "sqrt(12) + sqrt(27)", + // Not arithmetic, so that the boolean, comparison, set, factorial and totient + // rule sets are exercised rather than passing over inputs they cannot match. + "a and b or a and not b", + "x > 3 and x < 5", + "{ 1, 2 } unite { 2, 3 }", + // Not (x + 1)! / x!, which crashes Expand: + // https://github.com/asc-community/AngouriMath/issues/817 + "x! * (x + 1)", + "phi(12)", + "tan(x) * cot(x)", }.Select(x => new object[] { x }).ToArray(); private static Entity Parse(string raw) => MathS.FromString(raw); @@ -355,6 +365,17 @@ public void AnEquivalenceTransformationDoesNotChangeTheValue(string raw) Assert.Equal(TransformationRelation.Equivalence, transformation.Relation); if (transformation.Apply(expr).Output is not { } output) continue; + + // Subtracting a set from a set is elementwise, so the difference of two + // equal sets is the set of pairwise differences and not zero. The property + // is about expressions that denote a value; for the rest, the strongest + // honest statement is that the two simplify to the same thing. + if (expr is Entity.Set || output is Entity.Set) + { + Assert.Equal(expr.Simplify(), output.Simplify()); + continue; + } + // The property, not the printed form: subtract the two sides and simplify. // A domain condition may survive that -- the two sides agree only where both // are defined, which is exactly what SoundUnderAssumptions says -- so the @@ -362,12 +383,89 @@ public void AnEquivalenceTransformationDoesNotChangeTheValue(string raw) var difference = (expr - output).Simplify(); if (difference is Entity.Providedf(var value, _)) difference = value; - Assert.True( - difference == 0, - $"{transformation.Name} changed the value of {raw}: difference simplified to {difference}"); + if (difference == 0) + continue; + + // Simplifying the difference to zero proves the two agree; failing to is not + // proof that they differ, only that this simplifier could not settle it. + // x! * (x + 1) expands to (x + 1)!, which is right, and the difference of the + // two does not reduce. So the fallback looks for an actual counterexample + // instead of reporting the unproven case as a defect. + Assert.False( + DisagreesAtSomePoint(expr, output), + $"{transformation.Name} changed the value of {raw}: it gives {output.Stringize()}, " + + "and the two take different values at a point where both are defined"); + } + } + + /// + /// Whether the two take different values somewhere both are defined — a + /// counterexample to their being the same expression written two ways. + /// + /// + /// Points where either side fails to evaluate, or comes out infinite or NaN, say + /// nothing: a rewrite that is valid away from a pole is exactly what + /// means, so those are passed over + /// rather than counted against it. Small positive integers, since factorials and + /// logarithms are only defined on part of the line and 0 and 1 are degenerate for + /// both. + /// + private static bool DisagreesAtSomePoint(Entity one, Entity another) + { + var variables = one.Vars.Concat(another.Vars).Distinct().ToList(); + foreach (var point in new[] { 2, 3, 5 }) + { + Entity left = one, right = another; + foreach (var variable in variables) + { + left = left.Substitute(variable, point); + right = right.Substitute(variable, point); + } + + Entity.Number difference, scale; + try + { + difference = (left - right).EvalNumerical(); + scale = left.EvalNumerical(); + } + catch (AngouriMath.Core.Exceptions.AngouriMathBaseException) + { + continue; + } + + if (!difference.IsFinite || !scale.IsFinite) + continue; + + // Relative: the values here run from single digits to factorials, and an + // absolute threshold would either pass everything large or fail everything + // evaluated to a hundred digits and rounded. + if (Magnitude(difference) > 1e-9 * (1 + Magnitude(scale))) + return true; + } + return false; + + static double Magnitude(Entity.Number number) + { + var complex = (Entity.Number.Complex)number; + var real = complex.RealPart.EDecimal.ToDouble(); + var imaginary = complex.ImaginaryPart.EDecimal.ToDouble(); + return Math.Sqrt(real * real + imaginary * imaginary); } } + [Theory] + // Genuinely different, and the check must say so -- otherwise the property test + // above is a safety net that catches nothing. + [InlineData("x + 1", "x + 2", true)] + [InlineData("x ^ 2", "x ^ 3", true)] + [InlineData("sqrt(x)", "-sqrt(x)", true)] + // The same value written two ways, including the case Simplify cannot settle. + [InlineData("x * 2", "2 * x", false)] + [InlineData("x! * (x + 1)", "(x + 1)!", false)] + [InlineData("sqrt(12) + sqrt(27)", "5 * sqrt(3)", false)] + public void TheCounterexampleSearchFindsCounterexamplesAndOnlyThose(string one, string another, bool expected) + => Assert.Equal(expected, DisagreesAtSomePoint(Parse(one), Parse(another))); + [Fact] public void SubstitutionIsTheOneThingHereThatNeedsNoAssumptions() { From 66378cc2f47550ffaa073f588c68a6ef069e5f7c Mon Sep 17 00:00:00 2001 From: Rafael Vuijk Date: Sat, 8 Aug 2026 15:40:23 +0000 Subject: [PATCH 4/4] Check every levelled transformation at more than one level FactorizationAtLevel was swept across levels and the other two were compared against the legacy API at the default only -- three tests of the same shape, written three different ways, which is the inconsistency AGENTS.md calls a bug in its own right. All three are swept now, including negative levels, which Simplify passes itself when it re-simplifies a candidate, and levels outside the -4..4 range the catalogue keeps built: those are constructed on the spot, and the test says they behave the same rather than merely not throwing. Co-Authored-By: Claude Opus 5 (1M context) --- .../Transformations/TransformationTest.cs | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/Sources/Tests/UnitTests/Core/Transformations/TransformationTest.cs b/Sources/Tests/UnitTests/Core/Transformations/TransformationTest.cs index 67f8f28fb..aa93427a4 100644 --- a/Sources/Tests/UnitTests/Core/Transformations/TransformationTest.cs +++ b/Sources/Tests/UnitTests/Core/Transformations/TransformationTest.cs @@ -249,6 +249,9 @@ public void ExpandIsItsTransformation(string raw) public void FactorizeIsItsTransformation(string raw) => Assert.Equal(Parse(raw).Factorize(), Transformation.Factorization.Apply(Parse(raw)).Output); + // All three take a level, so all three are checked at one -- including the levels + // outside the range the catalogue keeps built, and the negative ones Simplify passes + // itself when it re-simplifies a candidate. [Theory] [InlineData(0)] [InlineData(1)] @@ -259,6 +262,40 @@ public void FactorizeRunsThePassAsManyTimesAsItIsAsked(int level) Parse("x * y + y + 1 + x").Factorize(level), Transformation.FactorizationAtLevel(level).Apply(Parse("x * y + y + 1 + x")).Output); + [Theory] + [InlineData(-2)] + [InlineData(-1)] + [InlineData(1)] + [InlineData(2)] + [InlineData(3)] + [InlineData(6)] + public void SimplifyIsItsTransformationAtEveryLevel(int level) + => Assert.Equal( + Parse("sin(x) / tan(x) + a / (b / c)").Simplify(level), + Transformation.SimplificationAtLevel(level).Apply(Parse("sin(x) / tan(x) + a / (b / c)")).Output); + + [Theory] + [InlineData(0)] + [InlineData(1)] + [InlineData(2)] + [InlineData(3)] + [InlineData(6)] + public void ExpandIsItsTransformationAtEveryLevel(int level) + => Assert.Equal( + Parse("(x + y) ^ 3 * (a + b)").Expand(level), + Transformation.ExpansionAtLevel(level).Apply(Parse("(x + y) ^ 3 * (a + b)")).Output); + + [Theory] + [InlineData(6)] + [InlineData(-6)] + public void ALevelOutsideTheCachedRangeStillWorks(int level) + { + // The catalogue keeps -4..4 built and constructs anything else on the spot; a + // level outside that range must behave the same, not merely not throw. + var built = Transformation.FactorizationAtLevel(level); + Assert.Equal(Parse("x * y + y + 1 + x").Factorize(level), built.Apply(Parse("x * y + y + 1 + x")).Output); + } + [Theory] [MemberData(nameof(Corpus))] public void DifferentiateIsItsTransformation(string raw)