Priority Scoring for Exception Work Queues
Two Fedwire breaks sit in the high-value review queue at 09:00. One is a $2.1M variance opened four business days ago with a Reg E clock running down; the other is a $6.8M no-match opened this morning with no consumer-dispute deadline attached. Which does the next available analyst see first? A FIFO queue answers "the older one" and a naive amount sort answers "the bigger one", and both are wrong often enough to breach an SLA or leave a fraud signal cooling. Within the broader exception routing and fraud-pattern detection practice, and specifically inside the routing of reconciliation exceptions into prioritized work queues, this page builds the function that resolves order within a queue: a weighted priority score over amount, business-day age, risk, and deadline proximity, with a deterministic tie-break so the same two items never shuffle between refreshes.
The router assigns a coarse base band (P0–P4); the scorer ranks items inside that band. Keeping the two separate is the whole point — a continuous score must never let a P2 item leapfrog a P0, so the score orders within a band and the band is never crossed. What follows is the scoring math, the copy-paste function, how to calibrate the weights per program, a before/after reordering, and the failure modes that quietly invert a queue.
Concept Spec: A Weighted Sum Over Normalized Signals
Each break contributes four signals, every one normalized to so no single dimension can dominate by unit choice. Let be the normalized amount, the normalized business-day age, the risk signal already in , and the deadline-proximity term. The priority score is the weighted sum
with weights , so and weights read directly as percentages of influence. Normalizing amount is the subtle part: a raw dollar figure is unbounded and would swamp the other three terms, so amount enters through a saturating transform against a program cap . Above the cap, amount contributes its maximum and stops growing — a $50M wire and a $500M wire are both "as large as it matters", which is what stops an outlier from pinning every other signal to irrelevance.
Deadline proximity is deliberately convex: an item is near-zero urgency until its regulatory clock approaches, then rises sharply. With business days remaining until the deadline and a horizon , is linear and adequate, but squaring it, , keeps distant deadlines quiet and lets an item approaching breach surge — matching how a Reg E 10-business-day provisional-credit clock actually feels. Scoring one item is ; scoring and sorting a queue of items is , dominated by the sort, with extra memory per item.
Full Annotated Python Implementation
The function below holds amount as decimal.Decimal (a float amount would make the log transform non-reproducible at the boundary), computes each normalized term explicitly, and returns both the score and its component breakdown so the audit log can explain why one item outranked another. The sort key appends a stable tie-break so two items with an identical score always resolve the same way.
from __future__ import annotations
from dataclasses import dataclass
from decimal import Decimal
from math import log10
from typing import Iterable
@dataclass(frozen=True, slots=True)
class ScoreWeights:
"""Weights must sum to 1.0 so the score stays in [0, 1]."""
amount: Decimal = Decimal("0.35")
age: Decimal = Decimal("0.20")
risk: Decimal = Decimal("0.25")
deadline: Decimal = Decimal("0.20")
def __post_init__(self) -> None:
total = self.amount + self.age + self.risk + self.deadline
if total != Decimal("1"):
raise ValueError(f"weights must sum to 1.0, got {total}")
@dataclass(frozen=True, slots=True)
class Break:
txn_id: str
amount: Decimal # never float
age_business_days: int # from open timestamp, business days only
risk_signal: Decimal # 0..1, from upstream fraud detection
days_to_deadline: int | None # business days to Reg E / SLA breach; None = no clock
opened_seq: int # monotonic intake sequence, for tie-breaks
@dataclass(frozen=True, slots=True)
class ScoredBreak:
txn_id: str
score: Decimal
components: dict[str, Decimal]
opened_seq: int
def _norm_amount(amount: Decimal, cap: Decimal) -> Decimal:
"""Saturating log transform: grows to 1.0 at the cap, then flat.
Log compression stops a single outlier wire from pinning every other
signal to zero; the cap makes 'large enough' a program-tunable constant.
"""
a = amount.copy_abs()
if a <= 0:
return Decimal("0")
ratio = Decimal(str(log10(1 + float(a)) / log10(1 + float(cap))))
return min(Decimal("1"), max(Decimal("0"), ratio))
def _norm_age(age_days: int, horizon: int) -> Decimal:
"""Linear age pressure, capped at the horizon so old items saturate."""
if age_days <= 0:
return Decimal("0")
return min(Decimal("1"), Decimal(age_days) / Decimal(horizon))
def _norm_deadline(days_to_deadline: int | None, horizon: int) -> Decimal:
"""Convex deadline proximity: quiet when far, surges near breach.
None means no regulatory clock, so this term contributes nothing.
A non-positive value means already past due -> maximum urgency.
"""
if days_to_deadline is None:
return Decimal("0")
if days_to_deadline <= 0:
return Decimal("1")
linear = max(Decimal("0"), Decimal("1") - Decimal(days_to_deadline) / Decimal(horizon))
return linear * linear # square to keep distant deadlines low-urgency
def priority_score(
item: Break,
weights: ScoreWeights,
amount_cap: Decimal = Decimal("1000000.00"),
age_horizon: int = 10,
deadline_horizon: int = 10,
) -> ScoredBreak:
"""Weighted priority score in [0, 1] with an explainable component breakdown.
Higher score = work first. Every term is normalized to [0, 1] before
weighting so no dimension dominates by unit choice.
"""
a = _norm_amount(item.amount, amount_cap)
g = _norm_age(item.age_business_days, age_horizon)
r = min(Decimal("1"), max(Decimal("0"), item.risk_signal))
d = _norm_deadline(item.days_to_deadline, deadline_horizon)
components = {
"amount": weights.amount * a,
"age": weights.age * g,
"risk": weights.risk * r,
"deadline": weights.deadline * d,
}
score = sum(components.values(), Decimal("0"))
return ScoredBreak(item.txn_id, score, components, item.opened_seq)
def rank_queue(items: Iterable[Break], weights: ScoreWeights) -> list[ScoredBreak]:
"""Sort a queue highest-priority first with a STABLE tie-break.
Ties resolve by intake sequence (older wins), so the ordering is
deterministic across refreshes and never shuffles equal-score items.
"""
scored = [priority_score(i, weights) for i in items]
# Sort by score descending, then opened_seq ascending (older first).
return sorted(scored, key=lambda s: (-s.score, s.opened_seq))
Calibration: Tuning Weights Per Program
The default weights (amount 0.35, age 0.20, risk 0.25, deadline 0.20) are a starting point, not a law. Calibrate against what the program is actually protecting:
- A consumer-ACH dispute desk under Reg E should raise
deadlinetoward0.35and loweramount, because a $40 unauthorized-debit dispute with two days left on its 10-business-day clock is more urgent than a $4,000 dispute opened this morning. The regulatory clock, not the dollar figure, is the binding constraint. - A high-value wire desk should keep
amountandriskdominant and set a lowage_horizon, because on Fedwire finality is absolute and a large unresolved break is a same-day problem — you want it saturating the age term within a business day, not ten. - A fraud-driven program raises
risktoward0.40so a strong duplicate-debit or velocity signal from upstream fraud-pattern detection pulls an item to the top regardless of its size.
Two calibration disciplines are non-negotiable. First, the weights must sum to 1.0 — the __post_init__ check enforces it — so the score stays a clean percentage and re-tuning one weight forces an explicit trade against the others. Second, tune the amount_cap to the program's real ceiling: set it too low and every wire above a modest figure looks identical; set it too high and mid-value items never register. Store weights and horizons in a version-controlled config keyed by queue, and parallel-run any change against a week of historical breaks before activation, exactly as a routing-rule change is validated.
Before and After: A Queue Reordered
Take four items in a high-value wire queue at 09:00, scored with the default weights, amount_cap = Decimal("1000000.00"), and 10-business-day horizons:
txn_id |
amount | age (bd) | risk | days to deadline | FIFO rank | scored rank |
|---|---|---|---|---|---|---|
FW-88213 |
$6,800,000.00 | 0 | 0.10 | — | 1 | 3 |
FW-88190 |
$2,100,000.00 | 4 | 0.20 | 2 | 2 | 1 |
FW-88176 |
$95,000.00 | 6 | 0.85 | 5 | 3 | 2 |
FW-88155 |
$12,400.00 | 9 | 0.05 | — | 4 | 4 |
FIFO would hand the analyst FW-88213 first — the biggest, newest item — and let FW-88190 drift toward its two-day Reg E breach. The scorer inverts that: FW-88190 wins because its squared deadline term weighted at 0.20 stacks on a healthy age term, pushing its total above the pure-size leader. FW-88176 places second on the strength of its 0.85 risk signal despite being the smallest wire above the fee line. The genuine no-deadline giant FW-88213 drops to third — still ahead of the stale-but-quiet FW-88155, which has age but no risk, no deadline, and a small amount. The reordering is the point: the item most likely to cause a regulatory or fraud loss surfaces first, and the audit log records each item's component breakdown so the ranking is defensible.
Failure Modes & Guardrails
Three defects silently corrupt the ranking even when the arithmetic looks right:
- Score inversion from a sign or weight error. Flip a weight sign, or sort ascending by mistake, and the queue serves the least urgent item first — and because scores still look plausible, nobody notices until an SLA breaches. Guard it: assert
sum(weights) == 1.0at construction, assert every component is in , and add a test that a known-urgent fixture outranks a known-quiet one so a bad sort key fails CI, not production. - Unbounded amount dominating. Without the saturating log transform and cap, a single $500M wire drives its amount term so high that age, risk, and deadline become rounding noise — every other item ranks by size alone and a near-breach dispute starves behind a fat, deadline-free wire. The
_norm_amountcap is the fix; alert whenever the amount component alone exceeds, say, 0.9 of an item's score, because that means the other three signals stopped mattering. - Stale age from a wall-clock, not business-day, counter. Counting calendar days — or worse, recomputing age from a mutable "last touched" timestamp — makes a Friday break look one day old on Monday and lets weekend drift distort the ranking. Age must derive from the immutable open timestamp counted in business days against the same calendar the SLA timers and exception aging logic uses, so the age term and the deadline term agree on what a "day" is.
Frequently Asked Questions
Why normalize every signal to [0, 1] before weighting?
So the weights, not the units, decide influence. A raw dollar amount and a day count live on wildly different scales; summing them directly lets whichever has the bigger numbers dominate regardless of intent. Normalizing each term to first means amount 0.35 genuinely contributes at most 35% of the score, and re-tuning a weight has a predictable, bounded effect you can reason about and audit.
How does the score avoid overriding the router's base priority band?
It doesn't touch the band. The router assigns P0–P4 as a coarse policy statement, and the scorer only orders items within a single band. A P0 sanctions hit and a P2 rounding item are never compared by score, so a high continuous score on a P2 item can never leapfrog a P0. Scoring resolves the tie the band leaves open; it never re-litigates the band itself.
Why square the deadline term instead of leaving it linear?
To keep distant deadlines quiet and make near-breach items surge. A linear term treats "eight days left" and "two days left" as a smooth slope, but operationally the last two days matter far more than the first eight. Squaring flattens the low-urgency tail and steepens the approach to breach, which matches how a Reg E 10-business-day clock actually pressures the desk.
What makes the tie-break stable, and why does it matter?
rank_queue sorts by (-score, opened_seq), so two items with an identical score always resolve by intake sequence — older first — every time. Without a deterministic secondary key, equal-score items shuffle unpredictably between queue refreshes, which erodes analyst trust ("it was at the top a second ago") and makes the ordering impossible to reproduce in an audit. A monotonic intake sequence gives a total order for free.
Related guides in this collection
- Routing Reconciliation Exceptions into Prioritized Work Queues — the parent reference that assigns the base priority band this score orders within.
- Auto-Assignment Rules for Exception Analysts — how the top-ranked item is handed to an eligible analyst under load balancing and segregation of duties.
- SLA Timers and Exception Aging for Reconciliation Breaks — the business-day aging and deadline clocks the age and deadline terms consume.
- Fraud-Pattern Detection Signals in Payment Reconciliation — the upstream source of the risk signal in the weighted sum.