Mapping State Small-Cell Rules Into a Policy File

This guide, part of Compliance Mapping Frameworks for Spatial Epidemiology Pipelines, turns the small-cell suppression rules in a state data-release policy into a machine-readable file that a surveillance pipeline enforces directly — so the rule applied is provably the rule approved.

Problem Context & Constraints

Data-release policies are written in prose for people. A typical one says that counts from 1 to 10 shall not be released for geographies below county level, that rates shall not be published where the numerator is below 20, that complementary suppression shall be applied to prevent recovery, and that certain conditions are exempt or stricter. Every one of those sentences is an interpretation problem when it becomes code.

Does “1 to 10” include zero? Most policies treat zero as releasable, some do not. Does “below county level” include a city that is coterminous with a county? Is the rate threshold of 20 applied before or after suppression of the count? When two rules conflict — a count of 12 that passes the count rule in a tract with a population of 90 — which wins? Analysts answer these questions one at a time, in code, and different analysts answer them differently. The result is that two products from the same agency apply subtly different rules, and neither matches the policy exactly.

Three constraints shape the fix. The policy must be data, not code — a structured file the pipeline reads, so the rule is stated once and every product applies the same one. Every ambiguity must be resolved explicitly in the file, with the resolution approved by whoever owns the policy, rather than resolved implicitly by whoever writes the code. And the file must be versioned and hashed, so each release can cite the exact policy version it was produced under.

One Policy File Instead of Many Interpretations On the left, a prose data-release policy is interpreted separately by three analysts writing three products, each resolving the same ambiguities differently: whether zero is suppressed, whether a coterminous city counts as sub-county, whether the rate threshold applies before or after count suppression. The three products apply three slightly different rules. On the right, the ambiguities are resolved once in a versioned policy file approved by the policy owner, and all three products read that file, so they apply one rule and can each cite its version. Resolve each ambiguity once, where the policy owner can approve it Prose policy, three readers weekly map: zero suppressed annual report: zero released partner feed: rate rule applied first three rules, none cited Policy file, three products policy v3, sha256 on file every ambiguity resolved map, report and feed all read v3 one rule, cited by version

Prerequisites

  • python 3.11
  • pydantic 2.7.4, pyyaml 6.0.1, pandas 2.2.2
  • The prose policy document, and access to the person or body who owns its interpretation
  • A list of every product the agency releases, with the geographies and measures each contains
  • Version control for the policy file, separate from the pipeline code

Step-by-Step Solution

Write the policy as a structured file with every ambiguity resolved, validate it against a schema, and give the pipeline a single function that applies it and returns the reason for each decision.

# disclosure_policy.yaml — approved by the Data Release Committee, 2026-08-14
policy_id: state-dph-small-cell
version: 3
effective_from: 2026-09-01
count_rule:
  suppress_min: 1          # counts 1..10 suppressed
  suppress_max: 10
  zero_is_releasable: true # RESOLVED: zero is not a small cell (committee minute 14.3)
rate_rule:
  min_numerator: 20        # rates with fewer events are not published
  applies_after_count_rule: true   # RESOLVED: a suppressed count has no rate
geography:
  sub_county_levels: [tract, block_group, zcta]
  coterminous_city_is_county: true # RESOLVED: consolidated city-county treated as county
complementary:
  required_when_primary_in_row: true
  row_definitions: [geography_hierarchy, demographic_margin]
exemptions:
  - condition_group: vaccination_coverage
    count_rule: {suppress_min: 1, suppress_max: 4}   # stricter-than-default rules listed too
stricter:
  - condition_group: hiv
    count_rule: {suppress_min: 1, suppress_max: 19}
# Load, validate and apply a machine-readable small-cell policy.
# Pinned: pydantic==2.7.4, pyyaml==6.0.1, pandas==2.2.2
import hashlib
import logging

import pandas as pd
import yaml
from pydantic import BaseModel, Field

logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("policy")


class CountRule(BaseModel):
    suppress_min: int = Field(ge=0)
    suppress_max: int = Field(ge=0)
    zero_is_releasable: bool = True


class Policy(BaseModel):
    policy_id: str
    version: int
    effective_from: str
    count_rule: CountRule
    rate_rule: dict
    geography: dict
    complementary: dict
    exemptions: list = []
    stricter: list = []


def load_policy(path: str) -> tuple[Policy, str]:
    raw = open(path, "rb").read()
    digest = hashlib.sha256(raw).hexdigest()
    pol = Policy(**yaml.safe_load(raw))
    log.info("policy %s v%d loaded, effective %s, sha256=%s",
             pol.policy_id, pol.version, pol.effective_from, digest[:16])
    return pol, digest


def rule_for(pol: Policy, condition_group: str) -> CountRule:
    for entry in pol.stricter + pol.exemptions:
        if entry["condition_group"] == condition_group:
            return CountRule(**{**pol.count_rule.model_dump(), **entry["count_rule"]})
    return pol.count_rule


