diff --git a/CLAUDE.md b/CLAUDE.md index 0e7c0ce..45413a7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -50,7 +50,7 @@ dotnet run -c Release --project PreciseNumber.Benchmarks -- --filter '*' --job s ### Test Structure -Tests use MSTest. `PreciseNumber.Test/PreciseNumberTests.cs` covers arithmetic, parsing, and formatting, `PreciseNumberConversionTests.cs` covers generic math conversion in every mode, `PreciseNumberRootTests.cs` pins the roots against published digits and against squaring back, `PreciseNumberExponentialTests.cs` does the same for the exponentials and logarithms and additionally pins the cases a `double` fallback cannot reach — fifty published digits of a fractional power, and `ExpM1`/`LogP1` of `1e-30` not collapsing to zero — `PreciseNumberHyperbolicTests.cs` pins the hyperbolics against published digits and against `cosh²x - sinh²x = 1`, and separates the small-argument assertions that actually discriminate (`sinh`, `asinh`, `atanh`) from the two that read like they do and don't (`tanh`, `acosh`) — the class remark records which is which, so the distinction survives the next person to read it — `PreciseNumberLargeArgumentTests.cs` bounds the time of `ExpM1`/`LogP1`/hyperbolics on huge arguments and pins digits the exact sums produced, and `PreciseNumberValueTypeTests.cs` pins `default` as zero and asserts that small-value addition, subtraction, multiplication, and comparison allocate nothing. The test project targets only .NET 10.0 while the main library multi-targets net7.0, net8.0, net9.0, and net10.0. +Tests use MSTest. `PreciseNumber.Test/PreciseNumberTests.cs` covers arithmetic, parsing, and formatting, `PreciseNumberConversionTests.cs` covers generic math conversion in every mode, `PreciseNumberRootTests.cs` pins the roots against published digits and against squaring back, `PreciseNumberExponentialTests.cs` does the same for the exponentials and logarithms and additionally pins the cases a `double` fallback cannot reach — fifty published digits of a fractional power, and `ExpM1`/`LogP1` of `1e-30` not collapsing to zero — `PreciseNumberHyperbolicTests.cs` pins the hyperbolics against published digits and against `cosh²x - sinh²x = 1`, and separates the small-argument assertions that actually discriminate (`sinh`, `asinh`, `atanh`) from the two that read like they do and don't (`tanh`, `acosh`) — the class remark records which is which, so the distinction survives the next person to read it — `PreciseNumberLargeArgumentTests.cs` bounds the time of `ExpM1`/`LogP1`/hyperbolics on huge arguments and pins digits the exact sums produced, `PreciseNumberDomainErrorTests.cs` holds every domain check's exception message to a short rendering of the argument however extreme its exponent (all of them build it through `DomainError`), and `PreciseNumberValueTypeTests.cs` pins `default` as zero and asserts that small-value addition, subtraction, multiplication, and comparison allocate nothing. The test project targets only .NET 10.0 while the main library multi-targets net7.0, net8.0, net9.0, and net10.0. ### Benchmarks diff --git a/PreciseNumber.Test/PreciseNumberDomainErrorTests.cs b/PreciseNumber.Test/PreciseNumberDomainErrorTests.cs new file mode 100644 index 0000000..7d19133 --- /dev/null +++ b/PreciseNumber.Test/PreciseNumberDomainErrorTests.cs @@ -0,0 +1,97 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.PreciseNumber.Test; + +using System.Globalization; + +/// +/// Covers the exception every domain check throws for an argument outside its function's domain. +/// Its message renders the argument, and must stay short and not throw, however far the argument's +/// exponent reaches: a fixed-point rendering of 1e-2147483648 needs more characters than a +/// string can hold. +/// +[TestClass] +public class PreciseNumberDomainErrorTests +{ + /// The longest message a domain error may carry. + private const int MessageLengthLimit = 1_000; + + private static PreciseNumber Parse(string text) => PreciseNumber.Parse(text, CultureInfo.InvariantCulture); + + private static PreciseNumber Call(string function, PreciseNumber x) => function switch + { + "Log" => PreciseNumber.Log(x), + "Log2" => PreciseNumber.Log2(x), + "Log10" => PreciseNumber.Log10(x), + "LogP1" => PreciseNumber.LogP1(x), + "Acosh" => PreciseNumber.Acosh(x), + "Atanh" => PreciseNumber.Atanh(x), + "Sqrt" => PreciseNumber.Sqrt(x), + "RootN4" => PreciseNumber.RootN(x, 4), + "RootN-4" => PreciseNumber.RootN(x, -4), + "Asin" => PreciseNumber.Asin(x), + "Acos" => PreciseNumber.Acos(x), + "PowOfNegativeTwo" => Parse("-2").Pow(x), + _ => throw new ArgumentException($"No such function: {function}", nameof(function)), + }; + + [TestMethod] + [DataRow("Log", "-1e2000000000")] + [DataRow("Log", "-1e-2000000000")] + [DataRow("Log", "-1e-2147483648")] + [DataRow("Log", "-1e-200000000")] + [DataRow("Log2", "-1e2000000000")] + [DataRow("Log2", "-1e-2147483648")] + [DataRow("Log10", "-1e2000000000")] + [DataRow("Log10", "-1e-2147483648")] + [DataRow("LogP1", "-1e2000000000")] + [DataRow("Acosh", "1e-2000000000")] + [DataRow("Acosh", "1e-2147483648")] + [DataRow("Acosh", "-1e2000000000")] + [DataRow("Acosh", "-1e-2147483648")] + [DataRow("Atanh", "1e2000000000")] + [DataRow("Atanh", "-1e2000000000")] + [DataRow("Sqrt", "-1e2000000000")] + [DataRow("Sqrt", "-1e-2000000000")] + [DataRow("Sqrt", "-1e-2147483648")] + [DataRow("RootN4", "-1e2000000000")] + [DataRow("RootN4", "-1e-2147483648")] + [DataRow("RootN-4", "-1e2000000000")] + [DataRow("RootN-4", "-1e-2147483648")] + [DataRow("Asin", "1e2000000000")] + [DataRow("Asin", "-1e2000000000")] + [DataRow("Acos", "1e2000000000")] + [DataRow("Acos", "-1e2000000000")] + [DataRow("PowOfNegativeTwo", "1e-2000000000")] + [DataRow("PowOfNegativeTwo", "1e-2147483648")] + [DataRow("PowOfNegativeTwo", "-1e-2147483648")] + public void TestDomainErrorMessageIsShortForAnExtremeExponent(string function, string argument) + { + PreciseNumber x = Parse(argument); + + ArgumentOutOfRangeException exception = Assert.ThrowsExactly(() => Call(function, x)); + + string message = exception.Message; + Assert.IsLessThan(MessageLengthLimit, message.Length, $"{function}({argument}) has a {message.Length}-character message"); + } + + [TestMethod] + public void TestDomainErrorCarriesTheArgument() + { + ArgumentOutOfRangeException exception = Assert.ThrowsExactly(() => PreciseNumber.Sqrt(Parse("-12.5"))); + + Assert.AreEqual("x", exception.ParamName); + Assert.AreEqual("-1.25E+001", exception.ActualValue); + Assert.Contains("-1.25E+001", exception.Message); + } + + [TestMethod] + public void TestDomainErrorKeepsThirtyFractionalDigitsOfALongArgument() + { + PreciseNumber x = -PreciseNumber.Pi; + + ArgumentOutOfRangeException exception = Assert.ThrowsExactly(() => PreciseNumber.Log(x)); + + Assert.AreEqual(x.ToString("E30", CultureInfo.InvariantCulture), exception.ActualValue); + } +} diff --git a/PreciseNumber/PreciseNumber.Exponentials.cs b/PreciseNumber/PreciseNumber.Exponentials.cs index 90995f9..2bb3749 100644 --- a/PreciseNumber/PreciseNumber.Exponentials.cs +++ b/PreciseNumber/PreciseNumber.Exponentials.cs @@ -71,6 +71,30 @@ public readonly partial record struct PreciseNumber /// The message carried by the exception thrown for a fractional power of a negative value. private const string NegativeBaseMessage = "A negative value has no real power with a fractional exponent."; + /// The most fractional digits renders of the offending value. + private const int DomainErrorFractionalDigits = 30; + + /// + /// Builds the exception a domain check throws for an argument outside the function's domain. + /// + /// The name of the offending parameter. + /// The offending value. + /// What the function's domain is. + /// The exception to throw. + /// + /// renders + /// with ToString(), which writes fixed point: hundreds of megabytes for an exponent in the hundreds of + /// millions, and an exception of its own past what a string can hold. So the value is carried in the + /// E format, every digit of it up to after the point, + /// and reading the message can neither throw nor stall. + /// + private static ArgumentOutOfRangeException DomainError(string paramName, PreciseNumber value, string message) + { + int fractionalDigits = Math.Clamp(value.SignificantDigits - 1, 0, DomainErrorFractionalDigits); + string rendered = value.ToString("E" + fractionalDigits.ToString(InvariantCulture), InvariantCulture); + return new ArgumentOutOfRangeException(paramName, rendered, message); + } + /// Two, as the divisor of the halving step and the multiplier of the atanh series. private static PreciseNumber Two { get; } = new(0, 2); @@ -143,7 +167,7 @@ public static PreciseNumber Log(PreciseNumber x, int significantDigits) if (x.Significand.Sign <= 0) { - throw new ArgumentOutOfRangeException(nameof(x), x, NonPositiveLogarithmMessage); + throw DomainError(nameof(x), x, NonPositiveLogarithmMessage); } if (x.IsUnit) @@ -257,7 +281,7 @@ public static PreciseNumber Log10(PreciseNumber x, int significantDigits) if (x.Significand.Sign <= 0) { - throw new ArgumentOutOfRangeException(nameof(x), x, NonPositiveLogarithmMessage); + throw DomainError(nameof(x), x, NonPositiveLogarithmMessage); } (PreciseNumber mantissa, long decimalExponent) = SplitAroundRootTen(x); @@ -310,7 +334,7 @@ public static PreciseNumber LogP1(PreciseNumber x, int significantDigits) if (x <= NegativeOne) { - throw new ArgumentOutOfRangeException(nameof(x), x, LogP1DomainMessage); + throw DomainError(nameof(x), x, LogP1DomainMessage); } // Away from zero there is nothing to cancel, so the general logarithm is both simpler and diff --git a/PreciseNumber/PreciseNumber.Hyperbolics.cs b/PreciseNumber/PreciseNumber.Hyperbolics.cs index c60c5b5..b1a9c85 100644 --- a/PreciseNumber/PreciseNumber.Hyperbolics.cs +++ b/PreciseNumber/PreciseNumber.Hyperbolics.cs @@ -311,7 +311,7 @@ public static PreciseNumber Acosh(PreciseNumber x, int significantDigits) if (x < One) { - throw new ArgumentOutOfRangeException(nameof(x), x, AcoshDomainMessage); + throw DomainError(nameof(x), x, AcoshDomainMessage); } if (x.IsUnit) @@ -364,7 +364,7 @@ public static PreciseNumber Atanh(PreciseNumber x, int significantDigits) if (Abs(x) >= One) { - throw new ArgumentOutOfRangeException(nameof(x), x, AtanhDomainMessage); + throw DomainError(nameof(x), x, AtanhDomainMessage); } if (x.Significand.IsZero) diff --git a/PreciseNumber/PreciseNumber.Roots.cs b/PreciseNumber/PreciseNumber.Roots.cs index 1ad4db6..c49ef1e 100644 --- a/PreciseNumber/PreciseNumber.Roots.cs +++ b/PreciseNumber/PreciseNumber.Roots.cs @@ -161,7 +161,7 @@ public static PreciseNumber RootN(PreciseNumber x, int n, int significantDigits) 0 => throw new DivideByZeroException(), > 0 => PositiveReciprocalRootN(x, -n, significantDigits), _ => int.IsEvenInteger(n) - ? throw new ArgumentOutOfRangeException(nameof(x), x, NegativeRootMessage) + ? throw DomainError(nameof(x), x, NegativeRootMessage) : -PositiveReciprocalRootN(-x, -n, significantDigits), }; } @@ -178,7 +178,7 @@ public static PreciseNumber RootN(PreciseNumber x, int n, int significantDigits) // An odd root of a negative value is real, so the sign comes out and goes back on. return int.IsEvenInteger(n) - ? throw new ArgumentOutOfRangeException(nameof(x), x, NegativeRootMessage) + ? throw DomainError(nameof(x), x, NegativeRootMessage) : -PositiveRootN(-x, n, significantDigits); } diff --git a/PreciseNumber/PreciseNumber.Trigonometry.cs b/PreciseNumber/PreciseNumber.Trigonometry.cs index 44ccef6..0f43a20 100644 --- a/PreciseNumber/PreciseNumber.Trigonometry.cs +++ b/PreciseNumber/PreciseNumber.Trigonometry.cs @@ -295,7 +295,7 @@ public static PreciseNumber Asin(PreciseNumber x, int significantDigits) PreciseNumber magnitude = Abs(x); if (magnitude > One) { - throw new ArgumentOutOfRangeException(nameof(x), x, InverseDomainMessage); + throw DomainError(nameof(x), x, InverseDomainMessage); } if (x.Significand.IsZero) @@ -345,7 +345,7 @@ public static PreciseNumber Acos(PreciseNumber x, int significantDigits) PreciseNumber magnitude = Abs(x); if (magnitude > One) { - throw new ArgumentOutOfRangeException(nameof(x), x, InverseDomainMessage); + throw DomainError(nameof(x), x, InverseDomainMessage); } int working = significantDigits + TrigonometricGuardDigits; diff --git a/PreciseNumber/PreciseNumber.cs b/PreciseNumber/PreciseNumber.cs index 602eeaf..e767f73 100644 --- a/PreciseNumber/PreciseNumber.cs +++ b/PreciseNumber/PreciseNumber.cs @@ -2224,7 +2224,7 @@ public PreciseNumber Pow(PreciseNumber power) // no complex results here, so this is rejected rather than quietly returned as a NaN. if (Significand.Sign < 0) { - throw new ArgumentOutOfRangeException(nameof(power), power, NegativeBaseMessage); + throw DomainError(nameof(power), power, NegativeBaseMessage); } int significantDigits = Math.Max(