AXLR / Catalog / politics_civics

seat_allocation

Allocate seats among parties from their votes by D'Hondt, Sainte-Laguë, modified Sainte-Laguë or Hare largest remainder, with an optional electoral threshold. Exact arithmetic; reports the award order and ties.

Install

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

About

Allocates seats among parties from their votes with four methods used in real legislatures: D'Hondt (Spain, Portugal, Belgium, Finland, Netherlands), Sainte-Laguë (Germany, Norway, New Zealand), modified Sainte-Laguë (first divisor 1.4; Sweden) and Hare largest remainder (Hong Kong, Colombia's old system). An optional threshold in percent of valid votes excludes parties below it before allocation. Arithmetic is exact (fractions.Fraction), so a tie is a real tie; ties go to the larger vote count and then to the party name, and are reported in ties.

Limits: one district per call (call it per district for a national result); no overhang, levelling or apparentement; blank and null votes must already be excluded from votes.

Method references: Gallagher & Mitchell, The Politics of Electoral Systems (2005), appendix A.

Arguments

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

nametyperequireddefaultmeaning
seatsintegeryesSeats to allocate. ≥ 1
votesobject of integeryesVotes per party
methodstringno"dhondt"one of "dhondt", "sainte_lague", "modified_sainte_lague", "hare"
thresholdnumberno0Minimum share of valid votes, in percent, to receive seats. ≥ 0; ≤ 99
Input schema (JSON)
{
  "type": "object",
  "required": [
    "seats",
    "votes"
  ],
  "additionalProperties": false,
  "properties": {
    "seats": {
      "type": "integer",
      "minimum": 1,
      "description": "Seats to allocate"
    },
    "votes": {
      "type": "object",
      "additionalProperties": {
        "type": "integer",
        "minimum": 0
      },
      "description": "Votes per party"
    },
    "method": {
      "type": "string",
      "enum": [
        "dhondt",
        "sainte_lague",
        "modified_sainte_lague",
        "hare"
      ],
      "default": "dhondt"
    },
    "threshold": {
      "type": "number",
      "minimum": 0,
      "maximum": 99,
      "default": 0,
      "description": "Minimum share of valid votes, in percent, to receive seats"
    }
  }
}

Example

Input

{
  "seats": 7,
  "votes": {
    "A": 100000,
    "B": 80000,
    "C": 30000,
    "D": 20000
  },
  "method": "dhondt",
  "threshold": 3
}

Output (stdout)

{
  "allocation": {
    "A": 3,
    "B": 3,
    "C": 1,
    "D": 0
  }
}

Files

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

seat_allocation.py · sha256 56d3e582c6fb977f64b25eefdeffa1da4d243339390c011afd249ebce7d9ea57
#!/usr/bin/env python3
"""Allocate seats among parties: D'Hondt, Sainte-Laguë (plain or modified) or Hare quota.

Input (one JSON object on stdin):
  seats     integer > 0
  votes     object {party: votes >= 0}
  method    "dhondt" (default) | "sainte_lague" | "modified_sainte_lague" | "hare"
  threshold number in [0, 100): parties under this share of valid votes get no seats (default 0)
Output (JSON on stdout):
  seats per party, the order in which seats were awarded (divisor methods), the last quotient
  per party, and which parties the threshold excluded. Ties go to the larger vote count, then
  to the party name, and are reported.
"""
import json
import sys
from fractions import Fraction


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


def divisor_method(seats, votes, divisors):
    awarded = {p: 0 for p in votes}
    order = []
    ties = []
    for _ in range(seats):
        quotients = {p: Fraction(v, divisors(awarded[p])) for p, v in votes.items()}
        best = max(quotients.values())
        winners = sorted((p for p, q in quotients.items() if q == best), key=lambda p: (-votes[p], p))
        if len(winners) > 1:
            ties.append({"between": winners, "awarded_to": winners[0]})
        awarded[winners[0]] += 1
        order.append({"party": winners[0], "quotient": str(best)})
    return awarded, order, ties


def hare(seats, votes):
    total = sum(votes.values())
    quota = Fraction(total, seats)
    awarded = {p: int(Fraction(v) / quota) for p, v in votes.items()}
    remainders = {p: Fraction(v) / quota - awarded[p] for p, v in votes.items()}
    left = seats - sum(awarded.values())
    ties = []
    ranking = sorted(votes, key=lambda p: (-remainders[p], -votes[p], p))
    for p in ranking[:left]:
        awarded[p] += 1
    if left and len(ranking) > left and remainders[ranking[left - 1]] == remainders[ranking[left]]:
        ties.append({"between": [ranking[left - 1], ranking[left]], "awarded_to": ranking[left - 1]})
    return awarded, [{"party": p, "remainder": str(remainders[p])} for p in ranking[:left]], ties


def main():
    try:
        request = json.load(sys.stdin)
    except ValueError as error:
        fail(f"stdin is not JSON: {error}")
    seats = request.get("seats")
    votes = request.get("votes")
    method = request.get("method", "dhondt")
    threshold = request.get("threshold", 0)
    if not isinstance(seats, int) or seats <= 0:
        fail("seats must be a positive integer")
    if not isinstance(votes, dict) or not votes or any(not isinstance(v, int) or v < 0 for v in votes.values()):
        fail("votes must be a non-empty object of non-negative integers")
    valid = sum(votes.values())
    if valid == 0:
        fail("no valid votes")
    excluded = [p for p, v in votes.items() if Fraction(v * 100, valid) < Fraction(str(threshold))]
    eligible = {p: v for p, v in votes.items() if p not in excluded and v > 0}
    if not eligible:
        fail("no party passes the threshold")
    if method == "dhondt":
        awarded, order, ties = divisor_method(seats, eligible, lambda s: s + 1)
    elif method == "sainte_lague":
        awarded, order, ties = divisor_method(seats, eligible, lambda s: 2 * s + 1)
    elif method == "modified_sainte_lague":
        awarded, order, ties = divisor_method(seats, eligible, lambda s: Fraction(7, 5) if s == 0 else 2 * s + 1)
    elif method == "hare":
        awarded, order, ties = hare(seats, eligible)
    else:
        fail("method must be dhondt, sainte_lague, modified_sainte_lague or hare")
    for p in excluded:
        awarded[p] = 0
    print(json.dumps({
        "method": method,
        "seats": seats,
        "valid_votes": valid,
        "allocation": dict(sorted(awarded.items(), key=lambda kv: (-kv[1], -votes[kv[0]], kv[0]))),
        "order": order,
        "excluded_by_threshold": excluded,
        "ties": ties,
    }, 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/seat_allocation/1.0.0/manifest.json
curl -A axlr-docs/1.0 -O https://registry.underpassai.com/tools/seat_allocation/1.0.0/seat_allocation.py