Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
97 changes: 97 additions & 0 deletions PreciseNumber.Test/PreciseNumberDomainErrorTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
// Copyright (c) 2023-2026 ktsu-dev contributors

namespace ktsu.PreciseNumber.Test;

using System.Globalization;

/// <summary>
/// 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 <c>1e-2147483648</c> needs more characters than a
/// string can hold.
/// </summary>
[TestClass]
public class PreciseNumberDomainErrorTests
{
/// <summary>The longest message a domain error may carry.</summary>
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<ArgumentOutOfRangeException>(() => 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<ArgumentOutOfRangeException>(() => 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<ArgumentOutOfRangeException>(() => PreciseNumber.Log(x));

Assert.AreEqual(x.ToString("E30", CultureInfo.InvariantCulture), exception.ActualValue);
}
}
30 changes: 27 additions & 3 deletions PreciseNumber/PreciseNumber.Exponentials.cs
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,30 @@ public readonly partial record struct PreciseNumber
/// <summary>The message carried by the exception thrown for a fractional power of a negative value.</summary>
private const string NegativeBaseMessage = "A negative value has no real power with a fractional exponent.";

/// <summary>The most fractional digits <see cref="DomainError"/> renders of the offending value.</summary>
private const int DomainErrorFractionalDigits = 30;

/// <summary>
/// Builds the exception a domain check throws for an argument outside the function's domain.
/// </summary>
/// <param name="paramName">The name of the offending parameter.</param>
/// <param name="value">The offending value.</param>
/// <param name="message">What the function's domain is.</param>
/// <returns>The exception to throw.</returns>
/// <remarks>
/// <see cref="ArgumentOutOfRangeException.Message"/> renders <see cref="ArgumentOutOfRangeException.ActualValue"/>
/// with <c>ToString()</c>, 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
/// <c>E</c> format, every digit of it up to <see cref="DomainErrorFractionalDigits"/> after the point,
/// and reading the message can neither throw nor stall.
/// </remarks>
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);
}

/// <summary>Two, as the divisor of the halving step and the multiplier of the atanh series.</summary>
private static PreciseNumber Two { get; } = new(0, 2);

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions PreciseNumber/PreciseNumber.Hyperbolics.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
4 changes: 2 additions & 2 deletions PreciseNumber/PreciseNumber.Roots.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
};
}
Expand All @@ -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);
}

Expand Down
4 changes: 2 additions & 2 deletions PreciseNumber/PreciseNumber.Trigonometry.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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;
Expand Down
2 changes: 1 addition & 1 deletion PreciseNumber/PreciseNumber.cs
Original file line number Diff line number Diff line change
Expand Up @@ -748,7 +748,7 @@
/// A fixed precision such as <c>E15</c> rounds values that need 17 digits, which turns
/// <see cref="double.MaxValue"/> into a number that converts back to infinity.
/// </remarks>
internal static string GetStringFormatForFloatType<TFloat>()

Check warning on line 751 in PreciseNumber/PreciseNumber.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Remove this method and declare a constant for this value.

Check warning on line 751 in PreciseNumber/PreciseNumber.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Remove this method and declare a constant for this value.
where TFloat : INumber<TFloat>
=> "R";

Expand Down Expand Up @@ -1209,7 +1209,7 @@
public static PreciseNumber MinMagnitudeNumber(PreciseNumber x, PreciseNumber y) => MinMagnitude(x, y);

/// <inheritdoc/>
public static PreciseNumber Parse(ReadOnlySpan<char> s, NumberStyles style, IFormatProvider? provider)

Check warning on line 1212 in PreciseNumber/PreciseNumber.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Refactor this method to reduce its Cognitive Complexity from 30 to the 15 allowed.

Check warning on line 1212 in PreciseNumber/PreciseNumber.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

All 'Parse' method overloads should be adjacent.

Check warning on line 1212 in PreciseNumber/PreciseNumber.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Refactor this method to reduce its Cognitive Complexity from 30 to the 15 allowed.

Check warning on line 1212 in PreciseNumber/PreciseNumber.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

All 'Parse' method overloads should be adjacent.
{
// Read the separator and signs from the same culture TryFormat writes them with, so that
// Parse(x.ToString(p), p) round-trips for every provider.
Expand Down Expand Up @@ -1268,14 +1268,14 @@
}

hasDecimal = true;
i += decimalSeparator.Length - 1;

Check warning on line 1271 in PreciseNumber/PreciseNumber.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Do not update the stop condition variable 'i' in the body of the for loop.

Check warning on line 1271 in PreciseNumber/PreciseNumber.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Do not update the stop condition variable 'i' in the body of the for loop.
continue;
}

// Group separators may only sit between integral digits.
if (allowThousands && !hasDecimal && digitCount > 0 && StartsWithSeparator(s[i..], groupSeparator))
{
i += groupSeparator.Length - 1;

Check warning on line 1278 in PreciseNumber/PreciseNumber.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Do not update the stop condition variable 'i' in the body of the for loop.

Check warning on line 1278 in PreciseNumber/PreciseNumber.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Do not update the stop condition variable 'i' in the body of the for loop.
continue;
}

Expand Down Expand Up @@ -2224,7 +2224,7 @@
// 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(
Expand Down
Loading