Fork Notes — starvote (vendored fork)¶
This page covers only the vendored upstream
starvote/package. For the full, consolidated list of everything we add to or change in the LH tabulator — new methods, new reports, and fixes — seeLH_ENGINE_CHANGES.md(canonical).
This directory contains a vendored fork of Larry Hastings' STAR Voting engine, larryhastings/starvote. We do not submit pull requests upstream; we edit the engine directly here and keep it as part of this project (the masiarek/star-voting-library repo).
What is what¶
| Path | Origin | Edit it when… |
|---|---|---|
starvote/ (__init__.py, __main__.py, reference.py) |
Upstream engine (Larry Hastings) | You're changing how the voting algorithm itself works — scoring, tabulation, tiebreak mechanics, CLI/option parsing of the engine. |
starvote_larry_hastings.py |
Our code | Anything about how we run, feed, or present an election — our LotNumberTiebreaker, matrix visualization, colored output, file loading. This import starvote; it should never duplicate engine logic. |
tools_adam/ |
Our code | Helper/automation scripts (conversion, simulation, BetterVoting automation, etc.). |
tests/, test_elections/ |
Mixed | Upstream tests plus ours. |
Rule of thumb: "Would Larry want this in the engine for everyone?" → it goes in starvote/. "Is this about my analysis, display, or workflow?" → it goes in our script/tools.
Our wrapper script (
starvote_larry_hastings.py), its display options, and the_tabulatedoutput format are documented in starvote_larry_hastings.py — presentation wrapper.
Upstream baseline¶
The pristine upstream version is starvote 2.1.6, verified byte-identical to the PyPI release. It is recorded as the git tag:
starvote-upstream-2.1.6 -> commit daa6bbd
See exactly what we've changed in the engine, any time¶
# full diff of our engine edits vs pristine upstream
git diff starvote-upstream-2.1.6 -- STARVote_LH_tabulation_engine/starvote/
# just a summary
git diff --stat starvote-upstream-2.1.6 -- STARVote_LH_tabulation_engine/starvote/
Current divergence from upstream 2.1.6¶
The engine algorithm is essentially unchanged. git diff --stat against the tag reports a large line count (≈ +725 / −299 even with -w), but that is almost entirely line-reflow (signatures and long calls re-wrapped): the two files are ~97 % character-identical once whitespace is removed, __version__ is still 2.1.6, no functions were removed, and exactly one helper was added (bool_converter). The functional edits are two optional output toggles plus five bug fixes (three detailed below; the SSS zero-score-ballot fix is documented in BUG_sss_zero_score_ballots.md and the Allocated Score count-vs-weight fix below and in BUG_allocated_count_vs_weight.md — the consolidated table lives in LH_ENGINE_CHANGES.md §1):
print_averagesoption (defaultFalse) + CLI flag-a/--print-averagesand config keyprint averages = <bool>. Suppresses the averages line unless asked.print_maximum_scoreoption (defaultFalse) + CLI flag-M/--print-maximum-scoreand config keyprint maximum score = <bool>. Suppresses the "Maximum score is …" line unless asked.bool_converterparses those two boolean config keys.- Both options are forwarded to method functions only when they differ from the default, so older/reference method implementations don't break.
Bug fix — five-star tiebreak default score (2025)¶
- File/location:
starvote/__init__.py,_maximum_score_count_round(), the 2-candidate fast path (theif len(candidates) == 2:branch). - What changed:
ballot_get(candidate1, 1)→ballot_get(candidate1, 0). The.get()default for the second candidate was1while the first candidate (and the general N-candidate path) correctly used0. - Effect: this function powers the five-star tiebreaker (it counts votes equal to
maximum_score). With the wrong default, a ballot that omits candidate1 contributed a phantom score of1; that only equalsmaximum_scorewhenmaximum_score == 1(Approval-style), so the miscount was dormant for normal 0–5 STAR (full ballots always include both candidates, and1 ≠ 5). It was still a latent correctness bug, now aligned withcandidate0and the general path so all three agree. - Why upstream: it's the voting algorithm's tiebreak mechanics, so it lives in
starvote/(per the table above), not our wrapper. Consider offering it to Larry. - Regression guard: the four
01_STAR/03_Criteria/tie_break_dead_rung/cases exercise the five-star rung firing vs. falling through to the lot in both rounds.
Bug fix — SSS ballot allocation gated on verbosity (2026-08)¶
- File/location:
starvote/__init__.py,sequentially_spent_score(), the "Ballot allocation round" block. - What changed: the ballot-allocation machinery (building
remaining_decorated_ballots/remaining_weighted_ballots, the star-spending/reweighting loop, and thedecorated_ballots = remaining_decorated_ballotsreassignment) was nested insideif options.verbosity:; it is now dedented so it runs at every verbosity, with only the printing still guarded. - Effect: at the engine's default
verbosity=0, no ballots were ever spent or reweighted, so SSS silently degenerated into repeated bloc score voting and could return different winners than the same election run verbosely — the defining proportionality of the method vanished in quiet runs. Reported upstream as larryhastings/starvote#17 (open; latest release 2.1.6 affected). Full analysis: BUG_sss_verbosity.md. - Why upstream: it's the voting algorithm's allocation mechanics, so it lives in
starvote/(per the table above), not our wrapper. Offer it to Larry via issue #17. - Regression guard:
tests/test_verbosity_invariance.pyasserts verbosity-invariant winners forsss/allocated/rrv/blocplus the exact proportional SSS outcome.
Bug fix — unbreakable-tie message never interpolated (2026-08)¶
- File/location:
starvote/__init__.py,_star_round(), bothoptions.break_tie(...)calls (the Scoring Round and Automatic Runoff Round dead ends). - What changed: two characters.
"{int_to_words(len(tie), flowery=False)}-way tie in …"→f"…". The strings were plain literals, so the placeholder was never interpolated. - Effect: presentation only, and only through the Python API — the
UnbreakableTieErrormessage read{int_to_words(len(tie), flowery=False)}-way tie in Scoring Roundinstead ofthree-way tie in Scoring Round. Because_star_round()serves both single-winner STAR and Bloc STAR, both methods raised the raw source text; the equivalent strings inallocated_score_voting()andsequentially_spent_score()already carried thefprefix, which is why the proportional methods looked fine. The printed report and the CLI are unaffected (the CLI prints its own[Unbreakable Tie]block and exits 0), and no winner anywhere changes — the exception is raised only when a tie is already unbreakable. - Why upstream: it is inside the engine's tiebreak mechanics, so it lives in
starvote/(per the table above). Present in upstream 2.1.6 and on upstreammain(lines 1690 / 1717 there). Reported as larryhastings/starvote#18 (open) — filed separately from #17 because that one changes winners and this one cannot. Repro also in the errata note. - Regression guard:
tests/test_unbreakable_tie_message.pypins the wording for the three reachable_star_round()ties andast-parses the engine so nobreak_tie()description can carry an uninterpolated{placeholder}again (including the allocated / SSS sites, whose ties are awkward to provoke).
Bug fix — Allocated Score fills the quota by ballot count, not weight (2026-08)¶
- File/location:
starvote/__init__.py,allocated_score_voting(), the "Ballot allocation round" loop. - What changed: the score group's weight sum (
allocation_weight = sum(t[INDEX_WEIGHT] for t in supporters[score_start:])) replaces the row count in the overfill test, the quota subtraction, and the fractional-surplus factor (quota ÷ allocation_weight). When the two differ, the verbose report adds one line:These ballots carry a remaining weight of W.Round-1 output is byte-identical to upstream. - Effect: winners change on any profile where a second allocation event touches already-reduced ballots — a bloc holding 3+ quotas paid
1 − quota/nper seat forever (geometric decay, D'Hondt-flavored) instead of surrendering one quota of weight per seat. On the 41/19/6 fingerprint (5 seats) upstream elects 4-1-0; the fix, BetterVoting production, and the reference implementation shipped instarvote/reference.pyall elect 3-1-1. Two repo cases flipped to the correct committee with the fix. - Why upstream: allocation mechanics, so it lives in
starvote/(per the table above). Reported as larryhastings/starvote#20 (open); full analysis in BUG_allocated_count_vs_weight.md. - Regression guard:
tests/test_allocated_weight_accounting.py(fingerprint, the coop-board organic case, and a single-surplus fixture that must NOT change), plusexpected_winnerson the fingerprint case file.
Note on
example.pyand the vendored README's transcripts.example.pyhere is NOT upstream's 3-ballot Amy/Brian/Chuck example — it was repurposed as a single-ballot tiebreak-cascade demo (scoring tie → head-to-head → five-star → Hashed Ballots). The vendoredREADME.mdstill shows the upstream example and transcripts with averages/"Maximum score" lines that the fork'sprint_averages=False/print_maximum_score=Falsedefaults now suppress — the README is kept as upstream wrote it; trust this file for what differs.Correction (do not repeat the old claim): the
No Preference→Equal Supportrelabel, the Runoff (Preference) Matrix,[Divergence from STAR], the[Runoff Reversal]summary, andshow_runoff_percentare NOT engine edits — they all live in our wrapperstarvote_larry_hastings.py. The vendoredstarvote/package still prints "No Preference" internally. Keeping the engine pristine-but-for-these-documented-edits is deliberate: it makes re-pulling a future upstream release trivial.
To regenerate this list precisely at any time, run the git diff commands above and compare the def/class inventory of the two versions.
How to pull a future upstream update (if ever wanted)¶
- Download the new pristine version (e.g.
pip download starvote==X.Y.Z --no-deps --no-binary :all:). - Tag it: copy the new
starvote/over a clean checkout, commit,git tag starvote-upstream-X.Y.Z. - Re-apply our diff:
git diff starvote-upstream-2.1.6 starvote-upstream-X.Y.Zshows what upstream changed; resolve against our edits listed above.
Because our edits are small and localized, re-applying them by hand is the simplest path.