Translating Ordinance Text into Machine-Readable Predicates
An adopted ordinance is a paragraph written to be read by a person who can ask a follow-up question. A rule engine cannot ask, so every ambiguity in the paragraph has to be resolved before evaluation rather than during it. This guide takes one real clause and decomposes it into the four parts a pipeline can act on — an applicability predicate, a named measurement, a threshold with a unit, and a citation back to the adopted text — and shows why the exception buried in the clause’s second half belongs in a separate rule rather than an if statement. It is the encoding half of regulatory code to spatial mapping.
Prerequisites
Step-by-step
Step 1: Split the clause into its four parts before writing any code
Take the clause exactly as adopted. A representative one reads:
§12-4.3(a). No principal building shall be erected within twenty-five (25) feet of the front lot line in the R-1 district. On a corner lot, the required front setback shall apply to both street frontages, except that the setback from the secondary frontage may be reduced to fifteen (15) feet where the lot is less than sixty (60) feet in width.
Before touching a schema, mark up which words do which job. The subject is principal building. The applicability test is in the R-1 district. The measurement is distance from the front lot line. The threshold is 25 feet. Everything after the first sentence is a second rule with its own applicability test, not a footnote to the first.
That separation is the whole task, and doing it in prose first is worth the ten minutes. A clause that resists the split — where you cannot say what is being measured without already knowing the answer — is a clause that needs a question asked of the planning department, not a cleverer encoding.
Step 2: Name the measurement before you write the number
The most common failure is encoding front_setback: 25 and nothing else. That records the threshold and discards the measurement, and the measurement is where the disagreements live: distance from the front lot line to what — the foundation, the wall face, the eave, the covered porch? Measured perpendicular to the line, or as the shortest distance to it?
Give the measurement a name that exists independently of any threshold, and define it once:
MEASUREMENTS = {
"front_setback_ft": {
"description": "Shortest distance from the front lot line to the principal "
"building footprint, excluding eaves up to 24 inches.",
"from": "parcel.front_lot_line",
"to": "building.footprint",
"operation": "shortest_distance",
"unit": "ft",
"excludes": ["eave_overhang_up_to_24in"],
},
}
Now a hundred districts can share one measurement and differ only in their thresholds. More importantly, when the county argues that eaves should have been included, there is exactly one place to change and one record of what the pipeline actually measured on every prior run.
Step 3: Write applicability as a predicate over named attributes
The applicability test decides which parcels a rule touches at all, and it belongs beside the rule rather than inside the code that loads it. Keep it to comparisons over attributes that exist in the parcel schema:
RULE_12_4_3_A = {
"id": "R1-front-setback-primary",
"citation": {"section": "12-4.3(a)", "adopted": "2019-06-11"},
"applies_when": {
"all": [
{"attr": "zoning_district", "op": "eq", "value": "R-1"},
{"attr": "structure_class", "op": "eq", "value": "principal"},
]
},
"measure": "front_setback_ft",
"op": "gte",
"threshold": 25.0,
"unit": "ft",
}
The predicate is data, so it can be listed, diffed and counted. Ask “which rules touch a corner lot in R-1?” and it is a query rather than a code review. That property is what makes deciding which parcels a rule applies to a tractable question later.
Step 4: Encode the exception as its own rule, with precedence
The clause’s second half is where most encodings go wrong. Written as a conditional inside the first rule it disappears into control flow: nothing can enumerate it, nothing can cite it, and a reviewer reading the rule list will not see that a fifteen-foot setback is possible anywhere in R-1.
Write it as a rule of its own, with a narrower applicability predicate and an explicit statement that it displaces the general one:
RULE_12_4_3_A_CORNER = {
"id": "R1-front-setback-secondary-narrow-lot",
"citation": {"section": "12-4.3(a)", "adopted": "2019-06-11"},
"applies_when": {
"all": [
{"attr": "zoning_district", "op": "eq", "value": "R-1"},
{"attr": "structure_class", "op": "eq", "value": "principal"},
{"attr": "frontage_count", "op": "gte", "value": 2},
{"attr": "lot_width_ft", "op": "lt", "value": 60.0},
]
},
"measure": "secondary_frontage_setback_ft",
"op": "gte",
"threshold": 15.0,
"unit": "ft",
"displaces": ["R1-front-setback-primary"], # on the secondary frontage only
"specificity": 2,
}
The displaces field is the part that pays for itself. It states the precedence relationship as data, so the engine resolves conflicts by a stated rule rather than by evaluation order, and the report can say “§12-4.3(a) second sentence displaced the general standard” instead of silently returning fifteen. The same mechanism carries the harder cases in variance and exception handling.
Step 5: Carry the citation, and the adopted text, with the predicate
A verdict a reader cannot trace back to a sentence is an opinion. Store the citation and — this is cheap and repeatedly worth it — the adopted text itself alongside the predicate:
import hashlib
def attach_source(rule: dict, text: str) -> dict:
"""Freeze the sentence a predicate was derived from, next to the predicate."""
rule["source_text"] = text.strip()
rule["source_hash"] = hashlib.sha256(text.strip().encode()).hexdigest()[:16]
return rule
When the municipality amends the section, the hash changes, and the pipeline can refuse to run rules whose source text has moved underneath them until someone re-reads the clause. That is the difference between a rule pack that ages and one that rots, and it is what makes versioning rule references in audit trails possible at all.
Step 6: Record what the text did not say
Every real clause leaves questions. Does lot width mean at the front lot line, at the building line, or the mean width? Is a corner lot with a curved frontage one frontage or two? Write these down as they are found, with the answer used and who gave it:
OPEN_QUESTIONS = [
{"rule": "R1-front-setback-secondary-narrow-lot",
"question": "Is lot_width_ft measured at the front lot line or at the building line?",
"assumption": "at the front lot line",
"confirmed_by": "planning dept, 2026-03-04",
"status": "resolved"},
]
An unresolved question is not a blocker — the pipeline runs on the stated assumption. What it must not be is invisible, because an assumption nobody recorded is indistinguishable from a bug once the run is six months old.
Verification
The encoding is correct when a planner who has never seen the code can read the predicate back to the clause and agree. Two mechanical checks catch most of what goes wrong before it gets that far:
def check_rule_pack(rules, measurements):
problems = []
for r in rules:
if r["measure"] not in measurements:
problems.append(f"{r['id']}: measure {r['measure']} is not defined")
if measurements[r["measure"]]["unit"] != r["unit"]:
problems.append(f"{r['id']}: threshold unit disagrees with the measurement")
if not r.get("citation", {}).get("section"):
problems.append(f"{r['id']}: no citation")
for d in r.get("displaces", []):
if d not in {x["id"] for x in rules}:
problems.append(f"{r['id']}: displaces unknown rule {d}")
return problems
assert not check_rule_pack(RULES, MEASUREMENTS)
The unit assertion is the one that earns its place most often; the failure mode it prevents is described in unit conversion pitfalls in setback thresholds. Beyond these, run the pack against a handful of parcels whose answers a planner has confirmed by hand, and lock those in with golden-file tests for zoning rule packs.
Common Pitfalls
- Encoding the threshold and discarding the measurement.
25is not a rule. What is measured, between which two things, in what unit, is the rule; the number is a parameter of it. - Burying exceptions in control flow. An exception written as an
ifcannot be listed, cited or counted, and the first anyone learns of it is when a verdict surprises them. - Predicates that reference attributes the schema does not have.
lot_width_ftmust exist and mean one thing. A predicate over an attribute that is computed differently in two places is worse than no predicate. - Paraphrasing the clause into the description field. Store the adopted sentence verbatim. A paraphrase drifts from the text and no one notices, because it reads correctly.
- Treating a numbered subsection as one rule. Subsection (a) above contains two rules with different applicability. The numbering follows the drafting, not the logic.
Frequently Asked Questions
Should the predicate language be a custom DSL or plain data?
Plain data, until it demonstrably cannot express something. The dictionary form above is diffable, serialisable, storable in any of the formats covered in rule storage formats, and readable by a planner with no training. A custom syntax buys terseness and costs everything else, and the moment it needs a parser it needs a test suite for the parser too.
How do I encode “the Director may permit”?
Not as a predicate. Discretionary language marks a decision the pipeline cannot make, so the rule should evaluate to a determinate outcome — usually the standard as written — and flag the parcel as eligible for discretionary relief. The pipeline’s job is to say what the code requires and to notice where relief is available, not to guess whether it would be granted.
What about clauses that reference other clauses?
Encode the reference explicitly rather than inlining the target’s content. A rule that says “as provided in §12-6.2” should carry a defers_to field naming that rule, so an amendment to §12-6.2 propagates automatically and the dependency is visible when someone asks what a change would affect.
How many predicates should one clause produce?
As many as it has distinct applicability tests — often two or three for a clause that reads as one sentence. Splitting is nearly always right: two narrow rules with clear predicates behave predictably under precedence resolution, while one broad rule with internal branching does not.
Does this scale to a whole municipal code?
The encoding does; the reading is the bottleneck. A typical zoning ordinance yields a few hundred predicates over perhaps thirty measurements, and the measurements converge fast — most districts differ only in thresholds. The work is front-loaded into the first two districts, after which the marginal district is an afternoon. Where a code has to be extracted from PDFs first, parsing municipal zoning PDFs into GeoJSON covers getting the text and the districts into a workable state first.
What happens when the ordinance is amended mid-project?
The source hash check fires, the affected rules are quarantined, and someone re-reads the changed sentence. This is the intended behaviour and it is much better than the alternative, in which the amended clause is discovered because a verdict changed and nobody could say why. Amendments are also the reason the adopted date belongs in the citation: two runs of the same pipeline against the same parcels can legitimately differ if the code changed between them, and the audit trail has to be able to say so.
Can this be automated with a language model?
For drafting, usefully — a model can propose the split of a clause into applicability, measurement and threshold, and it will be roughly right on routine text. It cannot be trusted with the result, because the failure mode is a confident, plausible predicate that misreads a qualifier, and that failure is invisible in the encoding. Treat generated predicates as a first draft that a person reconciles against the adopted text, and keep the reconciliation step in the process regardless of how good the drafts get.
Related
Part of: Regulatory code to spatial mapping
- Deciding which parcels a rule applies to — evaluating the applicability predicates written here.
- Migrating zoning rules from spreadsheets to YAML — for the rules a summary sheet never contained.
- Unit conversion pitfalls in setback thresholds — the unit half of a threshold, and how it goes wrong.
- Variance and exception handling — where displacement and discretion are resolved.
- Versioning rule references in audit trails — carrying the citation through to the report.