Skip to content

Implement IRootFunctions<PreciseNumber> with Sqrt, Cbrt, RootN and Hypot - #86

Merged
matt-edmondson merged 1 commit into
mainfrom
claude/nice-davinci-bc9plo
Sep 17, 2026
Merged

matt-edmondson merged 1 commit into
mainfrom
claude/nice-davinci-bc9plo

Conversation

@matt-edmondson

Copy link
Copy Markdown
Contributor

Fixes #80

What this adds

PreciseNumber now satisfies IRootFunctions<PreciseNumber>: Sqrt, Cbrt, RootN and Hypot, each with an overload taking the significant digits to produce. IFloatingPointConstants came for free — E, Pi and Tau were already there.

public static PreciseNumber Sqrt(PreciseNumber x);
public static PreciseNumber Sqrt(PreciseNumber x, int significantDigits);
// …and the same pair for Cbrt, RootN and Hypot

How it works, and where it diverges from the issue's design

The issue proposed Newton over PreciseNumber with a Math.Sqrt seed and a ReduceSignificance truncation per step. This does Newton on the significand as an integer instead, which turns out to be simpler and strictly better:

v = s · 10^e, so scaling s by 10^k until the degree divides e - k splits the problem in two. The exponent is rooted by division alone, and the significand is rooted by integer Newton — x ← ((n-1)·x + v / x^(n-1)) / n, seeded at 2^ceil(bits/n), which is above the root and within a factor of two of it. Started above the root the iteration is strictly decreasing and stops on the floor of the root exactly.

That buys three things the proposed version would have had to work for:

  • No tolerance and no working precision to carry. There is nothing to truncate per step, because the iteration terminates on an exact integer rather than converging toward one. The generalised form covers Sqrt too, so there is one loop rather than two.
  • Exact roots come back exact. BigInteger.Pow(root, n) == scaled decides whether to round at all, so a value whose root is exact keeps it however few digits were asked for — the same contract Divide has for a terminating quotient. Sqrt(144) is 12 with two significant digits, and Sqrt(x.Squared()) is x even at significantDigits: 1.
  • No double anywhere. The issue flagged the naive Math.Sqrt seed as the case that gets 10^400 wrong. There is no seed to get wrong here — the bit length is exact at any magnitude.

The two contract decisions in the issue are taken as proposed, and documented on the methods:

  • Negative Sqrt throws ArgumentOutOfRangeException. There is no NaN, so the alternative is returning something wrong. This is a real divergence from double and is called out in the XML docs and in the README's Limitations section, because an algorithm ported from double that leans on NaN has to test the sign itself now.
  • Non-convergence throws ArithmeticException, matching StorageMath.Sqrt<T>'s precedent. It is unreachable by construction — the descent is strictly decreasing from a bounded seed — so it is a backstop, not a path. The allowance is 64 + n, because the first phase closes a 1/n fraction of the gap per step; measured over degrees up to 2000 the worst case needs four iterations more than the degree.

One judgement call beyond the issue: the default precision is max(operand digits, MinimumDivisionPrecision) rather than a flat MinimumDivisionPrecision. That is the rule Divide actually follows, and a flat fifty would cap Sqrt(Pi) at fifty digits — exactly the cliff the ConstantPrecision remark was written to remove. Say the word if you'd rather have the flat fifty.

Tests

PreciseNumberRootTests.cs, 27 tests, covering the issue's list:

  • √2, √3, √10, ∛2 and the fifth root of 7 pinned digit for digit at 50 significant digits against published values, plus that the 50th digit of √3 rounds rather than truncating
  • Sqrt(x)² agrees with x across a sweep spanning 1e-400 to 1e400, and RootN(x, n)^n likewise for n of 2, 3, 4, 5, 9 and 17
  • Exact squares and cubes: Sqrt(144) == 12, Cbrt(343) == 7, Cbrt(0.008) == 0.2, and an exact root surviving significantDigits: 1
  • 2e400, 2e-400 and the odd-exponent 1e401 — the cases a double seed gets wrong — with both digits and exponent asserted
  • Cbrt(-8) == -2, the fifth root of -243 being -3, and an even root of a negative value throwing
  • Every rejected argument, with the exception type asserted: negative Sqrt, degree 0, degree int.MinValue, a negative degree of zero (DivideByZeroException), and fewer than one significant digit
  • Hypot on Pythagorean triples being exact, ignoring signs, and needing no scaling at 3e400/4e400
  • The roots being reachable through a where TNumber : IRootFunctions<TNumber> constraint, which is the point of implementing the interface

Proof the tests fail without the change: removing PreciseNumber.Roots.cs and rebuilding gives 104 compile errors — 'PreciseNumber' does not contain a definition for 'Sqrt' / 'Cbrt' / 'RootN' / 'Hypot', plus the IRootFunctions<PreciseNumber> constraint no longer being satisfiable. Restored, the full suite is 304 passing, 0 failing — 277 pre-existing, no regressions. The library builds clean with 0 warnings across net7.0, net8.0, net9.0 and net10.0.

Benchmarks

RootBenchmarks.cs, on the repository's Digits axis with allocation reported. --job short:

Method Digits Mean Ratio Allocated
Sqrt 8 3.46 us 1.00 1.55 KB
SqrtOfAPerfectSquare 8 4.30 us 1.24 1.73 KB
Cbrt 8 6.96 us 2.01 2.77 KB
RootN (n=17) 8 61.92 us 17.89 8.09 KB
Hypot 8 4.60 us 1.33 1.99 KB
Sqrt 30 3.47 us 1.00 1.55 KB
Cbrt 30 6.67 us 1.93 2.34 KB
RootN (n=17) 30 95.58 us 27.60 10.35 KB
Sqrt 200 24.16 us 1.00 5.08 KB
SqrtOfAPerfectSquare 200 86.87 us 3.60 9.34 KB
Cbrt 200 57.56 us 2.38 7.13 KB
RootN (n=17) 200 854.69 us 35.38 31.02 KB
Hypot 200 29.65 us 1.23 5.66 KB

Two rows read oddly and are explained in the class remarks rather than left to puzzle over. 8 and 30 digits cost the same because both meet the MinimumDivisionPrecision floor — the digit count only starts driving the work at 200. And the perfect square is the slowest case at 200 digits, not the fastest: its operand carries twice the digits its root does, so following the operand asks for twice the precision. Exactness saves the rounding, not the iterating.

No existing benchmark's numbers move; nothing on an existing path was touched.

Docs

README gains root examples, the precision rule, and the negative-input divergence under Limitations. CLAUDE.md gains the design note and the new test and benchmark files.

🤖 Generated with Claude Code

https://claude.ai/code/session_01MSvvb3UfRbL65zGQy2fip5


Generated by Claude Code

…pot [minor]

Every root scales the significand by a power of ten until the degree
divides the exponent, then takes an integer Newton root of what is left.
Started above the root that iteration is strictly decreasing and stops on
the floor of the root exactly, so there is no tolerance to choose and no
working precision to carry: a value whose root is exact gets it exactly,
however few digits were asked for, the way Divide is exact when a
quotient terminates.

Nothing routes through double, so 1e400 and 1e-400 root as accurately as
2 does. Precision follows Divide's rule, never below the operand's digits
and never below MinimumDivisionPrecision, so rooting a 150 digit constant
is not capped at fifty.

There is no NaN to return, so Sqrt of a negative value throws rather than
answering wrongly. An odd root of a negative value is real and is
returned.

Fixes #80

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

Copy link
Copy Markdown

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 IRootFunctions&lt;PreciseNumber&gt;

2 participants