diff --git a/CHANGELOG.md b/CHANGELOG.md index c6b52902..eebc9052 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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** diff --git a/random-tests.php b/random-tests.php index e8ec913d..4a032630 100644 --- a/random-tests.php +++ b/random-tests.php @@ -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)); diff --git a/src/BigDecimal.php b/src/BigDecimal.php index f7544ea4..3ecd1c1f 100644 --- a/src/BigDecimal.php +++ b/src/BigDecimal.php @@ -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. * diff --git a/src/BigInteger.php b/src/BigInteger.php index f6708f35..699d5723 100644 --- a/src/BigInteger.php +++ b/src/BigInteger.php @@ -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 { diff --git a/src/Exception/InvalidArgumentException.php b/src/Exception/InvalidArgumentException.php index 64c216a1..f30ef31e 100644 --- a/src/Exception/InvalidArgumentException.php +++ b/src/Exception/InvalidArgumentException.php @@ -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.'); + } } diff --git a/src/Exception/NegativeNumberException.php b/src/Exception/NegativeNumberException.php index 16d70315..644b1eb7 100644 --- a/src/Exception/NegativeNumberException.php +++ b/src/Exception/NegativeNumberException.php @@ -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 * diff --git a/src/Exception/RoundingNecessaryException.php b/src/Exception/RoundingNecessaryException.php index c9ffe95c..7c6796ea 100644 --- a/src/Exception/RoundingNecessaryException.php +++ b/src/Exception/RoundingNecessaryException.php @@ -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 * diff --git a/src/Internal/Calculator.php b/src/Internal/Calculator.php index 9ada37ba..84245c48 100644 --- a/src/Internal/Calculator.php +++ b/src/Internal/Calculator.php @@ -7,6 +7,7 @@ use Brick\Math\RoundingMode; use function chr; +use function intdiv; use function ltrim; use function ord; use function str_repeat; @@ -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. * diff --git a/src/Internal/Calculator/GmpCalculator.php b/src/Internal/Calculator/GmpCalculator.php index 3db06658..4ee29769 100644 --- a/src/Internal/Calculator/GmpCalculator.php +++ b/src/Internal/Calculator/GmpCalculator.php @@ -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. @@ -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)); + } } diff --git a/tests/BigDecimalTest.php b/tests/BigDecimalTest.php index 4d71a6b6..6133ba6c 100644 --- a/tests/BigDecimalTest.php +++ b/tests/BigDecimalTest.php @@ -3034,6 +3034,260 @@ public function testSqrtWithNegativeScale(): void $number->sqrt(-1); } + #[DataProvider('providerNthRoot')] + public function testNthRoot(string $number, int $n, int $scale, RoundingMode $roundingMode, string $expected): void + { + $number = BigDecimal::of($number); + + $expectedExceptionMessage = match ($expected) { + 'NTH_ROOT_NOT_EXACT' => 'The nth root is not exact and cannot be represented as a decimal without rounding.', + 'SCALE_TOO_SMALL' => 'The nth root is exact but cannot be represented at the requested scale without rounding.', + default => null, + }; + + if ($expectedExceptionMessage !== null) { + $this->expectException(RoundingNecessaryException::class); + $this->expectExceptionMessageExact($expectedExceptionMessage); + } + + $actual = $number->nthRoot($n, $scale, $roundingMode); + + if ($expectedExceptionMessage === null) { + self::assertBigDecimalEquals($expected, $actual); + } + } + + public static function providerNthRoot(): Generator + { + $tests = [ + // n = 1 is an identity passthrough, delegated to toScale. + ['1.23', 1, 2, RoundingMode::Unnecessary, '1.23'], + ['1.23', 1, 3, RoundingMode::Unnecessary, '1.230'], + ['1.23', 1, 5, RoundingMode::Unnecessary, '1.23000'], + ['-1.23', 1, 2, RoundingMode::Unnecessary, '-1.23'], + ['0', 1, 4, RoundingMode::Unnecessary, '0.0000'], + + // Zero at various scales for various n. + ['0', 2, 0, RoundingMode::Unnecessary, '0'], + ['0', 2, 5, RoundingMode::Unnecessary, '0.00000'], + ['0', 3, 0, RoundingMode::Unnecessary, '0'], + ['0', 3, 3, RoundingMode::Unnecessary, '0.000'], + ['0', 5, 2, RoundingMode::Unnecessary, '0.00'], + ['0.0', 3, 4, RoundingMode::Unnecessary, '0.0000'], + ['0.000', 7, 10, RoundingMode::Unnecessary, '0.0000000000'], + + // One at various scales for various n. + ['1', 2, 0, RoundingMode::Unnecessary, '1'], + ['1', 2, 4, RoundingMode::Unnecessary, '1.0000'], + ['1', 3, 2, RoundingMode::Unnecessary, '1.00'], + ['1', 100, 8, RoundingMode::Unnecessary, '1.00000000'], + + // Perfect cubes (integer). + ['8', 3, 0, RoundingMode::Unnecessary, '2'], + ['8', 3, 3, RoundingMode::Unnecessary, '2.000'], + ['27', 3, 0, RoundingMode::Unnecessary, '3'], + ['125', 3, 2, RoundingMode::Unnecessary, '5.00'], + + // Perfect cube with fractional input: 1.728 = 1.2^3. + ['1.728', 3, 1, RoundingMode::Unnecessary, '1.2'], + ['1.728', 3, 3, RoundingMode::Unnecessary, '1.200'], + ['1.728', 3, 5, RoundingMode::Unnecessary, '1.20000'], + // Exact but scale too small: 1.2 doesn't fit at scale 0. + ['1.728', 3, 0, RoundingMode::Unnecessary, 'SCALE_TOO_SMALL'], + ['1.728', 3, 0, RoundingMode::Up, '2'], + ['1.728', 3, 0, RoundingMode::Down, '1'], + ['1.728', 3, 0, RoundingMode::HalfUp, '1'], + + // Perfect cube on negative with odd n. + ['-1.728', 3, 1, RoundingMode::Unnecessary, '-1.2'], + ['-1.728', 3, 3, RoundingMode::Unnecessary, '-1.200'], + ['-8', 3, 0, RoundingMode::Unnecessary, '-2'], + ['-1.728', 3, 0, RoundingMode::Unnecessary, 'SCALE_TOO_SMALL'], + ['-1.728', 3, 0, RoundingMode::Up, '-2'], + ['-1.728', 3, 0, RoundingMode::Down, '-1'], + + // Perfect 4th: 16 = 2^4, 0.0016 = 0.2^4. + ['16', 4, 0, RoundingMode::Unnecessary, '2'], + ['0.0016', 4, 1, RoundingMode::Unnecessary, '0.2'], + ['0.0016', 4, 4, RoundingMode::Unnecessary, '0.2000'], + ['0.0016', 4, 0, RoundingMode::Unnecessary, 'SCALE_TOO_SMALL'], + + // Perfect 5th: 32 = 2^5, 243 = 3^5, 100000 = 10^5. + ['32', 5, 0, RoundingMode::Unnecessary, '2'], + ['243', 5, 0, RoundingMode::Unnecessary, '3'], + ['100000', 5, 2, RoundingMode::Unnecessary, '10.00'], + ['0.00032', 5, 1, RoundingMode::Unnecessary, '0.2'], + ['0.00032', 5, 5, RoundingMode::Unnecessary, '0.20000'], + + // Perfect 7th: 128 = 2^7. + ['128', 7, 0, RoundingMode::Unnecessary, '2'], + ['128', 7, 4, RoundingMode::Unnecessary, '2.0000'], + + // Non-exact cube root of 2 ≈ 1.2599210498948731647672106072... + ['2', 3, 0, RoundingMode::Unnecessary, 'NTH_ROOT_NOT_EXACT'], + ['2', 3, 0, RoundingMode::Up, '2'], + ['2', 3, 0, RoundingMode::Down, '1'], + ['2', 3, 0, RoundingMode::HalfUp, '1'], + + ['2', 3, 1, RoundingMode::Unnecessary, 'NTH_ROOT_NOT_EXACT'], + ['2', 3, 1, RoundingMode::Up, '1.3'], + ['2', 3, 1, RoundingMode::Down, '1.2'], + ['2', 3, 1, RoundingMode::HalfUp, '1.3'], + + ['2', 3, 5, RoundingMode::Unnecessary, 'NTH_ROOT_NOT_EXACT'], + ['2', 3, 5, RoundingMode::Up, '1.25993'], + ['2', 3, 5, RoundingMode::Down, '1.25992'], + ['2', 3, 5, RoundingMode::HalfUp, '1.25992'], + + ['2', 3, 10, RoundingMode::Unnecessary, 'NTH_ROOT_NOT_EXACT'], + ['2', 3, 10, RoundingMode::Up, '1.2599210499'], + ['2', 3, 10, RoundingMode::Down, '1.2599210498'], + ['2', 3, 10, RoundingMode::HalfUp, '1.2599210499'], + + ['2', 3, 20, RoundingMode::Unnecessary, 'NTH_ROOT_NOT_EXACT'], + ['2', 3, 20, RoundingMode::Up, '1.25992104989487316477'], + ['2', 3, 20, RoundingMode::Down, '1.25992104989487316476'], + ['2', 3, 20, RoundingMode::HalfUp, '1.25992104989487316477'], + + // Non-exact cube root of a decimal input. + // 2.5^(1/3) ≈ 1.35720880829745... + ['2.5', 3, 5, RoundingMode::Unnecessary, 'NTH_ROOT_NOT_EXACT'], + ['2.5', 3, 5, RoundingMode::Up, '1.35721'], + ['2.5', 3, 5, RoundingMode::Down, '1.35720'], + ['2.5', 3, 5, RoundingMode::HalfUp, '1.35721'], + + // Non-exact cube root of negative: -2^(1/3) ≈ -1.25992104989... + ['-2', 3, 10, RoundingMode::Unnecessary, 'NTH_ROOT_NOT_EXACT'], + ['-2', 3, 10, RoundingMode::Up, '-1.2599210499'], + ['-2', 3, 10, RoundingMode::Down, '-1.2599210498'], + ['-2', 3, 10, RoundingMode::HalfUp, '-1.2599210499'], + + // Non-exact 4th root of 2 ≈ 1.1892071150027210667... + ['2', 4, 10, RoundingMode::Unnecessary, 'NTH_ROOT_NOT_EXACT'], + ['2', 4, 10, RoundingMode::Up, '1.1892071151'], + ['2', 4, 10, RoundingMode::Down, '1.1892071150'], + ['2', 4, 10, RoundingMode::HalfUp, '1.1892071150'], + + // Non-exact 5th root of 2 ≈ 1.1486983549970350067... + ['2', 5, 10, RoundingMode::Unnecessary, 'NTH_ROOT_NOT_EXACT'], + ['2', 5, 10, RoundingMode::Up, '1.1486983550'], + ['2', 5, 10, RoundingMode::Down, '1.1486983549'], + ['2', 5, 10, RoundingMode::HalfUp, '1.1486983550'], + + // 5th root of negative: -2^(1/5) ≈ -1.14869835499... + ['-2', 5, 10, RoundingMode::Unnecessary, 'NTH_ROOT_NOT_EXACT'], + ['-2', 5, 10, RoundingMode::Up, '-1.1486983550'], + ['-2', 5, 10, RoundingMode::Down, '-1.1486983549'], + ['-2', 5, 10, RoundingMode::HalfUp, '-1.1486983550'], + + // Non-exact 7th root of 2 ≈ 1.1040895136738123820... + ['2', 7, 10, RoundingMode::Unnecessary, 'NTH_ROOT_NOT_EXACT'], + ['2', 7, 10, RoundingMode::Up, '1.1040895137'], + ['2', 7, 10, RoundingMode::Down, '1.1040895136'], + ['2', 7, 10, RoundingMode::HalfUp, '1.1040895137'], + + // n = 2 (cross-checks sqrt semantics). √2 ≈ 1.41421356237309504880... + ['2', 2, 10, RoundingMode::Unnecessary, 'NTH_ROOT_NOT_EXACT'], + ['2', 2, 10, RoundingMode::Up, '1.4142135624'], + ['2', 2, 10, RoundingMode::Down, '1.4142135623'], + ['2', 2, 10, RoundingMode::HalfUp, '1.4142135624'], + + // Large scale. + ['3', 3, 30, RoundingMode::Unnecessary, 'NTH_ROOT_NOT_EXACT'], + ['3', 3, 30, RoundingMode::Up, '1.442249570307408382321638310781'], + ['3', 3, 30, RoundingMode::Down, '1.442249570307408382321638310780'], + ['3', 3, 30, RoundingMode::HalfUp, '1.442249570307408382321638310780'], + + // Value with large non-trivial scale whose root is exact. + // 0.000001 = (0.01)^3. + ['0.000001', 3, 2, RoundingMode::Unnecessary, '0.01'], + ['0.000001', 3, 6, RoundingMode::Unnecessary, '0.010000'], + ['0.000001', 3, 1, RoundingMode::Unnecessary, 'SCALE_TOO_SMALL'], + ['0.000001', 3, 1, RoundingMode::Up, '0.1'], + ['0.000001', 3, 1, RoundingMode::Down, '0.0'], + + // Mixed inputScale % n != 0 path: inputScale=2, n=3, padding=1. + // 0.25 is not a perfect cube; 0.25^(1/3) ≈ 0.62996052494... + ['0.25', 3, 10, RoundingMode::Unnecessary, 'NTH_ROOT_NOT_EXACT'], + ['0.25', 3, 10, RoundingMode::Up, '0.6299605250'], + ['0.25', 3, 10, RoundingMode::Down, '0.6299605249'], + ['0.25', 3, 10, RoundingMode::HalfUp, '0.6299605249'], + + // inputScale=1, n=3, padding=2: 1.5^(1/3) ≈ 1.14471424255... + ['1.5', 3, 10, RoundingMode::Unnecessary, 'NTH_ROOT_NOT_EXACT'], + ['1.5', 3, 10, RoundingMode::Up, '1.1447142426'], + ['1.5', 3, 10, RoundingMode::Down, '1.1447142425'], + ['1.5', 3, 10, RoundingMode::HalfUp, '1.1447142426'], + ]; + + foreach ($tests as [$number, $n, $scale, $roundingMode, $expected]) { + yield [$number, $n, $scale, $roundingMode, $expected]; + + // Rounding-mode equivalences. Irrational nth roots never hit a midpoint tie, + // so all Half* modes collapse to HalfUp (round half away from zero). + $eqs = match ($roundingMode) { + RoundingMode::Up => ($number[0] === '-') ? [RoundingMode::Floor] : [RoundingMode::Ceiling], + RoundingMode::Down => ($number[0] === '-') ? [RoundingMode::Ceiling] : [RoundingMode::Floor], + RoundingMode::HalfUp => [ + RoundingMode::HalfCeiling, + RoundingMode::HalfDown, + RoundingMode::HalfEven, + RoundingMode::HalfFloor, + ], + default => [], + }; + + foreach ($eqs as $eq) { + yield [$number, $n, $scale, $eq, $expected]; + } + } + } + + public function testNthRootOfNegativeNumberWithEvenDegree(): void + { + $number = BigDecimal::of(-1); + $this->expectException(NegativeNumberException::class); + $this->expectExceptionMessageExact('Cannot take an even nth root of a negative number.'); + + $number->nthRoot(2, 0); + } + + public function testNthRootOfNegativeNumberWithLargeEvenDegree(): void + { + $number = BigDecimal::of('-1.5'); + $this->expectException(NegativeNumberException::class); + $this->expectExceptionMessageExact('Cannot take an even nth root of a negative number.'); + + $number->nthRoot(100, 10); + } + + public function testNthRootWithZeroDegree(): void + { + $number = BigDecimal::of('1.5'); + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessageExact('The degree of an nth root must be a positive integer.'); + + $number->nthRoot(0, 10); + } + + public function testNthRootWithNegativeDegree(): void + { + $number = BigDecimal::of('1.5'); + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessageExact('The degree of an nth root must be a positive integer.'); + + $number->nthRoot(-2, 10); + } + + public function testNthRootWithNegativeScale(): void + { + $number = BigDecimal::one(); + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessageExact('The scale must not be negative.'); + + $number->nthRoot(3, -1); + } + #[DataProvider('providerClamp')] public function testClamp(string $number, string $min, string $max, string $expected): void { diff --git a/tests/BigIntegerTest.php b/tests/BigIntegerTest.php index 45495c35..978e1168 100644 --- a/tests/BigIntegerTest.php +++ b/tests/BigIntegerTest.php @@ -3669,6 +3669,330 @@ public function testSqrtOfNegativeNumber(): void $number->sqrt(); } + #[DataProvider('providerNthRoot')] + public function testNthRoot(string $number, int $n, RoundingMode $roundingMode, ?string $expected): void + { + $number = BigInteger::of($number); + + if ($expected === null) { + $this->expectException(RoundingNecessaryException::class); + $this->expectExceptionMessageExact('The nth root is not exact and cannot be represented as an integer without rounding.'); + } + + $actual = $number->nthRoot($n, $roundingMode); + + if ($expected !== null) { + self::assertBigIntegerEquals($expected, $actual); + } + } + + public static function providerNthRoot(): Generator + { + $tests = [ + // n = 1 is an identity passthrough (any value, any rounding mode). + ['0', 1, RoundingMode::Unnecessary, '0'], + ['0', 1, RoundingMode::Down, '0'], + ['0', 1, RoundingMode::Up, '0'], + ['0', 1, RoundingMode::HalfUp, '0'], + + ['1', 1, RoundingMode::Unnecessary, '1'], + ['1', 1, RoundingMode::Down, '1'], + ['1', 1, RoundingMode::Up, '1'], + ['1', 1, RoundingMode::HalfUp, '1'], + + ['-1', 1, RoundingMode::Unnecessary, '-1'], + ['-1', 1, RoundingMode::Down, '-1'], + ['-1', 1, RoundingMode::Up, '-1'], + ['-1', 1, RoundingMode::HalfUp, '-1'], + + ['7', 1, RoundingMode::Unnecessary, '7'], + ['7', 1, RoundingMode::HalfUp, '7'], + + ['-12345678901234567890', 1, RoundingMode::Unnecessary, '-12345678901234567890'], + + // Zero input with various n. + ['0', 2, RoundingMode::Unnecessary, '0'], + ['0', 3, RoundingMode::Unnecessary, '0'], + ['0', 4, RoundingMode::Unnecessary, '0'], + ['0', 5, RoundingMode::Unnecessary, '0'], + ['0', 7, RoundingMode::Unnecessary, '0'], + ['0', 100, RoundingMode::Unnecessary, '0'], + + // Unit input with various n. + ['1', 2, RoundingMode::Unnecessary, '1'], + ['1', 3, RoundingMode::Unnecessary, '1'], + ['1', 5, RoundingMode::Unnecessary, '1'], + ['1', 100, RoundingMode::Unnecessary, '1'], + + ['-1', 3, RoundingMode::Unnecessary, '-1'], + ['-1', 5, RoundingMode::Unnecessary, '-1'], + ['-1', 7, RoundingMode::Unnecessary, '-1'], + ['-1', 99, RoundingMode::Unnecessary, '-1'], + + // n = 2 (agrees with sqrt). + ['4', 2, RoundingMode::Unnecessary, '2'], + ['4', 2, RoundingMode::Down, '2'], + ['4', 2, RoundingMode::Up, '2'], + ['4', 2, RoundingMode::HalfUp, '2'], + + ['2', 2, RoundingMode::Unnecessary, null], + ['2', 2, RoundingMode::Down, '1'], + ['2', 2, RoundingMode::Up, '2'], + ['2', 2, RoundingMode::HalfUp, '1'], + + ['3', 2, RoundingMode::Unnecessary, null], + ['3', 2, RoundingMode::Down, '1'], + ['3', 2, RoundingMode::Up, '2'], + ['3', 2, RoundingMode::HalfUp, '2'], + + // n = 3, perfect cubes. + ['8', 3, RoundingMode::Unnecessary, '2'], + ['8', 3, RoundingMode::Down, '2'], + ['8', 3, RoundingMode::Up, '2'], + ['8', 3, RoundingMode::HalfUp, '2'], + + ['27', 3, RoundingMode::Unnecessary, '3'], + ['1000', 3, RoundingMode::Unnecessary, '10'], + ['1000000', 3, RoundingMode::Unnecessary, '100'], + + // n = 3, non-exact, all rounding modes. truncatedRoot=1, nextStep=2, sum=9, 2*n: + // 7: 14 > 9 → HalfUp=2. + ['7', 3, RoundingMode::Unnecessary, null], + ['7', 3, RoundingMode::Down, '1'], + ['7', 3, RoundingMode::Up, '2'], + ['7', 3, RoundingMode::HalfUp, '2'], + + // truncatedRoot=2, nextStep=3, sum=8+27=35. + // 9: 18 < 35 → HalfUp=2. + ['9', 3, RoundingMode::Unnecessary, null], + ['9', 3, RoundingMode::Down, '2'], + ['9', 3, RoundingMode::Up, '3'], + ['9', 3, RoundingMode::HalfUp, '2'], + + // 17: 34 < 35 → HalfUp=2. + ['17', 3, RoundingMode::Unnecessary, null], + ['17', 3, RoundingMode::Down, '2'], + ['17', 3, RoundingMode::Up, '3'], + ['17', 3, RoundingMode::HalfUp, '2'], + + // 18: 36 > 35 → HalfUp=3. + ['18', 3, RoundingMode::Unnecessary, null], + ['18', 3, RoundingMode::Down, '2'], + ['18', 3, RoundingMode::Up, '3'], + ['18', 3, RoundingMode::HalfUp, '3'], + + // 26: 52 > 35 → HalfUp=3. + ['26', 3, RoundingMode::Unnecessary, null], + ['26', 3, RoundingMode::Down, '2'], + ['26', 3, RoundingMode::Up, '3'], + ['26', 3, RoundingMode::HalfUp, '3'], + + // n = 3, negative inputs (odd n is defined). + ['-8', 3, RoundingMode::Unnecessary, '-2'], + ['-8', 3, RoundingMode::Down, '-2'], + ['-8', 3, RoundingMode::Up, '-2'], + ['-8', 3, RoundingMode::HalfUp, '-2'], + + ['-27', 3, RoundingMode::Unnecessary, '-3'], + ['-1000', 3, RoundingMode::Unnecessary, '-10'], + + // truncatedRoot=-1, nextStep=-2, |sum|=9. + // -7: 2*7=14 > 9 → HalfUp=-2 (further from zero). Up (away from zero) = -2. Down = -1. + ['-7', 3, RoundingMode::Unnecessary, null], + ['-7', 3, RoundingMode::Down, '-1'], + ['-7', 3, RoundingMode::Up, '-2'], + ['-7', 3, RoundingMode::HalfUp, '-2'], + + // truncatedRoot=-2, nextStep=-3, |sum|=35. + // -17: 34 < 35 → HalfUp=-2. Up=-3, Down=-2. + ['-17', 3, RoundingMode::Unnecessary, null], + ['-17', 3, RoundingMode::Down, '-2'], + ['-17', 3, RoundingMode::Up, '-3'], + ['-17', 3, RoundingMode::HalfUp, '-2'], + + // -18: 36 > 35 → HalfUp=-3. + ['-18', 3, RoundingMode::Unnecessary, null], + ['-18', 3, RoundingMode::Down, '-2'], + ['-18', 3, RoundingMode::Up, '-3'], + ['-18', 3, RoundingMode::HalfUp, '-3'], + + // n = 4, perfect fourth powers. + ['16', 4, RoundingMode::Unnecessary, '2'], + ['81', 4, RoundingMode::Unnecessary, '3'], + ['10000', 4, RoundingMode::Unnecessary, '10'], + ['100000000', 4, RoundingMode::Unnecessary, '100'], + + // n = 4, non-exact. truncatedRoot=1, nextStep=2, sum=17. + // 8: 16 < 17 → HalfUp=1. + ['8', 4, RoundingMode::Unnecessary, null], + ['8', 4, RoundingMode::Down, '1'], + ['8', 4, RoundingMode::Up, '2'], + ['8', 4, RoundingMode::HalfUp, '1'], + + // 9: 18 > 17 → HalfUp=2. + ['9', 4, RoundingMode::Unnecessary, null], + ['9', 4, RoundingMode::Down, '1'], + ['9', 4, RoundingMode::Up, '2'], + ['9', 4, RoundingMode::HalfUp, '2'], + + // n = 5, perfect fifth powers. + ['32', 5, RoundingMode::Unnecessary, '2'], + ['243', 5, RoundingMode::Unnecessary, '3'], + ['100000', 5, RoundingMode::Unnecessary, '10'], + + // n = 5, non-exact. truncatedRoot=1, nextStep=2, sum=33. + // 15: 30 < 33 → HalfUp=1. + ['15', 5, RoundingMode::Unnecessary, null], + ['15', 5, RoundingMode::Down, '1'], + ['15', 5, RoundingMode::Up, '2'], + ['15', 5, RoundingMode::HalfUp, '1'], + + // 17: 34 > 33 → HalfUp=2. + ['17', 5, RoundingMode::Unnecessary, null], + ['17', 5, RoundingMode::Down, '1'], + ['17', 5, RoundingMode::Up, '2'], + ['17', 5, RoundingMode::HalfUp, '2'], + + // 31: 62 > 33 → HalfUp=2. + ['31', 5, RoundingMode::Unnecessary, null], + ['31', 5, RoundingMode::Down, '1'], + ['31', 5, RoundingMode::Up, '2'], + ['31', 5, RoundingMode::HalfUp, '2'], + + // n = 5, negative (odd). + ['-32', 5, RoundingMode::Unnecessary, '-2'], + ['-243', 5, RoundingMode::Unnecessary, '-3'], + + ['-17', 5, RoundingMode::Unnecessary, null], + ['-17', 5, RoundingMode::Down, '-1'], + ['-17', 5, RoundingMode::Up, '-2'], + ['-17', 5, RoundingMode::HalfUp, '-2'], + + ['-15', 5, RoundingMode::Unnecessary, null], + ['-15', 5, RoundingMode::Down, '-1'], + ['-15', 5, RoundingMode::Up, '-2'], + ['-15', 5, RoundingMode::HalfUp, '-1'], + + // n = 7, perfect seventh powers. + ['128', 7, RoundingMode::Unnecessary, '2'], + ['2187', 7, RoundingMode::Unnecessary, '3'], + + // n = 7, non-exact. truncatedRoot=1, nextStep=2, sum=129. + // 127: 254 > 129 → HalfUp=2. + ['127', 7, RoundingMode::Unnecessary, null], + ['127', 7, RoundingMode::Down, '1'], + ['127', 7, RoundingMode::Up, '2'], + ['127', 7, RoundingMode::HalfUp, '2'], + + ['-128', 7, RoundingMode::Unnecessary, '-2'], + + // Boundary cases for n = 3 on a larger number. + // truncatedRoot=9, nextStep=10, sum=729+1000=1729. + // 864: 2*864=1728 < 1729 → HalfUp=9. + ['864', 3, RoundingMode::Unnecessary, null], + ['864', 3, RoundingMode::Down, '9'], + ['864', 3, RoundingMode::Up, '10'], + ['864', 3, RoundingMode::HalfUp, '9'], + + // 865: 2*865=1730 > 1729 → HalfUp=10. + ['865', 3, RoundingMode::Unnecessary, null], + ['865', 3, RoundingMode::Down, '9'], + ['865', 3, RoundingMode::Up, '10'], + ['865', 3, RoundingMode::HalfUp, '10'], + + // Large perfect cube: 10^30 = (10^10)^3. + ['1000000000000000000000000000000', 3, RoundingMode::Unnecessary, '10000000000'], + ['1000000000000000000000000000000', 3, RoundingMode::Down, '10000000000'], + ['1000000000000000000000000000000', 3, RoundingMode::Up, '10000000000'], + ['1000000000000000000000000000000', 3, RoundingMode::HalfUp, '10000000000'], + + // Just above a large cube: truncated = 10^10, next = 10^10 + 1. + ['1000000000000000000000000000001', 3, RoundingMode::Unnecessary, null], + ['1000000000000000000000000000001', 3, RoundingMode::Down, '10000000000'], + ['1000000000000000000000000000001', 3, RoundingMode::Up, '10000000001'], + ['1000000000000000000000000000001', 3, RoundingMode::HalfUp, '10000000000'], + + // Large perfect 5th power: (10^6)^5 = 10^30. + ['1000000000000000000000000000000', 5, RoundingMode::Unnecessary, '1000000'], + + // Large perfect 7th power: 13^7 = 62748517. + ['62748517', 7, RoundingMode::Unnecessary, '13'], + + // Large perfect 2nd power, mirroring providerSqrt. + ['1000000000000000000000000000000000000000000000000000000000000', 2, RoundingMode::Unnecessary, '1000000000000000000000000000000'], + + // Very large n (degree), small value: for n=100, 1^100 = 1, 2^100 ≈ 1.27e30. + // Input 1 → exact 1. + ['1', 100, RoundingMode::Unnecessary, '1'], + // Input 2 → truncated 1, next 2, 1^100+2^100 = 1 + 2^100. 2*2=4 ≪ 2^100 → HalfUp=1. + ['2', 100, RoundingMode::Unnecessary, null], + ['2', 100, RoundingMode::Down, '1'], + ['2', 100, RoundingMode::Up, '2'], + ['2', 100, RoundingMode::HalfUp, '1'], + + // 2^100 is exact. + ['1267650600228229401496703205376', 100, RoundingMode::Unnecessary, '2'], + ]; + + foreach ($tests as [$number, $n, $roundingMode, $expected]) { + yield [$number, $n, $roundingMode, $expected]; + + $roundingModeEqs = match ($roundingMode) { + // Positive numbers make these rounding modes equivalent (and negative makes the opposite pair equivalent). + RoundingMode::Up => ($number[0] === '-') ? [RoundingMode::Floor] : [RoundingMode::Ceiling], + RoundingMode::Down => ($number[0] === '-') ? [RoundingMode::Ceiling] : [RoundingMode::Floor], + // For integer nth root, a midpoint tie is impossible, so all Half* modes collapse. + RoundingMode::HalfUp => [ + RoundingMode::HalfCeiling, + RoundingMode::HalfDown, + RoundingMode::HalfEven, + RoundingMode::HalfFloor, + ], + default => [], + }; + + foreach ($roundingModeEqs as $roundingModeEq) { + yield [$number, $n, $roundingModeEq, $expected]; + } + } + } + + public function testNthRootOfNegativeNumberWithEvenDegree(): void + { + $number = BigInteger::of(-8); + $this->expectException(NegativeNumberException::class); + $this->expectExceptionMessageExact('Cannot take an even nth root of a negative number.'); + + $number->nthRoot(2); + } + + public function testNthRootOfNegativeNumberWithLargeEvenDegree(): void + { + $number = BigInteger::of(-1); + $this->expectException(NegativeNumberException::class); + $this->expectExceptionMessageExact('Cannot take an even nth root of a negative number.'); + + $number->nthRoot(100); + } + + public function testNthRootWithZeroDegree(): void + { + $number = BigInteger::of(8); + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessageExact('The degree of an nth root must be a positive integer.'); + + $number->nthRoot(0); + } + + public function testNthRootWithNegativeDegree(): void + { + $number = BigInteger::of(8); + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessageExact('The degree of an nth root must be a positive integer.'); + + $number->nthRoot(-3); + } + /** * @param string $number The number as a string. * @param string $expected The expected absolute result.