diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..be5640e --- /dev/null +++ b/.editorconfig @@ -0,0 +1,19 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +indent_size = 4 +indent_style = space +max_line_length = 120 +insert_final_newline = true +trim_trailing_whitespace = true + +[*.{yml,yaml}] +indent_size = 2 + +[Makefile] +indent_style = tab + +[*.md] +trim_trailing_whitespace = false diff --git a/.gitattributes b/.gitattributes index 8c85471..17a3bff 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,13 +1,22 @@ -/tests export-ignore -/vendor export-ignore +* text=auto eol=lf -/LICENSE export-ignore -/Makefile export-ignore -/README.md export-ignore -/phpunit.xml export-ignore -/phpstan.neon.dist export-ignore -/infection.json.dist export-ignore +*.php text diff=php + +# Keep Claude tooling scripts out of GitHub's language statistics +/.claude export-ignore -/.github export-ignore -/.gitignore export-ignore -/.gitattributes export-ignore +# Dev-only, excluded from the Packagist tarball +/.github export-ignore +/docs export-ignore +/tests export-ignore +/UPGRADE.md export-ignore +/.editorconfig export-ignore +/.gitattributes export-ignore +/.gitignore export-ignore +/phpcs.xml export-ignore +/phpunit.xml export-ignore +/phpstan.neon.dist export-ignore +/infection.json.dist export-ignore +/Makefile export-ignore +/reports export-ignore +/.phpunit.cache export-ignore diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..8ddd1db --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,29 @@ +--- +name: Bug report +about: Report a bug to help improve the library +labels: bug +--- + +## Description + +A clear and concise description of the bug. + +## Steps to reproduce + +1. +2. +3. + +## Expected behavior + +What should happen. + +## Actual behavior + +What actually happens. + +## Environment + +- PHP version: +- Library version: +- OS: diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..b344d9e --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,17 @@ +--- +name: Feature request +about: Suggest a feature for the library +labels: enhancement +--- + +## Problem + +What problem does this feature solve? + +## Proposed solution + +How should the feature work? + +## Alternatives considered + +Other approaches considered. diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..e9cc769 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,16 @@ +> Please follow the [contributing guidelines](https://github.com/tiny-blocks/tiny-blocks/blob/main/CONTRIBUTING.md). + +## Summary + +What this pull request does. + +## Related issue + +Closes #... + +## Checklist + +- [ ] Tests added or updated. +- [ ] Documentation updated when applicable. +- [ ] `make review` passes. +- [ ] `make tests` passes. diff --git a/.github/workflows/auto-assign.yml b/.github/workflows/auto-assign.yml index d0ba49e..e87e331 100644 --- a/.github/workflows/auto-assign.yml +++ b/.github/workflows/auto-assign.yml @@ -8,12 +8,19 @@ on: types: - opened +concurrency: + group: auto-assign-${{ github.event.issue.number || github.event.pull_request.number }} + cancel-in-progress: true + +permissions: + issues: write + pull-requests: write + jobs: - run: + auto-assign: + name: Auto assign runs-on: ubuntu-latest - permissions: - issues: write - pull-requests: write + timeout-minutes: 5 steps: - name: Assign issues and pull requests uses: gustavofreze/auto-assign@2.1.0 @@ -22,4 +29,4 @@ jobs: github_token: '${{ secrets.GITHUB_TOKEN }}' allow_self_assign: 'true' allow_no_assignees: 'true' - assignment_options: 'ISSUE,PULL_REQUEST' \ No newline at end of file + assignment_options: 'ISSUE,PULL_REQUEST' diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bb0226d..515c9d8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,33 +3,47 @@ name: CI on: pull_request: +concurrency: + group: ci-${{ github.event.pull_request.number }} + cancel-in-progress: true + permissions: contents: read -env: - PHP_VERSION: '8.5' - jobs: + resolve-tooling-image: + name: Resolve tooling image + runs-on: ubuntu-latest + timeout-minutes: 5 + outputs: + php-image: ${{ steps.config.outputs.php-image }} + steps: + - name: Checkout + uses: actions/checkout@v7 + + - name: Resolve tooling image from the Makefile + id: config + run: echo "php-image=$(make show-image)" >> "$GITHUB_OUTPUT" + build: name: Build + needs: resolve-tooling-image runs-on: ubuntu-latest - + timeout-minutes: 15 + env: + image: ${{ needs.resolve-tooling-image.outputs.php-image }} + workspace: /var/www/html steps: - name: Checkout uses: actions/checkout@v7 - - name: Configure PHP - uses: shivammathur/setup-php@v2 - with: - php-version: ${{ env.PHP_VERSION }} - extensions: bcmath - tools: composer:2 - - name: Validate composer.json - run: composer validate --no-interaction + run: docker run --rm -v "${PWD}":${{ env.workspace }} ${{ env.image }} composer validate --no-interaction - name: Install dependencies - run: composer install --no-progress --optimize-autoloader --prefer-dist --no-interaction + run: > + docker run --rm -v "${PWD}":${{ env.workspace }} ${{ env.image }} + composer install --no-progress --optimize-autoloader --prefer-dist --no-interaction - name: Upload vendor and composer.lock as artifact uses: actions/upload-artifact@v7 @@ -41,20 +55,13 @@ jobs: auto-review: name: Auto review + needs: [resolve-tooling-image, build] runs-on: ubuntu-latest - needs: build - + timeout-minutes: 15 steps: - name: Checkout uses: actions/checkout@v7 - - name: Configure PHP - uses: shivammathur/setup-php@v2 - with: - php-version: ${{ env.PHP_VERSION }} - extensions: bcmath - tools: composer:2 - - name: Download vendor artifact from build uses: actions/download-artifact@v8 with: @@ -62,24 +69,17 @@ jobs: path: . - name: Run review - run: composer review + run: make review tests: name: Tests + needs: [resolve-tooling-image, auto-review] runs-on: ubuntu-latest - needs: auto-review - + timeout-minutes: 15 steps: - name: Checkout uses: actions/checkout@v7 - - name: Configure PHP - uses: shivammathur/setup-php@v2 - with: - php-version: ${{ env.PHP_VERSION }} - extensions: bcmath - tools: composer:2 - - name: Download vendor artifact from build uses: actions/download-artifact@v8 with: @@ -87,4 +87,4 @@ jobs: path: . - name: Run tests - run: composer tests + run: make tests tests-without-bcmath diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml deleted file mode 100644 index aed6dab..0000000 --- a/.github/workflows/codeql.yml +++ /dev/null @@ -1,35 +0,0 @@ -name: Security checks - -on: - push: - branches: [ "main" ] - pull_request: - branches: [ "main" ] - schedule: - - cron: "0 0 * * *" - -permissions: - actions: read - contents: read - security-events: write - -jobs: - analyze: - name: Analyze - runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: - language: [ "actions" ] - - steps: - - name: Checkout repository - uses: actions/checkout@v7 - - - name: Initialize CodeQL - uses: github/codeql-action/init@v4.37.3 - with: - languages: ${{ matrix.language }} - - - name: Perform CodeQL analysis - uses: github/codeql-action/analyze@v4.37.3 diff --git a/.gitignore b/.gitignore index 42b841a..29546dd 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,30 @@ -.idea +# PHP dependencies +/vendor/ +composer.lock -vendor -report -.phpunit.* +# Local config overrides (committed baselines are the .dist files) +/phpstan.neon +/infection.json -*.lock +# Tooling cache +.phpunit.cache/ +.phpunit.result.cache +__pycache__/ +*.pyc + +# Coverage and reports +build/ +reports/ +coverage/ +infection.log + +# Editors and agents +.idea/ +.cursor/ +.vscode/ +/.claude/settings.local.json + +# OS +Thumbs.db +.DS_Store +Desktop.ini diff --git a/Makefile b/Makefile index ef9a884..91f6c29 100644 --- a/Makefile +++ b/Makefile @@ -6,7 +6,15 @@ ifeq ($(ARCH),arm64) PLATFORM := --platform=linux/amd64 endif -DOCKER_RUN = docker run ${PLATFORM} --rm -it --net=host -v ${PWD}:/app -w /app gustavofreze/php:8.5-alpine +TTY := $(shell [ -t 0 ] && echo -it) + +PHP_VERSION := $(shell sed -n 's/.*"php": *"^\([0-9]*\.[0-9]*\)".*/\1/p' composer.json) +IMAGE_VERSION := 1.0.0 +PHP_IMAGE := gustavofreze/php:${PHP_VERSION}-cli-${IMAGE_VERSION} +WORKSPACE := /var/www/html +BCMATH_INI := $${PHP_INI_DIR}/conf.d/docker-php-ext-bcmath.ini + +DOCKER_RUN = docker run ${PLATFORM} --rm ${TTY} --net=host -v ${PWD}:${WORKSPACE} ${PHP_IMAGE} RESET := \033[0m GREEN := \033[0;32m @@ -16,43 +24,55 @@ YELLOW := \033[0;33m .PHONY: configure configure: ## Configure development environment - @${DOCKER_RUN} composer update --optimize-autoloader + @${DOCKER_RUN} composer configure + +.PHONY: configure-and-update +configure-and-update: ## Configure development environment and update dependencies + @${DOCKER_RUN} composer configure-and-update -.PHONY: test -test: ## Run all tests with coverage +.PHONY: tests +tests: ## Run unit and mutation tests with coverage @${DOCKER_RUN} composer tests .PHONY: test-file -test-file: ## Run tests for a specific file (usage: make test-file FILE=path/to/file) +test-file: ## Run tests for a specific file (usage: make test-file FILE=ClassNameTest) @${DOCKER_RUN} composer test-file ${FILE} -.PHONY: test-no-coverage -test-no-coverage: ## Run all tests without coverage - @${DOCKER_RUN} composer tests-no-coverage +.PHONY: tests-without-bcmath +tests-without-bcmath: ## Run unit tests on the pure PHP backend, with the extension unloaded + @${DOCKER_RUN} sh -c 'rm -f ${BCMATH_INI} && php ./vendor/bin/phpunit --configuration phpunit.xml --no-coverage tests' .PHONY: review -review: ## Run static code analysis +review: ## Run lint and static analysis @${DOCKER_RUN} composer review .PHONY: show-reports -show-reports: ## Open static analysis reports (e.g., coverage, lints) in the browser - @sensible-browser report/coverage/coverage-html/index.html report/coverage/mutation-report.html +show-reports: ## Open coverage and mutation reports in the browser + @sensible-browser reports/coverage/coverage-html/index.html reports/coverage/mutation-report.html + +.PHONY: show-outdated +show-outdated: ## Show outdated direct dependencies + @${DOCKER_RUN} composer outdated --direct + +.PHONY: show-image +show-image: ## Show the pinned PHP tooling image + @echo ${PHP_IMAGE} .PHONY: clean clean: ## Remove dependencies and generated artifacts @sudo chown -R ${USER}:${USER} ${PWD} - @rm -rf report vendor .phpunit.cache *.lock + @rm -rf reports vendor .phpunit.cache *.lock .PHONY: help -help: ## Display this help message +help: ## Display this help message @echo "Usage: make [target]" @echo "" @echo "$$(printf '$(GREEN)')Setup$$(printf '$(RESET)')" - @grep -E '^(configure):.*?## .*$$' $(MAKEFILE_LIST) \ + @grep -E '^(configure|configure-and-update):.*?## .*$$' $(MAKEFILE_LIST) \ | awk 'BEGIN {FS = ":.*? ## "}; {printf "$(YELLOW)%-25s$(RESET) %s\n", $$1, $$2}' @echo "" @echo "$$(printf '$(GREEN)')Testing$$(printf '$(RESET)')" - @grep -E '^(test|test-file|test-no-coverage):.*?## .*$$' $(MAKEFILE_LIST) \ + @grep -E '^(tests|test-file|tests-without-bcmath):.*?## .*$$' $(MAKEFILE_LIST) \ | awk 'BEGIN {FS = ":.*?## "}; {printf "$(YELLOW)%-25s$(RESET) %s\n", $$1, $$2}' @echo "" @echo "$$(printf '$(GREEN)')Quality$$(printf '$(RESET)')" @@ -60,7 +80,7 @@ help: ## Display this help message | awk 'BEGIN {FS = ":.*?## "}; {printf "$(YELLOW)%-25s$(RESET) %s\n", $$1, $$2}' @echo "" @echo "$$(printf '$(GREEN)')Reports$$(printf '$(RESET)')" - @grep -E '^(show-reports):.*?## .*$$' $(MAKEFILE_LIST) \ + @grep -E '^(show-reports|show-outdated|show-image):.*?## .*$$' $(MAKEFILE_LIST) \ | awk 'BEGIN {FS = ":.*?## "}; {printf "$(YELLOW)%-25s$(RESET) %s\n", $$1, $$2}' @echo "" @echo "$$(printf '$(GREEN)')Cleanup$$(printf '$(RESET)')" diff --git a/README.md b/README.md index 69809dd..ccdf7c0 100644 --- a/README.md +++ b/README.md @@ -1,18 +1,44 @@ # Math -[![License](https://img.shields.io/badge/license-MIT-green)](LICENSE) +[![License](https://img.shields.io/badge/license-MIT-green)](https://github.com/tiny-blocks/math/blob/main/LICENSE) * [Overview](#overview) * [Installation](#installation) * [How to use](#how-to-use) + + [Number](#number) + + [BigDecimal](#bigdecimal) + + [BigInteger](#biginteger) + + [BigRational](#bigrational) + + [Percentage](#percentage) + + [Ratio](#ratio) + + [BigDecimals](#bigdecimals) + + [RoundingMode](#roundingmode) + + [Failures](#failures) + + [Calculation backend](#calculation-backend) +* [FAQ](#faq) * [License](#license) * [Contributing](#contributing) -
+
## Overview -Value Objects for handling arbitrary precision numbers. +Arbitrary-precision numbers for PHP, where arithmetic is exact and rounding is explicit. + +Addition, subtraction and multiplication never round. Division returns a `BigRational`, so it is exact for every pair of +operands and raises only on a zero divisor. Rounding happens where you ask for it, by naming both a scale and a +`RoundingMode`. There is no ambient precision context, no process-global scale, and no default rounding mode to forget. + +The arithmetic runs through a single integer engine, and every value type is a thin facade over it. The engine is the +`bcmath` extension when it is loaded, and a pure PHP backend when it is not, so the library has no required extension +and no Composer dependency. Both produce identical results, so `bcmath` buys speed rather than correctness. On money +values the pure PHP backend stays within roughly an order of magnitude on arithmetic, rounding, comparison, allocation +and division. Square roots are the outlier, several dozen times slower. The gap widens with operand size, so install the +extension where wide operands meet throughput. Nothing in the library routes a number through `float`. + +Pairs with [tiny-blocks/currency](https://github.com/tiny-blocks/currency), whose +`Currency::getFractionDigits()` is exactly the scale `toScale` wants. The library has no Composer +dependencies of its own.
@@ -26,146 +52,511 @@ composer require tiny-blocks/math ## How to use -The library exposes some concrete implementations for arbitrary precision numbers. Concrete implementations implement -the `BigNumber` interface, which provides the behaviors for the respective **BigNumbers**. +Every numeric type is immutable, and every operation returns a new instance. + +
+ +### Number + +The contract `BigInteger`, `BigDecimal` and `BigRational` share. Every method below works across all three, so a +function can take a `Number` and compare or convert whatever arrives. + +Comparison is exact whatever the pair of types. Two numbers of the same type are compared on their own representation. +Across types the comparison goes through `BigRational`, the only form every number has exactly, which is what keeps the +contract to one method instead of one per pair of types. + +| Method | Returns | Notes | +|-----------------------------------------|---------------|--------------------------------------------------| +| `compareTo(Number $other)` | `int` | Negative, zero, or positive. Exact across types. | +| `isEqualTo(Number $other)` | `bool` | Arithmetic, so `1.0` equals `1.00`. | +| `isLessThan(Number $other)` | `bool` | | +| `isGreaterThan(Number $other)` | `bool` | | +| `isLessThanOrEqualTo(Number $other)` | `bool` | | +| `isGreaterThanOrEqualTo(Number $other)` | `bool` | | +| `isZero()` | `bool` | | +| `isNegative()` | `bool` | Strictly less than zero. | +| `isPositive()` | `bool` | Strictly greater than zero. | +| `negated()` | `Number` | | +| `absolute()` | `Number` | | +| `toBigRational()` | `BigRational` | | +| `toString()` | `string` | Round-trips through the type's own factory. | +| `hashCode()` | `string` | Agrees with the type's own `equals`. | +| `jsonSerialize()` | `string` | A JSON string, never a JSON number. | + +Each implementation adds an `equals` of its own, typed to its exact class: `BigDecimal::equals(BigDecimal $other)`, +`BigInteger::equals(BigInteger $other)`, `BigRational::equals(BigRational $other)`. That one is structural, so `1.0` +does **not** equal `1.00`, and comparing across types is a type error rather than a silent `false`. `isEqualTo` and +`equals` answer different questions on purpose, and FAQ 02 explains why. -### Using the fromString method +```php +isEqualTo(other: BigDecimal::of(value: '5.00')); +# true +``` + +`Percentage` and `Ratio` are not `Number` instances: a rate and a proportion are not quantities you add to an amount. +`Percentage` carries the same comparison predicates typed against `Percentage`. `Percentage` converts with `rate()` or +`toRatio()`, and `Ratio` with `toBigRational()` or `toPercentage()`. + +
+ +### BigDecimal + +An arbitrary-precision decimal, held as an unscaled integer and a non-negative scale. ```php -BigDecimal::fromString(value: '10'); -BigDecimal::fromString(value: '-123.456'); +decrease(amount: $price); + +$final->toScale(scale: 2, rounding: RoundingMode::HalfEven); +# 17.49 ``` -It is possible to set a `scale` for the object through this method. +| Method | Returns | Result scale | +|-----------------------------------------------------|---------------|---------------------------| +| `of(string\|int $value)` | `BigDecimal` | The scale of the literal. | +| `one()` | `BigDecimal` | 0. | +| `zero()` | `BigDecimal` | 0. | +| `fromFloat(float $value)` | `BigDecimal` | The shortest round-trip. | +| `ofUnscaledValue(int $scale, BigInteger $unscaled)` | `BigDecimal` | `$scale`. | +| `plus(BigDecimal $addend)` | `BigDecimal` | `max(a, b)`. | +| `minus(BigDecimal $subtrahend)` | `BigDecimal` | `max(a, b)`. | +| `multipliedBy(BigDecimal $multiplier)` | `BigDecimal` | `a + b`. | +| `dividedBy(BigDecimal $divisor)` | `BigRational` | Not applicable. | +| `power(int $exponent)` | `BigDecimal` | `a × exponent`. | +| `squareRoot(int $scale, RoundingMode $rounding)` | `BigDecimal` | `$scale`. | +| `toScale(int $scale, RoundingMode $rounding)` | `BigDecimal` | `$scale`. | +| `toScaleExact(int $scale)` | `BigDecimal` | `$scale`. | +| `withoutTrailingZeros()` | `BigDecimal` | The smallest possible. | +| `allocate(int $scale, BigDecimals $weights)` | `BigDecimals` | `$scale`. | +| `negated()` | `BigDecimal` | Unchanged. | +| `absolute()` | `BigDecimal` | Unchanged. | +| `scale()` | `int` | Not applicable. | +| `unscaledValue()` | `BigInteger` | Not applicable. | +| `integralPart()` | `BigInteger` | Not applicable. | +| `fractionalPart()` | `BigDecimal` | Unchanged. | +| `toBigInteger()` | `BigInteger` | Not applicable. | +| `toBigRational()` | `BigRational` | Not applicable. | +| `toFloat()` | `float` | Not applicable. | +| `toString()` | `string` | Not applicable. | + +Scale propagation matches Java's `BigDecimal`, so nothing here surprises a reader who knows it. + +`allocate` splits an amount so that the parts sum back to it exactly. Each part is the exact share truncated toward +negative infinity at the given scale, and the units left over are handed out one at a time in descending order of the +discarded remainder, ties broken by position. Plain rounding cannot preserve the total, which is why this exists. ```php -BigDecimal::fromString(value: '10', scale: 2); +allocate(scale: 2, weights: BigDecimals::of('1', '1', '1')); + +$parts->sum()->toString(); +# 100.00, and the parts are 33.34, 33.33, 33.33 ``` -Always prefer to instantiate from a string, which supports an unlimited number of digits and ensures no loss of -precision. +
-### Using the fromFloat method +### BigInteger -With the `fromFloat` method, a new instance of type `BigNumber` is created from a valid float value. +An arbitrary-precision integer, for problems that have no scale. ```php -BigDecimal::fromFloat(value: 10.0); -BigDecimal::fromFloat(value: -123.456); +multipliedBy(multiplier: BigInteger::of(value: '9007199254740993')) + ->toString(); +# 81129638414606699710187514626049 ``` -It is also possible to set a `scale` for the object through this method. +| Method | Returns | Notes | +|--------------------------------------------|---------------|-------------------------------------| +| `of(string\|int $value)` | `BigInteger` | A zero fractional part is accepted. | +| `one()` | `BigInteger` | | +| `zero()` | `BigInteger` | | +| `fromBase(int $base, string $value)` | `BigInteger` | Base 2 to 36, case-insensitive. | +| `plus(BigInteger $addend)` | `BigInteger` | | +| `minus(BigInteger $subtrahend)` | `BigInteger` | | +| `multipliedBy(BigInteger $multiplier)` | `BigInteger` | | +| `dividedBy(BigInteger $divisor)` | `BigRational` | Exact, never rounds. | +| `quotient(BigInteger $divisor)` | `BigInteger` | Truncated toward zero. | +| `remainder(BigInteger $divisor)` | `BigInteger` | Sign follows the dividend. | +| `modulo(BigInteger $modulus)` | `BigInteger` | Never negative. | +| `power(int $exponent)` | `BigInteger` | Rejects a negative exponent. | +| `squareRoot()` | `BigInteger` | Floor. | +| `greatestCommonDivisor(BigInteger $other)` | `BigInteger` | Never negative. | +| `isEven()` | `bool` | | +| `isOdd()` | `bool` | | +| `toBase(int $base)` | `string` | Lowercase for bases above ten. | +| `toBigDecimal()` | `BigDecimal` | Scale zero. | +| `toBigRational()` | `BigRational` | Denominator one. | +| `toInt()` | `int` | Raises outside the native range. | +| `toString()` | `string` | | + +`remainder` and `modulo` are separate methods because the two conventions genuinely differ: +`bcmod` follows the dividend's sign while `gmp_mod` never returns a negative. Hiding both behind one name is how a +backend swap silently changes answers. + +
+ +### BigRational + +An exact fraction, always in lowest terms with a strictly positive denominator. This is the type that lets division stay +total. ```php -BigDecimal::fromFloat(value: 10.0, scale: 2); +dividedBy(divisor: BigDecimal::of(value: '3')); + +$share->toDecimal(scale: 2, rounding: RoundingMode::HalfEven)->toString(); +# 33.33 ``` -### Using the methods of mathematical operations +| Method | Returns | Notes | +|--------------------------------------------------------------|---------------|--------------------------------------------| +| `of(string\|int $value)` | `BigRational` | Accepts `3/4`, `0.75`, and `3`. | +| `ofFraction(BigInteger $numerator, BigInteger $denominator)` | `BigRational` | Reduced on construction. | +| `one()` | `BigRational` | | +| `zero()` | `BigRational` | | +| `plus(BigRational $addend)` | `BigRational` | Exact. | +| `minus(BigRational $subtrahend)` | `BigRational` | Exact. | +| `multipliedBy(BigRational $multiplier)` | `BigRational` | Exact. | +| `dividedBy(BigRational $divisor)` | `BigRational` | Exact. | +| `power(int $exponent)` | `BigRational` | Negative exponents supported. | +| `reciprocal()` | `BigRational` | Raises on zero. | +| `numerator()` | `BigInteger` | Carries the sign. | +| `denominator()` | `BigInteger` | Always positive. | +| `hasTerminatingDecimal()` | `bool` | True when the denominator is 2^a·5^b. | +| `toDecimal(int $scale, RoundingMode $rounding)` | `BigDecimal` | | +| `toDecimalExact()` | `BigDecimal` | Raises when the expansion repeats. | +| `toBigInteger()` | `BigInteger` | Raises when the denominator is not one. | +| `toFloat()` | `float` | | +| `toString()` | `string` | `3/4`, or `3` when the denominator is one. | + +`hasTerminatingDecimal()` is the cheap way to ask before converting, instead of calling +`toDecimalExact()` and catching the failure. + +
+ +### Percentage + +A rate expressed per hundred, held as a `BigDecimal`. Applying it to an amount is a multiplication and therefore exact, +so no rounding decision is forced until the result is presented. + +```php +increase(amount: BigDecimal::of(value: '19.99')) + ->toScale(scale: 2, rounding: RoundingMode::HalfEven) + ->toString(); +# 22.49 +``` -Performs an addition operation between this value and another value. +| Method | Returns | Notes | +|---------------------------------------------------------------|--------------|--------------------------------------------------------------------| +| `of(string\|int $value)` | `Percentage` | `'12.5'` is twelve and a half percent. A trailing `%` is accepted. | +| `zero()` | `Percentage` | | +| `fromRatio(Ratio $ratio, int $scale, RoundingMode $rounding)` | `Percentage` | A scale is required, a ratio may not terminate. | +| `rate()` | `BigDecimal` | `0.125` for twelve and a half percent. | +| `applyTo(BigDecimal $amount)` | `BigDecimal` | Exact. | +| `increase(BigDecimal $amount)` | `BigDecimal` | Exact. | +| `decrease(BigDecimal $amount)` | `BigDecimal` | Exact. | +| `toRatio()` | `Ratio` | In lowest terms. | +| `isZero()` | `bool` | | +| `isNegative()` | `bool` | A negative rate is legal. | +| `isPositive()` | `bool` | | +| `isEqualTo(Percentage $other)` | `bool` | Arithmetic, so the scale plays no part. | +| `isLessThan(Percentage $other)` | `bool` | | +| `isGreaterThan(Percentage $other)` | `bool` | | +| `isLessThanOrEqualTo(Percentage $other)` | `bool` | | +| `isGreaterThanOrEqualTo(Percentage $other)` | `bool` | | +| `toString()` | `string` | `12.5%`. | +| `equals(Percentage $other)` | `bool` | Structural, so `'10'` does **not** equal `'10.0'`. | +| `hashCode()` | `string` | Agrees with `equals`. | +| `jsonSerialize()` | `string` | A JSON string, `12.5%`, which reads back through `of`. | + +Rates above one hundred and below zero are legal, because a one hundred and fifty percent increase and a negative growth +rate are both real. + +
+ +### Ratio + +An exact proportion between two quantities, written antecedent to consequent. ```php -$augend = BigDecimal::fromString(value: '1'); -$addend = BigDecimal::fromFloat(value: 1.0); +add(addend: $addend); +declare(strict_types=1); -$result->toString(); # 2 +use TinyBlocks\Math\Ratio; +use TinyBlocks\Math\RoundingMode; + +Ratio::of(antecedent: 16, consequent: 9) + ->toPercentage(scale: 2, rounding: RoundingMode::HalfEven) + ->toString(); +# 177.78% ``` -#### Subtraction +| Method | Returns | Notes | +|--------------------------------------------------------|---------------|---------------------------------------------------------| +| `of(int\|string $antecedent, int\|string $consequent)` | `Ratio` | Reduced on construction. | +| `from(string $value)` | `Ratio` | Reads the `16:9` form back. | +| `between(Number $antecedent, Number $consequent)` | `Ratio` | Exact, so no scale is involved. | +| `applyTo(Number $amount)` | `BigRational` | Exact. | +| `inverted()` | `Ratio` | Swaps the terms. | +| `antecedent()` | `BigInteger` | Carries the sign. | +| `consequent()` | `BigInteger` | Always positive. | +| `toPercentage(int $scale, RoundingMode $rounding)` | `Percentage` | | +| `toBigRational()` | `BigRational` | | +| `toString()` | `string` | `16:9`. | +| `equals(Ratio $other)` | `bool` | Structural, and a ratio is always in lowest terms. | +| `hashCode()` | `string` | Agrees with `equals`. | +| `jsonSerialize()` | `string` | A JSON string, `16:9`, which reads back through `from`. | + +
+ +### BigDecimals -Performs a subtraction operation between this value and another value. +An immutable, ordered collection of decimals. It carries the weights handed to `allocate` and the parts it produces. ```php -$minuend = BigDecimal::fromString(value: '1'); -$subtrahend = BigDecimal::fromFloat(value: 1.0); +subtract(subtrahend: $subtrahend); +declare(strict_types=1); -$result->toString(); # 0 +use TinyBlocks\Math\BigDecimal; +use TinyBlocks\Math\BigDecimals; + +BigDecimal::of(value: '100.00') + ->allocate(scale: 2, weights: BigDecimals::of('1', '1', '1')) + ->sum() + ->toString(); +# 100.00 ``` -#### Multiplication +| Method | Returns | Notes | +|-------------------------------|--------------------|---------------------------------------------------------| +| `of(string\|int ...$values)` | `BigDecimals` | `BigDecimals::of('1', '1', '1')`. | +| `from(BigDecimal ...$values)` | `BigDecimals` | | +| `sum()` | `BigDecimal` | Exact, at the largest scale in the set. | +| `all()` | `list` | In order. | +| `count()` | `int` | The collection is `Countable`. | +| `getIterator()` | `Traversable` | The collection is iterable with `foreach`. | +| `jsonSerialize()` | `list` | A JSON array of strings, which reads back through `of`. | + +
+ +### RoundingMode -Performs a multiplication operation between this value and another value. +Eight cases. A mode decides one thing: whether the discarded fraction pushes the kept digit away from zero, given how +that fraction compares with one half, the sign of the value, and the parity of the digit being kept. That is the +definition Java's `RoundingMode` javadoc gives each constant, and it is what `roundsAwayFromZero()` implements. The +table below rounds `-2.345` to two decimal places, and matches Java's published rounding table. ```php -$multiplicand = BigDecimal::fromString(value: '1'); -$multiplier = BigDecimal::fromFloat(value: 1.0); +multiply(multiplier: $multiplier); +declare(strict_types=1); -$result->toString(); # 1 +use TinyBlocks\Math\BigDecimal; +use TinyBlocks\Math\RoundingMode; + +BigDecimal::of(value: '-2.345')->toScale(scale: 2, rounding: RoundingMode::HalfEven)->toString(); +# -2.34 ``` -#### Division +| Case | Backing value | Native equivalent | Result | +|------------|---------------|--------------------|---------| +| `Up` | `up` | `AwayFromZero` | `-2.35` | +| `Down` | `down` | `TowardsZero` | `-2.34` | +| `Floor` | `floor` | `NegativeInfinity` | `-2.35` | +| `HalfUp` | `half-up` | `HalfAwayFromZero` | `-2.35` | +| `Ceiling` | `ceiling` | `PositiveInfinity` | `-2.34` | +| `HalfOdd` | `half-odd` | `HalfOdd` | `-2.35` | +| `HalfDown` | `half-down` | `HalfTowardsZero` | `-2.34` | +| `HalfEven` | `half-even` | `HalfEven` | `-2.34` | + +`fromNativeRoundingMode()` and `toNativeRoundingMode()` convert to and from PHP's native +`RoundingMode` enum, for consumers holding one from configuration or `Intl`. The library does not use them internally, +so a replacement calculation backend is never bypassed. There is no case meaning "do not round": that path is +`toScaleExact()` and `toDecimalExact()`, which raise instead of guessing. + +
-Performs a division operation between this value and another value. +### Failures + +Every failure implements `MathFailure`, so one catch clause covers the library. The interface exists because PHP splits +`Error` and `Exception` at the root and a zero divisor genuinely belongs under `DivisionByZeroError`. ```php -$dividend = BigDecimal::fromString(value: '1'); -$divisor = BigDecimal::fromFloat(value: 1.0); +divide(divisor: $divisor); +try { + BigDecimal::of(value: '1.00')->dividedBy(divisor: BigDecimal::zero()); +} catch (MathFailure $failure) { + $failure->getMessage(); + # Cannot divide <1> by zero. +} +``` + +| Class | Extends | Raised when | +|--------------------------|----------------------------|---------------------------------------------------------| +| `NumberNotWellFormed` | `InvalidArgumentException` | A literal is not a number, the empty string included. | +| `DivisionByZero` | `DivisionByZeroError` | A zero divisor, denominator, or reciprocal. | +| `NonTerminatingDecimal` | `DomainException` | An exact decimal was demanded of a repeating expansion. | +| `InexactConversion` | `DomainException` | An exact conversion would discard digits. | +| `ScaleOutOfRange` | `InvalidArgumentException` | A scale is negative or beyond the supported range. | +| `NegativeExponent` | `InvalidArgumentException` | An integer or decimal was raised to a negative power. | +| `ExponentOutOfRange` | `InvalidArgumentException` | An exponent is beyond the supported magnitude. | +| `NegativeRoot` | `DomainException` | The square root of a negative value. | +| `NegativeWeight` | `InvalidArgumentException` | An allocation weight is negative. | +| `BaseOutOfRange` | `InvalidArgumentException` | A positional base is outside 2 to 36. | +| `IntegerOverflow` | `OverflowException` | A conversion leaves the native integer or float range. | +| `CalculatorNotAvailable` | `RuntimeException` | The calculation backend cannot run in this process. | + +`NonTerminatingDecimal` and `InexactConversion` are separate on purpose. The first cannot be fixed by asking for more +digits, the second can. + +
+ +### Calculation backend + +`Calculator` is the integer engine every value type runs on. The contract is integer only, because that is the boundary +every candidate backend shares: GMP has no fractional type, and a decimal is an integer plus a scale. Scale bookkeeping +therefore stays inside the library and two backends cannot disagree about a result. + +Two backends ship. Resolution takes the first that can run: BCMath when the extension is loaded, otherwise a pure PHP +backend that needs nothing beyond 64-bit integers. The pure PHP backend is verified against libbcmath over a corpus +that crosses every limb boundary and both signs, so the two agree digit for digit and the extension is a speed +decision rather than a correctness one. + +The gap is worth planning for. Measured on the project image, 2000 scale-2 money operations take about 280 ms on BCMath +and about 3.4 s on the pure PHP backend, and 200 exact divisions take about 60 ms against about 15 s. Division is the +widest gap because each quotient digit costs a full-width multiply and compare. Install `ext-bcmath` on any host that +does real volume, and treat the pure PHP backend as the guarantee that the library still runs where you cannot. + +A registered backend always wins over the resolved one, and it is checked when it is registered rather than when it is +used, so a bootstrap mistake surfaces at bootstrap. When a GMP backend is added it goes at the front. + +A backend implements nine methods. Every operand is a plain decimal integer string: an optional leading minus, then +digits, with no leading zero and no decimal point. + +| Method | Returns | Notes | +|-----------------------------------------------------|----------|-----------------------------------------------| +| `add(string $left, string $right)` | `string` | | +| `subtract(string $minuend, string $subtrahend)` | `string` | | +| `multiply(string $left, string $right)` | `string` | | +| `quotient(string $numerator, string $denominator)` | `string` | Truncated toward zero. | +| `remainder(string $numerator, string $denominator)` | `string` | Sign follows the numerator. | +| `power(string $base, int $exponent)` | `string` | The exponent is never negative. | +| `squareRoot(string $radicand)` | `string` | Truncated. The radicand is never negative. | +| `compare(string $left, string $right)` | `int` | Exactly `-1`, `0`, or `1`. | +| `isAvailable()` | `bool` | Whether this backend can run in this process. | + +`Calculators` is the resolution surface. + +| Method | Returns | Notes | +|------------------------------------|--------------|---------------------------------------------------------| +| `active()` | `Calculator` | Resolves on first call, then caches. | +| `register(Calculator $calculator)` | `void` | Bootstrap only. Raises when the backend is unavailable. | +| `reset()` | `void` | Returns to automatic resolution. | -$result->toString(); # 1 +```php +Calculators::register(calculator: new GmpCalculator()); ``` -### Using other resources +`register` is bootstrap-only. What it replaces is a stateless, pure engine, so a replacement changes how fast a result +is produced and never what the result is. + +
+ +## FAQ + +### 01. Why does division return a BigRational instead of a BigDecimal? -If you need to perform rounding, you can use the `withRounding` method. +Because the exact answer always exists as a fraction, and hiding that costs the caller something either way. Libraries +that return a decimal must round silently, demand a scale at every division site, or raise when the quotient repeats. A +fraction is none of those: `dividedBy` has one signature on all three numeric types, takes one argument, and raises only +on a zero divisor. You leave exactness behind when you are ready, by naming a scale and a rounding mode, or by asking +for +`toDecimalExact()` and handling the case where no exact decimal exists. -Use one of the following constants to specify the [mode](https://www.php.net/manual/en/function.round.php) in which -rounding occurs: +### 02. Why is `equals` different from `isEqualTo`? -- `HALF_UP`: Round number away from zero when halfway. +`isEqualTo` is arithmetic and lives on `Number`, so `1.0` and `1.00` are equal and a `BigDecimal` can be compared with +a `BigInteger`. `equals` is structural and lives on each concrete type, typed to that exact type, so the same pair is +not equal because the scales differ and a cross-type call does not compile. Both notions are useful and both are wrong +as the only one on offer, so each has its own name. - ```php - $value = BigDecimal::fromFloat(value: 0.9950, scale: 2); - - $result = $value->withRounding(mode: RoundingMode::HALF_UP); - - $result->toString(); # 1 - ``` +> Oracle, *java.math.BigDecimal Javadoc* (Oracle, 2024), "Note: this class has a natural ordering +> that is inconsistent with equals". -- `HALF_DOWN`: Round number to zero when halfway. +### 03. Why is scale part of the value at all? - ```php - $value = BigDecimal::fromFloat(value: 0.9950, scale: 2); - - $result = $value->withRounding(mode: RoundingMode::HALF_DOWN); - - $result->toString(); # 0.99 - ``` +Because `USD 3.00` is not `USD 3`. A model based on significant digits cannot express the difference, and loses it on +ordinary addition: in decimal.js at `precision: 5`, +`new Decimal('100000').plus('0.0001')` is `100000`. Fixed scale survives addition, which is what money needs. -- `HALF_EVEN`: Round number to the nearest even value when halfway. +### 04. Why does `jsonSerialize` emit a string rather than a number? - ```php - $value = BigDecimal::fromFloat(value: 0.9950, scale: 2); - - $result = $value->withRounding(mode: RoundingMode::HALF_EVEN); - - $result->toString(); # 1 - ``` +A JSON number is read back as an IEEE-754 double by every JavaScript consumer, which discards exactly what this library +preserves. `json_encode(BigDecimal::of(value: '1.50'))` is `"1.50"`. -- `HALF_ODD`: Round number to the nearest odd value when halfway. +### 05. Why is there no `__toString`? - ```php - $value = BigDecimal::fromFloat(value: 0.9950, scale: 2); - - $result = $value->withRounding(mode: RoundingMode::HALF_ODD); - - $result->toString(); # 0.99 - ``` +The ecosystem's `phpcs` ruleset places magic methods after every other method, while the member ordering convention +places methods by name length. The two cannot both be satisfied for +`__toString`, so the magic method is absent and `toString()` is the single canonical string form. -#### Others +### 06. Why does `fromFloat` exist if floats are the problem? -Check out other available resources by looking at the [BigNumber](src/BigNumber.php) interface. +Because real programs receive floats from JSON and from other libraries, and refusing them only moves the conversion +somewhere less careful. It is the one lossy entry point, it is named so, and it reads the shortest decimal that +round-trips to the same float, so `0.1` becomes `'0.1'` rather than its exact binary expansion. Prefer a string literal +whenever one is available.
diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..7c9de3d --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,12 @@ +# Security Policy + +## Supported versions + +Only the latest release receives security updates. + +## Reporting a vulnerability + +Report security vulnerabilities privately via +[GitHub Security Advisories](https://github.com/tiny-blocks/math/security/advisories/new). + +Please do not disclose the vulnerability publicly until it has been addressed. diff --git a/UPGRADE.md b/UPGRADE.md new file mode 100644 index 0000000..2fba289 --- /dev/null +++ b/UPGRADE.md @@ -0,0 +1,152 @@ +# Upgrade from 3.x to 4.0 + +Version 4.0 replaces the whole public surface. Nothing from 3.x is preserved for compatibility, +because three of the operations that surface carried were not arbitrary precision at all. + +## Why the surface was replaced + +| Defect in 3.x | Where | What actually happened | +|---|---|---| +| Comparison lost precision past 53 bits. | `Internal\Number`, which compared with PHP's `<`, `>`, and `==`. | `'0.10000000000000000001' == '0.10000000000000000002'` is `true` in PHP, while `bccomp` at scale 30 returns `-1`. Every `isLessThan` and sibling on `BigNumber` was wrong past that point. | +| Rounding went through `float`. | `RoundingMode::round`, which called `toFloat()`. | `12345678901234567890.5` became `1.2345678901235E+19`. | +| Absolute value went through `float`. | `Internal\BigNumberBehavior::absolute`. | The same 53-bit ceiling. | +| Scale changes truncated instead of rounding. | `Internal\Scale::numberWithScale`. | It also indexed the fractional part without checking that one existed. | +| Sign subtypes broke substitutability. | `PositiveBigDecimal::add`. | Adding a larger negative addend produced a result the subtype's own constructor rejected. | + +## Type mapping + +| 3.x | 4.0 | Note | +|---|---|---| +| `BigNumber` | `Number` | An interface rather than a name that says "big". | +| `BigDecimal` | `BigDecimal` | Same name, new surface. | +| `PositiveBigDecimal` | Removed | A sign constraint is a consumer's domain invariant. Guard it where the domain lives, or call `isPositive()`. | +| `NegativeBigDecimal` | Removed | Same. | +| `TinyBlocks\Math\RoundingMode` | `TinyBlocks\Math\RoundingMode` | Same name, `string`-backed instead of `int`-backed, eight cases instead of four. Persisted values must be migrated. | +| Not present | `BigInteger` | New. | +| Not present | `BigRational` | New, and the reason division is total. | +| Not present | `Percentage` | New. | +| Not present | `Ratio` | New. | +| Not present | `BigDecimals` | New, for allocation weights and parts. | +| Not present | `Calculator`, `Calculators` | New, the pluggable calculation backend. | + +## Method mapping + +| 3.x | 4.0 | Note | +|---|---|---| +| `BigDecimal::fromString($value, $scale)` | `BigDecimal::of(value: $value)` | The scale comes from the literal. To change it, call `toScale` explicitly. | +| `BigDecimal::fromFloat($value, $scale)` | `BigDecimal::fromFloat(value: $value)` | No scale argument. Documented as the only lossy entry point. | +| `BigNumber::AUTOMATIC_SCALE` | No replacement | A null scale meaning "decide for me" is the ambient-context mistake in miniature. | +| `add(BigNumber $addend)` | `plus(BigDecimal $addend)` | | +| `subtract(BigNumber $subtrahend)` | `minus(BigDecimal $subtrahend)` | | +| `multiply(BigNumber $multiplier)` | `multipliedBy(BigDecimal $multiplier)` | | +| `divide(BigNumber $divisor)` | `dividedBy(BigDecimal $divisor)` | **Returns `BigRational`, not `BigDecimal`.** Follow it with `toDecimal(scale, rounding)` or `toDecimalExact()`. | +| `withRounding(RoundingMode $mode)` | `toScale(int $scale, RoundingMode $rounding)` | Rounding without naming a target scale is undefined, and 3.x resolved it through `float`. | +| `withScale(int $scale)` | `toScale(int $scale, RoundingMode $rounding)` or `toScaleExact(int $scale)` | Changing scale is a rounding operation, so it names its mode or refuses to lose digits. | +| `RoundingMode::round(BigNumber $value)` | `BigDecimal::toScale(int $scale, RoundingMode $rounding)` | Rounding is a method on the value, not on the mode. The mode is now an argument, and the arithmetic never touches `float`. | +| `getScale()` | `scale()` | | +| `absolute()` | `absolute()` | Same name, exact implementation. | +| `isZero()` | `isZero()` | | +| `isNegative()` | `isNegative()` | | +| `isPositive()` | `isPositive()` | | +| `isNegativeOrZero()` | `!$value->isPositive()` | | +| `isPositiveOrZero()` | `!$value->isNegative()` | | +| `isLessThan(BigNumber $other)` | `isLessThan(Number $other)` | Now exact past 53 bits. | +| `isLessThanOrEqual(BigNumber $other)` | `isLessThanOrEqualTo(Number $other)` | Renamed for symmetry with `isGreaterThanOrEqualTo`. | +| `isGreaterThan(BigNumber $other)` | `isGreaterThan(Number $other)` | Now exact past 53 bits. | +| `isGreaterThanOrEqual(BigNumber $other)` | `isGreaterThanOrEqualTo(Number $other)` | Renamed. | +| Not present | `isEqualTo(Number $other)` | Arithmetic equality. | +| Not present | `equals(BigDecimal $other)` | Structural equality, so `1.0` does not equal `1.00`. Declared on each concrete type with its own exact parameter type. | +| Not present | `compareTo(Number $other)` | | +| `toFloat()` | `toFloat()` | Now raises `IntegerOverflow` rather than returning infinity. | +| `toString()` | `toString()` | Always positional notation. | +| Not present | `jsonSerialize()` | Emits a JSON string. | + +## Rounding mode mapping + +The enum is now `string`-backed and gains the four directed modes 3.x could not express. The cases +are also renamed from `SCREAMING_SNAKE_CASE` to `PascalCase`, following PER Coding Style 2.0, which +requires PascalCase for enum cases, and matching PHP's own native `RoundingMode`. The backing +strings carry the persisted value, so the case name is a source-level rename only. + +| 3.x case | 3.x value | 4.0 case | 4.0 value | +|---------------|-----------|-----------------------|-------------| +| `HALF_UP` | `1` | `RoundingMode::HalfUp` | `half-up` | +| `HALF_DOWN` | `2` | `RoundingMode::HalfDown` | `half-down` | +| `HALF_EVEN` | `3` | `RoundingMode::HalfEven` | `half-even` | +| `HALF_ODD` | `4` | `RoundingMode::HalfOdd` | `half-odd` | +| Not present | | `RoundingMode::Up` | `up` | +| Not present | | `RoundingMode::Down` | `down` | +| Not present | | `RoundingMode::Ceiling` | `ceiling` | +| Not present | | `RoundingMode::Floor` | `floor` | + +A rounding mode persisted as the 3.x integer must be migrated to the 4.0 string. There is no +automatic bridge, because the integers were an implementation detail and reusing them would tie the +new enum to the old numbering forever. + +## Exception mapping + +Every exception moved from `TinyBlocks\Math\Internal\Exceptions` to `TinyBlocks\Math\Exceptions`, +because consumers catch them and they therefore belong on the public boundary. All of them now +implement `MathFailure`, so a single catch clause covers the library. + +| 3.x | 4.0 | Note | +|---|---|---| +| `Internal\Exceptions\DivisionByZero` | `Exceptions\DivisionByZero` | Now extends `DivisionByZeroError`. | +| `Internal\Exceptions\InvalidNumber` | `Exceptions\NumberNotWellFormed` | Named after the invariant. | +| `Internal\Exceptions\InvalidScale` | `Exceptions\ScaleOutOfRange` | Named after the invariant. | +| `Internal\Exceptions\MathOperationsNotAvailable` | `Exceptions\CalculatorNotAvailable` | Named after the invariant. | +| `Internal\Exceptions\NonPositiveValue` | Removed | Its only caller was `PositiveBigDecimal`. | +| `Internal\Exceptions\NonNegativeValue` | Removed | Its only caller was `NegativeBigDecimal`. | +| Not present | `Exceptions\NonTerminatingDecimal` | An exact decimal was demanded of a repeating expansion. | +| Not present | `Exceptions\InexactConversion` | An exact conversion would discard digits. | +| Not present | `Exceptions\NegativeExponent` | | +| Not present | `Exceptions\NegativeRoot` | | +| Not present | `Exceptions\ExponentOutOfRange` | An exponent is beyond the supported magnitude. | +| Not present | `Exceptions\NegativeWeight` | | +| Not present | `Exceptions\BaseOutOfRange` | | +| Not present | `Exceptions\IntegerOverflow` | | +| Not present | `Exceptions\MathFailure` | The marker interface every failure implements. | + +## Requirements + +| | 3.x | 4.0 | +|---|---|---| +| PHP | `^8.5` | `^8.5` | +| Extensions | `ext-bcmath` | None required, `ext-bcmath` suggested | +| Dependencies | None | None | + +The PHP floor is unchanged, and the ecosystem pins 8.5. The extension is no longer required. All +eight rounding modes are decided by the library itself, in `RoundingMode::roundsAwayFromZero`, and +applied over the four primitives every backend provides, which is why the pure PHP backend produces +identical digits. That is what let `ext-bcmath` move from `require` to `suggest`. Install it for +speed: money arithmetic, rounding, comparison, allocation and division stay within roughly an order +of magnitude without it, while square roots run several dozen times slower. + +## Worked example + +3.x, where division silently produced a value at whatever scale the operands happened to carry, and +rounding went through a float: + +```php +$total = BigDecimal::fromString(value: '100.00') + ->divide(divisor: BigDecimal::fromString(value: '3')) + ->withRounding(mode: RoundingMode::HALF_EVEN); +``` + +4.0, where the quotient is exact until you say otherwise, and the rounding names both a scale and a +mode: + +```php +dividedBy(divisor: BigDecimal::of(value: '3')) + ->toDecimal(scale: 2, rounding: RoundingMode::HalfEven); +# 33.33 +``` + diff --git a/composer.json b/composer.json index 0af7298..ab973cd 100644 --- a/composer.json +++ b/composer.json @@ -1,17 +1,14 @@ { "name": "tiny-blocks/math", - "type": "library", + "description": "Arbitrary-precision numbers for PHP, where arithmetic is exact and rounding is explicit.", "license": "MIT", - "homepage": "https://github.com/tiny-blocks/math", - "description": "Value Objects for handling arbitrary precision numbers.", - "prefer-stable": true, - "minimum-stability": "stable", + "type": "library", "keywords": [ - "vo", - "psr", "math", - "arithmetic", + "decimal", + "rational", "big-number", + "percentage", "tiny-blocks", "value-object", "arbitrary-precision" @@ -22,16 +19,27 @@ "homepage": "https://github.com/gustavofreze" } ], + "homepage": "https://github.com/tiny-blocks/math", "support": { "issues": "https://github.com/tiny-blocks/math/issues", "source": "https://github.com/tiny-blocks/math" }, - "config": { - "sort-packages": true, - "allow-plugins": { - "infection/extension-installer": true - } + "require": { + "php": "^8.5" + }, + "require-dev": { + "ergebnis/composer-normalize": "^2.52", + "infection/infection": "^0.34", + "phpstan/phpstan": "^2.2", + "phpunit/phpunit": "^13.2", + "slevomat/coding-standard": "^8.31", + "squizlabs/php_codesniffer": "^4.0" + }, + "suggest": { + "ext-bcmath": "Runs the arithmetic through libbcmath, which the pure PHP fallback matches to the digit but trails on speed, by an order of magnitude on most operations and by several dozen times on square roots." }, + "minimum-stability": "stable", + "prefer-stable": true, "autoload": { "psr-4": { "TinyBlocks\\Math\\": "src/" @@ -39,39 +47,35 @@ }, "autoload-dev": { "psr-4": { - "TinyBlocks\\Math\\": "tests/" + "Test\\TinyBlocks\\Math\\": "tests/" } }, - "require": { - "php": "^8.5", - "ext-bcmath": "*" - }, - "require-dev": { - "phpunit/phpunit": "^11.5", - "phpstan/phpstan": "^2.1", - "infection/infection": "^0.32", - "squizlabs/php_codesniffer": "^4.0" - }, - "suggest": { - "ext-bcmath": "Enables the extension which is an interface to the GNU implementation as a Basic Calculator utility library." + "config": { + "allow-plugins": { + "dealerdirect/phpcodesniffer-composer-installer": true, + "ergebnis/composer-normalize": true, + "infection/extension-installer": true + }, + "process-timeout": 0, + "sort-packages": true }, "scripts": { - "test": "php -d memory_limit=2G ./vendor/bin/phpunit --configuration phpunit.xml tests", - "phpcs": "php ./vendor/bin/phpcs --standard=PSR12 --extensions=php ./src", - "phpstan": "php ./vendor/bin/phpstan analyse -c phpstan.neon.dist --quiet --no-progress", - "test-file": "php ./vendor/bin/phpunit --configuration phpunit.xml --no-coverage --filter", - "mutation-test": "php ./vendor/bin/infection --threads=max --logger-html=report/coverage/mutation-report.html --coverage=report/coverage", - "test-no-coverage": "php ./vendor/bin/phpunit --configuration phpunit.xml --no-coverage tests", + "configure": [ + "@composer install --optimize-autoloader", + "@composer normalize" + ], + "configure-and-update": [ + "@composer update --optimize-autoloader", + "@composer normalize" + ], "review": [ - "@phpcs", - "@phpstan" + "@php ./vendor/bin/phpcs --standard=phpcs.xml --extensions=php ./src ./tests", + "@php ./vendor/bin/phpstan analyse -c phpstan.neon.dist --quiet --no-progress" ], + "test-file": "@php ./vendor/bin/phpunit --configuration phpunit.xml --no-coverage --filter", "tests": [ - "@test", - "@mutation-test" - ], - "tests-no-coverage": [ - "@test-no-coverage" + "@php -d memory_limit=2G ./vendor/bin/phpunit --configuration phpunit.xml tests", + "@php ./vendor/bin/infection --threads=max --logger-html=reports/coverage/mutation-report.html --coverage=reports/coverage" ] } } diff --git a/docs/specs/2026-08-13-math-redesign-design.md b/docs/specs/2026-08-13-math-redesign-design.md new file mode 100644 index 0000000..1650f69 --- /dev/null +++ b/docs/specs/2026-08-13-math-redesign-design.md @@ -0,0 +1,874 @@ +# tiny-blocks/math 2.0.0 design proposal + +Date: 2026-08-13. Status: approved and implemented. Supersedes the 1.x/3.x public surface entirely. + +## 1. Why 2.0 exists + +The current library is not arbitrary precision. Three of its core operations route through `float`, +and every comparison uses PHP's string operators. Both defects were reproduced in this session +against the project's own tooling image. + +| Defect | Site | Reproduction | Result | +|---|---|---|---| +| Comparison loses precision past 53 bits. | `Internal/Number::isLessThan` and siblings, which use `<`, `>`, `==` on strings. | `'0.10000000000000000001' == '0.10000000000000000002'` | `true` in PHP, while `bccomp(..., 30)` returns `-1`. | +| Rounding routes through `float`. | `RoundingMode::round`, which calls `toFloat()`. | `(string)(float)'12345678901234567890.5'` | `'1.2345678901235E+19'`, against `'12345678901234567890'` from `BcMath\Number::round`. | +| Absolute value routes through `float`. | `Internal/BigNumberBehavior::absolute`, which calls `abs((float)...)`. | Same mantissa ceiling as above. | Silent loss. | +| Scale change truncates and can fault. | `Internal/Scale::numberWithScale`, which slices the fractional digits and indexes `$result[1]`. | A value with no fractional part. | Truncation instead of rounding, plus an undefined index. | +| Sign subtypes break substitutability. | `PositiveBigDecimal::add` returns through `static::fromString`. | Adding a larger negative addend. | The invariant of the subtype rejects the result of its own operation. | + +Beyond the defects, the surface is narrower than what PHP developers already reach for. There are +four rounding modes, all of them half modes, so directed rounding (floor, ceiling, truncate) is +unreachable. There is no integer type, no rational type, and no percentage type. There is no +`Stringable`, no `JsonSerializable`, and no equality contract. + +## 2. What PHP 8.5 changed under the library's feet + +Every fact in this section was measured in this session by running probes in the project's image, +not read from documentation. + +`BcMath\Number` ships with the bundled `bcmath` extension since PHP 8.4 and is present in the image. +It is `final readonly`, implements `Stringable`, and exposes `public string $value` and +`public int $scale`. Its methods are `__construct(string|int)`, `add`, `sub`, `mul`, `div`, `mod`, +`divmod`, `powmod`, `pow`, `sqrt`, `floor`, `ceil`, `round`, `compare`, `__toString`, `__serialize`, +`__unserialize`. Binary operations take `BcMath\Number|string|int $num, ?int $scale = null`. + +Native `RoundingMode` (a pure enum, PHP 8.4) has eight cases: `HalfAwayFromZero`, `HalfTowardsZero`, +`HalfEven`, `HalfOdd`, `TowardsZero`, `AwayFromZero`, `NegativeInfinity`, `PositiveInfinity`. All +eight were verified exact at scale 2 on `0.995`, `-0.995`, `0.125`, `-0.125`, and `sqrt(2)` was +verified exact at scale 100, 1000, and 10000. + +Measured scale propagation: `add` and `sub` give `max(scaleA, scaleB)`, `mul` gives +`scaleA + scaleB`, and `div` computes at `scaleA + 10` (the divisor's scale is ignored) then strips +trailing zeros, so `1/7` gives scale 10, `1.0/7` gives scale 11, and `1/2048` truncates to +`0.0004882812`. + +Measured failure modes: `div` and `mod` by zero raise `DivisionByZeroError`. `'1e3'`, `'1E-3'`, +`'1_000'`, `'abc'`, and `'1/2'` all raise `ValueError`. `'.5'` and `'+5'` are accepted. The empty +string silently yields `0`. `pow` rejects a fractional exponent. `sqrt(-1)` raises `ValueError`. +`json_encode` emits `{"value":"1.50","scale":2}`, not a numeric string. `compare('1.0', '1.00')` +returns `0`, so comparison is numeric rather than lexical. Operator overloading works for +`+ - * / **` and for `== < >`. + +Performance, 200000 iterations per case, script `backend-bench.php`, run through the project's +tooling image with `php -d memory_limit=1G backend-bench.php`: + +``` +PHP 8.5.5 iterations=200000 +bcmath=1 gmp=0 + +--- addition --- +bcadd(string, string, scale) 333.22 ms +BcMath\Number->add(BcMath\Number) [fresh objects] 699.49 ms +BcMath\Number->add(BcMath\Number) [reused] 342.38 ms +BcMath\Number + BcMath\Number [operator] 164.69 ms + +--- multiplication --- +bcmul(string, string, scale) 856.75 ms +BcMath\Number->mul(BcMath\Number) [reused] 490.01 ms + +--- construction cost --- +new BcMath\Number(string) 419.30 ms +preg_match numeric validation 604.87 ms + +--- big integer work (256-bit factorial-ish chain) --- +bcmul chain 1..300 187.05 ms +BcMath\Number->mul chain 1..300 160.89 ms +``` + +Two readings drive the design. `BcMath\Number` beats the raw `bc*` string functions only when the +operand stays parsed between calls (342 ms against 333 ms for addition, and 490 ms against 857 ms +for multiplication), and loses when each call starts from strings (699 ms against 333 ms). The +calculation backend's contract is string in and string out, so it never reuses an operand, and +section 15 records the consequence. And constructing a `BcMath\Number` costs less than the +`preg_match` the 1.x `Internal\Number` runs on every instance, so the 1.x regex is a net loss +before any arithmetic happens. + +Rational arithmetic was also measured viable without GMP. A Euclidean gcd over `BcMath\Number` +costs about 7.6 microseconds on 30-digit operands, and detecting a terminating decimal expansion +(strip factors 2 and 5, check the remainder is 1) is exact and cheap. Script +`rational-viability.php`, same command shape. + +**`ext-gmp` is not present in the project's image.** This is measured, and it constrains section 7. + +## 3. Comparison of the references + +Every row below is backed by a source fetched or a file read in this session. Sources are listed in +section 12. + +| | brick/math 0.19.1 | ext-decimal 2.0.1 | BcMath\Number (PHP 8.5) | bcmath functions | maba/math | Java BigDecimal (21) | Python decimal | decimal.js 10.6.0 | +|---|---|---|---|---|---|---|---|---| +| **Type surface** | `BigNumber` (abstract), `BigInteger`, `BigDecimal`, `BigRational`, `RoundingMode`, 11 exceptions. | `Decimal\Number` (abstract), `Decimal\Decimal`, `Decimal\Rational`. | One class. | None, strings only. | No value object at all, four service interfaces over raw strings. | `BigDecimal`, `BigInteger`, `MathContext`, `RoundingMode`. | `Decimal`, `Context`. | `Decimal` constructor plus clones. | +| **Construction** | `of()` dispatches by string shape, `float` rejected, plus `fromFloatExact` and `fromFloatShortest`. Constructors `protected`. | Private constructors, `valueOf()`. | Public constructor taking `string\|int`. Empty string yields 0. | Any string matching `/^[+-]?[0-9]*(\.[0-9]*)?$/`, which also accepts `''`, `'+'`, and `'.'`. | Raw strings, regex validated twice per operation. | Public `BigDecimal(double)` kept despite its own javadoc advising against it. | `Decimal(3.14)` silently ingests a float by default. | `new Decimal(0.7 + 0.1)` yields `'0.7999999999999999'`. | +| **Precision model** | Fixed scale per instance. No context, no ambient default. | Significant figures, default 34, propagated as the **minimum** of the two operands. | Fixed scale per instance, per-operation propagation lattice. | Fixed scale from the process-global `bcscale()` / `bcmath.scale`, default `0`. | Single scale fixed at construction of the service. | Fixed scale on the value, significant digits in a per-call `MathContext`. | Significant digits in a thread-global ambient `Context`, default 28. | Significant digits in a constructor-global `precision`, default 20. | +| **Rounding modes** | 11, own pure enum, default `Unnecessary` everywhere. | 9, integer class constants, default `HALF_EVEN`, arithmetic rounding hardcoded and not configurable. | 8, the native pure enum, default `HalfAwayFromZero`. | 8 through `bcround`, only PHP 8.4+, and arithmetic itself truncates. | An unvalidated `int`, unknown values silently fall through. | 8, own enum, plus 8 deprecated `int` constants kept forever. | 8, bare strings, no type. | 10, bare integers typed as a union. | +| **Immutability** | `readonly` classes. | `final` classes, embedded `mpd_t`. | `final readonly`. | Not applicable. | Services are mutable, values are strings. | Immutable, but `setScale` is named as a mutator. | Immutable values, mutable ambient context. | Properties are writable at runtime, `readonly` only in the type definitions. | +| **Error model** | `interface MathException extends Throwable`, 10 final classes, named static constructors. | No library exception type. Nine failure modes across stock SPL classes. Precision overflow is an `E_WARNING`, and the computation continues. | `ValueError` and `DivisionByZeroError`, both extending `Error`. | Same two, distinguishable only by message. | `MathException` does not extend `Throwable`. The division-by-zero guard is dead code on PHP 8. | Everything is `ArithmeticException`, separable only by message string. | Signals plus traps. Untrapped conditions return `NaN` or `Infinity`. | Bare `Error` with a `[DecimalError]` string prefix. | +| **Backend selection** | `CalculatorRegistry`, GMP then BCMath then native, in a mutable public static, `@internal`. GMP and BCMath appear nowhere in `composer.json`. | libmpdec only. | libbcmath only. | libbcmath or libgmp, not selectable. | Manual injection, one implementation, `ext-bcmath` only suggested. | Not applicable. | C `_decimal` or pure Python, with observable behavior differences. | Not applicable. | +| **Formatting** | One string form. `jsonSerialize` returns a JSON string. No formatting layer. | `toString`, `toFixed`, `toScientific`. `toScientific`'s declared argument does not work. | `__toString` only. `json_encode` leaks the internal shape. | None. | A separate formatter service. | `toString` emits scientific notation for `0.00123`. `toPlainString` is the secondary method. | `str` emits `2E+2` for `Decimal('200').normalize()`. | Notation flips on global `toExpNeg`/`toExpPos`. | +| **What it gets wrong** | `dividedBy` has three incompatible signatures across the three types and is absent from the base, so generic code cannot divide. `1.0` and `1.00` are `isEqualTo` but have no `equals`. Permanently 0.x. | Documentation describes v1 while PECL ships v2 with inverted precision propagation. Comparing to `int` or `float` routes through `double`. A failed comparison is indistinguishable from "greater than". | No `abs`, no `negate`, no predicates, no `equals`. Not `JsonSerializable`. The `+10` division constant is arbitrary. Empty string is 0. | Ambient global scale. Silent truncation with no signal. Three different modulo sign conventions across `bcmod`, `gmp_mod`, and `gmp_div_r`. | `abs('5')` and `abs('-5')` return strings of different scale, so `===` on library output is wrong. Dead since 2014. | `equals` and `compareTo` disagree by design. `divide(divisor, mode)` silently takes the receiver's scale, so `2.0/3` and `2.00/3` give different answers. | Global mutable context makes addition non-associative: with `prec = 3`, `3.104 + 2.104` is `5.21` but `3.104 + 0 + 2.104` is `5.20`. | Addition is lossy: at `precision: 5`, `100000 + 0.0001` is `100000`. Trailing zeros unrepresentable, so `USD 3.00` cannot round-trip. | + +Adoption, read off Packagist on 2026-08-13: `brick/math` 571,930,439 downloads, `moneyphp/money` +96,428,226, `brick/money` 43,814,758, `moontoast/math` 26,470,797 (abandoned, replaced by +brick/math), `litipk/php-bignumbers` 2,187,334 (abandoned), `php-decimal/php-decimal` 1,134,136, +`maba/math` 72,994 (last release 2014), `tiny-blocks/math` 22,665. + +### What none of them does well + +1. **Every reference puts precision policy in the wrong place.** Python, decimal.js, and ext-decimal + put it in ambient global state, which makes arithmetic depend on code you did not write. Java + puts it in a per-call `MathContext` you must thread through every link of a chain. brick/math + puts it in a mandatory positional `$scale` on one method of one type. bcmath puts it in a + process-global INI setting that defaults to `0`. Nobody makes the exact answer the default and + the rounding an explicit, local, typed decision. +2. **Nobody makes inexactness a type.** brick/math and Java both signal it with an exception, and + both collapse two different failures into one class: a quotient that repeats forever (which the + caller cannot fix) and a quotient that terminates but needs more digits (which the caller fixes + by asking for more digits). Telling them apart requires matching message strings. +3. **Nobody separates representation equality from value equality by name.** Java has `equals` + against `compareTo` and documents the trap three times. brick/math has only `isEqualTo`, so + `1.0` and `1.00` are equal but not substitutable. ext-decimal orders values by their precision + field while its own source comment claims precision is ignored. +4. **No PHP library models a percentage or a proportion.** Every business codebase writes + `$amount * $rate / 100` by hand, with the rounding decision made implicitly by whatever the + division did. +5. **Nobody exploits what PHP 8.5 already ships.** `BcMath\Number` is faster than the `bc*` string + functions it replaces, and the eight native rounding modes are exact. A PHP library written in + 2026 that reimplements decimal string plumbing is doing work the runtime already did. + +## 4. Design stance + +Four rules decide every call below. + +1. **Arithmetic never rounds.** `plus`, `minus`, and `multipliedBy` are exact by construction. + Division returns a `BigRational`, which is exact for every pair of operands. No operation in the + library silently loses a digit, and none throws because it would have to. +2. **Rounding is an explicit, local, typed conversion.** It happens only when the caller names both + a scale and a `RoundingMode`. There is no default rounding mode, no ambient context, and no + per-value policy to forget. +3. **Inexactness is a type, not an exception.** Where the references throw + `RoundingNecessaryException` or `ArithmeticException`, this library hands back a `BigRational` + that holds the exact answer. The caller decides when to leave exactness behind. +4. **Delegate to the runtime.** `BcMath\Number` does the arithmetic and the native `RoundingMode` + performs the rounding. The library owns the vocabulary and the value semantics, not the digit + shuffling. + +## 5. Type surface + +Namespace `TinyBlocks\Math`. Layout follows the architecture rule: contracts, public enums, and +thin value objects at the `src/` root, all algorithms in `src/Internal/`, public exceptions in +`src/Exceptions/`. + +``` +src/ +├── Number.php # shared contract +├── BigInteger.php +├── BigDecimal.php +├── BigDecimals.php # collection, allocation weights and allocation result +├── BigRational.php +├── Percentage.php +├── Ratio.php +├── RoundingMode.php # public enum +├── Calculator.php # backend contract +├── Calculators.php # backend resolution, static surface +├── Exceptions/ +│ ├── MathFailure.php # marker interface over Error and Exception lineages +│ ├── CalculatorNotAvailable.php +│ ├── DivisionByZero.php +│ ├── InexactConversion.php +│ ├── IntegerOverflow.php +│ ├── NegativeRoot.php +│ ├── NegativeWeight.php +│ ├── NonTerminatingDecimal.php +│ ├── NumberNotWellFormed.php +│ └── ScaleOutOfRange.php +└── Internal/ + ├── Allocation.php # largest remainder distribution + ├── BcMathCalculator.php + ├── Digits.php # normalized decimal string, wraps BcMath\Number + ├── Fraction.php # gcd, lowest terms, terminating-expansion test + ├── NumberComparison.php # trait carrying the nine shared predicates + └── Scale.php # scale arithmetic and bounds +``` + +### 5.1 `Number` (interface) + +The shared contract. Extends `Stringable`, `JsonSerializable`, and `TinyBlocks\Vo\ValueObject`. +It exists so a consumer can write a function over "any number this library produces" without +caring which of the three concrete types arrived. brick/math uses an abstract class for this and +has to carry three `protected` constructor proxies to work around PHP's lack of friend access. An +interface plus composition avoids that entirely. + +| Method | Returns | Notes | +|---|---|---| +| `compareTo(Number $other)` | `int` | `-1`, `0`, or `1`. Numeric, never lexical. | +| `isZero()` | `bool` | | +| `isPositive()` | `bool` | Strictly greater than zero. | +| `isNegative()` | `bool` | Strictly less than zero. | +| `isEqualTo(Number $other)` | `bool` | **Value** equality. `1.0` equals `1.00`. | +| `isLessThan(Number $other)` | `bool` | | +| `isGreaterThan(Number $other)` | `bool` | | +| `isLessThanOrEqualTo(Number $other)` | `bool` | | +| `isGreaterThanOrEqualTo(Number $other)` | `bool` | | +| `negated()` | `Number` | | +| `absolute()` | `Number` | Exact, never through `float`. | +| `toBigRational()` | `BigRational` | Total on all three types. | +| `toString()` | `string` | Round-trips through the type's own `of()`. | +| `equals(ValueObject $other)` | `bool` | **Representation** equality from `tiny-blocks/value-object`. `1.0` does not equal `1.00`. | +| `hashCode()` | `string` | Agrees with `equals`, per the ecosystem contract. | +| `jsonSerialize()` | `string` | A JSON string, never a JSON number. | + +The `isEqualTo` against `equals` split is deliberate and is the answer to Java's most documented +trap. The names carry the difference: `isEqualTo` is arithmetic, `equals` is the ecosystem's +structural contract. Both are documented on the interface with the `1.0` against `1.00` example. + +### 5.2 `BigInteger` + +Exists because integer problems (identifiers, counters, combinatorics, modular arithmetic) have no +scale, and forcing them through a decimal type means carrying a scale that is always zero and +paying for fractional handling that never runs. + +| Member | Signature | Complexity | +|---|---|---| +| `of` | `static of(string\|int $value): BigInteger` | O(n) in digits. | +| `zero` | `static zero(): BigInteger` | O(1). | +| `one` | `static one(): BigInteger` | O(1). | +| `fromBase` | `static fromBase(string $value, int $base): BigInteger` | O(n) in digits. | +| `plus` | `plus(BigInteger $addend): BigInteger` | O(n). | +| `minus` | `minus(BigInteger $subtrahend): BigInteger` | O(n). | +| `multipliedBy` | `multipliedBy(BigInteger $multiplier): BigInteger` | O(n·m) via libbcmath. | +| `dividedBy` | `dividedBy(BigInteger $divisor): BigRational` | Exact. Throws `DivisionByZero`. | +| `quotient` | `quotient(BigInteger $divisor): BigInteger` | Truncated toward zero. | +| `remainder` | `remainder(BigInteger $divisor): BigInteger` | Sign follows the dividend, matching `bcmod`. | +| `modulo` | `modulo(BigInteger $modulus): BigInteger` | Always non-negative, matching `gmp_mod`. | +| `power` | `power(int $exponent): BigInteger` | Rejects a negative exponent. | +| `squareRoot` | `squareRoot(): BigInteger` | Floor. Throws `NegativeRoot`. | +| `greatestCommonDivisor` | `greatestCommonDivisor(BigInteger $other): BigInteger` | O(log n) Euclidean, measured at 7.6 microseconds on 30-digit operands. | +| `isEven` | `isEven(): bool` | O(1). | +| `isOdd` | `isOdd(): bool` | O(1). | +| `toBigDecimal` | `toBigDecimal(): BigDecimal` | Scale 0. | +| `toBase` | `toBase(int $base): string` | | +| `toInt` | `toInt(): int` | Throws `IntegerOverflow` outside the native range. | + +Plus the full `Number` contract. `remainder` and `modulo` are separate methods with documented sign +conventions, because the research found three different conventions across `bcmod`, `gmp_mod`, and +`gmp_div_r`, and hiding that behind one name is how backend swaps change answers. + +### 5.3 `BigDecimal` + +The primary type. Representation is an unscaled `BigInteger` plus a non-negative `int` scale, the +same model Java and brick/math use and the one `BcMath\Number` exposes. + +| Member | Signature | Result scale | +|---|---|---| +| `of` | `static of(string\|int $value): BigDecimal` | The scale of the literal. Exponent notation accepted and normalized. | +| `zero` | `static zero(): BigDecimal` | 0. | +| `one` | `static one(): BigDecimal` | 0. | +| `ofUnscaledValue` | `static ofUnscaledValue(BigInteger $unscaled, int $scale): BigDecimal` | `$scale`. | +| `fromFloat` | `static fromFloat(float $value): BigDecimal` | The shortest decimal that round-trips to the same float. The only lossy entry point, and named so. | +| `plus` | `plus(BigDecimal $addend): BigDecimal` | `max(a, b)`. | +| `minus` | `minus(BigDecimal $subtrahend): BigDecimal` | `max(a, b)`. | +| `multipliedBy` | `multipliedBy(BigDecimal $multiplier): BigDecimal` | `a + b`. | +| `dividedBy` | `dividedBy(BigDecimal $divisor): BigRational` | Exact. Throws `DivisionByZero`. | +| `power` | `power(int $exponent): BigDecimal` | `a × exponent`. | +| `squareRoot` | `squareRoot(int $scale, RoundingMode $rounding): BigDecimal` | `$scale`. Throws `NegativeRoot`. | +| `toScale` | `toScale(int $scale, RoundingMode $rounding): BigDecimal` | `$scale`. | +| `toScaleExact` | `toScaleExact(int $scale): BigDecimal` | `$scale`. Throws `InexactConversion` when digits would be lost. | +| `withoutTrailingZeros` | `withoutTrailingZeros(): BigDecimal` | Minimal scale representing the same value. | +| `scale` | `scale(): int` | | +| `unscaledValue` | `unscaledValue(): BigInteger` | | +| `integralPart` | `integralPart(): BigInteger` | Truncated toward zero. | +| `fractionalPart` | `fractionalPart(): BigDecimal` | Same scale, same sign as the receiver. | +| `allocate` | `allocate(BigDecimals $weights, int $scale): BigDecimals` | `$scale`. Largest remainder. The parts sum exactly to the receiver. | +| `toBigInteger` | `toBigInteger(): BigInteger` | Throws `InexactConversion` when the fractional part is non-zero. | +| `toFloat` | `toFloat(): float` | Throws on overflow, unlike brick/math which returns infinity. | + +Plus the full `Number` contract. `toString` always emits plain positional notation, never +scientific. Java's canonical `toString` emits `1.23E-3` for `0.00123`, which is wrong for every +money use case, and forces `toPlainString` as a secondary method. This library has one string form +and it is the one you want. + +There is no `dividedBy(divisor, scale, rounding)` overload. Division has one signature on all three +numeric types, takes one argument, and is total except for a zero divisor. This is the single +largest ergonomic gain over brick/math, where the same method has three incompatible signatures and +is absent from the shared base. + +### 5.4 `BigRational` + +Exists because it is what makes rule 1 possible. Without it, division must either round silently +(bcmath, decimal.js), demand a scale up front (brick/math), or throw (Java). With it, division has +an exact answer for every input, and the caller converts when ready. + +Always stored in lowest terms with a strictly positive denominator. + +| Member | Signature | Notes | +|---|---|---| +| `of` | `static of(string $value): BigRational` | Accepts `'3/4'`, `'0.75'`, and `'3'`. | +| `ofFraction` | `static ofFraction(BigInteger $numerator, BigInteger $denominator): BigRational` | Throws `DivisionByZero` on a zero denominator. | +| `zero` | `static zero(): BigRational` | | +| `one` | `static one(): BigRational` | | +| `plus` | `plus(BigRational $addend): BigRational` | Exact. | +| `minus` | `minus(BigRational $subtrahend): BigRational` | Exact. | +| `multipliedBy` | `multipliedBy(BigRational $multiplier): BigRational` | Exact. | +| `dividedBy` | `dividedBy(BigRational $divisor): BigRational` | Exact. Throws `DivisionByZero`. | +| `power` | `power(int $exponent): BigRational` | Negative exponents supported, unlike the other two types. | +| `reciprocal` | `reciprocal(): BigRational` | Throws `DivisionByZero` on zero. | +| `numerator` | `numerator(): BigInteger` | | +| `denominator` | `denominator(): BigInteger` | Always positive. | +| `hasTerminatingDecimal` | `hasTerminatingDecimal(): bool` | True when the reduced denominator is 2^a·5^b. Measured exact and cheap. | +| `toDecimal` | `toDecimal(int $scale, RoundingMode $rounding): BigDecimal` | | +| `toDecimalExact` | `toDecimalExact(): BigDecimal` | Throws `NonTerminatingDecimal` when the expansion repeats. | +| `toBigInteger` | `toBigInteger(): BigInteger` | Throws `InexactConversion` when the denominator is not 1. | +| `toFloat` | `toFloat(): float` | | + +Plus the full `Number` contract. `toString` emits `3/4`, collapsing to `3` when the denominator is +1. `hasTerminatingDecimal` is the API brick/math lacks: there, the only way to ask is to call +`dividedByExact` and catch an exception whose two meanings differ only by message text. + +### 5.5 `Percentage` + +Exists because applying a rate to an amount is the most common decimal operation in business code +and no PHP arbitrary-precision library models it. Backed by a `BigDecimal` rate, so application to +an amount is exact: a percentage is a decimal, a decimal times a decimal is exact, and no rounding +decision is forced on the caller until presentation. + +| Member | Signature | Notes | +|---|---|---| +| `of` | `static of(string\|int $value): Percentage` | `Percentage::of(value: '12.5')` is 12.5 percent. | +| `zero` | `static zero(): Percentage` | | +| `fromRatio` | `static fromRatio(Ratio $ratio, int $scale, RoundingMode $rounding): Percentage` | Requires a scale because a ratio may not terminate. | +| `applyTo` | `applyTo(BigDecimal $amount): BigDecimal` | Exact. Scale is `amount + rate + 2`. | +| `increase` | `increase(BigDecimal $amount): BigDecimal` | Exact. `amount + applyTo(amount)`. | +| `decrease` | `decrease(BigDecimal $amount): BigDecimal` | Exact. `amount - applyTo(amount)`. | +| `rate` | `rate(): BigDecimal` | `0.125` for 12.5 percent. | +| `toRatio` | `toRatio(): Ratio` | | +| `toString` | `toString(): string` | `12.5%`. | + +Plus the comparison predicates of `Number`, typed against `Percentage`. Negative percentages and +percentages above 100 are legal, because a negative growth rate and a 150 percent increase are both +real. There is no invariant to enforce and therefore no exception. + +### 5.6 `Ratio` + +An exact proportion between two quantities, stored as a `BigRational` in lowest terms with a +`a:b` string form. + +| Member | Signature | Notes | +|---|---|---| +| `of` | `static of(BigInteger\|int\|string $antecedent, BigInteger\|int\|string $consequent): Ratio` | Throws `DivisionByZero` on a zero consequent. | +| `between` | `static between(Number $antecedent, Number $consequent): Ratio` | Exact, no rounding decision. | +| `applyTo` | `applyTo(BigDecimal $amount): BigRational` | Exact, `amount × antecedent / consequent`. | +| `inverted` | `inverted(): Ratio` | | +| `antecedent` | `antecedent(): BigInteger` | | +| `consequent` | `consequent(): BigInteger` | | +| `toPercentage` | `toPercentage(int $scale, RoundingMode $rounding): Percentage` | | +| `toBigRational` | `toBigRational(): BigRational` | | +| `toString` | `toString(): string` | `16:9`. | + +Over `BigRational` it adds the `a:b` string form, the `between` factory, and the semantic that it +is a relationship between two quantities rather than a quantity. `Ratio::between` is the one place +in the library where a proportion is derived from two arbitrary numbers without anyone having to +name a scale, because the result stays exact. + +### 5.7 `BigDecimals` + +An immutable, `Countable` collection of `BigDecimal`, wrapping a `Collectible` from +`tiny-blocks/collection`, in the same shape as `Timezones` in `tiny-blocks/time`. It is both the +input to allocation (the weights) and its output (the parts). + +| Member | Signature | Notes | +|---|---|---| +| `of` | `static of(string\|int ...$values): BigDecimals` | `BigDecimals::of('1', '1', '1')`. | +| `from` | `static from(BigDecimal ...$values): BigDecimals` | | +| `sum` | `sum(): BigDecimal` | Exact. Scale is the maximum of the elements. Zero for an empty collection. | +| `all` | `all(): list` | | +| `count` | `count(): int` | | + +Weights are `BigDecimal` rather than `BigInteger` so that a `1.5 : 2.5` split is expressible. + +**Allocation.** `BigDecimal::allocate(BigDecimals $weights, int $scale)` computes each exact share +as a `BigRational`, truncates it toward negative infinity at `$scale`, then hands the leftover +minor units out one at a time in descending order of the discarded remainder, ties broken by +position. The result therefore sums to the receiver exactly, which plain rounding cannot guarantee: +`100.00` split three ways at scale 2 gives `33.34`, `33.33`, `33.33`, never three times `33.33` +with a cent missing. A negative weight raises `NegativeWeight`. Weights summing to zero raise +`DivisionByZero`. A weight of zero is legal and receives nothing. + +No reference library in section 3 offers this at the number level. `moneyphp/money` offers it only +on `Money`, which forces a currency on a problem that does not need one. + +### 5.8 `RoundingMode` + +A string-backed enum owned by the library, with the eight cases below. Every case maps one to one +onto PHP's native `RoundingMode`, and the mapping was verified in this session, in both directions, +against `-2.345` at scale 2. The results match Java's published rounding table for the same input. + +| Case | Backing value | Native equivalent | `round('-2.345', 2)` | +|---|---|---|---| +| `Up` | `up` | `AwayFromZero` | `-2.35` | +| `Down` | `down` | `TowardsZero` | `-2.34` | +| `Floor` | `floor` | `NegativeInfinity` | `-2.35` | +| `HalfUp` | `half-up` | `HalfAwayFromZero` | `-2.35` | +| `Ceiling` | `ceiling` | `PositiveInfinity` | `-2.34` | +| `HalfOdd` | `half-odd` | `HalfOdd` | `-2.35` | +| `HalfDown` | `half-down` | `HalfTowardsZero` | `-2.34` | +| `HalfEven` | `half-even` | `HalfEven` | `-2.34` | + +| Member | Signature | Notes | +|---|---|---| +| `fromNativeRoundingMode` | `static fromNativeRoundingMode(NativeRoundingMode $mode): RoundingMode` | For consumers holding a native mode from configuration or `Intl`. | +| `toNativeRoundingMode` | `toNativeRoundingMode(): NativeRoundingMode` | The value each case owns, per the tell-don't-ask rule for enums. | + +The names follow Java and brick/math (`Up`, `Down`, `Ceiling`, `Floor`, `Half*`) rather than the +native `AwayFromZero` and `PositiveInfinity` spelling, because that vocabulary is what the +literature on monetary rounding uses. The backing values are stable strings, so a rounding policy +survives a round trip through configuration or a database column. There is no `Unnecessary` case: +in this design nothing rounds implicitly, and the exact-or-fail path is `toScaleExact` and +`toDecimalExact`, which are methods rather than a mode meaning "do not round". + +The file imports the native enum as `use RoundingMode as NativeRoundingMode;`. This was verified to +compile alongside the library's own `RoundingMode` declaration in the same file. + +## 6. Precision and rounding + +The precision model is **fixed scale carried on the value**, never significant digits. Significant +digits are what Python, decimal.js, and ext-decimal use, and all three demonstrate the same failure: +at `precision: 5`, decimal.js computes `100000 + 0.0001` as `100000`, silently. Money needs a scale +that survives addition, and a scale is what `USD 3.00` is. + +Scale propagation, identical to Java's preferred-scale table and to the measured `BcMath\Number` +behavior, so nothing here will surprise a reader who knows either: + +| Operation | Result scale | +|---|---| +| `plus`, `minus` | `max(a, b)` | +| `multipliedBy` | `a + b` | +| `power(n)` | `a × n` | +| `dividedBy` | Not applicable, the result is a `BigRational`. | +| `toScale(s, r)`, `toScaleExact(s)`, `squareRoot(s, r)` | `s` | +| `withoutTrailingZeros()` | The minimum representing the same value. | + +Scale is bounded. `Internal\Scale` rejects a negative scale and a scale above libbcmath's ceiling +with `ScaleOutOfRange`. Scale arithmetic (`a + b` on multiply, `a × n` on power) is overflow-checked +and raises `ScaleOutOfRange` rather than producing a wrong scale, because brick/math's own +`Safe::mul` exists for exactly this reason. + +**Rounding modes are `TinyBlocks\Math\RoundingMode`**, defined in section 5.8. Eight cases against +the four of 1.x, adding the directed modes (`Up`, `Down`, `Ceiling`, `Floor`) that monetary and tax +code needs and 1.x cannot express. The arithmetic is still the runtime's: each case resolves to the +native `RoundingMode` and `BcMath\Number::round` does the work, so the library owns the vocabulary +and not the digit handling. The translation table is eight rows, exhaustive by `match` over a +sealed enum, and covered in both directions by tests. + +**When an operation cannot be represented exactly**, the library never guesses: + +- Division always can be represented exactly, as a `BigRational`. Nothing is thrown and nothing + is lost. +- `toDecimalExact()` on a repeating expansion throws `NonTerminatingDecimal`. The caller cannot fix + this by asking for more digits. +- `toScaleExact()` on a value needing more digits, and `toBigInteger()` on a value with a fractional + part, throw `InexactConversion`. The caller can fix this by asking for more digits or by rounding. +- Those two failures are separate classes. brick/math and Java both collapse them into one type, + and section 3 records that the only way to tell them apart there is matching message strings. + +## 7. Calculation backend + +`Calculator` is a public interface at the `src/` root. It is the seam a consumer with `ext-gmp` +uses to accelerate the integer path, and the seam the library uses to keep `BcMath\Number` out of +the value objects. + +```php +toScale(scale: Currency::BRL->getFractionDigits(), rounding: RoundingMode::HalfEven); +``` + +**`tiny-blocks/value-object`.** Added to `require` as `^5.0`. Every public numeric type implements +`ValueObject`. The trait is not used: it requires public properties, and the internal representation +is not part of the contract. `equals` and `hashCode` are implemented against the private state. + +**`tiny-blocks/collection`.** Added to `require` as `^2.6`, for `BigDecimals`. The collection is +held privately and never leaks, so `Collectible` is not part of this library's contract. + +These add two edges to the ecosystem dependency graph: `tiny-blocks/math` requires +`tiny-blocks/value-object`, and `tiny-blocks/math` requires `tiny-blocks/collection`. The meta +repository is not in this checkout, so the graph is not updated here and the new edges are reported +instead. Both packages are first-party and therefore exempt from the seven-day dependency cooldown. + +## 10. Minimum PHP version + +**`^8.5`.** Four reasons, in descending weight: + +1. `BcMath\Number` and the native `RoundingMode` enum arrived in **8.4** and the design rests on + both. Anything below 8.4 is impossible without reimplementing them. +2. The ecosystem floor is 8.5. `tiny-blocks/value-object`, `tiny-blocks/currency`, and + `tiny-blocks/time` all require `^8.5`, and the tooling rule pins `require.php` to the canonical + asset value. A library that pairs with them must not sit below them. +3. The project's Docker image is PHP 8.5.5, so 8.5 is what actually gets tested. + +`#[\NoDiscard]`, which is 8.5, was applied to every public instance method in a first pass and then +removed: on a library where every method is pure and returns a new instance, the attribute lands on +all of them, and a hundred and three annotations buy a diagnostic that costs more in noise than the +mistake costs in practice. + +## 11. Decisions that could reasonably have gone another way + +Items 1 to 4 were settled by the user at the approval gate. The alternative each was weighed +against is recorded so a later reader knows the call was made rather than defaulted into. + +1. **Division returns `BigRational`, not `BigDecimal`.** Settled. The alternative was brick/math's + mandatory scale plus rounding mode on every division. Chosen because it makes division total and + moves the rounding decision to the point of presentation. The cost is that `1/4` also comes back + as a rational and needs `toDecimalExact()`. +2. **The library owns `TinyBlocks\Math\RoundingMode`.** Settled against using PHP's native enum + directly. The cost is an eight-row translation table, exhaustive and tested in both directions. + The gain is the `Ceiling`/`Floor`/`HalfUp` vocabulary the monetary literature uses, and stable + backing strings that survive a database column. +3. **Only `BcMathCalculator` ships.** Settled against adding `ext-gmp` to the CI image first. + Driven by the measured absence of the extension against the MSI 100 threshold. +4. **`Percentage`, `Ratio`, and N-way allocation are all in scope.** Settled. Allocation brings a + `BigDecimals` collection and a dependency on `tiny-blocks/collection`. +5. **The backend override is a process-wide static.** Not put to the user, because the alternative + (no override at all) would make the public `Calculator` interface decorative. Bounded as + described in section 7. +6. **`PositiveBigDecimal` and `NegativeBigDecimal` are removed** rather than fixed. A sign + constraint is a consumer's domain invariant, and as subtypes they break substitutability. +7. **`fromFloat` is kept**, as the single named lossy entry point, rather than banning float + outright as brick/math's `of()` does. +8. **Weights for allocation are `BigDecimal`, not `BigInteger`**, so a `1.5 : 2.5` split is + expressible and only one collection type is needed. + +## 12. Removed from 1.x + +| 1.x symbol | 2.0 replacement | Reason | +|---|---|---| +| `BigNumber` (interface) | `Number` | The name said "big", the contract said "number". An interface, not an abstract class. | +| `BigNumber::AUTOMATIC_SCALE` | Removed | Scale comes from the literal or from an explicit conversion. A null scale meaning "decide for me" is the ambient-context mistake in miniature. | +| `BigDecimal::fromString` | `BigDecimal::of` | One factory, one name. | +| `BigDecimal::fromFloat` | `BigDecimal::fromFloat` | Kept, but no longer takes a scale and is documented as the only lossy entry point. | +| `PositiveBigDecimal` | Removed | Breaks substitutability, see section 11 item 7. | +| `NegativeBigDecimal` | Removed | Same. | +| `TinyBlocks\Math\RoundingMode` (int-backed, 4 cases) | `TinyBlocks\Math\RoundingMode` (string-backed, 8 cases) | Four half modes become eight, adding directed rounding. Backing type changes from `int` to `string`, so persisted values must be migrated. The rounding itself no longer routes through `float`. | +| `add`, `subtract`, `multiply`, `divide` | `plus`, `minus`, `multipliedBy`, `dividedBy` | Matches `Duration::plus` in `tiny-blocks/time` and reads as a copy operation on an immutable. | +| `withRounding(RoundingMode)` | `toScale(int, RoundingMode)` | Rounding without naming a target scale is undefined, and 1.x resolved it through `float`. | +| `withScale(int)` | `toScale(int, RoundingMode)` or `toScaleExact(int)` | Changing scale is a rounding operation, so it names its mode. | +| `getScale()` | `scale()` | No `get` prefix on a value accessor. | +| `absolute()` | `absolute()` | Same name, exact implementation. | +| `Internal\Exceptions\*` | `Exceptions\*` | Consumers catch these, so they belong on the public boundary. | +| `MathOperationsNotAvailable` | `CalculatorNotAvailable` | Named after the invariant. | +| `InvalidNumber` | `NumberNotWellFormed` | Same. | +| `InvalidScale` | `ScaleOutOfRange` | Same. | +| `NonPositiveValue`, `NonNegativeValue` | Removed | Their only callers were the removed sign subtypes. | + +## 13. Usage + +Four tasks, written as a user writes them. + +**Apply a discount and settle to the currency's scale.** + +```php +decrease(amount: $price); + +$final->toScale(scale: Currency::BRL->getFractionDigits(), rounding: RoundingMode::HalfEven); +# 17.49 +``` + +**Divide exactly, then present.** + +```php +dividedBy(divisor: BigDecimal::of(value: '3')); + +$share->toDecimal(scale: 2, rounding: RoundingMode::HalfEven)->toString(); +# 33.33 +``` + +**Split an amount with no minor unit lost.** + +```php +allocate(weights: BigDecimals::of('1', '1', '1'), scale: 2); + +$parts->sum()->toString(); +# 100.00, and the parts are 33.34, 33.33, 33.33 +``` + +**Total a set of lines with no rounding anywhere.** + +```php +plus(addend: BigDecimal::of(value: '5.10')) + ->plus(addend: BigDecimal::of(value: '0.01')); + +$total->toString(); +# 25.10 +``` + +## 14. Deviations recorded during implementation + +The proposal above was approved as written. Five things changed while building it, each forced by a +repository rule or by a measurement, and each is recorded here so the document stays true to the +code. + +1. **`Number` no longer extends `Stringable`, and no type defines `__toString`.** The canonical + `phpcs.xml` places magic methods after every other method, while the member ordering rule places + methods by name length ascending. Both are gates, and no arrangement of `__toString` satisfies + both. `toString()` is the single canonical string form. +2. **`tiny-blocks/collection` is not a dependency.** `BigDecimals` wraps a private array. It is + itself the typed collection the code style rule asks for, and pulling in a dependency to hold + five elements is not KISS. +3. **`tiny-blocks/value-object` is not a dependency either.** The interface it exposes adds nothing + the numeric types do not already declare themselves, and `equals` typed against the exact class + is stronger than one typed against the interface. Section 12 still describes it as added to + `require` as `^5.0`, and that is superseded here: `require` is `php` alone, so the ecosystem + graph gains no edge at all rather than the two that section claims. +4. **`Calculator` is an integer engine, not a decimal one.** The interface in section 7 carried a + `$scale` on every operation. Integer only is the boundary GMP and BCMath actually share, it + keeps scale bookkeeping inside the library so two backends cannot disagree, and it is what makes + a future GMP backend meaningful at all. +5. **Two exception classes were added**, `NegativeExponent` and `BaseOutOfRange`. Section 5.2 + specified that `power` rejects a negative exponent and that `fromBase` takes a base from 2 to 36 + without naming the failures those rules produce. +6. **`Ratio::of` takes `int|string`, not `BigInteger|int|string`.** Accepting the value object too + would have meant an `instanceof` for coercion in a constructor, which the polymorphism rule + discourages, for no gain over `$integer->toString()` at the call site. + +One addition carries its own justification. `Digits` bounds the exponent it expands at 10000, so a +literal such as `'1e10001'` is refused rather than allocating without limit. + +## 15. Refactoring pass + +A design review after the first working version drove a second pass. Each change below is either a +rule the first version broke, a defect the review demonstrated, or a cost a measurement exposed. + +**Rules the first version broke.** `BigInteger` carried a private `guardAgainstZero`, which the +code style rule permits only inside `src/Internal/`. `PositionalBase` passed two `sprintf` format +strings inline instead of through a `$template` variable. Both are fixed by moving the operations +they belonged to onto `Internal\Digits`, which is where the architecture rule wants the algorithm +anyway. `BigInteger` and `BigDecimal` are now facades that hold a `Digits` and delegate. + +**Defects the review demonstrated.** `BigInteger::fromBase(base: 16, value: '--ff')` parsed as +`-255`, because the sign was stripped with `ltrim` rather than once. `Internal\Digits::shiftedBy` +handed the calculator operands carrying leading zeros, which the `Calculator` contract forbids and +which a GMP backend would read as octal. The trait `NumberComparison` implemented `compareTo` +through `toBigRational()`, which recurses forever for a `BigRational`, and was saved only by that +class happening to override it. The trait now declares `compareTo` abstract and supplies only the +predicates derived from it, so the recursion is unreachable by construction. + +**Costs a measurement exposed.** Benchmarks over 50000 iterations, same command shape as section 2: + +| Operation | Before | After | +|---|---|---| +| `Calculators::active()` | 2.30 us | 0.70 us | +| `BigDecimal::plus` | 32.65 us | 19.24 us | +| `BigDecimal::toScale` | 33.03 us | 19.80 us | +| `BigDecimal::compareTo` | 191.75 us | 139.50 us | +| `BigDecimal::dividedBy` | 267.57 us | 181.35 us | + +Four changes produced it. `BcMathCalculator` moved from `BcMath\Number` to the raw `bc*` +functions, because the contract is string in and string out and the object re-parses both operands +on every call, which the section 2 benchmark had already shown. `Calculators::active()` stopped +asking the backend whether it is available on every call and now checks at registration, so a +bootstrap mistake surfaces at bootstrap. Scale alignment and powers of ten became string operations +rather than arbitrary-precision multiplications. And `Digits::from` parses with one regex carrying +named groups and applies the exponent as a shift of the decimal point, so the common path no longer +touches `bcmath` at all. + +**One cost stands.** `compareTo` remains an order dearer than `multipliedBy`, because comparing +three numeric types exactly means comparing them as fractions, and building a fraction from a +decimal routes through the public `BigInteger` factory, which validates a string the library itself +produced. Removing that round trip needs either a leak of `Internal\Fraction` into a public +signature or a visitor across the three types. Neither is worth the surface, so the cost is +documented in the README instead. + +**What the verification pass added.** Of thirty-five findings, three survived independent +refutation. `Internal\Digits::zero()` was unreferenced and is gone. `Internal\Allocation` carried +three docblocks that the conventions prohibit on a concrete collaborator inside `src/Internal/`, so +the allocation now yields its parts through a `Generator` whose only annotation is the type +parameter the carve-out permits, and the working lists route through `ignoreErrors` as the rule +prescribes. And `BigRational::toDecimalExact` factorized the denominator by two and by five four +times for one conversion, because `hasTerminatingDecimal()` and `minimalScale()` each ran both +passes. `Internal\Fraction::exactScale()` now does it once and raises when no exact decimal +exists, so the conversion costs half of what it did. + +**Where the rounding decision lives.** `Internal\Rounding` first resolved a tie by handing a +surrogate decimal to `BcMath\Number::round`. That hard-bound the calculation seam to bcmath and +re-parsed the whole magnitude for a one-digit decision. The decision now lives on +`RoundingMode::roundsAwayFromZero()`, which is where the polymorphism rule wants a value a case +owns, and it is expressed the way Java's javadoc defines each constant. + +## 16. Sources + +Fetched or executed in this session. + +**Executed.** A `php -r ''` run in the project's tooling image for the runtime facts in +section 2, and the three scripts `backend-bench.php`, `rounding-matrix.php`, +`rational-viability.php` for the measurements. + +**brick/math 0.19.1.** `raw.githubusercontent.com/brick/math/master/` for `README.md`, +`composer.json`, `CHANGELOG.md`, `src/BigNumber.php`, `src/BigInteger.php`, `src/BigDecimal.php`, +`src/BigRational.php`, `src/RoundingMode.php`, the eleven files under `src/Exception/`, +`src/Internal/Calculator.php`, `src/Internal/CalculatorRegistry.php`, and the three files under +`src/Internal/Calculator/`. + +**ext-decimal 2.0.1.** `github.com/php-decimal/ext-decimal` source (`php_decimal.c`, `src/context.h`, +`src/limits.h`, `src/round.h`, `src/errors.c`, `compare.c`, `decimal.c`), plus +`php-decimal.github.io`. + +**PHP core.** `php.net/manual/en/class.bcmath-number.php` and its method pages, +`wiki.php.net/rfc/support_object_type_in_bcmath`, `php.net/manual/en/book.bc.php`, +`php.net/manual/en/book.gmp.php`, and `php-src` `ext/gmp/gmp.c`. + +**maba/math and the PHP survey.** `github.com/mariusbalcytis/math`, and Packagist pages for +`brick/math`, `moneyphp/money`, `brick/money`, `moontoast/math`, `litipk/php-bignumbers`, +`krowinski/bcmath-extended`, `php-decimal/php-decimal`, `maba/math`, `tiny-blocks/math`, all read +on 2026-08-13. + +**Java SE 21.** `docs.oracle.com` Javadoc for `BigDecimal`, `BigInteger`, `MathContext`, +`RoundingMode`, `DecimalFormat`, cross-checked against OpenJDK tag `jdk-21+35`. + +**Python.** `docs.python.org/3/library/decimal.html` and CPython `Lib/_pydecimal.py`. + +**JavaScript.** `mikemcl.github.io/decimal.js/`, `github.com/MikeMcl/decimal.js`, MDN `BigInt`, +and `github.com/tc39/proposal-decimal`. + +**Local files.** `tiny-blocks/time` (`src/Duration.php`, `src/Timezone.php`, `src/Precision.php`, +`src/Exceptions/InvalidSeconds.php`, `src/Internal/Seconds.php`, `phpstan.neon.dist`, +`composer.json`), `tiny-blocks/value-object/src/ValueObject.php`, +`tiny-blocks/currency/src/Currency.php`, and the canonical config assets in +`.claude/skills/tiny-blocks-create/assets/config/`. diff --git a/docs/specs/math-redesign-state.md b/docs/specs/math-redesign-state.md new file mode 100644 index 0000000..dfda0d4 --- /dev/null +++ b/docs/specs/math-redesign-state.md @@ -0,0 +1,67 @@ +# math redesign, resume state + +## Checklist + +- [x] 1. Read repository: CLAUDE.md, .claude/rules/, src/, tests/, composer.json, Makefile, phpstan.neon.dist, infection.json.dist, phpmd.xml, README.md +- [x] 2. Research reference libraries +- [x] 3. Write comparison table (section 3 of the design document) +- [x] 4. Write design proposal to docs/specs/2026-08-13-math-redesign-design.md +- [x] 5. GATE, present proposal in pt-BR, approved by the user +- [x] 6. Implement src/ +- [x] 7. Implement tests/ +- [x] 8. Run the quality gates from the Makefile +- [x] 9. Write README.md +- [x] 10. Write UPGRADE.md (1.x to 2.0 mapping) +- [x] 11. Refactoring pass driven by an adversarial design review (section 15 of the design document) + +## Current position + +Complete. Every change is unstaged in the working tree, as instructed. No branch, commit, or tag was created. + +## Gate results + +- `make review`: `make: ok`. phpcs (PSR-12 plus the curated sniffs) and phpstan `level: max` over `src` and `tests`, with `reportUnmatchedIgnoredErrors: true`. +- `make tests`: `OK (434 tests, 602 assertions)`, then Infection: `739 mutations were generated`, `732 mutants were killed by Test Framework`, `6 errors`, `1 time out`, `0 escaped`, `0 skipped`, `Mutation Code Coverage: 100%`, `Covered Code MSI: 100%`. The single time out sits on `Magnitude::compareTo`: negating its loop guard makes every comparison report equality, which leaves the Euclid loop in `GreatestCommonDivisor` without a decreasing measure. Whether an assertion reaches it before the clock does depends on the random test order, so `--with-timeouts` stays off until that loop is bounded by construction. + +## Files created or modified + +- `src/` rewritten in full: `Number.php`, `BigInteger.php`, `BigDecimal.php`, `BigDecimals.php`, `BigRational.php`, `Percentage.php`, `Ratio.php`, `RoundingMode.php`, `Calculator.php`, `Calculators.php`, twelve classes under `Exceptions/`, and fourteen collaborators under `Internal/`, segregated by context into `Allocations/`, `Backends/`, `Bases/`, `Decimals/` and `Fractions/`, with `Exponent`, `GreatestCommonDivisor`, `NumberComparison` and `StructuralHash` as the shared kernel at the `Internal/` root. +- `tests/Unit/` written in full: seven test classes plus two calculator test doubles. +- `composer.json`: new description and keywords, `autoload-dev` moved to the canonical `Test\TinyBlocks\Math\` namespace, the `suggest` block removed. The library keeps zero Composer dependencies: `tiny-blocks/value-object` was added during the redesign and removed again afterwards, in favor of an `equals` declared on each concrete type with its own exact parameter type. `ext-bcmath` moved from `require` to `suggest` once `Internal\Backends\NativeCalculator` shipped as a pure PHP fallback. +- `phpstan.neon.dist`: brought to the canonical `level: max` over `src` and `tests`, with two scoped `ignoreErrors` entries, each carrying a comment. +- `.gitattributes`: `/.claude`, `/docs`, and `/UPGRADE.md` added under `export-ignore`. +- `README.md` and `UPGRADE.md` rewritten. +- `docs/specs/2026-08-13-math-redesign-design.md` (the approved proposal, with deviations and the refactoring pass appended). +- `docs/specs/math-redesign-state.md` (this file). + +## Decisions settled by the user at the gate + +1. Division returns `BigRational`, exact and total, on `BigInteger` and `BigDecimal`. +2. The library owns `TinyBlocks\Math\RoundingMode`, string-backed, eight cases. +3. Ship `BcMathCalculator` only. `Calculator` stays a public seam. +4. Scope includes `Percentage`, `Ratio`, and N-way allocation. + +## Deviations and later changes + +Section 14 of the design document records the deviations from the approved proposal. Section 15 records the refactoring pass: the rule violations it fixed, the three defects the review demonstrated, and the measured cost reductions. + +`#[\NoDiscard]` was applied to every public instance method in one pass and then removed at the user's request. On a library where every method is pure, the attribute lands on all of them, and the noise outweighs the diagnostic. Removing it also raised the mutant count from 400 to 467, because the attribute was suppressing generation on the methods it decorated, so the suite now proves more than it did with it. + +## Verification performed beyond the gates + +- An end-to-end script exercising every public method and every documented failure, 140 assertions, run against the built autoloader in the project image. Zero failures. +- A documentation conformance script asserting that every public method of every published type appears in the README, that every failure class the README names exists, that every 2.0 symbol `UPGRADE.md` promises exists on the type it is attributed to, and that every `RoundingMode` case the README lists is real. Zero failures. It caught two genuine gaps: the `Calculator` and `Calculators` method tables were missing from the README. +- A table-of-contents check confirming all sixteen headings are linked and that the FAQ is represented by a single entry, as the documentation rule requires. + +## Suggestions left out of scope + +- Ship a `GmpCalculator` once the pinned tooling image carries `ext-gmp`. The seam and the selection rule are already in place. +- Remove the internal round trip in `BigDecimal::toBigRational`, which validates strings the library produced. It still costs on the cross-type comparison path, since same-type comparison no longer goes through it. Doing it needs either a leak of `Internal\Fractions\Fraction` into a public signature or a visitor across the three numeric types. +- `BigDecimal::withPointMovedLeft` and `withPointMovedRight`. +- `BigRational::toRepeatingDecimalString`, for rendering `10/3` as `3.(3)`. +- `BigInteger::modularInverse` and `modularPower`, which are the operations a GMP backend would accelerate most. +- No change is needed in `doc/dependency-graph.svg` in the `tiny-blocks` meta repository: `tiny-blocks/math` still has no outgoing edge. That repository is not part of this checkout. + +## Next action + +None. Report the outcome. diff --git a/infection.json.dist b/infection.json.dist index 0e8ff90..aab8c7e 100644 --- a/infection.json.dist +++ b/infection.json.dist @@ -1,9 +1,9 @@ { "logs": { - "text": "report/infection/logs/infection-text.log", - "summary": "report/infection/logs/infection-summary.log" + "text": "reports/infection/logs/infection-text.log", + "summary": "reports/infection/logs/infection-summary.log" }, - "tmpDir": "report/infection/", + "tmpDir": "reports/infection/", "minMsi": 100, "timeout": 30, "source": { @@ -16,13 +16,7 @@ "customPath": "./vendor/bin/phpunit" }, "mutators": { - "@default": true, - "Minus": false, - "BCMath": false, - "Ternary": false, - "RoundingFamily": false, - "PublicVisibility": false, - "ProtectedVisibility": false + "@default": true }, "minCoveredMsi": 100, "testFramework": "phpunit" diff --git a/phpcs.xml b/phpcs.xml new file mode 100644 index 0000000..96c803e --- /dev/null +++ b/phpcs.xml @@ -0,0 +1,97 @@ + + + Code style for the tiny-blocks library. + + src + tests + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/phpmd.xml b/phpmd.xml deleted file mode 100644 index cd9072e..0000000 --- a/phpmd.xml +++ /dev/null @@ -1,57 +0,0 @@ - - - PHPMD Custom rules - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/phpstan.neon.dist b/phpstan.neon.dist index 3e20b2a..1781e83 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -1,14 +1,30 @@ parameters: - paths: - - src - level: 9 - tmpDir: report/phpstan - ignoreErrors: - - '#does not accept#' - - '#Binary operation#' - - '#should return numeric-string#' - - '#Access to an undefined property#' - - '#function number_format expects int#' - - '#expects TinyBlocks\\Math\\BigNumber#' - - '#type specified in iterable type array#' - reportUnmatchedIgnoredErrors: false + level: max + paths: + - src + - tests + ignoreErrors: + # The bcmath stubs type every operand as numeric-string. The library validates the shape + # before a value reaches a calculator, and the Calculator contract documents it, so the + # narrowing cannot be expressed without PHPDoc, which is prohibited inside src/Internal/. + - identifier: argument.type + path: src/Internal/Backends/BcMathCalculator.php + + # PHPDoc is prohibited on concrete collaborators inside src/Internal/, so the working + # lists inside the allocation algorithm are suppressed here rather than annotated. + - identifier: argument.type + path: src/Internal/Allocations/Allocation.php + - identifier: missingType.iterableValue + path: src/Internal/Allocations/Allocation.php + + # Schoolbook multiplication fills the product by computed index, so PHPStan cannot prove the + # result is still a list. It is: every index from zero upward is written before it is read. + - identifier: argument.type + path: src/Internal/Backends/Magnitude.php + count: 1 + + # PHPDoc is prohibited inside tests/, so data provider return types are suppressed here + # rather than annotated. + - identifier: missingType.iterableValue + path: tests + reportUnmatchedIgnoredErrors: true diff --git a/phpunit.xml b/phpunit.xml index 40c80a2..9cc6d13 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -1,13 +1,15 @@ + failOnDeprecation="true" + failOnNotice="true" + failOnPhpunitDeprecation="true" + failOnRisky="true" + failOnWarning="true"> @@ -23,15 +25,15 @@ - - - - + + + + - + diff --git a/src/BigDecimal.php b/src/BigDecimal.php index e5db078..4a5c680 100644 --- a/src/BigDecimal.php +++ b/src/BigDecimal.php @@ -4,27 +4,468 @@ namespace TinyBlocks\Math; -use TinyBlocks\Math\Internal\BigNumberBehavior; -use TinyBlocks\Math\Internal\Number; -use TinyBlocks\Math\Internal\Scale; +use TinyBlocks\Math\Exceptions\DivisionByZero; +use TinyBlocks\Math\Exceptions\ExponentOutOfRange; +use TinyBlocks\Math\Exceptions\InexactConversion; +use TinyBlocks\Math\Exceptions\IntegerOverflow; +use TinyBlocks\Math\Exceptions\NegativeExponent; +use TinyBlocks\Math\Exceptions\NegativeRoot; +use TinyBlocks\Math\Exceptions\NegativeWeight; +use TinyBlocks\Math\Exceptions\NumberNotWellFormed; +use TinyBlocks\Math\Exceptions\ScaleOutOfRange; +use TinyBlocks\Math\Internal\Allocations\Allocation; +use TinyBlocks\Math\Internal\Decimals\Digits; +use TinyBlocks\Math\Internal\Decimals\Scale; +use TinyBlocks\Math\Internal\Decimals\SquareRoot; +use TinyBlocks\Math\Internal\NumberComparison; +use TinyBlocks\Math\Internal\StructuralHash; -class BigDecimal extends BigNumberBehavior implements BigNumber +/** + * Arbitrary-precision decimal, held as an unscaled integer and a non-negative scale. + * + *

Addition, subtraction and multiplication are exact and never round. Division returns a + * {@see BigRational}, so it is exact for every pair of operands and raises only on a zero divisor. + * Rounding happens where the caller asks for it, by naming both a scale and a + * {@see RoundingMode}.

+ * + *

Scale propagates the way Java's BigDecimal specifies it: the maximum of the two + * operands for addition and subtraction, their sum for multiplication, and the scale times the + * exponent for a power.

+ */ +final readonly class BigDecimal implements Number { - protected function __construct(string|float $value, ?int $scale = null) + use NumberComparison; + + private const string ONE = '1'; + private const string ZERO = '0'; + + private function __construct(private Digits $digits) + { + } + + /** + * Creates a BigDecimal from a literal. + * + *

Accepts positional notation such as '19.99' and scientific notation such as + * '1.5e-3'. The scale of the result is the number of digits the literal carries + * after the decimal point, so '1.50' keeps its trailing zero.

+ * + * @param string|int $value The literal to read. + * @return BigDecimal The created instance. + * @throws NumberNotWellFormed If the literal is not a number, or its exponent exceeds 10000 in magnitude. + * @throws ScaleOutOfRange If the literal implies a scale beyond the supported range. + */ + public static function of(string|int $value): BigDecimal + { + return new BigDecimal(digits: Digits::from(value: $value)); + } + + /** + * Creates a BigDecimal holding one, at scale zero. + * + * @return BigDecimal The created instance. + */ + public static function one(): BigDecimal + { + return BigDecimal::of(value: self::ONE); + } + + /** + * Creates a BigDecimal holding zero, at scale zero. + * + * @return BigDecimal The created instance. + */ + public static function zero(): BigDecimal + { + return BigDecimal::of(value: self::ZERO); + } + + /** + * Creates a BigDecimal from a native float. + * + *

The only lossy entry point in the library, and named so. The float is read as the + * shortest decimal that round-trips to the same float, so 0.1 becomes + * '0.1' rather than its exact binary expansion. Prefer a string literal whenever + * one is available.

+ * + *

The shortest form is found by widening the significant digits until the decimal casts back + * to the same float. Nothing here reads an ini directive, so the result is the same in every + * process.

+ * + * @param float $value The float to read. + * @return BigDecimal The created instance. + * @throws NumberNotWellFormed If the float is NAN or infinite, since neither is a number. + */ + public static function fromFloat(float $value): BigDecimal + { + return new BigDecimal(digits: Digits::fromFloat(value: $value)); + } + + /** + * Creates a BigDecimal from an unscaled integer and a scale. + * + * @param int $scale The number of digits after the decimal point. + * @param BigInteger $unscaled The digits, with the decimal point removed. + * @return BigDecimal The created instance. + * @throws ScaleOutOfRange If the scale is negative or beyond the supported range. + */ + public static function ofUnscaledValue(int $scale, BigInteger $unscaled): BigDecimal + { + return new BigDecimal( + digits: Digits::of(scale: Scale::of(value: $scale), unscaled: $unscaled->toString()) + ); + } + + /** + * Adds another decimal to this one, exactly. + * + * @param BigDecimal $addend The decimal to add. + * @return BigDecimal A new instance whose scale is the larger of the two. + */ + public function plus(BigDecimal $addend): BigDecimal + { + return new BigDecimal(digits: $this->digits->plus(other: $addend->digits)); + } + + /** + * Subtracts another decimal from this one, exactly. + * + * @param BigDecimal $subtrahend The decimal to subtract. + * @return BigDecimal A new instance whose scale is the larger of the two. + */ + public function minus(BigDecimal $subtrahend): BigDecimal + { + return new BigDecimal(digits: $this->digits->minus(other: $subtrahend->digits)); + } + + /** + * Raises this decimal to a non-negative power, exactly. + * + * @param int $exponent The exponent, never negative. + * @return BigDecimal A new instance whose scale is this scale times the exponent. + * @throws NegativeExponent If the exponent is negative. + * @throws ExponentOutOfRange If the exponent is beyond the supported magnitude. + * @throws ScaleOutOfRange If the resulting scale is beyond the supported range. + */ + public function power(int $exponent): BigDecimal { - $scale = Scale::from(value: $scale); - $number = Number::from(value: $value); + if ($exponent < 0) { + throw NegativeExponent::becauseExponentIsNegative(exponent: $exponent); + } + + return new BigDecimal(digits: $this->digits->power(exponent: $exponent)); + } - parent::__construct(number: $number, scale: $scale); + /** + * Returns the number of digits after the decimal point. + * + * @return int The scale. + */ + public function scale(): int + { + return $this->digits->scale->value; + } + + /** + * Tells whether this decimal holds the same digits and scale as another decimal. + * + *

Structural equality, so 1.0 does not equal 1.00. For arithmetic + * equality, and for comparing against a number of another type, use + * {@see Number::isEqualTo()}.

+ * + * @param BigDecimal $other The decimal to compare against. + * @return bool True when both hold the same digits at the same scale. + */ + public function equals(BigDecimal $other): bool + { + return $other->digits->unscaled === $this->digits->unscaled + && $other->digits->scale->equals(other: $this->digits->scale); + } + + /** + * Tells whether this decimal is zero. + * + * @return bool True when the value is zero, at any scale. + */ + public function isZero(): bool + { + return $this->digits->isZero(); + } + + /** + * Returns this decimal with the opposite sign. + * + * @return BigDecimal A new instance with the sign flipped, at the same scale. + */ + public function negated(): BigDecimal + { + return new BigDecimal(digits: $this->digits->negated()); + } + + /** + * Returns this decimal as a native float. + * + * @return float The nearest float, which may lose precision. + * @throws IntegerOverflow If the value is outside the native float range. + */ + public function toFloat(): float + { + $float = (float)$this->digits->value(); + + if (is_infinite($float)) { + throw IntegerOverflow::becauseValueExceedsFloatRange(value: $this->toString()); + } + + return $float; + } + + /** + * Returns this decimal at another scale, rounding as instructed. + * + * @param int $scale The target number of digits after the decimal point. + * @param RoundingMode $rounding The policy applied to the discarded digits. + * @return BigDecimal A new instance at the requested scale. + * @throws ScaleOutOfRange If the scale is negative or beyond the supported range. + */ + public function toScale(int $scale, RoundingMode $rounding): BigDecimal + { + return new BigDecimal( + digits: $this->digits->withScale(scale: Scale::of(value: $scale), roundingMode: $rounding) + ); + } + + /** + * Returns the magnitude of this decimal. + * + * @return BigDecimal A new instance with the sign removed, at the same scale. + */ + public function absolute(): BigDecimal + { + return new BigDecimal(digits: $this->digits->absolute()); + } + + /** + * Splits this decimal among weights so that the parts sum back to it exactly. + * + *

Each part is the exact share truncated toward negative infinity at the given scale, and + * the units left over are handed out one at a time in descending order of the discarded + * remainder, ties broken by position. Plain rounding cannot preserve the total, which is why + * this exists.

+ * + * @param int $scale The number of digits after the decimal point in every part. + * @param BigDecimals $weights The weights, in order, each of them non-negative. + * @return BigDecimals The parts, in the order of the weights. + * @throws InexactConversion If this decimal cannot be represented at the given scale. + * @throws NegativeWeight If any weight is negative. + * @throws DivisionByZero If the weights sum to zero. + * @throws ScaleOutOfRange If the scale is negative or beyond the supported range. + */ + public function allocate(int $scale, BigDecimals $weights): BigDecimals + { + $weighed = []; + + foreach ($weights as $weight) { + $weighed[] = $weight->digits; + } + + $parts = []; + + $allocated = new Allocation()->of( + scale: Scale::of(value: $scale), + amount: $this->toScaleExact(scale: $scale)->digits, + weights: $weighed + ); + + foreach ($allocated as $part) { + $parts[] = new BigDecimal(digits: $part); + } + + return BigDecimals::from(...$parts); + } + + /** + * Returns a deterministic hash of this decimal. + * + * @return string The structural hash. + */ + public function hashCode(): string + { + return new StructuralHash()->of(type: BigDecimal::class, representation: $this->toString()); + } + + /** + * Returns this decimal as a string. + * + *

Always positional notation, never scientific, and trailing zeros are preserved because + * they are the scale.

+ * + * @return string The canonical string form. + */ + public function toString(): string + { + return $this->digits->value(); + } + + /** + * Compares this decimal with another number. + * + * @param Number $other The number to compare against. + * @return int A negative number, zero, or a positive number. + */ + public function compareTo(Number $other): int + { + return $other instanceof BigDecimal + ? $this->digits->compareTo(other: $other->digits) + : $this->toBigRational()->compareTo(other: $other); + } + + /** + * Divides this decimal by another, exactly. + * + * @param BigDecimal $divisor The decimal to divide by, never zero. + * @return BigRational The exact quotient. + * @throws DivisionByZero If the divisor is zero. + */ + public function dividedBy(BigDecimal $divisor): BigRational + { + return $this->toBigRational()->dividedBy(divisor: $divisor->toBigRational()); + } + + /** + * Tells whether this decimal is strictly less than zero. + * + * @return bool True when the value is negative. + */ + public function isNegative(): bool + { + return $this->digits->isNegative(); + } + + /** + * Returns the square root of this decimal at the given scale. + * + * @param int $scale The number of digits after the decimal point in the result. + * @param RoundingMode $rounding The policy applied to the discarded digits. + * @return BigDecimal A new instance holding the root. + * @throws NegativeRoot If this decimal is negative. + * @throws ScaleOutOfRange If the scale is negative or beyond the supported range. + */ + public function squareRoot(int $scale, RoundingMode $rounding): BigDecimal + { + if ($this->isNegative()) { + throw NegativeRoot::becauseRadicandIsNegative(radicand: $this->toString()); + } + + return new BigDecimal( + digits: new SquareRoot()->of( + scale: Scale::of(value: $scale), + radicand: $this->digits, + roundingMode: $rounding + ) + ); + } + + /** + * Returns the integer part of this decimal, truncated toward zero. + * + * @return BigInteger The digits before the decimal point. + */ + public function integralPart(): BigInteger + { + return BigInteger::of(value: $this->digits->integralPart()); + } + + /** + * Multiplies this decimal by another, exactly. + * + * @param BigDecimal $multiplier The decimal to multiply by. + * @return BigDecimal A new instance whose scale is the sum of the two. + */ + public function multipliedBy(BigDecimal $multiplier): BigDecimal + { + return new BigDecimal(digits: $this->digits->multipliedBy(other: $multiplier->digits)); + } + + /** + * Returns this decimal as an integer. + * + * @return BigInteger The integer value. + * @throws InexactConversion If the fractional part is not zero. + */ + public function toBigInteger(): BigInteger + { + return BigInteger::of(value: $this->digits->value()); + } + + /** + * Returns this decimal at another scale, without rounding. + * + * @param int $scale The target number of digits after the decimal point. + * @return BigDecimal A new instance at the requested scale. + * @throws InexactConversion If digits would be discarded. + * @throws ScaleOutOfRange If the scale is negative or beyond the supported range. + */ + public function toScaleExact(int $scale): BigDecimal + { + $rescaled = $this->toScale(scale: $scale, rounding: RoundingMode::Down); + + if ($rescaled->digits->compareTo(other: $this->digits) !== 0) { + throw InexactConversion::becauseScaleIsTooSmall(scale: $scale, value: $this->toString()); + } + + return $rescaled; + } + + /** + * Returns this decimal as a JSON string. + * + * @return string The canonical string form. + */ + public function jsonSerialize(): string + { + return $this->toString(); + } + + /** + * Returns this decimal as an exact fraction. + * + * @return BigRational The exact rational representation. + */ + public function toBigRational(): BigRational + { + return BigRational::ofFraction( + numerator: BigInteger::of(value: $this->digits->unscaled), + denominator: BigInteger::of(value: $this->digits->scaleFactor()) + ); + } + + /** + * Returns the unscaled digits of this decimal. + * + * @return BigInteger The value with the decimal point removed. + */ + public function unscaledValue(): BigInteger + { + return BigInteger::of(value: $this->digits->unscaled); } - public static function fromFloat(float $value, ?int $scale = BigNumber::AUTOMATIC_SCALE): BigDecimal + /** + * Returns the fractional part of this decimal. + * + * @return BigDecimal A new instance at the same scale, carrying the sign of this decimal. + */ + public function fractionalPart(): BigDecimal { - return new BigDecimal(value: $value, scale: $scale); + return $this->minus(subtrahend: $this->integralPart()->toBigDecimal()); } - public static function fromString(string $value, ?int $scale = BigNumber::AUTOMATIC_SCALE): BigDecimal + /** + * Returns this decimal at the smallest scale that represents the same value. + * + * @return BigDecimal A new instance without trailing zeros. + */ + public function withoutTrailingZeros(): BigDecimal { - return new BigDecimal(value: $value, scale: $scale); + return new BigDecimal(digits: $this->digits->withoutTrailingZeros()); } } diff --git a/src/BigDecimals.php b/src/BigDecimals.php new file mode 100644 index 0000000..cc946cc --- /dev/null +++ b/src/BigDecimals.php @@ -0,0 +1,119 @@ +Carries the weights handed to {@see BigDecimal::allocate()} and the parts it + * produces. Order is significant in both directions: the part at a position belongs to the weight + * at the same position, and ties in the allocation remainder are broken by position.

+ * + * @implements IteratorAggregate + */ +final readonly class BigDecimals implements Countable, IteratorAggregate, JsonSerializable +{ + /** @var list */ + private array $values; + + private function __construct(BigDecimal ...$values) + { + $this->values = array_values($values); + } + + /** + * Creates a BigDecimals from decimal literals. + * + * @param string|int ...$values The literals to read, in order. + * @return BigDecimals The created collection. + * @throws NumberNotWellFormed If any literal is not a number, or its exponent exceeds 10000 in magnitude. + * @throws ScaleOutOfRange If any literal implies a scale beyond the supported range. + */ + public static function of(string|int ...$values): BigDecimals + { + return new BigDecimals(...array_map( + static fn(string|int $value): BigDecimal => BigDecimal::of(value: $value), + $values + )); + } + + /** + * Creates a BigDecimals from decimals. + * + * @param BigDecimal ...$values The decimals, in order. + * @return BigDecimals The created collection. + */ + public static function from(BigDecimal ...$values): BigDecimals + { + return new BigDecimals(...$values); + } + + /** + * Returns every decimal in this collection, in order. + * + * @return list The decimals. + */ + public function all(): array + { + return $this->values; + } + + /** + * Returns the exact sum of every decimal in this collection. + * + *

No rounding happens. The scale of the result is the largest scale among the elements, + * and an empty collection sums to zero.

+ * + * @return BigDecimal The exact total. + */ + public function sum(): BigDecimal + { + return array_reduce( + $this->values, + static fn(BigDecimal $total, BigDecimal $value): BigDecimal => $total->plus(addend: $value), + BigDecimal::zero() + ); + } + + /** + * Returns the number of decimals in this collection. + * + * @return int The element count. + */ + public function count(): int + { + return count($this->values); + } + + /** + * Returns an iterator over the decimals, in order. + * + * @return Traversable The iterator. + */ + public function getIterator(): Traversable + { + yield from $this->values; + } + + /** + * Returns this collection as a JSON array of strings. + * + *

Strings rather than JSON numbers, for the reason {@see Number::jsonSerialize()} gives. The + * array reads back through {@see BigDecimals::of()}.

+ * + * @return list The canonical string form of every decimal, in order. + */ + public function jsonSerialize(): array + { + return array_map(static fn(BigDecimal $value): string => $value->toString(), $this->values); + } +} diff --git a/src/BigInteger.php b/src/BigInteger.php new file mode 100644 index 0000000..60cf0e8 --- /dev/null +++ b/src/BigInteger.php @@ -0,0 +1,380 @@ +Exists so that problems without a scale (identifiers, counters, combinatorics, modular + * arithmetic) never carry one. Division returns a {@see BigRational}, so it is exact for every + * pair of operands and raises only on a zero divisor. Truncating division is available separately + * as {@see BigInteger::quotient()} and {@see BigInteger::remainder()}.

+ */ +final readonly class BigInteger implements Number +{ + use NumberComparison; + + private const string ONE = '1'; + private const string ZERO = '0'; + + private function __construct(private Digits $digits) + { + } + + /** + * Creates a BigInteger from an integer literal. + * + *

A literal with a zero fractional part, such as '5.00', is accepted. A + * literal with a non-zero fractional part is not.

+ * + * @param string|int $value The literal to read. + * @return BigInteger The created instance. + * @throws NumberNotWellFormed If the literal is not a number, or its exponent exceeds 10000 in magnitude. + * @throws InexactConversion If the literal carries a non-zero fractional part. + */ + public static function of(string|int $value): BigInteger + { + $digits = Digits::from(value: $value)->withoutTrailingZeros(); + + if ($digits->scale->value > 0) { + throw InexactConversion::becauseFractionalPartWouldBeLost(value: (string)$value); + } + + return new BigInteger(digits: $digits); + } + + /** + * Creates a BigInteger holding one. + * + * @return BigInteger The created instance. + */ + public static function one(): BigInteger + { + return BigInteger::of(value: self::ONE); + } + + /** + * Creates a BigInteger holding zero. + * + * @return BigInteger The created instance. + */ + public static function zero(): BigInteger + { + return BigInteger::of(value: self::ZERO); + } + + /** + * Creates a BigInteger from its representation in another base. + * + * @param int $base The base of the literal, from 2 to 36. + * @param string $value The literal, case-insensitive, with an optional leading minus. + * @return BigInteger The created instance. + * @throws BaseOutOfRange If the base is outside 2 to 36. + * @throws NumberNotWellFormed If the literal carries a character the base does not define. + */ + public static function fromBase(int $base, string $value): BigInteger + { + return new BigInteger(digits: PositionalBase::of(base: $base)->toDigits(value: $value)); + } + + /** + * Adds another integer to this one. + * + * @param BigInteger $addend The integer to add. + * @return BigInteger A new instance holding the sum. + */ + public function plus(BigInteger $addend): BigInteger + { + return new BigInteger(digits: $this->digits->plus(other: $addend->digits)); + } + + /** + * Tells whether this integer is odd. + * + * @return bool True when the value is not divisible by two. + */ + public function isOdd(): bool + { + return !$this->isEven(); + } + + /** + * Subtracts another integer from this one. + * + * @param BigInteger $subtrahend The integer to subtract. + * @return BigInteger A new instance holding the difference. + */ + public function minus(BigInteger $subtrahend): BigInteger + { + return new BigInteger(digits: $this->digits->minus(other: $subtrahend->digits)); + } + + /** + * Raises this integer to a non-negative power. + * + * @param int $exponent The exponent, never negative. + * @return BigInteger A new instance holding the power. + * @throws NegativeExponent If the exponent is negative. + * @throws ExponentOutOfRange If the exponent is beyond the supported magnitude. + */ + public function power(int $exponent): BigInteger + { + if ($exponent < 0) { + throw NegativeExponent::becauseExponentIsNegative(exponent: $exponent); + } + + return new BigInteger(digits: $this->digits->power(exponent: $exponent)); + } + + /** + * Returns this integer as a native integer. + * + * @return int The native integer. + * @throws IntegerOverflow If the value is outside the native integer range. + */ + public function toInt(): int + { + return $this->digits->toInt(); + } + + /** + * Tells whether this integer holds the same digits as another integer. + * + *

Structural equality. Two BigInteger instances holding the same value are always equal, + * because an integer has a single representation. To compare against a number of another type, + * use {@see Number::isEqualTo()}.

+ * + * @param BigInteger $other The integer to compare against. + * @return bool True when both hold the same value. + */ + public function equals(BigInteger $other): bool + { + return $other->digits->unscaled === $this->digits->unscaled; + } + + /** + * Tells whether this integer is even. + * + * @return bool True when the value is divisible by two. + */ + public function isEven(): bool + { + return $this->digits->isEven(); + } + + /** + * Tells whether this integer is zero. + * + * @return bool True when the value is zero. + */ + public function isZero(): bool + { + return $this->digits->isZero(); + } + + /** + * Returns the remainder of a floored division, never negative. + * + *

This is the mathematical modulo. Its sign convention differs from + * {@see BigInteger::remainder()}, which follows the dividend.

+ * + * @param BigInteger $modulus The modulus, never zero. + * @return BigInteger A new instance holding the non-negative remainder. + * @throws DivisionByZero If the modulus is zero. + */ + public function modulo(BigInteger $modulus): BigInteger + { + $remainder = $this->remainder(divisor: $modulus); + + return $remainder->isNegative() ? $remainder->plus(addend: $modulus->absolute()) : $remainder; + } + + /** + * Returns this integer written in another base. + * + * @param int $base The target base, from 2 to 36. + * @return string The representation, lowercase for bases above ten. + * @throws BaseOutOfRange If the base is outside 2 to 36. + */ + public function toBase(int $base): string + { + return PositionalBase::of(base: $base)->toText(digits: $this->digits); + } + + /** + * Returns this integer with the opposite sign. + * + * @return BigInteger A new instance with the sign flipped. + */ + public function negated(): BigInteger + { + return new BigInteger(digits: $this->digits->negated()); + } + + /** + * Returns the magnitude of this integer. + * + * @return BigInteger A new instance with the sign removed. + */ + public function absolute(): BigInteger + { + return new BigInteger(digits: $this->digits->absolute()); + } + + /** + * Returns a deterministic hash of this integer. + * + * @return string The structural hash. + */ + public function hashCode(): string + { + return new StructuralHash()->of(type: BigInteger::class, representation: $this->toString()); + } + + /** + * Divides this integer by another, truncating toward zero. + * + * @param BigInteger $divisor The integer to divide by, never zero. + * @return BigInteger A new instance holding the truncated quotient. + * @throws DivisionByZero If the divisor is zero. + */ + public function quotient(BigInteger $divisor): BigInteger + { + return new BigInteger(digits: $this->digits->quotient(divisor: $divisor->digits)); + } + + /** + * Returns this integer as a string. + * + * @return string The canonical string form. + */ + public function toString(): string + { + return $this->digits->value(); + } + + /** + * Compares this integer with another number. + * + * @param Number $other The number to compare against. + * @return int A negative number, zero, or a positive number. + */ + public function compareTo(Number $other): int + { + return $other instanceof BigInteger + ? $this->digits->compareTo(other: $other->digits) + : $this->toBigRational()->compareTo(other: $other); + } + + /** + * Divides this integer by another, exactly. + * + * @param BigInteger $divisor The integer to divide by, never zero. + * @return BigRational The exact quotient. + * @throws DivisionByZero If the divisor is zero. + */ + public function dividedBy(BigInteger $divisor): BigRational + { + return $this->toBigRational()->dividedBy(divisor: $divisor->toBigRational()); + } + + /** + * Returns the remainder of a truncated division, carrying the sign of this integer. + * + * @param BigInteger $divisor The integer to divide by, never zero. + * @return BigInteger A new instance holding the remainder. + * @throws DivisionByZero If the divisor is zero. + */ + public function remainder(BigInteger $divisor): BigInteger + { + return new BigInteger(digits: $this->digits->remainder(divisor: $divisor->digits)); + } + + /** + * Tells whether this integer is strictly less than zero. + * + * @return bool True when the value is negative. + */ + public function isNegative(): bool + { + return $this->digits->isNegative(); + } + + /** + * Returns the integer square root, truncated toward zero. + * + * @return BigInteger A new instance holding the truncated root. + * @throws NegativeRoot If this integer is negative. + */ + public function squareRoot(): BigInteger + { + return new BigInteger(digits: $this->digits->squareRoot()); + } + + /** + * Multiplies this integer by another. + * + * @param BigInteger $multiplier The integer to multiply by. + * @return BigInteger A new instance holding the product. + */ + public function multipliedBy(BigInteger $multiplier): BigInteger + { + return new BigInteger(digits: $this->digits->multipliedBy(other: $multiplier->digits)); + } + + /** + * Returns this integer as a decimal of scale zero. + * + * @return BigDecimal The decimal representation. + */ + public function toBigDecimal(): BigDecimal + { + return BigDecimal::of(value: $this->digits->value()); + } + + /** + * Returns this integer as a JSON string. + * + * @return string The canonical string form. + */ + public function jsonSerialize(): string + { + return $this->toString(); + } + + /** + * Returns this integer as an exact fraction with a denominator of one. + * + * @return BigRational The exact rational representation. + */ + public function toBigRational(): BigRational + { + return BigRational::ofFraction(numerator: $this, denominator: BigInteger::one()); + } + + /** + * Returns the largest integer dividing both this integer and another, never negative. + * + * @param BigInteger $other The integer to share a divisor with. + * @return BigInteger A new instance holding the greatest common divisor. + */ + public function greatestCommonDivisor(BigInteger $other): BigInteger + { + return new BigInteger(digits: $this->digits->greatestCommonDivisor(other: $other->digits)); + } +} diff --git a/src/BigNumber.php b/src/BigNumber.php deleted file mode 100644 index 7e2ed38..0000000 --- a/src/BigNumber.php +++ /dev/null @@ -1,161 +0,0 @@ -This is the type that lets division stay total. Every quotient of two numbers is a rational, + * so dividedBy never rounds and never raises for inexactness. The caller leaves + * exactness behind when ready, by naming a scale and a rounding mode, or by asking for an exact + * decimal and handling the case where none exists.

+ */ +final readonly class BigRational implements Number +{ + use NumberComparison; + + private const string ONE = '1'; + private const string ZERO = '0'; + private const string SEPARATOR = '/'; + private const int FLOAT_SCALE = 341; + + private function __construct(private Fraction $fraction) + { + } + + /** + * Creates a BigRational from a literal. + * + *

Accepts a fraction such as '3/4', a decimal such as '0.75', and + * an integer such as '3'.

+ * + * @param string|int $value The literal to read. + * @return BigRational The created instance. + * @throws NumberNotWellFormed If the literal is not a fraction, a decimal, or an integer, or if its + * exponent exceeds 10000 in magnitude. + * @throws InexactConversion If either term of a fraction carries a fractional part. + * @throws DivisionByZero If the denominator is zero. + */ + public static function of(string|int $value): BigRational + { + $literal = (string)$value; + $parts = explode(self::SEPARATOR, $literal); + + if (count($parts) > 2) { + throw NumberNotWellFormed::becauseValueIsNotNumeric(value: $literal); + } + + if (count($parts) === 1) { + return BigDecimal::of(value: $value)->toBigRational(); + } + + return BigRational::ofFraction( + numerator: BigInteger::of(value: $parts[0]), + denominator: BigInteger::of(value: $parts[1]) + ); + } + + /** + * Creates a BigRational holding one. + * + * @return BigRational The created instance. + */ + public static function one(): BigRational + { + return BigRational::of(value: self::ONE); + } + + /** + * Creates a BigRational holding zero. + * + * @return BigRational The created instance. + */ + public static function zero(): BigRational + { + return BigRational::of(value: self::ZERO); + } + + /** + * Creates a BigRational from a numerator and a denominator. + * + *

The result is reduced to the lowest terms and the sign is carried by the numerator.

+ * + * @param BigInteger $numerator The numerator. + * @param BigInteger $denominator The denominator, never zero. + * @return BigRational The created instance. + * @throws DivisionByZero If the denominator is zero. + */ + public static function ofFraction(BigInteger $numerator, BigInteger $denominator): BigRational + { + return new BigRational( + fraction: Fraction::of(numerator: $numerator->toString(), denominator: $denominator->toString()) + ); + } + + /** + * Adds another fraction to this one. + * + * @param BigRational $addend The fraction to add. + * @return BigRational A new instance holding the exact sum. + */ + public function plus(BigRational $addend): BigRational + { + return new BigRational(fraction: $this->fraction->plus(other: $addend->fraction)); + } + + /** + * Subtracts another fraction from this one. + * + * @param BigRational $subtrahend The fraction to subtract. + * @return BigRational A new instance holding the exact difference. + */ + public function minus(BigRational $subtrahend): BigRational + { + return new BigRational(fraction: $this->fraction->minus(other: $subtrahend->fraction)); + } + + /** + * Raises this fraction to a power. + * + *

Unlike {@see BigInteger} and {@see BigDecimal}, a negative exponent is supported, because + * the rationals are closed under it.

+ * + * @param int $exponent The exponent, positive, zero, or negative. + * @return BigRational A new instance holding the exact power. + * @throws ExponentOutOfRange If the exponent is beyond the supported magnitude. + * @throws DivisionByZero If the exponent is negative and this fraction is zero. + */ + public function power(int $exponent): BigRational + { + return new BigRational(fraction: $this->fraction->power(exponent: $exponent)); + } + + /** + * Tells whether this fraction holds the same numerator and denominator as another fraction. + * + *

Structural equality. Because a fraction is always stored in lowest terms, two instances + * holding the same value are always equal. To compare against a number of another type, use + * {@see Number::isEqualTo()}.

+ * + * @param BigRational $other The fraction to compare against. + * @return bool True when both hold the same fraction. + */ + public function equals(BigRational $other): bool + { + return $other->fraction->numerator === $this->fraction->numerator + && $other->fraction->denominator === $this->fraction->denominator; + } + + /** + * Tells whether this fraction is zero. + * + * @return bool True when the numerator is zero. + */ + public function isZero(): bool + { + return $this->fraction->isZero(); + } + + /** + * Returns this fraction with the opposite sign. + * + * @return BigRational A new instance with the sign flipped. + */ + public function negated(): BigRational + { + return new BigRational(fraction: $this->fraction->negated()); + } + + /** + * Returns this fraction as a native float. + * + *

The expansion runs past the smallest positive float before it is read, so a fraction of any + * magnitude keeps every significant digit a float can hold. One smaller than the smallest + * positive float reads back as zero, because no float represents it.

+ * + * @return float The nearest float, which may lose precision. + * @throws IntegerOverflow If the value is outside the native float range. + */ + public function toFloat(): float + { + return $this->toDecimal(scale: self::FLOAT_SCALE, rounding: RoundingMode::HalfEven)->toFloat(); + } + + /** + * Returns the magnitude of this fraction. + * + * @return BigRational A new instance with the sign removed. + */ + public function absolute(): BigRational + { + return new BigRational(fraction: $this->fraction->absolute()); + } + + /** + * Returns a deterministic hash of this fraction. + * + * @return string The structural hash. + */ + public function hashCode(): string + { + return new StructuralHash()->of(type: BigRational::class, representation: $this->toString()); + } + + /** + * Returns this fraction as a string. + * + * @return string The numerator and denominator separated by a slash, or just the numerator + * when the denominator is one. + */ + public function toString(): string + { + if ($this->fraction->denominator === self::ONE) { + return $this->fraction->numerator; + } + + $template = '%s/%s'; + + return sprintf($template, $this->fraction->numerator, $this->fraction->denominator); + } + + /** + * Compares this fraction with another number. + * + * @param Number $other The number to compare against. + * @return int A negative number, zero, or a positive number. + */ + public function compareTo(Number $other): int + { + return $this->fraction->compareTo(other: $other->toBigRational()->fraction); + } + + /** + * Divides this fraction by another, exactly. + * + * @param BigRational $divisor The fraction to divide by, never zero. + * @return BigRational A new instance holding the exact quotient. + * @throws DivisionByZero If the divisor is zero. + */ + public function dividedBy(BigRational $divisor): BigRational + { + if ($divisor->isZero()) { + throw DivisionByZero::becauseDivisorIsZero(dividend: $this->toString()); + } + + return new BigRational(fraction: $this->fraction->dividedBy(other: $divisor->fraction)); + } + + /** + * Returns the numerator, carrying the sign of the fraction. + * + * @return BigInteger The numerator. + */ + public function numerator(): BigInteger + { + return BigInteger::of(value: $this->fraction->numerator); + } + + /** + * Returns this fraction as a decimal, rounded to the given scale. + * + * @param int $scale The number of digits after the decimal point. + * @param RoundingMode $rounding The policy applied to the discarded digits. + * @return BigDecimal The rounded decimal. + * @throws ScaleOutOfRange If the scale is negative or beyond the supported range. + */ + public function toDecimal(int $scale, RoundingMode $rounding): BigDecimal + { + return BigDecimal::of( + value: $this->fraction->toDigits(scale: Scale::of(value: $scale), roundingMode: $rounding)->value() + ); + } + + /** + * Tells whether this fraction is strictly less than zero. + * + * @return bool True when the value is negative. + */ + public function isNegative(): bool + { + return $this->fraction->isNegative(); + } + + /** + * Returns the reciprocal of this fraction. + * + * @return BigRational A new instance with the numerator and denominator swapped. + * @throws DivisionByZero If this fraction is zero. + */ + public function reciprocal(): BigRational + { + return new BigRational(fraction: $this->fraction->reciprocal()); + } + + /** + * Returns the denominator, always strictly positive. + * + * @return BigInteger The denominator. + */ + public function denominator(): BigInteger + { + return BigInteger::of(value: $this->fraction->denominator); + } + + /** + * Multiplies this fraction by another. + * + * @param BigRational $multiplier The fraction to multiply by. + * @return BigRational A new instance holding the exact product. + */ + public function multipliedBy(BigRational $multiplier): BigRational + { + return new BigRational(fraction: $this->fraction->multipliedBy(other: $multiplier->fraction)); + } + + /** + * Returns this fraction as an integer. + * + * @return BigInteger The integer value. + * @throws InexactConversion If the denominator is not one. + */ + public function toBigInteger(): BigInteger + { + if ($this->fraction->denominator !== self::ONE) { + throw InexactConversion::becauseFractionalPartWouldBeLost(value: $this->toString()); + } + + return $this->numerator(); + } + + /** + * Returns this fraction as a JSON string. + * + * @return string The canonical string form. + */ + public function jsonSerialize(): string + { + return $this->toString(); + } + + /** + * Returns this fraction unchanged. + * + * @return BigRational This instance. + */ + public function toBigRational(): BigRational + { + return $this; + } + + /** + * Returns this fraction as a decimal without rounding. + * + * @return BigDecimal The exact decimal, at the smallest scale that represents it. + * @throws NonTerminatingDecimal If the decimal expansion repeats forever. + */ + public function toDecimalExact(): BigDecimal + { + return BigDecimal::of( + value: $this->fraction + ->toDigits(scale: $this->fraction->exactScale(), roundingMode: RoundingMode::Down) + ->value() + ); + } + + /** + * Tells whether this fraction has a terminating decimal expansion. + * + *

True when the reduced denominator is a product of twos and fives. Asking this is the + * cheap alternative to calling {@see BigRational::toDecimalExact()} and catching + * the failure.

+ * + * @return bool True when an exact decimal exists. + */ + public function hasTerminatingDecimal(): bool + { + return $this->fraction->hasTerminatingDecimal(); + } +} diff --git a/src/Calculator.php b/src/Calculator.php new file mode 100644 index 0000000..b0812ca --- /dev/null +++ b/src/Calculator.php @@ -0,0 +1,110 @@ +The contract is integer only, because that is the boundary every candidate backend shares: + * GMP has no fractional type at all, and a decimal is an integer plus a scale. Scale bookkeeping + * therefore stays inside the library and never reaches an implementation, so two backends cannot + * disagree about a result.

+ * + *

Every operand is a plain decimal integer string: optional leading -, then digits, + * with no leading zero and no decimal point. Implementations may assume that shape.

+ * + *

An operand the contract forbids is refused rather than answered, so the shipped backends are + * indistinguishable on every input, valid or not. This is the only validation an implementation + * owes: scale, range and shape are settled before a value reaches here.

+ */ +interface Calculator +{ + /** + * Adds two integers. + * + * @param string $left The first operand. + * @param string $right The second operand. + * @return string The sum. + */ + public function add(string $left, string $right): string; + + /** + * Raises an integer to a non-negative power. + * + * @param string $base The value to raise. + * @param int $exponent The exponent, never negative. + * @return string The power. + * @throws NegativeExponent If the exponent is negative. + */ + public function power(string $base, int $exponent): string; + + /** + * Compares two integers. + * + * @param string $left The first operand. + * @param string $right The second operand. + * @return int Exactly -1, 0, or 1. + */ + public function compare(string $left, string $right): int; + + /** + * Multiplies two integers. + * + * @param string $left The first operand. + * @param string $right The second operand. + * @return string The product. + */ + public function multiply(string $left, string $right): string; + + /** + * Divides two integers, truncating toward zero. + * + * @param string $numerator The value being divided. + * @param string $denominator The value to divide by, never zero. + * @return string The truncated quotient. + * @throws DivisionByZero If the denominator is zero. + */ + public function quotient(string $numerator, string $denominator): string; + + /** + * Subtracts one integer from another. + * + * @param string $minuend The value to subtract from. + * @param string $subtrahend The value to subtract. + * @return string The difference. + */ + public function subtract(string $minuend, string $subtrahend): string; + + /** + * Returns the remainder of a truncated division, carrying the sign of the numerator. + * + * @param string $numerator The value being divided. + * @param string $denominator The value to divide by, never zero. + * @return string The remainder. + * @throws DivisionByZero If the denominator is zero. + */ + public function remainder(string $numerator, string $denominator): string; + + /** + * Returns the integer square root, truncated toward zero. + * + * @param string $radicand The value whose root is taken, never negative. + * @return string The truncated square root. + * @throws NegativeRoot If the radicand is negative. + */ + public function squareRoot(string $radicand): string; + + /** + * Tells whether this backend can run in the current process. + * + * @return bool True when whatever this backend needs is present, which for the shipped backends + * means the bcmath extension in one case and 64-bit integers in the other. + */ + public function isAvailable(): bool; +} diff --git a/src/Calculators.php b/src/Calculators.php new file mode 100644 index 0000000..28b4092 --- /dev/null +++ b/src/Calculators.php @@ -0,0 +1,70 @@ +Resolution takes the first shipped backend that can run: BCMath when the extension is loaded, + * otherwise a pure PHP backend that needs nothing beyond 64-bit integers. Both produce identical + * results, so the extension buys speed rather than correctness. A consumer that ships its own + * engine registers it, and a registered backend always wins over the resolved one.

+ * + *

Resolution happens once and is then cached, so the arithmetic path never pays for it again. + * A backend is checked when it is registered rather than when it is used, so a bootstrap mistake + * surfaces at bootstrap.

+ * + *

What is swapped here is a stateless, pure engine, so a replacement changes how fast a result + * is produced and never what the result is.

+ */ +final class Calculators +{ + private static ?Calculator $current = null; + + private function __construct() + { + } + + /** + * Returns to automatic resolution, discarding any registered backend. + */ + public static function reset(): void + { + Calculators::$current = null; + } + + /** + * Returns the backend in use, resolving it on first call. + * + * @return Calculator The active backend. + * @throws CalculatorNotAvailable If no candidate can run in this process. + */ + public static function active(): Calculator + { + return Calculators::$current ??= new CalculatorSelection()->resolved( + new BcMathCalculator(), + new NativeCalculator() + ); + } + + /** + * Registers a backend for the rest of the process. + * + *

Intended for bootstrap code that ships its own engine, and for tests. Call + * {@see Calculators::reset()} to return to automatic resolution.

+ * + * @param Calculator $calculator The backend to use. + * @throws CalculatorNotAvailable If the backend cannot run in this process. + */ + public static function register(Calculator $calculator): void + { + Calculators::$current = new CalculatorSelection()->verified(calculator: $calculator); + } +} diff --git a/src/Exceptions/BaseOutOfRange.php b/src/Exceptions/BaseOutOfRange.php new file mode 100644 index 0000000..c7b0bb6 --- /dev/null +++ b/src/Exceptions/BaseOutOfRange.php @@ -0,0 +1,27 @@ +.'; + + return new BaseOutOfRange(message: sprintf($template, $bounds, $base)); + } +} diff --git a/src/Exceptions/CalculatorNotAvailable.php b/src/Exceptions/CalculatorNotAvailable.php new file mode 100644 index 0000000..a5d7ebd --- /dev/null +++ b/src/Exceptions/CalculatorNotAvailable.php @@ -0,0 +1,38 @@ + is not available. Install the extension it requires.'; + + return new CalculatorNotAvailable(message: sprintf($template, $calculator)); + } +} diff --git a/src/Exceptions/DivisionByZero.php b/src/Exceptions/DivisionByZero.php new file mode 100644 index 0000000..f6c0d7b --- /dev/null +++ b/src/Exceptions/DivisionByZero.php @@ -0,0 +1,59 @@ + by zero.'; + + return new DivisionByZero(message: sprintf($template, $dividend)); + } + + /** + * Creates the failure for an allocation whose weights sum to zero. + * + * @return DivisionByZero The created failure. + */ + public static function becauseWeightsSumToZero(): DivisionByZero + { + return new DivisionByZero(message: 'Allocation weights must not sum to zero.'); + } + + /** + * Creates the failure for a fraction whose denominator is zero. + * + * @param string $numerator The numerator of the rejected fraction. + * @return DivisionByZero The created failure. + */ + public static function becauseDenominatorIsZero(string $numerator): DivisionByZero + { + $template = 'Fraction <%s> cannot have a denominator of zero.'; + + return new DivisionByZero(message: sprintf($template, $numerator)); + } + + /** + * Creates the failure for the reciprocal of zero. + * + * @return DivisionByZero The created failure. + */ + public static function becauseReciprocalOfZeroIsUndefined(): DivisionByZero + { + return new DivisionByZero(message: 'The reciprocal of zero is undefined.'); + } +} diff --git a/src/Exceptions/ExponentOutOfRange.php b/src/Exceptions/ExponentOutOfRange.php new file mode 100644 index 0000000..f6e5196 --- /dev/null +++ b/src/Exceptions/ExponentOutOfRange.php @@ -0,0 +1,31 @@ +The bound applies to the magnitude, so it is the same for the negative exponents + * {@see \TinyBlocks\Math\BigRational::power()} accepts. A negative exponent on an + * integer or a decimal is a different invariant and raises {@see NegativeExponent}.

+ */ +final class ExponentOutOfRange extends InvalidArgumentException implements MathFailure +{ + /** + * Creates the failure for an exponent beyond the supported magnitude. + * + * @param int $maximum The largest supported magnitude. + * @param int $exponent The rejected exponent. + * @return ExponentOutOfRange The created failure. + */ + public static function becauseExponentIsTooLarge(int $maximum, int $exponent): ExponentOutOfRange + { + $template = 'Exponent magnitude must not exceed %d, got <%d>.'; + + return new ExponentOutOfRange(message: sprintf($template, $maximum, $exponent)); + } +} diff --git a/src/Exceptions/InexactConversion.php b/src/Exceptions/InexactConversion.php new file mode 100644 index 0000000..00ad2d4 --- /dev/null +++ b/src/Exceptions/InexactConversion.php @@ -0,0 +1,43 @@ +The caller can act on this: asking for a larger scale, or rounding explicitly, resolves it. + * Its sibling {@see NonTerminatingDecimal} cannot be resolved by asking for more digits.

+ */ +final class InexactConversion extends DomainException implements MathFailure +{ + /** + * Creates the failure for a value that does not fit the requested scale. + * + * @param int $scale The requested scale. + * @param string $value The value being converted. + * @return InexactConversion The created failure. + */ + public static function becauseScaleIsTooSmall(int $scale, string $value): InexactConversion + { + $template = 'Value <%s> cannot be represented at scale <%d> without discarding digits.'; + + return new InexactConversion(message: sprintf($template, $value, $scale)); + } + + /** + * Creates the failure for a value carrying a fractional part where an integer was required. + * + * @param string $value The value being converted. + * @return InexactConversion The created failure. + */ + public static function becauseFractionalPartWouldBeLost(string $value): InexactConversion + { + $template = 'Value <%s> has a fractional part and is not an integer.'; + + return new InexactConversion(message: sprintf($template, $value)); + } +} diff --git a/src/Exceptions/IntegerOverflow.php b/src/Exceptions/IntegerOverflow.php new file mode 100644 index 0000000..a956db8 --- /dev/null +++ b/src/Exceptions/IntegerOverflow.php @@ -0,0 +1,39 @@ + is outside the range of a native float.'; + + return new IntegerOverflow(message: sprintf($template, $value)); + } + + /** + * Creates the failure for a value outside the native integer range. + * + * @param string $value The value being converted. + * @return IntegerOverflow The created failure. + */ + public static function becauseValueExceedsIntegerRange(string $value): IntegerOverflow + { + $template = 'Value <%s> is outside the range of a native integer.'; + + return new IntegerOverflow(message: sprintf($template, $value)); + } +} diff --git a/src/Exceptions/MathFailure.php b/src/Exceptions/MathFailure.php new file mode 100644 index 0000000..5c9e9e2 --- /dev/null +++ b/src/Exceptions/MathFailure.php @@ -0,0 +1,20 @@ +PHP splits unrecoverable and recoverable faults into Error and + * Exception, and this library raises both: a zero divisor belongs under + * DivisionByZeroError while a malformed literal belongs under + * InvalidArgumentException. Catching this interface catches every one of them in a + * single clause.

+ */ +interface MathFailure extends Throwable +{ +} diff --git a/src/Exceptions/NegativeExponent.php b/src/Exceptions/NegativeExponent.php new file mode 100644 index 0000000..bd0249a --- /dev/null +++ b/src/Exceptions/NegativeExponent.php @@ -0,0 +1,30 @@ +A negative exponent leaves the integers and the decimals, so the operation has no result in + * either type. {@see \TinyBlocks\Math\BigRational::power()} accepts one, because a + * rational is closed under it.

+ */ +final class NegativeExponent extends InvalidArgumentException implements MathFailure +{ + /** + * Creates the failure for a negative exponent. + * + * @param int $exponent The rejected exponent. + * @return NegativeExponent The created failure. + */ + public static function becauseExponentIsNegative(int $exponent): NegativeExponent + { + $template = 'Exponent must not be negative, got <%d>. Convert to a BigRational first.'; + + return new NegativeExponent(message: sprintf($template, $exponent)); + } +} diff --git a/src/Exceptions/NegativeRoot.php b/src/Exceptions/NegativeRoot.php new file mode 100644 index 0000000..3197893 --- /dev/null +++ b/src/Exceptions/NegativeRoot.php @@ -0,0 +1,26 @@ +.'; + + return new NegativeRoot(message: sprintf($template, $radicand)); + } +} diff --git a/src/Exceptions/NegativeWeight.php b/src/Exceptions/NegativeWeight.php new file mode 100644 index 0000000..f4815a1 --- /dev/null +++ b/src/Exceptions/NegativeWeight.php @@ -0,0 +1,26 @@ +.'; + + return new NegativeWeight(message: sprintf($template, $weight)); + } +} diff --git a/src/Exceptions/NonTerminatingDecimal.php b/src/Exceptions/NonTerminatingDecimal.php new file mode 100644 index 0000000..efde2bf --- /dev/null +++ b/src/Exceptions/NonTerminatingDecimal.php @@ -0,0 +1,28 @@ +No scale resolves this, which is what separates it from {@see InexactConversion}.

+ */ +final class NonTerminatingDecimal extends DomainException implements MathFailure +{ + /** + * Creates the failure for a fraction with a repeating decimal expansion. + * + * @param string $fraction The fraction in numerator/denominator form. + * @return NonTerminatingDecimal The created failure. + */ + public static function becauseExpansionRepeats(string $fraction): NonTerminatingDecimal + { + $template = 'Fraction <%s> has a non-terminating decimal expansion.'; + + return new NonTerminatingDecimal(message: sprintf($template, $fraction)); + } +} diff --git a/src/Exceptions/NumberNotWellFormed.php b/src/Exceptions/NumberNotWellFormed.php new file mode 100644 index 0000000..abfa92d --- /dev/null +++ b/src/Exceptions/NumberNotWellFormed.php @@ -0,0 +1,40 @@ + is not a well-formed number.'; + + return new NumberNotWellFormed(message: sprintf($template, $value)); + } + + /** + * Creates the failure for an exponent too large to expand. + * + * @param string $value The literal carrying the exponent. + * @param int $maximum The largest exponent magnitude the library expands. + * @return NumberNotWellFormed The created failure. + */ + public static function becauseExponentIsTooLarge(string $value, int $maximum): NumberNotWellFormed + { + $template = 'Value <%s> carries an exponent beyond the supported magnitude of <%d>.'; + + return new NumberNotWellFormed(message: sprintf($template, $value, $maximum)); + } +} diff --git a/src/Exceptions/ScaleOutOfRange.php b/src/Exceptions/ScaleOutOfRange.php new file mode 100644 index 0000000..e1012e3 --- /dev/null +++ b/src/Exceptions/ScaleOutOfRange.php @@ -0,0 +1,27 @@ +.'; + + return new ScaleOutOfRange(message: sprintf($template, $maximum, $value)); + } +} diff --git a/src/Internal/Allocations/Allocation.php b/src/Internal/Allocations/Allocation.php new file mode 100644 index 0000000..3a2bad1 --- /dev/null +++ b/src/Internal/Allocations/Allocation.php @@ -0,0 +1,95 @@ + $weights + * @return Generator + */ + public function of(Scale $scale, Digits $amount, array $weights): Generator + { + $calculator = Calculators::active(); + $integers = $this->integers(weights: $weights); + $total = array_reduce( + $integers, + static fn(string $carry, string $weight): string => $calculator->add(left: $carry, right: $weight), + self::ZERO + ); + + if ($calculator->compare(left: $total, right: self::ZERO) === 0) { + throw DivisionByZero::becauseWeightsSumToZero(); + } + + yield from $this->shared(scale: $scale, total: $total, units: $amount->unscaled, integers: $integers); + } + + /** @return Generator */ + private function shared(Scale $scale, string $total, string $units, array $integers): Generator + { + $calculator = Calculators::active(); + $shares = array_map( + static fn(string $weight): Share => Share::of( + total: $total, + numerator: $calculator->multiply(left: $units, right: $weight) + ), + $integers + ); + $leftover = array_reduce( + $shares, + static fn(string $carry, Share $share): string => $calculator->add( + left: $carry, + right: $share->floor + ), + self::ZERO + ) + |> (static fn(string $distributed): string => $calculator->subtract( + minuend: $units, + subtrahend: $distributed + )) + |> intval(...); + $ranked = array_keys($shares); + + usort( + $ranked, + static fn(int $left, int $right): int => $shares[$left]->byLargestRemainder(other: $shares[$right]) + ?: ($left <=> $right) + ); + + foreach (array_slice($ranked, 0, $leftover) as $position) { + $shares[$position] = $shares[$position]->incremented(); + } + + foreach ($shares as $share) { + yield Digits::of(scale: $scale, unscaled: $share->floor); + } + } + + private function integers(array $weights): array + { + $common = array_reduce( + $weights, + static fn(Scale $carry, Digits $weight): Scale => $carry->greatest(other: $weight->scale), + Scale::zero() + ); + + return array_map( + static fn(Digits $weight): string => $weight->isNegative() + ? throw NegativeWeight::becauseWeightIsNegative(weight: $weight->value()) + : $weight->shiftedBy(places: $common->placesFrom(other: $weight->scale)), + $weights + ); + } +} diff --git a/src/Internal/Allocations/Share.php b/src/Internal/Allocations/Share.php new file mode 100644 index 0000000..cae5eaf --- /dev/null +++ b/src/Internal/Allocations/Share.php @@ -0,0 +1,49 @@ +remainder( + numerator: $calculator->add( + left: $calculator->remainder(numerator: $numerator, denominator: $total), + right: $total + ), + denominator: $total + ); + + return new Share( + floor: $calculator->quotient( + numerator: $calculator->subtract(minuend: $numerator, subtrahend: $modulo), + denominator: $total + ), + remainder: $modulo + ); + } + + public function incremented(): Share + { + return new Share( + floor: Calculators::active()->add(left: $this->floor, right: self::ONE), + remainder: $this->remainder + ); + } + + public function byLargestRemainder(Share $other): int + { + return Calculators::active()->compare(left: $other->remainder, right: $this->remainder); + } +} diff --git a/src/Internal/Backends/BcMathCalculator.php b/src/Internal/Backends/BcMathCalculator.php new file mode 100644 index 0000000..797194b --- /dev/null +++ b/src/Internal/Backends/BcMathCalculator.php @@ -0,0 +1,80 @@ +nonZero(numerator: $numerator, denominator: $denominator), self::EXACT_SCALE); + } + + public function subtract(string $minuend, string $subtrahend): string + { + return bcsub($minuend, $subtrahend, self::EXACT_SCALE); + } + + public function remainder(string $numerator, string $denominator): string + { + return bcmod($numerator, $this->nonZero(numerator: $numerator, denominator: $denominator), self::EXACT_SCALE); + } + + public function squareRoot(string $radicand): string + { + if (str_starts_with($radicand, self::MINUS) && ltrim($radicand, self::SIGNED_ZERO) !== '') { + throw NegativeRoot::becauseRadicandIsNegative(radicand: $radicand); + } + + return bcsqrt($radicand, self::EXACT_SCALE); + } + + public function isAvailable(): bool + { + return extension_loaded(self::EXTENSION); + } +} diff --git a/src/Internal/Backends/CalculatorSelection.php b/src/Internal/Backends/CalculatorSelection.php new file mode 100644 index 0000000..f1851d1 --- /dev/null +++ b/src/Internal/Backends/CalculatorSelection.php @@ -0,0 +1,31 @@ +isAvailable()) { + return $candidate; + } + } + + throw CalculatorNotAvailable::becauseNoBackendCanRun(); + } + + public function verified(Calculator $calculator): Calculator + { + if (!$calculator->isAvailable()) { + throw CalculatorNotAvailable::becauseExtensionIsMissing(calculator: $calculator::class); + } + + return $calculator; + } +} diff --git a/src/Internal/Backends/Difference.php b/src/Internal/Backends/Difference.php new file mode 100644 index 0000000..45520c8 --- /dev/null +++ b/src/Internal/Backends/Difference.php @@ -0,0 +1,17 @@ + $limbs */ + private function __construct(private array $limbs) + { + } + + public static function of(string $magnitude): Magnitude + { + $limbs = array_map( + static fn(string $chunk): int => intval(strrev($chunk)), + str_split(strrev($magnitude), self::DIGITS_PER_LIMB) + ); + + return Magnitude::from(limbs: $limbs); + } + + /** @param list $limbs */ + private static function from(array $limbs): Magnitude + { + $significant = array_key_last(array_filter($limbs, static fn(int $limb): bool => $limb !== 0)); + + return new Magnitude(limbs: is_null($significant) ? [] : array_slice($limbs, 0, ($significant + 1))); + } + + public function plus(Magnitude $other): Magnitude + { + $total = max(count($this->limbs), count($other->limbs)); + $addend = array_pad($other->limbs, $total, 0); + $limbs = []; + $carry = 0; + + foreach (array_pad($this->limbs, $total, 0) as $index => $limb) { + $sum = ($carry + $limb + $addend[$index]); + $limbs[] = ($sum % self::BASE); + $carry = intdiv($sum, self::BASE); + } + + return Magnitude::from(limbs: [...$limbs, $carry]); + } + + private function minus(Magnitude $other): Magnitude + { + return $this->differenceFrom(other: $other)->magnitude; + } + + public function isZero(): bool + { + return $this->limbs === []; + } + + public function toText(): string + { + if ($this->isZero()) { + return self::ZERO; + } + + $parts = array_map( + static fn(int $limb): string => str_pad((string)$limb, self::DIGITS_PER_LIMB, self::ZERO, STR_PAD_LEFT), + array_reverse($this->limbs) + ); + + return ltrim(implode('', $parts), self::ZERO); + } + + private function shifted(): Magnitude + { + return $this->isZero() ? $this : Magnitude::from(limbs: array_merge([0], $this->limbs)); + } + + private function scaledBy(int $factor): Magnitude + { + $limbs = []; + $carry = 0; + + foreach ($this->limbs as $limb) { + $product = (($limb * $factor) + $carry); + $limbs[] = ($product % self::BASE); + $carry = intdiv($product, self::BASE); + } + + return Magnitude::from(limbs: [...$limbs, $carry]); + } + + private function trialFor(int $digit): Magnitude + { + return $this->plus(other: Magnitude::from(limbs: [$digit]))->scaledBy(factor: $digit); + } + + public function compareTo(Magnitude $other): int + { + if (count($this->limbs) !== count($other->limbs)) { + return (count($this->limbs) <=> count($other->limbs)); + } + + for ($index = (count($this->limbs) - 1); $index >= 0; $index--) { + if ($this->limbs[$index] !== $other->limbs[$index]) { + return ($this->limbs[$index] <=> $other->limbs[$index]); + } + } + + return 0; + } + + public function dividedBy(Magnitude $divisor): Division + { + return count($divisor->limbs) === 1 + ? $this->dividedByLimb(divisor: $divisor->limbs[0]) + : $this->longDivision(divisor: $divisor); + } + + public function squareRoot(): Magnitude + { + $text = $this->toText(); + $padded = str_pad($text, (strlen($text) + (strlen($text) % self::HALF)), self::ZERO, STR_PAD_LEFT); + $root = Magnitude::from(limbs: []); + $remainder = Magnitude::from(limbs: []); + + foreach (str_split($padded, self::HALF) as $pair) { + $remainder = $remainder->scaledBy(factor: self::PAIR) + ->plus(other: Magnitude::from(limbs: [intval($pair)])); + $doubled = $root->scaledBy(factor: self::TWENTY); + $digit = $doubled->largestRootDigitFor(remainder: $remainder); + $remainder = $remainder->minus(other: $doubled->trialFor(digit: $digit)); + $root = $root->scaledBy(factor: self::TEN)->plus(other: Magnitude::from(limbs: [$digit])); + } + + return $root; + } + + /** + * @param list $limbs + * @return list + */ + private function complemented(array $limbs): array + { + $complement = []; + $borrow = 0; + + foreach ($limbs as $limb) { + $difference = ((0 - $borrow) - $limb); + $borrow = intval($difference < 0); + $complement[] = ($difference + ($borrow * self::BASE)); + } + + return $complement; + } + + private function leadingValue(Magnitude $divisor): int + { + $tail = array_slice($this->limbs, (count($divisor->limbs) - 1)); + + return ((array_sum(array_slice($tail, 1)) * self::BASE) + $tail[0]); + } + + private function longDivision(Magnitude $divisor): Division + { + $normalizer = intdiv(self::BASE, ($divisor->limbs[(count($divisor->limbs) - 1)] + 1)); + $scaled = $divisor->scaledBy(factor: $normalizer); + $quotient = []; + $remainder = Magnitude::from(limbs: []); + + foreach (array_reverse($this->scaledBy(factor: $normalizer)->limbs) as $limb) { + $remainder = $remainder->shifted()->plus(other: Magnitude::from(limbs: [$limb])); + $factor = $remainder->largestFactorOf(divisor: $scaled); + $quotient[] = $factor; + $remainder = $remainder->minus(other: $scaled->scaledBy(factor: $factor)); + } + + return Division::of( + quotient: Magnitude::from(limbs: array_reverse($quotient)), + remainder: $remainder->dividedByLimb(divisor: $normalizer)->quotient + ); + } + + public function multipliedBy(Magnitude $other): Magnitude + { + $limbs = array_fill(0, (count($this->limbs) + count($other->limbs)), 0); + $multipliers = array_pad($other->limbs, (count($other->limbs) + 1), 0); + + foreach ($this->limbs as $left => $multiplicand) { + $carry = 0; + + foreach ($multipliers as $right => $multiplier) { + $product = ($limbs[($left + $right)] + ($multiplicand * $multiplier) + $carry); + $limbs[($left + $right)] = ($product % self::BASE); + $carry = intdiv($product, self::BASE); + } + } + + return Magnitude::from(limbs: $limbs); + } + + private function dividedByLimb(int $divisor): Division + { + $quotient = []; + $remainder = 0; + + foreach (array_reverse($this->limbs) as $limb) { + $current = (($remainder * self::BASE) + $limb); + $quotient[] = intdiv($current, $divisor); + $remainder = ($current % $divisor); + } + + return Division::of( + quotient: Magnitude::from(limbs: array_reverse($quotient)), + remainder: Magnitude::from(limbs: [$remainder]) + ); + } + + public function differenceFrom(Magnitude $other): Difference + { + $width = max(count($this->limbs), count($other->limbs)); + $subtrahend = array_pad($other->limbs, $width, 0); + $limbs = []; + $borrow = 0; + + foreach (array_pad($this->limbs, $width, 0) as $index => $limb) { + $difference = ($limb - $borrow - $subtrahend[$index]); + $borrow = intval($difference < 0); + $limbs[] = ($difference + ($borrow * self::BASE)); + } + + return $borrow === 0 + ? Difference::of(magnitude: Magnitude::from(limbs: $limbs), isNegative: false) + : Difference::of(magnitude: Magnitude::from(limbs: $this->complemented(limbs: $limbs)), isNegative: true); + } + + private function largestFactorOf(Magnitude $divisor): int + { + if ($this->compareTo(other: $divisor) < 0) { + return 0; + } + + $head = $divisor->limbs[(count($divisor->limbs) - 1)]; + $estimate = intdiv($this->leadingValue(divisor: $divisor), $head); + $once = ($estimate - intval($divisor->scaledBy(factor: $estimate)->compareTo(other: $this) > 0)); + + return ($once - intval($divisor->scaledBy(factor: $once)->compareTo(other: $this) > 0)); + } + + private function largestRootDigitFor(Magnitude $remainder): int + { + foreach (self::DIGITS as $candidate) { + if ($this->trialFor(digit: $candidate)->compareTo(other: $remainder) <= 0) { + return $candidate; + } + } + + return 0; + } +} diff --git a/src/Internal/Backends/NativeCalculator.php b/src/Internal/Backends/NativeCalculator.php new file mode 100644 index 0000000..7ee415f --- /dev/null +++ b/src/Internal/Backends/NativeCalculator.php @@ -0,0 +1,150 @@ +isNegative === $addend->isNegative) { + return $this->signed( + magnitude: $augend->magnitude->plus(other: $addend->magnitude), + isNegative: $augend->isNegative + ); + } + + $difference = $augend->magnitude->differenceFrom(other: $addend->magnitude); + + return $this->signed( + magnitude: $difference->magnitude, + isNegative: $difference->isNegative ? $addend->isNegative : $augend->isNegative + ); + } + + public function power(string $base, int $exponent): string + { + if ($exponent < 0) { + throw NegativeExponent::becauseExponentIsNegative(exponent: $exponent); + } + + $power = (string)self::ONE; + + foreach (str_split(decbin($exponent)) as $bit) { + $squared = $this->multiply(left: $power, right: $power); + $power = $bit === self::SET_BIT ? $this->multiply(left: $squared, right: $base) : $squared; + } + + return $power; + } + + private function signed(Magnitude $magnitude, bool $isNegative): string + { + if ($magnitude->isZero()) { + return self::ZERO; + } + + $template = '%s%s'; + + return sprintf($template, $isNegative ? self::MINUS : '', $magnitude->toText()); + } + + public function compare(string $left, string $right): int + { + $first = Signed::of(value: $left); + $second = Signed::of(value: $right); + + if ($first->isNegative !== $second->isNegative) { + return $first->isNegative ? (0 - self::ONE) : self::ONE; + } + + $order = $first->magnitude->compareTo(other: $second->magnitude); + + return $first->isNegative ? (0 - $order) : $order; + } + + private function nonZero(string $numerator, string $denominator): Signed + { + $divisor = Signed::of(value: $denominator); + + if ($divisor->magnitude->isZero()) { + throw DivisionByZero::becauseDivisorIsZero(dividend: $numerator); + } + + return $divisor; + } + + public function multiply(string $left, string $right): string + { + $multiplicand = Signed::of(value: $left); + $multiplier = Signed::of(value: $right); + + return $this->signed( + magnitude: $multiplicand->magnitude->multipliedBy(other: $multiplier->magnitude), + isNegative: $multiplicand->isNegative !== $multiplier->isNegative + ); + } + + public function quotient(string $numerator, string $denominator): string + { + $dividend = Signed::of(value: $numerator); + $divisor = $this->nonZero(numerator: $numerator, denominator: $denominator); + + return $this->signed( + magnitude: $dividend->magnitude->dividedBy(divisor: $divisor->magnitude)->quotient, + isNegative: $dividend->isNegative !== $divisor->isNegative + ); + } + + public function subtract(string $minuend, string $subtrahend): string + { + $template = '-%s'; + $negated = str_starts_with($subtrahend, self::MINUS) + ? ltrim($subtrahend, self::MINUS) + : sprintf($template, $subtrahend); + + return $this->add(left: $minuend, right: $negated); + } + + public function remainder(string $numerator, string $denominator): string + { + $dividend = Signed::of(value: $numerator); + $divisor = $this->nonZero(numerator: $numerator, denominator: $denominator); + + return $this->signed( + magnitude: $dividend->magnitude->dividedBy(divisor: $divisor->magnitude)->remainder, + isNegative: $dividend->isNegative + ); + } + + public function squareRoot(string $radicand): string + { + $signedRadicand = Signed::of(value: $radicand); + + if ($signedRadicand->isNegative) { + throw NegativeRoot::becauseRadicandIsNegative(radicand: $radicand); + } + + return $signedRadicand->magnitude->squareRoot()->toText(); + } + + public function isAvailable(): bool + { + return PHP_INT_SIZE >= self::REQUIRED_INTEGER_SIZE; + } +} diff --git a/src/Internal/Backends/Signed.php b/src/Internal/Backends/Signed.php new file mode 100644 index 0000000..8fa32c7 --- /dev/null +++ b/src/Internal/Backends/Signed.php @@ -0,0 +1,24 @@ +isZero() + ); + } +} diff --git a/src/Internal/Bases/PositionalBase.php b/src/Internal/Bases/PositionalBase.php new file mode 100644 index 0000000..d3c4979 --- /dev/null +++ b/src/Internal/Bases/PositionalBase.php @@ -0,0 +1,91 @@ +base < self::MINIMUM || $this->base > self::MAXIMUM) { + throw BaseOutOfRange::becauseBaseIsOutsideBounds(base: $this->base, bounds: self::BOUNDS); + } + } + + public static function of(int $base): PositionalBase + { + return new PositionalBase(base: $base); + } + + public function toText(Digits $digits): string + { + if ($digits->isZero()) { + return self::ZERO; + } + + $calculator = Calculators::active(); + $remaining = ltrim($digits->unscaled, self::MINUS); + $text = ''; + + while ($remaining !== self::ZERO) { + $index = intval($calculator->remainder(numerator: $remaining, denominator: (string)$this->base)); + $template = '%s%s'; + $text = sprintf($template, self::ALPHABET[$index], $text); + $remaining = $calculator->quotient(numerator: $remaining, denominator: (string)$this->base); + } + + $template = '%s%s'; + + return sprintf($template, $digits->isNegative() ? self::MINUS : '', $text); + } + + private function position(string $value, string $character): int + { + $position = strpos(substr(self::ALPHABET, 0, $this->base), $character); + + if ($position === false) { + throw NumberNotWellFormed::becauseValueIsNotNumeric(value: $value); + } + + return $position; + } + + public function toDigits(string $value): Digits + { + $isNegative = str_starts_with($value, self::MINUS); + $magnitude = strtolower(substr($value, intval($isNegative))); + + if ($magnitude === '') { + throw NumberNotWellFormed::becauseValueIsNotNumeric(value: $value); + } + + $calculator = Calculators::active(); + $unscaled = self::ZERO; + + foreach (str_split($magnitude) as $character) { + $unscaled = $calculator->add( + left: $calculator->multiply(left: $unscaled, right: (string)$this->base), + right: (string)$this->position(value: $value, character: $character) + ); + } + + return Digits::of( + scale: Scale::zero(), + unscaled: $isNegative ? $calculator->subtract(minuend: self::ZERO, subtrahend: $unscaled) : $unscaled + ); + } +} diff --git a/src/Internal/BigNumberBehavior.php b/src/Internal/BigNumberBehavior.php deleted file mode 100644 index 1419bbe..0000000 --- a/src/Internal/BigNumberBehavior.php +++ /dev/null @@ -1,139 +0,0 @@ -mathOperations = $operationsFactory->build(); - } - - abstract public static function fromFloat(float $value, ?int $scale = BigNumber::AUTOMATIC_SCALE): BigNumber; - - abstract public static function fromString(string $value, ?int $scale = BigNumber::AUTOMATIC_SCALE): BigNumber; - - public function absolute(): BigNumber - { - return static::fromString(value: (string)abs((float)$this->number->value)); - } - - public function add(BigNumber $addend): BigNumber - { - $result = $this->mathOperations->add(augend: $this, addend: $addend); - - return static::fromString(value: $result->number->value, scale: $result->scale->value); - } - - public function subtract(BigNumber $subtrahend): BigNumber - { - $result = $this->mathOperations->subtract(minuend: $this, subtrahend: $subtrahend); - - return static::fromString(value: $result->number->value, scale: $result->scale->value); - } - - public function multiply(BigNumber $multiplier): BigNumber - { - $result = $this->mathOperations->multiply(multiplicand: $this, multiplier: $multiplier); - - return static::fromString(value: $result->number->value, scale: $result->scale->value); - } - - public function divide(BigNumber $divisor): BigNumber - { - if ($divisor->isZero()) { - throw new DivisionByZero(dividend: $this->number->value, divisor: $divisor->number->value); - } - - $result = $this->mathOperations->divide(dividend: $this, divisor: $divisor); - - return static::fromString(value: $result->number->value, scale: $result->scale->value); - } - - public function withRounding(RoundingMode $mode): BigNumber - { - $rounded = $mode->round(bigNumber: $this); - - return static::fromString(value: $rounded->value, scale: $this->scale->value); - } - - public function withScale(int $scale): BigNumber - { - $number = $this->scale->numberWithScale(number: $this->number, scale: $scale); - - return static::fromString(value: $number->value, scale: $scale); - } - - public function getScale(): ?int - { - return $this->scale->value; - } - - public function isZero(): bool - { - return $this->number->isZero(); - } - - public function isNegative(): bool - { - return $this->number->isNegative(); - } - - public function isPositive(): bool - { - return !$this->isNegativeOrZero(); - } - - public function isNegativeOrZero(): bool - { - return $this->number->isNegativeOrZero(); - } - - public function isPositiveOrZero(): bool - { - return $this->isPositive() || $this->isZero(); - } - - public function isLessThan(BigNumber $other): bool - { - return $this->number->isLessThan(other: $other->number); - } - - public function isGreaterThan(BigNumber $other): bool - { - return $this->number->isGreaterThan(other: $other->number); - } - - public function isLessThanOrEqual(BigNumber $other): bool - { - return $this->number->isLessThanOrEqual(other: $other->number); - } - - public function isGreaterThanOrEqual(BigNumber $other): bool - { - return $this->number->isGreaterThanOrEqual(other: $other->number); - } - - public function toFloat(): float - { - return $this->number->toFloatWithScale(scale: $this->scale); - } - - public function toString(): string - { - return $this->number->value; - } -} diff --git a/src/Internal/Decimals/Digits.php b/src/Internal/Decimals/Digits.php new file mode 100644 index 0000000..a852a8c --- /dev/null +++ b/src/Internal/Decimals/Digits.php @@ -0,0 +1,287 @@ +[+-]?)(?=\.?\d)(?\d*)(?:\.(?\d*))?' + . '(?:[eE](?[+-]?\d+))?$/'; + private const array EVEN_DIGITS = ['0', '2', '4', '6', '8']; + private const int MAXIMUM_EXPANSION = 10000; + private const array SIGNIFICANT_DIGITS = [16, 17]; + + private function __construct(public Scale $scale, public string $unscaled) + { + } + + public static function of(Scale $scale, string $unscaled): Digits + { + return new Digits(scale: $scale, unscaled: $unscaled); + } + + public static function from(string|int $value): Digits + { + $text = (string)$value; + + if (preg_match(self::PATTERN, $text, $parts) !== 1) { + throw NumberNotWellFormed::becauseValueIsNotNumeric(value: $text); + } + + $exponent = intval(($parts['exponent'] ?? '')); + + if (abs($exponent) > self::MAXIMUM_EXPANSION) { + throw NumberNotWellFormed::becauseExponentIsTooLarge(value: $text, maximum: self::MAXIMUM_EXPANSION); + } + + $fractional = ($parts['fractional'] ?? ''); + $places = (strlen($fractional) - $exponent); + $template = '%s%s%s%s'; + + return new Digits( + scale: Scale::of(value: max(0, $places)), + unscaled: Digits::normalized( + text: sprintf( + $template, + $parts['sign'], + $parts['integral'], + $fractional, + str_repeat(self::ZERO, max(0, (0 - $places))) + ) + ) + ); + } + + public static function tenTo(int $exponent): string + { + $template = '%s%s'; + + return sprintf($template, self::ONE, str_repeat(self::ZERO, $exponent)); + } + + public static function fromFloat(float $value): Digits + { + $template = '%%.%dG'; + + foreach (self::SIGNIFICANT_DIGITS as $digits) { + $format = sprintf($template, $digits); + $candidate = sprintf($format, $value); + + if ((float)$candidate === $value) { + return Digits::from(value: $candidate); + } + } + + return Digits::from(value: (string)var_export($value, true)); + } + + public static function normalized(string $text): string + { + $magnitude = ltrim(ltrim($text, self::SIGNS), self::ZERO); + $template = '%s%s'; + + return $magnitude === '' + ? self::ZERO + : sprintf($template, str_starts_with($text, self::MINUS) ? self::MINUS : '', $magnitude); + } + + public function plus(Digits $other): Digits + { + $scale = $this->scale->greatest(other: $other->scale); + + return new Digits( + scale: $scale, + unscaled: Calculators::active()->add( + left: $this->shiftedBy(places: $scale->placesFrom(other: $this->scale)), + right: $other->shiftedBy(places: $scale->placesFrom(other: $other->scale)) + ) + ); + } + + public function minus(Digits $other): Digits + { + return $this->plus(other: $other->negated()); + } + + public function power(int $exponent): Digits + { + $bounded = Exponent::of(value: $exponent); + + return new Digits( + scale: $this->scale->multipliedBy(factor: $bounded->magnitude), + unscaled: Calculators::active()->power(base: $this->unscaled, exponent: $bounded->magnitude) + ); + } + + public function toInt(): int + { + $calculator = Calculators::active(); + $isOutside = $calculator->compare(left: $this->unscaled, right: (string)PHP_INT_MAX) > 0 + || $calculator->compare(left: $this->unscaled, right: (string)PHP_INT_MIN) < 0; + + if ($isOutside) { + throw IntegerOverflow::becauseValueExceedsIntegerRange(value: $this->unscaled); + } + + return intval($this->unscaled); + } + + public function value(): string + { + if ($this->scale->value === 0) { + return $this->unscaled; + } + + $padded = str_pad(ltrim($this->unscaled, self::MINUS), ($this->scale->value + 1), self::ZERO, STR_PAD_LEFT); + $template = '%s%s.%s'; + + return sprintf( + $template, + $this->isNegative() ? self::MINUS : '', + substr($padded, 0, -$this->scale->value), + substr($padded, -$this->scale->value) + ); + } + + public function isEven(): bool + { + return in_array(substr($this->unscaled, -1), self::EVEN_DIGITS, true); + } + + public function isZero(): bool + { + return $this->unscaled === self::ZERO; + } + + public function negated(): Digits + { + return new Digits( + scale: $this->scale, + unscaled: Calculators::active()->subtract(minuend: self::ZERO, subtrahend: $this->unscaled) + ); + } + + public function absolute(): Digits + { + return new Digits(scale: $this->scale, unscaled: ltrim($this->unscaled, self::MINUS)); + } + + public function quotient(Digits $divisor): Digits + { + return new Digits( + scale: $this->scale, + unscaled: Calculators::active()->quotient( + numerator: $this->unscaled, + denominator: $divisor->unscaled + ) + ); + } + + public function compareTo(Digits $other): int + { + $scale = $this->scale->greatest(other: $other->scale); + + return Calculators::active()->compare( + left: $this->shiftedBy(places: $scale->placesFrom(other: $this->scale)), + right: $other->shiftedBy(places: $scale->placesFrom(other: $other->scale)) + ); + } + + public function remainder(Digits $divisor): Digits + { + return new Digits( + scale: $this->scale, + unscaled: Calculators::active()->remainder( + numerator: $this->unscaled, + denominator: $divisor->unscaled + ) + ); + } + + public function shiftedBy(int $places): string + { + $template = '%s%s'; + + return Digits::normalized(text: sprintf($template, $this->unscaled, str_repeat(self::ZERO, $places))); + } + + public function withScale(Scale $scale, RoundingMode $roundingMode): Digits + { + $shift = $scale->placesFrom(other: $this->scale); + + return new Digits( + scale: $scale, + unscaled: new Rounding()->trimmed( + value: $this->shiftedBy(places: max(0, $shift)), + places: Scale::of(value: max(0, -$shift)), + roundingMode: $roundingMode + ) + ); + } + + public function isNegative(): bool + { + return str_starts_with($this->unscaled, self::MINUS); + } + + public function squareRoot(): Digits + { + return new Digits(scale: $this->scale, unscaled: Calculators::active()->squareRoot(radicand: $this->unscaled)); + } + + public function scaleFactor(): string + { + return Digits::tenTo(exponent: $this->scale->value); + } + + public function integralPart(): string + { + return Calculators::active()->quotient(numerator: $this->unscaled, denominator: $this->scaleFactor()); + } + + public function multipliedBy(Digits $other): Digits + { + return new Digits( + scale: $this->scale->plus(other: $other->scale), + unscaled: Calculators::active()->multiply(left: $this->unscaled, right: $other->unscaled) + ); + } + + public function withoutTrailingZeros(): Digits + { + if ($this->isZero()) { + return new Digits(scale: Scale::zero(), unscaled: self::ZERO); + } + + $length = strlen($this->unscaled); + $removable = min($this->scale->value, ($length - strlen(rtrim($this->unscaled, self::ZERO)))); + + return new Digits( + scale: Scale::of(value: ($this->scale->value - $removable)), + unscaled: substr($this->unscaled, 0, ($length - $removable)) + ); + } + + public function greatestCommonDivisor(Digits $other): Digits + { + return new Digits( + scale: Scale::zero(), + unscaled: new GreatestCommonDivisor()->of( + left: ltrim($this->unscaled, self::MINUS), + right: ltrim($other->unscaled, self::MINUS) + ) + ); + } +} diff --git a/src/Internal/Decimals/Rounding.php b/src/Internal/Decimals/Rounding.php new file mode 100644 index 0000000..65e057e --- /dev/null +++ b/src/Internal/Decimals/Rounding.php @@ -0,0 +1,88 @@ +roundsAwayFromZero( + isEven: $calculator->remainder(numerator: $whole, denominator: self::TWO) === self::ZERO, + comparison: $comparison, + isNegative: $isNegative + ); + + $unit = $isNegative ? self::MINUS_ONE : self::ONE; + + return Digits::normalized( + text: $roundsAwayFromZero ? $calculator->add(left: $whole, right: $unit) : $whole + ); + } + + public function trimmed(string $value, Scale $places, RoundingMode $roundingMode): string + { + $magnitude = ltrim($value, self::MINUS); + $length = max(0, (strlen($magnitude) - $places->value)); + $discarded = str_pad(substr($magnitude, $length), $places->value, self::ZERO, STR_PAD_LEFT); + $template = '%s%s%s'; + $signed = sprintf( + $template, + str_starts_with($value, self::MINUS) ? self::MINUS : '', + self::ZERO, + substr($magnitude, 0, $length) + ); + + if (ltrim($discarded, self::ZERO) === '') { + return Digits::normalized(text: $signed); + } + + return $this->nearest( + whole: $signed, + comparison: (strcmp($discarded, str_pad(self::FIVE, $places->value, self::ZERO)) <=> 0), + roundingMode: $roundingMode + ); + } + + public function quotient(string $numerator, string $denominator, RoundingMode $roundingMode): string + { + $calculator = Calculators::active(); + $whole = $calculator->quotient(numerator: $numerator, denominator: $denominator); + $remainder = $calculator->subtract( + minuend: $numerator, + subtrahend: $calculator->multiply(left: $whole, right: $denominator) + ); + + if ($remainder === self::ZERO) { + return $whole; + } + + $template = '%s%s'; + + return $this->nearest( + whole: sprintf( + $template, + str_starts_with($numerator, self::MINUS) ? self::MINUS : '', + ltrim($whole, self::MINUS) + ), + comparison: $calculator->compare( + left: $calculator->multiply(left: ltrim($remainder, self::MINUS), right: self::TWO), + right: $denominator + ), + roundingMode: $roundingMode + ); + } +} diff --git a/src/Internal/Decimals/Scale.php b/src/Internal/Decimals/Scale.php new file mode 100644 index 0000000..691f356 --- /dev/null +++ b/src/Internal/Decimals/Scale.php @@ -0,0 +1,55 @@ +value < self::MINIMUM || $this->value > self::MAXIMUM) { + throw ScaleOutOfRange::becauseValueIsOutsideBounds(value: $this->value, maximum: self::MAXIMUM); + } + } + + public static function of(int $value): Scale + { + return new Scale(value: $value); + } + + public static function zero(): Scale + { + return new Scale(value: self::MINIMUM); + } + + public function plus(Scale $other): Scale + { + return new Scale(value: ($this->value + $other->value)); + } + + public function equals(Scale $other): bool + { + return $this->value === $other->value; + } + + public function greatest(Scale $other): Scale + { + return new Scale(value: max($this->value, $other->value)); + } + + public function placesFrom(Scale $other): int + { + return ($this->value - $other->value); + } + + public function multipliedBy(int $factor): Scale + { + return new Scale(value: ($this->value * $factor)); + } +} diff --git a/src/Internal/Decimals/SquareRoot.php b/src/Internal/Decimals/SquareRoot.php new file mode 100644 index 0000000..b9a739b --- /dev/null +++ b/src/Internal/Decimals/SquareRoot.php @@ -0,0 +1,59 @@ +shiftedBy(places: ($scale->value * 2)); + $denominator = Digits::tenTo(exponent: $radicand->scale->value); + $whole = $calculator->squareRoot( + radicand: $calculator->quotient(numerator: $numerator, denominator: $denominator) + ); + + return Digits::of( + scale: $scale, + unscaled: $this->rounded( + whole: $whole, + numerator: $numerator, + denominator: $denominator, + roundingMode: $roundingMode + ) + ); + } + + private function rounded(string $whole, string $numerator, string $denominator, RoundingMode $roundingMode): string + { + $calculator = Calculators::active(); + $exact = $calculator->multiply(left: $calculator->multiply(left: $whole, right: $whole), right: $denominator); + + if ($calculator->compare(left: $numerator, right: $exact) === 0) { + return $whole; + } + + $halfway = $calculator->add(left: $calculator->multiply(left: $whole, right: self::TWO), right: self::ONE); + + return new Rounding()->nearest( + whole: $whole, + comparison: $calculator->compare( + left: $calculator->multiply(left: self::FOUR, right: $numerator), + right: $calculator->multiply( + left: $calculator->multiply(left: $halfway, right: $halfway), + right: $denominator + ) + ), + roundingMode: $roundingMode + ); + } +} diff --git a/src/Internal/Exceptions/DivisionByZero.php b/src/Internal/Exceptions/DivisionByZero.php deleted file mode 100644 index c5d2ac6..0000000 --- a/src/Internal/Exceptions/DivisionByZero.php +++ /dev/null @@ -1,17 +0,0 @@ - by <%.2f>.'; - - parent::__construct(message: sprintf($template, $this->dividend, $this->divisor)); - } -} diff --git a/src/Internal/Exceptions/InvalidNumber.php b/src/Internal/Exceptions/InvalidNumber.php deleted file mode 100644 index 81a7969..0000000 --- a/src/Internal/Exceptions/InvalidNumber.php +++ /dev/null @@ -1,17 +0,0 @@ - is not a valid number.'; - - parent::__construct(message: sprintf($template, $this->value)); - } -} diff --git a/src/Internal/Exceptions/InvalidScale.php b/src/Internal/Exceptions/InvalidScale.php deleted file mode 100644 index f1a3a3d..0000000 --- a/src/Internal/Exceptions/InvalidScale.php +++ /dev/null @@ -1,20 +0,0 @@ - is invalid. The value must be between <%s> and <%s>.'; - - parent::__construct(message: sprintf($template, $this->value, $this->minimum, $this->maximum)); - } -} diff --git a/src/Internal/Exceptions/MathOperationsNotAvailable.php b/src/Internal/Exceptions/MathOperationsNotAvailable.php deleted file mode 100644 index 40257e8..0000000 --- a/src/Internal/Exceptions/MathOperationsNotAvailable.php +++ /dev/null @@ -1,17 +0,0 @@ - extensions.'; - - parent::__construct(message: sprintf($template, implode(',', $this->extensions))); - } -} diff --git a/src/Internal/Exceptions/NonNegativeValue.php b/src/Internal/Exceptions/NonNegativeValue.php deleted file mode 100644 index 3304b39..0000000 --- a/src/Internal/Exceptions/NonNegativeValue.php +++ /dev/null @@ -1,18 +0,0 @@ - is not valid. Must be a negative number less than zero.'; - - parent::__construct(message: sprintf($template, $this->number->value)); - } -} diff --git a/src/Internal/Exceptions/NonPositiveValue.php b/src/Internal/Exceptions/NonPositiveValue.php deleted file mode 100644 index ad3446f..0000000 --- a/src/Internal/Exceptions/NonPositiveValue.php +++ /dev/null @@ -1,18 +0,0 @@ - is not valid. Must be a positive number greater than zero.'; - - parent::__construct(message: sprintf($template, $this->number->value)); - } -} diff --git a/src/Internal/Exponent.php b/src/Internal/Exponent.php new file mode 100644 index 0000000..2fd30c8 --- /dev/null +++ b/src/Internal/Exponent.php @@ -0,0 +1,25 @@ + self::MAXIMUM || $value < (0 - self::MAXIMUM)) { + throw ExponentOutOfRange::becauseExponentIsTooLarge(maximum: self::MAXIMUM, exponent: $value); + } + + return new Exponent(magnitude: max($value, -$value)); + } +} diff --git a/src/Internal/Fractions/Fraction.php b/src/Internal/Fractions/Fraction.php new file mode 100644 index 0000000..fe18df4 --- /dev/null +++ b/src/Internal/Fractions/Fraction.php @@ -0,0 +1,199 @@ +compare(left: $denominator, right: self::ZERO) === 0) { + throw DivisionByZero::becauseDenominatorIsZero(numerator: $numerator); + } + + $sign = (string)$calculator->compare(left: $denominator, right: self::ZERO); + $signed = $calculator->multiply(left: $numerator, right: $sign); + $positive = $calculator->multiply(left: $denominator, right: $sign); + $divisor = new GreatestCommonDivisor()->of(left: ltrim($signed, self::MINUS), right: $positive); + + return new Fraction( + numerator: $calculator->quotient(numerator: $signed, denominator: $divisor), + denominator: $calculator->quotient(numerator: $positive, denominator: $divisor) + ); + } + + public function plus(Fraction $other): Fraction + { + $calculator = Calculators::active(); + + return Fraction::of( + numerator: $calculator->add( + left: $calculator->multiply(left: $this->numerator, right: $other->denominator), + right: $calculator->multiply(left: $other->numerator, right: $this->denominator) + ), + denominator: $calculator->multiply(left: $this->denominator, right: $other->denominator) + ); + } + + public function minus(Fraction $other): Fraction + { + return $this->plus(other: $other->negated()); + } + + public function power(int $exponent): Fraction + { + $calculator = Calculators::active(); + $bounded = Exponent::of(value: $exponent); + $base = $exponent < 0 ? $this->reciprocal() : $this; + + return new Fraction( + numerator: $calculator->power(base: $base->numerator, exponent: $bounded->magnitude), + denominator: $calculator->power(base: $base->denominator, exponent: $bounded->magnitude) + ); + } + + public function isZero(): bool + { + return $this->numerator === self::ZERO; + } + + private function divides(Scale $scale): bool + { + return Calculators::active()->remainder( + numerator: Digits::tenTo(exponent: $scale->value), + denominator: $this->denominator + ) === self::ZERO; + } + + public function negated(): Fraction + { + return new Fraction( + numerator: Calculators::active()->subtract(minuend: self::ZERO, subtrahend: $this->numerator), + denominator: $this->denominator + ); + } + + public function absolute(): Fraction + { + return new Fraction(numerator: ltrim($this->numerator, self::MINUS), denominator: $this->denominator); + } + + public function toDigits(Scale $scale, RoundingMode $roundingMode): Digits + { + $calculator = Calculators::active(); + + return Digits::of( + scale: $scale, + unscaled: new Rounding()->quotient( + numerator: $calculator->multiply(left: $this->numerator, right: Digits::tenTo(exponent: $scale->value)), + denominator: $this->denominator, + roundingMode: $roundingMode + ) + ); + } + + public function compareTo(Fraction $other): int + { + $calculator = Calculators::active(); + + return $calculator->compare( + left: $calculator->multiply(left: $this->numerator, right: $other->denominator), + right: $calculator->multiply(left: $other->numerator, right: $this->denominator) + ); + } + + public function dividedBy(Fraction $other): Fraction + { + $calculator = Calculators::active(); + + return Fraction::of( + numerator: $calculator->multiply(left: $this->numerator, right: $other->denominator), + denominator: $calculator->multiply(left: $this->denominator, right: $other->numerator) + ); + } + + public function exactScale(): Scale + { + $scale = $this->minimalScale(); + + if (!$this->divides(scale: $scale)) { + $template = '%s/%s'; + + throw NonTerminatingDecimal::becauseExpansionRepeats( + fraction: sprintf($template, $this->numerator, $this->denominator) + ); + } + + return $scale; + } + + public function isNegative(): bool + { + return str_starts_with($this->numerator, self::MINUS); + } + + public function reciprocal(): Fraction + { + if ($this->isZero()) { + throw DivisionByZero::becauseReciprocalOfZeroIsUndefined(); + } + + return Fraction::of(numerator: $this->denominator, denominator: $this->numerator); + } + + private function factorCount(string $factor): int + { + $calculator = Calculators::active(); + $remaining = $this->denominator; + $count = 0; + + while ($calculator->remainder(numerator: $remaining, denominator: $factor) === self::ZERO) { + $remaining = $calculator->quotient(numerator: $remaining, denominator: $factor); + $count++; + } + + return $count; + } + + private function minimalScale(): Scale + { + return Scale::of(value: max($this->factorCount(factor: self::TWO), $this->factorCount(factor: self::FIVE))); + } + + public function multipliedBy(Fraction $other): Fraction + { + $calculator = Calculators::active(); + + return Fraction::of( + numerator: $calculator->multiply(left: $this->numerator, right: $other->numerator), + denominator: $calculator->multiply(left: $this->denominator, right: $other->denominator) + ); + } + + public function hasTerminatingDecimal(): bool + { + return $this->divides(scale: $this->minimalScale()); + } +} diff --git a/src/Internal/GreatestCommonDivisor.php b/src/Internal/GreatestCommonDivisor.php new file mode 100644 index 0000000..7606e85 --- /dev/null +++ b/src/Internal/GreatestCommonDivisor.php @@ -0,0 +1,25 @@ +remainder(numerator: $left, denominator: $right); + $left = $right; + $right = $remainder; + } + + return $left; + } +} diff --git a/src/Internal/Number.php b/src/Internal/Number.php deleted file mode 100644 index 758eeeb..0000000 --- a/src/Internal/Number.php +++ /dev/null @@ -1,124 +0,0 @@ -[\-\+]'; - private const string POINT = '?\.'; - private const string INTEGRAL = '?[0-9]+'; - private const string EXPONENT = '?[\-\+]?[0-9]+'; - private const string NUMERATOR = '?[0-9]+'; - private const string FRACTIONAL = '?[0-9]+'; - private const string DENOMINATOR = '?[0-9]+'; - private const string VALID_NUMBER = '/^(%s)?(?:(?:(%s)?(%s)?(%s)?(?:[eE](%s))?)|(?:(%s)\/?(%s)))$/'; - - private int $match; - - private array $matches = []; - - private function __construct(public readonly string $value) - { - $pattern = sprintf( - self::VALID_NUMBER, - self::SIGN, - self::INTEGRAL, - self::POINT, - self::FRACTIONAL, - self::EXPONENT, - self::NUMERATOR, - self::DENOMINATOR - ); - - $this->match = preg_match($pattern, $this->value, $this->matches); - - if ($this->isInvalidNumber()) { - throw new InvalidNumber(value: $this->value); - } - } - - public static function from(float|string $value): Number - { - if (is_float($value) && is_nan($value)) { - throw new InvalidNumber(value: "NAN"); - } - - return new Number(value: (string)$value); - } - - public function getExponent(): ?string - { - return $this->match(key: 'exponent'); - } - - public function getFractional(): ?string - { - return $this->match(key: 'fractional'); - } - - public function isZero(): bool - { - return $this->value == self::ZERO; - } - - public function isNegative(): bool - { - return $this->value < self::ZERO; - } - - public function isPositiveOrZero(): bool - { - return $this->isZero() || !$this->isNegative(); - } - - public function isNegativeOrZero(): bool - { - return $this->isZero() || $this->isNegative(); - } - - public function isLessThan(Number $other): bool - { - return $this->value < $other->value; - } - - public function isGreaterThan(Number $other): bool - { - return $this->value > $other->value; - } - - public function isLessThanOrEqual(Number $other): bool - { - return $this->value <= $other->value; - } - - public function isGreaterThanOrEqual(Number $other): bool - { - return $this->value >= $other->value; - } - - public function toFloatWithScale(Scale $scale): float - { - $value = (float)$this->value; - - return $scale->hasAutomaticScale() ? $value : (float)number_format($value, $scale->value, '.', ''); - } - - private function match(string $key): ?string - { - $math = $this->matches[$key] ?? null; - - return $math == '' ? null : $math; - } - - private function isInvalidNumber(): bool - { - $integral = $this->match(key: 'integral'); - - return ($this->match !== 1) || (is_null($integral) && is_null($this->getFractional())); - } -} diff --git a/src/Internal/NumberComparison.php b/src/Internal/NumberComparison.php new file mode 100644 index 0000000..ff0dc63 --- /dev/null +++ b/src/Internal/NumberComparison.php @@ -0,0 +1,46 @@ +compareTo(other: $other) === 0; + } + + public function isLessThan(Number $other): bool + { + return $this->compareTo(other: $other) < 0; + } + + abstract public function isNegative(): bool; + + public function isPositive(): bool + { + return !$this->isZero() && !$this->isNegative(); + } + + public function isGreaterThan(Number $other): bool + { + return $this->compareTo(other: $other) > 0; + } + + public function isLessThanOrEqualTo(Number $other): bool + { + return $this->compareTo(other: $other) <= 0; + } + + public function isGreaterThanOrEqualTo(Number $other): bool + { + return $this->compareTo(other: $other) >= 0; + } +} diff --git a/src/Internal/Operations/Adapters/BcMathAdapter.php b/src/Internal/Operations/Adapters/BcMathAdapter.php deleted file mode 100644 index d8b8186..0000000 --- a/src/Internal/Operations/Adapters/BcMathAdapter.php +++ /dev/null @@ -1,72 +0,0 @@ -applyScale(); - $number = Number::from( - value: bcadd( - $augend->toString(), - $addend->toString(), - $scale->value - ) - ); - - return new Result(number: $number, scale: $scale); - } - - public function subtract(BigNumber $minuend, BigNumber $subtrahend): Result - { - $scale = new Subtraction(minuend: $minuend, subtrahend: $subtrahend)->applyScale(); - $number = Number::from( - value: bcsub( - $minuend->toString(), - $subtrahend->toString(), - $scale->value - ) - ); - - return new Result(number: $number, scale: $scale); - } - - public function multiply(BigNumber $multiplicand, BigNumber $multiplier): Result - { - $scale = new Multiplication(multiplicand: $multiplicand, multiplier: $multiplier)->applyScale(); - $number = Number::from( - value: bcmul( - $multiplicand->toString(), - $multiplier->toString(), - $scale->value - ) - ); - - return new Result(number: $number, scale: $scale); - } - - public function divide(BigNumber $dividend, BigNumber $divisor): Result - { - $scale = new Division(dividend: $dividend, divisor: $divisor)->applyScale(); - $number = Number::from( - value: bcdiv( - $dividend->toString(), - $divisor->toString(), - $scale->value - ) - ); - - return new Result(number: $number, scale: $scale); - } -} diff --git a/src/Internal/Operations/Adapters/Result.php b/src/Internal/Operations/Adapters/Result.php deleted file mode 100644 index c4d329f..0000000 --- a/src/Internal/Operations/Adapters/Result.php +++ /dev/null @@ -1,15 +0,0 @@ -augendScale = Scale::from(value: $this->augend->getScale()); - $this->addendScale = Scale::from(value: $this->addend->getScale()); - } - - public function applyScale(): Scale - { - if ($this->augendScale->hasAutomaticScale() && $this->addendScale->hasAutomaticScale()) { - $augendScale = $this->augendScale->scaleOf(value: $this->augend->toString()); - $addendScale = $this->addendScale->scaleOf(value: $this->addend->toString()); - - return $augendScale->greaterScale(other: $addendScale); - } - - return $this->augendScale->greaterScale(other: $this->addendScale); - } -} diff --git a/src/Internal/Operations/Adapters/Scales/Division.php b/src/Internal/Operations/Adapters/Scales/Division.php deleted file mode 100644 index 370bb06..0000000 --- a/src/Internal/Operations/Adapters/Scales/Division.php +++ /dev/null @@ -1,32 +0,0 @@ -dividendScale = Scale::from(value: $this->dividend->getScale()); - $this->divisorScale = Scale::from(value: $this->divisor->getScale()); - } - - public function applyScale(): Scale - { - if ($this->dividendScale->hasAutomaticScale() && $this->divisorScale->hasAutomaticScale()) { - $quotient = $this->dividend->toString() / $this->divisor->toString(); - - return $this->dividendScale->scaleOf(value: (string)$quotient); - } - - return $this->dividendScale->greaterScale(other: $this->divisorScale); - } -} diff --git a/src/Internal/Operations/Adapters/Scales/Multiplication.php b/src/Internal/Operations/Adapters/Scales/Multiplication.php deleted file mode 100644 index 37edd7e..0000000 --- a/src/Internal/Operations/Adapters/Scales/Multiplication.php +++ /dev/null @@ -1,37 +0,0 @@ -multiplierScale = Scale::from(value: $this->multiplier->getScale()); - $this->multiplicandScale = Scale::from(value: $this->multiplicand->getScale()); - } - - public function applyScale(): Scale - { - if ($this->multiplicandScale->hasAutomaticScale() && $this->multiplierScale->hasAutomaticScale()) { - $multiplicandScale = $this->multiplicandScale->scaleOf(value: $this->multiplicand->toString()); - $multiplierScale = $this->multiplierScale->scaleOf(value: $this->multiplier->toString()); - - if ($multiplicandScale->equals(other: $multiplierScale)) { - return $multiplicandScale->add(other: $multiplierScale); - } - - return $multiplicandScale->greaterScale(other: $multiplierScale); - } - - return $this->multiplicandScale->greaterScale(other: $this->multiplierScale); - } -} diff --git a/src/Internal/Operations/Adapters/Scales/Scales.php b/src/Internal/Operations/Adapters/Scales/Scales.php deleted file mode 100644 index bf96168..0000000 --- a/src/Internal/Operations/Adapters/Scales/Scales.php +++ /dev/null @@ -1,22 +0,0 @@ -minuendScale = Scale::from(value: $this->minuend->getScale()); - $this->subtrahendScale = Scale::from(value: $this->subtrahend->getScale()); - } - - public function applyScale(): Scale - { - if ($this->minuendScale->hasAutomaticScale() && $this->subtrahendScale->hasAutomaticScale()) { - $minuendScale = $this->minuendScale->scaleOf(value: $this->minuend->toString()); - $subtrahendScale = $this->subtrahendScale->scaleOf(value: $this->subtrahend->toString()); - - return $minuendScale->greaterScale(other: $subtrahendScale); - } - - return $this->minuendScale->greaterScale(other: $this->subtrahendScale); - } -} diff --git a/src/Internal/Operations/Extension/Extension.php b/src/Internal/Operations/Extension/Extension.php deleted file mode 100644 index 65d40b2..0000000 --- a/src/Internal/Operations/Extension/Extension.php +++ /dev/null @@ -1,21 +0,0 @@ -extension->isAvailable(extension: Extension::BC_MATH)) { - return new BcMathAdapter(); - } - - throw new MathOperationsNotAvailable(extensions: [Extension::BC_MATH]); - } -} diff --git a/src/Internal/Scale.php b/src/Internal/Scale.php deleted file mode 100644 index 3207bfa..0000000 --- a/src/Internal/Scale.php +++ /dev/null @@ -1,80 +0,0 @@ -hasAutomaticScale() && ($this->value < self::MINIMUM || $this->value > self::MAXIMUM)) { - throw new InvalidScale(value: $this->value, minimum: self::MINIMUM, maximum: self::MAXIMUM); - } - } - - public static function from(?int $value): Scale - { - return new Scale(value: $value); - } - - public function scaleOf(string $value): Scale - { - $number = Number::from(value: $value); - - $exponent = $number->getExponent(); - $exponent = is_null($exponent) ? self::MINIMUM : $exponent; - - $fractional = $number->getFractional(); - $fractional = is_null($fractional) ? self::MINIMUM : strlen($fractional); - - return new Scale(value: max($fractional - $exponent, self::MINIMUM)); - } - - public function numberWithScale(Number $number, int $scale): Number - { - if ($number->isZero()) { - $formattedValue = number_format(0, $scale, '.', ''); - return Number::from(value: $formattedValue); - } - - $result = explode('.', $number->value); - $decimal = $result[0]; - - $places = substr($result[self::FIRST_DECIMAL_PLACE], self::ZERO_DECIMAL_PLACE, $scale); - $decimalPlaces = str_pad($places, $scale, '0'); - - $template = '%s.%s'; - $value = sprintf($template, $decimal, $decimalPlaces); - - return Number::from(value: $value); - } - - public function add(Scale $other): Scale - { - return new Scale(value: $this->value + $other->value); - } - - public function greaterScale(Scale $other): Scale - { - return new Scale(value: max($this->value, $other->value)); - } - - public function equals(Scale $other): bool - { - return $this->value == $other->value; - } - - public function hasAutomaticScale(): bool - { - return $this->value === BigNumber::AUTOMATIC_SCALE; - } -} diff --git a/src/Internal/StructuralHash.php b/src/Internal/StructuralHash.php new file mode 100644 index 0000000..fbba398 --- /dev/null +++ b/src/Internal/StructuralHash.php @@ -0,0 +1,17 @@ +isPositiveOrZero()) { - throw new NonNegativeValue(number: $number); - } - - parent::__construct(number: $number, scale: $scale); - } - - public static function fromFloat(float $value, ?int $scale = BigNumber::AUTOMATIC_SCALE): NegativeBigDecimal - { - return new NegativeBigDecimal(value: $value, scale: $scale); - } - - public static function fromString(string $value, ?int $scale = BigNumber::AUTOMATIC_SCALE): NegativeBigDecimal - { - return new NegativeBigDecimal(value: $value, scale: $scale); - } -} diff --git a/src/Number.php b/src/Number.php new file mode 100644 index 0000000..2fc038b --- /dev/null +++ b/src/Number.php @@ -0,0 +1,142 @@ +Two notions of sameness live here and they are deliberately named apart. + * isEqualTo is arithmetic and works across types, so 1.0 and + * 1.00 are equal. Each implementation also carries an equals of its own, + * typed to its exact class, which is structural and reads the representation, so the same pair is + * not equal because the scales differ. Java documents this trap three times because it exposes + * only one of the two under an ambiguous name.

+ * + *

Serialization is a JSON string rather than a JSON number, because a JSON number is read back + * as an IEEE-754 double and would discard exactly what this library preserves.

+ */ +interface Number extends JsonSerializable +{ + /** + * Tells whether this number is zero. + * + * @return bool True when the value is zero, at any scale. + */ + public function isZero(): bool; + + /** + * Returns this number with the opposite sign. + * + * @return Number A new instance with the sign flipped. + */ + public function negated(): Number; + + /** + * Returns the magnitude of this number. + * + * @return Number A new instance with the sign removed. + */ + public function absolute(): Number; + + /** + * Returns a deterministic hash of this number's representation. + * + *

Agrees with the implementation's own equals, so two numbers that are + * structurally equal share a hash. Two numbers that are only arithmetically equal, such as + * 1.0 and 1.00, do not.

+ * + * @return string The structural hash. + */ + public function hashCode(): string; + + /** + * Returns this number as a string. + * + *

Always positional notation, never scientific. The output round-trips through the + * originating type's own factory.

+ * + * @return string The canonical string form. + */ + public function toString(): string; + + /** + * Compares this number with another. + * + *

Exact whatever the pair of types. Two numbers of the same type are compared on their own + * representation. Across types the comparison goes through {@see BigRational}, the only form + * every number has exactly.

+ * + * @param Number $other The number to compare against. + * @return int A negative number, zero, or a positive number. + */ + public function compareTo(Number $other): int; + + /** + * Tells whether this number has the same value as another. + * + *

Arithmetic equality, so scale is irrelevant and the two numbers need not share a type. + * For structural equality that distinguishes 1.0 from 1.00, use the + * implementation's own equals, which accepts only its exact type.

+ * + * @param Number $other The number to compare against. + * @return bool True when both represent the same value. + */ + public function isEqualTo(Number $other): bool; + + /** + * Tells whether this number is strictly less than another. + * + * @param Number $other The number to compare against. + * @return bool True when this number is smaller. + */ + public function isLessThan(Number $other): bool; + + /** + * Tells whether this number is strictly less than zero. + * + * @return bool True when the value is negative. + */ + public function isNegative(): bool; + + /** + * Tells whether this number is strictly greater than zero. + * + * @return bool True when the value is positive. + */ + public function isPositive(): bool; + + /** + * Tells whether this number is strictly greater than another. + * + * @param Number $other The number to compare against. + * @return bool True when this number is larger. + */ + public function isGreaterThan(Number $other): bool; + + /** + * Returns this number as an exact fraction. + * + * @return BigRational The exact rational representation. + */ + public function toBigRational(): BigRational; + + /** + * Tells whether this number is less than or equal to another. + * + * @param Number $other The number to compare against. + * @return bool True when this number is smaller or equal. + */ + public function isLessThanOrEqualTo(Number $other): bool; + + /** + * Tells whether this number is greater than or equal to another. + * + * @param Number $other The number to compare against. + * @return bool True when this number is larger or equal. + */ + public function isGreaterThanOrEqualTo(Number $other): bool; +} diff --git a/src/Percentage.php b/src/Percentage.php new file mode 100644 index 0000000..a56baac --- /dev/null +++ b/src/Percentage.php @@ -0,0 +1,265 @@ +Percentage::of(value: '12.5') is twelve and a half percent. The value is held as + * a {@see BigDecimal}, so applying it to an amount is a multiplication and therefore exact: no + * rounding decision is forced on the caller until the result is presented.

+ * + *

Negative percentages and percentages above one hundred are legal, because a negative growth + * rate and a one hundred and fifty percent increase are both real. There is no invariant to + * enforce here and therefore no failure to raise.

+ */ +final readonly class Percentage implements JsonSerializable +{ + private const string SIGN = '%'; + private const string ZERO = '0'; + private const int PLACES_IN_A_HUNDRED = 2; + + private function __construct(private BigDecimal $percent) + { + } + + /** + * Creates a Percentage from a literal expressed per hundred. + * + * @param string|int $value The literal, where '12.5' means twelve and a half percent. + * A trailing percent sign is accepted, so the string form reads back. + * @return Percentage The created instance. + * @throws NumberNotWellFormed If the literal is not a number, or its exponent exceeds 10000 in magnitude. + * @throws ScaleOutOfRange If the literal implies a scale beyond the supported range. + */ + public static function of(string|int $value): Percentage + { + $literal = (string)$value; + $rate = str_ends_with($literal, self::SIGN) ? substr($literal, 0, -1) : $literal; + + return new Percentage(percent: BigDecimal::of(value: $rate)); + } + + /** + * Creates a Percentage holding zero. + * + * @return Percentage The created instance. + */ + public static function zero(): Percentage + { + return Percentage::of(value: self::ZERO); + } + + /** + * Creates a Percentage from a ratio, rounded to the given scale. + * + *

A scale is required because a proportion such as one third has no exact percentage.

+ * + * @param Ratio $ratio The proportion to express per hundred. + * @param int $scale The number of digits after the decimal point in the percentage. + * @param RoundingMode $rounding The policy applied to the discarded digits. + * @return Percentage The created instance. + * @throws ScaleOutOfRange If the scale is negative or beyond the supported range. + */ + public static function fromRatio(Ratio $ratio, int $scale, RoundingMode $rounding): Percentage + { + return $ratio->toPercentage(scale: $scale, rounding: $rounding); + } + + /** + * Returns this percentage as the factor it multiplies by. + * + *

Twelve and a half percent reads back as 0.125.

+ * + * @return BigDecimal The factor, at this percentage's scale plus two. + */ + public function rate(): BigDecimal + { + return BigDecimal::ofUnscaledValue( + scale: ($this->percent->scale() + self::PLACES_IN_A_HUNDRED), + unscaled: $this->percent->unscaledValue() + ); + } + + /** + * Tells whether this percentage holds the same value and scale as another percentage. + * + *

Structural equality, so ten percent written '10' does not equal the same rate + * written '10.0'. For arithmetic equality, use + * {@see Percentage::isEqualTo()}.

+ * + * @param Percentage $other The percentage to compare against. + * @return bool True when both hold the same value at the same scale. + */ + public function equals(Percentage $other): bool + { + return $other->percent->equals(other: $this->percent); + } + + /** + * Tells whether this percentage is zero. + * + * @return bool True when the value is zero. + */ + public function isZero(): bool + { + return $this->percent->isZero(); + } + + /** + * Applies this percentage to an amount. + * + * @param BigDecimal $amount The amount to take a percentage of. + * @return BigDecimal The exact result, at the amount's scale plus this percentage's scale plus two. + */ + public function applyTo(BigDecimal $amount): BigDecimal + { + return $amount->multipliedBy(multiplier: $this->rate()); + } + + /** + * Returns this percentage as a ratio. + * + * @return Ratio The equivalent proportion, in lowest terms. + */ + public function toRatio(): Ratio + { + return Ratio::between(antecedent: $this->rate(), consequent: BigDecimal::one()); + } + + /** + * Reduces an amount by this percentage. + * + * @param BigDecimal $amount The amount to reduce. + * @return BigDecimal The exact result of the amount minus this percentage of it. + */ + public function decrease(BigDecimal $amount): BigDecimal + { + return $amount->minus(subtrahend: $this->applyTo(amount: $amount)); + } + + /** + * Returns a deterministic hash of this percentage. + * + * @return string The structural hash. + */ + public function hashCode(): string + { + return new StructuralHash()->of(type: Percentage::class, representation: $this->toString()); + } + + /** + * Raises an amount by this percentage. + * + * @param BigDecimal $amount The amount to raise. + * @return BigDecimal The exact result of the amount plus this percentage of it. + */ + public function increase(BigDecimal $amount): BigDecimal + { + return $amount->plus(addend: $this->applyTo(amount: $amount)); + } + + /** + * Returns this percentage as a string. + * + * @return string The value followed by a percent sign, such as 12.5%. + */ + public function toString(): string + { + $template = '%s%%'; + + return sprintf($template, $this->percent->toString()); + } + + /** + * Tells whether this percentage has the same value as another. + * + * @param Percentage $other The percentage to compare against. + * @return bool True when both represent the same rate, at any scale. + */ + public function isEqualTo(Percentage $other): bool + { + return $this->percent->isEqualTo(other: $other->percent); + } + + /** + * Tells whether this percentage is strictly less than another. + * + * @param Percentage $other The percentage to compare against. + * @return bool True when this rate is smaller. + */ + public function isLessThan(Percentage $other): bool + { + return $this->percent->isLessThan(other: $other->percent); + } + + /** + * Tells whether this percentage is strictly less than zero. + * + * @return bool True when the rate is negative. + */ + public function isNegative(): bool + { + return $this->percent->isNegative(); + } + + /** + * Tells whether this percentage is strictly greater than zero. + * + * @return bool True when the rate is positive. + */ + public function isPositive(): bool + { + return $this->percent->isPositive(); + } + + /** + * Tells whether this percentage is strictly greater than another. + * + * @param Percentage $other The percentage to compare against. + * @return bool True when this rate is larger. + */ + public function isGreaterThan(Percentage $other): bool + { + return $this->percent->isGreaterThan(other: $other->percent); + } + + /** + * Returns this percentage as a JSON string. + * + * @return string The canonical string form, percent sign included. + */ + public function jsonSerialize(): string + { + return $this->toString(); + } + + /** + * Tells whether this percentage is less than or equal to another. + * + * @param Percentage $other The percentage to compare against. + * @return bool True when this rate is smaller or equal. + */ + public function isLessThanOrEqualTo(Percentage $other): bool + { + return $this->percent->isLessThanOrEqualTo(other: $other->percent); + } + + /** + * Tells whether this percentage is greater than or equal to another. + * + * @param Percentage $other The percentage to compare against. + * @return bool True when this rate is larger or equal. + */ + public function isGreaterThanOrEqualTo(Percentage $other): bool + { + return $this->percent->isGreaterThanOrEqualTo(other: $other->percent); + } +} diff --git a/src/PositiveBigDecimal.php b/src/PositiveBigDecimal.php deleted file mode 100644 index 26f23ae..0000000 --- a/src/PositiveBigDecimal.php +++ /dev/null @@ -1,35 +0,0 @@ -isNegativeOrZero()) { - throw new NonPositiveValue(number: $number); - } - - parent::__construct(number: $number, scale: $scale); - } - - public static function fromFloat(float $value, ?int $scale = BigNumber::AUTOMATIC_SCALE): PositiveBigDecimal - { - return new PositiveBigDecimal(value: $value, scale: $scale); - } - - public static function fromString(string $value, ?int $scale = BigNumber::AUTOMATIC_SCALE): PositiveBigDecimal - { - return new PositiveBigDecimal(value: $value, scale: $scale); - } -} diff --git a/src/Ratio.php b/src/Ratio.php new file mode 100644 index 0000000..4813a0a --- /dev/null +++ b/src/Ratio.php @@ -0,0 +1,212 @@ +Held as a {@see BigRational} in lowest terms, so 4:2 and 2:1 are the + * same ratio. Where a {@see BigRational} is a quantity, a Ratio is a relationship: it is applied to + * an amount rather than added to one, and it reads back as 16:9.

+ * + *

{@see Ratio::between()} derives a proportion from two arbitrary numbers without + * anyone naming a scale, because the result stays exact.

+ */ +final readonly class Ratio implements JsonSerializable +{ + private const string HUNDRED = '100'; + private const string SEPARATOR = ':'; + + private function __construct(private BigRational $proportion) + { + } + + /** + * Creates a Ratio from two integer terms. + * + * @param int|string $antecedent The first term. + * @param int|string $consequent The second term, never zero. + * @return Ratio The created instance. + * @throws NumberNotWellFormed If either term is not a number. + * @throws InexactConversion If either term carries a non-zero fractional part. + * @throws DivisionByZero If the consequent is zero. + */ + public static function of(int|string $antecedent, int|string $consequent): Ratio + { + return new Ratio( + proportion: BigRational::ofFraction( + numerator: BigInteger::of(value: $antecedent), + denominator: BigInteger::of(value: $consequent) + ) + ); + } + + /** + * Creates a Ratio from its two terms written as one string. + * + *

Reads the form {@see Ratio::toString()} emits, so + * '16:9' reads back as the ratio it came from.

+ * + * @param string $value The two terms separated by a colon. + * @return Ratio The created instance. + * @throws NumberNotWellFormed If the string does not carry exactly two terms, or either is not a number. + * @throws InexactConversion If either term carries a non-zero fractional part. + * @throws DivisionByZero If the consequent is zero. + */ + public static function from(string $value): Ratio + { + $terms = explode(self::SEPARATOR, $value); + + if (count($terms) !== 2) { + throw NumberNotWellFormed::becauseValueIsNotNumeric(value: $value); + } + + return Ratio::of(antecedent: $terms[0], consequent: $terms[1]); + } + + /** + * Creates a Ratio from two numbers of any kind. + * + *

Exact, so no scale and no rounding mode are involved.

+ * + * @param Number $antecedent The first quantity. + * @param Number $consequent The second quantity, never zero. + * @return Ratio The created instance. + * @throws DivisionByZero If the consequent is zero. + */ + public static function between(Number $antecedent, Number $consequent): Ratio + { + return new Ratio( + proportion: $antecedent->toBigRational()->dividedBy(divisor: $consequent->toBigRational()) + ); + } + + /** + * Tells whether this ratio holds the same proportion as another ratio. + * + *

Structural equality. Because a ratio is always stored in lowest terms, + * 32:18 equals 16:9.

+ * + * @param Ratio $other The ratio to compare against. + * @return bool True when both hold the same proportion. + */ + public function equals(Ratio $other): bool + { + return $other->proportion->equals(other: $this->proportion); + } + + /** + * Applies this ratio to an amount. + * + * @param Number $amount The amount to scale. + * @return BigRational The exact result of the amount times the ratio. + */ + public function applyTo(Number $amount): BigRational + { + return $this->proportion->multipliedBy(multiplier: $amount->toBigRational()); + } + + /** + * Returns a deterministic hash of this ratio. + * + * @return string The structural hash. + */ + public function hashCode(): string + { + return new StructuralHash()->of(type: Ratio::class, representation: $this->toString()); + } + + /** + * Returns this ratio with its two terms swapped. + * + * @return Ratio A new instance holding the inverse proportion. + * @throws DivisionByZero If the antecedent is zero. + */ + public function inverted(): Ratio + { + return new Ratio(proportion: $this->proportion->reciprocal()); + } + + /** + * Returns this ratio as a string. + * + * @return string The two terms separated by a colon, such as 16:9. + */ + public function toString(): string + { + $template = '%s:%s'; + + return sprintf( + $template, + $this->proportion->numerator()->toString(), + $this->proportion->denominator()->toString() + ); + } + + /** + * Returns the first term of this ratio. + * + * @return BigInteger The antecedent, carrying the sign of the ratio. + */ + public function antecedent(): BigInteger + { + return $this->proportion->numerator(); + } + + /** + * Returns the second term of this ratio. + * + * @return BigInteger The consequent, always strictly positive. + */ + public function consequent(): BigInteger + { + return $this->proportion->denominator(); + } + + /** + * Returns this ratio as a percentage, rounded to the given scale. + * + * @param int $scale The number of digits after the decimal point in the percentage. + * @param RoundingMode $rounding The policy applied to the discarded digits. + * @return Percentage The equivalent percentage. + * @throws ScaleOutOfRange If the scale is negative or beyond the supported range. + */ + public function toPercentage(int $scale, RoundingMode $rounding): Percentage + { + return Percentage::of( + value: $this->proportion + ->multipliedBy(multiplier: BigRational::of(value: self::HUNDRED)) + ->toDecimal(scale: $scale, rounding: $rounding) + ->toString() + ); + } + + /** + * Returns this ratio as a JSON string. + * + * @return string The canonical string form, such as 16:9. + */ + public function jsonSerialize(): string + { + return $this->toString(); + } + + /** + * Returns this ratio as an exact fraction. + * + * @return BigRational The proportion, in lowest terms. + */ + public function toBigRational(): BigRational + { + return $this->proportion; + } +} diff --git a/src/RoundingMode.php b/src/RoundingMode.php index 9ce7b34..7a5fadf 100644 --- a/src/RoundingMode.php +++ b/src/RoundingMode.php @@ -4,67 +4,99 @@ namespace TinyBlocks\Math; -use TinyBlocks\Math\Internal\Number; -use TinyBlocks\Math\Internal\Scale; +use RoundingMode as NativeRoundingMode; /** - * Use one of the following constants to specify the mode in which rounding occurs. + * Policy deciding how a discarded fraction affects the last digit kept. * - * @see https://www.php.net/manual/en/function.round.php + *

Nothing in this library rounds implicitly, so a mode is named only where a conversion is + * asked for. There is no case meaning "do not round": that path is + * {@see BigDecimal::toScaleExact()} and + * {@see BigRational::toDecimalExact()}, which raise instead of guessing.

+ * + *

Each case maps one to one onto PHP's native RoundingMode, so a policy crosses that + * boundary in either direction. The rounding itself is decided here and applied exactly on integers, + * never by PHP's float round(). The names follow the vocabulary used by the literature + * on monetary rounding, and the backing values are stable strings so a policy survives a round trip + * through configuration.

*/ -enum RoundingMode: int +enum RoundingMode: string { - case HALF_UP = 1; - case HALF_ODD = 4; - case HALF_EVEN = 3; - case HALF_DOWN = 2; - - private const int ODD_CORRECTION = 1; - private const int EVEN_CHECK_MODULO = 2; - private const float HALF_ODD_EVEN_THRESHOLD = 0.5; + case Up = 'up'; + case Down = 'down'; + case Floor = 'floor'; + case HalfUp = 'half-up'; + case Ceiling = 'ceiling'; + case HalfOdd = 'half-odd'; + case HalfDown = 'half-down'; + case HalfEven = 'half-even'; - public function round(BigNumber $bigNumber): Number + /** + * Creates a RoundingMode from PHP's native rounding mode. + * + * @param NativeRoundingMode $mode The native mode to translate. + * @return RoundingMode The equivalent case. + */ + public static function fromNativeRoundingMode(NativeRoundingMode $mode): RoundingMode { - $precision = $bigNumber->getScale() ?? Scale::MINIMUM; - $factor = 10 ** $precision; - $value = $bigNumber->toFloat(); - - $roundedValue = match ($this) { - self::HALF_UP => $this->roundHalfUp(value: $value, precision: $precision), - self::HALF_ODD => $this->roundHalfOdd(value: $value, factor: $factor), - self::HALF_EVEN => $this->roundHalfEven(value: $value, precision: $precision), - self::HALF_DOWN => $this->roundHalfDown(value: $value, factor: $factor) + return match ($mode) { + NativeRoundingMode::AwayFromZero => RoundingMode::Up, + NativeRoundingMode::TowardsZero => RoundingMode::Down, + NativeRoundingMode::NegativeInfinity => RoundingMode::Floor, + NativeRoundingMode::HalfAwayFromZero => RoundingMode::HalfUp, + NativeRoundingMode::PositiveInfinity => RoundingMode::Ceiling, + NativeRoundingMode::HalfOdd => RoundingMode::HalfOdd, + NativeRoundingMode::HalfTowardsZero => RoundingMode::HalfDown, + NativeRoundingMode::HalfEven => RoundingMode::HalfEven }; - - return Number::from($roundedValue); } - private function roundHalfUp(float $value, int $precision): float + /** + * Tells whether a discarded fraction pushes the kept digit away from zero. + * + *

This is the whole definition of a rounding mode, expressed the way Java's + * RoundingMode javadoc defines each constant: from how the discarded part compares + * with one half, the sign of the value, and the parity of the digit being kept.

+ * + *

A comparison of zero is a tie. The two tie-breaking modes raise their threshold by one + * when the tie should stay put, so HalfEven keeps an even digit and + * HalfOdd keeps an odd one.

+ * + * @param bool $isEven Whether the last kept digit is even. + * @param int $comparison How the discarded fraction compares with one half, never zero digits. + * @param bool $isNegative Whether the value is negative. + * @return bool True when the magnitude grows. + */ + public function roundsAwayFromZero(bool $isEven, int $comparison, bool $isNegative): bool { - return round($value, $precision); - } - - private function roundHalfOdd(float $value, int $factor): float - { - $scaledValue = $value * $factor; - $rounded = round($scaledValue); - - if ($rounded % self::EVEN_CHECK_MODULO === 0) { - $rounded += ($scaledValue > $rounded) ? self::ODD_CORRECTION : -self::ODD_CORRECTION; - } - - return $rounded / $factor; - } - - private function roundHalfEven(float $value, int $precision): float - { - return round($value, $precision, PHP_ROUND_HALF_EVEN); + return match ($this) { + RoundingMode::Up => true, + RoundingMode::Down => false, + RoundingMode::Floor => $isNegative, + RoundingMode::HalfUp => $comparison >= 0, + RoundingMode::Ceiling => !$isNegative, + RoundingMode::HalfOdd => $comparison >= intval(!$isEven), + RoundingMode::HalfDown => $comparison > 0, + RoundingMode::HalfEven => $comparison >= intval($isEven) + }; } - private function roundHalfDown(float $value, int $factor): float + /** + * Returns the native rounding mode equivalent to this case. + * + * @return NativeRoundingMode The equivalent native mode. + */ + public function toNativeRoundingMode(): NativeRoundingMode { - $value = ($value * $factor - self::HALF_ODD_EVEN_THRESHOLD) / $factor; - - return floor($value * $factor) / $factor; + return match ($this) { + RoundingMode::Up => NativeRoundingMode::AwayFromZero, + RoundingMode::Down => NativeRoundingMode::TowardsZero, + RoundingMode::Floor => NativeRoundingMode::NegativeInfinity, + RoundingMode::HalfUp => NativeRoundingMode::HalfAwayFromZero, + RoundingMode::Ceiling => NativeRoundingMode::PositiveInfinity, + RoundingMode::HalfOdd => NativeRoundingMode::HalfOdd, + RoundingMode::HalfDown => NativeRoundingMode::HalfTowardsZero, + RoundingMode::HalfEven => NativeRoundingMode::HalfEven + }; } } diff --git a/tests/BigDecimalTest.php b/tests/BigDecimalTest.php deleted file mode 100644 index 701d90c..0000000 --- a/tests/BigDecimalTest.php +++ /dev/null @@ -1,59 +0,0 @@ -toString()); - } - - #[DataProvider('dataProviderForFromFloat')] - public function testFromFloat(float $value): void - { - /** @Given a float value to create a BigDecimal instance */ - $actual = BigDecimal::fromFloat(value: $value); - - /** @Then the created object should be an instance of both BigNumber and BigDecimal */ - self::assertInstanceOf(BigNumber::class, $actual); - self::assertInstanceOf(BigDecimal::class, $actual); - - /** @And the scale and value should be correctly initialized */ - self::assertSame($value, $actual->toFloat()); - } - - public static function dataProviderForFromString(): array - { - return [ - 'Zero value' => ['value' => '0'], - 'Decimal string' => ['value' => '0.3333333333333333333333'], - 'Positive integer' => ['value' => '1'], - 'Negative integer' => ['value' => '-1'] - ]; - } - - public static function dataProviderForFromFloat(): array - { - return [ - 'Zero value' => ['value' => 0.0], - 'Positive float' => ['value' => 0.3333333333333], - 'Positive integer' => ['value' => 1.0], - 'Negative integer' => ['value' => -1.0] - ]; - } -} diff --git a/tests/BigNumberTest.php b/tests/BigNumberTest.php deleted file mode 100644 index be4ba55..0000000 --- a/tests/BigNumberTest.php +++ /dev/null @@ -1,589 +0,0 @@ -absolute(); - - /** @Then the result should be an instance of BigNumber */ - self::assertInstanceOf(BigNumber::class, $actual); - - /** @And the value should be the absolute value of the negative number */ - self::assertSame(abs($negativeValue), $actual->toFloat()); - self::assertSame(sprintf('%s', abs($negativeValue)), $actual->toString()); - } - - #[DataProvider('providerForTestAdd')] - public function testAdd(int $scale, mixed $value, mixed $other, array $expected): void - { - /** @Given two BigNumber instances to be added */ - $augend = LargeNumber::fromString(value: $value); - $addend = LargeNumber::fromString(value: $other); - - /** @When adding the two BigNumber instances */ - $actual = $augend->add(addend: $addend); - - /** @Then the result should have the correct scale and values */ - self::assertSame($scale, $actual->getScale()); - self::assertSame($expected['float'], $actual->toFloat()); - self::assertSame($expected['string'], $actual->toString()); - } - - #[DataProvider('providerForTestSubtract')] - public function testSubtract(int $scale, mixed $value, mixed $other, array $expected): void - { - /** @Given two BigNumber instances to be subtracted */ - $minuend = LargeNumber::fromString(value: $value); - $subtrahend = LargeNumber::fromString(value: $other); - - /** @When subtracting the second BigNumber from the first */ - $actual = $minuend->subtract(subtrahend: $subtrahend); - - /** @Then the result should have the correct scale and values */ - self::assertSame($scale, $actual->getScale()); - self::assertSame($expected['float'], $actual->toFloat()); - self::assertSame($expected['string'], $actual->toString()); - } - - #[DataProvider('providerForTestMultiply')] - public function testMultiply(int $scale, mixed $value, mixed $other, array $expected): void - { - /** @Given two BigNumber instances to be multiplied */ - $multiplicand = LargeNumber::fromString(value: $value); - $multiplier = LargeNumber::fromString(value: $other); - - /** @When multiplying the two BigNumber instances */ - $actual = $multiplicand->multiply(multiplier: $multiplier); - - /** @Then the result should have the correct scale and values */ - self::assertSame($scale, $actual->getScale()); - self::assertSame($expected['float'], $actual->toFloat()); - self::assertSame($expected['string'], $actual->toString()); - } - - #[DataProvider('providerForTestDivide')] - public function testDivide(int $scale, mixed $value, mixed $other, array $expected): void - { - /** @Given a BigNumber instance to be divided by another BigNumber */ - $dividend = LargeNumber::fromString(value: $value); - $divisor = LargeNumber::fromString(value: $other); - - /** @When dividing the first BigNumber by the second */ - $actual = $dividend->divide(divisor: $divisor); - - /** @Then the result should have the correct scale and values */ - self::assertSame($scale, $actual->getScale()); - self::assertSame($expected['float'], $actual->toFloat()); - self::assertSame($expected['string'], $actual->toString()); - } - - #[DataProvider('providerForTestDivisionByZero')] - public function testDivisionByZero(mixed $value, mixed $other): void - { - /** @Given a BigNumber instance to be divided by zero */ - $template = 'Cannot divide <%.2f> by <%.2f>.'; - - /** @Then an exception DivisionByZero should be thrown with the correct message */ - $this->expectException(DivisionByZero::class); - $this->expectExceptionMessage(sprintf($template, $value, $other)); - - /** @When attempting to divide the BigNumber by zero */ - $dividend = LargeNumber::fromFloat(value: $value); - $divisor = LargeNumber::fromFloat(value: $other); - - $dividend->divide(divisor: $divisor); - } - - #[DataProvider('providerForTestWithRounding')] - public function testWithRounding(RoundingMode $mode, int $scale, mixed $value, array $expected): void - { - /** @Given a BigNumber instance with specified rounding mode */ - $number = LargeNumber::fromFloat(value: $value, scale: $scale); - - /** @When rounding the BigNumber */ - $actual = $number->withRounding(mode: $mode); - - /** @Then the result should match the expected values */ - self::assertSame($scale, $actual->getScale()); - self::assertSame($expected['float'], $actual->toFloat()); - self::assertSame($expected['string'], $actual->toString()); - } - - #[DataProvider('providerForTestWithScale')] - public function testWithScale(mixed $value, int $scale, array $expected): void - { - /** @Given a BigNumber instance */ - $number = LargeNumber::fromFloat(value: $value); - - /** @When applying a new scale to the BigNumber */ - $actual = $number->withScale(scale: $scale); - - /** @Then the result should have the correct adjusted scale and values */ - self::assertSame($scale, $actual->getScale()); - self::assertSame($expected['float'], $actual->toFloat()); - self::assertSame($expected['string'], $actual->toString()); - } - - #[DataProvider('providerForTestIsZero')] - public function testIsZero(mixed $value, bool $expected): void - { - /** @Given a BigNumber instance */ - $number = LargeNumber::fromFloat(value: $value); - - /** @When checking if the BigNumber is zero */ - $actual = $number->isZero(); - - /** @Then the result should indicate if it is zero or not */ - self::assertSame($expected, $actual); - } - - #[DataProvider('providerForTestIsNegative')] - public function testIsNegative(mixed $value, bool $expected): void - { - /** @Given a BigNumber instance */ - $number = LargeNumber::fromFloat(value: $value); - - /** @When checking if the BigNumber is negative */ - $actual = $number->isNegative(); - - /** @Then the result should indicate if it is negative or not */ - self::assertSame($expected, $actual); - } - - #[DataProvider('providerForTestIsPositive')] - public function testIsPositive(mixed $value, bool $expected): void - { - /** @Given a BigNumber instance */ - $number = LargeNumber::fromFloat(value: $value); - - /** @When checking if the BigNumber is positive */ - $actual = $number->isPositive(); - - /** @Then the result should indicate if it is positive or not */ - self::assertSame($expected, $actual); - } - - #[DataProvider('providerForTestIsNegativeOrZero')] - public function testIsNegativeOrZero(mixed $value, bool $expected): void - { - /** @Given a BigNumber instance */ - $number = LargeNumber::fromFloat(value: $value); - - /** @When checking if the BigNumber is negative or zero */ - $actual = $number->isNegativeOrZero(); - - /** @Then the result should indicate if it is negative or zero */ - self::assertSame($expected, $actual); - } - - #[DataProvider('providerForTestIsPositiveOrZero')] - public function testIsPositiveOrZero(mixed $value, bool $expected): void - { - /** @Given a BigNumber instance */ - $number = LargeNumber::fromFloat(value: $value); - - /** @When checking if the BigNumber is positive or zero */ - $actual = $number->isPositiveOrZero(); - - /** @Then the result should indicate if it is positive or zero */ - self::assertSame($expected, $actual); - } - - #[DataProvider('providerForTestIsLessThan')] - public function testIsLessThan(BigNumber $value, BigNumber $other, bool $expected): void - { - /** @Given two BigNumber instances */ - /** @When checking if the first BigNumber is less than the second */ - $actual = $value->isLessThan(other: $other); - - /** @Then the result should indicate if it is less than */ - self::assertSame($expected, $actual); - } - - #[DataProvider('providerForTestIsGreaterThan')] - public function testIsGreaterThan(BigNumber $value, BigNumber $other, bool $expected): void - { - /** @Given two BigNumber instances */ - /** @When checking if the first BigNumber is greater than the second */ - $actual = $value->isGreaterThan(other: $other); - - /** @Then the result should indicate if it is greater than */ - self::assertSame($expected, $actual); - } - - #[DataProvider('providerForTestIsLessThanOrEqual')] - public function testIsLessThanOrEqual(BigNumber $value, BigNumber $other, bool $expected): void - { - /** @Given two BigNumber instances */ - /** @When checking if the first BigNumber is less than or equal to the second */ - $actual = $value->isLessThanOrEqual(other: $other); - - /** @Then the result should indicate if it is less than or equal to */ - self::assertSame($expected, $actual); - } - - #[DataProvider('providerForTestIsGreaterThanOrEqual')] - public function testIsGreaterThanOrEqual(BigNumber $value, BigNumber $other, bool $expected): void - { - /** @Given two BigNumber instances */ - /** @When checking if the first BigNumber is greater than or equal to the second */ - $actual = $value->isGreaterThanOrEqual(other: $other); - - /** @Then the result should indicate if it is greater than or equal to */ - self::assertSame($expected, $actual); - } - - public static function providerForTestAdd(): array - { - return [ - 'Adding integers' => [ - 'scale' => 0, - 'value' => '1', - 'other' => '1', - 'expected' => ['float' => 2.0, 'string' => '2'] - ], - 'Adding with scale' => [ - 'scale' => 3, - 'value' => '1002.771', - 'other' => '123', - 'expected' => ['float' => 1125.771, 'string' => '1125.771'] - ], - 'Adding positives and negatives' => [ - 'scale' => 0, - 'value' => '123', - 'other' => '-999', - 'expected' => ['float' => -876.0, 'string' => '-876'] - ], - 'Adding large numbers with decimal' => [ - 'scale' => 4, - 'value' => '-4565.9999', - 'other' => '999999999.04', - 'expected' => ['float' => 999995433.0401, 'string' => '999995433.0401'] - ] - ]; - } - - public static function providerForTestSubtract(): array - { - return [ - 'Simple subtraction' => [ - 'scale' => 2, - 'value' => '10.22', - 'other' => '5.11', - 'expected' => ['float' => 5.11, 'string' => '5.11'] - ], - 'Subtracting negatives' => [ - 'scale' => 3, - 'value' => '-10.099', - 'other' => '-10.095', - 'expected' => ['float' => -0.004, 'string' => '-0.004'] - ], - 'Resulting in negative' => [ - 'scale' => 0, - 'value' => '11', - 'other' => '12', - 'expected' => ['float' => -1.0, 'string' => '-1'] - ], - 'Subtraction with scale' => [ - 'scale' => 4, - 'value' => '12.9999', - 'other' => '6.3333', - 'expected' => ['float' => 6.6666, 'string' => '6.6666'] - ] - ]; - } - - public static function providerForTestMultiply(): array - { - return [ - 'Basic multiplication' => [ - 'scale' => 0, - 'value' => '2', - 'other' => '2', - 'expected' => ['float' => 4.0, 'string' => '4'] - ], - 'Multiplying negatives' => [ - 'scale' => 4, - 'value' => '-2.11', - 'other' => '55.33', - 'expected' => ['float' => -116.7463, 'string' => '-116.7463'] - ], - 'Multiplying large numbers' => [ - 'scale' => 2, - 'value' => '123.22', - 'other' => '999', - 'expected' => ['float' => 123096.78, 'string' => '123096.78'] - ], - 'Multiplication with decimal' => [ - 'scale' => 1, - 'value' => '123', - 'other' => '0.1', - 'expected' => ['float' => 12.3, 'string' => '12.3'] - ] - ]; - } - - public static function providerForTestDivide(): array - { - return [ - 'Large division' => [ - 'scale' => 16, - 'value' => '1.234', - 'other' => '123.456', - 'expected' => ['float' => 0.0099954639709694, 'string' => '0.0099954639709694'] - ], - 'Division with small scale' => [ - 'scale' => 5, - 'value' => '1.234', - 'other' => '100.00', - 'expected' => ['float' => 0.01234, 'string' => '0.01234'] - ], - 'Division resulting in zero' => [ - 'scale' => 0, - 'value' => '0.00', - 'other' => '8', - 'expected' => ['float' => 0.0, 'string' => '0'] - ], - 'Division resulting in negative' => [ - 'scale' => 0, - 'value' => '-7', - 'other' => '0.2', - 'expected' => ['float' => -35.0, 'string' => '-35'] - ] - ]; - } - - public static function providerForTestDivisionByZero(): array - { - return [ - 'Division of zero by zero' => ['value' => 0, 'other' => 0], - 'Division of positive by zero' => ['value' => 20, 'other' => 0], - 'Division of decimal zero by zero' => ['value' => 0.00, 'other' => 0.00] - ]; - } - - public static function providerForTestWithRounding(): array - { - return [ - 'Half up rounding' => [ - 'mode' => RoundingMode::HALF_UP, - 'scale' => 2, - 'value' => 0.9950, - 'expected' => ['float' => 1.0, 'string' => '1'] - ], - 'Half odd rounding' => [ - 'mode' => RoundingMode::HALF_ODD, - 'scale' => 2, - 'value' => 0.9950, - 'expected' => ['float' => 0.99, 'string' => '0.99'] - ], - 'Half down rounding' => [ - 'mode' => RoundingMode::HALF_DOWN, - 'scale' => 2, - 'value' => 0.9950, - 'expected' => ['float' => 0.99, 'string' => '0.99'] - ], - 'Half even rounding' => [ - 'mode' => RoundingMode::HALF_EVEN, - 'scale' => 2, - 'value' => 0.9950, - 'expected' => ['float' => 1.0, 'string' => '1'] - ] - ]; - } - - public static function providerForTestWithScale(): array - { - return [ - 'Zero scale with no decimals' => [ - 'value' => 0, - 'scale' => 0, - 'expected' => ['float' => 0.0, 'string' => '0'] - ], - 'Zero scale with one decimal place' => [ - 'value' => 0, - 'scale' => 1, - 'expected' => ['float' => 0.0, 'string' => '0.0'] - ], - 'Zero scale with two decimal places' => [ - 'value' => 0, - 'scale' => 2, - 'expected' => ['float' => 0.00, 'string' => '0.00'] - ], - 'Zero scale with three decimal places' => [ - 'value' => 0, - 'scale' => 3, - 'expected' => ['float' => 0.000, 'string' => '0.000'] - ], - 'Negative large value rounded to one decimal' => [ - 'value' => -553.99999, - 'scale' => 1, - 'expected' => ['float' => -553.9, 'string' => '-553.9'] - ], - 'Large positive number rounded to two decimals' => [ - 'value' => 999999.999, - 'scale' => 2, - 'expected' => ['float' => 999999.99, 'string' => '999999.99'] - ], - 'Small negative value rounded to four decimals' => [ - 'value' => -0.12345, - 'scale' => 4, - 'expected' => ['float' => -0.1234, 'string' => '-0.1234'] - ], - 'Decimal value with precision reduction to two' => [ - 'value' => 123.4567, - 'scale' => 2, - 'expected' => ['float' => 123.45, 'string' => '123.45'] - ], - 'Positive value with precision reduction to three' => [ - 'value' => 10.5555, - 'scale' => 3, - 'expected' => ['float' => 10.555, 'string' => '10.555'] - ] - ]; - } - - public static function providerForTestIsZero(): array - { - return [ - 'Exact zero float' => ['value' => 0.0, 'expected' => true], - 'NonZero negative' => ['value' => -1, 'expected' => false], - 'Zero with decimals' => ['value' => 0.0000000000, 'expected' => true] - ]; - } - - public static function providerForTestIsNegative(): array - { - return [ - 'NonNegative zero' => ['value' => 0, 'expected' => false], - 'Large negative float' => ['value' => -45.9999, 'expected' => true], - 'Small negative float' => ['value' => -0.1, 'expected' => true] - ]; - } - - public static function providerForTestIsPositive(): array - { - return [ - 'Negative one' => ['value' => -1, 'expected' => false], - 'Positive integer' => ['value' => 1, 'expected' => true], - 'Zero is not positive' => ['value' => 0, 'expected' => false] - ]; - } - - public static function providerForTestIsNegativeOrZero(): array - { - return [ - 'Zero' => ['value' => 0, 'expected' => true], - 'Positive integer' => ['value' => 1, 'expected' => false], - 'Negative integer' => ['value' => -1, 'expected' => true] - ]; - } - - public static function providerForTestIsPositiveOrZero(): array - { - return [ - 'Zero' => ['value' => 0, 'expected' => true], - 'Negative integer' => ['value' => -1, 'expected' => false], - 'Positive integer' => ['value' => 1, 'expected' => true] - ]; - } - - public static function providerForTestIsLessThan(): array - { - return [ - 'Value equal to other' => [ - 'value' => LargeNumber::fromFloat(value: 1), - 'other' => LargeNumber::fromFloat(value: 1), - 'expected' => false - ], - 'Value less than other with decimals' => [ - 'value' => LargeNumber::fromFloat(value: 45.333, scale: 3), - 'other' => LargeNumber::fromFloat(value: 45.334, scale: 3), - 'expected' => true - ], - 'Negative value less than other negative' => [ - 'value' => LargeNumber::fromString(value: '-51'), - 'other' => LargeNumber::fromString(value: '-11'), - 'expected' => true - ] - ]; - } - - public static function providerForTestIsGreaterThan(): array - { - return [ - 'Equal values' => [ - 'value' => LargeNumber::fromFloat(value: 1), - 'other' => LargeNumber::fromFloat(value: 1), - 'expected' => false - ], - 'Value greater than other' => [ - 'value' => LargeNumber::fromFloat(value: 12.12), - 'other' => LargeNumber::fromFloat(value: 12.11), - 'expected' => true - ], - 'Negative less than positive' => [ - 'value' => LargeNumber::fromString(value: '-1.2222'), - 'other' => LargeNumber::fromString(value: '1'), - 'expected' => false - ] - ]; - } - - public static function providerForTestIsLessThanOrEqual(): array - { - return [ - 'Values are equal' => [ - 'value' => LargeNumber::fromString(value: '88.664'), - 'other' => LargeNumber::fromString(value: '88.664'), - 'expected' => true - ], - 'Equal integer values' => [ - 'value' => LargeNumber::fromFloat(value: 1), - 'other' => LargeNumber::fromFloat(value: 1), - 'expected' => true - ], - 'Positive greater than negative' => [ - 'value' => LargeNumber::fromString(value: '12'), - 'other' => LargeNumber::fromString(value: '-90.95'), - 'expected' => false - ] - ]; - } - - public static function providerForTestIsGreaterThanOrEqual(): array - { - return [ - 'Greater than other' => [ - 'value' => LargeNumber::fromString(value: '45'), - 'other' => LargeNumber::fromString(value: '45.01'), - 'expected' => false - ], - 'Equal integer values' => [ - 'value' => LargeNumber::fromFloat(value: 1), - 'other' => LargeNumber::fromFloat(value: 1), - 'expected' => true - ], - 'Very large values equal' => [ - 'value' => LargeNumber::fromFloat(value: 99.999999999999999999), - 'other' => LargeNumber::fromFloat(value: 99.99999999999999999), - 'expected' => true - ] - ]; - } -} diff --git a/tests/Internal/NumberTest.php b/tests/Internal/NumberTest.php deleted file mode 100644 index da0d3b3..0000000 --- a/tests/Internal/NumberTest.php +++ /dev/null @@ -1,64 +0,0 @@ - is not a valid number.'; - - /** @Then an InvalidNumber exception should be thrown */ - $this->expectException(InvalidNumber::class); - $this->expectExceptionMessage(sprintf($template, $value)); - - /** @When attempting to create a Number instance with the invalid value */ - Number::from(value: $value); - } - - public function testInvalidNumberWhenValueIsNaN(): void - { - /** @Given a Not a Number (NaN) value */ - $value = NAN; - $template = 'The value is not a valid number.'; - - /** @Then an InvalidNumber exception should be thrown */ - $this->expectException(InvalidNumber::class); - $this->expectExceptionMessage($template); - - /** @When attempting to create a Number instance with the NaN value */ - Number::from(value: $value); - } - - public static function invalidNumberDataProvider(): array - { - return [ - 'Single dot' => ['value' => '.'], - 'Empty string' => ['value' => ''], - 'String "null"' => ['value' => 'null'], - 'String "true"' => ['value' => 'true'], - 'String "false"' => ['value' => 'false'], - 'Positive infinity' => ['value' => INF], - 'Negative infinity' => ['value' => -INF], - 'Zero followed by x' => ['value' => '0x'], - 'Invalid character x' => ['value' => 'x'], - 'Double leading dots' => ['value' => '..0'], - 'Single negative sign' => ['value' => '-'], - 'Single positive sign' => ['value' => '+'], - 'Negative sign with x' => ['value' => '-x'], - 'Positive sign with x' => ['value' => '+x'], - 'Zero followed by dash' => ['value' => '0-'], - 'Zero followed by plus' => ['value' => '0+'], - 'Multiple dots in value' => ['value' => '.0.'], - 'Leading space with zero' => ['value' => ' 0'] - ]; - } -} diff --git a/tests/Internal/Operations/Adapters/Scales/ScalesTest.php b/tests/Internal/Operations/Adapters/Scales/ScalesTest.php deleted file mode 100644 index a5d0459..0000000 --- a/tests/Internal/Operations/Adapters/Scales/ScalesTest.php +++ /dev/null @@ -1,189 +0,0 @@ -applyScale(); - - /** @Then the scale value should match the expected value */ - self::assertEquals($expected, $actual->value); - } - - #[DataProvider('subtractionDataProvider')] - public function testSubtraction(BigNumber $minuend, BigNumber $subtrahend, int $expected): void - { - /** @Given two BigNumber instances to be subtracted */ - $subtraction = new Subtraction(minuend: $minuend, subtrahend: $subtrahend); - - /** @When applying the scale to the result of the subtraction */ - $actual = $subtraction->applyScale(); - - /** @Then the scale value should match the expected value */ - self::assertEquals($expected, $actual->value); - } - - #[DataProvider('multiplicationDataProvider')] - public function testMultiplication(BigNumber $multiplier, BigNumber $multiplicand, int $expected): void - { - /** @Given two BigNumber instances to be multiplied */ - $multiplication = new Multiplication(multiplicand: $multiplicand, multiplier: $multiplier); - - /** @When applying the scale to the result of the multiplication */ - $actual = $multiplication->applyScale(); - - /** @Then the scale value should match the expected value */ - self::assertEquals($expected, $actual->value); - } - - #[DataProvider('divisionDataProvider')] - public function testDivision(BigNumber $dividend, BigNumber $divisor, int $expected): void - { - /** @Given two BigNumber instances to be divided */ - $division = new Division(dividend: $dividend, divisor: $divisor); - - /** @When applying the scale to the result of the division */ - $actual = $division->applyScale(); - - /** @Then the scale value should match the expected value */ - self::assertEquals($expected, $actual->value); - } - - public static function additionDataProvider(): array - { - return [ - 'Adding integers' => [ - 'addend' => BigDecimal::fromFloat(value: 1), - 'augend' => BigDecimal::fromFloat(value: 1), - 'expected' => 0 - ], - 'Adding large decimals' => [ - 'addend' => BigDecimal::fromFloat(value: 1.001), - 'augend' => BigDecimal::fromFloat(value: 1.0001), - 'expected' => 4 - ], - 'Adding integer and decimal' => [ - 'addend' => BigDecimal::fromFloat(value: 1), - 'augend' => BigDecimal::fromFloat(value: 1.1), - 'expected' => 1 - ], - 'Adding decimals with specific scale' => [ - 'addend' => BigDecimal::fromFloat(value: 1.001, scale: 3), - 'augend' => BigDecimal::fromFloat(value: 1.0001), - 'expected' => 3 - ], - 'Adding decimals with different scales' => [ - 'addend' => BigDecimal::fromFloat(value: 1.01), - 'augend' => BigDecimal::fromFloat(value: 1.1), - 'expected' => 2 - ] - ]; - } - - public static function subtractionDataProvider(): array - { - return [ - 'Subtracting integers' => [ - 'minuend' => BigDecimal::fromFloat(value: 1), - 'subtrahend' => BigDecimal::fromFloat(value: 1), - 'expected' => 0 - ], - 'Subtracting large decimals' => [ - 'minuend' => BigDecimal::fromFloat(value: 1.001), - 'subtrahend' => BigDecimal::fromFloat(value: 1.0001), - 'expected' => 4 - ], - 'Subtracting integer and decimal' => [ - 'minuend' => BigDecimal::fromFloat(value: 1), - 'subtrahend' => BigDecimal::fromFloat(value: 1.1), - 'expected' => 1 - ], - 'Subtracting decimals with specific scale' => [ - 'minuend' => BigDecimal::fromFloat(value: 1.001, scale: 3), - 'subtrahend' => BigDecimal::fromFloat(value: 1.0001), - 'expected' => 3 - ], - 'Subtracting decimals with different scales' => [ - 'minuend' => BigDecimal::fromFloat(value: 1.01), - 'subtrahend' => BigDecimal::fromFloat(value: 1.1), - 'expected' => 2 - ] - ]; - } - - public static function multiplicationDataProvider(): array - { - return [ - 'Multiplying integers' => [ - 'multiplier' => BigDecimal::fromFloat(value: 1), - 'multiplicand' => BigDecimal::fromFloat(value: 1), - 'expected' => 0 - ], - 'Multiplying large decimals' => [ - 'multiplier' => BigDecimal::fromFloat(value: 1.001), - 'multiplicand' => BigDecimal::fromFloat(value: 1.0001), - 'expected' => 4 - ], - 'Multiplying integer and decimal' => [ - 'multiplier' => BigDecimal::fromFloat(value: 1), - 'multiplicand' => BigDecimal::fromFloat(value: 1.1), - 'expected' => 1 - ], - 'Multiplying decimals with specific scale' => [ - 'multiplier' => BigDecimal::fromFloat(value: 1.001, scale: 3), - 'multiplicand' => BigDecimal::fromFloat(value: 1.0001), - 'expected' => 3 - ], - 'Multiplying decimals with different scales' => [ - 'multiplier' => BigDecimal::fromFloat(value: 1.01), - 'multiplicand' => BigDecimal::fromFloat(value: 1.1), - 'expected' => 2 - ], - ]; - } - - public static function divisionDataProvider(): array - { - return [ - 'Dividing integers' => [ - 'dividend' => BigDecimal::fromFloat(value: 1), - 'divisor' => BigDecimal::fromFloat(value: 1), - 'expected' => 0 - ], - 'Dividing large decimals' => [ - 'dividend' => BigDecimal::fromFloat(value: 1.001), - 'divisor' => BigDecimal::fromFloat(value: 1.0001), - 'expected' => 12 - ], - 'Dividing integer and decimal' => [ - 'dividend' => BigDecimal::fromFloat(value: 1), - 'divisor' => BigDecimal::fromFloat(value: 1.1), - 'expected' => 14 - ], - 'Dividing decimals with specific scale' => [ - 'dividend' => BigDecimal::fromFloat(value: 1.001, scale: 3), - 'divisor' => BigDecimal::fromFloat(value: 1.0001), - 'expected' => 3 - ], - 'Dividing decimals with different scales' => [ - 'dividend' => BigDecimal::fromFloat(value: 1.01), - 'divisor' => BigDecimal::fromFloat(value: 1.1), - 'expected' => 14 - ] - ]; - } -} diff --git a/tests/Internal/Operations/MathOperationsFactoryTest.php b/tests/Internal/Operations/MathOperationsFactoryTest.php deleted file mode 100644 index f375a8f..0000000 --- a/tests/Internal/Operations/MathOperationsFactoryTest.php +++ /dev/null @@ -1,22 +0,0 @@ - extensions.'; - - $this->expectException(MathOperationsNotAvailable::class); - $this->expectExceptionMessage(sprintf($template, 'bcmath')); - - new MathOperationsFactory(extension: new ExtensionAdapterMock())->build(); - } -} diff --git a/tests/Internal/ScaleTest.php b/tests/Internal/ScaleTest.php deleted file mode 100644 index e6691df..0000000 --- a/tests/Internal/ScaleTest.php +++ /dev/null @@ -1,56 +0,0 @@ -value; - - /** @Then the scale value should match the expected value */ - self::assertEquals($value, $actual); - } - - #[DataProvider('invalidScaleDataProvider')] - public function testInvalidScale(int $value): void - { - /** @Given an invalid scale value */ - $template = 'Scale value <%s> is invalid. The value must be between <%s> and <%s>.'; - - /** @Then an InvalidScale exception should be thrown */ - $this->expectException(InvalidScale::class); - $this->expectExceptionMessage(sprintf($template, $value, 0, 2147483647)); - - /** @When attempting to create a Scale with the invalid value */ - Scale::from(value: $value); - } - - public static function validScaleDataProvider(): array - { - return [ - 'Minimum valid scale' => ['value' => 0], - 'Maximum valid scale' => ['value' => 2147483647] - ]; - } - - public static function invalidScaleDataProvider(): array - { - return [ - 'PHP integer maximum' => ['value' => PHP_INT_MAX], - 'PHP integer minimum' => ['value' => PHP_INT_MIN], - 'Exceeding maximum scale' => ['value' => 2147483648] - ]; - } -} diff --git a/tests/Mocks/ExtensionAdapterMock.php b/tests/Mocks/ExtensionAdapterMock.php deleted file mode 100644 index 4bdf7ad..0000000 --- a/tests/Mocks/ExtensionAdapterMock.php +++ /dev/null @@ -1,15 +0,0 @@ -toFloat()); - - /** @Then the toString method should return the correct string representation */ - self::assertSame(sprintf('-%s', abs($value)), $negativeBigDecimal->toString()); - } - - #[DataProvider('dataProviderForTestNonNegativeValue')] - public function testNonNegativeValue(mixed $value): void - { - /** @Given a non-negative value */ - $template = 'Value <%s> is not valid. Must be a negative number less than zero.'; - - /** @Then a NonNegativeValue exception should be thrown with the correct message */ - $this->expectException(NonNegativeValue::class); - $this->expectExceptionMessage(sprintf($template, $value)); - - /** @When attempting to create a NegativeBigDecimal with a non-negative value */ - NegativeBigDecimal::fromFloat(value: $value); - } - - public static function dataProviderForTestNonNegativeValue(): array - { - return [ - 'Zero value' => ['value' => 0], - 'Positive integer' => ['value' => 1] - ]; - } -} diff --git a/tests/PositiveBigDecimalTest.php b/tests/PositiveBigDecimalTest.php deleted file mode 100644 index a1c9ea3..0000000 --- a/tests/PositiveBigDecimalTest.php +++ /dev/null @@ -1,82 +0,0 @@ -toFloat()); - self::assertNull($actual->getScale()); - } - - public function testFromString(): void - { - /** @Given a positive string value */ - $value = '0.3333333333333333333333'; - - /** @When creating a PositiveBigDecimal from the string */ - $actual = PositiveBigDecimal::fromString(value: $value); - - /** @Then the created object should be an instance of both BigNumber and PositiveBigDecimal */ - self::assertInstanceOf(BigNumber::class, $actual); - self::assertInstanceOf(PositiveBigDecimal::class, $actual); - - /** @And the scale and value should be correctly initialized */ - self::assertSame($value, $actual->toString()); - self::assertNull($actual->getScale()); - } - - #[DataProvider('dataProviderForTestNonPositiveValue')] - public function testNonPositiveValue(mixed $value): void - { - /** @Given a non-positive value */ - $template = 'Value <%s> is not valid. Must be a positive number greater than zero.'; - - /** @Then a NonPositiveValue exception should be thrown with the correct message */ - $this->expectException(NonPositiveValue::class); - $this->expectExceptionMessage(sprintf($template, $value)); - - /** @When attempting to create a PositiveBigDecimal with a non-positive value */ - PositiveBigDecimal::fromFloat(value: $value); - } - - public function testNonPositiveValueWithCustomClass(): void - { - /** @Given a non-positive value */ - $template = 'Value <%s> is not valid. Must be a positive number greater than zero.'; - - /** @Then a NonPositiveValue exception should be thrown with the correct message */ - $this->expectException(NonPositiveValue::class); - $this->expectExceptionMessage(sprintf($template, -1.00)); - - /** @When attempting to create a CustomPositiveBigDecimal with a non-positive value */ - CustomPositiveBigDecimal::fromFloat(value: -1.00); - } - - public static function dataProviderForTestNonPositiveValue(): array - { - return [ - 'Zero value' => ['value' => 0], - 'Negative integer' => ['value' => -1] - ]; - } -} diff --git a/tests/RoundingModeTest.php b/tests/RoundingModeTest.php deleted file mode 100644 index b1f4252..0000000 --- a/tests/RoundingModeTest.php +++ /dev/null @@ -1,111 +0,0 @@ -round(bigNumber: $bigNumber); - - /** @Then the result should match the expected */ - self::assertSame($expected, $actual->value); - } - - #[DataProvider('halfOddDataProvider')] - public function testRoundHalfOdd(float $value, string $expected): void - { - /** @Given a value and HALF_ODD rounding mode */ - $bigNumber = BigDecimal::fromFloat(value: $value); - - /** @When rounding the value using HALF_ODD */ - $actual = RoundingMode::HALF_ODD->round(bigNumber: $bigNumber); - - /** @Then the result should match the expected */ - self::assertSame($expected, $actual->value); - } - - #[DataProvider('halfDownDataProvider')] - public function testRoundHalfDown(float $value, string $expected): void - { - /** @Given a value and HALF_DOWN rounding mode */ - $bigNumber = BigDecimal::fromFloat(value: $value); - - /** @When rounding the value using HALF_DOWN */ - $actual = RoundingMode::HALF_DOWN->round(bigNumber: $bigNumber); - - /** @Then the result should match the expected */ - self::assertSame($expected, $actual->value); - } - - #[DataProvider('halfEvenDataProvider')] - public function testRoundHalfEven(float $value, string $expected): void - { - /** @Given a value and HALF_EVEN rounding mode */ - $bigNumber = BigDecimal::fromFloat(value: $value); - - /** @When rounding the value using HALF_EVEN */ - $actual = RoundingMode::HALF_EVEN->round(bigNumber: $bigNumber); - - /** @Then the result should match the expected */ - self::assertSame($expected, $actual->value); - } - - public static function halfUpDataProvider(): array - { - return [ - 'Half up, round 0.5 up to 1' => ['value' => 0.5, 'expected' => '1'], - 'Half up, round 1.50 up to 2' => ['value' => 1.50, 'expected' => '2'], - 'Half up, round 1.75 up to 2' => ['value' => 1.75, 'expected' => '2'], - 'Half up, round 2.50 up to 3' => ['value' => 2.50, 'expected' => '3'], - 'Half up, round 1.45 down to 1' => ['value' => 1.45, 'expected' => '1'], - 'Half up, round -1.50 down to -2' => ['value' => -1.50, 'expected' => '-2'] - ]; - } - - public static function halfOddDataProvider(): array - { - return [ - 'Half odd, round 0.5 up to 1' => ['value' => 0.5, 'expected' => '1'], - 'Half odd, round 2.55 up to 3' => ['value' => 2.55, 'expected' => '3'], - 'Half odd, round 1.50 down to 1' => ['value' => 1.50, 'expected' => '1'], - 'Half odd, round 1.25 down to 1' => ['value' => 1.25, 'expected' => '1'], - 'Half odd, round 1.75 down to 1' => ['value' => 1.75, 'expected' => '1'], - 'Half odd, round -1.50 down to -1' => ['value' => -1.50, 'expected' => '-1'] - ]; - } - - public static function halfDownDataProvider(): array - { - return [ - 'Half down, round 0.5 down to 0' => ['value' => 0.5, 'expected' => '0'], - 'Half down, round 1.50 down to 1' => ['value' => 1.50, 'expected' => '1'], - 'Half down, round 2.50 down to 2' => ['value' => 2.50, 'expected' => '2'], - 'Half down, round 1.45 down to 0' => ['value' => 1.45, 'expected' => '0'], - 'Half down, round 1.75 down to 1' => ['value' => 1.75, 'expected' => '1'], - 'Half down, round -1.50 down to -2' => ['value' => -1.50, 'expected' => '-2'] - ]; - } - - public static function halfEvenDataProvider(): array - { - return [ - 'Half even, round 2.55 up to 3' => ['value' => 2.55, 'expected' => '3'], - 'Half even, round 1.50 up to 2' => ['value' => 1.50, 'expected' => '2'], - 'Half even, round 1.75 up to 2' => ['value' => 1.75, 'expected' => '2'], - 'Half even, round 0.5 down to 0' => ['value' => 0.5, 'expected' => '0'], - 'Half even, round 1.25 down to 1' => ['value' => 1.25, 'expected' => '1'], - 'Half even, round -1.50 down to -2' => ['value' => -1.50, 'expected' => '-2'] - ]; - } -} diff --git a/tests/Unit/BigDecimalTest.php b/tests/Unit/BigDecimalTest.php new file mode 100644 index 0000000..1426160 --- /dev/null +++ b/tests/Unit/BigDecimalTest.php @@ -0,0 +1,959 @@ +negated(); + + /** @Then the sign flips and the scale is untouched */ + self::assertSame('-1.50', $actual->toString()); + } + + public function testAbsoluteThenKeepsTheScale(): void + { + /** @Given a negative decimal */ + $amount = BigDecimal::of(value: '-1.50'); + + /** @When its magnitude is taken */ + $actual = $amount->absolute(); + + /** @Then the sign is gone and the scale is untouched */ + self::assertSame('1.50', $actual->toString()); + } + + public function testOneThenHoldsOneAtScaleZero(): void + { + /** @Given nothing but the factory */ + + /** @When one is created */ + $actual = BigDecimal::one(); + + /** @Then it holds one with no fractional digits */ + self::assertSame('1', $actual->toString()); + } + + public function testZeroThenHoldsZeroAtScaleZero(): void + { + /** @Given nothing but the factory */ + + /** @When zero is created */ + $actual = BigDecimal::zero(); + + /** @Then it holds zero with no fractional digits */ + self::assertSame('0', $actual->toString()); + } + + #[DataProvider('literalProvider')] + public function testOfThenReadsTheLiteralAndItsScale(string|int $value, string $expected, int $scale): void + { + /** @Given a decimal literal with the value and scale it stands for */ + + /** @When a decimal is created from it */ + $actual = BigDecimal::of(value: $value); + + /** @Then both the value and the scale are read exactly */ + self::assertSame($expected, $actual->toString()); + self::assertSame($scale, $actual->scale()); + } + + public function testJsonSerializeThenEmitsAJsonString(): void + { + /** @Given a decimal whose trailing zero carries meaning */ + $amount = BigDecimal::of(value: '1.50'); + + /** @When it is encoded */ + $actual = json_encode($amount); + + /** @Then it is a JSON string so the trailing zero survives */ + self::assertSame('"1.50"', $actual); + } + + #[DataProvider('roundingProvider')] + public function testToScaleThenAppliesTheRoundingMode( + string $value, + int $scale, + RoundingMode $rounding, + string $expected + ): void { + /** @Given a decimal, a target scale, and a rounding mode */ + + /** @When it is taken to that scale */ + $actual = BigDecimal::of(value: $value)->toScale(scale: $scale, rounding: $rounding); + + /** @Then the discarded digits are resolved by the mode */ + self::assertSame($expected, $actual->toString()); + } + + public function testAllocateThenPartsSumBackToTheAmount(): void + { + /** @Given an amount that does not divide evenly */ + $amount = BigDecimal::of(value: '100.00'); + + /** @And three equal weights */ + $weights = BigDecimals::of('1', '1', '1'); + + /** @When the amount is allocated */ + $actual = $amount->allocate(scale: 2, weights: $weights); + + /** @Then the parts sum back to the amount with no unit lost */ + self::assertSame('100.00', $actual->sum()->toString()); + } + + public function testDividedByThenReturnsAnExactFraction(): void + { + /** @Given an amount */ + $amount = BigDecimal::of(value: '100.00'); + + /** @And a divisor whose quotient repeats forever */ + $divisor = BigDecimal::of(value: '3'); + + /** @When it is divided */ + $actual = $amount->dividedBy(divisor: $divisor); + + /** @Then the exact fraction is returned instead of a rounded decimal */ + self::assertSame('100/3', $actual->toString()); + } + + #[DataProvider('equalityProvider')] + public function testEqualsAndIsEqualToThenDisagreeOnScale( + string $value, + string $other, + bool $structural, + bool $arithmetic + ): void { + /** @Given two decimal literals with their structural and arithmetic verdicts */ + + /** @When both notions of equality are asked */ + $actual = BigDecimal::of(value: $value); + + /** @Then structural equality sees the scale and arithmetic equality does not */ + self::assertSame($structural, $actual->equals(other: BigDecimal::of(value: $other))); + self::assertSame($arithmetic, $actual->isEqualTo(other: BigDecimal::of(value: $other))); + } + + public function testToBigRationalThenReducesToLowestTerms(): void + { + /** @Given a decimal */ + $amount = BigDecimal::of(value: '0.75'); + + /** @When it is converted to a fraction */ + $actual = $amount->toBigRational(); + + /** @Then the fraction is in lowest terms */ + self::assertSame('3/4', $actual->toString()); + } + + public function testUnscaledValueThenDropsTheDecimalPoint(): void + { + /** @Given a decimal with two fractional digits */ + $amount = BigDecimal::of(value: '19.99'); + + /** @When its unscaled digits are taken */ + $actual = $amount->unscaledValue(); + + /** @Then the decimal point is gone */ + self::assertSame('1999', $actual->toString()); + } + + #[DataProvider('shortestRoundTripProvider')] + public function testFromFloatThenReadsTheShortestRoundTrip(float $value, string $expected): void + { + /** @Given a float and the shortest decimal that casts back to it */ + + /** @When a decimal is created from it */ + $actual = BigDecimal::fromFloat(value: $value); + + /** @Then the shortest decimal that round-trips is used */ + self::assertSame($expected, $actual->toString()); + } + + #[DataProvider('squareRootProvider')] + public function testSquareRootThenRoundsAtTheRequestedScale( + string $value, + int $scale, + RoundingMode $rounding, + string $expected + ): void { + /** @Given a radicand, a target scale, and a rounding mode */ + + /** @When its square root is taken */ + $actual = BigDecimal::of(value: $value)->squareRoot(scale: $scale, rounding: $rounding); + + /** @Then the root is correct at that scale */ + self::assertSame($expected, $actual->toString()); + } + + public function testHashCodeWhenScalesDifferThenHashesDiffer(): void + { + /** @Given a decimal at scale one */ + $amount = BigDecimal::of(value: '1.0'); + + /** @And the same value at scale two */ + $other = BigDecimal::of(value: '1.00'); + + /** @When both hashes are taken */ + $actual = $amount->hashCode(); + + /** @Then they differ, because the hash agrees with structural equality */ + self::assertNotSame($other->hashCode(), $actual); + } + + public function testOfUnscaledValueThenPlacesTheDecimalPoint(): void + { + /** @Given unscaled digits */ + $unscaled = BigInteger::of(value: 1999); + + /** @When a decimal is built at scale two */ + $actual = BigDecimal::ofUnscaledValue(scale: 2, unscaled: $unscaled); + + /** @Then the point sits two digits from the right */ + self::assertSame('19.99', $actual->toString()); + } + + #[DataProvider('powerProvider')] + public function testPowerThenMultipliesTheScaleByTheExponent(string $value, int $exponent, string $expected): void + { + /** @Given a decimal, an exponent, and the expected exact power */ + + /** @When it is raised to that power */ + $actual = BigDecimal::of(value: $value)->power(exponent: $exponent); + + /** @Then the result is exact and its scale is the scale times the exponent */ + self::assertSame($expected, $actual->toString()); + } + + #[DataProvider('arithmeticProvider')] + public function testArithmeticThenPropagatesScaleAsDocumented( + string $left, + string $right, + string $sum, + string $difference, + string $product + ): void { + /** @Given two decimal literals and the expected exact results */ + + /** @When the three exact operations run */ + $actual = BigDecimal::of(value: $left); + + /** @Then addition and subtraction keep the larger scale and multiplication sums them */ + self::assertSame($sum, $actual->plus(addend: BigDecimal::of(value: $right))->toString()); + self::assertSame($difference, $actual->minus(subtrahend: BigDecimal::of(value: $right))->toString()); + self::assertSame($product, $actual->multipliedBy(multiplier: BigDecimal::of(value: $right))->toString()); + } + + public function testAllocateWhenAWeightIsZeroThenThatPartIsZero(): void + { + /** @Given an amount */ + $amount = BigDecimal::of(value: '10.00'); + + /** @And a weight of zero beside a positive one */ + $weights = BigDecimals::of('1', '0'); + + /** @When the amount is allocated */ + $actual = $amount->allocate(scale: 2, weights: $weights); + + /** @Then the zero weight receives nothing */ + self::assertSame( + ['10.00', '0.00'], + array_map(static fn(BigDecimal $part): string => $part->toString(), $actual->all()) + ); + } + + public function testDividedByWhenDivisorIsZeroThenDivisionByZero(): void + { + /** @Given a decimal */ + $amount = BigDecimal::of(value: '1.00'); + + /** @Then a failure naming the dividend is raised */ + $this->expectException(DivisionByZero::class); + $this->expectExceptionMessage('Cannot divide <1> by zero.'); + + /** @When it is divided by zero */ + $amount->dividedBy(divisor: BigDecimal::zero()); + } + + public function testSquareRootWhenValueIsNegativeThenNegativeRoot(): void + { + /** @Given a negative decimal */ + $amount = BigDecimal::of(value: '-1.00'); + + /** @Then a failure naming the decimal, not its unscaled digits, is raised */ + $this->expectException(NegativeRoot::class); + $this->expectExceptionMessage('Cannot take the square root of the negative value <-1.00>.'); + + /** @When its square root is taken */ + $amount->squareRoot(scale: 2, rounding: RoundingMode::HalfEven); + } + + public function testToScaleWhenScaleIsNegativeThenScaleOutOfRange(): void + { + /** @Given a decimal */ + $amount = BigDecimal::of(value: '1.00'); + + /** @Then a failure describing the scale bounds is raised */ + $this->expectException(ScaleOutOfRange::class); + $this->expectExceptionMessage('Scale must be between 0 and 10000, got <-1>.'); + + /** @When it is taken to a negative scale */ + $amount->toScale(scale: -1, rounding: RoundingMode::Down); + } + + public function testAllocateWhenWeightsSumToZeroThenDivisionByZero(): void + { + /** @Given an amount */ + $amount = BigDecimal::of(value: '10.00'); + + /** @And weights that all hold zero */ + $weights = BigDecimals::of('0', '0'); + + /** @Then a failure describing the empty total is raised */ + $this->expectException(DivisionByZero::class); + $this->expectExceptionMessage('Allocation weights must not sum to zero.'); + + /** @When the amount is allocated */ + $amount->allocate(scale: 2, weights: $weights); + } + + public function testPowerWhenScaleWouldOverflowThenScaleOutOfRange(): void + { + /** @Given a decimal carrying two fractional digits */ + $amount = BigDecimal::of(value: '1.50'); + + /** @Then a failure naming the scale the power would need is raised */ + $this->expectException(ScaleOutOfRange::class); + $this->expectExceptionMessage('Scale must be between 0 and 10000, got <4294967294>.'); + + /** @When it is raised to a power whose scale exceeds the supported range */ + $amount->power(exponent: 2147483647); + } + + public function testAllocateWhenAWeightIsNegativeThenNegativeWeight(): void + { + /** @Given an amount */ + $amount = BigDecimal::of(value: '10.00'); + + /** @And a negative weight */ + $weights = BigDecimals::of('-1', '1'); + + /** @Then a failure naming the rejected weight is raised */ + $this->expectException(NegativeWeight::class); + $this->expectExceptionMessage('Allocation weights must not be negative, got <-1>.'); + + /** @When the amount is allocated */ + $amount->allocate(scale: 2, weights: $weights); + } + + #[DataProvider('malformedLiteralProvider')] + public function testOfWhenLiteralIsMalformedThenNumberNotWellFormed(string $value): void + { + /** @Given a literal that is not a number */ + + /** @Then a failure describing the malformed literal is raised */ + $this->expectException(NumberNotWellFormed::class); + + /** @When a decimal is created from it */ + BigDecimal::of(value: $value); + } + + public function testPowerWhenExponentIsNegativeThenNegativeExponent(): void + { + /** @Given a decimal */ + $amount = BigDecimal::of(value: '1.5'); + + /** @Then a failure describing the negative exponent is raised */ + $this->expectException(NegativeExponent::class); + + /** @When it is raised to a negative power */ + $amount->power(exponent: -1); + } + + #[DataProvider('allocationProvider')] + public function testAllocateThenHandsLeftoversToTheLargestRemainders( + string $amount, + BigDecimals $weights, + array $expected + ): void { + /** @Given an amount, its weights, and the expected parts */ + + /** @When the amount is allocated at scale two */ + $actual = BigDecimal::of(value: $amount); + + /** @Then the parts match, in the order of the weights */ + self::assertSame( + $expected, + array_map( + static fn(BigDecimal $part): string => $part->toString(), + $actual->allocate(scale: 2, weights: $weights)->all() + ) + ); + } + + #[DataProvider('crossTypeProvider')] + public function testCompareToWhenTheOtherIsAnotherTypeThenStaysExact( + string $value, + Number $other, + int $expected + ): void { + /** @Given a decimal and a number of another type with the expected comparison */ + + /** @When they are compared */ + $actual = BigDecimal::of(value: $value)->compareTo(other: $other); + + /** @Then the comparison crosses the type boundary */ + self::assertSame($expected, $actual); + } + + #[DataProvider('partsProvider')] + public function testIntegralAndFractionalPartsThenReconstructTheValue( + string $value, + string $integral, + string $fractional + ): void { + /** @Given a decimal literal with its expected parts */ + + /** @When both parts are taken */ + $actual = BigDecimal::of(value: $value); + + /** @Then each part matches and the integral part truncates toward zero */ + self::assertSame($integral, $actual->integralPart()->toString()); + self::assertSame($fractional, $actual->fractionalPart()->toString()); + } + + #[DataProvider('trailingZeroProvider')] + public function testWithoutTrailingZerosThenReducesToTheSmallestScale(string $value, string $expected): void + { + /** @Given a decimal literal and its smallest representation */ + + /** @When trailing zeros are dropped */ + $actual = BigDecimal::of(value: $value)->withoutTrailingZeros(); + + /** @Then the value is unchanged and the scale is minimal */ + self::assertSame($expected, $actual->toString()); + } + + public function testFractionalPartWhenValueIsNegativeThenCarriesTheSign(): void + { + /** @Given a negative decimal */ + $amount = BigDecimal::of(value: '-19.99'); + + /** @When its fractional part is taken */ + $actual = $amount->fractionalPart(); + + /** @Then the sign follows the value */ + self::assertSame('-0.99', $actual->toString()); + } + + public function testFromFloatWhenValueIsInfiniteThenNumberNotWellFormed(): void + { + /** @Given an infinite float */ + $value = INF; + + /** @Then a failure describing the malformed literal is raised */ + $this->expectException(NumberNotWellFormed::class); + + /** @When a decimal is created from it */ + BigDecimal::fromFloat(value: $value); + } + + public function testFromFloatWhenValueIsNotFiniteThenNumberNotWellFormed(): void + { + /** @Given a float that is not a number */ + $value = NAN; + + /** @Then a failure describing the malformed literal is raised */ + $this->expectException(NumberNotWellFormed::class); + + /** @When a decimal is created from it */ + BigDecimal::fromFloat(value: $value); + } + + public function testFromFloatWhenAdditionLostPrecisionThenTheLossIsVisible(): void + { + /** @Given the classic float addition that is not exact */ + $value = (0.1 + 0.2); + + /** @When a decimal is created from it */ + $actual = BigDecimal::fromFloat(value: $value); + + /** @Then the library shows the loss rather than hiding it */ + self::assertSame('0.30000000000000004', $actual->toString()); + } + + public function testToScaleExactWhenDigitsWouldBeLostThenInexactConversion(): void + { + /** @Given a decimal with three fractional digits */ + $amount = BigDecimal::of(value: '1.234'); + + /** @Then a failure describing the discarded digits is raised */ + $this->expectException(InexactConversion::class); + $this->expectExceptionMessage('Value <1.234> cannot be represented at scale <2> without discarding digits.'); + + /** @When it is taken to scale two without rounding */ + $amount->toScaleExact(scale: 2); + } + + public function testAllocateWhenAmountIsNegativeThenPartsSumBackToTheAmount(): void + { + /** @Given a negative amount that does not divide evenly */ + $amount = BigDecimal::of(value: '-100.00'); + + /** @And three equal weights */ + $weights = BigDecimals::of('1', '1', '1'); + + /** @When the amount is allocated */ + $actual = $amount->allocate(scale: 2, weights: $weights); + + /** @Then the parts still sum back to the amount */ + self::assertSame('-100.00', $actual->sum()->toString()); + } + + public function testToFloatWhenValueExceedsTheFloatRangeThenIntegerOverflow(): void + { + /** @Given a decimal beyond the largest representable float */ + $amount = BigDecimal::of(value: '1e400'); + + /** @Then a failure describing the overflow is raised */ + $this->expectException(IntegerOverflow::class); + + /** @When it is converted to a float */ + $amount->toFloat(); + } + + public function testOfWhenExponentIsAtTheNegativeLimitThenTheScaleIsAccepted(): void + { + /** @Given a literal carrying the smallest exponent the library expands */ + $value = '1e-10000'; + + /** @When a decimal is created from it */ + $actual = BigDecimal::of(value: $value)->scale(); + + /** @Then it is accepted and the exponent became the scale */ + self::assertSame(10000, $actual); + } + + public function testAllocateWhenAmountDoesNotFitTheScaleThenInexactConversion(): void + { + /** @Given an amount with more digits than the allocation scale */ + $amount = BigDecimal::of(value: '10.005'); + + /** @And a single weight */ + $weights = BigDecimals::of('1'); + + /** @Then a failure describing the discarded digits is raised */ + $this->expectException(InexactConversion::class); + + /** @When the amount is allocated */ + $amount->allocate(scale: 2, weights: $weights); + } + + public function testOfWhenExponentIsAtTheSupportedLimitThenTheLiteralIsAccepted(): void + { + /** @Given a literal carrying the largest exponent the library expands */ + $value = '1e10000'; + + /** @When a decimal is created from it */ + $actual = BigDecimal::of(value: $value); + + /** @Then it is accepted and carries every digit */ + self::assertSame(10001, strlen($actual->toString())); + } + + public function testToBigIntegerWhenFractionalPartIsNotZeroThenInexactConversion(): void + { + /** @Given a decimal with a non-zero fractional part */ + $amount = BigDecimal::of(value: '1.5'); + + /** @Then a failure describing the lost fractional part is raised */ + $this->expectException(InexactConversion::class); + + /** @When it is converted to an integer */ + $amount->toBigInteger(); + } + + public function testOfUnscaledValueWhenScaleIsTheLargestSupportedThenItIsAccepted(): void + { + /** @Given the largest scale the library supports */ + $scale = 10000; + + /** @When a decimal is built at that scale */ + $actual = BigDecimal::ofUnscaledValue(scale: $scale, unscaled: BigInteger::one())->scale(); + + /** @Then the boundary itself is accepted */ + self::assertSame($scale, $actual); + } + + public function testPowerWhenExponentExceedsTheMagnitudeCeilingThenExponentOutOfRange(): void + { + /** @Given a decimal carrying a fractional digit */ + $amount = BigDecimal::of(value: '1.5'); + + /** @Then a failure naming the rejected exponent is raised */ + $this->expectException(ExponentOutOfRange::class); + $this->expectExceptionMessage('Exponent magnitude must not exceed 2147483647, got <2147483648>.'); + + /** @When it is raised to an exponent beyond the supported magnitude */ + $amount->power(exponent: 2147483648); + } + + public function testPowerWhenExponentWouldOverflowTheScaleProductThenExponentOutOfRange(): void + { + /** @Given a decimal carrying two fractional digits */ + $amount = BigDecimal::of(value: '1.50'); + + /** @Then a failure naming the rejected exponent is raised */ + $this->expectException(ExponentOutOfRange::class); + $this->expectExceptionMessage('Exponent magnitude must not exceed 2147483647, got <9223372036854775807>.'); + + /** @When it is raised to an exponent whose product with the scale would overflow */ + $amount->power(exponent: PHP_INT_MAX); + } + + public static function partsProvider(): array + { + return [ + 'Positive' => ['value' => '19.99', 'integral' => '19', 'fractional' => '0.99'], + 'Negative' => ['value' => '-19.99', 'integral' => '-19', 'fractional' => '-0.99'], + 'No fraction' => ['value' => '19', 'integral' => '19', 'fractional' => '0'], + 'Below one' => ['value' => '0.99', 'integral' => '0', 'fractional' => '0.99'] + ]; + } + + public static function powerProvider(): array + { + return [ + 'Zero exponent' => ['value' => '1.5', 'exponent' => 0, 'expected' => '1'], + 'Identity' => ['value' => '1.50', 'exponent' => 1, 'expected' => '1.50'], + 'Cube' => ['value' => '1.5', 'exponent' => 3, 'expected' => '3.375'], + 'Negative base' => ['value' => '-1.5', 'exponent' => 2, 'expected' => '2.25'] + ]; + } + + public static function literalProvider(): array + { + return [ + 'Native integer' => ['value' => 42, 'expected' => '42', 'scale' => 0], + 'Trailing zero kept' => ['value' => '1.50', 'expected' => '1.50', 'scale' => 2], + 'Leading point' => ['value' => '.5', 'expected' => '0.5', 'scale' => 1], + 'Leading plus' => ['value' => '+5', 'expected' => '5', 'scale' => 0], + 'Leading zeros' => ['value' => '007.500', 'expected' => '7.500', 'scale' => 3], + 'Negative zero' => ['value' => '-0', 'expected' => '0', 'scale' => 0], + 'Zero with scale' => ['value' => '0.000', 'expected' => '0.000', 'scale' => 3], + 'Positive exponent' => ['value' => '1e3', 'expected' => '1000', 'scale' => 0], + 'Negative exponent' => ['value' => '1.5E-3', 'expected' => '0.0015', 'scale' => 4], + 'Trailing point' => ['value' => '5.', 'expected' => '5', 'scale' => 0] + ]; + } + + public static function equalityProvider(): array + { + return [ + 'Same scale' => [ + 'value' => '1.0', + 'other' => '1.0', + 'structural' => true, + 'arithmetic' => true + ], + 'Different scale' => [ + 'value' => '1.0', + 'other' => '1.00', + 'structural' => false, + 'arithmetic' => true + ], + 'Different value' => [ + 'value' => '1.0', + 'other' => '2.0', + 'structural' => false, + 'arithmetic' => false + ], + 'Past the float mantissa' => [ + 'value' => '0.10000000000000000001', + 'other' => '0.10000000000000000002', + 'structural' => false, + 'arithmetic' => false + ] + ]; + } + + public static function roundingProvider(): array + { + return [ + 'Truncating up' => [ + 'value' => '1.23456', + 'scale' => 2, + 'rounding' => RoundingMode::HalfEven, + 'expected' => '1.23' + ], + 'Half away' => [ + 'value' => '0.995', + 'scale' => 2, + 'rounding' => RoundingMode::HalfUp, + 'expected' => '1.00' + ], + 'Half toward' => [ + 'value' => '0.995', + 'scale' => 2, + 'rounding' => RoundingMode::HalfDown, + 'expected' => '0.99' + ], + 'Widening' => [ + 'value' => '1.2', + 'scale' => 4, + 'rounding' => RoundingMode::Down, + 'expected' => '1.2000' + ], + 'Nothing to lose' => [ + 'value' => '1.00', + 'scale' => 0, + 'rounding' => RoundingMode::Up, + 'expected' => '1' + ], + 'Everything lost' => [ + 'value' => '0.5', + 'scale' => 0, + 'rounding' => RoundingMode::HalfUp, + 'expected' => '1' + ], + 'Discarded wider than the digits' => [ + 'value' => '0.0045', + 'scale' => 0, + 'rounding' => RoundingMode::HalfUp, + 'expected' => '0' + ], + 'Everything lost negative' => [ + 'value' => '-0.5', + 'scale' => 0, + 'rounding' => RoundingMode::HalfUp, + 'expected' => '-1' + ], + 'Beyond the float' => [ + 'value' => '12345678901234567890.5', + 'scale' => 0, + 'rounding' => RoundingMode::HalfEven, + 'expected' => '12345678901234567890' + ], + 'Exact already' => [ + 'value' => '1.25', + 'scale' => 2, + 'rounding' => RoundingMode::Up, + 'expected' => '1.25' + ], + 'Toward zero' => [ + 'value' => '-0.4', + 'scale' => 0, + 'rounding' => RoundingMode::Down, + 'expected' => '0' + ], + 'Above half' => [ + 'value' => '1.226', + 'scale' => 2, + 'rounding' => RoundingMode::HalfEven, + 'expected' => '1.23' + ], + 'Below half' => [ + 'value' => '1.234', + 'scale' => 2, + 'rounding' => RoundingMode::HalfEven, + 'expected' => '1.23' + ] + ]; + } + + public static function crossTypeProvider(): array + { + return [ + 'Equal to a fraction' => ['value' => '1.50', 'other' => BigRational::of(value: '3/2'), 'expected' => 0], + 'Equal to an integer' => ['value' => '2.00', 'other' => BigInteger::of(value: 2), 'expected' => 0], + 'Less than an integer' => ['value' => '1.50', 'other' => BigInteger::of(value: 2), 'expected' => -1], + 'Greater than a fraction' => ['value' => '1.50', 'other' => BigRational::of(value: '1/3'), 'expected' => 1] + ]; + } + + public static function allocationProvider(): array + { + return [ + 'Three equal parts' => [ + 'amount' => '100.00', + 'weights' => BigDecimals::of('1', '1', '1'), + 'expected' => ['33.34', '33.33', '33.33'] + ], + 'Uneven weights' => [ + 'amount' => '0.05', + 'weights' => BigDecimals::of('3', '7'), + 'expected' => ['0.02', '0.03'] + ], + 'Fractional weights' => [ + 'amount' => '10.00', + 'weights' => BigDecimals::of('1.5', '2.5'), + 'expected' => ['3.75', '6.25'] + ], + 'Negative amount' => [ + 'amount' => '-100.00', + 'weights' => BigDecimals::of('1', '1', '1'), + 'expected' => ['-33.33', '-33.33', '-33.34'] + ], + 'Single weight' => [ + 'amount' => '100.00', + 'weights' => BigDecimals::of('1'), + 'expected' => ['100.00'] + ], + 'Exact split' => [ + 'amount' => '100.00', + 'weights' => BigDecimals::of('1', '1'), + 'expected' => ['50.00', '50.00'] + ], + 'Remainder ordering' => [ + 'amount' => '0.10', + 'weights' => BigDecimals::of('1', '2'), + 'expected' => ['0.03', '0.07'] + ] + ]; + } + + public static function arithmeticProvider(): array + { + return [ + 'Different scales' => [ + 'left' => '1.50', + 'right' => '2.250', + 'sum' => '3.750', + 'difference' => '-0.750', + 'product' => '3.37500' + ], + 'Same scale' => [ + 'left' => '19.99', + 'right' => '5.10', + 'sum' => '25.09', + 'difference' => '14.89', + 'product' => '101.9490' + ], + 'Negative' => [ + 'left' => '-1.5', + 'right' => '2.5', + 'sum' => '1.0', + 'difference' => '-4.0', + 'product' => '-3.75' + ], + 'Zero' => [ + 'left' => '0.00', + 'right' => '0.0', + 'sum' => '0.00', + 'difference' => '0.00', + 'product' => '0.000' + ] + ]; + } + + public static function squareRootProvider(): array + { + return [ + 'Irrational' => [ + 'value' => '2', + 'scale' => 10, + 'rounding' => RoundingMode::HalfEven, + 'expected' => '1.4142135624' + ], + 'Exact root' => [ + 'value' => '2.25', + 'scale' => 1, + 'rounding' => RoundingMode::HalfEven, + 'expected' => '1.5' + ], + 'Zero' => [ + 'value' => '0', + 'scale' => 2, + 'rounding' => RoundingMode::HalfEven, + 'expected' => '0.00' + ], + 'Truncated' => [ + 'value' => '17', + 'scale' => 0, + 'rounding' => RoundingMode::Down, + 'expected' => '4' + ], + 'Rounded up' => [ + 'value' => '17', + 'scale' => 0, + 'rounding' => RoundingMode::Up, + 'expected' => '5' + ], + 'Exact under up' => [ + 'value' => '2.25', + 'scale' => 1, + 'rounding' => RoundingMode::Up, + 'expected' => '1.5' + ], + 'Scale below half' => [ + 'value' => '0.0001', + 'scale' => 1, + 'rounding' => RoundingMode::HalfEven, + 'expected' => '0.0' + ] + ]; + } + + public static function trailingZeroProvider(): array + { + return [ + 'Some zeros' => ['value' => '1.200', 'expected' => '1.2'], + 'All zeros' => ['value' => '1.000', 'expected' => '1'], + 'Integral' => ['value' => '100', 'expected' => '100'], + 'Zero' => ['value' => '0.000', 'expected' => '0'], + 'Negative' => ['value' => '-1.500', 'expected' => '-1.5'] + ]; + } + + public static function malformedLiteralProvider(): array + { + return [ + 'Empty' => ['value' => ''], + 'Sign only' => ['value' => '+'], + 'Point only' => ['value' => '.'], + 'Letters' => ['value' => 'abc'], + 'Dangling e' => ['value' => '1e'], + 'Underscores' => ['value' => '1_000'], + 'Fraction' => ['value' => '1/2'], + 'Two points' => ['value' => '1.2.3'], + 'Leading space' => ['value' => ' 1'], + 'Huge exponent' => ['value' => '1e10001'], + 'Tiny exponent' => ['value' => '1e-10001'] + ]; + } + + public static function shortestRoundTripProvider(): array + { + return [ + 'Whole' => ['value' => 5.0, 'expected' => '5'], + 'One tenth' => ['value' => 0.1, 'expected' => '0.1'], + 'One third' => ['value' => (1 / 3), 'expected' => '0.3333333333333333'], + 'Negative zero' => ['value' => -0.0, 'expected' => '0'], + 'Inexact sum' => ['value' => (0.1 + 0.2), 'expected' => '0.30000000000000004'] + ]; + } +} diff --git a/tests/Unit/BigDecimalsTest.php b/tests/Unit/BigDecimalsTest.php new file mode 100644 index 0000000..14f67bf --- /dev/null +++ b/tests/Unit/BigDecimalsTest.php @@ -0,0 +1,130 @@ + $part->toString(), $actual->all()) + ); + } + + public function testAllThenKeepsTheOrderOfTheLiterals(): void + { + /** @Given three decimal literals in a deliberate order */ + $values = BigDecimals::of('3', '1', '2'); + + /** @When the collection is read back */ + $actual = $values->all(); + + /** @Then the order is untouched */ + self::assertSame( + ['3', '1', '2'], + array_map(static fn(BigDecimal $part): string => $part->toString(), $actual) + ); + } + + #[DataProvider('sumProvider')] + public function testSumThenStaysExactAtTheLargestScale(BigDecimals $values, string $expected, int $count): void + { + /** @Given a collection of decimals with its expected total and size */ + + /** @When the total is taken */ + $actual = $values->sum(); + + /** @Then the total is exact at the largest scale, and the size is unchanged */ + self::assertSame($expected, $actual->toString()); + self::assertCount($count, $values); + } + + public function testIterationThenYieldsEveryDecimalInOrder(): void + { + /** @Given three decimal literals in a deliberate order */ + $values = BigDecimals::of('3', '1', '2'); + + /** @When the collection is iterated */ + $actual = iterator_to_array($values); + + /** @Then every element is yielded in order */ + self::assertSame( + ['3', '1', '2'], + array_map(static fn(BigDecimal $part): string => $part->toString(), $actual) + ); + } + + public function testOfWhenALiteralIsMalformedThenNumberNotWellFormed(): void + { + /** @Given a literal that is not a number */ + $value = 'abc'; + + /** @Then a failure describing the malformed literal is raised */ + $this->expectException(NumberNotWellFormed::class); + + /** @When a collection is built from it */ + BigDecimals::of($value); + } + + public function testFromWhenArgumentsAreNamedThenTheCollectionStaysAList(): void + { + /** @Given a decimal bound to a named argument */ + $first = BigDecimal::of(value: '1.50'); + + /** @And a second decimal bound to another named argument */ + $second = BigDecimal::of(value: '2.25'); + + /** @When a collection is built from them */ + $actual = BigDecimals::from(first: $first, second: $second); + + /** @Then the names are dropped and the collection stays an ordered list */ + self::assertSame( + ['1.50', '2.25'], + array_map(static fn(BigDecimal $part): string => $part->toString(), $actual->all()) + ); + } + + public function testJsonSerializeThenEmitsAJsonArrayOfStringsThatReadsBack(): void + { + /** @Given a collection of decimals */ + $decimals = BigDecimals::of('1.50', '2.00'); + + /** @When it is serialized */ + $actual = $decimals->jsonSerialize(); + + /** @Then it is a list of canonical strings that encodes as a JSON array and reads back */ + self::assertSame(['1.50', '2.00'], $actual); + self::assertSame('["1.50","2.00"]', json_encode($decimals)); + self::assertEquals($decimals, BigDecimals::of(...$actual)); + } + + public static function sumProvider(): array + { + return [ + 'Empty' => ['values' => BigDecimals::of(), 'expected' => '0', 'count' => 0], + 'Single element' => ['values' => BigDecimals::of('1.50'), 'expected' => '1.50', 'count' => 1], + 'Mixed scales' => ['values' => BigDecimals::of('1.5', '2.25'), 'expected' => '3.75', 'count' => 2], + 'Negative total' => ['values' => BigDecimals::of('1.00', '-2.00'), 'expected' => '-1.00', 'count' => 2], + 'Native integers' => ['values' => BigDecimals::of(1, 2, 3), 'expected' => '6', 'count' => 3] + ]; + } +} diff --git a/tests/Unit/BigIntegerTest.php b/tests/Unit/BigIntegerTest.php new file mode 100644 index 0000000..f980b60 --- /dev/null +++ b/tests/Unit/BigIntegerTest.php @@ -0,0 +1,722 @@ +toString()); + } + + public function testZeroThenHoldsZero(): void + { + /** @Given nothing but the factory */ + + /** @When zero is created */ + $actual = BigInteger::zero(); + + /** @Then it holds zero */ + self::assertSame('0', $actual->toString()); + } + + #[DataProvider('literalProvider')] + public function testOfThenReadsTheLiteral(string|int $value, string $expected): void + { + /** @Given an integer literal and the value it stands for */ + + /** @When an integer is created from it */ + $actual = BigInteger::of(value: $value); + + /** @Then the value is read exactly */ + self::assertSame($expected, $actual->toString()); + } + + public function testNegatedThenFlipsTheSign(): void + { + /** @Given a positive integer */ + $number = BigInteger::of(value: 5); + + /** @When it is negated */ + $actual = $number->negated(); + + /** @Then the sign is flipped */ + self::assertSame('-5', $actual->toString()); + } + + public function testPlusThenSumsBothIntegers(): void + { + /** @Given an integer */ + $augend = BigInteger::of(value: '9223372036854775807'); + + /** @And another integer beyond the native range */ + $addend = BigInteger::of(value: '9223372036854775807'); + + /** @When they are added */ + $actual = $augend->plus(addend: $addend); + + /** @Then the sum keeps every digit */ + self::assertSame('18446744073709551614', $actual->toString()); + } + + #[DataProvider('moduloProvider')] + public function testModuloThenIsNeverNegative(string $dividend, string $divisor, string $modulo): void + { + /** @Given a dividend and a modulus with the expected mathematical modulo */ + + /** @When the modulo is taken */ + $actual = BigInteger::of(value: $dividend)->modulo(modulus: BigInteger::of(value: $divisor)); + + /** @Then the result is never negative */ + self::assertSame($modulo, $actual->toString()); + } + + public function testAbsoluteThenRemovesTheSign(): void + { + /** @Given a negative integer */ + $number = BigInteger::of(value: -5); + + /** @When its magnitude is taken */ + $actual = $number->absolute(); + + /** @Then the sign is gone */ + self::assertSame('5', $actual->toString()); + } + + #[DataProvider('equalityProvider')] + public function testEqualsThenComparesStructurally(string $value, string $other, bool $expected): void + { + /** @Given two integer literals and whether they are structurally equal */ + + /** @When they are compared */ + $actual = BigInteger::of(value: $value)->equals(other: BigInteger::of(value: $other)); + + /** @Then structural equality matches the expectation */ + self::assertSame($expected, $actual); + } + + #[DataProvider('powerProvider')] + public function testPowerThenReturnsTheExactResult(string $value, int $exponent, string $power): void + { + /** @Given a value, an exponent, and the expected power */ + + /** @When it is raised to that power */ + $actual = BigInteger::of(value: $value)->power(exponent: $exponent); + + /** @Then the result is exact */ + self::assertSame($power, $actual->toString()); + } + + public function testMinusThenSubtractsTheSubtrahend(): void + { + /** @Given an integer beyond the float mantissa */ + $minuend = BigInteger::of(value: '10000000000000000001'); + + /** @And a larger integer that differs only in the last digit */ + $subtrahend = BigInteger::of(value: '10000000000000000003'); + + /** @When the larger is subtracted */ + $actual = $minuend->minus(subtrahend: $subtrahend); + + /** @Then the difference keeps the last digit rather than collapsing to zero */ + self::assertSame('-2', $actual->toString()); + } + + #[DataProvider('quotientProvider')] + public function testQuotientThenTruncatesTowardZero(string $dividend, string $divisor, string $quotient): void + { + /** @Given a dividend and a divisor with the expected truncated quotient */ + + /** @When the quotient is taken */ + $actual = BigInteger::of(value: $dividend)->quotient(divisor: BigInteger::of(value: $divisor)); + + /** @Then the quotient truncates toward zero */ + self::assertSame($quotient, $actual->toString()); + } + + #[DataProvider('signProvider')] + public function testSignPredicatesThenAgreeOnTheSign( + string $value, + bool $isZero, + bool $isNegative, + bool $isPositive + ): void { + /** @Given an integer literal and its expected sign */ + + /** @When the sign predicates are asked */ + $actual = BigInteger::of(value: $value); + + /** @Then all three agree */ + self::assertSame($isZero, $actual->isZero()); + self::assertSame($isNegative, $actual->isNegative()); + self::assertSame($isPositive, $actual->isPositive()); + } + + public function testToBigDecimalThenCarriesScaleZero(): void + { + /** @Given an integer */ + $number = BigInteger::of(value: -42); + + /** @When it is converted to a decimal */ + $actual = $number->toBigDecimal(); + + /** @Then the decimal has no fractional digits */ + self::assertSame(0, $actual->scale()); + } + + public function testJsonSerializeThenEmitsAJsonString(): void + { + /** @Given an integer beyond the precision of a JSON number */ + $number = BigInteger::of(value: '9007199254740993'); + + /** @When it is encoded */ + $actual = json_encode($number); + + /** @Then it is a JSON string rather than a JSON number */ + self::assertSame('"9007199254740993"', $actual); + } + + #[DataProvider('rootProvider')] + public function testSquareRootThenTruncatesTowardZero(string $value, string $root): void + { + /** @Given a value and the expected truncated root */ + + /** @When its square root is taken */ + $actual = BigInteger::of(value: $value)->squareRoot(); + + /** @Then the root is truncated toward zero */ + self::assertSame($root, $actual->toString()); + } + + #[DataProvider('parityProvider')] + public function testIsEvenThenReportsDivisibilityByTwo(string $value, bool $expected): void + { + /** @Given an integer literal and whether it is even */ + + /** @When its parity is asked */ + $actual = BigInteger::of(value: $value); + + /** @Then the answer matches, and is the opposite of being odd */ + self::assertSame($expected, $actual->isEven()); + self::assertSame(!$expected, $actual->isOdd()); + } + + public function testDividedByThenReturnsAnExactFraction(): void + { + /** @Given an integer */ + $dividend = BigInteger::of(value: 10); + + /** @And a divisor that does not divide it evenly */ + $divisor = BigInteger::of(value: 4); + + /** @When it is divided */ + $actual = $dividend->dividedBy(divisor: $divisor); + + /** @Then the exact fraction is returned in lowest terms */ + self::assertSame('5/2', $actual->toString()); + } + + public function testToBigRationalThenHasADenominatorOfOne(): void + { + /** @Given an integer */ + $number = BigInteger::of(value: 7); + + /** @When it is converted to a fraction */ + $actual = $number->toBigRational(); + + /** @Then the denominator is one */ + self::assertSame('1', $actual->denominator()->toString()); + } + + public function testHashCodeWhenValuesMatchThenHashesMatch(): void + { + /** @Given an integer */ + $number = BigInteger::of(value: 5); + + /** @And another integer holding the same value */ + $other = BigInteger::of(value: '5.0'); + + /** @When both hashes are taken */ + $actual = $number->hashCode(); + + /** @Then they agree */ + self::assertSame($other->hashCode(), $actual); + } + + #[DataProvider('divisorProvider')] + public function testGreatestCommonDivisorThenIgnoresTheSigns(string $value, string $other): void + { + /** @Given two integer literals sharing divisors, in any sign combination */ + + /** @When their greatest common divisor is taken */ + $actual = BigInteger::of(value: $value)->greatestCommonDivisor(other: BigInteger::of(value: $other)); + + /** @Then the result is positive whatever the signs were */ + self::assertSame('6', $actual->toString()); + } + + #[DataProvider('remainderProvider')] + public function testRemainderThenCarriesTheSignOfTheDividend( + string $dividend, + string $divisor, + string $remainder + ): void { + /** @Given a dividend and a divisor with the expected remainder */ + + /** @When the remainder is taken */ + $actual = BigInteger::of(value: $dividend)->remainder(divisor: BigInteger::of(value: $divisor)); + + /** @Then the remainder follows the dividend */ + self::assertSame($remainder, $actual->toString()); + } + + #[DataProvider('positionalBaseProvider')] + public function testBaseConversionThenRoundTripsThroughTheBase(int $base, string $text, string $value): void + { + /** @Given a base, a representation in it, and the decimal value */ + + /** @When the value is written in that base */ + $actual = BigInteger::of(value: $value); + + /** @Then the text matches, and reading it back yields the value */ + self::assertSame($text, $actual->toBase(base: $base)); + self::assertSame($value, BigInteger::fromBase(base: $base, value: $text)->toString()); + } + + #[DataProvider('comparisonProvider')] + public function testComparisonPredicatesThenAgreeWithCompareTo(string $value, string $other, int $expected): void + { + /** @Given an integer literal */ + $number = BigInteger::of(value: $value); + + /** @And another to compare it against */ + $against = BigInteger::of(value: $other); + + /** @When they are compared */ + $actual = $number->compareTo(other: $against); + + /** @Then every predicate agrees with the comparison */ + self::assertSame($expected, $actual); + self::assertSame($expected === 0, $number->isEqualTo(other: $against)); + self::assertSame($expected < 0, $number->isLessThan(other: $against)); + self::assertSame($expected > 0, $number->isGreaterThan(other: $against)); + self::assertSame($expected <= 0, $number->isLessThanOrEqualTo(other: $against)); + self::assertSame($expected >= 0, $number->isGreaterThanOrEqualTo(other: $against)); + } + + public function testQuotientWhenDivisorIsZeroThenDivisionByZero(): void + { + /** @Given an integer */ + $number = BigInteger::of(value: 7); + + /** @Then a failure naming the dividend is raised */ + $this->expectException(DivisionByZero::class); + $this->expectExceptionMessage('Cannot divide <7> by zero.'); + + /** @When a truncated quotient by zero is asked for */ + $number->quotient(divisor: BigInteger::zero()); + } + + public function testDividedByWhenDivisorIsZeroThenDivisionByZero(): void + { + /** @Given an integer */ + $number = BigInteger::of(value: 7); + + /** @Then a failure naming the dividend is raised */ + $this->expectException(DivisionByZero::class); + $this->expectExceptionMessage('Cannot divide <7> by zero.'); + + /** @When it is divided by zero */ + $number->dividedBy(divisor: BigInteger::zero()); + } + + public function testRemainderWhenDivisorIsZeroThenDivisionByZero(): void + { + /** @Given an integer */ + $number = BigInteger::of(value: 7); + + /** @Then a failure naming the dividend is raised */ + $this->expectException(DivisionByZero::class); + $this->expectExceptionMessage('Cannot divide <7> by zero.'); + + /** @When a remainder by zero is asked for */ + $number->remainder(divisor: BigInteger::zero()); + } + + public function testSquareRootWhenValueIsNegativeThenNegativeRoot(): void + { + /** @Given a negative integer */ + $number = BigInteger::of(value: -4); + + /** @Then a failure describing the negative radicand is raised */ + $this->expectException(NegativeRoot::class); + $this->expectExceptionMessage('Cannot take the square root of the negative value <-4>.'); + + /** @When its square root is taken */ + $number->squareRoot(); + } + + #[DataProvider('nativeRangeProvider')] + public function testToIntWhenValueFitsThenReturnsTheNativeInteger(string $value, int $expected): void + { + /** @Given an integer literal at a boundary of the native range */ + + /** @When it is converted to a native integer */ + $actual = BigInteger::of(value: $value)->toInt(); + + /** @Then the boundary itself is accepted */ + self::assertSame($expected, $actual); + } + + public function testMultipliedByThenStaysExactPastTheFloatMantissa(): void + { + /** @Given an integer beyond the float mantissa */ + $multiplicand = BigInteger::of(value: '10000000000000000001'); + + /** @And a multiplier that forces every digit to carry */ + $multiplier = BigInteger::of(value: '10000000000000000001'); + + /** @When they are multiplied */ + $actual = $multiplicand->multipliedBy(multiplier: $multiplier); + + /** @Then the product keeps every digit a float would have discarded */ + self::assertSame('100000000000000000020000000000000000001', $actual->toString()); + } + + public function testEveryFailureThenCanBeCaughtAsASingleMathFailure(): void + { + /** @Given an integer */ + $number = BigInteger::one(); + + /** @Then the division failure is reachable through the shared contract */ + $this->expectException(MathFailure::class); + + /** @When it is divided by zero */ + $number->quotient(divisor: BigInteger::zero()); + } + + public function testFromBaseWhenValueIsEmptyThenNumberNotWellFormed(): void + { + /** @Given an empty literal */ + $value = ''; + + /** @Then a failure describing the malformed literal is raised */ + $this->expectException(NumberNotWellFormed::class); + + /** @When an integer is read in base sixteen */ + BigInteger::fromBase(base: 16, value: $value); + } + + public function testPowerWhenExponentIsNegativeThenNegativeExponent(): void + { + /** @Given an integer */ + $number = BigInteger::of(value: 2); + + /** @Then a failure describing the negative exponent is raised */ + $this->expectException(NegativeExponent::class); + $this->expectExceptionMessage('Exponent must not be negative, got <-1>.'); + + /** @When it is raised to a negative power */ + $number->power(exponent: -1); + } + + #[DataProvider('crossTypeProvider')] + public function testCompareToWhenTheOtherIsAnotherTypeThenStaysExact( + string $value, + Number $other, + int $expected + ): void { + /** @Given an integer and a number of another type with the expected comparison */ + + /** @When they are compared */ + $actual = BigInteger::of(value: $value)->compareTo(other: $other); + + /** @Then the comparison crosses the type boundary */ + self::assertSame($expected, $actual); + } + + public function testFromBaseWhenLiteralIsUppercaseThenItIsReadTheSame(): void + { + /** @Given a hexadecimal literal written in uppercase */ + $value = '-FF'; + + /** @When it is read in base sixteen */ + $actual = BigInteger::fromBase(base: 16, value: $value); + + /** @Then the case makes no difference */ + self::assertSame('-255', $actual->toString()); + } + + public function testFromBaseWhenSignIsRepeatedThenNumberNotWellFormed(): void + { + /** @Given a literal carrying more than one leading minus */ + $value = '--ff'; + + /** @Then a failure describing the malformed literal is raised */ + $this->expectException(NumberNotWellFormed::class); + + /** @When it is read in base sixteen */ + BigInteger::fromBase(base: 16, value: $value); + } + + public function testToIntWhenValueBelowTheNativeRangeThenIntegerOverflow(): void + { + /** @Given an integer one below the smallest native integer */ + $number = BigInteger::of(value: '-9223372036854775809'); + + /** @Then a failure describing the overflow is raised */ + $this->expectException(IntegerOverflow::class); + + /** @When it is converted to a native integer */ + $number->toInt(); + } + + public function testOfWhenValueCarriesAFractionalPartThenInexactConversion(): void + { + /** @Given a literal with a non-zero fractional part */ + $value = '1.5'; + + /** @Then a failure describing the lost fractional part is raised */ + $this->expectException(InexactConversion::class); + $this->expectExceptionMessage('Value <1.5> has a fractional part and is not an integer.'); + + /** @When an integer is created from it */ + BigInteger::of(value: $value); + } + + public function testToIntWhenValueExceedsTheNativeRangeThenIntegerOverflow(): void + { + /** @Given an integer one beyond the largest native integer */ + $number = BigInteger::of(value: '9223372036854775808'); + + /** @Then a failure describing the overflow is raised */ + $this->expectException(IntegerOverflow::class); + + /** @When it is converted to a native integer */ + $number->toInt(); + } + + public function testFromBaseWhenBaseIsAboveTheSupportedRangeThenBaseOutOfRange(): void + { + /** @Given a base above thirty-six */ + $base = 37; + + /** @Then a failure describing the bounds is raised */ + $this->expectException(BaseOutOfRange::class); + + /** @When an integer is read in that base */ + BigInteger::fromBase(base: $base, value: '1'); + } + + public function testFromBaseWhenCharacterIsNotInTheBaseThenNumberNotWellFormed(): void + { + /** @Given a literal carrying a character the base does not define */ + $value = '9'; + + /** @Then a failure describing the malformed literal is raised */ + $this->expectException(NumberNotWellFormed::class); + + /** @When an integer is read in base two */ + BigInteger::fromBase(base: 2, value: $value); + } + + public function testFromBaseWhenBaseIsOutsideTheSupportedRangeThenBaseOutOfRange(): void + { + /** @Given a base below two */ + $base = 1; + + /** @Then a failure describing the bounds is raised */ + $this->expectException(BaseOutOfRange::class); + $this->expectExceptionMessage('Base must be within 2 to 36, got <1>.'); + + /** @When an integer is read in that base */ + BigInteger::fromBase(base: $base, value: '1'); + } + + public function testPowerWhenExponentExceedsTheMagnitudeCeilingThenExponentOutOfRange(): void + { + /** @Given an integer */ + $number = BigInteger::of(value: 2); + + /** @Then a failure naming the rejected exponent is raised */ + $this->expectException(ExponentOutOfRange::class); + $this->expectExceptionMessage('Exponent magnitude must not exceed 2147483647, got <2147483648>.'); + + /** @When it is raised to an exponent beyond the supported magnitude */ + $number->power(exponent: 2147483648); + } + + public static function rootProvider(): array + { + return [ + 'Zero' => ['value' => '0', 'root' => '0'], + 'Perfect' => ['value' => '16', 'root' => '4'], + 'Truncate' => ['value' => '17', 'root' => '4'], + 'Large' => ['value' => '1267650600228229401496703205376', 'root' => '1125899906842624'] + ]; + } + + public static function signProvider(): array + { + return [ + 'Zero' => ['value' => '0', 'isZero' => true, 'isNegative' => false, 'isPositive' => false], + 'Negative' => ['value' => '-1', 'isZero' => false, 'isNegative' => true, 'isPositive' => false], + 'Positive' => ['value' => '1', 'isZero' => false, 'isNegative' => false, 'isPositive' => true] + ]; + } + + public static function powerProvider(): array + { + return [ + 'Zero' => ['value' => '0', 'exponent' => 0, 'power' => '1'], + 'Perfect' => ['value' => '16', 'exponent' => 2, 'power' => '256'], + 'Truncate' => ['value' => '17', 'exponent' => 1, 'power' => '17'], + 'Large' => ['value' => '2', 'exponent' => 100, 'power' => '1267650600228229401496703205376'] + ]; + } + + public static function moduloProvider(): array + { + return [ + 'Positive by positive' => ['dividend' => '7', 'divisor' => '2', 'modulo' => '1'], + 'Negative by positive' => ['dividend' => '-7', 'divisor' => '2', 'modulo' => '1'], + 'Positive by negative' => ['dividend' => '7', 'divisor' => '-2', 'modulo' => '1'], + 'Negative by negative' => ['dividend' => '-7', 'divisor' => '-2', 'modulo' => '1'], + 'Exact division' => ['dividend' => '8', 'divisor' => '2', 'modulo' => '0'] + ]; + } + + public static function parityProvider(): array + { + return [ + 'Zero' => ['value' => '0', 'expected' => true], + 'Even positive' => ['value' => '4', 'expected' => true], + 'Odd positive' => ['value' => '7', 'expected' => false], + 'Even negative' => ['value' => '-4', 'expected' => true], + 'Odd negative' => ['value' => '-3', 'expected' => false] + ]; + } + + public static function divisorProvider(): array + { + return [ + 'Both positive' => ['value' => '12', 'other' => '18'], + 'Negative left' => ['value' => '-12', 'other' => '18'], + 'Negative and larger' => ['value' => '-18', 'other' => '12'], + 'Negative right' => ['value' => '12', 'other' => '-18'], + 'Both negative' => ['value' => '-12', 'other' => '-18'] + ]; + } + + public static function literalProvider(): array + { + return [ + 'Native integer' => ['value' => 42, 'expected' => '42'], + 'Leading plus' => ['value' => '+7', 'expected' => '7'], + 'Leading zeros' => ['value' => '007', 'expected' => '7'], + 'Negative zero' => ['value' => '-0', 'expected' => '0'], + 'Zero fractional' => ['value' => '5.00', 'expected' => '5'], + 'Scientific notation' => ['value' => '1e3', 'expected' => '1000'], + 'Beyond native range' => ['value' => '92233720368547758070', 'expected' => '92233720368547758070'] + ]; + } + + public static function equalityProvider(): array + { + return [ + 'Same value' => ['value' => '5', 'other' => '5', 'expected' => true], + 'Same after trim' => ['value' => '5', 'other' => '5.00', 'expected' => true], + 'Different value' => ['value' => '5', 'other' => '6', 'expected' => false], + 'Opposite signs' => ['value' => '5', 'other' => '-5', 'expected' => false] + ]; + } + + public static function quotientProvider(): array + { + return [ + 'Positive by positive' => ['dividend' => '7', 'divisor' => '2', 'quotient' => '3'], + 'Negative by positive' => ['dividend' => '-7', 'divisor' => '2', 'quotient' => '-3'], + 'Positive by negative' => ['dividend' => '7', 'divisor' => '-2', 'quotient' => '-3'], + 'Negative by negative' => ['dividend' => '-7', 'divisor' => '-2', 'quotient' => '3'], + 'Exact division' => ['dividend' => '8', 'divisor' => '2', 'quotient' => '4'] + ]; + } + + public static function crossTypeProvider(): array + { + return [ + 'Equal to a decimal' => ['value' => '5', 'other' => BigDecimal::of(value: '5.00'), 'expected' => 0], + 'Less than a decimal' => ['value' => '5', 'other' => BigDecimal::of(value: '5.01'), 'expected' => -1], + 'Greater than a fraction' => ['value' => '5', 'other' => BigRational::of(value: '1/3'), 'expected' => 1] + ]; + } + + public static function remainderProvider(): array + { + return [ + 'Positive by positive' => ['dividend' => '7', 'divisor' => '2', 'remainder' => '1'], + 'Negative by positive' => ['dividend' => '-7', 'divisor' => '2', 'remainder' => '-1'], + 'Positive by negative' => ['dividend' => '7', 'divisor' => '-2', 'remainder' => '1'], + 'Negative by negative' => ['dividend' => '-7', 'divisor' => '-2', 'remainder' => '-1'], + 'Exact division' => ['dividend' => '8', 'divisor' => '2', 'remainder' => '0'] + ]; + } + + public static function comparisonProvider(): array + { + return [ + 'Less than' => ['value' => '9', 'other' => '10', 'expected' => -1], + 'Digits differ in count' => [ + 'value' => '99999999999999999999', + 'other' => '100000000000000000000', + 'expected' => -1 + ], + 'Equal' => ['value' => '10', 'other' => '10', 'expected' => 0], + 'Greater than' => ['value' => '10', 'other' => '9', 'expected' => 1] + ]; + } + + public static function nativeRangeProvider(): array + { + return [ + 'Largest' => ['value' => '9223372036854775807', 'expected' => PHP_INT_MAX], + 'Smallest' => ['value' => '-9223372036854775808', 'expected' => PHP_INT_MIN], + 'Zero' => ['value' => '0', 'expected' => 0] + ]; + } + + public static function positionalBaseProvider(): array + { + return [ + 'Zero in hex' => ['base' => 16, 'text' => '0', 'value' => '0'], + 'Binary' => ['base' => 2, 'text' => '11111111', 'value' => '255'], + 'Hexadecimal' => ['base' => 16, 'text' => 'ff', 'value' => '255'], + 'Base thirty-six' => ['base' => 36, 'text' => 'zz', 'value' => '1295'], + 'Negative in hex' => ['base' => 16, 'text' => '-ff', 'value' => '-255'] + ]; + } +} diff --git a/tests/Unit/BigRationalTest.php b/tests/Unit/BigRationalTest.php new file mode 100644 index 0000000..e054604 --- /dev/null +++ b/tests/Unit/BigRationalTest.php @@ -0,0 +1,586 @@ +toString()); + } + + public function testZeroThenHoldsZero(): void + { + /** @Given nothing but the factory */ + + /** @When zero is created */ + $actual = BigRational::zero(); + + /** @Then it holds zero */ + self::assertSame('0', $actual->toString()); + } + + public function testNegatedThenFlipsTheSign(): void + { + /** @Given a fraction */ + $fraction = BigRational::of(value: '3/4'); + + /** @When it is negated */ + $actual = $fraction->negated(); + + /** @Then the numerator carries the sign */ + self::assertSame('-3/4', $actual->toString()); + } + + #[DataProvider('arithmeticProvider')] + public function testArithmeticThenStaysExact( + string $left, + string $right, + string $sum, + string $difference, + string $product, + string $quotient + ): void { + /** @Given two fractions and the expected exact results */ + + /** @When the four operations run */ + $actual = BigRational::of(value: $left); + + /** @Then every result is exact and in lowest terms */ + self::assertSame($sum, $actual->plus(addend: BigRational::of(value: $right))->toString()); + self::assertSame($difference, $actual->minus(subtrahend: BigRational::of(value: $right))->toString()); + self::assertSame($product, $actual->multipliedBy(multiplier: BigRational::of(value: $right))->toString()); + self::assertSame($quotient, $actual->dividedBy(divisor: BigRational::of(value: $right))->toString()); + } + + public function testAbsoluteThenRemovesTheSign(): void + { + /** @Given a negative fraction */ + $fraction = BigRational::of(value: '-3/4'); + + /** @When its magnitude is taken */ + $actual = $fraction->absolute(); + + /** @Then the sign is gone */ + self::assertSame('3/4', $actual->toString()); + } + + public function testReciprocalThenSwapsTheTerms(): void + { + /** @Given a fraction */ + $fraction = BigRational::of(value: '2/3'); + + /** @When its reciprocal is taken */ + $actual = $fraction->reciprocal(); + + /** @Then the terms are swapped */ + self::assertSame('3/2', $actual->toString()); + } + + #[DataProvider('signProvider')] + public function testSignPredicatesThenAgreeOnTheSign( + string $value, + bool $isZero, + bool $isNegative, + bool $isPositive + ): void { + /** @Given a fraction literal and its expected sign */ + + /** @When the sign predicates are asked */ + $actual = BigRational::of(value: $value); + + /** @Then all three agree */ + self::assertSame($isZero, $actual->isZero()); + self::assertSame($isNegative, $actual->isNegative()); + self::assertSame($isPositive, $actual->isPositive()); + } + + public function testJsonSerializeThenEmitsAJsonString(): void + { + /** @Given a fraction */ + $fraction = BigRational::of(value: '3/4'); + + /** @When it is encoded */ + $actual = json_encode($fraction); + + /** @Then it is a JSON string carrying the slash */ + self::assertSame('"3\/4"', $actual); + } + + #[DataProvider('powerProvider')] + public function testPowerThenAcceptsNegativeExponents(string $value, int $exponent, string $expected): void + { + /** @Given a fraction, an exponent, and the expected exact power */ + + /** @When it is raised to that power */ + $actual = BigRational::of(value: $value)->power(exponent: $exponent); + + /** @Then the result is exact, including for a negative exponent */ + self::assertSame($expected, $actual->toString()); + } + + #[DataProvider('floatProvider')] + public function testToFloatThenReturnsTheNearestFloat(string $value, float $expected): void + { + /** @Given a fraction and the float nearest to it */ + + /** @When it is converted to a float */ + $actual = BigRational::of(value: $value)->toFloat(); + + /** @Then the nearest float is returned */ + self::assertSame($expected, $actual); + } + + #[DataProvider('literalProvider')] + public function testOfThenReadsTheLiteralInLowestTerms(string|int $value, string $expected): void + { + /** @Given a literal and the fraction it stands for */ + + /** @When a fraction is created from it */ + $actual = BigRational::of(value: $value); + + /** @Then the fraction is reduced and the sign sits on the numerator */ + self::assertSame($expected, $actual->toString()); + } + + #[DataProvider('decimalProvider')] + public function testToDecimalThenRoundsAtTheRequestedScale( + string $value, + int $scale, + RoundingMode $rounding, + string $expected + ): void { + /** @Given a fraction, a target scale, and a rounding mode */ + + /** @When it is converted to a decimal */ + $actual = BigRational::of(value: $value)->toDecimal(scale: $scale, rounding: $rounding); + + /** @Then the decimal is rounded as instructed */ + self::assertSame($expected, $actual->toString()); + } + + #[DataProvider('comparisonProvider')] + public function testCompareToThenWorksAcrossEveryNumberType(string $value, string $other, int $expected): void + { + /** @Given a fraction literal and a decimal literal with the expected comparison */ + + /** @When they are compared */ + $actual = BigRational::of(value: $value)->compareTo(other: BigDecimal::of(value: $other)); + + /** @Then the comparison crosses the type boundary */ + self::assertSame($expected, $actual); + } + + public function testToBigRationalThenReturnsTheSameInstance(): void + { + /** @Given a fraction */ + $fraction = BigRational::of(value: '2/3'); + + /** @When it is asked for its rational form */ + $actual = $fraction->toBigRational(); + + /** @Then it hands back itself */ + self::assertSame($fraction, $actual); + } + + public function testEqualsWhenFractionsMatchThenTheyAreEqual(): void + { + /** @Given a fraction */ + $fraction = BigRational::of(value: '3/4'); + + /** @And the same value written unreduced */ + $other = BigRational::of(value: '6/8'); + + /** @When they are compared structurally */ + $actual = $fraction->equals(other: $other); + + /** @Then they are equal, because a fraction is always stored in lowest terms */ + self::assertTrue($actual); + } + + public function testHashCodeWhenFractionsMatchThenHashesMatch(): void + { + /** @Given a fraction */ + $fraction = BigRational::of(value: '3/4'); + + /** @And the same value written unreduced */ + $other = BigRational::of(value: '6/8'); + + /** @When both hashes are taken */ + $actual = $fraction->hashCode(); + + /** @Then they agree */ + self::assertSame($other->hashCode(), $actual); + } + + #[DataProvider('exactDecimalProvider')] + public function testToDecimalExactThenConvertsAtTheMinimalScale(string $value, string $expected): void + { + /** @Given a terminating fraction and the decimal it stands for */ + + /** @When it is converted without rounding */ + $actual = BigRational::of(value: $value)->toDecimalExact(); + + /** @Then the conversion lands at the smallest scale that represents it */ + self::assertSame($expected, $actual->toString()); + } + + public function testDividedByWhenDivisorIsZeroThenDivisionByZero(): void + { + /** @Given a fraction */ + $fraction = BigRational::of(value: '3/4'); + + /** @Then a failure naming the dividend is raised */ + $this->expectException(DivisionByZero::class); + $this->expectExceptionMessage('Cannot divide <3/4> by zero.'); + + /** @When it is divided by zero */ + $fraction->dividedBy(divisor: BigRational::zero()); + } + + public function testReciprocalWhenFractionIsZeroThenDivisionByZero(): void + { + /** @Given a fraction holding zero */ + $fraction = BigRational::zero(); + + /** @Then a failure describing the undefined reciprocal is raised */ + $this->expectException(DivisionByZero::class); + $this->expectExceptionMessage('The reciprocal of zero is undefined.'); + + /** @When its reciprocal is taken */ + $fraction->reciprocal(); + } + + #[DataProvider('terminatingProvider')] + public function testTerminatingExpansionThenIsReportedForBothKinds(string $value, bool $terminates): void + { + /** @Given a fraction and whether its decimal expansion terminates */ + + /** @When the question is asked */ + $actual = BigRational::of(value: $value)->hasTerminatingDecimal(); + + /** @Then the answer matches */ + self::assertSame($terminates, $actual); + } + + #[DataProvider('termsProvider')] + public function testNumeratorAndDenominatorThenExposeTheReducedTerms( + string $value, + string $numerator, + string $denominator + ): void { + /** @Given a literal with its reduced terms */ + + /** @When both terms are taken */ + $actual = BigRational::of(value: $value); + + /** @Then the denominator is strictly positive and the numerator carries the sign */ + self::assertSame($numerator, $actual->numerator()->toString()); + self::assertSame($denominator, $actual->denominator()->toString()); + } + + public function testOfFractionWhenDenominatorIsZeroThenDivisionByZero(): void + { + /** @Given a numerator */ + $numerator = BigInteger::of(value: 3); + + /** @Then a failure naming the numerator is raised */ + $this->expectException(DivisionByZero::class); + $this->expectExceptionMessage('Fraction <3> cannot have a denominator of zero.'); + + /** @When a fraction is built over a zero denominator */ + BigRational::ofFraction(numerator: $numerator, denominator: BigInteger::zero()); + } + + public function testEqualsWhenOnlyTheNumeratorMatchesThenTheyAreNotEqual(): void + { + /** @Given a fraction */ + $fraction = BigRational::of(value: '1/2'); + + /** @And another fraction sharing the numerator but not the denominator */ + $other = BigRational::of(value: '1/3'); + + /** @When they are compared structurally */ + $actual = $fraction->equals(other: $other); + + /** @Then they are not equal, because both terms take part */ + self::assertFalse($actual); + } + + public function testOfWhenLiteralCarriesTwoSlashesThenNumberNotWellFormed(): void + { + /** @Given a literal with more than one slash */ + $value = '1/2/3'; + + /** @Then a failure describing the malformed literal is raised */ + $this->expectException(NumberNotWellFormed::class); + + /** @When a fraction is created from it */ + BigRational::of(value: $value); + } + + public function testToBigIntegerWhenDenominatorIsOneThenReturnsTheNumerator(): void + { + /** @Given a fraction that reduces to a whole number */ + $fraction = BigRational::of(value: '8/4'); + + /** @When it is converted to an integer */ + $actual = $fraction->toBigInteger(); + + /** @Then it carries the numerator of the reduced fraction */ + self::assertSame('2', $actual->toString()); + } + + public function testToBigIntegerWhenDenominatorIsNotOneThenInexactConversion(): void + { + /** @Given a fraction that is not an integer */ + $fraction = BigRational::of(value: '1/3'); + + /** @Then a failure describing the lost fractional part is raised */ + $this->expectException(InexactConversion::class); + + /** @When it is converted to an integer */ + $fraction->toBigInteger(); + } + + public function testToDecimalExactWhenExpansionRepeatsThenNonTerminatingDecimal(): void + { + /** @Given a fraction whose decimal expansion repeats forever */ + $fraction = BigRational::of(value: '1/3'); + + /** @Then a failure describing the repeating expansion is raised */ + $this->expectException(NonTerminatingDecimal::class); + $this->expectExceptionMessage('Fraction <1/3> has a non-terminating decimal expansion.'); + + /** @When an exact decimal is demanded */ + $fraction->toDecimalExact(); + } + + public function testPowerWhenExponentIsNegativeAndFractionIsZeroThenDivisionByZero(): void + { + /** @Given a fraction holding zero */ + $fraction = BigRational::zero(); + + /** @Then a failure describing the undefined reciprocal is raised */ + $this->expectException(DivisionByZero::class); + + /** @When it is raised to a negative power */ + $fraction->power(exponent: -2); + } + + public function testPowerWhenExponentExceedsTheMagnitudeCeilingThenExponentOutOfRange(): void + { + /** @Given a fraction holding zero */ + $fraction = BigRational::zero(); + + /** @Then a failure naming the rejected exponent is raised */ + $this->expectException(ExponentOutOfRange::class); + $this->expectExceptionMessage('Exponent magnitude must not exceed 2147483647, got <-2147483648>.'); + + /** @When it is raised to a negative exponent beyond the supported magnitude */ + $fraction->power(exponent: -2147483648); + } + + public function testPowerWhenExponentIsAtTheNegativeMagnitudeLimitThenTheBoundIsCleared(): void + { + /** @Given a fraction holding zero */ + $fraction = BigRational::zero(); + + /** @Then the reciprocal is what fails, so the exponent cleared the magnitude bound */ + $this->expectException(DivisionByZero::class); + + /** @When it is raised to the largest negative exponent the library accepts */ + $fraction->power(exponent: -2147483647); + } + + public static function signProvider(): array + { + return [ + 'Zero' => ['value' => '0', 'isZero' => true, 'isNegative' => false, 'isPositive' => false], + 'Negative' => ['value' => '-1/2', 'isZero' => false, 'isNegative' => true, 'isPositive' => false], + 'Positive' => ['value' => '1/2', 'isZero' => false, 'isNegative' => false, 'isPositive' => true] + ]; + } + + public static function floatProvider(): array + { + return [ + 'One third' => ['value' => '1/3', 'expected' => (1 / 3)], + 'One half' => ['value' => '1/2', 'expected' => 0.5], + 'Negative' => ['value' => '-1/4', 'expected' => -0.25], + 'Small' => ['value' => '1/30000000000', 'expected' => (1 / 30000000000)], + 'Small negative' => ['value' => '-1/30000000000', 'expected' => (-1 / 30000000000)], + 'Very small' => ['value' => '1/1000000000000000000000', 'expected' => 1.0E-21], + 'Below a float' => ['value' => '1/1e400', 'expected' => 0.0], + 'Digit sensitive' => ['value' => '155055128/874831071', 'expected' => 0.17724007884489051], + 'Wide integral' => ['value' => '123456789012345678/3', 'expected' => (123456789012345678 / 3)] + ]; + } + + public static function powerProvider(): array + { + return [ + 'Zero exponent' => ['value' => '2/3', 'exponent' => 0, 'expected' => '1'], + 'Zero base' => ['value' => '0', 'exponent' => 0, 'expected' => '1'], + 'Positive exponent' => ['value' => '2/3', 'exponent' => 2, 'expected' => '4/9'], + 'Negative exponent' => ['value' => '2/3', 'exponent' => -2, 'expected' => '9/4'] + ]; + } + + public static function termsProvider(): array + { + return [ + 'Reduced' => ['value' => '6/8', 'numerator' => '3', 'denominator' => '4'], + 'Negative numerator' => ['value' => '-3/4', 'numerator' => '-3', 'denominator' => '4'], + 'Negative denominator' => ['value' => '3/-4', 'numerator' => '-3', 'denominator' => '4'], + 'Zero' => ['value' => '0/5', 'numerator' => '0', 'denominator' => '1'] + ]; + } + + public static function decimalProvider(): array + { + return [ + 'Repeating' => [ + 'value' => '100/3', + 'scale' => 2, + 'rounding' => RoundingMode::HalfEven, + 'expected' => '33.33' + ], + 'Exact' => [ + 'value' => '1/4', + 'scale' => 2, + 'rounding' => RoundingMode::HalfEven, + 'expected' => '0.25' + ], + 'Negative' => [ + 'value' => '-1/3', + 'scale' => 3, + 'rounding' => RoundingMode::Floor, + 'expected' => '-0.334' + ], + 'Scale zero' => [ + 'value' => '3/2', + 'scale' => 0, + 'rounding' => RoundingMode::HalfEven, + 'expected' => '2' + ], + 'Padded scale' => [ + 'value' => '1/2', + 'scale' => 4, + 'rounding' => RoundingMode::Down, + 'expected' => '0.5000' + ], + 'Exact upward' => [ + 'value' => '1/2', + 'scale' => 1, + 'rounding' => RoundingMode::Up, + 'expected' => '0.5' + ], + 'Negative half' => [ + 'value' => '-1/2', + 'scale' => 0, + 'rounding' => RoundingMode::HalfUp, + 'expected' => '-1' + ] + ]; + } + + public static function literalProvider(): array + { + return [ + 'Fraction' => ['value' => '3/4', 'expected' => '3/4'], + 'Unreduced fraction' => ['value' => '6/8', 'expected' => '3/4'], + 'Decimal' => ['value' => '0.75', 'expected' => '3/4'], + 'Integer' => ['value' => '3', 'expected' => '3'], + 'Native integer' => ['value' => -3, 'expected' => '-3'], + 'Negative denominator' => ['value' => '3/-4', 'expected' => '-3/4'], + 'Whole fraction' => ['value' => '8/4', 'expected' => '2'] + ]; + } + + public static function arithmeticProvider(): array + { + return [ + 'Thirds and sixths' => [ + 'left' => '1/3', + 'right' => '1/6', + 'sum' => '1/2', + 'difference' => '1/6', + 'product' => '1/18', + 'quotient' => '2' + ], + 'Negative' => [ + 'left' => '-1/2', + 'right' => '1/4', + 'sum' => '-1/4', + 'difference' => '-3/4', + 'product' => '-1/8', + 'quotient' => '-2' + ], + 'Whole numbers' => [ + 'left' => '4', + 'right' => '2', + 'sum' => '6', + 'difference' => '2', + 'product' => '8', + 'quotient' => '2' + ] + ]; + } + + public static function comparisonProvider(): array + { + return [ + 'Less than' => ['value' => '1/3', 'other' => '0.5', 'expected' => -1], + 'Equal' => ['value' => '1/2', 'other' => '0.50', 'expected' => 0], + 'Greater than' => ['value' => '2/3', 'other' => '0.5', 'expected' => 1] + ]; + } + + public static function terminatingProvider(): array + { + return [ + 'Halves' => ['value' => '1/2', 'terminates' => true], + 'Thirds' => ['value' => '1/3', 'terminates' => false], + 'Sixths' => ['value' => '1/6', 'terminates' => false], + 'Eighths' => ['value' => '1/8', 'terminates' => true], + 'Sevenths' => ['value' => '1/7', 'terminates' => false], + 'Hundredths' => ['value' => '1/100', 'terminates' => true], + 'Twentieths' => ['value' => '1/20', 'terminates' => true], + 'Integer' => ['value' => '3', 'terminates' => true], + 'Zero' => ['value' => '0', 'terminates' => true] + ]; + } + + public static function exactDecimalProvider(): array + { + return [ + 'Halves' => ['value' => '1/2', 'expected' => '0.5'], + 'Eighths' => ['value' => '1/8', 'expected' => '0.125'], + 'Hundredths' => ['value' => '1/100', 'expected' => '0.01'], + 'Twentieths' => ['value' => '1/20', 'expected' => '0.05'], + 'Integer' => ['value' => '3', 'expected' => '3'], + 'Zero' => ['value' => '0', 'expected' => '0'] + ]; + } +} diff --git a/tests/Unit/CalculatorsTest.php b/tests/Unit/CalculatorsTest.php new file mode 100644 index 0000000..2d831e7 --- /dev/null +++ b/tests/Unit/CalculatorsTest.php @@ -0,0 +1,101 @@ +getConstructor()?->invoke($surface->newInstanceWithoutConstructor()); + + /** @Then it is private, so no public path can do the same */ + self::assertTrue($surface->getConstructor()?->isPrivate()); + } + + #[RequiresPhpExtension('bcmath')] + public function testRegisterWhenBackendIsAvailableThenArithmeticRunsThroughIt(): void + { + /** @Given a backend that records how often it is asked to add */ + $calculator = new CountingCalculatorMock(); + + /** @And that backend registered for the process */ + Calculators::register(calculator: $calculator); + + /** @When an addition runs */ + BigDecimal::one()->plus(addend: BigDecimal::one()); + + /** @Then the registered backend did the work */ + self::assertGreaterThan(0, $calculator->additions()); + } + + public function testRegisterWhenBackendIsUnavailableThenCalculatorNotAvailable(): void + { + /** @Given a backend that reports itself unavailable */ + $calculator = new UnavailableCalculatorMock(); + + /** @Then a failure naming the rejected backend is raised */ + $this->expectException(CalculatorNotAvailable::class); + $this->expectExceptionMessage('Calculator is not'); + + /** @When it is registered */ + Calculators::register(calculator: $calculator); + } + + #[RequiresPhpExtension('bcmath')] + public function testResetWhenABackendWasRegisteredThenReturnsToAutomaticResolution(): void + { + /** @Given a registered backend that is not the one resolution would pick */ + Calculators::register(calculator: new CountingCalculatorMock()); + + /** @When automatic resolution is restored */ + Calculators::reset(); + + /** @Then the resolved backend is in use again */ + self::assertInstanceOf(BcMathCalculator::class, Calculators::active()); + } +} diff --git a/tests/Unit/CountingCalculatorMock.php b/tests/Unit/CountingCalculatorMock.php new file mode 100644 index 0000000..18c9d5a --- /dev/null +++ b/tests/Unit/CountingCalculatorMock.php @@ -0,0 +1,72 @@ +delegate = new BcMathCalculator(); + } + + public function add(string $left, string $right): string + { + $this->additions++; + + return $this->delegate->add(left: $left, right: $right); + } + + public function power(string $base, int $exponent): string + { + return $this->delegate->power(base: $base, exponent: $exponent); + } + + public function compare(string $left, string $right): int + { + return $this->delegate->compare(left: $left, right: $right); + } + + public function multiply(string $left, string $right): string + { + return $this->delegate->multiply(left: $left, right: $right); + } + + public function quotient(string $numerator, string $denominator): string + { + return $this->delegate->quotient(numerator: $numerator, denominator: $denominator); + } + + public function subtract(string $minuend, string $subtrahend): string + { + return $this->delegate->subtract(minuend: $minuend, subtrahend: $subtrahend); + } + + public function additions(): int + { + return $this->additions; + } + + public function remainder(string $numerator, string $denominator): string + { + return $this->delegate->remainder(numerator: $numerator, denominator: $denominator); + } + + public function squareRoot(string $radicand): string + { + return $this->delegate->squareRoot(radicand: $radicand); + } + + public function isAvailable(): bool + { + return $this->delegate->isAvailable(); + } +} diff --git a/tests/Unit/NativeCalculatorDifferentialTest.php b/tests/Unit/NativeCalculatorDifferentialTest.php new file mode 100644 index 0000000..2ba0193 --- /dev/null +++ b/tests/Unit/NativeCalculatorDifferentialTest.php @@ -0,0 +1,257 @@ +add(left: $left, right: $right); + $actual[sprintf($template, 'add', $left, $right)] = $native->add(left: $left, right: $right); + + $expected[sprintf($template, 'subtract', $left, $right)] = + $extension->subtract(minuend: $left, subtrahend: $right); + $actual[sprintf($template, 'subtract', $left, $right)] = + $native->subtract(minuend: $left, subtrahend: $right); + + $expected[sprintf($template, 'multiply', $left, $right)] = + $extension->multiply(left: $left, right: $right); + $actual[sprintf($template, 'multiply', $left, $right)] = + $native->multiply(left: $left, right: $right); + + $expected[sprintf($template, 'compare', $left, $right)] = + $extension->compare(left: $left, right: $right); + $actual[sprintf($template, 'compare', $left, $right)] = + $native->compare(left: $left, right: $right); + } + } + + /** @Then the pure PHP backend reproduces libbcmath digit for digit */ + self::assertSame($expected, $actual); + } + + public function testEveryRootThenAgreesWithTheExtension(): void + { + /** @Given the pure PHP backend */ + $native = new NativeCalculator(); + + /** @And libbcmath as the reference implementation */ + $extension = new BcMathCalculator(); + + /** @When the truncated integer root runs over every non-negative radicand */ + $expected = []; + $actual = []; + $radicands = array_filter( + self::OPERANDS, + static fn(string $value): bool => !str_starts_with($value, '-') || ltrim($value, '-') === '0' + ); + + foreach ($radicands as $radicand) { + $expected[$radicand] = $extension->squareRoot(radicand: $radicand); + $actual[$radicand] = $native->squareRoot(radicand: $radicand); + } + + /** @Then every root lands on the digit libbcmath reports */ + self::assertSame($expected, $actual); + } + + public function testEveryPowerThenAgreesWithTheExtension(): void + { + /** @Given the pure PHP backend */ + $native = new NativeCalculator(); + + /** @And libbcmath as the reference implementation */ + $extension = new BcMathCalculator(); + + /** @When every base is raised to every exponent in the corpus */ + $expected = []; + $actual = []; + + foreach (self::OPERANDS as $base) { + foreach (self::EXPONENTS as $exponent) { + $template = '%s^%d'; + + $expected[sprintf($template, $base, $exponent)] = + $extension->power(base: $base, exponent: $exponent); + $actual[sprintf($template, $base, $exponent)] = + $native->power(base: $base, exponent: $exponent); + } + } + + /** @Then squaring and multiplying reproduce libbcmath exactly */ + self::assertSame($expected, $actual); + } + + public function testEveryDivisionThenAgreesWithTheExtension(): void + { + /** @Given the pure PHP backend */ + $native = new NativeCalculator(); + + /** @And libbcmath as the reference implementation */ + $extension = new BcMathCalculator(); + + /** @When the truncating division runs over every ordered pair with a non-zero divisor */ + $expected = []; + $actual = []; + $divisors = array_filter(self::OPERANDS, static fn(string $value): bool => ltrim($value, '-') !== '0'); + + foreach (self::OPERANDS as $numerator) { + foreach ($divisors as $denominator) { + $template = '%s(%s, %s)'; + + $expected[sprintf($template, 'quotient', $numerator, $denominator)] = + $extension->quotient(numerator: $numerator, denominator: $denominator); + $actual[sprintf($template, 'quotient', $numerator, $denominator)] = + $native->quotient(numerator: $numerator, denominator: $denominator); + + $expected[sprintf($template, 'remainder', $numerator, $denominator)] = + $extension->remainder(numerator: $numerator, denominator: $denominator); + $actual[sprintf($template, 'remainder', $numerator, $denominator)] = + $native->remainder(numerator: $numerator, denominator: $denominator); + } + } + + /** @Then quotient and remainder match libbcmath, signs included */ + self::assertSame($expected, $actual); + } + + #[DataProvider('backendProvider')] + public function testQuotientWhenDivisorIsZeroThenBothRefuse(Calculator $calculator): void + { + /** @Given a backend and a divisor the contract forbids */ + + /** @Then a failure naming the dividend is raised */ + $this->expectException(DivisionByZero::class); + $this->expectExceptionMessage('Cannot divide <7> by zero.'); + + /** @When the truncating division runs */ + $calculator->quotient(numerator: '7', denominator: '0'); + } + + #[DataProvider('backendProvider')] + public function testRemainderWhenDivisorIsZeroThenBothRefuse(Calculator $calculator): void + { + /** @Given a backend and a divisor the contract forbids */ + + /** @Then a failure naming the dividend is raised */ + $this->expectException(DivisionByZero::class); + $this->expectExceptionMessage('Cannot divide <7> by zero.'); + + /** @When the remainder runs */ + $calculator->remainder(numerator: '7', denominator: '0'); + } + + #[DataProvider('backendProvider')] + public function testPowerWhenExponentIsNegativeThenBothRefuse(Calculator $calculator): void + { + /** @Given a backend and an exponent the contract forbids */ + + /** @Then a failure naming the exponent is raised */ + $this->expectException(NegativeExponent::class); + + /** @When the power runs */ + $calculator->power(base: '1', exponent: -1); + } + + #[DataProvider('backendProvider')] + public function testSquareRootWhenRadicandIsNegativeThenBothRefuse(Calculator $calculator): void + { + /** @Given a backend and a radicand the contract forbids */ + + /** @Then a failure naming the radicand is raised */ + $this->expectException(NegativeRoot::class); + $this->expectExceptionMessage('Cannot take the square root of the negative value <-4>.'); + + /** @When the root runs */ + $calculator->squareRoot(radicand: '-4'); + } + + #[DataProvider('hardDivisionProvider')] + public function testDivisionWhenTheDigitEstimateOvershootsThenAgreesWithTheExtension( + string $numerator, + string $denominator + ): void { + /** @Given a pair whose quotient digit needs the full correction the estimate allows */ + + /** @When the truncating division runs on the pure PHP backend */ + $actual = new NativeCalculator()->quotient(numerator: $numerator, denominator: $denominator); + + /** @Then it still lands on the digits libbcmath reports */ + self::assertSame(new BcMathCalculator()->quotient(numerator: $numerator, denominator: $denominator), $actual); + } + + public static function backendProvider(): array + { + return [ + 'Extension' => ['calculator' => new BcMathCalculator()], + 'Pure PHP' => ['calculator' => new NativeCalculator()] + ]; + } + + public static function hardDivisionProvider(): array + { + return [ + 'Two limb divisor' => [ + 'numerator' => '459714127023879285092811644', + 'denominator' => '501933633760608253' + ], + 'Three limb divisor' => [ + 'numerator' => '504742242870603765137420602077423734', + 'denominator' => '589524224922116716183555725' + ], + 'Narrow overshoot' => [ + 'numerator' => '595293886058520101586123288', + 'denominator' => '695280179905058033' + ], + 'Wide overshoot' => [ + 'numerator' => '528914152814300517288849623674805144', + 'denominator' => '569339172710255931955000324' + ] + ]; + } +} diff --git a/tests/Unit/NativeCalculatorTest.php b/tests/Unit/NativeCalculatorTest.php new file mode 100644 index 0000000..cec043d --- /dev/null +++ b/tests/Unit/NativeCalculatorTest.php @@ -0,0 +1,355 @@ +squareRoot(); + + /** @Then the digit-by-digit pair extraction settles on the floor of the exact root */ + self::assertSame($expected, $actual->toString()); + } + + #[DataProvider('additionProvider')] + public function testAdditionThenMatchesTheDocumentedResult(string $left, string $right, string $expected): void + { + /** @Given two integers and the exact sum */ + + /** @When they are added through the pure PHP backend */ + $actual = BigInteger::of(value: $left)->plus(addend: BigInteger::of(value: $right)); + + /** @Then the sum crosses every limb boundary intact */ + self::assertSame($expected, $actual->toString()); + } + + #[DataProvider('powerProvider')] + public function testPowerThenMatchesRepeatedMultiplication(string $base, int $exponent, string $expected): void + { + /** @Given a base, an exponent, and the exact power */ + + /** @When it is raised through the pure PHP backend */ + $actual = BigInteger::of(value: $base)->power(exponent: $exponent); + + /** @Then squaring and multiplying agree with repeated multiplication */ + self::assertSame($expected, $actual->toString()); + } + + #[DataProvider('comparisonProvider')] + public function testComparisonThenOrdersAcrossTheSignBoundary(string $left, string $right, int $expected): void + { + /** @Given two integers and the expected ordering */ + + /** @When they are compared through the pure PHP backend */ + $actual = BigInteger::of(value: $left)->compareTo(other: BigInteger::of(value: $right)); + + /** @Then the ordering holds across signs and limb counts */ + self::assertSame($expected, $actual); + } + + #[DataProvider('subtractionProvider')] + public function testSubtractionThenMatchesTheDocumentedResult(string $left, string $right, string $expected): void + { + /** @Given two integers and the exact difference */ + + /** @When one is subtracted from the other through the pure PHP backend */ + $actual = BigInteger::of(value: $left)->minus(subtrahend: BigInteger::of(value: $right)); + + /** @Then every borrow propagates across the limbs */ + self::assertSame($expected, $actual->toString()); + } + + public function testDecimalArithmeticThenStaysExactWithoutBcMath(): void + { + /** @Given a price beyond the float mantissa */ + $price = BigDecimal::of(value: '19.99'); + + /** @When it is scaled and rounded through the pure PHP backend */ + $actual = $price->multipliedBy(multiplier: BigDecimal::of(value: '0.875')); + + /** @Then the decimal path produces the same exact result the extension would */ + self::assertSame('17.49125', $actual->toString()); + self::assertSame('17.49', $actual->toScale(scale: 2, rounding: RoundingMode::HalfEven)->toString()); + } + + #[DataProvider('productProvider')] + public function testMultiplicationThenMatchesTheDocumentedResult( + string $left, + string $right, + string $expected + ): void { + /** @Given two integers and the exact product */ + + /** @When they are multiplied through the pure PHP backend */ + $actual = BigInteger::of(value: $left)->multipliedBy(multiplier: BigInteger::of(value: $right)); + + /** @Then every carry lands in the right limb */ + self::assertSame($expected, $actual->toString()); + } + + #[DataProvider('divisionProvider')] + public function testDivisionThenFollowsTheDocumentedSignConventions( + string $numerator, + string $denominator, + string $quotient, + string $remainder + ): void { + /** @Given a dividend, a divisor, and both documented results */ + + /** @When the integer division runs through the pure PHP backend */ + $actual = BigInteger::of(value: $numerator); + + /** @Then the quotient truncates toward zero and the remainder follows the dividend */ + self::assertSame($quotient, $actual->quotient(divisor: BigInteger::of(value: $denominator))->toString()); + self::assertSame($remainder, $actual->remainder(divisor: BigInteger::of(value: $denominator))->toString()); + } + + public function testIsAvailableThenReportsWhetherIntegersAreWideEnough(): void + { + /** @Given the pure PHP backend */ + $calculator = new NativeCalculator(); + + /** @When it is asked whether it can run */ + $actual = $calculator->isAvailable(); + + /** @Then it runs here, because this platform carries 64-bit integers */ + self::assertTrue($actual); + self::assertSame(8, PHP_INT_SIZE); + } + + #[RequiresPhpExtension('bcmath')] + public function testEveryCollaboratorThenProducesTheSameResultAsTheExtension(): void + { + /** @Given a workload that reaches rounding, fractions, bases, allocation and roots */ + $workload = static fn(): array => [ + 'divided' => BigDecimal::of(value: '123456789012345.6789') + ->dividedBy(divisor: BigDecimal::of(value: '9876543.21')) + ->toDecimal(scale: 12, rounding: RoundingMode::HalfEven) + ->toString(), + 'reduced' => BigRational::of(value: '123456789012345678/98765432109876')->toString(), + 'commonRoot' => BigInteger::of(value: '123456789012345678') + ->greatestCommonDivisor(other: BigInteger::of(value: '98765432109876')) + ->toString(), + 'base' => BigInteger::of(value: '123456789012345678901234567890')->toBase(base: 36), + 'allocated' => implode(',', array_map( + static fn(BigDecimal $part): string => $part->toString(), + BigDecimal::of(value: '123456789012345.67') + ->allocate(scale: 2, weights: BigDecimals::of('1', '2', '7')) + ->all() + )), + 'root' => BigDecimal::of(value: '123456789012345.6789') + ->squareRoot(scale: 10, rounding: RoundingMode::HalfEven) + ->toString() + ]; + + /** @When it runs once on the extension and once on the pure PHP backend */ + Calculators::register(calculator: new BcMathCalculator()); + $expected = $workload(); + + Calculators::register(calculator: new NativeCalculator()); + $actual = $workload(); + + /** @Then every collaborator that routes through the backend produces the same digits */ + self::assertSame($expected, $actual); + } + + public function testResolutionWhenNoCandidateCanRunThenCalculatorNotAvailable(): void + { + /** @Given a candidate list where nothing can run */ + $unavailable = new UnavailableCalculatorMock(); + + /** @Then a failure describing the empty resolution is raised */ + $this->expectException(CalculatorNotAvailable::class); + + /** @When the selection resolves over it */ + new CalculatorSelection()->resolved($unavailable); + } + + public function testResolutionWhenBcMathIsMissingThenFallsBackToThePurePhpBackend(): void + { + /** @Given a candidate list whose first backend cannot run in this process */ + $unavailable = new UnavailableCalculatorMock(); + + /** @When the selection resolves over it and the pure PHP backend */ + $actual = new CalculatorSelection()->resolved($unavailable, new NativeCalculator()); + + /** @Then the pure PHP backend is taken, so the extension is not required */ + self::assertInstanceOf(NativeCalculator::class, $actual); + } + + public static function rootProvider(): array + { + return [ + 'Zero' => ['radicand' => '0', 'expected' => '0'], + 'One' => ['radicand' => '1', 'expected' => '1'], + 'Below a square' => ['radicand' => '8', 'expected' => '2'], + 'Exact square' => ['radicand' => '9', 'expected' => '3'], + 'Single limb' => ['radicand' => '999999999', 'expected' => '31622'], + 'Across limbs' => ['radicand' => '12345678901234567890123456789', 'expected' => '111111110611111'] + ]; + } + + public static function powerProvider(): array + { + return [ + 'Zero exponent' => ['base' => '7', 'exponent' => 0, 'expected' => '1'], + 'One exponent' => ['base' => '-7', 'exponent' => 1, 'expected' => '-7'], + 'Even exponent' => ['base' => '-3', 'exponent' => 4, 'expected' => '81'], + 'Odd exponent' => ['base' => '-3', 'exponent' => 5, 'expected' => '-243'], + 'Across limbs' => [ + 'base' => '1000000000', + 'exponent' => 3, + 'expected' => '1000000000000000000000000000' + ], + 'Zero base' => ['base' => '0', 'exponent' => 5, 'expected' => '0'] + ]; + } + + public static function productProvider(): array + { + return [ + 'By zero' => ['left' => '12345678901234567890', 'right' => '0', 'expected' => '0'], + 'Zero by value' => ['left' => '0', 'right' => '12345678901234567890', 'expected' => '0'], + 'Sign flips' => ['left' => '-7', 'right' => '6', 'expected' => '-42'], + 'Both negative' => ['left' => '-7', 'right' => '-6', 'expected' => '42'], + 'Limb boundary' => ['left' => '1000000000', 'right' => '1000000000', 'expected' => '1000000000000000000'], + 'Full carry' => [ + 'left' => '99999999999999999999', + 'right' => '99999999999999999999', + 'expected' => '9999999999999999999800000000000000000001' + ] + ]; + } + + public static function additionProvider(): array + { + return [ + 'Zero and zero' => ['left' => '0', 'right' => '0', 'expected' => '0'], + 'Carry across limbs' => ['left' => '999999999', 'right' => '1', 'expected' => '1000000000'], + 'Opposite signs' => ['left' => '1000000000', 'right' => '-1', 'expected' => '999999999'], + 'Both negative' => ['left' => '-999999999', 'right' => '-1', 'expected' => '-1000000000'], + 'Cancelling' => [ + 'left' => '-12345678901234567890', + 'right' => '12345678901234567890', + 'expected' => '0' + ], + 'Many limbs' => [ + 'left' => '999999999999999999999999999', + 'right' => '1', + 'expected' => '1000000000000000000000000000' + ] + ]; + } + + public static function divisionProvider(): array + { + return [ + 'Exact' => [ + 'numerator' => '100', + 'denominator' => '5', + 'quotient' => '20', + 'remainder' => '0' + ], + 'Truncates toward zero' => [ + 'numerator' => '7', + 'denominator' => '2', + 'quotient' => '3', + 'remainder' => '1' + ], + 'Negative dividend' => [ + 'numerator' => '-7', + 'denominator' => '2', + 'quotient' => '-3', + 'remainder' => '-1' + ], + 'Negative divisor' => [ + 'numerator' => '7', + 'denominator' => '-2', + 'quotient' => '-3', + 'remainder' => '1' + ], + 'Both negative' => [ + 'numerator' => '-7', + 'denominator' => '-2', + 'quotient' => '3', + 'remainder' => '-1' + ], + 'Divisor is larger' => [ + 'numerator' => '5', + 'denominator' => '1000000000000', + 'quotient' => '0', + 'remainder' => '5' + ], + 'Across limbs' => [ + 'numerator' => '12345678901234567890123456789', + 'denominator' => '987654321', + 'quotient' => '12499999887343749990', + 'remainder' => '156249999' + ] + ]; + } + + public static function comparisonProvider(): array + { + return [ + 'Equal' => ['left' => '0', 'right' => '0', 'expected' => 0], + 'Equal across limbs' => ['left' => '1000000000', 'right' => '1000000000', 'expected' => 0], + 'Negative to zero' => ['left' => '-1', 'right' => '0', 'expected' => -1], + 'Zero to negative' => ['left' => '0', 'right' => '-1', 'expected' => 1], + 'Both negative' => ['left' => '-2', 'right' => '-1', 'expected' => -1], + 'Limb count decides' => ['left' => '1000000000', 'right' => '999999999', 'expected' => 1], + 'Deep limb decides' => [ + 'left' => '1000000000000000000000000001', + 'right' => '1000000000000000000000000000', + 'expected' => 1 + ] + ]; + } + + public static function subtractionProvider(): array + { + return [ + 'Borrow across limbs' => ['left' => '1000000000', 'right' => '1', 'expected' => '999999999'], + 'To zero' => ['left' => '42', 'right' => '42', 'expected' => '0'], + 'Below zero' => ['left' => '1', 'right' => '1000000000', 'expected' => '-999999999'], + 'Negative minuend' => ['left' => '-1000000000', 'right' => '1', 'expected' => '-1000000001'], + 'Double negative' => ['left' => '-1000000000', 'right' => '-1', 'expected' => '-999999999'], + 'Many limbs' => [ + 'left' => '1000000000000000000000000000', + 'right' => '1', + 'expected' => '999999999999999999999999999' + ] + ]; + } +} diff --git a/tests/Unit/PercentageTest.php b/tests/Unit/PercentageTest.php new file mode 100644 index 0000000..2625349 --- /dev/null +++ b/tests/Unit/PercentageTest.php @@ -0,0 +1,268 @@ +toString()); + } + + #[DataProvider('applicationProvider')] + public function testApplyToThenStaysExact( + string $value, + string $amount, + string $applied, + string $increased, + string $decreased + ): void { + /** @Given a percentage, an amount, and the three expected exact results */ + + /** @When the percentage is applied to the amount */ + $actual = Percentage::of(value: $value); + + /** @Then every result is exact, with no rounding decision forced on the caller */ + self::assertSame($applied, $actual->applyTo(amount: BigDecimal::of(value: $amount))->toString()); + self::assertSame($increased, $actual->increase(amount: BigDecimal::of(value: $amount))->toString()); + self::assertSame($decreased, $actual->decrease(amount: BigDecimal::of(value: $amount))->toString()); + } + + public function testToRatioThenReducesToLowestTerms(): void + { + /** @Given a quarter expressed as a percentage */ + $percentage = Percentage::of(value: '25'); + + /** @When it is converted to a ratio */ + $actual = $percentage->toRatio(); + + /** @Then the ratio is in lowest terms */ + self::assertSame('1:4', $actual->toString()); + } + + #[DataProvider('signProvider')] + public function testSignPredicatesThenAgreeOnTheSign( + string $value, + bool $isZero, + bool $isNegative, + bool $isPositive + ): void { + /** @Given a percentage literal and its expected sign */ + + /** @When the sign predicates are asked */ + $actual = Percentage::of(value: $value); + + /** @Then all three agree, and a rate above one hundred stays legal */ + self::assertSame($isZero, $actual->isZero()); + self::assertSame($isNegative, $actual->isNegative()); + self::assertSame($isPositive, $actual->isPositive()); + } + + #[DataProvider('rateProvider')] + public function testRateThenDividesTheValueByOneHundred(string|int $value, string $expected): void + { + /** @Given a percentage literal and the factor it multiplies by */ + + /** @When its rate is taken */ + $actual = Percentage::of(value: $value)->rate(); + + /** @Then the factor carries two more fractional digits */ + self::assertSame($expected, $actual->toString()); + } + + #[DataProvider('comparisonProvider')] + public function testComparisonPredicatesThenIgnoreTheScale(string $value, string $other, int $expected): void + { + /** @Given a percentage literal */ + $rate = Percentage::of(value: $value); + + /** @And another to compare it against */ + $against = Percentage::of(value: $other); + + /** @When they are compared for equality */ + $actual = $rate->isEqualTo(other: $against); + + /** @Then every predicate agrees, and the scale plays no part */ + self::assertSame($expected === 0, $actual); + self::assertSame($expected < 0, $rate->isLessThan(other: $against)); + self::assertSame($expected > 0, $rate->isGreaterThan(other: $against)); + self::assertSame($expected <= 0, $rate->isLessThanOrEqualTo(other: $against)); + self::assertSame($expected >= 0, $rate->isGreaterThanOrEqualTo(other: $against)); + } + + public function testEqualsWhenScalesDifferThenTheyAreNotEqual(): void + { + /** @Given a percentage at scale zero */ + $percentage = Percentage::of(value: '10'); + + /** @And the same rate at scale one */ + $other = Percentage::of(value: '10.0'); + + /** @When they are compared structurally */ + $actual = $percentage->equals(other: $other); + + /** @Then they are not equal, because the scale is part of the representation */ + self::assertFalse($actual); + } + + public function testHashCodeWhenPercentagesMatchThenHashesMatch(): void + { + /** @Given a percentage */ + $percentage = Percentage::of(value: '12.5'); + + /** @And another percentage holding the same rate and scale */ + $other = Percentage::of(value: '12.5'); + + /** @When both hashes are taken */ + $actual = $percentage->hashCode(); + + /** @Then they agree */ + self::assertSame($other->hashCode(), $actual); + } + + public function testEqualsWhenValueAndScaleMatchThenTheyAreEqual(): void + { + /** @Given a percentage */ + $percentage = Percentage::of(value: '12.5'); + + /** @And another percentage holding the same value and scale */ + $other = Percentage::of(value: '12.5'); + + /** @When they are compared structurally */ + $actual = $percentage->equals(other: $other); + + /** @Then they are equal */ + self::assertTrue($actual); + } + + public function testFromRatioThenExpressesTheProportionPerHundred(): void + { + /** @Given a proportion of one to four */ + $ratio = Ratio::of(antecedent: 1, consequent: 4); + + /** @When it is expressed as a percentage */ + $actual = Percentage::fromRatio(ratio: $ratio, scale: 0, rounding: RoundingMode::HalfEven); + + /** @Then it reads as twenty-five percent */ + self::assertSame('25%', $actual->toString()); + } + + public function testJsonSerializeThenEmitsAJsonStringThatReadsBack(): void + { + /** @Given a percentage */ + $percentage = Percentage::of(value: '12.5'); + + /** @When it is encoded */ + $actual = json_encode($percentage); + + /** @Then it is a JSON string carrying the percent sign, and it round-trips */ + self::assertSame('"12.5%"', $actual); + self::assertTrue(Percentage::of(value: json_decode((string)$actual))->equals(other: $percentage)); + } + + public function testOfWhenLiteralIsMalformedThenNumberNotWellFormed(): void + { + /** @Given a literal that is not a number */ + $value = 'twelve'; + + /** @Then a failure describing the malformed literal is raised */ + $this->expectException(NumberNotWellFormed::class); + + /** @When a percentage is created from it */ + Percentage::of(value: $value); + } + + public function testFromRatioWhenProportionRepeatsThenRoundsAtTheScale(): void + { + /** @Given a proportion whose percentage repeats forever */ + $ratio = Ratio::of(antecedent: 1, consequent: 3); + + /** @When it is expressed at scale two */ + $actual = Percentage::fromRatio(ratio: $ratio, scale: 2, rounding: RoundingMode::HalfEven); + + /** @Then it is rounded at that scale */ + self::assertSame('33.33%', $actual->toString()); + } + + public static function rateProvider(): array + { + return [ + 'Whole' => ['value' => '10', 'expected' => '0.10'], + 'Fractional' => ['value' => '12.5', 'expected' => '0.125'], + 'Native integer' => ['value' => 25, 'expected' => '0.25'], + 'Hundred' => ['value' => '100', 'expected' => '1.00'], + 'Above hundred' => ['value' => '150', 'expected' => '1.50'], + 'Negative' => ['value' => '-5', 'expected' => '-0.05'], + 'Zero' => ['value' => '0', 'expected' => '0.00'] + ]; + } + + public static function signProvider(): array + { + return [ + 'Zero' => ['value' => '0', 'isZero' => true, 'isNegative' => false, 'isPositive' => false], + 'Negative' => ['value' => '-5', 'isZero' => false, 'isNegative' => true, 'isPositive' => false], + 'Positive' => ['value' => '5', 'isZero' => false, 'isNegative' => false, 'isPositive' => true], + 'Above hundred' => ['value' => '150', 'isZero' => false, 'isNegative' => false, 'isPositive' => true] + ]; + } + + public static function comparisonProvider(): array + { + return [ + 'Less than' => ['value' => '10', 'other' => '12.5', 'expected' => -1], + 'Equal' => ['value' => '10', 'other' => '10.00', 'expected' => 0], + 'Greater than' => ['value' => '12.5', 'other' => '10', 'expected' => 1] + ]; + } + + public static function applicationProvider(): array + { + return [ + 'Discount' => [ + 'value' => '12.5', + 'amount' => '19.99', + 'applied' => '2.49875', + 'increased' => '22.48875', + 'decreased' => '17.49125' + ], + 'Whole rate' => [ + 'value' => '10', + 'amount' => '100.00', + 'applied' => '10.0000', + 'increased' => '110.0000', + 'decreased' => '90.0000' + ], + 'Negative rate' => [ + 'value' => '-10', + 'amount' => '100.00', + 'applied' => '-10.0000', + 'increased' => '90.0000', + 'decreased' => '110.0000' + ], + 'Zero rate' => [ + 'value' => '0', + 'amount' => '100.00', + 'applied' => '0.0000', + 'increased' => '100.0000', + 'decreased' => '100.0000' + ] + ]; + } +} diff --git a/tests/Unit/RatioTest.php b/tests/Unit/RatioTest.php new file mode 100644 index 0000000..3b99542 --- /dev/null +++ b/tests/Unit/RatioTest.php @@ -0,0 +1,279 @@ +applyTo(amount: BigDecimal::of(value: $amount)); + + /** @Then the exact fraction is returned, with no rounding decision forced */ + self::assertSame($expected, $actual->toString()); + } + + public function testInvertedThenSwapsTheTerms(): void + { + /** @Given a ratio */ + $ratio = Ratio::of(antecedent: 16, consequent: 9); + + /** @When it is inverted */ + $actual = $ratio->inverted(); + + /** @Then the terms are swapped */ + self::assertSame('9:16', $actual->toString()); + } + + #[DataProvider('termProvider')] + public function testOfThenReducesToLowestTerms( + int|string $antecedent, + int|string $consequent, + string $expected, + string $first, + string $second + ): void { + /** @Given two terms with the reduced ratio they stand for */ + + /** @When a ratio is created */ + $actual = Ratio::of(antecedent: $antecedent, consequent: $consequent); + + /** @Then the ratio is reduced and both terms read back */ + self::assertSame($expected, $actual->toString()); + self::assertSame($first, $actual->antecedent()->toString()); + self::assertSame($second, $actual->consequent()->toString()); + } + + public function testBetweenThenStaysExactWithoutAScale(): void + { + /** @Given a quantity */ + $antecedent = BigDecimal::of(value: '1.5'); + + /** @And a second quantity three times as large */ + $consequent = BigDecimal::of(value: '4.5'); + + /** @When the proportion between them is taken */ + $actual = Ratio::between(antecedent: $antecedent, consequent: $consequent); + + /** @Then the proportion is exact and reduced, with no rounding involved */ + self::assertSame('1:3', $actual->toString()); + } + + public function testToBigRationalThenExposesTheProportion(): void + { + /** @Given a ratio */ + $ratio = Ratio::of(antecedent: 16, consequent: 9); + + /** @When its proportion is taken */ + $actual = $ratio->toBigRational(); + + /** @Then it reads as the equivalent fraction */ + self::assertSame('16/9', $actual->toString()); + } + + public function testOfWhenConsequentIsZeroThenDivisionByZero(): void + { + /** @Given a consequent of zero */ + $consequent = 0; + + /** @Then a failure describing the zero denominator is raised */ + $this->expectException(DivisionByZero::class); + + /** @When a ratio is created */ + Ratio::of(antecedent: 1, consequent: $consequent); + } + + #[DataProvider('percentageProvider')] + public function testToPercentageThenRoundsAtTheRequestedScale( + int $antecedent, + int $consequent, + int $scale, + string $expected + ): void { + /** @Given a ratio, a target scale, and the expected percentage */ + + /** @When it is expressed per hundred */ + $actual = Ratio::of(antecedent: $antecedent, consequent: $consequent); + + /** @Then the percentage is rounded at that scale */ + self::assertSame( + $expected, + $actual->toPercentage(scale: $scale, rounding: RoundingMode::HalfEven)->toString() + ); + } + + public function testEqualsWhenProportionsMatchThenTheyAreEqual(): void + { + /** @Given a ratio */ + $ratio = Ratio::of(antecedent: 16, consequent: 9); + + /** @And the same proportion written unreduced */ + $other = Ratio::of(antecedent: 32, consequent: 18); + + /** @When they are compared structurally */ + $actual = $ratio->equals(other: $other); + + /** @Then they are equal, because a ratio is always stored in lowest terms */ + self::assertTrue($actual); + } + + public function testHashCodeWhenProportionsMatchThenHashesMatch(): void + { + /** @Given a ratio */ + $ratio = Ratio::of(antecedent: 16, consequent: 9); + + /** @And the same proportion written unreduced */ + $other = Ratio::of(antecedent: 32, consequent: 18); + + /** @When both hashes are taken */ + $actual = $ratio->hashCode(); + + /** @Then they agree */ + self::assertSame($other->hashCode(), $actual); + } + + public function testBetweenWhenConsequentIsZeroThenDivisionByZero(): void + { + /** @Given a quantity */ + $antecedent = BigInteger::of(value: 1); + + /** @Then a failure describing the zero divisor is raised */ + $this->expectException(DivisionByZero::class); + + /** @When the proportion against zero is taken */ + Ratio::between(antecedent: $antecedent, consequent: BigInteger::zero()); + } + + public function testInvertedWhenAntecedentIsZeroThenDivisionByZero(): void + { + /** @Given a ratio whose antecedent is zero */ + $ratio = Ratio::of(antecedent: 0, consequent: 5); + + /** @Then a failure describing the undefined reciprocal is raised */ + $this->expectException(DivisionByZero::class); + + /** @When it is inverted */ + $ratio->inverted(); + } + + public function testJsonSerializeThenEmitsAJsonStringThatReadsBack(): void + { + /** @Given a ratio */ + $ratio = Ratio::of(antecedent: 16, consequent: 9); + + /** @When it is encoded */ + $actual = json_encode($ratio); + + /** @Then it is a JSON string carrying the colon, and it round-trips */ + self::assertSame('"16:9"', $actual); + self::assertTrue(Ratio::from(value: json_decode((string)$actual))->equals(other: $ratio)); + } + + public function testOfWhenTermCarriesAFractionalPartThenInexactConversion(): void + { + /** @Given a term with a non-zero fractional part */ + $antecedent = '1.5'; + + /** @Then a failure describing the lost fractional part is raised */ + $this->expectException(InexactConversion::class); + + /** @When a ratio is created */ + Ratio::of(antecedent: $antecedent, consequent: 2); + } + + public function testFromWhenTheStringDoesNotCarryTwoTermsThenNumberNotWellFormed(): void + { + /** @Given a string carrying three terms */ + $value = '16:9:4'; + + /** @Then a failure describing the malformed literal is raised */ + $this->expectException(NumberNotWellFormed::class); + + /** @When a ratio is created from it */ + Ratio::from(value: $value); + } + + public static function termProvider(): array + { + return [ + 'Already reduced' => [ + 'antecedent' => 16, + 'consequent' => 9, + 'expected' => '16:9', + 'first' => '16', + 'second' => '9' + ], + 'Reducible' => [ + 'antecedent' => 4, + 'consequent' => 2, + 'expected' => '2:1', + 'first' => '2', + 'second' => '1' + ], + 'Negative antecedent' => [ + 'antecedent' => -1, + 'consequent' => 2, + 'expected' => '-1:2', + 'first' => '-1', + 'second' => '2' + ], + 'Negative consequent' => [ + 'antecedent' => 1, + 'consequent' => -2, + 'expected' => '-1:2', + 'first' => '-1', + 'second' => '2' + ], + 'Zero antecedent' => [ + 'antecedent' => 0, + 'consequent' => 5, + 'expected' => '0:1', + 'first' => '0', + 'second' => '1' + ], + 'String terms' => [ + 'antecedent' => '1000000000000000000000', + 'consequent' => '2000000000000000000000', + 'expected' => '1:2', + 'first' => '1', + 'second' => '2' + ] + ]; + } + + public static function percentageProvider(): array + { + return [ + 'Quarter' => ['antecedent' => 1, 'consequent' => 4, 'scale' => 0, 'expected' => '25%'], + 'Third' => ['antecedent' => 1, 'consequent' => 3, 'scale' => 2, 'expected' => '33.33%'], + 'Whole' => ['antecedent' => 1, 'consequent' => 1, 'scale' => 0, 'expected' => '100%'], + 'Above one' => ['antecedent' => 3, 'consequent' => 2, 'scale' => 1, 'expected' => '150.0%'] + ]; + } + + public static function applicationProvider(): array + { + return [ + 'Exact third' => ['antecedent' => 1, 'consequent' => 3, 'amount' => '90.00', 'expected' => '30'], + 'Repeating' => ['antecedent' => 1, 'consequent' => 3, 'amount' => '100.00', 'expected' => '100/3'], + 'Scaling up' => ['antecedent' => 16, 'consequent' => 9, 'amount' => '9.00', 'expected' => '16'], + 'Negative term' => ['antecedent' => -1, 'consequent' => 2, 'amount' => '10.00', 'expected' => '-5'] + ]; + } +} diff --git a/tests/Unit/RoundingModeTest.php b/tests/Unit/RoundingModeTest.php new file mode 100644 index 0000000..16ebf2f --- /dev/null +++ b/tests/Unit/RoundingModeTest.php @@ -0,0 +1,84 @@ +toNativeRoundingMode(); + + /** @Then the native case is returned */ + self::assertSame($expected, $actual); + } + + #[DataProvider('nativeEquivalentProvider')] + public function testFromNativeRoundingModeThenMatchesTheLibraryCase( + RoundingMode $mode, + NativeRoundingMode $expected + ): void { + /** @Given a library rounding mode and the native case it stands for */ + + /** @When the library equivalent of the native case is requested */ + $actual = RoundingMode::fromNativeRoundingMode(mode: $expected); + + /** @Then the library case is returned */ + self::assertSame($mode, $actual); + } + + #[DataProvider('halfwayValueProvider')] + public function testRoundingWhenTheDiscardedPartIsExactlyHalfThenEachModeDiffers( + RoundingMode $mode, + string $expected + ): void { + /** @Given a value whose discarded part at scale two is exactly half */ + + /** @When it is taken to scale two under the mode */ + $actual = BigDecimal::of(value: '-2.345')->toScale(scale: 2, rounding: $mode); + + /** @Then the mode decides the last digit */ + self::assertSame($expected, $actual->toString()); + } + + public static function halfwayValueProvider(): array + { + return [ + 'Away from zero' => ['mode' => RoundingMode::Up, 'expected' => '-2.35'], + 'Toward zero' => ['mode' => RoundingMode::Down, 'expected' => '-2.34'], + 'Toward negative' => ['mode' => RoundingMode::Floor, 'expected' => '-2.35'], + 'Half away from zero' => ['mode' => RoundingMode::HalfUp, 'expected' => '-2.35'], + 'Toward positive' => ['mode' => RoundingMode::Ceiling, 'expected' => '-2.34'], + 'Half to odd' => ['mode' => RoundingMode::HalfOdd, 'expected' => '-2.35'], + 'Half toward zero' => ['mode' => RoundingMode::HalfDown, 'expected' => '-2.34'], + 'Half to even' => ['mode' => RoundingMode::HalfEven, 'expected' => '-2.34'] + ]; + } + + public static function nativeEquivalentProvider(): array + { + return [ + 'Up' => ['mode' => RoundingMode::Up, 'expected' => NativeRoundingMode::AwayFromZero], + 'Down' => ['mode' => RoundingMode::Down, 'expected' => NativeRoundingMode::TowardsZero], + 'Floor' => ['mode' => RoundingMode::Floor, 'expected' => NativeRoundingMode::NegativeInfinity], + 'HalfUp' => ['mode' => RoundingMode::HalfUp, 'expected' => NativeRoundingMode::HalfAwayFromZero], + 'Ceiling' => ['mode' => RoundingMode::Ceiling, 'expected' => NativeRoundingMode::PositiveInfinity], + 'HalfOdd' => ['mode' => RoundingMode::HalfOdd, 'expected' => NativeRoundingMode::HalfOdd], + 'HalfDown' => ['mode' => RoundingMode::HalfDown, 'expected' => NativeRoundingMode::HalfTowardsZero], + 'HalfEven' => ['mode' => RoundingMode::HalfEven, 'expected' => NativeRoundingMode::HalfEven] + ]; + } +} diff --git a/tests/Unit/UnavailableCalculatorMock.php b/tests/Unit/UnavailableCalculatorMock.php new file mode 100644 index 0000000..a75d1cb --- /dev/null +++ b/tests/Unit/UnavailableCalculatorMock.php @@ -0,0 +1,55 @@ +