YAML Test Case — Authoring Template¶
A fill-in guide for contributing an election test case to this library. One YAML file = one complete, reproducible test: the ballots, how to count them, and the winner(s) the engine must produce.
The 30-second version¶
Only three keys are required. Copy, fill in, done:
voting_method: STAR # STAR | Approval | RankedRobin | RCV_IRV | bloc | sss | rrv | allocated
num_winners: 1 # how many seats to fill (1 = single-winner)
ballots: |- # header row of candidate names, then one row per voter
Ann,Bob,Cal
5,4,0
3,5,2
0,3,5
expected_winners: # not required to tabulate — but REQUIRED for a test case:
- Bob # the pytest suite discovers this key and checks it
Run it:
python STARVote_LH_tabulation_engine/starvote_larry_hastings.py your_file.yaml
The engine prints the annotated count to the screen and writes a full-detail _tabulated.txt sibling. If your expected_winners matches what the engine elects, the file is a valid test case.
The full template (copy & fill in)¶
Everything below ballots: in the minimal version still applies; this adds the optional context and display fields, each with its meaning in a comment. Delete any optional block you don't need.
election_title: "Short human title — what this case shows"
scenario_description: |-
2–6 lines of plain language: what the election demonstrates, what to look
for in the result. Hidden on screen by default, but always included in the
_tabulated copy and on the generated case page.
voting_method: STAR # see the method table below
num_winners: 1
ballots: |-
Ann,Bob,Cal
5,4,0
3,5,2
0,3,5
# --- optional -------------------------------------------------------------
lot_numbers: [Cal, Ann, Bob] # official tie-break order (highest priority
# first). Only consulted when the deterministic
# tiebreakers can't separate candidates. Omit it
# and the engine uses ballot column order.
eligible_voters: 100 # registered electorate (for turnout reporting)
quorum: 25 # minimum ballots for a valid election
blocs: # candidate groupings for the vote-splitting check
Allies: [Ann, Cal]
# --- the answer key (required for a test case) -----------------------------
expected_winners:
- Bob # must list exactly num_winners names
# file: <your_file_name>.yaml
What the on-screen report shows (no options: needed)¶
The on-screen report is what the engine prints on screen as it tabulates, and its content is decided by the engine's built-in house defaults — your case file should not set an options: block (they were removed repo-wide 2026-08-09). The defaults show the finalists-only Preference Matrix (auto-omitted for multi-winner and 2-candidate races), the compact self-reconciling runoff line, collapsed ballots, and short headers; the [Divergence from STAR] block auto-prints whenever methods disagree. The saved _tabulated.txt mirror always shows everything — full grid, Condorcet lines, score distribution, the runoff funnel — and --full on the command line puts that same render on screen. Every divergent case across the whole library is auto-catalogued in the divergence-review ledger.
Display never changes the winner or the numbers. The per-option reference (for the rare deliberate override): LH reporting options.
Field reference¶
| Key | Required? | What it is |
|---|---|---|
voting_method |
recommended (defaults to STAR with a NOTE) | How to count — see table below. |
num_winners |
recommended (defaults to 1 with a NOTE) | Seats to fill. Multi-winner needs a multi-winner method. |
ballots |
yes | The ballot grid (see rules below). Must be a literal block: write ballots: \|- and indent every row. |
expected_winners |
yes, for a test case | Top-level list of winner name(s). This is the key the automated test suite discovers and asserts. |
expected_results |
optional | Richer answer key — per-round scores, the runoff, turnout/quorum figures — beyond the bare winner. Emitted by the BetterVoting converter; the engine reads it the same way. |
election_title |
optional | One-line human title, printed as the report header. |
scenario_description |
optional | Printable context — the teaching text (shown only if show_description: true; always in _tabulated). |
election_description |
BV-backed cases only | The BetterVoting election's own live blurb (results link, agreement note), kept distinct from the teaching text above. Don't use it as a synonym for scenario_description — that older usage was normalized away 2026-08. |
video_script |
optional (discouraged) | Free-text authoring note; never shown on screen. Keep it a terse factual note if used at all — do not write scripted "SAY / HOW / WHY / POINT OUT / TRANSITION" presenter copy (that style was removed repo-wide, 2026-07-23). |
options |
don't use (legal but reserved) | On-screen display overrides. Case files carry none since 2026-08-09 — the engine's defaults are the house style; the key survives for the option-demo files and rare special renders. The _tabulated copy ignores it and always shows everything. |
lot_numbers |
optional | Official tie-break (lot) order, highest priority first. |
eligible_voters, quorum |
optional | Turnout / minimum-participation reporting. |
blocs |
optional | Named candidate groups for the vote-splitting analysis. |
paradoxes |
optional, but the paradox index sees nothing else | List of paradox tags (valid tags: the pages under 07_Concepts/voting_paradoxes/). The sole input to the auto-generated voting-paradoxes index (build_paradox_index.py) — a case demonstrating a paradox without this key is invisible there. |
bv_election_id, bv_results_url, bv_test_id |
required for BV-backed cases | BetterVoting provenance: the live election id, its /results URL, and the assigned Test ID. These feed the auto-generated BV registry (build_bv_registry.py) and the case page's live-results lead line. Hand-written LH-only cases omit all three. |
A key outside this table fails the schema lint (check_repo_hygiene.py /
tests/test_yaml_keys.py) with a did-you-mean hint — that lint is what keeps a
typo like expected_winers: from silently removing a case from the test suite.
Adding a genuinely new key means updating both this table and ELECTION_KEYS
in the lint.
Richer, converter-produced files. Elections imported from BetterVoting (via the JSON→YAML converter) carry a fuller shape than a hand-written case: candidates as objects with explicit IDs (not just a name row), more
election_*context, and anexpected_results:block that pins per-round detail. You don't hand-write these — the converter emits them — but the flat, hand-authored fields above are all you need to write a case yourself.
voting_method values¶
| Value | Ballot | Counts as |
|---|---|---|
STAR |
scores 0–5 | Score Then Automatic Runoff (single-winner default) |
Approval |
0/1 only |
Most approvals wins |
RankedRobin (aka RCV_RR, Copeland, Consensus) |
scores 0–5 (read as an order) | Head-to-head round robin; best win–loss record wins |
RCV_IRV |
ranked, A>C>B |
Instant runoff (elimination rounds) |
bloc |
scores 0–5 | Bloc STAR (multi-winner, majoritarian) |
sss |
scores 0–5 | Sequentially Spent Score (proportional) |
rrv |
scores 0–5 | Reweighted Range Voting (proportional) |
allocated |
scores 0–5 | Allocated Score (proportional) |
A file whose ballots contain ranked A>C>B lines routes to RCV-IRV automatically. (Rank notes inside # comments are ignored.)
Ballot grid rules¶
- Row 1 = candidate names, comma-separated. Every voter row must have the same number of columns.
- Scores are
0–5(Approval:0/1only). - Markers — all tabulate as 0 but are reported honestly:
| Marker | Meaning |
|---|---|
- |
blank / left unmarked |
~ |
race-level abstention (skipped the whole race) |
& |
candidate-level abstention |
? |
spoiled ballot |
% |
spoiled and re-issued |
- Weighted (grouped) rows: prefix a count —
42 × 0,3,5(separators×,:,x,X). House rule: weights must be ≥ 6 so a count is never mistaken for a 0–5 score. # commentsare allowed at the end of any ballot row — use them to say what each ballot demonstrates.- Other ballot formats. These same rows in the election-methods ABIF interchange format look like
Allie/5 =Billy/5 >Candace/4— one dense self-describing line per ballot; decoded and compared there. (ABIF ≈ thisballots:block; the rest of the file stays ours.)
House style (so your case fits the library)¶
- Keep it small. The fewest ballots that make the point — a handful of individual voters beats 100 weighted ones. Scale up only if percentages or proportional seats genuinely need it. See TIPS_choosing_voter_counts.md.
- Candidate names: common, easy to say, distinct initials in A, B, C… order (Ann, Bob, Cal…), phonetically distinct, themed if you like. Use a fresh cast per scenario; keep the same cast across a matched pair of files.
- File name:
NN<letter>_c<candidates>_b<ballots>_short-description.yaml— e.g.03c_c6_b8_style-gallery.yaml(6 candidates, 8 ballots). - Booleans in
options:are writtentrue/false(long form). - Only feature the section your case teaches — start from the options block in the template and flip on just one heavier section (
show_score_counts,show_irv, full matrix…) if the case is about it.
What happens when you get it wrong (that's fine)¶
The engine is the validator — it fails with a plain-language message, never a traceback: bad YAML, missing ballots:, uneven column counts, out-of-range scores, invalid characters, ranked ballots under a score method, method/seat mismatches. Fix and re-run. (expected_winners itself is checked by the pytest suite, not the engine — a wrong answer key shows up as a test failure.)
Submitting¶
- Run the file through the engine; confirm the printed winner(s).
- Fill
expected_winnerswith exactly what the engine elected. - Re-run — the
_tabulated.txtsibling regenerates. - From
STARVote_LH_tabulation_engine/, runpytest tests/test_single_winner_positive.py— your file is discovered automatically (single-winner STAR cases withexpected_winners).
Live examples to crib from: 01_STAR — single-winner STAR Voting — start with bv2184_fyy886_lunch_vote.yaml (the clean beginner example) and 03c_c6_b8_style-gallery.yaml (every optional field in use).