Discrepancy Root-Cause Classification Permalink to this section

↑ Part of Exception Handling & Discrepancy Resolution.

An unclassified exception queue is not a work list — it is a graveyard. Every failed match lands in the same undifferentiated bucket with a status of “unresolved” and nothing else, so an analyst opening row 4,000 of 6,000 has no way to know whether it is a five-second fix or a symptom of a vendor feed that has been silently drifting for three weeks. Volume grows, mean time to resolution grows with it, and nobody can answer the one question that actually matters to the business: what is breaking, and how often?

The failure is architectural, not procedural. Teams reach for a status enum — open, in_review, resolved — and assume that is enough operational metadata, because the status field is what the queue UI already sorts on. Status describes where an exception is in its lifecycle; it says nothing about why the exception exists, which means every question an operations lead actually asks — “did the SKU crosswalk change last week?”, “is one supplier driving half of this backlog?”, “should we route this to procurement or to the warehouse?” — requires someone to manually open and read individual rows. That does not scale past a few dozen exceptions a day, and reconciliation pipelines at any real volume produce far more than that.

Root-cause classification closes that gap by attaching a bounded, machine-readable reason code to every exception the moment it is raised, derived from the same feature envelope the matching engine already computed. Once every row carries a code such as PRICE_TOLERANCE_EXCEEDED or VENDOR_DRIFT, the queue stops being a undifferentiated backlog and becomes a dataset — you can group by cause, chart drift over time, and route each class to the team actually equipped to fix it. This complements how Exception Queue Design and Triage prioritizes which row to work next; classification answers why the row exists in the first place, and a well-formed taxonomy is what makes prioritization rules possible to write at all.

Core Concept & Decision Criteria Permalink to this section

Classification has to happen at the boundary between the matching engine and the queue, operating on the same structured envelope the matcher used to fail the row — expected vs. observed price, quantity, SKU, counterparty identifiers, currency pair, and timestamps. Two architectures compete for that boundary: a rule-based classifier, which encodes explicit, ordered predicates over envelope fields, and a learned classifier, which trains a model on historically labeled exceptions to predict a reason code from the same feature set. Neither is universally correct; the choice tracks how well-understood and how stable your failure modes are.

Rules win when a failure signature is deterministic and auditable — a missing FX rate either exists in the rate dimension or it does not, and that fact should never be inferred probabilistically. Rules degrade badly, however, when a single exception is caused by an overlapping combination of small deviations that no individual threshold catches — a slightly late shipment, a slightly stale price list, and a rounding difference that together produce a variance no single rule flags as its primary cause. That is the regime where a learned classifier, trained on the same envelope features plus historical analyst-assigned labels, earns its keep: it can weigh combinations no one thought to write a rule for.

Decision axis Rule-based classification Learned (ML) classification
Reason code source Explicit, ordered predicate chain Trained model over labeled exception history
Explainability Fully deterministic — same input, same code, traceable rule Probabilistic — requires feature-importance or SHAP explanation
New failure mode Ship a code change, redeploy Retrain on newly labeled examples
Cold-start Works from day one with zero history Needs a labeled corpus (thousands of resolved exceptions)
Overlapping causes First-matching-rule wins; ties must be ordered explicitly Naturally handles blended signals via learned weights
Audit posture Preferred for SOX-adjacent financial reason codes Requires model versioning + explanation logging for audit
Failure mode Misses novel causes until a human writes a new rule Silent drift if the training distribution goes stale
Best for FX_RATE_MISSING, MISSING_COUNTERPART, SKU_MISMATCH — binary, deterministic VENDOR_DRIFT, ambiguous blended variance — multivariate, gradual

Most production pipelines run both, in sequence: the rule chain classifies everything it can deterministically resolve, and only the residual — exceptions no rule matches — falls through to a learned model or a UNCLASSIFIED holding code for manual labeling. That residual becomes the training set for the next model iteration, so the two approaches compound rather than compete.

There is also a cost axis that rarely gets enough weight in this decision: a rule chain is something any engineer on the team can read, review, and modify in a pull request, while a learned classifier requires a labeled corpus, a training pipeline, a versioned model artifact, and an explanation mechanism before it can be trusted with a financial reason code at all. That operational overhead is worth paying once UNCLASSIFIED volume is large and persistent enough that no reasonable number of new rules will absorb it — but it is a mistake to reach for a model on day one simply because “classification” sounds like a machine-learning problem. Most reconciliation exceptions have deterministic causes; the rule chain should be the default, and the model should be the fallback that earns its place with evidence from the residual bucket.

