Deadline Tracking Routing Engines

Priority Scoring Algorithms: Ranking Filing Obligations by Penalty Exposure, Proximity, and Entity Criticality

This guide is part of the Deadline Tracking & Routing Engines discipline, sitting at the prioritization stage between timezone-aware deadline resolution and dispatch. Its job is narrow and load-bearing: take the flat set of resolved, dated obligations a portfolio owes and turn it into an ordered work list, so that when capacity is constrained the engine acts on a Delaware franchise-tax cliff before a $25 informational report — deterministically, and with an audit trail explaining every rank.

A scoring algorithm exists because a calendar does not encode urgency. Two filings due the same morning can differ by three orders of magnitude in consequence: one carries a flat late fee that is annoying but survivable, the other triggers administrative dissolution and loss of good standing. Sorting purely by due date treats them identically. The scoring layer fixes that by collapsing penalty exposure, temporal proximity, and entity criticality into a single comparable number, then mapping that number onto routing tiers the rest of the system already understands.

Statutory and Regulatory Context

The score is only defensible if its inputs trace back to statute. Penalty exposure — the dominant signal — is computed upstream and quantifies what each jurisdiction does when a deadline lapses; the scoring layer consumes that work through Calculating Penalty Risk Scores Based on State Grace Periods, which encodes the relevant code sections directly. Delaware franchise tax accrues a $200 late penalty plus 1.5% monthly interest under DGCL § 502; California assesses a $250 penalty for a late Statement of Information under Corp. Code § 1502 and moves entities toward suspension; Texas applies a compounding surcharge under BOC § 4.002 with administrative dissolution at 90 days; New York recognizes no grace window on the biennial statement under BCL § 408. These are not interchangeable buffers, and a scorer that flattens them into “days late” discards the exact information that should drive routing.

Proximity is governed by the statutory due-date formula itself, which the State Filing Deadline Calendars layer resolves in each jurisdiction’s own timezone with weekend and holiday roll-forward. The scorer never recomputes a date; it consumes the timezone-aware deadline and measures distance from now. Entity criticality is sourced from the Entity Taxonomy & Classification layer — a revenue-bearing parent or a regulated subsidiary outranks a dormant shell of equal deadline proximity, because the blast radius of a missed filing is a function of the entity, not just the statute.

Architecture and Design Model

The scorer is a pure transformation: typed obligations in, scored-and-tiered obligations out, with no side effects and no I/O on the hot path. That purity is the design’s central decision. Because scoring is deterministic over its inputs, a score can be recomputed from a persisted input vector during an audit and must reproduce bitwise. Three further decisions follow from it:

  • Normalize before weighting. Penalty magnitude, days-to-deadline, and criticality live on incompatible scales (dollars, days, an ordinal tier). Each is normalized to [0, 1] against the current batch before the weighted sum, so no single raw unit silently dominates and weights mean what they say.
  • Weights are configuration, not code. The relative pull of penalty vs. proximity vs. criticality is a risk-appetite decision owned by compliance, not engineering. Weights live in a config object, must sum to 1.0, and are validated at construction.
  • Score maps to tiers, tiers map to routing. The continuous [0, 100] score is preserved for ordering, but a small set of thresholds projects it onto CRITICAL / HIGH / MEDIUM / LOW. The router downstream reasons about tiers, not floats, which keeps dispatch logic legible and the decision boundary immutable: one score, one tier, one action.
From resolved obligations to routing tiers Three normalized signals — penalty risk, deadline proximity and entity criticality, each on a zero-to-one scale — are combined in a weighted sum (weights 0.50, 0.30, 0.20 summing to 1.0) into a 0-to-100 score, which a threshold gate projects onto CRITICAL, HIGH, MEDIUM and LOW tiers; CRITICAL and HIGH route to immediate dispatch and MEDIUM and LOW to a batched queue. INPUT NORMALIZE → [0,1] WEIGHTED SUM TIER GATE ROUTE Resolved & dated obligations typed, per entity Penalty risk → n_penalty ∈ [0,1] statutory consequence Deadline proximity → n_proximity ∈ [0,1] linear decay to horizon Entity criticality → n_criticality ∈ [0,1] blast-radius tier Weighted-sum scorer Σ wᵢ·nᵢ × 100 → score ∈ [0,100] w_p + w_t + w_c = 1.0 CRITICAL score ≥ 85 HIGH score ≥ 65 MEDIUM score ≥ 40 LOW score < 40 Immediate dispatch to orchestrator Batched queue next window w_p = 0.50 w_t = 0.30 w_c = 0.20

