diff --git a/docs/plans/api_roadmap.md b/docs/plans/api_roadmap.md index 05fce23..44226a0 100644 --- a/docs/plans/api_roadmap.md +++ b/docs/plans/api_roadmap.md @@ -298,11 +298,11 @@ BigInt has `to_string_with_separators()`. This should be extended to 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 -7. **`bit_count()`** on BigInt — popcount (number of 1-bits in abs value) -8. **`__float__()`** on BigInt — `float(n)` interop -9. **`fma(a, b)`** on BigDecimal — `self * a + b` without intermediate rounding -10. **`to_string_with_separators()`** on BigDecimal — alias for `to_string(delimiter=...)` +6. ✓ **`scaleb(n)`** on BigDecimal — adjusts scale by `n` (O(1), no coefficient change) +7. ✓ **`bit_count()`** on BigInt — popcount (Kernighan's algorithm over UInt32 words) +8. ✓ **`__float__()`** on BigInt — `float(n)` via `Float64(String(self))`; `FloatableRaising` trait added +9. ✓ **`fma(a, b)`** on BigDecimal — `self * a + b` (exact, no intermediate rounding) +10. ✓ **`to_string_with_separators()`** on BigDecimal — alias for `to_string(delimiter=...)` ### Tier 3: Nice-to-Have (Remaining) diff --git a/src/cli/calculator/evaluator.mojo b/src/cli/calculator/evaluator.mojo index e017293..bbc7487 100644 --- a/src/cli/calculator/evaluator.mojo +++ b/src/cli/calculator/evaluator.mojo @@ -180,8 +180,12 @@ fn evaluate_rpn(rpn: List[Token], precision: Int) raises -> BDec: Error: On division by zero, missing operands, or other runtime errors — with source position when available. """ - comptime GUARD_DIGITS = 9 # Word size - var working_precision = precision + GUARD_DIGITS # working precision + # BigUInt uses base-1e9 words (~9 decimal digits per word). + # Adding 9 guard digits gives roughly one extra internal word of + # precision beyond the user-requested amount, which absorbs + # accumulated rounding errors from intermediate operations. + comptime GUARD_DIGITS = 9 + var working_precision = precision + GUARD_DIGITS var stack = List[BDec]() for i in range(len(rpn)): diff --git a/src/cli/main.mojo b/src/cli/main.mojo index 7f3fbc6..a4759c9 100644 --- a/src/cli/main.mojo +++ b/src/cli/main.mojo @@ -57,7 +57,7 @@ fn _run() raises: .required() ) - # Named option: decimal precision + # Named option: number of significant digits cmd.add_arg( Arg("precision", help="Number of significant digits (default: 50)") .long("precision") diff --git a/src/decimo/bigdecimal/bigdecimal.mojo b/src/decimo/bigdecimal/bigdecimal.mojo index dc55dab..bf402ca 100644 --- a/src/decimo/bigdecimal/bigdecimal.mojo +++ b/src/decimo/bigdecimal/bigdecimal.mojo @@ -753,6 +753,26 @@ struct BigDecimal( """ return self.to_string(engineering=True) + fn to_string_with_separators(self, separator: String = "_") -> String: + """Returns a string with digit-group separators inserted every 3 digits. + + Groups both the integer and fractional parts. Convenience alias for + `to_string(delimiter=separator)`. + + Args: + separator: The separator character (default: `"_"`). + + Examples: + + ``` + BigDecimal("1234567.89").to_string_with_separators() # "1_234_567.89" + BigDecimal("1234567.89").to_string_with_separators(",") # "1,234,567.89" + BigDecimal("-9876543210.123456").to_string_with_separators() + # "-9_876_543_210.123_456" + ``` + """ + return self.to_string(delimiter=separator) + # ===------------------------------------------------------------------=== # # Basic unary operation dunders # neg @@ -1350,6 +1370,34 @@ struct BigDecimal( """ return decimo.bigdecimal.rounding.quantize(self, exp, rounding_mode) + fn fma(self, a: Self, b: Self) raises -> Self: + """Fused multiply-add: returns `self * a + b` with no intermediate + rounding. + + Matches Python's `decimal.Decimal.fma(other, third)`. + + Because BigDecimal multiplication and addition are both exact + (no precision loss), this method is semantically equivalent to + `self * a + b`. It is provided for IEEE 754 / Python `Decimal` + API compatibility and to express intent clearly in numerical + algorithms. + + Args: + a: The value to multiply by. + b: The value to add after multiplication. + + Returns: + The exact result of `self * a + b`. + + Examples: + + ``` + BigDecimal("2").fma(BigDecimal("3"), BigDecimal("4")) # 10 (2*3+4) + BigDecimal("1.5").fma(BigDecimal("2"), BigDecimal("0.1")) # 3.1 + ``` + """ + return self * a + b + # ===------------------------------------------------------------------=== # # Other methods # ===------------------------------------------------------------------=== # @@ -1480,6 +1528,35 @@ struct BigDecimal( """ return self.scale == other.scale + @always_inline + fn scaleb(self, n: Int) -> Self: + """Multiplies the value by 10^n by adjusting the scale. + + Matches Python's `decimal.Decimal.scaleb(other)`. The name + comes from the IEEE 754 "scaleB" (scale Binary/Base) operation. + + This operation is O(1) — it only changes the exponent without + touching the coefficient digits. + + Args: + n: The power of ten to scale by. Positive values make the + number larger, negative values make it smaller. + + Returns: + A new BigDecimal whose value is `self * 10^n`. + + Examples: + + ``` + BigDecimal("1.23").scaleb(2) # 123 + BigDecimal("1.23").scaleb(-2) # 0.0123 + BigDecimal("100").scaleb(-1) # 10 + ``` + """ + var result = self.copy() + result.scale -= n + return result^ + 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/bigint/bigint.mojo b/src/decimo/bigint/bigint.mojo index fd2ba36..73f0156 100644 --- a/src/decimo/bigint/bigint.mojo +++ b/src/decimo/bigint/bigint.mojo @@ -48,6 +48,7 @@ struct BigInt( Absable, Comparable, Copyable, + FloatableRaising, IntableRaising, Movable, Representable, @@ -466,6 +467,18 @@ struct BigInt( """ return self.to_int() + fn __float__(self) raises -> Float64: + """Converts the BigInt to a floating-point number. + + Matches Python's `float(n)` for `int` objects. + + Note: Large values may lose precision or overflow to `inf`. + + Returns: + The value as a Float64. + """ + return Float64(String(self)) + fn __str__(self) -> String: """Returns a decimal string representation of the BigInt.""" return self.to_string() @@ -1238,6 +1251,32 @@ struct BigInt( return (n_words - 1) * 32 + bits_in_msw + fn bit_count(self) -> Int: + """Returns the number of ones in the binary representation of the + absolute value (population count). + + Matches Python 3.10+ `int.bit_count()`. + + Returns: + The number of set bits in the magnitude, or 0 if the value is zero. + + Examples: + + ``` + BigInt(13).bit_count() # 3 (13 = 0b1101) + BigInt(-7).bit_count() # 3 (7 = 0b111) + BigInt(0).bit_count() # 0 + ``` + """ + var count = 0 + for i in range(len(self.words)): + var w = self.words[i] + # Kernighan's bit-counting trick + while w != 0: + w &= w - 1 + count += 1 + return count + fn number_of_words(self) -> Int: """Returns the number of words in the magnitude.""" return len(self.words) diff --git a/tests/bigdecimal/test_bigdecimal_methods.mojo b/tests/bigdecimal/test_bigdecimal_methods.mojo index a2b0e98..25428a1 100644 --- a/tests/bigdecimal/test_bigdecimal_methods.mojo +++ b/tests/bigdecimal/test_bigdecimal_methods.mojo @@ -1,3 +1,4 @@ +# Consider incorporating some of the tests to other test files in future """ Tests for BigDecimal utility methods added in v0.8.x: - is_positive() @@ -8,6 +9,9 @@ Tests for BigDecimal utility methods added in v0.8.x: - copy_abs() / copy_negate() / copy_sign() - adjusted() - same_quantum() + - scaleb() + - fma() + - to_string_with_separators() """ import testing @@ -467,5 +471,95 @@ fn test_same_quantum_zero_variants() raises: testing.assert_false(BigDecimal("0").same_quantum(BigDecimal("0.0"))) +# ===----------------------------------------------------------------------=== # +# scaleb() +# ===----------------------------------------------------------------------=== # + + +fn test_scaleb_positive() raises: + """Tests scaleb with positive n multiplies value by 10^n.""" + testing.assert_equal(String(BigDecimal("1.23").scaleb(2)), "123") + testing.assert_equal(String(BigDecimal("5").scaleb(3)), "5E+3") + + +fn test_scaleb_negative() raises: + """Tests scaleb with negative n divides value by 10^|n|.""" + testing.assert_equal(String(BigDecimal("1.23").scaleb(-2)), "0.0123") + testing.assert_equal(String(BigDecimal("100").scaleb(-1)), "10.0") + + +fn test_scaleb_zero() raises: + """Tests scaleb(0) returns the same value.""" + testing.assert_equal(String(BigDecimal("42.5").scaleb(0)), "42.5") + + +fn test_scaleb_on_zero() raises: + """Tests scaleb on zero adjusts scale but value stays zero.""" + var result = BigDecimal("0").scaleb(5) + testing.assert_true(result.is_zero()) + + +# ===----------------------------------------------------------------------=== # +# fma() +# ===----------------------------------------------------------------------=== # + + +fn test_fma_basic() raises: + """Tests fma(a, b) = self * a + b.""" + var result = BigDecimal("2").fma(BigDecimal("3"), BigDecimal("4")) + testing.assert_equal(String(result), "10") + + +fn test_fma_decimal() raises: + """Tests fma with fractional values.""" + var result = BigDecimal("1.5").fma(BigDecimal("2"), BigDecimal("0.1")) + testing.assert_equal(String(result), "3.1") + + +fn test_fma_negative() raises: + """Tests fma with negative values.""" + var result = BigDecimal("-3").fma(BigDecimal("4"), BigDecimal("15")) + testing.assert_equal(String(result), "3") + + +fn test_fma_zero_multiplier() raises: + """Tests fma with zero multiplier: 0 * a + b = b.""" + var result = BigDecimal("0").fma(BigDecimal("999"), BigDecimal("42")) + testing.assert_equal(String(result), "42") + + +# ===----------------------------------------------------------------------=== # +# to_string_with_separators() +# ===----------------------------------------------------------------------=== # + + +fn test_to_string_with_separators_default() raises: + """Tests to_string_with_separators with default separator.""" + testing.assert_equal( + BigDecimal("1234567").to_string_with_separators(), "1_234_567" + ) + + +fn test_to_string_with_separators_comma() raises: + """Tests to_string_with_separators with custom comma separator.""" + testing.assert_equal( + BigDecimal("1234567.89").to_string_with_separators(","), + "1,234,567.89", + ) + + +fn test_to_string_with_separators_small() raises: + """Tests to_string_with_separators with numbers having fewer than 4 digits. + """ + testing.assert_equal(BigDecimal("123").to_string_with_separators(), "123") + + +fn test_to_string_with_separators_negative() raises: + """Tests to_string_with_separators with negative numbers.""" + testing.assert_equal( + BigDecimal("-1234567").to_string_with_separators(), "-1_234_567" + ) + + fn main() raises: testing.TestSuite.discover_tests[__functions_in_module()]().run() diff --git a/tests/bigint/test_bigint_bitwise.mojo b/tests/bigint/test_bigint_bitwise.mojo index 9b6e428..1f1640d 100644 --- a/tests/bigint/test_bigint_bitwise.mojo +++ b/tests/bigint/test_bigint_bitwise.mojo @@ -416,5 +416,48 @@ fn test_word_boundary_values() raises: testing.assert_equal(String(~w64), "-18446744073709551616") +# ===----------------------------------------------------------------------=== # +# Test: bit_count() — population count +# ===----------------------------------------------------------------------=== # + + +fn test_bit_count_zero() raises: + """Tests bit_count(0) = 0.""" + testing.assert_equal(BigInt(0).bit_count(), 0) + + +fn test_bit_count_one() raises: + """Tests bit_count(1) = 1.""" + testing.assert_equal(BigInt(1).bit_count(), 1) + + +fn test_bit_count_powers_of_two() raises: + """Powers of 2 have exactly 1 bit set.""" + testing.assert_equal(BigInt(2).bit_count(), 1) + testing.assert_equal(BigInt(4).bit_count(), 1) + testing.assert_equal(BigInt(1024).bit_count(), 1) + + +fn test_bit_count_small() raises: + """Small values: 7 = 0b111 (3 bits), 13 = 0b1101 (3 bits).""" + testing.assert_equal(BigInt(7).bit_count(), 3) + testing.assert_equal(BigInt(13).bit_count(), 3) + testing.assert_equal(BigInt(15).bit_count(), 4) + testing.assert_equal(BigInt(255).bit_count(), 8) + + +fn test_bit_count_negative() raises: + """Tests bit_count on negative values counts bits in the magnitude.""" + testing.assert_equal(BigInt(-7).bit_count(), 3) + testing.assert_equal(BigInt(-1).bit_count(), 1) + + +fn test_bit_count_large() raises: + """2^32 - 1 = 0xFFFFFFFF has 32 bits set.""" + var n = BigInt(1) << 32 + n = n - BigInt(1) # 2^32 - 1 + testing.assert_equal(n.bit_count(), 32) + + fn main() raises: testing.TestSuite.discover_tests[__functions_in_module()]().run() diff --git a/tests/bigint/test_bigint_conversion.mojo b/tests/bigint/test_bigint_conversion.mojo index 0dc878c..b344c69 100644 --- a/tests/bigint/test_bigint_conversion.mojo +++ b/tests/bigint/test_bigint_conversion.mojo @@ -267,11 +267,29 @@ fn test_from_string_non_integer_raises() raises: fn test_from_string_plus_sign() raises: - """Test from_string handles explicit positive sign.""" + """Tests from_string handles explicit positive sign.""" testing.assert_equal(String(BigInt("+42")), "42") testing.assert_equal(String(BigInt("+0")), "0") testing.assert_equal(String(BigInt("+1,000")), "1000") +# ===----------------------------------------------------------------------=== # +# Test: __float__ +# ===----------------------------------------------------------------------=== # + + +fn test_float_small() raises: + """Tests __float__ with small integers.""" + testing.assert_equal(Float64(BigInt(0)), 0.0) + testing.assert_equal(Float64(BigInt(1)), 1.0) + testing.assert_equal(Float64(BigInt(42)), 42.0) + testing.assert_equal(Float64(BigInt(-7)), -7.0) + + +fn test_float_large() raises: + """Tests __float__ with a large-ish integer.""" + testing.assert_equal(Float64(BigInt(1000000)), 1000000.0) + + fn main() raises: testing.TestSuite.discover_tests[__functions_in_module()]().run() diff --git a/tests/cli/test_evaluator.mojo b/tests/cli/test_evaluator.mojo index 649df22..fd78b3c 100644 --- a/tests/cli/test_evaluator.mojo +++ b/tests/cli/test_evaluator.mojo @@ -3,6 +3,7 @@ import testing from calculator import evaluate +from decimo.rounding_mode import RoundingMode # ===----------------------------------------------------------------------=== # @@ -379,6 +380,60 @@ fn test_csc_pi_over_2() raises: ) +# ===----------------------------------------------------------------------=== # +# Tests: rounding modes +# ===----------------------------------------------------------------------=== # + + +fn test_rounding_half_even_tie() raises: + """2.5 rounded to 1 significant digit with half_even → 2 (round to even).""" + var result = String( + evaluate("2.5", precision=1, rounding_mode=RoundingMode.half_even()) + ) + testing.assert_equal(result, "2", "2.5 half_even p=1") + + +fn test_rounding_half_up_tie() raises: + """2.5 rounded to 1 significant digit with half_up → 3 (round away from 0). + """ + var result = String( + evaluate("2.5", precision=1, rounding_mode=RoundingMode.half_up()) + ) + testing.assert_equal(result, "3", "2.5 half_up p=1") + + +fn test_rounding_floor() raises: + """1.9 rounded to 1 significant digit with floor → 1.""" + var result = String( + evaluate("1.9", precision=1, rounding_mode=RoundingMode.floor()) + ) + testing.assert_equal(result, "1", "1.9 floor p=1") + + +fn test_rounding_ceiling() raises: + """1.1 rounded to 1 significant digit with ceiling → 2.""" + var result = String( + evaluate("1.1", precision=1, rounding_mode=RoundingMode.ceiling()) + ) + testing.assert_equal(result, "2", "1.1 ceiling p=1") + + +fn test_rounding_half_even_division() raises: + """1/3 with half_even should produce a correctly rounded trailing digit.""" + var result = String( + evaluate("1/3", precision=4, rounding_mode=RoundingMode.half_even()) + ) + testing.assert_equal(result, "0.3333", "1/3 half_even p=4") + + +fn test_rounding_half_up_division() raises: + """2/3 with half_up should round trailing 6… → 7.""" + var result = String( + evaluate("2/3", precision=4, rounding_mode=RoundingMode.half_up()) + ) + testing.assert_equal(result, "0.6667", "2/3 half_up p=4") + + # ===----------------------------------------------------------------------=== # # Main # ===----------------------------------------------------------------------=== #