Skip to content

lzear/votes

Repository files navigation

votes

version npm bundle size language downloads last commit license CI 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'] ]

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 Run the tiebreaker on all candidates instead of the tied subset. Useful when a method's relative ranking is more meaningful globally.
stable false After one pass, recurse into any remaining sub-ties until no further progress can be made.
Any other prop Passed through to the method constructor (e.g. rng for random methods).

Method type is auto-detected: matrix methods (Copeland, Schulze, …) receive a pairwise matrix; ballot methods receive the filtered ballots; candidates-only methods (RandomCandidates) receive just the candidate list.

Round trace

computeRounds() returns detailed per-round results:

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

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

Election: chaining rankers

Election chains pre-built ranker instances. The first provides the primary ranking; subsequent rankers refine any remaining ties.

import {
  Election,
  InstantRunoff,
  Schulze,
  matrixFromBallots,
  tb,
  Copeland,
} from 'votes'

const election = new Election({
  rankers: [
    new InstantRunoff({ candidates, ballots, tieBreakers: [tb(Copeland)] }),
    new Schulze(matrixFromBallots(ballots, candidates)),
  ],
})

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

Each StepResult records rankerName, before, after, and optionally rounds / scores from that step.

Voting systems

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

⚠️ Kemeny runs in O(n!) time — impractical beyond ~8 candidates.

Matrix-input methods take the output of matrixFromBallots(ballots, candidates).

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.

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

new Borda({ candidates, ballots }).deTie()
// same as ranking() but each tied tier is re-ranked with Borda on that subset

Utilities

import { matrixFromBallots, rngGenerator } from 'votes'

// Build a pairwise matrix from ballots
const matrix = matrixFromBallots(ballots, candidates)

// Seeded RNG for reproducible random methods
const rng = rngGenerator('my-seed')
new RandomCandidates({ candidates, rng })

Condorcet election format

Parse and serialize the Condorcet Election Format (.blt-style text 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.

Packages

 
 
 

Contributors

Languages