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
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ dotnet run -c Release --project PreciseNumber.Benchmarks -- --filter '*' --job s
- 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
- The `sanitize` constructor parameter controls whether trailing zeros are removed (default: true)
- Constants (`Zero`, `One`, `Pi`, `E`, `Tau`) are pre-computed static instances
- `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
- 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
- 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

Expand Down
56 changes: 56 additions & 0 deletions PreciseNumber.Test/PreciseNumberTrigonometryTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,62 @@
AssertAgreesTo(Parse(SinOneMillionDigits), sine, 120, "Sin(1000000) did not match its wide reference");
}

[TestMethod]
public void TestSinRefusesMoreDigitsThanPiCanReduceAgainst()
{
// The issue's acceptance criterion. Reducing modulo π/2 cancels the argument's integer
// digits, so π has to carry those on top of the answer. PiTo caps at ConstantPrecision and
// returns the capped constant rather than failing, so this used to come back reporting every
// digit asked for while only about ConstantPrecision - argumentDigits of them were real.
PreciseNumber million = Parse("1000000");

ArgumentOutOfRangeException thrown = Assert.ThrowsExactly<ArgumentOutOfRangeException>(
() => PreciseNumber.Sin(million, PreciseNumber.ConstantPrecision - 6));

// The message has to name the shortfall, or a caller cannot tell what to ask for instead.
StringAssert.Contains(thrown.Message, "144 significant digits needs", StringComparison.Ordinal);

Check warning on line 138 in PreciseNumber.Test/PreciseNumberTrigonometryTests.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use 'Assert.Contains' instead of 'StringAssert.Contains'

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_PreciseNumber&issues=AaDK5nF8yHLj2dbzh7jZ&open=AaDK5nF8yHLj2dbzh7jZ&pullRequest=97
StringAssert.Contains(thrown.Message, "143 significant digits are available", StringComparison.Ordinal);

Check warning on line 139 in PreciseNumber.Test/PreciseNumberTrigonometryTests.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use 'Assert.Contains' instead of 'StringAssert.Contains'

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_PreciseNumber&issues=AaDK5nF8yHLj2dbzh7ja&open=AaDK5nF8yHLj2dbzh7ja&pullRequest=97
}

[TestMethod]
public void TestSinDeliversEveryDigitUpToTheReductionCeiling()
{
// The other half, and the reason the bound is where it is rather than somewhere safer. One
// million has seven integer digits, so ConstantPrecision - 7 digits is the most π can
// support — and that request has to succeed and be correct, not be refused out of caution.
PreciseNumber sine = PreciseNumber.Sin(Parse("1000000"), PreciseNumber.ConstantPrecision - 7);

Assert.AreEqual(PreciseNumber.ConstantPrecision - 7, sine.SignificantDigits);
AssertAgreesTo(Parse(SinOneMillionDigits), sine, 120, "Sin at the reduction ceiling did not match its wide reference");
}

[TestMethod]
public void TestSinRefusesAnArgumentTooLargeToReduceAtAll()
{
// Magnitude alone is enough, without asking for many digits. An argument of 201 integer
// digits cancels more of π than exists, so there is no precision at which the answer means
// anything — where before it returned digits that were simply invented.
PreciseNumber enormous = Parse("1E200");

Assert.ThrowsExactly<ArgumentOutOfRangeException>(() => PreciseNumber.Sin(enormous, 10));
Assert.ThrowsExactly<ArgumentOutOfRangeException>(() => PreciseNumber.Cos(enormous, 10));
Assert.ThrowsExactly<ArgumentOutOfRangeException>(() => PreciseNumber.Tan(enormous, 10));
}

[TestMethod]
public void TestOrdinaryAnglesAreUnaffectedByTheReductionCeiling()
{
// The guard on the guard. Every angle in the sweep at a sane precision has to keep working,
// so the ceiling cannot be reached by ordinary use — only the combination of a large
// magnitude and a high precision is beyond what the constant can serve.
foreach (PreciseNumber angle in AngleSweep())
{
(PreciseNumber sin, PreciseNumber cos) = PreciseNumber.SinCos(angle, 60);
Assert.IsTrue(sin.SignificantDigits <= 60, $"Sin({angle}) reported more digits than were asked for");

Check warning on line 176 in PreciseNumber.Test/PreciseNumberTrigonometryTests.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use 'Assert.IsLessThanOrEqualTo' instead of 'Assert.IsTrue'

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_PreciseNumber&issues=AaDK5nF8yHLj2dbzh7jb&open=AaDK5nF8yHLj2dbzh7jb&pullRequest=97
Assert.IsTrue(cos.SignificantDigits <= 60, $"Cos({angle}) reported more digits than were asked for");

Check warning on line 177 in PreciseNumber.Test/PreciseNumberTrigonometryTests.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use 'Assert.IsLessThanOrEqualTo' instead of 'Assert.IsTrue'

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_PreciseNumber&issues=AaDK5nF8yHLj2dbzh7jc&open=AaDK5nF8yHLj2dbzh7jc&pullRequest=97
}
}

[TestMethod]
public void TestPythagoreanIdentityHoldsAcrossTheSweep()
{
Expand Down
85 changes: 82 additions & 3 deletions PreciseNumber/PreciseNumber.Trigonometry.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,13 @@ namespace ktsu.PreciseNumber;
/// rather than the <see cref="Pi"/> property — which is exactly why <see cref="Sin(PreciseNumber)"/>
/// of a large angle is meaningful at all.
/// <para>
/// π is stored to <see cref="ConstantPrecision"/> digits, so that same <c>d + n</c> is a ceiling as
/// well as a requirement: about <c>ConstantPrecision - d</c> digits are available at magnitude
/// <c>10^d</c>, and a circular function asked for more is refused rather than answered with digits
/// the constant never carried. The half-turn family below has no such ceiling, because it reduces
/// before it multiplies and so never needs a wide π at all.
/// </para>
/// <para>
/// <see cref="Sin(PreciseNumber)"/> and <see cref="Cos(PreciseNumber)"/> reduce modulo <c>π/2</c>
/// into <c>[-π/4, π/4]</c> with an octant index, so one kernel serves both and the series stays
/// short. <see cref="SinCos(PreciseNumber)"/> exists to do that reduction once for a caller that
Expand Down Expand Up @@ -100,7 +107,12 @@ public static PreciseNumber Sin(PreciseNumber x) =>
/// <param name="x">The angle, in radians.</param>
/// <param name="significantDigits">The number of significant digits to produce.</param>
/// <returns>The sine of <paramref name="x"/>.</returns>
/// <exception cref="ArgumentOutOfRangeException">Thrown when <paramref name="significantDigits"/> is less than one.</exception>
/// <exception cref="ArgumentOutOfRangeException">
/// Thrown when <paramref name="significantDigits"/> is less than one, or when it and the integer
/// digits of <paramref name="x"/> together exceed <see cref="ConstantPrecision"/>. Reducing the
/// argument cancels its integer digits out of π, so those and the answer both have to fit inside
/// the stored constant; see <see cref="SinCos(PreciseNumber, int)"/>.
/// </exception>
public static PreciseNumber Sin(PreciseNumber x, int significantDigits) =>
SinCos(x, significantDigits).Sin;

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

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

RequireReducibleArgument(significantDigits, argumentDigits);

PreciseNumber piOverTwo = Divide(PiTo(reductionDigits), Two, reductionDigits);
BigInteger quadrant = RoundToNearestInteger(Divide(x, piOverTwo, reductionDigits));
PreciseNumber remainder = Subtract(x, Multiply(new(0, quadrant), piOverTwo))
Expand Down Expand Up @@ -869,4 +897,55 @@ private static PreciseNumber AtanSeries(PreciseNumber z, int workingDigits)
throw new ArithmeticException(
$"The arc tangent series did not converge to {workingDigits.ToString(InvariantCulture)} significant digits.");
}

/// <summary>
/// Throws when reducing an argument modulo <c>π/2</c> would need more of π than
/// <see cref="ConstantPrecision"/> carries.
/// </summary>
/// <param name="significantDigits">The significant digits the caller asked for.</param>
/// <param name="argumentDigits">The integer digits of the argument, which the reduction cancels.</param>
/// <exception cref="ArgumentOutOfRangeException">
/// Thrown when the two together exceed <see cref="ConstantPrecision"/>.
/// </exception>
/// <remarks>
/// <para>
/// <see cref="PiTo(int)"/> serves at most <see cref="ConstantPrecision"/> digits and says so, but
/// it returns the capped constant rather than failing, so a reduction that asked for more had no
/// way to know it had been short-changed. The result still reported the full
/// <paramref name="significantDigits"/>, which on this type is a promise that those digits are
/// correct.
/// </para>
/// <para>
/// The bound is the answer's width plus the digits the reduction destroys, not the padded
/// <c>reductionDigits</c> the caller passes to <see cref="PiTo(int)"/>. Those carry
/// <see cref="TrigonometricGuardDigits"/> twice over as margin, and margin is allowed to be
/// unavailable: <c>Sin(1000000, 130)</c> asks π for 157 digits, is served 150, and is still
/// correct to every digit it reports, which
/// <c>TestSinReducesALargeArgumentAgainstAWidePi</c> pins against an independent reference.
/// Bounding <c>reductionDigits</c> instead would refuse that call.
/// </para>
/// <para>
/// Measured rather than assumed. Against references computed independently at several hundred
/// digits, the correct digits of a sine come out as
/// <c>min(significantDigits, ConstantPrecision - argumentDigits)</c>: an argument of 80 integer
/// digits yields 71 correct digits whether 100 or 140 are requested, and one of 50 integer digits
/// yields 100. The sum crossing <see cref="ConstantPrecision"/> is exactly where the requested
/// digits stop being delivered.
/// </para>
/// </remarks>
private static void RequireReducibleArgument(int significantDigits, int argumentDigits)
{
if (significantDigits + argumentDigits > ConstantPrecision)
{
int available = Math.Max(0, ConstantPrecision - argumentDigits);
throw new ArgumentOutOfRangeException(
nameof(significantDigits),
significantDigits,
$"Reducing an argument of {argumentDigits.ToString(InvariantCulture)} integer digits to " +
$"{significantDigits.ToString(InvariantCulture)} significant digits needs π to " +
$"{(significantDigits + argumentDigits).ToString(InvariantCulture)} digits, but it is stored to " +
$"{ConstantPrecision.ToString(InvariantCulture)}. At most " +
$"{available.ToString(InvariantCulture)} significant digits are available at this magnitude.");
}
}
}
Loading