Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ dotnet run -c Release --project PreciseNumber.Benchmarks -- --filter '*' --job s
- The `sanitize` constructor parameter controls whether trailing zeros are removed (default: true)
- Constants (`Zero`, `One`, `Pi`, `E`, `Tau`) are pre-computed static instances
- As a value type it can't be null or inherited. Don't add null checks for `PreciseNumber` parameters, and don't reintroduce `protected` members
- Conversions to integer types go through `BigInteger`, so range checks, clamping, and wrapping follow its conventions. Conversions to `double`, `float`, `Half`, and `decimal` render `significand E exponent` and parse it, because the runtime parsers round correctly, with Clinger's fast path for small values. NaN and infinity coming in follow `BigInteger` too
- Conversions to integer types go through `BigInteger`, so range checks, clamping, and wrapping follow its conventions. Conversions to `double`, `float`, `Half`, and `decimal` render normalized scientific notation (`d.ddd…E±n`) and parse it, because the runtime parsers round correctly, with Clinger's fast path for small values. Keep one digit before the point. The .NET 7 and 8 parsers clamp an exponent above 1000 and still offset it by every digit ahead of the point, so a long significand rendered as an integer parses as zero there. Conversions from `double`, `float`, and `Half` use the shortest text that round-trips (`"R"`). NaN and infinity coming in follow `BigInteger` too

### Test Structure

Expand All @@ -58,8 +58,10 @@ Most classes are parameterised by `Digits` (8, 30, 200). That axis is the point:

Allocation is reported alongside time and matters just as much. The number is a value type, so the
only allocations are `BigInteger` digit arrays, and avoiding an intermediate shows up in `Allocated`
before it shows up in `Mean`. Comparisons, and addition, subtraction, and multiplication of
significands that fit in an `int`, should allocate nothing at all.
before it shows up in `Mean`. Comparison, addition, subtraction, and multiplication should allocate
nothing when the operands and every intermediate and final significand fit in an `int`. Exponent
alignment counts, so `1 + 0.0000000001` allocates because it scales 1 by 10^10, and `99999 * 99999`
allocates because its product is 9,999,800,001.

Run the relevant benchmarks before and after any change to the library's internals. See
`PreciseNumber.Benchmarks/README.md` for details.
85 changes: 84 additions & 1 deletion PreciseNumber.Test/PreciseNumberConversionTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -45,8 +45,11 @@ private static void AssertFromInAllModes<TFrom>(TFrom value, string expected)

private static void AssertToInAllModes<TTo>(string value, TTo expected)
where TTo : INumberBase<TTo>
=> AssertToInAllModes(P(value), expected);

private static void AssertToInAllModes<TTo>(PreciseNumber number, TTo expected)
where TTo : INumberBase<TTo>
{
PreciseNumber number = P(value);
Assert.AreEqual(expected, Checked<TTo, PreciseNumber>(number), $"Checked to {typeof(TTo).Name}");
Assert.AreEqual(expected, Saturating<TTo, PreciseNumber>(number), $"Saturating to {typeof(TTo).Name}");
Assert.AreEqual(expected, Truncating<TTo, PreciseNumber>(number), $"Truncating to {typeof(TTo).Name}");
Expand Down Expand Up @@ -295,4 +298,84 @@ public void ToUsesTheSameConversions()
double.Parse("3.14159265358979323846264338327950288419716939937510582097494", NumberStyles.Float, CultureInfo.InvariantCulture),
P("3.14159265358979323846264338327950288419716939937510582097494").To<double>());
}

[TestMethod]
public void FromBinaryFloatingPointKeepsShortestRoundTripDigits()
{
AssertFromInAllModes(double.MaxValue, "1.7976931348623157e308");
AssertFromInAllModes(double.MinValue, "-1.7976931348623157e308");
AssertFromInAllModes(0.1 + 0.2, "0.30000000000000004");
AssertFromInAllModes(float.MaxValue, "3.4028235e38");
AssertFromInAllModes(1.0f / 3, "0.33333334");
AssertFromInAllModes(0.3048, "0.3048");
AssertFromInAllModes(0.1f, "0.1");
}

[TestMethod]
public void BinaryFloatingPointRoundTripsThroughPreciseNumber()
{
foreach (double value in new[] { double.MaxValue, double.MinValue, double.Epsilon, -double.Epsilon, 2.2250738585072014e-308, 0.1 + 0.2, 1.0 / 3 })
{
Assert.AreEqual(value, Checked<double, PreciseNumber>(Checked<PreciseNumber, double>(value)), value.ToString("R", CultureInfo.InvariantCulture));
}

foreach (float value in new[] { float.MaxValue, float.MinValue, float.Epsilon, 1.0f / 3, 16777216f, 0.1f })
{
Assert.AreEqual(value, Checked<float, PreciseNumber>(Checked<PreciseNumber, float>(value)), value.ToString("R", CultureInfo.InvariantCulture));
}

foreach (Half value in new[] { Half.MaxValue, Half.MinValue, Half.Epsilon, (Half)0.1 })
{
Assert.AreEqual(value, Checked<Half, PreciseNumber>(Checked<PreciseNumber, Half>(value)), value.ToString(CultureInfo.InvariantCulture));
}
}

[TestMethod]
public void ToFloatingPointWithOverAThousandFractionDigitsIsCorrectlyRounded()
{
// The .NET 7 and 8 parsers clamp an exponent above 1000 while still counting every digit before
// the 'E', so rendering the whole significand ahead of the exponent turned these values into zero.
string oneWithLongTail = "1." + new string('0', 1000) + "1";
AssertToInAllModes(oneWithLongTail, 1.0);
AssertToInAllModes(oneWithLongTail, 1.0f);
AssertToInAllModes(oneWithLongTail, Half.One);
AssertToInAllModes(oneWithLongTail, 1m);
AssertToInAllModes("0.1" + new string('0', 1100) + "1", 0.1m);

// Halfway cases round to even, so the nonzero digit far past the halfway point has to be seen to round up.
AssertToInAllModes("9007199254740993." + new string('0', 1500) + "1", 9007199254740994.0);
AssertToInAllModes("16777217." + new string('0', 1200) + "1", 16777218f);

string manyDigits = "1." + string.Concat(Enumerable.Repeat("2345678901", 150));
string smallWithManyDigits = "0." + new string('0', 300) + "1" + string.Concat(Enumerable.Repeat("2345678901", 120));
foreach (string text in new[] { manyDigits, "-" + manyDigits, smallWithManyDigits })
{
AssertToInAllModes(text, double.Parse(text, NumberStyles.Float, CultureInfo.InvariantCulture));
AssertToInAllModes(text, float.Parse(text, NumberStyles.Float, CultureInfo.InvariantCulture));
}
}

[TestMethod]
public void SmallestExponentConvertsToZero()
{
PreciseNumber[] values =
[
P("1E-2147483648"),
PreciseNumber.CreateFromComponents(int.MinValue, BigInteger.Parse("-123456789012345678901234567890", CultureInfo.InvariantCulture)),
];

foreach (PreciseNumber value in values)
{
AssertToInAllModes(value, 0.0);
AssertToInAllModes(value, 0.0f);
AssertToInAllModes(value, Half.Zero);
AssertToInAllModes(value, 0m);
AssertToInAllModes(value, 0);
AssertToInAllModes(value, 0L);
AssertToInAllModes(value, (byte)0);
AssertToInAllModes(value, Int128.Zero);
AssertToInAllModes(value, UInt128.Zero);
AssertToInAllModes(value, BigInteger.Zero);
}
}
}
91 changes: 86 additions & 5 deletions PreciseNumber.Test/PreciseNumberTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -403,7 +403,9 @@ public void TestStaticRound()
{
PreciseNumber number = 1.2345.ToPreciseNumber();
PreciseNumber result = PreciseNumber.Round(number, 2);
Assert.AreEqual(1.24.ToPreciseNumber(), result);

// The dropped digits are 45, below half of 100, so this rounds down.
Assert.AreEqual(1.23.ToPreciseNumber(), result);
}

[TestMethod]
Expand Down Expand Up @@ -963,11 +965,74 @@ public void TestReduceSignificance()
{
PreciseNumber number = PreciseNumber.CreateFromComponents(0, 12345);
PreciseNumber result = number.ReduceSignificance(3);
Assert.AreEqual(124, result.Significand);

// The dropped digits are 45, below half of 100, so this rounds down.
Assert.AreEqual(123, result.Significand);
Assert.AreEqual(2, result.Exponent);
Assert.AreEqual(number, number.ReduceSignificance(5));
}

[TestMethod]
public void TestReduceSignificanceRoundsHalfAwayFromZeroOnTheDroppedDigits()
{
static PreciseNumber P(string text) => PreciseNumber.Parse(text, CultureInfo.InvariantCulture);

(string Input, int Digits, string Expected)[] cases =
[
("123.456", 3, "123"),
("123.5", 3, "124"),
("123.449", 3, "123"),
("123.4999999", 3, "123"),
("123.5000001", 3, "124"),
("999.5", 3, "1000"),
("-123.456", 3, "-123"),
("-123.5", 3, "-124"),
("-123.449", 3, "-123"),
("-999.96", 3, "-1000"),

// One dropped digit, where the old rounding was already right.
("123.45", 4, "123.5"),
("123.44", 4, "123.4"),
("-123.45", 4, "-123.5"),
];

foreach ((string input, int digits, string expected) in cases)
{
Assert.AreEqual(P(expected), P(input).ReduceSignificance(digits), $"{input} to {digits} significant digits");
}
}

