Skip to content

Adds nthroot methods#113

Open
Pablo1Gustavo wants to merge 6 commits intobrick:mainfrom
Pablo1Gustavo:nthroot
Open

Adds nthroot methods#113
Pablo1Gustavo wants to merge 6 commits intobrick:mainfrom
Pablo1Gustavo:nthroot

Conversation

@Pablo1Gustavo
Copy link
Copy Markdown

@Pablo1Gustavo Pablo1Gustavo commented Apr 20, 2026

Add nthRoot() to BigInteger and BigDecimal

Summary

This PR adds nthRoot() methods to BigInteger and BigDecimal, providing
arbitrary-precision nth root computation with the full RoundingMode contract
used everywhere else in the library. Odd degrees accept negative inputs.

BigInteger::of('1267650600228229401496703205376')->nthRoot(100);
// => BigInteger('2')   — exact: 2^100

BigDecimal::of(2)->nthRoot(3, 20, RoundingMode::HalfUp);
// => BigDecimal('1.25992104989487316477')   — cube root of 2

BigInteger::of(-27)->nthRoot(3);
// => BigInteger('-3')   — negative input, odd degree

Motivation

brick/math already exposes sqrt() on both BigInteger and BigDecimal,
but callers who need a cube root, fourth root, or any higher degree root have
to reach for ad-hoc Newton iterations, or drop down to native float
arithmetic — losing the arbitrary precision that the library exists to
provide.

Real-world cases where this matters:

  • Financial & statistical workloads — geometric means, CAGR
    ((final/initial)^(1/n) − 1), and volatility calculations commonly require
    non-square roots at precisions beyond IEEE-754 double.
  • Cryptography & number theory — checking whether a large integer is a
    perfect kth power (e.g., Miller–Rabin preconditions, AKS primality) needs a
    floor integer nth root.
  • Geometric modelling — cube roots and fifth roots show up in volume
    inversions, bezier parameterizations, and physics-engine mass
    distributions.
  • Scientific computing — any formula involving x^(1/n) where x has
    more than 15 significant digits.

Today every one of these callers either rolls their own loop — a
well-known source of off-by-one bugs in the floor/ceiling boundary — or
accepts silent float rounding. Both are avoidable.

API

BigInteger::nthRoot()

public function nthRoot(
    int $n,
    RoundingMode $roundingMode = RoundingMode::Unnecessary,
): BigInteger;

Returns the integer nth root of $this, rounded according to
$roundingMode.

  • $n must be ≥ 1.
  • $this may be negative only when $n is odd.
  • Default RoundingMode::Unnecessary throws RoundingNecessaryException if
    $this is not a perfect nth power — matching the library-wide contract
    that lossy operations require explicit opt-in.

BigDecimal::nthRoot()

public function nthRoot(
    int $n,
    int $scale,
    RoundingMode $roundingMode = RoundingMode::Unnecessary,
): BigDecimal;

Returns the nth root rounded to $scale decimal places.

  • Same constraints on $n as above.
  • $scale must be non-negative (as in BigDecimal::sqrt() and
    dividedBy()).
  • Throws RoundingNecessaryException with distinct messages for "not
    exact" vs "exact but scale too small", mirroring sqrt().

Signature parity with sqrt()

sqrt() nthRoot()
BigInteger sqrt(RoundingMode = Unnecessary) nthRoot(int $n, RoundingMode = Unnecessary)
BigDecimal sqrt(int $scale, RoundingMode = Unnecessary) nthRoot(int $n, int $scale, RoundingMode = Unnecessary)
Negative input rejected rejected only for even $n
All 10 RoundingModes

Implementation

Two-layer split (matches sqrt())

┌────────────────────────────────────────────────────────────────┐
│  Public layer                                                  │
│  ├─ BigInteger::nthRoot(int $n, RoundingMode)                  │
│  │     · signs, identity fast-paths, rounding-mode logic       │
│  └─ BigDecimal::nthRoot(int $n, int $scale, RoundingMode)      │
│        · scale padding, one-extra-digit precision, rescale     │
│                         │                                      │
│                         ▼                                      │
│  Internal layer                                                │
│  └─ Calculator::nthRoot(string $n, int $k) → truncated root    │
│         ├─ GmpCalculator override → gmp_root()                 │
│         └─ shared fallback → Newton-Raphson                    │
└────────────────────────────────────────────────────────────────┘

Calculator layer

Calculator::nthRoot() returns the truncated-toward-zero integer nth root.
It has one backend-specific override:

  • GMPgmp_root(), with sign re-applied manually to freeze
    truncation-toward-zero semantics across PHP/GMP versions.

  • BCMath & Native → shared Newton-Raphson fallback:

    x₀ = 10^(⌈digits(m)/k⌉)                                (overshoot)
    xᵢ₊₁ = ⌊((k−1)·xᵢ + ⌊m/xᵢ^(k−1)⌋) / k⌋                (recurrence)
    stop when xᵢ₊₁ ≥ xᵢ                                    (terminate)
    

    Starting strictly above the true root guarantees monotone convergence
    from above; the iterate stabilises when it can no longer decrease.

BigInteger::nthRoot() — rounding

After obtaining the truncated root r and computing r^n, the method
branches on $roundingMode:

  • Down / Floor / Ceiling / Up — direct choice between r and
    the next step one unit further from zero.
  • Half* — would normally require a midpoint comparison. But for
    consecutive integers r and r±1, the sum r^n + (r±1)^n is always
    odd
    , while 2·|value| is always even, so a midpoint tie is
    provably impossible. All five Half* modes therefore collapse to a
    single comparison: increment ⇔ 2·|value| > r^n + (r±1)^n.

