diff --git a/.github/workflows/run_tests.yaml b/.github/workflows/run_tests.yaml index 1f16f6b..7c24fe4 100644 --- a/.github/workflows/run_tests.yaml +++ b/.github/workflows/run_tests.yaml @@ -13,7 +13,8 @@ jobs: strategy: fail-fast: false matrix: - os: ["ubuntu-22.04"] + os: ["macos-latest"] + # os: ["ubuntu-22.04"] runs-on: ${{ matrix.os }} timeout-minutes: 30 @@ -28,6 +29,20 @@ jobs: - name: Checkout repo uses: actions/checkout@v4 + # - name: Install GLIBC + # run: | + # sudo apt-get update + # mkdir $HOME/glibc/ && cd $HOME/glibc + # wget http://ftp.gnu.org/gnu/libc/glibc-2.39.tar.gz + # tar -xvf glibc-2.39.tar.gz + # cd glibc-2.39 + # mkdir build + # mkdir glibc-2.39-install + # cd build + # ~/glibc/glibc-2.39/configure --prefix=$HOME/glibc/glibc-2.39-install + # make -j + # make install + - name: Install magic run: | curl -ssL https://magic.modular.com/deb181c4-455c-4abe-a263-afcff49ccf67 | bash @@ -44,9 +59,12 @@ jobs: . $HOME/venv/bin/activate echo PATH=$PATH >> $GITHUB_ENV - - name: Build package + - name: Magic install run: | magic install + + - name: Build package + run: | magic run mojo package src/decimojo cp decimojo.mojopkg tests/ cp decimojo.mojopkg benches/ @@ -55,3 +73,13 @@ jobs: run: | magic run mojo test tests magic run bench + + - name: Install pre-commit + run: | + pip install pre-commit + pre-commit install + + - name: Run pre-commit + run: | + magic install + pre-commit run --all-files \ No newline at end of file diff --git a/.github/workflows/test_pre_commit.yaml b/.github/workflows/test_pre_commit.yaml deleted file mode 100644 index b75e1a3..0000000 --- a/.github/workflows/test_pre_commit.yaml +++ /dev/null @@ -1,51 +0,0 @@ -name: Run pre-commit -on: - # Run pre-commit on pull requests - pull_request: - # Add a workflow_dispatch event to run pre-commit manually - workflow_dispatch: - -permissions: - contents: read - pull-requests: read - -jobs: - lint: - runs-on: "ubuntu-22.04" - timeout-minutes: 30 - - defaults: - run: - shell: bash - env: - DEBIAN_FRONTEND: noninteractive - - steps: - - name: Checkout repo - uses: actions/checkout@v4 - - - name: Install magic - run: | - curl -ssL https://magic.modular.com/deb181c4-455c-4abe-a263-afcff49ccf67 | bash - - - name: Add path - run: | - echo "MODULAR_HOME=$HOME/.modular" >> $GITHUB_ENV - echo "$HOME/.modular/bin" >> $GITHUB_PATH - echo "$HOME/.modular/pkg/packages.modular.com_mojo/bin" >> $GITHUB_PATH - - - name: Activate virtualenv - run: | - python3 -m venv $HOME/venv/ - . $HOME/venv/bin/activate - echo PATH=$PATH >> $GITHUB_ENV - - - name: Install pre-commit - run: | - pip install pre-commit - pre-commit install - - - name: Run pre-commit - run: | - magic install - pre-commit run --all-files \ No newline at end of file diff --git a/README.md b/README.md index 3014813..eabfa94 100644 --- a/README.md +++ b/README.md @@ -6,76 +6,21 @@ A fixed-point decimal arithmetic library implemented in [the Mojo programming la DeciMojo provides a Decimal type implementation for Mojo with fixed-precision arithmetic, designed to handle financial calculations and other scenarios where floating-point rounding errors are problematic. -Repo: [https://github.com/forFudan/DeciMojo](https://github.com/forFudan/DeciMojo) +## Installation -## Objective - -Financial calculations and data analysis require precise decimal arithmetic that floating-point numbers cannot reliably provide. As someone working in finance and credit risk model validation, I needed a dependable correctly-rounded, fixed-precision numeric type when migrating my personal projects from Python to Mojo. - -Since Mojo currently lacks a native Decimal type in its standard library, I decided to create my own implementation to fill that gap. - -This project draws inspiration from several established decimal implementations and documentation, e.g., [Python built-in `Decimal` type](https://docs.python.org/3/library/decimal.html), [Rust `rust_decimal` crate](https://docs.rs/rust_decimal/latest/rust_decimal/index.html), [Microsoft's `Decimal` implementation](https://learn.microsoft.com/en-us/dotnet/api/system.decimal.getbits?view=net-9.0&redirectedfrom=MSDN#System_Decimal_GetBits_System_Decimal_), [General Decimal Arithmetic Specification](https://speleotrove.com/decimal/decarith.html), etc. Many thanks to these predecessors for their contributions and their commitment to open knowledge sharing. - -## Nomenclature +DeciMojo can be directly added to your project environment by typing `magic add decimojo` in the Modular CLI. This command fetches the latest version of DeciMojo and makes it available for import in your Mojo project. -DeciMojo combines "Decimal" and "Mojo" - reflecting both its purpose (decimal arithmetic) and the programming language it's implemented in. The name emphasizes the project's focus on bringing precise decimal calculations to the Mojo ecosystem. - -For brevity, you can refer to it as "deci" (derived from the Latin root "decimus" meaning "tenth"). - -When you add `from decimojo import dm, Decimal` at the top of your script, this imports the `decimojo` module into your namespace with the shorter alias `dm` and directly imports the `Decimal` type. This is equivalent to: +To use DeciMojo, import the necessary components from the `decimojo.prelude` module. This module provides convenient access to the most commonly used classes and functions, including `dm` (an alias for the `decimojo` module itself), `Decimal` and `RoundingMode`. ```mojo from decimojo.prelude import dm, Decimal, RoundingMode -``` - -## Advantages -DeciMojo provides exceptional computational precision without sacrificing performance. It maintains accuracy throughout complex calculations where floating-point or other decimal implementations might introduce subtle errors. - -Consider the square root of `15.9999`. When comparing DeciMojo's implementation with Python's decimal module (both rounded to 16 decimal places): - -- DeciMojo calculates: `3.9999874999804687` -- Python's decimal returns: `3.9999874999804685` - -The mathematically correct value (to 50+ digits) is: -`3.9999874999804686889646053303778122644631365491812...` - -When rounded to 16 decimal places, the correct result is `3.9999874999804687`, confirming that DeciMojo produces the more accurate result in this case. - -```log -Function: sqrt() -Decimal value: 15.9999 -DeciMojo result: 3.9999874999804686889646053305 -Python's decimal result: 3.9999874999804685 +fn main() raises: + print(dm.sqrt(Decimal("3.1415926"))) + # Output: 1.7724538357881143980200972894 ``` -This precision advantage becomes increasingly important in financial, scientific, and engineering calculations where small rounding errors can compound into significant discrepancies. - -## Status - -Rome wasn't built in a day. DeciMojo is currently under active development, positioned between the **"make it work"** and **"make it right"** phases, with a stronger emphasis on the latter. Bug reports and feature requests are welcome! If you encounter issues, please [file them here](https://github.com/forFudan/decimojo/issues). - -### Make it Work āœ… (MOSTLY COMPLETED) - -- Core decimal implementation exists and functions -- Basic arithmetic operations (+, -, *, /) are implemented -- Type conversions to/from various formats work -- String representation and parsing are functional -- Construction from different sources (strings, numbers) is supported - -### Make it Right šŸ”„ (IN PROGRESS) - -- Edge case handling is being addressed (division by zero, zero to negative power) -- Scale and precision management shows sophistication -- Financial calculations demonstrate proper rounding -- High precision support is implemented (up to 28 decimal places) -- The examples show robust handling of various scenarios - -### Make it Fast ā³ (IN PROGRESS & FUTURE WORK) - -- Core arithmetic operations (+, -, *, /) have been optimized for performance, with comprehensive benchmarking reports available comparing performance against Python's built-in decimal module ([PR#16](https://github.com/forFudan/DeciMojo/pull/16), [PR#20](https://github.com/forFudan/DeciMojo/pull/20), [PR#21](https://github.com/forFudan/DeciMojo/pull/21)). -- Regular benchmarking against Python's `decimal` module (see `bench/` folder) -- Performance optimization on other functions are acknowledged but not currently prioritized +The Github repo of the project is at [https://github.com/forFudan/DeciMojo](https://github.com/forFudan/DeciMojo). ## Examples @@ -248,6 +193,70 @@ var payment = principal * (numerator / denominator) print("Monthly payment: $" + String(round(payment, 2))) # $1,073.64 ``` + +## Advantages + +DeciMojo provides exceptional computational precision without sacrificing performance. It maintains accuracy throughout complex calculations where floating-point or other decimal implementations might introduce subtle errors. + +Consider the square root of `15.9999`. When comparing DeciMojo's implementation with Python's decimal module (both rounded to 16 decimal places): + +- DeciMojo calculates: `3.9999874999804687` +- Python's decimal returns: `3.9999874999804685` + +The mathematically correct value (to 50+ digits) is: +`3.9999874999804686889646053303778122644631365491812...` + +When rounded to 16 decimal places, the correct result is `3.9999874999804687`, confirming that DeciMojo produces the more accurate result in this case. + +```log +Function: sqrt() +Decimal value: 15.9999 +DeciMojo result: 3.9999874999804686889646053305 +Python's decimal result: 3.9999874999804685 +``` + +This precision advantage becomes increasingly important in financial, scientific, and engineering calculations where small rounding errors can compound into significant discrepancies. + +## Objective + +Financial calculations and data analysis require precise decimal arithmetic that floating-point numbers cannot reliably provide. As someone working in finance and credit risk model validation, I needed a dependable correctly-rounded, fixed-precision numeric type when migrating my personal projects from Python to Mojo. + +Since Mojo currently lacks a native Decimal type in its standard library, I decided to create my own implementation to fill that gap. + +This project draws inspiration from several established decimal implementations and documentation, e.g., [Python built-in `Decimal` type](https://docs.python.org/3/library/decimal.html), [Rust `rust_decimal` crate](https://docs.rs/rust_decimal/latest/rust_decimal/index.html), [Microsoft's `Decimal` implementation](https://learn.microsoft.com/en-us/dotnet/api/system.decimal.getbits?view=net-9.0&redirectedfrom=MSDN#System_Decimal_GetBits_System_Decimal_), [General Decimal Arithmetic Specification](https://speleotrove.com/decimal/decarith.html), etc. Many thanks to these predecessors for their contributions and their commitment to open knowledge sharing. + +## Nonmenclature + +DeciMojo combines "Decimal" and "Mojo" - reflecting both its purpose (decimal arithmetic) and the programming language it's implemented in. The name emphasizes the project's focus on bringing precise decimal calculations to the Mojo ecosystem. + +For brevity, you can refer to it as "deci" (derived from the Latin root "decimus" meaning "tenth"). + +## Status + +Rome wasn't built in a day. DeciMojo is currently under active development, positioned between the **"make it work"** and **"make it right"** phases, with a stronger emphasis on the latter. Bug reports and feature requests are welcome! If you encounter issues, please [file them here](https://github.com/forFudan/decimojo/issues). + +### Make it Work āœ… (MOSTLY COMPLETED) + +- Core decimal implementation exists and functions +- Basic arithmetic operations (+, -, *, /) are implemented +- Type conversions to/from various formats work +- String representation and parsing are functional +- Construction from different sources (strings, numbers) is supported + +### Make it Right šŸ”„ (IN PROGRESS) + +- Edge case handling is being addressed (division by zero, zero to negative power) +- Scale and precision management shows sophistication +- Financial calculations demonstrate proper rounding +- High precision support is implemented (up to 28 decimal places) +- The examples show robust handling of various scenarios + +### Make it Fast ā³ (IN PROGRESS & FUTURE WORK) + +- Core arithmetic operations (+, -, *, /) have been optimized for performance, with comprehensive benchmarking reports available comparing performance against Python's built-in decimal module ([PR#16](https://github.com/forFudan/DeciMojo/pull/16), [PR#20](https://github.com/forFudan/DeciMojo/pull/20), [PR#21](https://github.com/forFudan/DeciMojo/pull/21)). +- Regular benchmarking against Python's `decimal` module (see `bench/` folder) +- Performance optimization on other functions are acknowledged but not currently prioritized + ## Tests and benches After cloning the repo onto your local disk, you can: diff --git a/benches/bench.mojo b/benches/bench.mojo index 07954ba..e231372 100644 --- a/benches/bench.mojo +++ b/benches/bench.mojo @@ -3,6 +3,7 @@ from bench_subtract import main as bench_subtract from bench_multiply import main as bench_multiply from bench_divide import main as bench_divide from bench_sqrt import main as bench_sqrt +from bench_from_float import main as bench_from_float fn main() raises: @@ -11,3 +12,4 @@ fn main() raises: bench_multiply() bench_divide() bench_sqrt() + bench_from_float() diff --git a/benches/bench_from_float.mojo b/benches/bench_from_float.mojo new file mode 100644 index 0000000..2eaecda --- /dev/null +++ b/benches/bench_from_float.mojo @@ -0,0 +1,276 @@ +""" +Comprehensive benchmarks for Decimal(Float64) constructor operations. +Compares performance against Python's decimal module with 10 diverse test cases. +""" + +from decimojo.prelude import dm, Decimal, RoundingMode +from python import Python, PythonObject +from time import perf_counter_ns +import time +import os +from collections import List + + +fn open_log_file() raises -> PythonObject: + """ + Creates and opens a log file with a timestamp in the filename. + + Returns: + A file object opened for writing. + """ + var python = Python.import_module("builtins") + var datetime = Python.import_module("datetime") + + # Create logs directory if it doesn't exist + var log_dir = "./logs" + if not os.path.exists(log_dir): + os.makedirs(log_dir) + + # Generate a timestamp for the filename + var timestamp = String(datetime.datetime.now().isoformat()) + var log_filename = log_dir + "/benchmark_from_float_" + timestamp + ".log" + + print("Saving benchmark results to:", log_filename) + return python.open(log_filename, "w") + + +fn log_print(msg: String, log_file: PythonObject) raises: + """ + Prints a message to both the console and the log file. + + Args: + msg: The message to print. + log_file: The file object to write to. + """ + print(msg) + log_file.write(msg + "\n") + log_file.flush() # Ensure the message is written immediately + + +fn run_benchmark( + name: String, + value: Float64, + iterations: Int, + log_file: PythonObject, + mut speedup_factors: List[Float64], +) raises: + """ + Run a benchmark comparing Mojo Decimal(Float64) with Python Decimal(float). + + Args: + name: Name of the benchmark case. + value: Float64 value to convert. + iterations: Number of iterations to run. + log_file: File object for logging results. + speedup_factors: Mojo List to store speedup factors for averaging. + """ + log_print("\nBenchmark: " + name, log_file) + log_print("Float64 value: " + String(value), log_file) + + # Get Python decimal module + var pydecimal = Python.import_module("decimal") + + # Execute the operations once to verify correctness + var mojo_result = Decimal.from_float(value) + var py_value = Python.evaluate("float(" + String(value) + ")") + var py_result = pydecimal.Decimal(py_value) + + # Display results for verification + log_print("Mojo result: " + String(mojo_result), log_file) + log_print("Python result: " + String(py_result), log_file) + + # Benchmark Mojo implementation + var t0 = perf_counter_ns() + for _ in range(iterations): + _ = Decimal.from_float(value) + var mojo_time = (perf_counter_ns() - t0) / iterations + if mojo_time == 0: + mojo_time = 1 # Prevent division by zero + + # Benchmark Python implementation + t0 = perf_counter_ns() + for _ in range(iterations): + _ = pydecimal.Decimal(py_value) + var python_time = (perf_counter_ns() - t0) / iterations + + # Calculate speedup factor + var speedup = python_time / mojo_time + speedup_factors.append(Float64(speedup)) + + # Print results with speedup comparison + log_print( + "Mojo Decimal: " + String(mojo_time) + " ns per iteration", + log_file, + ) + log_print( + "Python Decimal: " + String(python_time) + " ns per iteration", + log_file, + ) + log_print("Speedup factor: " + String(speedup), log_file) + + +fn main() raises: + # Open log file + var log_file = open_log_file() + var datetime = Python.import_module("datetime") + + # Create a Mojo List to store speedup factors for averaging later + var speedup_factors = List[Float64]() + + # Display benchmark header with system information + log_print("=== DeciMojo Float64 Constructor Benchmark ===", log_file) + log_print("Time: " + String(datetime.datetime.now().isoformat()), log_file) + + # Try to get system info + try: + var platform = Python.import_module("platform") + log_print( + "System: " + + String(platform.system()) + + " " + + String(platform.release()), + log_file, + ) + log_print("Processor: " + String(platform.processor()), log_file) + log_print( + "Python version: " + String(platform.python_version()), log_file + ) + except: + log_print("Could not retrieve system information", log_file) + + var iterations = 10000 + var pydecimal = Python().import_module("decimal") + + # Set Python decimal precision to match Mojo's + pydecimal.getcontext().prec = 28 + log_print( + "Python decimal precision: " + String(pydecimal.getcontext().prec), + log_file, + ) + log_print("Mojo decimal precision: " + String(Decimal.MAX_SCALE), log_file) + + # Define benchmark cases + log_print( + "\nRunning Float64 constructor benchmarks with " + + String(iterations) + + " iterations each", + log_file, + ) + + # Case 1: Simple integer + run_benchmark( + "Simple integer", + 123.0, + iterations, + log_file, + speedup_factors, + ) + + # Case 2: Simple decimal with few places + run_benchmark( + "Simple decimal with few places", + 123.45, + iterations, + log_file, + speedup_factors, + ) + + # Case 3: Negative number + run_benchmark( + "Negative number", + -123.45, + iterations, + log_file, + speedup_factors, + ) + + # Case 4: Very small decimal (close to zero) + run_benchmark( + "Very small decimal", + 1e-15, + iterations, + log_file, + speedup_factors, + ) + + # Case 5: Very large decimal (close to Float64 limits) + run_benchmark( + "Very large decimal", + 1e20, + iterations, + log_file, + speedup_factors, + ) + + # Case 6: Value with many decimal places + run_benchmark( + "Value with many decimal places", + 3.141592653589793, + iterations, + log_file, + speedup_factors, + ) + + # Case 7: Value requiring binary to decimal conversion + run_benchmark( + "Value requiring binary to decimal conversion", + 0.1 + 0.2, # 0.30000000000000004 in binary + iterations, + log_file, + speedup_factors, + ) + + # Case 8: Value that has repeating pattern in decimal + run_benchmark( + "Value with repeating pattern in decimal", + 1.0 / 3.0, # 0.3333... + iterations, + log_file, + speedup_factors, + ) + + # Case 9: Value with exact decimal representation + run_benchmark( + "Value with exact decimal representation", + 0.5, + iterations, + log_file, + speedup_factors, + ) + + # Case 10: Value at boundary of precision + run_benchmark( + "Value at boundary of precision", + 9007199254740991.0, # Maximum exact integer in Float64 + iterations, + log_file, + speedup_factors, + ) + + # Calculate average speedup factor + var sum_speedup: Float64 = 0.0 + for i in range(len(speedup_factors)): + sum_speedup += speedup_factors[i] + var average_speedup = sum_speedup / Float64(len(speedup_factors)) + + # Display summary + log_print("\n=== Float64 Constructor Benchmark Summary ===", log_file) + log_print( + "Benchmarked: 10 different Float64 constructor cases", log_file + ) + log_print( + "Each case ran: " + String(iterations) + " iterations", log_file + ) + log_print("Average speedup: " + String(average_speedup) + "Ɨ", log_file) + + # List all speedup factors + log_print("\nIndividual speedup factors:", log_file) + for i in range(len(speedup_factors)): + log_print( + String("Case {}: {}Ɨ").format(i + 1, round(speedup_factors[i], 2)), + log_file, + ) + + # Close the log file + log_file.close() + print("Benchmark completed. Log file closed.") diff --git a/mojoproject.toml b/mojoproject.toml index 6f8e246..4446733 100644 --- a/mojoproject.toml +++ b/mojoproject.toml @@ -8,6 +8,10 @@ platforms = ["osx-arm64", "linux-64"] readme = "README.md" version = "0.1.0" +[system-requirements] +linux = "4.4" +libc = { family = "glibc", version = "2.39" } + [tasks] # format the code format = "magic run mojo format ./" diff --git a/src/decimojo/decimal.mojo b/src/decimojo/decimal.mojo index 93e4de2..9626781 100644 --- a/src/decimojo/decimal.mojo +++ b/src/decimojo/decimal.mojo @@ -9,7 +9,10 @@ # # ===----------------------------------------------------------------------=== # # -# Organization of methods: +# Organization of files and methods of Decimal: +# - Internal representation fields +# - Constants (aliases) +# - Special values (methods) # - Constructors and life time methods # - Constructing methods that are not dunders # - Output dunders, type-transfer dunders, and other type-transfer methods @@ -36,6 +39,8 @@ Implements basic object methods for working with decimal numbers. """ +from memory import UnsafePointer + import decimojo.logic import decimojo.maths from decimojo.rounding_mode import RoundingMode @@ -120,7 +125,7 @@ struct Decimal( Returns a Decimal representing positive infinity. Internal representation: `0b0000_0000_0000_0000_0000_0000_0001`. """ - return Decimal.from_raw_words(0, 0, 0, 0x00000001) + return Decimal.from_words(0, 0, 0, 0x00000001) @staticmethod fn NEGATIVE_INFINITY() -> Decimal: @@ -128,7 +133,7 @@ struct Decimal( Returns a Decimal representing negative infinity. Internal representation: `0b1000_0000_0000_0000_0000_0000_0001`. """ - return Decimal.from_raw_words(0, 0, 0, 0x80000001) + return Decimal.from_words(0, 0, 0, 0x80000001) @staticmethod fn NAN() -> Decimal: @@ -136,7 +141,7 @@ struct Decimal( Returns a Decimal representing Not a Number (NaN). Internal representation: `0b0000_0000_0000_0000_0000_0000_0010`. """ - return Decimal.from_raw_words(0, 0, 0, 0x00000010) + return Decimal.from_words(0, 0, 0, 0x00000010) @staticmethod fn NEGATIVE_NAN() -> Decimal: @@ -144,28 +149,28 @@ struct Decimal( Returns a Decimal representing negative Not a Number. Internal representation: `0b1000_0000_0000_0000_0000_0000_0010`. """ - return Decimal.from_raw_words(0, 0, 0, 0x80000010) + return Decimal.from_words(0, 0, 0, 0x80000010) @staticmethod fn ZERO() -> Decimal: """ Returns a Decimal representing 0. """ - return Decimal.from_raw_words(0, 0, 0, 0) + return Decimal.from_words(0, 0, 0, 0) @staticmethod fn ONE() -> Decimal: """ Returns a Decimal representing 1. """ - return Decimal.from_raw_words(1, 0, 0, 0) + return Decimal.from_words(1, 0, 0, 0) @staticmethod fn NEGATIVE_ONE() -> Decimal: """ Returns a Decimal representing -1. """ - return Decimal.from_raw_words(1, 0, 0, Decimal.SIGN_MASK) + return Decimal.from_words(1, 0, 0, Decimal.SIGN_MASK) @staticmethod fn MAX() -> Decimal: @@ -173,14 +178,14 @@ struct Decimal( Returns the maximum possible Decimal value. This is equivalent to 79228162514264337593543950335. """ - return Decimal.from_raw_words(0xFFFFFFFF, 0xFFFFFFFF, 0xFFFFFFFF, 0) + return Decimal.from_words(0xFFFFFFFF, 0xFFFFFFFF, 0xFFFFFFFF, 0) @staticmethod fn MIN() -> Decimal: """Returns the minimum possible Decimal value (negative of MAX). This is equivalent to -79228162514264337593543950335. """ - return Decimal.from_raw_words( + return Decimal.from_words( 0xFFFFFFFF, 0xFFFFFFFF, 0xFFFFFFFF, Decimal.SIGN_MASK ) @@ -656,26 +661,16 @@ struct Decimal( if is_negative: self.flags |= Self.SIGN_MASK - # TODO: Use generic floating-point type if possible. - fn __init__(out self, f: Float64, *, MAX_SCALE: Bool = True) raises: + fn __init__(out self, value: Float64) raises: """ Initializes a Decimal from a floating-point value. - You may lose precision because float representation is inexact. + See `from_float` for more information. """ - var float_str: String - - if MAX_SCALE: - # Use maximum precision - # Convert float to string ith high precision to capture all significant digits - # The format ensures we get up to MAX_SCALE decimal places - float_str = decimojo.str._float_to_decimal_str(f, Self.MAX_SCALE) - else: - # Use default string representation - # Convert float to string with Mojo's default precision - float_str = String(f) - # Use the string constructor which already handles overflow correctly - self = Decimal(float_str) + try: + self = Decimal.from_float(value) + except e: + raise Error("Error in `Decimal__init__()` with Float64: ", e) fn __copyinit__(out self, other: Self): """ @@ -691,7 +686,7 @@ struct Decimal( # ===------------------------------------------------------------------=== # @staticmethod - fn from_raw_words( + fn from_words( low: UInt32, mid: UInt32, high: UInt32, flags: UInt32 ) -> Self: """ @@ -707,6 +702,125 @@ struct Decimal( return result + @staticmethod + fn from_float(value: Float64) raises -> Decimal: + """ + Initializes a Decimal from a floating-point value. + The reliability of this method is limited by the precision of Float64. + Float64 is reliable up to 15 significant digits and marginally + reliable up to 16 siginficant digits. Be careful when using this method. + + Args: + value: The floating-point value to convert to Decimal. + + Returns: + The Decimal representation of the floating-point value. + + Raises: + Error: If the input is too large to be transformed into Decimal. + Error: If the input is infinity or NaN. + + Example: + ```mojo + from decimojo import Decimal + print(Decimal.from_float(Float64(3.1415926535897932383279502))) + # 3.1415926535897932 (17 significant digits) + print(Decimal.from_float(12345678901234567890.12345678901234567890)) + # 12345678901234567168 (20 significant digits, but only 15 are reliable) + ``` + . + """ + + # CASE: Zero + if value == Float64(0): + return Decimal.ZERO() + + # Get the positive value of the input + var abs_value: Float64 = value + var is_negative: Bool = value < 0 + if is_negative: + abs_value = -value + else: + abs_value = value + + # Early exit if the value is too large + if UInt128(abs_value) > Decimal.MAX_AS_UINT128: + raise Error( + String( + "Error in `from_float`: The float value {} is too" + " large (>=2^96) to be transformed into Decimal" + ).format(value) + ) + + # Extract binary exponent using IEEE 754 bit manipulation + var bits: UInt64 = UnsafePointer[Float64].address_of(abs_value).bitcast[ + UInt64 + ]().load() + var biased_exponent: Int = Int((bits >> 52) & 0x7FF) + + # print("DEBUG: biased_exponent = ", biased_exponent) + + # CASE: Denormalized number that is very close to zero + if biased_exponent == 0: + return Decimal(0, 0, 0, Decimal.MAX_SCALE, is_negative) + + # CASE: Infinity or NaN + if biased_exponent == 0x7FF: + raise Error("Cannot convert infinity or NaN to Decimal") + + # Get unbias exponent + var binary_exp: Int = biased_exponent - 1023 + # print("DEBUG: binary_exp = ", binary_exp) + + # Convert binary exponent to approximate decimal exponent + # log10(2^exp) = exp * log10(2) + var decimal_exp: Int = Int(Float64(binary_exp) * 0.301029995663981) + # print("DEBUG: decimal_exp = ", decimal_exp) + + # Fine-tune decimal exponent + var power_check: Float64 = abs_value / Float64(10) ** decimal_exp + if power_check >= 10.0: + decimal_exp += 1 + elif power_check < 1.0: + decimal_exp -= 1 + + # print("DEBUG: decimal_exp = ", decimal_exp) + + var coefficient: UInt128 = UInt128(abs_value) + var remainder = abs(abs_value - Float64(coefficient)) + # print("DEBUG: integer_part = ", coefficient) + # print("DEBUG: remainder = ", remainder) + + var scale = 0 + var temp_coef: UInt128 + var num_trailing_zeros: Int = 0 + while scale < Decimal.MAX_SCALE: + remainder *= 10 + var int_part = UInt128(remainder) + remainder = abs(remainder - Float64(int_part)) + temp_coef = coefficient * 10 + int_part + if temp_coef > Decimal.MAX_AS_UINT128: + break + coefficient = temp_coef + scale += 1 + if int_part == 0: + num_trailing_zeros += 1 + else: + num_trailing_zeros = 0 + # print("DEBUG: coefficient = ", coefficient) + # print("DEBUG: scale = ", scale) + # print("DEBUG: remainder = ", remainder) + + coefficient = coefficient // UInt128(10) ** num_trailing_zeros + scale -= num_trailing_zeros + + var low = UInt32(coefficient & 0xFFFFFFFF) + var mid = UInt32((coefficient >> 32) & 0xFFFFFFFF) + var high = UInt32((coefficient >> 64) & 0xFFFFFFFF) + + # Return both the significant digits and the scale + return Decimal(low, mid, high, scale, is_negative) + # ===------------------------------------------------------------------=== # # Output dunders, type-transfer dunders, and other type-transfer methods # ===------------------------------------------------------------------=== # @@ -842,7 +956,7 @@ struct Decimal( Returns: The absolute value of this Decimal. """ - var result = Decimal.from_raw_words( + var result = Decimal.from_words( self.low, self.mid, self.high, self.flags ) result.flags &= ~Self.SIGN_MASK # Clear sign bit @@ -855,7 +969,7 @@ struct Decimal( if self.is_zero(): return Decimal.ZERO() - var result = Decimal.from_raw_words( + var result = Decimal.from_words( self.low, self.mid, self.high, self.flags ) result.flags ^= Self.SIGN_MASK # Flip sign bit diff --git a/tests/test_creation.mojo b/tests/test_creation.mojo index 4aa2341..97fad3c 100644 --- a/tests/test_creation.mojo +++ b/tests/test_creation.mojo @@ -1,6 +1,9 @@ """ Test Decimal creation from integer, float, or string values. """ + +# TODO: Split into separate test files for each type of constructor + from decimojo.prelude import dm, Decimal, RoundingMode import testing @@ -408,6 +411,5 @@ fn test_decimal_from_components() raises: fn main() raises: test_decimal_from_int() - test_decimal_from_float() test_decimal_from_string() test_decimal_from_components() # Add the new test to the main function diff --git a/tests/test_division.mojo b/tests/test_divide.mojo similarity index 100% rename from tests/test_division.mojo rename to tests/test_divide.mojo diff --git a/tests/test_from_float.mojo b/tests/test_from_float.mojo new file mode 100644 index 0000000..9bec2f7 --- /dev/null +++ b/tests/test_from_float.mojo @@ -0,0 +1,480 @@ +""" +Comprehensive tests for the Decimal.from_float() constructor method. +Tests 50 different cases to ensure proper conversion from Float64 values. +Note: Comparisons are based on the expected precision of the input value rather +than expecting exact decimal representation for all digits. +""" + +import testing +from math import nan, inf +from python import Python + +from decimojo.prelude import dm, Decimal, RoundingMode + + +fn test_simple_integers() raises: + """Test conversion of simple integer float values.""" + print("Testing simple integer float conversions...") + + # Test case 1: Zero + var zero = Decimal.from_float(0.0) + testing.assert_equal( + String(zero), "0", "Float 0.0 should convert to Decimal 0" + ) + + # Test case 2: One + var one = Decimal.from_float(1.0) + testing.assert_equal( + String(one), "1", "Float 1.0 should convert to Decimal 1" + ) + + # Test case 3: Ten + var ten = Decimal.from_float(10.0) + testing.assert_equal( + String(ten), "10", "Float 10.0 should convert to Decimal 10" + ) + + # Test case 4: Hundred + var hundred = Decimal.from_float(100.0) + testing.assert_equal( + String(hundred), "100", "Float 100.0 should convert to Decimal 100" + ) + + # Test case 5: Thousand + var thousand = Decimal.from_float(1000.0) + testing.assert_equal( + String(thousand), "1000", "Float 1000.0 should convert to Decimal 1000" + ) + + print("āœ“ Simple integer tests passed") + + +fn test_simple_decimals() raises: + """Test conversion of simple decimal float values.""" + print("Testing simple decimal float conversions...") + + # Test case 6: 0.5 (exact representation) + var half = Decimal.from_float(0.5) + testing.assert_equal( + String(half), "0.5", "Float 0.5 should convert to Decimal 0.5" + ) + + # Test case 7: 0.25 (exact representation) + var quarter = Decimal.from_float(0.25) + testing.assert_equal( + String(quarter), "0.25", "Float 0.25 should convert to Decimal 0.25" + ) + + # Test case 8: 1.5 (exact representation) + var one_half = Decimal.from_float(1.5) + testing.assert_equal( + String(one_half), "1.5", "Float 1.5 should convert to Decimal 1.5" + ) + + # Test case 9: 3.14 (check first 3 chars) + var pi_approx = Decimal.from_float(3.14) + testing.assert_true( + String(pi_approx).startswith("3.14"), + "Float 3.14 should convert to a Decimal starting with 3.14", + ) + + # Test case 10: 2.71828 (check first 6 chars) + var e_approx = Decimal.from_float(2.71828) + testing.assert_true( + String(e_approx).startswith("2.7182"), + "Float 2.71828 should convert to a Decimal starting with 2.7182", + ) + + print("āœ“ Simple decimal tests passed") + + +fn test_negative_numbers() raises: + """Test conversion of negative float values.""" + print("Testing negative float conversions...") + + # Test case 11: -1.0 + var neg_one = Decimal.from_float(-1.0) + testing.assert_equal( + String(neg_one), "-1", "Float -1.0 should convert to Decimal -1" + ) + + # Test case 12: -0.5 + var neg_half = Decimal.from_float(-0.5) + testing.assert_equal( + String(neg_half), "-0.5", "Float -0.5 should convert to Decimal -0.5" + ) + + # Test case 13: -123.456 (check first 7 chars) + var neg_decimal = Decimal.from_float(-123.456) + testing.assert_true( + String(neg_decimal).startswith("-123.45"), + "Float -123.456 should convert to a Decimal starting with -123.45", + ) + + # Test case 14: -0.0 (negative zero) + var neg_zero = Decimal.from_float(-0.0) + testing.assert_equal( + String(neg_zero), "0", "Float -0.0 should convert to Decimal 0" + ) + + # Test case 15: -999.999 (check first 7 chars) + var neg_nines = Decimal.from_float(-999.999) + testing.assert_true( + String(neg_nines).startswith("-999.99"), + "Float -999.999 should convert to a Decimal starting with -999.99", + ) + + print("āœ“ Negative number tests passed") + + +fn test_very_large_numbers() raises: + """Test conversion of very large float values.""" + print("Testing very large float conversions...") + + # Test case 16: 1e10 + var ten_billion = Decimal.from_float(1e10) + testing.assert_equal( + String(ten_billion), + "10000000000", + "Float 1e10 should convert to Decimal 10000000000", + ) + + # Test case 17: 1e15 + var quadrillion = Decimal.from_float(1e15) + testing.assert_equal( + String(quadrillion), + "1000000000000000", + "Float 1e15 should convert to Decimal 1000000000000000", + ) + + # Test case 18: Max safe integer in JavaScript (2^53 - 1) + var max_safe_int = Decimal.from_float(9007199254740991.0) + testing.assert_equal( + String(max_safe_int), + "9007199254740991", + "Float 2^53-1 should convert to exact Decimal 9007199254740991", + ) + + # Test case 19: 1e20 + var hundred_quintillion = Decimal.from_float(1e20) + testing.assert_equal( + String(hundred_quintillion), + "100000000000000000000", + "Float 1e20 should convert to Decimal 100000000000000000000", + ) + + # Test case 20: Large number with limited precision + var large_number = Decimal.from_float(1.23456789e15) + testing.assert_true( + String(large_number).startswith("1234567890000000"), + "Large float should convert with appropriate precision", + ) + + print("āœ“ Very large number tests passed") + + +fn test_very_small_numbers() raises: + """Test conversion of very small float values.""" + print("Testing very small float conversions...") + + # Test case 21: 1e-10 + var tiny = Decimal.from_float(1e-10) + testing.assert_true( + String(tiny).startswith("0.00000000"), + "Float 1e-10 should convert to a Decimal with appropriate zeros", + ) + + # Test case 22: 1e-15 + var tinier = Decimal.from_float(1e-15) + testing.assert_true( + String(tinier).startswith("0.000000000000001"), + "Float 1e-15 should convert to a Decimal with appropriate zeros", + ) + + # Test case 23: Small number with precision + var small_with_precision = Decimal.from_float(1.234e-10) + var expected_prefix = "0.0000000001" + testing.assert_true( + String(small_with_precision).startswith(expected_prefix), + "Small float should preserve available precision", + ) + + # Test case 24: Very small but non-zero + var very_small = Decimal.from_float(1e-20) + testing.assert_true( + String(very_small).startswith("0.00000000000000000001"), + "Very small float should convert to appropriate Decimal", + ) + + # Test case 25: Denormalized float + var denorm = Decimal.from_float(1e-310) + testing.assert_true( + String(denorm).startswith("0."), + "Denormalized float should convert to small Decimal", + ) + + print("āœ“ Very small number tests passed") + + +fn test_binary_to_decimal_conversion() raises: + """Test conversion of float values that require binary to decimal conversion. + """ + print("Testing binary to decimal conversion edge cases...") + + # Test case 26: 0.1 (known inexact in binary) + var point_one = Decimal.from_float(0.1) + testing.assert_true( + String(point_one).startswith("0.1"), + "Float 0.1 should convert to a Decimal starting with 0.1", + ) + + # Test case 27: 0.2 (known inexact in binary) + var point_two = Decimal.from_float(0.2) + testing.assert_true( + String(point_two).startswith("0.2"), + "Float 0.2 should convert to a Decimal starting with 0.2", + ) + + # Test case 28: 0.3 (known inexact in binary) + var point_three = Decimal.from_float(0.3) + testing.assert_true( + String(point_three).startswith("0.3"), + "Float 0.3 should convert to a Decimal that starts with 0.3", + ) + + # Test case 29: 0.1 + 0.2 (famously != 0.3 in binary) + var point_one_plus_two = Decimal.from_float(0.1 + 0.2) + testing.assert_true( + String(point_one_plus_two).startswith("0.3"), + "Float 0.1+0.2 should convert to a Decimal starting with 0.3", + ) + + # Test case 30: Repeating binary fraction + var repeating = Decimal.from_float(0.1) + testing.assert_true( + String(repeating).startswith("0.1"), + "Float with repeating binary fraction should convert properly", + ) + + print("āœ“ Binary to decimal conversion tests passed") + + +fn test_rounding_behavior() raises: + """Test rounding behavior during float to Decimal conversion.""" + print("Testing rounding behavior in float to Decimal conversion...") + + # Test case 31: Pi with limited precision + var pi = Decimal.from_float(3.141592653589793) + testing.assert_true( + String(pi).startswith("3.14159265358979"), + "Float Pi should maintain appropriate precision in Decimal", + ) + + # Test case 32: 1/3 (repeating decimal in base 10) + var one_third = Decimal.from_float(1.0 / 3.0) + testing.assert_true( + String(one_third).startswith("0.33333333"), + "Float 1/3 should maintain appropriate precision in Decimal", + ) + + # Test case 33: 2/3 (repeating decimal in base 10) + var two_thirds = Decimal.from_float(2.0 / 3.0) + testing.assert_true( + String(two_thirds).startswith("0.66666666"), + "Float 2/3 should maintain appropriate precision in Decimal", + ) + + # Test case 34: Round trip conversion + var x = 123.456 + var decimal_x = Decimal.from_float(x) + testing.assert_true( + String(decimal_x).startswith("123.456"), + "Float-to-Decimal conversion should preserve input precision", + ) + + # Test case 35: Number at float precision boundary + var precision_boundary = Decimal.from_float(9.9999999999999999) + testing.assert_true( + String(precision_boundary).startswith("10"), + "Float near precision boundary should convert appropriately", + ) + + print("āœ“ Rounding behavior tests passed") + + +fn test_special_values() raises: + """Test handling of special float values.""" + print("Testing special float values...") + + # Test case 36: 0.0 (already covered but included for completeness) + var zero = Decimal.from_float(0.0) + testing.assert_equal( + String(zero), "0", "Float 0.0 should convert to Decimal 0" + ) + + # Test case 37: Epsilon (smallest Float64 increment from 1.0) + var epsilon = Decimal.from_float(2.220446049250313e-16) + testing.assert_true( + String(epsilon).startswith("0.000000000000000"), + "Float64 epsilon should convert with appropriate precision", + ) + + # Test case 38: power of 2 (exact in binary) + var pow2 = Decimal.from_float(1024.0) + testing.assert_equal( + String(pow2), "1024", "Powers of 2 should convert exactly" + ) + + # Test case 39: Small power of 2 + var small_pow2 = Decimal.from_float(0.125) # 2^-3 + testing.assert_equal( + String(small_pow2), "0.125", "Small powers of 2 should convert exactly" + ) + + # Test case 40: Float with many 9s + var many_nines = Decimal.from_float(9.9999) + testing.assert_true( + String(many_nines).startswith("9.9999"), + "Float with many 9s should preserve precision appropriately", + ) + + print("āœ“ Special value tests passed") + + +fn test_scientific_notation() raises: + """Test handling of scientific notation values.""" + print("Testing scientific notation float values...") + + # Test case 41: Simple scientific notation + var sci1 = Decimal.from_float(1.23e5) + testing.assert_equal( + String(sci1), + "123000", + "Float in scientific notation should convert properly", + ) + + # Test case 42: Negative exponent + var sci2 = Decimal.from_float(4.56e-3) + testing.assert_true( + String(sci2).startswith("0.00456"), + ( + "Float with negative exponent should convert with appropriate" + " precision" + ), + ) + + # Test case 43: Extreme positive exponent + var sci3 = Decimal.from_float(1.0e20) + testing.assert_equal( + String(sci3), + "100000000000000000000", + "Float with large exponent should convert properly", + ) + + # Test case 44: Extreme negative exponent + var sci4 = Decimal.from_float(1.0e-10) + testing.assert_true( + String(sci4).startswith("0.00000000"), + "Float with negative exponent should have appropriate zeros", + ) + + # Test case 45: Low precision, high exponent + var sci5 = Decimal.from_float(5e20) + testing.assert_true( + String(sci5).startswith("5"), + "Float with low precision but high exponent should convert properly", + ) + + print("āœ“ Scientific notation tests passed") + + +fn test_boundary_cases() raises: + """Test boundary cases for float to Decimal conversion.""" + print("Testing boundary cases...") + + # Test case 46: Exact power of 10 + var pow10 = Decimal.from_float(1000.0) + testing.assert_equal( + String(pow10), "1000", "Powers of 10 should convert exactly" + ) + + # Test case 47: Max safe integer precision + var safe_int = Decimal.from_float(9007199254740990.0) + testing.assert_equal( + String(safe_int), + "9007199254740990", + "Max safe integer values should convert exactly", + ) + + # Test case 48: Just beyond safe integer precision + var beyond_safe = Decimal.from_float(9007199254740994.0) + testing.assert_true( + String(beyond_safe).startswith("9007199254740"), + "Beyond safe integer values should maintain appropriate precision", + ) + + # Test case 49: Float with many trailing zeros + var trailing_zeros = Decimal.from_float(123.000000) + testing.assert_true( + String(trailing_zeros).startswith("123"), + "Floats with trailing zeros should convert properly", + ) + + # Test case 50: Simple fraction + var fraction = Decimal.from_float(0.125) # 1/8, exact in binary + testing.assert_equal( + String(fraction), + "0.125", + ( + "Simple fractions with exact binary representation should convert" + " precisely" + ), + ) + + print("āœ“ Boundary case tests passed") + + +fn run_test_with_error_handling( + test_fn: fn () raises -> None, test_name: String +) raises: + """Helper function to run a test function with error handling and reporting. + """ + try: + print("\n" + "=" * 50) + print("RUNNING: " + test_name) + print("=" * 50) + test_fn() + print("\nāœ“ " + test_name + " passed\n") + except e: + print("\nāœ— " + test_name + " FAILED!") + print("Error message: " + String(e)) + raise e + + +fn main() raises: + print("=========================================") + print("Running 50 tests for Decimal.from_float()") + print("=========================================") + + run_test_with_error_handling(test_simple_integers, "Simple integers test") + run_test_with_error_handling(test_simple_decimals, "Simple decimals test") + run_test_with_error_handling(test_negative_numbers, "Negative numbers test") + run_test_with_error_handling( + test_very_large_numbers, "Very large numbers test" + ) + run_test_with_error_handling( + test_very_small_numbers, "Very small numbers test" + ) + run_test_with_error_handling( + test_binary_to_decimal_conversion, "Binary to decimal conversion test" + ) + run_test_with_error_handling( + test_rounding_behavior, "Rounding behavior test" + ) + run_test_with_error_handling(test_special_values, "Special values test") + run_test_with_error_handling( + test_scientific_notation, "Scientific notation test" + ) + run_test_with_error_handling(test_boundary_cases, "Boundary cases test") + + print("All 50 Decimal.from_float() tests passed!") diff --git a/tests/test_utility.mojo b/tests/test_utility.mojo index b98bde8..c148e08 100644 --- a/tests/test_utility.mojo +++ b/tests/test_utility.mojo @@ -267,7 +267,7 @@ fn test_bitcast() raises: assert_equal(large_scale_coef, large_scale_bits) # Test case 6: Custom bit pattern - var test_decimal = Decimal.from_raw_words(12345, 67890, 0xABCDEF, 0x55) + var test_decimal = Decimal.from_words(12345, 67890, 0xABCDEF, 0x55) var test_coef = test_decimal.coefficient() var test_bits = dm.utility.bitcast[DType.uint128](test_decimal) assert_equal(test_coef, test_bits)