AXLR / Catalog / politics_civics

vote_irv

Count an instant-runoff (single-winner ranked-choice) election round by round: each round's tally, exhausted ballots and the majority base, who is eliminated and why, with a stated tie rule (fewest first preferences, then earlier rounds, then a lot order you supply). Final ties are reported, not broken.

Install

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

Arguments

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

nametyperequireddefaultmeaning
ballotsarrayyesEach a strict ranking (list of names, may stop early) or {ranking, count}. items ≥ 1
candidatesarray of stringnoEvery candidate, including any no ballot ranks; default the names on the ballots
tie_orderarray of stringnoOrder 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": [
    "ballots"
  ],
  "additionalProperties": false,
  "properties": {
    "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

{
  "ballots": [
    {
      "ranking": [
        "Memphis",
        "Nashville",
        "Chattanooga",
        "Knoxville"
      ],
      "count": 42
    },
    {
      "ranking": [
        "Nashville",
        "Chattanooga",
        "Knoxville",
        "Memphis"
      ],
      "count": 26
    },
    {
      "ranking": [
        "Chattanooga",
        "Knoxville",
        "Nashville",
        "Memphis"
      ],
      "count": 15
    },
    {
      "ranking": [
        "Knoxville",
        "Chattanooga",
        "Nashville",
        "Memphis"
      ],
      "count": 17
    }
  ]
}

Output (stdout)

{
  "winner": "Knoxville",
  "round_count": 3,
  "elimination_order": [
    "Chattanooga",
    "Nashville"
  ]
}

Files

The command the install card registers is python3 vote_irv.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_irv.py · sha256 6b75567f3bd4b2b92eb1db34cc4e6364071b43ee2c228ba816fca230fd8a9576
#!/usr/bin/env python3
"""Instant-runoff voting (single-winner ranked choice) with every round reported.

Input (JSON on stdin):
  ballots     [{"ranking": ["A", "B", "C"], "count": 3}, ...] or bare rankings ["A", "B"];
              rankings are strict (no ties) and may stop early
  candidates  ["A", "B", ...]  optional; default every name on a ballot, in first-seen order
  tie_order   ["C", "B", ...]  optional; the order in which candidates lose a tie that the
              rules below cannot break (for example the result of a drawing of lots);
              default the reverse of the candidate order (the last listed loses first)
Rules:
  Each round every ballot counts for its highest-ranked continuing candidate; a ballot with
  none left is exhausted. A candidate with more than half of the continuing (non-exhausted)
  votes wins. Otherwise the candidate with the fewest votes is eliminated; 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, and every tie is reported.
"""
import json
import sys


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


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; instant runoff 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")
        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


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) - {"ballots", "candidates", "tie_order"}
    if extra:
        fail(f"unknown fields: {sorted(extra)}")
    candidates, ballots = read_ballots(request)
    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")
    lot = {c: i for i, c in enumerate(tie_order)}
    total = sum(count for _, count in ballots)
    if total == 0:
        fail("no ballots to count")
    continuing = list(candidates)
    history = []
    rounds = []
    winner, tied_winners = None, None
    while True:
        tally = {c: 0 for c in continuing}
        exhausted = 0
        for ranking, count in ballots:
            top = next((c for c in ranking if c in tally), None)
            if top is None:
                exhausted += count
            else:
                tally[top] += count
        history.append(tally)
        active = total - exhausted
        record = {"round": len(rounds) + 1, "tally": dict(sorted(tally.items(), key=lambda kv: (-kv[1], candidates.index(kv[0])))),
                  "continuing_votes": active, "exhausted": exhausted}
        rounds.append(record)
        leader = max(tally.values())
        leaders = [c for c in continuing if tally[c] == leader]
        if active and 2 * leader > active:
            winner = leaders[0]
            record["result"] = f"{winner} has a majority of continuing votes"
            break
        if len(continuing) == 2 and len(leaders) == 2:
            tied_winners = leaders
            record["result"] = "the last two candidates are tied"
            break
        if len(continuing) == 1:
            winner = continuing[0]
            record["result"] = f"{winner} is the last candidate left"
            break
        low = min(tally.values())
        tied = [c for c in continuing if tally[c] == low]
        loser, how = tied[0], None
        if len(tied) > 1:
            pool = list(tied)
            for earlier in [history[0]] + history[1:-1]:
                fewest = min(earlier.get(c, 0) for c in pool)
                pool = [c for c in pool if earlier.get(c, 0) == fewest]
                if len(pool) == 1:
                    how = "first preferences" if earlier is history[0] else "earlier round"
                    break
            if len(pool) > 1:
                pool.sort(key=lambda c: lot[c])
                how = "tie_order (unresolved by the counts)"
            loser = pool[0]
            record["tie"] = {"between": tied, "eliminated": loser, "resolved_by": how}
        record["eliminated"] = loser
        continuing.remove(loser)
    result = {
        "candidates": candidates,
        "ballots": total,
        "winner": winner,
        "tied_winners": tied_winners,
        "rounds": rounds,
        "round_count": len(rounds),
        "elimination_order": [r["eliminated"] for r in rounds if "eliminated" in r],
    }
    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/vote_irv/1.0.0/manifest.json
curl -A axlr-docs/1.0 -O https://registry.underpassai.com/tools/vote_irv/1.0.0/vote_irv.py