Debugging zoning taxonomy mapping conflicts between adjacent jurisdictions
A cross-jurisdiction dashboard reports that two parcels facing each other across a city line are both “medium-density multifamily,” and an underwriter flags it because one of them is plainly a row of attached single-family houses. You trace it back and find the culprit: both cities publish the local code R-3, your mapping table has a single global R-3 → MF-Low row, and the second city’s ordinance actually defines R-3 as attached single-family. One string, two meanings, and a query that joins across the boundary silently picks whichever row it hit first. This guide answers one narrow question: how do you detect, disambiguate, and regression-guard a taxonomy collision where the same local code means different things in adjacent jurisdictions? It is a runnable companion to the broader work on zoning taxonomy mapping that underpins the whole municipal zoning data architecture and compliance frameworks reference.
Diagnosis: how a shared code corrupts boundary parcels jump to heading
The failure is structural, not a bad row. A taxonomy mapping keyed only on the raw local code assumes codes are globally unique. They are not — municipal zoning vocabularies are authored independently, so R-3, C-2, and PUD collide constantly between neighbors. The corruption has two recognizable signatures.
- Collision in the mapping table. The same
local_codeappears with two or more different canonical classes across the corpus, but the lookup key ignores jurisdiction, so only one wins. Whichever row loads last (or first, depending on yourdictinsertion) becomes the de facto global answer, and the other jurisdiction’s parcels are silently relabeled. - Boundary parcels flip class. The damage concentrates geographically. A parcel a mile inside City B is surrounded by other City B parcels and looks internally consistent; a parcel on the shared line sits next to City A parcels carrying the correct class, so the mislabel shows up as an impossible class discontinuity exactly at the boundary — a multifamily parcel abutting single-family with no zoning change between them.
Reproduce it deterministically. Load the mapping as it exists, group by the raw code, and let any code that resolves to more than one canonical class fail loudly:
from collections import defaultdict
# As currently stored: keyed on the raw local code, jurisdiction ignored.
mappings = [
{"jurisdiction": "elmwood", "local_code": "R-3", "canonical": "MF-Low"},
{"jurisdiction": "ashford", "local_code": "R-3", "canonical": "SF-Attached"},
{"jurisdiction": "elmwood", "local_code": "C-2", "canonical": "Commercial-Neighborhood"},
{"jurisdiction": "ashford", "local_code": "C-2", "canonical": "Commercial-Neighborhood"},
]
by_code = defaultdict(set)
for m in mappings:
by_code[m["local_code"]].add(m["canonical"])
collisions = {code: sorted(v) for code, v in by_code.items() if len(v) > 1}
print(collisions) # {'R-3': ['MF-Low', 'SF-Attached']}
A non-empty collisions dict proves the key is under-specified. No amount of confidence scoring inside a single-key resolver fixes this — the key itself has to carry the jurisdiction. This is the same normalization boundary enforced upstream by attribute normalization rules, applied here to the taxonomy key rather than the field values.
Step-by-step implementation jump to heading
Each step addresses one concern; compose them into a single resolver in the last step.
Step 1 — Namespace every code by jurisdiction jump to heading
The root cause is a global key, so make the key carry the jurisdiction. Build a stable, normalized composite key and rekey the entire mapping table on it. Choose a jurisdiction identifier that never changes — a FIPS place code beats a display name, which can be renamed or annexed.
def namespaced_key(jurisdiction_id: str, local_code: str) -> str:
"""Composite key so identical local codes in different cities never collide."""
j = jurisdiction_id.strip().lower()
c = " ".join(local_code.strip().upper().split()) # collapse stray whitespace
if not j or not c:
raise ValueError(f"empty key component: jurisdiction={j!r} code={c!r}")
return f"{j}:{c}"
# Rekey the whole table; a collision on the raw code is no longer a collision here.
resolution_table = {
namespaced_key("elmwood", "R-3"): "MF-Low",
namespaced_key("ashford", "R-3"): "SF-Attached",
}
Step 2 — Build a conflict report before you disambiguate jump to heading
You cannot fix collisions you cannot see. Produce a report that lists every raw code resolving to more than one canonical class, with the jurisdictions and classes involved, so a data steward can review the real ordinance meaning instead of guessing. Persist it as an artifact — this is the same review gate that schema validation and data quality checks apply to structural drift, focused here on semantic drift.
from collections import defaultdict
def build_conflict_report(mappings: list[dict]) -> list[dict]:
"""Group by raw code and emit one row per genuine cross-jurisdiction collision."""
grouped: dict[str, list[dict]] = defaultdict(list)
for m in mappings:
grouped[m["local_code"].strip().upper()].append(m)
report = []
for code, rows in grouped.items():
classes = {r["canonical"] for r in rows}
if len(classes) > 1: # same code, different meanings
report.append({
"local_code": code,
"class_count": len(classes),
"variants": sorted(
{(r["jurisdiction"], r["canonical"]) for r in rows}
),
})
return sorted(report, key=lambda r: (-r["class_count"], r["local_code"]))
Step 3 — Disambiguate with authoritative ordinance text and priority jump to heading
A namespaced key removes accidental collisions, but each key still needs the correct canonical class, sourced from the jurisdiction’s adopted ordinance, not inferred from the code string. Encode a resolution entry that cites the governing ordinance and an explicit priority so that if two rules could both apply, the higher-authority one wins deterministically.
from dataclasses import dataclass
@dataclass(frozen=True)
class Resolution:
canonical: str
ordinance: str # citation to the adopted text, e.g. "Ashford ZO 12.04"
priority: int # higher wins when multiple rules match
# Authored from adopted ordinances and reviewed, keyed by namespaced code.
RESOLUTIONS: dict[str, Resolution] = {
namespaced_key("elmwood", "R-3"): Resolution("MF-Low", "Elmwood ZC 4.3", 100),
namespaced_key("ashford", "R-3"): Resolution("SF-Attached", "Ashford ZO 12.04", 100),
}
def resolve(jurisdiction_id: str, local_code: str) -> Resolution:
key = namespaced_key(jurisdiction_id, local_code)
try:
return RESOLUTIONS[key]
except KeyError:
raise KeyError(f"unmapped zoning code: {key!r} — add a reviewed resolution")
Step 4 — Add regression tests for boundary parcels jump to heading
The collision does its worst damage at jurisdiction lines, so pin those exact parcels as fixtures. Pick real parcels straddling each shared boundary, record the class each jurisdiction’s ordinance mandates, and assert the resolver reproduces it. These fixtures fail the moment someone reintroduces a global key or edits the wrong ordinance row.
import pytest
# Real parcels on each shared boundary, class fixed from the adopted ordinance.
BOUNDARY_FIXTURES = [
{"parcel": "ELM-0442", "jurisdiction": "elmwood", "code": "R-3", "expect": "MF-Low"},
{"parcel": "ASH-1197", "jurisdiction": "ashford", "code": "R-3", "expect": "SF-Attached"},
{"parcel": "ASH-1198", "jurisdiction": "ashford", "code": "C-2",
"expect": "Commercial-Neighborhood"},
]
@pytest.mark.parametrize("fx", BOUNDARY_FIXTURES, ids=lambda f: f["parcel"])
def test_boundary_parcel_class(fx):
result = resolve(fx["jurisdiction"], fx["code"])
assert result.canonical == fx["expect"], (
f"{fx['parcel']} flipped to {result.canonical}; expected {fx['expect']}"
)
A quick reference for the collision classes you will meet most often across neighbors:
| Local code | Jurisdiction A meaning | Jurisdiction B meaning |
|---|---|---|
| R-3 | Low-rise multifamily | Attached single-family |
| C-2 | Neighborhood commercial | Highway commercial |
| PUD | Planned residential | Mixed-use overlay |
| A-1 | Agricultural | Apartment high-density |
| MU | Mixed-use downtown | Manufacturing utility |
Verification & testing jump to heading
Confirm you resolved the semantics rather than merely renaming the key.
- Zero surviving raw-key collisions. Re-run
build_conflict_reportafter namespacing and assert every remaining entry is intentional and cited; an uncited collision means a resolution row is missing. - Boundary continuity check. Query parcels within a small buffer of each shared line and assert that a class discontinuity coincides with an actual jurisdiction change, never appears mid-jurisdiction. An impossible discontinuity inside one city is a mislabel resurfacing.
- Every code resolves or raises. Assert
resolve()never returns a silent default; an unmapped namespaced key must raise so it lands in review, not in the canonical layer. - Cross-jurisdiction query correctness. Run the original failing dashboard query and confirm the two facing parcels now carry their distinct, ordinance-correct classes.
Failure recovery jump to heading
When a new collision appears mid-pipeline, the run must fail safe and stay auditable.
- Quarantine unmapped keys, never default. A namespaced key with no reviewed resolution is written to a review queue with its jurisdiction, code, and source feed, and excluded from the canonical layer until a steward adds a cited entry.
- Alert on newly appearing collisions. Diff each run’s conflict report against the last accepted one; a code that gains a new canonical variant fires an alert, because it usually means a jurisdiction amended its ordinance or an upstream feed changed its code strings.
- Version the resolution table. Keep resolutions under change control with the ordinance citation and effective date, so a boundary parcel’s class is reproducible as of any date and a wrong edit is traceable to a commit.
- Stamp provenance on every record. Carry the namespaced key, resolved class, and ordinance citation onto the output so an underwriter reviewing a flagged parcel can see exactly which rule labeled it, the same lineage discipline the compliance layer expects.
Frequently asked questions jump to heading
Why not just add a confidence score to the existing R-3 lookup?
Confidence scoring ranks candidate matches for one key, but a cross-jurisdiction collision is not a low-confidence match — both mappings are fully correct within their own city. The key itself is under-specified. Namespacing the code by jurisdiction removes the collision at the structural level; confidence scoring cannot, because there is no single right answer for a global R-3.
How do I pick the jurisdiction identifier for the namespace?
Use a stable code that survives renames and annexations, such as a Census FIPS place or county code, rather than a display name. Display names change, merge, and get spelled inconsistently across feeds, which reintroduces the collision under a new guise. Keep a small table mapping each feed source to its canonical FIPS identifier.
What should resolve() do when a code is genuinely unmapped?
Raise, never return a default. A silent fallback class is exactly how a boundary parcel gets mislabeled without anyone noticing. Route the unmapped namespaced key to a review queue with its source feed and jurisdiction, and only add it to the canonical layer once a steward supplies a resolution cited to the adopted ordinance.
How do I stop a fixed conflict from silently regressing later?
Pin real boundary parcels as regression fixtures with the class their ordinance mandates, and run them in CI. Also diff each run’s conflict report against the last accepted baseline so a code that gains a new canonical variant alerts a human. Together these catch both a reintroduced global key and a genuine ordinance amendment.
Related jump to heading
- Parent topic: Zoning Taxonomy Mapping
- Section overview: Municipal Zoning Data Architecture & Compliance Frameworks
- Attribute Normalization Rules — normalizing code strings before they reach the taxonomy key
- Schema Validation & Data Quality Checks — gating semantic and structural drift as a review artifact
- Data Lineage & Provenance Tracking — stamping the ordinance citation onto every resolved record