Deadline Tracking Routing Engines

Calculating Penalty Risk Scores Based on State Grace Periods

This guide sits inside the Priority Scoring Algorithms cluster of the Deadline Tracking & Routing Engines discipline, and it supplies the single signal that prioritization depends on most: a per-obligation penalty_risk value in [0, 100] that the scorer never recomputes. Grace periods are not standardized buffers — they are statutory phase-shifters that interact with portal-specific penalty engines, compounding interest, and administrative dissolution triggers. The job here is to translate those jurisdictional nuances into a deterministic, decimal-precise number a routing engine can trust.

Scope

This page covers exactly one transformation: given an obligation’s jurisdiction, due date, base penalty schedule, and entity context, produce a reproducible penalty_risk score that quantifies consequence-weighted exposure against that state’s grace window. It includes the grace-period boundary logic, compounding-versus-flat penalty modelling, the fixed-point arithmetic that keeps scores audit-stable, and the conservative fallback applied when jurisdiction metadata is missing.

It deliberately excludes deadline resolution (timezone-aware due dates are owned by State Filing Deadline Calendars), the downstream weighted-sum ranking and tiering (owned by the parent cluster), and penalty-waiver or reinstatement workflows. This is the upstream sensor, not the router.

Statutory Constraint Driving the Task

Grace periods are defined per statute and enforced per portal, and the two rarely match the prose on a state website. Delaware does not begin compounding franchise-tax interest until its 30-day window expires (DGCL § 502); California assesses a flat penalty immediately under Corp. Code § 1502 and walks an entity toward suspension at the 60-day mark; Texas adds a compounding surcharge after 30 days under BOC § 4.002 and runs administrative dissolution at 90 days; New York recognizes no grace window at all on the biennial statement under BCL § 408, so exposure is maximal on day one. A scorer that flattens these into “days late” discards the exact information that should drive routing. Encoding the boundaries faithfully is what makes the resulting number legally defensible rather than a heuristic.

The jurisdiction parameters below are the minimum statutory surface the engine models. Source values from the compliance metadata schemas layer so a single statutory update propagates everywhere.

State Code section Grace window Penalty model Existential trigger
Delaware DGCL § 502 30 days $200 base + 1.5% monthly interest, compounding after grace Suspension on prolonged non-payment
California Corp. Code § 1502 None (flat) $250 immediate on late Statement of Information Suspension flagged at 60 days
Texas BOC § 4.002 30 days $50 base + 5% compounding surcharge after grace Administrative dissolution at 90 days
New York BCL § 408 0 days $250 immediate, full magnitude on day 1 Loss of good standing
Penalty-risk trajectory versus days past due, by state grace regime Four state penalty-risk curves on shared axes. New York (no grace) spikes to 100 on day 1; California rises linearly to 100 at its 60-day suspension cliff; Delaware is flat through its 30-day grace window then a compounding staircase; Texas is flat through grace, steps up at day 30, then makes an existential jump at the 90-day dissolution threshold. penalty_risk vs. days past due — same statute, four grace regimes, four shapes NY CA DE TX 100 75 50 25 0 30 grace edge (DE / TX) 60 CA suspension cliff 90 days past due penalty risk (0–100) NY · BCL §408 — 100 on day 1, no grace CA · flat on day 0, linear to suspension DE · +1.5%/mo compounding step after day 30 TX · 5% surcharge step @30, dissolution jump @90

Prerequisites

  • Python 3.10+ — for match statements, the | union syntax, and modern type hints.
  • Standard library only for the core engine: decimal (fixed-point arithmetic), dataclasses, datetime (timezone-aware), hashlib (audit hashing), logging (structured JSON), enum.
  • A jurisdiction registry — a DB table or service exposing per-state grace days, base penalty, monthly interest rate, enforcement velocity, and suspension day. Supplied by the compliance metadata layer; never hard-coded in the scorer.
  • A timezone-aware due date for every obligation, already resolved by the deadline-calendar layer. The scorer consumes it and must never recompute a date.

Implementation

The module below is the complete scorer. Every financial calculation uses Decimal with a fixed context so a score is bitwise-reproducible during an audit; floats are never used for penalty arithmetic. The grace-period boundary is a hard step at grace_days, and missing jurisdiction metadata routes to the highest-risk default rather than silently scoring low.

import hashlib
import logging
import time
from dataclasses import dataclass
from datetime import datetime, timezone
from decimal import Decimal, ROUND_HALF_UP, getcontext
from enum import Enum
from typing import Any, Optional

# Fixed-point context set once at import — NOT inside request handlers,
# where thread-local leakage would cause silent rounding drift in batch runs.
getcontext().prec = 12
getcontext().rounding = ROUND_HALF_UP

logger = logging.getLogger("compliance.penalty_risk")
logger.setLevel(logging.INFO)


