Founders

How to Write a Reconciliation Prompt That Cites the Row It Flags

Antonio Evans8 min read

A split-screen diagram: on the left, a table of ledger rows and plan rows with colored row IDs; on the right.
Key points

Key points

  1. 1A reconciliation prompt must require, on every output line, the ledger row ID, the plan row ID, both amounts, and the arithmetic that connects them.
  2. 2Row identifiers have to be defined in the prompt itself, exactly as they appear in the source files, or the model will invent plausible substitutes.
  3. 3Many-to-one matches and unmatched items need their own schema fields, because a single "difference" column silently hides them.
  4. 4A deterministic verification pass that re-reads the cited rows and recomputes the arithmetic is what turns a draft into an auditable workpaper.

The failure mode you are actually designing against

A model writing variance commentary without row citations produces text that reads well and cannot be checked. The typical output says something like "Utilities are overstated by roughly 20 versus plan," which is a sentence a controller cannot verify without redoing the entire comparison by hand. That is worse than no commentary, because it looks finished.

The same problem shows up in matching itself. One practitioner who built a model-driven reconciliation pipeline reported that the model handled the easy majority of pairs quickly, was useful on the ambiguous middle, and failed with confidence on a small remainder, which in reconciliation is the worst possible failure mode (Amit Mishra on LLM reconciliation). The lesson generalizes: let the model draft language, but make every factual claim in that language traceable to a row you can open.

The design principle is simple. The model is not the source of truth. The rows are. The prompt exists to make the model's output a set of pointers back to those rows.

Define row identifiers before you ask for anything

The first section of your prompt is not the task. It is the identity scheme. A model cannot cite "row 4521" unless it knows what a row is, which column carries the ID, and whether IDs are unique.

Write this block explicitly, and keep it short enough that it stays in attention:

SOURCE ROW IDENTIFIERS

Ledger file (ledger.csv):
  - Column "row_id" is the unique ledger identifier. Format: integer, e.g. 4521.
  - Column "account" is the GL account name, e.g. "Utilities".
  - Column "amount" is in USD, two decimals, positive for debits.
  - 1,204 rows, row_id values are unique. Do not construct IDs.

Plan file (plan.csv):
  - Column "plan_id" is the unique plan identifier. Format: string, e.g. "889".
  - Column "account" uses the same account names as the ledger.
  - Column "amount" is in USD, two decimals, positive for debits.
  - 342 rows, plan_id values are unique.

Cite row_id and plan_id exactly as they appear. Never reformat, zero-pad or abbreviate them.
If you cannot find a row that supports a claim, write "NO_CITATION" and stop that line.

Two details do most of the work here. First, stating the uniqueness and the expected magnitude ("integer, e.g. 4521") lets the model validate its own citations against the shape of the data. Second, the NO_CITATION escape hatch matters more than it looks. Without it, a model under pressure to produce a complete table will manufacture a row rather than admit a gap. Giving it a legal way to fail converts silent fabrication into a visible flag.

If your source data is large, this is also where prompt format earns its keep. A benchmark of structured prompt formats found that lossless evidence aliases, which define repeated metadata once and encode each record as a compact row with a stable citation ID, beat JSON for large evidence-heavy inputs on both token cost and citation validity (structured prompt format benchmark). The practical takeaway: send a compact table with a citation column, not a pretty-printed JSON array with the same field names repeated on every record.

The output schema that makes a line auditable

The schema is the contract. Every field exists because a reviewer will need it. Here is a schema that covers the ordinary cases:

FieldRequiredExampleWhy it exists
ledger_row_idyes (or NO_CITATION)4521Opens the exact actual row
plan_row_idyes (or NO_CITATION)889Opens the exact plan row
accountyesUtilitiesConfirms the match is on the same line item
ledger_amountyes120.00Source figure, copied not computed
plan_amountyes100.00Source figure, copied not computed
differenceyes20.00Must equal ledger_amount minus plan_amount
arithmeticyes120.00 - 100.00 = 20.00Lets a script recompute without parsing prose
match_typeyesone_to_one / many_to_one / unmatchedSeparates clean matches from judgment calls
favourabilityyesU (cost over plan)Removes the plus-equals-good ambiguity
narrativeyesOne sentence, cites both IDsThe human-readable line

The favourability field is not decoration. A positive variance on revenue is good news and a positive variance on cost is bad news, and a column of plus and minus signs invites exactly that confusion in a management meeting (Smartbooks on variance analysis). Forcing an explicit F or U label in the schema means the model cannot hedge by writing "favorable" in one row and "unfavorable" for the same sign in the next.

