AXLR / Catalog / politics_civics

gerrymander_metrics

Measure a districting plan: wasted votes and the efficiency gap from two-party district votes, the mean-median difference and seats against votes, and Polsby-Popper and Reock compactness from district polygons in planar coordinates. Vote metrics are exact fractions.

Install

/tools install gerrymander_metrics
/tools install gerrymander_metrics@1.0.0

In a console session, or let the model find it in normal mode: axlr_catalog describes it and axlr_install_tool asks for the same install card. The card reads catalog axlr · gerrymander_metrics 1.0.0 · verified once the index signature, the manifest digest and every file digest check out. Then the model runs it with axlr_run_tool gerrymander_metrics; each run is approved as a local_exec.

Arguments

One JSON object on stdin, validated against the schema before the program starts.

nametyperequireddefaultmeaning
districtsarray of objectyesitems ≥ 1
partiesarray of stringnoThe two parties; the first is the reference for signed figures. Default the order of the first district's votes. items ≥ 2; items ≤ 2
winner_thresholdstringno"half"Winner's wasted votes: above half the district's votes (Stephanopoulos & McGhee) or above floor(total/2)+1. one of "half", "majority"
Input schema (JSON)
{
  "type": "object",
  "required": [
    "districts"
  ],
  "additionalProperties": false,
  "properties": {
    "districts": {
      "type": "array",
      "minItems": 1,
      "items": {
        "type": "object",
        "additionalProperties": false,
        "properties": {
          "name": {
            "type": "string"
          },
          "votes": {
            "type": "object",
            "additionalProperties": {
              "type": "integer",
              "minimum": 0
            },
            "description": "Two-party votes, the same two parties in every district"
          },
          "polygon": {
            "type": "array",
            "items": {
              "type": "array",
              "items": {
                "type": "number"
              },
              "minItems": 2,
              "maxItems": 2
            },
            "description": "Outline as [x, y] points in planar (projected) coordinates"
          },
          "parts": {
            "type": "array",
            "items": {
              "type": "array",
              "items": {
                "type": "array",
                "items": {
                  "type": "number"
                },
                "minItems": 2,
                "maxItems": 2
              }
            },
            "description": "Several outlines for a district in pieces (no holes)"
          }
        }
      }
    },
    "parties": {
      "type": "array",
      "items": {
        "type": "string"
      },
      "minItems": 2,
      "maxItems": 2,
      "description": "The two parties; the first is the reference for signed figures. Default the order of the first district's votes"
    },
    "winner_threshold": {
      "type": "string",
      "enum": [
        "half",
        "majority"
      ],
      "default": "half",
      "description": "Winner's wasted votes: above half the district's votes (Stephanopoulos & McGhee) or above floor(total/2)+1"
    }
  }
}

Example

Input

{
  "districts": [
    {
      "name": "1",
      "votes": {
        "A": 53,
        "B": 47
      }
    },
    {
      "name": "2",
      "votes": {
        "A": 53,
        "B": 47
      }
    },
    {
      "name": "3",
      "votes": {
        "A": 53,
        "B": 47
      }
    },
    {
      "name": "4",
      "votes": {
        "A": 53,
        "B": 47
      }
    },
    {
      "name": "5",
      "votes": {
        "A": 15,
        "B": 85
      }
    }
  ],
  "winner_threshold": "majority"
}

Output (stdout)

{
  "wasted_votes": {
    "A": "23",
    "B": "222"
  },
  "efficiency_gap": "0.398",
  "efficiency_gap_favours": "A"
}

Files

The command the install card registers is python3 gerrymander_metrics.py, run in the workspace with PATH and HOME as its only environment. Each file's SHA-256 is in the signed index; the console checks them before showing the card and again before writing.

gerrymander_metrics.py · sha256 6fcc24dd340d5fb657875b4d9bc00f59ee16f58437b82270bf9ad936691199df
#!/usr/bin/env python3
"""Partisan-fairness and compactness metrics for a districting plan.

Input (JSON on stdin):
  districts  [{"name": "1", "votes": {"A": 53, "B": 47},
               "polygon": [[x, y], ...]  or  "parts": [[[x, y], ...], ...]}, ...]
             votes: two-party votes per district (both parties in every district);
             polygon / parts: the district outline in PLANAR coordinates (project
             longitude/latitude first); either may be left out
  parties    ["A", "B"]  optional; the first is the reference party for signed figures
  winner_threshold  "half" (default; Stephanopoulos & McGhee: a winner's votes above half the
             district's votes are wasted) | "majority" (votes above floor(total / 2) + 1)
Output: wasted votes per district and party, the efficiency gap, its equal-turnout shortcut,
the mean-median difference, seats against votes, and Polsby-Popper and Reock scores.
"""
import json
import math
import random
import sys
from fractions import Fraction


def fail(message):
    print(json.dumps({"error": message}))
    sys.exit(2)


