diff --git a/Sources/AngouriMath/Convenience/MathS.cs b/Sources/AngouriMath/Convenience/MathS.cs index ea15c8657..09dc50a68 100644 --- a/Sources/AngouriMath/Convenience/MathS.cs +++ b/Sources/AngouriMath/Convenience/MathS.cs @@ -5700,7 +5700,41 @@ internal static EDecimal DowncastingTolerance /// public static Setting DecimalPrecisionContext => decimalPrecisionContext ??= new EContext(100, ERounding.HalfUp, -100, 1000, false); - [ThreadStatic] private static Setting? decimalPrecisionContext; + [ThreadStatic] private static Setting? decimalPrecisionContext; + + /// + /// Whether functions are being read as real-valued or complex-valued. It is a + /// statement about the reading and not about any one expression -- an + /// 's own Codomain says what a particular node is + /// declared over, where this says what the library should take a function to be. + /// + /// + /// + /// by default, which is what AngouriMath has always + /// done: lim x->0- ln(x) answers -oo, reading the logarithm along + /// its complex continuation, since the logarithm of a negative real is + /// ln|x| + i*pi and only its magnitude is being reported. + /// + /// + /// Under a limit approached through values the function + /// does not take in the reals has no value rather than the continued one. That is + /// the missing information behind a family of questions -- whether two agreeing + /// one-sided limits may be promoted to a two-sided one, and whether + /// sqrt(x^2) is x -- because each of them is answerable one way over + /// the reals and another over the complex plane, and the library had no way to be + /// told which was meant. + /// #719 + /// + /// + /// + /// using var _ = MathS.Settings.Codomain.Set(Domain.Real); + /// Console.WriteLine("ln(x)".Limit("x", 0, ApproachFrom.Left)); + /// + /// prints the unevaluated limit, where the default prints -oo. + /// + /// + public static Setting Codomain => codomain ??= Domain.Complex; + [ThreadStatic] private static Setting? codomain; } /// Returns an in polynomial order if possible diff --git a/Sources/AngouriMath/Functions/Continuous/Limits/Solvers/Solvers.Definition.cs b/Sources/AngouriMath/Functions/Continuous/Limits/Solvers/Solvers.Definition.cs index 3f3eba18f..e0d413b07 100644 --- a/Sources/AngouriMath/Functions/Continuous/Limits/Solvers/Solvers.Definition.cs +++ b/Sources/AngouriMath/Functions/Continuous/Limits/Solvers/Solvers.Definition.cs @@ -98,6 +98,20 @@ private static Entity ExpandLogarithm(Entity expr) // differentiate it. if (expr is not ContinuousNode and not Variable and not Piecewise) return null; + + // Under a real codomain a limit reached only through values the function does not + // take in the reals is not a limit of it. + // + // Before the rewrites below and not after them, which is load-bearing rather than + // tidy: each of those asks for limits of its own, and under this reading those + // sub-limits come back unevaluated -- whereupon evaluating the unevaluated limit + // asks for it again, through the same rewrite, without end. Answering here costs + // nothing and asks nothing. + // https://github.com/asc-community/AngouriMath/issues/719 + if (RealCodomainWithdraws(expr, x, dest, + side is ApproachFrom.Left ? ApproachFrom.Left : ApproachFrom.Right)) + return null; + expr = expr.Replace(ExpandLogarithm); // In front of both paths, not only the two-sided one. Each of these is guarded on diff --git a/Sources/AngouriMath/Functions/Continuous/Limits/Transformations.cs b/Sources/AngouriMath/Functions/Continuous/Limits/Transformations.cs index 3f39f5590..60ca10a0d 100644 --- a/Sources/AngouriMath/Functions/Continuous/Limits/Transformations.cs +++ b/Sources/AngouriMath/Functions/Continuous/Limits/Transformations.cs @@ -39,6 +39,94 @@ private static Entity EvalAssumingContinuous(Entity expr) => Providedf(var inner, _) => inner, var x => x }; + /// + /// How close in the approach is sampled when asking whether a function stays real on + /// the way to its destination. Powers of ten rather than a fixed step, because what + /// matters is near and not evenly spaced: sqrt(x - 1) is real just to the left + /// of 2 and not just to the left of 1, and only the nearer samples tell those apart. + /// + /// + /// It stops at a thousandth, and the reason is cost rather than principle. The sample + /// point goes into the expression, and an expression may put it in an exponent: + /// (1 + x)^(1/x) at 1e-9 is 1.000000001 raised to a billion, evaluated + /// at a hundred digits, and asking that six times on every limit the machinery takes + /// turned a 20 ms limit into a timeout. + /// + /// Sampling less far in only ever *misses* a function that leaves the reals nearer the + /// destination than this reaches, and a miss leaves the limit answered exactly as it + /// was before. The guard withdraws on evidence and never on the absence of it, so the + /// cheap end of that trade is the safe one. + /// + [ConstantField] private static readonly int[] ApproachScales = { 1, 2, 3 }; + + /// + /// Whether the expression takes values outside the reals on the way in. Under a real + /// codomain such a limit has no value, rather than the value its complex continuation + /// approaches. + /// + /// + /// lim x->0- ln(x) is the plain case: the logarithm of a negative real is + /// ln|x| + i*pi, and answering -oo reports the magnitude of something + /// that is not a real number at any point of the approach. + /// + /// Decided by sampling rather than symbolically, and deliberately: what is being asked + /// is whether the function *takes* non-real values near a point, which no property of + /// the tree answers. The direction of error is chosen too. A sample that cannot be + /// evaluated, or that comes back non-finite, is passed over rather than counted, and an + /// expression carrying a second variable is not judged at all -- so the answer is only + /// ever "yes, demonstrably" or "not shown", and a limit is withdrawn on evidence. + /// #719 + /// + private static bool LeavesTheRealsOnTheApproach(Entity expr, Variable x, Entity dest, ApproachFrom side) + { + if (expr.Vars.Count() != 1) + return false; + var towardsNegative = side is ApproachFrom.Left; + if (!dest.IsFinite) + towardsNegative = dest.Evaled is Real { IsNegative: true }; + foreach (var scale in ApproachScales) + { + var ten = EInteger.FromInt32(10).Pow(scale); + Entity point; + if (dest.IsFinite) + { + var step = (Entity)Rational.Create(EInteger.One, ten); + point = towardsNegative ? dest - step : dest + step; + } + else + { + var far = (Entity)Integer.Create(ten); + point = towardsNegative ? -far : far; + } + Complex value; + try + { + if (expr.Substitute(x, point).EvalNumerical() is not Complex evaluated) + continue; + value = evaluated; + } + catch (Core.Exceptions.AngouriBugException) { throw; } + catch (System.Exception) { continue; } + if (!value.IsFinite) + continue; + var imaginary = value.ImaginaryPart.EDecimal.Abs(); + var real = value.RealPart.EDecimal.Abs(); + // Relative, since the point may be evaluated at a scale where an absolute + // threshold means nothing, and a rounding artefact must not withdraw a limit. + if (imaginary.GreaterThan(EDecimal.Create(1, -6).Multiply(EDecimal.Max(real, EDecimal.One, null)))) + return true; + } + return false; + } + + /// + /// Whether the reading in force is of real-valued functions, in which case a limit + /// reached only through the complex plane is not one. + /// + internal static bool RealCodomainWithdraws(Entity expr, Variable x, Entity dest, ApproachFrom side) + => MathS.Settings.Codomain.Value is AngouriMath.Core.Domain.Real + && LeavesTheRealsOnTheApproach(expr, x, dest, side); + private static Entity ApplyFirstRemarkable(Entity expr, Variable x, Entity dest) => expr switch { diff --git a/Sources/Tests/UnitTests/Calculus/RealCodomainLimitTest.cs b/Sources/Tests/UnitTests/Calculus/RealCodomainLimitTest.cs new file mode 100644 index 000000000..e8b55c1c3 --- /dev/null +++ b/Sources/Tests/UnitTests/Calculus/RealCodomainLimitTest.cs @@ -0,0 +1,151 @@ +// +// 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 System.Linq; +using AngouriMath; +using AngouriMath.Core; +using AngouriMath.Extensions; +using Xunit; + +namespace AngouriMath.Tests.Calculus +{ + /// + /// AngouriMath's one-sided limits did not stay inside the reals: lim x->0- ln(x) + /// answered -oo, which is the magnitude of ln|x| + i*pi and not the limit of + /// anything real-valued. There was no way to say which reading was meant, and that missing + /// information is what stops agreeing one-sided limits from being promoted to a two-sided + /// one -- promoting them without it would also give lim x->0 x^x the value 1, + /// which the suite pins as non-existent because x^x is not real for negative x. + /// is that information. + /// https://github.com/asc-community/AngouriMath/issues/719 + /// + public sealed class RealCodomainLimitTest + { + private static Entity Limit(string expr, string destination, ApproachFrom side) => + expr.ToEntity().Limit("x", destination.ToEntity(), side).Simplify(); + + private static Entity LimitOverTheReals(string expr, string destination, ApproachFrom side) + { + using var _ = MathS.Settings.Codomain.Set(Domain.Real); + return Limit(expr, destination, side); + } + + private static bool IsUnevaluated(Entity limit) => limit.Nodes.Any(node => node is Entity.Limitf); + + /// + /// The default is what AngouriMath has always done, so that nothing changes for anyone + /// who does not ask. This is the whole of the compatibility claim and is checked + /// against the values themselves rather than against the setting's value. + /// + [Theory] + [InlineData("ln(x)", "0", "-oo")] + [InlineData("x * ln(x)", "0", "0")] + [InlineData("x ^ x", "0", "1")] + [InlineData("sqrt(x)", "0", "0")] + public void TheDefaultReadingIsUnchanged(string expr, string destination, string expected) + { + Assert.Equal(Domain.Complex, MathS.Settings.Codomain.Value); + Assert.Equal(expected.ToEntity().Evaled, + Limit(expr, destination, ApproachFrom.Left).Evaled); + } + + /// + /// Over the reals each of these is approached through values the function does not + /// take, so it has no limit rather than the one its continuation approaches. + /// + [Theory] + [InlineData("ln(x)", "0")] + [InlineData("x * ln(x)", "0")] + [InlineData("x ^ x", "0")] + [InlineData("sqrt(x)", "0")] + [InlineData("ln(x) / x", "0")] + [InlineData("sqrt(x) + 1", "0")] + public void ALimitReachedOnlyThroughTheComplexPlaneIsWithdrawn(string expr, string destination) => + Assert.True(IsUnevaluated(LimitOverTheReals(expr, destination, ApproachFrom.Left)), + $"{expr} at {destination}- still answered over the reals"); + + /// + /// The other side of the same point, where the function *is* real, is untouched. That + /// is the whole distinction: it is the approach that is judged, not the expression. + /// + [Theory] + [InlineData("ln(x)", "0", "-oo")] + [InlineData("x * ln(x)", "0", "0")] + [InlineData("x ^ x", "0", "1")] + [InlineData("sqrt(x)", "0", "0")] + public void TheSideOnWhichItIsRealStillAnswers(string expr, string destination, string expected) => + Assert.Equal(expected.ToEntity().Evaled, + LimitOverTheReals(expr, destination, ApproachFrom.Right).Evaled); + + /// + /// A function real on both sides is unaffected in either direction, which is most of + /// them -- the setting has to be nearly invisible or it is not usable. + /// + [Theory] + [InlineData("sin(x) / x", "0", "1")] + [InlineData("(1 - cos(x)) / x ^ 2", "0", "1/2")] + [InlineData("x ^ 2", "0", "0")] + [InlineData("1 / x ^ 2", "0", "+oo")] + [InlineData("e ^ x", "0", "1")] + [InlineData("(1 + x) ^ (1/x)", "0", "e")] + public void AFunctionRealOnBothSidesIsUnaffected(string expr, string destination, string expected) + { + foreach (var side in new[] { ApproachFrom.Left, ApproachFrom.Right }) + Assert.Equal(expected.ToEntity().Evaled, + LimitOverTheReals(expr, destination, side).Evaled); + } + + // At infinity there is one direction of approach and it is the destination's own sign. + [Theory] + [InlineData("ln(x)", "+oo", "+oo")] + [InlineData("x ^ 2", "+oo", "+oo")] + [InlineData("1 / x", "+oo", "0")] + [InlineData("x ^ 2", "-oo", "+oo")] + public void ALimitAtInfinityThatStaysRealIsUnaffected(string expr, string destination, string expected) => + Assert.Equal(expected.ToEntity().Evaled, + LimitOverTheReals(expr, destination, ApproachFrom.Left).Evaled); + + [Theory] + [InlineData("ln(x)", "-oo")] + [InlineData("sqrt(x)", "-oo")] + public void ALimitAtInfinityThatLeavesTheRealsIsWithdrawn(string expr, string destination) => + Assert.True(IsUnevaluated(LimitOverTheReals(expr, destination, ApproachFrom.Left)), + $"{expr} at {destination} still answered over the reals"); + + /// + /// The setting is restored on the way out, and it is thread-static like every other + /// one -- worth pinning, since a codomain that leaked would change answers far from + /// where it was set. + /// + [Fact] + public void TheSettingIsRestoredWhenItGoesOutOfScope() + { + Assert.Equal(Domain.Complex, MathS.Settings.Codomain.Value); + using (var _ = MathS.Settings.Codomain.Set(Domain.Real)) + Assert.Equal(Domain.Real, MathS.Settings.Codomain.Value); + Assert.Equal(Domain.Complex, MathS.Settings.Codomain.Value); + } + + /// + /// What this does not yet do. Promoting two agreeing one-sided limits to a two-sided + /// one is the point of the setting and is deliberately not part of it: step 1 changes + /// what every one-sided limit answers under a real codomain, and that wants measuring + /// on its own before anything is built on top. Pinned so the next step has somewhere + /// to land and so the current state is a decision rather than an oversight. + /// + [Fact] + public void AgreeingOneSidedLimitsAreNotYetPromoted() + { + using var _ = MathS.Settings.Codomain.Set(Domain.Real); + // Both sides are 0 and the two-sided answer is still NaN. + Assert.Equal(Entity.Number.Integer.Create(0), + Limit("x * ln(x)", "0", ApproachFrom.Right).Evaled); + Assert.Equal(MathS.NaN.Evaled, + "x * ln(x)".ToEntity().Limit("x", 0).Simplify().Evaled); + } + } +}