Prerequisites and Dependencies

Component Requirement Rationale
Python 3.10+ Structural pattern matching, match on tiers, modern typing
numpy 1.24+ Vectorized normalization over the batch without Python-level loops
pandas 2.0+ Columnar obligation frames; one-pass scoring over the portfolio
Pydantic v2 optional Validating the inbound obligation schema if not already enforced upstream
Upstream: penalty risk per-obligation penalty_risk in [0, 100] Supplied by the grace-period scorer; never recomputed here
Upstream: deadlines timezone-aware due_date Supplied by the deadline calendar layer
Upstream: criticality ordinal entity_tier in [0, 1] Supplied by entity taxonomy classification
Logging structured JSON (stdlib logging + extra) Every score is an auditable event, consistent with NIST SP 800-92

Step-by-Step Implementation

Phase 1 — Type the inputs and the scoring configuration

Model the obligation and the weights as validated, frozen structures. Weights that do not sum to 1.0 are a configuration error, not a runtime surprise, so they fail at construction.

from __future__ import annotations

import logging
from dataclasses import dataclass, field

import numpy as np
import pandas as pd

logger = logging.getLogger("compliance.priority_scorer")


class ScoringConfigError(ValueError):
    """Raised when scoring weights or thresholds are internally inconsistent."""


@dataclass(frozen=True)
class ScoringConfig:
    w_penalty: float = 0.50      # statutory consequence dominates
    w_proximity: float = 0.30    # how soon the clock runs out
    w_criticality: float = 0.20  # blast radius of the entity
    critical_at: float = 85.0
    high_at: float = 65.0
    medium_at: float = 40.0
    proximity_horizon_days: int = 45  # beyond this, proximity contributes ~0

    def __post_init__(self) -> None:
        total = self.w_penalty + self.w_proximity + self.w_criticality
        if not np.isclose(total, 1.0, atol=1e-6):
            raise ScoringConfigError(f"weights must sum to 1.0, got {total:.6f}")
        if not (self.critical_at > self.high_at > self.medium_at > 0):
            raise ScoringConfigError("tier thresholds must be strictly descending")

Phase 2 — Normalize each signal to a common [0, 1] scale

Penalty risk arrives pre-normalized on [0, 100]. Proximity is an inverse-distance signal bounded by a configurable horizon, so a deadline three months out does not crowd out one due tomorrow. Criticality is already an ordinal tier in [0, 1].

def _normalize_signals(df: pd.DataFrame, cfg: ScoringConfig) -> pd.DataFrame:
    out = df.copy()

    # Penalty risk: supplied 0-100 by the grace-period scorer -> 0-1.
    out["n_penalty"] = (out["penalty_risk"].clip(0, 100) / 100.0)

    # Proximity: timezone-aware days-to-deadline, floored at 0 (overdue == 0 days).
    now = pd.Timestamp.now(tz="UTC")
    days = (pd.to_datetime(out["due_date"], utc=True) - now).dt.total_seconds() / 86_400.0
    days = days.clip(lower=0.0)
    # Linear decay to zero at the horizon; overdue and today both score 1.0.
    out["n_proximity"] = (1.0 - (days / cfg.proximity_horizon_days)).clip(0.0, 1.0)

    # Criticality: already an ordinal tier in [0, 1] from entity taxonomy.
    out["n_criticality"] = out["entity_tier"].clip(0.0, 1.0)
    return out

Phase 3 — Compute the composite score and project onto tiers

The weighted sum runs as a single vectorized operation over the whole portfolio, then np.select assigns tiers from the thresholds. No row-by-row Python loop touches the hot path.