class EnforcementVelocity(Enum):
    """How aggressively a portal escalates to suspension/dissolution."""
    LOW = Decimal("0.25")
    MEDIUM = Decimal("0.50")
    HIGH = Decimal("0.85")
    CRITICAL = Decimal("1.00")


@dataclass(frozen=True)
class JurisdictionConfig:
    state_code: str
    grace_days: int
    base_penalty: Decimal
    monthly_interest_rate: Decimal
    enforcement_velocity: EnforcementVelocity
    suspension_days: int


@dataclass(frozen=True)
class PenaltyRiskResult:
    entity_id: str
    state_code: str
    days_past_due: int
    projected_penalty: Decimal
    penalty_risk: int          # the 0-100 signal the router consumes
    input_hash: str
    output_hash: str
    timestamp: datetime


class TTLCache:
    """Per-jurisdiction config cache so a statutory update can be invalidated."""

    def __init__(self, ttl_seconds: int = 3600) -> None:
        self._cache: dict[str, tuple[Any, float]] = {}
        self._ttl = ttl_seconds

    def get(self, key: str) -> Optional[Any]:
        hit = self._cache.get(key)
        if hit is None:
            return None
        value, ts = hit
        if time.time() - ts < self._ttl:
            return value
        self._cache.pop(key, None)
        return None

    def set(self, key: str, value: Any) -> None:
        self._cache[key] = (value, time.time())

    def invalidate(self, key: Optional[str] = None) -> None:
        if key:
            self._cache.pop(key, None)
        else:
            self._cache.clear()


class PenaltyRiskCalculator:
    # Weights for the four sub-signals; sum to 1.0 by contract.
    W_TEMPORAL = Decimal("0.35")
    W_FINANCIAL = Decimal("0.25")
    W_CRITICALITY = Decimal("0.20")
    W_ENFORCEMENT = Decimal("0.20")
    PENALTY_CAP = Decimal("10000.00")   # normalization ceiling for $ exposure

    def __init__(self, cache: Optional[TTLCache] = None) -> None:
        self.cache = cache or TTLCache()
        # Conservative fallback: an unknown state is treated as high-risk,
        # never low-risk, so missing metadata can never hide exposure.
        self._default_config = JurisdictionConfig(
            state_code="UNKNOWN",
            grace_days=0,
            base_penalty=Decimal("250.00"),
            monthly_interest_rate=Decimal("0.015"),
            enforcement_velocity=EnforcementVelocity.HIGH,
            suspension_days=60,
        )

    def _get_config(self, state_code: str) -> JurisdictionConfig:
        cached = self.cache.get(state_code)
        if cached is not None:
            return cached
        try:
            config = self._fetch_from_registry(state_code)
        except Exception as exc:  # registry down or state unknown
            logger.warning(
                "jurisdiction_metadata_unavailable_applying_fallback",
                extra={"state_code": state_code, "error": str(exc)},
            )
            config = self._default_config
        self.cache.set(state_code, config)
        return config

    def _fetch_from_registry(self, state_code: str) -> JurisdictionConfig:
        # Wire this to the compliance metadata registry in production.
        raise NotImplementedError("Registry integration required")

    def calculate(
        self,
        entity_id: str,
        state_code: str,
        due_date: datetime,
        criticality_weight: Decimal,
    ) -> PenaltyRiskResult:
        config = self._get_config(state_code)
        now = datetime.now(timezone.utc)
        # Truncate to whole calendar days; portals apply penalties at midnight,
        # not on fractional hours. due_date MUST be timezone-aware.
        days_past_due = max(0, (now - due_date).days)

        # Temporal exposure, normalized against the suspension cliff.
        if config.suspension_days > 0:
            t_norm = min(Decimal("1.0"),
                         Decimal(days_past_due) / Decimal(config.suspension_days))
        else:
            t_norm = Decimal("1.0")

        # Financial magnitude: flat until grace expires, then compounding.
        penalty = config.base_penalty
        if days_past_due > config.grace_days:
            months_late = (Decimal(days_past_due - config.grace_days)
                           / Decimal(30)).quantize(Decimal("0.01"))
            penalty += penalty * config.monthly_interest_rate * months_late
        f_norm = min(Decimal("1.0"), penalty / self.PENALTY_CAP)

        raw = (
            t_norm * self.W_TEMPORAL
            + f_norm * self.W_FINANCIAL
            + criticality_weight * self.W_CRITICALITY
            + config.enforcement_velocity.value * self.W_ENFORCEMENT
        )
        score = int((raw * Decimal("100")).quantize(Decimal("1"),
                                                     rounding=ROUND_HALF_UP))
        penalty_risk = min(100, max(0, score))

        # Cryptographic audit chain: input and output hashes make every
        # score replayable and tamper-evident during compliance review.
        input_payload = f"{entity_id}|{state_code}|{days_past_due}|{criticality_weight}"
        input_hash = hashlib.sha256(input_payload.encode()).hexdigest()
        output_payload = f"{penalty_risk}|{penalty}|{now.isoformat()}"
        output_hash = hashlib.sha256(output_payload.encode()).hexdigest()

        logger.info(
            "penalty_risk_scored",
            extra={
                "entity_id": entity_id,
                "state_code": state_code,
                "days_past_due": days_past_due,
                "penalty_risk": penalty_risk,
                "audit_hash": output_hash,
            },
        )

        return PenaltyRiskResult(
            entity_id=entity_id,
            state_code=state_code,
            days_past_due=days_past_due,
            projected_penalty=penalty,
            penalty_risk=penalty_risk,
            input_hash=input_hash,
            output_hash=output_hash,
            timestamp=now,
        )

