Skip to content
lzearPublic

Repository files navigation

votes

version npm bundle size language downloads last commit license CI Codacy grade Codacy coverage Website

TypeScript library of ranked voting methods, published to NPM.

Install

yarn add votes
# or: npm install votes

Basic usage

import { Borda } from 'votes'

const borda = new Borda({
  candidates: ['Lion', 'Bear', 'Sheep'],
  ballots: [
    { ranking: [['Lion'], ['Bear'], ['Sheep']], weight: 4 },
    { ranking: [['Sheep'], ['Bear'], ['Lion']], weight: 3 },
    { ranking: [['Bear', 'Sheep'], ['Lion']], weight: 2 },
  ],
})

borda.scores()
// { Lion: 17, Bear: 19, Sheep: 18 }

borda.ranking()
// [ ['Bear'], ['Sheep'], ['Lion'] ]

A ballot ranks candidates in tiers: [['Bear', 'Sheep'], ['Lion']] means Bear and Sheep tied first, Lion last. weight is how many voters cast that ballot (default 1). Candidates a ballot leaves unranked join it as one tied bottom tier by default; pass unrankedLast: false to score only expressed preferences (unranked candidates then earn nothing from that ballot). Blank ballots, ranking none of the candidates still in the race, don't count as cast; pass countBlank: true to count them toward absolute majorities (AbsoluteMajority, Coombs, TwoRoundRunoff), in RandomDictator's draw and, with unrankedLast, as worst grades in MajorityJudgment.

Every method takes this same { candidates, ballots, unrankedLast, countBlank } input. Matrix methods (see the table below) also take a pairwise matrix { candidates, array }, such as matrixFromBallots(ballots, candidates).

Tiebreakers

Round-based methods (InstantRunoff, Baldwin, Coombs, Nanson, TwoRoundRunoff, BottomTwoRunoff) accept a tieBreakers array. When multiple candidates tie for last place in a round, tiebreakers are applied in order until one candidate can be singled out.

import {
  InstantRunoff,
  RandomCandidates,
  Borda,
  Copeland,
  tb,
  rngGenerator,
} from 'votes'

// Simple: pass constructors directly
new InstantRunoff({ candidates, ballots, tieBreakers: [Borda, Copeland] })

// tb() lets you pass extra constructor options
const rng = rngGenerator('seed')
new InstantRunoff({
  candidates,
  ballots,
  tieBreakers: [
    Copeland, // first try Copeland
    tb(Borda, { full: true }), // run Borda on ALL candidates, not just the tied subset
    tb(Borda, { stable: true }), // recurse into sub-ties until stable
    tb(RandomCandidates, { rng }), // last resort: random with seeded RNG
  ],
})

tb(Ctor, opts?) options

Option Default Description
full false Rank the tie by the method's ranking of all candidates instead of re-running it on the tied subset.
stable false After one pass, recurse into any remaining sub-ties until no further progress can be made.
label class name Name in traces (TieBreakStep.name, StepResult.name).
Any other prop — Passed through to the method constructor (e.g. rng for random methods).

Round trace

rounds() returns detailed per-round results:

const rounds = new InstantRunoff({
  candidates,
  ballots,
  tieBreakers: [Borda],
}).rounds()

rounds[0]
// {
//   index: 0,
//   candidates: ['a', 'b', 'c'],
//   scores: { a: 5, b: 3, c: 3 },
//   qualified: ['a', 'b'],
//   eliminated: ['c'],          // resolved by tiebreaker
//   tieBreakSteps: [{
//     index: 0,                 // first tiebreaker
//     name: 'Borda',
//     candidates: ['b', 'c'],   // tied candidates
//     ranking: [['b'], ['c']],  // tiebreaker's verdict
//     resolved: ['b'],          // promoted out of tie
//     remaining: ['c'],         // eliminated
//   }]
// }

Some methods add method-specific detail as info: Coombs reports whether a round was resolved by majority or elimination (CoombsInfo), Nanson reports the Borda average used as elimination cutoff (NansonInfo).

Election: chaining methods

Election chains methods on one input. The first ranks the candidates; each next one re-ranks the tiers still tied, taking the same entries and tb() options as tieBreakers.

import { Election, InstantRunoff, RandomCandidates, Schulze, tb } from 'votes'

const election = new Election({
  candidates,
  ballots,
  methods: [InstantRunoff, Schulze, tb(RandomCandidates, { rng })],
})

election.ranking() // final ranking after all tie-breaking
election.result() // { ranking, steps: StepResult[] }

