Priority Scoring Algorithms for Government Records Automation

Within Intake & Routing Workflows, priority scoring algorithms are the deterministic layer that turns unstructured request metadata into a defensible queue position before any custodian touches the request. The score executes immediately after Email & Form Parsing Pipelines emit a validated payload, and the tier it produces becomes an immutable directive that Department Routing Logic and Async Queue Management consume downstream. This guide covers the statutory stakes, the environment, a runnable scoring service, how to verify it, and the failure modes that bite government records teams in production.

Problem Framing & Statutory Requirement

A public records program does not get to process requests in the order it prefers. Federal FOIA fixes a 20-business-day response window under 5 U.S.C. § 552(a)(6)(A)(i), most state open-records acts impose their own (often shorter) clocks, and several statutes grant accelerated handling — for example expedited processing under 5 U.S.C. § 552(a)(6)(E) when there is a compelling need or urgency to inform the public. A naive first-in-first-out queue silently breaches the tightest deadlines first, because it has no concept of how much statutory runway each request has left.

Priority scoring solves that by making urgency a quantified, auditable number rather than a custodian’s judgement call. Two compliance properties matter more than raw cleverness here. First, determinism: the same metadata must always yield the same score, because an agency may have to defend a routing decision in litigation or before an oversight body. Second, transparency: every component that contributed to the score, and the exact weight matrix version in force at the time, must be reconstructable from the audit log. A scoring engine that cannot be replayed is a liability, not an asset.

This page treats scoring as a stateless, configuration-driven function. Compliance officers tune the weight matrix; engineers guarantee the function is pure, bounded, and logged. Keeping those concerns separate is what lets you change policy without a code deployment and still prove, months later, why request 2026-004417 was marked critical.

Prerequisites & Environment Setup

  • Python 3.11+ — required for datetime.UTC, the tomllib standard-library TOML reader (handy for externalized weight matrices), and exception-group ergonomics.
  • Standard library only for the core: dataclasses, enum, datetime, json, logging, hashlib. The scoring kernel deliberately takes no third-party runtime dependency so it can run inside any worker without supply-chain risk.
  • hypothesis>=6.100 (dev/test only) for property-based validation of the bounds and monotonicity invariants.
  • A versioned configuration source for the weight matrix — a TOML/JSON file in version control or a read-only config table. The scorer must never read mutable application state.
  • Access controls: the scoring worker needs read access to parsed request metadata and append-only write access to the audit sink. It needs nothing else; enforce that boundary per Security Boundary Configuration so a compromised scorer cannot mutate request bodies or routing tables.

Sensitivity flags consumed by the scorer (PII, health, law-enforcement, minor) should already be attached upstream during parsing; the scorer reads them, it does not re-derive them.

Architecture Overview

The scoring stage sits between parsing and routing as a pure function: validated metadata in, an immutable ScoreResult plus one audit line out. It never blocks on I/O beyond emitting that audit record, which keeps it cheap enough to run inline in an ingestion worker or as its own queue-dispatched task.

The priority scoring data-flow Validated metadata enters from the left and a versioned weight matrix is loaded from the top as read-only config. Inside the stateless PriorityScorer, four component scorers — deadline proximity (weight 0.35), requestor type (0.20), content sensitivity (0.25) and department latency (0.20) — converge on a weighted sum. The sum is clamped to a 0 to 100 score plus component breakdown, then handed downstream to department routing and async queue management. A single append-only audit line carrying the weights version, input hash and breakdown is emitted beneath, aligned with NIST SP 800-53 AU-9. Versioned weight matrix · read-only config Parsed metadata validated PriorityScorer · pure function, no I/O Deadline proximity weight 0.35 Requestor type weight 0.20 Content sensitivity weight 0.25 Dept latency weight 0.20 Σ wᵢ·sᵢ weighted sum Clamp → 0–100 score + component breakdown Department routing & async queue (band) emit Append-only audit line · weights_version · input_hash · breakdown — NIST SP 800-53 AU-9

Step-by-Step Implementation

1. Define the weight schema and statutory factor model

A production scoring algorithm is a weighted additive model where each factor maps to a specific compliance or operational trigger: statutory deadline proximity, requestor classification, content sensitivity, and the target department’s historical processing latency. Each factor carries a normalized weight in 0.0–1.0 and the weights must sum to 1.0, so the final score is the bounded sum score=iwisi\text{score} = \sum_{i} w_i \cdot s_i, scaled to a fixed 0–100 integer range. Validating the weight sum at construction time turns a misconfigured policy into an immediate, loud failure instead of silent priority drift.

