AXLR / Catalog / politics_civics

apportionment

Apportion seats among districts by population with Huntington-Hill, Webster (Sainte-Laguë), Jefferson (D'Hondt), Adams or Hamilton (largest remainder), with a minimum number of seats per district. Exact arithmetic; shows each district's quota, the order and priority values of the seats awarded, and ties for the last seat.

Install

/tools install apportionment
/tools install apportionment@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 · apportionment 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 apportionment; each run is approved as a local_exec.

Arguments

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

nametyperequireddefaultmeaning
seatsintegeryesHouse size. ≥ 1
populationsobject of integeryesPopulation per district
methodstringno"huntington_hill"one of "huntington_hill", "webster", "jefferson", "adams", "hamilton"
min_seatsintegerno0Seats every district gets first; huntington_hill and adams always give at least 1. ≥ 0
Input schema (JSON)
{
  "type": "object",
  "required": [
    "seats",
    "populations"
  ],
  "additionalProperties": false,
  "properties": {
    "seats": {
      "type": "integer",
      "minimum": 1,
      "description": "House size"
    },
    "populations": {
      "type": "object",
      "additionalProperties": {
        "type": "integer",
        "minimum": 1
      },
      "description": "Population per district"
    },
    "method": {
      "type": "string",
      "enum": [
        "huntington_hill",
        "webster",
        "jefferson",
        "adams",
        "hamilton"
      ],
      "default": "huntington_hill"
    },
    "min_seats": {
      "type": "integer",
      "minimum": 0,
      "default": 0,
      "description": "Seats every district gets first; huntington_hill and adams always give at least 1"
    }
  }
}

Example

Input

{
  "seats": 8,
  "populations": {
    "A": 100000,
    "B": 80000,
    "C": 30000
  }
}

Output (stdout)

{
  "method": "huntington_hill",
  "min_seats": 1,
  "allocation": {
    "A": 4,
    "B": 3,
    "C": 1
  }
}

Files

The command the install card registers is python3 apportionment.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.

apportionment.py · sha256 8b29cbd2274211c0f1f150ac4e0ad67c31af42b98dc1bdfd4fc4db6fb12f3798
#!/usr/bin/env python3
"""Apportion seats among districts (states, provinces, regions) by population.

Input (JSON on stdin):
  seats        integer > 0
  populations  object {district: population > 0}
  method       "huntington_hill" (default) | "webster" | "jefferson" | "adams" | "hamilton"
  min_seats    integer >= 0, seats every district gets first (default 0; huntington_hill and
               adams always give at least 1)
Output (JSON on stdout):
  seats per district, each district's standard quota, the order of the seats awarded after
  the minimums with their priority values (divisor methods) or remainders (hamilton), and ties.
"""
import json
import math
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
    rounded = (scaled.numerator * 2 + scaled.denominator) // (2 * scaled.denominator)
    text = str(rounded).rjust(places + 1, "0")
    return (text[:-places] + "." + text[-places:]).rstrip("0").rstrip(".")


def priority_key(method, population, seats):
    """A value that orders claims to the next seat exactly; for Huntington-Hill the square
    of the priority value p / sqrt(s (s + 1)), so comparisons stay exact."""
    if method == "jefferson":
        return Fraction(population, seats + 1)
    if method == "webster":
        return Fraction(2 * population, 2 * seats + 1)
    if method == "adams":
        return Fraction(population, seats)
    return Fraction(population * population, seats * (seats + 1))


def shown_priority(method, key):
    if method == "huntington_hill":
        return round(math.sqrt(key.numerator) / math.sqrt(key.denominator), 3)
    return fmt(key)


def divisor(method, seats, pops, start):
    awarded = dict(start)
    order = []
    left = seats - sum(awarded.values())
    for _ in range(left):
        keys = {d: priority_key(method, p, awarded[d]) for d, p in pops.items()}
        best = max(keys.values())
        winner = sorted((d for d in pops if keys[d] == best), key=lambda d: (-pops[d], d))[0]
        awarded[winner] += 1
        order.append({"district": winner, "seat_number": awarded[winner], "priority": shown_priority(method, best)})
    ties = []
    keys = {d: priority_key(method, p, awarded[d]) for d, p in pops.items()}
    if order:
        last = order[-1]["district"]
        last_key = priority_key(method, pops[last], awarded[last] - 1)
        rivals = [d for d in pops if keys[d] == last_key]
        if rivals:
            ties.append({"between": [last] + rivals, "awarded_to": last, "rule": "larger population, then name"})
    nxt = max(keys.values())
    return awarded, order, ties, shown_priority(method, nxt)


def hamilton(seats, pops, minimum):
    fixed = {}
    free = dict(pops)
    while True:
        left = seats - sum(fixed.values())
        total = sum(free.values())
        quotas = {d: Fraction(p * left, total) for d, p in free.items()}
        low = [d for d in free if int(quotas[d]) < minimum]
        if not low:
            break
        for d in low:
            fixed[d] = minimum
            del free[d]
        if not free:
            break
    awarded = dict(fixed)
    order, ties = [], []
    if free:
        left = seats - sum(fixed.values())
        whole = {d: int(quotas[d]) for d in free}
        remainders = {d: quotas[d] - whole[d] for d in free}
        awarded.update(whole)
        extra = left - sum(whole.values())
        ranking = sorted(free, key=lambda d: (-remainders[d], -pops[d], d))
        for d in ranking[:extra]:
            awarded[d] += 1
            order.append({"district": d, "seat_number": awarded[d], "remainder": fmt(remainders[d])})
        if 0 < extra < len(ranking) and remainders[ranking[extra - 1]] == remainders[ranking[extra]]:
            cut = remainders[ranking[extra]]
            ties.append({"between": [d for d in ranking if remainders[d] == cut],
                         "awarded_to": [d for d in ranking[:extra] if remainders[d] == cut],
                         "rule": "larger population, then name"})
    return awarded, order, ties, sorted(fixed)


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) - {"seats", "populations", "method", "min_seats"}
    if extra:
        fail(f"unknown fields: {sorted(extra)}")
    seats = request.get("seats")
    pops = request.get("populations")
    method = request.get("method", "huntington_hill")
    minimum = request.get("min_seats", 0)
    if not isinstance(seats, int) or isinstance(seats, bool) or seats <= 0:
        fail("seats must be a positive integer")
    if not isinstance(pops, dict) or not pops or any(
            not isinstance(p, int) or isinstance(p, bool) or p <= 0 for p in pops.values()):
        fail("populations must be a non-empty object of positive integers")
    if method not in ("huntington_hill", "webster", "jefferson", "adams", "hamilton"):
        fail("method must be huntington_hill, webster, jefferson, adams or hamilton")
    if not isinstance(minimum, int) or isinstance(minimum, bool) or minimum < 0:
        fail("min_seats must be a non-negative integer")
    effective = max(minimum, 1) if method in ("huntington_hill", "adams") else minimum
    if effective * len(pops) > seats:
        fail(f"{len(pops)} districts with at least {effective} seat(s) each need more than {seats} seats")
    total = sum(pops.values())
    standard = Fraction(total, seats)
    result = {"method": method, "seats": seats, "population": total, "standard_divisor": fmt(standard),
              "min_seats": effective}
    if method == "hamilton":
        awarded, order, ties, raised = hamilton(seats, pops, effective)
        result["raised_to_minimum"] = raised
    else:
        awarded, order, ties, nxt = divisor(method, seats, pops, {d: effective for d in pops})
        result["next_priority"] = nxt
    result["allocation"] = {d: awarded[d] for d in pops}
    result["districts"] = [{"district": d, "population": p, "quota": fmt(Fraction(p) / standard), "seats": awarded[d],
                            "people_per_seat": fmt(Fraction(p, awarded[d])) if awarded[d] else None}
                           for d, p in pops.items()]
    result["awards"] = order
    result["ties"] = ties
    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/apportionment/1.0.0/manifest.json
curl -A axlr-docs/1.0 -O https://registry.underpassai.com/tools/apportionment/1.0.0/apportionment.py