Skip to content

Implement IExponentialFunctions, ILogarithmicFunctions and IPowerFunctions [minor] - #90

Merged
matt-edmondson merged 2 commits into
mainfrom
claude/precisenumber-exp-log-pow
Sep 22, 2026
Merged

matt-edmondson merged 2 commits into
mainfrom
claude/precisenumber-exp-log-pow

Conversation

@matt-edmondson

Copy link
Copy Markdown
Contributor

Fixes #81.

The bug half

Pow and Exp computed Math.Exp(Math.Log(x.To<double>()) * y), so a fractional power of a fifty-digit value came back with about fifteen correct digits wearing a fifty-digit type — with nothing in the signature, the return value or the documentation to say so.

Both are now computed on the significand, following the precision rule Divide and the roots already follow: the significant digits of the argument, never fewer than MinimumDivisionPrecision, with an overload to choose that count.

Design

Implemented in a new PreciseNumber/PreciseNumber.Exponentials.cs, alongside PreciseNumber.Roots.cs and following its precedent for guard digits, termination and the significantDigits overload shape.

The base-10 representation does most of the work:

  • Logln(m · 10^k) = ln m + k · ln 10, with ln 10 read from the stored Ln10 rather than computed per call. The mantissa is centred on [1/√10, √10) — the test for it is exact, since m > √10 exactly when m² > 10 and squaring is exact — then fed to 2 · atanh((m-1)/(m+1)), whose argument is never larger than about 0.52.
  • Expexp(v) = 10^k · exp(r) with k = round(v / ln 10), so the whole power of ten is an Exponent shift and the series only sees |r| ≤ ln(10)/2. That is halved until it is under 1/64 and the sum squared back afterwards, which trades most of the terms for a handful of multiplications.
  • Pow — the integer fast path is untouched. A fractional exponent is exp(y · ln x), with the logarithm carried wider by the integer digits of y · ln x, because Exp's range reduction cancels exactly those digits.

Ln2 and Ln10 were already stored at ConstantPrecision, so no new constants were needed.

The two "must not" constraints

Both are load-bearing rather than stylistic, and each has a test that fails on the naive implementation:

  • Exp10/Log10 do not route through the natural log. Exp10 of an integer is an exponent shift and no series at all; Log10 of a power of ten returns that exponent exactly. TestExp10OfAnIntegerIsAnExponentAndNothingElse asserts the significand is 1, not merely that the value is right.
  • The …M1/…P1 variants are computed directly, not as Exp(x) - 1 and Log(1 + x), which would cancel away precisely the precision near zero they exist to keep. ExpM1(1e-30) is 1e-30, not 0.

Behaviour changes

  • A fractional power of a negative value throws ArgumentOutOfRangeException rather than returning a NaN round trip. There are no complex results here, and this matches Sqrt of a negative value.
  • An exponential whose result needs a decimal exponent outside int throws OverflowException. There is no infinity to saturate to.
  • Exp(One) still returns the full stored E; the significantDigits overload returns ETo(n).

Tests

PreciseNumber.Test/PreciseNumberExponentialTests.cs, in the style of PreciseNumberRootTests.cs — published digits rather than values this library produced, so a change that makes the series agree with themselves but not with mathematics still fails. It covers every item the issue lists: ln 2, ln 10, , 2^0.5, 10^(1/3) against published fifty-digit values; Exp(Log(x)) round-tripping across a sweep; Pow(x, 2) == x.Squared() exactly; ExpM1(1e-30) and LogP1(1e-30) not collapsing to zero; and Exp10(50) allocating one significand rather than running a series.

Proven to fail without the fix. With the double route temporarily restored and everything else unchanged, 13 tests fail — 9 of the new ones plus the 4 existing ones updated below. With the implementation in place, all 330 pass, and the solution builds clean across net7.0, net8.0, net9.0 and net10.0.

Four existing tests pinned the old double-precision answers — one of them documenting its 17th digit as coming from binary rounding — and now carry the published digits instead:

Test Was Now
TestExpWithNegativePower 0.36787944117144233 0.36787944117144232159…
TestExpWithLargePositivePower 148.4131591025766 148.41315910257660342…
TestExpWithLargeNegativePower 0.006737946999085467 0.00673794699908546709…
PowShouldReturnCorrectValue 5.656854249492381 5.65685424949238019520…

Benchmarks

