FifeRouter

31 August 2026 · ledger money

Integer micro-cents, and the float that nearly shipped

Money is a signed integer of 1e-8 dollars everywhere it is stored. The router still computes cost as a float, and the boundary between those two facts is the whole of it.

Everybody knows not to store money in a float. The advice is universal, correct, and slightly harder to follow than it sounds, because the number does not arrive as an integer.

Where the float is legitimate

The router prices a request by multiplying token counts by a per-million rate:

price_in:  0.60      # USD per 1M tokens
price_out: 0.80

(1_234 / 1_000_000) * 0.60 is a float operation and there is no honest way for it not to be. The rate is a decimal fraction, the token count is small, and the product is a fraction of a cent. Refusing to use floats here means implementing decimal arithmetic for a number that will immediately be rounded.

So the router computes cost as a float. That is fine, and it is fine because of where it stops.

The boundary

One function, called once, at the edge of the ledger:

MICROCENTS_PER_USD = 100_000_000  # 1 USD = 100 cents = 1e8 micro-cents

def usd_to_microcents(usd: float) -> int:
    scaled = usd * MICROCENTS_PER_USD
    return int(scaled + (0.5 if scaled >= 0 else -0.5))

After that, every stored, summed and compared amount is a signed integer. The conversion happens once and never goes back — microcents_to_usd exists, is documented "for display only", and its result is never fed into a stored amount.

The reason for the granularity: a routed request can cost a small fraction of a cent. Storing cents would round most individual charges to zero, and an account could then make thousands of requests for nothing. Micro-cents give eight decimal places of headroom, and BIGINT because an INT overflows at about $21.

Why not round()

That + 0.5 looks like something a linter would replace with round(). It is deliberate, and this is the part worth the post.

Python's round() is banker's rounding: exact halves go to the nearest even number. round(0.5) is 0, round(1.5) is 2, round(2.5) is 2.

For statistics that is the right default — it does not bias a large sample in either direction. For money it is wrong in a specific way: whether a half-unit charge rounds up or down depends on whether the neighbouring digit is even, which is not a property anybody's billing policy mentions. Across millions of rows it produces a systematic pattern nobody chose and nobody can explain to an auditor.

Half away from zero is the convention people expect. It is also the one that behaves symmetrically for negative amounts, which matters because debits are stored negative:

assert usd_to_microcents( 0.000000005) ==  1
assert usd_to_microcents(-0.000000005) == -1

Both tested, because "rounds the same in both directions" is exactly the sort of thing that stays true until somebody simplifies the expression.

The type check that looks redundant

if not isinstance(amount_microcents, int):
    raise TypeError("amounts are integer micro-cents — see CON-money-is-integer-microcents")

A type annotation says int already. This raises anyway, at runtime, in the one function that writes to the ledger.

Because annotations are not enforced, and the failure mode is quiet: a float that reaches the database becomes a value that sums almost correctly. Postgres will accept 1000.0 into a BIGINT column. You do not find out from an error; you find out from a balance that is off by a cent in a way nobody can reproduce.

The error message names the constraint it is protecting, so the next person to hit it can read the argument rather than deleting the check.

What it does not solve

Currency. Everything here is USD, and multi-currency is a different problem than precision — it needs a currency on every row and a rate at the moment of conversion, which is a decision about when a rate is captured rather than about how many decimal places to keep.

Doing the precision properly first at least means that when currency arrives, it arrives into a ledger where every number already means exactly one thing.


← All posts