The taxonomy itself has to be closed — a fixed enumeration, not a freeform text field — or none of the downstream analytics work. Analysts writing “price off again” and “pricing discrepancy” into the same free-text reason field produce a queue that cannot be grouped, charted, or routed by rule. The full design considerations for building and versioning that enumeration — how to add a code without breaking historical joins, how to deprecate one safely — are covered in Building a Failure-Reason Taxonomy for Reconciliation; this page assumes a taxonomy exists and focuses on the classifier that assigns codes from it.

Reason code Trigger signal Primary feature(s) inspected Typical downstream routing
MISSING_COUNTERPART No matching document found in the canonical ledger within the lookback window counterpart_found, lookback_days_scanned Ingestion / late-arrival triage
FX_RATE_MISSING No validated rate for the currency pair at the effective date rate_resolved, currency_pair, effective_date Rate hydration retry queue
SKU_MISMATCH Line-item SKU absent from the canonical catalog crosswalk sku_in_crosswalk, sku_confidence Catalog / master-data team
QTY_VARIANCE Quantity delta exceeds the configured tolerance band qty_delta_pct, qty_delta_abs Warehouse / receiving reconciliation
PRICE_TOLERANCE_EXCEEDED Price delta exceeds the configured tolerance band price_delta_pct, price_delta_abs Procurement / pricing team
VENDOR_DRIFT Vendor identifier, bank routing, or remit-to address deviates from history vendor_id_zscore, remit_to_changed Vendor master / fraud review
UNCLASSIFIED No rule matched; below classifier confidence floor (residual) Manual labeling queue, feeds retraining

Implementation Permalink to this section

The reference classifier operates on a typed ExceptionEnvelope — the same structured record the matching engine emits when a row fails exact and tolerance matching. Feature extraction is a pure function so it can be unit-tested independently of the rule chain, and the rule chain itself is an ordered list evaluated top-to-bottom so precedence is explicit and auditable rather than implicit in code layout.

PYTHON
import logging
from dataclasses import dataclass
from datetime import date
from decimal import Decimal
from enum import Enum
from typing import Callable, Optional

from pydantic import BaseModel

logger = logging.getLogger("recon.classify")


class ReasonCode(str, Enum):
    """Closed taxonomy — see the failure-reason taxonomy page before adding a member."""
    MISSING_COUNTERPART = "MISSING_COUNTERPART"
    FX_RATE_MISSING = "FX_RATE_MISSING"
    SKU_MISMATCH = "SKU_MISMATCH"
    QTY_VARIANCE = "QTY_VARIANCE"
    PRICE_TOLERANCE_EXCEEDED = "PRICE_TOLERANCE_EXCEEDED"
    VENDOR_DRIFT = "VENDOR_DRIFT"
    UNCLASSIFIED = "UNCLASSIFIED"


class ExceptionEnvelope(BaseModel):
    """The failed-match record handed off by the matching engine."""
    exception_id: str
    counterpart_found: bool
    rate_resolved: bool
    currency_pair: Optional[str] = None
    sku_in_crosswalk: bool = True
    sku_confidence: float = 1.0
    expected_qty: Optional[Decimal] = None
    observed_qty: Optional[Decimal] = None
    expected_price: Optional[Decimal] = None
    observed_price: Optional[Decimal] = None
    vendor_id_zscore: float = 0.0
    remit_to_changed: bool = False
    event_date: date


@dataclass
class Features:
    """Derived, classifier-ready signals — never raw envelope fields directly."""
    qty_delta_pct: Optional[float]
    price_delta_pct: Optional[float]
    vendor_id_zscore: float
    remit_to_changed: bool
    sku_confidence: float


@dataclass
class ClassificationResult:
    exception_id: str
    reason_code: ReasonCode
    matched_rule: str
    confidence: float


def extract_features(env: ExceptionEnvelope) -> Features:
    """Pure transform: envelope -> normalized, classifier-ready features."""
    qty_delta_pct = None
    if env.expected_qty and env.expected_qty != 0 and env.observed_qty is not None:
        qty_delta_pct = float(abs(env.observed_qty - env.expected_qty) / env.expected_qty)

    price_delta_pct = None
    if env.expected_price and env.expected_price != 0 and env.observed_price is not None:
        price_delta_pct = float(abs(env.observed_price - env.expected_price) / env.expected_price)

    return Features(
        qty_delta_pct=qty_delta_pct,
        price_delta_pct=price_delta_pct,
        vendor_id_zscore=env.vendor_id_zscore,
        remit_to_changed=env.remit_to_changed,
        sku_confidence=env.sku_confidence,
    )


