Bug: SSS silently discards zero-score ballots whenever any ballot exhausts in the same round¶
STATUS: FIXED in this fork (2026-08-09; found 2026-08-08) — still open upstream (larryhastings/starvote#19, filed 2026-08-09; latest release 2.1.6 affected at HEAD). Distinct from #17 (the verbosity bug, fixed in this fork — see BUG_sss_verbosity.md), but the two interact: fixing #17 by dedenting the allocation block, as this fork did, promotes THIS bug from the verbose-only path to every run — which is how the fork carried it at all verbosities until the sprint census surfaced it. Regression guard:
tests/test_sss_zero_score_ballots.py; ledger row: LH_ENGINE_CHANGES.md §1; teaching case: the two bullet voters.Repo impact: none. All 5 SSS case files were checked — the trigger condition never fires in any of them, and the full verbose reports are byte-identical under the fix (so no
_tabulatedmirror would change). Verified 2026-08-09; details below.
Summary¶
In sequentially_spent_score(), the ballot-allocation step rebuilds the remaining-ballots list, but the remaining_decorated_ballots.append(t) sits inside the if score: branch — only ballots that scored the round's winner above 0 are ever appended. The swap to the rebuilt list happens whenever allocated is nonzero, i.e. whenever any ballot exhausts to 0 stars. Consequence: in any SSS round where at least one supporter exhausts, every ballot that scored the winner 0 is silently removed too — full remaining budget and all. Those voters spent nothing and, by the method's definition, keep their stars for later rounds; deleting them changes who wins later seats.
The trigger is narrow but real: a ballot exhausts only when its raw score for the winner is 5 and the round has no surplus (weighted spend = raw × stars/5 ≤ stars, with equality exactly when raw = 5 and the reduction ratio is 1). When it fires, it can flip the committee.
Environment¶
- This fork:
starvote/__init__.py,sequentially_spent_score(), allocation loop at ~L2704–2747 — the bug manifested at every verbosity (the fork's #17 fix dedented the whole block out of the verbosity guard, structure preserved) until the 2026-08-09 fix below. - Upstream starvote 2.1.6 (current HEAD, verified 2026-08-09): same loop at ~L2387–2428, still nested inside
if options.verbosity:— so upstream manifests this bug atverbosity>=1and the #17 bug (no spending at all) atverbosity=0. - Method:
sss(Sequentially Spent Score), multi-winner.
Reproduction¶
7 voters, 3 candidates, 2 seats. Runs against either engine:
import starvote
ballots = [
{'Amy': 0, 'Bo': 5, 'Cy': 3},
{'Amy': 5, 'Bo': 0, 'Cy': 0}, # Amy bullet voter — scores Cy 0
{'Amy': 0, 'Bo': 3, 'Cy': 4},
{'Amy': 2, 'Bo': 2, 'Cy': 5}, # exhausts in round 1 (raw 5, no surplus)
{'Amy': 5, 'Bo': 1, 'Cy': 0}, # scores Cy 0
{'Amy': 0, 'Bo': 0, 'Cy': 4},
{'Amy': 2, 'Bo': 2, 'Cy': 1},
]
print(sorted(starvote.election(starvote.sss, ballots, seats=2, verbosity=1)))
(The YAML form lived at the STAR-PR sprint's scratchpad as exp_census/cli/quirk_sss.yaml. The profile is now a permanent LH-only teaching case — two_bullet_voters_sss.yaml, with the vote-unitarity lesson at the two bullet voters.)
Actual output (this fork, any verbosity; upstream at verbosity>=1)¶
['Bo', 'Cy']
Expected output (textbook SSS)¶
['Amy', 'Cy']
Upstream at verbosity=0 happens to also print ['Amy', 'Cy'] — but only because issue #17 means no stars are spent at all there; round 2 is just bloc score voting minus Cy. Same answer, wrong reasons, and a second live demonstration that upstream's winners depend on the logging flag.
Hand-trace of the quirk round¶
Hare score quota = 7·5/2 = 17½. Round 1 scores: Cy 17, Amy 14, Bo 13 → Cy seated. 17 < 17½ → no surplus, reduction ratio 1, so each ballot spends its full Cy score:
| Ballot (Amy,Bo,Cy) | Cy score | stars 5 → | fate in the loop |
|---|---|---|---|
| 0,5,3 | 3 | 2 | reweighted to 2/5, appended |
| 5,0,0 | 0 | 5 (untouched) | never appended (if score: skipped) |
| 0,3,4 | 4 | 1 | reweighted to 1/5, appended |
| 2,2,5 | 5 | 0 | exhausted → allocated = 1 |
| 5,1,0 | 0 | 5 (untouched) | never appended |
| 0,0,4 | 4 | 1 | reweighted to 1/5, appended |
| 2,2,1 | 1 | 4 | reweighted to 4/5, appended |
allocated is nonzero, so decorated_ballots = remaining_decorated_ballots — and the two Amy bullet voters vanish with 5 unspent stars each. Round 2 with the truncated list: Bo 4⅕ vs Amy 1⅗ → Bo. Round 2 with the zero-score ballots kept (textbook): Amy 11⅗ vs Bo 5⅕ → Amy. Both arithmetic lines were verified against actual engine runs (current engine → Bo; patched scratch copy → Amy, matching to the fraction).
Counterfactual confirming the trigger: when no ballot exhausts, allocated is 0, the rebuilt supporters-only list is discarded, and the zero-score ballots survive by luck. The bug needs the exhaustion.
Why "keep them" is the correct behavior¶
The electowiki SSS definition (the method's canonical write-up; Keith Edmonds' method, advocacy-adjacent source but this is a mechanics question, where it's the clearest): the procedure's spend step is "Each voter spends the amount of stars they gave the elected candidate" — a voter who gave 0 spends 0, and "Voters cannot influence subsequent rounds more than the stars they have remaining" — these voters have all 5 remaining. That is the whole point of Vote Unitarity: influence is spent only in exchange for representation gained.
Decisively, the reference implementation on that page never removes any ballot: it keeps the full ballot frame for the whole tabulation and updates budgets with ballot_weight = (ballot_weight - score_spent).clip(0.0, 1.0) — a zero-score voter's score_spent is 0 and their weight is untouched. (Removing exhausted ballots, as the engine does, is a legitimate optimization — weight-0 ballots contribute nothing. The defect is only the removal of the zero-score non-supporters riding along with them.)
BetterVoting offers no SSS tabulator (its score-PR is Allocated Score only — packages/backend/src/Tabulators/ has no SSS), so there is no BV cross-check to run; electowiki's definition and reference code are the arbiters here.
How often it matters¶
From the 2026-08-08 STAR-PR sprint census (2-seat committees, reimplementations validated against the LH engine on 1,800 profile/rule pairs with 0 mismatches): the quirk changed the elected committee in 25 of 1,783 tie-free impartial-culture profiles (~1.4%), and in 0 of ~2,000 spatial-culture (1D/2D) profiles. Rare, systematic, and biased in a describable direction: it strips unspent budget from blocs that abstained on an early winner — exactly the blocs SSS is designed to protect into later rounds.
Repo impact — checked, none¶
All 5 repo SSS cases (coop_board_scores_sss, three_neighbors_sss, two_officers_sss, 02b_c5_b63_proportional-sss, 03b_star_pr_3seats) were run through an instrumented reimplementation and through a patched scratch copy of the engine (2026-08-09):
- the trigger (a supporter exhausting while a zero-score ballot is still alive) never fires in any of them — every allocation round in those files has a surplus, so no ballot ever exhausts;
- winners are unchanged, and the full
verbosity=2reports are byte-identical under the fix — so no_tabulatedmirror or generated page would shift.
The fix (applied 2026-08-09)¶
Dedent the two append lines out of if score: (fork lines ~2742–2743), so non-supporters ride through the rebuild; exhausted supporters still continue past the append:
if score:
...
stars = max(stars - star_reduction, 0)
if stars != starting_stars:
if not stars:
allocated += 1
continue # exhausted: still dropped
...reweight t...
# zero-score ballots spent nothing and keep their
# budget — they must survive the rebuild too.
remaining_decorated_ballots.append(t)
remaining_weighted_ballots.append(weighted_ballot)
Verified before applying, on a scratch copy: the repro flips to ['Amy', 'Cy'] at every verbosity, and all 5 repo SSS cases produce byte-identical reports. Landed with a regression guard, tests/test_sss_zero_score_ballots.py — the repro profile asserted at verbosities 0/1/2, plus a print-capture assertion that the profile still exercises the trigger (no-surplus round, one ballot allocated), so the test can't go vacuous if the profile is ever edited. The 5 repo SSS cases stay pinned by the existing _tabulated-mirror currency tests. Ledger row: LH_ENGINE_CHANGES.md §1.
Upstream issue¶
Filed 2026-08-09 as larryhastings/starvote#19: the Summary, Reproduction, Hand-trace, and Why-keep-them sections above, framed against 2.1.6's line numbers (~L2387–2428), plus the interplay warning: upstream currently manifests this bug only at verbosity>=1, because at verbosity=0 the entire allocation block is skipped (issue #17). Fixing #17 by dedenting the block — the natural fix — promotes this bug to every run unless the two remaining_*.append(...) calls are also dedented out of if score: at the same time. The repro shows upstream returning ['Amy', 'Cy'] at verbosity 0 and ['Bo', 'Cy'] at verbosity 1 — a second winners-depend-on-verbosity demonstration for #17's thread.