diff --git a/0-source/Just.PreciseMath/DoubleDouble.GenericConversion.cs b/0-source/Just.PreciseMath/DoubleDouble.GenericConversion.cs
new file mode 100644
index 0000000..0b8d54e
--- /dev/null
+++ b/0-source/Just.PreciseMath/DoubleDouble.GenericConversion.cs
@@ -0,0 +1,202 @@
+namespace Just.PreciseMath;
+
+///
+/// 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.
+///
+public readonly partial struct DoubleDouble
+{
+ ///
+ /// 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.
+ ///
+ public static DoubleDouble CreateChecked(TOther value) where TOther : INumberBase
+ {
+ 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.");
+ }
+
+ /// Converts with floating-point range semantics: overflow produces signed infinity.
+ public static DoubleDouble CreateSaturating(TOther value) where TOther : INumberBase
+ {
+ 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.");
+ }
+
+ /// Converts with floating-point rounding; finite precision is not integer truncation.
+ public static DoubleDouble CreateTruncating(TOther value) where TOther : INumberBase
+ {
+ 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.TryConvertFromChecked(TOther value, out DoubleDouble result)
+ {
+ return GenericTryConvertFrom(value, out result);
+ }
+
+ static bool INumberBase.TryConvertFromSaturating(TOther value, out DoubleDouble result)
+ {
+ return GenericTryConvertFrom(value, out result);
+ }
+
+ static bool INumberBase.TryConvertFromTruncating(TOther value, out DoubleDouble result)
+ {
+ return GenericTryConvertFrom(value, out result);
+ }
+
+ static bool INumberBase.TryConvertToChecked(DoubleDouble value, out TOther result)
+ {
+ return GenericTryConvertTo(value, GenericConversionMode.Checked, out result);
+ }
+
+ static bool INumberBase.TryConvertToSaturating(DoubleDouble value, out TOther result)
+ {
+ return GenericTryConvertTo(value, GenericConversionMode.Saturating, out result);
+ }
+
+ static bool INumberBase.TryConvertToTruncating(DoubleDouble value, out TOther result)
+ {
+ return GenericTryConvertTo(value, GenericConversionMode.Truncating, out result);
+ }
+
+ private enum GenericConversionMode
+ {
+ Checked,
+ Saturating,
+ Truncating,
+ }
+
+ private static bool GenericIsInteger()
+ {
+ 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 value, out DoubleDouble result) where TOther : INumberBase
+ {
+ // 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())
+ {
+ result = PreciseMathHelper.ArithmeticFromRatio(BigInteger.CreateChecked(value), BigInteger.One);
+ }
+ else
+ {
+ result = Zero;
+ return false;
+ }
+ return true;
+ }
+
+ private static bool GenericTryConvertTo(DoubleDouble value, GenericConversionMode mode, out TOther result) where TOther : INumberBase
+ {
+ 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())
+ {
+ 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;
+ }
+}
diff --git a/0-source/Just.PreciseMath/DoubleDouble.NumberBase.cs b/0-source/Just.PreciseMath/DoubleDouble.NumberBase.cs
new file mode 100644
index 0000000..bb9f93e
--- /dev/null
+++ b/0-source/Just.PreciseMath/DoubleDouble.NumberBase.cs
@@ -0,0 +1,143 @@
+namespace Just.PreciseMath;
+
+public readonly partial struct DoubleDouble
+{
+ /// Gets the binary radix of the components.
+ public static int Radix => 2;
+
+ /// Returns the absolute value, preserving both components and canonicalizing NaN.
+ public static DoubleDouble Abs(DoubleDouble value)
+ {
+ if (IsNaN(value))
+ {
+ return NaN;
+ }
+ return IsNegative(value) ? -value : value;
+ }
+
+ /// Returns the operand with greater magnitude; ties prefer positive values and NaN propagates.
+ 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;
+ }
+
+ /// Returns the operand with lesser magnitude; ties prefer negative values and NaN propagates.
+ 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;
+ }
+
+ /// Returns the greater-magnitude operand, preferring a number over NaN and positive values on ties.
+ public static DoubleDouble MaxMagnitudeNumber(DoubleDouble x, DoubleDouble y)
+ {
+ if (IsNaN(x))
+ {
+ return y;
+ }
+ return IsNaN(y) ? x : MaxMagnitude(x, y);
+ }
+
+ /// Returns the lesser-magnitude operand, preferring a number over NaN and negative values on ties.
+ public static DoubleDouble MinMagnitudeNumber(DoubleDouble x, DoubleDouble y)
+ {
+ if (IsNaN(x))
+ {
+ return y;
+ }
+ return IsNaN(y) ? x : MinMagnitude(x, y);
+ }
+
+ /// Adds one using double-double arithmetic, including its overflow and nonfinite behavior.
+ public static DoubleDouble operator ++(DoubleDouble value)
+ {
+ return value + One;
+ }
+
+ /// Subtracts one using double-double arithmetic, including its overflow and nonfinite behavior.
+ public static DoubleDouble operator --(DoubleDouble value)
+ {
+ return value - One;
+ }
+
+ /// Tests normalization and canonical NaN and zero-residual bit patterns.
+ 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);
+ }
+
+ /// Returns false: this type has no complex values.
+ public static bool IsComplexNumber(DoubleDouble value)
+ {
+ return false;
+ }
+
+ /// Returns false: this type has no imaginary values.
+ public static bool IsImaginaryNumber(DoubleDouble value)
+ {
+ return false;
+ }
+
+ /// Tests whether the value is real, including infinities but excluding NaN.
+ public static bool IsRealNumber(DoubleDouble value)
+ {
+ return !IsNaN(value);
+ }
+
+ /// Tests the high component's sign bit, including positive zero.
+ public static bool IsPositive(DoubleDouble value)
+ {
+ return double.IsPositive(value._high);
+ }
+
+ /// Tests whether the normalized high component is a normal binary64 value.
+ public static bool IsNormal(DoubleDouble value)
+ {
+ return double.IsNormal(value._high);
+ }
+
+ /// Tests whether the normalized high component is subnormal.
+ public static bool IsSubnormal(DoubleDouble value)
+ {
+ return double.IsSubnormal(value._high);
+ }
+
+ /// Tests for either sign of zero.
+ public static bool IsZero(DoubleDouble value)
+ {
+ return value._high == 0.0 && value._low == 0.0;
+ }
+
+ /// Tests whether the exact sum of the normalized components is an integer.
+ 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);
+ }
+
+ /// Tests integer parity without discarding the residual above 2^53.
+ public static bool IsEvenInteger(DoubleDouble value)
+ {
+ return IsInteger(value) && double.IsOddInteger(value._high) == double.IsOddInteger(value._low);
+ }
+
+ /// Tests integer parity without discarding the residual above 2^53.
+ public static bool IsOddInteger(DoubleDouble value)
+ {
+ return IsInteger(value) && double.IsOddInteger(value._high) != double.IsOddInteger(value._low);
+ }
+}
diff --git a/0-source/Just.PreciseMath/DoubleDouble.NumberStyles.cs b/0-source/Just.PreciseMath/DoubleDouble.NumberStyles.cs
new file mode 100644
index 0000000..cdfc5fe
--- /dev/null
+++ b/0-source/Just.PreciseMath/DoubleDouble.NumberStyles.cs
@@ -0,0 +1,222 @@
+using System.Globalization;
+
+namespace Just.PreciseMath;
+
+public readonly partial struct DoubleDouble
+{
+ /// Parses decimal text using the specified styles and culture (current when null).
+ /// 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.
+ /// The style contains hexadecimal, binary, or undefined flags.
+ /// The input is null.
+ /// The input is invalid or exceeds 2048 characters.
+ public static DoubleDouble Parse(string s, NumberStyles style, IFormatProvider? provider = null)
+ {
+ ValidateParsingStyle(style);
+ ArgumentNullException.ThrowIfNull(s);
+ return Parse(s.AsSpan(), style, provider);
+ }
+
+ /// Parses decimal text using the specified styles and culture (current when null).
+ /// The style contains hexadecimal, binary, or undefined flags.
+ /// The input is invalid or exceeds 2048 characters.
+ public static DoubleDouble Parse(ReadOnlySpan 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;
+ }
+
+ /// Parses decimal text; returns false and Zero for null, invalid, or oversized input.
+ /// The style contains hexadecimal, binary, or undefined flags.
+ public static bool TryParse(string? s, NumberStyles style, IFormatProvider? provider, out DoubleDouble result)
+ {
+ return TryParse(s.AsSpan(), style, provider, out result);
+ }
+
+ /// Parses decimal text; returns false and Zero for invalid or oversized input.
+ /// 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.
+ /// The style contains hexadecimal, binary, or undefined flags.
+ public static bool TryParse(ReadOnlySpan 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 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 text, NumberFormatInfo info, out bool negative)
+ {
+ negative = false;
+ if (StyledConsumeToken(ref text, info.PositiveSign))
+ {
+ return true;
+ }
+ negative = StyledConsumeToken(ref text, info.NegativeSign);
+ return negative;
+ }
+}
diff --git a/0-source/Just.PreciseMath/DoubleDouble.Parsing.cs b/0-source/Just.PreciseMath/DoubleDouble.Parsing.cs
index fd32c93..4a2acef 100644
--- a/0-source/Just.PreciseMath/DoubleDouble.Parsing.cs
+++ b/0-source/Just.PreciseMath/DoubleDouble.Parsing.cs
@@ -5,8 +5,11 @@ namespace Just.PreciseMath;
///
/// Parsing supports decimal/scientific notation with ASCII digits, surrounding whitespace,
/// culture-specific signs and decimal separator, and NaN/infinity symbols (case-insensitive).
-/// Group separators, currency, hexadecimal notation, and NumberStyles options are not supported.
-/// Inputs are limited to 4096 characters, including surrounding whitespace, to bound work.
+/// The case-insensitive alias "inf" also denotes infinity, with optional culture-specific sign.
+/// 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,
/// 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.
@@ -15,11 +18,11 @@ namespace Just.PreciseMath;
///
public readonly partial struct DoubleDouble : ISpanParsable
{
- private const int MaximumParsingLength = 4096;
+ private const int MaximumParsingLength = 2048;
/// Parses decimal/scientific text, using the current culture when provider is null.
/// The input is null.
- /// The input is malformed, unsupported, or longer than 4096 characters.
+ /// The input is malformed, unsupported, or longer than 2048 characters.
public static DoubleDouble Parse(string s, IFormatProvider? provider = null)
{
ArgumentNullException.ThrowIfNull(s);
@@ -27,12 +30,12 @@ public readonly partial struct DoubleDouble : ISpanParsable
}
/// Parses decimal/scientific text, using the current culture when provider is null.
- /// The input is malformed, unsupported, or longer than 4096 characters.
+ /// The input is malformed, unsupported, or longer than 2048 characters.
public static DoubleDouble Parse(ReadOnlySpan s, IFormatProvider? provider = null)
{
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;
}
@@ -69,33 +72,11 @@ public readonly partial struct DoubleDouble : ISpanParsable
return false;
}
NumberFormatInfo info = NumberFormatInfo.GetInstance(provider);
- // Custom symbols can themselves start with a numeric sign.
- if (s.Equals(info.NaNSymbol, StringComparison.OrdinalIgnoreCase))
+ if (ParsingTrySpecial(s, info, out result))
{
- 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))
- {
- result = new DoubleDouble(negative ? double.NegativeInfinity : double.PositiveInfinity);
- return true;
- }
BigInteger coefficient = BigInteger.Zero;
int significantDigits = 0;
@@ -190,6 +171,42 @@ public readonly partial struct DoubleDouble : ISpanParsable
return true;
}
+ // Special symbols accept outer whitespace and culture signs independently of numeric styles.
+ private static bool ParsingTrySpecial(ReadOnlySpan 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.
private static bool ParsingConsumeSign(ref ReadOnlySpan text, NumberFormatInfo info)
{
diff --git a/0-source/Just.PreciseMath/DoubleDouble.cs b/0-source/Just.PreciseMath/DoubleDouble.cs
index 534bcaa..23bf0a3 100644
--- a/0-source/Just.PreciseMath/DoubleDouble.cs
+++ b/0-source/Just.PreciseMath/DoubleDouble.cs
@@ -13,7 +13,8 @@ namespace Just.PreciseMath;
///
public readonly partial struct DoubleDouble :
IEquatable,
- IEqualityOperators
+ IEqualityOperators,
+ ISignedNumber
{
internal readonly double _high;
internal readonly double _low;
diff --git a/0-source/Just.PreciseMath/DoubleDoubleSpanFormatting.cs b/0-source/Just.PreciseMath/DoubleDoubleSpanFormatting.cs
new file mode 100644
index 0000000..f36ab40
--- /dev/null
+++ b/0-source/Just.PreciseMath/DoubleDoubleSpanFormatting.cs
@@ -0,0 +1,20 @@
+namespace Just.PreciseMath;
+
+public readonly partial struct DoubleDouble : ISpanFormattable
+{
+ /// Formats the exact component sum using the same G, E, and F formats as ToString.
+ /// Uses the existing string formatter to preserve its rounding and culture contracts.
+ /// This implementation allocates; a short destination is unchanged and charsWritten is zero.
+ /// The format is unsupported or its precision exceeds 999.
+ public bool TryFormat(Span destination, out int charsWritten, ReadOnlySpan 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;
+ }
+}
diff --git a/1-tests/Just.PreciseMath.Tests/DoubleDoubleNumberStylesTests.cs b/1-tests/Just.PreciseMath.Tests/DoubleDoubleNumberStylesTests.cs
new file mode 100644
index 0000000..38f8a66
--- /dev/null
+++ b/1-tests/Just.PreciseMath.Tests/DoubleDoubleNumberStylesTests.cs
@@ -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(() => DoubleDouble.Parse(text, style, CultureInfo.InvariantCulture));
+ Should.Throw(() => 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(() => DoubleDouble.Parse("1", style, null));
+ Should.Throw(() => DoubleDouble.Parse("1".AsSpan(), style, null));
+ Should.Throw(() => DoubleDouble.TryParse((string?)null, style, null, out _));
+ Should.Throw(() => DoubleDouble.TryParse(ReadOnlySpan.Empty, style, null, out _));
+ }
+
+ [Fact]
+ public void NullAndLengthContractsArePreserved()
+ {
+ Should.Throw(() => 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);
+ }
+}
diff --git a/1-tests/Just.PreciseMath.Tests/DoubleDoubleParsingTests.cs b/1-tests/Just.PreciseMath.Tests/DoubleDoubleParsingTests.cs
index 8458755..0140ec2 100644
--- a/1-tests/Just.PreciseMath.Tests/DoubleDoubleParsingTests.cs
+++ b/1-tests/Just.PreciseMath.Tests/DoubleDoubleParsingTests.cs
@@ -170,17 +170,17 @@ public class DoubleDoubleParsingTests
[Fact]
public void InputLengthIsBoundedBeforeIgnoringWhitespaceOrLeadingZeros()
{
- AssertParsed(new string('0', 4095) + "1", CultureInfo.InvariantCulture, 1.0, 0.0);
- AssertParsed("1" + new string('0', 4095), CultureInfo.InvariantCulture, double.PositiveInfinity, 0.0);
- InvalidInputFailsWithoutLeavingAPartialResult(new string('0', 4096) + "1");
- InvalidInputFailsWithoutLeavingAPartialResult(new string(' ', 4096) + "1");
+ AssertParsed(new string('0', 2047) + "1", CultureInfo.InvariantCulture, 1.0, 0.0);
+ AssertParsed("1" + new string('0', 2047), CultureInfo.InvariantCulture, double.PositiveInfinity, 0.0);
+ InvalidInputFailsWithoutLeavingAPartialResult(new string('0', 2048) + "1");
+ InvalidInputFailsWithoutLeavingAPartialResult(new string(' ', 2048) + "1");
}
[Fact]
public void LongMantissasCanCancelLargeExponentsWithoutOverflowOrUnderflow()
{
- AssertParsed("1" + new string('0', 4000) + "e-4000", CultureInfo.InvariantCulture, 1.0, 0.0);
- AssertParsed("0." + new string('0', 4000) + "1e4001", 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', 2000) + "1e2001", CultureInfo.InvariantCulture, 1.0, 0.0);
}
[Fact]
diff --git a/1-tests/Just.PreciseMath.Tests/DoubleDoubleSignedNumberTests.cs b/1-tests/Just.PreciseMath.Tests/DoubleDoubleSignedNumberTests.cs
new file mode 100644
index 0000000..f3895f8
--- /dev/null
+++ b/1-tests/Just.PreciseMath.Tests/DoubleDoubleSignedNumberTests.cs
@@ -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();
+ 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 value) where T : ISignedNumber
+ {
+ return ++value;
+ }
+
+ private static T Decrement(T value) where T : ISignedNumber
+ {
+ 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() where T : ISignedNumber
+ {
+ return T.NegativeOne;
+ }
+}
diff --git a/1-tests/Just.PreciseMath.Tests/DoubleDoubleSpanFormattingTests.cs b/1-tests/Just.PreciseMath.Tests/DoubleDoubleSpanFormattingTests.cs
new file mode 100644
index 0000000..a6ef0eb
--- /dev/null
+++ b/1-tests/Just.PreciseMath.Tests/DoubleDoubleSpanFormattingTests.cs
@@ -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 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.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 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(() => DoubleDouble.One.TryFormat(Span.Empty, out _, format, CultureInfo.InvariantCulture));
+ }
+}
diff --git a/1-tests/Just.PreciseMath.Tests/DoubleDoubleSpecialValueTests.cs b/1-tests/Just.PreciseMath.Tests/DoubleDoubleSpecialValueTests.cs
new file mode 100644
index 0000000..7134170
--- /dev/null
+++ b/1-tests/Just.PreciseMath.Tests/DoubleDoubleSpecialValueTests.cs
@@ -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(() => DoubleDouble.Parse(tooLong, NumberStyles.None, CultureInfo.InvariantCulture));
+ Should.Throw(() => 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(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(string text, IFormatProvider provider) where T : INumberBase
+ {
+ 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;
+ }
+}
diff --git a/1-tests/Just.PreciseMath.Tests/GenericConversionTests.cs b/1-tests/Just.PreciseMath.Tests/GenericConversionTests.cs
new file mode 100644
index 0000000..c7f5bfe
--- /dev/null
+++ b/1-tests/Just.PreciseMath.Tests/GenericConversionTests.cs
@@ -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(() => 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(() => 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(() => 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 value) where T : INumberBase
+ {
+ 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.FromChecked(Complex.One, out DoubleDouble from).ShouldBeFalse();
+ from.ShouldBe(DoubleDouble.Zero);
+ ConversionProbe.FromSaturating(Complex.One, out _).ShouldBeFalse();
+ ConversionProbe.FromTruncating(Complex.One, out _).ShouldBeFalse();
+ ConversionProbe.ToChecked(DoubleDouble.One, out Complex to).ShouldBeFalse();
+ to.ShouldBe(default);
+ ConversionProbe.ToSaturating(DoubleDouble.One, out Complex _).ShouldBeFalse();
+ ConversionProbe.ToTruncating(DoubleDouble.One, out Complex _).ShouldBeFalse();
+ }
+
+ private interface ConversionProbe : INumberBase where T : INumberBase
+ {
+ public static bool FromChecked(TOther value, [MaybeNullWhen(false)] out T result) where TOther : INumberBase
+ {
+ return T.TryConvertFromChecked(value, out result);
+ }
+
+ public static bool FromSaturating(TOther value, [MaybeNullWhen(false)] out T result) where TOther : INumberBase
+ {
+ return T.TryConvertFromSaturating(value, out result);
+ }
+
+ public static bool FromTruncating(TOther value, [MaybeNullWhen(false)] out T result) where TOther : INumberBase
+ {
+ return T.TryConvertFromTruncating(value, out result);
+ }
+
+ public static bool ToChecked(T value, [MaybeNullWhen(false)] out TOther result) where TOther : INumberBase
+ {
+ return T.TryConvertToChecked(value, out result);
+ }
+
+ public static bool ToSaturating(T value, [MaybeNullWhen(false)] out TOther result) where TOther : INumberBase
+ {
+ return T.TryConvertToSaturating(value, out result);
+ }
+
+ public static bool ToTruncating(T value, [MaybeNullWhen(false)] out TOther result) where TOther : INumberBase
+ {
+ return T.TryConvertToTruncating(value, out result);
+ }
+ }
+
+ [Fact]
+ public void UnsupportedComplexConversionTerminatesWithNotSupported()
+ {
+ Should.Throw(() => DoubleDouble.CreateChecked(Complex.One));
+ Should.Throw(() => DoubleDouble.CreateSaturating(Complex.One));
+ Should.Throw(() => DoubleDouble.CreateTruncating(Complex.One));
+ }
+}
diff --git a/README.md b/README.md
index 755e416..8ecc1bc 100644
--- a/README.md
+++ b/README.md
@@ -63,6 +63,23 @@ has not been benchmarked, including the allocating exponent-boundary path.
0–999; other standard and custom formats throw `FormatException`. Default
`G32` is **not** shortest-round-trip formatting. NaN, infinities, and signed
zero are supported without converting through decimal.
+- `TryFormat(Span, ...)` 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`, 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
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);
```
-- 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
mantissa digit is required; `.5` and `1.` are accepted with invariant culture.
Surrounding whitespace is allowed; internal whitespace is not.
- Signs and the decimal separator come from the supplied culture; a null or
omitted provider uses the current culture. Culture-specific NaN and infinity
- symbols are recognized case-insensitively. Signed zero is preserved.
-- Group separators, currency, parentheses, hexadecimal notation, digit separators,
- and `NumberStyles` overloads are not supported.
-- Input is limited to **4096 characters**, including surrounding whitespace.
+ symbols are recognized case-insensitively. The additional alias `inf` accepts an
+ optional culture-specific sign (`inf`, `+inf`, `-inf` with invariant culture).
+ Exact custom special symbols take precedence over the alias. Special values accept
+ 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
overflow succeeds with signed infinity; underflow rounds to a subnormal or
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
The planned `PreciseMath` static class and its `Abs`, `Sqrt`, `Pow`, `Exp`, and
-`Log` functions are not implemented. Broader generic-math interfaces, expanded
-parsing/round-trip formatting, and performance benchmarks remain deferred.
+`Log` functions are not implemented. Generic-math interfaces beyond `ISignedNumber`,
+additional text formats/general round-trip formatting, and performance benchmarks
+remain deferred.
Replacing allocating arithmetic boundary fallbacks is also deferred; the current
`BigInteger` paths remain in place. That optimization does not require removing
`BigInteger` from conversions, parsing, formatting, or independent test oracles.