# Each rule is (name, predicate, code). Order encodes precedence: structural
# failures (missing data) are checked before variance-magnitude failures,
# because a missing counterpart makes a price/qty delta meaningless.
Rule = tuple[str, Callable[[ExceptionEnvelope, Features], bool], ReasonCode]

RULES: list[Rule] = [
    ("no_counterpart", lambda e, f: not e.counterpart_found, ReasonCode.MISSING_COUNTERPART),
    ("rate_unresolved", lambda e, f: not e.rate_resolved, ReasonCode.FX_RATE_MISSING),
    ("sku_absent", lambda e, f: not e.sku_in_crosswalk or f.sku_confidence < 0.6, ReasonCode.SKU_MISMATCH),
    ("qty_out_of_band", lambda e, f: f.qty_delta_pct is not None and f.qty_delta_pct > 0.02, ReasonCode.QTY_VARIANCE),
    ("price_out_of_band", lambda e, f: f.price_delta_pct is not None and f.price_delta_pct > 0.01, ReasonCode.PRICE_TOLERANCE_EXCEEDED),
    ("vendor_drift", lambda e, f: abs(f.vendor_id_zscore) > 3.0 or f.remit_to_changed, ReasonCode.VENDOR_DRIFT),
]


def classify_exception(env: ExceptionEnvelope) -> ClassificationResult:
    """Evaluate the ordered rule chain; fall through to UNCLASSIFIED for ML/manual review."""
    features = extract_features(env)
    for name, predicate, code in RULES:
        if predicate(env, features):
            logger.info(
                "classified exception=%s rule=%s code=%s",
                env.exception_id, name, code.value,
            )
            return ClassificationResult(env.exception_id, code, name, confidence=1.0)

    logger.warning(
        "unclassified exception=%s qty_delta=%.4f price_delta=%.4f vendor_z=%.2f",
        env.exception_id,
        features.qty_delta_pct or -1.0,
        features.price_delta_pct or -1.0,
        features.vendor_id_zscore,
    )
    return ClassificationResult(env.exception_id, ReasonCode.UNCLASSIFIED, "none", confidence=0.0)

Rule order is itself a design artifact worth reviewing in code review, not just the predicates. Placing vendor_drift ahead of price_out_of_band, for instance, would silently reclassify every drifting-vendor row that also happens to carry a price delta, which erases the fact that pricing teams need to see those rows too. Reason codes routed downstream also drive fuzzy-vs-exact routing decisions — see When to Use Fuzzy Matching Over Exact PO Matching for how SKU_MISMATCH and QTY_VARIANCE codes feed the decision to re-attempt a fuzzy pass before a row is finalized as an exception.

Note what classify_exception deliberately does not do: it does not mutate the envelope, it does not write to the exception store, and it does not decide how the row should be prioritized. Keeping the function pure and side-effect-free is what makes it safe to call twice — once at classification time and again during a reclassification batch job after a threshold change — without worrying about double-writes or duplicate log lines. The caller owns persistence; the classifier owns only the mapping from features to a code. This separation also makes the rule chain trivially unit-testable: construct an ExceptionEnvelope fixture per reason code, assert the expected ReasonCode comes back, and run the full suite on every pull request that touches RULES before it merges.

A confidence field is threaded through ClassificationResult even in the pure rule-based path, always 1.0 for a deterministic match and 0.0 for the UNCLASSIFIED fallback. That is not decorative — it lets the exception store use a single schema whether a row was classified by a rule or by a learned model, and it lets downstream dashboards distinguish “classified with certainty” from “classified probabilistically” without a separate code path per classifier type.

Root-cause classification decision cascade A sequential cascade evaluated in precedence order against the exception envelope. Each check asks whether a specific structural or variance condition is true; a yes branches right into the matching reason code and the exception is written to the queue with that code; a no continues down to the next check. Order: counterpart record missing yields MISSING_COUNTERPART; FX rate unresolved yields FX_RATE_MISSING; SKU absent from the crosswalk yields SKU_MISMATCH; quantity delta exceeds the tolerance band yields QTY_VARIANCE; price delta exceeds the tolerance band yields PRICE_TOLERANCE_EXCEEDED; vendor identifier or remit-to drifted yields VENDOR_DRIFT. If none match, the exception falls through to UNCLASSIFIED and routes to the learned-model fallback or a manual labeling queue. Exception envelope Counterpart record missing? yes MISSING_COUNTERPART → ingestion / late-arrival triage no FX rate unresolved for pair/date? yes FX_RATE_MISSING → rate hydration retry queue no SKU absent from catalog crosswalk? yes SKU_MISMATCH → catalog / master-data team no Qty delta exceeds tolerance band? yes QTY_VARIANCE → warehouse / receiving team no Price delta exceeds tolerance band? yes PRICE_TOLERANCE_EXCEEDED → procurement / pricing team no Vendor id / remit-to drifted from history? yes VENDOR_DRIFT → vendor master / fraud review no No rule matched code = UNCLASSIFIED confidence = 0.0 Learned-model fallback or manual labeling queue