python
from dataclasses import dataclass, field
from typing import Dict, List
from datetime import datetime, timedelta, timezone
from enum import Enum

class RequestorType(str, Enum):
    CITIZEN = "citizen"
    MEDIA = "media"
    LEGISLATIVE = "legislative"
    AGENCY = "agency"

@dataclass(frozen=True)
class ScoringWeights:
    # Weights are policy, not code: load these from a versioned config source
    # (TOML/JSON in VCS) so compliance can retune without a deployment.
    deadline_proximity: float = 0.35
    requestor_type: float = 0.20
    content_sensitivity: float = 0.25
    dept_latency: float = 0.20
    version: str = "weights-2026-01"  # logged with every score for replay

    def __post_init__(self):
        total = (
            self.deadline_proximity
            + self.requestor_type
            + self.content_sensitivity
            + self.dept_latency
        )
        # Fail loudly on misconfiguration rather than skewing every score.
        if not (0.99 <= total <= 1.01):
            raise ValueError(f"Weights must sum to 1.0. Current sum: {total}")

@dataclass(frozen=True)
class RequestMetadata:
    request_id: str
    received_at: datetime          # tz-aware; the statutory clock starts here
    statutory_deadline: datetime   # precomputed per jurisdiction upstream
    requestor_type: RequestorType
    sensitivity_flags: List[str]
    target_department: str
    historical_avg_days: float

Note that statutory_deadline is supplied, not computed here — deadline calculation is jurisdiction-specific (business-day counting, tolling, holiday suspension) and belongs with the rules maintained under State-Law Compliance Frameworks. The scorer only reasons about proximity to a deadline it is handed.

2. Compute deterministic component scores

Each component is a small, side-effect-free method returning a normalized 0.0–1.0 value. Keeping them isolated is what makes the engine testable: you can assert each curve independently and assert their composition separately.

python
@dataclass
class ScoreResult:
    request_id: str
    priority_score: int
    breakdown: Dict[str, float]
    weights_version: str
    audit_timestamp: datetime
    warnings: List[str] = field(default_factory=list)

class PriorityScorer:
    def __init__(self, weights: ScoringWeights):
        self.weights = weights
        # Media and legislative requests often carry expedited-processing
        # exposure (5 U.S.C. § 552(a)(6)(E)); reflect that in the multiplier.
        self._type_multipliers = {
            RequestorType.MEDIA: 1.0,
            RequestorType.LEGISLATIVE: 0.9,
            RequestorType.CITIZEN: 0.7,
            RequestorType.AGENCY: 0.5,
        }

    def _deadline_score(self, m: RequestMetadata) -> float:
        # 5 U.S.C. § 552(a)(6)(A)(i): the 20-business-day clock. Less runway
        # left => higher urgency. An elapsed deadline pins to maximum.
        days_remaining = (m.statutory_deadline - m.received_at).days
        if days_remaining <= 0:
            return 1.0
        if days_remaining <= 5:
            return 0.9
        if days_remaining <= 15:
            return 0.6
        return 0.2

    def _requestor_score(self, m: RequestMetadata) -> float:
        return self._type_multipliers.get(m.requestor_type, 0.5)

    def _sensitivity_score(self, m: RequestMetadata) -> float:
        # Sensitive content needs senior/dedicated handling and review headroom
        # before the deadline; weight it up, but keep it bounded.
        base = 0.3
        for flag in m.sensitivity_flags:
            if flag.lower() in ("pii", "health", "law_enforcement", "minor"):
                base += 0.25
        return min(base, 1.0)

    def _latency_score(self, m: RequestMetadata) -> float:
        # Departments that historically run slow need an earlier start to land
        # inside the same statutory window.
        if m.historical_avg_days <= 0:
            return 0.5
        if m.historical_avg_days > 20:
            return 0.9
        if m.historical_avg_days > 10:
            return 0.6
        return 0.3

3. Emit the audited priority score

The compute method composes the components, clamps the result, and — critically — emits a single structured JSON audit line carrying the weight-matrix version, an integrity hash of the input, and every component score. That line is the record an oversight body replays.

python
import json
import logging
import hashlib

audit_logger = logging.getLogger("priority_scoring_audit")

