diff --git a/src/Service/Allocation/EvenSplitter.php b/src/Service/Allocation/EvenSplitter.php new file mode 100644 index 0000000..a7511d2 --- /dev/null +++ b/src/Service/Allocation/EvenSplitter.php @@ -0,0 +1,88 @@ + $slots per-slot maximum + * + * @return list per-slot allocated, positionally aligned with $slots + */ + public function split(int $amount, array $slots): array + { + $allocated = array_map(static fn () => 0, $slots); + + return $this->distribute($amount, $slots, $allocated); + } + + /** + * @param list $slots + * @param list $allocated amounts placed so far (accumulator) + * + * @return list + */ + private function distribute(int $amount, array $slots, array $allocated): array + { + if (0 === $amount) { + return $allocated; + } + + $unfilled = $this->getUnfilled($slots, $allocated); + if (0 === \count($unfilled)) { + return $allocated; + } + + $remaining = $amount; + $avgAmount = intdiv($remaining, \count($unfilled)); + $this->applyAvg($avgAmount, $allocated, $slots, $remaining); + + return $this->distribute($remaining, $slots, $allocated); + } + + /** + * @param list $slots + * @param list $allocated + * + * @return list + */ + private function getUnfilled(array $slots, array $allocated): array + { + // get unfilled + $unfilled = array_filter( + $slots, + static fn ($slot, int $i) => $slot > $allocated[$i], + \ARRAY_FILTER_USE_BOTH + ); + + return $unfilled; + } + + /** + * @param list &$allocated + * @param list $slots + */ + private function applyAvg(int $avgAmount, array &$allocated, array $slots, int &$remaining): void + { + $unfilledCount = \count($this->getUnfilled($slots, $allocated)); + + $remainderCents = $unfilledCount > 0 ? $remaining % $unfilledCount : 0; + + // apply remaining to slots + $position = 0; + foreach ($slots as $i => $slot) { + if ($slot <= $allocated[$i]) { + continue; + } + + $share = $avgAmount + ($position < $remainderCents ? 1 : 0); + ++$position; + + $gap = $slot - $allocated[$i]; + $toAssign = min($share, $gap); + + $allocated[$i] += $toAssign; + $remaining -= $toAssign; + } + } +} diff --git a/tests/Service/Allocation/EvenSplitterTest.php b/tests/Service/Allocation/EvenSplitterTest.php new file mode 100644 index 0000000..40c3e4a --- /dev/null +++ b/tests/Service/Allocation/EvenSplitterTest.php @@ -0,0 +1,125 @@ + $caps -> list + * amounts, positionally aligned with $caps. The caller maps buckets<->positions + * outside this class entirely. + * + * Algorithm pinned (all intdiv, no floats), per round while pool > 0 and at + * least one slot still has remaining room: + * - eligible = slot indices where (caps[i] - givenSoFar[i]) > 0 + * - share = intdiv(pool, count(eligible)); remainder = pool % count(eligible) + * - the FIRST `remainder` eligible slots (in order) get +1 on top of share + * - each eligible slot takes min(itsShare, itsRemainingRoom) + * - a slot that took LESS than its share is now capped (drops out next round) + * - stops when a round places nothing, or no slot has remaining room + * + * Signature pinned: + * EvenSplitter::split(int $pool, array $caps): array (list, same + * length/order as $caps; sum of result <= $pool). + */ +final class EvenSplitterTest extends TestCase +{ + public function testFullyAbsorbedByBothSlots(): void + { + $result = (new EvenSplitter())->split(10000, [5000, 5000]); + + self::assertSame([5000, 5000], $result); + } + + public function testUnderfundedWithEqualCapsSplitsEvenly(): void + { + $result = (new EvenSplitter())->split(6000, [5000, 5000]); + + self::assertSame([3000, 3000], $result); + } + + public function testRemainderCentGoesToFirstSlot(): void + { + // intdiv(1001, 2) = 500, remainder 1 -> first slot gets +1. + $result = (new EvenSplitter())->split(1001, [100000, 100000]); + + self::assertSame([501, 500], $result); + } + + public function testRemainderOfTwoCentsGoesToFirstTwoSlots(): void + { + // intdiv(1001, 3) = 333, remainder 2 -> first TWO slots get +1. + $result = (new EvenSplitter())->split(1001, [100000, 100000, 100000]); + + self::assertSame([334, 334, 333], $result); + } + + public function testRedistributesSurplusFromCappedSlotToItsPeer(): void + { + // Round 1: share = intdiv(1000, 2) = 500 each. Slot0's cap is only 100 -> + // caps at 100, drops out; surplus carries to round 2. + // Round 2: only slot1 eligible, remaining = 1000 - 100 - 500 = 400 -> + // takes min(400, 4500) = 400 -> slot1 total = 900. + $result = (new EvenSplitter())->split(1000, [100, 5000]); + + self::assertSame([100, 900], $result); + } + + public function testAllSlotsCappedLeavesPoolLeftoverUnplaced(): void + { + $result = (new EvenSplitter())->split(1000, [100, 100]); + + self::assertSame([100, 100], $result); + } + + public function testAllSlotsCappedSumIsLessThanPool(): void + { + $result = (new EvenSplitter())->split(1000, [100, 100]); + + self::assertSame(200, array_sum($result)); + } + + public function testSingleSlotWithAmpleCapTakesItAll(): void + { + $result = (new EvenSplitter())->split(3000, [5000]); + + self::assertSame([3000], $result); + } + + public function testSingleSlotCapsAndLeavesPoolLeftoverUnplaced(): void + { + $result = (new EvenSplitter())->split(500, [100]); + + self::assertSame([100], $result); + } + + public function testZeroPoolAllocatesNothing(): void + { + $result = (new EvenSplitter())->split(0, [5000, 5000]); + + self::assertSame([0, 0], $result); + } + + public function testEmptyCapsReturnsEmptyList(): void + { + $result = (new EvenSplitter())->split(1000, []); + + self::assertSame([], $result); + } + + public function testCascadingRedistributeAcrossTwoCappedSlots(): void + { + // Round 1: eligible=[0,1,2], share=intdiv(1000,3)=333, remainder=1 -> + // slot0 gets 334, slot1/2 get 333. + // slot0: min(334,100)=100 (capped) + // slot1: min(333,200)=200 (capped) + // slot2: min(333,5000)=333 (not capped) + // placed=633, pool=367. + // Round 2: eligible=[2], share=intdiv(367,1)=367 -> slot2 +367=700. + $result = (new EvenSplitter())->split(1000, [100, 200, 5000]); + + self::assertSame([100, 200, 700], $result); + } +}