Skip to content

Eight lines of CSV, eight questions

Level: reference · deep dive

One line: a bare CSV of scores can't say what it is, so the same eight lines have eight different readings — which is the whole argument for putting the election in the file.

This page works through the example posted in Equal-Vote/bettervoting#778 — "YAML File standard" (Feb 2025), because it is the tidiest statement of the problem this library's file format exists to solve. The ticket is about BetterVoting's upload/discussion format; the library is what that proposal turned into once it had an engine behind it.

The example

Eight ballot lines, exactly as a CSV upload might arrive:

ex1 — a bare CSV, from bettervoting#778
line 1:   0,1,0,0
line 2:   0,0,0,0
line 3:   0,1,0
line 4:   0,0,1,1
line 5:   0,0,0,0
line 6:   0,1,0
line 7:   0,0,0,0
line 8:   0, ,0,,

Before reading a single ballot you already can't answer: what method counts this? Plurality, Approval, STAR, IRV, Ranked Robin? Are those scores or ranks? Is it single- or multi-winner? And how many candidates are even in the race — three, or four?

Then the lines themselves:

Line What it looks like What it could mean
2, 7 0,0,0,0 A voter who deliberately scored everyone zero · a ballot that was left blank · a spoiled ballot · a file-format error
3, 6 0,1,0 — three values, not four The last candidate was left blank on purpose · a candidate-level abstention · a truncated row
4 0,0,1,1 Fine under Approval or STAR; under Plurality it's two votes in a one-vote race — an invalid ballot, or a mis-declared method
5 a value that is a space Is whitespace even legal? And is a blank an abstention, a spoiled ballot, or a deliberate non-mark?
8 0, ,0,, Five fields where there should be four. Blank, spoiled, abstained, or a stray comma?
all Are weighted rows allowed at all? (BetterVoting's ballot-multiplier request is #349.)

Every one of those questions is answerable — just not from the file. Which means it gets answered by whoever loads it, differently each time, and silently.

What the format has to carry

Two separate jobs, and a grid of numbers does neither:

  1. The election's configuration — the method, the seat count, the candidate names, the tie-break order. Without it the same grid tabulates to different winners, quite legitimately. (That the same ballots yield different winners under different methods isn't a bug; it's the point. Which is exactly why the file has to say which one it means.)
  2. The intent behind every zero — a real low score, a blank, a deliberate abstention and a spoiled ballot all tabulate as 0, but they are four different things, and a report that folds them together can't tell participation from rejection. → Abstention vs. a zero vs. "None of the Above"

The same eight lines, in the schema that settled

voting_method: STAR
num_winners: 1
ballots: |-
  A,B,C,D
  0,1,0,0   # line 1 - four real scores, nothing ambiguous
  ~,~,~,~   # line 2 - race abstention: skipped the whole race
  0,1,0,&   # line 3 - candidate-level abstention on D
  0,0,1,1   # line 4 - four real scores
  ?,?,?,?   # line 5 - spoiled ballot
  0,1,0,-   # line 6 - D left blank
  0,0,0,0   # line 7 - all zeros ON PURPOSE - this is NOT an abstention
  0,-,0,-   # line 8 - B and D left blank
lot_numbers: [A, B, C, D]
expected_winners: [B]

Nothing was added to the tally — every marker still counts as zero, and the winner is the same. What was added is the file's ability to say what it is: the method, the seats, the candidate names, the tie-break order, and one marker per cell recording why that cell is a zero. The # comments survive in the file, so a row explains itself where it sits.

Run it, and the count comes out with the intent intact — note the abstention line:

[Divergence from STAR]
  STAR     = B
  Approval = A   (differs from STAR)

--- STAR Voting Method (single winner) ---

[STAR Voting]
 Tabulating 8 ballots. Note: 2 of 8 ballots are marked as abstentions.
A,B,C,D
0,1,0,0
~,~,~,~
0,1,0,&
0,0,1,1
?,?,?,?
0,1,0,-
0,0,0,0
0,-,0,-
  ('-' = left blank / abstained; '0' = scored zero — both count as 0 stars.)

[STAR Voting: Scoring Round]
 The two highest-scoring candidates advance to the next round.
   B             -- 3 -- First place
   C             -- 1 -- Tied for second place
   D             -- 1 -- Tied for second place
   A             -- 0
 B advances, but there's a two-way tie for second.

[STAR Voting: Scoring Round: First tiebreaker]
 The candidate preferred in the most head-to-head matchups advances.
   C             -- 0 -- Tied for second place
   D             -- 0 -- Tied for second place
   Equal Support -- 8
 There's still a two-way tie for second.

[STAR Voting: Scoring Round: Second tiebreaker]
 The candidate with the most votes of score 5 advances.
   C             -- 0 -- Tied for second place
   D             -- 0 -- Tied for second place
 There's still a two-way tie for second.

*(Ties are resolved by choosing the tied candidate with the highest-priority official lot number.)*
    Lot-number priority order: ['A', 'B', 'C', 'D']

[Tiebreaker: Lot Number Priority]
  Tie among: ['C', 'D']
  Resolved: ['C'] (selected by lot-number priority).

[Lot-decided tie — rare]
  ⚠ The ballots did not break this tie: the deterministic rungs
    (pairwise / score, then five-star) all came back equal, so the
    pre-published LOT order chose among the tied candidates — the
    result here was set by lot, not by the votes. Usually the
    "dead rung": no tied candidate held a score-5 vote (five-star
    counts fives, not fours). Verify the tied candidates' 5-counts.

[STAR Voting: Automatic Runoff Round]
 The candidate preferred in the most head-to-head matchups wins.
   B             -- 3 -- First place
   C             -- 1
   Equal Support -- 4
 B wins.
   Runoff math:
     8  ballots cast
   − 4  Equal Support (no preference between the two finalists)
     ─
     4  voters with a preference  (majority = 3)
           B 3 (75%)  ·  C 1 (25%)

[STAR Voting: Winner — STAR Voting Method (single winner)]
 B

2 of 8 — the race abstention and the spoiled ballot. The all-zero ballot on line 7 stayed in the count, which is what its voter asked for.

→ The runnable file: csv_ambiguity_ex1_c4_b8.md · .yaml

Does an all-equal ballot count as an abstention?

Line 7 carries a claim in its comment — "this voter marked all candidates as zero (this is not abstention!)" — and the two engines disagree about it. The rule in the Larry Hastings engine is a ballot is an abstention when it is marked as one, never when it merely holds equal scores:

Ballot LH engine Counted as an abstention?
~,~,~,~ (race abstention) marker yes
&,&,&,& (candidate abstention) marker yes
?,?,?,? (spoiled) marker yes
%,%,%,% (spoiled and re-issued) marker yes
-,-,-,- (all blank) marker yes
0,0,0,0 (real zeros) scores no
3,3,3,3 · 5,5,5,5 (all-equal scores) scores no
0,-,0,- (zeros and blanks) mixed no

BetterVoting's rule went the other way: since #884 a ballot whose marks are all equal — all 3s, all 0s, a mix of 0s and nulls — is counted as an abstention. Which reading is right is a live disagreement, not a bug report; the whole cluster of tickets it feeds is catalogued in the BV abstain / blank / zero issue index.

Worth noticing either way: the argument is only possible because the format distinguishes a marker from a score. In the bare CSV, line 2 and line 7 are the same eight characters.

What changed between the 2025 sketch and the schema as it settled

The ticket proposed a nested container — election_parameters: holding an election id, an election title, abstention permissions, a count of races, a ballot format parameters block declaring the marker characters, and then race_1: with the method, the candidates and the ballots. Two and a half thousand tabulated ballots later, five things came out differently:

The 2025 sketch Where it landed Why
Nested election_parameters: → race_1: → ballots: Flat. Three required keys: voting_method, num_winners, ballots Indentation depth is where hand-written YAML goes wrong. The engine still recognizes the nested shape by name and prints the flat template — see below
candidates: [A,B,C,D] as its own key The header row of the ballot block is the candidate list One place to change a name, and the columns can't drift out of step with the header
single_or_multi_winner: single num_winners: 1 A seat count answers both questions and also carries "three seats"
ballot format parameters: declaring each marker character per file One fixed vocabulary, the same in every file: - blank · ~ race abstention · & candidate abstention · ? spoiled · % spoiled and re-issued A per-file alphabet means two files with the same characters can mean different things. % was added; ^ was dropped in favour of -
number of races + race_1:, race_2: One race per file, with a multi-race election held together by a shared filename prefix and a shared frozen export Each race is separately runnable, testable and linkable; the set is still indexed as one election in multi-race elections

Two of those are enforced rather than merely documented. Feed today's engine the nested sketch and it names the old schema in the error:

Abridged for the lesson — not verbatim engine output
Error: no 'ballots:' block found in 'feb2025_sketch.yaml'.
(If this is the old nested schema 'election_parameters -> races ->
 race_1 -> ballots', convert it to the flat form shown below.)

And the retired ^ marker has a deliberately-broken fixture keeping it retired:

Abridged for the lesson — not verbatim engine output
Error: STAR ballots use scores 0..5 (blank or a marker counts as 0).
  Offending ballot(s)  [Austin,Boston,Cairo]:
    ballot 1: 5, ^, 2   (invalid: Boston=^)
  Accepted marks: 0..5, blank, or a marker (-, ~, &, ?, %).

One idea from the sketch had no home anywhere — so it was filed upstream. race abstention allowed: and candidate abstention allowed: describe what the rules of the election permit, not what a voter did. This library records the ballot rather than the rulebook, so there's no key for it here; BetterVoting has no field for it either, which means an export can't distinguish "nobody abstained" from "abstaining was impossible." Filed 2026-08-06 as #1485 — Record the abstention policy on the race, scoped to the recording side because #699 already holds the admin-setting half. Report archived at bv_github_issue_abstention_policy.md.

The weights question

Are weights allowed? — yes, as a count prefix on a row:

ballots: |-
  Ann,Bob,Cal
  42 × 5,4,0
  17 × 0,3,5

Separators ×, :, x or X; house rule is that a weight must be ≥ 6, so a count is never misread as a 0–5 score. (Keep examples small anyway — a handful of individual voters teaches better than a hundred weighted ones. → Choosing voter counts.)

Filed upstream from this page

Reading #778 back through eighteen months of running the format turned up two gaps that were worth taking to BetterVoting rather than only writing down here. Both were filed 2026-08-06, both are narrowly scoped, and both keep their full report archived in the repo:

Issue The gap Archive
#1485 An export can't distinguish "nobody abstained" from "abstaining was impossible" — the policy that governed the ballots isn't in the artifact the ballots ship in bv_github_issue_abstention_policy.md
#1486 The same gap at the upload end: the CVR parser #810 plans to reuse is rank-only and takes the method from the filename, so it can't express what the export writes bv_github_issue_bulk_upload_format.md

The second one carries an argument worth repeating here, because it isn't ours: BetterVoting already keeps voting_method (and the IRV exhaustion rule) in a YAML sidecar, public_archive_settings.yaml, looked up by filename when a CVR is imported — "used to infer election settings when elections are uploaded." The conclusion that a ballot CSV can't carry the election's configuration is already drawn in their own code; the only question left is whether that information lives in a lookup table keyed on the filename, or in the file.