def score_obligations(df: pd.DataFrame, cfg: ScoringConfig) -> pd.DataFrame:
    required = {"entity_id", "jurisdiction", "due_date", "penalty_risk", "entity_tier"}
    missing = required - set(df.columns)
    if missing:
        raise ScoringConfigError(f"missing required columns: {sorted(missing)}")

    out = _normalize_signals(df, cfg)
    out["priority_score"] = (
        cfg.w_penalty * out["n_penalty"]
        + cfg.w_proximity * out["n_proximity"]
        + cfg.w_criticality * out["n_criticality"]
    ) * 100.0

    conditions = [
        out["priority_score"] >= cfg.critical_at,
        out["priority_score"] >= cfg.high_at,
        out["priority_score"] >= cfg.medium_at,
    ]
    out["routing_tier"] = np.select(conditions, ["CRITICAL", "HIGH", "MEDIUM"], default="LOW")

    for row in out.itertuples():
        logger.info(
            "obligation scored",
            extra={
                "entity_id": row.entity_id,
                "jurisdiction": row.jurisdiction,
                "priority_score": round(row.priority_score, 4),
                "routing_tier": row.routing_tier,
                "n_penalty": round(row.n_penalty, 4),
                "n_proximity": round(row.n_proximity, 4),
                "n_criticality": round(row.n_criticality, 4),
            },
        )
    return out.sort_values("priority_score", ascending=False, kind="stable")

Phase 4 — Adaptive weighting past the statutory cliff

A static weight set under-prioritizes an obligation the moment it crosses a grace boundary and penalty exposure starts compounding. Re-derive a per-obligation weight set that lets penalty dominate once the deadline is breached, without ever violating the sum-to-one invariant.

def adaptive_weights(days_to_deadline: float, base: ScoringConfig) -> ScoringConfig:
    """Shift weight toward penalty as a deadline crosses zero, renormalized to 1.0."""
    if days_to_deadline > 0:
        return base
    boosted_penalty = min(0.80, base.w_penalty + 0.25)
    remaining = 1.0 - boosted_penalty
    prox = base.w_proximity / (base.w_proximity + base.w_criticality) * remaining
    crit = remaining - prox
    return ScoringConfig(
        w_penalty=boosted_penalty,
        w_proximity=prox,
        w_criticality=crit,
        critical_at=base.critical_at,
        high_at=base.high_at,
        medium_at=base.medium_at,
        proximity_horizon_days=base.proximity_horizon_days,
    )

A CRITICAL result is then handed to the dispatcher as a single atomic action: scored obligations flow into Multi-Entity Batch Orchestration in score order, while assignment of the responsible filer is resolved through Registered Agent Assignment Logic and the resulting alerts are pushed by the Calendar Sync & Notification Pipelines.

Edge Cases and Jurisdiction-Specific Gotchas

The proximity signal is jurisdiction-neutral, but the penalty signal it is summed with is not — and that asymmetry produces non-obvious ranking behavior near each state’s grace boundary.

Jurisdiction Statutory anchor Scoring gotcha
Delaware DGCL § 502 — $200 + 1.5%/mo Penalty steps up at day 30, so two obligations equally overdue can diverge sharply in rank once one crosses the compounding boundary; do not cache penalty_risk across the cliff.
California Corp. Code § 1502 — $250, suspension path Risk should saturate near the 60-day suspension cliff; clamp n_penalty so a near-suspension entity cannot be out-ranked by a high-revenue but low-risk filing.
Texas BOC § 4.002 — surcharge + dissolution at 90d Dual threshold: financial compounding at day 30 and existential risk at day 90. The score must reflect the existential jump, not just the surcharge.
New York BCL § 408 — no grace period n_penalty is effectively 1.0 on day one overdue; proximity adds little, so weighting must let penalty alone push these to CRITICAL immediately.

Two further traps are not jurisdictional. A naive day count is timezone-sensitive: an obligation “due tomorrow” in Pacific time can already be overdue in UTC, so due_date must arrive timezone-aware and proximity must be computed in a single reference frame. And missing upstream metadata must fail safe — an absent penalty_risk should be treated as maximum risk, never silently zeroed, or the engine will deprioritize exactly the obligations it knows least about.

Verification and Testing

Scoring is deterministic, which makes it unusually testable: pin the inputs, assert the rank. The properties worth locking down are monotonicity (more penalty or less time can only raise a score), the sum-to-one invariant, and stable ordering for ties.

import pandas as pd
import pytest

from scorer import ScoringConfig, ScoringConfigError, score_obligations


