using System.Numerics; using Shouldly; using Xunit; 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)] [InlineData(0.0, double.Epsilon, true)] [InlineData(0.0, -double.Epsilon, true)] [InlineData(double.NaN, 0.0, true)] [InlineData(double.PositiveInfinity, 0.0, true)] [InlineData(double.NegativeInfinity, 0.0, true)] public void BooleanConversionUsesNormalizedZero(double high, double low, bool expected) { IConvertible value = DoubleDouble.FromComponents(high, low); value.ToBoolean(null).ShouldBe(expected); value.ToType(typeof(bool), null).ShouldBe(expected); } [Theory] [InlineData(0.0)] [InlineData(double.Epsilon)] [InlineData(-double.Epsilon)] [InlineData(double.MaxValue)] [InlineData(-double.MaxValue)] [InlineData(double.PositiveInfinity)] [InlineData(double.NegativeInfinity)] [InlineData(double.NaN)] public void SingleComponentFloatConversionsMatchBinary64Casts(double high) { DoubleDouble value = new(high); int expectedBits = BitConverter.SingleToInt32Bits((float)high); BitConverter.SingleToInt32Bits((float)value).ShouldBe(expectedBits); BitConverter.SingleToInt32Bits(((IConvertible)value).ToSingle(null)).ShouldBe(expectedBits); } [Fact] public void SingleComponentFloatConversionDoesNotAllocate() { DoubleDouble value = new(1.25); float result = 0.0f; for (int i = 0; i < 100; ++i) { result = (float)value; } long before = GC.GetAllocatedBytesForCurrentThread(); for (int i = 0; i < 100; ++i) { result = (float)value; } long allocated = GC.GetAllocatedBytesForCurrentThread() - before; result.ShouldBe(1.25f); allocated.ShouldBe(0L); } [Fact] public void DecimalConstructionPreservesSignedScaledZero() { decimal zero = new(0, 0, 0, true, 28); DoubleDouble value = new(zero); BitConverter.DoubleToInt64Bits(value.High).ShouldBe(long.MinValue); BitConverter.DoubleToInt64Bits(value.Low).ShouldBe(0L); ((IConvertible)value).ToBoolean(null).ShouldBeFalse(); } [Fact] public void IntegerInputsRemainExactAcrossBinary64RoundingBoundaries() { foreach (long input in new[] { 0L, 1L, -1L, long.MinValue, long.MinValue + 1, long.MaxValue - 1, long.MaxValue }) { CheckIntegerInput(input); } for (int exponent = 53; exponent < 63; ++exponent) { long center = 1L << exponent; long halfUlp = 1L << (exponent - 53); foreach (long offset in new[] { -halfUlp - 1, -halfUlp, -halfUlp + 1, halfUlp - 1, halfUlp, halfUlp + 1 }) { CheckIntegerInput(center + offset); CheckIntegerInput(-center - offset); } } Random random = new(1729); for (int i = 0; i < 250; ++i) { CheckIntegerInput(random.NextInt64(long.MinValue, long.MaxValue)); } } [Fact] public void IntegerConstructionDoesNotAllocate() { DoubleDouble value = default; for (int i = 0; i < 100; ++i) { value = new DoubleDouble(long.MaxValue); } long before = GC.GetAllocatedBytesForCurrentThread(); for (int i = 0; i < 100; ++i) { value = new DoubleDouble(long.MaxValue); } long allocated = GC.GetAllocatedBytesForCurrentThread() - before; value.High.ShouldBe(Math.ScaleB(1.0, 63)); value.Low.ShouldBe(-1.0); allocated.ShouldBe(0L); } private static void CheckIntegerInput(long input) { foreach (DoubleDouble value in new[] { new DoubleDouble(input), (DoubleDouble)input }) { // Both components of an integer input are integers. BigInteger // recombines them exactly without rounding the sum to binary64. (new BigInteger(value.High) + new BigInteger(value.Low)).ShouldBe(new BigInteger(input)); value.High.ShouldBe((double)input); ((long)value).ShouldBe(input); } } [Fact] public void IntegerInputsPreserveEveryBit() { DoubleDouble value = (DoubleDouble)9007199254740993L; value.High.ShouldBe(9007199254740992.0); value.Low.ShouldBe(1.0); new DoubleDouble(long.MaxValue).Low.ShouldBe(-1.0); new DoubleDouble(long.MinValue).Low.ShouldBe(0.0); ((DoubleDouble)int.MaxValue).High.ShouldBe(2147483647.0); new DoubleDouble(1).High.ShouldBe(1.0); } [Fact] public void BinaryConversionsRoundOnceUsingBothComponents() { double midpoint = 1.0 + Math.ScaleB(1.0, -24); ((float)DoubleDouble.FromComponents(midpoint, Math.ScaleB(1.0, -80))).ShouldBe(MathF.BitIncrement(1.0f)); ((float)DoubleDouble.FromComponents(midpoint, -Math.ScaleB(1.0, -80))).ShouldBe(1.0f); ((float)new DoubleDouble(midpoint)).ShouldBe(1.0f); ((float)DoubleDouble.FromComponents(Math.ScaleB(1.0, -150), double.Epsilon)).ShouldBe(float.Epsilon); ((float)new DoubleDouble(Math.ScaleB(1.0, -150))).ShouldBe(0.0f); ((float)new DoubleDouble(double.MaxValue)).ShouldBe(float.PositiveInfinity); float.IsNaN((float)DoubleDouble.NaN).ShouldBeTrue(); ((double)new DoubleDouble(double.NegativeInfinity)).ShouldBe(double.NegativeInfinity); ((double)DoubleDouble.FromComponents(1.0, Math.ScaleB(1.0, -54))).ShouldBe(1.0); ((DoubleDouble)double.Epsilon).High.ShouldBe(double.Epsilon); ((DoubleDouble)float.Epsilon).High.ShouldBe(Math.ScaleB(1.0, -149)); BitConverter.DoubleToInt64Bits((double)(DoubleDouble)(-0.0)).ShouldBe(long.MinValue); BitConverter.SingleToInt32Bits((float)(DoubleDouble)(-0.0f)).ShouldBe(int.MinValue); } [Fact] public void DecimalInputComputesTheExactBinaryResidual() { DoubleDouble tenth = new(0.1m); tenth.High.ShouldBe(0.1); // 1/10 - binary64(0.1) = -1/(5 * 2^55), rounded to binary64. tenth.Low.ShouldBe(-5.551115123125783e-18); DoubleDouble maximum = (DoubleDouble)decimal.MaxValue; maximum.High.ShouldBe(Math.ScaleB(1.0, 96)); maximum.Low.ShouldBe(-1.0); ((decimal)maximum).ShouldBe(decimal.MaxValue); ((decimal)new DoubleDouble(decimal.MinValue)).ShouldBe(decimal.MinValue); ((decimal)new DoubleDouble(0.0000000000000000000000000001m)).ShouldBe(0.0000000000000000000000000001m); ((decimal)tenth).ShouldBe(0.1m); } [Fact] public void DecimalOutputRoundsExactSumToNearestEven() { ((decimal)DoubleDouble.FromComponents(1.0, Math.ScaleB(1.0, -54))).ShouldBe(1.0000000000000000555111512313m); ((decimal)new DoubleDouble(Math.ScaleB(1.0, -29))).ShouldBe(0.0000000018626451492309570312m); ((decimal)DoubleDouble.FromComponents(Math.ScaleB(1.0, -29), double.Epsilon)).ShouldBe(0.0000000018626451492309570313m); ((decimal)new DoubleDouble(double.Epsilon)).ShouldBe(0m); Should.Throw(() => (decimal)new DoubleDouble(Math.ScaleB(1.0, 96))); Should.Throw(() => (decimal)DoubleDouble.NaN); Should.Throw(() => (decimal)new DoubleDouble(double.NegativeInfinity)); } [Fact] public void ConvertibleIntegersUseNearestEvenAndCheckAllTargetRanges() { IConvertible value = DoubleDouble.FromComponents(2.5, double.Epsilon); value.ToByte(null).ShouldBe((byte)3); value.ToSByte(null).ShouldBe((sbyte)3); value.ToInt16(null).ShouldBe((short)3); value.ToUInt16(null).ShouldBe((ushort)3); value.ToInt32(null).ShouldBe(3); value.ToUInt32(null).ShouldBe(3U); value.ToInt64(null).ShouldBe(3L); value.ToUInt64(null).ShouldBe(3UL); ((IConvertible)new DoubleDouble(2.5)).ToInt32(null).ShouldBe(2); ((IConvertible)new DoubleDouble(-2.5)).ToInt32(null).ShouldBe(-2); ((IConvertible)DoubleDouble.FromComponents(-2.5, -double.Epsilon)).ToInt32(null).ShouldBe(-3); ((IConvertible)DoubleDouble.FromComponents(Math.ScaleB(1.0, 64), -1.0)).ToUInt64(null).ShouldBe(ulong.MaxValue); Should.Throw(() => ((IConvertible)new DoubleDouble(255.5)).ToByte(null)); Should.Throw(() => ((IConvertible)new DoubleDouble(127.5)).ToSByte(null)); Should.Throw(() => ((IConvertible)new DoubleDouble(32767.5)).ToInt16(null)); Should.Throw(() => ((IConvertible)new DoubleDouble(65535.5)).ToUInt16(null)); Should.Throw(() => ((IConvertible)new DoubleDouble(2147483647.5)).ToInt32(null)); Should.Throw(() => ((IConvertible)new DoubleDouble(4294967295.5)).ToUInt32(null)); Should.Throw(() => ((IConvertible)new DoubleDouble(Math.ScaleB(1.0, 63))).ToInt64(null)); Should.Throw(() => ((IConvertible)new DoubleDouble(Math.ScaleB(1.0, 64))).ToUInt64(null)); Should.Throw(() => ((IConvertible)new DoubleDouble(-1.0)).ToUInt64(null)); Should.Throw(() => ((IConvertible)DoubleDouble.NaN).ToInt32(null)); } [Fact] public void ConvertibleDispatchPreservesTypeAndUsesNumericPolicies() { IConvertible value = new DoubleDouble(1.25); value.GetTypeCode().ShouldBe(TypeCode.Object); value.ToBoolean(null).ShouldBeTrue(); ((IConvertible)DoubleDouble.Zero).ToBoolean(null).ShouldBeFalse(); ((IConvertible)DoubleDouble.NaN).ToBoolean(null).ShouldBeTrue(); value.ToDecimal(null).ShouldBe(1.25m); value.ToDouble(null).ShouldBe(1.25); value.ToSingle(null).ShouldBe(1.25f); value.ToString(System.Globalization.CultureInfo.InvariantCulture).ShouldBe("1.25"); value.ToType(typeof(DoubleDouble), null).ShouldBe(new DoubleDouble(1.25)); value.ToType(typeof(object), null).ShouldBe(new DoubleDouble(1.25)); value.ToType(typeof(int), null).ShouldBe(1); value.ToType(typeof(string), System.Globalization.CultureInfo.InvariantCulture).ShouldBe("1.25"); value.ToType(typeof(decimal), null).ShouldBe(1.25m); value.ToType(typeof(double), null).ShouldBe(1.25); value.ToType(typeof(float), null).ShouldBe(1.25f); value.ToType(typeof(bool), null).ShouldBe(true); Should.Throw(() => value.ToChar(null)); Should.Throw(() => value.ToDateTime(null)); Should.Throw(() => value.ToType(typeof(Guid), null)); Should.Throw(() => value.ToType(typeof(DayOfWeek), null)); Should.Throw(() => value.ToType(null!, null)); } [Fact] public void ChangeTypeRecognizesDoubleDoubleAndDelegatesNumericTargets() { // Review-1 ยง10: Convert.ChangeType(One, typeof(DoubleDouble)) threw // InvalidCastException because ToType did not recognize its own type. System.Globalization.CultureInfo invariant = System.Globalization.CultureInfo.InvariantCulture; Convert.ChangeType(DoubleDouble.One, typeof(DoubleDouble), invariant).ShouldBe(DoubleDouble.One); Convert.ChangeType(new DoubleDouble(1.25), typeof(int), invariant).ShouldBe(1); Convert.ChangeType(new DoubleDouble(1.25), typeof(double), invariant).ShouldBe(1.25); Convert.ChangeType(new DoubleDouble(1.25), typeof(string), invariant).ShouldBe("1.25"); Should.Throw(() => Convert.ChangeType(DoubleDouble.One, typeof(Guid), invariant)); } [Fact] public void ExplicitIntegersTruncateTheCompleteExpansion() { ((int)DoubleDouble.FromComponents(1.0, -1e-30)).ShouldBe(0); ((int)DoubleDouble.FromComponents(-1.0, 1e-30)).ShouldBe(0); ((long)DoubleDouble.FromComponents(9007199254740992.0, 1.0)).ShouldBe(9007199254740993L); ((long)DoubleDouble.FromComponents(9223372036854775808.0, -1.0)).ShouldBe(long.MaxValue); ((long)new DoubleDouble(-9223372036854775808.0)).ShouldBe(long.MinValue); ((int)DoubleDouble.FromComponents(2147483648.0, -0.25)).ShouldBe(int.MaxValue); Should.Throw(() => (int)new DoubleDouble(2147483648.0)); Should.Throw(() => (long)new DoubleDouble(9223372036854775808.0)); Should.Throw(() => (int)DoubleDouble.NaN); Should.Throw(() => (long)new DoubleDouble(double.PositiveInfinity)); } }