(tagging @ArendPeter @larry @ClayShentrup, FYI)
The 2025 Steering Council election is in the books. This is the first using STAR voting, & the BetterVoting.com voting service.
- 6 candidates
- 5 winners
- 106 eligible voters
- 74 ballots
Verification
Anonymized ballots were downloaded from the election results page, in CSV
format. @larry’s starvote library was used in an attempt to reproduce the voting service’s results. This was successful: results were duplicated in all respects.
Alas, that takes some doing. The CSV format changed in some ways since Larry wrote his library, and there are other ways in which their processing differs. The library “blows up” almost at once with a raw download. Instead it takes some doing to create ballots the library knows how to work with:
Turns out much of this workaround code wasn’t actually needed: there were no ties of any kind along the way, and there were no all-the-same-rank ballots.
[EDIT: oops! I forgot to include the code for manual tiebreaking - repaired]
Python code to drive Larry's library
import starvote
import csv
KEEP_ALL_ThE_SAME = False
METANAMES = {'voterID',
'voteTime',
'pollID',
'ballot_id',
'precinct'}
FN = "/Users/Tim/Documents/ballots/sc-2025.csv"
def distinct(ballot):
result = set(ballot.values())
if len(ballot) < ncands: # not given maps to 0
result.add(0)
return result
def manual(options, tie, desired, exception):
"""Resolve tie by hand
In case of a tie requiring randomness, at the prompt
enter a space-separated list of the ordinals of the
required number of winners.
"""
print(" tied:")
options.print_candidates(tie, numbered=True)
xs = input(str(desired) + " wioners: ")
xs = list(set(map(int, xs.split())))
assert len(xs) == desired
return [tie[i-1] for i in xs]
starvote.tiebreakers['manual'] = manual
print("[Building ballots from", FN + ']')
with open(FN, "r", encoding="utf-8-sig") as f:
it = iter(csv.reader(f))
cands = next(it)
ncols = len(cands)
print('', ncols, "columns:")
for cand in cands:
print(' ', cand)
nmeta = 0
while cands[nmeta] in METANAMES:
nmeta += 1
if nmeta:
print('', "Removing columns:")
for i in range(nmeta):
print(' ', cands[i])
del cands[:nmeta]
ncands = len(cands)
print()
ballots = []
for line in it:
b = {}
assert len(line) == ncols
del line[:nmeta]
for cand, this in zip(cands, line):
if this:
b[cand] = int(this)
if len(distinct(b)) == 1:
print("ballot all the same:", b)
if KEEP_ALL_ThE_SAME:
ballots.append(b)
else:
ballots.append(b)
## starvote.star,
## starvote.bloc,
## starvote.allocated,
## starvote.rrv
## starvote.sss.
winners = starvote.election(starvote.bloc,
ballots,
verbosity=1,
tiebreaker=manual,
seats=5)
Because there were no ties of any kind, Larry’s output is extraordinarily easy to follow:
Output from Larry's starvote
[Building ballots from /Users/Tim/Documents/ballots/sc-2025.csv]
8 columns:
ballot_id
precinct
Barry Warsaw
Donghee Na
Gregory P. Smith
Pablo Galindo Salgado
Savannah Ostrowski
Thomas Wouters
Removing columns:
ballot_id
precinct
[Bloc STAR]
Tabulating 74 ballots.
Maximum score is 5.
Want to fill 5 seats.
[Bloc STAR: Resolve tie by hand]
In case of a tie requiring randomness, at the prompt
enter a space-separated list of the ordinals of the
required number of winners.
[Bloc STAR: Round 1: Scoring Round]
The two highest-scoring candidates advance to the next round.
Pablo Galindo Salgado -- 313 (average 4+17/74) -- First place
Savannah Ostrowski -- 249 (average 3+27/74) -- Second place
Barry Warsaw -- 239 (average 3+17/74)
Donghee Na -- 191 (average 2+43/74)
Thomas Wouters -- 187 (average 2+39/74)
Gregory P. Smith -- 173 (average 2+25/74)
Pablo Galindo Salgado and Savannah Ostrowski advance.
[Bloc STAR: Round 1: Automatic Runoff Round]
The candidate preferred in the most head-to-head matchups wins.
Pablo Galindo Salgado -- 36 -- First place
Savannah Ostrowski -- 19
No Preference -- 19
Pablo Galindo Salgado wins.
[Bloc STAR: Round 2: Scoring Round]
The two highest-scoring candidates advance to the next round.
Savannah Ostrowski -- 249 (average 3+27/74) -- First place
Barry Warsaw -- 239 (average 3+17/74) -- Second place
Donghee Na -- 191 (average 2+43/74)
Thomas Wouters -- 187 (average 2+39/74)
Gregory P. Smith -- 173 (average 2+25/74)
Savannah Ostrowski and Barry Warsaw advance.
[Bloc STAR: Round 2: Automatic Runoff Round]
The candidate preferred in the most head-to-head matchups wins.
Savannah Ostrowski -- 34 -- First place
Barry Warsaw -- 29
No Preference -- 11
Savannah Ostrowski wins.
[Bloc STAR: Round 3: Scoring Round]
The two highest-scoring candidates advance to the next round.
Barry Warsaw -- 239 (average 3+17/74) -- First place
Donghee Na -- 191 (average 2+43/74) -- Second place
Thomas Wouters -- 187 (average 2+39/74)
Gregory P. Smith -- 173 (average 2+25/74)
Barry Warsaw and Donghee Na advance.
[Bloc STAR: Round 3: Automatic Runoff Round]
The candidate preferred in the most head-to-head matchups wins.
Barry Warsaw -- 38 -- First place
Donghee Na -- 25
No Preference -- 11
Barry Warsaw wins.
[Bloc STAR: Round 4: Scoring Round]
The two highest-scoring candidates advance to the next round.
Donghee Na -- 191 (average 2+43/74) -- First place
Thomas Wouters -- 187 (average 2+39/74) -- Second place
Gregory P. Smith -- 173 (average 2+25/74)
Donghee Na and Thomas Wouters advance.
[Bloc STAR: Round 4: Automatic Runoff Round]
The candidate preferred in the most head-to-head matchups wins.
Donghee Na -- 36 -- First place
Thomas Wouters -- 33
No Preference -- 5
Donghee Na wins.
[Bloc STAR: Round 5: Scoring Round]
The two highest-scoring candidates advance to the next round.
Thomas Wouters -- 187 (average 2+39/74) -- First place
Gregory P. Smith -- 173 (average 2+25/74) -- Second place
Thomas Wouters and Gregory P. Smith advance.
[Bloc STAR: Round 5: Automatic Runoff Round]
The candidate preferred in the most head-to-head matchups wins.
Thomas Wouters -- 34 -- First place
Gregory P. Smith -- 26
No Preference -- 14
Thomas Wouters wins.
[Bloc STAR: Winners]
Pablo Galindo Salgado
Savannah Ostrowski
Barry Warsaw
Donghee Na
Thomas Wouters
Ballots by span
By the “span” of a ballot, I mean the difference between the maximum and minimum number of stars a ballot gave. This can vary from 0 through 5 inclusive. A candidate with no stars specified acts the same as if 0 stars were explicitly specified.
The ballot instructions said to give 5 to your favorite, and a 0 to your least favorite, so only ballots with span 5 followed the instructions. In Approval, the span is always 0 or 1.
ballots by span; 74 total
1 3 4.1% ***
2 5 6.8% *****
3 8 10.8% *******
4 9 12.2% ********
5 49 66.2% ****************************************
A surprisingly high number of voters followed instructions
.
Ballots by distinct ranks
There are 6 possibilities for the number of stars to give a candidate. My belief coming in was that the runoff phase of STAR would incentivize voters to use that variety to maximize their power in the runoff phase, as opposed to plain score voting (with no runoff phase) where scores are routinely exaggerated to the endpoints to try to gain “strategic” advantage. Oar data says our voters are mostly utilizing most of the possibilities STAR offers.
ballots by number of distinct ranks; 74 total
2 12 16.2% **********
3 9 12.2% ********
4 22 29.7% ******************
5 16 21.6% *************
6 15 20.3% *************
There were no all-the-same ballots, but about a sixth of voters restricted themselves to Approval-like only two distinct ranks.
Star distribution
The total number of rankings expressed is the number of ballots times the number of candidates. While “candidate left blank” acts the same as “candidate given 0 stars”, the ballots do record which was the case, and this analysis accounts for them. “Left blank” is indicated by -1.
distribution of stars (-1 = empty); 444 total
-1 21 4.7% ***
0 49 11.0% *******
1 40 9.0% ******
2 45 10.1% *******
3 71 16.0% **********
4 81 18.2% ***********
5 137 30.9% *******************
I was surprised by that 5 stars is clearly the most popular rank given. It suggests voters are very happy with the candidates, and especially so since 4 stars was the second-most popular choice. The two ways of spelling “0 stars” add up to 70, so far less common.
Proportional representation
Larry’s library also supports 3 variations of “proportional representation” scoring for STAR ballots. All 3 delivered the same winners in the same order as plain Bloc STAR. Not surprising. PR is intended to force minority representation in highly polarized electorates. If our candidates had different positions on anything, it’s hard to tell from their nomination statements ![]()
Other
I think I’m done! In the PSF Board election I made heroic further efforts to try to pin down why multiple versions of proportional Approval did change the final winners (slightly so, and by the tiniest of margins). There’s no similar mystery here to investigate, so I see no reason to go on to do heavy analysis of correlations among candidates and their supporters.