Configuration & Threshold Calibration Permalink to this section

Every threshold in the rule chain is a calibration decision with a direct precision/recall tradeoff: set qty_out_of_band too tight and routine rounding noise gets classified as QTY_VARIANCE, flooding the warehouse team with false positives; set it too loose and real short-shipments get silently absorbed and never routed. Calibrate per document type and per supplier tier rather than globally, and treat the classifier confidence itself as a monitored signal — a rising UNCLASSIFIED rate is the earliest evidence that a new, uncoded failure mode has entered the pipeline.

For the residual band routed to a learned model, define a confidence floor below which the model’s prediction is not trusted automatically:

auto_assign(x)    P(c^x)θconf    (P(c^x)P(c^2x))θmargin\text{auto\_assign}(x) \iff P(\hat{c} \mid x) \ge \theta_{\text{conf}} \;\land\; \left( P(\hat{c} \mid x) - P(\hat{c}_2 \mid x) \right) \ge \theta_{\text{margin}}

where c^\hat{c} is the top-predicted reason code, c^2\hat{c}_2 the runner-up, θconf\theta_{\text{conf}} a minimum absolute confidence, and θmargin\theta_{\text{margin}} a minimum separation from the second-best candidate. A high top score with a thin margin over the runner-up means the model is genuinely torn between two causes — that row should go to manual labeling, not an auto-assigned code, or the training set inherits the model’s own ambiguity in its next iteration.

Parameter Recommended value Rationale
qty_tolerance_pct 2% (standard SKUs), 0.5% (high-value/serialized) Absorbs count/scan noise without masking short-ships
price_tolerance_pct 1% major suppliers, 3% tail-spend/spot suppliers Tail-spend suppliers carry looser contract pricing discipline
sku_confidence_min 0.6 Below this, crosswalk match is unreliable enough to treat as absent
vendor_id_zscore_max 3.0 Standard anomaly-detection threshold on identifier deviation
theta_conf (ML floor) 0.75 Below this, an ML prediction is not trustworthy enough to auto-assign
theta_margin (ML floor) 0.15 Guards against confident-but-tied predictions between two codes
unclassified_rate_alert >8% of daily volume Leading indicator of a new, uncoded failure mode entering the pipeline
rule_precedence_review_cadence Quarterly Catches drift between rule order and current business priority

Threshold changes are configuration, not code, and every change must be versioned alongside a rule_version written onto each ClassificationResult so historical exceptions can be re-classified against the rule set that was actually active when they were raised — reclassifying the whole backlog against today’s thresholds silently rewrites history and breaks trend charts.

Orchestration & Integration Permalink to this section

Classification runs as the last step inside the matching engine’s failure path, immediately after a row fails exact and tolerance matching and before it is persisted to the exception store — the envelope is already in memory, so classifying there avoids a second read of source data. The classifier writes reason_code, matched_rule, rule_version, and confidence onto the exception record alongside the fields the queue already tracks (age, financial materiality, retry count). Downstream, Exception Queue Design and Triage uses reason_code as a first-class routing dimension — a queue view segmented by cause is what lets a pricing analyst work only PRICE_TOLERANCE_EXCEEDED rows without wading through vendor and catalog issues that belong to other teams.

Reason codes also change how retries behave. A FX_RATE_MISSING exception is transient and worth retrying automatically once the rate dimension is re-hydrated; a SKU_MISMATCH exception will not resolve itself no matter how many times it is retried, because the catalog gap requires a human edit. Wire the reason code into the retry policy in Dead-Letter Queues and Retry Orchestration so transient codes get capped automatic retries and structural codes route straight to manual review instead of burning retry budget on a condition that cannot self-heal.

This also means the classifier and the retry orchestrator share a contract: the reason code is the field the orchestrator switches on to decide whether a retry is even worth scheduling. A FX_RATE_MISSING row that has already exhausted its capped retry budget should escalate to a materially different alert than a SKU_MISMATCH row hitting the same limit, because the first indicates the rate-hydration job itself is failing, and the second indicates a catalog gap nobody has closed. Encoding that distinction requires the reason code to exist before the retry decision is made — which is exactly why classification runs inside the matching engine’s failure path rather than as a separate batch job that sweeps the queue after the fact. A batch-swept classifier means every retry decision made before that sweep runs is made blind.

