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.

Signal-scored disambiguation of a candidate merchant-category-code set behind a confidence gate A candidate MCC set for one vendor feeds three deterministic signals: vendor-name similarity by token-set ratio, an amount-band prior per code, and a prior-history match from this vendor's cleared routings. The three signals combine into a weighted confidence score in the zero-to-one range. A gate resolves to a single MCC only when the top candidate clears both a confidence floor and a margin over the runner-up; otherwise the candidate set is kept and the record routes to a reviewer queue. Both exits append the chosen code, candidate set, per-signal scores, final confidence, a SHA-256 decision hash, and the pinned policy version to one append-only audit ledger. Deterministic disambiguation, then a confidence gate Three signals score every candidate code; only a decisive single winner auto-resolves. Candidate MCC set 5812 · 7011 · 5999 one vendor, many codes Vendor-name similarity token-set ratio ≥ 0.90 Amount-band prior typical spend per code Prior-history match vendor's past routing Weighted confidence w₁·name + w₂·amt + w₃·history score ∈ [0, 1] score ≥ τ? + margin decisive winner tie or low RESOLVED single MCC → auto-route MANUAL_REVIEW candidate set kept → reviewer queue Append-only audit ledger chosen_mcc · candidate_set · signal_scores · confidence · decision_hash · policy_version

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_code returns whichever category the code was first registered under, so a hotel restaurant stamped 5812 “eating places” by one acquirer and 7011 “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

  1. Trigger only on the AMBIGUOUS state. 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.

  2. Build profiles from cleared history, not from the code table. Populate each MCCProfile from 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.

  3. Pin the weights and thresholds together. Version-pin name/amount/history weights, confidence_floor, and margin_floor in config under one policy_version — the resolution is a function of all five, so an unpinned change is an unlogged behaviour change.

  4. 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
  5. Send MANUAL_REVIEW records to the queue with the ranked set attached. Route escalations with the full ranked list 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.

  6. Mirror every resolution to the append-only ledger. Append each structlog event to the same ledger the router writes, so decision_hash, confidence, and policy_version chain 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.