diff --git a/AGENTS.md b/AGENTS.md index 4f84238e7..92e00db57 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -222,6 +222,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 @@ -315,6 +347,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..51358d855 --- /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, 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 + /// 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 + } +}