def fmt(value, places=6):
    if value.denominator == 1:
        return str(value.numerator)
    scaled = value * 10 ** places
    sign = -1 if scaled < 0 else 1
    rounded = sign * ((abs(scaled.numerator) * 2 + scaled.denominator) // (2 * scaled.denominator))
    text = str(abs(rounded)).rjust(places + 1, "0")
    text = (text[:-places] + "." + text[-places:]).rstrip("0").rstrip(".")
    return ("-" if rounded < 0 and text != "0" else "") + text


def median(values):
    ordered = sorted(values)
    n = len(ordered)
    mid = n // 2
    return ordered[mid] if n % 2 else (ordered[mid - 1] + ordered[mid]) / 2


def read_ring(ring, where):
    if not isinstance(ring, list):
        fail(f"{where} must be a list of [x, y] points")
    points = []
    for point in ring:
        if (not isinstance(point, list) or len(point) != 2
                or not all(isinstance(v, (int, float)) and not isinstance(v, bool) and math.isfinite(v) for v in point)):
            fail(f"{where} must be a list of [x, y] number pairs")
        points.append((float(point[0]), float(point[1])))
    if len(points) > 1 and points[0] == points[-1]:
        points.pop()
    if len(set(points)) < 3:
        fail(f"{where} needs at least three distinct points")
    return points


def ring_area(points):
    s = 0.0
    for (x1, y1), (x2, y2) in zip(points, points[1:] + points[:1]):
        s += x1 * y2 - x2 * y1
    return abs(s) / 2


def ring_perimeter(points):
    return sum(math.hypot(x2 - x1, y2 - y1) for (x1, y1), (x2, y2) in zip(points, points[1:] + points[:1]))


def circle_two(a, b):
    cx, cy = (a[0] + b[0]) / 2, (a[1] + b[1]) / 2
    return cx, cy, math.hypot(a[0] - cx, a[1] - cy)


def circle_three(a, b, c):
    d = 2 * (a[0] * (b[1] - c[1]) + b[0] * (c[1] - a[1]) + c[0] * (a[1] - b[1]))
    if abs(d) < 1e-15:
        pairs = [circle_two(a, b), circle_two(a, c), circle_two(b, c)]
        return max(pairs, key=lambda circle: circle[2])
    a2, b2, c2 = a[0] ** 2 + a[1] ** 2, b[0] ** 2 + b[1] ** 2, c[0] ** 2 + c[1] ** 2
    ux = (a2 * (b[1] - c[1]) + b2 * (c[1] - a[1]) + c2 * (a[1] - b[1])) / d
    uy = (a2 * (c[0] - b[0]) + b2 * (a[0] - c[0]) + c2 * (b[0] - a[0])) / d
    return ux, uy, math.hypot(a[0] - ux, a[1] - uy)


def inside(circle, p):
    return math.hypot(p[0] - circle[0], p[1] - circle[1]) <= circle[2] * (1 + 1e-12) + 1e-12


def enclosing_circle(points):
    """Smallest circle around the points (Welzl's algorithm, iterative, fixed shuffle)."""
    pts = sorted(set(points))
    random.Random(0).shuffle(pts)
    circle = (pts[0][0], pts[0][1], 0.0)
    for i, p in enumerate(pts):
        if inside(circle, p):
            continue
        circle = (p[0], p[1], 0.0)
        for j in range(i):
            q = pts[j]
            if inside(circle, q):
                continue
            circle = circle_two(p, q)
            for k in range(j):
                r = pts[k]
                if not inside(circle, r):
                    circle = circle_three(p, q, r)
    return circle


def compactness(rings):
    area = sum(ring_area(r) for r in rings)
    perimeter = sum(ring_perimeter(r) for r in rings)
    cx, cy, radius = enclosing_circle([p for r in rings for p in r])
    circle_area = math.pi * radius ** 2
    return {
        "area": round(area, 9),
        "perimeter": round(perimeter, 9),
        "polsby_popper": round(4 * math.pi * area / perimeter ** 2, 9) if perimeter else None,
        "reock": round(area / circle_area, 9) if circle_area else None,
        "enclosing_circle": {"center": [round(cx, 9) + 0.0, round(cy, 9) + 0.0], "radius": round(radius, 9)},
    }


def main():
    try:
        request = json.load(sys.stdin)
    except ValueError as error:
        fail(f"stdin is not JSON: {error}")
    if not isinstance(request, dict):
        fail("input must be a JSON object")
    extra = set(request) - {"districts", "parties", "winner_threshold"}
    if extra:
        fail(f"unknown fields: {sorted(extra)}")
    districts = request.get("districts")
    if not isinstance(districts, list) or not districts:
        fail("districts must be a non-empty list")
    rule = request.get("winner_threshold", "half")
    if rule not in ("half", "majority"):
        fail("winner_threshold must be half or majority")
    parties = request.get("parties")
    with_votes = [d for d in districts if isinstance(d, dict) and "votes" in d]
    if parties is None and with_votes:
        votes0 = with_votes[0]["votes"]
        parties = list(votes0) if isinstance(votes0, dict) else None
    if with_votes and (not isinstance(parties, list) or len(parties) != 2 or len(set(parties)) != 2
                       or not all(isinstance(p, str) for p in parties)):
        fail("parties must be two distinct names (two-party votes)")
    rows = []
    for i, d in enumerate(districts):
        if not isinstance(d, dict):
            fail(f"districts[{i}] must be an object")
        unknown = set(d) - {"name", "votes", "polygon", "parts"}
        if unknown:
            fail(f"districts[{i}] has unknown fields {sorted(unknown)}")
        name = d.get("name", str(i + 1))
        if not isinstance(name, str):
            fail(f"districts[{i}].name must be a string")
        row = {"name": name}
        if "votes" in d:
            votes = d["votes"]
            if (not isinstance(votes, dict) or set(votes) != set(parties)
                    or any(not isinstance(v, int) or isinstance(v, bool) or v < 0 for v in votes.values())):
                fail(f"districts[{i}].votes must give non-negative integer votes for exactly {parties}")
            total = votes[parties[0]] + votes[parties[1]]
            if total == 0:
                fail(f"districts[{i}] has no votes")
            a, b = votes[parties[0]], votes[parties[1]]
            needed = Fraction(total, 2) if rule == "half" else Fraction(total // 2 + 1)
            if a == b:
                winner, wasted = None, {parties[0]: Fraction(a), parties[1]: Fraction(b)}
            elif a > b:
                winner, wasted = parties[0], {parties[0]: a - needed, parties[1]: Fraction(b)}
            else:
                winner, wasted = parties[1], {parties[0]: Fraction(a), parties[1]: b - needed}
            row.update({"votes": {p: votes[p] for p in parties}, "total": total, "winner": winner,
                        "_wasted": wasted, "_share": Fraction(a, total)})
        rings = None
        if "polygon" in d and "parts" in d:
            fail(f"districts[{i}] gives both polygon and parts")
        if "polygon" in d:
            rings = [read_ring(d["polygon"], f"districts[{i}].polygon")]
        elif "parts" in d:
            if not isinstance(d["parts"], list) or not d["parts"]:
                fail(f"districts[{i}].parts must be a non-empty list of rings")
            rings = [read_ring(r, f"districts[{i}].parts[{k}]") for k, r in enumerate(d["parts"])]
        if rings:
            if sum(ring_area(r) for r in rings) == 0:
                fail(f"districts[{i}] outline has zero area")
            row["compactness"] = compactness(rings)
        rows.append(row)
    result = {"parties": parties, "winner_threshold": rule}
    voted = [r for r in rows if "votes" in r]
    if voted:
        total = sum(r["total"] for r in voted)
        party_votes = {p: sum(r["votes"][p] for r in voted) for p in parties}
        wasted = {p: sum((r["_wasted"][p] for r in voted), Fraction(0)) for p in parties}
        seats = {p: sum(1 for r in voted if r["winner"] == p) for p in parties}
        signed = (wasted[parties[1]] - wasted[parties[0]]) / total
        shares = [r["_share"] for r in voted]
        mean = sum(shares, Fraction(0)) / len(shares)
        med = median(shares)
        vote_share = {p: Fraction(party_votes[p], total) for p in parties}
        seat_share = {p: Fraction(seats[p], len(voted)) for p in parties}
        a = parties[0]
        result.update({
            "districts_counted": len(voted),
            "votes": party_votes,
            "vote_share": {p: fmt(v) for p, v in vote_share.items()},
            "seats": seats,
            "tied_districts": [r["name"] for r in voted if r["winner"] is None],
            "seat_share": {p: fmt(v) for p, v in seat_share.items()},
            "seat_bonus": {p: fmt(seat_share[p] - vote_share[p]) for p in parties},
            "wasted_votes": {p: fmt(w) for p, w in wasted.items()},
            "efficiency_gap": fmt(abs(signed)),
            "efficiency_gap_favours": None if signed == 0 else (parties[0] if signed > 0 else parties[1]),
            "efficiency_gap_signed": fmt(signed),
            "efficiency_gap_equal_turnout": fmt((seat_share[a] - Fraction(1, 2)) - 2 * (vote_share[a] - Fraction(1, 2))),
            "mean_median": {"party": a, "mean_share": fmt(mean), "median_share": fmt(med),
                            "median_minus_mean": fmt(med - mean)},
        })
    result["districts"] = []
    for r in rows:
        out = {"name": r["name"]}
        if "votes" in r:
            out.update({"votes": r["votes"], "winner": r["winner"], "share": {parties[0]: fmt(r["_share"])},
                        "wasted": {p: fmt(w) for p, w in r["_wasted"].items()}})
        if "compactness" in r:
            out["compactness"] = r["compactness"]
        result["districts"].append(out)
    if any("compactness" in r for r in rows):
        result["coordinates"] = "planar: distances and areas are taken as given; project longitude/latitude first"
    print(json.dumps(result, ensure_ascii=False))


if __name__ == "__main__":
    main()

Where it is served

The registry serves only AXLR clients, so a browser gets 403; fetch with a User-Agent that starts with axlr:

curl -A axlr-docs/1.0 -O https://registry.underpassai.com/tools/gerrymander_metrics/1.0.0/manifest.json
curl -A axlr-docs/1.0 -O https://registry.underpassai.com/tools/gerrymander_metrics/1.0.0/gerrymander_metrics.py