Resolving conflicting overlay stacking rules

A parcel sits inside both an airport-height overlay capping height at 28 feet and a scenic-corridor overlay capping it at 32. Your resolver applies them in whatever order the source returned them, so the answer is 28 feet on Tuesday and 32 on Wednesday when the portal reorders its rows. Neither answer is wrong in isolation; what is wrong is that the pipeline has no rule, so the output depends on row order in somebody else’s database. This guide builds the rule — a precedence table sourced from the ordinance, a declared restrictiveness direction per standard, and an explicit refusal when both are silent. It extends the composition model in overlay district modeling.

Diagnosis: finding the conflicts you already have jump to heading

A conflict exists wherever two overlays that co-occur on at least one parcel both constrain the same standard. That is computable from the registry and the parcel data, and the list is usually short.

from collections import defaultdict
from itertools import combinations


def find_conflicts(parcels, registry):
    """Pairs of overlays that co-occur AND touch the same standard."""
    co_occurring = set()
    for overlays in parcels["overlay_codes"]:
        for a, b in combinations(sorted(set(overlays)), 2):
            co_occurring.add((a, b))

    conflicts = []
    for a, b in sorted(co_occurring):
        oa, ob = registry[a], registry[b]
        shared = set(oa.numeric) & set(ob.numeric)
        for standard in sorted(shared):
            if oa.numeric[standard] == ob.numeric[standard]:
                continue                       # agree: not a conflict
            conflicts.append({
                "overlays": (a, b),
                "standard": standard,
                "values": (oa.numeric[standard], ob.numeric[standard]),
                "kinds": (oa.kind, ob.kind),
                "parcels": count_parcels_with_both(parcels, a, b),
                # Two REPLACE overlays are the dangerous kind: order decides.
                "order_dependent": oa.kind == "replace" and ob.kind == "replace",
            })
    return conflicts

Two kinds of conflict come out, and they are not equally serious.

Restrictiveness direction, standard by standard An eight-row by three-column matrix of zoning standards. Rows are height, floor area ratio, lot coverage, units per acre, front setback, minimum lot area, minimum lot width and minimum parking. Columns are which direction is more restrictive, what a naive minimum produces, and whether the error is visible. For the first four a lower value is stricter and a minimum is correct. For the last four a higher value is stricter, so taking a minimum produces a more permissive standard, and in none of those cases is the error visible in the output. Restrictiveness direction, standard by standard more restrictive is… a naive min() gives error visible? height_ft lower correct n/a far lower correct n/a lot_coverage lower correct n/a units_per_acre lower correct n/a front_setback_ft higher a smaller setback no min_lot_area_sf higher a smaller minimum lot no min_lot_width_ft higher a narrower minimum lot no parking_spaces_min higher less parking required no min is right max is right silently permissive
Four rows want min and four want max. Applying one rule to all eight silently relaxes every setback and lot-size minimum — a more permissive answer, which is the direction nobody double-checks. That is why the resolver refuses on a standard whose direction has not been declared.

Both overlays restrict. These are safe once a direction is declared, because restriction composes: the stricter value wins regardless of the order they are applied in. min for a height cap, max for a setback.

Both overlays replace. These are genuinely order-dependent, and no amount of restrictiveness reasoning settles them — a replacement asserts a value rather than a bound. This is the set that needs a precedence table, and it is the set the order_dependent flag isolates.

Step-by-step implementation jump to heading

1. Declare a restrictiveness direction per standard, and refuse without one jump to heading

Restrictiveness is a property of the standard, not of the overlay, and getting the direction backwards is silent: a “stricter” setback computed with min produces a smaller setback, which is more permissive.

MORE_RESTRICTIVE_IS_LOWER = {
    "height_ft", "far", "units_per_acre", "lot_coverage", "max_impervious_pct",
}
MORE_RESTRICTIVE_IS_HIGHER = {
    "front_setback_ft", "side_setback_ft", "rear_setback_ft", "min_lot_area_sf",
    "min_lot_width_ft", "parking_spaces_min", "min_open_space_pct",
}


def stricter(standard: str, a: float, b: float) -> float:
    if standard in MORE_RESTRICTIVE_IS_LOWER:
        return min(a, b)
    if standard in MORE_RESTRICTIVE_IS_HIGHER:
        return max(a, b)
    raise ValueError(
        f"{standard!r} has no declared restrictiveness direction. Add it to one of "
        f"the two sets before any overlay is allowed to restrict it — guessing gets "
        f"setbacks exactly backwards.")

2. Build the precedence table from the ordinance, with a citation jump to heading

