AXLR / Catalog / politics_civics

seat_allocation_droop

Allocate seats among parties by largest remainder with a Droop, Hagenbach-Bischoff, Imperiali or Hare quota, with an optional electoral threshold. Exact arithmetic; reports each party's quotient and remainder, ties at the cut, and seats taken back when a quota hands out more seats than exist.

Install

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

Arguments

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

nametyperequireddefaultmeaning
seatsintegeryesSeats to allocate. ≥ 1
votesobject of integeryesVotes per party
quotastringno"droop"droop = floor(V/(S+1))+1, hagenbach_bischoff = V/(S+1), imperiali = V/(S+2), hare = V/S. one of "droop", "hagenbach_bischoff", "imperiali", "hare"
thresholdnumberno0Minimum share of valid votes, in percent, to receive seats. ≥ 0; ≤ 99
on_overallocationstringno"smallest_remainders"When whole quotas exceed the seats: take seats back from the smallest remainders, or fail. one of "smallest_remainders", "error"
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"
    },
    "quota": {
      "type": "string",
      "enum": [
        "droop",
        "hagenbach_bischoff",
        "imperiali",
        "hare"
      ],
      "default": "droop",
      "description": "droop = floor(V/(S+1))+1, hagenbach_bischoff = V/(S+1), imperiali = V/(S+2), hare = V/S"
    },
    "threshold": {
      "type": "number",
      "minimum": 0,
      "maximum": 99,
      "default": 0,
      "description": "Minimum share of valid votes, in percent, to receive seats"
    },
    "on_overallocation": {
      "type": "string",
      "enum": [
        "smallest_remainders",
        "error"
      ],
      "default": "smallest_remainders",
      "description": "When whole quotas exceed the seats: take seats back from the smallest remainders, or fail"
    }
  }
}

Example

Input

{
  "seats": 10,
  "votes": {
    "Yellows": 47000,
    "Whites": 16000,
    "Reds": 15800,
    "Greens": 12000,
    "Blues": 6100,
    "Pinks": 3100
  }
}

Output (stdout)

{
  "quota": "9091",
  "remainder_seats": [
    "Whites",
    "Reds"
  ],
  "allocation": {
    "Yellows": 5,
    "Whites": 2,
    "Reds": 2,
    "Greens": 1,
    "Blues": 0,
    "Pinks": 0
  }
}

Files

The command the install card registers is python3 seat_allocation_droop.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_droop.py · sha256 882f622b602ab74f2f4d7b4461486fa60da736e4460a8eb58b3f10174941cb9b
#!/usr/bin/env python3
"""Largest-remainder seat allocation with a Droop, Hagenbach-Bischoff, Imperiali or Hare quota.

Input (one JSON object on stdin):
  seats      integer > 0
  votes      object {party: votes >= 0}
  quota      "droop" (default) | "hagenbach_bischoff" | "imperiali" | "hare"
  threshold  number in [0, 100): parties under this share of valid votes get no seats (default 0)
  on_overallocation  "smallest_remainders" (default) | "error": what to do when the quota hands
             out more whole seats than exist (possible with Imperiali)
Output (JSON on stdout):
  the quota, each party's quotient, whole seats, remainder and total, the parties given a
  remainder seat, ties at the cut and any seats taken back after an overallocation.
"""
import json
import sys
from fractions import Fraction


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


def fmt(value, places=6):
    """A Fraction as an integer string, or a decimal string rounded half up to `places`."""
    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 + abs(scaled.denominator)) // (2 * scaled.denominator))
    text = str(abs(rounded)).rjust(places + 1, "0")
    text = text[:-places] + "." + text[-places:]
    text = text.rstrip("0").rstrip(".")
    return ("-" if rounded < 0 else "") + text


