This is the whole thing: a function that looks obvious, and the frozen table of what it actually does on eight tricky inputs — every value captured by running the code, every row green. The exercise is this same page with the answers blanked out — you predict them first.
Before you can trust an agent's code — or safely change it — you have to say what the code does. Not what it should do, not what its name suggests: what it actually does, pinned to a value you can check. Say it as a line that runs and that value becomes a small, exact spec of the real behaviour. That move has a name — a characterization test — and it is the first thing you reach for on legacy code in Module 4 — Refactoring.
The function
Nothing exotic — the rounding you have called a thousand times:
static double RoundTo(double value, int digits) => Math.Round(value, digits);
Ask ten engineers what RoundTo(2.5, 0) returns and ten say 3. Run it: you get 2. .NET's Math.Round rounds a halfway value to the nearest even number — banker's rounding, not "half up" — and because a double cannot hold every decimal exactly, even that rule has a twist. None of this is a bug. It is behaviour, and behaviour is what you pin.
The frozen table — prediction vs. actual
The Naive column is what "round half up" predicts; the Actual column is the frozen truth, captured by running RoundTo. Seven of the eight defy the naive guess:
| Call | Naive (half-up) | Actual (.NET) | Why |
|---|---|---|---|
RoundTo(0.5, 0) |
1 | 0 | half to even — 0 is even |
RoundTo(1.5, 0) |
2 | 2 | half to even — 2 is even (the one that agrees) |
RoundTo(2.5, 0) |
3 | 2 | half to even — 2, not 3 |
RoundTo(4.5, 0) |
5 | 4 | half to even — 4, not 5 |
RoundTo(-2.5, 0) |
-3 | -2 | rounds toward even on both signs |
RoundTo(0.125, 2) |
0.13 | 0.12 | 0.125 is exact in binary; the even digit wins |
RoundTo(0.625, 2) |
0.63 | 0.62 | 0.625 is exact; the even digit wins again |
RoundTo(1.005, 2) |
1.01 | 1.00 | 1.005 is really 1.00499… — it can't be halved |
The last row is a different lesson: it is not banker's rounding at all, it is the double itself. 1.005 cannot be stored exactly, so the value held is a hair below 1.005 and rounds down. Guessing was never going to reach it — only running it does.
Live and green
The function and the whole frozen battery, in one runnable cell. It feeds every input through RoundTo and checks the actual against the pinned prediction. Press Run — every row must say ✓.
Wrap Math.Round in a RoundTo helper and pin its real behaviour: feed it the eight tricky cases — 0.5, 1.5, 2.5, 4.5 and -2.5 at zero digits, plus 0.125, 0.625 and 1.005 at two — and render a grid comparing the naive half-up guess, the pinned prediction, and the actual result, with a green check per row.
Green all the way down means you have characterized the function: you no longer believe what it should do, you know what it does — written as a test that will shout the day the behaviour changes. That is the safety net every refactor stands on.
← Back to the exercise · Module 1 — Introduction · All solutions