def _frame(**over) -> pd.DataFrame:
    base = {
        "entity_id": ["A"], "jurisdiction": ["DE"],
        "due_date": ["2026-07-15T00:00:00Z"],
        "penalty_risk": [50.0], "entity_tier": [0.5],
    }
    base.update({k: [v] for k, v in over.items()})
    return pd.DataFrame(base)


def test_weights_must_sum_to_one() -> None:
    with pytest.raises(ScoringConfigError):
        ScoringConfig(w_penalty=0.6, w_proximity=0.6, w_criticality=0.2)


def test_higher_penalty_never_lowers_score() -> None:
    cfg = ScoringConfig()
    low = score_obligations(_frame(penalty_risk=10.0), cfg)["priority_score"].iloc[0]
    high = score_obligations(_frame(penalty_risk=90.0), cfg)["priority_score"].iloc[0]
    assert high >= low


def test_no_grace_jurisdiction_reaches_critical_when_overdue() -> None:
    cfg = ScoringConfig()
    overdue = _frame(jurisdiction="NY", due_date="2020-01-01T00:00:00Z", penalty_risk=100.0)
    assert score_obligations(overdue, cfg)["routing_tier"].iloc[0] == "CRITICAL"

For integration coverage, feed a fixed 50-entity multi-jurisdiction frame through score_obligations and snapshot the resulting (entity_id, routing_tier) ordering; a diff in that snapshot on an unrelated change is the signal that a normalization or weighting regression slipped in.

Troubleshooting

Every obligation lands in the same tier regardless of urgency

Root cause: the signals were not normalized before the weighted sum, so one raw unit (usually dollar penalty) saturated the score for every row. Confirm _normalize_signals runs first and that n_penalty, n_proximity, and n_criticality are all in [0, 1] before weighting. A constant tier is almost always an un-normalized input, not a threshold problem.

A high-revenue dormant entity outranks a near-dissolution filing

Root cause: criticality weight is too high relative to penalty, or penalty_risk for the near-dissolution entity was stale and cached across a statutory cliff. Lower w_criticality, and invalidate the upstream penalty score whenever days_to_deadline crosses a jurisdiction’s grace boundary so the compounding step is reflected.

Scores differ between a local run and the production batch

Root cause: timezone drift. Proximity was computed against a naive local clock in one environment and UTC in the other. Ensure due_date is timezone-aware and that pd.Timestamp.now(tz="UTC") is the only reference clock. Naive datetimes are the single most common source of non-reproducible scores.

An obligation with missing penalty data scores near zero

Root cause: a null penalty_risk was coerced to 0 instead of failing safe to maximum risk. Treat absent upstream metadata as the highest-risk configuration and emit a WARNING with the entity_id and jurisdiction, so the engine over-protects the obligations it knows least about rather than burying them.

Operational Checklist

Frequently Asked Questions

Why score at all instead of sorting by due date?

A due date encodes when, not how much it costs to miss. Two filings due the same day can differ by orders of magnitude in consequence — a flat fee versus administrative dissolution. Scoring folds penalty exposure and entity blast radius into the ordering, so when capacity is constrained the engine spends it on the obligations whose lapse is genuinely unrecoverable, not whichever happens to be alphabetically first.

How are the weights chosen and who owns them?

Weights are a risk-appetite decision owned by compliance, not engineering, which is why they live in configuration and are validated to sum to 1.0. A defensible starting point is calibrated by backtesting against 24 months of completed filings: tune the weights until historical late-filing incidents correlate with a high composite score, then apply jurisdiction-specific overrides for high-penalty states.

What stops the score from flattening near a deadline?

Two mechanisms. The proximity signal decays linearly to the deadline rather than asymptotically, so it keeps contributing as the clock runs out, and adaptive weighting shifts pull toward penalty exposure the moment an obligation crosses zero days. Together they keep an overdue, high-penalty filing climbing toward CRITICAL instead of plateauing alongside lower-consequence work.

Is the score reproducible for an audit?

Yes — the scorer is a pure function over typed inputs with no I/O on the hot path, and every scoring event is logged with its full input vector and resulting tier in structured JSON consistent with NIST SP 800-92. An examiner can replay a persisted input vector through the same code and must obtain the identical score and tier; any divergence indicates configuration drift rather than a defensible decision.