ExponentialBenchmarks, with the double calls these replaced as an explicit baseline, so the cost of correctness is on the record rather than discovered later (issue #76 flags Pow among the regressed benchmarks). Short-run, 8/30/200 digits:

Method 8 digits 30 digits 200 digits
Log 180 µs 222 µs 2.86 ms
Exp 31 µs 40 µs 876 µs
PowFractional 305 µs 416 µs 4.84 ms
PowInteger 1.6 µs 37 µs 1.07 ms
Exp10OfAnInteger 63 ns 64 ns 73 ns
Log10OfAPowerOfTen 95 ns 93 ns 92 ns
DoubleLogBaseline 4.4 ns 4.6 ns 4.6 ns

The two free paths are flat across the digit axis and allocate nothing, which is the property worth regressing against: a series being run where none is needed would show up there first.

Docs

README.md's Limitations section no longer claims Exp and non-integer Pow route through double, and CLAUDE.md records the design and the two "must not" constraints so they are not lost to a later simplification.

🤖 Generated with Claude Code

https://claude.ai/code/session_01JVVMyN8uWMrqzKf3iCd84T


Generated by Claude Code

…tions [minor]

Exp and non-integer Pow computed Math.Exp(Math.Log(x.To<double>()) * y),
so a fractional power of a fifty-digit value came back with about fifteen
correct digits wearing a fifty-digit type, with nothing in the signature,
the return value or the documentation to say so.

Both are now computed on the significand, following the precision rule
Divide and the roots already follow: the significant digits of the
argument, never fewer than MinimumDivisionPrecision, with an overload to
choose that count.

The base-10 representation does most of the work. ln(m . 10^k) splits into
ln m + k . ln 10 against the stored Ln10, with the mantissa centred on
[1/root10, root10) and fed to the atanh series. exp(v) factors out
10^round(v / ln 10) as an exponent shift, then halves what is left until
it is under 1/64 and squares the sum back afterwards.

Two constraints in the design are load-bearing rather than stylistic, and
each has a test that fails on the naive implementation:

- Exp10 and Log10 do not route through the natural log. Exp10 of an
  integer is an exponent and no series at all; Log10 of a power of ten
  returns that exponent exactly.
- The M1 and P1 variants are computed directly, not as Exp(x) - 1 and
  Log(1 + x), which would cancel away the precision near zero they exist
  to keep. ExpM1(1e-30) is 1e-30, not 0.

The integer Pow path is untouched and stays exact; the tests pin that with
equality against Squared() and Cubed() rather than with a tolerance. A
fractional power of a negative value now throws ArgumentOutOfRangeException
instead of returning a NaN round trip.

Four existing tests pinned the old double-precision answers, one of them
documenting the 17th digit as coming from binary rounding. They now carry
the published digits.

Fixes #81
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JVVMyN8uWMrqzKf3iCd84T
Comment thread PreciseNumber.Test/PreciseNumberExponentialTests.cs Fixed
Comment thread PreciseNumber.Test/PreciseNumberExponentialTests.cs Fixed
…patch]

Two CodeQL "missed opportunity to use Select" findings from
github-code-quality on the exponential tests: both loops bound a value and
then immediately mapped it to the one actually asserted on.

TestLogAndExpRoundTripAcrossASweep now iterates the exponents directly,
since the value each came from plays no further part.

TestExpM1AndLogP1InvertEachOther takes an InversionSweep() of parsed values
rather than parsing strings in the body. A .Select(Parse) over the inline
array literal would trip CA1861, so this follows the Sweep() already in the
file instead, which reads better here anyway.

No assertion changed; 330/330 still pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JVVMyN8uWMrqzKf3iCd84T

Copy link
Copy Markdown
Contributor Author

github-advanced-security is failing, and it isn't this PR

The Code scanning AI findings on PR #90 run (35627452935) failed. The job log gives the reason outright:

_t [SessionModelError]: You have exceeded your monthly quota (Request ID: 606A:1DC001:2D8D29:31DEA0:6AB15EE0)
  errorType: 'quota',
  statusCode: 402,

That is the Copilot autofix agent behind the GHAS check exhausting an account-level monthly quota. It is not a finding against the diff — the agent never got far enough to produce one — and no change to this branch can clear it.

Confirmed rather than assumed: the identical 402 hit ktsu-dev/Semantics#264 seven minutes later (35628216826, Request ID D001:6332D:391C98:3E4C37:6AB1608F)) — a different repository and an unrelated diff of XML doc comments. One quota, two repositories, same failure.

I could not spend the usual single re-run to confirm it a third way: rerun-failed-jobs on a dynamic/agents/github-advanced-security run returns 403 This workflow run cannot be retried. It should pass by itself once the quota resets or is raised — nothing here is waiting on me for it.

The .NET Workflow — the build and test job that actually covers this change — is a separate run and was still in progress when this was written; it is the one worth reading. Locally, 330/330 pass and the solution builds clean across net7.0, net8.0, net9.0 and net10.0.

I am not treating the GHAS failure as this PR's and am not changing the diff for it. Keeping the PR watched until the rest is green.


Generated by Claude Code

@sonarqubecloud

Copy link
Copy Markdown

@matt-edmondson
matt-edmondson merged commit 7cc71a3 into main Sep 22, 2026
11 of 12 checks passed
@matt-edmondson
matt-edmondson deleted the claude/precisenumber-exp-log-pow branch September 22, 2026 00:30
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Implement IExponentialFunctions, ILogarithmicFunctions and IPowerFunctions, and stop Pow falling back to double

1 participant