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 |
Prerequisites
- Python 3.10+ — for
matchstatements, 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.
- Missing jurisdiction metadata (configuration). A registry miss or unknown
state_coderaises inside_fetch_from_registry. The engine logs aWARNINGwith the exactstate_codeand applies_default_config—grace_days=0,HIGHenforcement — so the entity scores high, surfaces for review, and is never silently buried. Never default to a low-risk config. - 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. - Grace-boundary off-by-one (terminal logic bug). A naive-datetime
due_dateor fractional-hour arithmetic mis-countsdays_past_dueat exactlygrace_days. Pin tests atgrace_daysandgrace_days + 1; require timezone-aware inputs and whole-day truncation so the day-30 step fires on the correct calendar date. - 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 assertgetcontext().prec == 12in 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.