Skip to content

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 an expected_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 05 (Approval: 0/1 only).
  • 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.
  • # comments are 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 ≈ this ballots: 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 written true / 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

  1. Run the file through the engine; confirm the printed winner(s).
  2. Fill expected_winners with exactly what the engine elected.
  3. Re-run — the _tabulated.txt sibling regenerates.
  4. From STARVote_LH_tabulation_engine/, run pytest tests/test_single_winner_positive.py — your file is discovered automatically (single-winner STAR cases with expected_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).