From 9fb9e50f81ad7127a2ca2d13da79cc6b3c39632b Mon Sep 17 00:00:00 2001 From: Rafael Vuijk Date: Wed, 30 Sep 2026 08:43:57 +0000 Subject: [PATCH 1/4] An expression can be written as a single fraction: AsSingleFraction Entity.AsSingleFraction(), with the string extension and Transformation.AsSingleFraction, writes an expression as one numerator over one denominator, nothing divided inside either: 1 + 2/(1+t^2) is (t^2 + 3)/(t^2 + 1). Any expression, functions included, and nothing cancelled or multiplied out, so the denominator's factors stay and x/x stays x/x; a rational number counts as the fraction it is written as. Simplify never does it, and for a rational function in lowest terms Transformation.RationalCanonicalization is the canonical form. The name is the textbook's, "as a single fraction", as asked on #1239. Closes #1239. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_012sonx8iAspMiwRwokT1Ura --- .../Convenience/AngouriMathExtensions.cs | 16 ++++ .../Transformation.Catalogue.cs | 47 +++++++++++ .../Docs/Contributing/Transformations.md | 1 + .../Evaluation/Evaluation.Definition.cs | 29 +++++++ .../AngouriMath/Functions/SingleQuotient.cs | 5 ++ .../UnitTests/Algebra/AsSingleFractionTest.cs | 78 +++++++++++++++++++ Sources/Tests/UnitTests/Common/PublicApi.txt | 3 + 7 files changed, 179 insertions(+) create mode 100644 Sources/Tests/UnitTests/Algebra/AsSingleFractionTest.cs diff --git a/Sources/AngouriMath/Convenience/AngouriMathExtensions.cs b/Sources/AngouriMath/Convenience/AngouriMathExtensions.cs index fdfe1d497..dee989172 100644 --- a/Sources/AngouriMath/Convenience/AngouriMathExtensions.cs +++ b/Sources/AngouriMath/Convenience/AngouriMathExtensions.cs @@ -406,6 +406,22 @@ public static Interval ToEntity(this (Entity left, bool leftClosed, Entity right /// public static Entity Factorize(this string expr) => expr.ToEntity().Factorize(); + /// + /// Parses the given expression and writes it as a single fraction: one numerator over one + /// denominator, nothing cancelled. See . + /// + /// The expression as one fraction, or unchanged where it has no division in it + /// + /// + /// Console.WriteLine("a + b/c".AsSingleFraction()); + /// + /// Prints + /// + /// (a * c + b) / c + /// + /// + public static Entity AsSingleFraction(this string expr) => expr.ToEntity().AsSingleFraction(); + /// /// Subsitutes a variable by replacing all its occurances with the given value /// diff --git a/Sources/AngouriMath/Core/Transformations/Transformation.Catalogue.cs b/Sources/AngouriMath/Core/Transformations/Transformation.Catalogue.cs index f0857ec8f..bfec750dc 100644 --- a/Sources/AngouriMath/Core/Transformations/Transformation.Catalogue.cs +++ b/Sources/AngouriMath/Core/Transformations/Transformation.Catalogue.cs @@ -164,6 +164,53 @@ private sealed class NumericContentTransformation : Transformation => Functions.NumericContent.Extracted(input); } + /// + /// Writes an expression as a single fraction, as + /// does: one numerator over one denominator, nothing divided inside either, and nothing + /// cancelled or multiplied out. Where there is no division the input comes back. + /// + /// + /// Held in a nested class for the reason is. + /// + public static Transformation AsSingleFraction => SingleFractionHolder.Instance; + + private static class SingleFractionHolder + { + [ConstantField] + internal static readonly Transformation Instance = new SingleFractionTransformation(); + } + + private sealed class SingleFractionTransformation : Transformation + { + public override string Name => "single-fraction"; + public override TransformationRelation Relation => TransformationRelation.Equivalence; + + // Sound: a sum or a product of quotients is defined exactly where each quotient is, + // and so is the one fraction it is gathered into, since nothing is cancelled. + public override Soundness Soundness => Soundness.Sound; + + // The two halves are tidied each on its own -- the operands put in order and like + // terms collected, which is Simplify's own tidying pass without its search, so + // `t + 1 + t^2 + 1` is `2 + t + t^2` -- and the quotient is not, so that no factor of + // the numerator meets one of the denominator and cancels. + protected override Entity? ApplyCore(Entity input) + { + // A number is already what it is; there is nothing in it to gather. + if (input is Entity.Number) + return input; + // A rational number is a fraction too, as it is written: 2/3 + x/2 is (3 x + 4)/6, + // not (x + 4/3)/2. Inside a function's argument it folds back when its half is + // tidied, since the argument is not gathered. + var written = input.Replace(static node => node is Entity.Number.Rational { ERational: var value } and not Entity.Number.Integer + ? new Entity.Divf(Entity.Number.Integer.Create(value.Numerator), Entity.Number.Integer.Create(value.Denominator)) + : node); + var (numerator, denominator) = Functions.SingleQuotient.Of(written); + return denominator == Entity.Number.Integer.One + ? input + : new Entity.Divf(Functions.Simplificator.SimplifyChildren(numerator), Functions.Simplificator.SimplifyChildren(denominator)); + } + } + /// /// The rule-based half of , without the polynomial /// layer. diff --git a/Sources/AngouriMath/Docs/Contributing/Transformations.md b/Sources/AngouriMath/Docs/Contributing/Transformations.md index 0cb49b7f6..5ea836c5d 100644 --- a/Sources/AngouriMath/Docs/Contributing/Transformations.md +++ b/Sources/AngouriMath/Docs/Contributing/Transformations.md @@ -55,6 +55,7 @@ on hashing or on which type loaded first. 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.AsSingleFraction() -> Transformation.AsSingleFraction -> SingleQuotient.Of, each half tidied 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 diff --git a/Sources/AngouriMath/Functions/Evaluation/Evaluation.Definition.cs b/Sources/AngouriMath/Functions/Evaluation/Evaluation.Definition.cs index 5dbfde217..df6f15a67 100644 --- a/Sources/AngouriMath/Functions/Evaluation/Evaluation.Definition.cs +++ b/Sources/AngouriMath/Functions/Evaluation/Evaluation.Definition.cs @@ -508,6 +508,35 @@ private static Entity CollectLikeTerms(Entity expanded) public Entity Factorize(int level = 2) => Transformation.FactorizationAtLevel(level).ApplyOrKeep(this); + /// + /// This expression written as a single fraction: one numerator over one denominator, with + /// nothing divided inside either, as a + b/c is written (a c + b)/c. Any + /// expression, functions included, and nothing cancelled or multiplied out, so the factors + /// of the denominator stay as they were: 1/(t^2 + 1) + 1/(t + 1) is + /// (2 + t + t^2) / ((t^2 + 1)(t + 1)), and x/x stays x/x. A rational number + /// counts as the fraction it is written as, so 2/3 + x/2 is (4 + 3x)/6. An + /// expression with no division in it comes back as it was. + /// + /// + /// never does this, since one fraction is not always the + /// simpler form: 1/x + 1/y reads more easily than (x + y)/(x y). For a + /// rational function in lowest terms, the denominator multiplied out and monic, use + /// , which is a canonical form and + /// declines anything with a function in it. + /// #1239 + /// + /// + /// + /// Console.WriteLine("sin(x) + 1/cos(x)".ToEntity().AsSingleFraction()); + /// + /// Prints + /// + /// (1 + cos(x) * sin(x)) / cos(x) + /// + /// + public Entity AsSingleFraction() + => Transformation.AsSingleFraction.ApplyOrKeep(this); + /// /// Simplifies an equation ( e.g. (x - y) * (x + y) -> x^2 - y^2, but 3 * x + y * x = (3 + y) * x ) /// diff --git a/Sources/AngouriMath/Functions/SingleQuotient.cs b/Sources/AngouriMath/Functions/SingleQuotient.cs index af3a81369..3046f243f 100644 --- a/Sources/AngouriMath/Functions/SingleQuotient.cs +++ b/Sources/AngouriMath/Functions/SingleQuotient.cs @@ -34,6 +34,11 @@ namespace AngouriMath.Functions /// distribution and is integrated at once, and was declined for want of it. /// /// + /// The public entry is , through + /// , which also + /// reads a rational number as the fraction it is written as and tidies each half. + /// + /// /// No cancellation. The two halves are returned as built, with no common factor taken /// out: x/x comes back as (x, x) and not as (1, 1). Cancelling needs a /// gcd, which needs to know what the expression is a polynomial in, and this runs diff --git a/Sources/Tests/UnitTests/Algebra/AsSingleFractionTest.cs b/Sources/Tests/UnitTests/Algebra/AsSingleFractionTest.cs new file mode 100644 index 000000000..643c88b4c --- /dev/null +++ b/Sources/Tests/UnitTests/Algebra/AsSingleFractionTest.cs @@ -0,0 +1,78 @@ +// +// 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.Linq; +using AngouriMath; +using AngouriMath.Core.Transformations; +using AngouriMath.Extensions; +using Xunit; + +namespace AngouriMath.Tests.Algebra +{ + /// + /// An expression written as a single fraction: one numerator over one denominator, nothing + /// divided inside either, nothing cancelled or multiplied out. + /// https://github.com/asc-community/AngouriMath/issues/1239 + /// + [Trait("Area", "Algebra")] + public sealed class AsSingleFractionTest + { + /// The printed form is the point of the operation, so it is what these assert. + [Theory] + [InlineData("a + b/c", "(a * c + b) / c")] + [InlineData("1 + 2/(1+t^2)", "(t ^ 2 + 3) / (t ^ 2 + 1)")] + [InlineData("1/(t^2+1) + 1/(t+1)", "(2 + t + t ^ 2) / ((t ^ 2 + 1) * (t + 1))")] + [InlineData("sin(x) + 1/cos(x)", "(1 + cos(x) * sin(x)) / cos(x)")] + [InlineData("2/3 + x/2", "(4 + 3 * x) / 6")] + [InlineData("x^(-2) + 1", "(x ^ 2 + 1) / x ^ 2")] + [InlineData("(a/b)/(c/d)", "a * d / (b * c)")] + public void WrittenAsOneFraction(string written, string fraction) + => Assert.Equal(fraction, written.AsSingleFraction().Stringize()); + + /// Nothing cancels, and a function's argument is not gathered. + [Theory] + [InlineData("x/x", "x / x")] + [InlineData("sin(x/2) + 1/x", "(1 + sin(x / 2) * x) / x")] + public void NothingIsCancelledAndArgumentsAreLeft(string written, string fraction) + => Assert.Equal(fraction, written.AsSingleFraction().Stringize()); + + /// With no division in it, or a number, the expression comes back as it was. + [Theory] + [InlineData("x")] + [InlineData("x^2 + 1")] + [InlineData("1/2")] + public void WithNothingToGatherItComesBack(string written) + => Assert.Equal(written.ToEntity(), written.ToEntity().AsSingleFraction()); + + /// The same value everywhere both are defined. + [Theory] + [InlineData("a + b/c")] + [InlineData("1/(t^2+1) + 1/(t+1)")] + [InlineData("sin(x) + 1/cos(x)")] + [InlineData("2/3 + x/2")] + public void TheValueIsKept(string written) + { + var original = written.ToEntity(); + var fraction = original.AsSingleFraction(); + foreach (var (name, value) in new[] { ("a", 2), ("b", -3), ("c", 5), ("t", 3), ("x", 7) }) + { + original = original.Substitute(name, value); + fraction = fraction.Substitute(name, value); + } + Assert.Equal(original.EvalNumerical().RealPart.EDecimal.RoundToPrecision(PeterO.Numbers.EContext.ForPrecision(30)), + fraction.EvalNumerical().RealPart.EDecimal.RoundToPrecision(PeterO.Numbers.EContext.ForPrecision(30))); + } + + [Fact] + public void TheTransformationIsTheMethod() + { + Entity written = "1 + 2/(1+t^2)"; + Assert.Equal(written.AsSingleFraction(), Transformation.AsSingleFraction.ApplyOrKeep(written)); + Assert.Equal("single-fraction", Transformation.AsSingleFraction.Name); + } + } +} diff --git a/Sources/Tests/UnitTests/Common/PublicApi.txt b/Sources/Tests/UnitTests/Common/PublicApi.txt index d7302cae4..29f0783ca 100644 --- a/Sources/Tests/UnitTests/Common/PublicApi.txt +++ b/Sources/Tests/UnitTests/Common/PublicApi.txt @@ -343,6 +343,7 @@ AngouriMath.Core.Transformations.Soundness.value__ : System.Int32 AngouriMath.Core.Transformations.Transformation.Apply(AngouriMath.Entity) : AngouriMath.Core.Transformations.TransformationResult AngouriMath.Core.Transformations.Transformation.ApplyCore(AngouriMath.Entity) : AngouriMath.Entity AngouriMath.Core.Transformations.Transformation.ApplyOrKeep(AngouriMath.Entity) : AngouriMath.Entity +AngouriMath.Core.Transformations.Transformation.AsSingleFraction { } : AngouriMath.Core.Transformations.Transformation AngouriMath.Core.Transformations.Transformation.Canonicalization { } : AngouriMath.Core.Transformations.Transformation AngouriMath.Core.Transformations.Transformation.CanonicalizationOverGraph(AngouriMath.Core.Budgets.WorkBudget) : AngouriMath.Core.Transformations.Transformation AngouriMath.Core.Transformations.Transformation.CanonicalizationOverGraph(AngouriMath.Core.Budgets.WorkBudget, AngouriMath.Core.Transformations.RewriteRuleGrowth) : AngouriMath.Core.Transformations.Transformation @@ -2766,6 +2767,7 @@ AngouriMath.Entity.Arccotan() : AngouriMath.Entity AngouriMath.Entity.Arcsec() : AngouriMath.Entity AngouriMath.Entity.Arcsin() : AngouriMath.Entity AngouriMath.Entity.Arctan() : AngouriMath.Entity +AngouriMath.Entity.AsSingleFraction() : AngouriMath.Entity AngouriMath.Entity.Canonicalize() : AngouriMath.Entity AngouriMath.Entity.CanonicalizeAsRationalFunction() : AngouriMath.Entity AngouriMath.Entity.Ceil() : AngouriMath.Entity @@ -2914,6 +2916,7 @@ AngouriMath.Entity.op_Multiply(AngouriMath.Entity, AngouriMath.Entity) : Angouri AngouriMath.Entity.op_Subtraction(AngouriMath.Entity, AngouriMath.Entity) : AngouriMath.Entity AngouriMath.Entity.op_UnaryNegation(AngouriMath.Entity) : AngouriMath.Entity AngouriMath.Entity.op_UnaryPlus(AngouriMath.Entity) : AngouriMath.Entity +AngouriMath.Extensions.AngouriMathExtensions.AsSingleFraction(System.String) : AngouriMath.Entity AngouriMath.Extensions.AngouriMathExtensions.Compile(System.String, AngouriMath.Core.Compilation.IntoLinq.CompilationProtocol, System.Type, System.Collections.Generic.IEnumerable>) : TDelegate AngouriMath.Extensions.AngouriMathExtensions.Compile(System.String, AngouriMath.Entity+Variable) : Func AngouriMath.Extensions.AngouriMathExtensions.Compile(System.String, AngouriMath.Entity+Variable, AngouriMath.Entity+Variable) : Func From b0c457002c34dd8f5a30f7aba0111662360284e4 Mon Sep 17 00:00:00 2001 From: Rafael Vuijk Date: Wed, 30 Sep 2026 09:01:09 +0000 Subject: [PATCH 2/4] Turning a fraction over says its denominator is nonzero Dividing by a fraction moves its denominator into the numerator, where it no longer stops the answer having a value: 1/(1/x) gathers to x, which is 0 at x = 0 where the expression has no value, and (a/b)/(c/d) to a d/(b c), which is 0 at d = 0. That is the one thing the simplification contract forbids (O4), so the answer now says so: x provided not x = 0. SingleQuotient.Of gains an overload that records each denominator it carries across; the one-argument form passes nothing and is unchanged for its callers. A denominator the new one still contains is not repeated, so (1/x)/(1/x) stays x / x. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_012sonx8iAspMiwRwokT1Ura --- .../Transformation.Catalogue.cs | 50 ++++++++++++++++--- .../Evaluation/Evaluation.Definition.cs | 4 +- .../AngouriMath/Functions/SingleQuotient.cs | 40 +++++++++++---- .../UnitTests/Algebra/AsSingleFractionTest.cs | 43 +++++++++++++++- 4 files changed, 117 insertions(+), 20 deletions(-) diff --git a/Sources/AngouriMath/Core/Transformations/Transformation.Catalogue.cs b/Sources/AngouriMath/Core/Transformations/Transformation.Catalogue.cs index bfec750dc..5ad7120a4 100644 --- a/Sources/AngouriMath/Core/Transformations/Transformation.Catalogue.cs +++ b/Sources/AngouriMath/Core/Transformations/Transformation.Catalogue.cs @@ -167,7 +167,9 @@ private sealed class NumericContentTransformation : Transformation /// /// Writes an expression as a single fraction, as /// does: one numerator over one denominator, nothing divided inside either, and nothing - /// cancelled or multiplied out. Where there is no division the input comes back. + /// cancelled or multiplied out. Where there is no division the input comes back, and where + /// dividing by a fraction moves its denominator into the numerator the answer says that + /// denominator is nonzero. /// /// /// Held in a nested class for the reason is. @@ -186,7 +188,10 @@ private sealed class SingleFractionTransformation : Transformation public override TransformationRelation Relation => TransformationRelation.Equivalence; // Sound: a sum or a product of quotients is defined exactly where each quotient is, - // and so is the one fraction it is gathered into, since nothing is cancelled. + // and so is the fraction it is gathered into, since nothing is cancelled. Turning a + // quotient over is the one step that moves a denominator out of the way: + // (a/b)/(c/d) is (a d)/(b c), which has a value where d is zero and the expression + // has none, so the answer carries `provided not d = 0`. public override Soundness Soundness => Soundness.Sound; // The two halves are tidied each on its own -- the operands put in order and like @@ -204,10 +209,43 @@ private sealed class SingleFractionTransformation : Transformation var written = input.Replace(static node => node is Entity.Number.Rational { ERational: var value } and not Entity.Number.Integer ? new Entity.Divf(Entity.Number.Integer.Create(value.Numerator), Entity.Number.Integer.Create(value.Denominator)) : node); - var (numerator, denominator) = Functions.SingleQuotient.Of(written); - return denominator == Entity.Number.Integer.One - ? input - : new Entity.Divf(Functions.Simplificator.SimplifyChildren(numerator), Functions.Simplificator.SimplifyChildren(denominator)); + var carried = new List(); + var (numerator, denominator) = Functions.SingleQuotient.Of(written, carried); + if (denominator == Entity.Number.Integer.One && carried.Count == 0) + return input; + var top = Functions.Simplificator.SimplifyChildren(numerator); + var bottom = Functions.Simplificator.SimplifyChildren(denominator); + var fraction = bottom == Entity.Number.Integer.One ? top : new Entity.Divf(top, bottom); + + // Only what the new denominator does not already exclude: in (1/x)/(1/x), which + // is x/x, the x carried up is still in the denominator. + var nonzero = new List(); + foreach (var factor in carried) + { + var tidied = Functions.Simplificator.SimplifyChildren(factor); + if (!AlreadyNonzero(tidied, bottom) && !nonzero.Contains(tidied)) + nonzero.Add(tidied); + } + if (nonzero.Count == 0) + return fraction; + var condition = !nonzero[0].EqualTo(0); + for (var i = 1; i < nonzero.Count; i++) + condition &= !nonzero[i].EqualTo(0); + return new Entity.Providedf(fraction, condition); + } + + // Whether `denominator` being nonzero already says `factor` is: a nonzero number + // always is, and otherwise each factor of `factor` has to be one of the + // denominator's, up to a whole positive power, since a product vanishes exactly where + // one of its factors does and a power exactly where its base does. + private static bool AlreadyNonzero(Entity factor, Entity denominator) + { + var excluded = Entity.Mulf.LinearChildren(denominator).Select(Base).ToList(); + return Entity.Mulf.LinearChildren(factor).All(piece => + piece is Entity.Number.Complex { IsZero: false } || excluded.Contains(Base(piece))); + + static Entity Base(Entity piece) + => piece is Entity.Powf(var @base, Entity.Number.Integer power) && power.EInteger.Sign > 0 ? @base : piece; } } diff --git a/Sources/AngouriMath/Functions/Evaluation/Evaluation.Definition.cs b/Sources/AngouriMath/Functions/Evaluation/Evaluation.Definition.cs index df6f15a67..36fc1025e 100644 --- a/Sources/AngouriMath/Functions/Evaluation/Evaluation.Definition.cs +++ b/Sources/AngouriMath/Functions/Evaluation/Evaluation.Definition.cs @@ -515,7 +515,9 @@ public Entity Factorize(int level = 2) /// of the denominator stay as they were: 1/(t^2 + 1) + 1/(t + 1) is /// (2 + t + t^2) / ((t^2 + 1)(t + 1)), and x/x stays x/x. A rational number /// counts as the fraction it is written as, so 2/3 + x/2 is (4 + 3x)/6. An - /// expression with no division in it comes back as it was. + /// expression with no division in it comes back as it was. Dividing by a fraction moves + /// its denominator into the numerator, and the expression has no value where that + /// denominator is zero, so the answer says so: 1/(1/x) is x provided not x = 0. /// /// /// never does this, since one fraction is not always the diff --git a/Sources/AngouriMath/Functions/SingleQuotient.cs b/Sources/AngouriMath/Functions/SingleQuotient.cs index 3046f243f..d5f79ac86 100644 --- a/Sources/AngouriMath/Functions/SingleQuotient.cs +++ b/Sources/AngouriMath/Functions/SingleQuotient.cs @@ -17,7 +17,7 @@ namespace AngouriMath.Functions /// /// /// - /// This is what other systems call together or ratsimp, and it is deliberately + /// This is writing an expression as a single fraction, and it is deliberately /// not part of . Putting a sum over a common /// denominator makes some expressions worse — 1/x + 1/y is easier to read than /// (x + y)/(x*y) — which is why every system that has it keeps it as an operation you @@ -49,7 +49,7 @@ namespace AngouriMath.Functions /// It always terminates and never grows without bound, because it recurses only into /// the operands of the node it is given and each recursion is on a strictly smaller tree. What /// it can do is make the tree bigger — combining a sum of n quotients multiplies the - /// denominators — so is a transformation to ask for rather than one to apply + /// denominators — so is a transformation to ask for rather than one to apply /// on the way past. /// /// @@ -60,35 +60,45 @@ internal static class SingleQuotient /// expression that had no division in it, which is the signal that nothing was combined. /// internal static (Entity Numerator, Entity Denominator) Of(Entity expr) + => Of(expr, carried: null); + + /// + /// As , adding to each denominator + /// that turning a quotient over moves into the numerator. The expression is undefined + /// where one of those is zero, and the single quotient need not be: 1/(1/x) is + /// (x, 1), which has a value at zero. + /// + internal static (Entity Numerator, Entity Denominator) Of(Entity expr, List? carried) { switch (expr) { case Divf(var dividend, var divisor): { - var (an, ad) = Of(dividend); - var (bn, bd) = Of(divisor); + var (an, ad) = Of(dividend, carried); + var (bn, bd) = Of(divisor, carried); // (an/ad) / (bn/bd) = (an * bd) / (ad * bn) + Carry(bd, carried); return (Times(an, bd), Times(ad, bn)); } case Mulf(var left, var right): { - var (an, ad) = Of(left); - var (bn, bd) = Of(right); + var (an, ad) = Of(left, carried); + var (bn, bd) = Of(right, carried); return (Times(an, bn), Times(ad, bd)); } case Sumf(var augend, var addend): { - var (an, ad) = Of(augend); - var (bn, bd) = Of(addend); + var (an, ad) = Of(augend, carried); + var (bn, bd) = Of(addend, carried); return (Plus(Times(an, bd), Times(bn, ad)), Times(ad, bd)); } case Minusf(var minuend, var subtrahend): { - var (an, ad) = Of(minuend); - var (bn, bd) = Of(subtrahend); + var (an, ad) = Of(minuend, carried); + var (bn, bd) = Of(subtrahend, carried); return (Minus(Times(an, bd), Times(bn, ad)), Times(ad, bd)); } @@ -97,9 +107,11 @@ internal static (Entity Numerator, Entity Denominator) Of(Entity expr) // (a/b)^(1/2) is not sqrt(a)/sqrt(b) on the branch cut. case Powf(var @base, Integer power): { - var (bn, bd) = Of(@base); + var (bn, bd) = Of(@base, carried); if (bd == Integer.One && power.EInteger.Sign >= 0) return (expr, Integer.One); + if (power.EInteger.Sign < 0) + Carry(bd, carried); // The first power is the base itself: `A^(-1)` is `1/A`, not `1^1/A^1`, // which nothing reading the factors of the denominator took for `A`. if (power.EInteger.Equals(EInteger.FromInt32(-1))) @@ -114,6 +126,12 @@ internal static (Entity Numerator, Entity Denominator) Of(Entity expr) } } + private static void Carry(Entity denominator, List? carried) + { + if (carried is not null && denominator != Integer.One) + carried.Add(denominator); + } + /// /// rewritten as a single quotient, or unchanged where it had no /// division in it to combine. diff --git a/Sources/Tests/UnitTests/Algebra/AsSingleFractionTest.cs b/Sources/Tests/UnitTests/Algebra/AsSingleFractionTest.cs index 643c88b4c..a1308fa70 100644 --- a/Sources/Tests/UnitTests/Algebra/AsSingleFractionTest.cs +++ b/Sources/Tests/UnitTests/Algebra/AsSingleFractionTest.cs @@ -29,10 +29,47 @@ public sealed class AsSingleFractionTest [InlineData("sin(x) + 1/cos(x)", "(1 + cos(x) * sin(x)) / cos(x)")] [InlineData("2/3 + x/2", "(4 + 3 * x) / 6")] [InlineData("x^(-2) + 1", "(x ^ 2 + 1) / x ^ 2")] - [InlineData("(a/b)/(c/d)", "a * d / (b * c)")] public void WrittenAsOneFraction(string written, string fraction) => Assert.Equal(fraction, written.AsSingleFraction().Stringize()); + /// + /// Dividing by a fraction moves its denominator into the numerator, where it no longer + /// stops the answer having a value, so the answer says it is nonzero -- unless the new + /// denominator still says so itself. + /// + [Theory] + [InlineData("1/(1/x)", "x provided not x = 0")] + [InlineData("(a/b)/(c/d)", "a * d / (b * c) provided not d = 0")] + [InlineData("(x/y)^(-2)", "y ^ 2 / x ^ 2 provided not y = 0")] + [InlineData("(1/x)/(1/x)", "x / x")] + public void TurningAFractionOverSaysItsDenominatorIsNonzero(string written, string fraction) + => Assert.Equal(fraction, written.AsSingleFraction().Stringize()); + + /// + /// Undefined where the expression is: at zero for the variable named, and one for every + /// other, each of these has no value, and neither has its single fraction. + /// + [Theory] + [InlineData("1/(1/x)", "x")] + [InlineData("(a/b)/(c/d)", "d")] + [InlineData("(1/x)^(-1)", "x")] + [InlineData("(x/y)^(-2)", "y")] + [InlineData("x/(y/x)", "x")] + [InlineData("(1/x)/(1/x)", "x")] + public void TheDomainIsKept(string written, string atZero) + { + var original = written.ToEntity(); + var fraction = original.AsSingleFraction(); + foreach (var variable in original.Vars) + { + Entity value = variable.Name == atZero ? 0 : 1; + original = original.Substitute(variable, value); + fraction = fraction.Substitute(variable, value); + } + Assert.Equal(MathS.NaN, original.Evaled); + Assert.Equal(MathS.NaN, fraction.Evaled); + } + /// Nothing cancels, and a function's argument is not gathered. [Theory] [InlineData("x/x", "x / x")] @@ -54,11 +91,13 @@ public void WithNothingToGatherItComesBack(string written) [InlineData("1/(t^2+1) + 1/(t+1)")] [InlineData("sin(x) + 1/cos(x)")] [InlineData("2/3 + x/2")] + [InlineData("(a/b)/(c/d)")] + [InlineData("x/(y/x)")] public void TheValueIsKept(string written) { var original = written.ToEntity(); var fraction = original.AsSingleFraction(); - foreach (var (name, value) in new[] { ("a", 2), ("b", -3), ("c", 5), ("t", 3), ("x", 7) }) + foreach (var (name, value) in new[] { ("a", 2), ("b", -3), ("c", 5), ("d", 11), ("t", 3), ("x", 7), ("y", 13) }) { original = original.Substitute(name, value); fraction = fraction.Substitute(name, value); From 7a6865acf3e71f31baf1cebce430eb06ba1b8641 Mon Sep 17 00:00:00 2001 From: Rafael Vuijk Date: Wed, 30 Sep 2026 09:29:56 +0000 Subject: [PATCH 3/4] A sum is written over the least common multiple of its denominators As it is by hand: 1/x + 1/x^2 is (x + 1)/x^2, where the product of the denominators put it over x^3 with a factor x in both halves. The common denominator is taken from the denominators as they are written -- each factor to the highest power any of them has it, and the whole numbers by their least common multiple -- so nothing is factorised to find it, and x^2 - 1 and x + 1 share nothing. SingleQuotient.OverLeastCommonDenominator is the entry AsSingleFraction uses; SingleQuotient.Of keeps the product for its callers, which divide out afterwards, and is unchanged for them. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_012sonx8iAspMiwRwokT1Ura --- .../Transformation.Catalogue.cs | 2 +- .../Docs/Contributing/Transformations.md | 2 +- .../Evaluation/Evaluation.Definition.cs | 4 +- .../AngouriMath/Functions/SingleQuotient.cs | 143 +++++++++++++++--- .../UnitTests/Algebra/AsSingleFractionTest.cs | 21 ++- 5 files changed, 149 insertions(+), 23 deletions(-) diff --git a/Sources/AngouriMath/Core/Transformations/Transformation.Catalogue.cs b/Sources/AngouriMath/Core/Transformations/Transformation.Catalogue.cs index 5ad7120a4..4fe45c8d9 100644 --- a/Sources/AngouriMath/Core/Transformations/Transformation.Catalogue.cs +++ b/Sources/AngouriMath/Core/Transformations/Transformation.Catalogue.cs @@ -210,7 +210,7 @@ private sealed class SingleFractionTransformation : Transformation ? new Entity.Divf(Entity.Number.Integer.Create(value.Numerator), Entity.Number.Integer.Create(value.Denominator)) : node); var carried = new List(); - var (numerator, denominator) = Functions.SingleQuotient.Of(written, carried); + var (numerator, denominator) = Functions.SingleQuotient.OverLeastCommonDenominator(written, carried); if (denominator == Entity.Number.Integer.One && carried.Count == 0) return input; var top = Functions.Simplificator.SimplifyChildren(numerator); diff --git a/Sources/AngouriMath/Docs/Contributing/Transformations.md b/Sources/AngouriMath/Docs/Contributing/Transformations.md index 5ea836c5d..711cb8743 100644 --- a/Sources/AngouriMath/Docs/Contributing/Transformations.md +++ b/Sources/AngouriMath/Docs/Contributing/Transformations.md @@ -55,7 +55,7 @@ on hashing or on which type loaded first. 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.AsSingleFraction() -> Transformation.AsSingleFraction -> SingleQuotient.Of, each half tidied +Entity.AsSingleFraction() -> Transformation.AsSingleFraction -> SingleQuotient.OverLeastCommonDenominator, each half tidied 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 diff --git a/Sources/AngouriMath/Functions/Evaluation/Evaluation.Definition.cs b/Sources/AngouriMath/Functions/Evaluation/Evaluation.Definition.cs index 36fc1025e..a6edd314c 100644 --- a/Sources/AngouriMath/Functions/Evaluation/Evaluation.Definition.cs +++ b/Sources/AngouriMath/Functions/Evaluation/Evaluation.Definition.cs @@ -513,7 +513,9 @@ public Entity Factorize(int level = 2) /// nothing divided inside either, as a + b/c is written (a c + b)/c. Any /// expression, functions included, and nothing cancelled or multiplied out, so the factors /// of the denominator stay as they were: 1/(t^2 + 1) + 1/(t + 1) is - /// (2 + t + t^2) / ((t^2 + 1)(t + 1)), and x/x stays x/x. A rational number + /// (2 + t + t^2) / ((t^2 + 1)(t + 1)), and x/x stays x/x. The common + /// denominator is the least common multiple of the denominators as they are written, so + /// 1/x + 1/x^2 is (x + 1)/x^2; nothing is factorised to find it. A rational number /// counts as the fraction it is written as, so 2/3 + x/2 is (4 + 3x)/6. An /// expression with no division in it comes back as it was. Dividing by a fraction moves /// its denominator into the numerator, and the expression has no value where that diff --git a/Sources/AngouriMath/Functions/SingleQuotient.cs b/Sources/AngouriMath/Functions/SingleQuotient.cs index d5f79ac86..3788e2978 100644 --- a/Sources/AngouriMath/Functions/SingleQuotient.cs +++ b/Sources/AngouriMath/Functions/SingleQuotient.cs @@ -35,8 +35,9 @@ namespace AngouriMath.Functions /// /// /// The public entry is , through - /// , which also - /// reads a rational number as the fraction it is written as and tidies each half. + /// , which + /// gathers through , reads a rational number as the + /// fraction it is written as, and tidies each half. /// /// /// No cancellation. The two halves are returned as built, with no common factor taken @@ -60,22 +61,34 @@ internal static class SingleQuotient /// expression that had no division in it, which is the signal that nothing was combined. /// internal static (Entity Numerator, Entity Denominator) Of(Entity expr) - => Of(expr, carried: null); + => Of(expr, carried: null, leastCommon: false); /// - /// As , adding to each denominator - /// that turning a quotient over moves into the numerator. The expression is undefined - /// where one of those is zero, and the single quotient need not be: 1/(1/x) is - /// (x, 1), which has a value at zero. + /// The expression as one fraction, written the way it is by hand: a sum goes over the + /// least common multiple of its terms' denominators as they stand, each factor to the + /// highest power any of them has it and the whole numbers by their least common multiple, + /// so 1/x + 1/x^2 is (x + 1, x^2) and not (x^2 + x, x^3). Nothing is + /// factorised to find it: x^2 - 1 and x + 1 share no factor here. Each + /// denominator that turning a quotient over moves into the numerator is added to + /// , since the expression is undefined where one of those is + /// zero and the fraction need not be: 1/(1/x) is (x, 1), which has a value + /// at zero. /// - internal static (Entity Numerator, Entity Denominator) Of(Entity expr, List? carried) + /// + /// keeps the product of the denominators, for the callers above, + /// which divide out afterwards. + /// + internal static (Entity Numerator, Entity Denominator) OverLeastCommonDenominator(Entity expr, List carried) + => Of(expr, carried, leastCommon: true); + + private static (Entity Numerator, Entity Denominator) Of(Entity expr, List? carried, bool leastCommon) { switch (expr) { case Divf(var dividend, var divisor): { - var (an, ad) = Of(dividend, carried); - var (bn, bd) = Of(divisor, carried); + var (an, ad) = Of(dividend, carried, leastCommon); + var (bn, bd) = Of(divisor, carried, leastCommon); // (an/ad) / (bn/bd) = (an * bd) / (ad * bn) Carry(bd, carried); return (Times(an, bd), Times(ad, bn)); @@ -83,23 +96,25 @@ internal static (Entity Numerator, Entity Denominator) Of(Entity expr, List= 0) return (expr, Integer.One); if (power.EInteger.Sign < 0) @@ -132,6 +147,96 @@ private static void Carry(Entity denominator, List? carried) carried.Add(denominator); } + /// + /// A common denominator of and , with what each + /// is multiplied by to reach it: their product, or with + /// their least common multiple as written. + /// + private static (Entity Common, Entity ForA, Entity ForB) Over(Entity a, Entity b, bool leastCommon) + { + if (!leastCommon) + return (Times(a, b), b, a); + if (a == Integer.One) + return (b, b, Integer.One); + if (b == Integer.One || a == b) + return (a, Integer.One, b == Integer.One ? a : Integer.One); + var (aWhole, aFactors) = Factors(a); + var (bWhole, bFactors) = Factors(b); + if (aWhole.IsZero || bWhole.IsZero) + return (Times(a, b), b, a); + var whole = aWhole.Abs().Divide(aWhole.Abs().Gcd(bWhole.Abs())).Multiply(bWhole.Abs()); + var common = new List<(Entity Base, EInteger Power)>(aFactors); + foreach (var (@base, power) in bFactors) + { + var at = common.FindIndex(factor => factor.Base == @base); + if (at < 0) + common.Add((@base, power)); + else if (power.CompareTo(common[at].Power) > 0) + common[at] = (@base, power); + } + return (Product(whole, common, null), + Product(whole.Divide(aWhole), common, aFactors), + Product(whole.Divide(bWhole), common, bFactors)); + } + + /// + /// A denominator as a whole number times its other factors, each with the whole power it + /// is raised to: a factor written twice is one factor with the powers added, and a power + /// of a whole number is part of the whole number. + /// + private static (EInteger Whole, List<(Entity Base, EInteger Power)> Factors) Factors(Entity denominator) + { + var whole = EInteger.One; + var factors = new List<(Entity Base, EInteger Power)>(); + foreach (var factor in Mulf.LinearChildren(denominator)) + { + switch (factor) + { + case Integer integer: + whole = whole.Multiply(integer.EInteger); + continue; + case Powf(Integer number, Integer power) when power.EInteger.Sign > 0 + && power.EInteger.CompareTo(EInteger.FromInt32(MaxFoldedPower)) <= 0: + whole = whole.Multiply(number.EInteger.Pow(power.EInteger)); + continue; + } + var (@base, exponent) = factor is Powf(var raised, Integer raisedTo) && raisedTo.EInteger.Sign > 0 + ? (raised, raisedTo.EInteger) + : (factor, EInteger.One); + var at = factors.FindIndex(known => known.Base == @base); + if (at < 0) + factors.Add((@base, exponent)); + else + factors[at] = (@base, factors[at].Power.Add(exponent)); + } + return (whole, factors); + } + + /// + /// A power of a whole number is multiplied out into the whole number only up to this + /// exponent; past it the power stays a factor like any other, which is still correct. + /// + private const int MaxFoldedPower = 64; + + /// + /// times each factor of to the power + /// that is short of it, or to its whole power where + /// is . + /// + private static Entity Product(EInteger whole, List<(Entity Base, EInteger Power)> common, + List<(Entity Base, EInteger Power)>? own) + { + Entity product = Integer.Create(whole); + foreach (var (@base, power) in common) + { + var ownPower = own?.Find(factor => factor.Base == @base).Power ?? EInteger.Zero; + var missing = power.Subtract(ownPower); + if (missing.Sign > 0) + product = Times(product, missing.Equals(EInteger.One) ? @base : MathS.Pow(@base, Integer.Create(missing))); + } + return product; + } + /// /// rewritten as a single quotient, or unchanged where it had no /// division in it to combine. diff --git a/Sources/Tests/UnitTests/Algebra/AsSingleFractionTest.cs b/Sources/Tests/UnitTests/Algebra/AsSingleFractionTest.cs index a1308fa70..9a08a25ad 100644 --- a/Sources/Tests/UnitTests/Algebra/AsSingleFractionTest.cs +++ b/Sources/Tests/UnitTests/Algebra/AsSingleFractionTest.cs @@ -32,6 +32,21 @@ public sealed class AsSingleFractionTest public void WrittenAsOneFraction(string written, string fraction) => Assert.Equal(fraction, written.AsSingleFraction().Stringize()); + /// + /// Over the least common multiple of the denominators as they are written, as it is done + /// by hand: a factor two terms share is not multiplied in twice. Nothing is factorised to + /// find it, so x^2 - 1 and x + 1 share nothing. + /// + [Theory] + [InlineData("1/x + 1/x^2", "(x + 1) / x ^ 2")] + [InlineData("x/2 + x/4", "3 * x / 4")] + [InlineData("1/6 + 1/4", "5 / 12")] + [InlineData("1/(x + 1) + 1/(x + 1)^2", "(x + 2) / (x + 1) ^ 2")] + [InlineData("a/(b*c) + d/(b*e)", "(a * e + d * c) / (b * c * e)")] + [InlineData("1/(x^2 - 1) + 1/(x + 1)", "(x + 1 + x ^ 2 - 1) / ((x ^ 2 - 1) * (x + 1))")] + public void OverTheLeastCommonDenominator(string written, string fraction) + => Assert.Equal(fraction, written.AsSingleFraction().Stringize()); + /// /// Dividing by a fraction moves its denominator into the numerator, where it no longer /// stops the answer having a value, so the answer says it is nonzero -- unless the new @@ -56,6 +71,7 @@ public void TurningAFractionOverSaysItsDenominatorIsNonzero(string written, stri [InlineData("(x/y)^(-2)", "y")] [InlineData("x/(y/x)", "x")] [InlineData("(1/x)/(1/x)", "x")] + [InlineData("1/x + 1/x^2", "x")] public void TheDomainIsKept(string written, string atZero) { var original = written.ToEntity(); @@ -93,11 +109,14 @@ public void WithNothingToGatherItComesBack(string written) [InlineData("2/3 + x/2")] [InlineData("(a/b)/(c/d)")] [InlineData("x/(y/x)")] + [InlineData("1/x + 1/x^2")] + [InlineData("a/(b*c) + d/(b*e)")] + [InlineData("1/(x + 1) + 1/(x + 1)^2")] public void TheValueIsKept(string written) { var original = written.ToEntity(); var fraction = original.AsSingleFraction(); - foreach (var (name, value) in new[] { ("a", 2), ("b", -3), ("c", 5), ("d", 11), ("t", 3), ("x", 7), ("y", 13) }) + foreach (var (name, value) in new[] { ("a", 2), ("b", -3), ("c", 5), ("d", 11), ("e", 17), ("t", 3), ("x", 7), ("y", 13) }) { original = original.Substitute(name, value); fraction = fraction.Substitute(name, value); From 9e23f6397c80667338818acad610f8f1bdb67c77 Mon Sep 17 00:00:00 2001 From: Rafael Vuijk Date: Wed, 30 Sep 2026 10:56:45 +0000 Subject: [PATCH 4/4] Each half of a single fraction ends on the neatening Simplify ends on The tidying pass writes x^2 - 1 as x ^ 2 + -1, and Simplify's last step, NumericNeat, writes it back. So 1/(x^2 - 1) + 1/(x + 1) is (x + x ^ 2) / ((x ^ 2 - 1) * (x + 1)), and every other written form here is as it was. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_012sonx8iAspMiwRwokT1Ura --- .../Core/Transformations/Transformation.Catalogue.cs | 11 ++++++++--- .../Tests/UnitTests/Algebra/AsSingleFractionTest.cs | 6 +++--- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/Sources/AngouriMath/Core/Transformations/Transformation.Catalogue.cs b/Sources/AngouriMath/Core/Transformations/Transformation.Catalogue.cs index 4fe45c8d9..c49d6713a 100644 --- a/Sources/AngouriMath/Core/Transformations/Transformation.Catalogue.cs +++ b/Sources/AngouriMath/Core/Transformations/Transformation.Catalogue.cs @@ -213,8 +213,8 @@ private sealed class SingleFractionTransformation : Transformation var (numerator, denominator) = Functions.SingleQuotient.OverLeastCommonDenominator(written, carried); if (denominator == Entity.Number.Integer.One && carried.Count == 0) return input; - var top = Functions.Simplificator.SimplifyChildren(numerator); - var bottom = Functions.Simplificator.SimplifyChildren(denominator); + var top = Tidied(numerator); + var bottom = Tidied(denominator); var fraction = bottom == Entity.Number.Integer.One ? top : new Entity.Divf(top, bottom); // Only what the new denominator does not already exclude: in (1/x)/(1/x), which @@ -222,7 +222,7 @@ private sealed class SingleFractionTransformation : Transformation var nonzero = new List(); foreach (var factor in carried) { - var tidied = Functions.Simplificator.SimplifyChildren(factor); + var tidied = Tidied(factor); if (!AlreadyNonzero(tidied, bottom) && !nonzero.Contains(tidied)) nonzero.Add(tidied); } @@ -234,6 +234,11 @@ private sealed class SingleFractionTransformation : Transformation return new Entity.Providedf(fraction, condition); } + // Simplify's tidying pass, and the neatening it ends on, which writes `x^2 + -1` as + // `x^2 - 1`. + private static Entity Tidied(Entity half) + => Functions.Simplificator.SimplifyChildren(half).Rewrite(RewriteRules.NumericNeat); + // Whether `denominator` being nonzero already says `factor` is: a nonzero number // always is, and otherwise each factor of `factor` has to be one of the // denominator's, up to a whole positive power, since a product vanishes exactly where diff --git a/Sources/Tests/UnitTests/Algebra/AsSingleFractionTest.cs b/Sources/Tests/UnitTests/Algebra/AsSingleFractionTest.cs index 9a08a25ad..4f102fae8 100644 --- a/Sources/Tests/UnitTests/Algebra/AsSingleFractionTest.cs +++ b/Sources/Tests/UnitTests/Algebra/AsSingleFractionTest.cs @@ -39,11 +39,11 @@ public void WrittenAsOneFraction(string written, string fraction) /// [Theory] [InlineData("1/x + 1/x^2", "(x + 1) / x ^ 2")] - [InlineData("x/2 + x/4", "3 * x / 4")] + [InlineData("x/2 + x/4", "x * 3 / 4")] [InlineData("1/6 + 1/4", "5 / 12")] [InlineData("1/(x + 1) + 1/(x + 1)^2", "(x + 2) / (x + 1) ^ 2")] - [InlineData("a/(b*c) + d/(b*e)", "(a * e + d * c) / (b * c * e)")] - [InlineData("1/(x^2 - 1) + 1/(x + 1)", "(x + 1 + x ^ 2 - 1) / ((x ^ 2 - 1) * (x + 1))")] + [InlineData("a/(b*c) + d/(b*e)", "(a * e + c * d) / (b * c * e)")] + [InlineData("1/(x^2 - 1) + 1/(x + 1)", "(x + x ^ 2) / ((x ^ 2 - 1) * (x + 1))")] public void OverTheLeastCommonDenominator(string written, string fraction) => Assert.Equal(fraction, written.AsSingleFraction().Stringize());