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.

  1. Collision in the mapping table. The same local_code appears 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 your dict insertion) becomes the de facto global answer, and the other jurisdiction’s parcels are silently relabeled.
  2. 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.

Resolving a shared zoning code that collides between two adjacent jurisdictions Two adjacent city feeds each publish the local code R-3, but Elmwood maps it to MF-Low while Ashford maps it to SF-Attached. Both flow into a namespacing step that builds jurisdiction-scoped keys, then a conflict report that flags the same code mapping to different classes, then a disambiguation step that resolves each using authoritative ordinance text and a priority order. The result feeds a canonical taxonomy layer with namespaced classes. Below, boundary parcels sampled from the canonical layer feed regression tests with pinned expected classes, and those tests guard the disambiguation step against future drift. same "R-3" duplicate key sample boundary guards Elmwood feed R-3 → MF-Low Ashford feed R-3 → SF-Attached namespace jurisdiction:code conflict report same code, diff class disambiguate ordinance + priority canonical layer namespaced classes boundary parcels flip near line regression tests pinned expectations
Namespacing the key surfaces the collision; ordinance-backed disambiguation and boundary regression tests keep it fixed.

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_report after 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.