Skip to content

Commit 13e5655

Browse files
committed
Stop ExpM1, LogP1 and the hyperbolics building million-digit sums for large arguments [patch]
Exact addition aligns both operands to the smaller exponent, so subtracting one from e^-1e7, or adding one to 1e1000000, built a significand millions of digits wide only for the caller to round it away. ExpM1(-1e10) overflowed the exponent of e^-1e10 before it got that far. AddToPrecision and SubtractToPrecision collapse an operand that lies wholly below the larger one's last digit and the caller's rounding position into a single sticky unit, which rounds the same way as the exact sum. ExpM1, LogP1, Sinh, Cosh, Asinh and Acosh use them, and ExpM1 returns -1 outright once e^x is below the working width. Fixes #133 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TTeaDNAaTEviJbNWuEi5hP
1 parent 8d3b341 commit 13e5655

5 files changed

Lines changed: 187 additions & 9 deletions

File tree

‎CLAUDE.md‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ dotnet run -c Release --project PreciseNumber.Benchmarks -- --filter '*' --job s
4141
- Exponentials, logarithms and powers (`PreciseNumber/PreciseNumber.Exponentials.cs`, satisfying `IExponentialFunctions`, `ILogarithmicFunctions` and `IPowerFunctions`) follow the same precision rule and do not route through `double` either. `ln(m · 10^k)` is `ln m + k · ln 10` against the stored `Ln10`, with the mantissa centred on `[1/√10, √10)` and fed to the atanh series; `exp(v)` factors out `10^round(v / ln 10)` as an exponent shift and halves what is left before a Taylor sum. Nothing here is a free-standing decision: `Exp10`/`Log10` must not route through the natural log, because the exponent is the whole answer for a power of ten, and the `…M1`/`…P1` variants must not be computed as `Exp(x) - 1`/`Log(1 + x)`, because that cancels away the precision near zero they exist to keep
4242
- A fractional `Pow` is `exp(y · ln x)`, carried wider by the integer digits of `y · ln x` because `Exp`'s range reduction consumes them. The integer path stays exponentiation by squaring and is exact; tests pin that exactness rather than a tolerance
4343
- Hyperbolics (`PreciseNumber/PreciseNumber.Hyperbolics.cs`, satisfying `IHyperbolicFunctions`) are the exponentials and logarithms under other names and add no transcendental machinery of their own. Which of them needs its textbook form rearranged is narrower than floating-point habit suggests, and the reason is worth keeping straight: addition, subtraction and multiplication here are *exact*, so cancelling two nearly equal values costs nothing by itself. Digits are lost only by cancelling against something `Exp`, `Log`, `Sqrt` or `Divide` has **already** rounded to a working width. That is why `sinh` sums its own series below one half instead of taking `(e^x - e^-x)/2`, and why `asinh` and `atanh` subtract their one analytically and go through `LogP1` — each of those three otherwise returns about thirty correct digits from a fifty-digit type at `1e-30`, and the tests fail outright on them rather than drifting in the last place. `cosh`, `tanh` and `acosh` do **not** need it: `cosh` sums two positive terms, and the other two cancel only against operands nothing has rounded yet. `tanh` is still written as `-t/(2 + t)` with `t = expm1(-2x)`, but for range rather than precision — the negative exponent decays instead of growing, so it saturates to `±1` where `e^2x` would overflow — and `acosh` still factors the difference of squares, to keep a `2n`-digit intermediate out of the root. Don't simplify the first three back, and don't defend the last two on precision grounds
44+
- `Add` and `Subtract` are exact, which is a trap when one operand sits millions of digits below the other: aligning them builds a significand that wide only for the caller to round it away. Where a sum is only ever going to be rounded (the `±1` in `ExpM1` and `LogP1`, the reciprocal in `Sinh` and `Cosh`, the ones in `Asinh` and `Acosh`), use `AddToPrecision`/`SubtractToPrecision`, which collapse an operand wholly below the kept digits into one sticky unit that rounds the same way. `ExpM1` also returns `-1` outright once `e^x` is below the working width, since `Exp` of a very negative argument overflows its exponent
4445
- The `sanitize` constructor parameter controls whether trailing zeros are removed (default: true)
4546
- Constants (`Zero`, `One`, `Pi`, `E`, `Tau`) are pre-computed static instances
4647
- `ConstantPrecision` is a ceiling, not just a width. `PiTo`/`ETo`/`Ln2To`/`Ln10To` serve at most that many digits and return the capped constant rather than failing, which is deliberate and pinned by `TestConstantAccessorsReturnTheWholeConstantWhenAskedForMore` — so a caller that *needs* what it asked for has to check, because the accessor will not. The circular functions are where this bites: reducing modulo `π/2` spends one digit of π per integer digit of the argument, so only about `ConstantPrecision - integerDigits` are left for the answer. `RequireReducibleArgument` enforces exactly that sum and nothing wider. Do not tighten it to `reductionDigits`: that pads the requirement with `TrigonometricGuardDigits` twice over as margin, margin is allowed to be unavailable, and bounding it would refuse `Sin(1000000, 130)` — a call whose every reported digit is correct. The half-turn family (`SinPi` and the rest) has no such ceiling, because it reduces before it multiplies, and `Log` has none either, because it carries the magnitude in the decimal exponent rather than cancelling it against a constant
@@ -49,7 +50,7 @@ dotnet run -c Release --project PreciseNumber.Benchmarks -- --filter '*' --job s
4950

