From e46976281d3299aacf5a085a825ef103c5942ee7 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 14:30:30 +0000 Subject: [PATCH 1/2] Compare exact values in IComparable so sorting is a total order [major] CompareTo(SignificantNumber) and CompareTo(object) rounded both values to the lower of their significant digit counts. That is not transitive (1.23 == 1.2 and 1.2 == 1.17, but 1.23 > 1.17), so Array.Sort, OrderBy and SortedSet produced a wrong order, and CompareTo disagreed with ==, <, > and Equals. Both interface implementations now compare exact values. The significance-aware comparison is kept as the static CompareAtSignificance(left, right), which replaces the static CompareTo(left, right). This is a breaking behaviour and API change. Fixes #105 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015ef5k7pqkftQmMd6x5w9xM --- README.md | 12 +++- .../SignificantNumberTests.cs | 6 +- .../SignificantNumberValueTypeTests.cs | 64 +++++++++++++++++++ SignificantNumber/SignificantNumber.cs | 25 ++++++-- 4 files changed, 95 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 9a27d0c..db44957 100644 --- a/README.md +++ b/README.md @@ -168,7 +168,14 @@ bool isGreater = number1 > number2; bool isLessOrEqual = number1 <= number2; ``` -Ordering operators compare values exactly. `SignificantNumber.CompareTo(left, right)` and `CompareTo(SignificantNumber)` compare both numbers at the lower of their significant digit counts. +Ordering operators, `Equals` and `CompareTo` compare values exactly, so `Array.Sort`, `OrderBy`, `SortedSet` and `Comparer.Default` all see one consistent order. + +To ask whether two measurements agree to the precision they share, use `SignificantNumber.CompareAtSignificance(left, right)`. It compares both numbers at the lower of their significant digit counts, so `1.23` and `1.2` compare equal. That comparison is not transitive (`1.2` also equals `1.17`, but `1.23` does not), so don't sort with it. + +```csharp +int exact = number1.CompareTo(number2); +int atSharedPrecision = SignificantNumber.CompareAtSignificance(number1, number2); +``` ### Formatting and parsing @@ -306,7 +313,7 @@ Consider the number `123.456000`: - `bool Equals(SignificantNumber other)` - Determines whether two numbers have the same significand and exponent. - `int CompareTo(object? obj)` - Compares the current instance with another object. -- `int CompareTo(SignificantNumber other)` - Compares the current instance with another significant number at the lower of their significant digit counts. +- `int CompareTo(SignificantNumber other)` - Compares the value of the current instance with another significant number exactly. - `int CompareTo(TInput other) where TInput : INumber` - Compares the value of the current instance with another number. - `PreciseNumber Abs()` - Returns the absolute value of the current instance. - `PreciseNumber Round(int decimalDigits)` - Rounds the current instance to the specified number of decimal digits. @@ -324,6 +331,7 @@ Consider the number `123.456000`: - `static SignificantNumber Add`, `Subtract`, `Multiply`, `Divide`, and `Mod(PreciseNumber left, PreciseNumber right)` - Apply the significant figure rules to two numbers. - `static SignificantNumber Exp(PreciseNumber power)` - Raises e to a power. - `static SignificantNumber Max`, `Min(SignificantNumber x, SignificantNumber y)`, and `Clamp(SignificantNumber value, SignificantNumber min, SignificantNumber max)` - Compare by value. +- `static int CompareAtSignificance(PreciseNumber left, PreciseNumber right)` - Compares two numbers at the lower of their significant digit counts. Not a total order, so not for sorting. - `static SignificantNumber Round(SignificantNumber value, int decimalDigits)` - Rounds a number to the specified number of decimal digits. - `static SignificantNumber Abs(SignificantNumber value)` - Returns the absolute value of a `SignificantNumber`. - `static bool IsCanonical(SignificantNumber value)` - Determines whether the specified value is canonical. diff --git a/SignificantNumber.Test/SignificantNumberTests.cs b/SignificantNumber.Test/SignificantNumberTests.cs index 4cebeee..f21ed8d 100644 --- a/SignificantNumber.Test/SignificantNumberTests.cs +++ b/SignificantNumber.Test/SignificantNumberTests.cs @@ -178,13 +178,13 @@ public void NotEqual_TwoNumbers_ReturnsTrue() } [TestMethod] - public void CompareTo_TwoNumbers_ReturnsCorrectComparison() + public void CompareAtSignificance_TwoNumbers_ReturnsCorrectComparison() { SignificantNumber left = SignificantNumber.CreateFromComponents(1, new BigInteger(10)); SignificantNumber right = SignificantNumber.CreateFromComponents(1, new BigInteger(5)); - int result = SignificantNumber.CompareTo(left, right); + int result = SignificantNumber.CompareAtSignificance(left, right); - Assert.IsGreaterThan(0, result, "CompareTo should return a positive value when left (10) is greater than right (5)"); + Assert.IsGreaterThan(0, result, "CompareAtSignificance should return a positive value when left (10) is greater than right (5)"); } [TestMethod] diff --git a/SignificantNumber.Test/SignificantNumberValueTypeTests.cs b/SignificantNumber.Test/SignificantNumberValueTypeTests.cs index 65ea469..e11c482 100644 --- a/SignificantNumber.Test/SignificantNumberValueTypeTests.cs +++ b/SignificantNumber.Test/SignificantNumberValueTypeTests.cs @@ -231,4 +231,68 @@ public void ToSignificantNumberWithDigits_FromSignificantNumber_ReducesSignifica Assert.AreEqual(Parse("123"), result); } + + // IComparable is what Array.Sort, OrderBy, SortedSet and Comparer.Default use, so it has to be a + // consistent total order. It used to compare at the lower of the two significant digit counts, under + // which 1.23 == 1.2 and 1.2 == 1.17 but 1.23 > 1.17, so sorting gave a wrong order. + + [TestMethod] + public void ArraySort_OrdersByExactValue() + { + SignificantNumber[] values = [Parse("1.17"), Parse("1.23"), Parse("1.2"), Parse("1.21"), Parse("1.19")]; + SignificantNumber[] expected = [Parse("1.17"), Parse("1.19"), Parse("1.2"), Parse("1.21"), Parse("1.23")]; + + Array.Sort(values); + + CollectionAssert.AreEqual(expected, values); + CollectionAssert.AreEqual(expected, values.Reverse().OrderBy(x => x).ToArray()); + } + + [TestMethod] + public void SortedSet_KeepsValuesThatDifferOnlyBelowTheSharedSignificance() + { + SortedSet set = [Parse("1.23"), Parse("1.2"), Parse("1.17")]; + + Assert.HasCount(3, set); + Assert.IsTrue(set.Contains(Parse("1.2"))); + } + + [TestMethod] + public void CompareTo_AgreesWithEqualityAndOrderingOperators() + { + SignificantNumber[] values = [Parse("1.17"), Parse("1.19"), Parse("1.2"), Parse("1.21"), Parse("1.23"), Parse("1.20"), Parse("-3"), Parse("0"), Parse("1200")]; + + foreach (SignificantNumber left in values) + { + foreach (SignificantNumber right in values) + { + int comparison = left.CompareTo(right); + Assert.AreEqual(left == right, comparison == 0, $"{left}.CompareTo({right}) == 0"); + Assert.AreEqual(left < right, comparison < 0, $"{left}.CompareTo({right}) < 0"); + Assert.AreEqual(left > right, comparison > 0, $"{left}.CompareTo({right}) > 0"); + Assert.AreEqual(int.Sign(comparison), int.Sign(left.CompareTo((object)right)), $"{left}.CompareTo((object){right})"); + Assert.AreEqual(int.Sign(comparison), int.Sign(left.CompareTo(right.Value)), $"{left}.CompareTo({right}.Value)"); + } + } + } + + [TestMethod] + public void CompareToObject_BoxedPreciseNumber_ComparesExactValue() + { + object precise = PreciseNumber.Parse("1.2", CultureInfo.InvariantCulture); + + Assert.IsGreaterThan(0, Parse("1.23").CompareTo(precise)); + } + + [TestMethod] + [DataRow("1.23", "1.2", 0)] + [DataRow("1.2", "1.17", 0)] + [DataRow("1.23", "1.17", 1)] + [DataRow("1.17", "1.23", -1)] + [DataRow("1200", "1234", 0)] + [DataRow("1.5", "1.4", 1)] + public void CompareAtSignificance_ComparesAtTheLowerSignificantDigitCount(string left, string right, int expected) + { + Assert.AreEqual(expected, int.Sign(SignificantNumber.CompareAtSignificance(Parse(left), Parse(right)))); + } } diff --git a/SignificantNumber/SignificantNumber.cs b/SignificantNumber/SignificantNumber.cs index 9191bf9..c90ff9e 100644 --- a/SignificantNumber/SignificantNumber.cs +++ b/SignificantNumber/SignificantNumber.cs @@ -364,6 +364,11 @@ public static bool NotEqual(PreciseNumber left, PreciseNumber right) => /// /// Compares two numbers at the lower of their significant digit counts. /// + /// + /// This is not a total order: 1.23 and 1.2 compare equal, as do 1.2 and 1.17, but + /// 1.23 is greater than 1.17. Use it to ask whether two measurements agree to the precision they + /// share, and use to sort. + /// /// The first number to compare. /// The second number to compare. /// @@ -380,7 +385,7 @@ public static bool NotEqual(PreciseNumber left, PreciseNumber right) => /// /// /// - public static int CompareTo(PreciseNumber left, PreciseNumber right) + public static int CompareAtSignificance(PreciseNumber left, PreciseNumber right) { int lowestSignificantDigits = LowestSignificantDigits(left, right); return left.ReduceSignificance(lowestSignificantDigits).CompareTo(right.ReduceSignificance(lowestSignificantDigits)); @@ -787,7 +792,7 @@ private static PreciseNumber ExactIntegerPower(PreciseNumber value, BigInteger e } /// - /// Compares the current instance with another at the lower of their significant digit counts. + /// Compares the value of the current instance with another . /// /// The to compare with the current instance. /// @@ -804,8 +809,14 @@ private static PreciseNumber ExactIntegerPower(PreciseNumber value, BigInteger e /// /// /// + /// + /// The values are compared exactly, without applying significant figure rules, so the order agrees with + /// ==, < and > and is a consistent total order for sorting. Use + /// to compare at the lower of the two + /// significant digit counts. + /// public int CompareTo(SignificantNumber other) => - CompareTo(this, other); + Value.CompareTo(other.Value); /// /// Compares the current instance with an object. @@ -816,15 +827,15 @@ public int CompareTo(SignificantNumber other) => /// object sorts before every number. /// /// - /// A or is compared at the lower of the two significant - /// digit counts. Any other object is passed to . + /// A or is compared by exact value, the same as + /// . Any other object is passed to . /// public int CompareTo(object? obj) => obj switch { null => 1, - SignificantNumber significantNumber => CompareTo(significantNumber), - PreciseNumber preciseNumber => CompareTo(this, preciseNumber), + SignificantNumber significantNumber => Value.CompareTo(significantNumber.Value), + PreciseNumber preciseNumber => Value.CompareTo(preciseNumber), _ => Value.CompareTo(obj), }; From 54db98b5d97b65f5dad16c07f039c43a1c9a6364 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 14:43:15 +0000 Subject: [PATCH 2/2] Use Assert.AreSequenceEqual and Assert.Contains in the new comparison tests Addresses the MSTEST0068 and MSTEST0037 analyzer findings Sonar reported. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015ef5k7pqkftQmMd6x5w9xM --- SignificantNumber.Test/SignificantNumberValueTypeTests.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/SignificantNumber.Test/SignificantNumberValueTypeTests.cs b/SignificantNumber.Test/SignificantNumberValueTypeTests.cs index e11c482..c4a6b00 100644 --- a/SignificantNumber.Test/SignificantNumberValueTypeTests.cs +++ b/SignificantNumber.Test/SignificantNumberValueTypeTests.cs @@ -244,8 +244,8 @@ public void ArraySort_OrdersByExactValue() Array.Sort(values); - CollectionAssert.AreEqual(expected, values); - CollectionAssert.AreEqual(expected, values.Reverse().OrderBy(x => x).ToArray()); + Assert.AreSequenceEqual(expected, values); + Assert.AreSequenceEqual(expected, values.Reverse().OrderBy(x => x)); } [TestMethod] @@ -254,7 +254,7 @@ public void SortedSet_KeepsValuesThatDifferOnlyBelowTheSharedSignificance() SortedSet set = [Parse("1.23"), Parse("1.2"), Parse("1.17")]; Assert.HasCount(3, set); - Assert.IsTrue(set.Contains(Parse("1.2"))); + Assert.Contains(Parse("1.2"), set); } [TestMethod]