Skip to content

Latest commit

 

History

1 Commit

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Penny

Penny finding the bills that overcharge

Penny splits a restaurant bill between three friends. It gets it right almost every time.

This repo is about the times it doesn't, and about the difference between looking for bugs and knowing there are none left.

Why this exists

Testing tells you what you found. It never tells you what you missed.

That is fine when there are a handful of inputs. Penny has 226 billion. You cannot try them all, so every test you write leaves a gap, and you have no way to measure how big the gap is.

This page puts both approaches on the same screen, gives them the same job, and lets you watch one of them run out of road.

The app

Nothing to configure. This is the space the board sweeps:

People 3
Tax 8.25%
Tip 0% to 30%, each person picks their own
Bill 10.00 to 10,000.00

Small enough to read in a morning. But each person picks a tip independently, and that is what makes it big:

       61 tips  (you)
  x    61 tips  (Alex)
  x    61 tips  (Sam)
  =   226,981 tip combinations
  x   999,001 bills
  = 226,754,245,981 (combination, bill) pairs

The bug

Two parts of the app work out each person's tip, and they round differently.

flowchart LR
    A["$19.70 bill<br/>tips 20% · 15% · 20%<br/>split 3 ways"] --> B["Alex's tip share is<br/><b>98.5 cents</b><br/>exactly half"]
    B --> C["payments side<br/>rounds up<br/><b>99c</b>"]
    B --> D["ledger side<br/>rounds to even<br/><b>98c</b>"]
    C --> E["card charged<br/><b>$24.94</b>"]
    D --> F["receipt says<br/><b>$24.93</b>"]
    E --> G(["1 cent too much"])
    F --> G
Loading

Money is kept in whole cents, so a share of 98.5 cents has to go one way or the other. Somebody has to decide which.

  • The payments side rounds up. That is normal in money code, and deliberate.
  • The ledger side rounds to the nearest even number, because that is what Python does by default and nobody chose it.

Both answers are correct. They just disagree, and when they do the card is charged a cent more than the receipt says.

No floating point is involved anywhere. This is not a rounding error. It is two teams answering the same question differently and never comparing notes.

How often: 1 of every 72 (combination, bill) pairs. When everyone tips 15%, 1 bill in 40.

How would you test it?

Approach What you try What happens
Pick examples a small bill, a big one, a round one all pass
Try the edges $10.00, $10,000.00, 0% tip, 30% tip all pass, the bug is in the middle
Random inputs thousands of generated bills finds a bad one in 0.11 seconds
Try everything all 226 billion 4.8 days

Random input testing is genuinely good here. It finds the bug almost instantly.

Then it stops at the first failure and goes quiet. Is there one bad bill, or ten billion? Did the fix work, or did it just move the problem? It cannot say. And the only way to be certain by testing is the last row, which takes most of a week.

The two lanes

Press Find defects and the page runs both at once, on the same bills.

Covers Time
Testing, one bill at a time 0.0017% 7 seconds
Asking Z3 100% 0.27 seconds
Testing      |                                    0.0017%   after 7 seconds
Z3           |###################################  100%     after 0.27 seconds

The testing lane runs flat out for seven seconds, gets through 3.9 million pairs, and stops. The page then tells you what finishing would cost, measured from how fast it was actually going: 4.8 days. Then it runs that clock forward so you can watch the days go past.

The other lane answers for all 226 billion in a quarter of a second, and finds 3,161,415,420 pairs that overcharge.

How it can be that fast

It never checks bills one at a time.

The bad bills are not scattered at random. They fall in a regular pattern, evenly spaced. So rather than hunting for them, the page asks Z3 up to three questions about each tip:

  1. What is the smallest bill that breaks?
  2. What is the next one?
  3. Is there anything in between that we missed?

The gap between the first two answers gives the spacing. The third question is the one that matters: when Z3 reports that nothing else exists, the list is finished. Not "we looked and found no more", but "there are no more".

167 questions covers the whole thing. And the cost does not grow with the range. A hundred times more bills is still 167 questions.

That is the whole difference. One lane is looking. The other is knowing.

What it found that nobody asked

Some tips can never break. 8 of the 61: 0%, 4%, 8%, 12%, 16%, 20%, 24% and 28%. Every whole multiple of 4% is safe for every bill. Not "we tested it and it was fine" but "it cannot happen".

The tax never matters. Both sides work the tax out identically, so it cancels. Every bad cent comes from the tip. That was not the assumption going in.

The damage has a ceiling. At most one cent per person, so never more than three.

Running it

Needs Python 3.11+ and uv.

uv run python -m app.server

Open http://127.0.0.1:8000 and press Find defects.

Can you trust the numbers?

The two lanes are built from completely separate code and are checked against each other on every bill they both look at. If they ever disagreed, the page would say so instead of quietly showing you a number.

So why not just do the arithmetic?

Here is the part that would be dishonest to leave out.

Halve the bill. If you can do it three times and still have whole cents, no tip can ever break that bill.

$10.00 is 1000 cents: 500, 250, 125. Three halvings, all whole. Safe forever. Every whole multiple of two dollars clears the same bar, which is why the values in every test plan ever written are the worst possible choices here.

$25.00 halves twice and stops. One short. It ties at a 1.5% tip.

The same arithmetic answers both questions the solver was asked. Whether a tip can ever tie is one check on the spacing it lands on, and 20% fails that check, which is where the immunity list comes from. Where it ties is one step to the first one and read off the interval. At 7% the ties start at $10.50 and repeat every $6.00.

That is app/closed_form.py. Nine lines, no solver, roughly 8,000 times faster than asking Z3 the same question.

So Z3 was never necessary here. What it bought was not having to work any of that out. Nobody had to notice the condition was regular, derive the spacing, or handle the case where the arithmetic has no answer. You state the condition and you ask.

And the fragility is not in the constants. Moving to a currency with three decimals changes a number. Nest the rounding, split the remainder unevenly, add a fourth path, and the neat arithmetic stops applying at all while the model in prover.py needs one line edited.

The closed form is not a rebuttal of the solver. It is how you check one. tests/test_closed_form.py puts them in front of each other, and two methods that share no code agree on all 61 tips. That agreement is worth more than either answer alone.

Checking the checks

A page about not trusting what you have not verified should not ask you to take its own tests on trust either.

uv run pytest                      # does the code do what it says?
uv run python tools/mutation.py    # would the tests notice if it did not?

The second breaks the code on purpose, fourteen ways, and fails if a mutant that should die survives. It runs the suite unmutated first, because a harness that grades a test suite has to know the suite was green before it started.

The fix

Whole cents everywhere. Round once at the end, not at every step. The same rounding rule on both sides of the wire. And when a cent is left over, hand it to someone on purpose rather than letting it appear out of nowhere.

Licence

MIT

About

A restaurant bill splitter with one rounding defect, used to show what boundary values, property based testing, brute force and a solver can each actually prove about the same 226,754,245,981 inputs.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages