Skip to content

Shapes of a Bloc STAR election

Ten elections, from one ballot to fourteen candidates, chosen to cover the ways a bloc count can surprise you — and to check, on every one of them, whether proportional STAR would have seated somebody else.

Level: 101 → 301 · deep dive

→ the method itself: Bloc STAR · how it counts: what Bloc STAR is · the majoritarian/proportional boundary: Bloc STAR vs Proportional STAR


What Bloc STAR does, in one paragraph

Bloc STAR is single-winner STAR run once per seat. Each round scores every remaining candidate, sends the top two scorers to an automatic runoff, elects whoever more voters prefer, removes them, and starts again — with every ballot still counting at full weight, every time. That last clause is the whole method. It is why a cohesive group can win every seat, and it is the one thing that separates Bloc STAR from its proportional cousins.

Everything in this folder is that loop, with something different going wrong (or right) inside it.

Why this folder exists

The existing Bloc STAR cases cluster hard. Before this set, 17 of 35 had exactly three candidates and 24 of 35 had exactly two seats; none had more than nine candidates, none had more than four seats, and not one used weighted ballots. That is a fine sample of small bloc elections and a poor sample of bloc elections.

There is a second reason, upstream. BetterVoting tabulates Bloc STAR through a shared loop, runBlocTabulator, wrapped around its single-winner STAR round function. That loop is covered by its test suite — Approval.test.ts, Plurality.test.ts and RankedRobin.test.ts each run it with two winners. But Star.test.ts at origin/main is entirely single-winner: 18 tests, of which 7 call Star(…, 1) and 11 call singleWinnerStar directly, so no BV test exercises STAR with more than one seat. Bloc STAR is the one bloc path its own suite never runs. Cross-checking a live BV election against this engine is therefore worth more here than almost anywhere else in the repo, which is why the cases below that are BV-backed carry live results links.

Count that at origin/main if you ever quote it, not in whichever local BetterVoting clone happens to be open — they sit at different distances behind main and give 18, 19, 20 or 22 for this one file. Exactly one clone here holds a two-seat Star(…, 2) test, and it is ours: commit a892a0ff on the unpushed branch fix/1484-race-details-runner-up, on no remote and not in origin/main.

The ten cases

Read top to bottom: the first three are small enough to check in your head, the last three are the shape a real at-large council election actually has.

Smallest — the mechanism with nothing in the way

Case Shape What it shows
One voter fills a two-seat council 3 cand · 2 seats · 1 ballot The loop as a definition. One voter's ordering becomes the council, in order. There is no arithmetic to hide behind.
The Condorcet winner never reaches a runoff 4 cand · 2 seats · 5 ballots Bex beats every rival head-to-head and wins nothing — because only the top two scorers advance, and Bex scores last.
The score leader is shut out of every seat 4 cand · 3 seats · 5 ballots Ada leads the scoring round in all three rounds and finishes with no seat. The council is literally everybody else.
Making the first runoff buys you nothing 3 cand · 2 seats · 7 ballots Blake is a seat-1 finalist and the #2 scorer, and goes home empty-handed. Reaching a runoff is not a down payment on the next seat.

Those middle two are mirror images, and between them they exhaust the ways Bloc STAR can pass a candidate over: either you get into the runoff and lose it, or you would win it and never get in. Nothing else can happen.

Middle — where the electorate starts to matter

Case Shape What it shows
A divided majority wins nothing 5 cand · 2 seats · 12 ballots 58% of voters split three ways and elect nobody; the united 42% takes both seats.
A seat decided by 11 voters out of 31 5 cand · 3 seats · 31 ballots Twenty voters rate both finalists identically, so the seat turns on the eleven who differentiate. Equal Support is a real answer, not a missing one.
Five seats, six candidates 6 cand · 5 seats · 7 ballots When you elect almost everyone, every method agrees. Also the folder's one deliberate engine fixture — see below.

Largest — the shape a real council race has

Case Shape What it shows
Harborview city council 12 cand · 5 seats · 108 voters A 52% slate takes all five seats. The majority sweep at municipal scale — and the mechanism behind Voting Rights Act litigation over at-large seats.
No faction has a majority 10 cand · 4 seats · 101 voters Blue 40 / Green 35 / Amber 26. An independent nobody ranks first wins seat 1 on breadth; the second-largest faction elects nobody while the smallest gets a seat.
Fourteen candidates, six seats 14 cand · 6 seats · 175 voters A 38% group — not a majority — takes 67% of the council. And the widest field is the only one where the three proportional methods disagree with each other.

Does proportional STAR elect somebody else?

On seven of the ten, yes. This table is generated from the same ballots run under four methods — only the voting_method: line changes.

