Intake
api/app/intake.py
- Normalise names and dates
- Money becomes integer paise, never a float
- Reject junk before it costs anything
Hisaab · Razorpay AI Buildathon · Track 04
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.
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 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.
Deduction
What it is
−₹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
Three passes, in the order they happened.
Explore
Scored every pair, took the best
Realise
82% automated, eleven wrong approvals
Insight
Let rules decide, not the score
A good score is a suggestion. Only a rule can approve money.
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.
The router is three branches of ordinary code. Which one a record takes decides whether it costs anything at all.
api/app/intake.py
fast path
no model, no scoring
most records land here
the work
blocking → settlement → scoring
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.
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.
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"}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.
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.
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.
Every row is a real run over the same 85 records, with one more layer switched on.
Configuration
What it bought
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.
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.
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 I saw
What it meant
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.
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
The model chooses between options that code has already priced, and rules decide whether its choice is allowed to become an action.
02
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
Not how much was matched. The build with the best automation rate was the one quietly approving eleven payments it should not have.