Scoring Detention & Demurrage Charges for Dispute Likelihood
Detention and demurrage lines arrive with thin documentation, and auditing every one wastes analyst hours while disputing the weak ones burns carrier goodwill — you need a score that ranks each charge by how likely a dispute is to win.
The Failure You Are Hitting
Detention (a truck held past free time at a dock) and demurrage (a container held past free time at a terminal) are the accessorials carriers bill most aggressively and document least. Teams handle them one of two ways, and both fail:
- Audit everything. Every detention line goes to an analyst who requests the gate logs, the signed delivery receipt, and the free-time clause, then adjudicates by hand. At a few thousand accessorial lines a month this is unaffordable, so the queue backs up and genuine overcharges age past the carrier’s dispute window and become unrecoverable.
- Dispute on a flat rule. Someone writes “flag any detention over $250” and fires a dispute at every hit. Half of them have a signed timestamp proving the delay was the shipper’s fault, the dispute is rejected, and now the carrier’s account team treats every future claim as noise — the goodwill cost of a losing dispute outlasts the charge itself.
The root cause is the same in both: there is no model of evidence. A $600 detention charge with no proof of free-time start, no signature, and a contract cap of $400 is a strong dispute; the same dollar amount with a driver-signed departure timestamp inside free time is not. A flat rule cannot tell them apart, so it either reviews both or disputes both.
This scoring stage lives inside Accessorial Charge Scoring, downstream of the charge classification that first tags a line as accessorial. Its job is to turn a detention or demurrage line plus whatever evidence exists into a single dispute-likelihood score and a routing band.
Root Cause Analysis
The flat-rule failure is not a threshold that needs tuning — it is the absence of a weighted evidence model. Four production realities make a single rule structurally unable to rank these charges:
- Evidence is multi-signal and partial. Dispute-worthiness depends on several independent facts — was free time proven, is there a signed timestamp, does the charge exceed the contract cap, how often has this carrier lost this dispute — and any of them can be missing. A rule keyed on one signal ignores the rest.
- Signals carry different weight. A charge that breaches the contracted per-day cap is far stronger evidence than a charge that is merely large. Treating “over $250” and “over the cap” as equivalent conflates a pricing fact with a contractual violation.
- Missing evidence is informative, not neutral. A detention line with no free-time proof and no signature is more disputable, not less — the carrier billed without support. A flat rule reads absence as zero and under-scores exactly the cases worth disputing.
- Historical outcomes should feed back. If disputes against carrier
EXLAon demurrage win 70% of the time and againstABCDwin 12%, that base rate belongs in the score. A static rule never learns from what already settled.
The fix is a weighted, logistic-style scorer: extract evidence features, combine them with calibrated weights into a bounded score, and cut the score into auto-approve / review / dispute bands. It is not machine learning for its own sake — it is an explicit, auditable weighting of the evidence a freight analyst already reasons about.
Reproducible Diagnostic
Before building a scorer, confirm that a flat dollar rule is actually mis-ranking your charges. This snippet compares the flat rule’s flags against known dispute outcomes on a sample, exposing the false positives (disputed and lost) and false negatives (approved but winnable):
import csv
def flat_rule_flag(charge: dict, threshold: float = 250.0) -> bool:
return float(charge["amount"]) > threshold
def audit_flat_rule(path: str) -> dict:
tp = fp = fn = tn = 0
with open(path) as fh:
for row in csv.DictReader(fh):
flagged = flat_rule_flag(row)
won = row["dispute_outcome"] == "WON" # ground truth from settled cases
tp += flagged and won
fp += flagged and not won # disputed, lost — goodwill burned
fn += (not flagged) and won # approved, was winnable — money left
tn += (not flagged) and not won
return {"disputed_and_lost": fp, "approved_but_winnable": fn,
"precision": tp / (tp + fp) if tp + fp else 0.0}
if __name__ == "__main__":
print(audit_flat_rule("settled_detention.csv"))
Read the result as a decision table:
| Signal | What it means | Action |
|---|---|---|
disputed_and_lost high |
Flat rule disputes unwinnable charges | Evidence weighting needed (Step 2) |
approved_but_winnable high |
Flat rule misses winnable charges | Missing-evidence features needed (Step 1) |
precision below ~0.6 |
The dollar threshold does not rank dispute-worthiness | Replace the rule with the scorer |
| Both errors low | Flat rule is adequate | Do not over-engineer |
If both error columns are high, the dollar amount is simply the wrong axis — a weighted evidence model is warranted, not a re-tuned threshold.
Resolution Path
Build a feature extractor, a calibrated weighted scorer, and band thresholds. Pin the dependencies so weights are reproducible across environments:
# requirements.txt
pydantic==2.10.6
structlog==24.4.0
Step 1 — Extract evidence features, treating absence as signal
Each feature is a normalized number in a known range, and missing evidence maps to the value that reflects a weaker carrier position — not to a neutral zero that hides it:
# scoring/features.py
from decimal import Decimal
from dataclasses import dataclass
@dataclass
class Evidence:
amount: Decimal
contract_cap_per_day: Decimal | None # None = no cap on file
days_billed: int
free_time_proven: bool # gate logs establish free-time start
signed_timestamp: bool # driver/consignee signature on departure
carrier_win_rate: float # historical WON / total on this dispute type
def extract_features(e: Evidence) -> dict[str, float]:
"""Map evidence to normalized [0,1] features; absence pushes toward disputable."""
# Overage vs the contracted cap: 0 if at/under cap, ramps to 1 well above it.
if e.contract_cap_per_day and e.days_billed > 0:
capped = e.contract_cap_per_day * e.days_billed
overage = float(max(Decimal("0"), e.amount - capped) / capped) if capped else 1.0
over_cap = min(1.0, overage)
else:
over_cap = 0.6 # no cap on file is mildly disputable, not neutral
return {
"over_cap": over_cap,
"no_free_time_proof": 0.0 if e.free_time_proven else 1.0, # absence = disputable
"no_signature": 0.0 if e.signed_timestamp else 1.0,
"carrier_loses_often": 1.0 - e.carrier_win_rate, # low carrier win = we win
}
Common mistake: defaulting missing free-time proof to 0.0 as if it were neutral. A charge billed with no support is the most disputable case; encoding absence as zero under-scores exactly the lines worth pursuing.
Step 2 — Combine features with calibrated weights into a bounded score
A logistic function keeps the score in [0,1] and lets weights express how much each signal matters. The weights are calibrated against settled outcomes, not guessed:
# scoring/model.py
import math
# Calibrated on settled disputes: cap breach and missing proof dominate;
# carrier history is a smaller nudge. Bias sets the neutral operating point.
WEIGHTS = {
"over_cap": 2.4,
"no_free_time_proof": 1.8,
"no_signature": 1.1,
"carrier_loses_often": 1.3,
}
BIAS = -2.2
def dispute_score(features: dict[str, float]) -> float:
"""Weighted logistic score in [0,1]: higher means more dispute-worthy."""
z = BIAS + sum(WEIGHTS[k] * features[k] for k in WEIGHTS)
return 1.0 / (1.0 + math.exp(-z))
The bias is set so a charge at the cap, fully documented, against a carrier that usually wins scores well below the review floor — the model must not dispute a well-supported charge just because it is large.
Step 3 — Cut the score into calibrated routing bands
Thresholds convert the continuous score into the three operational outcomes, and every routed charge carries its score and contributing features so an analyst can see why:
# scoring/route.py
import structlog
from scoring.features import Evidence, extract_features
from scoring.model import dispute_score
logger = structlog.get_logger()
REVIEW_FLOOR = 0.35
DISPUTE_FLOOR = 0.70
def route_charge(charge_id: str, e: Evidence) -> dict:
features = extract_features(e)
score = dispute_score(features)
if score >= DISPUTE_FLOOR:
band = "AUTO_DISPUTE"
elif score >= REVIEW_FLOOR:
band = "ANALYST_REVIEW"
else:
band = "AUTO_APPROVE"
decision = {"charge_id": charge_id, "score": round(score, 4),
"band": band, "evidence": features}
logger.info("dd_scored", **decision) # auditable: score + evidence travel together
return decision
Charges landing in ANALYST_REVIEW and AUTO_DISPUTE carry forward to the dispute workflow the same way flagged lines from Charge Variance Classification do; the accessorial code itself is first normalized through Accessorial Charge Taxonomy Mapping so “DET”, “detention”, and “DEMUR” resolve to one scored category.
Verification
Prove the score orders charges the way an analyst would and that the bands behave at the boundaries. The load-bearing assertions are monotonicity — more missing evidence must never lower the score — and correct band assignment:
from decimal import Decimal
from scoring.features import Evidence, extract_features
from scoring.model import dispute_score
from scoring.route import route_charge
def _ev(**kw) -> Evidence:
base = dict(amount=Decimal("600"), contract_cap_per_day=Decimal("200"),
days_billed=1, free_time_proven=True, signed_timestamp=True,
carrier_win_rate=0.5)
base.update(kw)
return Evidence(**base)
def test_missing_evidence_never_lowers_score():
documented = dispute_score(extract_features(_ev()))
undocumented = dispute_score(extract_features(
_ev(free_time_proven=False, signed_timestamp=False)))
assert undocumented > documented # absence raises dispute-worthiness
def test_well_supported_charge_auto_approves():
# At cap, fully documented, carrier usually wins — must not auto-dispute.
d = route_charge("C1", _ev(amount=Decimal("200"), carrier_win_rate=0.85))
assert d["band"] == "AUTO_APPROVE"
def test_uncapped_unsupported_charge_auto_disputes():
d = route_charge("C2", _ev(contract_cap_per_day=None,
free_time_proven=False, signed_timestamp=False,
carrier_win_rate=0.2))
assert d["band"] == "AUTO_DISPUTE"
assert 0.0 <= d["score"] <= 1.0
In production, the score is only trustworthy if it stays calibrated: track the realized win rate of AUTO_DISPUTE charges and the approval-reversal rate of AUTO_APPROVE charges. If auto-disputed charges start losing, the weights have drifted from reality — recalibrate against the newest settled outcomes rather than nudging the thresholds, which only moves the errors around.
Preventive Configuration
Externalize the weights and band thresholds so recalibration is a config change with an audit trail, never an edit buried in code:
detention_demurrage_scoring:
weights:
over_cap: 2.4 # contractual breach dominates
no_free_time_proof: 1.8 # missing proof is strong signal
no_signature: 1.1
carrier_loses_often: 1.3
bias: -2.2 # neutral point: well-supported charge scores low
bands:
review_floor: 0.35
dispute_floor: 0.70
missing_evidence_policy:
absent_free_time_proof: disputable # never treat absence as neutral
no_contract_cap_on_file: 0.6
recalibration:
min_settled_cases: 200 # do not recalibrate on thin samples
review_cadence_days: 30
- Version the weights. Stamp every scored decision with the weight-set version so a later audit can reproduce exactly why a charge was disputed.
- Calibrate on settled cases only. Fit weights against disputes that actually closed WON or LOST; open disputes are not ground truth and bias the model optimistic.
- Guard the neutral point. A CI test asserts a fully-documented at-cap charge scores below
review_floor— the regression guard against a scorer that disputes everything expensive. - Feed outcomes back to threshold tuning. Export realized win rates so Threshold Tuning & Alerting can alert when the dispute band’s win rate drops below target and trigger recalibration.
FAQ
Why not just dispute every detention charge above a dollar threshold?
Because dollar amount does not measure dispute-worthiness. A large charge with a driver-signed departure timestamp inside free time is unwinnable, and disputing it burns carrier goodwill that outlasts the charge. A weighted evidence model ranks by the facts that actually decide a dispute — free-time proof, signature, contract-cap breach, and the carrier’s history — so analysts pursue the winnable cases and leave the well-supported ones alone.
How should the score treat a charge with no free-time documentation at all?
As more disputable, not less. A carrier billing detention with no gate logs and no signature has billed without support, which is exactly the case worth challenging. Map missing free-time proof to the feature value that raises the score, never to a neutral zero — treating absence as neutral is the single most common way a scorer under-flags the charges it should surface.
Do I need machine learning to score dispute likelihood?
No. The scorer here is an explicit weighted logistic function whose weights you calibrate against settled outcomes — it is auditable and every decision can be traced to its contributing evidence, which matters for a financial process. You can later fit those weights with logistic regression on your dispute history, but the structure stays the same. The value is in modeling evidence, not in the sophistication of the estimator.
How do I keep the score from drifting out of calibration over time?
Track the realized win rate of the auto-dispute band and the reversal rate of the auto-approve band against newly settled cases. When auto-disputed charges start losing, recalibrate the weights on the latest closed outcomes rather than nudging the band thresholds, which only relocates the errors. Recalibrate on a fixed cadence and only when you have enough settled cases to fit against — thin samples produce unstable weights.
Related
- Accessorial Charge Scoring — the parent stage that weighs accessorial exceptions for dispute.
- Accessorial Charge Taxonomy Mapping — normalizes carrier detention and demurrage codes into the one category this scorer reads.
- Charge Variance Classification — classifies the variance that feeds scored charges into the dispute workflow.
- Threshold Tuning & Alerting — turns realized dispute win rates into alerts that trigger recalibration.
- Rule-Based Rate Validation & Accessorial Auditing — the validation tier this scoring stage sits within.
Up one level: Accessorial Charge Scoring · Section: Rule-Based Rate Validation & Accessorial Auditing