5051
### Test Structure
5152

52-
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 — 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.
53+
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.
5354

5455
### Benchmarks
5556

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
// Copyright (c) 2023-2026 ktsu-dev contributors
2+
3+
namespace ktsu.PreciseNumber.Test;
4+
5+
using System;
6+
using System.Globalization;
7+
using System.Threading.Tasks;
8+
9+
/// <summary>
10+
/// Covers the functions that add or subtract one, or a reciprocal, against an intermediate whose
11+
/// exponent lies far from zero: <c>ExpM1</c>, <c>LogP1</c>, <c>Sinh</c>, <c>Cosh</c>, <c>Asinh</c>
12+
/// and <c>Acosh</c>.
13+
/// </summary>
14+
/// <remarks>
15+
/// Exact addition aligns both operands to the smaller exponent, so <c>e^-1e7 - 1</c> used to build a
16+
/// four-million-digit significand for an answer that rounds to <c>-1</c>, and <c>ExpM1(-1e10)</c>
17+
/// overflowed the exponent of <c>e^-1e10</c> before it got that far. The pinned digits below were
18+
/// produced by the exact path before it was bounded, so they show the bounded path rounds the same.
19+
/// </remarks>
20+
[TestClass]
21+
public class PreciseNumberLargeArgumentTests
22+
{
23+
/// <summary>
24+
/// Longer than any of these calls takes once bounded, and far shorter than any took before.
25+
/// </summary>
26+
private static readonly TimeSpan Budget = TimeSpan.FromSeconds(5);
27+
28+
private static PreciseNumber Parse(string text) =>
29+
PreciseNumber.Parse(text, CultureInfo.InvariantCulture);
30+
31+
/// <summary>
32+
/// Runs a call on the thread pool and fails as soon as it overruns <see cref="Budget"/>, rather
33+
/// than holding the test run until a call that takes minutes finishes.
34+
/// </summary>
35+
private static PreciseNumber WithinBudget(Func<PreciseNumber> call, string description)
36+
{
37+
Task<PreciseNumber> task = Task.Run(call);
38+
Assert.IsTrue(task.Wait(Budget), $"{description} took longer than {Budget.TotalSeconds} seconds");
39+
return task.Result;
40+
}
41+
42+
[TestMethod]
43+
public void TestExpM1OfAVeryNegativeArgumentIsMinusOne()
44+
{
45+
Assert.AreEqual(PreciseNumber.NegativeOne, PreciseNumber.ExpM1(Parse("-1E10")), "ExpM1(-1e10) is not -1");
46+
Assert.AreEqual(PreciseNumber.NegativeOne, PreciseNumber.Exp2M1(Parse("-1E10")), "Exp2M1(-1e10) is not -1");
47+
Assert.AreEqual(PreciseNumber.NegativeOne, PreciseNumber.Exp10M1(Parse("-1E10")), "Exp10M1(-1e10) is not -1");
48+
Assert.AreEqual(PreciseNumber.NegativeOne, PreciseNumber.ExpM1(Parse("-1E10"), 200), "ExpM1(-1e10) at 200 digits is not -1");
49+
}
50+
51+
[TestMethod]
52+
public void TestExpM1OfALargeArgumentFinishesQuickly()
53+
{
54+
Assert.AreEqual(PreciseNumber.NegativeOne, WithinBudget(() => PreciseNumber.ExpM1(Parse("-1E7")), "ExpM1(-1e7)"), "ExpM1(-1e7) is not -1");
55+
Assert.AreEqual(PreciseNumber.NegativeOne, WithinBudget(() => PreciseNumber.Exp10M1(Parse("-1E7")), "Exp10M1(-1e7)"), "Exp10M1(-1e7) is not -1");
56+
57+
PreciseNumber expected = PreciseNumber.Exp(Parse("1E6"));
58+
PreciseNumber actual = WithinBudget(() => PreciseNumber.ExpM1(Parse("1E6")), "ExpM1(1e6)");
59+
Assert.AreEqual(expected, actual, "ExpM1(1e6) differs from Exp(1e6), though the one is far below its last digit");
60+
}
61+
62+
[TestMethod]
63+
public void TestLogP1OfAHugeArgumentFinishesQuickly()
64+
{
65+
PreciseNumber x = Parse("1E1000000");
66+
Assert.AreEqual(PreciseNumber.Log(x), WithinBudget(() => PreciseNumber.LogP1(x), "LogP1(1e1000000)"), "LogP1(1e1000000) differs from Log(1e1000000)");
67+
}
68+
69+
[TestMethod]
70+
public void TestSinhAndCoshOfALargeArgumentFinishQuickly()
71+
{
72+
PreciseNumber sinh = WithinBudget(() => PreciseNumber.Sinh(Parse("1E7")), "Sinh(1e7)");
73+
PreciseNumber cosh = WithinBudget(() => PreciseNumber.Cosh(Parse("-1E7")), "Cosh(-1e7)");
74+
75+
// At this size e^-x is millions of digits below e^x, so both are e^x / 2 to every digit kept.
76+
Assert.AreEqual(sinh, cosh, "Sinh(1e7) and Cosh(-1e7) disagree");
77+
Assert.AreEqual(-sinh, WithinBudget(() => PreciseNumber.Sinh(Parse("-1E7")), "Sinh(-1e7)"), "Sinh is not odd at 1e7");
78+
}
79+
80+
[TestMethod]
81+
public void TestAsinhAndAcoshOfAHugeArgumentFinishQuickly()
82+
{
83+
PreciseNumber x = Parse("1E1000000");
84+
85+
// Both are ln(2x) once 1/x is far below the last digit kept.
86+
PreciseNumber expected = PreciseNumber.Log(Parse("2E1000000"));
87+
Assert.AreEqual(expected, WithinBudget(() => PreciseNumber.Asinh(x), "Asinh(1e1000000)"), "Asinh(1e1000000) is not ln(2e1000000)");
88+
Assert.AreEqual(-expected, WithinBudget(() => PreciseNumber.Asinh(-x), "Asinh(-1e1000000)"), "Asinh(-1e1000000) is not -ln(2e1000000)");
89+
Assert.AreEqual(expected, WithinBudget(() => PreciseNumber.Acosh(x), "Acosh(1e1000000)"), "Acosh(1e1000000) is not ln(2e1000000)");
90+
}
91+
92+
[TestMethod]
93+
public void TestBoundedSumsKeepTheDigitsTheExactSumsGave()
94+
{
95+
Assert.AreEqual(Parse("-0.99999999999999999999999999999999999999999996279924"), PreciseNumber.ExpM1(Parse("-100"), 50), "ExpM1(-100) changed");
96+
Assert.AreEqual(Parse("1.9424263952412559365842088360176992193662086219516E130"), PreciseNumber.ExpM1(Parse("300"), 50), "ExpM1(300) changed");
97+
Assert.AreEqual(Parse("3.1622776601683793319988935444327185337195551393252E100"), PreciseNumber.Exp10M1(Parse("100.5"), 50), "Exp10M1(100.5) changed");
98+
Assert.AreEqual(Parse("692.08386071786388396574754062165634523662942268717"), PreciseNumber.LogP1(Parse("3.7E300"), 50), "LogP1(3.7e300) changed");
99+
Assert.AreEqual(Parse("9.712131976206279682921044180088496096831043109758E129"), PreciseNumber.Sinh(Parse("300"), 50), "Sinh(300) changed");
100+
Assert.AreEqual(Parse("1.60125985730181641832516084312451811087207753341E130"), PreciseNumber.Cosh(Parse("-300.5"), 50), "Cosh(-300.5) changed");
101+
Assert.AreEqual(Parse("-692.3849658106478055799981957385354499198560479429"), PreciseNumber.Asinh(Parse("-2.5E300"), 50), "Asinh(-2.5e300) changed");
102+
Assert.AreEqual(Parse("693.44967654764023392316347631621299731774945936819"), PreciseNumber.Acosh(Parse("7.25E300"), 50), "Acosh(7.25e300) changed");
103+
Assert.AreEqual(Parse("92.796550900321772670136890308832744872119559679511"), PreciseNumber.Acosh(Parse("1E40"), 50), "Acosh(1e40) changed");
104+
}
105+
}