def apply_primary(cells: pd.DataFrame, pol: Policy) -> pd.DataFrame:
    """Primary suppression with a reason per cell. Complementary is a later step."""
    out = cells.copy()
    reasons = []
    for r in out.itertuples():
        cr = rule_for(pol, r.condition_group)
        sub_county = r.level in pol.geography["sub_county_levels"]
        if r.count == 0 and cr.zero_is_releasable:
            reasons.append("release:zero")
        elif sub_county and cr.suppress_min <= r.count <= cr.suppress_max:
            reasons.append(f"suppress:count {cr.suppress_min}-{cr.suppress_max}")
        elif r.count < pol.rate_rule["min_numerator"]:
            reasons.append("release_count_only:rate below numerator floor")
        else:
            reasons.append("release")
    out["decision"] = reasons
    out["suppressed"] = out["decision"].str.startswith("suppress")
    out["rate_publishable"] = out["decision"] == "release"
    log.info("primary suppression under v%d: %s", pol.version,
             out["decision"].str.split(":").str[0].value_counts().to_dict())
    return out

The comments marked RESOLVED are the point of the exercise. Each is an ambiguity in the prose policy that the policy owner decided, with a reference to where the decision was made. When an analyst later asks whether zero is suppressed, the answer is in the file with its provenance, rather than in someone’s memory or in whichever product happened to be written first.

Returning a reason per cell, rather than a bare boolean, is what lets the audit trail explain each decision. A reviewer asking why a particular tract count was released can see that it was a zero under an explicit zero-is-releasable rule, which is a very different conversation from finding that the code simply did not suppress it.

The Order in Which the Rules Are Applied A decision sequence for one cell. First, a zero count is released if the policy treats zero as releasable. Second, the condition group's count rule is looked up, using a stricter or exempted rule where the policy lists one. Third, at sub-county geographies, counts inside the suppression range are suppressed. Fourth, counts that survive but fall below the rate numerator floor are released as counts without a rate. Otherwise the count and its rate are both released. Each outcome carries a reason string into the audit trail. The order is part of the policy, so it is written down too 1 · zero? release if allowed 2 · which rule stricter or exempt? 3 · count rule sub-county range 4 · rate floor count only if low 5 · release count and rate every outcome carries a reason string into the audit trail release:zero · suppress:count 1-10 · release_count_only · release complementary suppression runs afterwards, on the primary result

Validation & Edge Cases

Ambiguities discovered after deployment. New products expose new questions — does the rule apply to a count of zero cases among a denominator of zero? Add each resolution to the file as a new version with the approving reference, rather than handling it in the product that found it.

Conflicting rules for one cell. A cell may fall under both a stricter condition rule and a general exemption. The file must define precedence — stricter wins is the usual choice — and the loader should reject a file where precedence is undefined.

Policy changes mid-series. A version change on 1 September means weekly products before and after apply different thresholds, and a series spanning the change is inconsistent. Either reprocess the earlier weeks under the new version or mark the discontinuity in every product that crosses it.

Test coverage of the policy itself. Build a table of boundary cases — counts of 0, 1, 10, 11, 19, 20 at each geography level and condition group — with the expected decision for each, and assert the function reproduces them. That table doubles as the plain-language explanation of the policy for new staff.

Rules that belong to complementary suppression. Primary suppression cannot see what else is released. Keep the complementary rules in the same file but apply them in a separate step that considers the whole release, as in Complementary Suppression to Defeat Back-Calculation.

INFO policy policy state-dph-small-cell v3 loaded, effective 2026-09-01, sha256=5e21c08a7fd4b913
INFO policy primary suppression under v3: {'release': 1427, 'suppress': 318, 'release_count_only': 97}
Test the Policy at Its Edges A grid of test cases with counts across the columns — zero, one, ten, eleven, nineteen and twenty — and geography levels down the rows — county and tract. Each cell shows the expected decision. At county level every non-zero count is released, with a rate from twenty. At tract level zero is released, one to ten are suppressed, eleven to nineteen are released as counts without a rate, and twenty is released with a rate. The table is both an automated test and the clearest explanation of the policy for new staff. The edge cases are the policy, stated as a test 0 1 10 11 19 20 county release count count count count + rate tract release suppress count count + rate "count" means count released without a rate, because the numerator is below 20 assert every cell in CI; add a row for every condition group with its own rule

Compliance Notes

Store the policy file in version control with its approval record: who approved each version, when, and the minute or decision reference for each resolved ambiguity. Record in every release’s provenance the policy identifier, version and content hash, so any published cell can be traced to the exact rule that governed it.

The policy file is the interface between the people who own disclosure risk and the people who write code. Keeping it readable by the former — plain keys, comments explaining each resolution — is what allows the committee to review what the pipeline actually enforces, rather than trusting that the code matches a document they approved. That review should happen whenever the file changes, and the change log is its agenda.