Skip to content

Commit dbbe8b3

Browse files
Merge pull request #97 from ktsu-dev/claude/precisenumber-96-constant-precision-ceiling
2 parents efc0bd0 + 6f79d8c commit dbbe8b3

3 files changed

Lines changed: 139 additions & 3 deletions

File tree

‎CLAUDE.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ dotnet run -c Release --project PreciseNumber.Benchmarks -- --filter '*' --job s
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
4444
- The `sanitize` constructor parameter controls whether trailing zeros are removed (default: true)
4545
- Constants (`Zero`, `One`, `Pi`, `E`, `Tau`) are pre-computed static instances
46+
- `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
4647
- As a value type it can't be null or inherited. Don't add null checks for `PreciseNumber` parameters, and don't reintroduce `protected` members
4748
- Conversions to integer types go through `BigInteger`, so range checks, clamping, and wrapping follow its conventions. Conversions to `double`, `float`, `Half`, and `decimal` render normalized scientific notation (`d.ddd…E±n`) and parse it, because the runtime parsers round correctly, with Clinger's fast path for small values. Keep one digit before the point. The .NET 7 and 8 parsers clamp an exponent above 1000 and still offset it by every digit ahead of the point, so a long significand rendered as an integer parses as zero there. Conversions from `double`, `float`, and `Half` use the shortest text that round-trips (`"R"`). NaN and infinity coming in follow `BigInteger` too
4849

‎PreciseNumber.Test/PreciseNumberTrigonometryTests.cs‎

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,62 @@ public void TestSinReducesALargeArgumentAgainstAWidePi()
122122
AssertAgreesTo(Parse(SinOneMillionDigits), sine, 120, "Sin(1000000) did not match its wide reference");
123123
}
124124

125+
[TestMethod]
126+
public void TestSinRefusesMoreDigitsThanPiCanReduceAgainst()
127+
{
128+
// The issue's acceptance criterion. Reducing modulo π/2 cancels the argument's integer
129+
// digits, so π has to carry those on top of the answer. PiTo caps at ConstantPrecision and
130+
// returns the capped constant rather than failing, so this used to come back reporting every
131+
// digit asked for while only about ConstantPrecision - argumentDigits of them were real.
132+
PreciseNumber million = Parse("1000000");
133+
134+
ArgumentOutOfRangeException thrown = Assert.ThrowsExactly<ArgumentOutOfRangeException>(
135+
() => PreciseNumber.Sin(million, PreciseNumber.ConstantPrecision - 6));
136+
137+
// The message has to name the shortfall, or a caller cannot tell what to ask for instead.
138+
StringAssert.Contains(thrown.Message, "144 significant digits needs", StringComparison.Ordinal);
139+
StringAssert.Contains(thrown.Message, "143 significant digits are available", StringComparison.Ordinal);
140+
}
141+
142+
[TestMethod]
143+
public void TestSinDeliversEveryDigitUpToTheReductionCeiling()
144+
{
145+
// The other half, and the reason the bound is where it is rather than somewhere safer. One
146+
// million has seven integer digits, so ConstantPrecision - 7 digits is the most π can
147+
// support — and that request has to succeed and be correct, not be refused out of caution.
148+
PreciseNumber sine = PreciseNumber.Sin(Parse("1000000"), PreciseNumber.ConstantPrecision - 7);
149+
150+
Assert.AreEqual(PreciseNumber.ConstantPrecision - 7, sine.SignificantDigits);
151+
AssertAgreesTo(Parse(SinOneMillionDigits), sine, 120, "Sin at the reduction ceiling did not match its wide reference");
152+
}
153+
154+
[TestMethod]
155+
public void TestSinRefusesAnArgumentTooLargeToReduceAtAll()
156+
{
157+
// Magnitude alone is enough, without asking for many digits. An argument of 201 integer
158+
// digits cancels more of π than exists, so there is no precision at which the answer means
159+
// anything — where before it returned digits that were simply invented.
160+
PreciseNumber enormous = Parse("1E200");
161+
162+
Assert.ThrowsExactly<ArgumentOutOfRangeException>(() => PreciseNumber.Sin(enormous, 10));
163+
Assert.ThrowsExactly<ArgumentOutOfRangeException>(() => PreciseNumber.Cos(enormous, 10));
164+
Assert.ThrowsExactly<ArgumentOutOfRangeException>(() => PreciseNumber.Tan(enormous, 10));
165+
}
166+
167+
[TestMethod]
168+
public void TestOrdinaryAnglesAreUnaffectedByTheReductionCeiling()
169+
{
170+
// The guard on the guard. Every angle in the sweep at a sane precision has to keep working,
171+
// so the ceiling cannot be reached by ordinary use — only the combination of a large
172+
// magnitude and a high precision is beyond what the constant can serve.
173+
foreach (PreciseNumber angle in AngleSweep())
174+
{
175+
(PreciseNumber sin, PreciseNumber cos) = PreciseNumber.SinCos(angle, 60);
176+
Assert.IsTrue(sin.SignificantDigits <= 60, $"Sin({angle}) reported more digits than were asked for");
177+
Assert.IsTrue(cos.SignificantDigits <= 60, $"Cos({angle}) reported more digits than were asked for");
178+
}
179+
}
180+
125181
[TestMethod]
126182
public void TestPythagoreanIdentityHoldsAcrossTheSweep()
127183
{

‎PreciseNumber/PreciseNumber.Trigonometry.cs‎

Lines changed: 82 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,13 @@ namespace ktsu.PreciseNumber;
1616
/// rather than the <see cref="Pi"/> property — which is exactly why <see cref="Sin(PreciseNumber)"/>
1717
/// of a large angle is meaningful at all.
1818
/// <para>
19+
/// π is stored to <see cref="ConstantPrecision"/> digits, so that same <c>d + n</c> is a ceiling as
20+
/// well as a requirement: about <c>ConstantPrecision - d</c> digits are available at magnitude
21+
/// <c>10^d</c>, and a circular function asked for more is refused rather than answered with digits
22+
/// the constant never carried. The half-turn family below has no such ceiling, because it reduces
23+
/// before it multiplies and so never needs a wide π at all.
24+
/// </para>
25+
/// <para>
1926
/// <see cref="Sin(PreciseNumber)"/> and <see cref="Cos(PreciseNumber)"/> reduce modulo <c>π/2</c>
2027
/// into <c>[-π/4, π/4]</c> with an octant index, so one kernel serves both and the series stays
2128
/// short. <see cref="SinCos(PreciseNumber)"/> exists to do that reduction once for a caller that
@@ -100,7 +107,12 @@ public static PreciseNumber Sin(PreciseNumber x) =>
100107
/// <param name="x">The angle, in radians.</param>
101108
/// <param name="significantDigits">The number of significant digits to produce.</param>
102109
/// <returns>The sine of <paramref name="x"/>.</returns>
103-
/// <exception cref="ArgumentOutOfRangeException">Thrown when <paramref name="significantDigits"/> is less than one.</exception>
110+
/// <exception cref="ArgumentOutOfRangeException">
111+
/// Thrown when <paramref name="significantDigits"/> is less than one, or when it and the integer
112+
/// digits of <paramref name="x"/> together exceed <see cref="ConstantPrecision"/>. Reducing the
113+
/// argument cancels its integer digits out of π, so those and the answer both have to fit inside
114+
/// the stored constant; see <see cref="SinCos(PreciseNumber, int)"/>.
115+
/// </exception>
104116
public static PreciseNumber Sin(PreciseNumber x, int significantDigits) =>
105117
SinCos(x, significantDigits).Sin;
106118

@@ -123,7 +135,12 @@ public static PreciseNumber Cos(PreciseNumber x) =>
123135
/// <param name="x">The angle, in radians.</param>
124136
/// <param name="significantDigits">The number of significant digits to produce.</param>
125137
/// <returns>The cosine of <paramref name="x"/>.</returns>
126-
/// <exception cref="ArgumentOutOfRangeException">Thrown when <paramref name="significantDigits"/> is less than one.</exception>
138+
/// <exception cref="ArgumentOutOfRangeException">
139+
/// Thrown when <paramref name="significantDigits"/> is less than one, or when it and the integer
140+
/// digits of <paramref name="x"/> together exceed <see cref="ConstantPrecision"/>. Reducing the
141+
/// argument cancels its integer digits out of π, so those and the answer both have to fit inside
142+
/// the stored constant; see <see cref="SinCos(PreciseNumber, int)"/>.
143+
/// </exception>
127144
public static PreciseNumber Cos(PreciseNumber x, int significantDigits) =>
128145
SinCos(x, significantDigits).Cos;
129146

@@ -146,13 +163,22 @@ public static (PreciseNumber Sin, PreciseNumber Cos) SinCos(PreciseNumber x) =>
146163
/// <param name="x">The angle, in radians.</param>
147164
/// <param name="significantDigits">The number of significant digits to produce.</param>
148165
/// <returns>A tuple of the sine and cosine of <paramref name="x"/>.</returns>
149-
/// <exception cref="ArgumentOutOfRangeException">Thrown when <paramref name="significantDigits"/> is less than one.</exception>
166+
/// <exception cref="ArgumentOutOfRangeException">
167+
/// Thrown when <paramref name="significantDigits"/> is less than one, or when it and the integer
168+
/// digits of <paramref name="x"/> together exceed <see cref="ConstantPrecision"/>.
169+
/// </exception>
150170
/// <remarks>
151171
/// <c>x = q · π/2 + r</c> with <c>q</c> the nearest integer and <c>r</c> in <c>[-π/4, π/4]</c>.
152172
/// The kernel evaluates the sine and cosine of <c>r</c>, and <c>q</c> mod four selects which, and
153173
/// with which sign, becomes the sine and cosine of <c>x</c>. The <c>π/2</c> the reduction
154174
/// subtracts is read wide enough that the integer part it cancels was present in the constant
155175
/// rather than invented.
176+
/// <para>
177+
/// That width is bounded: π is stored to <see cref="ConstantPrecision"/> digits, the reduction
178+
/// spends one per integer digit of <paramref name="x"/>, and what is left is the most the answer
179+
/// can carry. Beyond it the call is refused rather than answered with digits the constant never
180+
/// held — see <see cref="RequireReducibleArgument(int, int)"/>.
181+
/// </para>
156182
/// </remarks>
157183
public static (PreciseNumber Sin, PreciseNumber Cos) SinCos(PreciseNumber x, int significantDigits)
158184
{
@@ -170,6 +196,8 @@ public static (PreciseNumber Sin, PreciseNumber Cos) SinCos(PreciseNumber x, int
170196
int argumentDigits = IntegerDigitCount(x);
171197
int reductionDigits = working + argumentDigits + TrigonometricGuardDigits;
172198

199+
RequireReducibleArgument(significantDigits, argumentDigits);
200+
173201
PreciseNumber piOverTwo = Divide(PiTo(reductionDigits), Two, reductionDigits);
174202
BigInteger quadrant = RoundToNearestInteger(Divide(x, piOverTwo, reductionDigits));
175203
PreciseNumber remainder = Subtract(x, Multiply(new(0, quadrant), piOverTwo))
@@ -869,4 +897,55 @@ private static PreciseNumber AtanSeries(PreciseNumber z, int workingDigits)
869897
throw new ArithmeticException(
870898
$"The arc tangent series did not converge to {workingDigits.ToString(InvariantCulture)} significant digits.");
871899
}
900+
901+
/// <summary>
902+
/// Throws when reducing an argument modulo <c>π/2</c> would need more of π than
903+
/// <see cref="ConstantPrecision"/> carries.
904+
/// </summary>
905+
/// <param name="significantDigits">The significant digits the caller asked for.</param>
906+
/// <param name="argumentDigits">The integer digits of the argument, which the reduction cancels.</param>
907+
/// <exception cref="ArgumentOutOfRangeException">
908+
/// Thrown when the two together exceed <see cref="ConstantPrecision"/>.
909+
/// </exception>
910+
/// <remarks>
911+
/// <para>
912+
/// <see cref="PiTo(int)"/> serves at most <see cref="ConstantPrecision"/> digits and says so, but
913+
/// it returns the capped constant rather than failing, so a reduction that asked for more had no
914+
/// way to know it had been short-changed. The result still reported the full
915+
/// <paramref name="significantDigits"/>, which on this type is a promise that those digits are
916+
/// correct.
917+
/// </para>
918+
/// <para>
919+
/// The bound is the answer's width plus the digits the reduction destroys, not the padded
920+
/// <c>reductionDigits</c> the caller passes to <see cref="PiTo(int)"/>. Those carry
921+
/// <see cref="TrigonometricGuardDigits"/> twice over as margin, and margin is allowed to be
922+
/// unavailable: <c>Sin(1000000, 130)</c> asks π for 157 digits, is served 150, and is still
923+
/// correct to every digit it reports, which
924+
/// <c>TestSinReducesALargeArgumentAgainstAWidePi</c> pins against an independent reference.
925+
/// Bounding <c>reductionDigits</c> instead would refuse that call.
926+
/// </para>
927+
/// <para>
928+
/// Measured rather than assumed. Against references computed independently at several hundred
929+
/// digits, the correct digits of a sine come out as
930+
/// <c>min(significantDigits, ConstantPrecision - argumentDigits)</c>: an argument of 80 integer
931+
/// digits yields 71 correct digits whether 100 or 140 are requested, and one of 50 integer digits
932+
/// yields 100. The sum crossing <see cref="ConstantPrecision"/> is exactly where the requested
933+
/// digits stop being delivered.
934+
/// </para>
935+
/// </remarks>
936+
private static void RequireReducibleArgument(int significantDigits, int argumentDigits)
937+
{
938+
if (significantDigits + argumentDigits > ConstantPrecision)
939+
{
940+
int available = Math.Max(0, ConstantPrecision - argumentDigits);
941+
throw new ArgumentOutOfRangeException(
942+
nameof(significantDigits),
943+
significantDigits,
944+
$"Reducing an argument of {argumentDigits.ToString(InvariantCulture)} integer digits to " +
945+
$"{significantDigits.ToString(InvariantCulture)} significant digits needs π to " +
946+
$"{(significantDigits + argumentDigits).ToString(InvariantCulture)} digits, but it is stored to " +
947+
$"{ConstantPrecision.ToString(InvariantCulture)}. At most " +
948+
$"{available.ToString(InvariantCulture)} significant digits are available at this magnitude.");
949+
}
950+
}
872951
}

0 commit comments

Comments
 (0)