Lion Cipher Lab
Watch NaN and float rounding slip past a limit check, and fix it with Decimal and validation
- Foundations
- Intermediate
- ~60 min
- Free · no account required
Why it matters
Almost every app that handles a number from a user does the same thing: turn the text into a float and compare it to a limit. It looks safe. It is not, and the reasons are not obscure once you have seen the bits. A float is a 64-bit pattern with a fixed number of significant bits, so it cannot hold 0.1 exactly, it cannot tell 2**53 from 2**53 + 1, and it has special values, infinity and NaN (not a number), that ordinary comparison rules do not cover. Every comparison with NaN is False. So 'if amount > limit: reject' quietly approves NaN, and a NaN that reaches a running total turns that total…
What you'll learn
- decoding a Python float into its IEEE 754 binary64 sign, exponent and fraction bits with struct, and recognising the bit patterns of zero, -0.0, infinity and NaN
- explaining from your own output why 0.1 + 0.2 prints 0.30000000000000004, why a ten-step running total of 0.1 is 0.9999999999999999, and why float(2**53 + 1) equals float(2**53)
- measuring a naive 'if amount > limit: reject' check against a seeded harness: 20 wrong approvals out of 35 edge-case decisions (NaN, -inf, negatives, '5_00', ' 500 ', Arabic-Indic digits, a 2**53+1 payee id, a NaN-poisoned daily ledger)
- writing comparisons as allow-conditions (if not (0 < amount <= limit): reject) so NaN fails closed, and measuring that this alone cuts the wrong approvals from 20 to 7
- parsing money strictly: an ASCII [0-9] format check, decimal.Decimal, an is_finite check and integer cents, measured at 0 wrong decisions on 35 edge cases and 1,077 seeded random ones
- refusing NaN and Infinity in JSON with json.loads(parse_constant=...) and closing the 1e400 -> inf gap with parse_float=Decimal (non-finite numbers through: 5 of 7, then 2 of 7, then 0 of 7)
- naming what the fix costs, with numbers: 1.5x to 1.75x slower per call across three runs, banker's rounding in quantize (0.125 -> 0.12), a 28-digit default precision, 3-decimal currencies, and JavaScript's 2**53 - 1 safe-integer ceiling
Steps in this lab
- What you are building, and what a float actually is
- Set up: a terminal, Python, and a lab folder
- Look inside a float: decode the bits with struct
- Write the transfer check three ways (transfer.py)
- Write the seeded harness (harness.py)
- Measure BEFORE: the naive check approves 20 of 35 edge decisions it should refuse
- Half a fix: fail closed and check isfinite, still with floats (20 -> 7)
- The fix: strict format, Decimal, integer cents (7 -> 0)
- JSON: Python reads and writes NaN, and parse_constant is not enough
- Honest negatives: what the fix costs and what it does not cover
- When to use which, and how a pro does it better