[TestMethod]
public void TestRoundRoundsHalfAwayFromZeroOnTheDroppedDigits()
{
static PreciseNumber P(string text) => PreciseNumber.Parse(text, CultureInfo.InvariantCulture);

(string Input, int Decimals, string Expected)[] cases =
[
("1.2345", 2, "1.23"),
("1.235", 2, "1.24"),
("1.2349", 2, "1.23"),
("1.2349999", 2, "1.23"),
("1.2350001", 2, "1.24"),
("9.995", 2, "10"),
("0.0049", 2, "0"),
("0.005", 2, "0.01"),
("-1.2345", 2, "-1.23"),
("-1.235", 2, "-1.24"),
("-1.2349", 2, "-1.23"),

// One dropped digit, where the old rounding was already right.
("1.234", 2, "1.23"),
("1.225", 2, "1.23"),
("-1.225", 2, "-1.23"),
];

foreach ((string input, int decimals, string expected) in cases)
{
Assert.AreEqual(P(expected), P(input).Round(decimals), $"{input} to {decimals} decimal places");
}
}

[TestMethod]
public void TestMakeCommonizedAndGetExponent()
{
Expand Down Expand Up @@ -1606,11 +1671,12 @@ public void TestExpWithNegativePower()
{
PreciseNumber result = PreciseNumber.Exp(-1.ToPreciseNumber());

// Exp routes through a double, so 1/e is taken to the precision Exp actually delivers
// rather than the full precision Divide is now capable of.
PreciseNumber expected = PreciseNumber.Divide(PreciseNumber.One, PreciseNumber.E, result.SignificantDigits);
// Exp routes through a double, and the result keeps every digit that double needs to round-trip.
// Its 17th digit comes from binary rounding, so it's 3 where 1/e continues 0.36787944117144232159.
PreciseNumber expected = PreciseNumber.Parse("0.36787944117144233", CultureInfo.InvariantCulture);

Assert.AreEqual(expected, result);
Assert.AreEqual(Math.Exp(-1), result.To<double>());
}

[TestMethod]
Expand Down Expand Up @@ -1800,6 +1866,21 @@ public void TestTryParseStringWithInvalidInput()
Assert.AreEqual(default, result);
}

[TestMethod]
public void TestParseRejectsExponentsOutsideIntRange()
{
foreach (string input in new[] { "1e99999999999", "1e-99999999999", "1.5E-2147483648", "10E2147483647" })
{
Assert.ThrowsExactly<OverflowException>(() => PreciseNumber.Parse(input, CultureInfo.InvariantCulture), input);
bool success = PreciseNumber.TryParse(input, null, out PreciseNumber result);
Assert.IsFalse(success, input);
Assert.AreEqual(PreciseNumber.Zero, result, input);
}

Assert.AreEqual(int.MinValue, PreciseNumber.Parse("1E-2147483648", CultureInfo.InvariantCulture).Exponent);
Assert.AreEqual(int.MaxValue, PreciseNumber.Parse("1E2147483647", CultureInfo.InvariantCulture).Exponent);
}

[TestMethod]
public void TestEValue()
{
Expand Down
48 changes: 37 additions & 11 deletions PreciseNumber/PreciseNumber.Conversions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -85,8 +85,9 @@
/// <remarks>
/// Integers, <see cref="BigInteger"/> and <see cref="decimal"/> convert exactly. Binary floating point
/// values convert through their decimal text, so <c>0.3048</c> becomes exactly 0.3048 rather than the
/// binary fraction nearest to it. A <see cref="double"/> keeps 16 significant digits and a
/// <see cref="float"/> 8, which is the same rounding <see cref="PreciseNumberExtensions.ToPreciseNumber{TInput}(TInput)"/> applies.
/// binary fraction nearest to it. The text is the shortest that round-trips, so converting the result back
/// gives the original value, even for <see cref="double.MaxValue"/>. It's the same text
/// <see cref="PreciseNumberExtensions.ToPreciseNumber{TInput}(TInput)"/> uses.
/// </remarks>
public static bool TryConvertFromChecked<TOther>(TOther value, out PreciseNumber result)
where TOther : INumberBase<TOther>
Expand Down Expand Up @@ -382,8 +383,9 @@
return Significand * Pow10(Exponent);
}