The criticality_weight argument is the only signal sourced outside this module — it comes from the Entity Taxonomy & Classification layer, because a revenue-bearing parent’s missed filing has a larger blast radius than a dormant shell’s at identical lateness. Everything else is derived from statute and the clock.

Configuration Reference

Parameter Typical value Legal / operational justification
getcontext().prec 12 Enough significant digits for compounding interest without IEEE-754 drift; set once at import.
grace_days 0–30 per state The statutory window before compounding/escalation begins (DGCL § 502 = 30, BCL § 408 = 0).
monthly_interest_rate 0.015 (DE) Per-state statutory accrual; drives the post-grace step function.
suspension_days 60–90 per state The existential cliff used to normalize temporal exposure; CA flags at 60, TX dissolves at 90.
PENALTY_CAP $10,000 Normalization ceiling so a single large penalty cannot saturate the score beyond its weight.
EnforcementVelocity LOW–CRITICAL Ordinal escalation aggressiveness; missing metadata defaults to HIGH, never LOW.
TTLCache(ttl_seconds) 3600 Bounds how long a stale statutory config can survive a registry update before forced invalidation.
Weight set 0.35 / 0.25 / 0.20 / 0.20 Temporal-dominant by design; weights are a compliance risk-appetite decision and must sum to 1.0.

Failure Modes and Fallback Routing

These map onto the broader error categorization and retry logic taxonomy: distinguish transient (retry), configuration (fallback + alert), and terminal (escalate) failures rather than blanket-retrying everything.

  1. Missing jurisdiction metadata (configuration). A registry miss or unknown state_code raises inside _fetch_from_registry. The engine logs a WARNING with the exact state_code and applies _default_configgrace_days=0, HIGH enforcement — so the entity scores high, surfaces for review, and is never silently buried. Never default to a low-risk config.
  2. Stale config after a statutory change (configuration). If a state amends its penalty schedule but cached entries still serve old values, scores drift quietly. Call cache.invalidate(state_code) from a post-deploy hook whenever metadata changes, and monitor cache hit/miss ratios via structured log aggregation.
  3. Grace-boundary off-by-one (terminal logic bug). A naive-datetime due_date or fractional-hour arithmetic mis-counts days_past_due at exactly grace_days. Pin tests at grace_days and grace_days + 1; require timezone-aware inputs and whole-day truncation so the day-30 step fires on the correct calendar date.
  4. Decimal context leak (terminal). If getcontext() is reconfigured inside a request handler, batch runs produce non-reproducible scores and audit replays diverge. Keep context setup at module import and assert getcontext().prec == 12 in a startup health check.

Frequently Asked Questions

Why use Decimal instead of float for the penalty math?

Because the score has to be reproducible bitwise during an audit and compounding interest accumulates sub-cent error under IEEE-754. A fixed Decimal context with ROUND_HALF_UP makes every projected penalty and every score replayable from the persisted input vector — a float pipeline cannot guarantee that, which makes it legally indefensible.

How does the grace period actually change the score?

Up to grace_days the financial component reflects only the flat base penalty, so the score is dominated by temporal and enforcement signals. The moment days_past_due crosses grace_days, compounding interest is added as a step function and the financial component climbs each month, pushing the obligation toward CRITICAL. New York, with zero grace, starts at full financial magnitude on day one.

What happens when a state's metadata is missing?

The engine applies a conservative high-risk fallback — zero grace days and HIGH enforcement velocity — and emits a WARNING with the offending state_code. The entity scores high and is routed for manual review rather than scored low and forgotten. Defaulting to a benign config would let unmapped jurisdictions hide real exposure.

Is this score the same as the routing priority?

No. This page produces only penalty_risk in [0, 100] — the dominant input to the parent scoring algorithm’s weighted-sum scorer, which combines it with deadline proximity and entity criticality before projecting onto CRITICAL/HIGH/MEDIUM/LOW tiers. Penalty risk is the sensor; the routing algorithm is the router.