Equal Person: a districting rule you can read
Loading state…
Every ten years, each state redraws its congressional map. The rules are short and vague. Districts must hold equal population and be contiguous. They should be compact, “as practicable.” Most voters never see the drawing happen. What comes out decides elections. It also has almost no audit trail.
This post is an attempt at that audit trail. Below is a written rule that turns 2020 census block groups into districts. Above is a page that runs the rule on real data while you watch. Press play, change the state, change the seed, or open it full size.
The claim is narrow: I am not saying a machine draws fairer maps. Fairness is a political question and this code does not answer it. But a rule you can read and re-run is a different kind of thing than a map. Anyone with the data can check a rule. To challenge a map, you have to argue with the people who drew it.
The problem is a partition with one hard constraint
The country has 242,298 census block groups. A block group is a census neighborhood of a few thousand people. It’s the smallest area the Census Bureau publishes. Every one has a population count and a fixed border. Each also lists its neighbours.
Drawing districts means cutting that graph into connected pieces, one piece per seat. Each piece has to hold almost exactly:
quota = resident population ÷ seats
Everything else is a preference laid on top. Compactness, county lines, communities of interest, the Voting Rights Act: all preferences. Population is the only constraint that is arithmetic. It is also the only one this project enforces without exception.
That split is why the problem is worth handing to a computer. The arithmetic part has a right answer, and computers are good at arithmetic. The preference part is contested, and that’s fine. A map hides its preferences, and then nobody can argue with them. A rule prints them on the page, where you can.
Three things made this practical in the last few years. The census now publishes block-group population and borders in a form a program can read. A laptop can score millions of candidate units per second. And the whole pipeline is a few hundred lines of code: download, join, simplify, verify. In 1990 this was expensive. Today it runs on a laptop.
The rule
Every district goes through the same four steps, in the same order, from the same inputs. That’s the entire method.
function draw(state, seats):
quota ← population(state) / seats
floor ← largest_unit_population(state) / quota
bound ← max(tolerance, floor) // the arithmetic ceiling, below
assignment ← every unit unassigned
district ← 0
while district < seats:
region ← largest connected region still unassigned
seed ← densest unit in that region
grow(district, seed, quota, bound)
district ← district + 1
repair(assignment, quota, bound)
smooth(assignment, bound, passes)
return assignment
Growth is the step that produces shapes. It keeps a list of units sitting on the edge of the district. Each time it adds people, it chooses the unit that helps the outline most per person.
function grow(district, seed, quota, bound):
assign(seed, district)
frontier ← neighbours(seed)
while population(district) < quota × (1 − bound):
if frontier is empty: break
// the whole algorithm is this line
u ← argmin over frontier of Δperimeter(u, district)
// never overshoot the upper bound
if population(district) + population(u) > quota × (1 + bound):
defer(u); continue
assign(u, district)
remove u from frontier
frontier ← frontier ∪ neighbours(u)
// Adding u exposes its own outline and absorbs the edges it shares
// with the district. Exact, from the shared-arc table — no geometry pass.
Δperimeter(u, district) = perimeter(u) − 2 × shared(u, district)
Two passes finish what growth could not, and neither is decoration. Each one repairs a specific problem growth leaves behind.
function repair(assignment, quota, bound):
// Regions the growth carved off. Merge each into the neighbour with room.
for each connected component of unassigned units:
give it to the adjacent district with the most room
record how many people moved
// Now fix the imbalance. Move compact slabs, not scattered blocks:
// 300 single-unit moves turn two districts into tendrils.
while some district is above quota:
donor ← the district furthest above quota
receiver ← adjacent district with the most room
chunk ← connected part of donor touching receiver,
sized to |population(donor) − quota|
if moving chunk lowers Σ (population − quota)²:
move it
else:
break
function smooth(assignment, bound, passes):
repeat passes times:
for each boundary unit u of district A:
B ← a neighbouring district of u
if moving u would push B outside the bound: continue
if u is an articulation point of A: continue // A would split
// moving u exposes its edges to A, absorbs its edges to B
if shared(u, B) > shared(u, A):
move u from A to B
// Articulation points are one Tarjan pass per district, recomputed
// after every accepted move. A stale set silently splits a district.
The pipeline is deterministic. A seed only matters when the seed strategy is
random. The default, densest, builds the same plan byte for byte every run.
That’s what makes the map testable.
The ceiling nobody mentions
This part surprised me, and it explains why the page prints two numbers next to the tolerance control.
The last block group added to a district cannot be split. So the tightest deviation a state can promise is not the tolerance on the slider. It is:
devFloor(state) = largest block group in the state ÷ quota
California’s largest block group holds 37,892 people. A California district holds about 760,350. So that one unit is 4.98% of a district all by itself. Asking for ±0.5% there is not ambitious. It is impossible, and no algorithm gets around it.
| state | largest block group | quota | floor |
|---|---|---|---|
| California | 37,892 | 760,350 | 4.98% |
| North Carolina | 8,962 | 745,671 | 1.20% |
| Rhode Island | 6,495 | 548,690 | 1.18% |
| Oregon | 7,964 | 706,209 | 1.13% |
| Montana | 5,898 | 542,113 | 1.09% |
| Wyoming | 3,327 | 576,851 | 0.58% |
So the algorithm aims at max(tolerance, devFloor), and the interface shows both.
Most runs land far tighter than the floor, because growth prefers the smallest unit
that fits. But the floor is the guarantee.
This is a fact about census geography, not about the code. It is also the best argument for drawing on blocks instead of block groups. California’s largest block is 8,727 people, not 37,892. The next release measures what that finer unit actually buys.
What it produces
This build covers every state with voting seats. That is 238,304 block groups and 42.8 MB of committed topology. It draws 429 districts across 44 multi-seat states. The other 6 states are one district each, so there is no line to draw.
| districts | 429 in 44 states, plus 6 whole-state |
| worst deviation | 0.00%–4.98%, against floors of 0.45%–4.98% |
| states outside their floor | none |
| national compactness | population-weighted Polsby-Popper 0.303 |
| slowest run | California, about one second |
| route JavaScript | 7 KB gzip |
Compactness is reported here, never optimized. West Virginia comes out at 0.13 and Wyoming at 0.77. Wyoming has one district, so that number just describes the shape of Wyoming. Any claim that one compactness score ranks two maps is a claim about taste, not math.
Does it favor anyone?
This is where a project like this usually overreaches. So the audit trail comes first, and the tables second.
What the algorithm can see. The type definitions are the whole list: population, land area, county FIPS, and adjacency. Land area only picks the densest seed. County FIPS is a soft penalty that ships turned off. There’s no election result, no race, no ethnicity, and no voter file. No weight stands in for one either. Party isn’t a field this code could read, even if someone brought the data.
What the census can and cannot tell us. The PL 94-171 file lists race and Hispanic origin for every block group. So composition is measurable. It doesn’t list party. We ship no precinct returns, so any partisan score printed here would be invented. The honest answer to “which party does this help?” is that this code can’t know. A reader can’t know either, unless they joined an election dataset to these same districts.
What the numbers show. Three states, six runs each: the default plus five random seeds, joined to the 2020 census. The ranges below cover all six runs.
North Carolina
14 seats · 6 runs · worst deviation 1.17%–1.19%
| Group | State | Largest | ≥ 25% | ≥ 50% | Drift (pt) |
|---|---|---|---|---|---|
| Hispanic | 10.7% | 14.2%–16.0% | 0 | 0 | 1.8 |
| White | 60.5% | 79.0%–83.9% | 14 | 10–14 | 9.5 |
| Black | 20.2% | 31.4%–34.1% | 3–5 | 0 | 5.5 |
| Asian | 3.3% | 7.2%–12.1% | 0 | 0 | 4.9 |
| AIAN | 1.0% | 6.0%–7.5% | 0 | 0 | 1.5 |
| Two+ | 3.9% | 4.4%–5.3% | 0 | 0 | 0.9 |
Pennsylvania
17 seats · 6 runs · worst deviation 0.93%–0.96%
| Group | State | Largest | ≥ 25% | ≥ 50% | Drift (pt) |
|---|---|---|---|---|---|
| Hispanic | 8.1% | 19.5%–23.2% | 0 | 0 | 6.3 |
| White | 73.5% | 88.5%–91.6% | 16–17 | 15–16 | 18.9 |
| Black | 10.5% | 34.3%–45.1% | 2–3 | 0 | 11.8 |
| Asian | 3.9% | 8.0%–9.7% | 0 | 0 | 1.8 |
| Two+ | 3.5% | 4.1%–4.4% | 0 | 0 | 0.5 |
Texas
38 seats · 6 runs · worst deviation 2.50%–2.55%
| Group | State | Largest | ≥ 25% | ≥ 50% | Drift (pt) |
|---|---|---|---|---|---|
| Hispanic | 39.3% | 91.4%–92.6% | 27–31 | 6–8 | 15.8 |
| White | 39.7% | 62.2%–70.2% | 28–31 | 9–14 | 10.5 |
| Black | 11.8% | 25.9%–36.3% | 1–3 | 0 | 10.3 |
| Asian | 5.4% | 18.0%–23.3% | 0 | 0 | 6.6 |
| Two+ | 3.0% | 4.7%–5.0% | 0 | 0 | 0.8 |
State is the group's share of the state. Largest is the biggest share in any one district. ≥ 25% and ≥ 50% count districts above those thresholds. Drift is the largest movement of a district's share between any two runs. Ranges are across the runs in each heading.
Two things in that table are true at the same time, and both matter.
First, every group is concentrated somewhere. The largest district share sits well above that group’s share of the state. People who share a background tend to live near each other. That’s geography, and no map avoids it.
Second, the composition moves when you reseed. The largest white share in a Pennsylvania district swings three points. The number of Texas districts with a Hispanic majority ranges from six to eight. If the rule were optimizing for a racial outcome, those numbers would hold still. A rule pointed at a target hits the target. Those numbers wander instead. That’s what a rule looks like when it can’t see the group.
That’s evidence of blindness. It isn’t evidence of fairness. Those are two different claims, and this page only supports the first.
What this is not
This is not a Voting Rights Act analysis, and it doesn’t try to be one. It isn’t a partisan model either. With no election data it can’t score a plan’s partisan effect. And compactness is not a stand-in for fairness. Nor is it a proposal that a legislature adopt this rule, though a legislature could.
What it is: a working demonstration that the artifact is possible. Every district has a reason. Every step is logged and can be replayed. And the test suite fails if the population does not add up.
Run it
All of the data is public. PL 94-171 gives population, TIGER gives topology, and the Census apportionment table gives seats. The pipeline downloads and joins them. The reconciliation is a gate, not a comment.
pnpm districts:prep # download + build units + topology (~1.5 GB, cached)
pnpm districts:verify # 49 states, every invariant, twice
pnpm test:districts # 1.9M join and population checks
pnpm dev # the map, in a browser
The repo holds the design doc and the ticket history, both with the parts that failed. One balance pass turned Pennsylvania into confetti. One anti-stranding fix repaired Pennsylvania and cost North Carolina more than it gained. Both are still in the changelog, because a rule’s failures belong in its audit trail.


