Skip to content
Open
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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,12 @@

All notable changes to this project will be documented in this file.

## Unreleased

✨ **New features**

- New methods: `BigInteger::nthRoot()` and `BigDecimal::nthRoot()` compute the nth root of a number with the full `RoundingMode` contract. Odd degrees accept negative inputs.

## [0.17.1](https://github.com/brick/math/releases/tag/0.17.1) - 2026-04-19

👌 **Improvements**
Expand Down
9 changes: 9 additions & 0 deletions random-tests.php
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,15 @@ public function __invoke(): void
foreach ([$a, $b] as $n) {
$this->test("SQRT $n", fn (Calculator $c) => $c->sqrt($n));

foreach ([2, 3, 4, 5, 7] as $k) {
$this->test("ROOT$k $n", fn (Calculator $c) => $c->nthRoot($n, $k));

// Negative input only defined for odd k.
if ($n !== '0' && $k % 2 === 1) {
$this->test("ROOT$k -$n", fn (Calculator $c) => $c->nthRoot("-$n", $k));
}
}

for ($exp = 0; $exp <= 3; $exp++) {
$this->test("$n POW $exp", fn (Calculator $calc) => $calc->pow($n, $exp));

Expand Down
105 changes: 105 additions & 0 deletions src/BigDecimal.php
Original file line number Diff line number Diff line change
Expand Up @@ -702,6 +702,111 @@ public function sqrt(int $scale, RoundingMode $roundingMode = RoundingMode::Unne
return new BigDecimal($scaled, $scale);
}

/**
* Returns the nth root of this number, rounded to the given scale according to the given rounding mode.
*
* For odd $n, the operation is defined for negative inputs: the sign is preserved and the
* magnitude of the root is |$this|^(1/$n).
*
* @param int $n The root degree. Must be a strictly positive integer.
* @param non-negative-int $scale The target scale. Must be non-negative.
* @param RoundingMode $roundingMode An optional rounding mode, defaults to Unnecessary.
*
* @throws InvalidArgumentException If $n is less than 1 or $scale is negative.
* @throws NegativeNumberException If this number is negative and $n is even.
* @throws RoundingNecessaryException If RoundingMode::Unnecessary is used and the result cannot be represented
* exactly at the given scale.
*
* @pure
*/
public function nthRoot(int $n, int $scale, RoundingMode $roundingMode = RoundingMode::Unnecessary): BigDecimal
{
if ($n < 1) {
throw InvalidArgumentException::nonPositiveNthRootDegree();
}

if ($scale < 0) { // @phpstan-ignore smaller.alwaysFalse
throw InvalidArgumentException::negativeScale();
}

$isNegative = $this->isNegative();

if ($isNegative && $n % 2 === 0) {
throw NegativeNumberException::nthRootOfNegativeNumber();
}

if ($n === 1) {
return $this->toScale($scale, $roundingMode);
}

if ($this->isZero()) {
return new BigDecimal('0', $scale);
}

$value = $this->value;
$inputScale = $this->scale;

// Pad inputScale up to a multiple of $n so the shift by n*intermediateScale lands cleanly.
$remainder = $inputScale % $n;

if ($remainder !== 0) {
$padding = $n - $remainder;
$value .= str_repeat('0', $padding);
$inputScale = Safe::add($inputScale, $padding);
}

$calculator = CalculatorRegistry::get();

// Keep one extra digit beyond the target scale for rounding.
$intermediateScale = Safe::add(max($scale, intdiv($inputScale, $n)), 1);
$value .= str_repeat('0', Safe::sub(Safe::mul($n, $intermediateScale), $inputScale));

$root = $calculator->nthRoot($value, $n);
$isExact = $calculator->pow($root, $n) === $value;

if (! $isExact) {
if ($roundingMode === RoundingMode::Unnecessary) {
throw RoundingNecessaryException::decimalNthRootNotExact();
}

$isPositive = ! $isNegative;

// Non-perfect-nth-power root is irrational, so the true value has strictly greater
// magnitude than this truncated root. For "round away from zero" modes, bump the
// integer root one step further from zero so the subsequent rescale rounds up.
if (
$roundingMode === RoundingMode::Up
|| ($roundingMode === RoundingMode::Ceiling && $isPositive)
|| ($roundingMode === RoundingMode::Floor && ! $isPositive)
) {
$root = $isPositive
? $calculator->add($root, '1')
: $calculator->sub($root, '1');
}

// Irrational nth root cannot land on a midpoint. For any Half* mode, the "tie" case
// never occurs, so rewrite them all to HalfUp (round half away from zero), which is
// the mode whose away-from-zero direction matches the sign of the (strictly larger
// in magnitude) true value for both positive and negative inputs.
elseif (in_array($roundingMode, [
RoundingMode::HalfDown,
RoundingMode::HalfEven,
RoundingMode::HalfFloor,
RoundingMode::HalfCeiling,
], true)) {
$roundingMode = RoundingMode::HalfUp;
}
}

$scaled = DecimalHelper::scale($root, $intermediateScale, $scale, $roundingMode);

if ($scaled === null) {
throw RoundingNecessaryException::decimalNthRootScaleTooSmall();
}

return new BigDecimal($scaled, $scale);
}

/**
* Returns a copy of this BigDecimal with the decimal point moved to the left by the given number of places.
*
Expand Down
77 changes: 77 additions & 0 deletions src/BigInteger.php
Original file line number Diff line number Diff line change
Expand Up @@ -902,6 +902,83 @@ public function sqrt(RoundingMode $roundingMode = RoundingMode::Unnecessary): Bi
return new BigInteger($sqrt);
}

/**
* Returns the integer nth root of this number, rounded according to the given rounding mode.
*
* For odd $n, the operation is defined for negative inputs: the sign is preserved and the
* magnitude of the root is |$this|^(1/$n).
*
* @param int $n The root degree. Must be a strictly positive integer.
* @param RoundingMode $roundingMode An optional rounding mode, defaults to Unnecessary.
*
* @throws InvalidArgumentException If $n is less than 1.
* @throws NegativeNumberException If this number is negative and $n is even.
* @throws RoundingNecessaryException If RoundingMode::Unnecessary is used, and this number is not a perfect nth power.
*
* @pure
*/
public function nthRoot(int $n, RoundingMode $roundingMode = RoundingMode::Unnecessary): BigInteger
{
if ($n < 1) {
throw InvalidArgumentException::nonPositiveNthRootDegree();
}

if ($n === 1) {
return $this;
}

$isNegative = $this->isNegative();

if ($isNegative && $n % 2 === 0) {
throw NegativeNumberException::nthRootOfNegativeNumber();
}

$calculator = CalculatorRegistry::get();

// Truncation toward zero: for positive $this this is the floor root, for negative $this
// with odd $n this is the ceiling of the true root (i.e., the root of smaller magnitude).
$truncatedRoot = $calculator->nthRoot($this->value, $n);
$rootPow = $calculator->pow($truncatedRoot, $n);

if ($rootPow === $this->value) {
return new BigInteger($truncatedRoot);
}

if ($roundingMode === RoundingMode::Unnecessary) {
throw RoundingNecessaryException::integerNthRootNotExact();
}

$isPositive = ! $isNegative;

// The next-step root is one unit further from zero than the truncated root.
$nextStep = $isPositive
? $calculator->add($truncatedRoot, '1')
: $calculator->sub($truncatedRoot, '1');

if ($roundingMode === RoundingMode::Up) {
$increment = true;
} elseif ($roundingMode === RoundingMode::Down) {
$increment = false;
} elseif ($roundingMode === RoundingMode::Ceiling) {
$increment = $isPositive;
} elseif ($roundingMode === RoundingMode::Floor) {
$increment = ! $isPositive;
} else {
// Half* modes: compare 2*|$this| to |truncated^n| + |nextStep^n|.
// For consecutive integers r and r±1, the sum r^n + (r±1)^n is always odd, while
// 2*|$this| is always even, so a midpoint tie is impossible. Therefore all five Half*
// modes collapse to: increment iff 2*|$this| > |truncated^n| + |nextStep^n|.
$nextPow = $calculator->pow($nextStep, $n);

$twoAbsValue = $calculator->mul($calculator->abs($this->value), '2');
$midSum = $calculator->add($calculator->abs($rootPow), $calculator->abs($nextPow));

$increment = $calculator->cmp($twoAbsValue, $midSum) > 0;
}

return new BigInteger($increment ? $nextStep : $truncatedRoot);
}

#[Override]
public function negated(): static
{
Expand Down
10 changes: 10 additions & 0 deletions src/Exception/InvalidArgumentException.php
Original file line number Diff line number Diff line change
Expand Up @@ -120,4 +120,14 @@ public static function negativeModulus(): self
{
return new self('The modulus must not be negative.');
}

/**
* @internal
*
* @pure
*/
public static function nonPositiveNthRootDegree(): self
{
return new self('The degree of an nth root must be a positive integer.');
}
}
10 changes: 10 additions & 0 deletions src/Exception/NegativeNumberException.php
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,16 @@ public static function squareRootOfNegativeNumber(): self
return new self('Cannot calculate the square root of a negative number.');
}

/**
* @internal
*
* @pure
*/
public static function nthRootOfNegativeNumber(): self
{
return new self('Cannot take an even nth root of a negative number.');
}

/**
* @internal
*
Expand Down
30 changes: 30 additions & 0 deletions src/Exception/RoundingNecessaryException.php
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,36 @@ public static function decimalSquareRootScaleTooSmall(): self
return new self('The square root is exact but cannot be represented at the requested scale without rounding.');
}

/**
* @internal
*
* @pure
*/
public static function integerNthRootNotExact(): self
{
return new self('The nth root is not exact and cannot be represented as an integer without rounding.');
}

/**
* @internal
*
* @pure
*/
public static function decimalNthRootNotExact(): self
{
return new self('The nth root is not exact and cannot be represented as a decimal without rounding.');
}

/**
* @internal
*
* @pure
*/
public static function decimalNthRootScaleTooSmall(): self
{
return new self('The nth root is exact but cannot be represented at the requested scale without rounding.');
}

/**
* @internal
*
Expand Down
59 changes: 59 additions & 0 deletions src/Internal/Calculator.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
use Brick\Math\RoundingMode;

use function chr;
use function intdiv;
use function ltrim;
use function ord;
use function str_repeat;
Expand Down Expand Up @@ -271,6 +272,64 @@ public function lcm(string $a, string $b): string
*/
abstract public function sqrt(string $n): string;

/**
* Returns the integer nth root of the given number, truncated toward zero.
*
* If $n is non-negative, the result is the largest x such that x^$k ≤ $n (floor).
* If $n is negative, $k MUST be odd, and the result is the negation of the floor root of |$n|
* (i.e., truncation toward zero: the smallest x such that x^$k ≥ $n).
*
* The caller MUST guarantee that $k ≥ 1 and that $n is non-negative when $k is even.
*
* This method can be overridden by the concrete implementation if the underlying library
* has built-in support for nth root calculations.
*
* @param string $n The number. May be negative only when $k is odd.
* @param int $k The root degree. Must be strictly positive.
*
* @pure
*/
public function nthRoot(string $n, int $k): string
{
if ($n === '0') {
return '0';
}

$negative = ($n[0] === '-');
$m = $negative ? substr($n, 1) : $n;

if ($m === '1') {
return $negative ? '-1' : '1';
}

// Initial overshoot: 10^ceil(strlen(m)/k) is strictly greater than the true root.
// Newton-Raphson requires starting above the true root to converge monotonically down.
$x = '1' . str_repeat('0', intdiv(strlen($m) - 1, $k) + 1);

$kStr = (string) $k;
$kMinusOneStr = (string) ($k - 1);

// Newton-Raphson recurrence for integer nth root:
// x_{i+1} = floor(((k-1) * x_i + floor(m / x_i^{k-1})) / k)
for (; ;) {
$nx = $this->divQ(
$this->add(
$this->mul($kMinusOneStr, $x),
$this->divQ($m, $this->pow($x, $k - 1)),
),
$kStr,
);

if ($this->cmp($nx, $x) >= 0) {
break;
}

$x = $nx;
}

return $negative ? $this->neg($x) : $x;
}

/**
* Converts a number from an arbitrary base.
*
Expand Down
15 changes: 15 additions & 0 deletions src/Internal/Calculator/GmpCalculator.php
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,12 @@
use function gmp_or;
use function gmp_pow;
use function gmp_powm;
use function gmp_root;
use function gmp_sqrt;
use function gmp_strval;
use function gmp_sub;
use function gmp_xor;
use function substr;

/**
* Calculator implementation built around the GMP library.
Expand Down Expand Up @@ -149,4 +151,17 @@ public function sqrt(string $n): string
{
return gmp_strval(gmp_sqrt($n));
}

#[Override]
public function nthRoot(string $n, int $k): string
{
// Delegate on the absolute value and re-apply the sign ourselves so the
// truncation-toward-zero convention matches the shared Newton-Raphson fallback
// bit-for-bit, regardless of any PHP/GMP behaviour changes for negative inputs.
if ($n[0] === '-') {
return '-' . gmp_strval(gmp_root(substr($n, 1), $k));
}

return gmp_strval(gmp_root($n, $k));
}
}
Loading
Loading