Skip to content

Equal Person: a districting rule you can read

Equal Person Districting 2020 census block groups · apportionment seats · one rule

Loading state…

6 districts · about 706,209 people each Colours are graph-coloured so no two neighbours share one
1×
0 / 0

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.

draw(state, seats)

      
      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
    
The order matters: districts are finished one at a time, so the map has a readable story rather than a global optimisation with no narrative.

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.

grow(district, seed, quota, bound)

      
      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)
    
A negative delta means the addition fills a notch and shortens the outline. That is why districts come out compact without a compactness term anywhere in the loop.

Two passes finish what growth could not, and neither is decoration. Each one repairs a specific problem growth leaves behind.

repair(assignment, quota, bound)

      
      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
    
The chunk has to stay connected to the receiver and must not disconnect the donor. Both are checked before the move.
smooth(assignment, bound, passes)

      
      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.
    
Smoothing only accepts moves that shorten the two outlines, keep both districts inside the population bound, and leave the losing district connected.

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.

statelargest block groupquotafloor
California37,892760,3504.98%
North Carolina8,962745,6711.20%
Rhode Island6,495548,6901.18%
Oregon7,964706,2091.13%
Montana5,898542,1131.09%
Wyoming3,327576,8510.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.

districts429 in 44 states, plus 6 whole-state
worst deviation0.00%–4.98%, against floors of 0.45%–4.98%
states outside their floornone
national compactnesspopulation-weighted Polsby-Popper 0.303
slowest runCalifornia, about one second
route JavaScript7 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.

Keyboard Shortcuts