Case Bloc STAR elects Allocated Score (= BV's STAR_PR) elects Differs?
One voter Ada, Ben Ada, Ben —
Condorcet winner no seat Cyrus, Ada Ada, Bex ✔
Score leader shut out Dev, Bo, Cleo Ada, Cleo, Dev ✔
Finalist wins nothing Ada, Cleo Ada, Cleo ✔ (sss/rrv only)
Divided majority Uma, Ugo Maya, Uma ✔
Equal Support seat Croissant, Almond, Brioche same three —
All but one Ana, Cleo, Bram, Dov, Esme same five —
Harborview council Ana, Beto, Cora, Dmitri, Elena Ana, Beto, Cora, Farid, Gina ✔
No majority † Jaya, Ada, Gita, Bram Ada, Dov, Gita, Jaya ✔
Widest field Lena, Alma, Mateo, Bruno, Clara, Dex Alma, Bruno, Elsie, Frank, Ivan, Lena ✔

† Read that one as a seat count, not a list of names. Nine of the ten cases are tie-free under Allocated Score as well as under Bloc STAR. bloc_no_majority_bridge is the exception, and it is tied harder than it first looks: once Jaya's quota is spent, Ada and Bram hold exactly the same reweighted score (2727⁄16), Gita and Hank the same (303⁄2), and Dov, Esme and Ines are in a three-way tie at 2121⁄16. The engine prints three [Lot-decided tie — rare] banners — three of the four seats go to a lot. Every proportional method still gives Blue, Green and Amber one seat each plus the independent, which is the claim the case supports; which candidate fills a faction's seat is a coin toss.

Worth naming the mistake that first put "two" here, because it is an easy one: the count was assembled from the LH-vs-BetterVoting diff rather than from the election. Seat 3 is lot-decided too, but both engines' lots happen to land on Dov, so it never appears in a divergence. Where two engines disagree and where the ballots failed to decide are different questions, and only the second is a fact about the election — count ties from the engine's own banners or a from-ballots recomputation, never from the diff.

Three things in that table are worth stating plainly.

The divergence is not random — it is always the same correction. In every diverging case, proportional STAR seats somebody the bloc count left out because they were already outvoted somewhere else: the Condorcet winner, the shut-out score leader, the divided majority's best candidate, the minority slate. That is what reweighting is for. Bloc STAR asks "who do the voters most want?" once per seat; Allocated Score asks "who is not yet represented?" Neither is cheating — they answer different questions, and choosing between them is a decision about what the elected body is for.

Agreement is common too, and the three no-divergence rows are there on purpose. A folder of nothing but failures would misrepresent how often the choice of method actually changes an outcome. When one voter decides, when nearly every candidate wins, or when the same candidates lead on both breadth and depth, the methods converge — and bloc_all_but_one shows the structural reason: as seats approach candidates, proportionality has nothing left to reallocate.

"Proportional" names a family, not an answer. Allocated Score, Sequentially Spent Score and Reweighted Range agree with each other on nine of the ten. They part company on the widest field, which is the only case with enough candidates to give them room to disagree.

Six of them are live on BetterVoting — and its Bloc STAR path agrees

Each of the six BV elections below counts the same ballots twice: one race as Bloc STAR (BV's STAR with more than one winner, which routes through runBlocTabulator) and one as STAR_PR. So the majoritarian/proportional divergence is not just an argument in this repo — you can click it.

Case Live Bloc STAR race STAR-PR race
Condorcet winner no seat BV2287 ↗ ✅ matches LH ✅ matches LH
Score leader shut out BV2288 ↗ ✅ matches LH ✅ matches LH
Divided majority BV2289 ↗ ✅ matches LH ✅ matches LH
Harborview council BV2290 ↗ ✅ matches LH ✅ matches LH
No majority BV2291 ↗ ✅ matches LH ⚠ tie broken differently
Widest field BV2292 ↗ ✅ matches LH ✅ matches LH

All six Bloc STAR races match this engine exactly, up to and including fourteen candidates over six seats and 175 ballots, every one reporting tieBreakType: none. Given that BV's own suite never runs STAR with more than one seat, that is worth having on record rather than assumed.

BetterVoting's result page for BV2290: 'Ana, Beto, Cora, Dmitri, and Elena win!', 108 voters, scoring round Ana 316, Beto 304, Cora 260, Dmitri 248, Elena 236, Farid 230; the automatic runoff shows Ana 11% against Beto with Equal Support at 89%.

That is BV2290, seat 1 of the Harborview sweep, and it makes the folder's least obvious point better than prose does. The scoring-round bars are the Harbor slate occupying the top five places. The runoff underneath is Ana against Beto — two candidates from the same slate — and the grey bar reports Equal Support 89%. Ninety-six of the 108 voters scored both finalists identically and expressed no preference at all; the seat was settled by the twelve who did. That is what the first four rounds of a sweep look like from the inside: not a contest between the factions, but the winning faction sorting out its own running order while everybody else sits out.

The single ⚠ is not a count bug in either engine. It is the last rung of the tie-break ladder — LH's published lot against BV's seeded shuffle — landing on different candidates in the one case where Allocated Score produces an exact tie. Both engines agree on every number in that race; they disagree only about how to toss a coin. (BV labels every STAR_PR race tieBreakType: "random" whether or not a tie occurred — that mislabel is bettervoting#1507, and it is why the label alone cannot tell you a tie happened. Here one did.)

One case is aimed at the engine, not the reader

bloc_all_but_one has a round-2 runoff tie resolved by a deterministic rung — highest score — inside a bloc round. The printed report says so:

Round 2: Automatic Runoff Round: First tiebreaker
 The highest-scoring candidate wins.

…while --json reports "tiebreaks": [] at schema 1.2.0. That is not a new bug; it is a blind spot the result contract already names in print — the rungs below the lot inside a Bloc/PR round run in starvote's own counting functions and report nothing back, so the contract cannot see them. Keep this file as the fixture that fails first when someone closes that gap. The tie decides the order of two seats, not the winner set, so the answer key is stable either way.

Run them yourself

Every case is one file, and the only thing you change to get the proportional answer is the method:

.venv/bin/python STARVote_LH_tabulation_engine/starvote_larry_hastings.py 02_STAR_Bloc/02_Examples/bloc_shapes/cases/bloc_harborview_council.yaml

Add --full for the everything-on render (preference matrix, score distribution), or --json for the machine-readable result.

Where to go next