CLAUDE.md — working guidance for this repo¶
Standing instructions for Claude when working in this project — and the house conventions for anyone contributing (human front door: CONTRIBUTING.md). The Claude-only operational grants live at the bottom of this file.
What this is¶
A STAR Voting education repo built on a fork of Larry Hastings' starvote
(single-winner STAR engine + extra reporting), plus a vendored RCV-IRV engine,
example YAML elections, teaching docs, and Larry↔Adam conversation scripts.
Audience: voters, presenters, and debaters learning/teaching STAR.
Terminology policy (important — keep it consistent & correct)¶
Background: "RCV" is widely used loosely to mean IRV (FairVote-era usage that also trained most AIs to be sloppy). We meet people where they are, but we stay precise. The key idea: RCV names a BALLOT (ranked); IRV names one TABULATION of it. Other tabulations of the same ranked ballot: Ranked Robin (Condorcet / "consensus"), STV (proportional).
House style:
- Default to RCV-IRV in this repo's method comparisons, engine output, and
debate/teaching docs. Unambiguous, and already the engine's term.
- Use IRV in technical/critical passages — center squeeze, exhausted
ballots, non-monotonicity are IRV-specific, not properties of all ranked
ballots (Ranked Robin isn't squeezed). Saying "RCV does X" there is imprecise
and an easy target.
- Reserve bare RCV for "the ranked-ballot family," and say so when used that
way.
- Name Ranked Robin / STV explicitly; never fold them into "RCV" = IRV.
- Don't be a purist who derails. When others use "RCV" loosely, keep their
word, correct once, move on. Don't fight the wind.
US-usage caveat (the nuance): RCV-IRV is a deliberate house compound, not
standard US usage — appending "IRV" can look odd or confusing to a general
public audience, who only know "RCV." So:
- Technical / debate / engine / docs → RCV-IRV (or IRV). Precision wins.
- Public-facing copy (slides, intro talk) → "RCV" is fine, but clarify once
on first mention: e.g. "RCV — ranked ballots counted by instant runoff (IRV)."
Then use the familiar word.
Family tree, when-to-use table, and glossary are canonical — do not restate the
taxonomy from memory: see 07_Concepts/tips/TIPS_terminology.md and GLOSSARY.md.
Other voting-term canon:
- STAR = Score Then Automatic Runoff (a score ballot + that tabulation; the
same ballot can be Approval / Score / Proportional STAR).
- Equal Support is the canonical label for the no-preference runoff bucket
(matrix legend and runoff) — printed plain, just "Equal Support". The aka
(Equal Preference / No Preference) is documented once in GLOSSARY.md, not
shown on screen on every runoff line. Do not reintroduce "Equal Preference" as the
lead term.
- Favorite Betrayal Criterion ≠ Later-No-Harm — keep distinct. Neither STAR
nor IRV is FBC-compliant; RCV-IRV fails it structurally (center squeeze), STAR
only in rare constructions. See 01_STAR/01_Learn/properties_and_limits/favorite_betrayal_voting_301.md.
- Spelling: Bucklin (not "Buckling"). Hare ≈ IRV single-winner, STV
multi-winner. Borda & Bucklin are ranked but not Condorcet.
Repo conventions (so output stays consistent)¶
- Case files carry NO
options:block (settled 2026-08-09). The display flags were maintainer conveniences that had accreted into ~10 lines of noise per file — a quarter of a median case file — and a case file should read as a plain-text election scenario: title → description → method → ballots → expected winners. The engine's own defaults ARE the house on-screen style now — oneDEFAULT_OPTIONSdict instarvote_larry_hastings.py: finalists matrix ON, the self-reconciling runoff summary ON, description / Condorcet / score-distribution OFF, ballots collapsed with×. Two auto-gates cover what files used to hand-set: the matrix switches itself off for multi-winner races (a "Top 2 Finalist" grid is a single-winner concept, misleading for PR/Bloc) and for 2-candidate races (it would just echo the runoff); and Ranked Robin prints its pairwise table by default (the round-robin table IS the method; the Smith block stays a separate opt-in). The saved_tabulatedcopy still renders maximum info automatically, and the--fullCLI flag puts that same everything-on render on screen. Consequences: - Don't add an
options:block to a case file. A lesson that wants a heavier section on the page (score distribution, full grid, Smith set) links the case's generated page /_tabulatedmirror — which force everything on — or pastes from a--fullrun; it does not flip flags in the yaml. - A file MAY still set
options:to override any default. That is reserved for the option-demo files (04b_…display-options-all,display_options_demo, and the engine'soptions_examples.yamlreference — they exist to showcase the feature) and rare deliberate special renders. When one is used, booleans are the longtrue/falseform (the parser also accepts t/f/y/n/etc.). - The
[Divergence from STAR]block prints whenever methods differ, regardless of options — comparative demos keep their punch with no flags at all. - History: before 2026-08-09 every case file restated a 10-line "house minimal
block" (501 files, ~5,000 lines); the sweep deleted them all, and the render
diffs were machine-verified (no winner line changed).
show_irvwas already vestigial — the divergence block always prints — and survives only so old blocks still parse. The defaults + auto-gates are locked bytests/test_default_render.py. show_runoff_percent(engine defaulttruesince 2026-08-09): prints the two-line, self-reconciling runoff summary under the Automatic Runoff winner — e.g.Voters with a preference: 363 of 461 (98 Equal Support). Dog 190 (52%) vs Cat 173 (48%); majority = 182— using the decided-voters denominator (Equal Support excluded) but stating it against the total ballots with the Equal Support gap named inline, so the denominator never has to be inferred. The always-full_tabulatedcopy expands it into a "Runoff math" funnel (461 − 98 = 363, majority) — don't hand-set that. The wording/funnel/default are locked bytests/test_runoff_percent.py; change them together.show_smith_set(Ranked Robin only): still opt-in on screen (engine defaultfalse) and always forced on in the_tabulatedmirror — deliberately NOT dragged along by RR's default-on matrix (a dedicatedsmithgate in the RR echo keeps them separate). Prints the Smith set (the smallest group whose every member beats every candidate outside it), says whether that's a lone Condorcet winner or a top cycle, and whether the winner landed inside it. RCV-IRV mirrors get the same block automatically (no option — the IRV path has no options plumbing). The two uses are opposite: RR is Smith-efficient so the block is descriptive; RCV-IRV is not, so it's a genuine pass/fail. Wording locked bytests/test_smith_set.py; concept page07_Concepts/topics/smith_set.md.- Voter counts — keep examples SMALL. Default to the fewest ballots that
make the point; prefer individual ballots (one row per voter, a handful of
them) over large weighted blocs. A 3-voter example that shows the effect beats a
100-voter one. Only scale up when a larger electorate is genuinely essential
(e.g., percentages or proportional seats). When you do weight,
Countvalues must be ≥ 6 (avoid collision with 0–5 scores); scaling all weights ×N preserves STAR/proportional winners. See07_Concepts/tips/TIPS_choosing_voter_counts.md. - A ballot's weight goes BEFORE the scores — machine-checked
(
check_ballot_weight_side, gated bytests/test_md_links.py). One election is written one way everywhere:Count × Ada,Ben,Caraover3 × 5,2,0. That is the YAML schema (Count:Ada,Ben,Cara/15:5,2,0) and it is what the engine echoes back (Count × Memphis,…/42 × 5,4,3,2). A source file cannot drift — the parser only ever matches a leading weight — so0,4,5 ×3only ever shows up in hand-authored Markdown, the one surface with neither a parser nor a generator holding the line. Eight pages had accumulated it by 2026-08-07, and it hides well: each page is internally consistent, so the inconsistency is only visible to someone reading two of them. A reader who meets both forms has to work out, per page, which number is the ballot and which is the bloc size — on a three-candidate row (0,4,5 ×3) the trailing count looks exactly like a fourth candidate's score. The check also fires on YAML comments andscenario_descriptionprose, which teach the wrong form just as loudly and ride into the_tabulatedmirror and the generated page. Note the trailing form's best hiding place is an annotation, not a bare row —5,4,0 ← the 3-voter majority (×3)states the weight twice, in words and in a glyph, and neither one is where the schema puts it; write the count in the column and let the note say what the bloc is. - Candidate names — a fresh, easy cast per scenario; the same cast within one.
Prefer a new set of names for each scenario (memorable beats uniform — "the
Ada/Ben/Cara split," "the Tennessee cities") over one fixed roster. Four rules:
(1) common and easy to say — no obscure or confusable names (the "Cy" problem);
(2) distinct initials, in order — A, B, C, D… so names line up with the ballot
columns and reading order; (3) phonetically distinct within a scenario — avoid
rhyming/blurring pairs (Dana/Hana, Ben/Glen) that don't carry when spoken aloud in a
recording; (4) use a theme when one fits (Star Wars, cities, flavors) — that's
the best kind of variety. Variety between scenarios, consistency within: a
matched pair or family (e.g.
05a/05b) keeps the same cast — it's the same election with one thing changed, so new names would imply a different election. Use bareA/B/C/Donly for purely abstract/academic illustrations where names are noise. Canonical reusable elections (Ann/Bob/Cal, the team lunch, the runoff reversal, Tennessee, the pets) are registered in07_Concepts/tips/TIPS_canonical_elections.mdwith their jobs and the two rules — one election per page and canonical = frozen ballots (same cast ⇒ same election; a new lesson gets a new cast, never a tweak). Reuse a canonical for generic examples instead of minting a new cast. - Markers (all tabulate as 0):
-blank ·~race abstention ·&candidate abstention ·?spoiled ·%spoiled+reissued. No^(removed). Approval ballots accept only0/1(+ blank/marker = not approved). - Levels (101/201/301) live ONLY in the curriculum — now a hub
(
07_Concepts/CURRICULUM.md) plus one page per level (07_Concepts/curriculum/CURRICULUM_101.md/_201.md/_301.md); the hub links them and holds no level content itself, so there's a single source of truth per level (no sync drift). Don't tag every file. Example folders stay content-typed (01_STAR/…05_Ranked_Robin/,method_comparisons/,06_Other/). - One door per voting method (reorganized 2026-07-29). A method's concept
pages live inside that method's folder, in its
01_Learn/bucket —01_STAR/01_Learn/(incl.01_Learn/reporting/),03_STAR_PR/01_Learn/,04_Approval/01_Learn/,05_Ranked_Robin/01_Learn/. The two 06_Other methods still use the olderconcepts/name:06_Other/RCV_IRV/concepts/,06_Other/Range/concepts/. The folder'sREADME.mdis that method's start-here (what it is → its concepts → its runnable examples). - The method-folder spine (reorganized 2026-08-02). Inside
01_STAR/…05_Ranked_Robin/, the second level is a fixed, ordered set of buckets —01_Learn/,02_Examples/,03_Criteria/,04_Real_Elections/,05_Practice/,09_Parked/— and each method takes only the ones it needs. Adding a case set? Put it in the bucket that fits rather than creating a new second-level sibling. Capitalize the word after the number (02_Examples, not02_examples): MkDocs derives sidebar labels from folder names and renders an all-lowercase name lowercase. Difficulty stays out of the folder names — a case is often 101 for its basic idea and 301 for the deep dive, so levels live in07_Concepts/CURRICULUM.mdand per-set tables, never in a path. They previously lived in a parallel07_Concepts/<Method>/tree, which gave each method two competing front doors.07_Concepts/is now cross-method only (topics, paradoxes, scores_and_ranks, curriculum, glossary, engines, tips, books) — don't put method-specific pages back into it. Moving concept pages again? Usetools_adam/scripts/migrate_concept_links.py(resolves relative links per source file — a blind string replace corrupts them), run it before thegit mv, and add aredirect_mapsentry per moved page. Those redirects are permanent: published URLs are quoted in BetterVoting election descriptions that can never be edited, so a deleted redirect is an unfixable 404. Three things that script will NOT do for you — each one fails silently, and all three bit the 2026-08-02 reorganization: - Repoint existing redirect DESTINATIONS. You must pass
--exclude mkdocs.yml(its redirect keys are historical URLs and must never move), but that also leaves every value pointing into the folder you just moved. Those already-published URLs then 404 — the exact outcome the redirects exist to prevent. Freeze the keys, repoint the values by hand, and assert every destination exists on disk afterward. - Fix segment-wise paths in Python.
REPO_ROOT / "01_STAR" / "_main"contains no literal01_STAR/_main, so the literal pass cannot see it, and a glob over the now-missing directory yields nothing without erroring — parameterized cases just vanish.tests/test_case_roots_exist.pynow fails the suite when a test module names a path that doesn't resolve; keep it. - Touch
.claude/. It is in the script'sSKIP_DIRS, so paths inside the repo's own skill files survive every rename. Grep it by hand. And re-read the prose afterward: the literal pass cannot tell a live path from a sentence about the old path, and a link whose visible label is a backticked folder name keeps saying the old name after its target is repointed — the label is text, not a path. - Where text lives: per-file context in the YAML (
scenario_descriptionprintable,video_script= notes, never shown on screen); cross-file teaching in Markdown. No hand-authored.mdper YAML (the generated pages are the exception — see next bullet). SeeYAML_library/ORGANIZATION.md. - Folder overview pages are named exactly
README.md(notREADME_<folder>.md). GitHub only auto-renders a file namedREADME.mdin a folder's tree view, so each folder's landing/overview page must beREADME.md— that's what makes the folder show a friendly page instead of a bare file list. Keep the descriptive title in the file's# H1(e.g.# 01_STAR — single-winner STAR). A folder may keep secondary docs under descriptive names (e.g.README_larry_hastings.md,FORK_NOTES.md), but the one overview is alwaysREADME.md. - The repo publishes as a searchable website — https://masiarek.github.io/star-voting-library/,
built by root
mkdocs.yml(MkDocs Material +mkdocs-same-dir+mkdocs-redirects) straight from the repo's own Markdown (nodocs/copy;.yaml/_tabulatedfiles carried through) and deployed by.github/workflows/docs.ymlon every push to master. FolderREADME.mds become the site's section index pages (one more reason that naming rule matters), and links keep GitHub's file-relative semantics (use_directory_urls: false— don't flip it). Local preview:uv run --group docs mkdocs serve(the docs toolchain is pinned inpyproject.toml'sdocsdependency group +uv.lock;mkdocs-same-dir/mkdocs-redirectsare capped at the last releases free of the MkDocs-impersonatingproperdocspackage — investigate before raising those pins).site/is generated output — never commit. Details + known nits:07_Concepts/about_this_repo/website_build.md. Site-only redirects (redirects.redirect_mapsinmkdocs.yml) — use sparingly. They replace the built page at a URL while leaving the.mdon disk intact, so GitHub still renders the source but the site never shows it. Live case:05_Ranked_Robin/README.md→05_Ranked_Robin/01_Learn/ranked_robin.md, because the case folder is a top-level nav section and is where visitors land wanting "Ranked Robin," but the page that teaches the method is the concept page. Whenever you add a redirect, move or mirror whatever the source said onto the destination (here the case index is mirrored under "Worked examples" onranked_robin.md) and leave a maintainer note in the redirected README — otherwise edits there silently never ship. Adding a plugin means updating two places:mkdocs.ymlplugins and thedocsdependency group inpyproject.toml(thenuv lock). CI and the local preview both resolve fromuv.lock, so there is no separate install command to keep in sync. Prefer a hook over a plugin for small build-time fixes:hooks:inmkdocs.ymlloads a plain repo file (mkdocs_hooks.py) with no dependency and no lock churn. It already carries the sidebar acronym casing (rr_tiebreaks→ "RR tiebreaks"), which is fixed at build time precisely because renaming the folder would move permanent URLs. Sidebar reading order lives there too —NAV_ORDERinmkdocs_hooks.py, keyed by folder path, listing children by on-disk name. Auto-nav is alphabetical, which for a lesson folder is actively wrong (01_Learnopened on the ballot page, with "Welcome to STAR Voting" third). Set the order there, never by renaming files to01_,02_…: that is a number in a permanent URL, and inserting one lesson later moves a whole run of them. Unlisted pages keep their alphabetical slot at the bottom, so adding a page needs no edit; a folder'sREADME.mdis always pinned first (navigation.indexesneeds the index atchildren[0]). Entries beforeSPINE_BREAKget a visibleN.in the sidebar — keep that run short and mostly sections, since numbering a page also prefixes that page's<title>.tests/test_nav_labels.pyfails if a listed name no longer exists on disk. - Companion repo — research-paper topics live OUTSIDE this repo.
https://github.com/masiarek/star-voting-research-topics (private) holds the
vetted research-paper prospectuses that use this library as their reproducibility
artifact — one
topics/NN_<slug>.mdper topic, plus aREADME.mdslate table. Each topic page follows a fixed structure: status/venues/supporting-library header, Abstract, Research question, Methodology, Literature gap and closest prior work, Cautions and framing corrections, Supporting assets in star-voting-library (links into this repo atblob/master/…), Execution sketch. Topics 1–5 were produced by a 12-agent workflow (2026-07-24) and each passed a five-agent adversarial novelty check; anything added later must state its vetting level honestly rather than inherit that badge (topic 6, the metric distortion of STAR, started with a preliminary check and passed the full five-agent protocol on 2026-07-26 — its status line records the run and the residual caveats). Don't search this repo for research topics — they aren't here; clone the companion, match the house structure, update the README slate table, and keep the two repos pointing at each other. Working the other direction: when a teaching page states an open gap in print (as07_Concepts/topics/distortion.mddoes for STAR's missing distortion bound), that's exactly the raw material for a new topic page. - When creating education pages or cross-referencing, prefer the
.mdpage over the raw.yaml(and MD/links in general). The generated per-election pages (<set>_pages/<name>.md, built bySTARVote_LH_tabulation_engine/tools_adam/scripts/build_yaml_pages.py) are the reader-friendly surface: lead with them in tables, navs, and cross-references (left-most / primary link). Link a.yamlonly when the tabulatable source is genuinely the point (e.g. a "run this file" command), and demote it (right-most column / secondary link). Page structure is teaching first, raw tally last: thescenario_descriptionand educational value up top, then the ballots, then the results — with the full engine detail (the same content as the_tabulatedmirror, or the mirror embedded) at the bottom of the page, so the reader gets the lesson before the numbers. - Link a folder by naming its
README.md— machine-checked (check_folder_links, gated bytests/test_md_links.py). Write[label](some_folder/README.md), never[label](some_folder/),[label](some_folder), or[label](some_folder/#anchor). The bare forms are seductive because they work on two of the three surfaces: GitHub's tree view renders the folder's README, and the built site serves itsindex.html. The one that fails is the one that matters — MkDocs does not rewrite a bare folder link; its build log says "contains an unrecognized relative link … it was left as is" and the raw href ships to the published page, which then 404s. A plain local Markdown viewer can't open it either. That is how 635 dead links accumulated before the 2026-08 sweep (~1,000 rewritten across 400 files). Two notes: the check is the complement of the broken-link check —check_linksalready flagged folder links whose folder has no README, so between them every folder link is either named or reported; andbuild_yaml_pages.pyis a source of these too (itsMETHOD_DOCSmap and the07_Conceptsfallback feed the generated**Method:**line on all 507 case pages), so fix the generator, not its output. - A repo path in backticks must be a LINK, not bare code text — machine-checked
(
check_code_span_paths, gated bytests/test_md_links.py). Writing`07_Concepts/tips/TIPS_terminology.md`on a page under06_Other/RCV_IRV/concepts/variants/reads as "go look at this file" — but the path is root-relative while every reader resolves it from the page's own folder, so the desktop app and any local Markdown viewer open…/variants/07_Concepts/tips/TIPS_terminology.mdand report the file missing. Put the backticks in the label and a real relative path in the href:
BAD `07_Concepts/tips/TIPS_terminology.md`
GOOD [`TIPS_terminology.md`](../../../../07_Concepts/tips/TIPS_terminology.md)
This one hides better than the folder-link bug, because on GitHub and the built site the
code span is inert — it renders as grey text and 404s nowhere, so only a reader who
tries to follow it ever finds out. check_links cannot see it either (it's not a
link), which is exactly why it rotted: not being links, these were invisible to
migrate_concept_links.py during the 2026-08-02 reorganization, so four still named
pre-reorg paths (07_Concepts/residual_vote_splitting.md, split_voting/*.yaml) weeks
after those files moved. The check fires only when the path resolves from the repo
root but not from the containing page — provably a real repo file written the wrong
way round. A path that resolves from neither is left alone: that's a reference to
another codebase (BetterVoting's packages/frontend/src/i18n/en.yaml), which is what
code text is legitimately for. Generated pages are exempt (_hand_authored_pages()
skips them), so the divergence_review index may keep printing source paths as data.
- Voice — learner by default; "how to teach it" is a folder, not a mode.
The asymmetry decides it: a learner page serves a presenter fine (they read
"you score every candidate 0–5" and say "you all score…"), but a presenter
page fails a learner — someone who lands on "explain to your audience
that…" has been handed a script for a job they didn't take. Traffic runs one
way too: every inbound link from the wild delivers a learner; presenters are a
small group who deliberately walk into hands_on/. So:
- learner-you — the default. The 01_Learn spine, getting_started/,
voting_styles/, every start_here.
- reference-neutral (third person, the **One line:** … openers) — the
201/301 property and theory pages: properties_and_limits/, the_count/,
most of 07_Concepts/topics/.
- presenter-you — hands_on/ and slide decks only.
- Never two voices in one page. If a learner page needs a presenter aside,
link to hands_on/; don't switch person mid-page. (This is the drift that
let teaching_star_voting.md claim it owned STAR_start_here.md's
structure.)
- Titles name the subject, not the reader's task — "The STAR ballot &
voting styles", not "How to learn the ballot". The exception is hands_on/,
where the task is the subject ("Count a STAR election by hand").
- **Level:** — one shape, machine-checked (check_repo_hygiene.py):
**Level: <rung> · <audience>**, where rung is 101 / 201 / 301 /
401, an arrow range (201 → 301), or reference; and audience is
for voters · for presenters · for debaters · deep dive. No "Voting"
prefix, no trailing period inside the bold, no parenthetical inside the token —
put any elaboration after the closing **. The audience token is what makes
the voice rule above enforceable, so pick it before you write the page, not
after. Untagged pages are fine (the 101 spine mostly is); a malformed tag is
not.
- External sourcing — match the source to the claim, and disclose the lean.
Cite by tier: electowiki for niche/branded method definitions & mechanics
(Ranked Robin, STAR variants, exotic methods — where Wikipedia is thin, it's the
clearest); Wikipedia for the neutral family term, notability, and any
criteria/critical claim (it has NPOV pressure, electowiki doesn't); academic
(Stanford Encyclopedia, papers) for rigor (impossibility theorems, VSE math,
proofs). electowiki and the campaign sites (equal.vote, starvoting.org,
electionscience.org, fairvote.org, rangevoting.org) are advocacy-adjacent —
fine for definitions, weak for verdicts; whenever we lean on one, disclose
its lean inline (as the naming decoder, how_to_learn, and the "leans toward"
table do). Never trade the repo's neutrality for better niche coverage.
- Link key terms on first meaningful use — with restraint (a gentle habit, not
an enforced gate). The first time a page uses a jargon term that has a canonical
home (a topics/ hub, a concept page, or a GLOSSARY entry) — Condorcet
winner/loser, center squeeze, monotonicity, later-no-harm, favorite betrayal,
Equal Support, exhausted ballots, mutual majority, VSE, summability, Copeland /
Ranked Robin, spoiler effect, the impossibility theorems — link it there so a
newcomer can click through. But follow the overlinking discipline: (1) link to
aid, not decorate — skip common words and what the audience plainly knows;
(2) never self-link the page's own subject; (3) link a term once per page,
not every occurrence; (4) prefer the topic hub if one exists, else the concept
page, else the glossary; (5) in a parallel list, link all or none — when a
bullet list, table column, or run-on sentence names several methods side by side
(STAR · RCV-IRV · Ranked Robin · Plurality), linking only some of them reads as
arbitrary even when each individual choice was defensible. Decide once for the
whole list. (Rule 3 still wins: a term already linked earlier on the page stays
unlinked, and the list is consistent because of that, not in spite of it — say
so in the commit if it looks uneven. One carve-out, added 2026-08-07: rule 3
forbids repeating a destination, not linking one term at two anchors that answer
two different questions — the definition where a summary table needs it, the
evidence where the prose leaning on it sits. Rare and deliberate: 18 of 602
hand-authored pages do it, 3%. Don't reach for the looser count as licence —
pages linking any target at 2+ anchors is 92/602, but that tallies different
terms and says nothing about this one. That conflation is what made the carve-out
necessary: it was first argued from a "487 pages" figure that was neither pages
nor hand-authored, but (file, target) pairs over the whole tree.) When in
doubt, fewer links read better than
a wall of blue. (Not machine-checked — the failure mode is over-linking, not
under-linking; rule 5 is the exception, where the failure mode is partial
linking, and only a human reading the rendered page will catch it.)
- Case-folder layout — README-alone, sources in cases/ (repo standard, 2026-07-20).
In a teaching case folder, the source files (.yaml, _bv_export.json) live in a
cases/ subfolder, and only the README.md (plus any hand-authored teaching .md)
sits at the folder top — so opening the folder shows the explanation, not a wall of
data. Because the engine derives output paths from the yaml's parent
(p.parent / (p.parent.name + "_tabulated"), and the parent is now cases), the
generated _tabulated mirrors and built _pages nest inside cases/ as
cases/cases_tabulated/ and cases/cases_pages/ (e.g.
method_comparisons/black_curtain/cases/cases_tabulated/). Regenerate mirrors by
re-running the YAMLs; pages via build_yaml_pages.py; both always show full context.
New case folders follow this (README at top, sources in cases/). Folders
without a README.md keep the flat layout (yamls at top — e.g. the jfk7pd /
three_way_dead_rung_tie sub-cases, split_voting/_main); engine/tool folders and
test-fixture folders (2_negative, harness_cases) are never reorganized. Test
discovery and discover() glob both *.yaml and cases/*.yaml, so either layout works.
- Markdown prose: do NOT hard-wrap paragraphs (Adam's preference). Write each
paragraph as a single unwrapped line (soft wrap) — no fixed ~76/80-char line limit. Hard-wrapping is cosmetic: Markdown collapses single newlines inside a paragraph into spaces, so wrapped and unwrapped prose render identically. Keep real line breaks only where they're semantic: blank lines between paragraphs, fenced code blocks, tables, and list items.
- Embed LH output as text in Markdown (Adam's preference), sized to the election.
When a teaching/reporting .md discusses a result, paste the actual LH output inline
as a fenced code block (strip ANSI) rather than only linking the _tabulated file —
the reader should see the output on the page. Match the depth to the election:
- Small / simple examples → embed the short on-screen report (the on-screen render with
the file's minimal options), not a full dump.
- Large or complex elections (many ballots/candidates), or docs whose point is
the matrix / Condorcet / score-distribution detail → embed the fuller
_tabulated report, or just the specific section being discussed.
Either way, keep a link to the full _tabulated mirror too.
- Route the short snippet to the full report (Adam likes the long LH reports). When a
hand-authored teaching page embeds a short on-screen snippet and the example is a real
case file, add a one-click pointer to that case's full generated page
(…/cases_pages/<stem>.md, which carries the matrix / Condorcet / score-distribution audit)
or its _tabulated mirror — e.g. "Want the whole count? see the full LH report → …/cases_pages/<stem>.md." Keep
the crisp snippet for the lesson; the full report stays one click away and never drifts (the
link auto-updates; a pasted long report would go stale). Skip this for generic
illustrations (invented candidates with no backing case file) — a "full report" link there
is a dead end. Prefer the generated page over pasting the long report inline on a teaching
page, which buries the lesson (e.g. the runoff page is about the reversal, not the matrix).
- Engine reports get GENERATED into the page, never hand-pasted. To show a case's
count, mark the spot and let build_yaml_pages.py fill it:
```
<!-- report:<stem> -->
<!-- /report -->
```
The generator copies in the report fence from that case's generated page
(`<set>/cases/cases_pages/<stem>.md`, wrapped there in `[start:report]` / `[end:report]`
markers for exactly this), so there is still one source of truth and
`tests/test_yaml_pages_current.py::test_report_blocks_are_current` fails on drift.
Same contract as `case-meta` and `ballots:` — inside the markers is generated, outside
is yours. The `<stem>` is a bare case stem, no path: generated-page stems are unique
repo-wide. Reach for a *different* case's stem when the block is a different election —
a page can show several (`ex06_bullet_backfire.md` embeds `ex06_bullet_honest` for its
honest-ballot half).
**Do NOT use `--8<-- "…:report"` for this** (the idiom this replaced, 2026-08-04).
`pymdownx.snippets` is a MkDocs extension, so the include renders on the site and
prints as a **line of literal text on GitHub** — 82 pages showed a "the LH report"
heading followed by `--8<-- "…"` and no report at all to anyone reading the repo on
GitHub. `test_no_snippet_report_includes_remain` now fails on a new one. Snippets are
still right for whole-file embeds *inside* a fence (a `.yaml`, say):
`--8<-- "<repo-relative path>"`, paths resolving from the repo root, `title="…"` naming
the file — those degrade to a visible placeholder inside a code block rather than to
broken prose. **Never embed the `_tabulated` mirror** — it drags in its ~50-line YAML
echo and, for a big field like `Runoff_08_ca_governor_reversal_gvdy42`, 785 lines of
audit; link it instead.
`check_repo_hygiene.py::check_pasted_reports` (gated by `tests/test_md_links.py`) fails
on a new ≥8-line engine-shaped fence that is outside a `report:` block and not labelled
abridged.
- Deliberate compressions stay — label them, don't convert them. Put
title="Abridged for the lesson — not verbatim engine output"on the fence: it renders as a visible caption and satisfies the gate.bv750_tie_breaking_bloc.md'sa 15 ; b 15 ; c 15 ← three-way tieis the lesson, not stale output. - An annotated fence is NEVER convertible. If a block carries
←margin notes (Ada -- 15 ← Ada is now THIRD), a#aside, or·-joined tallies, it is a rendition the author built for the lesson — replacing it with an include deletes the annotation that was the entire point. Nine were destroyed that way before this was written down; they are restored and labelled. Annotation ⇒ abridged, always. The same goes for a curated excerpt (just the matrix, just the divergence block): the selection is authorial, so label it rather than swapping in the whole report. - Companion pages carry a generated
case-metablock. A case with both a generated page (<set>/cases/cases_pages/<stem>.md) and a hand-authored companion (<set>/<stem>.md) gets a method / seats / expected-winners line plus a full-count link under the companion's H1, written bybuild_yaml_pages.pybetween<!-- case-meta:start -->/<!-- case-meta:end -->. Don't hand-edit inside the markers or restate those facts alongside them — change the YAML and rerun the generator.tests/test_yaml_pages_current.pyfails when a block drifts. - Ballot art on a case page is drawn, not hand-embedded (added 2026-08-04).
tools_adam/scripts/build_style_ballot_images.py --from-yaml <case.yaml>draws the repo's 0–5 ballot — the same art as the voting-style gallery — one image per ballot row, into<yaml dir>/img/<stem>_ballot_<n>.png(+.svg, kept so the art stays editable).build_yaml_pages.pythen puts whatever art it finds into the page's Ballots section as a table: the marked-up ballot beside the very numbers the file records, one column per candidate, so a beginner reads a filled bubble straight across into its column. Three things to know: - Which cases get pictures is editorial; keeping them current is not.
regen_all.pyruns--refresh, which redraws (and prunes) art only for cases that already have some — so edit aballots:block and the pictures follow, but the other ~300 cases stay text. Worth drawing for the small 101 rungs (2–3 candidates, a handful of ballots) and the ballot-style sets; pointless for a 100-ballot field. - Blanks and markers (
-~&?%) draw as no mark at all. The engine counts them 0; the voter marked nothing. That gap is the whole reason the abstention cases have pictures. On an Approval ballot the same rule reads as:1fills Yes,0fills No (a real 0 is a No on a double-bubble ballot), a blank fills neither. Ranked cases are refused outright. - The title on each ballot is that row's
#comment (else "Voter N" / "N voters"), and the alt text is generated from the same parse — so a good trailing comment in the YAML is the figure caption. Long titles shrink to fit; they don't wrap. Adding comments to a case's ballot rows is a real improvement — but the_tabulatedmirror echoes the YAML, so re-run the engine on that file afterwards ortest_tabulated_mirrors_current.pyfails. - Three ballots are drawn, and only three — the 0–5 STAR grid (
SCORE_METHODS: STAR, Bloc STAR, the PR variants, Score/Range); since 2026-08-04 the Approval Yes/No double bubble (APPROVAL_METHODS, single- and multi-winner: bloc changes the count, not the paper); and since 2026-08-07 the grade ballot (GRADE_METHODS), a column per grade word for Majority Judgment. They are separate renderers, not one drawing with a flag, because they are separate pieces of paper. Everything else — Plurality, every ranked method — is refused rather than approximated; all three sets are allowlists on purpose, so a new method defaults to no picture, not to a wrong one. An Approval file holding a real score (a3) is refused too:parse_ballot_block(..., max_score=1)won't round it to a bubble. - The grade ballot is drawn from a
grades:file, which is transposed — its header names the voters and each row is a candidate, soparse_grade_block()transposes it back into one ballot per voter. Three consequences: a voter's caption cannot ride on a#comment (use the optionalvoter_notes:map, read only by the drawer); the scale travels with the ballot rather than being a constant, since a grade ballot's columns are the election's own words (grade_scale: "To Reject|Poor|…|Excellent", the pipe form both the drawer andgrade_methods_report.pyaccept alongside1-10/A-H); and its width is computed from the labels, so a page shows it at roughly twice the score ballot's width or the headings — which are the ballot — would be illegible. A grade case has no generated page, so its<!-- ballots: -->block on a hand-authored lesson is the only place a reader ever sees the art. Covered bytests/test_ballot_art.py(parser, the method allowlist, and "every drawn ballot appears on a page" — the generated one, or a lesson's block for the grade cases that have no generated page). - A hand-authored page shows its election with a
<!-- ballots:<stem> -->block — art if the case has any, otherwise the YAML's ownballots:text in a fence. The generated page's table can't be pulled in with--8<--: its<img>paths are relative tocases/cases_pages/, and a snippet is pasted verbatim, so every picture would 404 from a page at a different depth. Instead the lesson marks the spot andbuild_yaml_pages.pyfills it, with paths relative to that page:
<!-- ballots:small_abstention_c2_b5 -->
<!-- /ballots -->
Same contract as case-meta: inside the markers is generated, outside is yours, and tests/test_yaml_pages_current.py fails on drift. The stem is looked up as cases/<stem>.yaml (or <stem>.yaml) beside the page or one level up, so it works from a folder-top lesson and from a flat case folder — and failing that, searched downward through the page's own subtree (unique hit only, never widened to the repo root), so a method front door like 04_Approval/README.md can show a ballot from a case two levels below it — and failing that, resolved through the generated-page index, exactly the way a report: stem is, so a cross-method page in 07_Concepts/ can show a case that lives under method_comparisons/. (Proximity still goes first: it is the only step that can tell two same-named cases apart, and the index drops those rather than guess.) The block may sit on any hand-authored page in the repo — until 2026-08-07 discovery walked the case roots only, so a block outside them was never even looked at, got no fill and no note, and check_ballot_blocks() couldn't see it either; both markers now go through one walk. This is the mechanism that matters — a beginner usually lands on the lesson, not on the generated case page, which is exactly the gap the first pass left. When you place one, prefer it in place of a hand-typed ballot table (the block already carries the numbers) and keep the annotations by moving them into the YAML's row comments.
A case with no picture still fills the block — with the file's ballots: text in a text fence under the same "how to read" line its generated page uses (added 2026-08-08). That closes the gap that made this rule unenforceable for ranked cases: nothing draws a ranked ballot (the drawer refuses them outright), so a ranked lesson had no generated ballots surface at all, and 38 pages hand-typed their profile as a Markdown table instead — an election transcribed by hand directly above a generated report of the same file, with nothing checking that the two agreed. Write the block; don't retype the ballots. Three things that sweep taught:
- The leading column had acquired two meanings. | 9 | A > B > C | is nine voters; | 3 | Clara > Amy > Bruno | is voter #3 — and both forms are live on 07_Concepts/topics/ties/batch_elimination.md. Same failure mode as the trailing-×3 bug: a number in a ballot row whose job the reader has to infer per page. The schema form has one reading, because a parser depends on it.
- A summary table can quietly contradict its own page. copeland_vs_borda_margins/README.md weighted its rows 5/3/2/2 directly above the sentence "Twelve individual ballots, no weighting" — the case file has twelve unweighted rows, and the fence now says so.
- Annotated tables are NOT convertible, exactly as the pasted-report rule has it. Left as tables on purpose: batch_elimination.md (bold marks the one ballot that changed between its before/after pair), 05_Ranked_Robin/03_Criteria/burial/README.md (per-row gloss naming the buriers), alaska_2022/alaska_201.md (*(only)* marks the truncated ballots). And a ranked table over a score case is a deliberate ordinal projection, not a transcription — edelman_condorcet_myth.md and dark_horse_borda/README.md encode their profiles as 0–5 scores with the ranking in row comments, so the fence there would print scores into an ordinal argument. Don't "fix" those five.
- Cross-reference slides by title via 07_Concepts/LINKS.md
short names — never page numbers or #slide=id… deep links.
- Case-file naming. LH-only cases (no BV election) → descriptive name, no bvid
segment. BV-backed cases lead with the bvid — b<bvid>_<descriptor>, or
bv<testid>_<bvid>_<descriptor> when a sheet Test ID already exists; details in the
bettervoting skill. Applies to the whole case group — .yaml, .md, frozen
_bv_export.json, _tabulated mirror. Older cases keep their names; re-align
only if you're already touching them. This applies to a MULTIRACE set too — one
election backing several cases still prefixes every file (settled 2026-08-05, after
it was briefly gotten wrong). The tempting objection is that a shared prefix
"discriminates nothing"; the answer is that the suffix carries the discrimination
and the reading order (bv2145_6fj2kg_irv / _ranked_robin / _star;
bv2275_6mcgkq_a0_plurality / _ahalf_borda / _a1_negative), while the prefix makes
rg <bvid> return the whole set at once. 33 of the repo's 45 multi-yaml elections
already do this. Name the shared frozen export for the set —
bv<testid>_<bvid>_bv_export.json — and put bv_test_id / bv_election_id /
bv_results_url in every yaml of the set (that is what build_bv_registry.py
reads, so all of them index individually).
- Filed a bug upstream? Add a row to 07_Concepts/about_this_repo/upstream_bug_reports.md.
Running one election through several engines is a good bug detector, so this repo files a
fair number of reports against projects it doesn't own — BetterVoting, Larry's starvote,
once GitHub Pages. They then get forgotten, because the page that motivated the report goes
on to say something else. That page is the standing follow-up list (18 rows as of
2026-08-09, 16 open), and it holds the API one-liner that re-checks every state at once.
Two rules: it tracks only reports we opened — not every upstream issue the repo cites,
which need no follow-up from us; and a missing-guard finding in BetterVoting goes to
Arend privately before it goes on any page (the bettervoting-qa ground rule outranks
the table). When one closes, the job isn't to flip the word — it's to ask what this repo
teaches that the fix invalidates.
- BetterVoting (BV) work → load the bettervoting skill. Everything BV-specific lives
there and loads on demand: minting with create_bv_test_election.py / bv_election_specs.py,
fetching exports with fetch_bv_export.py, screenshots in img/<bvid>_<what>.png, the
clickable /results lead line, the BV→repo description backlink, the BV<n> collision
rule, BV_registry.md regeneration, and BV's method strings + bloc multi-winner.
Read it BEFORE creating or fetching any BV election — BV titles, descriptions and
numbers are PERMANENT and unfixable, so those rules have to be right the first time.
- BetterVoting documentation work → load the bv-docs skill. A different job from
the one above, so a different skill: BV's help site (docs.bettervoting.com) is Jekyll +
just-the-docs built by GitHub Pages from docs/ in the BV checkout, and the skill holds
the local Docker preview (plus its three traps), the front-matter parent: exact-match
rule, the .md link convention, the eight doc URLs the app hardcodes (two of them deep
anchors, so heading text is load-bearing), the verified election-state facts, and the
fork/PR path. Load it before writing or moving any BV help page — and note the real
bottleneck is that ~60 pages sit unpublished in Google Docs, so the job is usually
publish/dedupe/shelve rather than write.
Scratch drafting (any method)¶
Draft new scenarios in trash_delete.yaml and tabulate until the behavior shows (a tie
rung, a method divergence, a criterion failure…). Nothing there is permanent; iterate
freely, keep examples small. Two gotchas: there is no separate candidates: key —
the first line of the ballots: block is the candidate header, comma-separated; and
weighted rows use a Count: header (Count:Ada,Ben,Cara then 15:5,2,0 per bloc).
A third: the title key is election_title:, not title: — the engine accepts bare
title as an alias so a scratch file runs either way, but it is not in the documented
schema, so check_top_level_keys fails the moment that scratch case is promoted to a
real one (which is exactly how 8 files drifted before 2026-08-07).
election_title: Scratch (delete me)
voting_method: STAR
num_winners: 1
ballots: |-
Ada,Ben,Cara
5,2,0
0,4,5
2,5,4
expected_winners: [Ben]
(No options: block — the engine's defaults are the house style; add --full
to the run for the everything-on render.)
Tabulate with .venv/bin/python STARVote_LH_tabulation_engine/starvote_larry_hastings.py trash_delete.yaml.
The run writes _tabulated mirrors into a sibling <parentdir>_tabulated/ folder — for a
scratch file at the repo root that's a junk YAML_tabulated/ directory; delete it (and
the scratch files) when done, never commit them.
Promoting a scratch case to a real one: LH-only cases (no BetterVoting election — e.g.
a reproduction of a Larry starvote test file) go straight to case files + _tabulated
mirror + indexes + commit. For a BV-backed case, the full nine-step mint/freeze/
reproduce loop is in the bettervoting skill.
Engines¶
STARVote_LH_tabulation_engine/starvote_larry_hastings.py— STAR + Bloc/ proportional; reporting options;blocs:vote-splitting check; quorum;[Divergence from STAR]comparison; optionalshow_runoff_percentrunoff summary line (decided-voters denominator; forced on in_tabulated). Auto-dispatches to RCV-IRV / Approval / Ranked Robin byvoting_method, or to RCV-IRV when ballots contain ranked>(comments with->are ignored). Ranked Robin (RCV-RR / Copeland) is first-class:voting_method: RankedRobin(aliasesRCV_RR/Copeland/Consensus) prints the round-robin report (ballots + pairwise table + win-loss record), flags a Condorcet cycle, and writes its_tabulatedmirror — it does not fall through to the IRV rounds. Bloc RR (multi-winner):num_winners > 1now elects the top-N by record (most wins → margin → lot), printing a seats list and flagging a lot-decided last seat — it no longer silently downgrades to one winner. Multi-winner Plurality = SNTV / Bloc Plurality (run_plurality_multi):Plurality+num_winners > 1elects the top-N by first-choice count (ties → lot); single-winner Plurality still routes through the STAR path. So LH multi-winner coverage is now complete for BV's bloc set: STAR→Bloc STAR, Approval→Approval_Multi_Winner, RankedRobin→Bloc RR, Plurality→SNTV, plus STV and STAR_PR/allocated/sss/rrv. (The old "LH has no Plurality" caveat is retired — single-winner via STAR path, multi-winner via SNTV.) RR triple-check — always cross-verify a Ranked Robin case three ways: this native tally, BetterVoting'sRankedRobin.ts(the frozen_bv_export.jsonResults), andpref_voting's independent Copeland viatools_adam/pref_voting_tabulation_engine/ranked_robin_report.py(declared inpyproject.toml;uv syncthenuv run …). Thepref_votingleg is the third-party cross-check — a library nobody here wrote — and it is the one that makes an RR result trustworthy rather than self-confirming, so run it on every RR case, not just the awkward ones. On a tie it reports the whole Copeland leader set and declines to pick, then tells you whether LH's winner sits inside that set (CONSISTENT ✓) — which is exactly the check you want, since the disagreement between engines is never about the tally, only about the tiebreak.
Tiebreak ladder — LH and BV diverge at rung 2: LH breaks a Copeland tie by
total margin → lot (lot_numbers, published in the YAML); BV by head-to-head
(2-way only) → its rung of last resort.
BV's JSON export records the tie-breaking SEQUENCE just fine — don't repeat the
old "can't be frozen" claim (corrected 2026-07-29). BV's rung 3 is labelled
"random" but is a seeded shuffle, documented as deliberately deterministic in
shuffleCandidatesForRandomTiebreak.ts: seed = (rawVoteCount + hash(raceId)) >>> 0,
shuffled once by TinyRand, each candidate's index written back as tieBreakOrder.
The export publishes the complete order — perm (ids in tiebreak order),
per-candidate tieBreakOrder, tied[] and other[] sorted by it, tieBreakType,
and a logs line — so winner and runners-up survive, and a re-tally reproduces
them. Pin lot_numbers: to BV's perm and LH replays the draw exactly. Verified
live at 3 candidates (BV2261 y2fbpc) and 9 (BV2262 2gvwr9, all nine
positions matched). Replay the shuffle yourself with
tools_adam/bv_replay_tiebreak.py <frozen export> (stdlib-only Python port).
The real limit is narrower: BV's order is recorded but not derivable — a
function of the ballot count and the race id, never of how anyone voted —
so a case whose winner turns on it is still LH-only (only LH's published
lot lets a reader derive the result from the file). Publishing such a case on BV is
fine when the recording mechanism is the subject and the page says to ignore who
won. Worked: 05_Ranked_Robin/01_Learn/rr_tiebreak_lh_vs_bv.md,
05_Ranked_Robin/03_Criteria/rr_tiebreaks/bv2261_…md / bv2262_…md.
- Minimax and Coombs are tabulable (added 2026-08-07) —
tools_adam/pref_voting_tabulation_engine/minimax_report.py and
coombs_report.py, each cross-checked against pref_voting on every run.
Neither method exists in the LH engine or on BetterVoting, which is why
Felsenthal's §A7 and §A10 examples were prose for so long; all 18 are now
runnable case files in method_comparisons/felsenthal_paradoxes/cases/.
Two things to know before quoting a Minimax result: "worst loss" has three
published readings (winning votes = Felsenthal's, margins = pref_voting's,
pairwise opposition), which agree on an odd electorate with no drawn pairs and
need not otherwise; and a truncated ballot's unstated pair is a convention,
not arithmetic — this repo counts it for neither candidate, Felsenthal splits
it ½–½ (--equal-prob), and Example 31's winner changes with the choice. Say
which convention a number came from.
- Successive elimination and the grade methods are tabulable too (added
2026-08-07) — successive_elimination_report.py (the parliamentary agenda
procedure) and grade_methods_report.py (Range = mean, Majority Judgment =
median + Balinski–Laraki), completing Felsenthal's five uncountable
procedures. Two things that are easy to get wrong: successive elimination
takes the agenda as an argument, not a default — under a cycle the
agenda-setter picks the winner, so --agenda is required and a tied round is
broken by an explicitly-chosen --tiebreak (the published examples disagree:
alphabetical in Ex.11/12, "random" in Ex.10). And grade cases are not LH
election files: Felsenthal's 1–10 and A–J scales fit neither the engine's
0–5 validation nor BetterVoting, so they carry a grades: block instead of
ballots: — which keeps them invisible to check_top_level_keys and
check_descriptions (both gate on ballots) and means no _tabulated
mirror and no generated page. Their counts live on the concept pages.
Rescaling to 0–5 to make them engine-runnable would change the published
numbers, which is why it wasn't done. A grade file's scale may be words
(grade_scale: "To Reject|Poor|Acceptable|Good|Very Good|Excellent"), which is
what Majority Judgment actually asks for — B&L's claim is not "six levels" but
a shared common language, so the method's own front door
(06_Other/Majority_Judgment/) uses it and its ballots are drawn. Two
published tie-breaks, not one: this tool implements the iterative rule
(strip a shared median, recompute, repeat); pref_voting implements the
majority gauge (share above the median vs share below), and on a profile
where both tied candidates have more detractors than supporters at the median
the gauge as implemented compares only the losing shares and returns a tie
where the iteration separates them — an observed DISAGREE, not a bug in either.
Say which reading a number came from.
- 06_Other/RCV_IRV/RCV_IRV_tabulation_engine/rcv_irv_tabulation.py — vendored pyrankvote; reads
ranked (A>C>B) or score ballots.
- 06_Other/abcvoting_tabulation_engine/abc_tabulation.py — multi-winner Approval (ABC)
rules via Martin Lackner's abcvoting (in the dev dependency group since
2026-08, so uv sync brings it in and the cross-check actually runs — locally
and in CI). av doubles as an independent cross-check of the LH bloc-Approval
count; seqpav / pav / seqphragmen add the proportional rules the LH
engine doesn't have. Tested by tests/test_abcvoting_crosscheck.py (still
guards on the import for bare-pip environments).
- Score / range voting & the 0–5 cap (don't misstate this). Larry's underlying
starvote engine is range-parametric: starvote.election(starvote.star, rows,
maximum_score=N) tabulates any range (verified at 0–10 → C). The 0–5 limit is the
fork's teaching guardrail, NOT an engine limit — validate_star_rows(…,
max_score=5) in starvote_larry_hastings.py (def ~L2239, called with max_score=5
~L2366) rejects scores >5 on the YAML-CLI path because STAR ballots are 0–5 by
convention; it's a single adjustable arg. Pure Score / Range IS tabulable — via
pref_voting.grade_methods (score_voting = mean, greatest_median = the median
variant, plus star / approval / majority_judgement), starvote's RRV
(Reweighted_Range_Voting, range-based PR), and the sim/divergence tools
(06_Other/simulations/star_vs_approval_divergence.py, tools_adam/find_divergence.py)
which compute the score-total winner. The STAR Scoring Round output is itself the
score tally (the Score-Voting winner = whoever leads the scoring round before the
runoff). What's absent is only a first-class voting_method: Score on the teaching
CLI — capability is not the blocker.
- Quick checks can use system python3 (engines are vendored); the user runs via
their .venv / uv.
- The engine errors clearly (no tracebacks) for the common mistakes: bad YAML,
no ballots: block / old nested schema (prints the key-components template),
wrong column counts, invalid chars / out-of-range scores, ranked ballots under a
score method, and method/seats mismatches. Missing voting_method / num_winners
is a non-fatal NOTE (defaults to STAR / 1). Generated _tabulated.txt files are
refused as input.
Tests¶
STARVote_LH_tabulation_engine/tests/test_single_winner_positive.py— every single-winner STAR file withexpected_winners(in01_STAR/,method_comparisons/,YAML_library/1_positive/) is run through the CLI (which also writes its_tabulatedcopy) and checked for exit 0 + correct winner.…/tests/test_harness_selfcheck.py— meta-tests proving the winner check isn't vacuous: deliberately-wrong answer keys (single- and multi-winner) intests/harness_cases/must NOT match the engine's real result.…/tests/test_json_to_yaml_conversion.py— guards the BetterVoting-JSON → YAML pipeline (YAML_library/1_positive/01_convert_json_yaml.py): converts a real export in an isolated tmp dir and checks the produced YAML tabulates to the embedded winners (catches engine-signature drift like theparse_ballots_from_stringarity bug).…/tests/test_negative_validation.py— malformed fixtures (intests/negative_cases/and the migratedYAML_library/2_negative/) must exit 1 with the right message and no traceback; covers single messages and multiple-errors-in-one-file.- Run:
pytest tests/test_single_winner_positive.py tests/test_negative_validation.pyfrom the engine dir. A repo pre-commit hook (STARVote_LH_tabulation_engine/tools_adam/scripts/git-hooks/, wired viagit config core.hooksPath STARVote_LH_tabulation_engine/tools_adam/scripts/git-hooks) runs these on every commit.
Git¶
- Commit after every significant addition or completed piece of work (Adam's
standing rule) — don't leave finished work sitting uncommitted. Write a real
commit message: short imperative summary line, then a body listing what
changed and why. Include regenerated
_tabulated/_pages/index files in the same commit as their source changes. - This checkout is often open in two sessions at once, sharing one working tree,
one index, and one HEAD — so a pathspec commit is not as scoped as it looks. The
mechanism is worth knowing, because it is not obvious: for
git commit -- <paths>git builds a temporary index and pointsGIT_INDEX_FILEat it, so anything the pre-commit hook stages lands in your commit even though you scoped it. (git commit,-aand--amendall get the main index instead.) The hook auto-stages the four regenerated index/registry surfaces, and until 2026-08-06 it did so with a folder-widegit addthat adopted everything dirty in those directories: a 3-file commit landed as 18, another as 11, once carrying a concurrent session's deletions. The hook now hashes its output paths before and after each generator and stages only what that run actually changed, so a colleague's untouched edits are left alone (stage_regenerated, covered bytests/test_precommit_staging.py— which tests both directions, since staging too little silently ships a stale index). What still legitimately rides along is a regenerated index reflecting work the other session has already committed; that is the hook doing its job. So keep the verification habit:git show --stat HEADafterwards, and specificallygit show --diff-filter=D --name-only HEADfor deletions. Additions and coherent regenerations are harmless — leave them and say so in your report. Deletions are the case to stop on: if one removes a file something still links to, don't push — work out whether the other session's generator pruned it deliberately (is it also gone from disk? does anything still reference it?) or whether it's a half-finished state you'd be freezing. - Adding an entry to a shared index file? Re-fetch and check
origin/masterimmediately before the commit — not just at the start of the task.07_Concepts/GLOSSARY.md, theYAML_test_case_indextables,PARADOX_index.md, a folder README's case table: these are where sessions converge, because writing the index entry is the natural last step of whoever built the thing being indexed. Worktree isolation makes this worse, not better — a colleague's entry sits on their branch with no trace in this working tree until they push, so the tree looks empty right up to the moment it isn't. 2026-08-09: asked for aVote unitarityglossary entry, a main-tree session checked, correctly found none, and wrote one — while the worktree session that had just built03_STAR_PR/03_Criteria/vote_unitarity/README.mdwas writing its own as the last step of that same job. Both were right when they looked; the two entries collided on the merge an hour later and one was discarded. Note what was stale: the page was already upstream when the task began, and the entry appeared between the first check and the commit — which is exactly why the fetch has to be late. No git setting prevents this; it is a coordination gap, not a merge conflict. - Don't rewrite history to unpick a sweep, even unpushed. A
reset/rebasein a shared checkout drops the other session's commits into the working tree. Report the muddled attribution instead — the content is what matters, and it's recoverable. - Transient breakage is normal while the other session is mid-operation. A held
.git/index.lock(wait for it, never delete it), agit ls-filesthat reports committed files as untracked, and hygiene/test runs that suddenly report huge numbers of failures — 101 broken links and 4 failing tests in one run on 2026-08-05 — are almost always someone else's half-applied rename, not your bug. Wait for the tree to settle and re-run before "fixing" any of it. Nevergit stashhere. check_repo_hygiene.pywarns about links whose target isn't committed yet (check_untracked_link_targets) — the one failure the other checks structurally cannot see, becausecheck_links()resolves against the working tree where the file exists, while CI builds the committed tree where it doesn't andmkdocs build --strictthen fails the whole docs deploy. If it fires on another session's in-flight files, that's not noise: wait for them to land before pushing, or you redden the build for everyone.
When unsure¶
Consistency matters more than cleverness here. If a terminology or convention
question isn't covered by the 07_Concepts/ docs, ask rather than guess.
Agent operations (Claude-specific)¶
Everything below is operational instruction from Adam to Claude — human contributors can stop reading here.
File access (standing permission from Adam)¶
Adam grants Claude permission to read, edit, and delete files anywhere in
this repo's working copy — whether that's the T7 checkout
(/Volumes/T7/Voting/Larry Hastings/YAML) or a fresh clone of
masiarek/star-voting-library. No need to ask before modifying or removing
files here as part of a requested task. (Note: file deletion is also gated by
the app's permission layer, so a new session may still prompt once to enable
it — approve and proceed.)
Working autonomy (standing instruction from Adam — 2026-07-24)¶
Be self-driven. Don't wait for approval. Adam's words: "You have very good sense of direction — I agree with 99%, and even if we go the wrong direction these are educational materials, mostly. This is not critical code that must be carefully reviewed. I am reading these pages and coming back to you if I find something questionable. So please be more self-driven, no need to wait for my approvals."
So: when you've analyzed a task and have a clear, sensible direction, just do it — build the page, write the case, make the edit, commit, and push. Don't stop to ask "want me to build this?" or offer a menu of options and wait. Tell Adam what you did (a short summary + what changed), and flag anything genuinely uncertain so he can spot-check while reading. Adam is the reviewer-after-the-fact, not a gate you wait at.
Still confirm for the genuinely irreversible / outward-facing (per the safety rules): creating permanent public BetterVoting elections (they can't be deleted — lock the ballot set first), sending anything on Adam's behalf, or a destructive action you can't undo. Everything internal to this repo — edits, new pages, deletions, commits, pushes (standing auth) — proceeds without asking.