def quota_for(kind, total, seats):
    if kind == "droop":
        return Fraction(total // (seats + 1) + 1)
    if kind == "hagenbach_bischoff":
        return Fraction(total, seats + 1)
    if kind == "imperiali":
        return Fraction(total, seats + 2)
    if kind == "hare":
        return Fraction(total, seats)
    fail("quota must be droop, hagenbach_bischoff, imperiali or hare")


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")
    unknown = set(request) - {"seats", "votes", "quota", "threshold", "on_overallocation"}
    if unknown:
        fail(f"unknown fields: {sorted(unknown)}")
    seats = request.get("seats")
    votes = request.get("votes")
    kind = request.get("quota", "droop")
    threshold = request.get("threshold", 0)
    policy = request.get("on_overallocation", "smallest_remainders")
    if not isinstance(seats, int) or isinstance(seats, bool) 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 isinstance(v, bool) or v < 0 for v in votes.values()):
        fail("votes must be a non-empty object of non-negative integers")
    if isinstance(threshold, bool) or not isinstance(threshold, (int, float, str)):
        fail("threshold must be a number of percent")
    try:
        threshold = Fraction(str(threshold))
    except (ValueError, ZeroDivisionError):
        fail("threshold must be a number of percent")
    if not 0 <= threshold < 100:
        fail("threshold must be between 0 and 100")
    if policy not in ("smallest_remainders", "error"):
        fail("on_overallocation must be smallest_remainders or error")
    valid = sum(votes.values())
    if valid == 0:
        fail("no valid votes")
    excluded = [p for p, v in votes.items() if Fraction(v * 100, valid) < threshold]
    eligible = {p: v for p, v in votes.items() if p not in excluded}
    counted = sum(eligible.values())
    if counted == 0:
        fail("no party passes the threshold")
    quota = quota_for(kind, counted, seats)
    quotients = {p: Fraction(v) / quota for p, v in eligible.items()}
    whole = {p: int(q) for p, q in quotients.items()}
    remainders = {p: quotients[p] - whole[p] for p in eligible}
    awarded = dict(whole)
    left = seats - sum(whole.values())
    ties, taken_back, remainder_seats = [], [], []
    if left >= 0:
        ranking = sorted(eligible, key=lambda p: (-remainders[p], -eligible[p], p))
        remainder_seats = ranking[:left]
        for p in remainder_seats:
            awarded[p] += 1
        if 0 < left < len(ranking) and remainders[ranking[left - 1]] == remainders[ranking[left]]:
            cut = remainders[ranking[left]]
            tied = [p for p in ranking if remainders[p] == cut]
            ties.append({"between": tied, "awarded_to": [p for p in tied if p in remainder_seats],
                         "rule": "larger vote count, then party name"})
    else:
        if policy == "error":
            fail(f"the {kind} quota hands out {sum(whole.values())} whole seats for {seats} seats")
        holders = sorted((p for p in eligible if whole[p] > 0), key=lambda p: (remainders[p], eligible[p], p))
        excess = -left
        while excess:
            for p in holders:
                if excess and awarded[p] > 0:
                    awarded[p] -= 1
                    taken_back.append(p)
                    excess -= 1
    for p in excluded:
        awarded[p] = 0
    table = [{"party": p, "votes": eligible[p], "quotient": fmt(quotients[p]), "whole_seats": whole[p],
              "remainder": fmt(remainders[p]), "seats": awarded[p]} for p in eligible]
    result = {
        "quota_type": kind,
        "quota": fmt(quota),
        "seats": seats,
        "valid_votes": valid,
        "votes_counted": counted,
        "allocation": dict(sorted(awarded.items(), key=lambda kv: (-kv[1], -votes[kv[0]], kv[0]))),
        "whole_seats_total": sum(whole.values()),
        "remainder_seats": remainder_seats,
        "parties": table,
        "excluded_by_threshold": excluded,
        "ties": ties,
        "overallocation": {"excess": len(taken_back), "taken_back_from": taken_back,
                           "rule": "one seat each from the parties with the smallest remainders"} if taken_back else None,
    }
    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/seat_allocation_droop/1.0.0/manifest.json
curl -A axlr-docs/1.0 -O https://registry.underpassai.com/tools/seat_allocation_droop/1.0.0/seat_allocation_droop.py