from dataclasses import dataclass


@dataclass(frozen=True)
class Precedence:
    jurisdiction: str
    winner: str            # overlay code
    loser: str
    standard: str | None   # None = applies to every standard they share
    authority: str         # the ordinance section, or 'policy:most-restrictive'
    decided_by: str        # a person, for the policy cases
    decided_on: str


class PrecedenceTable:
    def __init__(self, rows: list[Precedence]):
        self._by_pair = {}
        for r in rows:
            self._by_pair[(r.jurisdiction, r.winner, r.loser, r.standard)] = r

    def resolve(self, jurisdiction, a, b, standard) -> Precedence | None:
        for key in ((jurisdiction, a, b, standard), (jurisdiction, a, b, None),
                    (jurisdiction, b, a, standard), (jurisdiction, b, a, None)):
            if key in self._by_pair:
                return self._by_pair[key]
        return None

3. Apply the ladder: ordinance, then policy, then refuse jump to heading

class UnresolvedConflict(Exception):
    """Two replacements, no ordinance rule, no policy. The pipeline must not pick."""


def resolve_pair(jurisdiction, oa, ob, standard, table, policy_most_restrictive=True):
    """Returns (value, citation). Never returns a value it cannot attribute."""
    explicit = table.resolve(jurisdiction, oa.code, ob.code, standard)
    if explicit is not None:
        winner = oa if explicit.winner == oa.code else ob
        return winner.numeric[standard], f"{winner.code}:{explicit.authority}"

    if oa.kind == "restrict" and ob.kind == "restrict":
        value = stricter(standard, oa.numeric[standard], ob.numeric[standard])
        src = oa if value == oa.numeric[standard] else ob
        return value, f"{src.code}:restrict(stricter of two)"

    if policy_most_restrictive:
        value = stricter(standard, oa.numeric[standard], ob.numeric[standard])
        src = oa if value == oa.numeric[standard] else ob
        # Recorded as a POLICY decision, not as an ordinance one. The distinction
        # matters when somebody checks the answer against the code.
        return value, f"{src.code}:policy:most-restrictive"

    raise UnresolvedConflict(
        f"{oa.code} and {ob.code} both replace {standard} "
        f"({oa.numeric[standard]} vs {ob.numeric[standard]}) with no precedence rule")

The three rungs are ordered by authority, and the citation says which rung answered. “28 ft, from AH:§14-207” and “28 ft, from AH:policy:most-restrictive” are both usable answers and they are not the same claim — the second one is your organisation’s decision, not the city’s, and a reviewer is entitled to know that.

Three rungs, and what the citation says at each A three-row by four-column matrix of the resolution ladder. Rows are an explicit ordinance precedence rule, two restricting overlays resolved by the stricter value, and the policy default of most-restrictive-wins. Columns are what settles the conflict, the citation produced, whether a reviewer will find the rule in the municipal code, and who owns the decision. The third rung produces a defensible answer that is your organisation's decision rather than the city's, which the citation states explicitly. Three rungs, and what the citation says at each what settles it citation produced in the municipal code? owned by 1 · ordinance precedence rule the ordinance AH:§14-207 yes the jurisdiction 2 · both restrict — stricter wins restrictiveness direction WP:restrict(stricter) implied the ordinance 3 · policy default your declared policy policy:most-restrictive no you, with a date authoritative defensible, and yours not in the code
The third column is the reason the citation distinguishes the rungs. An answer citing §14-310 can be checked against the code; one citing policy:most-restrictive cannot, because it is your decision — and a reviewer is entitled to know which they are looking at.

4. Make the policy default explicit and reviewable jump to heading

Most-restrictive-wins is a defensible default and it is still a choice. Record it once, with an owner and a date, in the same registry as the ordinance-sourced rows — a default that lives only in code is a decision nobody can find later.

Verification & testing jump to heading

def test_two_restricting_overlays_are_order_independent():
    a, b = overlay("AH", "restrict", height_ft=28), overlay("SC", "restrict", height_ft=32)
    v1, _ = resolve_pair("larimer", a, b, "height_ft", EMPTY_TABLE)
    v2, _ = resolve_pair("larimer", b, a, "height_ft", EMPTY_TABLE)
    assert v1 == v2 == 28


def test_setback_direction_is_not_inverted():
    a, b = overlay("A", "restrict", front_setback_ft=25), overlay("B", "restrict",
                                                                 front_setback_ft=40)
    v, _ = resolve_pair("larimer", a, b, "front_setback_ft", EMPTY_TABLE)
    assert v == 40, "the LARGER setback is the stricter one"


