I run a small static site that estimates pension figures for Indian central government pensioners. The rule I had to implement sounds trivial: Dearness Relief (DR) is a percentage of basic pension, and any fraction of a rupee is rounded up to the next rupee. That's straight from the government orders.
So my first version was the obvious one:
const dr = Math.ceil(basic * rate); // rate = 0.28 for 28%
It passed every example I tried by hand. Then I wrote a brute-force test and found it was wrong for thousands of inputs.
The bug
Try this in your console:
9000 * 0.28 // 2520.0000000000005
Math.ceil(9000 * 0.28) // 2521
₹9,000 is the minimum basic pension, and 28% was a real DR rate in 2021. The correct answer is ₹2,520. My code said ₹2,521.
0.28 can't be stored exactly in binary floating point, so the product lands a hair above 2520. Math.ceil does exactly what it's told and jumps to the next integer. Normally float noise is invisible because you round to 2 decimals for display. Ceiling is different: it turns a 0.0000000000005 error into a full rupee.
The opposite direction exists too:
25000 * 2.28 // 56999.99999999999
Math.ceil(25000 * 2.28) // 57000 (correct, by luck)
Here ceil happens to save you. But if you'd used Math.floor or Math.trunc you'd get 56999. So the same class of bug bites whichever rounding rule your domain uses.
How many inputs are actually affected?
I didn't want to guess, so I looped over every whole-rupee basic pension from ₹9,000 to ₹1,25,000 (the real min and max) for a set of DR rates used during the 7th Pay Commission years, and counted products that were mathematically an integer but came out slightly above it:
const rates = [0.07, 0.12, 0.17, 0.28, 0.31, 0.34, 0.38, 0.42,
0.46, 0.50, 0.53, 0.55, 0.58, 0.60, 0.63];
for (const r of rates) {
let bad = 0;
for (let b = 9000; b <= 125000; b++) {
const p = b * r;
const exact = Math.round(p * 1e6) / 1e6;
if (Number.isInteger(exact) && p > exact) bad++;
}
console.log(r, bad);
}
Results on Node:
- 0.28 → 3,640 wrong values
- 0.55 → 3,488
- 0.07 → 911
- 0.34 → 820
- 0.17 → 409
- 0.12, 0.31, 0.38, 0.42, 0.46, 0.50, 0.53, 0.58, 0.60, 0.63 → 0
That last line is the dangerous part. The current rate is 63%, and it has zero bad cases. If I had only tested with today's rate, everything would look fine, and the bug would show up the day someone used the "custom rate" box or the rate changed to something unlucky.
Fix 1: trim the noise before ceiling
This is what I shipped first, because it was a one-line change:
// Round UP to the next rupee, after trimming float noise
// such as 23129.999999999996 or 2520.0000000000005
function up(x) {
return Math.ceil(Math.round(x * 100) / 100);
}
Rounding to paise first snaps 2520.0000000000005 back to 2520, and a genuine 2520.004 still ceils to 2521. It works because my inputs are whole rupees times a rate with at most 3 decimals, so real fractions are never smaller than a paisa's worth of noise. That assumption matters. Write it down next to the function.
Fix 2: keep it in integers
The cleaner approach, and the one my test suite uses as the reference answer, is to never multiply by a decimal at all. Store the rate as an integer in the smallest unit you need, multiply integers (exact up to 2^53), and divide once:
// rate as an integer percentage: 28, 60, 63
function drRupees(basic, ratePct) {
return Math.ceil((basic * ratePct) / 100);
}
drRupees(9000, 28); // 2520
9000 * 28 is exactly 252000. Dividing by 100 gives exactly 2520 when the true answer is an integer, and when it isn't, the result isn't close enough to an integer to fool Math.ceil. For fitment factors like 2.57 or 3.833 I do the same with a scale of 1000:
function revisedBasic(basic, factor) {
const f = Math.round(factor * 1000); // 2.57 -> 2570
return Math.ceil((basic * f) / 1000);
}
Math.round(factor * 1000) is the one place a float is allowed in, and rounding there is safe because the factor has at most 3 decimals by definition.
If your amounts can go beyond Number.MAX_SAFE_INTEGER after scaling, use BigInt. For pensions, they can't.
Fix 3: the test that would have caught it
Hand-written examples didn't catch this. A boring exhaustive loop did. This is roughly what lives in my tools/test.js now, run before every push to Vercel:
const assert = require("assert");
for (const p of CONFIG.presetFactors) {
for (let b = 9000; b <= 125000; b += 500) {
const exact = Math.ceil((b * Math.round(p.value * 1000)) / 1000);
assert.strictEqual(
calcPension({ basic: b, type: "sup", age: 0, factor: p.value }).revised.basic,
exact,
`${b} x ${p.value}`
);
}
}
The step of 500 keeps it fast. For a one-off audit I ran it with a step of 1, which takes a couple of seconds on my laptop and is worth doing once.
I also pinned values from published government examples: the 7th CPC pension revision orders include worked cases, and my pre-2016 calculator asserts all four of them. Exhaustive loops prove internal consistency; known official examples prove you're consistent with the real world. You want both.
What I'd tell myself a week ago
-
Math.ceilandMath.flooramplify float error from invisible to a whole unit. Any time you see them next to a decimal multiplication, be suspicious. - Testing with "today's" config hides bugs. My current rate had zero bad cases; old rates had thousands.
- Integers in, one division, one rounding. Do the rounding exactly once, at the step the rules say to.
- Write the rounding rule as a comment with its source. Future me won't remember why it's ceil and not round.
The calculator is a plain static site (Python build script, vanilla JS, no framework), and every rupee figure on it now goes through up(), with the integer version checking it in tests. If you want to poke at the numbers, the DA/DR arrears calculator is where this rounding shows up most.
Have you hit a rounding bug like this in production? I'm curious whether people reach for integer cents, a decimal library, or just toFixed and hope.
Top comments (0)