The real payoff of a closed taxonomy shows up in aggregation: with reason_code as a groupable dimension, a daily rollup of exception counts by code becomes a drift chart — a sudden spike in VENDOR_DRIFT for one supplier is a fraud or master-data signal, a slow climb in QTY_VARIANCE across a whole product line points at a receiving process problem, and both are invisible in a queue that only reports “unresolved: 6,412.” Feed these grouped counts into the dashboards and threshold alerts described under Monitoring and Alerting for Reconciliation Pipelines so a reason-code spike triggers a page before it becomes a quarter-end surprise, rather than being discovered only when someone manually charts the backlog.

Debugging & Pipeline Recovery Permalink to this section

Misclassification almost always traces to one of three places: rule precedence, feature extraction, or a stale threshold — check in that order, because a precedence bug can masquerade as a threshold problem.

  • Wrong code assigned despite a correct-looking rule: check precedence first. A later rule may be catching rows an earlier rule should have claimed because the earlier predicate is too narrow — log matched_rule, not just reason_code, on every classification so you can see exactly which predicate fired.
  • Rising UNCLASSIFIED rate: this is the residual bucket working as intended, not a bug — it means a real failure mode exists that no current rule encodes. Pull a sample of UNCLASSIFIED rows, look for a shared feature signature, and either add a new rule or add them to the next model training batch. Do not silently suppress the alert; a growing residual is exactly the leading indicator this taxonomy exists to surface.
  • Feature extraction returning None unexpectedly: confirm the envelope actually carries expected_qty / expected_price — a row that fails matching before those fields are populated (e.g., a structural parse failure) should never reach the variance rules at all; guard the rule chain so structural failures short-circuit before delta rules evaluate on missing data.
  • Reclassification after a threshold change: never mutate historical reason_code values in place. Run a batch reclassification job that writes a new record referencing the original exception ID and the new rule_version, preserving the original classification for audit and trend continuity.
  • Model confidence collapsing over time: a learned classifier trained on last quarter’s exception mix degrades as the taxonomy or supplier base shifts. Track rolling confidence distribution per reason code — a downward drift on a previously stable code is a retraining trigger, not a threshold problem.

Emit a structured log line per classification carrying exception_id, reason_code, matched_rule, rule_version, and confidence, and alert on both the unclassified_rate_alert threshold and any single reason code’s daily count breaching its own rolling baseline — the second catches the vendor- or SKU-specific spikes a global rate would average away.

FAQ Permalink to this section

Why not let analysts free-type a reason when they resolve an exception? Permalink to this section

Free text cannot be grouped, charted, or used to drive routing rules — “price off again” and “pricing discrepancy” describe the same cause but are two distinct strings to any aggregation. A closed taxonomy trades a small amount of expressive flexibility for the ability to run GROUP BY reason_code and get a trustworthy answer, which is the entire point of classifying in the first place.

How do I decide when a reason code needs a learned model instead of another rule? Permalink to this section

Add another rule as long as the failure signature is a clean, deterministic predicate over one or two fields. Reach for a learned model only once you are stacking three or more soft conditions with no clean threshold — at that point the rule chain becomes an unreadable nest of and/or clauses that a model handles more robustly, and you have enough historical UNCLASSIFIED volume to train on.

What should happen to an exception classified as UNCLASSIFIED? Permalink to this section

It should never sit invisibly — route it to a manual labeling queue where an analyst assigns the correct code from the closed taxonomy (adding a new code only through the taxonomy’s own change process). Those labels become training data for the next classifier iteration, so the UNCLASSIFIED bucket is not dead weight; it is the mechanism by which the rule chain and the model both improve.

Can a single exception have more than one reason code? Permalink to this section

The rule chain here assigns exactly one — the highest-precedence match — because a queue segmented by a single dimension is what makes routing and dashboards tractable. If a row genuinely has two independent causes, model that as two linked exception records rather than a multi-valued reason field, so each cause is still individually routable and countable.

How often should reason-code thresholds be recalibrated? Permalink to this section

Review the full threshold table on the cadence in the configuration section (quarterly is a reasonable default), plus immediately after any change to supplier contracts, tolerance windows, or the taxonomy itself. A threshold calibrated against last year’s supplier mix silently misclassifies this year’s volume if nobody revisits it.