From b318f1106e5c65af9a51876498ef778f038215bd Mon Sep 17 00:00:00 2001 From: ZHU Yuhao Date: Wed, 24 Jun 2026 23:58:07 +0200 Subject: [PATCH 1/2] Implement factorial for bigint and bigdecimal --- src/decimo/bigdecimal/bigdecimal.mojo | 20 +++++- src/decimo/bigdecimal/special.mojo | 93 +++++++++++++++++++++++++++ src/decimo/bigint/__init__.mojo | 2 + src/decimo/bigint/bigint.mojo | 15 +++++ src/decimo/bigint/special.mojo | 69 ++++++++++++++++++++ 5 files changed, 198 insertions(+), 1 deletion(-) create mode 100644 src/decimo/bigdecimal/special.mojo create mode 100644 src/decimo/bigint/special.mojo diff --git a/src/decimo/bigdecimal/bigdecimal.mojo b/src/decimo/bigdecimal/bigdecimal.mojo index bd03b069..6eb62827 100644 --- a/src/decimo/bigdecimal/bigdecimal.mojo +++ b/src/decimo/bigdecimal/bigdecimal.mojo @@ -37,6 +37,7 @@ import decimo.bigdecimal.comparison as bigdecimal_comparison import decimo.bigdecimal.constants as bigdecimal_constants import decimo.bigdecimal.exponential as bigdecimal_exponential import decimo.bigdecimal.rounding as bigdecimal_rounding +import decimo.bigdecimal.special as bigdecimal_special import decimo.bigdecimal.trigonometric as bigdecimal_trigonometric import decimo.biguint.arithmetics as biguint_arithmetics @@ -194,7 +195,7 @@ struct BigDecimal( @implicit def __init__(out self, value: BigInt10): - """Constructs a BigDecimal from a big integer. + """Constructs a BigDecimal from a base-10 big integer. Args: value: The `BigInt10` to convert. @@ -1850,6 +1851,23 @@ struct BigDecimal( """ return bigdecimal_exponential.exp(self, precision) + def factorial(self, precision: Int = 0) raises -> Self: + """Returns the factorial of this value (`self!`). + + Args: + precision: Significant digits for the result. `0` (the default) + computes the exact factorial with no rounding. A positive + value rounds intermediate products to keep the computation + bounded and returns `precision` correct significant digits. + + Returns: + `self!` (`0! == 1`). Exact when `precision == 0`. + + Raises: + ValueError: If `self` is negative or larger than 10^9. + """ + return bigdecimal_special.factorial(self, precision) + @always_inline def ln(self, precision: Int = PRECISION) raises -> Self: """Returns the natural logarithm of the BigDecimal number. diff --git a/src/decimo/bigdecimal/special.mojo b/src/decimo/bigdecimal/special.mojo new file mode 100644 index 00000000..9d2aacc5 --- /dev/null +++ b/src/decimo/bigdecimal/special.mojo @@ -0,0 +1,93 @@ +# ===----------------------------------------------------------------------=== # +# Copyright 2025-2026 Yuhao Zhu +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ===----------------------------------------------------------------------=== # +# +# Implements special functions for the BigDecimal type +# +# ===----------------------------------------------------------------------=== # + +"""Implements functions for special operations on BigDecimal objects.""" + +from decimo.bigdecimal.bigdecimal import BigDecimal +from decimo.errors import ValueError + +# Extra significant digits carried during a rounded factorial, on top of the +# requested precision and the digit count of `n`. Covers the rounding error +# that accumulates over the `n` intermediate products. +comptime FACTORIAL_GUARD_DIGITS = 9 # word size + +# Largest argument accepted by `factorial`. 10^9 already needs ~10^9 +# multiplications and produces a result with billions of digits, so anything +# beyond it is computationally infeasible. The cap also keeps the value within +# Mojo's `Int` range, so an out-of-range argument raises a clear error +# instead of an `Int` overflow. +comptime FACTORIAL_MAX_INPUT = 1_000_000_000 + + +def factorial(x: BigDecimal, precision: Int = 0) raises -> BigDecimal: + """Calculates the factorial of a non-negative integer value. + + Args: + x: The non-negative integer value to take the factorial of. + precision: Significant digits for the result. `0` (the default) + computes the exact factorial with no rounding. A positive value + rounds the intermediate products to a bounded working width, + which lowers the cost for large `x`, and returns `precision` + correct significant digits. + + Returns: + `x!`, the product of all positive integers up to `x` (`0! == 1`). + Exact when `precision == 0`. + + Raises: + ValueError: If `x` is negative or larger than `FACTORIAL_MAX_INPUT` + (10^9). + + Notes: + + The value must currently fit in a Mojo `Int`. Arbitrarily large + arguments will be supported later. + """ + if x < BigDecimal(0): + raise ValueError( + message="Factorial is not defined for negative numbers.", + function="factorial()", + ) + if x > BigDecimal(FACTORIAL_MAX_INPUT): + raise ValueError( + message=( + "Factorial argument is too large to compute (must be <= 10^9)." + ), + function="factorial()", + ) + + var n = Int(x) + if precision <= 0: + # Exact: full-width products, no rounding. + var result = BigDecimal(1) + for i in range(2, n + 1): + result = result.multiply(BigDecimal(i)) + return result^ + + # Rounded: keep every product at `precision + guard` significant digits, + # where the guard also grows with the number of digits in `n`. Round the + # final result back to `precision` (HALF_EVEN, via multiply-by-one). + var working_precision = ( + precision + String(n).byte_length() + FACTORIAL_GUARD_DIGITS + ) + var result = BigDecimal(1) + for i in range(2, n + 1): + result = result.multiply(BigDecimal(i), working_precision) + return result.multiply(BigDecimal(1), precision) diff --git a/src/decimo/bigint/__init__.mojo b/src/decimo/bigint/__init__.mojo index 45341862..2e4f7755 100644 --- a/src/decimo/bigint/__init__.mojo +++ b/src/decimo/bigint/__init__.mojo @@ -29,4 +29,6 @@ Modules: - bitwise: AND, OR, XOR, NOT (Python two's complement semantics) - comparison: compare, greater, less, equal - exponential: sqrt, isqrt +- number_theory: gcd, extended_gcd, lcm, mod_pow, mod_inverse +- special: factorial (and future special functions) """ diff --git a/src/decimo/bigint/bigint.mojo b/src/decimo/bigint/bigint.mojo index c49bdd81..f6dbce03 100644 --- a/src/decimo/bigint/bigint.mojo +++ b/src/decimo/bigint/bigint.mojo @@ -35,6 +35,7 @@ import decimo.bigint.bitwise as bigint_bitwise import decimo.bigint.comparison as bigint_comparison import decimo.bigint.exponential as bigint_exponential import decimo.bigint.number_theory as bigint_number_theory +import decimo.bigint.special as bigint_special import decimo.str as decimo_str from decimo.bigint10.bigint10 import BigInt10 from decimo.biguint.biguint import BigUInt @@ -219,6 +220,9 @@ struct BigInt( # Constructing methods that are not dunders # ===------------------------------------------------------------------=== # + # IMPORTANT: + # This function will be removed in the future when `Int` type is + # a comptime alias of `SIMD[DType.int, 1]`. @staticmethod def from_int(value: Int) -> Self: """Creates a BigInt from a Mojo Int. @@ -1467,6 +1471,17 @@ struct BigInt( """ return bigint_exponential.sqrt(self) + def factorial(self) raises -> Self: + """Returns the factorial of this value. + + Returns: + `self!` (`0! == 1`). + + Raises: + ValueError: If `self` is negative or larger than 10^9. + """ + return bigint_special.factorial(self) + @always_inline def compare_magnitudes(self, other: Self) -> Int8: """Compares the magnitudes (absolute values) of two BigInt numbers. diff --git a/src/decimo/bigint/special.mojo b/src/decimo/bigint/special.mojo new file mode 100644 index 00000000..40e7b5bc --- /dev/null +++ b/src/decimo/bigint/special.mojo @@ -0,0 +1,69 @@ +# ===----------------------------------------------------------------------=== # +# Copyright 2025-2026 Yuhao Zhu +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ===----------------------------------------------------------------------=== # +# +# Implements special functions for the BigInt type +# +# ===----------------------------------------------------------------------=== # + +"""Implements functions for special operations on BigInt objects.""" + +from decimo.bigint.bigint import BigInt +from decimo.errors import ValueError + +# Largest argument accepted by `factorial`. 10^9 already needs ~10^9 +# multiplications and produces a result with billions of digits, so anything +# beyond it is computationally infeasible. The cap also keeps the value within +# Mojo's `Int` range, so an out-of-range argument raises a clear error +# instead of an `Int` overflow. +comptime FACTORIAL_MAX_INPUT = 1_000_000_000 + + +def factorial(x: BigInt) raises -> BigInt: + """Calculates the factorial of a non-negative integer value. + + Args: + x: The non-negative integer value to take the factorial of. + + Returns: + `x!`, the product of all positive integers up to `x` (`0! == 1`). + + Raises: + ValueError: If `x` is negative or larger than `FACTORIAL_MAX_INPUT` + (10^9). + + Notes: + + The value must currently fit in a Mojo `Int`. Arbitrarily large + arguments will be supported later. + """ + if x < BigInt.zero(): + raise ValueError( + message="Factorial is not defined for negative numbers.", + function="factorial()", + ) + if x > BigInt(FACTORIAL_MAX_INPUT): + raise ValueError( + message=( + "Factorial argument is too large to compute (must be <= 10^9)." + ), + function="factorial()", + ) + + var n = Int(x) + var result = BigInt.one() + for i in range(2, n + 1): + result *= BigInt(i) + return result^ From ee64ef97bafcab0276d530243f14a2f656aa0358 Mon Sep 17 00:00:00 2001 From: ZHU Yuhao Date: Thu, 25 Jun 2026 21:36:48 +0200 Subject: [PATCH 2/2] Address comments --- src/decimo/bigdecimal/bigdecimal.mojo | 32 ++++++--- src/decimo/bigdecimal/special.mojo | 27 +++++--- src/decimo/bigint/bigint.mojo | 2 +- src/decimo/bigint/special.mojo | 16 ++--- tests/bigdecimal/test_bigdecimal_special.mojo | 65 +++++++++++++++++++ tests/bigint/test_bigint_special.mojo | 47 ++++++++++++++ 6 files changed, 161 insertions(+), 28 deletions(-) create mode 100644 tests/bigdecimal/test_bigdecimal_special.mojo create mode 100644 tests/bigint/test_bigint_special.mojo diff --git a/src/decimo/bigdecimal/bigdecimal.mojo b/src/decimo/bigdecimal/bigdecimal.mojo index 6eb62827..2f49040b 100644 --- a/src/decimo/bigdecimal/bigdecimal.mojo +++ b/src/decimo/bigdecimal/bigdecimal.mojo @@ -1567,10 +1567,10 @@ struct BigDecimal( return truncated^ def __trunc__(self) raises -> Self: - """Returns self truncated toward zero (removes fractional part). + """Returns self truncated toward zero (removes the fractional part). - Equivalent to `math.trunc()` in Python. Returns a BigDecimal - with scale 0. + Equivalent to `math.trunc()` in Python; delegates to `truncate()`. + Returns a BigDecimal with scale 0. Returns: The truncated integer part of this value. @@ -1578,11 +1578,7 @@ struct BigDecimal( Raises: Error: If the underlying rounding operation fails. """ - if self.scale <= 0: - return self.copy() - return bigdecimal_rounding.round( - self, ndigits=0, rounding_mode=RoundingMode.down() - ) + return self.truncate() # ===------------------------------------------------------------------=== # # Basic arithmetic operation without dunders @@ -1864,7 +1860,8 @@ struct BigDecimal( `self!` (`0! == 1`). Exact when `precision == 0`. Raises: - ValueError: If `self` is negative or larger than 10^9. + ValueError: If `self` is not an integer, is negative, or is + larger than 10^6. """ return bigdecimal_special.factorial(self, precision) @@ -2327,6 +2324,23 @@ struct BigDecimal( """ return self * a + b + def truncate(self) raises -> Self: + """Returns self truncated toward zero (removes the fractional part). + + Returns a BigDecimal with scale 0. + + Returns: + The truncated integer part of this value. + + Raises: + Error: If the underlying rounding operation fails. + """ + if self.scale <= 0: + return self.copy() + return bigdecimal_rounding.round( + self, ndigits=0, rounding_mode=RoundingMode.down() + ) + # ===------------------------------------------------------------------=== # # Other methods # ===------------------------------------------------------------------=== # diff --git a/src/decimo/bigdecimal/special.mojo b/src/decimo/bigdecimal/special.mojo index 9d2aacc5..336ec44c 100644 --- a/src/decimo/bigdecimal/special.mojo +++ b/src/decimo/bigdecimal/special.mojo @@ -28,12 +28,12 @@ from decimo.errors import ValueError # that accumulates over the `n` intermediate products. comptime FACTORIAL_GUARD_DIGITS = 9 # word size -# Largest argument accepted by `factorial`. 10^9 already needs ~10^9 -# multiplications and produces a result with billions of digits, so anything -# beyond it is computationally infeasible. The cap also keeps the value within -# Mojo's `Int` range, so an out-of-range argument raises a clear error -# instead of an `Int` overflow. -comptime FACTORIAL_MAX_INPUT = 1_000_000_000 +# Largest argument accepted by `factorial`. Even 10^6 already needs ~10^6 +# multiplications, so anything beyond it is impractical with the simple +# iterative product. The cap also keeps the value within Mojo's `Int` range, +# so an out-of-range argument raises a clear error instead of an `Int` +# overflow. (A faster algorithm, e.g. binary splitting, could lift this.) +comptime FACTORIAL_MAX_INPUT = 1_000_000 def factorial(x: BigDecimal, precision: Int = 0) raises -> BigDecimal: @@ -52,14 +52,19 @@ def factorial(x: BigDecimal, precision: Int = 0) raises -> BigDecimal: Exact when `precision == 0`. Raises: - ValueError: If `x` is negative or larger than `FACTORIAL_MAX_INPUT` - (10^9). + ValueError: If `x` is not an integer, is negative, or is larger than + `FACTORIAL_MAX_INPUT` (10^6). Notes: The value must currently fit in a Mojo `Int`. Arbitrarily large arguments will be supported later. """ + if not x.is_integer(): + raise ValueError( + message="Factorial is only defined for integer values.", + function="factorial()", + ) if x < BigDecimal(0): raise ValueError( message="Factorial is not defined for negative numbers.", @@ -68,12 +73,14 @@ def factorial(x: BigDecimal, precision: Int = 0) raises -> BigDecimal: if x > BigDecimal(FACTORIAL_MAX_INPUT): raise ValueError( message=( - "Factorial argument is too large to compute (must be <= 10^9)." + "Factorial argument is too large to compute (must be <= 10^6)." ), function="factorial()", ) - var n = Int(x) + # `truncate` gives a scale-0 BigDecimal, so integer values written with a + # fractional part (e.g. "5.00") convert cleanly to `Int`. + var n = Int(x.truncate()) if precision <= 0: # Exact: full-width products, no rounding. var result = BigDecimal(1) diff --git a/src/decimo/bigint/bigint.mojo b/src/decimo/bigint/bigint.mojo index f6dbce03..c6a8a136 100644 --- a/src/decimo/bigint/bigint.mojo +++ b/src/decimo/bigint/bigint.mojo @@ -1478,7 +1478,7 @@ struct BigInt( `self!` (`0! == 1`). Raises: - ValueError: If `self` is negative or larger than 10^9. + ValueError: If `self` is negative or larger than 10^6. """ return bigint_special.factorial(self) diff --git a/src/decimo/bigint/special.mojo b/src/decimo/bigint/special.mojo index 40e7b5bc..fcb2ded1 100644 --- a/src/decimo/bigint/special.mojo +++ b/src/decimo/bigint/special.mojo @@ -23,12 +23,12 @@ from decimo.bigint.bigint import BigInt from decimo.errors import ValueError -# Largest argument accepted by `factorial`. 10^9 already needs ~10^9 -# multiplications and produces a result with billions of digits, so anything -# beyond it is computationally infeasible. The cap also keeps the value within -# Mojo's `Int` range, so an out-of-range argument raises a clear error -# instead of an `Int` overflow. -comptime FACTORIAL_MAX_INPUT = 1_000_000_000 +# Largest argument accepted by `factorial`. Even 10^6 already needs ~10^6 +# multiplications, so anything beyond it is impractical with the simple +# iterative product. The cap also keeps the value within Mojo's `Int` range, +# so an out-of-range argument raises a clear error instead of an `Int` +# overflow. (A faster algorithm, e.g. binary splitting, could lift this.) +comptime FACTORIAL_MAX_INPUT = 1_000_000 def factorial(x: BigInt) raises -> BigInt: @@ -42,7 +42,7 @@ def factorial(x: BigInt) raises -> BigInt: Raises: ValueError: If `x` is negative or larger than `FACTORIAL_MAX_INPUT` - (10^9). + (10^6). Notes: @@ -57,7 +57,7 @@ def factorial(x: BigInt) raises -> BigInt: if x > BigInt(FACTORIAL_MAX_INPUT): raise ValueError( message=( - "Factorial argument is too large to compute (must be <= 10^9)." + "Factorial argument is too large to compute (must be <= 10^6)." ), function="factorial()", ) diff --git a/tests/bigdecimal/test_bigdecimal_special.mojo b/tests/bigdecimal/test_bigdecimal_special.mojo new file mode 100644 index 00000000..34cbd800 --- /dev/null +++ b/tests/bigdecimal/test_bigdecimal_special.mojo @@ -0,0 +1,65 @@ +# ===----------------------------------------------------------------------=== # +# Test BigDecimal special functions (factorial) +# ===----------------------------------------------------------------------=== # + +from std import testing +from decimo.bigdecimal.bigdecimal import BigDecimal + + +def test_factorial_exact() raises: + """Test exact factorial (precision == 0).""" + testing.assert_equal(String(BigDecimal(0).factorial()), "1") + testing.assert_equal(String(BigDecimal(1).factorial()), "1") + testing.assert_equal(String(BigDecimal(5).factorial()), "120") + testing.assert_equal(String(BigDecimal(10).factorial()), "3628800") + testing.assert_equal( + String(BigDecimal(30).factorial()), + "265252859812191058636308480000000", + ) + + +def test_factorial_integer_with_scale() raises: + """Test that an integer value written with a fractional part (e.g. + "5.00") is accepted.""" + testing.assert_equal(String(BigDecimal("5.00").factorial()), "120") + + +def test_factorial_rounded() raises: + """Test the rounded mode returns `precision` significant digits.""" + testing.assert_equal( + String(BigDecimal(30).factorial(10)), "2.652528598E+32" + ) + + +def test_factorial_non_integer_raises() raises: + """Test that a non-integer argument raises.""" + var raised = False + try: + _ = BigDecimal("5.5").factorial() + except: + raised = True + testing.assert_true(raised, "factorial of a non-integer should raise") + + +def test_factorial_negative_raises() raises: + """Test that a negative argument raises.""" + var raised = False + try: + _ = BigDecimal(-1).factorial() + except: + raised = True + testing.assert_true(raised, "factorial of a negative value should raise") + + +def test_factorial_too_large_raises() raises: + """Test that an argument above the cap raises.""" + var raised = False + try: + _ = BigDecimal(2_000_000).factorial() # above the 10^6 cap + except: + raised = True + testing.assert_true(raised, "factorial above the cap should raise") + + +def main() raises: + testing.TestSuite.discover_tests[__functions_in_module()]().run() diff --git a/tests/bigint/test_bigint_special.mojo b/tests/bigint/test_bigint_special.mojo new file mode 100644 index 00000000..66df5061 --- /dev/null +++ b/tests/bigint/test_bigint_special.mojo @@ -0,0 +1,47 @@ +# ===----------------------------------------------------------------------=== # +# Test BigInt special functions (factorial) +# ===----------------------------------------------------------------------=== # + +from std import testing +from decimo.bigint.bigint import BigInt + + +def test_factorial_basic() raises: + """Test factorial of small non-negative integers.""" + testing.assert_equal(String(BigInt(0).factorial()), "1") + testing.assert_equal(String(BigInt(1).factorial()), "1") + testing.assert_equal(String(BigInt(2).factorial()), "2") + testing.assert_equal(String(BigInt(5).factorial()), "120") + testing.assert_equal(String(BigInt(10).factorial()), "3628800") + testing.assert_equal(String(BigInt(20).factorial()), "2432902008176640000") + + +def test_factorial_large() raises: + """Test factorial that exceeds 64-bit range.""" + testing.assert_equal( + String(BigInt(25).factorial()), "15511210043330985984000000" + ) + + +def test_factorial_negative_raises() raises: + """Test that a negative argument raises.""" + var raised = False + try: + _ = BigInt(-1).factorial() + except: + raised = True + testing.assert_true(raised, "factorial of a negative value should raise") + + +def test_factorial_too_large_raises() raises: + """Test that an argument above the cap raises.""" + var raised = False + try: + _ = BigInt(2_000_000).factorial() # above the 10^6 cap + except: + raised = True + testing.assert_true(raised, "factorial above the cap should raise") + + +def main() raises: + testing.TestSuite.discover_tests[__functions_in_module()]().run()