A macro calculator takes a calorie target and splits it into grams of carbs, protein and fat. Mine did this:
const carb = cals * split[0] / 100 / 4;
const prot = cals * split[1] / 100 / 4;
const fat = cals * split[2] / 100 / 9;
return `${Math.round(prot)} / ${Math.round(carb)} / ${Math.round(fat)} g`;
Ask it for 2,000 kcal on a 40/30/30 split and it says 150 / 200 / 67 g.
Multiply that back out — protein and carbs are 4 kcal a gram, fat is 9:
200 × 4 + 150 × 4 + 67 × 9 = 2,003
You asked for 2,000. It is not a big miss. It is a miss on the one thing the tool exists to do, and any user with a phone can find it, because adding up to the number you typed is the entire point of a split.
It was not one unlucky input either:
| target | plan | it said | that is actually |
|---|---|---|---|
| 2,000 | balanced | 150 / 200 / 67 | 2,003 |
| 2,000 | keto | 125 / 25 / 156 | 2,004 |
| 1,750 | low carb | 175 / 109 / 68 | 1,748 |
| 2,500 | high protein | 250 / 250 / 56 | 2,504 |
Every plan. Every time.
Why rounding each one cannot work
Because the three values are not independent. They have to satisfy
4c + 4p + 9f = calories
and Math.round does not know that. It rounds c to whatever is nearest to c, which is the right answer to a question nobody asked. The constraint is on the sum, and three separately-correct roundings do not add up to a correctly-rounded total.
This is the same family as a percentage table where the column has to total 100% and comes out at 99.9%. The usual fix there is largest-remainder: hand out the floors, then give the spare units to whoever was robbed most by rounding down.
That does not work here, and the reason is the interesting part.
The weights are 4, 4 and 9
In a percentage table every unit is worth the same, so you can move one from anywhere to anywhere. Here, moving a gram of carbs changes the total by 4, and moving a gram of fat changes it by 9. You cannot always close a gap of 3 kcal, because nothing in your inventory is worth 3.
So the order matters. Pick fat first, and pick it so that what is left over divides by 4 — because whatever is left has to be made up entirely of carbs and protein, and those only come in fours.
const want = kcal * split[2] / 100 / 9; // ideal fat, in grams
for (let f = Math.round(want) - 3; f <= Math.round(want) + 3; f++) {
const left = kcal - 9 * f;
if (left < 0 || left % 4 !== 0) continue; // no integer c,p can finish this
const rest = left / 4; // carbs + protein, in grams
const c = Math.round(rest * split[0] / (split[0] + split[1]));
const p = rest - c;
// ...
}
And the search never has to go far. 9 ≡ 1 (mod 4), so incrementing f by one shifts kcal - 9f by one modulo 4 — which means one fat value in every four consecutive integers satisfies the condition. A candidate always exists within two grams of the ideal. There is no failure case to handle, only a choice to make.
The first version was still wrong, and quietly
I originally took the nearest valid f and stopped. Every combination hit its calorie target exactly, the tests I had written passed, and I nearly shipped it.
Then I measured how far the grams had drifted from the ideal split, which is the other thing the tool is promising:
worst drift: 4.75 g on protein at 2,500 kcal keto
Keto puts 70% of the calories in fat, so fat is large, its rounding error is large in kcal, and all of that leftover lands on carbs and protein. Taking the first valid fat value meant taking whatever drift came with it.
There are usually several valid candidates in that ±3 window, and they do not cost the same. Scoring all of them by total drift and keeping the best brings the worst case down to 3.94 g — under 3% of that macro — while still landing exactly on the calorie target. Seven candidates is nothing to evaluate.
That is the general lesson and it is not about food:
Satisfying the constraint is necessary, not sufficient. The first solution that satisfies it is rarely the best one, and if you only assert on the constraint your tests will happily bless the worst member of the solution set.
Testing it
Sweeping, not sampling:
for (let cals = 800; cals <= 4000; cals += 25) {
for (const p of Object.values(PLANS)) {
const g = macroGrams(cals, p);
if (g.kcal !== cals) miss++;
}
}
516 combinations, and they all have to land exactly. Sampling round numbers would have been useless here — whether the bug shows depends on where the ideal fat value happens to fall relative to a multiple of four, and 2,000 kcal is exactly the kind of tidy input that could have gone either way.
The drift bound in the test is the measured number, not a guess:
// 3.94 g is what the algorithm actually holds across the swept range,
// on protein in a keto split. Four is that, rounded up.
check('no macro drifts more than 4 g from its ideal', worst <= 4, true);
Writing worst <= 10 because it passes is not a test, it is a formality. Writing worst <= 3 because it sounds tidy fails on an input that is perfectly fine. Measure the bound, then assert it.
And say the number
The last change was not code:
Protein 149 g
Carbohydrates 198 g
Fat 68 g
These add up to 2,000 kcal
The reader was going to multiply it out anyway. Better that the page does it first.
I build Utilorax, a set of free browser-based tools. This came out of the macro calculator, which now hands you three numbers that add up to the one you asked for.
Top comments (2)
EN 16931, the EU e-invoice standard, hit exactly this and split it two ways. Its BR-CO-17 rule says the VAT per category is the taxable base times the rate rounded to two decimals, but the Schematron actually implements that with a whole currency unit of slack on each side. BR-CO-15, grand total equals net plus VAT, is a plain equality with none. So rounding is allowed to break in exactly one place and nowhere else. There is also a dedicated field for the leftover, BT-114 Rounding amount, so the drift gets recorded rather than hidden.
Did you consider a visible leftover instead of scoring candidates, or does the tool's promise rule that out?
I did not, and EN 16931 is a better-designed answer than mine for the problem
it has. BT-114 existing at all is the interesting bit — the standard accepts
that the drift is real and gives it somewhere to live, instead of pretending
it away.
The reason it does not carry over here is that the two problems differ in one
respect: an exact solution always exists in mine. Because 9 ≡ 1 (mod 4), one
fat value in every four consecutive integers makes the remainder divisible by
4, so there is always an integer triple that lands exactly on the calorie
target. My BT-114 would read zero every single time, which is a field that
teaches the reader nothing.
So the scoring is not choosing whether to have a leftover. It is choosing
which exact solution to take, and the thing being minimised is drift from the
requested ratio rather than drift from the total.
Where your framing does apply: the tool does print the reconciliation line —
These add up to 2,000 kcal
— which is BT-114's job, just with a value that happens to always match. And
if the weights were different, or the target could be fractional, I would need
the tolerance-in-exactly-one-place structure rather than a promise I can
actually keep.