AXLR / Catalog / politics_civics

vote_borda

Score ranked ballots by the Borda count (n-1 to 0, or n to 1) or the Dowdall system (1, 1/2, 1/3, ...), in exact fractions. Ballots may tie candidates or leave some unranked; tied and unranked candidates score by the lowest position, the average, or the modified Borda count.

Install

/tools install vote_borda
/tools install vote_borda@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_borda 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_borda; 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 ranking (list of names; an inner list is a tie) or {ranking, count}. items ≥ 1
candidatesarray of stringnoEvery candidate, including any no ballot ranks; default the names on the ballots
variantstringno"borda"one of "borda", "borda_n", "dowdall"
partialstringno"lowest"Scoring of tied and unranked candidates. one of "lowest", "average", "modified"
Input schema (JSON)
{
  "type": "object",
  "required": [
    "ballots"
  ],
  "additionalProperties": false,
  "properties": {
    "ballots": {
      "type": "array",
      "minItems": 1,
      "description": "Each a ranking (list of names; an inner list is a tie) or {ranking, count}",
      "items": {
        "anyOf": [
          {
            "type": "array",
            "items": {
              "anyOf": [
                {
                  "type": "string"
                },
                {
                  "type": "array",
                  "items": {
                    "type": "string"
                  }
                }
              ]
            }
          },
          {
            "type": "object",
            "required": [
              "ranking"
            ],
            "additionalProperties": false,
            "properties": {
              "ranking": {
                "type": "array",
                "items": {
                  "anyOf": [
                    {
                      "type": "string"
                    },
                    {
                      "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"
    },
    "variant": {
      "type": "string",
      "enum": [
        "borda",
        "borda_n",
        "dowdall"
      ],
      "default": "borda"
    },
    "partial": {
      "type": "string",
      "enum": [
        "lowest",
        "average",
        "modified"
      ],
      "default": "lowest",
      "description": "Scoring of tied and unranked candidates"
    }
  }
}

Example

Input

{
  "variant": "dowdall",
  "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)

{
  "scores": {
    "Nashville": "57.666667",
    "Memphis": "56.5",
    "Chattanooga": "50.5",
    "Knoxville": "43.666667"
  },
  "winners": [
    "Nashville"
  ]
}

Files

The command the install card registers is python3 vote_borda.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_borda.py · sha256 3c6cd802dce2172487b1e230c7b375eba2959118babd93c6c734379c10765321
#!/usr/bin/env python3
"""Borda count and Dowdall scores over ranked ballots, exact.

Input (JSON on stdin):
  ballots     [{"ranking": ["A", ["B", "C"], "D"], "count": 3}, ...] or bare rankings;
              an inner list is a tie; candidates a ballot leaves out are unranked
  candidates  ["A", "B", ...]  optional; default every name on a ballot, in first-seen order
  variant     "borda" (n-1 ... 0, default) | "borda_n" (n ... 1) | "dowdall" (1, 1/2, 1/3 ...)
  partial     how tied and unranked candidates score:
              "lowest"   (default) each candidate in a tie gets the points of the lowest
                         position the tie spans; unranked candidates tie at the bottom
              "average"  each gets the mean of the points of the positions the tie spans
              "modified" unranked candidates get 0 and the k ranked candidates are scored
                         k ... 1 (modified Borda count; ties among them averaged); Borda only
              Under Dowdall unranked candidates always score 0.
"""
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")
        groups = []
        for item in ranking:
            group = item if isinstance(item, list) else [item]
            if not group or not all(isinstance(c, str) and c for c in group):
                fail(f"ballots[{i}] ranks something that is not a candidate name")
            groups.append(group)
            for c in group:
                if c not in seen:
                    seen.append(c)
        flat = [c for g in groups for c in g]
        if len(flat) != len(set(flat)):
            fail(f"ballots[{i}] ranks a candidate twice")
        parsed.append((groups, 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 ballot_points(groups, candidates, variant, partial):
    n = len(candidates)
    ranked = [c for g in groups for c in g]
    unranked = [c for c in candidates if c not in ranked]
    size = len(ranked) if partial == "modified" else n

    def points(i):
        if variant == "dowdall":
            return Fraction(1, i + 1)
        if partial == "modified":
            return Fraction(size - i)
        return Fraction(n - 1 - i) if variant == "borda" else Fraction(n - i)

    scores = {}
    start = 0
    tiers = [(g, False) for g in groups] + ([(unranked, True)] if unranked else [])
    for group, is_unranked in tiers:
        span = [points(i) for i in range(start, start + len(group))]
        if is_unranked and (variant == "dowdall" or partial == "modified"):
            value = Fraction(0)
        elif partial == "lowest":
            value = span[-1]
        else:
            value = sum(span) / len(span)
        for c in group:
            scores[c] = value
        start += len(group)
    return scores


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", "variant", "partial"}
    if extra:
        fail(f"unknown fields: {sorted(extra)}")
    variant = request.get("variant", "borda")
    partial = request.get("partial", "lowest")
    if variant not in ("borda", "borda_n", "dowdall"):
        fail("variant must be borda, borda_n or dowdall")
    if partial not in ("lowest", "average", "modified"):
        fail("partial must be lowest, average or modified")
    if partial == "modified" and variant == "dowdall":
        fail("partial modified applies to the Borda variants only")
    candidates, ballots = read_ballots(request)
    totals = {c: Fraction(0) for c in candidates}
    for groups, count in ballots:
        for c, value in ballot_points(groups, candidates, variant, partial).items():
            totals[c] += value * count
    order = sorted(candidates, key=lambda c: (-totals[c], candidates.index(c)))
    ranking, i = [], 0
    while i < len(order):
        tier = [c for c in order if totals[c] == totals[order[i]]]
        ranking.append(tier if len(tier) > 1 else tier[0])
        i += len(tier)
    top = totals[order[0]]
    print(json.dumps({
        "variant": variant,
        "partial": partial,
        "candidates": candidates,
        "voters": sum(count for _, count in ballots),
        "scores": {c: fmt(totals[c]) for c in order},
        "exact": {c: str(totals[c]) for c in order},
        "ranking": ranking,
        "winners": [c for c in order if totals[c] == top],
    }, 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_borda/1.0.0/manifest.json
curl -A axlr-docs/1.0 -O https://registry.underpassai.com/tools/vote_borda/1.0.0/vote_borda.py