From 05dc386b5ccc43ef8098e787256638729fbe44cb Mon Sep 17 00:00:00 2001 From: ZHU Yuhao Date: Sat, 28 Feb 2026 16:23:50 +0100 Subject: [PATCH 1/2] exponent -> adjustd, copy_abs() / copy_negate() / copy_sign() --- docs/plans/api_roadmap.md | 18 +-- src/decimo/bigdecimal/bigdecimal.mojo | 70 +++++++++-- src/decimo/bigdecimal/exponential.mojo | 26 ++--- tests/bigdecimal/test_bigdecimal_methods.mojo | 110 ++++++++++++++++++ 4 files changed, 190 insertions(+), 34 deletions(-) diff --git a/docs/plans/api_roadmap.md b/docs/plans/api_roadmap.md index 7181ee4..c28550f 100644 --- a/docs/plans/api_roadmap.md +++ b/docs/plans/api_roadmap.md @@ -87,10 +87,10 @@ These are the gaps vs Python's `decimal.Decimal`, prioritized by user impact. | Method | What It Does | Notes | | ----------------------------------- | -------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- | | `as_tuple()` | Returns `(sign, digits, exponent)` | ✓ **DONE** — returns `(sign: Bool, digits: List[UInt8], exponent: Int)` matching Python's `DecimalTuple` | -| `adjusted()` | Returns adjusted exponent (= exponent + len(digits) - 1) | Useful for formatting and comparison. | -| `copy_abs()` | Returns `abs(self)` | Alias for `__abs__()`. Trivial to add. | -| `copy_negate()` | Returns `-self` | Alias for `__neg__()`. Trivial to add. | -| `copy_sign(other)` | Returns self with the sign of other | One-liner. | +| `adjusted()` | Returns adjusted exponent (= exponent + len(digits) - 1) | ✅ **DONE** — alias for `exponent()`. | +| `copy_abs()` | Returns `abs(self)` | ✅ **DONE** — alias for `__abs__()`. | +| `copy_negate()` | Returns `-self` | ✅ **DONE** — alias for `__neg__()`. | +| `copy_sign(other)` | Returns self with the sign of other | ✅ **DONE**. | | `same_quantum(other)` | True if both have same exponent/scale | Useful for financial code. | | `normalize()` | Already exists | ✓ | | `to_eng_string()` | Engineering notation (exponent multiple of 3) | ✓ **DONE** — alias for `to_string(engineering=True)` | @@ -294,8 +294,8 @@ BigInt has `to_string_with_separators()`. This should be extended to BigDecimal. ### Tier 2: Important (Remaining) 1. ✓ **`as_tuple()`** on BigDecimal — returns `(sign: Bool, digits: List[UInt8], exponent: Int)` matching Python's `DecimalTuple` -2. **`copy_abs()` / `copy_negate()` / `copy_sign(other)`** on BigDecimal -3. **`adjusted()`** on BigDecimal — returns adjusted exponent (= `exponent + len(digits) - 1`) +2. ✅ **`copy_abs()` / `copy_negate()` / `copy_sign(other)`** on BigDecimal +3. ✅ **`adjusted()`** on BigDecimal — renamed from `exponent()` to match Python's `Decimal.adjusted()` 4. **`same_quantum(other)`** on BigDecimal 5. **`ROUND_HALF_DOWN`** rounding mode 6. **`scaleb(n)`** on BigDecimal — multiply by 10^n efficiently @@ -344,9 +344,9 @@ For tracking against the above: ✓ __rmod__ ✓ __rmul__ ✓ __round__ ✓ __rpow__ ✓ __rsub__ ✓ __rtruediv__ ✓ __str__ ✓ __sub__ ✓ __truediv__ ✓ __trunc__ -✗ adjusted ✗ as_integer_ratio ✓ as_tuple ✗ canonical -✓ compare ✗ conjugate ✗ copy_abs ✗ copy_negate -✗ copy_sign ✓ exp ✗ fma ✗ is_canonical +✓ adjusted ✗ as_integer_ratio ✓ as_tuple ✗ canonical +✓ compare ✗ conjugate ✓ copy_abs ✓ copy_negate +✓ copy_sign ✓ exp ✗ fma ✗ is_canonical ✗ is_finite ✓ is_integer ✗ is_nan ✗ is_normal ✗ is_signed ✗ is_snan ✗ is_subnormal ✗ is_qnan ✓ ln ✓ log10 ✗ logb ✗ logical_and diff --git a/src/decimo/bigdecimal/bigdecimal.mojo b/src/decimo/bigdecimal/bigdecimal.mojo index 6134dd3..5d1379f 100644 --- a/src/decimo/bigdecimal/bigdecimal.mojo +++ b/src/decimo/bigdecimal/bigdecimal.mojo @@ -851,8 +851,9 @@ struct BigDecimal( `decimal.Decimal`. Notes: - Uses truncated division (toward zero), matching Python's - `decimal.Decimal.__divmod__()` behavior. + + Uses truncated division (toward zero), matching Python's + `decimal.Decimal.__divmod__()` behavior. """ var quotient = decimo.bigdecimal.arithmetics.truncate_divide( self, other @@ -1353,20 +1354,27 @@ struct BigDecimal( # Other methods # ===------------------------------------------------------------------=== # - fn exponent(self) -> Int: - """Returns the exponent of the number in scientific notation. + fn adjusted(self) -> Int: + """Returns the adjusted exponent. - Notes: + This is the exponent of the number when written with a single leading + digit in scientific notation. Equivalently, + `as_tuple_exponent + number_of_digits - 1` where `as_tuple_exponent` + is `-scale`. + + Examples: - 123.45 (coefficient = 12345, scale = 2) is represented as 1.2345E+2. - 0.00123 (coefficient = 123, scale = 5) is represented as 1.23E-3. - 123000 (coefficient = 123, scale = -3) is represented as 1.23E+5. + ``` + BigDecimal("123.45").adjusted() # 2 (1.2345E+2) + BigDecimal("0.00123").adjusted() # -3 (1.23E-3) + BigDecimal("100").adjusted() # 2 (1E+2) + BigDecimal("1").adjusted() # 0 (1E0) + ``` """ return self.coefficient.number_of_digits() - 1 - self.scale fn as_tuple(self) -> Tuple[Bool, List[UInt8], Int]: - """Returns a 3-tuple `(sign, digits, exponent)` matching Python's - `decimal.Decimal.as_tuple()` convention. + """Returns a 3-tuple `(sign, digits, exponent)`. The components are: - `sign`: `False` for positive/zero, `True` for negative. @@ -1395,8 +1403,13 @@ struct BigDecimal( ``` Notes: - To reconstruct the value from the tuple, join the digits into a - coefficient string, parse as BigUInt, then apply sign and exponent. + + To reconstruct the value from the tuple, join the digits into a + coefficient string, parse as BigUInt, then apply sign and exponent. + + This method is designed to match Python's `decimal.Decimal.as_tuple()` + output, except that the `digits` are returned as a `List[UInt8]` + instead of a `Tuple[int]` for better performance in Mojo. """ var coef_str = self.coefficient.to_string() var cb = coef_str.as_string_slice().as_bytes() @@ -1407,6 +1420,39 @@ struct BigDecimal( digits.append(ptr[i] - 48) # 48 == ord('0') return (self.sign, digits^, -self.scale) + @always_inline + fn copy_abs(self) -> Self: + """Returns a copy with the sign set to positive. + + Equivalent to `abs(self)`. Matches Python's + `decimal.Decimal.copy_abs()`. + """ + return self.__abs__() + + @always_inline + fn copy_negate(self) -> Self: + """Returns a copy with the sign inverted. + + Equivalent to `-self`. Matches Python's + `decimal.Decimal.copy_negate()`. + """ + return self.__neg__() + + @always_inline + fn copy_sign(self, other: Self) -> Self: + """Returns a copy of `self` with the sign of `other`. + + Matches Python's `decimal.Decimal.copy_sign(other)`. + + Args: + other: The BigDecimal whose sign to copy. + """ + return Self( + coefficient=self.coefficient, + scale=self.scale, + sign=other.sign, + ) + fn extend_precision(self, precision_diff: Int) -> Self: """Returns a number with additional decimal places (trailing zeros). This multiplies the coefficient by 10^precision_diff and increases diff --git a/src/decimo/bigdecimal/exponential.mojo b/src/decimo/bigdecimal/exponential.mojo index 0cf1ac3..1a61474 100644 --- a/src/decimo/bigdecimal/exponential.mojo +++ b/src/decimo/bigdecimal/exponential.mojo @@ -623,7 +623,7 @@ fn integer_root( # Initial guess using Float64 approximation # Use exponent to get log10(x), then compute 10^(log10(x)/n) - var x_exp = abs_x.exponent() # floor(log10(x)) + var x_exp = abs_x.adjusted() # floor(log10(x)) # Extract leading digits for a more precise Float64 approximation var top_word = Float64(abs_x.coefficient.words[-1]) @@ -1020,7 +1020,7 @@ fn fast_isqrt(c: BigUInt, working_digits: Int) raises -> BigUInt: var c_bd = BigDecimal(c.copy(), 0, False) # --- Normalization --- - var c_exp = c_bd.exponent() + var c_exp = c_bd.adjusted() var norm_shift: Int if c_exp >= 0: norm_shift = (c_exp // 2) * 2 @@ -1045,7 +1045,7 @@ fn fast_isqrt(c: BigUInt, working_digits: Int) raises -> BigUInt: Float64(10.0) ** Float64(digits_in_top + 8) ) - var c_norm_exp = c_norm.exponent() + var c_norm_exp = c_norm.adjusted() var c_norm_f64 = mantissa * Float64(10.0) ** Float64(c_norm_exp) var r_f64 = c_norm_f64 ** (-0.5) if r_f64 != r_f64 or r_f64 <= 0.0: @@ -1333,7 +1333,7 @@ fn sqrt_reciprocal(x: BigDecimal, precision: Int) raises -> BigDecimal: # Shift x by an even power of 10 to bring it into [0.1, 100) for a # stable Float64 initial guess. Then sqrt(x) = sqrt(x_norm) * 10^(shift/2). var x_norm = x.copy() - var x_exp = x_norm.exponent() # floor(log10(x)) + var x_exp = x_norm.adjusted() # floor(log10(x)) # Make shift even and bring x_norm near 1 var shift: Int @@ -1359,7 +1359,7 @@ fn sqrt_reciprocal(x: BigDecimal, precision: Int) raises -> BigDecimal: Float64(10.0) ** Float64(digits_in_top + 8) ) - var x_norm_exp = x_norm.exponent() + var x_norm_exp = x_norm.adjusted() var x_norm_f64 = mantissa * Float64(10.0) ** Float64(x_norm_exp) var r_f64 = x_norm_f64 ** (-0.5) # 1/sqrt(x_norm) if r_f64 != r_f64 or r_f64 <= 0.0: # NaN or degenerate @@ -1764,11 +1764,11 @@ fn exp(x: BigDecimal, precision: Int) raises -> BigDecimal: # For very large positive values, result will overflow BigDecimal capacity # TODO: Use BigInt10 as scale can avoid overflow in this case - if not x.sign and x.exponent() >= 20: # x > 10^20 + if not x.sign and x.adjusted() >= 20: # x > 10^20 raise Error("Error in `exp`: Result too large to represent") # For very large negative values, result will be effectively zero - if x.sign and x.exponent() >= 20: # x < -10^20 + if x.sign and x.adjusted() >= 20: # x < -10^20 return BigDecimal(BigUInt.zero(), precision, False) # Handle negative x using identity: exp(-x) = 1/exp(x) @@ -1789,7 +1789,7 @@ fn exp(x: BigDecimal, precision: Int) raises -> BigDecimal: # # For |x| ≈ 1: M ≈ √(3.322·p), giving ~2·√(3.322·p) total multiplications # vs the old approach of ~2.5·p multiplications. - var x_exp = x.exponent() # floor(log10(x)) + var x_exp = x.adjusted() # floor(log10(x)) var p_float = Float64(precision) # Compute optimal number of halvings @@ -1909,7 +1909,7 @@ fn exp_taylor_series( # print("DEUBG: round {}, term {}, result {}".format(n, term, result)) # Check if we've reached desired precision - if term.exponent() < -minimum_precision: + if term.adjusted() < -minimum_precision: break result.round_to_precision( @@ -1990,7 +1990,7 @@ fn ln(x: BigDecimal, precision: Int, mut cache: MathCache) raises -> BigDecimal: var adj_power_of_2: Int = 0 var adj_power_of_5: Int = 0 # First, scale down to [0.1, 1) - var power_of_10 = m.exponent() + 1 + var power_of_10 = m.adjusted() + 1 m.scale += power_of_10 # Second, scale to [0.5, 1.5) if m < BigDecimal(BigUInt(raw_words=[135]), 3, False): @@ -2241,7 +2241,7 @@ fn ln_series_expansion( else: result += next_term - if next_term.exponent() < -working_precision: + if next_term.adjusted() < -working_precision: break result.round_to_precision( @@ -2294,7 +2294,7 @@ fn ln_series_expansion( result += term - if term.exponent() < -working_precision: + if term.adjusted() < -working_precision: break result.round_to_precision( @@ -2390,7 +2390,7 @@ fn compute_ln2(working_precision: Int) raises -> BigDecimal: term.coefficient, k ) k = new_k - if term.exponent() < -working_precision: + if term.adjusted() < -working_precision: break result.round_to_precision( diff --git a/tests/bigdecimal/test_bigdecimal_methods.mojo b/tests/bigdecimal/test_bigdecimal_methods.mojo index 2f44c02..a8e0d6c 100644 --- a/tests/bigdecimal/test_bigdecimal_methods.mojo +++ b/tests/bigdecimal/test_bigdecimal_methods.mojo @@ -5,6 +5,8 @@ Tests for BigDecimal utility methods added in v0.8.x: - to_scientific_string() / to_eng_string() - number_of_digits() - as_tuple() + - copy_abs() / copy_negate() / copy_sign() + - adjusted() """ import testing @@ -321,5 +323,113 @@ fn test_as_tuple_reconstruct() raises: ) +# ===----------------------------------------------------------------------=== # +# copy_abs() / copy_negate() / copy_sign() +# ===----------------------------------------------------------------------=== # + + +fn test_copy_abs_positive() raises: + """Positive value unchanged.""" + var x = BigDecimal("3.14") + testing.assert_equal(String(x.copy_abs()), "3.14") + + +fn test_copy_abs_negative() raises: + """Negative value becomes positive.""" + var x = BigDecimal("-3.14") + testing.assert_equal(String(x.copy_abs()), "3.14") + + +fn test_copy_abs_zero() raises: + """Zero stays zero.""" + testing.assert_equal(String(BigDecimal("0").copy_abs()), "0") + + +fn test_copy_abs_matches_abs() raises: + """Verify copy_abs() == abs(x).""" + var x = BigDecimal("-42.5") + testing.assert_equal(String(x.copy_abs()), String(x.__abs__())) + + +fn test_copy_negate_positive() raises: + """Positive becomes negative.""" + testing.assert_equal(String(BigDecimal("3.14").copy_negate()), "-3.14") + + +fn test_copy_negate_negative() raises: + """Negative becomes positive.""" + testing.assert_equal(String(BigDecimal("-3.14").copy_negate()), "3.14") + + +fn test_copy_negate_zero() raises: + """Negating zero.""" + var z = BigDecimal("0").copy_negate() + # Zero negated — coefficient is still 0 + testing.assert_true(z.is_zero()) + + +fn test_copy_negate_matches_neg() raises: + """Verify copy_negate() == -x.""" + var x = BigDecimal("42.5") + testing.assert_equal(String(x.copy_negate()), String(x.__neg__())) + + +fn test_copy_sign_positive_to_negative() raises: + """Copy sign of negative onto positive value.""" + var x = BigDecimal("3.14") + var y = BigDecimal("-1") + testing.assert_equal(String(x.copy_sign(y)), "-3.14") + + +fn test_copy_sign_negative_to_positive() raises: + """Copy sign of positive onto negative value.""" + var x = BigDecimal("-3.14") + var y = BigDecimal("1") + testing.assert_equal(String(x.copy_sign(y)), "3.14") + + +fn test_copy_sign_same_sign() raises: + """Same sign leaves value unchanged.""" + var x = BigDecimal("3.14") + var y = BigDecimal("99") + testing.assert_equal(String(x.copy_sign(y)), "3.14") + + +fn test_copy_sign_zero_source() raises: + """Zero takes sign of other.""" + var x = BigDecimal("0") + var y = BigDecimal("-5") + var result = x.copy_sign(y) + # Sign bit set but coefficient is 0 + testing.assert_true(result.sign) + testing.assert_true(result.is_zero()) + + +# ===----------------------------------------------------------------------=== # +# adjusted() +# ===----------------------------------------------------------------------=== # + + +fn test_adjusted_basic() raises: + """Basic adjusted exponent values.""" + testing.assert_equal(BigDecimal("123.45").adjusted(), 2) + testing.assert_equal(BigDecimal("0.00123").adjusted(), -3) + testing.assert_equal(BigDecimal("100").adjusted(), 2) + testing.assert_equal(BigDecimal("1").adjusted(), 0) + testing.assert_equal(BigDecimal("10").adjusted(), 1) + + +fn test_adjusted_zero() raises: + """Zero has adjusted exponent 0.""" + testing.assert_equal(BigDecimal("0").adjusted(), 0) + + +fn test_adjusted_scientific() raises: + """Scientific notation inputs.""" + testing.assert_equal(BigDecimal("1E+5").adjusted(), 5) + testing.assert_equal(BigDecimal("1E-5").adjusted(), -5) + testing.assert_equal(BigDecimal("1.23E+10").adjusted(), 10) + + fn main() raises: testing.TestSuite.discover_tests[__functions_in_module()]().run() From b0827a714b896cf7c67dd3fea265ddf189917055 Mon Sep 17 00:00:00 2001 From: ZHU Yuhao Date: Sat, 28 Feb 2026 16:33:43 +0100 Subject: [PATCH 2/2] address comments --- docs/plans/api_roadmap.md | 2 +- src/decimo/bigdecimal/bigdecimal.mojo | 9 ++++++++- tests/bigdecimal/test_bigdecimal_methods.mojo | 5 ++++- 3 files changed, 13 insertions(+), 3 deletions(-) diff --git a/docs/plans/api_roadmap.md b/docs/plans/api_roadmap.md index c28550f..ac4b97c 100644 --- a/docs/plans/api_roadmap.md +++ b/docs/plans/api_roadmap.md @@ -87,7 +87,7 @@ These are the gaps vs Python's `decimal.Decimal`, prioritized by user impact. | Method | What It Does | Notes | | ----------------------------------- | -------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- | | `as_tuple()` | Returns `(sign, digits, exponent)` | ✓ **DONE** — returns `(sign: Bool, digits: List[UInt8], exponent: Int)` matching Python's `DecimalTuple` | -| `adjusted()` | Returns adjusted exponent (= exponent + len(digits) - 1) | ✅ **DONE** — alias for `exponent()`. | +| `adjusted()` | Returns adjusted exponent (= exponent + len(digits) - 1) | ✅ **DONE** — renamed from former `exponent()` method. | | `copy_abs()` | Returns `abs(self)` | ✅ **DONE** — alias for `__abs__()`. | | `copy_negate()` | Returns `-self` | ✅ **DONE** — alias for `__neg__()`. | | `copy_sign(other)` | Returns self with the sign of other | ✅ **DONE**. | diff --git a/src/decimo/bigdecimal/bigdecimal.mojo b/src/decimo/bigdecimal/bigdecimal.mojo index 5d1379f..b766b9d 100644 --- a/src/decimo/bigdecimal/bigdecimal.mojo +++ b/src/decimo/bigdecimal/bigdecimal.mojo @@ -1355,13 +1355,17 @@ struct BigDecimal( # ===------------------------------------------------------------------=== # fn adjusted(self) -> Int: - """Returns the adjusted exponent. + """Returns the adjusted exponent, matching Python's + `decimal.Decimal.adjusted()`. This is the exponent of the number when written with a single leading digit in scientific notation. Equivalently, `as_tuple_exponent + number_of_digits - 1` where `as_tuple_exponent` is `-scale`. + For zero, the adjusted exponent is always 0 regardless of scale, + matching Python's behavior. + Examples: ``` @@ -1369,8 +1373,11 @@ struct BigDecimal( BigDecimal("0.00123").adjusted() # -3 (1.23E-3) BigDecimal("100").adjusted() # 2 (1E+2) BigDecimal("1").adjusted() # 0 (1E0) + BigDecimal("0.00").adjusted() # 0 (zero has no order of magnitude) ``` """ + if self.coefficient.is_zero(): + return 0 return self.coefficient.number_of_digits() - 1 - self.scale fn as_tuple(self) -> Tuple[Bool, List[UInt8], Int]: diff --git a/tests/bigdecimal/test_bigdecimal_methods.mojo b/tests/bigdecimal/test_bigdecimal_methods.mojo index a8e0d6c..f3cc254 100644 --- a/tests/bigdecimal/test_bigdecimal_methods.mojo +++ b/tests/bigdecimal/test_bigdecimal_methods.mojo @@ -420,8 +420,11 @@ fn test_adjusted_basic() raises: fn test_adjusted_zero() raises: - """Zero has adjusted exponent 0.""" + """Zero has adjusted exponent 0 regardless of scale.""" testing.assert_equal(BigDecimal("0").adjusted(), 0) + testing.assert_equal(BigDecimal("0.00").adjusted(), 0) + testing.assert_equal(BigDecimal("0.000000").adjusted(), 0) + testing.assert_equal(BigDecimal("0E+10").adjusted(), 0) fn test_adjusted_scientific() raises: