Founders

How to Write a Posting Gate That Rejects a Row Matched Twice

Antonio Evans8 min read

A flow diagram with three branches from a single input box labeled 'model output'.
Key points

Key points

  1. 1Use match cardinality, the number of candidate rows the model returns for a key that must be unique, as the only decision variable in the posting gate.
  2. 2Zero matches means block the post, exactly one match means allow it, and two or more matches means escalate to a person with every competing row and its score attached.
  3. 3Never let the gate pick the highest-scoring row, because a confidence score ranks candidates but does not establish identity.
  4. 4Detect duplicates by grouping the model output on the target key and counting, then branch on that count rather than on the score.

Why cardinality beats confidence scores

Most posting gates are wired the wrong way around. They take the model's top result, compare its confidence score to a threshold, and post if the score clears the bar. That design works for classification (one label, several candidates) but fails for matching, where the job is not to rank candidates but to decide whether a unique identity has been established.

A reconciliation pipeline that passes every candidate pair to a model and asks for a confidence score and a one-sentence reason will clear the easy majority of cases. The author of one such build reports that the model was confident and correct on the obvious matches, handled a middle band of harder cases well, and was confidently wrong on the rest, which is the worst failure mode in reconciliation, because there is no signal in the output that flags it (Amit Mishra on letting AI handle reconciliation). The score tells you how sure the model is; it tells you nothing about whether two ledger rows are the same economic event.