// Every digit sits after the decimal point, so the integral part is zero.
return -Exponent >= SignificantDigits
// Every digit sits after the decimal point, so the integral part is zero. Widened, because
// negating int.MinValue wraps.
return -(long)Exponent >= SignificantDigits
? BigInteger.Zero
: BigInteger.Divide(Significand, Pow10(-Exponent));
}
Expand All @@ -392,7 +394,8 @@
{
// Clinger's fast path: when the significand and the power of ten are both exact doubles, one
// multiplication or division is correctly rounded by IEEE 754 itself.
if (int.Abs(Exponent) <= MaxExactDoublePowerOfTen && BigInteger.Abs(Significand) <= MaxExactDoubleSignificand)
// A range pattern rather than int.Abs, which throws for int.MinValue.
if (Exponent is >= -MaxExactDoublePowerOfTen and <= MaxExactDoublePowerOfTen && BigInteger.Abs(Significand) <= MaxExactDoubleSignificand)
{
double significand = (double)Significand;
return Exponent >= 0
Expand All @@ -405,7 +408,7 @@

private float ToSingle()
{
if (int.Abs(Exponent) <= MaxExactSinglePowerOfTen && BigInteger.Abs(Significand) <= MaxExactSingleSignificand)
if (Exponent is >= -MaxExactSinglePowerOfTen and <= MaxExactSinglePowerOfTen && BigInteger.Abs(Significand) <= MaxExactSingleSignificand)
{
float significand = (float)Significand;
return Exponent >= 0
Expand Down Expand Up @@ -440,21 +443,34 @@

return mode == ConversionMode.Checked
? throw new OverflowException("Value was either too large or too small for a Decimal.")
: Significand.Sign < 0 ? decimal.MinValue : decimal.MaxValue;

Check warning on line 446 in PreciseNumber/PreciseNumber.Conversions.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Extract this nested ternary operation into an independent statement.

Check warning on line 446 in PreciseNumber/PreciseNumber.Conversions.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Extract this nested ternary operation into an independent statement.

Check warning on line 446 in PreciseNumber/PreciseNumber.Conversions.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Extract this nested ternary operation into an independent statement.

Check warning on line 446 in PreciseNumber/PreciseNumber.Conversions.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Extract this nested ternary operation into an independent statement.

Check warning on line 446 in PreciseNumber/PreciseNumber.Conversions.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Extract this nested ternary operation into an independent statement.

Check warning on line 446 in PreciseNumber/PreciseNumber.Conversions.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Extract this nested ternary operation into an independent statement.
}

/// <summary>
/// Renders the number as <c>significand E exponent</c> and parses it as <typeparamref name="TNumber"/>.
/// Renders the number in normalized scientific notation, <c>d.ddd…E±n</c>, and parses it as <typeparamref name="TNumber"/>.
/// </summary>
/// <remarks>
/// The runtime's parsers round correctly however many digits they are given, which is what makes this
/// exact where multiplying by <c>Math.Pow(10, exponent)</c> is not.
/// exact where multiplying by <c>Math.Pow(10, exponent)</c> is not. Every digit is rendered, because a
/// value just past a halfway point can differ from it only in a digit far beyond what the destination
/// holds. Only one digit goes before the point because the .NET 7 and 8 parsers clamp an exponent above
/// 1000 to 9999 and still offset it by every digit ahead of the point, so a rendered integer significand
/// with more than 1000 fraction digits parsed as zero. In normalized form, any value within the range of
/// the destination has an exponent well inside that limit.
/// </remarks>
private TNumber ParseAs<TNumber>()
where TNumber : INumberBase<TNumber>
{
// The significand's digits, its sign, the 'E', and an exponent of up to eleven characters.
int length = SignificantDigits + 13;
if (Significand.IsZero)
{
return TNumber.Zero;
}

// Widened, because an exponent near int.MinValue or int.MaxValue would otherwise wrap.
long scientificExponent = (long)Exponent + SignificantDigits - 1;

// The significand's digits, its sign, the decimal point, the 'E', and an exponent of up to twenty characters.
int length = SignificantDigits + 23;
char[]? rented = length > MaxStackAllocChars ? ArrayPool<char>.Shared.Rent(length) : null;
Span<char> stackBuffer = stackalloc char[MaxStackAllocChars];
Span<char> buffer = rented is null ? stackBuffer : rented.AsSpan();
Expand All @@ -466,9 +482,19 @@
throw new InvalidOperationException("The significand did not fit the buffer sized for it.");
}

int leadingDigit = Significand.Sign < 0 ? 1 : 0;
int trailingDigits = written - leadingDigit - 1;
if (trailingDigits > 0)
{
// CopyTo handles the overlap, shifting every digit after the leading one right by one place.
buffer.Slice(leadingDigit + 1, trailingDigits).CopyTo(buffer[(leadingDigit + 2)..]);
buffer[leadingDigit + 1] = '.';
written++;
}

buffer[written++] = 'E';

if (!Exponent.TryFormat(buffer[written..], out int exponentWritten, default, InvariantCulture))
if (!scientificExponent.TryFormat(buffer[written..], out int exponentWritten, default, InvariantCulture))
{
throw new InvalidOperationException("The exponent did not fit the buffer sized for it.");
}
Expand Down
Loading