All work

Hisaab · Razorpay AI Buildathon · Track 04

The model recommends. Plain code decides.

Hisaab works out which bank payment paid which invoice. A language model helps with the ambiguous ones, but it never does the arithmetic and it never has the last word: it proposes, and hard-coded rules decide what a proposal is allowed to become.

Role

Sole author

Language

Python, FastAPI

Model

GPT-4o-mini, ~20% of records

Data

Synthetic, with an answer key

Somebody has to say which payment paid which bill

A company sends out invoices. Money arrives in its bank account. Someone has to decide that the ₹9,564 that landed on Tuesday pays off the ₹10,000 invoice from last month.

That sounds like a lookup and it is not, for four reasons. The name on the invoice is ABC Technologies and the name on the bank line is NEFT/ABCTECHPVTLTD/882910. The amounts do not agree. The shapes do not agree either: one payment can settle three invoices, one invoice can arrive in two parts. And sometimes there is no answer at all, because the customer simply has not paid.

The amount is always smaller, and always legally so

The gap between billed and received is not an error. It is the payment gateway's fee, the tax on that fee, and the tax the customer is required by law to withhold. On a ₹10,000 invoice that is ₹200, ₹36 and ₹200, and ₹9,564 arrives.

A matcher that compares ₹10,000 to ₹9,564 raises an exception for every single well-behaved payment. So the deductions get computed first, and only then compared. That turns a pile of fake exceptions into clean matches, and the arithmetic it used becomes the explanation shown to whoever is reviewing.

Where the money goes on a ₹10,000 invoice

−₹200 · MDR

the payment gateway's own fee for handling the money

−₹36 · GST on the fee

18% tax on that fee, not on the invoice

−₹200 · TDS

tax the customer is obliged to withhold and pay directly to the government

₹9,564 received

the number that actually appears on the bank statement, and the number to compare against

How I got there

Three passes, in the order they happened.

Explore

Scored every pair, took the best

Weigh the pair
Rank the candidates
Take the top one

Realise

82% automated, eleven wrong approvals

Weigh the pair
Rank the candidates
Take the top one

Insight

Let rules decide, not the score

Rules decide
What settled before
The rest to a person

A good score is a suggestion. Only a rule can approve money.

Three rules I did not break

The routing is plain code, not a model. Three fixed branches, decided by whether a reference number is present. A model there would add cost, latency and randomness in exchange for nothing.

The model never does arithmetic. Every number in the system is computed by code. The model picks between options that code has already priced, and writes the sentence explaining the choice.

The model never has the final say on money. It recommends. Hard-coded rules decide whether that recommendation is allowed to become an action. It touches roughly a fifth of records; the rest never reach it.

One record, from bank line to decision

The router is three branches of ordinary code. Which one a record takes decides whether it costs anything at all.

Intake

api/app/intake.py

  1. Normalise names and dates
  2. Money becomes integer paise, never a float
  3. Reject junk before it costs anything

fast path

Exact match

no model, no scoring

  • The reference names the invoice outright
  • Straight to the rules

Costs nothing

most records land here

the work

Narrow, price, score

blocking → settlement → scoring

  • Cut the field to about ten plausible invoices
  • Work out what should have been deducted
  • Score each pair, then take the margin over the runner-up
  • Resolve conflicts across the whole batch at once

The model is asked

and it can only endorse two rules

Both branches end at the same place: the hard rules, and then one of three outcomes. Approved on its own, held for review, or an exception with the reason written out. Every decision is appended to an audit log.

Three layers, all of them if statements

Most projects build only the third layer, the one that checks what a model said. That is the least important of the three. The second one is where the money is actually protected, and it runs whether or not a model was ever involved.

Before anything runs: the amount is positive, the currency is there, the date parses, every record has an ID. On every proposed match: the list below, all of it. On the model's output: valid JSON, and the ID it chose has to be one of the ones it was given.

The thresholds, in one file

Kept together rather than scattered through the checks, so the tuning curve has one place to move and a reader has one place to look.

AUTO_SCORE = 0.90
MARGIN_FLOOR = 0.15
ADJUDICATE_FLOOR = 0.70

# A wrong Rs 500 match is annoying. A wrong Rs 5,00,000 match is how fraud
# gets through.
VALUE_CEILING_PAISE = rupees(500_000)

# Payment timing, measured from the due date. Must use the same anchor as
# score_date, or a record passes one check and fails the other arbitrarily.
DATE_WINDOW_DAYS = (-7, 45)

# Same amount, same counterparty, this close together: flag both.
DUPLICATE_WINDOW = timedelta(hours=48)

# New names are the highest-risk category, so a counterparty must have been
# settled with this many times before their payments can be automated.
NEW_COUNTERPARTY_MIN = 3

# The only two rules an adjudicator's answer can satisfy. Everything else is
# policy rather than judgement, so a model's opinion cannot move it.
ENDORSABLE_RULES = {"score", "margin"}
api/app/guardrails.py

