modulus implementation
.NET Test / .NET tests (push) Successful in 2m8s

This commit is contained in:
2026-09-24 02:25:35 +04:00
parent cb06df8865
commit 4a7ce1b8ed
7 changed files with 646 additions and 8 deletions
@@ -0,0 +1,337 @@
namespace Just.PreciseMath.Tests;
public class DoubleDoubleModulusTests
{
[Fact]
public void ModulusRetainsTheDividendResidualThroughNamedOperatorAndGenericCalls()
{
// (2^53 + 1) % 2 = 1 exactly; using only High would return zero.
DoubleDouble left = DoubleDouble.FromComponents(9007199254740992.0, 1.0);
DoubleDouble right = new(2.0);
AssertBits(DoubleDouble.Modulus(left, right), DoubleDouble.One);
AssertBits(left % right, DoubleDouble.One);
AssertBits(GenericModulus(left, right), DoubleDouble.One);
AssertBits(DoubleDouble.Modulus(left, 2.0), DoubleDouble.One);
AssertBits(left % 2.0, DoubleDouble.One);
AssertBits(GenericScalarModulus(left, 2.0), DoubleDouble.One);
}
[Fact]
public void ModulusRetainsTheDivisorResidualWithAScalarDividend()
{
// 2 % (1 + 2^-54) = 1 - 2^-54 exactly, with truncated quotient 1.
DoubleDouble right = DoubleDouble.FromComponents(1.0, Math.ScaleB(1.0, -54));
DoubleDouble expected = DoubleDouble.FromComponents(1.0, -Math.ScaleB(1.0, -54));
AssertBits(DoubleDouble.Modulus(2.0, right), expected);
AssertBits(2.0 % right, expected);
AssertBits(new DoubleDouble(2.0) % right, expected);
}
[Fact]
public void ScalarSpecialValuesAndRemainderSignsMatchBinary64()
{
double[] values = [0.0, -0.0, 1.0, -1.0, 2.0, -2.0, 5.5, -5.5,
double.Epsilon, -double.Epsilon, double.MaxValue, -double.MaxValue,
double.PositiveInfinity, double.NegativeInfinity, double.NaN];
foreach (double left in values)
{
foreach (double right in values)
{
// The BCL is an independent oracle for zero-low inputs. NaNs
// are canonicalized by this library, regardless of the CPU payload.
DoubleDouble expected = new(left % right);
AssertBits(DoubleDouble.Modulus(new DoubleDouble(left), new DoubleDouble(right)), expected);
AssertBits(new DoubleDouble(left) % new DoubleDouble(right), expected);
AssertBits(new DoubleDouble(left) % right, expected);
AssertBits(left % new DoubleDouble(right), expected);
}
}
}
[Fact]
public void ExactCasesIncludeSparseLowsCancellationAndUnrepresentableQuotients()
{
// Exact dyadic derivations; no expected result is calculated with DD arithmetic.
(DoubleDouble Left, DoubleDouble Right, DoubleDouble Expected)[] cases =
[
// Tiny perturbations must decide the integer quotient before rounding.
(new(2.0, -double.Epsilon), new(1.0), new(1.0, -double.Epsilon)),
(new(2.0, double.Epsilon), new(1.0), new(double.Epsilon)),
(new(2.0), new(1.0, double.Epsilon), new(1.0, -double.Epsilon)),
(new(2.0), new(1.0, -double.Epsilon), new(2.0 * double.Epsilon)),
// Huge quotient exceeds both binary64 range and DD integer precision.
// MaxValue/epsilon = (2^53 - 1)*2^2045, congruent to 2 modulo 3.
(new(double.MaxValue, double.Epsilon), new(3.0 * double.Epsilon), new(0.0)),
(new(Math.ScaleB(1.0, 1000), 1.0), new(3.0), new(2.0)),
// Dividend smaller than the divisor, and an exact multiple with a low.
(new(1.0, -double.Epsilon), new(1.0), new(1.0, -double.Epsilon)),
(new(2.0, Math.ScaleB(1.0, -53)), new(1.0, Math.ScaleB(1.0, -54)), new(0.0)),
// Exact component sums satisfy left = 5*right, but High/High
// rounds below 5. The estimate must be corrected upward, not accepted.
(new(7.350727155000126, 2.4501029207264783e-16), new(1.4701454310000253, -8.422470454048922e-17), new(0.0)),
// Subnormal integer units: 5 % 3 = 2.
(new(5.0 * double.Epsilon), new(3.0 * double.Epsilon), new(2.0 * double.Epsilon))
];
foreach ((DoubleDouble left, DoubleDouble right, DoubleDouble expected) in cases)
{
foreach (int leftSign in new[] { -1, 1 })
{
foreach (int rightSign in new[] { -1, 1 })
{
DoubleDouble a = leftSign < 0 ? -left : left;
DoubleDouble b = rightSign < 0 ? -right : right;
DoubleDouble result = leftSign < 0 ? -expected : expected;
AssertBits(a % b, result);
AssertBits(DoubleDouble.Modulus(a, b), result);
AssertBits(GenericModulus(a, b), result);
if (b.Low == 0.0)
{
AssertBits(a % b.High, result);
}
if (a.Low == 0.0)
{
AssertBits(a.High % b, result);
}
}
}
}
}
[Fact]
public void NonfiniteDispatchDoesNotDiscardAFiniteDividendLow()
{
DoubleDouble value = new(1.0, double.Epsilon);
foreach (DoubleDouble left in new[] { value, -value })
{
AssertBits(left % DoubleDouble.PositiveInfinity, left);
AssertBits(left % DoubleDouble.NegativeInfinity, left);
AssertBits(left % double.PositiveInfinity, left);
AssertBits(left % DoubleDouble.Zero, DoubleDouble.NaN);
AssertBits(left % DoubleDouble.NegativeZero, DoubleDouble.NaN);
AssertBits(left % DoubleDouble.NaN, DoubleDouble.NaN);
AssertBits(DoubleDouble.PositiveInfinity % left, DoubleDouble.NaN);
AssertBits(DoubleDouble.NaN % left, DoubleDouble.NaN);
}
}
[Fact]
public void ModulusMeetsExactRationalBoundAcrossExponentsAndOverloads()
{
DoubleDouble[] values = FiniteCases();
foreach (DoubleDouble left in values)
{
foreach (DoubleDouble right in values)
{
AssertRemainder(left % right, left, right);
AssertRemainder(left % right.High, left, new DoubleDouble(right.High));
AssertRemainder(left.High % right, new DoubleDouble(left.High), right);
AssertBits(DoubleDouble.Modulus(left, right), left % right);
AssertBits(DoubleDouble.Modulus(left, right.High), left % right.High);
AssertBits(DoubleDouble.Modulus(left.High, right), left.High % right);
AssertBits(left % right.High, left % new DoubleDouble(right.High));
AssertBits(left.High % right, new DoubleDouble(left.High) % right);
}
}
}
[Fact]
public void ModulusSamplesEveryFiniteBinaryExponent()
{
for (int exponent = -1074; exponent <= 1023; ++exponent)
{
double high = Math.ScaleB(1.5, exponent);
DoubleDouble left = DoubleDouble.FromComponents(high, Math.ScaleB(high, -54));
DoubleDouble right = DoubleDouble.FromComponents(3.0, -double.Epsilon);
AssertRemainder(left % right, left, right);
AssertRemainder(right % left, right, left);
AssertRemainder(-left % right, -left, right);
}
}
[Theory]
[InlineData(0)]
[InlineData(1)]
[InlineData(2)]
public void OrdinaryTwoComponentModulusDoesNotAllocate(int overload)
{
// Include ordinary/dense operands, sparse quotient-boundary corrections,
// and common power-of-two rescalings; assertions/setup are not measured.
(DoubleDouble Left, DoubleDouble Right)[] cases =
[
(new(5.5, Math.ScaleB(1.0, -54)), new(2.0, Math.ScaleB(1.0, -53))),
(new(2.0, -double.Epsilon), new(1.0, double.Epsilon)),
(new(2.0, double.Epsilon), new(1.0, -double.Epsilon)),
(new(2.0, Math.ScaleB(1.0, -53)), new(1.0, Math.ScaleB(1.0, -54))),
(new(7.350727155000126, 2.4501029207264783e-16), new(1.4701454310000253, -8.422470454048922e-17)),
(new(Math.ScaleB(5.5, -1000), Math.ScaleB(1.0, -1054)), new(Math.ScaleB(2.0, -1000), Math.ScaleB(1.0, -1053))),
(new(Math.ScaleB(5.5, 1000), Math.ScaleB(1.0, 946)), new(Math.ScaleB(2.0, 1000), Math.ScaleB(1.0, 947))),
// Identity/equality need no arithmetic even outside the kernel range.
(new(1.0, -double.Epsilon), new(double.MaxValue, double.Epsilon)),
(new(double.MaxValue, double.Epsilon), new(double.MaxValue, double.Epsilon))
];
foreach ((DoubleDouble left, DoubleDouble right) in cases)
{
foreach (int sign in new[] { -1, 1 })
{
DoubleDouble dividend = sign < 0 ? -left : left;
_ = MeasureModulusAllocations(overload, dividend, right, out _);
long allocated = MeasureModulusAllocations(overload, dividend, right, out DoubleDouble result);
AssertRemainder(result, overload == 2 ? new DoubleDouble(dividend.High) : dividend,
overload == 1 ? new DoubleDouble(right.High) : right);
allocated.ShouldBe(0L, $"overload {overload}: ({dividend.High:R}, {dividend.Low:R}) % ({right.High:R}, {right.Low:R})");
}
}
}
[Fact]
public void ModulusHandlesKernelRangeAndQuotientGuardNeighbors()
{
foreach (int exponent in new[] { -1000, -969, -54, 0, 450, 968, 1018, 1019 })
{
DoubleDouble left = DoubleDouble.FromComponents(Math.ScaleB(5.5, exponent), Math.ScaleB(1.0, exponent - 54));
DoubleDouble right = DoubleDouble.FromComponents(Math.ScaleB(2.0, exponent), -Math.ScaleB(1.0, exponent - 53));
AssertRemainder(left % right, left, right);
AssertRemainder(left % right.High, left, new DoubleDouble(right.High));
AssertRemainder(left.High % right, new DoubleDouble(left.High), right);
}
double limit = Math.ScaleB(1.0, 52);
foreach (double high in new[] { Math.BitDecrement(limit), limit, Math.BitIncrement(limit), limit + 1.0 })
{
foreach (double low in new[] { -double.Epsilon, 0.0, double.Epsilon, -0.25, 0.25 })
{
DoubleDouble left = DoubleDouble.FromComponents(high, low);
foreach (DoubleDouble right in new[] { new DoubleDouble(1.0, -double.Epsilon),
DoubleDouble.One, new DoubleDouble(1.0, double.Epsilon) })
{
AssertRemainder(left % right, left, right);
AssertRemainder(-left % right, -left, right);
}
}
}
}
[Fact]
public void RandomSignificandsMeetTheIndependentRemainderBound()
{
Random random = new(0x51a7);
int[] gaps = [0, 1, 2, 10, 51, 52, 53, 54, 500];
for (int i = 0; i < 5000; ++i)
{
int exponent = random.Next(-1000, 501);
int gap = gaps[i % gaps.Length];
DoubleDouble left = DoubleDouble.FromComponents(Math.ScaleB(1.0 + random.NextDouble(), exponent + gap),
Math.ScaleB(random.NextDouble() - 0.5, exponent + gap - 53));
DoubleDouble right = DoubleDouble.FromComponents(Math.ScaleB(1.0 + random.NextDouble(), exponent),
Math.ScaleB(random.NextDouble() - 0.5, exponent - 53));
AssertRemainder(left % right, left, right);
AssertRemainder(-left % -right, -left, -right);
AssertRemainder(left % right.High, left, new DoubleDouble(right.High));
AssertRemainder(left.High % right, new DoubleDouble(left.High), right);
AssertBits(left % right.High, left % new DoubleDouble(right.High));
AssertBits(left.High % right, new DoubleDouble(left.High) % right);
}
}
[Fact]
public void UnsafeQuotientAndUpperRangeRetainTheExactAllocatingFallback()
{
(DoubleDouble Left, DoubleDouble Right)[] cases =
[
(new(Math.ScaleB(1.0, 1000), 1.0), new(3.0)),
(new(Math.ScaleB(1.0, 1022), Math.ScaleB(1.0, 968)), new(Math.ScaleB(3.0, 1020), Math.ScaleB(1.0, 966)))
];
foreach ((DoubleDouble left, DoubleDouble right) in cases)
{
_ = MeasureModulusAllocations(0, left, right, out _);
long allocated = MeasureModulusAllocations(0, left, right, out DoubleDouble result);
allocated.ShouldBeGreaterThan(0L);
AssertRemainder(result, left, right);
}
}
private static long MeasureModulusAllocations(int overload, DoubleDouble left, DoubleDouble right, out DoubleDouble result)
{
result = default;
long before = GC.GetAllocatedBytesForCurrentThread();
for (int i = 0; i < 128; ++i)
{
result = overload switch
{
0 => left % right,
1 => left % right.High,
2 => left.High % right,
_ => throw new ArgumentOutOfRangeException(nameof(overload))
};
}
return GC.GetAllocatedBytesForCurrentThread() - before;
}
private static DoubleDouble[] FiniteCases()
{
List<DoubleDouble> values = [];
foreach (int exponent in new[] { -1074, -1022, -969, -450, -54, 0, 54, 450, 969, 1023 })
{
double high = Math.ScaleB(1.5, exponent);
foreach (double low in new[] { 0.0, Math.ScaleB(high, -54), -Math.ScaleB(high, -54), double.Epsilon, -double.Epsilon })
{
DoubleDouble value = DoubleDouble.FromComponents(high, low);
if (!DoubleDouble.IsZero(value))
{
values.Add(value);
values.Add(-value);
}
}
}
values.Add(DoubleDouble.FromComponents(double.MaxValue, Math.BitDecrement(Math.ScaleB(1.0, 970))));
values.Add(new DoubleDouble(1.0, -Math.ScaleB(1.0, -54)));
values.Add(new DoubleDouble(2.0, -double.Epsilon));
return values.ToArray();
}
private static void AssertRemainder(DoubleDouble actual, DoubleDouble left, DoubleDouble right)
{
// Independent exact dyadic oracle: extract significands with BCL scaling,
// then truncate the exact rational quotient as an integer. Never divide,
// multiply or subtract using the implementation under test.
BigInteger dividend = Units(left.High) + Units(left.Low);
BigInteger divisor = Units(right.High) + Units(right.Low);
BigInteger expected = dividend - ((dividend / divisor) * divisor);
DoubleDouble.IsFinite(actual).ShouldBeTrue();
DoubleDouble.IsCanonical(actual).ShouldBeTrue();
DoubleDouble.IsNegative(actual).ShouldBe(DoubleDouble.IsNegative(left));
if (expected.IsZero)
{
AssertBits(actual, new DoubleDouble(Math.CopySign(0.0, left.High)));
return;
}
actual.High.ShouldNotBe(0.0);
BigInteger observed = Units(actual.High) + Units(actual.Low);
BigInteger error = BigInteger.Abs(observed - expected);
// The usual arithmetic bound, in units of epsilon: relative 2^-100
// plus one minimum subnormal. Keep both components in the comparison.
(error << 100).ShouldBeLessThanOrEqualTo(BigInteger.Abs(expected) + (BigInteger.One << 100));
}
private static BigInteger Units(double value)
{
if (value == 0.0)
{
return BigInteger.Zero;
}
int exponent = Math.ILogB(value);
BigInteger significand = new(Math.ScaleB(Math.Abs(value), -exponent) * 4503599627370496.0);
BigInteger units = significand << (exponent + 1022);
return double.IsNegative(value) ? -units : units;
}
private static T GenericModulus<T>(T left, T right) where T : INumber<T> => left % right;
private static T GenericScalarModulus<T>(T left, double right) where T : IModulusOperators<T, double, T> => left % right;
private static void AssertBits(DoubleDouble actual, DoubleDouble expected)
{
BitConverter.DoubleToInt64Bits(actual.High).ShouldBe(BitConverter.DoubleToInt64Bits(expected.High));
BitConverter.DoubleToInt64Bits(actual.Low).ShouldBe(BitConverter.DoubleToInt64Bits(expected.Low));
}
}
@@ -0,0 +1,99 @@
namespace Just.PreciseMath.Tests;
public class DoubleDoubleNumberTests
{
[Fact]
public void NumberDefaultsUseBothComponents()
{
DoubleDouble lower = new(1.0, -double.Epsilon);
DoubleDouble upper = new(1.0, double.Epsilon);
AssertBits(Max(lower, upper), upper);
AssertBits(Max(upper, lower), upper);
AssertBits(MaxNumber(lower, upper), upper);
AssertBits(Min(lower, upper), lower);
AssertBits(Min(upper, lower), lower);
AssertBits(MinNumber(lower, upper), lower);
AssertBits(Clamp(DoubleDouble.Zero, lower, upper), lower);
AssertBits(Clamp(DoubleDouble.One, lower, upper), DoubleDouble.One);
AssertBits(Clamp(new DoubleDouble(2.0), lower, upper), upper);
Should.Throw<ArgumentException>(() => Clamp(DoubleDouble.One, upper, lower));
AssertBits(CopySign(upper, DoubleDouble.NegativeZero), new DoubleDouble(-1.0, -double.Epsilon));
AssertBits(CopySign(-upper, DoubleDouble.Zero), upper);
Sign(lower).ShouldBe(1);
Sign(-upper).ShouldBe(-1);
}
[Fact]
public void NumberDefaultsHandleSpecialValuesAndSignedZeros()
{
double[] values = [0.0, -0.0, 1.0, -1.0, double.Epsilon, -double.Epsilon,
double.MaxValue, double.PositiveInfinity, double.NegativeInfinity, double.NaN];
foreach (double left in values)
{
DoubleDouble x = new(left);
foreach (double right in values)
{
DoubleDouble y = new(right);
AssertBits(Max(x, y), new DoubleDouble(double.Max(left, right)));
AssertBits(Min(x, y), new DoubleDouble(double.Min(left, right)));
AssertBits(MaxNumber(x, y), new DoubleDouble(double.MaxNumber(left, right)));
AssertBits(MinNumber(x, y), new DoubleDouble(double.MinNumber(left, right)));
AssertBits(CopySign(x, y), new DoubleDouble(double.CopySign(left, right)));
}
AssertBits(Clamp(x, DoubleDouble.NegativeOne, DoubleDouble.One), new DoubleDouble(double.Clamp(left, -1.0, 1.0)));
if (double.IsNaN(left))
{
Should.Throw<ArithmeticException>(() => Sign(x));
Should.Throw<ArithmeticException>(() => DoubleDouble.Sign(x));
}
else
{
Sign(x).ShouldBe(double.Sign(left));
DoubleDouble.Sign(x).ShouldBe(double.Sign(left));
}
}
}
#if NET10_0_OR_GREATER
[Fact]
public void NativeDefaultsRetainFullComponentOrdering()
{
DoubleDouble lower = new(1.0, -double.Epsilon);
DoubleDouble upper = new(1.0, double.Epsilon);
AssertBits(MaxNative(lower, upper), upper);
AssertBits(MinNative(lower, upper), lower);
AssertBits(ClampNative(DoubleDouble.Zero, lower, upper), lower);
AssertBits(ClampNative(DoubleDouble.One, lower, upper), DoubleDouble.One);
AssertBits(ClampNative(new DoubleDouble(2.0), lower, upper), upper);
Should.Throw<ArgumentException>(() => ClampNative(DoubleDouble.One, upper, lower));
}
#endif
private static T Clamp<T>(T value, T min, T max) where T : INumber<T> => T.Clamp(value, min, max);
private static T CopySign<T>(T value, T sign) where T : INumber<T> => T.CopySign(value, sign);
private static T Max<T>(T x, T y) where T : INumber<T> => T.Max(x, y);
private static T MaxNumber<T>(T x, T y) where T : INumber<T> => T.MaxNumber(x, y);
private static T Min<T>(T x, T y) where T : INumber<T> => T.Min(x, y);
private static T MinNumber<T>(T x, T y) where T : INumber<T> => T.MinNumber(x, y);
private static int Sign<T>(T value) where T : INumber<T> => T.Sign(value);
#if NET10_0_OR_GREATER
private static T ClampNative<T>(T value, T min, T max) where T : INumber<T> => T.ClampNative(value, min, max);
private static T MaxNative<T>(T x, T y) where T : INumber<T> => T.MaxNative(x, y);
private static T MinNative<T>(T x, T y) where T : INumber<T> => T.MinNative(x, y);
#endif
private static void AssertBits(DoubleDouble actual, DoubleDouble expected)
{
BitConverter.DoubleToInt64Bits(actual.High).ShouldBe(BitConverter.DoubleToInt64Bits(expected.High));
BitConverter.DoubleToInt64Bits(actual.Low).ShouldBe(BitConverter.DoubleToInt64Bits(expected.Low));
}
}