Debug: The Vanishing Cent
A bug squash in the Stripe shape: the starter is finished, plausible code that rounds each payee alone, so the parts stop adding up to the whole. Diagnose it, then make the fix fair and then hostile-input proof.
- Pattern
- floor everyone, then hand out the remainder by largest fraction
- Time
- O(n log n)
- Space
- O(n)
Most drills hand you an empty function. This one hands you a finished one that is wrong. That is the Stripe bug squash format, and the skill it tests is diagnosis, not typing.
The function is splitAmount(totalCents, shares). Integer cents in, integer cents out, split by weight. shares are weights, not percentages, so [1, 1, 2] is 25 / 25 / 50.
The recipe
Say before you type: "Rounding decides one payee at a time. The invariant is about the group. So floor everyone, count what is left, and hand it out."
Phase 1: diagnose before you patch
The starter does this:
payouts.push(Math.round((totalCents * share) / totalShares));
Read it out loud and the bug names itself. Each payee is rounded independently, and nothing anywhere checks the total. 100 across [1, 1, 1] gives three 33s, which is 99. 10 across six equal payees gives six 2s, which is 12. The error is not small and it is not one-directional: rounding both loses and invents money.
The reflex fix is a better rounding mode. There is no better rounding mode. Math.round, Math.ceil, Math.floor, banker's rounding: every one of them makes a per-payee decision, and no set of per-payee decisions can guarantee a group total. Say that out loud in the interview. It is the whole insight.
So change the shape of the answer instead:
- Floor every payee. Flooring can only under-pay, so what is left is a small non-negative integer, never a signed drift you have to hunt.
remainder = totalCents - sum(floors).- Hand out the remainder one cent at a time.
remainder is always strictly less than shares.length, which is why nobody ends up more than one cent off their exact share. That bound is worth stating, because it is what rules out the tempting shortcut of dumping the whole difference on the last payee. That sums correctly and puts one payee two or three cents off, which an auditor will find.
Phase 2: largest remainder, and why floats bite
Now the leftover cents go to the payees with the largest fractional parts, ties to the lowest index. This is the largest remainder method, the same rule used to apportion legislative seats.
The obvious implementation is the trap:
const exact = (totalCents * share) / totalShares;
const frac = exact - Math.floor(exact); // a float division stands between you and the answer
You are about to sort on frac. But frac came out of a division that rounded, so two payees whose true fractions differ can compare equal, and two who are truly equal can compare different. Keep the fraction scaled instead:
const numerator = totalCents * share;
const base = Math.floor(numerator / totalShares);
const rest = numerator - base * totalShares; // the fraction, times totalShares
Every payee has the same denominator, so rest orders exactly like the fractions do. When the weights are whole numbers rest is an exact integer, and integers compare exactly. That is the sentence to say.
Two more things the bench checks:
- Sort indexes, not
shares. Sorting the caller's array in place is a silent corruption of somebody else's data. - Write the tiebreak.
(rests[b] - rests[a]) || (a - b).Array.prototype.sortis stable in modern engines, so leaning on it usually works, but "usually works" is not what you want to say about money. Say the tiebreak, then write it.
Phase 3: zero is not empty
Six inputs, and one of them is a trap:
| Input | Result |
|---|---|
splitAmount(0, [1, 2, 3]) | [0, 0, 0] |
splitAmount(97, [5]) | [97] |
splitAmount(3, [1, 1, 1, 1, 1]) | [1, 1, 1, 0, 0] |
splitAmount(100, [1.5, 1.5, 1]) | [38, 37, 25] |
splitAmount(-100, [1, 1]) | throws RangeError |
splitAmount(100, []) | throws RangeError |
The trap is the pair at the bottom against the row at the top. A zero total is a valid split: it returns one zero per payee. An empty payee list is a broken call: there is nobody to pay and no total to divide by. So this one line is two bugs:
if (!totalCents || !shares.length) throw new RangeError("bad input"); // wrong
It throws on splitAmount(0, [1, 1]), which should return [0, 0], and it lets splitAmount(-100, [1, 1]) through, because -100 is truthy. Ask the two questions separately:
if (!Array.isArray(shares) || shares.length === 0) throw new RangeError("needs at least one payee");
if (totalCents < 0) throw new RangeError("cannot split a negative total");
Throw RangeError, not a bare Error. The type is the API.
Notice that a total of zero needs no special case once the guards are right. Every floor is 0, the remainder is 0, the handout loop never runs, and the shape is already one entry per payee. A special case there is a sign you do not trust your own arithmetic.
What this is actually testing
Money that does not reconcile is the bug class that gets a payments engineer paged. The interviewer is watching for three moves: you reproduce before you theorise, you name why the class of fix is wrong rather than trying three rounding modes, and you state the invariant (sum(out) === totalCents) before you touch the code. Everything after that is bookkeeping.