The score does not get a veto. 0.99 with an unexplained gap of ₹340 is still an exception, and no amount of confidence from the model changes that: the two rules it is allowed to speak to are score and margin. Everything else is policy, and policy is not a thing to have an opinion about.

Two ideas in the scoring worth keeping

A pair is scored on four independent signals: the reference number carries half the weight, the amount a quarter, the name and the date the rest. When a signal is missing, the remaining weights are renormalised rather than the missing one being scored zero. A bank line with no reference would otherwise be capped at 0.50 however perfect everything else about it was. Absence of evidence is not evidence against.

And the score alone is not enough. What matters is the margin: the best score minus the runner-up. A 0.95 means nothing if second place scored 0.94. That is a coin flip, not a match, and only the margin catches it.

What it does on the held-out set

160 records were generated and split three ways: 30 to seed the alias table, 45 to tune the weights, and 85 held back. Only the 85 are reported.

0

Wrong auto-approvals

the number that was never allowed to move

72.9%

Handled without a human

62 of 85

₹0.24

Per 1,000 records

2.1s for the whole batch

Source · synthetic data, generated with its own answer key. Real bank statements would be a different test.

72.9% is not a number I tuned towards. It is the ceiling. Sixty-two of those 85 records are the ones that should be automated; the other 23 genuinely need a person to look at them. Anything above 62 would not be an improvement, it would be a wrong approval.

That is the whole reason match rate on its own is a bad measure. Match everything to anything and you score 100%. So missed matches and wrong matches are counted separately, and both are broken down by scenario.

Turning the pieces on one at a time

Every row is a real run over the same 85 records, with one more layer switched on.

Scoring only · 82.4% automated · 11 wrong

The obvious build, and the one that quietly approves eleven payments it should not have. A high automation rate on its own is not a good result, it is the warning sign.

+ guardrails · 0% automated · 0 wrong

Nothing gets approved at all, because every counterparty looks new and new counterparties cannot be automated. Correct, and useless.

+ memory · 69.4% automated · 0 wrong

The single biggest contribution, worth 69 points. Guardrails without memory are useless and memory without guardrails is pointless. Neither number means anything on its own.

+ the model · 72.9% automated · 0 wrong

Three and a half points, for three calls and six paise. The rules run first, so the model is only asked when its answer could still change the outcome.

Source · api/ablate.py, same held-out set for every row

The layer everyone would build first is the one that does the damage, and the layer with the model on it is worth the least.

Memory is the easiest place to lie to yourself

Since memory is worth more than everything else combined, it is also where a fake number would be easiest to produce: let a batch learn from the records it is being graded on and the score climbs beautifully and means nothing.

So a batch cannot learn from itself. Learning returns a new memory rather than modifying the one in use, and worked examples drawn from the graded records are filtered out of a graded run. An improvement can only ever show up in a later run.

The same records, two days apart

Half the customers start with history. The batch runs, a reviewer confirms what was held, and it runs again.

Day one

Day two

Handled without a human

29 of 85

61 of 85

Held as a new customer

40

resolved 32 of them

Wrong approvals

0

0

Source · learn.py, 85 records

What building it taught me

A high automation rate was the warning sign

82.4% looked like the best version until I counted what it had approved. The metric that felt like success was measuring the wrong thing entirely.

The model was the cheapest part to remove

It is worth 3.5 points. If it went away tomorrow the system would still be right about everything it approved, just less often willing to approve. That is the correct shape for a thing handling money.

Two layers were worthless apart and decisive together

Guardrails alone automate nothing. Memory alone approves wrongly. Measuring them one at a time is what showed that, and I would not have guessed it from reading the code.

Computing the deduction turned exceptions into explanations

The arithmetic that stopped the amount looking wrong is the same arithmetic that tells the reviewer why it was right. One piece of work, two jobs.

The interesting question was never how to match more. It was what a match has to survive before it is allowed to count.

Where it is still rough

Three things, and the first is the one that matters most.

Still open

The data is synthetic. That is what makes an answer key possible, and it is also what stops any of these numbers being evidence about real bank statements. Generated data contains the messes I thought to generate. A real ledger contains the ones I did not.

Still open

Eighty-five held-out records is a small set. Zero wrong approvals out of 85 is a good sign and not a rate: the honest reading is that nothing went wrong at this size, not that nothing would.

Still open

The thresholds were tuned on 45 records. They are sensible numbers rather than derived ones, and the value ceiling in particular is a judgement about risk that ought to belong to whoever owns the ledger, not to me.

What Hisaab came down to:

01

Recommend, never decide

The model chooses between options that code has already priced, and rules decide whether its choice is allowed to become an action.

02

Price the difference before comparing

The fee, the tax on the fee and the withheld tax are worked out first, so a well-behaved payment stops looking like an exception.

03

Measure what got approved wrongly

Not how much was matched. The build with the best automation rate was the one quietly approving eleven payments it should not have.