‎PreciseNumber/PreciseNumber.Exponentials.cs‎

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -317,7 +317,7 @@ public static PreciseNumber LogP1(PreciseNumber x, int significantDigits)
317317
// better conditioned than an atanh argument approaching one.
318318
if (Abs(x) > DirectSeriesLimit)
319319
{
320-
return Log(Add(One, x), significantDigits);
320+
return Log(AddToPrecision(One, x, significantDigits + ExponentialGuardDigits), significantDigits);
321321
}
322322

323323
int working = significantDigits + ExponentialGuardDigits;
@@ -486,7 +486,16 @@ public static PreciseNumber ExpM1(PreciseNumber x, int significantDigits)
486486
if (Abs(x) > DirectSeriesLimit)
487487
{
488488
int wide = significantDigits + ExponentialGuardDigits;
489-
return Subtract(Exp(x, wide), One).ReduceSignificance(significantDigits);
489+
490+
// Below -3 · (wide + 1), e^x is under 10^-(wide + 1) because ln 10 is under three, so it
491+
// cannot reach the digits kept and the answer is -1. Exp itself would build that tiny
492+
// value first, or overflow its exponent trying to.
493+
if (x < new PreciseNumber(0, -3 * ((BigInteger)wide + 1)))
494+
{
495+
return NegativeOne;
496+
}
497+
498+
return SubtractToPrecision(Exp(x, wide), One, wide).ReduceSignificance(significantDigits);
490499
}
491500