The arithmetic field is the one people skip. Do not. A prose sentence can be skimmed and believed. A string that says 120.00 - 100.00 = 20.00 can be split on the operator and recomputed by twelve lines of Python. That difference is the entire audit trail.

A worked example, from bad to auditable

Take a utilities line where the ledger booked 120.00 and the plan assumed 100.00.

The weak draft: "Utilities are overstated by about 20 versus plan, likely due to higher rates."

The auditable draft, following the schema:

Ledger row 4521 (Utilities, 120.00) vs plan row 889 (Utilities, 100.00):
difference 20.00 (120.00 - 100.00). Favourability: U. Match type: one_to_one.

The second version is shorter, not longer, and a reviewer can settle it in ten seconds. They open row 4521, see 120.00, open plan row 889, see 100.00, and the arithmetic is already written down. No interpretation is required to check the fact. The interpretation ("likely due to higher rates") has been removed entirely, because the model has no citation for it. That removal is a feature: root cause belongs to a human or to a system that can cite a contract or a rate change, not to a model guessing.

Scale this to a monthly close. If you have 40 material variances and each one carries four checkable fields, a reviewer is checking 160 facts against source rather than reading 40 paragraphs and trusting them. That is the difference between a draft that saves an afternoon and a draft that creates review work.

Handling many-to-one matches and unmatched items

Real reconciliations are not clean pairs. Four or five ledger rows may map to a single plan row, and some rows map to nothing at all. A schema with one ledger ID and one plan ID per line will force the model to pick arbitrarily or to leave one side blank with no explanation.

Extend the schema rather than bending it:

SituationRepresentationRule
One ledger row to one plan rowSingle linedifference equals ledger_amount minus plan_amount
Many ledger rows to one plan rowOne line per ledger row with the same plan_row_id, plus a summary lineSummary line difference equals the sum of ledger rows minus plan_amount
Ledger row with no plan rowplan_row_id = UNMATCHEDExplain in narrative that no plan row exists, cite ledger_row_id
Plan row with no ledger rowledger_row_id = UNMATCHEDSame rule, mirrored
Aggregate difference with no traceable rowsReject the lineRewrite or drop; never output an aggregate without member rows

The many-to-one case is where a reconciliation draft earns or loses trust. An aggregate line that says "Marketing is 640 over plan" is not auditable. A block that lists four ledger rows, each citing the same plan row, with a summation line whose arithmetic names those four IDs, is. In the prompt, state the summation rule explicitly: the summary difference must equal the sum of the member ledger amounts minus the plan amount, and the arithmetic field must list the member IDs.

For unmatched items, the useful output is a short, citation-bearing statement of absence. "Ledger row 5120 (Travel, 340.00) has no plan row; searched plan_id column, no account match" is checkable: a reviewer can confirm both that the row exists and that the search was appropriate. "Several travel costs appear unbudgeted" is not.

The verification step that closes the loop

The prompt is only half the system. The other half is a script that treats the model's output as a set of claims to be tested against the same CSV files the model was given.

The checks are mechanical:

  1. Every non-UNMATCHED ledger_row_id exists in ledger.csv.
  2. Every non-UNMATCHED plan_row_id exists in plan.csv.
  3. ledger_amount and plan_amount equal the amounts in those rows, to the cent.
  4. Parsing the arithmetic string and recomputing it yields the stated difference.
  5. The account on the ledger row matches the account on the plan row, or match_type is not one_to_one.
  6. The sum of differences across all lines equals any total the output claims.

Any line that fails goes back as a rejection with the specific failure, not as a general retry. A model told "row 4521 exists but its amount is 118.50, not 120.00" corrects quickly. A model told "please check your work" will rewrite something plausible and possibly wrong again.

Keep a standing instruction in the prompt that anticipates this loop: any line that fails verification is rejected in full, and the model must re-derive it from the rows rather than paraphrase the rejected version. This is the same discipline that finance prompt frameworks recommend when they tell you to define verification steps inside the prompt and to validate calculations before use (AFP on finance prompts). It also reflects the practical lesson from reconciliation engineering: the model is best treated as a writer of draft commentary sitting on top of a deterministic matcher, never as the matcher itself (Amit Mishra).

A minimal check looks like this:

import csv, re

ledger = {r["row_id"]: r for r in csv.DictReader(open("ledger.csv"))}
plan = {r["plan_id"]: r for r in csv.DictReader(open("plan.csv"))}