def test_undeclared_standard_refuses():
    a, b = overlay("A", "restrict", noise_db=55), overlay("B", "restrict", noise_db=60)
    with pytest.raises(ValueError, match="restrictiveness direction"):
        resolve_pair("larimer", a, b, "noise_db", EMPTY_TABLE)


def test_two_replacements_without_a_rule_raise():
    a, b = overlay("AH", "replace", height_ft=28), overlay("SC", "replace", height_ft=32)
    with pytest.raises(UnresolvedConflict):
        resolve_pair("larimer", a, b, "height_ft", EMPTY_TABLE,
                     policy_most_restrictive=False)


def test_ordinance_precedence_beats_policy():
    table = PrecedenceTable([Precedence("larimer", "SC", "AH", "height_ft",
                                        "§14-310", "j.doe", "2026-03-02")])
    a, b = overlay("AH", "replace", height_ft=28), overlay("SC", "replace", height_ft=32)
    v, why = resolve_pair("larimer", a, b, "height_ft", table)
    assert v == 32 and "§14-310" in why      # the ordinance says the corridor wins


def test_no_conflict_is_order_dependent_in_production(parcels, registry, table):
    """The production gate: every order-dependent conflict must have a rule."""
    unresolved = [c for c in find_conflicts(parcels, registry)
                  if c["order_dependent"]
                  and table.resolve("larimer", *c["overlays"], c["standard"]) is None]
    assert not unresolved, f"{len(unresolved)} conflicts have no precedence rule"

The setback test is the one that catches the most damaging class of bug, because reversing the direction produces a smaller setback — a more permissive answer that looks entirely reasonable in the output.

Failure recovery jump to heading

Answers that changed when the portal reordered its rows. Every resolved standard produced before the precedence table existed is suspect for the conflicting pairs. Identify affected parcels from the conflict list, re-resolve, and re-derive downstream capacity and compliance results for those parcels only — the set is usually small and geographically clustered.

Parcels affected by each conflict in one county Lollipop chart of how many parcels are affected by each overlay conflict found in one county. The airport height overlay against the scenic corridor overlay affects 412 parcels and is order-dependent because both replace the height standard. Historic preservation against the transit overlay affects 188. Wellhead protection against the floodplain overlay affects 96 and both restrict, so it is order-independent. Two further pairs affect fewer than 40 parcels each. A dashed rule marks 100 parcels. Parcels affected by each conflict in one county 0 100 200 300 400 500 100 parcels — worth a rule before the next run AH vs SC — both replace height 412 parcels HP vs TOD — both replace parking 188 parcels WP vs FP — both restrict coverage 96 parcels SC vs FP — both restrict height 38 parcels HP vs WP — different standards 12 parcels Parcels carrying both overlays
Five conflicts, and only the two order-dependent ones need a precedence rule — the rest resolve by restrictiveness whatever order they arrive in. Six hundred parcels is a small enough set to re-resolve and re-derive precisely once the rules exist.

A direction declared backwards. Every parcel where that standard was restricted by an overlay has the wrong value, and in the permissive direction. Fix the set membership, re-resolve, and treat any compliance answer issued in the interim as needing re-issue.

A precedence rule that contradicts the ordinance. Correct the table row, keep the old row as superseded with its dates, and re-resolve. Keeping the superseded row is what lets you explain an answer given last quarter, rather than only the answer you would give now.

Frequently asked questions jump to heading

Why not always take the most restrictive value?

Because a replace overlay asserts a value rather than a bound, and the ordinance may deliberately relax a standard — a transit overlay raising a height limit is a real and common instrument. Most-restrictive-wins as a default is defensible; as a universal rule it silently overrides the intent of every permissive overlay in the code.

Where does precedence come from when the ordinance is silent?

From a recorded policy decision with an owner and a date, applied consistently, and cited as policy rather than as ordinance in the output. The important part is that the answer’s citation distinguishes the two, so a reviewer checking against the code knows which lines they will find there and which are yours.

Should the resolver ever raise rather than answer?

Yes, for two replacements with no rule and no policy, and for a standard with no declared restrictiveness direction. Both are cases where any value the resolver produced would be arbitrary, and an arbitrary number that enters a compliance decision is worse than a failure that gets somebody’s attention.

Is restrictiveness really a property of the standard?

Yes, and that is what makes a single min or max unsafe. A smaller height limit is stricter; a smaller setback is more permissive; a smaller minimum lot area is more permissive. The direction has to be declared per standard, which is why the resolver refuses on an unknown one rather than defaulting.