Resolving ambiguous MCC mappings for expense routing
An ambiguous MCC mapping is a transaction whose vendor legitimately resolves to more than one merchant category code, so a first-match lookup routes identical spend down conflicting policies depending on which network stamped the charge. This guide is the disambiguation detail delegated by the Merchant Category Code Routing stage, which itself sits inside the broader Automated Policy Validation & Anomaly Flagging framework. The parent stage owns the priority-ordered gate that flags a record as AMBIGUOUS; here we own what happens next — a deterministic score built from the vendor name, the amount, and this vendor’s cleared history that either resolves to a single defensible code or routes to a reviewer, never a coin flip.
The engineering objective is not to guess the “most likely” category — it is to resolve only when the evidence is decisive and to escalate loudly when it is not, so no auto-approval ever rests on a tie.
Why Standard Approaches Fail
The routing stage hands off a record only after it has already been marked AMBIGUOUS, which means the easy cases are gone and what remains is exactly where naive heuristics misfire. Three named failure modes account for almost every bad auto-resolution:
- First-match table lookup. A dictionary keyed on
mcc_codereturns whichever category the code was first registered under, so a hotel restaurant stamped5812“eating places” by one acquirer and7011“lodging” by another routes down two different policies for the same stay. The lookup is deterministic but wrong, and because it never emits a confidence, nothing downstream can tell a decisive match from a lucky one. - Single-signal classification. Ranking candidates on vendor-name text alone collapses the moment the payment-network descriptor diverges from the receipt string owned by Receipt Ingestion & OCR Data Extraction — “GRAND HOTEL F&B #1180” fuzzy-matches nothing in your table. Ranking on amount alone routes a $420 dinner into lodging. Any one signal has a failure surface wide enough to leak spend past Dynamic Threshold Tuning.
- Argmax without a margin. Picking the top-scoring candidate with no floor and no gap check turns a 0.31-versus-0.29 near-tie into a confident auto-approval. The two candidates were statistically indistinguishable, yet the record posts as if the router were certain, and the audit trail records a decision that no reviewer would have made.
The remedy is a score that fuses several independent signals, and a gate that resolves only when one candidate is both above an absolute floor and clearly ahead of the field — otherwise the whole candidate set is preserved and sent to a human.
Architecture & Algorithm
Reliable disambiguation combines three signals that fail independently: token-set name similarity, an amount-band prior learned per code, and this vendor’s cleared-history distribution across the candidate codes. Each signal is bounded to [0, 1], the three are combined under fixed weights, and the winner must clear both a confidence_floor and a margin_floor over the runner-up before it resolves. Money stays an integer count of minor units so amount-band comparisons never drift, and every decision carries a deterministic hash so re-running the same input reproduces the same audit fingerprint.
from __future__ import annotations
import hashlib
import re
from dataclasses import dataclass
from datetime import datetime, timezone
from enum import Enum
from typing import Optional
import structlog
logger = structlog.get_logger("expense.mcc_disambiguation")
POLICY_VERSION = "mcc-disambig/2026.07"
_TOKEN = re.compile(r"[a-z0-9]+")
class ResolutionState(str, Enum):
RESOLVED = "RESOLVED"
MANUAL_REVIEW = "MANUAL_REVIEW"
@dataclass(frozen=True)
class MCCProfile:
"""Prior knowledge for one candidate code, learned from cleared history."""
mcc_code: str
canonical_names: tuple[str, ...]
typical_low_minor: int
typical_high_minor: int
prior_count: int = 0
@dataclass(frozen=True)
class AmbiguousTxn:
transaction_id: str
vendor_name: str
amount_minor: int
candidate_mccs: tuple[str, ...]
@dataclass(frozen=True)
class DisambiguationWeights:
name: float = 0.5
amount: float = 0.3
history: float = 0.2
@dataclass(frozen=True)
class Resolution:
transaction_id: str
state: ResolutionState
chosen_mcc: Optional[str]
confidence: float
ranked: tuple[tuple[str, float], ...]
detail: str
policy_version: str
decision_hash: str
resolved_at_utc: str
def _tokens(name: str) -> frozenset[str]:
return frozenset(_TOKEN.findall(name.lower()))
def _name_score(vendor: str, profile: MCCProfile) -> float:
"""Best Jaccard token overlap between the vendor string and any known name."""
v = _tokens(vendor)
if not v:
return 0.0
best = 0.0
for canonical in profile.canonical_names:
c = _tokens(canonical)
if c:
best = max(best, len(v & c) / len(v | c))
return best
def _amount_score(amount_minor: int, profile: MCCProfile) -> float:
"""1.0 inside the code's typical band, decaying linearly outside it."""
lo, hi = profile.typical_low_minor, profile.typical_high_minor
if lo <= amount_minor <= hi:
return 1.0
span = max(hi - lo, 1)
dist = (lo - amount_minor) if amount_minor < lo else (amount_minor - hi)
return max(0.0, 1.0 - dist / span)
def _history_score(profile: MCCProfile, total_priors: int) -> float:
"""Share of this vendor's cleared routings that landed on this code."""
return profile.prior_count / total_priors if total_priors > 0 else 0.0
With the three signals defined, the engine ranks every known candidate, applies the floor-and-margin gate, and emits a Resolution. The gate is the safety property: a code resolves only when it is both above the floor and clearly ahead of the runner-up, so near-ties can never auto-approve.
class MCCDisambiguator:
"""Resolve an ambiguous candidate set to one code, or escalate to review."""
def __init__(
self,
profiles: dict[str, MCCProfile],
*,
weights: DisambiguationWeights = DisambiguationWeights(),
confidence_floor: float = 0.70,
margin_floor: float = 0.15,
policy_version: str = POLICY_VERSION,
) -> None:
self._profiles = profiles
self._w = weights
self._floor = confidence_floor
self._margin = margin_floor
self._policy_version = policy_version
def _score(self, txn: AmbiguousTxn, profile: MCCProfile, total_priors: int) -> float:
return (
self._w.name * _name_score(txn.vendor_name, profile)
+ self._w.amount * _amount_score(txn.amount_minor, profile)
+ self._w.history * _history_score(profile, total_priors)
)
def resolve(self, txn: AmbiguousTxn) -> Resolution:
known = [self._profiles[c] for c in txn.candidate_mccs if c in self._profiles]
total_priors = sum(p.prior_count for p in known)
ranked = tuple(sorted(
((p.mcc_code, round(self._score(txn, p, total_priors), 4)) for p in known),
key=lambda kv: (-kv[1], kv[0]),
))
if not ranked:
return self._decide(txn, ResolutionState.MANUAL_REVIEW, None, 0.0, ranked,
"no known profile for any candidate code")
top_code, top_conf = ranked[0]
runner_conf = ranked[1][1] if len(ranked) > 1 else 0.0
if top_conf >= self._floor and (top_conf - runner_conf) >= self._margin:
return self._decide(txn, ResolutionState.RESOLVED, top_code, top_conf,
ranked, "single decisive candidate above floor and margin")
return self._decide(txn, ResolutionState.MANUAL_REVIEW, None, top_conf,
ranked, "no candidate cleared both floor and margin")
def _decide(
self,
txn: AmbiguousTxn,
state: ResolutionState,
chosen: Optional[str],
confidence: float,
ranked: tuple[tuple[str, float], ...],
detail: str,
) -> Resolution:
fingerprint = "|".join([txn.transaction_id, state.value, str(chosen),
f"{confidence:.4f}", self._policy_version])
decision_hash = hashlib.sha256(fingerprint.encode("utf-8")).hexdigest()
resolution = Resolution(
transaction_id=txn.transaction_id, state=state, chosen_mcc=chosen,
confidence=confidence, ranked=ranked, detail=detail,
policy_version=self._policy_version, decision_hash=decision_hash,
resolved_at_utc=datetime.now(timezone.utc).isoformat(),
)
logger.info("mcc_disambiguated", transaction_id=txn.transaction_id,
state=state.value, chosen_mcc=chosen, confidence=confidence,
decision_hash=decision_hash, policy_version=self._policy_version)
return resolution
Because the profiles, the weights, and both thresholds are frozen, the ranking is stable and the decision_hash is reproducible: reprocessing a corrected batch under the same policy_version yields identical fingerprints, so audit continuity survives any replay.
Step-by-Step Integration
-
Trigger only on the
AMBIGUOUSstate. Records that the Merchant Category Code Routing engine already resolved cleanly must skip this path; disambiguation runs only when the router could not choose, so you never re-litigate a decisive routing. -
Build profiles from cleared history, not from the code table. Populate each
MCCProfilefrom transactions that already passed audit for this vendor — canonical name variants, the observed amount band, and the count per code. The internal categories these codes map onto come from Expense Category Taxonomies; keep that mapping read-only here. -
Pin the weights and thresholds together. Version-pin
name/amount/historyweights,confidence_floor, andmargin_floorin config under onepolicy_version— the resolution is a function of all five, so an unpinned change is an unlogged behaviour change. -
Assert the floor-and-margin gate before wiring it in. Confirm a decisive case resolves and a near-tie escalates, and that the decision is idempotent:
profiles = { "5812": MCCProfile("5812", ("grand hotel restaurant", "hotel dining"), 2_000, 9_000, prior_count=3), "7011": MCCProfile("7011", ("grand hotel", "grand hotel lodging"), 15_000, 60_000, prior_count=27), } engine = MCCDisambiguator(profiles) lodging = AmbiguousTxn("T-88", "Grand Hotel", 42_000, ("5812", "7011")) decided = engine.resolve(lodging) assert decided.state is ResolutionState.RESOLVED assert decided.chosen_mcc == "7011" assert decided.decision_hash == engine.resolve(lodging).decision_hash toss_up = AmbiguousTxn("T-89", "Airport Kiosk", 1_200, ("5812", "7011")) assert engine.resolve(toss_up).state is ResolutionState.MANUAL_REVIEW -
Send
MANUAL_REVIEWrecords to the queue with the ranked set attached. Route escalations with the fullrankedlist and per-signal detail so a reviewer sees why no candidate won, not just that one did not. A resolved single code with no history should still route to the fallback path described in building fallback MCC taxonomy rules. -
Mirror every resolution to the append-only ledger. Append each
structlogevent to the same ledger the router writes, sodecision_hash,confidence, andpolicy_versionchain the disambiguation back to the routing decision that requested it.
Edge Cases & Gotchas
| Edge condition | What breaks | Mitigation |
|---|---|---|
| Cold-start vendor, no history | history signal is 0 for every candidate, deflating all scores below the floor |
Correct behaviour — route to review; only promote to auto-resolve after N cleared priors accrue |
| Two candidates within the margin | Argmax would pick a near-tie and post a wrong category | Enforce margin_floor; a sub-margin gap is MANUAL_REVIEW by design |
| Network-descriptor divergence | Card-feed name and OCR receipt name differ, zeroing the name signal | Store multiple canonical_names per code; let amount and history carry the decision |
| Amount band too wide | A permissive band scores 1.0 for unrelated spend and stops discriminating | Learn bands from cleared history percentiles, not min/max; re-derive per policy_version |
| Weight drift over releases | Silent weight edits change past decisions and break audit reconstruction | Fold weights into policy_version; never mutate them without a version bump |
| Split transaction, one receipt two codes | Both codes are legitimately correct for different line items | Do not force one winner; hand the split to Duplicate Receipt Detection and route per line |
FAQ
How many signals should the confidence score combine?
Use at least two that fail independently; three is the practical sweet spot. Vendor-name similarity, an amount-band prior, and a per-vendor history distribution each break under different conditions, so their weighted sum is far more robust than any single ranker. Adding more signals rarely helps once these three are in place, and every extra weight is another value you must pin under the policy version.
What confidence floor and margin should I start with?
Start with a confidence_floor of 0.70 and a margin_floor of 0.15 on the zero-to-one scale, then tune against your own false-resolution and escalation rates rather than copying them blindly. The floor stops a weak best-guess from resolving, and the margin stops a near-tie from resolving even when the top score is high. Pin both in config so audit reconstruction resolves to exactly one gate.
What happens when a vendor has no prior history?
The history signal contributes zero for every candidate, which usually pulls the top score below the floor and routes the record to review — which is the correct outcome for a cold-start vendor. Do not backfill history with guesses; let the reviewer’s decision become the first cleared prior. After a few audited routings accrue, the same vendor will start resolving automatically.
Should the disambiguator ever overwrite a network-provided MCC?
No. This component only chooses among the candidate codes the routing stage already flagged as ambiguous; it never invents a code the network did not supply or overwrite a clean one. When it resolves, it records which candidate won and why; when it cannot, it preserves the whole candidate set for a human. Overwriting a network code would break the chain of custody an auditor relies on.
How is this different from building fallback taxonomy rules?
Disambiguation runs when a code is present but maps to several categories; fallback rules run when the code is missing or unknown to the router entirely. This component scores real candidate codes against each other, whereas the fallback taxonomy assigns a default category by pattern when there is no code to score. They are complementary stages — see building fallback MCC taxonomy rules for the missing-code path.
Related
- Merchant Category Code Routing — the parent guide whose
AMBIGUOUSstate this disambiguation path resolves - Building fallback MCC taxonomy rules for unmapped merchants — the sibling path for missing or unknown codes
- Expense Category Taxonomies — owns the code-to-category mapping this stage reads
- Duplicate Receipt Detection — reconciles network-divergent duplicates and split transactions
- Dynamic Threshold Tuning — the downstream spend-band gate that must never receive a coin-flip category