BetterVoting (BV) Tabulation Engine — technical description¶
Reverse-engineered from the source of Equal-Vote/bettervoting
(local clone: /Volumes/T7/Voting/BetterVoting/BV/bettervoting). This folder documents how
BetterVoting.com actually counts ballots — the same engine behind the live site and the
sandbox. All paths below are relative to that repo root.
Working with the source yourself — run it, contribute to it¶
This page explains how the engine works; these explain how to run and change it:
- Run BetterVoting locally — the two setups (dev server vs Docker) and the gotchas that cost time (AirPlay port, Keycloak,
crypto.randomUUID, shared-package rebuilds) → running BetterVoting locally. - Contribute a change — the fork → PR workflow and lessons from the first contribution (PR #1419 — split by a maintainer, its CSV fixes merged as #1428 with authorship intact, its JSON-v2 half deferred), since you don't have direct push to
Equal-Vote/bettervoting→ contributing a change. - Official docs & contribution guide — docs.bettervoting.com · Contribution Guide (first-time local setup · opening a PR · testing).
- Repo-side BV notes — BV website / UI to-do backlog · creating BV elections via the API · BV concept hub.
Where it lives¶
BetterVoting is a TypeScript monorepo with three packages: shared (domain types), backend (Express API + the tabulators), and frontend (React 17 / MUI results views). The engine is entirely server-side — the browser never tabulates; it only renders the JSON result object.
packages/shared/src/domain_model/ITabulators.ts # the input/output type contract
packages/backend/src/Tabulators/ # the engine
packages/backend/src/Controllers/Election/
getElectionResultsController.ts # production entry point (GET /API/ElectionResult/:id)
sandboxController.ts # sandbox entry point
packages/frontend/src/components/Election/Results/ # renders roundResults / summaryData / logs
How a count runs¶
getElectionResultsController authorizes the request, loads every ballot, and for each race
builds a candidate list (including approved write-ins) and a Cast Vote Record (rawVote[],
marks: candidateId -> score/rank). It then calls
shuffleCandidatesForRandomTiebreak(...) to fix a reproducible tieBreakOrder, dispatches to
the right tabulator via the VotingMethods map in VotingMethodSelecter.ts, and returns
{ election, results }.
Every tabulator has the same signature and is pure — no DB, no IO:
(candidates, votes, nWinners?, electionSettings?) => ResultsType
The full production pipeline in getElectionResultsController.ts:
- Authorize — an open election with
public_results: falsethrowsForbidden; otherwise the caller needscanViewPreliminaryResults. - Load ballots —
BallotModel.getBallotsByElectionID(id). - Build candidates per race —
race.candidatesplus any approved write-ins (ids frommakeWriteInCandidateId). - Build the CVR — each ballot's vote for the race becomes a
rawVote(marks: candidateId -> score/rank); write-ins are resolved to candidates by alias (trimLower), duplicates keep the first and warn,overvote_rank/has_duplicate_rankare carried along. - Shuffle —
shuffleCandidatesForRandomTiebreak(...)fixes a reproducibletieBreakOrder; the resulting id order is returned asperm. - Tabulate —
VotingMethods[voting_method](candidates, cvr, num_winners, settings). - Respond — random-tie logs get
tiebreak_candidate_namesattached,writeInDiagnosticsis added, and the response is{ election, results }(one entry per race).
The sandbox (sandboxController.ts) calls the same VotingMethods map with no persistence or auth gating.
Type contract (ITabulators.ts)¶
Inputs and outputs are shared types, so the engine emits the exact objects the UI renders:
rawVote{ marks: candidateId -> number|null, overvote_rank?, has_duplicate_rank? }candidate{ id, name, tieBreakOrder, votesPreferredOver, winsAgainst }genericResults{ votingMethod, elected, tied, other, roundResults[], summaryData, tieBreakType }roundResults{ winners, runner_up, tied, tieBreakType, logs[] }summaryDataalways carriesnOutOfBoundsVotes,nAbstentions,nTallyVotes(wherenVotes = nOutOfBoundsVotes + nAbstentions + nTallyVotes), plus method-specific fields.tieBreakType∈none | score | head_to_head | five_star | random.
The shared core (Util.ts)¶
Most methods lean on three helpers:
getSummaryDatafilters invalid ballots (bounds / abstention →nOutOfBoundsVotes,nAbstentions,nTallyVotes) and builds the cross-candidate matrices: pairwisevotesPreferredOver/winsAgainst, totalscore,copelandScore, andfiveStarCount. Acardinalvsordinalswitch decides whether "preferred" means higher score or lower rank (with rank 0 remapped to ∞).sortCandidates— generic multi-field sort that always falls back totieBreakOrderand usesfraction.jsfor exact comparisons.runBlocTabulator— generic bloc/sequential multi-winner driver used by STAR, Approval, Plurality and Ranked Robin.
The seven methods¶
| Method | File | Ballot | Core rule |
|---|---|---|---|
| STAR | Star.ts |
score 0–5 | Score to pick top-2, then pairwise automatic runoff. Tie cascade: score → head-to-head → five-star → random |
| STAR_PR | AllocatedScore.ts |
score 0–5 | Proportional (Allocated Score): elect the top scorer, spend a Hare quota V/nWinners of their strongest ballots, repeat; ties → random |
| Approval | Approval.ts |
approve 0/1 | Most approvals; random tiebreak |
| Plurality | Plurality.ts |
choose-one | Most votes; tracks overvotes; random tiebreak |
| Ranked Robin | RankedRobin.ts |
ranked | Highest Copeland (win +1, tie +0.5); 2-way tie → head-to-head; else random |
| IRV | IRV.ts |
ranked | Eliminate lowest, transfer to next choice, quota ⌊n/2+1⌋ |
| STV | IRV.ts |
ranked | IRV + Droop quota ⌊n/(nWinners+1)+1⌋ and fractional surplus transfer |
IRV/STV/STAR_PR use fraction.js (exact rationals) so surplus and weight transfers never
drift on floating point. Every round emits logs (i18n keys) that become the human-readable
count narrative in the results UI.
A few method specifics worth keeping:
- IRV/STV ballot exhaustion is tracked as
nExhaustedViaOvervote,nExhaustedViaSkippedRank, andnExhaustedViaDuplicateRank. Skipped-rank exhaustion is governed byexhaust_on_N_repeated_skipped_marksinElectionSettings; a ballot also exhausts when its top remaining rank equals itsovervote_rank. - STV surplus transfer — on election, surplus fraction
= (maxVotes − quota) / maxVotes; each of the winner's ballots is down-weighted by that fraction (floored to 5 dp) and redistributed. If the remaining candidates can fill every remaining seat, they are all elected. - STAR tie-breaks escalate to the most "extreme" protocol reached, reported via
setTieBreakin priority ordernone → score → head_to_head → five_star → random.
Deterministic "random" tie-breaks¶
shuffleCandidatesForRandomTiebreak.ts + tinyrand.ts make random tie-breaks reproducible:
a deterministic PRNG (TinyRand, language-agnostic) is seeded with
(rawVoteCount + hash(raceId)) >>> 0 and shuffles candidates once into a fixed order. The raw-vote
term re-rolls the order as new ballots arrive; the race hash keeps identical-candidate races from
sharing a tie order. electionCreateDate is passed but currently unused (reserved for versioning).
The drawn order is published, and you can recompute it. The shuffled id order ships in the
results as perm, with each candidate's index stored as tieBreakOrder; tied[] and
other[] are sorted by it, so the export carries the whole tiebreak sequence, not just the
winner. tools_adam/bv_replay_tiebreak.py is a stdlib-only Python port of TinyRand + this
shuffle — point it at a frozen _bv_export.json and it reproduces each race's perm from
(rawVoteCount, raceId) and diffs it against BV's:
python3 STARVote_LH_tabulation_engine/tools_adam/bv_replay_tiebreak.py <case>_bv_export.json
Confirmed live on BV2261 y2fbpc
(3 candidates, two races) and BV2262 2gvwr9
(9 candidates, a nine-deep order). Note what the seed inputs imply: the order is recorded but
not derivable — it depends on the ballot count and the race id, never on how anyone voted.
This is exactly the surface area of #1417 (closed as completed, 2026-07-15) — because the seed tracks the live ballot count, the tie-break lot order isn't fixed until polls close. What that issue got was the recording half (the order now ships in the export, via #1371); the pre-published lot numbers it asked for are still open as #1063.
Tests¶
Jest unit tests sit beside each tabulator (*.test.ts); run with
npm test -w @equal-vote/star-vote-backend. Root fixture: tabulator_test_cases.yaml.