From cd84d98faf96bb7bc8cf95a28a266095261ea365 Mon Sep 17 00:00:00 2001 From: Rafael Vuijk Date: Fri, 30 Oct 2026 21:24:16 +0000 Subject: [PATCH] A step function at a jump takes the side its argument arrives from A limit of floor, ceil or sgn whose argument arrives on a jump was left unevaluated: limitleft(floor(x), x, 0) and limitright(sgn(x), x, 0) among them. The remark gave the reason that which side the argument arrives from is not the side x comes from, floor(2 - x^2) coming to 2 from below as x comes to 0 from the right. That side is the sign the argument less the jump keeps there, which SignNear reads off the limit or off the first derivative that does not vanish. floor gives the jump from above and one less from below, ceil one more from above and the jump from below, and sgn the side itself. A two-sided limit asks each side, and where they differ, as at a jump, it is NaN. StepFunctionAtAJumpTest takes sixteen limits; fifteen fail on master. FloorCeilTest and SignumLimitTest pinned the declines, and now pin the values: NaN for the two-sided limits, which do not exist, and the sign of the side for the one-sided ones, each still bounded in time, which is what they are for. The suite, the trio, F#, AOT and the eight harnesses pass. With #2268, which declines the substitution through a jump, the composed expressions are answered too: limitleft(floor(x) - 1, x, 0) is -2 and limit(floor(x) + x, x, 1) is NaN. Part of #2266. Co-Authored-By: Claude Opus 5.5 (1M context) --- BREAKING-CHANGES.md | 16 ++++++ Sources/.editorconfig | 3 ++ .../Limits/Solvers/Limit.Classes.cs | 51 ++++++++++++------- .../Continuous/Limits/Transformations.cs | 2 +- .../UnitTests/Calculus/SignumLimitTest.cs | 22 ++++---- .../Calculus/StepFunctionAtAJumpTest.cs | 43 ++++++++++++++++ .../UnitTests/Convenience/FloorCeilTest.cs | 14 ++--- 7 files changed, 116 insertions(+), 35 deletions(-) create mode 100644 Sources/Tests/UnitTests/Calculus/StepFunctionAtAJumpTest.cs diff --git a/BREAKING-CHANGES.md b/BREAKING-CHANGES.md index 4958f4e35..48ee87fc0 100644 --- a/BREAKING-CHANGES.md +++ b/BREAKING-CHANGES.md @@ -245,6 +245,22 @@ is the canonical one: square-free, and without what the answer's own denominator | `"(a - 1)/(1 - b) - (1 - a)/(b - 1)".Simplify()` | `0 provided not (b - 1) * (1 - b) = 0` | `0 provided not b - 1 = 0` | | `"(x*y*a*b*c)/(c*b*a*x*x)".Simplify()` | `y / x provided not a * b * c * x = 0` | `y / x provided not a * b * c = 0` | +### A step function at a jump takes the side its argument arrives from + +**Silent: a limit of `floor`, `ceil` or `sgn` whose argument arrives on a jump is answered, where it was left +unevaluated.** A one-sided limit is the value on the side the argument arrives from, which is the sign the argument less +the jump keeps near the destination, and not always the side x comes from: `floor(2 - x^2)` comes to 2 from below as x comes +to 0 from the right, and its limit there is 1. A two-sided limit at a jump has two sides that differ, and is `NaN`, which +says that there is none ([#2266](https://github.com/asc-community/AngouriMath/issues/2266)). + +| Input | Was (2.6.0) | Now | +|---|---|---| +| `"limitleft(floor(x), x, 0)".ToEntity().Simplify()` | `limitleft(floor(x), x, 0)` | `-1` | +| `"limitright(floor(2 - x^2), x, 0)".ToEntity().Simplify()` | `limitright(floor(2 - x ^ 2), x, 0)` | `1` | +| `"limitright(sgn(x^3 - x), x, 0)".ToEntity().Simplify()` | `limitright(sgn(x ^ 3 - x), x, 0)` | `-1` | +| `"limit(floor(x), x, 1)".ToEntity().Simplify()` | `limit(floor(x), x, 1)` | `NaN` | +| `"limit(sgn(x), x, 0)".ToEntity().Simplify()` | `limit(sgn(x), x, 0)` | `NaN` | + ### A polynomial system is not answered with some of its solutions missing **Silent: a polynomial system that lost solutions has all of them.** `EquationSystem.Solve` triangularises a diff --git a/Sources/.editorconfig b/Sources/.editorconfig index 96598d54f..07703cf42 100644 --- a/Sources/.editorconfig +++ b/Sources/.editorconfig @@ -120,6 +120,9 @@ file_header_template=\nCopyright (c) 2019-2026 Angouri.\nAngouriMath is licensed [Tests/UnitTests/Calculus/ProductOfPowersDerivativeTest.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/Calculus/StepFunctionAtAJumpTest.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/Calculus/LogarithmOfARationalFunctionByPartsTest.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/Functions/Continuous/Limits/Solvers/Limit.Classes.cs b/Sources/AngouriMath/Functions/Continuous/Limits/Solvers/Limit.Classes.cs index d4dba0e85..ca11d7fdd 100644 --- a/Sources/AngouriMath/Functions/Continuous/Limits/Solvers/Limit.Classes.cs +++ b/Sources/AngouriMath/Functions/Continuous/Limits/Solvers/Limit.Classes.cs @@ -410,21 +410,27 @@ partial record Signumf return null; // The sign is constant on either side of zero, so wherever the argument tends // to anything but zero the limit is simply the sign of that -- including the - // infinities, where it is 1 and -1. At zero it is the one place there is - // nothing to say: the sign is 1 on one side and -1 on the other, and which one - // a one-sided limit takes depends on the direction the argument approaches - // from rather than only on what it tends to. + // infinities, where it is 1 and -1. At zero the sign is 1 on one side and -1 on + // the other, and which one a one-sided limit takes depends on the direction the + // argument approaches from rather than only on what it tends to. // - // Nothing is returned there rather than an unevaluated limit of this very - // expression. That is what used to be here, and it does not merely fail to + // Where that direction cannot be read, nothing is returned rather than an + // unevaluated limit of this very expression. That is what used to be here, and it + // does not merely fail to // answer -- the two-sided path compares its two one-sided results by evaluating // them, evaluating a limit computes it, and computing it arrives back here. The // recursion ends by overflowing the stack, which kills the process rather than // raising anything a caller could catch: // https://github.com/asc-community/AngouriMath/issues/704. Null says the same // thing to the caller, which hands back an unevaluated limit of its own. - if (argument.Evaled is not Number value || value == 0 || value.Evaled == MathS.NaN) + if (argument.Evaled is not Number value || value.Evaled == MathS.NaN) return null; + // At zero the side the argument arrives from is the sign, where it can be read: + // sgn(x^3 - x) at 0+ is -1, from below. A two-sided limit is its two sides, each + // asked of this, and is not answered here. + // https://github.com/asc-community/AngouriMath/issues/2266 + if (value == 0) + return SignNear(Argument, x, dist, side) is { } arrivesFrom ? Integer.Create(arrivesFrom) : null; return new Signumf(argument); } } @@ -441,14 +447,19 @@ partial record Absf /// /// /// Away from the jumps the function is locally constant, so the limit is simply the - /// function of the argument's limit. On a jump there is nothing to say: the value - /// differs on the two sides of it, and which side the argument arrives from is not - /// decided by the side approaches its destination from -- - /// lim(x -> 0+) floor(2 - x^2) reaches 2 from below and lim(x -> 0+) floor(2 + x^2) - /// from above, and both are limits from the right. + /// function of the argument's limit. On a jump the value differs on the two sides of it, + /// and which side the argument arrives from is not decided by the side + /// approaches its destination from -- lim(x -> 0+) floor(2 - x^2) + /// reaches 2 from below and lim(x -> 0+) floor(2 + x^2) from above, and both are limits + /// from the right. It is the sign the argument less the jump keeps on that side, which + /// reads off the first derivative that does not vanish, and + /// gives the value from it. A two-sided limit is asked of + /// each side, where the sign is not one sign, and is not answered here. + /// #2266 /// - /// Null is returned there rather than an unevaluated limit of the very expression being - /// asked about. The latter is what the inherited default does, and it does not merely + /// Null is returned where the sign cannot be read, rather than an unevaluated limit of + /// the very expression being asked about. The latter is what the inherited default does, + /// and it does not merely /// fail to answer: the two-sided path compares its one-sided results by evaluating them, /// evaluating a limit computes it, and computing it arrives back here. The recursion /// ends by overflowing the stack, which kills the process rather than raising anything a @@ -459,7 +470,7 @@ partial record Absf /// same one on . /// private static Entity? LimitOfAStepFunction(Entity argument, Variable x, Entity dist, - ApproachFrom side, System.Func rebuild) + ApproachFrom side, System.Func rebuild, System.Func atAJump) { if (argument.ComputeLimitDivideEtImpera(x, dist, side) is not { } limit) return null; @@ -482,7 +493,9 @@ partial record Absf if (value is not Complex { RealPart: var real, ImaginaryPart: var imaginary }) return null; if (SitsOnAJump(real)) - return null; + return value is Integer jump && SignNear(argument - jump, x, dist, side) is { } arrivesFrom + ? atAJump(jump, arrivesFrom) + : null; if (value is not Real && SitsOnAJump(imaginary)) return null; return rebuild(limit); @@ -494,13 +507,15 @@ static bool SitsOnAJump(Real part) => partial record Floorf { internal override Entity? ComputeLimitDivideEtImpera(Variable x, Entity dist, ApproachFrom side) - => LimitOfAStepFunction(Argument, x, dist, side, static a => new Floorf(a)); + => LimitOfAStepFunction(Argument, x, dist, side, static a => new Floorf(a), + static (jump, arrivesFrom) => arrivesFrom > 0 ? jump : jump - 1); } partial record Ceilf { internal override Entity? ComputeLimitDivideEtImpera(Variable x, Entity dist, ApproachFrom side) - => LimitOfAStepFunction(Argument, x, dist, side, static a => new Ceilf(a)); + => LimitOfAStepFunction(Argument, x, dist, side, static a => new Ceilf(a), + static (jump, arrivesFrom) => arrivesFrom > 0 ? jump + 1 : jump); } partial record Providedf diff --git a/Sources/AngouriMath/Functions/Continuous/Limits/Transformations.cs b/Sources/AngouriMath/Functions/Continuous/Limits/Transformations.cs index 46d3584a0..0607d390c 100644 --- a/Sources/AngouriMath/Functions/Continuous/Limits/Transformations.cs +++ b/Sources/AngouriMath/Functions/Continuous/Limits/Transformations.cs @@ -1037,7 +1037,7 @@ internal static bool IsDeterminate(Entity combined, Variable x) /// The sign an expression keeps throughout a punctured one-sided neighbourhood of the /// destination, or where it cannot be read off. /// - private static int? SignNear(Entity expr, Variable x, Entity dest, ApproachFrom side) + internal static int? SignNear(Entity expr, Variable x, Entity dest, ApproachFrom side) { if (EvalAssumingContinuous(expr.Limit(x, dest, side)) is not Real { IsNaN: false } limit) return null; diff --git a/Sources/Tests/UnitTests/Calculus/SignumLimitTest.cs b/Sources/Tests/UnitTests/Calculus/SignumLimitTest.cs index df9bc0a7a..1136b3a65 100644 --- a/Sources/Tests/UnitTests/Calculus/SignumLimitTest.cs +++ b/Sources/Tests/UnitTests/Calculus/SignumLimitTest.cs @@ -43,29 +43,33 @@ public void TheSignOfWhereTheArgumentGoes(string expression, string destination, Assert.Equal(expected.ToEntity().Evaled, Limit(expression, destination).Evaled); /// - /// Where the argument tends to zero there is nothing to say: the sign is 1 on one side - /// and -1 on the other, and which one is taken depends on the direction the argument - /// approaches from rather than only on what it tends to. Left unevaluated -- and, above + /// Where the argument tends to zero the sign is 1 on one side and -1 on the other, so + /// there is no two-sided limit, and NaN says so -- it was left unevaluated before the + /// sides were read (https://github.com/asc-community/AngouriMath/issues/2266). Above /// all, terminating, which is what this test is really for. /// [Theory] [InlineData("signum(x)", "0")] [InlineData("signum(x ^ 3)", "0")] - public void AtZeroItIsLeftUnevaluatedAndTerminates(string expression, string destination) + public void AtZeroThereIsNoLimitAndItTerminates(string expression, string destination) { var task = Task.Run(() => Limit(expression, destination)); Assert.True(task.Wait(LimitTermination.Guard), "the limit did not terminate"); - Assert.IsType(task.Result); + Assert.Equal(MathS.NaN, task.Result); } + /// + /// Which one a one-sided limit takes depends on the direction the argument approaches + /// from, which here is the direction x does. Terminating as the two-sided one does. + /// [Theory] - [InlineData(ApproachFrom.Left)] - [InlineData(ApproachFrom.Right)] - public void OneSidedAtZeroTerminatesToo(ApproachFrom side) + [InlineData(ApproachFrom.Left, -1)] + [InlineData(ApproachFrom.Right, 1)] + public void OneSidedAtZeroIsTheSignOfItsSide(ApproachFrom side, int sign) { var task = Task.Run(() => "signum(x)".ToEntity().Limit("x", 0, side).Simplify()); Assert.True(task.Wait(LimitTermination.Guard), "the limit did not terminate"); - Assert.IsType(task.Result); + Assert.Equal(Entity.Number.Integer.Create(sign), task.Result); } /// diff --git a/Sources/Tests/UnitTests/Calculus/StepFunctionAtAJumpTest.cs b/Sources/Tests/UnitTests/Calculus/StepFunctionAtAJumpTest.cs new file mode 100644 index 000000000..e84b025d6 --- /dev/null +++ b/Sources/Tests/UnitTests/Calculus/StepFunctionAtAJumpTest.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. +// + +using AngouriMath.Extensions; +using Xunit; + +namespace AngouriMath.Tests.Calculus +{ + /// + /// A one-sided limit of floor, ceil or sgn whose argument arrives on a jump is the value on the side the argument arrives + /// from, where it was left unevaluated. That side is the sign the argument less the jump keeps near the destination, and + /// is not the side x comes from: floor(2 - x^2) comes to 2 from below as x comes to 0 from the right. A two-sided limit at + /// a jump has two sides that differ, and is NaN. + /// #2266 + /// + [Trait("Area", "Calculus")] + public sealed class StepFunctionAtAJumpTest + { + [Theory] + [InlineData("limitleft(floor(x), x, 0)", "-1")] + [InlineData("limitright(floor(x), x, 0)", "0")] + [InlineData("limitright(floor(2 - x^2), x, 0)", "1")] + [InlineData("limitright(floor(2 + x^2), x, 0)", "2")] + [InlineData("limitright(floor(-x), x, 0)", "-1")] + [InlineData("limitleft(floor(cos(x)), x, 0)", "0")] + [InlineData("limitleft(ceil(x), x, 0)", "0")] + [InlineData("limitright(ceil(x), x, 0)", "1")] + [InlineData("limitright(sgn(x), x, 0)", "1")] + [InlineData("limitleft(sgn(x), x, 0)", "-1")] + [InlineData("limitright(sgn(x^3 - x), x, 0)", "-1")] + [InlineData("limit(sgn(x^2), x, 0)", "1")] + [InlineData("limit(floor(x^2), x, 0)", "0")] + [InlineData("limit(sgn(x), x, 0)", "NaN")] + [InlineData("limit(floor(x), x, 1)", "NaN")] + [InlineData("limit(floor(x), x, 1/2)", "0")] + public void ItIsTheValueOnTheSideTheArgumentArrivesFrom(string limit, string value) + => Assert.Equal(value.ToEntity().Evaled, limit.ToEntity().Simplify().Evaled); + } +} diff --git a/Sources/Tests/UnitTests/Convenience/FloorCeilTest.cs b/Sources/Tests/UnitTests/Convenience/FloorCeilTest.cs index dc56c0a89..e1bcac041 100644 --- a/Sources/Tests/UnitTests/Convenience/FloorCeilTest.cs +++ b/Sources/Tests/UnitTests/Convenience/FloorCeilTest.cs @@ -214,10 +214,10 @@ public void TheLimitAwayFromAJumpIsTheValue(string input, string destination, st input.ToEntity().Limit("x", destination.ToEntity()).Evaled); /// - /// On a jump the two sides disagree, and which side the argument arrives from is not - /// decided by the side x approaches its destination from. So the answer is an - /// unevaluated limit — the same thing signum returns at zero. What it must not - /// do is pick one of the two values. + /// On a jump the two sides disagree, so there is no two-sided limit, and the answer is + /// NaN -- the same thing signum returns at zero. It was an unevaluated limit + /// before the sides were read (https://github.com/asc-community/AngouriMath/issues/2266). + /// What it must not do is pick one of the two values. /// [Theory] [InlineData("floor(x)", "0")] @@ -225,9 +225,9 @@ public void TheLimitAwayFromAJumpIsTheValue(string input, string destination, st [InlineData("floor(x)", "-3")] [InlineData("ceil(x)", "0")] [InlineData("ceil(x)", "2")] - public void TheLimitOnAJumpIsDeclined(string input, string destination) - => Assert.IsType( - input.ToEntity().Limit("x", destination.ToEntity())); + public void TheLimitOnAJumpDoesNotExist(string input, string destination) + => Assert.Equal(MathS.NaN, + input.ToEntity().Limit("x", destination.ToEntity()).Evaled); /// /// A rational with more digits than the precision, a third away from an integer on either