BigDecimal::nthRoot() — scale handling

Given input scale s and target scale S:

  1. Pad the value so s ≡ 0 (mod n) (append up to n−1 zeros).
  2. Choose intermediateScale = max(S, s/n) + 1 (one guard digit).
  3. Left-shift the value by n·intermediateScale − s zeros.
  4. Take the integer nth root at the intermediate scale.
  5. If the root isn't exact, pre-adjust for Up / Ceiling / Floor away-
    from-zero direction, and — since the true value is irrational —
    collapse all Half* modes to HalfUp (no midpoint tie is possible).
  6. Rescale to S via DecimalHelper::scale().

All scale arithmetic goes through Safe::{add, sub, mul} so overflow
surfaces as IntegerOverflowException rather than silent float
corruption.

Testing

  • 551 new PHPUnit assertions spread across 8 new test methods
    covering: identity cases (n=1), zero and unit inputs at all degrees,
    perfect powers for n ∈ {2, 3, 4, 5, 7, 100}, non-exact boundary
    cases exercising every RoundingMode, negative inputs with odd n,
    fractional inputs (inputScale % n ≠ 0), large scales (30), and every
    exception path.
  • Rounding-mode equivalence expansion — each provider row
    automatically fans out to the modes that are mathematically
    equivalent for the given sign (e.g., positive-value Up ≡ Ceiling),
    so no branch is left unexercised.
  • Cross-backend parity fuzzrandom-tests.php now fuzzes
    nthRoot for k ∈ {2, 3, 4, 5, 7} on both positive and negative
    operands against all three backends, exiting on the first
    mismatch.
  • PHPStan level 10 clean.

Test plan

  • Full PHPUnit suite on CALCULATOR=Native (13,469 tests pass)
  • Full PHPUnit suite on CALCULATOR=BCMath (13,469 tests pass)
  • Full PHPUnit suite on CALCULATOR=BCMath with BCMATH_DEFAULT_SCALE=8
  • vendor/bin/phpstan --no-progress — no errors at level 10
  • BCMath ↔ Native parity fuzz on Calculator::nthRoot
  • CI matrix: verify CALCULATOR=GMP on PHP 8.2–8.5 (64-bit and 32-bit)
  • CI: random-tests.php for ≥ several million iterations across all three backends
  • CI: tools/ecs coding-standard check

Backward compatibility

Purely additive: no existing API surface changes. Matches the
0.x.y bump convention documented in README.md for non-breaking
additions.

Implements arbitrary-precision nth root with the full RoundingMode contract.
Odd degrees accept negative inputs.

- Add Calculator::nthRoot() with Newton-Raphson fallback and
  GmpCalculator override via gmp_root()
- Add BigInteger::nthRoot(int $n, RoundingMode = Unnecessary)
- Add BigDecimal::nthRoot(int $n, int $scale, RoundingMode = Unnecessary)
- Add named exception constructors for the new failure modes
- Extend random-tests.php to fuzz nthRoot across all three backends
- Update CHANGELOG
@codecov
Copy link
Copy Markdown

codecov Bot commented Apr 20, 2026

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 98.98%. Comparing base (6aef71a) to head (e2aa4c9).

Additional details and impacted files
@@             Coverage Diff              @@
##               main     #113      +/-   ##
============================================
+ Coverage     98.86%   98.98%   +0.12%     
- Complexity      709      755      +46     
============================================
  Files            20       20              
  Lines          1759     1872     +113     
============================================
+ Hits           1739     1853     +114     
+ Misses           20       19       -1     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@BenMorel
Copy link
Copy Markdown
Member

BenMorel commented May 3, 2026

Hi @Pablo1Gustavo, thank you for your PR!

Claude found a bug affecting Half* rounding modes in the BigInteger::nthRoot() implementation:

BigInteger::of('17')->nthRoot(3, RoundingMode::HalfUp);

This code returns 2 instead of 3, when the actual value is 2.57...

I pushed a fix, do you want to review it?

@Pablo1Gustavo
Copy link
Copy Markdown
Author

Test cleanup in tests/BigIntegerTest.php

The five sections appended at the end of providerNthRoot() (17, 16, 864, 8, -17) are exact duplicates of cases already present earlier in the same provider:

Value n Existing location
'16' 3 lines 3773–3776
'17' 3 lines 3779–3782
'-17' 3 lines 3814–3817
'8' 4 lines 3833–3836
'864' 3 lines 3898–3901 (boundary band)

PHPUnit ran each scenario twice without adding coverage, so I removed the duplicated block.

New tests: tightest possible Half* boundary

Added V = 43 and V = 91 at n = 3 (and their negatives) to both BigIntegerTest and BigDecimalTest (scale 0). These are the smallest inputs where 2^n · |V| differs from (2·|t| + 1)^n by exactly ±1:

  • V = 43: 8·43 = 344 vs 7³ = 343 → diff +1, smallest "round up" (∛43 ≈ 3.5034)
  • V = 91: 8·91 = 728 vs 9³ = 729 → diff -1, smallest "round down" (∛91 ≈ 4.4979)

The other Half* cases sit comfortably away from the threshold (e.g. 864 is 53 units inside the band). These two values pin the comparison to its most discriminative point — any off-by-one in the threshold computation would flip them.

@Pablo1Gustavo
Copy link
Copy Markdown
Author

Thanks @BenMorel. Makes sense and LGTM.
The tests are also very thorough and address this problem that was found and I added some more.
👍

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.

2 participants