class PriorityScorer(PriorityScorer):  # continued for illustration
    def _input_hash(self, m: RequestMetadata) -> str:
        canonical = json.dumps(
            {
                "request_id": m.request_id,
                "received_at": m.received_at.isoformat(),
                "statutory_deadline": m.statutory_deadline.isoformat(),
                "requestor_type": m.requestor_type.value,
                "sensitivity_flags": sorted(f.lower() for f in m.sensitivity_flags),
                "target_department": m.target_department,
                "historical_avg_days": m.historical_avg_days,
            },
            sort_keys=True,
            separators=(",", ":"),
        )
        return hashlib.sha256(canonical.encode("utf-8")).hexdigest()

    def compute(self, m: RequestMetadata) -> ScoreResult:
        warnings: List[str] = []
        try:
            components = {
                "deadline_proximity": self._deadline_score(m),
                "requestor_type": self._requestor_score(m),
                "content_sensitivity": self._sensitivity_score(m),
                "dept_latency": self._latency_score(m),
            }
        except Exception:
            # Never silently drop a request: log and re-raise so the queue
            # routes it to the DLQ rather than losing the obligation.
            audit_logger.exception(
                json.dumps({"event": "scoring_error", "request_id": m.request_id})
            )
            raise

        raw = (
            self.weights.deadline_proximity * components["deadline_proximity"]
            + self.weights.requestor_type * components["requestor_type"]
            + self.weights.content_sensitivity * components["content_sensitivity"]
            + self.weights.dept_latency * components["dept_latency"]
        )
        priority_score = max(0, min(100, int(raw * 100)))  # bounded by contract

        if priority_score >= 85:
            warnings.append("HIGH_PRIORITY_THRESHOLD_EXCEEDED")

        result = ScoreResult(
            request_id=m.request_id,
            priority_score=priority_score,
            breakdown={k: round(v, 3) for k, v in components.items()},
            weights_version=self.weights.version,
            audit_timestamp=datetime.now(timezone.utc),
            warnings=warnings,
        )

        # Append-only audit line (NIST SP 800-53 AU-9: protect audit info).
        audit_logger.info(
            json.dumps(
                {
                    "event": "priority_scored",
                    "request_id": result.request_id,
                    "input_hash": self._input_hash(m),
                    "weights_version": result.weights_version,
                    "priority_score": result.priority_score,
                    "breakdown": result.breakdown,
                    "warnings": result.warnings,
                    "ts": result.audit_timestamp.isoformat(),
                }
            )
        )
        return result

Expected output for a media request with five days of runway and a PII flag against a slow department is a high score and a single audit line:

json
{"event": "priority_scored", "request_id": "2026-004417", "input_hash": "9f2c…", "weights_version": "weights-2026-01", "priority_score": 86, "breakdown": {"deadline_proximity": 0.9, "requestor_type": 1.0, "content_sensitivity": 0.55, "dept_latency": 0.9}, "warnings": ["HIGH_PRIORITY_THRESHOLD_EXCEEDED"], "ts": "2026-06-27T14:02:11.481209+00:00"}

For agencies whose weights must shift with municipal load, Implementing dynamic priority scoring for urgent municipal requests extends this kernel with adaptive recalibration that still respects statutory baselines.

4. Map scores to interoperable priority bands

Routing and SLA enforcement consume bands, not raw integers, so that a request forwarded between agencies keeps a stable meaning. Standardize on fixed, documented bands and pass them downstream as immutable directives:

Priority-band tiering of the 0–100 score A single 0 to 100 priority score is split into four contiguous, documented bands along an escalating scale: Standard covers scores 0 to 30, Elevated covers 31 to 60, Urgent covers 61 to 85, and Critical covers 86 to 100. The band is derived once from the score and passed downstream as an immutable routing directive, so no consumer re-ranks the request. Priority score 0 – 100 Standard 0 – 30 Elevated 31 – 60 Urgent 61 – 85 Critical 86 – 100 Band derived once from the score, then passed downstream as an immutable routing directive — no consumer re-ranks it.

Because the band is derived once and never recomputed downstream, neither Department Routing Logic nor a worker pool can quietly re-rank a request and invalidate the deadline reasoning that produced it.

Validation & Verification

Deterministic scoring earns its name only if you prove the invariants hold. Treat the kernel like any other safety-critical pure function:

  • Bounds invariant (property-based). Generate arbitrary valid metadata with hypothesis and assert 0 <= result.priority_score <= 100 always. A failing case is almost always a new sensitivity flag or weight set that broke the clamp assumption.
  • Determinism / replay. Score the same RequestMetadata twice and assert identical priority_score, breakdown, and input_hash. This is the test that lets you defend a routing decision: the audit line is reproducible from the inputs.
  • Monotonicity. Holding everything else constant, reducing days_remaining must never decrease the score. Encode this as a targeted parametric test, not just a property, so regressions name the exact factor.
  • Log assertion. Capture the priority_scoring_audit logger in tests and assert exactly one JSON line per compute call, that it parses, and that it carries weights_version and input_hash. A scored request with no audit line is a compliance gap, not a passing test.
python
import json, logging
from hypothesis import given, strategies as st

def test_score_is_always_bounded(caplog):
    scorer = PriorityScorer(ScoringWeights())
    with caplog.at_level(logging.INFO, logger="priority_scoring_audit"):
        r = scorer.compute(sample_metadata())  # build a valid fixture
    assert 0 <= r.priority_score <= 100
    line = json.loads(caplog.records[-1].message)   # exactly-one audit line
    assert line["weights_version"] == r.weights_version

Troubleshooting & Edge Cases

  • Naive vs. tz-aware datetimes. If received_at is naive and statutory_deadline is tz-aware (or vice versa), the subtraction raises TypeError and the request lands in the DLQ. Fix: normalize every timestamp to UTC at the parsing boundary and reject naive datetimes before they reach the scorer.
  • Weight matrix drift after a policy edit. A hand-edited config whose factors no longer sum to 1.0 either fails construction (good) or, if validation is bypassed, silently compresses the dynamic range so nothing ever reaches Critical. Diagnosis: watch the score distribution; a sudden ceiling well below 86 signals a bad matrix. Fix: gate every weight change through the __post_init__ sum check in CI and log weights_version on every score.
  • Duplicate submissions inflating apparent load. The same request arriving twice produces two identical scores and can double-book a department’s capacity planning. Fix: dedupe on the canonical input_hash (or the upstream idempotency key) before scoring, and let Async Queue Management reject the redelivery rather than scoring it again.
  • Litigation-hold conflict. A request under a litigation or records hold must not be auto-expedited or auto-closed by its score. Fix: treat an active hold as an override that pins the request to a manual review lane regardless of band, and align the audit retention with Records-Retention Scheduling.
  • Unknown sensitivity flags. A new flag (e.g. cjis) the scorer doesn’t recognize is silently ignored, under-scoring genuinely sensitive material. Fix: validate flags against an allowlist at parse time and emit a UNKNOWN_SENSITIVITY_FLAG warning so the gap surfaces instead of hiding.
  • Emergency freeze during outages or declared emergencies. During statutory tolling, holiday suspension, or a system outage, bypass scoring entirely with a circuit breaker and route everything to a manual triage lane carrying a FROZEN_PRIORITY flag, so automated routing cannot misallocate during degraded operation.

Compliance Verification Checklist

FAQ

Why use a deterministic weighted model instead of a machine-learning ranker?

Because the output has to be explainable and replayable. An agency may be asked — in litigation or by an oversight body — to justify exactly why one request was prioritized over another. A weighted additive model reconstructs that answer from the logged weight-matrix version and component breakdown; a learned ranker generally cannot, and it risks encoding bias that conflicts with the statutory duty to treat requesters even-handedly. Determinism is a compliance feature here, not a limitation.

How do compliance officers change priorities without a code deployment?

The weight matrix is configuration, not code. Officers edit a versioned TOML/JSON file (or config table), the change passes the sum-to-1.0 validation in CI, and the scorer picks up the new version string, which is then stamped on every subsequent audit line. Because scores carry their weights_version, you can always tell which policy produced a given decision and replay it. The computation logic stays frozen while policy moves.

Where does the statutory deadline itself come from if the scorer doesn't compute it?

Deadline calculation — business-day counting, holiday suspension, and tolling — is jurisdiction-specific and lives with the rules in State-Law Compliance Frameworks. Those rules run upstream and attach a tz-aware statutory_deadline to the parsed metadata. The scorer only reasons about proximity to that deadline, which keeps the urgency curve uniform across jurisdictions while the hard date stays authoritative and separately auditable.

← Back to all public records automation topics