From e51874b0c393a9d89cebe33cf9a6a5bc1e74d834 Mon Sep 17 00:00:00 2001 From: just Date: Sun, 13 Sep 2026 23:08:27 +0400 Subject: [PATCH] implemented ISignedNumber --- .../DoubleDouble.GenericConversion.cs | 202 ++++++++++++++++ .../DoubleDouble.NumberBase.cs | 143 +++++++++++ .../DoubleDouble.NumberStyles.cs | 222 ++++++++++++++++++ .../Just.PreciseMath/DoubleDouble.Parsing.cs | 75 +++--- 0-source/Just.PreciseMath/DoubleDouble.cs | 3 +- .../DoubleDoubleSpanFormatting.cs | 20 ++ .../DoubleDoubleNumberStylesTests.cs | 115 +++++++++ .../DoubleDoubleParsingTests.cs | 12 +- .../DoubleDoubleSignedNumberTests.cs | 168 +++++++++++++ .../DoubleDoubleSpanFormattingTests.cs | 70 ++++++ .../DoubleDoubleSpecialValueTests.cs | 108 +++++++++ .../GenericConversionTests.cs | 151 ++++++++++++ README.md | 40 +++- 13 files changed, 1286 insertions(+), 43 deletions(-) create mode 100644 0-source/Just.PreciseMath/DoubleDouble.GenericConversion.cs create mode 100644 0-source/Just.PreciseMath/DoubleDouble.NumberBase.cs create mode 100644 0-source/Just.PreciseMath/DoubleDouble.NumberStyles.cs create mode 100644 0-source/Just.PreciseMath/DoubleDoubleSpanFormatting.cs create mode 100644 1-tests/Just.PreciseMath.Tests/DoubleDoubleNumberStylesTests.cs create mode 100644 1-tests/Just.PreciseMath.Tests/DoubleDoubleSignedNumberTests.cs create mode 100644 1-tests/Just.PreciseMath.Tests/DoubleDoubleSpanFormattingTests.cs create mode 100644 1-tests/Just.PreciseMath.Tests/DoubleDoubleSpecialValueTests.cs create mode 100644 1-tests/Just.PreciseMath.Tests/GenericConversionTests.cs 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.