Skip to content

Regenerating the derived files

Level: reference · deep dive

Most of what this repo publishes is generated — the per-election pages, the by-method index, the catalog, the BV registry, the divergence ledger, the paradox index, the _tabulated mirrors, the ballot art. Only the .yaml election files and the hand-authored teaching Markdown are typed by a person.

That means "I changed a case, what do I rebuild?" has a real answer, and getting it wrong fails silently — the generators don't error, they just write something slightly wrong that nobody notices until CI, or until a reader clicks a dead link. This page is the answer, plus the four traps that have actually bitten.

The one-command version:

.venv/bin/python STARVote_LH_tabulation_engine/tools_adam/scripts/regen_all.py

Read the first word of that command before you read the rest of this page. It is the trap that cost the most.


What runs, and in what order

regen_all.py runs nine generators. The order is load-bearing for the first five — each consumes an earlier one's output — and free for the rest, which only read the .yaml sources.

flowchart TD
    yaml["election .yaml files<br/>(the only hand-typed source)"]

    art["build_style_ballot_images --refresh<br/>ballot art"]
    sankey["build_sankey --refresh<br/>ranked-case Sankeys"]
    div["build_divergence_index<br/>divergence_review/"]
    pages["build_yaml_pages<br/>per-election pages"]
    index["build_yaml_index<br/>by-method index"]

    cat["build_catalog<br/>CATALOG + races.csv + elections.csv"]
    bv["build_bv_registry<br/>BV_registry + bv_cases.csv"]
    multi["build_multirace_index<br/>multirace_elections"]
    para["build_paradox_index<br/>paradox tag index"]

    yaml --> art
    yaml --> sankey
    yaml --> div
    art -->|"decides which pages show a picture"| pages
    sankey --> pages
    div -->|"pages LINK the divergence case"| pages
    pages -->|"index checks each page EXISTS"| index

    yaml --> cat
    yaml --> bv
    yaml --> multi
    yaml --> para

The four on the right only read the sources, so their relative order doesn't matter; they run last so anything they link already exists.

Generator Writes
build_style_ballot_images.py --refresh ballot art, only for cases that already have some
build_sankey.py --refresh round-by-round Sankeys, same refresh-only rule
build_divergence_index.py method_comparisons/divergence_review/ — INDEX, csv, per-case pages
build_yaml_pages.py <set>/cases/cases_pages/*.md
build_yaml_index.py the by-method index
build_catalog.py CATALOG.md + races.csv + elections.csv
build_bv_registry.py BV_registry.md + bv_cases.csv
build_multirace_index.py multirace_elections.md
build_paradox_index.py PARADOX_index.md + paradox_cases.csv

Three things regen_all.py deliberately does not do: it does not rebuild the _tabulated engine mirrors (those come from re-running each YAML through its engine), it does not stage anything, and it does not commit. Add --check to run the read-only checkers afterwards.


Trap 1 — use .venv/bin/python, never bare python3

The generators and engines degrade gracefully when an optional dependency is missing. That is good behaviour at runtime and a disaster during a regeneration, because the degraded output looks fine.

Observed 2026-08-16, ten files:

-Cross-check — pref_voting score_voting: Beth  (✓ agrees with the hand count)
+Cross-check — pref_voting not installed; hand count only (install `pref_voting` to enable the independent check).

The venv had pref_voting 1.18.1 the whole time. Someone regenerated with the system interpreter, and every affected mirror and page quietly dropped the third-party verification — the leg that makes a Range or CAV result trustworthy rather than self-confirming. Committing that would have made the repo claim less verification than it actually has, on exactly the pages whose job is to show the count is right.

The same failure mode hits PyYAML (mirrors regenerate with a degraded YAML echo) and abcvoting. There is no warning line and no non-zero exit — that is the whole problem.

Rule: every regeneration command in this repo starts .venv/bin/python. If you use uv run, that is equivalent.

Trap 2 — git add a new case before building the divergence ledger

build_divergence_index.py discovers cases through git ls-files, which lists tracked and staged paths. A brand-new .yaml that has not been git added is invisible to it, so the case gets no divergence entry — and then build_yaml_pages.py finds no divergence case to link, and the page ships without it. No error at any step.

So landing a new case has an order, and git add is inside it rather than at the end:

flowchart LR
    write["write the .yaml"] --> tab["run it through the engine<br/>(writes the _tabulated mirror)"]
    tab --> add["git add the .yaml"]
    add --> regen["regen_all.py"]
    regen --> commit["git commit<br/>(include divergence_review/)"]

The reverse also matters: divergence_review/ is generated but not staged for you unless the pre-commit hook rewrote it, so include it in the commit yourself.

Trap 3 — a scratch file in a case folder poisons the whole set

Every generated page carries a "More cases in this set" line listing its siblings. That list is built from whatever .yaml files are sitting in the folder — tracked or not.

Observed the same day: an untracked probe file named _yaml_type_probe.yaml, sitting in the 02_Examples case folder, left over from testing a hygiene check. A single build_yaml_pages.py run gave it a page and inserted a link to it into the sibling line of all 31 pages in that set. Committing that would have published 31 links to a file that does not exist in the repository — precisely the failure check_untracked_link_targets exists to catch, and the one that reddens the docs deploy for everyone.

Rule: delete scratch and probe files before regenerating. CLAUDE.md already says never to commit them; this is the second reason. A quick check before a big rebuild:

git status --porcelain --untracked-files=all | grep -E '/_|/trash_delete'

Trap 4 — the pre-commit hook covers four surfaces, not nine

The pre-commit hook refreshes and stages four generated surfaces — the divergence ledger, the multirace index, the by-method index, and the catalog — then runs a pytest subset. It is a safety net, not the regeneration.

What it does not touch: the per-election pages, the BV registry, the paradox index, the ballot art, the Sankeys, and every _tabulated mirror. And its pytest subset includes test_yaml_index_current, test_catalog_current and test_divergence_index_current but not test_yaml_pages_current — so stale generated pages are caught only by CI on master, not by your commit.

Two more things about the hook worth knowing before you read a surprising git show --stat:

  • Each refresh block is non-blocking. A generator that dies leaves a stale file and says so in a line that scrolls past.
  • It stages only the paths its own run changed (hashed before and after), specifically so a concurrent session's unrelated edits in the same directory are not adopted. That mechanism exists because a pathspec commit builds a temporary index, so anything the hook stages lands in your commit even when you scoped it.

Quick reference

I changed… Run
a case's ballots: or options the engine on that file, then regen_all.py
added a new case engine → git add the yaml → regen_all.py
a bv_* field regen_all.py (registry + catalog)
a case's ballot-row # comments the engine on that file (the mirror echoes the YAML)
a generator itself regen_all.py --check, then read the diff before staging

Related: Repository & Engine Guide · How the website is built · the test suite