Each StepResult records name, before, after, and runs: the method's ranking of each tied tier, with its scores or rounds.

Iterated ranking

ranking() is each method's own full ranking. iteratedRanking() instead fills places by repeated wins: run the method, take the winning tier, re-run the method without the placed candidates, and so on. The two differ whenever a method's full ranking disagrees with how it ranks subsets — e.g. instant runoff orders losers by elimination time.

const irv = new InstantRunoff({ candidates, ballots })
irv.ranking() // losers ordered by elimination time
irv.iteratedRanking() // 2nd place = winner of a re-run without the winner

new Election({ candidates, ballots, methods }).iteratedRanking() // re-runs the chain per place

Under the hood it uses restrict(candidates), which every method exposes to re-run itself on a subset of candidates.

Voting systems

Method Class Input
Absolute majority AbsoluteMajority ballots
Approval voting Approval ballots
Baldwin method Baldwin ballots
Borda count Borda ballots
Bottom-two-runoff BottomTwoRunoff ballots
Coombs' method Coombs ballots
Copeland's method Copeland ballots or matrix
First-past-the-post FirstPastThePost ballots
Instant-runoff (IRV) InstantRunoff ballots
Kemeny–Young ⚠️ Kemeny ballots or matrix
Majority judgment MajorityJudgment ballots as grades
Maximal lotteries MaximalLotteries ballots or matrix
Minimax Condorcet Minimax ballots or matrix
Minimax-TD MinimaxTD ballots or matrix
Nanson method Nanson ballots
Random candidate RandomCandidates —
Random dictator RandomDictator ballots
Randomized Condorcet RandomizedCondorcet ballots or matrix
Ranked pairs RankedPairs ballots or matrix
Schulze method Schulze ballots or matrix
Smith's method Smith ballots or matrix
Two-round runoff TwoRoundRunoff ballots

Approval approves the candidates in each ballot's first tier.

Baldwin and Nanson take Borda scores from the pairwise matrix: the same as Borda's when ballots rank everyone, and a Condorcet winner always wins.

⚠️ Kemeny runs in O(n·2ⁿ) time and memory — slow beyond ~20 candidates, and throws beyond 30.

MajorityJudgment reads a ballot's tiers as grades, best first (grades, default 6), and breaks ties one vote at a time: give it whole weights.

BottomTwoRunoff always prepends tb(FirstPastThePost) to tieBreakers — that FPTP step is the head-to-head runoff mechanism, not a fallback. It will appear as the first entry in tieBreakSteps. User-supplied tieBreakers fire after it only if the head-to-head itself ties.

Random methods expose probabilities(), each candidate's chance to win, instead of scores(), and draw their ranking once per instance.

Schulze also exposes strengths() — the beatpath strength matrix its scores derive from.

RankedPairs locks equal-strength pairs simultaneously by default (ties are preserved rather than order-dependent). Pass edgeSorter — e.g. the exported byTotalParticipation — to lock them in its order like canonical Tideman; pairs it cannot tell apart still lock together.

Every method also exposes deTie(), which recursively resolves ties by re-running the same method on each tied subset:

new Borda({ candidates, ballots }).deTie()
// ranking(), each tie re-ranked by Borda on it, until no tie splits further

Utilities

matrixFromBallots(ballots, candidates) // array[i][j]: weight ranking i above j
makeAntisymmetric(matrix) // margins: array[i][j] - array[j][i]
smithSet({ candidates, ballots }) // smallest set beating everyone outside it
toWeightedBallots([
  [['a'], ['b']],
  [['a'], ['b']],
]) // one ballot per ranking
groupBallots(ballots) // equal ballots merged, heaviest first
normalizeBallots(ballots, candidates) // unknown and repeated candidates dropped
scoresToRanking({ a: 2, b: 1 }) // [['a'], ['b']]
rngGenerator('my-seed') // seeded RNG for random methods' `rng`

Condorcet election format

Parse and serialize the Condorcet Election Format (.cvotes files):

import {
  parseCondorcetElectionFormat,
  stringifyCondorcetElectionFormat,
} from 'votes'

const { candidates, ballots } = parseCondorcetElectionFormat(`
#/Candidates: A; B; C
A > B > C * 3
B > C > A * 2
`)

const text = stringifyCondorcetElectionFormat({ candidates, ballots })

Documentation

Contributing

Contributions, issues and feature requests are welcome. Feel free to check the issues page.

Releases

Packages

Used by

Contributors

Languages