vote_stv
Count a single transferable vote election with the Droop quota and Gregory fractional surplus transfers in exact fractions: every round's tallies, transfer values, exhausted votes, elections and exclusions, with a stated tie rule (fewest first preferences, then earlier rounds, then a lot order you supply).
Install
/tools install vote_stv /tools install vote_stv@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 · vote_stv 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 vote_stv; each run is approved as a local_exec.
Arguments
One JSON object on stdin, validated against the schema before the program starts.
| name | type | required | default | meaning |
|---|---|---|---|---|
seats | integer | yes | Seats to fill; fewer than the candidates. ≥ 1 | |
ballots | array | yes | Each a strict ranking (list of names, may stop early) or {ranking, count}. items ≥ 1 | |
candidates | array of string | no | Every candidate, including any no ballot ranks; default the names on the ballots | |
tie_order | array of string | no | Order in which candidates lose a tie the counts cannot break (e.g. a drawing of lots); default the reverse candidate order |
Input schema (JSON)
{
"type": "object",
"required": [
"seats",
"ballots"
],
"additionalProperties": false,
"properties": {
"seats": {
"type": "integer",
"minimum": 1,
"description": "Seats to fill; fewer than the candidates"
},
"ballots": {
"type": "array",
"minItems": 1,
"description": "Each a strict ranking (list of names, may stop early) or {ranking, count}",
"items": {
"anyOf": [
{
"type": "array",
"items": {
"type": "string"
}
},
{
"type": "object",
"required": [
"ranking"
],
"additionalProperties": false,
"properties": {
"ranking": {
"type": "array",
"items": {
"type": "string"
}
},
"count": {
"type": "integer",
"minimum": 0,
"default": 1
}
}
}
]
}
},
"candidates": {
"type": "array",
"items": {
"type": "string"
},
"description": "Every candidate, including any no ballot ranks; default the names on the ballots"
},
"tie_order": {
"type": "array",
"items": {
"type": "string"
},
"description": "Order in which candidates lose a tie the counts cannot break (e.g. a drawing of lots); default the reverse candidate order"
}
}
}
Example
Input
{
"seats": 2,
"ballots": [
{
"ranking": [
"A",
"B"
],
"count": 8
},
{
"ranking": [
"A",
"C"
],
"count": 4
},
{
"ranking": [
"B"
],
"count": 1
},
{
"ranking": [
"C"
],
"count": 2
},
{
"ranking": [
"D",
"C"
],
"count": 3
}
]
}
Output (stdout)
{
"quota": "7",
"elected": [
"A",
"C"
],
"excluded": [
"D",
"B"
],
"round_count": 4
}
Files
The command the install card registers is python3 vote_stv.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.
vote_stv.py · sha256 1c2c8aa5d061d36024e191b6b6b6f589f91baf4e6626f09270064ad27b34e27b
#!/usr/bin/env python3
"""Single transferable vote with the Droop quota and Gregory fractional surplus transfers.
Input (JSON on stdin):
seats integer > 0
ballots [{"ranking": ["A", "B", "C"], "count": 3}, ...] or bare rankings; strict
rankings that may stop early
candidates ["A", "B", ...] optional; default every name on a ballot, in first-seen order
tie_order ["C", "B", ...] optional; who loses a tie the counts cannot break, first listed
first (a drawing of lots); default the reverse candidate order
Rules (weighted inclusive Gregory method, exact fractions):
quota = floor(valid ballots / (seats + 1)) + 1. A continuing candidate whose votes reach the
quota is elected. The largest untransferred surplus is transferred first: every ballot the
elected candidate holds moves to its next continuing preference at its current value times
surplus / candidate's votes. With no surplus to transfer, the continuing candidate with the
fewest votes is excluded and its ballots move on at their current value. A tie for fewest
goes to the one with fewer first-preference votes, then fewer votes in each earlier round
from the second onward, then tie_order. When the continuing candidates are no more than the
seats left, they are all elected.
"""
import json
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 read_ballots(request):
ballots = request.get("ballots")
if not isinstance(ballots, list) or not ballots:
fail("ballots must be a non-empty list")
parsed, seen = [], []
for i, ballot in enumerate(ballots):
if isinstance(ballot, list):
ranking, count = ballot, 1
elif isinstance(ballot, dict):
extra = set(ballot) - {"ranking", "count"}
if extra:
fail(f"ballots[{i}] has unknown fields {sorted(extra)}")
ranking, count = ballot.get("ranking"), ballot.get("count", 1)
else:
fail(f"ballots[{i}] must be a list or an object with ranking")
if not isinstance(ranking, list) or not isinstance(count, int) or isinstance(count, bool) or count < 0:
fail(f"ballots[{i}] needs a ranking list and a non-negative integer count")
for c in ranking:
if isinstance(c, list):
fail(f"ballots[{i}] ranks a tie; STV needs strict rankings")
if not isinstance(c, str) or not c:
fail(f"ballots[{i}] ranks something that is not a candidate name")
if c not in seen:
seen.append(c)
if len(ranking) != len(set(ranking)):
fail(f"ballots[{i}] ranks a candidate twice")
if ranking and count:
parsed.append((ranking, count))
candidates = request.get("candidates", seen)
if (not isinstance(candidates, list) or len(candidates) < 2 or len(set(candidates)) != len(candidates)
or not all(isinstance(c, str) and c for c in candidates)):
fail("candidates must be a list of at least two distinct names")
unknown = [c for c in seen if c not in candidates]
if unknown:
fail(f"ballots rank candidates not in the candidate list: {unknown}")
return candidates, parsed
class Count:
def __init__(self, candidates, ballots, seats, lot):
self.candidates = candidates
self.seats = seats
self.lot = lot
self.continuing = list(candidates)
self.elected = []
self.excluded = []
self.piles = {c: [] for c in candidates}
self.exhausted = Fraction(0)
self.history = []
self.rounds = []
self.pending = []
valid = sum(count for _, count in ballots)
self.valid = valid
self.quota = Fraction(valid // (seats + 1) + 1)
for ranking, count in ballots:
self.place(ranking, 0, Fraction(count))
def place(self, ranking, start, value):
for position in range(start, len(ranking)):
c = ranking[position]
if c in self.continuing:
self.piles[c].append((ranking, position, value))
return
self.exhausted += value
def votes(self, c):
return sum((value for _, _, value in self.piles[c]), Fraction(0))
def move(self, c, factor):
papers, self.piles[c] = self.piles[c], []
for ranking, position, value in papers:
self.place(ranking, position + 1, value * factor)
def record(self, action, **extra):
tally = {c: self.votes(c) for c in self.candidates if c not in self.excluded}
self.history.append({c: self.votes(c) for c in self.candidates})
entry = {"round": len(self.rounds) + 1, "action": action}
entry.update(extra)
entry["tally"] = {c: fmt(v) for c, v in sorted(tally.items(), key=lambda kv: (-kv[1], self.candidates.index(kv[0])))}
entry["exhausted"] = fmt(self.exhausted)
self.rounds.append(entry)
return entry
def elect_reached(self, entry):
reached = [c for c in self.continuing if self.votes(c) >= self.quota]
reached.sort(key=lambda c: (-self.votes(c), self.candidates.index(c)))
for c in reached:
if len(self.elected) >= self.seats:
break
self.continuing.remove(c)
self.elected.append(c)
if self.votes(c) > self.quota:
self.pending.append(c)
if reached:
entry["elected"] = reached
def lowest(self):
low = min(self.votes(c) for c in self.continuing)
tied = [c for c in self.continuing if self.votes(c) == low]
if len(tied) == 1:
return tied[0], None
pool = list(tied)
for index, earlier in enumerate(self.history[:-1]):
fewest = min(earlier[c] for c in pool)
pool = [c for c in pool if earlier[c] == fewest]
if len(pool) == 1:
how = "first preferences" if index == 0 else f"round {index + 1} votes"
return pool[0], {"between": tied, "excluded": pool[0], "resolved_by": how}
pool.sort(key=lambda c: self.lot[c])
return pool[0], {"between": tied, "excluded": pool[0], "resolved_by": "tie_order (unresolved by the counts)"}
def run(self):
entry = self.record("first preferences")
self.elect_reached(entry)
while len(self.elected) < self.seats:
left = self.seats - len(self.elected)
if len(self.continuing) <= left:
entry = self.rounds[-1]
entry.setdefault("elected_without_quota", [])
for c in sorted(self.continuing, key=lambda c: (-self.votes(c), self.candidates.index(c))):
entry["elected_without_quota"].append(c)
self.elected.append(c)
self.continuing = []
break
if self.pending:
self.pending.sort(key=lambda c: -self.votes(c))
c = self.pending.pop(0)
total = self.votes(c)
surplus = total - self.quota
factor = surplus / total
self.move(c, factor)
self.piles[c] = [(None, 0, self.quota)]
entry = self.record(f"transfer surplus of {c}", surplus=fmt(surplus), transfer_value=str(factor))
else:
c, tie = self.lowest()
self.continuing.remove(c)
self.excluded.append(c)
self.move(c, Fraction(1))
entry = self.record(f"exclude {c}")
if tie:
entry["tie"] = tie
self.elect_reached(entry)
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", "ballots", "candidates", "tie_order"}
if extra:
fail(f"unknown fields: {sorted(extra)}")
seats = request.get("seats")
if not isinstance(seats, int) or isinstance(seats, bool) or seats <= 0:
fail("seats must be a positive integer")
candidates, ballots = read_ballots(request)
if seats >= len(candidates):
fail("seats must be fewer than the candidates")
if not ballots:
fail("no ballot ranks anyone")
tie_order = request.get("tie_order", list(reversed(candidates)))
if not isinstance(tie_order, list) or sorted(tie_order, key=str) != sorted(candidates, key=str):
fail("tie_order must list every candidate exactly once")
count = Count(candidates, ballots, seats, {c: i for i, c in enumerate(tie_order)})
count.run()
print(json.dumps({
"seats": seats,
"valid_ballots": count.valid,
"quota": fmt(count.quota),
"elected": count.elected,
"excluded": count.excluded,
"rounds": count.rounds,
"round_count": len(count.rounds),
}, 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/vote_stv/1.0.0/manifest.json
curl -A axlr-docs/1.0 -O https://registry.underpassai.com/tools/vote_stv/1.0.0/vote_stv.py