diff --git a/BREAKING-CHANGES.md b/BREAKING-CHANGES.md index 8df4bc356..1d512c595 100644 --- a/BREAKING-CHANGES.md +++ b/BREAKING-CHANGES.md @@ -24,6 +24,33 @@ read first. | | `(Entity.Number)someBigInteger` | `FormatException: Illegal character found`, for almost every value | the number | | **Silent** | `"1/x".Integrate("x")`, and every antiderivative with a logarithm | `ln(abs(x)) + C` | `ln(x) + C` | | **Silent** | `CostModel.FewestDivisions.Cost(y ^ (1 * (-1)) * x)` | `0.007`, cheaper than `x / y` | `1.007` | +| **Silent** | `Real.NaN > (Real)1`, and the other three operators | `true` | `false` | + +### `Real`'s comparison operators refuse `NaN` instead of ordering it + +`Real`'s `>`, `>=`, `<` and `<=` ordered `NaN` above every number, so `NaN > 1` was `true` and +`1 < NaN` was `true` as well. That is `EDecimal`'s total order showing through, and it is not what +`double` does, where every comparison against `NaN` is false in both directions. + +```csharp +Real one = 1; +Real.NaN > one // was: true now: false +Real.NaN >= one // was: true now: false +one < Real.NaN // was: true now: false +Real.NaN <= one // was: false now: false +``` + +The failure it caused is one-sided and unsafe: a guard written as `if (value > threshold)` treated +an undefined value as *exceeding* the threshold. Of the two possible defaults that is the worse one, +and this library's stated position is that answering wrongly is worse than not answering. + +**`CompareTo` is unchanged and still orders `NaN` above every number.** That is deliberate, not an +inconsistency left behind: sorting requires a total order — `Array.Sort` may loop or throw without +one — while an operator does not. "Where does this sort" and "is this greater" are different +questions, and only the second has no answer for a value that is not a number. Anything relying on +the old operator behaviour to sort should call `CompareTo`, which never changed. + +[#947](https://github.com/asc-community/AngouriMath/issues/947). ### `FewestDivisions` and `FewestRadicals` score by value, not by spelling diff --git a/Sources/AngouriMath/Core/Entity/Continuous/Entity.Continuous.Real.Definition.cs b/Sources/AngouriMath/Core/Entity/Continuous/Entity.Continuous.Real.Definition.cs index 58a99a734..4a42657af 100644 --- a/Sources/AngouriMath/Core/Entity/Continuous/Entity.Continuous.Real.Definition.cs +++ b/Sources/AngouriMath/Core/Entity/Continuous/Entity.Continuous.Real.Definition.cs @@ -113,34 +113,62 @@ internal static bool TryParse(string s, // Comparison here is on the value and answers a bool, unlike the operators on // Entity, which build an inequality node to be solved or evaluated later. // - // These order NaN rather than refusing to: NaN sorts above every number, so - // NaN > 1 and NaN >= 1 are both true and 1 < NaN is true as well. That is a - // total order and it is not what double does, where every comparison against - // NaN is false. Measured at +1, -1 and NaN rather than assumed, because the - // IEEE habit is the one a reader arrives with. + // The four operators refuse NaN: every one of them is false when either side is + // NaN, as with double. CompareTo below deliberately does not, because sorting + // needs a total order and an operator does not. See + // https://github.com/asc-community/AngouriMath/issues/947 -- these used to order + // NaN above every number, so `NaN > 1` was true and a guard reading + // `if (value > threshold)` treated an undefined value as exceeding the threshold, + // which is the least safe of the two available defaults. /// - /// Whether the first is strictly greater. counts as greater - /// than every number, so NaN > x holds for any real x. + /// Whether the first is strictly greater, and where + /// either is . /// - public static bool operator >(Real a, Real b) => a.EDecimal.GreaterThan(b.EDecimal); + /// + /// A comparison against is false in both directions, as with + /// : NaN > x and x > NaN are both + /// false, and so are the other three operators. "Not a number" is not a position + /// in the order, so asking where it sits has no true answer to give. + /// answers a different question and still orders it. + /// + public static bool operator >(Real a, Real b) + => !a.IsNaN && !b.IsNaN && a.EDecimal.GreaterThan(b.EDecimal); - /// Whether the first is greater or they are equal. - public static bool operator >=(Real a, Real b) => a.EDecimal.GreaterThanOrEquals(b.EDecimal); + /// + /// Whether the first is greater or they are equal, and + /// where either is -- including NaN >= NaN. + /// + public static bool operator >=(Real a, Real b) + => !a.IsNaN && !b.IsNaN && a.EDecimal.GreaterThanOrEquals(b.EDecimal); /// - /// Whether the first is strictly less. is less than nothing, - /// and every number is less than it. + /// Whether the first is strictly less, and where either + /// is . See . /// - public static bool operator <(Real a, Real b) => a.EDecimal.LessThan(b.EDecimal); + public static bool operator <(Real a, Real b) + => !a.IsNaN && !b.IsNaN && a.EDecimal.LessThan(b.EDecimal); - /// Whether the first is less or they are equal. - public static bool operator <=(Real a, Real b) => a.EDecimal.LessThanOrEquals(b.EDecimal); + /// + /// Whether the first is less or they are equal, and where + /// either is -- including NaN <= NaN. + /// + public static bool operator <=(Real a, Real b) + => !a.IsNaN && !b.IsNaN && a.EDecimal.LessThanOrEquals(b.EDecimal); /// /// Negative, zero or positive as this is less than, equal to or greater than /// , which is what sorting wants. /// + /// + /// This orders and the operators do not, which is + /// deliberate rather than an inconsistency left behind. Sorting requires a total + /// order -- may loop or throw + /// without one -- so has to sort somewhere, and it sorts above + /// every number. "Where does this sort" and "is this greater" are different + /// questions, and only the second has no answer for a value that is not a number. + /// See https://github.com/asc-community/AngouriMath/issues/947. + /// /// /// Thrown where is . Unlike /// 's usual contract, null does not sort first diff --git a/Sources/Tests/UnitTests/Core/RealComparisonTest.cs b/Sources/Tests/UnitTests/Core/RealComparisonTest.cs index afb1656b8..1d01c85ba 100644 --- a/Sources/Tests/UnitTests/Core/RealComparisonTest.cs +++ b/Sources/Tests/UnitTests/Core/RealComparisonTest.cs @@ -1,4 +1,4 @@ -// +// // Copyright (c) 2019-2026 Angouri. // AngouriMath is licensed under MIT. // Details: https://github.com/asc-community/AngouriMath/blob/master/LICENSE.md. @@ -12,32 +12,77 @@ namespace AngouriMath.Tests.Core { /// - /// How 's comparison operators order , which is not - /// how does and is now written into their documentation. + /// How 's comparisons treat : the operators refuse + /// it, orders it, and the split is the point. /// /// - /// Pinned because it is surprising and because the documentation asserts it. A reader - /// arrives with the IEEE habit, where every comparison against NaN is false; here the - /// comparison is a total order and NaN sits at the top of it. + /// https://github.com/asc-community/AngouriMath/issues/947. Both halves are pinned because + /// each is surprising on its own and only makes sense beside the other. /// [Trait("Area", "Core")] public sealed class RealComparisonTest { + /// Every operator is false against NaN, in both directions, as with double. [Theory] [InlineData(1)] [InlineData(-1)] [InlineData(0)] [InlineData(100000)] - public void NaNIsAboveEveryNumber(int number) + public void NoOperatorHoldsAgainstNaN(int number) { Real value = number; - Assert.True(Real.NaN > value); - Assert.True(Real.NaN >= value); + Assert.False(Real.NaN > value); + Assert.False(Real.NaN >= value); Assert.False(Real.NaN < value); Assert.False(Real.NaN <= value); - Assert.True(value < Real.NaN); Assert.False(value > Real.NaN); + Assert.False(value >= Real.NaN); + Assert.False(value < Real.NaN); + Assert.False(value <= Real.NaN); + } + + /// + /// Including against itself: NaN is not greater than, less than, or equal-or-either to + /// NaN. This is the row a reader is most likely to get wrong from habit. + /// + [Fact] + public void NorAgainstItself() + { + Assert.False(Real.NaN > Real.NaN); + Assert.False(Real.NaN >= Real.NaN); + Assert.False(Real.NaN < Real.NaN); + Assert.False(Real.NaN <= Real.NaN); + } + + /// + /// The guard that motivated the change: an undefined value must not read as exceeding a + /// threshold, which is the least safe way for it to fail. + /// + [Fact] + public void AnUndefinedValueDoesNotExceedAThreshold() + { + Real one = 1, zero = 0; + var undefined = one / zero; + Assert.True(undefined.IsNaN); + Assert.False(undefined > (Real)100); + } + + /// + /// CompareTo still orders NaN, because sorting needs a total order and would otherwise + /// loop or throw. This is the half that did *not* change. + /// + [Fact] + public void CompareToStillTotallyOrdersSoSortingWorks() + { + Real one = 1, two = 2; + Assert.True(Real.NaN.CompareTo(one) > 0); + Assert.True(one.CompareTo(Real.NaN) < 0); + Assert.Equal(0, Real.NaN.CompareTo(Real.NaN)); + + var values = new[] { two, Real.NaN, one }; + System.Array.Sort(values); + Assert.Equal(new[] { one, two, Real.NaN }, values); } /// And the ordinary order is the ordinary one.