def verify(line):
    lid, pid = line["ledger_row_id"], line["plan_row_id"]
    if lid != "UNMATCHED":
        row = ledger[lid]
        if abs(float(row["amount"]) - float(line["ledger_amount"])) > 0.005:
            return "amount mismatch on ledger row " + lid
    if pid != "UNMATCHED":
        row = plan[pid]
        if abs(float(row["amount"]) - float(line["plan_amount"])) > 0.005:
            return "amount mismatch on plan row " + pid
    left, right = line["arithmetic"].split("=")[0].split("-")
    if abs((float(left) - float(right)) - float(line["difference"])) > 0.005:
        return "arithmetic does not reconcile"
    return None

The point is not the code, it is the posture. Every claim the model makes about a number is a testable assertion, and the system tests all of them before a human reads the draft. Humans then read only the lines that passed, plus the list of failures, which is where their judgment is actually worth spending.

A compact checklist before you send the prompt

CheckWhy it matters
Row ID columns named in the prompt with format and uniqueness statedPrevents invented or reformatted IDs
NO_CITATION and UNMATCHED defined as legal outputsConverts fabrication into a visible gap
Arithmetic field mandatory on every lineMakes each line script-checkable
Favourability label separate from signStops favorable and unfavorable being confused
Many-to-one rule stated with a summation formulaStops aggregates with no member rows
Materiality threshold stated in the promptStops the model explaining trivial lines
Verification loop described inside the promptTells the model that uncited claims are rejected

On that sixth row: a threshold matters for the same reason it matters in human commentary. Agreeing to explain only variances above, say, a stated floor in currency and percentage keeps attention on the lines that drive the result rather than twenty small differences (Smartbooks). State the threshold as a rule in the prompt and let the model mark everything below it as "below threshold, no commentary required" with its rows still cited. You keep the audit trail without drowning in it.

The same discipline applies when the model drafts the written commentary that sits above the numbers. Good variance narrative states what happened, why, the forward impact and the action, in that order, and a decomposition is challengeable where a vague explanation is not (Planir on variance commentary). Row citations let you keep the good structure while making every "why" accountable to a specific transaction, which is also how modern planning tools describe their own traceability (Cube on variance analysis).

Once the prompt and verifier exist, running the same job across every model you want to trial is mostly plumbing. Quanta Inference puts text, image, video, audio and embedding models behind one OpenAI-compatible API key with prepaid US dollar pricing, so you can point the same reconciliation prompt and the same verifier at more than one model and compare citation pass rates on your own data (https://quantaintelligence.ai/inference).

FAQ

FAQ

How many source rows should I include in one prompt?

Include the rows that plausibly match, not the whole ledger. If the pre-matched candidate set is small, everything fits. If it is not, run a deterministic match first, group the result by account, and send one account per prompt with only its member rows. That keeps citation IDs stable and keeps the evidence small enough that the model does not lose track of which row is which.

What should the model do when two ledger rows are identical?

Tell it explicitly in the identifier block: if two ledger rows share an account, amount and description, cite both IDs on separate lines and set match_type to one_to_one for each against the same plan row. Duplicate-looking rows are usually real duplicates or real recurring charges, and the choice between those is a human decision that the citation makes possible.

Does a citation guarantee the number is right?

No. A citation guarantees the claim is checkable. The verifier confirms that the cited row exists and holds the stated amount, but it cannot confirm that the match is economically correct. That second judgment stays with the reviewer, who now has the rows in front of them instead of a paragraph.

Should the model write the narrative sentence, or should I template it?

Template it for the routine lines and let the model write only where judgment adds something, such as explaining why an unmatched item appeared. A templated line like "Ledger row 4521 vs plan row 889: difference 20.00" is free, consistent and always correct if the fields are correct. Reserve model-generated prose for the few lines a human will actually read closely.

How do I handle currency and sign conventions in the schema?

State both in the identifier block, per file, and require every amount to be quoted in the source currency with no conversion performed by the model. If ledger and plan use different currencies, add a required fx_rate field with its own citation, and make the arithmetic string show the multiplication explicitly so the verifier can recompute it.

Sources

Sources

  1. What I learned trying to let AI handle financial reconciliation
  2. Best Structured Prompt Formats for LLMs, Ranked
  3. Variance analysis: budget vs actual in 5 steps
  4. AI Prompts for Finance: 15 Real-World Examples + A ...
  5. How to Write Variance Analysis Commentary Boards Read
  6. Variance Analysis: Every Why, Traced to the Transaction
About the author

About the author

Antonio Evans
Antonio Evans

Antonio Evans is the founder of Quanta Intelligence and a fractional CTO who builds HIPAA-ready AI systems for health and life-science companies. Before that he was technical co-founder of FriendsEAT for twenty years, CEO of Socialdraft, and a data engineer at IBM. Antonio lives with his dog Santino in Lisbon, Portugal.

All articles