32 - Add EvenSplitter for even distribution with redistribute
This commit is contained in:
parent
d7af49cf8e
commit
556f45146c
2 changed files with 213 additions and 0 deletions
88
src/Service/Allocation/EvenSplitter.php
Normal file
88
src/Service/Allocation/EvenSplitter.php
Normal file
|
|
@ -0,0 +1,88 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Service\Allocation;
|
||||||
|
|
||||||
|
class EvenSplitter
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param list<int> $slots per-slot maximum
|
||||||
|
*
|
||||||
|
* @return list<int> 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<int> $slots
|
||||||
|
* @param list<int> $allocated amounts placed so far (accumulator)
|
||||||
|
*
|
||||||
|
* @return list<int>
|
||||||
|
*/
|
||||||
|
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<int> $slots
|
||||||
|
* @param list<int> $allocated
|
||||||
|
*
|
||||||
|
* @return list<int>
|
||||||
|
*/
|
||||||
|
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<int> &$allocated
|
||||||
|
* @param list<int> $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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
125
tests/Service/Allocation/EvenSplitterTest.php
Normal file
125
tests/Service/Allocation/EvenSplitterTest.php
Normal file
|
|
@ -0,0 +1,125 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Tests\Service\Allocation;
|
||||||
|
|
||||||
|
use App\Service\Allocation\EvenSplitter;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pins EvenSplitter::split() -- pure integer even-split-with-redistribute.
|
||||||
|
* ZERO bucket/domain knowledge: just int $pool + list<int> $caps -> list<int>
|
||||||
|
* 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<int>, 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue