Charge Variance Classification

A validation verdict tells you a line is wrong; it does not tell you how it is wrong, and the how decides everything that follows. Charge variance classification is the stage that turns an undifferentiated “this line breached tolerance” flag into a typed, scored case — a linehaul overcharge, a duplicate billing, an accessorial that is not on the contract, fuel-surcharge drift, a weight or zone error, or a reweigh/reclass — and then decides whether that case is worth disputing at all. It sits at the front of Discrepancy Resolution & Dispute Routing: the router downstream cannot pick a carrier channel, assemble an evidence package, or check a filing deadline until this stage has named the variance and attached a confidence it can trust.

The reason to build this as a real engine rather than a stack of if statements is that the variance types overlap. A line can look like both an overcharge and a duplicate; a fuel line can look like a rate error when it is really index drift. A classifier that assigns exactly one type with a calibrated confidence — and abstains to manual review when the signals conflict — is what keeps the wrong claim from reaching the wrong channel. This stage does not adjudicate the dollar amount and it does not file anything; it produces a VarianceCase the dispute router can act on deterministically.

Prerequisites

This stage is a transform: given one flagged line plus its resolved rate context, it emits one typed, scored case. It assumes the upstream tiers have already done their jobs.

Input / dependency Source Why it is required
Flagged line + variance amount Rule-Based Rate Validation & Accessorial Auditing The trigger; carries billed vs expected
Resolved rate context Freight Contract Architecture & Rate Mapping Contracted rate + contract_version_id
Accessorial schedule snapshot Accessorial Charge Scoring Tells overcharge from off-contract charge
Weight/zone cross-check flags Weight & Zone Cross-Validation Distinguishes weight errors from rate errors
Prior settlement fingerprints Reconciliation ledger Detects duplicate billing of the same PRO
python 3.10+, pydantic 2.x, pandas runtime match typing, schema validation, vectorized features

The classifier reads, it never re-derives: if weight-and-zone validation says the billed weight is fine, this stage does not second-guess it. Feeding the engine a signal it did not compute is what keeps the taxonomy honest and the confidence meaningful.

Architecture: from flagged line to typed case

The engine is a feature-extraction front end feeding a bank of per-type rules, whose scores are reconciled into a single winning type and a confidence. The hard part is not any one rule — it is arbitrating between types when several fire, which is exactly what the diagram below makes explicit.

Charge variance classification field map and decision flow A flagged line carrying billed amount, expected amount, PRO, charge code and ship date enters a feature extractor. The extractor derives five signals: contract delta, charge fingerprint match against prior settlements, accessorial-schedule presence, recomputed fuel-surcharge delta, and weight-or-zone flags. Those signals feed a bank of per-type rules, one row each for linehaul overcharge, duplicate billing, accessorial mismatch, fuel-surcharge drift, and weight or zone error. Each rule emits a score. A confidence aggregator takes the highest-scoring type and computes a margin over the runner-up. The output splits three ways: a confident, above-threshold case exits as dispute-worthy to the router; a thin-margin case exits to manual review; and a below-minimum case exits as a write-off. Flagged line billed · expected PRO · charge_code ship_date Feature extractor contract delta fingerprint match schedule presence FSC delta weight / zone flag Per-type rule bank · linehaul overcharge · duplicate billing · accessorial mismatch · fuel-surcharge drift · weight / zone error score score score score score Confidence aggregator argmax type margin over runner-up Dispute-worthy to router · high conf Manual review thin margin Write-off below minimum
The classifier extracts five signals from each flagged line, scores them against a bank of per-type rules, and reconciles the scores into one winning variance type plus a confidence margin. The margin — not the raw top score — decides whether a case is confident enough to dispute, thin enough for review, or too small to pursue.

The signals map to variance types below. A single line can trip more than one signal, which is precisely why the aggregator scores every type rather than short-circuiting on the first match.

Signal Field Feeds type Interpretation
contract_delta billed - expected Linehaul overcharge Positive delta beyond tolerance
fingerprint_hit prior-settlement match Duplicate billing Same PRO + charge fingerprint seen
not_on_schedule accessorial code absent Accessorial mismatch Charge has no contracted entry
fsc_delta recomputed FSC gap Fuel-surcharge drift Billed FSC above DOE-indexed value
weight_zone_flag cross-check failed Weight / zone error Billable weight or zone mismatched

Step-by-step implementation

The engine is four stages: extract features, score each type, reconcile to a single type with confidence, then gate on dispute-worthiness. Keep each stage a pure function so the whole classifier is testable without a database.

Step 1 — Extract normalized features

Derive the signals once, from the flagged line and its rate context, into a flat feature record. Everything downstream reads this record, never the raw inputs — which keeps the type rules from silently disagreeing about what “the delta” is.

# classification/features.py
from decimal import Decimal
from dataclasses import dataclass


@dataclass(frozen=True)
class Features:
    contract_delta: Decimal      # billed - expected, signed
    delta_ratio: float           # delta as fraction of expected
    fingerprint_hit: bool        # same PRO+charge seen in ledger
    not_on_schedule: bool        # accessorial code absent from contract
    fsc_delta: Decimal           # billed FSC - recomputed FSC
    weight_zone_flag: bool       # upstream cross-check failed


def extract(line: dict, rate_ctx: dict, ledger_index) -> Features:
    billed = Decimal(str(line["billed_amount"]))
    expected = Decimal(str(rate_ctx["expected_amount"]))
    delta = billed - expected
    # Guard against a zero expected — an off-contract charge has no
    # contracted baseline, so ratio is undefined, not infinite.
    ratio = float(delta / expected) if expected != 0 else 0.0
    return Features(
        contract_delta=delta,
        delta_ratio=ratio,
        fingerprint_hit=ledger_index.seen(line["pro_number"], line["charge_fingerprint"]),
        not_on_schedule=(line["charge_code"] not in rate_ctx["accessorial_schedule"]),
        fsc_delta=Decimal(str(line.get("billed_fsc", "0"))) - Decimal(str(rate_ctx.get("expected_fsc", "0"))),
        weight_zone_flag=bool(line.get("weight_zone_failed", False)),
    )

Common mistake: computing the delta in float. A sub-cent representation error flips the sign of a near-zero delta and mis-types a clean line as an overcharge. Keep every monetary feature in Decimal and only cast to float for the unitless ratio.

Step 2 — Score each variance type

Give every type a scoring rule that returns a value in [0, 1]. The rules are intentionally independent — none knows the others exist — so adding a new variance type never perturbs the existing ones.

# classification/rules.py
from decimal import Decimal
from models.dispute import VarianceType

OVERCHARGE_TOL = Decimal("0.01")   # 1% band absorbs rounding drift


def score_types(f) -> dict:
    scores = {}

    # Linehaul overcharge: positive delta beyond tolerance, and the charge
    # IS on the contract (otherwise it is an accessorial mismatch instead).
    if f.contract_delta > 0 and not f.not_on_schedule:
        scores[VarianceType.LINEHAUL_OVERCHARGE] = min(1.0, abs(f.delta_ratio) / 0.05)

    # Duplicate billing: a fingerprint hit is near-conclusive on its own.
    if f.fingerprint_hit:
        scores[VarianceType.DUPLICATE_BILLING] = 0.95

    # Accessorial mismatch: charge is simply not on the contracted schedule.
    if f.not_on_schedule and f.contract_delta > 0:
        scores[VarianceType.ACCESSORIAL_MISMATCH] = 0.85

    # Fuel-surcharge drift: billed FSC exceeds the recomputed indexed value.
    if f.fsc_delta > OVERCHARGE_TOL:
        scores[VarianceType.FUEL_SURCHARGE_DRIFT] = min(1.0, float(f.fsc_delta) / 25.0)

    # Weight / zone error: trust the upstream cross-check outright.
    if f.weight_zone_flag:
        scores[VarianceType.WEIGHT_ZONE_ERROR] = 0.90

    return scores

Common mistake: letting overcharge and accessorial-mismatch both fire on an off-contract charge. Gate the overcharge rule on not f.not_on_schedule so a charge with no contracted baseline is typed as a mismatch, not a rate overcharge — the two go to different evidence packages and, often, different channels.

Step 3 — Reconcile to one type with a confidence margin

The winning type is the argmax, but the confidence is the margin between the top score and the runner-up. A top score of 0.9 means little if the second-place type also scored 0.88 — that is an ambiguous line a human should see, not a claim to fire automatically.

# classification/reconcile.py
from dataclasses import dataclass
from models.dispute import VarianceType


@dataclass
class VarianceCase:
    variance_type: VarianceType | None
    confidence: float
    claimed_amount: object


def reconcile(scores: dict, claimed_amount) -> VarianceCase:
    if not scores:
        return VarianceCase(None, 0.0, claimed_amount)   # nothing fired
    ranked = sorted(scores.items(), key=lambda kv: kv[1], reverse=True)
    top_type, top_score = ranked[0]
    runner_up = ranked[1][1] if len(ranked) > 1 else 0.0
    # Confidence blends absolute strength with separation from the runner-up,
    # so two near-tied types yield a deliberately low confidence.
    margin = top_score - runner_up
    confidence = round(0.5 * top_score + 0.5 * margin, 4)
    return VarianceCase(top_type, confidence, claimed_amount)

Common mistake: using the raw top score as confidence. It hides ambiguity — the router then submits a claim on the wrong type with false confidence. The margin term is what makes the abstention in the next step meaningful.

Step 4 — Gate on dispute-worthiness

Finally, decide the disposition. A confident case above the carrier minimum is dispute-worthy; a thin-margin case goes to review; anything below the minimum claim is a write-off. This is the exact contract the router in the parent stage consumes.

# classification/gate.py
from decimal import Decimal


def decide(case, min_claim: Decimal, min_confidence: float = 0.80) -> dict:
    if case.variance_type is None or case.claimed_amount < min_claim:
        return {"disposition": "WRITE_OFF"}
    if case.confidence < min_confidence:
        return {"disposition": "MANUAL_REVIEW", "type": case.variance_type}
    return {
        "disposition": "DISPUTE_WORTHY",
        "type": case.variance_type,
        "confidence": case.confidence,
    }

Validation and testing

Because the classifier is a chain of pure functions, it is cheap to test exhaustively, and the cases that matter are the ambiguous ones — a line that looks like two types at once — not the clean single-signal happy paths.

import pytest
from decimal import Decimal
from classification.features import Features
from classification.rules import score_types
from classification.reconcile import reconcile
from models.dispute import VarianceType


def _features(**kw) -> Features:
    base = dict(contract_delta=Decimal("0"), delta_ratio=0.0, fingerprint_hit=False,
                not_on_schedule=False, fsc_delta=Decimal("0"), weight_zone_flag=False)
    base.update(kw)
    return Features(**base)


def test_clean_overcharge_types_high_confidence():
    f = _features(contract_delta=Decimal("120"), delta_ratio=0.04)
    case = reconcile(score_types(f), Decimal("120"))
    assert case.variance_type == VarianceType.LINEHAUL_OVERCHARGE
    assert case.confidence > 0.4


def test_fingerprint_hit_wins_duplicate():
    f = _features(fingerprint_hit=True, contract_delta=Decimal("80"), delta_ratio=0.02)
    case = reconcile(score_types(f), Decimal("80"))
    assert case.variance_type == VarianceType.DUPLICATE_BILLING


def test_ambiguous_line_yields_low_confidence():
    # Off-contract AND a positive delta: mismatch vs overcharge sit close.
    f = _features(contract_delta=Decimal("50"), delta_ratio=0.9, not_on_schedule=True)
    case = reconcile(score_types(f), Decimal("50"))
    assert case.confidence < 0.6   # margin is thin -> route to review


def test_nothing_fires_returns_none():
    case = reconcile(score_types(_features()), Decimal("0"))
    assert case.variance_type is None

Maintain fixtures for each pairwise ambiguity: overcharge vs duplicate, overcharge vs accessorial mismatch, and a fuel line that looks like a rate error but is really drift. Each should assert a low confidence, because the correct behaviour on ambiguity is abstention, not a confident guess.

Performance and tuning

Feature extraction is the only stage that touches I/O — the ledger fingerprint lookup — so it dominates latency. Cache the prior-settlement index in memory per batch and the scoring stage runs at CPU speed over Decimal math.

Knob Typical range Effect Watch for
min_confidence 0.75–0.85 Review vs auto-dispute split Too low floods carriers with weak claims
Fingerprint index scope 90–180 days Duplicate recall Memory growth on wide PRO history
Overcharge ratio scale 0.03–0.07 Score sensitivity Over-sensitive scale mis-types rounding
Batch feature cache 500–2,000 lines Ledger-lookup amortization Stale index within a long batch

A single worker classifies several thousand lines per second with a warm fingerprint index; a cold index that hits the ledger per line is one to two orders of magnitude slower, which is why the per-batch cache is load-bearing.

Failure modes

Scenario Root cause Resolution
Duplicate typed as overcharge Fingerprint index cold or too shallow Widen index window; warm it per batch
Off-contract charge typed as overcharge Overcharge rule not gated on schedule Gate on not_on_schedule (Step 2)
Everything low-confidence Runner-up scores near-tie constantly Recalibrate per-type score scales
Fuel line mis-typed as rate error FSC recompute missing from features Populate fsc_delta from the index recompute
Confident but wrong type Raw top score used as confidence Use the margin-based confidence (Step 3)

Integration points

The output is a VarianceCase plus a disposition — a stable contract the dispute router consumes without ever re-reading the raw line. Dispute-worthy cases carry the type, confidence, and claimed amount the router needs to select a channel and check a filing window. Cases typed DUPLICATE_BILLING are recovered by short-pay rather than an API claim, and the identity signal that makes that classification safe is detailed in Classifying Overcharge vs Duplicate Billing in Python. Fuel-surcharge cases whose drift only appears in aggregate come from Detecting Fuel-Surcharge Drift Across Invoice Batches. Every typed case — disputed, reviewed, or written off — is recorded against the invoice in the Reconciliation Ledger Schema so the audit trail is complete regardless of disposition.

In this section


Up: Discrepancy Resolution & Dispute Routing