diff --git a/1-tests/Just.PreciseMath.Tests/DoubleDoubleArithmeticTests.cs b/1-tests/Just.PreciseMath.Tests/DoubleDoubleArithmeticTests.cs index 1fd6b98..b7bec28 100644 --- a/1-tests/Just.PreciseMath.Tests/DoubleDoubleArithmeticTests.cs +++ b/1-tests/Just.PreciseMath.Tests/DoubleDoubleArithmeticTests.cs @@ -182,6 +182,170 @@ public class DoubleDoubleArithmeticTests Check(DoubleDouble.FromComponents(sign * double.MaxValue, halfUlp), sign * double.PositiveInfinity, 0.0); } + [Theory] + [InlineData(-1)] + [InlineData(0)] + [InlineData(1)] + public void ProductAndQuotientCrossTheExactOverflowMidpoint(int side) + { + // M = 2^1024 - 2^970. M/2 is the normalized pair (2^1023, -2^969). + // Its adjacent normalized pairs use different highs across this tie: + // below uses (MaxValue/2, BitDecrement(2^969)), above increments -2^969. + // Doubling gives M +/- 2^917; below has residual BitDecrement(2^970). + // The tie rounds to the even significand at 2^1024, hence infinity. + double high = side < 0 ? Math.ScaleB(double.MaxValue, -1) : Math.ScaleB(1.0, 1023); + double low = side switch + { + -1 => Math.BitDecrement(Math.ScaleB(1.0, 969)), + 1 => Math.BitIncrement(-Math.ScaleB(1.0, 969)), + _ => -Math.ScaleB(1.0, 969) + }; + BigInteger midpointUnits = Units(double.MaxValue) + Units(Math.ScaleB(1.0, 970)); + foreach (double sign in new[] { -1.0, 1.0 }) + { + DoubleDouble value = DoubleDouble.FromComponents(sign * high, sign * low); + (BigInteger.Abs(Units(value)) * 2).ShouldBe(midpointUnits + (side * Units(Math.ScaleB(1.0, 917)))); + double expectedHigh = sign * (side < 0 ? double.MaxValue : double.PositiveInfinity); + double expectedLow = side < 0 ? sign * Math.BitDecrement(Math.ScaleB(1.0, 970)) : 0.0; + CheckBoundary(value * new DoubleDouble(2.0), expectedHigh, expectedLow); + CheckBoundary(new DoubleDouble(2.0) * value, expectedHigh, expectedLow); + CheckBoundary(value * 2.0, expectedHigh, expectedLow); + CheckBoundary(2.0 * value, expectedHigh, expectedLow); + CheckBoundary(value / new DoubleDouble(0.5), expectedHigh, expectedLow); + CheckBoundary(value / 0.5, expectedHigh, expectedLow); + } + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void ScalarLeftDivisionStraddlesTheOverflowMidpoint(bool below) + { + // An exact M = (2^54 - 1)*2^970 quotient is impossible with a finite + // binary64 numerator and dyadic denominator: its odd numerator would + // need all 54 bits. Instead use adjacent low components bracketing + // 2^1023/M = 1/2 + 2^-55 + 2^-109 + ... . + double low = Math.ScaleB(1.0, -55); + DoubleDouble denominator = DoubleDouble.FromComponents(0.5, below ? Math.BitIncrement(low) : low); + BigInteger midpointUnits = Units(double.MaxValue) + Units(Math.ScaleB(1.0, 970)); + BigInteger numeratorUnits = Units(Math.ScaleB(1.0, 1023)); + ((numeratorUnits << 1074) < midpointUnits * Units(denominator)).ShouldBe(below); + // Exact rational residual rounding gives BitDecrement(2^970) below M. + foreach (double sign in new[] { -1.0, 1.0 }) + { + double numerator = sign * Math.ScaleB(1.0, 1023); + double high = sign * (below ? double.MaxValue : double.PositiveInfinity); + double residual = below ? sign * Math.BitDecrement(Math.ScaleB(1.0, 970)) : 0.0; + CheckBoundary(numerator / denominator, high, residual); + CheckBoundary(new DoubleDouble(numerator) / denominator, high, residual); + CheckBoundary((-numerator) / (-denominator), high, residual); + } + } + + [Theory] + [InlineData(-1)] + [InlineData(0)] + [InlineData(1)] + public void ProductAndQuotientRoundUnderflowTiesToSignedZero(int side) + { + // (2^-1022 + side*2^-1074)*2^-53 = epsilon/2 + side*2^-1127. + // The low component is stepped by its smallest possible increment. + // Ties select even zero, retaining the exact nonzero result's sign. + foreach (double sign in new[] { -1.0, 1.0 }) + { + DoubleDouble value = DoubleDouble.FromComponents(sign * Math.ScaleB(1.0, -1022), sign * side * double.Epsilon); + double multiplier = Math.ScaleB(1.0, -53); + double divisor = Math.ScaleB(1.0, 53); + double expected = Math.CopySign(side > 0 ? double.Epsilon : 0.0, sign); + CheckBits(value * new DoubleDouble(multiplier), expected); + CheckBits(new DoubleDouble(multiplier) * value, expected); + CheckBits(value * multiplier, expected); + CheckBits(multiplier * value, expected); + CheckBits(value / new DoubleDouble(divisor), expected); + CheckBits(value / divisor, expected); + + // epsilon/(2 - side*epsilon) brackets the same tie; the nonzero + // denominator residual is essential despite being invisible in double. + DoubleDouble denominator = DoubleDouble.FromComponents(2.0, -side * double.Epsilon); + CheckBits((sign * double.Epsilon) / denominator, expected); + CheckBits(new DoubleDouble(sign * double.Epsilon) / denominator, expected); + CheckBits((-sign * double.Epsilon) / (-denominator), expected); + } + } + + [Theory] + [InlineData(0)] + [InlineData(1)] + [InlineData(2)] + [InlineData(3)] + [InlineData(4)] + [InlineData(5)] + [InlineData(6)] + [InlineData(7)] + [InlineData(8)] + [InlineData(9)] + [InlineData(10)] + [InlineData(11)] + public void WarmedFiniteArithmeticAllocatesSubstantiallyLessThanBoundaryFallback(int operation) + { + DoubleDouble ordinary = DoubleDouble.FromComponents(1.25, Math.ScaleB(1.0, -70)); + DoubleDouble boundary = DoubleDouble.FromComponents(Math.ScaleB(1.0, 1022), Math.ScaleB(1.0, 968)); + DoubleDouble right = DoubleDouble.FromComponents(1.5, Math.ScaleB(1.0, -55)); + // Synchronous per-thread counters exclude other parallel tests. Warm both + // branches, keep setup/assertions outside measurement, and consume results. + _ = MeasureArithmeticAllocations(operation, ordinary, right, 128, out _); + _ = MeasureArithmeticAllocations(operation, boundary, right, 128, out _); + long ordinaryBytes = long.MaxValue; + long boundaryBytes = long.MaxValue; + for (int sample = 0; sample < 3; ++sample) + { + long finite = MeasureArithmeticAllocations(operation, ordinary, right, 256, out double finiteChecksum); + long fallback = MeasureArithmeticAllocations(operation, boundary, right, 256, out double fallbackChecksum); + double.IsFinite(finiteChecksum).ShouldBeTrue(); + double.IsFinite(fallbackChecksum).ShouldBeTrue(); + ordinaryBytes = Math.Min(ordinaryBytes, finite); + boundaryBytes = Math.Min(boundaryBytes, fallback); + } + // A coarse relative distinction, not a runtime-dependent BigInteger byte + // count or timing benchmark. Minima discard incidental warm-up allocation. + boundaryBytes.ShouldBeGreaterThan(0L); + ordinaryBytes.ShouldBeLessThan(boundaryBytes / 16); + } + + private static long MeasureArithmeticAllocations(int operation, DoubleDouble left, DoubleDouble right, int iterations, out double checksum) + { + checksum = 0.0; + long before = GC.GetAllocatedBytesForCurrentThread(); + for (int i = 0; i < iterations; ++i) + { + DoubleDouble result = operation switch + { + 0 => left + right, + 1 => left - right, + 2 => left * right, + 3 => left / right, + 4 => left + right.High, + 5 => right.High + left, + 6 => left - right.High, + 7 => right.High - left, + 8 => left * right.High, + 9 => right.High * left, + 10 => left / right.High, + 11 => right.High / left, + _ => throw new ArgumentOutOfRangeException(nameof(operation)) + }; + // Scale before accumulation so boundary-sized results cannot overflow. + checksum += Math.ScaleB(result.High, -1023) + Math.ScaleB(result.Low, -1023); + } + return GC.GetAllocatedBytesForCurrentThread() - before; + } + + private static void CheckBoundary(DoubleDouble value, double high, double low) + { + BitConverter.DoubleToInt64Bits(value.High).ShouldBe(BitConverter.DoubleToInt64Bits(high)); + BitConverter.DoubleToInt64Bits(value.Low).ShouldBe(BitConverter.DoubleToInt64Bits(low)); + } + [Fact] public void SpecialValueMatrixMatchesBinary64IncludingZeroSigns() { diff --git a/1-tests/Just.PreciseMath.Tests/DoubleDoubleConversionTests.cs b/1-tests/Just.PreciseMath.Tests/DoubleDoubleConversionTests.cs index 5b00d46..3867bcc 100644 --- a/1-tests/Just.PreciseMath.Tests/DoubleDoubleConversionTests.cs +++ b/1-tests/Just.PreciseMath.Tests/DoubleDoubleConversionTests.cs @@ -6,6 +6,229 @@ namespace Just.PreciseMath.Tests; public class DoubleDoubleConversionTests { + [Theory] + [InlineData(false)] + [InlineData(true)] + public void FloatOverflowMidpointUsesDecidingResidual(bool negative) + { + // Max = 2^128 - 2^104; midpoint to the next binade is + // 2^128 - 2^103. Its upper significand is even, hence the tie overflows. + double midpoint = Math.ScaleB(1.0, 128) - Math.ScaleB(1.0, 103); + foreach (int direction in new[] { -1, 0, 1 }) + { + double sign = negative ? -1.0 : 1.0; + DoubleDouble value = DoubleDouble.FromComponents(sign * midpoint, sign * direction); + uint magnitude = direction < 0 ? 0x7f7fffffU : 0x7f800000U; + uint expected = magnitude | (negative ? 0x80000000U : 0U); + BitConverter.SingleToUInt32Bits((float)value).ShouldBe(expected); + BitConverter.SingleToUInt32Bits(((IConvertible)value).ToSingle(null)).ShouldBe(expected); + } + } + + [Theory] + [InlineData(0, false)] + [InlineData(1, false)] + [InlineData(2, false)] + [InlineData(0, true)] + [InlineData(1, true)] + [InlineData(2, true)] + public void FloatSubnormalMidpointsRoundEvenWithBothSigns(int lower, bool negative) + { + // Subnormal bits are integer multiples of 2^-149. The exact midpoint + // (2*lower+1)*2^-150 chooses the even integer; +/-2^-1074 decides sides. + foreach (int direction in new[] { -1, 0, 1 }) + { + double sign = negative ? -1.0 : 1.0; + DoubleDouble value = DoubleDouble.FromComponents( + sign * Math.ScaleB((2 * lower) + 1, -150), sign * direction * double.Epsilon); + int rounded = direction < 0 ? lower : direction > 0 ? lower + 1 : lower + (lower & 1); + uint expected = (uint)rounded | (negative ? 0x80000000U : 0U); + BitConverter.SingleToUInt32Bits((float)value).ShouldBe(expected); + BitConverter.SingleToUInt32Bits(((IConvertible)value).ToSingle(null)).ShouldBe(expected); + } + } + + [Theory] + [InlineData(32)] + [InlineData(64)] + public void ExplicitIntegerEndpointsTruncateBeforeCheckingRange(int bits) + { + BigInteger minimum = -(BigInteger.One << (bits - 1)); + BigInteger maximum = -minimum - 1; + foreach (BigInteger endpoint in new[] { minimum, maximum }) + { + // Probe the endpoint and each truncation transition with exact dyadics. + // Division of signed BigIntegers truncates toward zero independently. + foreach (int offset in new[] { -1, 0, 1 }) + { + foreach (int side in new[] { -1, 0, 1 }) + { + BigInteger denominator = BigInteger.One << 40; + BigInteger numerator = ((endpoint + offset) * denominator) + side; + BigInteger expected = numerator / denominator; + DoubleDouble value = ExactEndpointInput(numerator, denominator); + if (expected < minimum || expected > maximum) + { + Should.Throw(() => ExplicitInteger(value, bits)); + } + else + { + ExplicitInteger(value, bits).ShouldBe(expected); + } + } + } + } + } + + [Theory] + [InlineData(TypeCode.SByte, 8, true)] + [InlineData(TypeCode.Byte, 8, false)] + [InlineData(TypeCode.Int16, 16, true)] + [InlineData(TypeCode.UInt16, 16, false)] + [InlineData(TypeCode.Int32, 32, true)] + [InlineData(TypeCode.UInt32, 32, false)] + [InlineData(TypeCode.Int64, 64, true)] + [InlineData(TypeCode.UInt64, 64, false)] + public void ConvertibleIntegerEndpointsRoundBeforeCheckingRange(TypeCode type, int bits, bool isSigned) + { + BigInteger minimum = isSigned ? -(BigInteger.One << (bits - 1)) : BigInteger.Zero; + BigInteger maximum = (BigInteger.One << (isSigned ? bits - 1 : bits)) - 1; + BigInteger denominator = BigInteger.One << 40; + foreach (BigInteger endpoint in new[] { minimum, maximum }) + { + foreach (int halfOffset in new[] { -2, -1, 0, 1, 2 }) + { + foreach (int side in new[] { -1, 0, 1 }) + { + BigInteger numerator = (endpoint * denominator) + (halfOffset * (denominator / 2)) + side; + // Choose the closest of floor(x) and floor(x)+1 by exact distances, + // selecting the even candidate at a tie. Includes unsigned -0.5. + BigInteger lower = numerator / denominator; + if (numerator < 0 && numerator % denominator != 0) + { + lower--; + } + BigInteger distanceBelow = numerator - (lower * denominator); + BigInteger distanceAbove = ((lower + 1) * denominator) - numerator; + BigInteger expected = distanceBelow < distanceAbove || + (distanceBelow == distanceAbove && lower.IsEven) ? lower : lower + 1; + IConvertible value = ExactEndpointInput(numerator, denominator); + if (expected < minimum || expected > maximum) + { + Should.Throw(() => ConvertibleInteger(value, type)); + Should.Throw(() => Convert.ChangeType(value, type, System.Globalization.CultureInfo.InvariantCulture)); + } + else + { + ConvertibleInteger(value, type).ShouldBe(expected); + Convert.ChangeType(value, type, System.Globalization.CultureInfo.InvariantCulture) + .ShouldBe(Convert.ChangeType((decimal)expected, type, System.Globalization.CultureInfo.InvariantCulture)); + } + } + } + } + } + + private static DoubleDouble ExactEndpointInput(BigInteger numerator, BigInteger denominator) + { + // All inputs have denominator 2^40, magnitude <= 2^64+2, and a + // residual requiring <= 53 bits. Splitting the integer part is exact. + double high = (double)(numerator / denominator); + double low = (double)(numerator - (new BigInteger(high) * denominator)) / (double)denominator; + DoubleDouble value = DoubleDouble.FromComponents(high, low); + (new BigInteger(value.High * (double)denominator) + + new BigInteger(value.Low * (double)denominator)).ShouldBe(numerator); + return value; + } + + private static BigInteger ExplicitInteger(DoubleDouble value, int bits) + { + return bits == 32 ? (int)value : (long)value; + } + + private static BigInteger ConvertibleInteger(IConvertible value, TypeCode type) + { + return type switch + { + TypeCode.SByte => value.ToSByte(null), + TypeCode.Byte => value.ToByte(null), + TypeCode.Int16 => value.ToInt16(null), + TypeCode.UInt16 => value.ToUInt16(null), + TypeCode.Int32 => value.ToInt32(null), + TypeCode.UInt32 => value.ToUInt32(null), + TypeCode.Int64 => value.ToInt64(null), + TypeCode.UInt64 => value.ToUInt64(null), + _ => throw new ArgumentOutOfRangeException(nameof(type)) + }; + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void DecimalEndpointsCheckExactMagnitudeBeforeRounding(bool negative) + { + // M = 2^96-1; at scale zero M is odd. M-1/2 rounds to M-1, + // while M +/- 2^-40 would round to M but only the inside value is legal. + double step = Math.ScaleB(1.0, -40); + foreach (double offset in new[] { -1.0, -0.5 - step, -0.5, -0.5 + step, -step, 0.0, step, 0.5, 1.0 }) + { + double sign = negative ? -1.0 : 1.0; + DoubleDouble value = DoubleDouble.FromComponents(sign * Math.ScaleB(1.0, 96), sign * (-1.0 + offset)); + if (offset > 0) + { + Should.Throw(() => (decimal)value); + Should.Throw(() => ((IConvertible)value).ToDecimal(null)); + } + else + { + decimal expected = decimal.MaxValue - (offset <= -0.5 ? 1m : 0m); + expected = negative ? -expected : expected; + ((decimal)value).ShouldBe(expected); + ((IConvertible)value).ToDecimal(null).ShouldBe(expected); + } + } + } + + [Theory] + [InlineData(1)] + [InlineData(3)] + public void NegativeDecimalMidpointsRoundEvenAtMaximumScale(int multiplier) + { + // -m*2^-29 * 10^28 = -m*5^28/2. For m=1 the magnitude's + // lower coefficient is even; for m=3 it is odd. No decimal input oracle. + BigInteger twiceMagnitude = multiplier * BigInteger.Pow(5, 28); + BigInteger lowerMagnitude = twiceMagnitude / 2; + foreach (int direction in new[] { -1, 0, 1 }) + { + DoubleDouble value = DoubleDouble.FromComponents(-Math.ScaleB(multiplier, -29), direction * double.Epsilon); + BigInteger coefficient = lowerMagnitude + + (direction < 0 || (direction == 0 && !lowerMagnitude.IsEven) ? 1 : 0); + decimal expected = new((int)(uint)(coefficient & uint.MaxValue), + (int)(uint)((coefficient >> 32) & uint.MaxValue), (int)(uint)(coefficient >> 64), true, 28); + ((decimal)value).ShouldBe(expected); + ((IConvertible)value).ToDecimal(null).ShouldBe(expected); + } + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void DecimalOutputPreservesZeroSignIncludingUnderflow(bool negative) + { + // 2^-95 < 1/(2*10^28), proved by 2*10^28 < 2^95. + // All nonzero magnitudes below therefore round to coefficient zero. + (2 * BigInteger.Pow(10, 28) < (BigInteger.One << 95)).ShouldBeTrue(); + foreach (double magnitude in new[] { 0.0, double.Epsilon, Math.ScaleB(1.0, -95) }) + { + DoubleDouble value = new(negative ? -magnitude : magnitude); + foreach (decimal result in new[] { (decimal)value, ((IConvertible)value).ToDecimal(null) }) + { + int[] bits = decimal.GetBits(result); + bits.ShouldBe(new[] { 0, 0, 0, (28 << 16) | (negative ? int.MinValue : 0) }); + } + } + } + [Theory] [InlineData(0.0, 0.0, false)] [InlineData(1.0, -1.0, false)] diff --git a/1-tests/Just.PreciseMath.Tests/DoubleDoubleFormattingTests.cs b/1-tests/Just.PreciseMath.Tests/DoubleDoubleFormattingTests.cs index d801b56..914fa27 100644 --- a/1-tests/Just.PreciseMath.Tests/DoubleDoubleFormattingTests.cs +++ b/1-tests/Just.PreciseMath.Tests/DoubleDoubleFormattingTests.cs @@ -6,6 +6,30 @@ namespace Just.PreciseMath.Tests; public class DoubleDoubleFormattingTests { + [Theory] + [InlineData("G0")] + [InlineData("g0")] + public void ZeroGeneralPrecisionUses32SignificantDigits(string format) + { + // Exact dyadic 1 + 2^-54, rounded in decimal to 32 significant digits. + DoubleDouble value = DoubleDouble.FromComponents(1.0, Math.ScaleB(1.0, -54)); + const string expected = "1.0000000000000000555111512312578"; + value.ToString(format, CultureInfo.InvariantCulture).ShouldBe(expected); + ((IFormattable)value).ToString(format, CultureInfo.InvariantCulture).ShouldBe(expected); + Span destination = stackalloc char[64]; + value.TryFormat(destination, out int written, format, CultureInfo.InvariantCulture).ShouldBeTrue(); + destination[..written].ToString().ShouldBe(expected); + + // A zero residual lets the independent BCL G32 formatter supply the oracle, + // including the scientific-notation threshold and exponent letter case. + string referenceFormat = format[0] == 'G' ? "G32" : "g32"; + foreach (double scalar in new double[] { 1e31, 1e32, 1e-5, -0.0, double.Epsilon, double.MaxValue }) + { + new DoubleDouble(scalar).ToString(format, CultureInfo.InvariantCulture) + .ShouldBe(scalar.ToString(referenceFormat, CultureInfo.InvariantCulture)); + } + } + [Fact] public void GeneralFormattingTrimsOnlyFractionalZerosAtMaximumPrecision() { diff --git a/1-tests/Just.PreciseMath.Tests/DoubleDoubleNumberStylesTests.cs b/1-tests/Just.PreciseMath.Tests/DoubleDoubleNumberStylesTests.cs index 38f8a66..c8a10bd 100644 --- a/1-tests/Just.PreciseMath.Tests/DoubleDoubleNumberStylesTests.cs +++ b/1-tests/Just.PreciseMath.Tests/DoubleDoubleNumberStylesTests.cs @@ -1,4 +1,5 @@ using System.Globalization; +using System.Numerics; using Shouldly; using Xunit; @@ -6,6 +7,54 @@ namespace Just.PreciseMath.Tests; public class DoubleDoubleNumberStylesTests { + [Theory] + [InlineData(" 9,007,199,254,740,993 ", NumberStyles.Number, 9007199254740992.0, 1.0)] + [InlineData("-1.000000000000000055511151231257827021181583404541015625", NumberStyles.Float, -1.0, -5.5511151231257827e-17)] + public void GenericStyledParsingRetainsExactResidual(string text, NumberStyles style, double high, double low) + { + // Exact integers 2^53 + 1 and dyadic -(1 + 2^-54), not rounded double parses. + AssertGenericStyledParsing(text, style, high, low); + } + + [Theory] + [InlineData("1,000", NumberStyles.Float)] + [InlineData("1e+", NumberStyles.Any)] + [InlineData("not-a-number", NumberStyles.Number)] + public void GenericStyledParsingRejectsInvalidInput(string text, NumberStyles style) + { + AssertGenericStyledParsingFailure(text, style); + } + + private static void AssertGenericStyledParsing(string text, NumberStyles style, double high, double low) + where T : INumberBase + { + T fromString = T.Parse(text, style, CultureInfo.InvariantCulture); + T fromSpan = T.Parse(text.AsSpan(), style, CultureInfo.InvariantCulture); + T.TryParse(text, style, CultureInfo.InvariantCulture, out T? triedString).ShouldBeTrue(); + T.TryParse(text.AsSpan(), style, CultureInfo.InvariantCulture, out T? triedSpan).ShouldBeTrue(); + foreach (T? parsed in new[] { fromString, fromSpan, triedString, triedSpan }) + { + DoubleDouble value = parsed.ShouldBeOfType(); + value.High.ShouldBe(high); + value.Low.ShouldBe(low); + } + } + + private static void AssertGenericStyledParsingFailure(string text, NumberStyles style) + where T : INumberBase + { + Should.Throw(() => T.Parse(text, style, CultureInfo.InvariantCulture)); + Should.Throw(() => T.Parse(text.AsSpan(), style, CultureInfo.InvariantCulture)); + T.TryParse(text, style, CultureInfo.InvariantCulture, out T? fromString).ShouldBeFalse(); + T.TryParse(text.AsSpan(), style, CultureInfo.InvariantCulture, out T? fromSpan).ShouldBeFalse(); + foreach (T? parsed in new[] { fromString, fromSpan }) + { + DoubleDouble value = parsed.ShouldBeOfType(); + BitConverter.DoubleToInt64Bits(value.High).ShouldBe(0L); + BitConverter.DoubleToInt64Bits(value.Low).ShouldBe(0L); + } + } + [Theory] [InlineData("123", NumberStyles.None, 123.0)] [InlineData(" -1.25e+2 ", NumberStyles.Float, -125.0)] diff --git a/1-tests/Just.PreciseMath.Tests/GenericConversionTests.cs b/1-tests/Just.PreciseMath.Tests/GenericConversionTests.cs index c7f5bfe..5c9d7b6 100644 --- a/1-tests/Just.PreciseMath.Tests/GenericConversionTests.cs +++ b/1-tests/Just.PreciseMath.Tests/GenericConversionTests.cs @@ -7,6 +7,227 @@ namespace Just.PreciseMath.Tests; public sealed class GenericConversionTests { + [Theory] + [InlineData("byte")] + [InlineData("sbyte")] + [InlineData("short")] + [InlineData("ushort")] + [InlineData("int")] + [InlineData("uint")] + [InlineData("long")] + [InlineData("ulong")] + [InlineData("nint")] + [InlineData("nuint")] + [InlineData("Int128")] + [InlineData("UInt128")] + [InlineData("char")] + public void EveryBoundedIntegerCoversBothEndpointsAndAdjacentValues(string type) + { + switch (type) + { + case "byte": AssertIntegerBoundaries(byte.MinValue, byte.MaxValue); break; + case "sbyte": AssertIntegerBoundaries(sbyte.MinValue, sbyte.MaxValue); break; + case "short": AssertIntegerBoundaries(short.MinValue, short.MaxValue); break; + case "ushort": AssertIntegerBoundaries(ushort.MinValue, ushort.MaxValue); break; + case "int": AssertIntegerBoundaries(int.MinValue, int.MaxValue); break; + case "uint": AssertIntegerBoundaries(uint.MinValue, uint.MaxValue); break; + case "long": AssertIntegerBoundaries(long.MinValue, long.MaxValue); break; + case "ulong": AssertIntegerBoundaries(ulong.MinValue, ulong.MaxValue); break; + case "nint": AssertIntegerBoundaries(nint.MinValue, nint.MaxValue); break; + case "nuint": AssertIntegerBoundaries(nuint.MinValue, nuint.MaxValue); break; + case "Int128": AssertIntegerBoundaries(Int128.MinValue, Int128.MaxValue); break; + case "UInt128": AssertIntegerBoundaries(UInt128.MinValue, UInt128.MaxValue); break; + case "char": AssertIntegerBoundaries(char.MinValue, char.MaxValue); break; + default: throw new ArgumentOutOfRangeException(nameof(type)); + } + } + + private static void AssertIntegerBoundaries(T minimum, T maximum) where T : struct, INumberBase + { + BigInteger min = BigInteger.CreateChecked(minimum); + BigInteger max = BigInteger.CreateChecked(maximum); + BigInteger width = max - min + 1; + + // Integer inputs near these power-of-two endpoints have exact two-component + // representations, even for Int128/UInt128. No round-trip is used as an oracle. + foreach (BigInteger integer in new[] { min, min + 1, max - 1, max }) + { + T input = T.CreateChecked(integer); + foreach (int mode in new[] { 0, 1, 2 }) + { + DoubleDouble direct = mode switch + { + 0 => DoubleDouble.CreateChecked(input), + 1 => DoubleDouble.CreateSaturating(input), + _ => DoubleDouble.CreateTruncating(input), + }; + AssertExactQuarters(direct, integer * 4); + AssertExactQuarters(CreateInMode(input, mode), integer * 4); + DoubleDouble hooked; + bool supported = mode switch + { + 0 => ConversionProbe.FromChecked(input, out hooked), + 1 => ConversionProbe.FromSaturating(input, out hooked), + _ => ConversionProbe.FromTruncating(input, out hooked), + }; + supported.ShouldBeTrue(); + AssertExactQuarters(hooked, integer * 4); + } + } + + foreach (BigInteger endpoint in new[] { min, max }) + { + // Whole-unit neighbors test range transitions; quarter-unit neighbors + // prove truncation happens BEFORE range handling, on the exact sum. + // Including +/-1.25 crosses the negative endpoint's truncation boundary. + foreach (int offset in new[] { -5, -4, -3, -1, 0, 1, 3, 4, 5 }) + { + BigInteger numerator = (endpoint * 4) + offset; + DoubleDouble input = FromExactQuarters(numerator); + BigInteger integer = numerator / 4; + BigInteger clamped = BigInteger.Min(max, BigInteger.Max(min, integer)); + // Finite DD sources use BigInteger-style modular truncation, NOT + // binary64's floating-to-integer clamping. Derive modulo independently. + BigInteger wrapped = (((integer - min) % width) + width) % width + min; + foreach (int mode in new[] { 0, 1, 2 }) + { + if (mode == 0 && (integer < min || integer > max)) + { + Should.Throw(() => CreateInMode(input, mode)); + Should.Throw(() => ConvertToInMode(input, mode)); + } + else + { + BigInteger expected = mode == 0 ? integer : mode == 1 ? clamped : wrapped; + BigInteger.CreateChecked(CreateInMode(input, mode)).ShouldBe(expected); + BigInteger.CreateChecked(ConvertToInMode(input, mode)).ShouldBe(expected); + } + } + } + } + + AssertNonfiniteIntegerPolicy(); + } + + private static TTarget CreateInMode(TSource value, int mode) + where TTarget : INumberBase + where TSource : INumberBase + { + return mode switch + { + 0 => TTarget.CreateChecked(value), + 1 => TTarget.CreateSaturating(value), + _ => TTarget.CreateTruncating(value), + }; + } + + private static T ConvertToInMode(DoubleDouble value, int mode) where T : struct, INumberBase + { + T result; + bool supported = mode switch + { + 0 => ConversionProbe.ToChecked(value, out result), + 1 => ConversionProbe.ToSaturating(value, out result), + _ => ConversionProbe.ToTruncating(value, out result), + }; + supported.ShouldBeTrue(); + return result; + } + + private static DoubleDouble FromExactQuarters(BigInteger numerator) + { + // Choose the nearest signed power of two using exact integer distances. + // A direct BigInteger-to-double cast can truncate rather than round, leaving + // an inexact large residual at 128-bit endpoints. Powers of two cast exactly. + BigInteger magnitude = BigInteger.Abs(numerator); + BigInteger integral = magnitude / 4; + BigInteger anchor = BigInteger.Zero; + if (!integral.IsZero) + { + anchor = BigInteger.One << checked((int)integral.GetBitLength() - 1); + if (BigInteger.Abs(magnitude - (anchor * 8)) < BigInteger.Abs(magnitude - (anchor * 4))) + { + anchor *= 2; + } + } + double high = (double)(anchor * numerator.Sign); + double low = (double)(numerator - (new BigInteger(high) * 4)) / 4.0; + DoubleDouble result = DoubleDouble.FromComponents(high, low); + AssertExactQuarters(result, numerator); + return result; + } + + private static void AssertExactQuarters(DoubleDouble value, BigInteger numerator) + { + double high = value.High * 4.0; + double low = value.Low * 4.0; + double.IsFinite(high).ShouldBeTrue(); + double.IsFinite(low).ShouldBeTrue(); + Math.Truncate(high).ShouldBe(high); + Math.Truncate(low).ShouldBe(low); + (new BigInteger(high) + new BigInteger(low)).ShouldBe(numerator); + } + + private static void AssertNonfiniteIntegerPolicy() where T : struct, INumberBase + { + // Unlike finite DD inputs, NaN/infinities explicitly inherit the destination's + // BCL binary64-source policy. Query that independent API, including exceptions; + // do not assume every signed/unsigned/native target maps NaN identically. + foreach (double special in new[] { double.NaN, double.NegativeInfinity, double.PositiveInfinity }) + { + foreach (int mode in new[] { 0, 1, 2 }) + { + T expected; + try + { + expected = CreateInMode(special, mode); + } + catch (OverflowException) + { + Should.Throw(() => CreateInMode(new DoubleDouble(special), mode)); + Should.Throw(() => ConvertToInMode(new DoubleDouble(special), mode)); + continue; + } + CreateInMode(new DoubleDouble(special), mode).ShouldBe(expected); + ConvertToInMode(new DoubleDouble(special), mode).ShouldBe(expected); + } + } + } + + [Fact] + public void UnboundedIntegerUsesFloatingInputOverflowButHasNoFiniteOutputEndpoints() + { + foreach (int sign in new[] { -1, 1 }) + { + BigInteger huge = sign * (BigInteger.One << 2000); + foreach (int mode in new[] { 0, 1, 2 }) + { + DoubleDouble created = CreateInMode(huge, mode); + created.High.ShouldBe(sign < 0 ? double.NegativeInfinity : double.PositiveInfinity); + created.Low.ShouldBe(0.0); + DoubleDouble hooked; + bool supported = mode switch + { + 0 => ConversionProbe.FromChecked(huge, out hooked), + 1 => ConversionProbe.FromSaturating(huge, out hooked), + _ => ConversionProbe.FromTruncating(huge, out hooked), + }; + supported.ShouldBeTrue(); + hooked.High.ShouldBe(created.High); + hooked.Low.ShouldBe(0.0); + + // BigInteger has no endpoints: exact sparse integers beyond UInt128 + // still truncate without clamping or wrapping in every output mode. + BigInteger integer = sign * ((BigInteger.One << 200) + 1); + DoubleDouble finite = FromExactQuarters((integer * 4) + sign); + CreateInMode(finite, mode).ShouldBe(integer); + ConvertToInMode(finite, mode).ShouldBe(integer); + AssertExactQuarters(CreateInMode(integer, mode), integer * 4); + } + } + AssertNonfiniteIntegerPolicy(); + } + [Fact] public void GenericCreationPreservesComponentsAndExactIntegers() {