Skip to content

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

Description

@matt-edmondson

Part of #78.

The bug half of this

PreciseNumber.Pow(power) uses exponentiation by squaring for integer exponents — exact, fast, correct — and then for everything else:

// Use logarithm and exponential to support decimal powers
double logValue = Math.Log(To<double>());
return Math.Exp(logValue * power.To<double>()).ToPreciseNumber();

So x.Pow(0.5) on a 50-digit x returns about 15 correct digits in a type that claims 50, with nothing in the signature, the return value or the documentation to say so. Exp(power) has the same shape. A drag model using an exponential atmosphere, or anything taking a fractional power, silently drops to double precision.

Design

Log

The base-10 representation does half the work for free. v = s · 10^e with s ∈ [1, 10), so:

ln v = ln s + e · ln 10

Reduce s further into [1/√10, √10) so the argument is centred, then use the atanh series, which converges quickly there:

ln m = 2 · atanh(z),  z = (m - 1) / (m + 1)
atanh z = z + z³/3 + z⁵/5 + …

ln 10 must be a stored constant at the same precision as π (#79), not computed per call — otherwise every logarithm pays for it.

Exp

Reduce by ln 10: exp(v) = 10^k · exp(r) with k = round(v / ln 10), so the 10^k is a free exponent shift and |r| ≤ ln10/2. Then Taylor exp r = Σ rⁿ/n!, truncating each term with ReduceSignificance. If |r| is still large enough to make the series long, halve it m times and square the result back m times.

Pow

Keep the integer fast path exactly as it is — it is exact and must not regress. For non-integer exponents: x^y = Exp(y · Log(x)), with the working precision raised by the number of integer digits in y · ln x, because Exp's reduction consumes them. Negative base with a non-integer exponent throws ArgumentOutOfRangeException; there are no complex results here.

The rest of the interface surface

Exp2, Exp10, Log2, Log10 and the …M1 / …P1 variants.

Exp10 and Log10 must not route through the natural log. In this representation they are nearly free: Log10(s · 10^e) = e + Log10(s), and Exp10(n) for integer n is an exponent shift and nothing else. Going via ln would be both slower and less accurate.

The M1 / P1 variants must be implemented directly, not as Exp(x) - 1. They exist precisely to keep precision near zero, and computing them the naive way throws away everything they are for. ExpM1 is the series with its first term omitted; LogP1 is atanh on z = v / (v + 2).

New stored constants

Ln10 and Ln2, correctly rounded, at the precision agreed in #79. Neither derived from the other.

Tests

  • ln 2, ln 10, e, 2^0.5, 10^(1/3) against published 50-digit values
  • Exp(Log(x)) == x to 49 of 50 digits across a sweep
  • Pow(x, 2) == x.Squared() exactly — the integer path must not regress
  • ExpM1(1e-30) returns ~1e-30, not 0; LogP1(1e-30) likewise
  • Exp10(50) is exact and allocates one significand, not a series
  • Benchmark against the current double fallback, so the cost of correctness is on the record rather than discovered later. Related: Investigate benchmark regressions in Max, CompareTo, ReduceSignificance, Pow, and Divide since 2.0 #76, which already flags Pow among the regressed benchmarks.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions