Skip to content

Fix allocation remainders for large amounts - #832

Open
kadyrbekovhamit-cyber wants to merge 3 commits into
moneyphp:masterfrom
kadyrbekovhamit-cyber:codex/fix-large-allocation-remainders
Open

kadyrbekovhamit-cyber wants to merge 3 commits into
moneyphp:masterfrom
kadyrbekovhamit-cyber:codex/fix-large-allocation-remainders

Conversation

@kadyrbekovhamit-cyber

Copy link
Copy Markdown

Summary

Avoid converting allocation shares to PHP float when ranking largest remainders.

For example:

(new Money('7000000000000000', new Currency('USD')))->allocate([1, 2]);

Before this change the result is:

[2333333333333334, 4666666666666666]

The exact largest-remainder result is:

[2333333333333333, 4666666666666667]

At this magnitude both fractional shares collapse to zero in binary64, so the residual unit is assigned to the first ratio rather than the larger exact remainder.

Change

  • normalize integer and decimal ratios to common-scale integer weights
  • calculate residual numerators with the configured Calculator
  • compare those exact residuals while preserving input order for ties
  • add a numeric-string regression case so the test remains portable to 32-bit PHP

Validation

  • reproduced the failure on PHP 8.5.10 before the patch
  • verified the counterexample after the patch
  • manually checked the existing allocation cases with both BcMathCalculator and GmpCalculator
  • php -l and git diff --check pass

This is an ordinary numerical-correctness fix; the allocated total was conserved before the patch, but the wrong recipient could receive the residual minor unit.

Signed-off-by: kadyrbekovhamit-cyber <288885044+kadyrbekovhamit-cyber@users.noreply.github.com>

@frederikbosch frederikbosch left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Some remarks on the number of loops. I like the fact that we do not use float anymore. But I also wonder if the behavioral change - e.g. last ratio now gets larger proportion - is preventable.

Comment thread src/Money.php Outdated
throw new InvalidArgumentException('Cannot allocate to none, ratio must be zero or positive');
}

$ratio = is_float($ratio) ? Number::fromFloat($ratio) : Number::fromNumber($ratio);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can't we modify $ratios here and prevent another loop with array_map afterwards.

@kadyrbekovhamit-cyber kadyrbekovhamit-cyber Sep 8, 2026

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The common decimal scale is only known after all ratios have been inspected, so one additional pass is unavoidable. I now parse each ratio only once in the first loop, then normalize and sum in the second pass; the array_map call and repeated Number conversion are gone.

Comment thread src/Money.php Outdated
}

$total = '0';
$ratios = array_map(static function (float|int $ratio) use ($scale): string {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This loop should be merged into the former loop.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 40bfb1e: normalization and total calculation now share one loop, reducing the three preprocessing passes to two.

Comment thread src/Money.php Outdated
return ltrim($normalized, '0') ?: '0';
}, $ratios);

foreach ($ratios as $ratio) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This total calculation should be merged in the earlier loop. And then checked.

@kadyrbekovhamit-cyber kadyrbekovhamit-cyber Sep 8, 2026

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done. Each normalized ratio is added to the total immediately in the normalization loop, and the total is checked directly afterwards.

Signed-off-by: kadyrbekovhamit-cyber <288885044+kadyrbekovhamit-cyber@users.noreply.github.com>
@kadyrbekovhamit-cyber

Copy link
Copy Markdown
Author

Thank you for the review. I pushed commit 40bfb1e to reduce preprocessing from three passes to two and added a compatibility regression for amount 4 with ratios 1:2. The last entry receiving the extra unit is not new behavior when its exact fractional remainder is larger; the previous implementation already returns 1 and 3 for that ordinary case. For amount 7000000000000000, the exact residual numerators are 1 and 2, so the second ratio must receive the remaining minor unit. Binary64 collapses that distinction and incorrectly gives it to the first ratio. Preserving that particular old result would preserve the numerical defect. Exact ties still keep input order and award the first entry.

Signed-off-by: kadyrbekovhamit-cyber <288885044+kadyrbekovhamit-cyber@users.noreply.github.com>
@kadyrbekovhamit-cyber

Copy link
Copy Markdown
Author

Hello Frederik,

Thank you again for the review. The loop changes and compatibility regression are in the PR, and GitHub currently shows all 15 checks passing. I would be glad to work through any remaining concerns about allocation behavior.

The independently checkable report and evidence are here: https://www.gero.uz/research/articles/moneyphp-largest-remainder-float-collapse.html

I would also welcome ongoing collaboration with MoneyPHP. My background is applied mathematics and mechanics, and I can help with exact monetary calculations, rounding conventions and regression checks across calculators. I am available remotely and would be happy to start with a small agreed task.

Xamit Kadirbekov / GERO
kadyrbekovhamit@gmail.com

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants