From 0175d7390592094614e00eb31507adf69310a7c9 Mon Sep 17 00:00:00 2001 From: Rafael Vuijk Date: Tue, 4 Aug 2026 15:37:49 +0000 Subject: [PATCH 1/2] Do on the one-sided path what the two-sided one does The one-sided path skipped everything the two-sided path has, in front of the descent and behind it. In front, four transformations ran only under `side is BothSides`: the sec/csc rewrite, the substitution of finite sub-limits, and the first and second remarkable limits. Without the second one, (1 + x)^(1/x) at 0+ is read by the descent as 1^(+oo), and it answers 1 -- definitely, not as a NaN -- where the same expression approached from both sides gives e. Each of the four is guarded on a two-sided limit, and a two-sided limit that exists is also the limit from either side, so hoisting them above the dispatch says nothing one-sided they did not already say two-sided. Behind, there was nothing at all: if (side is ApproachFrom.Left or ApproachFrom.Right) return expr.ComputeLimitDivideEtImpera(x, dest, side); The descent can make an indeterminate form definite on the way down, by putting a part's own limit in place of the part. x * ln(x) at 0+ becomes 0 * ln(x), so 0 * -oo, so NaN -- and NaN is the claim that the limit does not exist, not that it was not found. sin(x) / x from either side came back the same way: the library said the most familiar limit in the subject did not exist. Where nothing above answers, this now moves x out to infinity and asks there, which is what ComputeLimitImpl does for a finite destination in any case. Whatever the descent found is kept if that finds nothing better, so it can only add answers. The substituted expression is simplified before being asked about, and not for tidiness: the destination is left behind as a term, and it is exactly that leftover which produces the NaN. (0 + 1/x) * ln(0 + 1/x) at infinity is NaN where (1/x) * ln(1/x) is 0. Measured over 30 one-sided limits worked out by hand: 14 correct and 15 wrong before, 22 correct and 7 wrong after. Nothing that was right became wrong. Suite unchanged at 3m06s. Seven remain wrong and this does not claim them. Three want the machinery at infinity to be stronger and answer once the differences-at-infinity and Gruntz branches are in, with no further change here. The others -- (1 - cos(x))/x^2, 1/x - 1/sin(x) and csc(x)*x -- do not, and csc(x)*x shows why: the rewrite turns it into (1/sin(x))*x, a product, and the first remarkable limit only matches a quotient. Part of https://github.com/asc-community/AngouriMath/issues/209. Co-Authored-By: Claude Opus 5 --- .../Limits/Solvers/Solvers.Definition.cs | 47 +++++++-- .../UnitTests/Calculus/OneSidedLimitTest.cs | 96 +++++++++++++++++++ 2 files changed, 135 insertions(+), 8 deletions(-) create mode 100644 Sources/Tests/UnitTests/Calculus/OneSidedLimitTest.cs diff --git a/Sources/AngouriMath/Functions/Continuous/Limits/Solvers/Solvers.Definition.cs b/Sources/AngouriMath/Functions/Continuous/Limits/Solvers/Solvers.Definition.cs index 8980a0c27..e37877f0f 100644 --- a/Sources/AngouriMath/Functions/Continuous/Limits/Solvers/Solvers.Definition.cs +++ b/Sources/AngouriMath/Functions/Continuous/Limits/Solvers/Solvers.Definition.cs @@ -50,17 +50,48 @@ private static Entity ExpandLogarithm(Entity expr) return null; expr = expr.Replace(ExpandLogarithm); + // In front of both paths, not only the two-sided one. Each of these is guarded on + // a two-sided limit, and a two-sided limit that exists is also the limit from + // either side, so none of them says anything one-sided that it did not already say + // two-sided. Skipping them is what made (1 + x)^(1/x) at 0+ answer 1: without the + // second remarkable limit the descent reads it as 1^(+oo) and is definite about + // it, where the same expression approached from both sides gives e. + expr = expr.Replace(a => TrivialTrigonometricReplacement(a, x)); + expr = ApplyTrivialTransformations(expr, x, dest, (_, exprLim) => exprLim); + expr = ApplyFirstRemarkable(expr, x, dest); + expr = expr.Replace(c => ApplySecondRemarkable(c, x, dest)); + MultithreadingFunctional.ExitIfCancelled(); + if (side is ApproachFrom.Left or ApproachFrom.Right) - return expr.ComputeLimitDivideEtImpera(x, dest, side); + { + var oneSided = expr.ComputeLimitDivideEtImpera(x, dest, side); + if (oneSided is { } found && (acceptNaN || found.Evaled != MathS.NaN)) + return oneSided; + // The one-sided path is the only one with nothing behind it, and the descent + // can make an indeterminate form definite on the way down: for x * ln(x) at 0+ + // it substitutes the first factor's own limit and then asks for 0 * -oo, which + // is NaN. NaN is the claim that the limit does not exist, and here it is 0. + // + // Moving x out to infinity is what ComputeLimitImpl does for a finite + // destination in any case, so this asks the same question in the place where + // l'Hopital's rule and the rest of the machinery for infinity live -- none of + // which this path can otherwise reach. Only where nothing above answered, and + // whatever was found before is kept if this finds nothing better. + // Simplified, and not only for tidiness: the substitution leaves the + // destination behind as a term, and it is exactly that leftover which makes + // the answer NaN. x * ln(x) at 0+ becomes (0 + 1/x) * ln(0 + 1/x), whose limit + // at infinity comes back NaN, where the same expression written as + // (1/x) * ln(1/x) comes back 0. + if (dest.IsFinite + && ComputeLimit(expr.Substitute(x, side is ApproachFrom.Left ? dest - 1 / x : dest + 1 / x) + .InnerSimplified, + x, Real.PositiveInfinity) is { } byInfinity + && byInfinity.Evaled != MathS.NaN) + return byInfinity; + return oneSided; + } if (side is ApproachFrom.BothSides) { - expr = expr.Replace(a => TrivialTrigonometricReplacement(a, x)); - expr = ApplyTrivialTransformations(expr, x, dest, (_, exprLim) => exprLim); - expr = ApplyFirstRemarkable(expr, x, dest); - // expr = ApplySecondRemarkable(expr, x, dest); - expr = expr.Replace(c => ApplySecondRemarkable(c, x, dest)); - - MultithreadingFunctional.ExitIfCancelled(); if (!dest.IsFinite) { // just compute limit with no check for left/right equality diff --git a/Sources/Tests/UnitTests/Calculus/OneSidedLimitTest.cs b/Sources/Tests/UnitTests/Calculus/OneSidedLimitTest.cs new file mode 100644 index 000000000..8fb660b52 --- /dev/null +++ b/Sources/Tests/UnitTests/Calculus/OneSidedLimitTest.cs @@ -0,0 +1,96 @@ +// +// Copyright (c) 2019-2022 Angouri. +// AngouriMath is licensed under MIT. +// Details: https://github.com/asc-community/AngouriMath/blob/master/LICENSE.md. +// Website: https://am.angouri.org. +// + +using AngouriMath; +using AngouriMath.Core; +using AngouriMath.Extensions; +using Xunit; + +namespace AngouriMath.Tests.Calculus +{ + /// + /// A one-sided limit had nothing behind it. Where the two-sided and the infinite paths + /// fall through to l'Hopital's rule, this one returned whatever the descent produced -- + /// and the descent can make an indeterminate form definite on the way down, by putting a + /// part's own limit in place of the part. x * ln(x) at 0+ becomes 0 * ln(x), so 0 * -oo, + /// so NaN, and NaN is the claim that the limit does not exist. + /// + public sealed class OneSidedLimitTest + { + private static Entity Limit(string expression, string destination, ApproachFrom side) => + expression.ToEntity().Limit("x", destination.ToEntity(), side).Simplify(); + + private static void AssertLimit(string expression, string destination, ApproachFrom side, string expected) => + Assert.Equal(expected.ToEntity().Evaled, Limit(expression, destination, side).Evaled); + + /// + /// Every one of these came back NaN. sin(x) / x is the one worth naming: approached + /// from either side it is 1, and the library said the limit did not exist. + /// + [Theory] + [InlineData("sin(x) / x", "0", "1")] + [InlineData("tan(x) / x", "0", "1")] + [InlineData("x / sin(x)", "0", "1")] + [InlineData("x * ln(x)", "0", "0")] + public void FormsThatUsedToBeCalledNonExistentFromTheRight(string expression, string destination, string expected) => + AssertLimit(expression, destination, ApproachFrom.Right, expected); + + [Theory] + [InlineData("sin(x) / x", "0", "1")] + [InlineData("x / sin(x)", "0", "1")] + public void FormsThatUsedToBeCalledNonExistentFromTheLeft(string expression, string destination, string expected) => + AssertLimit(expression, destination, ApproachFrom.Left, expected); + + /// + /// The second remarkable limit was applied only on the two-sided path, so from one side + /// the descent read (1 + x)^(1/x) as 1^(+oo) and was definite about it: it answered 1, + /// where the same expression approached from both sides gives e. Not a NaN, so nothing + /// behind the descent would have been consulted -- this one had to be fixed in front + /// of it. + /// + [Theory] + [InlineData("(1 + x) ^ (1/x)", "0", ApproachFrom.Right, "e")] + [InlineData("(1 + x) ^ (1/x)", "0", ApproachFrom.Left, "e")] + [InlineData("(1 + 2 * x) ^ (1/x)", "0", ApproachFrom.Right, "e ^ 2")] + [InlineData("(1 + 2 * x) ^ (1/x)", "0", ApproachFrom.Left, "e ^ 2")] + public void TheRemarkableLimitsApplyFromOneSideToo(string expression, string destination, ApproachFrom side, string expected) => + AssertLimit(expression, destination, side, expected); + + /// + /// The one-sided answers that were already right, kept. The fallback is only consulted + /// where the descent returned nothing or NaN, so none of these should reach it at all. + /// + [Theory] + [InlineData("1 / x", "0", ApproachFrom.Right, "+oo")] + [InlineData("1 / x", "0", ApproachFrom.Left, "-oo")] + [InlineData("1 / x ^ 2", "0", ApproachFrom.Right, "+oo")] + [InlineData("1 / x ^ 2", "0", ApproachFrom.Left, "+oo")] + [InlineData("ln(x)", "0", ApproachFrom.Right, "-oo")] + [InlineData("ln(x) / x", "0", ApproachFrom.Right, "-oo")] + [InlineData("e ^ (1/x)", "0", ApproachFrom.Right, "+oo")] + [InlineData("e ^ (1/x)", "0", ApproachFrom.Left, "0")] + [InlineData("1 / (x - 2)", "2", ApproachFrom.Right, "+oo")] + [InlineData("1 / (x - 2)", "2", ApproachFrom.Left, "-oo")] + [InlineData("(x ^ 2 - 1) / (x - 1)", "1", ApproachFrom.Right, "2")] + [InlineData("(x ^ 2 - 1) / (x - 1)", "1", ApproachFrom.Left, "2")] + [InlineData("x / (x + 1)", "0", ApproachFrom.Right, "0")] + public void EstablishedOneSidedLimitsAreUnaffected(string expression, string destination, ApproachFrom side, string expected) => + AssertLimit(expression, destination, side, expected); + + /// + /// The fallback moves x out to infinity, which is what a finite destination does in any + /// case. It must not be reached for a destination that is already infinite, where the + /// substitution would mean nothing. + /// + [Theory] + [InlineData("1 / x", "+oo", ApproachFrom.Right, "0")] + [InlineData("x ^ 2 / (x ^ 2 + 1)", "+oo", ApproachFrom.Left, "1")] + [InlineData("1 / x", "-oo", ApproachFrom.Left, "0")] + public void AnInfiniteDestinationIsLeftAlone(string expression, string destination, ApproachFrom side, string expected) => + AssertLimit(expression, destination, side, expected); + } +} From ee916f2f75b357db9bcc9c43a902640b67f6947f Mon Sep 17 00:00:00 2001 From: Rafael Vuijk Date: Tue, 4 Aug 2026 17:22:13 +0000 Subject: [PATCH 2/2] Apply l'Hopital's rule from one side as well The rule was reached from the two-sided path only, so a one-sided limit of a quotient the descent has no reading of had nothing behind it at all. It is the same rule either way: l'Hopital's is stated one-sidedly to begin with, and the two-sided case is the two one-sided ones agreeing. The side is carried through rather than dropped, because both questions the rule asks along the way can have a one-sided answer and no two-sided one. The first step of (1 - cos(x)) / x^3 reaches sin(x) / (3x^2), which is +oo on the right and -oo on the left; asked about both sides at once it comes back NaN and the step is wasted. Measured on a corpus of 49 one-sided limits with known answers: 36 correct without carrying the side and calling the rule at all, 40 with the rule but asked two-sided, 42 with the side carried. No answer changed for the worse in any of the three. csc(x) * x is the one worth naming: the csc rewrite leaves the product (1 / sin(x)) * x, which the descent does not take apart, and the rule reads it back as the quotient x / sin(x). One-sided corpus 36/49 -> 42/49. Two-sided corpus unchanged at 101/117, 0 wrong. Suite 4041 passed, 0 failed. --- .../Limits/Solvers/Solvers.Definition.cs | 9 ++++++ .../Continuous/Limits/Transformations.cs | 21 ++++++++++---- .../UnitTests/Calculus/OneSidedLimitTest.cs | 28 +++++++++++++++++++ 3 files changed, 53 insertions(+), 5 deletions(-) diff --git a/Sources/AngouriMath/Functions/Continuous/Limits/Solvers/Solvers.Definition.cs b/Sources/AngouriMath/Functions/Continuous/Limits/Solvers/Solvers.Definition.cs index e37877f0f..3d49d7fb7 100644 --- a/Sources/AngouriMath/Functions/Continuous/Limits/Solvers/Solvers.Definition.cs +++ b/Sources/AngouriMath/Functions/Continuous/Limits/Solvers/Solvers.Definition.cs @@ -67,6 +67,15 @@ private static Entity ExpandLogarithm(Entity expr) var oneSided = expr.ComputeLimitDivideEtImpera(x, dest, side); if (oneSided is { } found && (acceptNaN || found.Evaled != MathS.NaN)) return oneSided; + // The same rule the two-sided path falls through to, asked with the side it was + // given. l'Hopital's rule is stated one-sidedly to begin with -- the two-sided + // case is the two one-sided ones agreeing -- so this is not a weaker reading of + // it than the one above. Where the descent has nothing to say about a quotient, + // it is what answers (1 - cos(x)) / x^2 at 0+ with 1/2 rather than NaN, and + // csc(x) * x with 1: the csc rewrite leaves a product, which the descent does + // not take apart but which the rule reads back as the quotient x / sin(x). + if (ApplylHopitalRule(expr, x, dest, side) is { } lhopital && lhopital.Evaled != MathS.NaN) + return lhopital; // The one-sided path is the only one with nothing behind it, and the descent // can make an indeterminate form definite on the way down: for x * ln(x) at 0+ // it substitutes the first factor's own limit and then asks for 0 * -oo, which diff --git a/Sources/AngouriMath/Functions/Continuous/Limits/Transformations.cs b/Sources/AngouriMath/Functions/Continuous/Limits/Transformations.cs index b92023309..e59de5767 100644 --- a/Sources/AngouriMath/Functions/Continuous/Limits/Transformations.cs +++ b/Sources/AngouriMath/Functions/Continuous/Limits/Transformations.cs @@ -165,7 +165,18 @@ private static bool GrewTooMuch(Entity quotient, Entity applied) return reciprocal ? numerator / denominator : null; } - private static Entity? ApplylHopitalRule(Entity expr, Variable x, Entity dest) + /// + /// The side is carried through because the rule is stated one-sidedly to begin with -- + /// the two-sided case is the two one-sided ones agreeing -- so it is the same rule + /// either way, asked of the same quotient under the same premises. Carrying it is what + /// lets the rule answer where the two-sided reading has nothing to say, and the two + /// questions the rule asks along the way are both of that kind: what the parts tend to, + /// and what the differentiated quotient tends to. For (1 - cos(x)) / x^3 at 0 + /// the second of those reaches sin(x) / (3x^2), which is +oo on the right and + /// -oo on the left, so asking about both sides at once gives NaN and the step is + /// wasted. Asked one side at a time it answers. + /// + private static Entity? ApplylHopitalRule(Entity expr, Variable x, Entity dest, ApproachFrom side = ApproachFrom.BothSides) { if (lHopitalDepth == 0) lHopitalApplications = 0; @@ -175,16 +186,16 @@ private static bool GrewTooMuch(Entity quotient, Entity applied) // asking what the two parts tend to is itself a limit that the rule may be applied // to, and counting only the recursion left those two free to nest without a bound. lHopitalDepth++; - try { return ApplylHopitalRuleImpl(expr, x, dest); } + try { return ApplylHopitalRuleImpl(expr, x, dest, side); } finally { lHopitalDepth--; } } - private static Entity? ApplylHopitalRuleImpl(Entity expr, Variable x, Entity dest) + private static Entity? ApplylHopitalRuleImpl(Entity expr, Variable x, Entity dest, ApproachFrom side) { if (expr is not Divf && AsQuotient(expr) is { } quotient) expr = quotient; if (expr is Divf(var num, var den)) - if (EvalAssumingContinuous(num.Limit(x, dest)) is var numLimit && EvalAssumingContinuous(den.Limit(x, dest)) is var denLimit) + if (EvalAssumingContinuous(num.Limit(x, dest, side)) is var numLimit && EvalAssumingContinuous(den.Limit(x, dest, side)) is var denLimit) if (numLimit == 0 && denLimit == 0 || IsInfiniteNode(numLimit) && IsInfiniteNode(denLimit)) if (num is not Number && den is not Number) @@ -209,7 +220,7 @@ private static bool GrewTooMuch(Entity quotient, Entity applied) lHopitalChain!.Add(applied); try { - if (ComputeLimit(applied, x, dest) is { } resLim) + if (ComputeLimit(applied, x, dest, side) is { } resLim) return resLim; } finally { lHopitalChain.RemoveAt(lHopitalChain.Count - 1); } diff --git a/Sources/Tests/UnitTests/Calculus/OneSidedLimitTest.cs b/Sources/Tests/UnitTests/Calculus/OneSidedLimitTest.cs index 8fb660b52..3cdfa1c7f 100644 --- a/Sources/Tests/UnitTests/Calculus/OneSidedLimitTest.cs +++ b/Sources/Tests/UnitTests/Calculus/OneSidedLimitTest.cs @@ -60,6 +60,34 @@ public void FormsThatUsedToBeCalledNonExistentFromTheLeft(string expression, str public void TheRemarkableLimitsApplyFromOneSideToo(string expression, string destination, ApproachFrom side, string expected) => AssertLimit(expression, destination, side, expected); + /// + /// l'Hopital's rule was reached from the two-sided path only, so a one-sided limit of a + /// quotient the descent could not read had nothing to fall back on. csc(x) * x is the + /// odd one out: the csc rewrite makes it the product (1 / sin(x)) * x, which the descent + /// does not take apart, and the rule reads it back as the quotient x / sin(x). + /// + [Theory] + [InlineData("(1 - cos(x)) / x ^ 2", "0", ApproachFrom.Right, "1/2")] + [InlineData("(1 - cos(x)) / x ^ 2", "0", ApproachFrom.Left, "1/2")] + [InlineData("csc(x) * x", "0", ApproachFrom.Right, "1")] + [InlineData("csc(x) * x", "0", ApproachFrom.Left, "1")] + public void AQuotientTheDescentCannotReadGoesToTheRule(string expression, string destination, ApproachFrom side, string expected) => + AssertLimit(expression, destination, side, expected); + + /// + /// The rule is asked with the side it was given, which is what the rule is stated with + /// in the first place. Both questions it asks along the way -- what the two parts tend + /// to, and what the differentiated quotient tends to -- can have a one-sided answer and + /// no two-sided one. Here the first step of (1 - cos(x)) / x^3 reaches sin(x) / (3x^2), + /// which is +oo on the right and -oo on the left, so asking about both sides at once + /// gives NaN and the step is wasted. + /// + [Theory] + [InlineData("(1 - cos(x)) / x ^ 3", "0", ApproachFrom.Right, "+oo")] + [InlineData("(1 - cos(x)) / x ^ 3", "0", ApproachFrom.Left, "-oo")] + public void TheRuleIsAskedAboutTheSideItWasGiven(string expression, string destination, ApproachFrom side, string expected) => + AssertLimit(expression, destination, side, expected); + /// /// The one-sided answers that were already right, kept. The fallback is only consulted /// where the descent returned nothing or NaN, so none of these should reach it at all.