492501
return ExpSeriesWithoutLeadingOne(x, significantDigits + ExponentialGuardDigits)

‎PreciseNumber/PreciseNumber.Hyperbolics.cs‎

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,7 @@ public static PreciseNumber Sinh(PreciseNumber x, int significantDigits)
107107

108108
PreciseNumber raised = Exp(x, working);
109109
PreciseNumber lowered = Divide(One, raised, working);
110-
return Divide(Subtract(raised, lowered), Two, working).ReduceSignificance(significantDigits);
110+
return Divide(SubtractToPrecision(raised, lowered, working), Two, working).ReduceSignificance(significantDigits);
111111
}
112112

113113
/// <summary>
@@ -150,7 +150,7 @@ public static PreciseNumber Cosh(PreciseNumber x, int significantDigits)
150150
int working = significantDigits + ExponentialGuardDigits;
151151
PreciseNumber raised = Exp(x, working);
152152
PreciseNumber lowered = Divide(One, raised, working);
153-
return Divide(Add(raised, lowered), Two, working).ReduceSignificance(significantDigits);
153+
return Divide(AddToPrecision(raised, lowered, working), Two, working).ReduceSignificance(significantDigits);
154154
}
155155

156156
/// <summary>
@@ -256,8 +256,8 @@ public static PreciseNumber Asinh(PreciseNumber x, int significantDigits)
256256
int working = significantDigits + ExponentialGuardDigits;
257257
PreciseNumber magnitude = Abs(x);
258258
PreciseNumber square = Multiply(magnitude, magnitude);
259-
PreciseNumber root = Sqrt(Add(One, square), working);
260-
PreciseNumber excess = Divide(square, Add(One, root), working);
259+
PreciseNumber root = Sqrt(AddToPrecision(One, square, working), working);
260+
PreciseNumber excess = Divide(square, AddToPrecision(One, root, working), working);
261261
PreciseNumber result = LogP1(Add(magnitude, excess), significantDigits);
262262

263263
return x.Significand.Sign > 0 ? result : -result;
@@ -320,8 +320,8 @@ public static PreciseNumber Acosh(PreciseNumber x, int significantDigits)
320320
}
321321

322322
int working = significantDigits + ExponentialGuardDigits;
323-
PreciseNumber below = Subtract(x, One);
324-
PreciseNumber root = Sqrt(Multiply(below, Add(x, One)), working);
323+
PreciseNumber below = SubtractToPrecision(x, One, working);
324+
PreciseNumber root = Sqrt(Multiply(below, AddToPrecision(x, One, working)), working);
325325
return LogP1(Add(below, root), significantDigits);
326326
}
327327

