implemented ISignedNumber
.NET Test / .NET tests (push) Successful in 4m27s

This commit is contained in:
2026-09-13 23:08:27 +04:00
parent 082fd84c87
commit e51874b0c3
13 changed files with 1286 additions and 43 deletions
@@ -0,0 +1,202 @@
namespace Just.PreciseMath;
/// <remarks>
/// Generic conversions support the built-in binary floating, decimal, and integer types,
/// including native integers, 128-bit integers, and BigInteger. Integer output truncates
/// the exact component sum toward zero before range handling: checked throws, saturating
/// clamps, and truncating keeps the low destination-width bits. Nonfinite integer output
/// follows the destination's binary64 conversion policy. Decimal saturating/truncating
/// output clamps infinities and out-of-range values and maps NaN to zero. Binary floating
/// output rounds directly to the destination precision and preserves IEEE special values.
/// </remarks>
public readonly partial struct DoubleDouble
{
/// <summary>
/// Converts a supported number, rounding the high component and its residual to binary64.
/// Floating overflow produces signed infinity, including for BigInteger inputs.
/// Unsupported types are offered the corresponding source conversion hook, then rejected.
/// </summary>
public static DoubleDouble CreateChecked<TOther>(TOther value) where TOther : INumberBase<TOther>
{
if (GenericTryConvertFrom(value, out DoubleDouble result) || TOther.TryConvertToChecked(value, out result))
{
return result;
}
throw new NotSupportedException($"Conversion from {typeof(TOther)} to DoubleDouble is not supported.");
}
/// <summary>Converts with floating-point range semantics: overflow produces signed infinity.</summary>
public static DoubleDouble CreateSaturating<TOther>(TOther value) where TOther : INumberBase<TOther>
{
if (GenericTryConvertFrom(value, out DoubleDouble result) || TOther.TryConvertToSaturating(value, out result))
{
return result;
}
throw new NotSupportedException($"Conversion from {typeof(TOther)} to DoubleDouble is not supported.");
}
/// <summary>Converts with floating-point rounding; finite precision is not integer truncation.</summary>
public static DoubleDouble CreateTruncating<TOther>(TOther value) where TOther : INumberBase<TOther>
{
if (GenericTryConvertFrom(value, out DoubleDouble result) || TOther.TryConvertToTruncating(value, out result))
{
return result;
}
throw new NotSupportedException($"Conversion from {typeof(TOther)} to DoubleDouble is not supported.");
}
static bool INumberBase<DoubleDouble>.TryConvertFromChecked<TOther>(TOther value, out DoubleDouble result)
{
return GenericTryConvertFrom(value, out result);
}
static bool INumberBase<DoubleDouble>.TryConvertFromSaturating<TOther>(TOther value, out DoubleDouble result)
{
return GenericTryConvertFrom(value, out result);
}
static bool INumberBase<DoubleDouble>.TryConvertFromTruncating<TOther>(TOther value, out DoubleDouble result)
{
return GenericTryConvertFrom(value, out result);
}
static bool INumberBase<DoubleDouble>.TryConvertToChecked<TOther>(DoubleDouble value, out TOther result)
{
return GenericTryConvertTo(value, GenericConversionMode.Checked, out result);
}
static bool INumberBase<DoubleDouble>.TryConvertToSaturating<TOther>(DoubleDouble value, out TOther result)
{
return GenericTryConvertTo(value, GenericConversionMode.Saturating, out result);
}
static bool INumberBase<DoubleDouble>.TryConvertToTruncating<TOther>(DoubleDouble value, out TOther result)
{
return GenericTryConvertTo(value, GenericConversionMode.Truncating, out result);
}
private enum GenericConversionMode
{
Checked,
Saturating,
Truncating,
}
private static bool GenericIsInteger<TOther>()
{
return typeof(TOther) == typeof(byte) || typeof(TOther) == typeof(sbyte)
|| typeof(TOther) == typeof(short) || typeof(TOther) == typeof(ushort)
|| typeof(TOther) == typeof(int) || typeof(TOther) == typeof(uint)
|| typeof(TOther) == typeof(long) || typeof(TOther) == typeof(ulong)
|| typeof(TOther) == typeof(nint) || typeof(TOther) == typeof(nuint)
|| typeof(TOther) == typeof(Int128) || typeof(TOther) == typeof(UInt128)
|| typeof(TOther) == typeof(char) || typeof(TOther) == typeof(BigInteger);
}
private static bool GenericTryConvertFrom<TOther>(TOther value, out DoubleDouble result) where TOther : INumberBase<TOther>
{
// Never forward an unknown type to Create: the default two-sided dispatch would recurse.
if (value is DoubleDouble same)
{
result = same;
}
else if (value is double binary64)
{
result = new DoubleDouble(binary64);
}
else if (value is float binary32)
{
result = new DoubleDouble((double)binary32);
}
else if (value is Half binary16)
{
result = new DoubleDouble((double)binary16);
}
else if (value is decimal decimalValue)
{
result = new DoubleDouble(decimalValue);
}
else if (GenericIsInteger<TOther>())
{
result = PreciseMathHelper.ArithmeticFromRatio(BigInteger.CreateChecked(value), BigInteger.One);
}
else
{
result = Zero;
return false;
}
return true;
}
private static bool GenericTryConvertTo<TOther>(DoubleDouble value, GenericConversionMode mode, out TOther result) where TOther : INumberBase<TOther>
{
object converted;
if (typeof(TOther) == typeof(DoubleDouble))
{
converted = value;
}
else if (typeof(TOther) == typeof(double))
{
converted = (double)value;
}
else if (typeof(TOther) == typeof(float))
{
converted = (float)value;
}
else if (typeof(TOther) == typeof(Half))
{
if (value._low == 0.0)
{
converted = (Half)value._high;
}
else
{
(BigInteger numerator, BigInteger denominator) = value.ConversionFraction();
converted = (Half)ConversionRoundBinary(numerator, denominator, 11, -24);
}
}
else if (typeof(TOther) == typeof(decimal))
{
if (mode == GenericConversionMode.Checked)
{
converted = (decimal)value;
}
else if (double.IsNaN(value._high))
{
converted = decimal.Zero;
}
else
{
DoubleDouble maximum = new(decimal.MaxValue);
converted = value > maximum ? decimal.MaxValue
: value < -maximum ? decimal.MinValue : (decimal)value;
}
}
else if (GenericIsInteger<TOther>())
{
if (!double.IsFinite(value._high))
{
// BigInteger has no finite endpoints. Match its BCL nonfinite policy (throw).
// Bounded integer targets use the BCL's NaN and infinity mapping.
result = mode == GenericConversionMode.Checked ? TOther.CreateChecked(value._high)
: mode == GenericConversionMode.Saturating ? TOther.CreateSaturating(value._high)
: TOther.CreateTruncating(value._high);
return true;
}
// Truncate the exact sum first. Saturating clamps; truncating retains the
// low destination-width bits, as in BigInteger's generic conversion contract.
BigInteger integer = value.ConversionInteger();
result = mode == GenericConversionMode.Checked ? TOther.CreateChecked(integer)
: mode == GenericConversionMode.Saturating ? TOther.CreateSaturating(integer)
: TOther.CreateTruncating(integer);
return true;
}
else
{
result = default!;
return false;
}
result = (TOther)converted;
return true;
}
}
@@ -0,0 +1,143 @@
namespace Just.PreciseMath;
public readonly partial struct DoubleDouble
{
/// <summary>Gets the binary radix of the components.</summary>
public static int Radix => 2;
/// <summary>Returns the absolute value, preserving both components and canonicalizing NaN.</summary>
public static DoubleDouble Abs(DoubleDouble value)
{
if (IsNaN(value))
{
return NaN;
}
return IsNegative(value) ? -value : value;
}
/// <summary>Returns the operand with greater magnitude; ties prefer positive values and NaN propagates.</summary>
public static DoubleDouble MaxMagnitude(DoubleDouble x, DoubleDouble y)
{
if (IsNaN(x) || IsNaN(y))
{
return NaN;
}
DoubleDouble ax = Abs(x);
DoubleDouble ay = Abs(y);
return ax > ay || (ax == ay && !IsNegative(x)) ? x : y;
}
/// <summary>Returns the operand with lesser magnitude; ties prefer negative values and NaN propagates.</summary>
public static DoubleDouble MinMagnitude(DoubleDouble x, DoubleDouble y)
{
if (IsNaN(x) || IsNaN(y))
{
return NaN;
}
DoubleDouble ax = Abs(x);
DoubleDouble ay = Abs(y);
return ax < ay || (ax == ay && IsNegative(x)) ? x : y;
}
/// <summary>Returns the greater-magnitude operand, preferring a number over NaN and positive values on ties.</summary>
public static DoubleDouble MaxMagnitudeNumber(DoubleDouble x, DoubleDouble y)
{
if (IsNaN(x))
{
return y;
}
return IsNaN(y) ? x : MaxMagnitude(x, y);
}
/// <summary>Returns the lesser-magnitude operand, preferring a number over NaN and negative values on ties.</summary>
public static DoubleDouble MinMagnitudeNumber(DoubleDouble x, DoubleDouble y)
{
if (IsNaN(x))
{
return y;
}
return IsNaN(y) ? x : MinMagnitude(x, y);
}
/// <summary>Adds one using double-double arithmetic, including its overflow and nonfinite behavior.</summary>
public static DoubleDouble operator ++(DoubleDouble value)
{
return value + One;
}
/// <summary>Subtracts one using double-double arithmetic, including its overflow and nonfinite behavior.</summary>
public static DoubleDouble operator --(DoubleDouble value)
{
return value - One;
}
/// <summary>Tests normalization and canonical NaN and zero-residual bit patterns.</summary>
public static bool IsCanonical(DoubleDouble value)
{
DoubleDouble normalized = FromComponents(value._high, value._low);
return BitConverter.DoubleToInt64Bits(value._high) == BitConverter.DoubleToInt64Bits(normalized._high)
&& BitConverter.DoubleToInt64Bits(value._low) == BitConverter.DoubleToInt64Bits(normalized._low);
}
/// <summary>Returns false: this type has no complex values.</summary>
public static bool IsComplexNumber(DoubleDouble value)
{
return false;
}
/// <summary>Returns false: this type has no imaginary values.</summary>
public static bool IsImaginaryNumber(DoubleDouble value)
{
return false;
}
/// <summary>Tests whether the value is real, including infinities but excluding NaN.</summary>
public static bool IsRealNumber(DoubleDouble value)
{
return !IsNaN(value);
}
/// <summary>Tests the high component's sign bit, including positive zero.</summary>
public static bool IsPositive(DoubleDouble value)
{
return double.IsPositive(value._high);
}
/// <summary>Tests whether the normalized high component is a normal binary64 value.</summary>
public static bool IsNormal(DoubleDouble value)
{
return double.IsNormal(value._high);
}
/// <summary>Tests whether the normalized high component is subnormal.</summary>
public static bool IsSubnormal(DoubleDouble value)
{
return double.IsSubnormal(value._high);
}
/// <summary>Tests for either sign of zero.</summary>
public static bool IsZero(DoubleDouble value)
{
return value._high == 0.0 && value._low == 0.0;
}
/// <summary>Tests whether the exact sum of the normalized components is an integer.</summary>
public static bool IsInteger(DoubleDouble value)
{
// In a normalized pair, a fractional high cannot be made integral by
// its nonoverlapping residual. An integral high requires an integral low.
return double.IsInteger(value._high) && double.IsInteger(value._low);
}
/// <summary>Tests integer parity without discarding the residual above 2^53.</summary>
public static bool IsEvenInteger(DoubleDouble value)
{
return IsInteger(value) && double.IsOddInteger(value._high) == double.IsOddInteger(value._low);
}
/// <summary>Tests integer parity without discarding the residual above 2^53.</summary>
public static bool IsOddInteger(DoubleDouble value)
{
return IsInteger(value) && double.IsOddInteger(value._high) != double.IsOddInteger(value._low);
}
}
@@ -0,0 +1,222 @@
using System.Globalization;
namespace Just.PreciseMath;
public readonly partial struct DoubleDouble
{
/// <summary>Parses decimal text using the specified styles and culture (current when null).</summary>
/// <remarks>Supports all combinations of the decimal flags in NumberStyles.Any. The 2048-character limit,
/// exact coefficient conversion, signed zero, and overflow behavior of the provider-only parser apply.
/// Special values (including "inf") accept surrounding whitespace and culture-specific signs
/// independently of decimal style flags.</remarks>
/// <exception cref="ArgumentException">The style contains hexadecimal, binary, or undefined flags.</exception>
/// <exception cref="ArgumentNullException">The input is null.</exception>
/// <exception cref="FormatException">The input is invalid or exceeds 2048 characters.</exception>
public static DoubleDouble Parse(string s, NumberStyles style, IFormatProvider? provider = null)
{
ValidateParsingStyle(style);
ArgumentNullException.ThrowIfNull(s);
return Parse(s.AsSpan(), style, provider);
}
/// <summary>Parses decimal text using the specified styles and culture (current when null).</summary>
/// <exception cref="ArgumentException">The style contains hexadecimal, binary, or undefined flags.</exception>
/// <exception cref="FormatException">The input is invalid or exceeds 2048 characters.</exception>
public static DoubleDouble Parse(ReadOnlySpan<char> s, NumberStyles style, IFormatProvider? provider = null)
{
if (!TryParse(s, style, provider, out DoubleDouble result))
{
throw new FormatException($"Invalid or unsupported DoubleDouble text (maximum {MaximumParsingLength} characters).");
}
return result;
}
/// <summary>Parses decimal text; returns false and Zero for null, invalid, or oversized input.</summary>
/// <exception cref="ArgumentException">The style contains hexadecimal, binary, or undefined flags.</exception>
public static bool TryParse(string? s, NumberStyles style, IFormatProvider? provider, out DoubleDouble result)
{
return TryParse(s.AsSpan(), style, provider, out result);
}
/// <summary>Parses decimal text; returns false and Zero for invalid or oversized input.</summary>
/// <remarks>Group sizes are not validated. Currency styles recognize currency separators, and number
/// separators before a currency symbol. No conversion through double or decimal is performed.</remarks>
/// <exception cref="ArgumentException">The style contains hexadecimal, binary, or undefined flags.</exception>
public static bool TryParse(ReadOnlySpan<char> s, NumberStyles style, IFormatProvider? provider, out DoubleDouble result)
{
ValidateParsingStyle(style);
result = Zero;
if (s.Length > MaximumParsingLength)
{
return false;
}
if ((style & NumberStyles.AllowLeadingWhite) != 0)
{
s = s.TrimStart();
}
if ((style & NumberStyles.AllowTrailingWhite) != 0)
{
s = s.TrimEnd();
}
if (s.IsEmpty)
{
return false;
}
NumberFormatInfo info = NumberFormatInfo.GetInstance(provider);
if (ParsingTrySpecial(s, info, out result))
{
return true;
}
bool negative = false;
bool signSeen = false;
bool parentheses = false;
bool currencySeen = false;
bool allowCurrency = (style & NumberStyles.AllowCurrencySymbol) != 0;
while (!s.IsEmpty)
{
if ((style & NumberStyles.AllowLeadingWhite) != 0 && char.IsWhiteSpace(s[0])
&& (!signSeen || currencySeen || info.NumberNegativePattern == 2))
{
s = s[1..];
}
else if (!signSeen && (style & NumberStyles.AllowLeadingSign) != 0
&& StyledConsumeSign(ref s, info, out negative))
{
signSeen = true;
}
else if (!signSeen && (style & NumberStyles.AllowParentheses) != 0 && s[0] == '(')
{
s = s[1..];
signSeen = negative = parentheses = true;
}
else if (allowCurrency && !currencySeen && StyledConsumeToken(ref s, info.CurrencySymbol))
{
currencySeen = true;
}
else
{
break;
}
}
// Normalize only syntax; preserve every digit for the exact-rational parser.
// Reserving the first character lets a trailing sign become a leading sign.
char[] buffer = new char[s.Length + 2];
int length = 1;
bool digitSeen = false;
bool decimalSeen = false;
string decimalSeparator = allowCurrency ? info.CurrencyDecimalSeparator : info.NumberDecimalSeparator;
string groupSeparator = allowCurrency ? info.CurrencyGroupSeparator : info.NumberGroupSeparator;
while (!s.IsEmpty)
{
if (s[0] is >= '0' and <= '9')
{
buffer[length++] = s[0];
s = s[1..];
digitSeen = true;
}
else if (!decimalSeen && (style & NumberStyles.AllowDecimalPoint) != 0
&& (StyledConsumeToken(ref s, decimalSeparator)
|| (allowCurrency && !currencySeen && StyledConsumeToken(ref s, info.NumberDecimalSeparator))))
{
decimalSeen = true;
buffer[length++] = '.';
}
else if (digitSeen && !decimalSeen && (style & NumberStyles.AllowThousands) != 0
&& (StyledConsumeToken(ref s, groupSeparator)
|| (allowCurrency && !currencySeen && StyledConsumeToken(ref s, info.NumberGroupSeparator))))
{
// Group sizes are deliberately not enforced, like standard numeric parsing.
}
else
{
break;
}
}
if (!digitSeen)
{
return false;
}
if (!s.IsEmpty && s[0] is 'e' or 'E' && (style & NumberStyles.AllowExponent) != 0)
{
buffer[length++] = 'E';
s = s[1..];
if (StyledConsumeSign(ref s, info, out bool negativeExponent))
{
buffer[length++] = negativeExponent ? '-' : '+';
}
int exponentStart = length;
while (!s.IsEmpty && s[0] is >= '0' and <= '9')
{
buffer[length++] = s[0];
s = s[1..];
}
if (length == exponentStart)
{
return false;
}
}
while (!s.IsEmpty)
{
if ((style & NumberStyles.AllowTrailingWhite) != 0 && char.IsWhiteSpace(s[0]))
{
s = s[1..];
}
else if (!signSeen && (style & NumberStyles.AllowTrailingSign) != 0
&& StyledConsumeSign(ref s, info, out negative))
{
signSeen = true;
}
else if (parentheses && s[0] == ')')
{
parentheses = false;
s = s[1..];
}
else if (allowCurrency && !currencySeen && StyledConsumeToken(ref s, info.CurrencySymbol))
{
currencySeen = true;
}
else
{
return false;
}
}
if (parentheses)
{
return false;
}
buffer[0] = '-';
int start = negative ? 0 : 1;
return TryParse(buffer.AsSpan(start, length - start), CultureInfo.InvariantCulture, out result);
}
private static void ValidateParsingStyle(NumberStyles style)
{
if ((style & ~NumberStyles.Any) != 0)
{
throw new ArgumentException("Only decimal NumberStyles flags are supported.", nameof(style));
}
}
private static bool StyledConsumeToken(ref ReadOnlySpan<char> text, string token)
{
if (token.Length == 0 || !text.StartsWith(token, StringComparison.Ordinal))
{
return false;
}
text = text[token.Length..];
return true;
}
private static bool StyledConsumeSign(ref ReadOnlySpan<char> text, NumberFormatInfo info, out bool negative)
{
negative = false;
if (StyledConsumeToken(ref text, info.PositiveSign))
{
return true;
}
negative = StyledConsumeToken(ref text, info.NegativeSign);
return negative;
}
}
@@ -5,8 +5,11 @@ namespace Just.PreciseMath;
/// <remarks> /// <remarks>
/// Parsing supports decimal/scientific notation with ASCII digits, surrounding whitespace, /// Parsing supports decimal/scientific notation with ASCII digits, surrounding whitespace,
/// culture-specific signs and decimal separator, and NaN/infinity symbols (case-insensitive). /// culture-specific signs and decimal separator, and NaN/infinity symbols (case-insensitive).
/// Group separators, currency, hexadecimal notation, and NumberStyles options are not supported. /// The case-insensitive alias "inf" also denotes infinity, with optional culture-specific sign.
/// Inputs are limited to 4096 characters, including surrounding whitespace, to bound work. /// Exact custom special symbols take precedence over the alias.
/// Provider-only overloads do not accept group separators or currency; explicit NumberStyles
/// overloads enable those decimal options. Hexadecimal and binary notation are not supported.
/// Inputs are limited to 2048 characters, including surrounding whitespace, to bound work.
/// The exact decimal coefficient and exponent are converted to a normalized high/low pair, /// The exact decimal coefficient and exponent are converted to a normalized high/low pair,
/// not through double or decimal. Components are rounded nearest, ties to even, then normalized; /// not through double or decimal. Components are rounded nearest, ties to even, then normalized;
/// a second rounding at the overflow midpoint is kept finite when the exact input is below it. /// a second rounding at the overflow midpoint is kept finite when the exact input is below it.
@@ -15,11 +18,11 @@ namespace Just.PreciseMath;
/// </remarks> /// </remarks>
public readonly partial struct DoubleDouble : ISpanParsable<DoubleDouble> public readonly partial struct DoubleDouble : ISpanParsable<DoubleDouble>
{ {
private const int MaximumParsingLength = 4096; private const int MaximumParsingLength = 2048;
/// <summary>Parses decimal/scientific text, using the current culture when provider is null.</summary> /// <summary>Parses decimal/scientific text, using the current culture when provider is null.</summary>
/// <exception cref="ArgumentNullException">The input is null.</exception> /// <exception cref="ArgumentNullException">The input is null.</exception>
/// <exception cref="FormatException">The input is malformed, unsupported, or longer than 4096 characters.</exception> /// <exception cref="FormatException">The input is malformed, unsupported, or longer than 2048 characters.</exception>
public static DoubleDouble Parse(string s, IFormatProvider? provider = null) public static DoubleDouble Parse(string s, IFormatProvider? provider = null)
{ {
ArgumentNullException.ThrowIfNull(s); ArgumentNullException.ThrowIfNull(s);
@@ -27,12 +30,12 @@ public readonly partial struct DoubleDouble : ISpanParsable<DoubleDouble>
} }
/// <summary>Parses decimal/scientific text, using the current culture when provider is null.</summary> /// <summary>Parses decimal/scientific text, using the current culture when provider is null.</summary>
/// <exception cref="FormatException">The input is malformed, unsupported, or longer than 4096 characters.</exception> /// <exception cref="FormatException">The input is malformed, unsupported, or longer than 2048 characters.</exception>
public static DoubleDouble Parse(ReadOnlySpan<char> s, IFormatProvider? provider = null) public static DoubleDouble Parse(ReadOnlySpan<char> s, IFormatProvider? provider = null)
{ {
if (!TryParse(s, provider, out DoubleDouble result)) if (!TryParse(s, provider, out DoubleDouble result))
{ {
throw new FormatException("Invalid or unsupported DoubleDouble text (maximum 4096 characters)."); throw new FormatException($"Invalid or unsupported DoubleDouble text (maximum {MaximumParsingLength} characters).");
} }
return result; return result;
} }
@@ -69,33 +72,11 @@ public readonly partial struct DoubleDouble : ISpanParsable<DoubleDouble>
return false; return false;
} }
NumberFormatInfo info = NumberFormatInfo.GetInstance(provider); NumberFormatInfo info = NumberFormatInfo.GetInstance(provider);
// Custom symbols can themselves start with a numeric sign. if (ParsingTrySpecial(s, info, out result))
if (s.Equals(info.NaNSymbol, StringComparison.OrdinalIgnoreCase))
{ {
result = NaN;
return true;
}
if (s.Equals(info.PositiveInfinitySymbol, StringComparison.OrdinalIgnoreCase))
{
result = new DoubleDouble(double.PositiveInfinity);
return true;
}
if (s.Equals(info.NegativeInfinitySymbol, StringComparison.OrdinalIgnoreCase))
{
result = new DoubleDouble(double.NegativeInfinity);
return true; return true;
} }
bool negative = ParsingConsumeSign(ref s, info); bool negative = ParsingConsumeSign(ref s, info);
if (s.Equals(info.NaNSymbol, StringComparison.OrdinalIgnoreCase))
{
result = NaN;
return true;
}
if (s.Equals(info.PositiveInfinitySymbol, StringComparison.OrdinalIgnoreCase))
{
result = new DoubleDouble(negative ? double.NegativeInfinity : double.PositiveInfinity);
return true;
}
BigInteger coefficient = BigInteger.Zero; BigInteger coefficient = BigInteger.Zero;
int significantDigits = 0; int significantDigits = 0;
@@ -190,6 +171,42 @@ public readonly partial struct DoubleDouble : ISpanParsable<DoubleDouble>
return true; return true;
} }
// Special symbols accept outer whitespace and culture signs independently of numeric styles.
private static bool ParsingTrySpecial(ReadOnlySpan<char> s, NumberFormatInfo info, out DoubleDouble result)
{
s = s.Trim();
result = Zero;
// Custom symbols can themselves start with a numeric sign.
if (s.Equals(info.NaNSymbol, StringComparison.OrdinalIgnoreCase))
{
result = NaN;
return true;
}
if (s.Equals(info.PositiveInfinitySymbol, StringComparison.OrdinalIgnoreCase))
{
result = new DoubleDouble(double.PositiveInfinity);
return true;
}
if (s.Equals(info.NegativeInfinitySymbol, StringComparison.OrdinalIgnoreCase))
{
result = new DoubleDouble(double.NegativeInfinity);
return true;
}
bool negative = ParsingConsumeSign(ref s, info);
if (s.Equals(info.NaNSymbol, StringComparison.OrdinalIgnoreCase))
{
result = NaN;
return true;
}
if (s.Equals(info.PositiveInfinitySymbol, StringComparison.OrdinalIgnoreCase)
|| s.Equals("inf", StringComparison.OrdinalIgnoreCase))
{
result = new DoubleDouble(negative ? double.NegativeInfinity : double.PositiveInfinity);
return true;
}
return false;
}
// A sign is optional; do not consume whitespace between it and the number. // A sign is optional; do not consume whitespace between it and the number.
private static bool ParsingConsumeSign(ref ReadOnlySpan<char> text, NumberFormatInfo info) private static bool ParsingConsumeSign(ref ReadOnlySpan<char> text, NumberFormatInfo info)
{ {
+2 -1
View File
@@ -13,7 +13,8 @@ namespace Just.PreciseMath;
/// </remarks> /// </remarks>
public readonly partial struct DoubleDouble : public readonly partial struct DoubleDouble :
IEquatable<DoubleDouble>, IEquatable<DoubleDouble>,
IEqualityOperators<DoubleDouble, DoubleDouble, bool> IEqualityOperators<DoubleDouble, DoubleDouble, bool>,
ISignedNumber<DoubleDouble>
{ {
internal readonly double _high; internal readonly double _high;
internal readonly double _low; internal readonly double _low;
@@ -0,0 +1,20 @@
namespace Just.PreciseMath;
public readonly partial struct DoubleDouble : ISpanFormattable
{
/// <summary>Formats the exact component sum using the same G, E, and F formats as ToString.</summary>
/// <remarks>Uses the existing string formatter to preserve its rounding and culture contracts.
/// This implementation allocates; a short destination is unchanged and charsWritten is zero.</remarks>
/// <exception cref="FormatException">The format is unsupported or its precision exceeds 999.</exception>
public bool TryFormat(Span<char> destination, out int charsWritten, ReadOnlySpan<char> format = default, IFormatProvider? provider = null)
{
charsWritten = 0;
string text = ToString(format.IsEmpty ? null : format.ToString(), provider);
if (!text.AsSpan().TryCopyTo(destination))
{
return false;
}
charsWritten = text.Length;
return true;
}
}
@@ -0,0 +1,115 @@
using System.Globalization;
using Shouldly;
using Xunit;
namespace Just.PreciseMath.Tests;
public class DoubleDoubleNumberStylesTests
{
[Theory]
[InlineData("123", NumberStyles.None, 123.0)]
[InlineData(" -1.25e+2 ", NumberStyles.Float, -125.0)]
[InlineData("1,234.5-", NumberStyles.Number, -1234.5)]
[InlineData("(¤1,234.5)", NumberStyles.Currency, -1234.5)]
[InlineData("1,234.5¤", NumberStyles.Currency, 1234.5)]
[InlineData("(1.25E2)", NumberStyles.Any, -125.0)]
public void DecimalStylesSupportTheirStandardSyntax(string text, NumberStyles style, double expected)
{
DoubleDouble.Parse(text, style, CultureInfo.InvariantCulture).ShouldBe(new DoubleDouble(expected));
DoubleDouble.Parse(text.AsSpan(), style, CultureInfo.InvariantCulture).ShouldBe(new DoubleDouble(expected));
}
[Theory]
[InlineData(" 1", NumberStyles.None)]
[InlineData("1 ", NumberStyles.None)]
[InlineData("-1", NumberStyles.None)]
[InlineData("1-", NumberStyles.Float)]
[InlineData("1.0", NumberStyles.Integer)]
[InlineData("1e2", NumberStyles.Number)]
[InlineData("1,000", NumberStyles.Float)]
[InlineData("(1)", NumberStyles.Number)]
[InlineData("¤1", NumberStyles.Number)]
[InlineData("--1", NumberStyles.Any)]
[InlineData("1.2,3", NumberStyles.Any)]
[InlineData("(1)-", NumberStyles.Any)]
[InlineData("1e+", NumberStyles.Any)]
public void DisallowedOrMalformedSyntaxFailsWithZero(string text, NumberStyles style)
{
DoubleDouble.TryParse(text, style, CultureInfo.InvariantCulture, out DoubleDouble result).ShouldBeFalse();
result.ShouldBe(DoubleDouble.Zero);
DoubleDouble.TryParse(text.AsSpan(), style, CultureInfo.InvariantCulture, out result).ShouldBeFalse();
result.ShouldBe(DoubleDouble.Zero);
Should.Throw<FormatException>(() => DoubleDouble.Parse(text, style, CultureInfo.InvariantCulture));
Should.Throw<FormatException>(() => DoubleDouble.Parse(text.AsSpan(), style, CultureInfo.InvariantCulture));
}
[Theory]
[InlineData(NumberStyles.HexNumber)]
[InlineData(NumberStyles.BinaryNumber)]
[InlineData((NumberStyles)1024)]
[InlineData((NumberStyles)(-1))]
public void InvalidStylesThrowEvenForNullTryParse(NumberStyles style)
{
Should.Throw<ArgumentException>(() => DoubleDouble.Parse("1", style, null));
Should.Throw<ArgumentException>(() => DoubleDouble.Parse("1".AsSpan(), style, null));
Should.Throw<ArgumentException>(() => DoubleDouble.TryParse((string?)null, style, null, out _));
Should.Throw<ArgumentException>(() => DoubleDouble.TryParse(ReadOnlySpan<char>.Empty, style, null, out _));
}
[Fact]
public void NullAndLengthContractsArePreserved()
{
Should.Throw<ArgumentNullException>(() => DoubleDouble.Parse(null!, NumberStyles.Any, null));
DoubleDouble.TryParse((string?)null, NumberStyles.Any, null, out DoubleDouble result).ShouldBeFalse();
result.ShouldBe(DoubleDouble.Zero);
DoubleDouble.TryParse(new string('0', 2049), NumberStyles.Any, null, out result).ShouldBeFalse();
DoubleDouble.Parse(new string('0', 2048), NumberStyles.None, null).ShouldBe(DoubleDouble.Zero);
DoubleDouble.TryParse("1,000", CultureInfo.InvariantCulture, out _).ShouldBeFalse();
}
[Fact]
public void CultureAndExponentPreserveExactComponents()
{
NumberFormatInfo info = new()
{
NegativeSign = "minus",
PositiveSign = "plus",
NumberDecimalSeparator = ";;",
NumberGroupSeparator = "_",
CurrencyDecimalSeparator = ":",
CurrencyGroupSeparator = "~",
CurrencySymbol = "USD"
};
DoubleDouble.Parse("minus9_007_199_254_740_993;;0Eplus0", NumberStyles.Any, info)
.ShouldBe(DoubleDouble.FromComponents(-9007199254740992.0, -1.0));
DoubleDouble.Parse("USD9~007~199~254~740~993:0", NumberStyles.Currency, info)
.ShouldBe(DoubleDouble.FromComponents(9007199254740992.0, 1.0));
}
[Theory]
[InlineData("-0", -0.0)]
[InlineData("-1e-9999", -0.0)]
[InlineData("1e9999", double.PositiveInfinity)]
[InlineData("-Infinity", double.NegativeInfinity)]
[InlineData("NaN", double.NaN)]
public void NonfiniteAndSignedZeroContractsArePreserved(string text, double expected)
{
DoubleDouble value = DoubleDouble.Parse(text, NumberStyles.Float, CultureInfo.InvariantCulture);
BitConverter.DoubleToInt64Bits(value.High).ShouldBe(BitConverter.DoubleToInt64Bits(expected));
value.Low.ShouldBe(0.0);
}
[Fact]
public void StyledParsingRetainsExactIntegerResidualAcrossOverloads()
{
const string text = " 9,007,199,254,740,993 ";
NumberStyles style = NumberStyles.Number;
DoubleDouble expected = DoubleDouble.FromComponents(9007199254740992.0, 1.0);
DoubleDouble.Parse(text, style, CultureInfo.InvariantCulture).ShouldBe(expected);
DoubleDouble.Parse(text.AsSpan(), style, CultureInfo.InvariantCulture).ShouldBe(expected);
DoubleDouble.TryParse(text, style, CultureInfo.InvariantCulture, out DoubleDouble fromString).ShouldBeTrue();
fromString.ShouldBe(expected);
DoubleDouble.TryParse(text.AsSpan(), style, CultureInfo.InvariantCulture, out DoubleDouble fromSpan).ShouldBeTrue();
fromSpan.ShouldBe(expected);
}
}
@@ -170,17 +170,17 @@ public class DoubleDoubleParsingTests
[Fact] [Fact]
public void InputLengthIsBoundedBeforeIgnoringWhitespaceOrLeadingZeros() public void InputLengthIsBoundedBeforeIgnoringWhitespaceOrLeadingZeros()
{ {
AssertParsed(new string('0', 4095) + "1", CultureInfo.InvariantCulture, 1.0, 0.0); AssertParsed(new string('0', 2047) + "1", CultureInfo.InvariantCulture, 1.0, 0.0);
AssertParsed("1" + new string('0', 4095), CultureInfo.InvariantCulture, double.PositiveInfinity, 0.0); AssertParsed("1" + new string('0', 2047), CultureInfo.InvariantCulture, double.PositiveInfinity, 0.0);
InvalidInputFailsWithoutLeavingAPartialResult(new string('0', 4096) + "1"); InvalidInputFailsWithoutLeavingAPartialResult(new string('0', 2048) + "1");
InvalidInputFailsWithoutLeavingAPartialResult(new string(' ', 4096) + "1"); InvalidInputFailsWithoutLeavingAPartialResult(new string(' ', 2048) + "1");
} }
[Fact] [Fact]
public void LongMantissasCanCancelLargeExponentsWithoutOverflowOrUnderflow() public void LongMantissasCanCancelLargeExponentsWithoutOverflowOrUnderflow()
{ {
AssertParsed("1" + new string('0', 4000) + "e-4000", CultureInfo.InvariantCulture, 1.0, 0.0); AssertParsed("1" + new string('0', 2000) + "e-2000", CultureInfo.InvariantCulture, 1.0, 0.0);
AssertParsed("0." + new string('0', 4000) + "1e4001", CultureInfo.InvariantCulture, 1.0, 0.0); AssertParsed("0." + new string('0', 2000) + "1e2001", CultureInfo.InvariantCulture, 1.0, 0.0);
} }
[Fact] [Fact]
@@ -0,0 +1,168 @@
using System.Numerics;
using Shouldly;
using Xunit;
namespace Just.PreciseMath.Tests;
public class DoubleDoubleSignedNumberTests
{
[Fact]
public void GenericSignedNumberExposesNegativeOneAndBinaryRadix()
{
DoubleDouble value = NegativeOne<DoubleDouble>();
value.High.ShouldBe(-1.0);
BitConverter.DoubleToInt64Bits(value.Low).ShouldBe(0L);
DoubleDouble.Radix.ShouldBe(2);
}
[Theory]
[InlineData(0.0)]
[InlineData(1.0)]
[InlineData(-1.5)]
[InlineData(double.Epsilon)]
[InlineData(-double.Epsilon)]
[InlineData(double.MaxValue)]
[InlineData(double.PositiveInfinity)]
[InlineData(double.NegativeInfinity)]
[InlineData(double.NaN)]
public void ScalarClassificationMatchesBinary64(double scalar)
{
DoubleDouble value = new(scalar);
DoubleDouble.IsCanonical(value).ShouldBeTrue();
DoubleDouble.IsComplexNumber(value).ShouldBeFalse();
DoubleDouble.IsImaginaryNumber(value).ShouldBeFalse();
DoubleDouble.IsRealNumber(value).ShouldBe(!double.IsNaN(scalar));
DoubleDouble.IsPositive(value).ShouldBe(double.IsPositive(value.High));
DoubleDouble.IsNormal(value).ShouldBe(double.IsNormal(scalar));
DoubleDouble.IsSubnormal(value).ShouldBe(double.IsSubnormal(scalar));
DoubleDouble.IsZero(value).ShouldBe(scalar == 0.0);
DoubleDouble.IsInteger(value).ShouldBe(double.IsInteger(scalar));
DoubleDouble.IsEvenInteger(value).ShouldBe(double.IsEvenInteger(scalar));
DoubleDouble.IsOddInteger(value).ShouldBe(double.IsOddInteger(scalar));
}
[Theory]
[InlineData(9007199254740992.0, 1.0, true, false)]
[InlineData(9007199254740992.0, -1.0, true, false)]
[InlineData(18014398509481984.0, 2.0, true, true)]
[InlineData(1.0, 5.551115123125783e-17, false, false)]
[InlineData(9007199254740992.0, 0.5, false, false)]
public void IntegerClassificationIncludesResidual(double high, double low, bool isIntegral, bool even)
{
// Exact binary sums: the residual determines fractional bits and parity above 2^53.
foreach (DoubleDouble value in new[] { DoubleDouble.FromComponents(high, low), -DoubleDouble.FromComponents(high, low) })
{
DoubleDouble.IsInteger(value).ShouldBe(isIntegral);
DoubleDouble.IsEvenInteger(value).ShouldBe(even);
DoubleDouble.IsOddInteger(value).ShouldBe(isIntegral && !even);
DoubleDouble.IsCanonical(value).ShouldBeTrue();
}
}
[Fact]
public void CanonicalClassificationRejectsUnnormalizedAndNoncanonicalRawPairs()
{
DoubleDouble[] values = [new(1.0, 1.0), new(0.0, 1.0), new(1.0, -0.0),
new(double.PositiveInfinity, 1.0), new(double.NaN, 1.0),
new(BitConverter.Int64BitsToDouble(0x7ff8000000000001L), 0.0)];
foreach (DoubleDouble value in values)
{
DoubleDouble.IsCanonical(value).ShouldBeFalse();
}
}
[Fact]
public void NegativeZeroClassificationPreservesSign()
{
DoubleDouble value = new(-0.0);
DoubleDouble.IsCanonical(value).ShouldBeTrue();
DoubleDouble.IsZero(value).ShouldBeTrue();
DoubleDouble.IsPositive(value).ShouldBeFalse();
DoubleDouble.IsNegative(value).ShouldBeTrue();
DoubleDouble.IsEvenInteger(value).ShouldBeTrue();
DoubleDouble.IsOddInteger(value).ShouldBeFalse();
}
[Fact]
public void MagnitudeSelectionUsesBothComponentsAndBreaksTiesBySign()
{
DoubleDouble smaller = new(1.0);
DoubleDouble larger = DoubleDouble.FromComponents(1.0, Math.ScaleB(1.0, -54));
foreach ((DoubleDouble left, DoubleDouble right) in new[] { (smaller, -larger), (-larger, smaller) })
{
DoubleDouble.MaxMagnitude(left, right).ShouldBe(-larger);
DoubleDouble.MaxMagnitudeNumber(left, right).ShouldBe(-larger);
DoubleDouble.MinMagnitude(left, right).ShouldBe(smaller);
DoubleDouble.MinMagnitudeNumber(left, right).ShouldBe(smaller);
}
foreach ((DoubleDouble left, DoubleDouble right) in new[] { (larger, -larger), (-larger, larger) })
{
DoubleDouble.MaxMagnitude(left, right).ShouldBe(larger);
DoubleDouble.MinMagnitude(left, right).ShouldBe(-larger);
}
}
[Fact]
public void MagnitudeSpecialValuesMatchBinary64()
{
double[] values = [0.0, -0.0, 1.0, -1.0, double.Epsilon, double.MaxValue,
double.PositiveInfinity, double.NegativeInfinity, double.NaN];
foreach (double left in values)
{
foreach (double right in values)
{
AssertBits(DoubleDouble.MaxMagnitude(new(left), new(right)), double.MaxMagnitude(left, right));
AssertBits(DoubleDouble.MinMagnitude(new(left), new(right)), double.MinMagnitude(left, right));
AssertBits(DoubleDouble.MaxMagnitudeNumber(new(left), new(right)), double.MaxMagnitudeNumber(left, right));
AssertBits(DoubleDouble.MinMagnitudeNumber(new(left), new(right)), double.MinMagnitudeNumber(left, right));
}
AssertBits(DoubleDouble.Abs(new(left)), double.IsNaN(left) ? double.NaN : double.Abs(left));
}
DoubleDouble negative = DoubleDouble.FromComponents(-1.0, Math.ScaleB(1.0, -54));
DoubleDouble.Abs(negative).High.ShouldBe(1.0);
DoubleDouble.Abs(negative).Low.ShouldBe(-Math.ScaleB(1.0, -54));
}
[Fact]
public void IncrementAndDecrementRetainResidualAndSupportGenericDispatch()
{
// 2^53 +/- 1 are exact two-component integers.
DoubleDouble start = new(9007199254740992.0);
DoubleDouble incremented = Increment(start);
incremented.High.ShouldBe(9007199254740992.0);
incremented.Low.ShouldBe(1.0);
DoubleDouble decremented = Decrement(incremented);
decremented.ShouldBe(start);
DoubleDouble post = start++;
post.High.ShouldBe(9007199254740992.0);
start.Low.ShouldBe(1.0);
post = start--;
post.Low.ShouldBe(1.0);
start.Low.ShouldBe(0.0);
Increment(DoubleDouble.NegativeOne).ShouldBe(DoubleDouble.Zero);
Decrement(DoubleDouble.One).ShouldBe(DoubleDouble.Zero);
Increment(DoubleDouble.NaN).ShouldBe(DoubleDouble.NaN);
AssertBits(Decrement(new DoubleDouble(double.NegativeInfinity)), double.NegativeInfinity);
}
private static T Increment<T>(T value) where T : ISignedNumber<T>
{
return ++value;
}
private static T Decrement<T>(T value) where T : ISignedNumber<T>
{
return --value;
}
private static void AssertBits(DoubleDouble actual, double expected)
{
BitConverter.DoubleToInt64Bits(actual.High).ShouldBe(BitConverter.DoubleToInt64Bits(expected));
BitConverter.DoubleToInt64Bits(actual.Low).ShouldBe(0L);
}
private static T NegativeOne<T>() where T : ISignedNumber<T>
{
return T.NegativeOne;
}
}
@@ -0,0 +1,70 @@
using System.Globalization;
using Shouldly;
using Xunit;
namespace Just.PreciseMath.Tests;
public class DoubleDoubleSpanFormattingTests
{
[Theory]
[InlineData("")]
[InlineData("G32")]
[InlineData("g999")]
[InlineData("E20")]
[InlineData("F54")]
public void SpanFormattingMatchesExactComponentFormatting(string format)
{
DoubleDouble value = DoubleDouble.FromComponents(1.0, Math.ScaleB(1.0, -54));
NumberFormatInfo provider = new() { NumberDecimalSeparator = "::", NegativeSign = "minus" };
string expected = value.ToString(format, provider);
char[] buffer = new char[expected.Length + 1];
buffer[^1] = '!';
((ISpanFormattable)value).TryFormat(buffer.AsSpan(0, expected.Length), out int written, format, provider).ShouldBeTrue();
written.ShouldBe(expected.Length);
new string(buffer, 0, written).ShouldBe(expected);
buffer[^1].ShouldBe('!');
}
[Fact]
public void DefaultSpanFormattingRetainsLowComponentDigits()
{
Span<char> buffer = stackalloc char[64];
DoubleDouble.FromComponents(1.0, Math.ScaleB(1.0, -54))
.TryFormat(buffer, out int written, provider: CultureInfo.InvariantCulture).ShouldBeTrue();
buffer[..written].ToString().ShouldBe("1.0000000000000000555111512312578");
}
[Fact]
public void ShortDestinationIsUnchangedAndReportsZero()
{
char[] buffer = ['!', '!'];
DoubleDouble.PI.TryFormat(buffer, out int written, "G32", CultureInfo.InvariantCulture).ShouldBeFalse();
written.ShouldBe(0);
new string(buffer).ShouldBe("!!");
DoubleDouble.One.TryFormat(Span<char>.Empty, out written, provider: CultureInfo.InvariantCulture).ShouldBeFalse();
written.ShouldBe(0);
}
[Theory]
[InlineData(-0.0)]
[InlineData(double.NaN)]
[InlineData(double.PositiveInfinity)]
[InlineData(double.NegativeInfinity)]
public void SpanFormattingPreservesSpecialValues(double high)
{
NumberFormatInfo provider = new() { NegativeSign = "minus", NaNSymbol = "unknown" };
DoubleDouble value = new(high);
Span<char> buffer = stackalloc char[64];
value.TryFormat(buffer, out int written, "F2", provider).ShouldBeTrue();
buffer[..written].ToString().ShouldBe(value.ToString("F2", provider));
}
[Theory]
[InlineData("R")]
[InlineData("F1000")]
[InlineData("G-1")]
public void InvalidFormatThrowsEvenForEmptyDestination(string format)
{
Should.Throw<FormatException>(() => DoubleDouble.One.TryFormat(Span<char>.Empty, out _, format, CultureInfo.InvariantCulture));
}
}
@@ -0,0 +1,108 @@
using System.Globalization;
using System.Numerics;
using Shouldly;
using Xunit;
namespace Just.PreciseMath.Tests;
public class DoubleDoubleSpecialValueTests
{
[Theory]
[InlineData("+NaN")]
[InlineData("-NaN")]
[InlineData("+Infinity")]
[InlineData("-Infinity")]
[InlineData(" NaN ")]
[InlineData(" \t+Infinity\r\n")]
public void StandardSpecialValuesMatchBinary64WithoutStyleFlags(string text)
{
double.TryParse(text, NumberStyles.None, CultureInfo.InvariantCulture, out double expected).ShouldBeTrue();
AssertAllParsers(text, CultureInfo.InvariantCulture, expected);
}
[Theory]
[InlineData("inf", double.PositiveInfinity)]
[InlineData("+INF", double.PositiveInfinity)]
[InlineData("-iNf", double.NegativeInfinity)]
[InlineData(" \t-inf\r\n", double.NegativeInfinity)]
public void ShortInfinityAliasIsCaseInsensitive(string text, double expected)
{
AssertAllParsers(text, CultureInfo.InvariantCulture, expected);
}
[Fact]
public void ShortInfinityUsesCultureSignsAndRespectsCustomSymbolPrecedence()
{
NumberFormatInfo info = new() { PositiveSign = "plus", NegativeSign = "minus" };
AssertAllParsers(" minusINF ", info, double.NegativeInfinity);
AssertAllParsers("plusinf", info, double.PositiveInfinity);
info.NaNSymbol = "inf";
AssertAllParsers("inf", info, double.NaN);
info.NaNSymbol = "missing";
info.NegativeInfinitySymbol = "inf";
AssertAllParsers("inf", info, double.NegativeInfinity);
}
[Theory]
[InlineData("in")]
[InlineData("infx")]
[InlineData("infinite")]
[InlineData("--inf")]
[InlineData("+ inf")]
[InlineData("inf-")]
public void MalformedAliasesAreRejected(string text)
{
DoubleDouble.TryParse(text, CultureInfo.InvariantCulture, out DoubleDouble result).ShouldBeFalse();
result.ShouldBe(DoubleDouble.Zero);
DoubleDouble.TryParse(text.AsSpan(), NumberStyles.Any, CultureInfo.InvariantCulture, out result).ShouldBeFalse();
result.ShouldBe(DoubleDouble.Zero);
}
[Fact]
public void SpecialValuesStillRespectRawLengthAndInvalidStyles()
{
AssertAllParsers(new string(' ', 2045) + "inf", CultureInfo.InvariantCulture, double.PositiveInfinity);
string tooLong = new string(' ', 2046) + "inf";
DoubleDouble.TryParse(tooLong, CultureInfo.InvariantCulture, out _).ShouldBeFalse();
DoubleDouble.TryParse(tooLong.AsSpan(), NumberStyles.None, CultureInfo.InvariantCulture, out _).ShouldBeFalse();
Should.Throw<FormatException>(() => DoubleDouble.Parse(tooLong, NumberStyles.None, CultureInfo.InvariantCulture));
Should.Throw<ArgumentException>(() => DoubleDouble.TryParse("inf", NumberStyles.HexNumber, null, out _));
DoubleDouble.TryParse(" +1 ", NumberStyles.None, CultureInfo.InvariantCulture, out _).ShouldBeFalse();
}
[Fact]
public void AbsCanonicalizesRawNaNPayloadsOfEitherSign()
{
foreach (long bits in new[] { 0x7ff8000000000001L, unchecked((long)0xfff8000000000001UL) })
{
DoubleDouble result = DoubleDouble.Abs(new DoubleDouble(BitConverter.Int64BitsToDouble(bits), 0.0));
BitConverter.DoubleToInt64Bits(result.High).ShouldBe(BitConverter.DoubleToInt64Bits(double.NaN));
BitConverter.DoubleToInt64Bits(result.Low).ShouldBe(0L);
DoubleDouble.IsCanonical(result).ShouldBeTrue();
}
}
private static void AssertAllParsers(string text, IFormatProvider provider, double expected)
{
DoubleDouble.TryParse(text, provider, out DoubleDouble fromString).ShouldBeTrue();
DoubleDouble.TryParse(text.AsSpan(), provider, out DoubleDouble fromSpan).ShouldBeTrue();
DoubleDouble[] values = [fromString, fromSpan, DoubleDouble.Parse(text, provider), DoubleDouble.Parse(text.AsSpan(), provider),
ParseStyled<DoubleDouble>(text, provider)];
foreach (DoubleDouble value in values)
{
BitConverter.DoubleToInt64Bits(value.High).ShouldBe(BitConverter.DoubleToInt64Bits(expected));
BitConverter.DoubleToInt64Bits(value.Low).ShouldBe(0L);
}
}
private static T ParseStyled<T>(string text, IFormatProvider provider) where T : INumberBase<T>
{
T.TryParse(text, NumberStyles.None, provider, out T? fromString).ShouldBeTrue();
T.TryParse(text.AsSpan(), NumberStyles.None, provider, out T? fromSpan).ShouldBeTrue();
T result = T.Parse(text, NumberStyles.None, provider);
fromString.ShouldBe(result);
fromSpan.ShouldBe(result);
T.Parse(text.AsSpan(), NumberStyles.None, provider).ShouldBe(result);
return result;
}
}
@@ -0,0 +1,151 @@
using System.Diagnostics.CodeAnalysis;
using System.Numerics;
using Shouldly;
using Xunit;
namespace Just.PreciseMath.Tests;
public sealed class GenericConversionTests
{
[Fact]
public void GenericCreationPreservesComponentsAndExactIntegers()
{
DoubleDouble value = DoubleDouble.FromComponents(1.0, double.Epsilon);
DoubleDouble.CreateChecked(value).Low.ShouldBe(double.Epsilon);
DoubleDouble.CreateSaturating(value).Low.ShouldBe(double.Epsilon);
DoubleDouble.CreateTruncating(value).Low.ShouldBe(double.Epsilon);
DoubleDouble.CreateChecked(ulong.MaxValue).High.ShouldBe(Math.ScaleB(1.0, 64));
DoubleDouble.CreateChecked(ulong.MaxValue).Low.ShouldBe(-1.0);
DoubleDouble.CreateChecked(decimal.MaxValue).Low.ShouldBe(-1.0);
BigInteger integer = (BigInteger.One << 100) + 1;
BigInteger.CreateChecked(DoubleDouble.CreateChecked(integer)).ShouldBe(integer);
BitConverter.DoubleToInt64Bits(DoubleDouble.CreateChecked(-0.0).High).ShouldBe(long.MinValue);
}
[Fact]
public void IntegerTargetsTruncateTheExactSumBeforeApplyingRangePolicy()
{
int.CreateChecked(DoubleDouble.FromComponents(1.0, -double.Epsilon)).ShouldBe(0);
int.CreateChecked(DoubleDouble.FromComponents(-1.0, double.Epsilon)).ShouldBe(0);
long.CreateChecked(DoubleDouble.FromComponents(Math.ScaleB(1.0, 63), -1.0)).ShouldBe(long.MaxValue);
ulong.CreateChecked(DoubleDouble.FromComponents(Math.ScaleB(1.0, 64), -1.0)).ShouldBe(ulong.MaxValue);
DoubleDouble overflow = new(256.75);
Should.Throw<OverflowException>(() => byte.CreateChecked(overflow));
byte.CreateSaturating(overflow).ShouldBe(byte.MaxValue);
byte.CreateTruncating(overflow).ShouldBe((byte)0);
byte.CreateSaturating(new DoubleDouble(-1.0)).ShouldBe((byte)0);
byte.CreateTruncating(new DoubleDouble(-1.0)).ShouldBe(byte.MaxValue);
}
[Fact]
public void HugeIntegerInputsUseFloatingOverflowAndExactRatioRounding()
{
BigInteger huge = BigInteger.One << 2000;
double.IsPositiveInfinity(DoubleDouble.CreateChecked(huge).High).ShouldBeTrue();
double.IsPositiveInfinity(DoubleDouble.CreateSaturating(huge).High).ShouldBeTrue();
double.IsNegativeInfinity(DoubleDouble.CreateTruncating(-huge).High).ShouldBeTrue();
// A sparse exact integer must retain its low component rather than pass through double.
DoubleDouble sparse = DoubleDouble.CreateChecked((BigInteger.One << 100) + 1);
sparse.High.ShouldBe(Math.ScaleB(1.0, 100));
sparse.Low.ShouldBe(1.0);
}
[Fact]
public void FloatingAndDecimalTargetsRetainResidualRounding()
{
DoubleDouble aboveFloatMidpoint = DoubleDouble.FromComponents(1.0 + Math.ScaleB(1.0, -24), double.Epsilon);
float.CreateChecked(aboveFloatMidpoint).ShouldBe(float.BitIncrement(1.0f));
DoubleDouble aboveHalfMidpoint = DoubleDouble.FromComponents(1.0 + Math.ScaleB(1.0, -11), double.Epsilon);
Half.CreateChecked(aboveHalfMidpoint).ShouldBe(Half.BitIncrement((Half)1));
decimal.CreateChecked(DoubleDouble.CreateChecked(0.1m)).ShouldBe(0.1m);
Should.Throw<OverflowException>(() => decimal.CreateChecked(new DoubleDouble(double.PositiveInfinity)));
decimal.CreateSaturating(new DoubleDouble(double.PositiveInfinity)).ShouldBe(decimal.MaxValue);
decimal.CreateTruncating(DoubleDouble.NaN).ShouldBe(0m);
int.CreateSaturating(DoubleDouble.NaN).ShouldBe(0);
int.CreateTruncating(new DoubleDouble(double.PositiveInfinity)).ShouldBe(int.MaxValue);
Should.Throw<OverflowException>(() => int.CreateChecked(DoubleDouble.NaN));
}
[Fact]
public void BuiltInNumericFamiliesRoundTripThroughAllCreationModes()
{
RoundTrip((byte)123);
RoundTrip((sbyte)-123);
RoundTrip((short)-12345);
RoundTrip((ushort)54321);
RoundTrip(int.MinValue);
RoundTrip(uint.MaxValue);
RoundTrip(long.MinValue);
RoundTrip(ulong.MaxValue);
RoundTrip((nint)(-12345));
RoundTrip((nuint)54321);
RoundTrip((Int128.One << 100) + 1);
RoundTrip((UInt128.One << 100) + 1);
RoundTrip('A');
RoundTrip((Half)1.25);
RoundTrip(1.25f);
RoundTrip(1.25);
RoundTrip(1.25m);
}
private static void RoundTrip<T>(T value) where T : INumberBase<T>
{
T.CreateChecked(DoubleDouble.CreateChecked(value)).ShouldBe(value);
T.CreateSaturating(DoubleDouble.CreateSaturating(value)).ShouldBe(value);
T.CreateTruncating(DoubleDouble.CreateTruncating(value)).ShouldBe(value);
}
[Fact]
public void UnsupportedHooksReturnFalseWithoutTwoSidedRecursion()
{
ConversionProbe<DoubleDouble>.FromChecked(Complex.One, out DoubleDouble from).ShouldBeFalse();
from.ShouldBe(DoubleDouble.Zero);
ConversionProbe<DoubleDouble>.FromSaturating(Complex.One, out _).ShouldBeFalse();
ConversionProbe<DoubleDouble>.FromTruncating(Complex.One, out _).ShouldBeFalse();
ConversionProbe<DoubleDouble>.ToChecked(DoubleDouble.One, out Complex to).ShouldBeFalse();
to.ShouldBe(default);
ConversionProbe<DoubleDouble>.ToSaturating(DoubleDouble.One, out Complex _).ShouldBeFalse();
ConversionProbe<DoubleDouble>.ToTruncating(DoubleDouble.One, out Complex _).ShouldBeFalse();
}
private interface ConversionProbe<T> : INumberBase<T> where T : INumberBase<T>
{
public static bool FromChecked<TOther>(TOther value, [MaybeNullWhen(false)] out T result) where TOther : INumberBase<TOther>
{
return T.TryConvertFromChecked(value, out result);
}
public static bool FromSaturating<TOther>(TOther value, [MaybeNullWhen(false)] out T result) where TOther : INumberBase<TOther>
{
return T.TryConvertFromSaturating(value, out result);
}
public static bool FromTruncating<TOther>(TOther value, [MaybeNullWhen(false)] out T result) where TOther : INumberBase<TOther>
{
return T.TryConvertFromTruncating(value, out result);
}
public static bool ToChecked<TOther>(T value, [MaybeNullWhen(false)] out TOther result) where TOther : INumberBase<TOther>
{
return T.TryConvertToChecked(value, out result);
}
public static bool ToSaturating<TOther>(T value, [MaybeNullWhen(false)] out TOther result) where TOther : INumberBase<TOther>
{
return T.TryConvertToSaturating(value, out result);
}
public static bool ToTruncating<TOther>(T value, [MaybeNullWhen(false)] out TOther result) where TOther : INumberBase<TOther>
{
return T.TryConvertToTruncating(value, out result);
}
}
[Fact]
public void UnsupportedComplexConversionTerminatesWithNotSupported()
{
Should.Throw<NotSupportedException>(() => DoubleDouble.CreateChecked(Complex.One));
Should.Throw<NotSupportedException>(() => DoubleDouble.CreateSaturating(Complex.One));
Should.Throw<NotSupportedException>(() => DoubleDouble.CreateTruncating(Complex.One));
}
}
+33 -7
View File
@@ -63,6 +63,23 @@ has not been benchmarked, including the allocating exponent-boundary path.
0999; other standard and custom formats throw `FormatException`. Default 0999; other standard and custom formats throw `FormatException`. Default
`G32` is **not** shortest-round-trip formatting. NaN, infinities, and signed `G32` is **not** shortest-round-trip formatting. NaN, infinities, and signed
zero are supported without converting through decimal. zero are supported without converting through decimal.
- `TryFormat(Span<char>, ...)` implements `ISpanFormattable` with the same formats.
It currently allocates via `ToString`; insufficient space returns `false`, writes
zero characters, and leaves the destination unchanged.
`DoubleDouble` implements `ISignedNumber<DoubleDouble>`, including the inherited
`INumberBase` contracts: binary radix, classification, absolute value, magnitude
selection, increment/decrement, and generic numeric conversions. Integer/parity
tests and magnitude comparisons retain both components. Magnitude ties prefer
positive values for maximum and negative values for minimum, including signed zero;
the `Number` variants prefer a number over NaN.
`CreateChecked`, `CreateSaturating`, and `CreateTruncating` support built-in numeric
types and `BigInteger`. Floating overflow produces signed infinity in all modes.
Finite integer output truncates the exact sum, then throws on overflow, clamps,
or retains the low destination-width bits, respectively. Decimal nonchecked output
clamps out-of-range values and maps NaN to zero. These policies are distinct from
the existing casts and `IConvertible` conversions above.
Conversions, parsing, and formatting use allocating `BigInteger` intermediates Conversions, parsing, and formatting use allocating `BigInteger` intermediates
where needed to preserve precision; no additional dependency is required. where needed to preserve precision; no additional dependency is required.
@@ -87,16 +104,24 @@ bool success = DoubleDouble.TryParse("1.25e-2".AsSpan(), CultureInfo.InvariantCu
out DoubleDouble parsed); out DoubleDouble parsed);
``` ```
- Supported grammar: optional sign, ASCII decimal digits with an optional decimal - Provider-only finite grammar: optional sign, ASCII decimal digits with an optional decimal
separator, and optional `e`/`E` exponent with sign and digits. At least one separator, and optional `e`/`E` exponent with sign and digits. At least one
mantissa digit is required; `.5` and `1.` are accepted with invariant culture. mantissa digit is required; `.5` and `1.` are accepted with invariant culture.
Surrounding whitespace is allowed; internal whitespace is not. Surrounding whitespace is allowed; internal whitespace is not.
- Signs and the decimal separator come from the supplied culture; a null or - Signs and the decimal separator come from the supplied culture; a null or
omitted provider uses the current culture. Culture-specific NaN and infinity omitted provider uses the current culture. Culture-specific NaN and infinity
symbols are recognized case-insensitively. Signed zero is preserved. symbols are recognized case-insensitively. The additional alias `inf` accepts an
- Group separators, currency, parentheses, hexadecimal notation, digit separators, optional culture-specific sign (`inf`, `+inf`, `-inf` with invariant culture).
and `NumberStyles` overloads are not supported. Exact custom special symbols take precedence over the alias. Special values accept
- Input is limited to **4096 characters**, including surrounding whitespace. surrounding whitespace and signs even with `NumberStyles.None`; ordinary finite
numbers still obey the supplied style flags. Signed zero is preserved.
- Provider-only overloads reject grouping, currency, and parentheses. Explicit
`NumberStyles` overloads support decimal flags through `NumberStyles.Any`, including
grouping, currency, parentheses, and trailing signs; group sizes are not validated.
Hexadecimal, binary, and undefined style flags throw `ArgumentException`, including
in `TryParse`. Hexadecimal notation and programming-language digit separators
remain unsupported.
- Input is limited to **2048 characters**, including surrounding whitespace.
Huge exponents are bounded before constructing powers of ten. Well-formed Huge exponents are bounded before constructing powers of ten. Well-formed
overflow succeeds with signed infinity; underflow rounds to a subnormal or overflow succeeds with signed infinity; underflow rounds to a subnormal or
signed zero. A second rounding just below the overflow midpoint stays finite. signed zero. A second rounding just below the overflow midpoint stays finite.
@@ -107,8 +132,9 @@ bool success = DoubleDouble.TryParse("1.25e-2".AsSpan(), CultureInfo.InvariantCu
## Deferred scope ## Deferred scope
The planned `PreciseMath` static class and its `Abs`, `Sqrt`, `Pow`, `Exp`, and The planned `PreciseMath` static class and its `Abs`, `Sqrt`, `Pow`, `Exp`, and
`Log` functions are not implemented. Broader generic-math interfaces, expanded `Log` functions are not implemented. Generic-math interfaces beyond `ISignedNumber`,
parsing/round-trip formatting, and performance benchmarks remain deferred. additional text formats/general round-trip formatting, and performance benchmarks
remain deferred.
Replacing allocating arithmetic boundary fallbacks is also deferred; the current Replacing allocating arithmetic boundary fallbacks is also deferred; the current
`BigInteger` paths remain in place. That optimization does not require removing `BigInteger` paths remain in place. That optimization does not require removing
`BigInteger` from conversions, parsing, formatting, or independent test oracles. `BigInteger` from conversions, parsing, formatting, or independent test oracles.