This matters because the downstream consequence of a wrong post is not a bad recommendation. It is a journal entry that no longer ties to its source document, a variance that appears in the close with no explanation, and a controller spending the day pulling general ledger exports to find the one entry that broke the tie (Cube on variance analysis). A general ledger reconciliation process verifies that recorded balances match supporting records; one duplicate post breaks that chain for every account it touches (Numeric's reconciliation guide). The right unit of decision is therefore not the score but the count.

There is a second reason. Model scores are not calibrated across prompts, documents or model versions, so a threshold tuned on one batch drifts on the next. A count of matching rows is exact, reproducible and auditable. Anyone reviewing the post months later can see why the gate allowed it: exactly one candidate carried that key. That is a far stronger audit trail than "the score was 0.94."

The decision rule in full

Define the key that must be unique across the population you are matching. In accounts payable that is usually the invoice number combined with the vendor; in bank reconciliation, the statement line reference; in a customer master cleanup, the tax registration number. The key defines what "one row" means. Then branch:

Cardinality of candidate rows for the keyOutcomeWhat the gate does
0BlockReject the post, mark the source row unmatched, queue for exception review
1AllowPost the match, record the matched row id and the model score for audit
2 or moreEscalateDo not post, open a human task with all candidates, their fields and their scores

The table is the whole rule. Everything else in this article is implementation.

Note what is not in the table: the score. The score still has a place, as evidence attached to the escalation, and as a diagnostic you monitor to see whether the model is drifting. But it has no vote. A gate that allows when the top score is 0.98 and the second is 0.61 has quietly decided that identity is a ranking problem, and that decision is the bug.

There is one refinement worth making. Zero matches is a block, not an escalation, because there is nothing to escalate: no candidate exists to compare. Route it to an unmatched queue with a different label so that reviewers can triage missing data separately from ambiguous data. Mixing the two queues is a common reason human review slows down; the reviewer opening a missing-invoice case needs a different playbook from the one judging two plausible candidates.

Detecting duplicate matches: group by the key and count

Before you can branch on cardinality, you have to measure it. The model returns a list of candidates per source row, so the detection step is a grouping operation, exactly the pattern used to find duplicate values in any table.

In SQL, grouping on the key and keeping groups with a count above one returns every ambiguous source row:

SELECT
    source_row_id,
    target_key,
    COUNT(*) AS candidate_count
FROM model_candidates
GROUP BY
    source_row_id,
    target_key
HAVING COUNT(*) > 1;

The HAVING clause is what does the work here, because unlike WHERE it filters on the aggregate rather than on individual rows (Atlassian on finding duplicate values). Grouping on a single column such as an invoice number catches collisions where one identifier was reused; grouping on invoice number plus vendor catches collisions within a vendor while allowing the same invoice number to legitimately appear under different vendors (the canonical Stack Overflow answer). The second form is almost always the right one for financial matching, because invoice numbering schemes are frequently vendor-specific.

The same logic in Python, operating on the model's response before anything is written:

from collections import Counter

def cardinality_by_key(candidates, key_fields):
    counts = Counter()
    for row in candidates:
        key = tuple(row[f] for f in key_fields)
        counts[key] += 1
    return counts

Two practical details. First, normalize the key before counting. Leading zeros in a reference, trailing whitespace in a vendor name, and case differences will split what is logically one key into two groups and hide a genuine duplicate. Second, count candidates, not distinct target ids. If the model returns the same target row twice for the same source row, that is still a cardinality of two and should still escalate, because it means the retrieval step is not deterministic and the pipeline has a second problem worth seeing.

For larger batches, do the grouping in the database where the candidates land, not in application memory. A single HAVING COUNT(*) > 1 query over the full candidate table gives you every escalation in one pass and leaves an auditable record of the decision inputs.

Encoding the three outcomes

The gate itself should be a pure function: take the candidates for one source row, return one of three outcomes plus the evidence. Keeping it pure makes it trivially testable, which is the next section.

from enum import Enum

class Outcome(Enum):
    ALLOW = "allow"
    BLOCK = "block"
    ESCALATE = "escalate"

def gate(source_row, candidates, key_fields, score_field="score"):
    grouped = {}
    for cand in candidates:
        key = tuple(cand[f] for f in key_fields)
        grouped.setdefault(key, []).append(cand)

    if not grouped:
        return Outcome.BLOCK, {"reason": "no_candidates", "source_row": source_row}

    if len(grouped) > 1:
        return Outcome.ESCALATE, {
            "reason": "key_collision",
            "source_row": source_row,
            "keys": list(grouped),
        }

    group = next(iter(grouped.values()))
    if len(group) > 1:
        ranked = sorted(group, key=lambda c: c[score_field], reverse=True)
        return Outcome.ESCALATE, {
            "reason": "multiple_candidates",
            "source_row": source_row,
            "candidates": [
                {"target_id": c["target_id"], "score": c[score_field]}
                for c in ranked
            ],
        }

    return Outcome.ALLOW, {"source_row": source_row, "target": group[0]}

Three points about this implementation.

The escalation payload carries every candidate, sorted by score for the reviewer's convenience, but the order is presentation only. The gate has not chosen. That distinction matters when someone later reads the audit log and assumes the first row was the model's preference; label the field explicitly as candidates, not best_match.

The gate handles both cardinality shapes. If the model returns candidates across two different keys (a real problem in messy data), that is a key collision and escalates before the per-key count is even considered. If it returns two candidates for one key, that escalates as multiple candidates. Both are one-to-many in the sense that matters: the model could not settle on one row.

The block branch returns no candidates at all. Resist the temptation to let the model suggest a "closest miss" here. A block means nothing matched; a reviewer needs to see the source row and the search criteria, not a weak candidate that may anchor them.

Wiring this into a broader pipeline is where the platform question arises. If the same gate has to run against several model providers, or against text, embedding and document models in one flow, Quanta Inference (https://quantaintelligence.ai/inference) exposes them through one OpenAI-compatible API key, so the gate function above does not change when the model behind candidates does. If you would rather have someone build the reconciliation pipeline around your own ledger schema, describe it at https://quantaintelligence.ai/ and a specialist will pick it up with you.

Testing the gate with fixtures for 0, 1 and 2 matches

The gate has three outcomes and one edge case, so the test suite is short and should never grow much. The value of writing it now is that the fixtures double as documentation of what escalation means.

def test_zero_matches_blocks():
    outcome, _ = gate({"id": 1}, [], ["invoice_no", "vendor_id"])
    assert outcome is Outcome.BLOCK

def test_one_match_allows():
    cands = [{"target_id": "t1", "invoice_no": "A100", "vendor_id": 7, "score": 0.91}]
    outcome, evidence = gate({"id": 1}, cands, ["invoice_no", "vendor_id"])
    assert outcome is Outcome.ALLOW
    assert evidence["target"]["target_id"] == "t1"

def test_two_matches_escalates_and_keeps_both():
    cands = [
        {"target_id": "t1", "invoice_no": "A100", "vendor_id": 7, "score": 0.91},
        {"target_id": "t2", "invoice_no": "A100", "vendor_id": 7, "score": 0.62},
    ]
    outcome, evidence = gate({"id": 1}, cands, ["invoice_no", "vendor_id"])
    assert outcome is Outcome.ESCALATE
    assert len(evidence["candidates"]) == 2

The third test is the one that matters most, and it should assert something a score-threshold gate would fail: that the lower-scoring candidate is still present in the evidence. A gate that allows the top row and silently drops the other has passed the first two tests and failed the article.

Add two more fixtures that catch the mistakes from the previous section. A case where the same target appears twice for the same key should escalate, not allow, proving the gate counts candidates rather than distinct ids. A case where the key differs on trailing whitespace should also escalate, proving that you have not yet normalized, which is a bug you want the suite to find rather than the close. Seed the fixtures from real production rows once the gate is live, and keep one fixture per outcome at minimum permanently.

Finally, test the reason codes, not just the outcome. Reviewers triage by reason, and an escalation labelled "key_collision" that actually contains two candidates for one key will send them down the wrong path.

Tuning the review queue, not the gate

The gate has no knobs, and that is deliberate. But the volume of escalations will vary by data source, and you need a way to manage human load without moving the decision boundary.

Track escalation rate and reason per source. If, say, escalations from one vendor feed run at 40 percent while the portfolio average is 5 percent, the problem is almost certainly that vendor's data (reused references, inconsistent names), not the gate. Fix the upstream data. The same habit applies to variance analysis: agree on a materiality threshold, then spend your attention on the few lines that drive the result rather than on twenty small differences (SmartBooks on variance analysis). For the gate, the equivalent threshold flags which sources need investigation, never which rows to auto-post.

Attach root cause to every escalation, so that a reviewer marks each case as a genuine ambiguity (two real candidates) or a data defect (bad key, missing field). Only the first category justifies human judgment each month. The second is a defect with a fix.

Do not let reviewers post from the escalation queue without writing down which candidate they chose and why. That record is what lets you later check whether the model's ranking was informative at all, and if it turns out to be entirely uninformative, you have learned something the scores alone could never tell you.

Where this rule does not apply

The cardinality rule assumes the key really must be unique. In some matching problems it does not. A single payment can legitimately settle several invoices, and several payments can settle one. If your domain has genuine one-to-many relationships, the gate must know which keys are expected to fan out, and only apply the strict rule where uniqueness is a business invariant.

The rule also assumes the candidate set is complete. If the model returns one row because the retrieval step only ever returns one, the gate will allow everything. Measure candidate-set size in production: a median of exactly one across all source rows usually means the retrieval step is capping results, not that the data is clean.

Neither caveat argues for a score threshold. They argue for making the uniqueness assumption explicit in configuration, tested like everything else, so that the gate's promises are visible to the people who depend on them.

FAQ

FAQ

Why not let the gate pick the higher-scoring row when the model matches twice?

Because the score ranks candidates, it does not prove identity. In a one-to-many match, two rows both look plausible, and the difference between their scores is often noise rather than evidence. Posting the higher one turns an ambiguity into a silent error that appears later as an unexplained variance in the close.

What counts as a duplicate match for the gate?

Two candidates for the same key, where the key is the field or combination of fields that must be unique. For invoices, that is usually invoice number plus vendor, since invoice numbering schemes are often vendor-specific and reusing the number alone would produce false collisions.

Should zero matches ever auto-post?

No. Zero candidates means no evidence of a match, so there is nothing to review except the source row and the search criteria. Route it to a separate unmatched queue rather than the ambiguity queue, because the playbook for missing data differs from the playbook for two plausible candidates.

How do I handle a key that is legitimately one-to-many, such as a payment settling several invoices?

Declare those keys as fan-out keys in the gate's configuration and exempt them from the strict uniqueness rule. Apply the cardinality gate only where uniqueness is a genuine business invariant, and keep the assumption visible and tested so the gate's guarantees stay honest.

What should the escalation payload contain?

Every candidate row, its identifier, its score, and the key that grouped them, plus a reason code that distinguishes a key collision from multiple candidates on one key. The gate must not present a preferred candidate, because doing so invites the reviewer to rubber-stamp the model's ranking instead of judging it.

Sources

Sources

  1. What I learned trying to let AI handle financial reconciliation
  2. Variance Analysis: Every Why, Traced to the Transaction
  3. General Ledger Reconciliation: Step-by-Step Guide
  4. How to Find Duplicate Values in a SQL Table
  5. Finding and deleting duplicate values in a SQL table
  6. Variance analysis: budget vs actual in 5 steps
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