‎PreciseNumber/PreciseNumber.cs‎

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1554,6 +1554,69 @@ public static PreciseNumber Add(PreciseNumber left, PreciseNumber right)
15541554
return new PreciseNumber(commonExponent, commonLeft + commonRight);
15551555
}
15561556

1557+
/// <summary>
1558+
/// Digits kept below the last one a precision-bounded sum is wanted to, before a far smaller
1559+
/// operand is collapsed into a single sticky digit.
1560+
/// </summary>
1561+
private const int StickyGuardDigits = 3;
1562+
1563+
/// <summary>
1564+
/// Adds two numbers whose sum is only ever going to be rounded to a given number of significant
1565+
/// digits.
1566+
/// </summary>
1567+
/// <param name="left">The first number to add.</param>
1568+
/// <param name="right">The second number to add.</param>
1569+
/// <param name="significantDigits">The precision the caller will round the sum to.</param>
1570+
/// <returns>
1571+
/// The exact sum when the operands are close in magnitude. Otherwise the larger operand plus a
1572+
/// single unit of the smaller one's sign, placed below every digit of the larger operand and
1573+
/// <see cref="StickyGuardDigits"/> below the last digit the caller keeps.
1574+
/// </returns>
1575+
/// <remarks>
1576+
/// <see cref="Add(PreciseNumber, PreciseNumber)"/> is exact, so <c>1 + 1e-4000000</c> scales the
1577+
/// one by <c>10^4000000</c> and builds a four-million-digit significand only for the caller to
1578+
/// round almost all of it away. When the smaller operand lies wholly below the larger one's last
1579+
/// digit and below the caller's rounding position, the exact sum and the sticky sum lie strictly
1580+
/// inside the same gap between neighbouring multiples of that digit. Every rounding boundary at
1581+
/// the caller's precision, or one digit coarser after a halving, is such a multiple, so both
1582+
/// round the same way. Only the sign of the small operand can still matter, and the sticky unit
1583+
/// keeps it.
1584+
/// </remarks>
1585+
internal static PreciseNumber AddToPrecision(PreciseNumber left, PreciseNumber right, int significantDigits)
1586+
{
1587+
if (left.Significand.IsZero || right.Significand.IsZero)
1588+
{
1589+
return Add(left, right);
1590+
}
1591+
1592+
long leftMagnitude = (long)left.Exponent + left.SignificantDigits;
1593+
long rightMagnitude = (long)right.Exponent + right.SignificantDigits;
1594+
(PreciseNumber larger, PreciseNumber smaller, long largerMagnitude, long smallerMagnitude) = leftMagnitude >= rightMagnitude
1595+
? (left, right, leftMagnitude, rightMagnitude)
1596+
: (right, left, rightMagnitude, leftMagnitude);
1597+
1598+
// The smaller operand is below 10^smallerMagnitude, so it is wholly beneath 10^floor once
1599+
// smallerMagnitude is at most floor.
1600+
long floor = Math.Min(larger.Exponent, largerMagnitude - significantDigits - StickyGuardDigits);
1601+
if (smallerMagnitude > floor || floor - 1 < int.MinValue)
1602+
{
1603+
return Add(left, right);
1604+
}
1605+
1606+
return Add(larger, new PreciseNumber((int)(floor - 1), smaller.Significand.Sign));
1607+
}
1608+
1609+
/// <summary>
1610+
/// Subtracts one number from another where the difference is only ever going to be rounded to a
1611+
/// given number of significant digits.
1612+
/// </summary>
1613+
/// <param name="left">The number to subtract from.</param>
1614+
/// <param name="right">The number to subtract.</param>
1615+
/// <param name="significantDigits">The precision the caller will round the difference to.</param>
1616+
/// <returns>The difference, as <see cref="AddToPrecision(PreciseNumber, PreciseNumber, int)"/> forms it.</returns>
1617+
internal static PreciseNumber SubtractToPrecision(PreciseNumber left, PreciseNumber right, int significantDigits) =>
1618+
AddToPrecision(left, -right, significantDigits);
1619+
15571620
/// <summary>
15581621
/// Multiplies two numbers.
15591622
/// </summary>

0 commit comments

Comments
 (0)