diff --git a/.gitignore b/.gitignore index 73beb9b..0d8db24 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,4 @@ frontend/dist/ .env.* !.env.example *.local +var/ diff --git a/CMakeLists.txt b/CMakeLists.txt index 29c4e65..842314e 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -8,25 +8,32 @@ set(CMAKE_CXX_STANDARD 23) set(CMAKE_CXX_STANDARD_REQUIRED ON) set(CMAKE_CXX_EXTENSIONS OFF) -find_package(modulus CONFIG REQUIRED COMPONENTS boost_json openssl) +find_package(modulus CONFIG REQUIRED COMPONENTS boost_json openssl sqlite) find_package(ligo CONFIG REQUIRED) +find_package(noto CONFIG REQUIRED) find_package(Threads REQUIRED) +add_library(fpol_core src/domain.cpp src/store.cpp) +target_link_libraries(fpol_core PUBLIC modulus::modulus modulus::boost_json noto::noto Threads::Threads) +add_executable(fpol_admin src/admin.cpp) +target_link_libraries(fpol_admin PRIVATE fpol_core) add_executable(fpol_server src/main.cpp src/server.cpp) -target_link_libraries(fpol_server PRIVATE modulus::modulus ligo::ligo Threads::Threads) -target_compile_options(fpol_server PRIVATE $<$:-Wall;-Wextra;-Wpedantic>) +target_link_libraries(fpol_server PRIVATE fpol_core ligo::ligo) +foreach(target fpol_core fpol_admin fpol_server) + target_compile_options(${target} PRIVATE $<$:-Wall;-Wextra;-Wpedantic>) +endforeach() if(BUILD_TESTING) find_package(Python3 REQUIRED COMPONENTS Interpreter) add_test(NAME product_fpol - COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/tests/test_server.py $ + COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/tests/test_all.py $ $ ) - set_tests_properties(product_fpol PROPERTIES TIMEOUT 60) + set_tests_properties(product_fpol PROPERTIES TIMEOUT 180) add_custom_target(check COMMAND ${CMAKE_CTEST_COMMAND} --test-dir ${CMAKE_BINARY_DIR} -R ^product_fpol$ --output-on-failure - DEPENDS fpol_server + DEPENDS fpol_admin fpol_server USES_TERMINAL ) endif() -install(TARGETS fpol_server RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR}) +install(TARGETS fpol_server fpol_admin RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR}) diff --git a/README.md b/README.md index 7d0216b..8b79527 100644 --- a/README.md +++ b/README.md @@ -1,28 +1,33 @@ # Fantasy Politics -`product-fpol` is a private, noncommercial fantasy-style game for friends around the 2026 US midterm elections. The eventual game will have a candidate draft, trades, and final standings based on real-world candidate performance. +`product-fpol` is a private, noncommercial fantasy politics game for friends around the 2026 US midterms. This first playable development slice lets you browse candidates, build a 12-slot party, check league capacity and replay fictional election-night scoring. -This repository currently contains only the foundation: a basic webpage, a C++ HTTP server, and development tooling. There are no accounts, candidate records, league rules, drafts, trades, scores, or election data yet. +The backend is **C++23**, with **Modulus** for builds, **Ligo** for HTTP, and **Noto/SQLite** for immutable catalog, rating and results snapshots. The frontend is TypeScript/Vite. Accounts, live drafts, cross-team ownership, trades, automatic data ingestion and deployment are later milestones. -## Prerequisites +## Start developing -- Linux with Docker available to your user and Python 3 for the backend tooling. -- Node.js 22.12 or newer and npm for the frontend; CI uses Node 24. -- Network access on the first setup to fetch npm packages, the build image, and public Conan dependencies. - -Modulus supplies the shared C++23/GCC 12 build environment, Conan/CMake integration, formatting, and editor configuration. Ligo supplies HTTP networking and routing. Both are consumed from the public khodo Conan registry; sibling source checkouts are not required. Package reads do not need registry credentials. - -## Development - -From the repository root, configure and build the backend: +Prerequisites: Linux, Docker available to your user, Python 3, and Node.js 22.12+ with npm (CI uses Node 24). Initial setup requires network access for public Conan packages, the build image and npm dependencies. Sibling library checkouts are not required. ```sh python3 modulus.py configure python3 modulus.py build +``` + +Seed the development database with explicitly fictional candidates, ratings and results. Run the built admin executable from the repo root in the build environment, or directly on a compatible Linux host: + +```sh +build/build_x86_debug/fpol_admin import \ + data/demo/catalog.json data/demo/ratings.json \ + data/demo/results-0.json data/demo/results-1.json \ + data/demo/results-2.json data/demo/results-3.json data/demo/results-4.json + +# Optional: a separate, partial real catalog. It has no real ratings/results. +build/build_x86_debug/fpol_admin import data/real/mi-2026-10-04.json + python3 modulus.py run ``` -In another terminal, start the frontend: +In another terminal: ```sh cd frontend @@ -30,67 +35,75 @@ npm ci npm run dev ``` -Open **http://127.0.0.1:5173**. Vite reloads frontend edits and proxies `/api` to **http://127.0.0.1:8080**. Rebuild and restart the backend after C++ changes. Both configured ports fail explicitly when occupied. +Open **http://127.0.0.1:5173**. Select the fictional catalog, click **Try an example roster**, then **Play replay**. Search/filter the field, change picks and compare points with/without race multipliers. Vite proxies `/api` to port 8080. Restart the backend after C++ changes; refresh the browser after imports. The roster is an in-page sandbox and is cleared on refresh or catalog change. -Check the backend directly or through Vite: +Data is stored at `var/fpol.sqlite` (ignored by Git), relative to the working directory. Both executables accept `--database PATH`; use the same path when importing and serving. Importing the same document again is harmless. Corrections require new snapshot IDs, preserving the earlier result for replay. `python3 tools/generate_demo.py` deterministically regenerates only fictional fixtures. + +The standard Modulus runner launches `fpol_server` inside its container, publishes port 8080 only on host loopback, and mounts the repository so the development database persists. For local direct builds, launch the built server executable instead. There is no hosted environment or authentication yet. + +## Scoring to try + +Every office — House, Senate, Governor — has a Democrat, Republican, Other and Flex slot. Other includes independent and third-party candidates; Flex takes any affiliation in that office. Each candidate can appear only once in a roster, and opposing candidates may both be picked. + +`(vote share percentage + 50 if called/certified winner) × frozen race multiplier` + +| Rating | Multiplier | Example | +| --- | --- | --- | +| Safe | 1.00 | 80% winner → 130 points | +| Likely | 1.10 | 60% winner → 121 points | +| Lean | 1.25 | 54% winner → 130 points | +| Toss-up | 1.50 | 51% winner → 151.5; 49% loser → 73.5 | + +Candidates are rounded half up to two decimals before summing. Reporting shares score without a win bonus; the current-leader preview is hypothetical and displayed separately. A missing count/rating is unknown, never an invented zero. Only a filled, fully scored roster gets a complete total. Corrections can retract a call. The replay demonstrates unreported votes, early totals, changing leads, calls and corrections. All five frames are synthetic, not a historical backtest. + +## Admin and API ```sh -curl http://127.0.0.1:8080/api/health -curl http://127.0.0.1:5173/api/health +build/build_x86_debug/fpol_admin --help +build/build_x86_debug/fpol_admin capacity data/demo/capacity.json +build/build_x86_debug/fpol_admin score-preview data/demo/preview.json +build/build_x86_debug/fpol_admin catalog ``` -The only application API is `GET /api/health`, which returns HTTP 200 using Ligo's existing envelope: +The last three commands use the same C++ handlers as `POST /api/capacity`, `POST /api/score-preview` and `GET /api/catalog`. `GET /api/rules` describes the slots and scoring. `GET /api/health` is process liveness. Ligo returns `{ "ok": true, "payload": ... }`, or an error envelope. No admin write endpoint is exposed. -```json -{"ok":true,"payload":{"status":"ok"}} -``` +See [the data/API contract](docs/data-contract.md) for schemas, immutable import behavior, input bounds and round handling. The capacity report checks the per-party and total candidate supply for 2–12 players; it does not certify ratings or results coverage. -Unknown routes return HTTP 404 with Ligo's error envelope. Health is a process liveness check; there is no database or external data source to check yet. +## Data readiness -## Built frontend +**The real league is not ready to draft.** The included Michigan snapshot contains 73 candidates in 15 races; the remaining states, real ratings and live result/call coverage are unresolved. Switching to that catalog shows those gaps rather than fictional replacements. An official candidate list may change and must be reconciled before use. -Build the frontend, then serve it with the C++ backend: +Read the dated [source feasibility report](docs/data-coverage.md) and [office/state coverage matrix](data/coverage_2026.csv). Free automatic updates remain a launch requirement. No provider transport or national coverage is claimed verified, and no background scraper/poller has been enabled. Runoff/ranked-choice resolution also requires a settled policy before affected races can launch. + +## Serve the built frontend ```sh npm --prefix frontend run build python3 modulus.py run -- --web-root frontend/dist ``` -Open **http://127.0.0.1:8080**. Node is only needed to build the assets; the C++ server serves the resulting HTML, JavaScript, and CSS. Assets are loaded at startup, so restart the server after rebuilding. `npm --prefix frontend run preview` is also available on port 4173, with the same API proxy, for local frontend previews. +Open **http://127.0.0.1:8080**. Assets load at startup, so restart after rebuilding. Node is not needed to serve built assets. The server accepts `--host`, `--port`, `--web-root`, `--database` and `--help`; defaults are `127.0.0.1:8080`, API-only, and `var/fpol.sqlite`. Port 0 selects an available port. SIGINT/SIGTERM stop it cleanly. Exact registered static paths are served; hidden files, symlinks and `/api` asset collisions are excluded. Use only Vite output as the web root. `npm --prefix frontend run preview` is available on port 4173 with the same API proxy. -The server accepts `--host ADDRESS`, `--port PORT`, `--web-root DIRECTORY`, and `--help`. It defaults to `127.0.0.1:8080` and API-only mode. An explicit web root must contain `index.html`. Only exact registered asset paths are served; there is no SPA fallback or query-string normalization in this scaffold. Hidden files and symlinks are excluded, and `/api` is reserved. Use only the Vite build output as the web root. - -The Modulus runner binds the server to `0.0.0.0` **inside its container**, publishing port 8080 only on the host's loopback interface. An installed executable defaults to loopback. Port `0` selects an available port and is useful for tests. SIGINT and SIGTERM stop the server cleanly. - -## Checks and editor support - -```sh -python3 modulus.py test -python3 modulus.py format --check -npm --prefix frontend run build -``` - -The frontend build includes strict TypeScript checking. `npm --prefix frontend run typecheck` runs that check alone. The backend CTest smoke suite starts real server processes to check health, routing, binary/static assets, startup errors, port conflicts, and shutdown. - -Check both native configurations: +## Verify changes ```sh python3 modulus.py configure --all python3 modulus.py build --all python3 modulus.py test --all +python3 modulus.py format --check +npm --prefix frontend run build ``` -Configure generates ignored editor files. Customize `modulus.json`, not those generated files; refresh compile databases with `python3 modulus.py lsp`. C++ and Python use the shared Modulus formatting policy and full names rather than aliases. Run `python3 modulus.py format` before committing. +CTest's `product_fpol` suite runs the domain/persistence tests through the production admin executable, then HTTP process tests for API envelopes, validation, static assets, startup errors, occupied ports and shutdown. The frontend build includes strict TypeScript checking. For socket-restricted environments, core checks can also run independently: -The checked-in `modulus.py` is copied unchanged from Modulus's canonical `modulus/bootstrap.py`. `configure --update` refreshes shared tooling and packages. `--no-remote` on configure uses only the project-local Conan cache, which must already contain dependencies. Keep build outputs and downloaded tooling untracked. +```sh +python3 tests/test_core.py build/build_x86_debug/fpol_admin +``` -The Gitea workflow checks the frontend build plus backend formatting, debug/release builds, and tests. It has no publishing or deployment job. Its direct build mode provisions the shared compiler and tools on the CI runner, following the sibling repositories' convention. CMake installation installs the backend executable; distribute built frontend assets separately when deployment is added. +Passing core checks does not replace the HTTP suite. See [the local verification record](docs/verification.md) for the compiler and environmental limitations of this implementation session. CI retains the shared GCC 12 debug/release builds and full test suite. -## Future product work +Configure generates ignored editor support; customize `modulus.json` and refresh with `python3 modulus.py lsp`. C++ and Python follow shared Modulus formatting; run `python3 modulus.py format` before committing. The checked-in bootstrap remains byte-identical to Modulus's canonical bootstrap. `configure --update` refreshes tooling/packages; `--no-remote` needs an already-populated project cache. CMake installs both executables; distribute frontend assets separately. -- Define league rules, eligible races and candidates, drafting, trades, and scoring together before introducing domain models. -- Add persistence when needed; Noto is the existing SQLite library to evaluate then. -- Choose an election data source later. Live feeds, scraping, update schedules, and correction handling remain undecided; this scaffold makes no external election requests. -- Add accounts, invitations, deployment, and operational requirements when the game needs them. +## Next milestone -Keep reusable networking and infrastructure in the shared libraries and product rules in this repository. Luma and Sonus are native UI/audio libraries and are not dependencies of this web application. +Clear the data gates first. Then add the private league and invitations, persist team ownership, freeze the chosen catalog/ratings before a live snake draft, add trades/locks, and connect verified polling to the existing snapshot/import layer. Election-night standings can reuse the deterministic scorer and correction history. Keep product rules here and reusable infrastructure in the shared library repos. diff --git a/conanfile.py b/conanfile.py index e7a01fd..32b9c12 100644 --- a/conanfile.py +++ b/conanfile.py @@ -5,7 +5,7 @@ import conan.tools.cmake class product_fpol_conan(conan.ConanFile): name = "product_fpol" version = "latest" - description = "Fantasy Politics web application scaffold" + description = "Fantasy Politics catalog and scoring prototype" package_type = "application" settings = "os", "arch", "compiler", "build_type" options = {"with_tests": [True, False]} @@ -17,10 +17,12 @@ class product_fpol_conan(conan.ConanFile): "src/*.cpp", "src/*.hpp", "tests/*.py", + "data/demo/*.json", ) def requirements(self): - self.requires(str(self.python_requires["modulus"].ref), transitive_headers=True, transitive_libs=True, options={"with_openssl": True}) + self.requires(str(self.python_requires["modulus"].ref), transitive_headers=True, transitive_libs=True, options={"with_openssl": True, "with_sqlite": True}) self.requires("ligo/latest", transitive_headers=True, transitive_libs=True) + self.requires("noto/latest", transitive_headers=True, transitive_libs=True) def generate(self): dependencies = conan.tools.cmake.CMakeDeps(self) dependencies.generate() diff --git a/data/coverage_2026.csv b/data/coverage_2026.csv new file mode 100644 index 0000000..95cb688 --- /dev/null +++ b/data/coverage_2026.csv @@ -0,0 +1,151 @@ +state,office,scheduled_2026,catalog,ratings,civicapi_results,civicapi_calls,openamerica_results,openamerica_calls,checked_date +AL,house,yes,missing,missing,unverified,unverified,missing,missing,2026-10-04 +AL,senate,yes,missing,missing,unverified,unverified,missing,missing,2026-10-04 +AL,governor,yes,missing,missing,unverified,unverified,missing,missing,2026-10-04 +AK,house,yes,missing,missing,unverified,unverified,missing,missing,2026-10-04 +AK,senate,yes,missing,missing,unverified,unverified,missing,missing,2026-10-04 +AK,governor,yes,missing,missing,unverified,unverified,missing,missing,2026-10-04 +AZ,house,yes,missing,missing,unverified,unverified,documented_state_unverified_office,missing,2026-10-04 +AZ,senate,no,not_applicable,not_applicable,not_applicable,not_applicable,not_applicable,not_applicable,2026-10-04 +AZ,governor,yes,missing,missing,unverified,unverified,documented_state_unverified_office,missing,2026-10-04 +AR,house,yes,missing,missing,unverified,unverified,documented_state_unverified_office,missing,2026-10-04 +AR,senate,yes,missing,missing,unverified,unverified,documented_state_unverified_office,missing,2026-10-04 +AR,governor,yes,missing,missing,unverified,unverified,documented_state_unverified_office,missing,2026-10-04 +CA,house,yes,missing,missing,unverified,unverified,missing,missing,2026-10-04 +CA,senate,no,not_applicable,not_applicable,not_applicable,not_applicable,not_applicable,not_applicable,2026-10-04 +CA,governor,yes,missing,missing,unverified,unverified,missing,missing,2026-10-04 +CO,house,yes,missing,missing,unverified,unverified,documented_state_unverified_office,missing,2026-10-04 +CO,senate,yes,missing,missing,unverified,unverified,documented_state_unverified_office,missing,2026-10-04 +CO,governor,yes,missing,missing,unverified,unverified,documented_state_unverified_office,missing,2026-10-04 +CT,house,yes,missing,missing,unverified,unverified,documented_state_unverified_office,missing,2026-10-04 +CT,senate,no,not_applicable,not_applicable,not_applicable,not_applicable,not_applicable,not_applicable,2026-10-04 +CT,governor,yes,missing,missing,unverified,unverified,documented_state_unverified_office,missing,2026-10-04 +DE,house,yes,missing,missing,unverified,unverified,missing,missing,2026-10-04 +DE,senate,yes,missing,missing,unverified,unverified,missing,missing,2026-10-04 +DE,governor,no,not_applicable,not_applicable,not_applicable,not_applicable,not_applicable,not_applicable,2026-10-04 +FL,house,yes,missing,missing,unverified,unverified,documented_state_unverified_office,missing,2026-10-04 +FL,senate,yes,missing,missing,unverified,unverified,documented_state_unverified_office,missing,2026-10-04 +FL,governor,yes,missing,missing,unverified,unverified,documented_state_unverified_office,missing,2026-10-04 +GA,house,yes,missing,missing,unverified,unverified,documented_state_unverified_office,missing,2026-10-04 +GA,senate,yes,missing,missing,unverified,unverified,documented_state_unverified_office,missing,2026-10-04 +GA,governor,yes,missing,missing,unverified,unverified,documented_state_unverified_office,missing,2026-10-04 +HI,house,yes,missing,missing,unverified,unverified,missing,missing,2026-10-04 +HI,senate,no,not_applicable,not_applicable,not_applicable,not_applicable,not_applicable,not_applicable,2026-10-04 +HI,governor,yes,missing,missing,unverified,unverified,missing,missing,2026-10-04 +ID,house,yes,missing,missing,unverified,unverified,documented_state_unverified_office,missing,2026-10-04 +ID,senate,yes,missing,missing,unverified,unverified,documented_state_unverified_office,missing,2026-10-04 +ID,governor,yes,missing,missing,unverified,unverified,documented_state_unverified_office,missing,2026-10-04 +IL,house,yes,missing,missing,unverified,unverified,missing,missing,2026-10-04 +IL,senate,yes,missing,missing,unverified,unverified,missing,missing,2026-10-04 +IL,governor,yes,missing,missing,unverified,unverified,missing,missing,2026-10-04 +IN,house,yes,missing,missing,unverified,unverified,documented_state_unverified_office,missing,2026-10-04 +IN,senate,no,not_applicable,not_applicable,not_applicable,not_applicable,not_applicable,not_applicable,2026-10-04 +IN,governor,no,not_applicable,not_applicable,not_applicable,not_applicable,not_applicable,not_applicable,2026-10-04 +IA,house,yes,missing,missing,unverified,unverified,documented_state_unverified_office,missing,2026-10-04 +IA,senate,yes,missing,missing,unverified,unverified,documented_state_unverified_office,missing,2026-10-04 +IA,governor,yes,missing,missing,unverified,unverified,documented_state_unverified_office,missing,2026-10-04 +KS,house,yes,missing,missing,unverified,unverified,missing,missing,2026-10-04 +KS,senate,yes,missing,missing,unverified,unverified,missing,missing,2026-10-04 +KS,governor,yes,missing,missing,unverified,unverified,missing,missing,2026-10-04 +KY,house,yes,missing,missing,unverified,unverified,missing,missing,2026-10-04 +KY,senate,yes,missing,missing,unverified,unverified,missing,missing,2026-10-04 +KY,governor,no,not_applicable,not_applicable,not_applicable,not_applicable,not_applicable,not_applicable,2026-10-04 +LA,house,yes,missing,missing,unverified,unverified,documented_state_unverified_office,missing,2026-10-04 +LA,senate,yes,missing,missing,unverified,unverified,documented_state_unverified_office,missing,2026-10-04 +LA,governor,no,not_applicable,not_applicable,not_applicable,not_applicable,not_applicable,not_applicable,2026-10-04 +ME,house,yes,missing,missing,unverified,unverified,missing,missing,2026-10-04 +ME,senate,yes,missing,missing,unverified,unverified,missing,missing,2026-10-04 +ME,governor,yes,missing,missing,unverified,unverified,missing,missing,2026-10-04 +MD,house,yes,missing,missing,unverified,unverified,missing,missing,2026-10-04 +MD,senate,no,not_applicable,not_applicable,not_applicable,not_applicable,not_applicable,not_applicable,2026-10-04 +MD,governor,yes,missing,missing,unverified,unverified,missing,missing,2026-10-04 +MA,house,yes,missing,missing,unverified,unverified,missing,missing,2026-10-04 +MA,senate,yes,missing,missing,unverified,unverified,missing,missing,2026-10-04 +MA,governor,yes,missing,missing,unverified,unverified,missing,missing,2026-10-04 +MI,house,yes,verified_partial_import,missing,unverified,unverified,documented_state_unverified_office,missing,2026-10-04 +MI,senate,yes,verified_partial_import,missing,unverified,unverified,documented_state_unverified_office,missing,2026-10-04 +MI,governor,yes,verified_partial_import,missing,unverified,unverified,documented_state_unverified_office,missing,2026-10-04 +MN,house,yes,missing,missing,unverified,unverified,documented_state_unverified_office,missing,2026-10-04 +MN,senate,yes,missing,missing,unverified,unverified,documented_state_unverified_office,missing,2026-10-04 +MN,governor,yes,missing,missing,unverified,unverified,documented_state_unverified_office,missing,2026-10-04 +MS,house,yes,missing,missing,unverified,unverified,missing,missing,2026-10-04 +MS,senate,yes,missing,missing,unverified,unverified,missing,missing,2026-10-04 +MS,governor,no,not_applicable,not_applicable,not_applicable,not_applicable,not_applicable,not_applicable,2026-10-04 +MO,house,yes,missing,missing,unverified,unverified,missing,missing,2026-10-04 +MO,senate,no,not_applicable,not_applicable,not_applicable,not_applicable,not_applicable,not_applicable,2026-10-04 +MO,governor,no,not_applicable,not_applicable,not_applicable,not_applicable,not_applicable,not_applicable,2026-10-04 +MT,house,yes,missing,missing,unverified,unverified,missing,missing,2026-10-04 +MT,senate,yes,missing,missing,unverified,unverified,missing,missing,2026-10-04 +MT,governor,no,not_applicable,not_applicable,not_applicable,not_applicable,not_applicable,not_applicable,2026-10-04 +NE,house,yes,missing,missing,unverified,unverified,missing,missing,2026-10-04 +NE,senate,yes,missing,missing,unverified,unverified,missing,missing,2026-10-04 +NE,governor,yes,missing,missing,unverified,unverified,missing,missing,2026-10-04 +NV,house,yes,missing,missing,unverified,unverified,missing,missing,2026-10-04 +NV,senate,no,not_applicable,not_applicable,not_applicable,not_applicable,not_applicable,not_applicable,2026-10-04 +NV,governor,yes,missing,missing,unverified,unverified,missing,missing,2026-10-04 +NH,house,yes,missing,missing,unverified,unverified,missing,missing,2026-10-04 +NH,senate,yes,missing,missing,unverified,unverified,missing,missing,2026-10-04 +NH,governor,yes,missing,missing,unverified,unverified,missing,missing,2026-10-04 +NJ,house,yes,missing,missing,unverified,unverified,missing,missing,2026-10-04 +NJ,senate,yes,missing,missing,unverified,unverified,missing,missing,2026-10-04 +NJ,governor,no,not_applicable,not_applicable,not_applicable,not_applicable,not_applicable,not_applicable,2026-10-04 +NM,house,yes,missing,missing,unverified,unverified,missing,missing,2026-10-04 +NM,senate,yes,missing,missing,unverified,unverified,missing,missing,2026-10-04 +NM,governor,yes,missing,missing,unverified,unverified,missing,missing,2026-10-04 +NY,house,yes,missing,missing,unverified,unverified,missing,missing,2026-10-04 +NY,senate,no,not_applicable,not_applicable,not_applicable,not_applicable,not_applicable,not_applicable,2026-10-04 +NY,governor,yes,missing,missing,unverified,unverified,missing,missing,2026-10-04 +NC,house,yes,missing,missing,unverified,unverified,documented_state_unverified_office,missing,2026-10-04 +NC,senate,yes,missing,missing,unverified,unverified,documented_state_unverified_office,missing,2026-10-04 +NC,governor,no,not_applicable,not_applicable,not_applicable,not_applicable,not_applicable,not_applicable,2026-10-04 +ND,house,yes,missing,missing,unverified,unverified,missing,missing,2026-10-04 +ND,senate,no,not_applicable,not_applicable,not_applicable,not_applicable,not_applicable,not_applicable,2026-10-04 +ND,governor,no,not_applicable,not_applicable,not_applicable,not_applicable,not_applicable,not_applicable,2026-10-04 +OH,house,yes,missing,missing,unverified,unverified,missing,missing,2026-10-04 +OH,senate,yes,missing,missing,unverified,unverified,missing,missing,2026-10-04 +OH,governor,yes,missing,missing,unverified,unverified,missing,missing,2026-10-04 +OK,house,yes,missing,missing,unverified,unverified,missing,missing,2026-10-04 +OK,senate,yes,missing,missing,unverified,unverified,missing,missing,2026-10-04 +OK,governor,yes,missing,missing,unverified,unverified,missing,missing,2026-10-04 +OR,house,yes,missing,missing,unverified,unverified,missing,missing,2026-10-04 +OR,senate,yes,missing,missing,unverified,unverified,missing,missing,2026-10-04 +OR,governor,yes,missing,missing,unverified,unverified,missing,missing,2026-10-04 +PA,house,yes,missing,missing,unverified,unverified,documented_state_unverified_office,missing,2026-10-04 +PA,senate,no,not_applicable,not_applicable,not_applicable,not_applicable,not_applicable,not_applicable,2026-10-04 +PA,governor,yes,missing,missing,unverified,unverified,documented_state_unverified_office,missing,2026-10-04 +RI,house,yes,missing,missing,unverified,unverified,documented_state_unverified_office,missing,2026-10-04 +RI,senate,yes,missing,missing,unverified,unverified,documented_state_unverified_office,missing,2026-10-04 +RI,governor,yes,missing,missing,unverified,unverified,documented_state_unverified_office,missing,2026-10-04 +SC,house,yes,missing,missing,unverified,unverified,documented_state_unverified_office,missing,2026-10-04 +SC,senate,yes,missing,missing,unverified,unverified,documented_state_unverified_office,missing,2026-10-04 +SC,governor,yes,missing,missing,unverified,unverified,documented_state_unverified_office,missing,2026-10-04 +SD,house,yes,missing,missing,unverified,unverified,missing,missing,2026-10-04 +SD,senate,yes,missing,missing,unverified,unverified,missing,missing,2026-10-04 +SD,governor,yes,missing,missing,unverified,unverified,missing,missing,2026-10-04 +TN,house,yes,missing,missing,unverified,unverified,missing,missing,2026-10-04 +TN,senate,yes,missing,missing,unverified,unverified,missing,missing,2026-10-04 +TN,governor,yes,missing,missing,unverified,unverified,missing,missing,2026-10-04 +TX,house,yes,missing,missing,unverified,unverified,documented_state_unverified_office,missing,2026-10-04 +TX,senate,yes,missing,missing,unverified,unverified,documented_state_unverified_office,missing,2026-10-04 +TX,governor,yes,missing,missing,unverified,unverified,documented_state_unverified_office,missing,2026-10-04 +UT,house,yes,missing,missing,unverified,unverified,documented_state_unverified_office,missing,2026-10-04 +UT,senate,no,not_applicable,not_applicable,not_applicable,not_applicable,not_applicable,not_applicable,2026-10-04 +UT,governor,no,not_applicable,not_applicable,not_applicable,not_applicable,not_applicable,not_applicable,2026-10-04 +VT,house,yes,missing,missing,unverified,unverified,documented_state_unverified_office,missing,2026-10-04 +VT,senate,no,not_applicable,not_applicable,not_applicable,not_applicable,not_applicable,not_applicable,2026-10-04 +VT,governor,yes,missing,missing,unverified,unverified,documented_state_unverified_office,missing,2026-10-04 +VA,house,yes,missing,missing,unverified,unverified,documented_state_unverified_office,missing,2026-10-04 +VA,senate,yes,missing,missing,unverified,unverified,documented_state_unverified_office,missing,2026-10-04 +VA,governor,no,not_applicable,not_applicable,not_applicable,not_applicable,not_applicable,not_applicable,2026-10-04 +WA,house,yes,missing,missing,unverified,unverified,documented_state_unverified_office,missing,2026-10-04 +WA,senate,no,not_applicable,not_applicable,not_applicable,not_applicable,not_applicable,not_applicable,2026-10-04 +WA,governor,no,not_applicable,not_applicable,not_applicable,not_applicable,not_applicable,not_applicable,2026-10-04 +WV,house,yes,missing,missing,unverified,unverified,documented_state_unverified_office,missing,2026-10-04 +WV,senate,yes,missing,missing,unverified,unverified,documented_state_unverified_office,missing,2026-10-04 +WV,governor,no,not_applicable,not_applicable,not_applicable,not_applicable,not_applicable,not_applicable,2026-10-04 +WI,house,yes,missing,missing,unverified,unverified,missing,missing,2026-10-04 +WI,senate,no,not_applicable,not_applicable,not_applicable,not_applicable,not_applicable,not_applicable,2026-10-04 +WI,governor,yes,missing,missing,unverified,unverified,missing,missing,2026-10-04 +WY,house,yes,missing,missing,unverified,unverified,missing,missing,2026-10-04 +WY,senate,yes,missing,missing,unverified,unverified,missing,missing,2026-10-04 +WY,governor,yes,missing,missing,unverified,unverified,missing,missing,2026-10-04 diff --git a/data/demo/capacity.json b/data/demo/capacity.json new file mode 100644 index 0000000..1a67ca2 --- /dev/null +++ b/data/demo/capacity.json @@ -0,0 +1,4 @@ +{ + "catalog_id": "demo-2026", + "players": 12 +} diff --git a/data/demo/catalog.json b/data/demo/catalog.json new file mode 100644 index 0000000..d2d5444 --- /dev/null +++ b/data/demo/catalog.json @@ -0,0 +1,2862 @@ +{ + "schema_version": 1, + "id": "demo-2026", + "kind": "catalog", + "mode": "demo", + "label": "Fictional league \u2022 144 candidates", + "as_of": "2026-10-01T00:00:00Z", + "source": { + "name": "Fictional scoring fixtures", + "url": "fixture:product-fpol/demo", + "retrieved_at": "2026-10-01T00:00:00Z" + }, + "complete": true, + "races": [ + { + "id": "demo-house-01", + "office": "house", + "state": "AZ", + "round": "general", + "name": "Fictional AZ House race 1" + }, + { + "id": "demo-house-02", + "office": "house", + "state": "GA", + "round": "general", + "name": "Fictional GA House race 2" + }, + { + "id": "demo-house-03", + "office": "house", + "state": "MI", + "round": "general", + "name": "Fictional MI House race 3" + }, + { + "id": "demo-house-04", + "office": "house", + "state": "PA", + "round": "general", + "name": "Fictional PA House race 4" + }, + { + "id": "demo-house-05", + "office": "house", + "state": "NC", + "round": "general", + "name": "Fictional NC House race 5" + }, + { + "id": "demo-house-06", + "office": "house", + "state": "TX", + "round": "general", + "name": "Fictional TX House race 6" + }, + { + "id": "demo-house-07", + "office": "house", + "state": "OH", + "round": "general", + "name": "Fictional OH House race 7" + }, + { + "id": "demo-house-08", + "office": "house", + "state": "WI", + "round": "general", + "name": "Fictional WI House race 8" + }, + { + "id": "demo-house-09", + "office": "house", + "state": "NV", + "round": "general", + "name": "Fictional NV House race 9" + }, + { + "id": "demo-house-10", + "office": "house", + "state": "FL", + "round": "general", + "name": "Fictional FL House race 10" + }, + { + "id": "demo-house-11", + "office": "house", + "state": "ME", + "round": "general", + "name": "Fictional ME House race 11" + }, + { + "id": "demo-house-12", + "office": "house", + "state": "CO", + "round": "general", + "name": "Fictional CO House race 12" + }, + { + "id": "demo-senate-01", + "office": "senate", + "state": "AZ", + "round": "general", + "name": "Fictional AZ Senate race 1" + }, + { + "id": "demo-senate-02", + "office": "senate", + "state": "GA", + "round": "general", + "name": "Fictional GA Senate race 2" + }, + { + "id": "demo-senate-03", + "office": "senate", + "state": "MI", + "round": "general", + "name": "Fictional MI Senate race 3" + }, + { + "id": "demo-senate-04", + "office": "senate", + "state": "PA", + "round": "general", + "name": "Fictional PA Senate race 4" + }, + { + "id": "demo-senate-05", + "office": "senate", + "state": "NC", + "round": "general", + "name": "Fictional NC Senate race 5" + }, + { + "id": "demo-senate-06", + "office": "senate", + "state": "TX", + "round": "general", + "name": "Fictional TX Senate race 6" + }, + { + "id": "demo-senate-07", + "office": "senate", + "state": "OH", + "round": "general", + "name": "Fictional OH Senate race 7" + }, + { + "id": "demo-senate-08", + "office": "senate", + "state": "WI", + "round": "general", + "name": "Fictional WI Senate race 8" + }, + { + "id": "demo-senate-09", + "office": "senate", + "state": "NV", + "round": "general", + "name": "Fictional NV Senate race 9" + }, + { + "id": "demo-senate-10", + "office": "senate", + "state": "FL", + "round": "general", + "name": "Fictional FL Senate race 10" + }, + { + "id": "demo-senate-11", + "office": "senate", + "state": "ME", + "round": "general", + "name": "Fictional ME Senate race 11" + }, + { + "id": "demo-senate-12", + "office": "senate", + "state": "CO", + "round": "general", + "name": "Fictional CO Senate race 12" + }, + { + "id": "demo-governor-01", + "office": "governor", + "state": "AZ", + "round": "general", + "name": "Fictional AZ Governor race 1" + }, + { + "id": "demo-governor-02", + "office": "governor", + "state": "GA", + "round": "general", + "name": "Fictional GA Governor race 2" + }, + { + "id": "demo-governor-03", + "office": "governor", + "state": "MI", + "round": "general", + "name": "Fictional MI Governor race 3" + }, + { + "id": "demo-governor-04", + "office": "governor", + "state": "PA", + "round": "general", + "name": "Fictional PA Governor race 4" + }, + { + "id": "demo-governor-05", + "office": "governor", + "state": "NC", + "round": "general", + "name": "Fictional NC Governor race 5" + }, + { + "id": "demo-governor-06", + "office": "governor", + "state": "TX", + "round": "general", + "name": "Fictional TX Governor race 6" + }, + { + "id": "demo-governor-07", + "office": "governor", + "state": "OH", + "round": "general", + "name": "Fictional OH Governor race 7" + }, + { + "id": "demo-governor-08", + "office": "governor", + "state": "WI", + "round": "general", + "name": "Fictional WI Governor race 8" + }, + { + "id": "demo-governor-09", + "office": "governor", + "state": "NV", + "round": "general", + "name": "Fictional NV Governor race 9" + }, + { + "id": "demo-governor-10", + "office": "governor", + "state": "FL", + "round": "general", + "name": "Fictional FL Governor race 10" + }, + { + "id": "demo-governor-11", + "office": "governor", + "state": "ME", + "round": "general", + "name": "Fictional ME Governor race 11" + }, + { + "id": "demo-governor-12", + "office": "governor", + "state": "CO", + "round": "general", + "name": "Fictional CO Governor race 12" + } + ], + "candidates": [ + { + "id": "demo-house-01-1", + "race_id": "demo-house-01", + "name": "Avery House 01", + "affiliation": "democrat", + "qualified": true, + "ballot_lines": [ + "Democrat" + ], + "provider_ids": { + "fictional": "demo-house-01-1" + }, + "source": { + "name": "Fictional scoring fixtures", + "url": "fixture:product-fpol/demo", + "retrieved_at": "2026-10-01T00:00:00Z" + } + }, + { + "id": "demo-house-01-2", + "race_id": "demo-house-01", + "name": "Blair House 01", + "affiliation": "republican", + "qualified": true, + "ballot_lines": [ + "Republican" + ], + "provider_ids": { + "fictional": "demo-house-01-2" + }, + "source": { + "name": "Fictional scoring fixtures", + "url": "fixture:product-fpol/demo", + "retrieved_at": "2026-10-01T00:00:00Z" + } + }, + { + "id": "demo-house-01-3", + "race_id": "demo-house-01", + "name": "Casey House 01", + "affiliation": "other", + "qualified": true, + "ballot_lines": [ + "Other" + ], + "provider_ids": { + "fictional": "demo-house-01-3" + }, + "source": { + "name": "Fictional scoring fixtures", + "url": "fixture:product-fpol/demo", + "retrieved_at": "2026-10-01T00:00:00Z" + } + }, + { + "id": "demo-house-01-4", + "race_id": "demo-house-01", + "name": "Drew House 01", + "affiliation": "other", + "qualified": true, + "ballot_lines": [ + "Other" + ], + "provider_ids": { + "fictional": "demo-house-01-4" + }, + "source": { + "name": "Fictional scoring fixtures", + "url": "fixture:product-fpol/demo", + "retrieved_at": "2026-10-01T00:00:00Z" + } + }, + { + "id": "demo-house-02-1", + "race_id": "demo-house-02", + "name": "Avery House 02", + "affiliation": "democrat", + "qualified": true, + "ballot_lines": [ + "Democrat" + ], + "provider_ids": { + "fictional": "demo-house-02-1" + }, + "source": { + "name": "Fictional scoring fixtures", + "url": "fixture:product-fpol/demo", + "retrieved_at": "2026-10-01T00:00:00Z" + } + }, + { + "id": "demo-house-02-2", + "race_id": "demo-house-02", + "name": "Blair House 02", + "affiliation": "republican", + "qualified": true, + "ballot_lines": [ + "Republican" + ], + "provider_ids": { + "fictional": "demo-house-02-2" + }, + "source": { + "name": "Fictional scoring fixtures", + "url": "fixture:product-fpol/demo", + "retrieved_at": "2026-10-01T00:00:00Z" + } + }, + { + "id": "demo-house-02-3", + "race_id": "demo-house-02", + "name": "Casey House 02", + "affiliation": "other", + "qualified": true, + "ballot_lines": [ + "Other" + ], + "provider_ids": { + "fictional": "demo-house-02-3" + }, + "source": { + "name": "Fictional scoring fixtures", + "url": "fixture:product-fpol/demo", + "retrieved_at": "2026-10-01T00:00:00Z" + } + }, + { + "id": "demo-house-02-4", + "race_id": "demo-house-02", + "name": "Drew House 02", + "affiliation": "other", + "qualified": true, + "ballot_lines": [ + "Other" + ], + "provider_ids": { + "fictional": "demo-house-02-4" + }, + "source": { + "name": "Fictional scoring fixtures", + "url": "fixture:product-fpol/demo", + "retrieved_at": "2026-10-01T00:00:00Z" + } + }, + { + "id": "demo-house-03-1", + "race_id": "demo-house-03", + "name": "Avery House 03", + "affiliation": "democrat", + "qualified": true, + "ballot_lines": [ + "Democrat" + ], + "provider_ids": { + "fictional": "demo-house-03-1" + }, + "source": { + "name": "Fictional scoring fixtures", + "url": "fixture:product-fpol/demo", + "retrieved_at": "2026-10-01T00:00:00Z" + } + }, + { + "id": "demo-house-03-2", + "race_id": "demo-house-03", + "name": "Blair House 03", + "affiliation": "republican", + "qualified": true, + "ballot_lines": [ + "Republican" + ], + "provider_ids": { + "fictional": "demo-house-03-2" + }, + "source": { + "name": "Fictional scoring fixtures", + "url": "fixture:product-fpol/demo", + "retrieved_at": "2026-10-01T00:00:00Z" + } + }, + { + "id": "demo-house-03-3", + "race_id": "demo-house-03", + "name": "Casey House 03", + "affiliation": "other", + "qualified": true, + "ballot_lines": [ + "Other" + ], + "provider_ids": { + "fictional": "demo-house-03-3" + }, + "source": { + "name": "Fictional scoring fixtures", + "url": "fixture:product-fpol/demo", + "retrieved_at": "2026-10-01T00:00:00Z" + } + }, + { + "id": "demo-house-03-4", + "race_id": "demo-house-03", + "name": "Drew House 03", + "affiliation": "other", + "qualified": true, + "ballot_lines": [ + "Other" + ], + "provider_ids": { + "fictional": "demo-house-03-4" + }, + "source": { + "name": "Fictional scoring fixtures", + "url": "fixture:product-fpol/demo", + "retrieved_at": "2026-10-01T00:00:00Z" + } + }, + { + "id": "demo-house-04-1", + "race_id": "demo-house-04", + "name": "Avery House 04", + "affiliation": "democrat", + "qualified": true, + "ballot_lines": [ + "Democrat" + ], + "provider_ids": { + "fictional": "demo-house-04-1" + }, + "source": { + "name": "Fictional scoring fixtures", + "url": "fixture:product-fpol/demo", + "retrieved_at": "2026-10-01T00:00:00Z" + } + }, + { + "id": "demo-house-04-2", + "race_id": "demo-house-04", + "name": "Blair House 04", + "affiliation": "republican", + "qualified": true, + "ballot_lines": [ + "Republican" + ], + "provider_ids": { + "fictional": "demo-house-04-2" + }, + "source": { + "name": "Fictional scoring fixtures", + "url": "fixture:product-fpol/demo", + "retrieved_at": "2026-10-01T00:00:00Z" + } + }, + { + "id": "demo-house-04-3", + "race_id": "demo-house-04", + "name": "Casey House 04", + "affiliation": "other", + "qualified": true, + "ballot_lines": [ + "Other" + ], + "provider_ids": { + "fictional": "demo-house-04-3" + }, + "source": { + "name": "Fictional scoring fixtures", + "url": "fixture:product-fpol/demo", + "retrieved_at": "2026-10-01T00:00:00Z" + } + }, + { + "id": "demo-house-04-4", + "race_id": "demo-house-04", + "name": "Drew House 04", + "affiliation": "other", + "qualified": true, + "ballot_lines": [ + "Other" + ], + "provider_ids": { + "fictional": "demo-house-04-4" + }, + "source": { + "name": "Fictional scoring fixtures", + "url": "fixture:product-fpol/demo", + "retrieved_at": "2026-10-01T00:00:00Z" + } + }, + { + "id": "demo-house-05-1", + "race_id": "demo-house-05", + "name": "Avery House 05", + "affiliation": "democrat", + "qualified": true, + "ballot_lines": [ + "Democrat" + ], + "provider_ids": { + "fictional": "demo-house-05-1" + }, + "source": { + "name": "Fictional scoring fixtures", + "url": "fixture:product-fpol/demo", + "retrieved_at": "2026-10-01T00:00:00Z" + } + }, + { + "id": "demo-house-05-2", + "race_id": "demo-house-05", + "name": "Blair House 05", + "affiliation": "republican", + "qualified": true, + "ballot_lines": [ + "Republican" + ], + "provider_ids": { + "fictional": "demo-house-05-2" + }, + "source": { + "name": "Fictional scoring fixtures", + "url": "fixture:product-fpol/demo", + "retrieved_at": "2026-10-01T00:00:00Z" + } + }, + { + "id": "demo-house-05-3", + "race_id": "demo-house-05", + "name": "Casey House 05", + "affiliation": "other", + "qualified": true, + "ballot_lines": [ + "Other" + ], + "provider_ids": { + "fictional": "demo-house-05-3" + }, + "source": { + "name": "Fictional scoring fixtures", + "url": "fixture:product-fpol/demo", + "retrieved_at": "2026-10-01T00:00:00Z" + } + }, + { + "id": "demo-house-05-4", + "race_id": "demo-house-05", + "name": "Drew House 05", + "affiliation": "other", + "qualified": true, + "ballot_lines": [ + "Other" + ], + "provider_ids": { + "fictional": "demo-house-05-4" + }, + "source": { + "name": "Fictional scoring fixtures", + "url": "fixture:product-fpol/demo", + "retrieved_at": "2026-10-01T00:00:00Z" + } + }, + { + "id": "demo-house-06-1", + "race_id": "demo-house-06", + "name": "Avery House 06", + "affiliation": "democrat", + "qualified": true, + "ballot_lines": [ + "Democrat" + ], + "provider_ids": { + "fictional": "demo-house-06-1" + }, + "source": { + "name": "Fictional scoring fixtures", + "url": "fixture:product-fpol/demo", + "retrieved_at": "2026-10-01T00:00:00Z" + } + }, + { + "id": "demo-house-06-2", + "race_id": "demo-house-06", + "name": "Blair House 06", + "affiliation": "republican", + "qualified": true, + "ballot_lines": [ + "Republican" + ], + "provider_ids": { + "fictional": "demo-house-06-2" + }, + "source": { + "name": "Fictional scoring fixtures", + "url": "fixture:product-fpol/demo", + "retrieved_at": "2026-10-01T00:00:00Z" + } + }, + { + "id": "demo-house-06-3", + "race_id": "demo-house-06", + "name": "Casey House 06", + "affiliation": "other", + "qualified": true, + "ballot_lines": [ + "Other" + ], + "provider_ids": { + "fictional": "demo-house-06-3" + }, + "source": { + "name": "Fictional scoring fixtures", + "url": "fixture:product-fpol/demo", + "retrieved_at": "2026-10-01T00:00:00Z" + } + }, + { + "id": "demo-house-06-4", + "race_id": "demo-house-06", + "name": "Drew House 06", + "affiliation": "other", + "qualified": true, + "ballot_lines": [ + "Other" + ], + "provider_ids": { + "fictional": "demo-house-06-4" + }, + "source": { + "name": "Fictional scoring fixtures", + "url": "fixture:product-fpol/demo", + "retrieved_at": "2026-10-01T00:00:00Z" + } + }, + { + "id": "demo-house-07-1", + "race_id": "demo-house-07", + "name": "Avery House 07", + "affiliation": "democrat", + "qualified": true, + "ballot_lines": [ + "Democrat" + ], + "provider_ids": { + "fictional": "demo-house-07-1" + }, + "source": { + "name": "Fictional scoring fixtures", + "url": "fixture:product-fpol/demo", + "retrieved_at": "2026-10-01T00:00:00Z" + } + }, + { + "id": "demo-house-07-2", + "race_id": "demo-house-07", + "name": "Blair House 07", + "affiliation": "republican", + "qualified": true, + "ballot_lines": [ + "Republican" + ], + "provider_ids": { + "fictional": "demo-house-07-2" + }, + "source": { + "name": "Fictional scoring fixtures", + "url": "fixture:product-fpol/demo", + "retrieved_at": "2026-10-01T00:00:00Z" + } + }, + { + "id": "demo-house-07-3", + "race_id": "demo-house-07", + "name": "Casey House 07", + "affiliation": "other", + "qualified": true, + "ballot_lines": [ + "Other" + ], + "provider_ids": { + "fictional": "demo-house-07-3" + }, + "source": { + "name": "Fictional scoring fixtures", + "url": "fixture:product-fpol/demo", + "retrieved_at": "2026-10-01T00:00:00Z" + } + }, + { + "id": "demo-house-07-4", + "race_id": "demo-house-07", + "name": "Drew House 07", + "affiliation": "other", + "qualified": true, + "ballot_lines": [ + "Other" + ], + "provider_ids": { + "fictional": "demo-house-07-4" + }, + "source": { + "name": "Fictional scoring fixtures", + "url": "fixture:product-fpol/demo", + "retrieved_at": "2026-10-01T00:00:00Z" + } + }, + { + "id": "demo-house-08-1", + "race_id": "demo-house-08", + "name": "Avery House 08", + "affiliation": "democrat", + "qualified": true, + "ballot_lines": [ + "Democrat" + ], + "provider_ids": { + "fictional": "demo-house-08-1" + }, + "source": { + "name": "Fictional scoring fixtures", + "url": "fixture:product-fpol/demo", + "retrieved_at": "2026-10-01T00:00:00Z" + } + }, + { + "id": "demo-house-08-2", + "race_id": "demo-house-08", + "name": "Blair House 08", + "affiliation": "republican", + "qualified": true, + "ballot_lines": [ + "Republican" + ], + "provider_ids": { + "fictional": "demo-house-08-2" + }, + "source": { + "name": "Fictional scoring fixtures", + "url": "fixture:product-fpol/demo", + "retrieved_at": "2026-10-01T00:00:00Z" + } + }, + { + "id": "demo-house-08-3", + "race_id": "demo-house-08", + "name": "Casey House 08", + "affiliation": "other", + "qualified": true, + "ballot_lines": [ + "Other" + ], + "provider_ids": { + "fictional": "demo-house-08-3" + }, + "source": { + "name": "Fictional scoring fixtures", + "url": "fixture:product-fpol/demo", + "retrieved_at": "2026-10-01T00:00:00Z" + } + }, + { + "id": "demo-house-08-4", + "race_id": "demo-house-08", + "name": "Drew House 08", + "affiliation": "other", + "qualified": true, + "ballot_lines": [ + "Other" + ], + "provider_ids": { + "fictional": "demo-house-08-4" + }, + "source": { + "name": "Fictional scoring fixtures", + "url": "fixture:product-fpol/demo", + "retrieved_at": "2026-10-01T00:00:00Z" + } + }, + { + "id": "demo-house-09-1", + "race_id": "demo-house-09", + "name": "Avery House 09", + "affiliation": "democrat", + "qualified": true, + "ballot_lines": [ + "Democrat" + ], + "provider_ids": { + "fictional": "demo-house-09-1" + }, + "source": { + "name": "Fictional scoring fixtures", + "url": "fixture:product-fpol/demo", + "retrieved_at": "2026-10-01T00:00:00Z" + } + }, + { + "id": "demo-house-09-2", + "race_id": "demo-house-09", + "name": "Blair House 09", + "affiliation": "republican", + "qualified": true, + "ballot_lines": [ + "Republican" + ], + "provider_ids": { + "fictional": "demo-house-09-2" + }, + "source": { + "name": "Fictional scoring fixtures", + "url": "fixture:product-fpol/demo", + "retrieved_at": "2026-10-01T00:00:00Z" + } + }, + { + "id": "demo-house-09-3", + "race_id": "demo-house-09", + "name": "Casey House 09", + "affiliation": "other", + "qualified": true, + "ballot_lines": [ + "Other" + ], + "provider_ids": { + "fictional": "demo-house-09-3" + }, + "source": { + "name": "Fictional scoring fixtures", + "url": "fixture:product-fpol/demo", + "retrieved_at": "2026-10-01T00:00:00Z" + } + }, + { + "id": "demo-house-09-4", + "race_id": "demo-house-09", + "name": "Drew House 09", + "affiliation": "other", + "qualified": true, + "ballot_lines": [ + "Other" + ], + "provider_ids": { + "fictional": "demo-house-09-4" + }, + "source": { + "name": "Fictional scoring fixtures", + "url": "fixture:product-fpol/demo", + "retrieved_at": "2026-10-01T00:00:00Z" + } + }, + { + "id": "demo-house-10-1", + "race_id": "demo-house-10", + "name": "Avery House 10", + "affiliation": "democrat", + "qualified": true, + "ballot_lines": [ + "Democrat" + ], + "provider_ids": { + "fictional": "demo-house-10-1" + }, + "source": { + "name": "Fictional scoring fixtures", + "url": "fixture:product-fpol/demo", + "retrieved_at": "2026-10-01T00:00:00Z" + } + }, + { + "id": "demo-house-10-2", + "race_id": "demo-house-10", + "name": "Blair House 10", + "affiliation": "republican", + "qualified": true, + "ballot_lines": [ + "Republican" + ], + "provider_ids": { + "fictional": "demo-house-10-2" + }, + "source": { + "name": "Fictional scoring fixtures", + "url": "fixture:product-fpol/demo", + "retrieved_at": "2026-10-01T00:00:00Z" + } + }, + { + "id": "demo-house-10-3", + "race_id": "demo-house-10", + "name": "Casey House 10", + "affiliation": "other", + "qualified": true, + "ballot_lines": [ + "Other" + ], + "provider_ids": { + "fictional": "demo-house-10-3" + }, + "source": { + "name": "Fictional scoring fixtures", + "url": "fixture:product-fpol/demo", + "retrieved_at": "2026-10-01T00:00:00Z" + } + }, + { + "id": "demo-house-10-4", + "race_id": "demo-house-10", + "name": "Drew House 10", + "affiliation": "other", + "qualified": true, + "ballot_lines": [ + "Other" + ], + "provider_ids": { + "fictional": "demo-house-10-4" + }, + "source": { + "name": "Fictional scoring fixtures", + "url": "fixture:product-fpol/demo", + "retrieved_at": "2026-10-01T00:00:00Z" + } + }, + { + "id": "demo-house-11-1", + "race_id": "demo-house-11", + "name": "Avery House 11", + "affiliation": "democrat", + "qualified": true, + "ballot_lines": [ + "Democrat" + ], + "provider_ids": { + "fictional": "demo-house-11-1" + }, + "source": { + "name": "Fictional scoring fixtures", + "url": "fixture:product-fpol/demo", + "retrieved_at": "2026-10-01T00:00:00Z" + } + }, + { + "id": "demo-house-11-2", + "race_id": "demo-house-11", + "name": "Blair House 11", + "affiliation": "republican", + "qualified": true, + "ballot_lines": [ + "Republican" + ], + "provider_ids": { + "fictional": "demo-house-11-2" + }, + "source": { + "name": "Fictional scoring fixtures", + "url": "fixture:product-fpol/demo", + "retrieved_at": "2026-10-01T00:00:00Z" + } + }, + { + "id": "demo-house-11-3", + "race_id": "demo-house-11", + "name": "Casey House 11", + "affiliation": "other", + "qualified": true, + "ballot_lines": [ + "Other" + ], + "provider_ids": { + "fictional": "demo-house-11-3" + }, + "source": { + "name": "Fictional scoring fixtures", + "url": "fixture:product-fpol/demo", + "retrieved_at": "2026-10-01T00:00:00Z" + } + }, + { + "id": "demo-house-11-4", + "race_id": "demo-house-11", + "name": "Drew House 11", + "affiliation": "other", + "qualified": true, + "ballot_lines": [ + "Other" + ], + "provider_ids": { + "fictional": "demo-house-11-4" + }, + "source": { + "name": "Fictional scoring fixtures", + "url": "fixture:product-fpol/demo", + "retrieved_at": "2026-10-01T00:00:00Z" + } + }, + { + "id": "demo-house-12-1", + "race_id": "demo-house-12", + "name": "Avery House 12", + "affiliation": "democrat", + "qualified": true, + "ballot_lines": [ + "Democrat" + ], + "provider_ids": { + "fictional": "demo-house-12-1" + }, + "source": { + "name": "Fictional scoring fixtures", + "url": "fixture:product-fpol/demo", + "retrieved_at": "2026-10-01T00:00:00Z" + } + }, + { + "id": "demo-house-12-2", + "race_id": "demo-house-12", + "name": "Blair House 12", + "affiliation": "republican", + "qualified": true, + "ballot_lines": [ + "Republican" + ], + "provider_ids": { + "fictional": "demo-house-12-2" + }, + "source": { + "name": "Fictional scoring fixtures", + "url": "fixture:product-fpol/demo", + "retrieved_at": "2026-10-01T00:00:00Z" + } + }, + { + "id": "demo-house-12-3", + "race_id": "demo-house-12", + "name": "Casey House 12", + "affiliation": "other", + "qualified": true, + "ballot_lines": [ + "Other" + ], + "provider_ids": { + "fictional": "demo-house-12-3" + }, + "source": { + "name": "Fictional scoring fixtures", + "url": "fixture:product-fpol/demo", + "retrieved_at": "2026-10-01T00:00:00Z" + } + }, + { + "id": "demo-house-12-4", + "race_id": "demo-house-12", + "name": "Drew House 12", + "affiliation": "other", + "qualified": true, + "ballot_lines": [ + "Other" + ], + "provider_ids": { + "fictional": "demo-house-12-4" + }, + "source": { + "name": "Fictional scoring fixtures", + "url": "fixture:product-fpol/demo", + "retrieved_at": "2026-10-01T00:00:00Z" + } + }, + { + "id": "demo-senate-01-1", + "race_id": "demo-senate-01", + "name": "Avery Senate 01", + "affiliation": "democrat", + "qualified": true, + "ballot_lines": [ + "Democrat" + ], + "provider_ids": { + "fictional": "demo-senate-01-1" + }, + "source": { + "name": "Fictional scoring fixtures", + "url": "fixture:product-fpol/demo", + "retrieved_at": "2026-10-01T00:00:00Z" + } + }, + { + "id": "demo-senate-01-2", + "race_id": "demo-senate-01", + "name": "Blair Senate 01", + "affiliation": "republican", + "qualified": true, + "ballot_lines": [ + "Republican" + ], + "provider_ids": { + "fictional": "demo-senate-01-2" + }, + "source": { + "name": "Fictional scoring fixtures", + "url": "fixture:product-fpol/demo", + "retrieved_at": "2026-10-01T00:00:00Z" + } + }, + { + "id": "demo-senate-01-3", + "race_id": "demo-senate-01", + "name": "Casey Senate 01", + "affiliation": "other", + "qualified": true, + "ballot_lines": [ + "Other" + ], + "provider_ids": { + "fictional": "demo-senate-01-3" + }, + "source": { + "name": "Fictional scoring fixtures", + "url": "fixture:product-fpol/demo", + "retrieved_at": "2026-10-01T00:00:00Z" + } + }, + { + "id": "demo-senate-01-4", + "race_id": "demo-senate-01", + "name": "Drew Senate 01", + "affiliation": "other", + "qualified": true, + "ballot_lines": [ + "Other" + ], + "provider_ids": { + "fictional": "demo-senate-01-4" + }, + "source": { + "name": "Fictional scoring fixtures", + "url": "fixture:product-fpol/demo", + "retrieved_at": "2026-10-01T00:00:00Z" + } + }, + { + "id": "demo-senate-02-1", + "race_id": "demo-senate-02", + "name": "Avery Senate 02", + "affiliation": "democrat", + "qualified": true, + "ballot_lines": [ + "Democrat" + ], + "provider_ids": { + "fictional": "demo-senate-02-1" + }, + "source": { + "name": "Fictional scoring fixtures", + "url": "fixture:product-fpol/demo", + "retrieved_at": "2026-10-01T00:00:00Z" + } + }, + { + "id": "demo-senate-02-2", + "race_id": "demo-senate-02", + "name": "Blair Senate 02", + "affiliation": "republican", + "qualified": true, + "ballot_lines": [ + "Republican" + ], + "provider_ids": { + "fictional": "demo-senate-02-2" + }, + "source": { + "name": "Fictional scoring fixtures", + "url": "fixture:product-fpol/demo", + "retrieved_at": "2026-10-01T00:00:00Z" + } + }, + { + "id": "demo-senate-02-3", + "race_id": "demo-senate-02", + "name": "Casey Senate 02", + "affiliation": "other", + "qualified": true, + "ballot_lines": [ + "Other" + ], + "provider_ids": { + "fictional": "demo-senate-02-3" + }, + "source": { + "name": "Fictional scoring fixtures", + "url": "fixture:product-fpol/demo", + "retrieved_at": "2026-10-01T00:00:00Z" + } + }, + { + "id": "demo-senate-02-4", + "race_id": "demo-senate-02", + "name": "Drew Senate 02", + "affiliation": "other", + "qualified": true, + "ballot_lines": [ + "Other" + ], + "provider_ids": { + "fictional": "demo-senate-02-4" + }, + "source": { + "name": "Fictional scoring fixtures", + "url": "fixture:product-fpol/demo", + "retrieved_at": "2026-10-01T00:00:00Z" + } + }, + { + "id": "demo-senate-03-1", + "race_id": "demo-senate-03", + "name": "Avery Senate 03", + "affiliation": "democrat", + "qualified": true, + "ballot_lines": [ + "Democrat" + ], + "provider_ids": { + "fictional": "demo-senate-03-1" + }, + "source": { + "name": "Fictional scoring fixtures", + "url": "fixture:product-fpol/demo", + "retrieved_at": "2026-10-01T00:00:00Z" + } + }, + { + "id": "demo-senate-03-2", + "race_id": "demo-senate-03", + "name": "Blair Senate 03", + "affiliation": "republican", + "qualified": true, + "ballot_lines": [ + "Republican" + ], + "provider_ids": { + "fictional": "demo-senate-03-2" + }, + "source": { + "name": "Fictional scoring fixtures", + "url": "fixture:product-fpol/demo", + "retrieved_at": "2026-10-01T00:00:00Z" + } + }, + { + "id": "demo-senate-03-3", + "race_id": "demo-senate-03", + "name": "Casey Senate 03", + "affiliation": "other", + "qualified": true, + "ballot_lines": [ + "Other" + ], + "provider_ids": { + "fictional": "demo-senate-03-3" + }, + "source": { + "name": "Fictional scoring fixtures", + "url": "fixture:product-fpol/demo", + "retrieved_at": "2026-10-01T00:00:00Z" + } + }, + { + "id": "demo-senate-03-4", + "race_id": "demo-senate-03", + "name": "Drew Senate 03", + "affiliation": "other", + "qualified": true, + "ballot_lines": [ + "Other" + ], + "provider_ids": { + "fictional": "demo-senate-03-4" + }, + "source": { + "name": "Fictional scoring fixtures", + "url": "fixture:product-fpol/demo", + "retrieved_at": "2026-10-01T00:00:00Z" + } + }, + { + "id": "demo-senate-04-1", + "race_id": "demo-senate-04", + "name": "Avery Senate 04", + "affiliation": "democrat", + "qualified": true, + "ballot_lines": [ + "Democrat" + ], + "provider_ids": { + "fictional": "demo-senate-04-1" + }, + "source": { + "name": "Fictional scoring fixtures", + "url": "fixture:product-fpol/demo", + "retrieved_at": "2026-10-01T00:00:00Z" + } + }, + { + "id": "demo-senate-04-2", + "race_id": "demo-senate-04", + "name": "Blair Senate 04", + "affiliation": "republican", + "qualified": true, + "ballot_lines": [ + "Republican" + ], + "provider_ids": { + "fictional": "demo-senate-04-2" + }, + "source": { + "name": "Fictional scoring fixtures", + "url": "fixture:product-fpol/demo", + "retrieved_at": "2026-10-01T00:00:00Z" + } + }, + { + "id": "demo-senate-04-3", + "race_id": "demo-senate-04", + "name": "Casey Senate 04", + "affiliation": "other", + "qualified": true, + "ballot_lines": [ + "Other" + ], + "provider_ids": { + "fictional": "demo-senate-04-3" + }, + "source": { + "name": "Fictional scoring fixtures", + "url": "fixture:product-fpol/demo", + "retrieved_at": "2026-10-01T00:00:00Z" + } + }, + { + "id": "demo-senate-04-4", + "race_id": "demo-senate-04", + "name": "Drew Senate 04", + "affiliation": "other", + "qualified": true, + "ballot_lines": [ + "Other" + ], + "provider_ids": { + "fictional": "demo-senate-04-4" + }, + "source": { + "name": "Fictional scoring fixtures", + "url": "fixture:product-fpol/demo", + "retrieved_at": "2026-10-01T00:00:00Z" + } + }, + { + "id": "demo-senate-05-1", + "race_id": "demo-senate-05", + "name": "Avery Senate 05", + "affiliation": "democrat", + "qualified": true, + "ballot_lines": [ + "Democrat" + ], + "provider_ids": { + "fictional": "demo-senate-05-1" + }, + "source": { + "name": "Fictional scoring fixtures", + "url": "fixture:product-fpol/demo", + "retrieved_at": "2026-10-01T00:00:00Z" + } + }, + { + "id": "demo-senate-05-2", + "race_id": "demo-senate-05", + "name": "Blair Senate 05", + "affiliation": "republican", + "qualified": true, + "ballot_lines": [ + "Republican" + ], + "provider_ids": { + "fictional": "demo-senate-05-2" + }, + "source": { + "name": "Fictional scoring fixtures", + "url": "fixture:product-fpol/demo", + "retrieved_at": "2026-10-01T00:00:00Z" + } + }, + { + "id": "demo-senate-05-3", + "race_id": "demo-senate-05", + "name": "Casey Senate 05", + "affiliation": "other", + "qualified": true, + "ballot_lines": [ + "Other" + ], + "provider_ids": { + "fictional": "demo-senate-05-3" + }, + "source": { + "name": "Fictional scoring fixtures", + "url": "fixture:product-fpol/demo", + "retrieved_at": "2026-10-01T00:00:00Z" + } + }, + { + "id": "demo-senate-05-4", + "race_id": "demo-senate-05", + "name": "Drew Senate 05", + "affiliation": "other", + "qualified": true, + "ballot_lines": [ + "Other" + ], + "provider_ids": { + "fictional": "demo-senate-05-4" + }, + "source": { + "name": "Fictional scoring fixtures", + "url": "fixture:product-fpol/demo", + "retrieved_at": "2026-10-01T00:00:00Z" + } + }, + { + "id": "demo-senate-06-1", + "race_id": "demo-senate-06", + "name": "Avery Senate 06", + "affiliation": "democrat", + "qualified": true, + "ballot_lines": [ + "Democrat" + ], + "provider_ids": { + "fictional": "demo-senate-06-1" + }, + "source": { + "name": "Fictional scoring fixtures", + "url": "fixture:product-fpol/demo", + "retrieved_at": "2026-10-01T00:00:00Z" + } + }, + { + "id": "demo-senate-06-2", + "race_id": "demo-senate-06", + "name": "Blair Senate 06", + "affiliation": "republican", + "qualified": true, + "ballot_lines": [ + "Republican" + ], + "provider_ids": { + "fictional": "demo-senate-06-2" + }, + "source": { + "name": "Fictional scoring fixtures", + "url": "fixture:product-fpol/demo", + "retrieved_at": "2026-10-01T00:00:00Z" + } + }, + { + "id": "demo-senate-06-3", + "race_id": "demo-senate-06", + "name": "Casey Senate 06", + "affiliation": "other", + "qualified": true, + "ballot_lines": [ + "Other" + ], + "provider_ids": { + "fictional": "demo-senate-06-3" + }, + "source": { + "name": "Fictional scoring fixtures", + "url": "fixture:product-fpol/demo", + "retrieved_at": "2026-10-01T00:00:00Z" + } + }, + { + "id": "demo-senate-06-4", + "race_id": "demo-senate-06", + "name": "Drew Senate 06", + "affiliation": "other", + "qualified": true, + "ballot_lines": [ + "Other" + ], + "provider_ids": { + "fictional": "demo-senate-06-4" + }, + "source": { + "name": "Fictional scoring fixtures", + "url": "fixture:product-fpol/demo", + "retrieved_at": "2026-10-01T00:00:00Z" + } + }, + { + "id": "demo-senate-07-1", + "race_id": "demo-senate-07", + "name": "Avery Senate 07", + "affiliation": "democrat", + "qualified": true, + "ballot_lines": [ + "Democrat" + ], + "provider_ids": { + "fictional": "demo-senate-07-1" + }, + "source": { + "name": "Fictional scoring fixtures", + "url": "fixture:product-fpol/demo", + "retrieved_at": "2026-10-01T00:00:00Z" + } + }, + { + "id": "demo-senate-07-2", + "race_id": "demo-senate-07", + "name": "Blair Senate 07", + "affiliation": "republican", + "qualified": true, + "ballot_lines": [ + "Republican" + ], + "provider_ids": { + "fictional": "demo-senate-07-2" + }, + "source": { + "name": "Fictional scoring fixtures", + "url": "fixture:product-fpol/demo", + "retrieved_at": "2026-10-01T00:00:00Z" + } + }, + { + "id": "demo-senate-07-3", + "race_id": "demo-senate-07", + "name": "Casey Senate 07", + "affiliation": "other", + "qualified": true, + "ballot_lines": [ + "Other" + ], + "provider_ids": { + "fictional": "demo-senate-07-3" + }, + "source": { + "name": "Fictional scoring fixtures", + "url": "fixture:product-fpol/demo", + "retrieved_at": "2026-10-01T00:00:00Z" + } + }, + { + "id": "demo-senate-07-4", + "race_id": "demo-senate-07", + "name": "Drew Senate 07", + "affiliation": "other", + "qualified": true, + "ballot_lines": [ + "Other" + ], + "provider_ids": { + "fictional": "demo-senate-07-4" + }, + "source": { + "name": "Fictional scoring fixtures", + "url": "fixture:product-fpol/demo", + "retrieved_at": "2026-10-01T00:00:00Z" + } + }, + { + "id": "demo-senate-08-1", + "race_id": "demo-senate-08", + "name": "Avery Senate 08", + "affiliation": "democrat", + "qualified": true, + "ballot_lines": [ + "Democrat" + ], + "provider_ids": { + "fictional": "demo-senate-08-1" + }, + "source": { + "name": "Fictional scoring fixtures", + "url": "fixture:product-fpol/demo", + "retrieved_at": "2026-10-01T00:00:00Z" + } + }, + { + "id": "demo-senate-08-2", + "race_id": "demo-senate-08", + "name": "Blair Senate 08", + "affiliation": "republican", + "qualified": true, + "ballot_lines": [ + "Republican" + ], + "provider_ids": { + "fictional": "demo-senate-08-2" + }, + "source": { + "name": "Fictional scoring fixtures", + "url": "fixture:product-fpol/demo", + "retrieved_at": "2026-10-01T00:00:00Z" + } + }, + { + "id": "demo-senate-08-3", + "race_id": "demo-senate-08", + "name": "Casey Senate 08", + "affiliation": "other", + "qualified": true, + "ballot_lines": [ + "Other" + ], + "provider_ids": { + "fictional": "demo-senate-08-3" + }, + "source": { + "name": "Fictional scoring fixtures", + "url": "fixture:product-fpol/demo", + "retrieved_at": "2026-10-01T00:00:00Z" + } + }, + { + "id": "demo-senate-08-4", + "race_id": "demo-senate-08", + "name": "Drew Senate 08", + "affiliation": "other", + "qualified": true, + "ballot_lines": [ + "Other" + ], + "provider_ids": { + "fictional": "demo-senate-08-4" + }, + "source": { + "name": "Fictional scoring fixtures", + "url": "fixture:product-fpol/demo", + "retrieved_at": "2026-10-01T00:00:00Z" + } + }, + { + "id": "demo-senate-09-1", + "race_id": "demo-senate-09", + "name": "Avery Senate 09", + "affiliation": "democrat", + "qualified": true, + "ballot_lines": [ + "Democrat" + ], + "provider_ids": { + "fictional": "demo-senate-09-1" + }, + "source": { + "name": "Fictional scoring fixtures", + "url": "fixture:product-fpol/demo", + "retrieved_at": "2026-10-01T00:00:00Z" + } + }, + { + "id": "demo-senate-09-2", + "race_id": "demo-senate-09", + "name": "Blair Senate 09", + "affiliation": "republican", + "qualified": true, + "ballot_lines": [ + "Republican" + ], + "provider_ids": { + "fictional": "demo-senate-09-2" + }, + "source": { + "name": "Fictional scoring fixtures", + "url": "fixture:product-fpol/demo", + "retrieved_at": "2026-10-01T00:00:00Z" + } + }, + { + "id": "demo-senate-09-3", + "race_id": "demo-senate-09", + "name": "Casey Senate 09", + "affiliation": "other", + "qualified": true, + "ballot_lines": [ + "Other" + ], + "provider_ids": { + "fictional": "demo-senate-09-3" + }, + "source": { + "name": "Fictional scoring fixtures", + "url": "fixture:product-fpol/demo", + "retrieved_at": "2026-10-01T00:00:00Z" + } + }, + { + "id": "demo-senate-09-4", + "race_id": "demo-senate-09", + "name": "Drew Senate 09", + "affiliation": "other", + "qualified": true, + "ballot_lines": [ + "Other" + ], + "provider_ids": { + "fictional": "demo-senate-09-4" + }, + "source": { + "name": "Fictional scoring fixtures", + "url": "fixture:product-fpol/demo", + "retrieved_at": "2026-10-01T00:00:00Z" + } + }, + { + "id": "demo-senate-10-1", + "race_id": "demo-senate-10", + "name": "Avery Senate 10", + "affiliation": "democrat", + "qualified": true, + "ballot_lines": [ + "Democrat" + ], + "provider_ids": { + "fictional": "demo-senate-10-1" + }, + "source": { + "name": "Fictional scoring fixtures", + "url": "fixture:product-fpol/demo", + "retrieved_at": "2026-10-01T00:00:00Z" + } + }, + { + "id": "demo-senate-10-2", + "race_id": "demo-senate-10", + "name": "Blair Senate 10", + "affiliation": "republican", + "qualified": true, + "ballot_lines": [ + "Republican" + ], + "provider_ids": { + "fictional": "demo-senate-10-2" + }, + "source": { + "name": "Fictional scoring fixtures", + "url": "fixture:product-fpol/demo", + "retrieved_at": "2026-10-01T00:00:00Z" + } + }, + { + "id": "demo-senate-10-3", + "race_id": "demo-senate-10", + "name": "Casey Senate 10", + "affiliation": "other", + "qualified": true, + "ballot_lines": [ + "Other" + ], + "provider_ids": { + "fictional": "demo-senate-10-3" + }, + "source": { + "name": "Fictional scoring fixtures", + "url": "fixture:product-fpol/demo", + "retrieved_at": "2026-10-01T00:00:00Z" + } + }, + { + "id": "demo-senate-10-4", + "race_id": "demo-senate-10", + "name": "Drew Senate 10", + "affiliation": "other", + "qualified": true, + "ballot_lines": [ + "Other" + ], + "provider_ids": { + "fictional": "demo-senate-10-4" + }, + "source": { + "name": "Fictional scoring fixtures", + "url": "fixture:product-fpol/demo", + "retrieved_at": "2026-10-01T00:00:00Z" + } + }, + { + "id": "demo-senate-11-1", + "race_id": "demo-senate-11", + "name": "Avery Senate 11", + "affiliation": "democrat", + "qualified": true, + "ballot_lines": [ + "Democrat" + ], + "provider_ids": { + "fictional": "demo-senate-11-1" + }, + "source": { + "name": "Fictional scoring fixtures", + "url": "fixture:product-fpol/demo", + "retrieved_at": "2026-10-01T00:00:00Z" + } + }, + { + "id": "demo-senate-11-2", + "race_id": "demo-senate-11", + "name": "Blair Senate 11", + "affiliation": "republican", + "qualified": true, + "ballot_lines": [ + "Republican" + ], + "provider_ids": { + "fictional": "demo-senate-11-2" + }, + "source": { + "name": "Fictional scoring fixtures", + "url": "fixture:product-fpol/demo", + "retrieved_at": "2026-10-01T00:00:00Z" + } + }, + { + "id": "demo-senate-11-3", + "race_id": "demo-senate-11", + "name": "Casey Senate 11", + "affiliation": "other", + "qualified": true, + "ballot_lines": [ + "Other" + ], + "provider_ids": { + "fictional": "demo-senate-11-3" + }, + "source": { + "name": "Fictional scoring fixtures", + "url": "fixture:product-fpol/demo", + "retrieved_at": "2026-10-01T00:00:00Z" + } + }, + { + "id": "demo-senate-11-4", + "race_id": "demo-senate-11", + "name": "Drew Senate 11", + "affiliation": "other", + "qualified": true, + "ballot_lines": [ + "Other" + ], + "provider_ids": { + "fictional": "demo-senate-11-4" + }, + "source": { + "name": "Fictional scoring fixtures", + "url": "fixture:product-fpol/demo", + "retrieved_at": "2026-10-01T00:00:00Z" + } + }, + { + "id": "demo-senate-12-1", + "race_id": "demo-senate-12", + "name": "Avery Senate 12", + "affiliation": "democrat", + "qualified": true, + "ballot_lines": [ + "Democrat" + ], + "provider_ids": { + "fictional": "demo-senate-12-1" + }, + "source": { + "name": "Fictional scoring fixtures", + "url": "fixture:product-fpol/demo", + "retrieved_at": "2026-10-01T00:00:00Z" + } + }, + { + "id": "demo-senate-12-2", + "race_id": "demo-senate-12", + "name": "Blair Senate 12", + "affiliation": "republican", + "qualified": true, + "ballot_lines": [ + "Republican" + ], + "provider_ids": { + "fictional": "demo-senate-12-2" + }, + "source": { + "name": "Fictional scoring fixtures", + "url": "fixture:product-fpol/demo", + "retrieved_at": "2026-10-01T00:00:00Z" + } + }, + { + "id": "demo-senate-12-3", + "race_id": "demo-senate-12", + "name": "Casey Senate 12", + "affiliation": "other", + "qualified": true, + "ballot_lines": [ + "Other" + ], + "provider_ids": { + "fictional": "demo-senate-12-3" + }, + "source": { + "name": "Fictional scoring fixtures", + "url": "fixture:product-fpol/demo", + "retrieved_at": "2026-10-01T00:00:00Z" + } + }, + { + "id": "demo-senate-12-4", + "race_id": "demo-senate-12", + "name": "Drew Senate 12", + "affiliation": "other", + "qualified": true, + "ballot_lines": [ + "Other" + ], + "provider_ids": { + "fictional": "demo-senate-12-4" + }, + "source": { + "name": "Fictional scoring fixtures", + "url": "fixture:product-fpol/demo", + "retrieved_at": "2026-10-01T00:00:00Z" + } + }, + { + "id": "demo-governor-01-1", + "race_id": "demo-governor-01", + "name": "Avery Governor 01", + "affiliation": "democrat", + "qualified": true, + "ballot_lines": [ + "Democrat" + ], + "provider_ids": { + "fictional": "demo-governor-01-1" + }, + "source": { + "name": "Fictional scoring fixtures", + "url": "fixture:product-fpol/demo", + "retrieved_at": "2026-10-01T00:00:00Z" + } + }, + { + "id": "demo-governor-01-2", + "race_id": "demo-governor-01", + "name": "Blair Governor 01", + "affiliation": "republican", + "qualified": true, + "ballot_lines": [ + "Republican" + ], + "provider_ids": { + "fictional": "demo-governor-01-2" + }, + "source": { + "name": "Fictional scoring fixtures", + "url": "fixture:product-fpol/demo", + "retrieved_at": "2026-10-01T00:00:00Z" + } + }, + { + "id": "demo-governor-01-3", + "race_id": "demo-governor-01", + "name": "Casey Governor 01", + "affiliation": "other", + "qualified": true, + "ballot_lines": [ + "Other" + ], + "provider_ids": { + "fictional": "demo-governor-01-3" + }, + "source": { + "name": "Fictional scoring fixtures", + "url": "fixture:product-fpol/demo", + "retrieved_at": "2026-10-01T00:00:00Z" + } + }, + { + "id": "demo-governor-01-4", + "race_id": "demo-governor-01", + "name": "Drew Governor 01", + "affiliation": "other", + "qualified": true, + "ballot_lines": [ + "Other" + ], + "provider_ids": { + "fictional": "demo-governor-01-4" + }, + "source": { + "name": "Fictional scoring fixtures", + "url": "fixture:product-fpol/demo", + "retrieved_at": "2026-10-01T00:00:00Z" + } + }, + { + "id": "demo-governor-02-1", + "race_id": "demo-governor-02", + "name": "Avery Governor 02", + "affiliation": "democrat", + "qualified": true, + "ballot_lines": [ + "Democrat" + ], + "provider_ids": { + "fictional": "demo-governor-02-1" + }, + "source": { + "name": "Fictional scoring fixtures", + "url": "fixture:product-fpol/demo", + "retrieved_at": "2026-10-01T00:00:00Z" + } + }, + { + "id": "demo-governor-02-2", + "race_id": "demo-governor-02", + "name": "Blair Governor 02", + "affiliation": "republican", + "qualified": true, + "ballot_lines": [ + "Republican" + ], + "provider_ids": { + "fictional": "demo-governor-02-2" + }, + "source": { + "name": "Fictional scoring fixtures", + "url": "fixture:product-fpol/demo", + "retrieved_at": "2026-10-01T00:00:00Z" + } + }, + { + "id": "demo-governor-02-3", + "race_id": "demo-governor-02", + "name": "Casey Governor 02", + "affiliation": "other", + "qualified": true, + "ballot_lines": [ + "Other" + ], + "provider_ids": { + "fictional": "demo-governor-02-3" + }, + "source": { + "name": "Fictional scoring fixtures", + "url": "fixture:product-fpol/demo", + "retrieved_at": "2026-10-01T00:00:00Z" + } + }, + { + "id": "demo-governor-02-4", + "race_id": "demo-governor-02", + "name": "Drew Governor 02", + "affiliation": "other", + "qualified": true, + "ballot_lines": [ + "Other" + ], + "provider_ids": { + "fictional": "demo-governor-02-4" + }, + "source": { + "name": "Fictional scoring fixtures", + "url": "fixture:product-fpol/demo", + "retrieved_at": "2026-10-01T00:00:00Z" + } + }, + { + "id": "demo-governor-03-1", + "race_id": "demo-governor-03", + "name": "Avery Governor 03", + "affiliation": "democrat", + "qualified": true, + "ballot_lines": [ + "Democrat" + ], + "provider_ids": { + "fictional": "demo-governor-03-1" + }, + "source": { + "name": "Fictional scoring fixtures", + "url": "fixture:product-fpol/demo", + "retrieved_at": "2026-10-01T00:00:00Z" + } + }, + { + "id": "demo-governor-03-2", + "race_id": "demo-governor-03", + "name": "Blair Governor 03", + "affiliation": "republican", + "qualified": true, + "ballot_lines": [ + "Republican" + ], + "provider_ids": { + "fictional": "demo-governor-03-2" + }, + "source": { + "name": "Fictional scoring fixtures", + "url": "fixture:product-fpol/demo", + "retrieved_at": "2026-10-01T00:00:00Z" + } + }, + { + "id": "demo-governor-03-3", + "race_id": "demo-governor-03", + "name": "Casey Governor 03", + "affiliation": "other", + "qualified": true, + "ballot_lines": [ + "Other" + ], + "provider_ids": { + "fictional": "demo-governor-03-3" + }, + "source": { + "name": "Fictional scoring fixtures", + "url": "fixture:product-fpol/demo", + "retrieved_at": "2026-10-01T00:00:00Z" + } + }, + { + "id": "demo-governor-03-4", + "race_id": "demo-governor-03", + "name": "Drew Governor 03", + "affiliation": "other", + "qualified": true, + "ballot_lines": [ + "Other" + ], + "provider_ids": { + "fictional": "demo-governor-03-4" + }, + "source": { + "name": "Fictional scoring fixtures", + "url": "fixture:product-fpol/demo", + "retrieved_at": "2026-10-01T00:00:00Z" + } + }, + { + "id": "demo-governor-04-1", + "race_id": "demo-governor-04", + "name": "Avery Governor 04", + "affiliation": "democrat", + "qualified": true, + "ballot_lines": [ + "Democrat" + ], + "provider_ids": { + "fictional": "demo-governor-04-1" + }, + "source": { + "name": "Fictional scoring fixtures", + "url": "fixture:product-fpol/demo", + "retrieved_at": "2026-10-01T00:00:00Z" + } + }, + { + "id": "demo-governor-04-2", + "race_id": "demo-governor-04", + "name": "Blair Governor 04", + "affiliation": "republican", + "qualified": true, + "ballot_lines": [ + "Republican" + ], + "provider_ids": { + "fictional": "demo-governor-04-2" + }, + "source": { + "name": "Fictional scoring fixtures", + "url": "fixture:product-fpol/demo", + "retrieved_at": "2026-10-01T00:00:00Z" + } + }, + { + "id": "demo-governor-04-3", + "race_id": "demo-governor-04", + "name": "Casey Governor 04", + "affiliation": "other", + "qualified": true, + "ballot_lines": [ + "Other" + ], + "provider_ids": { + "fictional": "demo-governor-04-3" + }, + "source": { + "name": "Fictional scoring fixtures", + "url": "fixture:product-fpol/demo", + "retrieved_at": "2026-10-01T00:00:00Z" + } + }, + { + "id": "demo-governor-04-4", + "race_id": "demo-governor-04", + "name": "Drew Governor 04", + "affiliation": "other", + "qualified": true, + "ballot_lines": [ + "Other" + ], + "provider_ids": { + "fictional": "demo-governor-04-4" + }, + "source": { + "name": "Fictional scoring fixtures", + "url": "fixture:product-fpol/demo", + "retrieved_at": "2026-10-01T00:00:00Z" + } + }, + { + "id": "demo-governor-05-1", + "race_id": "demo-governor-05", + "name": "Avery Governor 05", + "affiliation": "democrat", + "qualified": true, + "ballot_lines": [ + "Democrat" + ], + "provider_ids": { + "fictional": "demo-governor-05-1" + }, + "source": { + "name": "Fictional scoring fixtures", + "url": "fixture:product-fpol/demo", + "retrieved_at": "2026-10-01T00:00:00Z" + } + }, + { + "id": "demo-governor-05-2", + "race_id": "demo-governor-05", + "name": "Blair Governor 05", + "affiliation": "republican", + "qualified": true, + "ballot_lines": [ + "Republican" + ], + "provider_ids": { + "fictional": "demo-governor-05-2" + }, + "source": { + "name": "Fictional scoring fixtures", + "url": "fixture:product-fpol/demo", + "retrieved_at": "2026-10-01T00:00:00Z" + } + }, + { + "id": "demo-governor-05-3", + "race_id": "demo-governor-05", + "name": "Casey Governor 05", + "affiliation": "other", + "qualified": true, + "ballot_lines": [ + "Other" + ], + "provider_ids": { + "fictional": "demo-governor-05-3" + }, + "source": { + "name": "Fictional scoring fixtures", + "url": "fixture:product-fpol/demo", + "retrieved_at": "2026-10-01T00:00:00Z" + } + }, + { + "id": "demo-governor-05-4", + "race_id": "demo-governor-05", + "name": "Drew Governor 05", + "affiliation": "other", + "qualified": true, + "ballot_lines": [ + "Other" + ], + "provider_ids": { + "fictional": "demo-governor-05-4" + }, + "source": { + "name": "Fictional scoring fixtures", + "url": "fixture:product-fpol/demo", + "retrieved_at": "2026-10-01T00:00:00Z" + } + }, + { + "id": "demo-governor-06-1", + "race_id": "demo-governor-06", + "name": "Avery Governor 06", + "affiliation": "democrat", + "qualified": true, + "ballot_lines": [ + "Democrat" + ], + "provider_ids": { + "fictional": "demo-governor-06-1" + }, + "source": { + "name": "Fictional scoring fixtures", + "url": "fixture:product-fpol/demo", + "retrieved_at": "2026-10-01T00:00:00Z" + } + }, + { + "id": "demo-governor-06-2", + "race_id": "demo-governor-06", + "name": "Blair Governor 06", + "affiliation": "republican", + "qualified": true, + "ballot_lines": [ + "Republican" + ], + "provider_ids": { + "fictional": "demo-governor-06-2" + }, + "source": { + "name": "Fictional scoring fixtures", + "url": "fixture:product-fpol/demo", + "retrieved_at": "2026-10-01T00:00:00Z" + } + }, + { + "id": "demo-governor-06-3", + "race_id": "demo-governor-06", + "name": "Casey Governor 06", + "affiliation": "other", + "qualified": true, + "ballot_lines": [ + "Other" + ], + "provider_ids": { + "fictional": "demo-governor-06-3" + }, + "source": { + "name": "Fictional scoring fixtures", + "url": "fixture:product-fpol/demo", + "retrieved_at": "2026-10-01T00:00:00Z" + } + }, + { + "id": "demo-governor-06-4", + "race_id": "demo-governor-06", + "name": "Drew Governor 06", + "affiliation": "other", + "qualified": true, + "ballot_lines": [ + "Other" + ], + "provider_ids": { + "fictional": "demo-governor-06-4" + }, + "source": { + "name": "Fictional scoring fixtures", + "url": "fixture:product-fpol/demo", + "retrieved_at": "2026-10-01T00:00:00Z" + } + }, + { + "id": "demo-governor-07-1", + "race_id": "demo-governor-07", + "name": "Avery Governor 07", + "affiliation": "democrat", + "qualified": true, + "ballot_lines": [ + "Democrat" + ], + "provider_ids": { + "fictional": "demo-governor-07-1" + }, + "source": { + "name": "Fictional scoring fixtures", + "url": "fixture:product-fpol/demo", + "retrieved_at": "2026-10-01T00:00:00Z" + } + }, + { + "id": "demo-governor-07-2", + "race_id": "demo-governor-07", + "name": "Blair Governor 07", + "affiliation": "republican", + "qualified": true, + "ballot_lines": [ + "Republican" + ], + "provider_ids": { + "fictional": "demo-governor-07-2" + }, + "source": { + "name": "Fictional scoring fixtures", + "url": "fixture:product-fpol/demo", + "retrieved_at": "2026-10-01T00:00:00Z" + } + }, + { + "id": "demo-governor-07-3", + "race_id": "demo-governor-07", + "name": "Casey Governor 07", + "affiliation": "other", + "qualified": true, + "ballot_lines": [ + "Other" + ], + "provider_ids": { + "fictional": "demo-governor-07-3" + }, + "source": { + "name": "Fictional scoring fixtures", + "url": "fixture:product-fpol/demo", + "retrieved_at": "2026-10-01T00:00:00Z" + } + }, + { + "id": "demo-governor-07-4", + "race_id": "demo-governor-07", + "name": "Drew Governor 07", + "affiliation": "other", + "qualified": true, + "ballot_lines": [ + "Other" + ], + "provider_ids": { + "fictional": "demo-governor-07-4" + }, + "source": { + "name": "Fictional scoring fixtures", + "url": "fixture:product-fpol/demo", + "retrieved_at": "2026-10-01T00:00:00Z" + } + }, + { + "id": "demo-governor-08-1", + "race_id": "demo-governor-08", + "name": "Avery Governor 08", + "affiliation": "democrat", + "qualified": true, + "ballot_lines": [ + "Democrat" + ], + "provider_ids": { + "fictional": "demo-governor-08-1" + }, + "source": { + "name": "Fictional scoring fixtures", + "url": "fixture:product-fpol/demo", + "retrieved_at": "2026-10-01T00:00:00Z" + } + }, + { + "id": "demo-governor-08-2", + "race_id": "demo-governor-08", + "name": "Blair Governor 08", + "affiliation": "republican", + "qualified": true, + "ballot_lines": [ + "Republican" + ], + "provider_ids": { + "fictional": "demo-governor-08-2" + }, + "source": { + "name": "Fictional scoring fixtures", + "url": "fixture:product-fpol/demo", + "retrieved_at": "2026-10-01T00:00:00Z" + } + }, + { + "id": "demo-governor-08-3", + "race_id": "demo-governor-08", + "name": "Casey Governor 08", + "affiliation": "other", + "qualified": true, + "ballot_lines": [ + "Other" + ], + "provider_ids": { + "fictional": "demo-governor-08-3" + }, + "source": { + "name": "Fictional scoring fixtures", + "url": "fixture:product-fpol/demo", + "retrieved_at": "2026-10-01T00:00:00Z" + } + }, + { + "id": "demo-governor-08-4", + "race_id": "demo-governor-08", + "name": "Drew Governor 08", + "affiliation": "other", + "qualified": true, + "ballot_lines": [ + "Other" + ], + "provider_ids": { + "fictional": "demo-governor-08-4" + }, + "source": { + "name": "Fictional scoring fixtures", + "url": "fixture:product-fpol/demo", + "retrieved_at": "2026-10-01T00:00:00Z" + } + }, + { + "id": "demo-governor-09-1", + "race_id": "demo-governor-09", + "name": "Avery Governor 09", + "affiliation": "democrat", + "qualified": true, + "ballot_lines": [ + "Democrat" + ], + "provider_ids": { + "fictional": "demo-governor-09-1" + }, + "source": { + "name": "Fictional scoring fixtures", + "url": "fixture:product-fpol/demo", + "retrieved_at": "2026-10-01T00:00:00Z" + } + }, + { + "id": "demo-governor-09-2", + "race_id": "demo-governor-09", + "name": "Blair Governor 09", + "affiliation": "republican", + "qualified": true, + "ballot_lines": [ + "Republican" + ], + "provider_ids": { + "fictional": "demo-governor-09-2" + }, + "source": { + "name": "Fictional scoring fixtures", + "url": "fixture:product-fpol/demo", + "retrieved_at": "2026-10-01T00:00:00Z" + } + }, + { + "id": "demo-governor-09-3", + "race_id": "demo-governor-09", + "name": "Casey Governor 09", + "affiliation": "other", + "qualified": true, + "ballot_lines": [ + "Other" + ], + "provider_ids": { + "fictional": "demo-governor-09-3" + }, + "source": { + "name": "Fictional scoring fixtures", + "url": "fixture:product-fpol/demo", + "retrieved_at": "2026-10-01T00:00:00Z" + } + }, + { + "id": "demo-governor-09-4", + "race_id": "demo-governor-09", + "name": "Drew Governor 09", + "affiliation": "other", + "qualified": true, + "ballot_lines": [ + "Other" + ], + "provider_ids": { + "fictional": "demo-governor-09-4" + }, + "source": { + "name": "Fictional scoring fixtures", + "url": "fixture:product-fpol/demo", + "retrieved_at": "2026-10-01T00:00:00Z" + } + }, + { + "id": "demo-governor-10-1", + "race_id": "demo-governor-10", + "name": "Avery Governor 10", + "affiliation": "democrat", + "qualified": true, + "ballot_lines": [ + "Democrat" + ], + "provider_ids": { + "fictional": "demo-governor-10-1" + }, + "source": { + "name": "Fictional scoring fixtures", + "url": "fixture:product-fpol/demo", + "retrieved_at": "2026-10-01T00:00:00Z" + } + }, + { + "id": "demo-governor-10-2", + "race_id": "demo-governor-10", + "name": "Blair Governor 10", + "affiliation": "republican", + "qualified": true, + "ballot_lines": [ + "Republican" + ], + "provider_ids": { + "fictional": "demo-governor-10-2" + }, + "source": { + "name": "Fictional scoring fixtures", + "url": "fixture:product-fpol/demo", + "retrieved_at": "2026-10-01T00:00:00Z" + } + }, + { + "id": "demo-governor-10-3", + "race_id": "demo-governor-10", + "name": "Casey Governor 10", + "affiliation": "other", + "qualified": true, + "ballot_lines": [ + "Other" + ], + "provider_ids": { + "fictional": "demo-governor-10-3" + }, + "source": { + "name": "Fictional scoring fixtures", + "url": "fixture:product-fpol/demo", + "retrieved_at": "2026-10-01T00:00:00Z" + } + }, + { + "id": "demo-governor-10-4", + "race_id": "demo-governor-10", + "name": "Drew Governor 10", + "affiliation": "other", + "qualified": true, + "ballot_lines": [ + "Other" + ], + "provider_ids": { + "fictional": "demo-governor-10-4" + }, + "source": { + "name": "Fictional scoring fixtures", + "url": "fixture:product-fpol/demo", + "retrieved_at": "2026-10-01T00:00:00Z" + } + }, + { + "id": "demo-governor-11-1", + "race_id": "demo-governor-11", + "name": "Avery Governor 11", + "affiliation": "democrat", + "qualified": true, + "ballot_lines": [ + "Democrat" + ], + "provider_ids": { + "fictional": "demo-governor-11-1" + }, + "source": { + "name": "Fictional scoring fixtures", + "url": "fixture:product-fpol/demo", + "retrieved_at": "2026-10-01T00:00:00Z" + } + }, + { + "id": "demo-governor-11-2", + "race_id": "demo-governor-11", + "name": "Blair Governor 11", + "affiliation": "republican", + "qualified": true, + "ballot_lines": [ + "Republican" + ], + "provider_ids": { + "fictional": "demo-governor-11-2" + }, + "source": { + "name": "Fictional scoring fixtures", + "url": "fixture:product-fpol/demo", + "retrieved_at": "2026-10-01T00:00:00Z" + } + }, + { + "id": "demo-governor-11-3", + "race_id": "demo-governor-11", + "name": "Casey Governor 11", + "affiliation": "other", + "qualified": true, + "ballot_lines": [ + "Other" + ], + "provider_ids": { + "fictional": "demo-governor-11-3" + }, + "source": { + "name": "Fictional scoring fixtures", + "url": "fixture:product-fpol/demo", + "retrieved_at": "2026-10-01T00:00:00Z" + } + }, + { + "id": "demo-governor-11-4", + "race_id": "demo-governor-11", + "name": "Drew Governor 11", + "affiliation": "other", + "qualified": true, + "ballot_lines": [ + "Other" + ], + "provider_ids": { + "fictional": "demo-governor-11-4" + }, + "source": { + "name": "Fictional scoring fixtures", + "url": "fixture:product-fpol/demo", + "retrieved_at": "2026-10-01T00:00:00Z" + } + }, + { + "id": "demo-governor-12-1", + "race_id": "demo-governor-12", + "name": "Avery Governor 12", + "affiliation": "democrat", + "qualified": true, + "ballot_lines": [ + "Democrat" + ], + "provider_ids": { + "fictional": "demo-governor-12-1" + }, + "source": { + "name": "Fictional scoring fixtures", + "url": "fixture:product-fpol/demo", + "retrieved_at": "2026-10-01T00:00:00Z" + } + }, + { + "id": "demo-governor-12-2", + "race_id": "demo-governor-12", + "name": "Blair Governor 12", + "affiliation": "republican", + "qualified": true, + "ballot_lines": [ + "Republican" + ], + "provider_ids": { + "fictional": "demo-governor-12-2" + }, + "source": { + "name": "Fictional scoring fixtures", + "url": "fixture:product-fpol/demo", + "retrieved_at": "2026-10-01T00:00:00Z" + } + }, + { + "id": "demo-governor-12-3", + "race_id": "demo-governor-12", + "name": "Casey Governor 12", + "affiliation": "other", + "qualified": true, + "ballot_lines": [ + "Other" + ], + "provider_ids": { + "fictional": "demo-governor-12-3" + }, + "source": { + "name": "Fictional scoring fixtures", + "url": "fixture:product-fpol/demo", + "retrieved_at": "2026-10-01T00:00:00Z" + } + }, + { + "id": "demo-governor-12-4", + "race_id": "demo-governor-12", + "name": "Drew Governor 12", + "affiliation": "other", + "qualified": true, + "ballot_lines": [ + "Other" + ], + "provider_ids": { + "fictional": "demo-governor-12-4" + }, + "source": { + "name": "Fictional scoring fixtures", + "url": "fixture:product-fpol/demo", + "retrieved_at": "2026-10-01T00:00:00Z" + } + } + ] +} diff --git a/data/demo/preview.json b/data/demo/preview.json new file mode 100644 index 0000000..aab481e --- /dev/null +++ b/data/demo/preview.json @@ -0,0 +1,55 @@ +{ + "catalog_id": "demo-2026", + "ratings_id": "demo-ratings-v1", + "results_id": "demo-results-3", + "roster": [ + { + "slot": "house:democrat", + "candidate_id": "demo-house-04-1" + }, + { + "slot": "house:republican", + "candidate_id": "demo-house-01-2" + }, + { + "slot": "house:other", + "candidate_id": "demo-house-02-3" + }, + { + "slot": "house:flex", + "candidate_id": "demo-house-03-4" + }, + { + "slot": "senate:democrat", + "candidate_id": "demo-senate-04-1" + }, + { + "slot": "senate:republican", + "candidate_id": "demo-senate-01-2" + }, + { + "slot": "senate:other", + "candidate_id": "demo-senate-02-3" + }, + { + "slot": "senate:flex", + "candidate_id": "demo-senate-03-4" + }, + { + "slot": "governor:democrat", + "candidate_id": "demo-governor-04-1" + }, + { + "slot": "governor:republican", + "candidate_id": "demo-governor-01-2" + }, + { + "slot": "governor:other", + "candidate_id": "demo-governor-02-3" + }, + { + "slot": "governor:flex", + "candidate_id": "demo-governor-03-4" + } + ] +} diff --git a/data/demo/ratings.json b/data/demo/ratings.json new file mode 100644 index 0000000..5a18e2f --- /dev/null +++ b/data/demo/ratings.json @@ -0,0 +1,160 @@ +{ + "schema_version": 1, + "id": "demo-ratings-v1", + "kind": "ratings", + "mode": "demo", + "label": "Frozen fictional ratings", + "as_of": "2026-10-02T00:00:00Z", + "source": { + "name": "Fictional scoring fixtures", + "url": "fixture:product-fpol/demo", + "retrieved_at": "2026-10-01T00:00:00Z" + }, + "catalog_id": "demo-2026", + "entries": [ + { + "race_id": "demo-house-01", + "rating": "safe" + }, + { + "race_id": "demo-house-02", + "rating": "likely" + }, + { + "race_id": "demo-house-03", + "rating": "lean" + }, + { + "race_id": "demo-house-04", + "rating": "toss_up" + }, + { + "race_id": "demo-house-05", + "rating": "safe" + }, + { + "race_id": "demo-house-06", + "rating": "likely" + }, + { + "race_id": "demo-house-07", + "rating": "lean" + }, + { + "race_id": "demo-house-08", + "rating": "toss_up" + }, + { + "race_id": "demo-house-09", + "rating": "safe" + }, + { + "race_id": "demo-house-10", + "rating": "likely" + }, + { + "race_id": "demo-house-11", + "rating": "lean" + }, + { + "race_id": "demo-house-12", + "rating": "toss_up" + }, + { + "race_id": "demo-senate-01", + "rating": "safe" + }, + { + "race_id": "demo-senate-02", + "rating": "likely" + }, + { + "race_id": "demo-senate-03", + "rating": "lean" + }, + { + "race_id": "demo-senate-04", + "rating": "toss_up" + }, + { + "race_id": "demo-senate-05", + "rating": "safe" + }, + { + "race_id": "demo-senate-06", + "rating": "likely" + }, + { + "race_id": "demo-senate-07", + "rating": "lean" + }, + { + "race_id": "demo-senate-08", + "rating": "toss_up" + }, + { + "race_id": "demo-senate-09", + "rating": "safe" + }, + { + "race_id": "demo-senate-10", + "rating": "likely" + }, + { + "race_id": "demo-senate-11", + "rating": "lean" + }, + { + "race_id": "demo-senate-12", + "rating": "toss_up" + }, + { + "race_id": "demo-governor-01", + "rating": "safe" + }, + { + "race_id": "demo-governor-02", + "rating": "likely" + }, + { + "race_id": "demo-governor-03", + "rating": "lean" + }, + { + "race_id": "demo-governor-04", + "rating": "toss_up" + }, + { + "race_id": "demo-governor-05", + "rating": "safe" + }, + { + "race_id": "demo-governor-06", + "rating": "likely" + }, + { + "race_id": "demo-governor-07", + "rating": "lean" + }, + { + "race_id": "demo-governor-08", + "rating": "toss_up" + }, + { + "race_id": "demo-governor-09", + "rating": "safe" + }, + { + "race_id": "demo-governor-10", + "rating": "likely" + }, + { + "race_id": "demo-governor-11", + "rating": "lean" + }, + { + "race_id": "demo-governor-12", + "rating": "toss_up" + } + ] +} diff --git a/data/demo/results-0.json b/data/demo/results-0.json new file mode 100644 index 0000000..fc511ae --- /dev/null +++ b/data/demo/results-0.json @@ -0,0 +1,916 @@ +{ + "schema_version": 1, + "id": "demo-results-0", + "kind": "results", + "mode": "demo", + "label": "Synthetic replay 1: No votes yet", + "as_of": "2026-11-04T00:00:00Z", + "source": { + "name": "Fictional scoring fixtures", + "url": "fixture:product-fpol/demo", + "retrieved_at": "2026-10-01T00:00:00Z" + }, + "catalog_id": "demo-2026", + "entries": [ + { + "race_id": "demo-house-01", + "round": "general", + "status": "reporting", + "total_votes": 0, + "winner_id": null, + "votes": [ + { + "candidate_id": "demo-house-01-1", + "votes": 0 + }, + { + "candidate_id": "demo-house-01-2", + "votes": 0 + }, + { + "candidate_id": "demo-house-01-3", + "votes": 0 + }, + { + "candidate_id": "demo-house-01-4", + "votes": 0 + } + ] + }, + { + "race_id": "demo-house-02", + "round": "general", + "status": "reporting", + "total_votes": 0, + "winner_id": null, + "votes": [ + { + "candidate_id": "demo-house-02-1", + "votes": 0 + }, + { + "candidate_id": "demo-house-02-2", + "votes": 0 + }, + { + "candidate_id": "demo-house-02-3", + "votes": 0 + }, + { + "candidate_id": "demo-house-02-4", + "votes": 0 + } + ] + }, + { + "race_id": "demo-house-03", + "round": "general", + "status": "reporting", + "total_votes": 0, + "winner_id": null, + "votes": [ + { + "candidate_id": "demo-house-03-1", + "votes": 0 + }, + { + "candidate_id": "demo-house-03-2", + "votes": 0 + }, + { + "candidate_id": "demo-house-03-3", + "votes": 0 + }, + { + "candidate_id": "demo-house-03-4", + "votes": 0 + } + ] + }, + { + "race_id": "demo-house-04", + "round": "general", + "status": "reporting", + "total_votes": 0, + "winner_id": null, + "votes": [ + { + "candidate_id": "demo-house-04-1", + "votes": 0 + }, + { + "candidate_id": "demo-house-04-2", + "votes": 0 + }, + { + "candidate_id": "demo-house-04-3", + "votes": 0 + }, + { + "candidate_id": "demo-house-04-4", + "votes": 0 + } + ] + }, + { + "race_id": "demo-house-05", + "round": "general", + "status": "reporting", + "total_votes": 0, + "winner_id": null, + "votes": [ + { + "candidate_id": "demo-house-05-1", + "votes": 0 + }, + { + "candidate_id": "demo-house-05-2", + "votes": 0 + }, + { + "candidate_id": "demo-house-05-3", + "votes": 0 + }, + { + "candidate_id": "demo-house-05-4", + "votes": 0 + } + ] + }, + { + "race_id": "demo-house-06", + "round": "general", + "status": "reporting", + "total_votes": 0, + "winner_id": null, + "votes": [ + { + "candidate_id": "demo-house-06-1", + "votes": 0 + }, + { + "candidate_id": "demo-house-06-2", + "votes": 0 + }, + { + "candidate_id": "demo-house-06-3", + "votes": 0 + }, + { + "candidate_id": "demo-house-06-4", + "votes": 0 + } + ] + }, + { + "race_id": "demo-house-07", + "round": "general", + "status": "reporting", + "total_votes": 0, + "winner_id": null, + "votes": [ + { + "candidate_id": "demo-house-07-1", + "votes": 0 + }, + { + "candidate_id": "demo-house-07-2", + "votes": 0 + }, + { + "candidate_id": "demo-house-07-3", + "votes": 0 + }, + { + "candidate_id": "demo-house-07-4", + "votes": 0 + } + ] + }, + { + "race_id": "demo-house-08", + "round": "general", + "status": "reporting", + "total_votes": 0, + "winner_id": null, + "votes": [ + { + "candidate_id": "demo-house-08-1", + "votes": 0 + }, + { + "candidate_id": "demo-house-08-2", + "votes": 0 + }, + { + "candidate_id": "demo-house-08-3", + "votes": 0 + }, + { + "candidate_id": "demo-house-08-4", + "votes": 0 + } + ] + }, + { + "race_id": "demo-house-09", + "round": "general", + "status": "reporting", + "total_votes": 0, + "winner_id": null, + "votes": [ + { + "candidate_id": "demo-house-09-1", + "votes": 0 + }, + { + "candidate_id": "demo-house-09-2", + "votes": 0 + }, + { + "candidate_id": "demo-house-09-3", + "votes": 0 + }, + { + "candidate_id": "demo-house-09-4", + "votes": 0 + } + ] + }, + { + "race_id": "demo-house-10", + "round": "general", + "status": "reporting", + "total_votes": 0, + "winner_id": null, + "votes": [ + { + "candidate_id": "demo-house-10-1", + "votes": 0 + }, + { + "candidate_id": "demo-house-10-2", + "votes": 0 + }, + { + "candidate_id": "demo-house-10-3", + "votes": 0 + }, + { + "candidate_id": "demo-house-10-4", + "votes": 0 + } + ] + }, + { + "race_id": "demo-house-11", + "round": "general", + "status": "reporting", + "total_votes": 0, + "winner_id": null, + "votes": [ + { + "candidate_id": "demo-house-11-1", + "votes": 0 + }, + { + "candidate_id": "demo-house-11-2", + "votes": 0 + }, + { + "candidate_id": "demo-house-11-3", + "votes": 0 + }, + { + "candidate_id": "demo-house-11-4", + "votes": 0 + } + ] + }, + { + "race_id": "demo-house-12", + "round": "general", + "status": "reporting", + "total_votes": 0, + "winner_id": null, + "votes": [ + { + "candidate_id": "demo-house-12-1", + "votes": 0 + }, + { + "candidate_id": "demo-house-12-2", + "votes": 0 + }, + { + "candidate_id": "demo-house-12-3", + "votes": 0 + }, + { + "candidate_id": "demo-house-12-4", + "votes": 0 + } + ] + }, + { + "race_id": "demo-senate-01", + "round": "general", + "status": "reporting", + "total_votes": 0, + "winner_id": null, + "votes": [ + { + "candidate_id": "demo-senate-01-1", + "votes": 0 + }, + { + "candidate_id": "demo-senate-01-2", + "votes": 0 + }, + { + "candidate_id": "demo-senate-01-3", + "votes": 0 + }, + { + "candidate_id": "demo-senate-01-4", + "votes": 0 + } + ] + }, + { + "race_id": "demo-senate-02", + "round": "general", + "status": "reporting", + "total_votes": 0, + "winner_id": null, + "votes": [ + { + "candidate_id": "demo-senate-02-1", + "votes": 0 + }, + { + "candidate_id": "demo-senate-02-2", + "votes": 0 + }, + { + "candidate_id": "demo-senate-02-3", + "votes": 0 + }, + { + "candidate_id": "demo-senate-02-4", + "votes": 0 + } + ] + }, + { + "race_id": "demo-senate-03", + "round": "general", + "status": "reporting", + "total_votes": 0, + "winner_id": null, + "votes": [ + { + "candidate_id": "demo-senate-03-1", + "votes": 0 + }, + { + "candidate_id": "demo-senate-03-2", + "votes": 0 + }, + { + "candidate_id": "demo-senate-03-3", + "votes": 0 + }, + { + "candidate_id": "demo-senate-03-4", + "votes": 0 + } + ] + }, + { + "race_id": "demo-senate-04", + "round": "general", + "status": "reporting", + "total_votes": 0, + "winner_id": null, + "votes": [ + { + "candidate_id": "demo-senate-04-1", + "votes": 0 + }, + { + "candidate_id": "demo-senate-04-2", + "votes": 0 + }, + { + "candidate_id": "demo-senate-04-3", + "votes": 0 + }, + { + "candidate_id": "demo-senate-04-4", + "votes": 0 + } + ] + }, + { + "race_id": "demo-senate-05", + "round": "general", + "status": "reporting", + "total_votes": 0, + "winner_id": null, + "votes": [ + { + "candidate_id": "demo-senate-05-1", + "votes": 0 + }, + { + "candidate_id": "demo-senate-05-2", + "votes": 0 + }, + { + "candidate_id": "demo-senate-05-3", + "votes": 0 + }, + { + "candidate_id": "demo-senate-05-4", + "votes": 0 + } + ] + }, + { + "race_id": "demo-senate-06", + "round": "general", + "status": "reporting", + "total_votes": 0, + "winner_id": null, + "votes": [ + { + "candidate_id": "demo-senate-06-1", + "votes": 0 + }, + { + "candidate_id": "demo-senate-06-2", + "votes": 0 + }, + { + "candidate_id": "demo-senate-06-3", + "votes": 0 + }, + { + "candidate_id": "demo-senate-06-4", + "votes": 0 + } + ] + }, + { + "race_id": "demo-senate-07", + "round": "general", + "status": "reporting", + "total_votes": 0, + "winner_id": null, + "votes": [ + { + "candidate_id": "demo-senate-07-1", + "votes": 0 + }, + { + "candidate_id": "demo-senate-07-2", + "votes": 0 + }, + { + "candidate_id": "demo-senate-07-3", + "votes": 0 + }, + { + "candidate_id": "demo-senate-07-4", + "votes": 0 + } + ] + }, + { + "race_id": "demo-senate-08", + "round": "general", + "status": "reporting", + "total_votes": 0, + "winner_id": null, + "votes": [ + { + "candidate_id": "demo-senate-08-1", + "votes": 0 + }, + { + "candidate_id": "demo-senate-08-2", + "votes": 0 + }, + { + "candidate_id": "demo-senate-08-3", + "votes": 0 + }, + { + "candidate_id": "demo-senate-08-4", + "votes": 0 + } + ] + }, + { + "race_id": "demo-senate-09", + "round": "general", + "status": "reporting", + "total_votes": 0, + "winner_id": null, + "votes": [ + { + "candidate_id": "demo-senate-09-1", + "votes": 0 + }, + { + "candidate_id": "demo-senate-09-2", + "votes": 0 + }, + { + "candidate_id": "demo-senate-09-3", + "votes": 0 + }, + { + "candidate_id": "demo-senate-09-4", + "votes": 0 + } + ] + }, + { + "race_id": "demo-senate-10", + "round": "general", + "status": "reporting", + "total_votes": 0, + "winner_id": null, + "votes": [ + { + "candidate_id": "demo-senate-10-1", + "votes": 0 + }, + { + "candidate_id": "demo-senate-10-2", + "votes": 0 + }, + { + "candidate_id": "demo-senate-10-3", + "votes": 0 + }, + { + "candidate_id": "demo-senate-10-4", + "votes": 0 + } + ] + }, + { + "race_id": "demo-senate-11", + "round": "general", + "status": "reporting", + "total_votes": 0, + "winner_id": null, + "votes": [ + { + "candidate_id": "demo-senate-11-1", + "votes": 0 + }, + { + "candidate_id": "demo-senate-11-2", + "votes": 0 + }, + { + "candidate_id": "demo-senate-11-3", + "votes": 0 + }, + { + "candidate_id": "demo-senate-11-4", + "votes": 0 + } + ] + }, + { + "race_id": "demo-senate-12", + "round": "general", + "status": "reporting", + "total_votes": 0, + "winner_id": null, + "votes": [ + { + "candidate_id": "demo-senate-12-1", + "votes": 0 + }, + { + "candidate_id": "demo-senate-12-2", + "votes": 0 + }, + { + "candidate_id": "demo-senate-12-3", + "votes": 0 + }, + { + "candidate_id": "demo-senate-12-4", + "votes": 0 + } + ] + }, + { + "race_id": "demo-governor-01", + "round": "general", + "status": "reporting", + "total_votes": 0, + "winner_id": null, + "votes": [ + { + "candidate_id": "demo-governor-01-1", + "votes": 0 + }, + { + "candidate_id": "demo-governor-01-2", + "votes": 0 + }, + { + "candidate_id": "demo-governor-01-3", + "votes": 0 + }, + { + "candidate_id": "demo-governor-01-4", + "votes": 0 + } + ] + }, + { + "race_id": "demo-governor-02", + "round": "general", + "status": "reporting", + "total_votes": 0, + "winner_id": null, + "votes": [ + { + "candidate_id": "demo-governor-02-1", + "votes": 0 + }, + { + "candidate_id": "demo-governor-02-2", + "votes": 0 + }, + { + "candidate_id": "demo-governor-02-3", + "votes": 0 + }, + { + "candidate_id": "demo-governor-02-4", + "votes": 0 + } + ] + }, + { + "race_id": "demo-governor-03", + "round": "general", + "status": "reporting", + "total_votes": 0, + "winner_id": null, + "votes": [ + { + "candidate_id": "demo-governor-03-1", + "votes": 0 + }, + { + "candidate_id": "demo-governor-03-2", + "votes": 0 + }, + { + "candidate_id": "demo-governor-03-3", + "votes": 0 + }, + { + "candidate_id": "demo-governor-03-4", + "votes": 0 + } + ] + }, + { + "race_id": "demo-governor-04", + "round": "general", + "status": "reporting", + "total_votes": 0, + "winner_id": null, + "votes": [ + { + "candidate_id": "demo-governor-04-1", + "votes": 0 + }, + { + "candidate_id": "demo-governor-04-2", + "votes": 0 + }, + { + "candidate_id": "demo-governor-04-3", + "votes": 0 + }, + { + "candidate_id": "demo-governor-04-4", + "votes": 0 + } + ] + }, + { + "race_id": "demo-governor-05", + "round": "general", + "status": "reporting", + "total_votes": 0, + "winner_id": null, + "votes": [ + { + "candidate_id": "demo-governor-05-1", + "votes": 0 + }, + { + "candidate_id": "demo-governor-05-2", + "votes": 0 + }, + { + "candidate_id": "demo-governor-05-3", + "votes": 0 + }, + { + "candidate_id": "demo-governor-05-4", + "votes": 0 + } + ] + }, + { + "race_id": "demo-governor-06", + "round": "general", + "status": "reporting", + "total_votes": 0, + "winner_id": null, + "votes": [ + { + "candidate_id": "demo-governor-06-1", + "votes": 0 + }, + { + "candidate_id": "demo-governor-06-2", + "votes": 0 + }, + { + "candidate_id": "demo-governor-06-3", + "votes": 0 + }, + { + "candidate_id": "demo-governor-06-4", + "votes": 0 + } + ] + }, + { + "race_id": "demo-governor-07", + "round": "general", + "status": "reporting", + "total_votes": 0, + "winner_id": null, + "votes": [ + { + "candidate_id": "demo-governor-07-1", + "votes": 0 + }, + { + "candidate_id": "demo-governor-07-2", + "votes": 0 + }, + { + "candidate_id": "demo-governor-07-3", + "votes": 0 + }, + { + "candidate_id": "demo-governor-07-4", + "votes": 0 + } + ] + }, + { + "race_id": "demo-governor-08", + "round": "general", + "status": "reporting", + "total_votes": 0, + "winner_id": null, + "votes": [ + { + "candidate_id": "demo-governor-08-1", + "votes": 0 + }, + { + "candidate_id": "demo-governor-08-2", + "votes": 0 + }, + { + "candidate_id": "demo-governor-08-3", + "votes": 0 + }, + { + "candidate_id": "demo-governor-08-4", + "votes": 0 + } + ] + }, + { + "race_id": "demo-governor-09", + "round": "general", + "status": "reporting", + "total_votes": 0, + "winner_id": null, + "votes": [ + { + "candidate_id": "demo-governor-09-1", + "votes": 0 + }, + { + "candidate_id": "demo-governor-09-2", + "votes": 0 + }, + { + "candidate_id": "demo-governor-09-3", + "votes": 0 + }, + { + "candidate_id": "demo-governor-09-4", + "votes": 0 + } + ] + }, + { + "race_id": "demo-governor-10", + "round": "general", + "status": "reporting", + "total_votes": 0, + "winner_id": null, + "votes": [ + { + "candidate_id": "demo-governor-10-1", + "votes": 0 + }, + { + "candidate_id": "demo-governor-10-2", + "votes": 0 + }, + { + "candidate_id": "demo-governor-10-3", + "votes": 0 + }, + { + "candidate_id": "demo-governor-10-4", + "votes": 0 + } + ] + }, + { + "race_id": "demo-governor-11", + "round": "general", + "status": "reporting", + "total_votes": 0, + "winner_id": null, + "votes": [ + { + "candidate_id": "demo-governor-11-1", + "votes": 0 + }, + { + "candidate_id": "demo-governor-11-2", + "votes": 0 + }, + { + "candidate_id": "demo-governor-11-3", + "votes": 0 + }, + { + "candidate_id": "demo-governor-11-4", + "votes": 0 + } + ] + }, + { + "race_id": "demo-governor-12", + "round": "general", + "status": "reporting", + "total_votes": 0, + "winner_id": null, + "votes": [ + { + "candidate_id": "demo-governor-12-1", + "votes": 0 + }, + { + "candidate_id": "demo-governor-12-2", + "votes": 0 + }, + { + "candidate_id": "demo-governor-12-3", + "votes": 0 + }, + { + "candidate_id": "demo-governor-12-4", + "votes": 0 + } + ] + } + ] +} diff --git a/data/demo/results-1.json b/data/demo/results-1.json new file mode 100644 index 0000000..26385a2 --- /dev/null +++ b/data/demo/results-1.json @@ -0,0 +1,916 @@ +{ + "schema_version": 1, + "id": "demo-results-1", + "kind": "results", + "mode": "demo", + "label": "Synthetic replay 2: Early reporting", + "as_of": "2026-11-04T01:00:00Z", + "source": { + "name": "Fictional scoring fixtures", + "url": "fixture:product-fpol/demo", + "retrieved_at": "2026-10-01T00:00:00Z" + }, + "catalog_id": "demo-2026", + "entries": [ + { + "race_id": "demo-house-01", + "round": "general", + "status": "reporting", + "total_votes": 1000, + "winner_id": null, + "votes": [ + { + "candidate_id": "demo-house-01-1", + "votes": 800 + }, + { + "candidate_id": "demo-house-01-2", + "votes": 150 + }, + { + "candidate_id": "demo-house-01-3", + "votes": 30 + }, + { + "candidate_id": "demo-house-01-4", + "votes": 20 + } + ] + }, + { + "race_id": "demo-house-02", + "round": "general", + "status": "reporting", + "total_votes": 1000, + "winner_id": null, + "votes": [ + { + "candidate_id": "demo-house-02-1", + "votes": 600 + }, + { + "candidate_id": "demo-house-02-2", + "votes": 300 + }, + { + "candidate_id": "demo-house-02-3", + "votes": 70 + }, + { + "candidate_id": "demo-house-02-4", + "votes": 30 + } + ] + }, + { + "race_id": "demo-house-03", + "round": "general", + "status": "reporting", + "total_votes": 1000, + "winner_id": null, + "votes": [ + { + "candidate_id": "demo-house-03-1", + "votes": 540 + }, + { + "candidate_id": "demo-house-03-2", + "votes": 420 + }, + { + "candidate_id": "demo-house-03-3", + "votes": 25 + }, + { + "candidate_id": "demo-house-03-4", + "votes": 15 + } + ] + }, + { + "race_id": "demo-house-04", + "round": "general", + "status": "reporting", + "total_votes": 1000, + "winner_id": null, + "votes": [ + { + "candidate_id": "demo-house-04-1", + "votes": 510 + }, + { + "candidate_id": "demo-house-04-2", + "votes": 490 + }, + { + "candidate_id": "demo-house-04-3", + "votes": 0 + }, + { + "candidate_id": "demo-house-04-4", + "votes": 0 + } + ] + }, + { + "race_id": "demo-house-05", + "round": "general", + "status": "reporting", + "total_votes": 1000, + "winner_id": null, + "votes": [ + { + "candidate_id": "demo-house-05-1", + "votes": 800 + }, + { + "candidate_id": "demo-house-05-2", + "votes": 150 + }, + { + "candidate_id": "demo-house-05-3", + "votes": 30 + }, + { + "candidate_id": "demo-house-05-4", + "votes": 20 + } + ] + }, + { + "race_id": "demo-house-06", + "round": "general", + "status": "reporting", + "total_votes": 1000, + "winner_id": null, + "votes": [ + { + "candidate_id": "demo-house-06-1", + "votes": 600 + }, + { + "candidate_id": "demo-house-06-2", + "votes": 300 + }, + { + "candidate_id": "demo-house-06-3", + "votes": 70 + }, + { + "candidate_id": "demo-house-06-4", + "votes": 30 + } + ] + }, + { + "race_id": "demo-house-07", + "round": "general", + "status": "reporting", + "total_votes": 1000, + "winner_id": null, + "votes": [ + { + "candidate_id": "demo-house-07-1", + "votes": 540 + }, + { + "candidate_id": "demo-house-07-2", + "votes": 420 + }, + { + "candidate_id": "demo-house-07-3", + "votes": 25 + }, + { + "candidate_id": "demo-house-07-4", + "votes": 15 + } + ] + }, + { + "race_id": "demo-house-08", + "round": "general", + "status": "reporting", + "total_votes": 1000, + "winner_id": null, + "votes": [ + { + "candidate_id": "demo-house-08-1", + "votes": 510 + }, + { + "candidate_id": "demo-house-08-2", + "votes": 490 + }, + { + "candidate_id": "demo-house-08-3", + "votes": 0 + }, + { + "candidate_id": "demo-house-08-4", + "votes": 0 + } + ] + }, + { + "race_id": "demo-house-09", + "round": "general", + "status": "reporting", + "total_votes": 1000, + "winner_id": null, + "votes": [ + { + "candidate_id": "demo-house-09-1", + "votes": 800 + }, + { + "candidate_id": "demo-house-09-2", + "votes": 150 + }, + { + "candidate_id": "demo-house-09-3", + "votes": 30 + }, + { + "candidate_id": "demo-house-09-4", + "votes": 20 + } + ] + }, + { + "race_id": "demo-house-10", + "round": "general", + "status": "reporting", + "total_votes": 1000, + "winner_id": null, + "votes": [ + { + "candidate_id": "demo-house-10-1", + "votes": 600 + }, + { + "candidate_id": "demo-house-10-2", + "votes": 300 + }, + { + "candidate_id": "demo-house-10-3", + "votes": 70 + }, + { + "candidate_id": "demo-house-10-4", + "votes": 30 + } + ] + }, + { + "race_id": "demo-house-11", + "round": "general", + "status": "reporting", + "total_votes": 1000, + "winner_id": null, + "votes": [ + { + "candidate_id": "demo-house-11-1", + "votes": 540 + }, + { + "candidate_id": "demo-house-11-2", + "votes": 420 + }, + { + "candidate_id": "demo-house-11-3", + "votes": 25 + }, + { + "candidate_id": "demo-house-11-4", + "votes": 15 + } + ] + }, + { + "race_id": "demo-house-12", + "round": "general", + "status": "reporting", + "total_votes": 1000, + "winner_id": null, + "votes": [ + { + "candidate_id": "demo-house-12-1", + "votes": 510 + }, + { + "candidate_id": "demo-house-12-2", + "votes": 490 + }, + { + "candidate_id": "demo-house-12-3", + "votes": 0 + }, + { + "candidate_id": "demo-house-12-4", + "votes": 0 + } + ] + }, + { + "race_id": "demo-senate-01", + "round": "general", + "status": "reporting", + "total_votes": 1000, + "winner_id": null, + "votes": [ + { + "candidate_id": "demo-senate-01-1", + "votes": 800 + }, + { + "candidate_id": "demo-senate-01-2", + "votes": 150 + }, + { + "candidate_id": "demo-senate-01-3", + "votes": 30 + }, + { + "candidate_id": "demo-senate-01-4", + "votes": 20 + } + ] + }, + { + "race_id": "demo-senate-02", + "round": "general", + "status": "reporting", + "total_votes": 1000, + "winner_id": null, + "votes": [ + { + "candidate_id": "demo-senate-02-1", + "votes": 600 + }, + { + "candidate_id": "demo-senate-02-2", + "votes": 300 + }, + { + "candidate_id": "demo-senate-02-3", + "votes": 70 + }, + { + "candidate_id": "demo-senate-02-4", + "votes": 30 + } + ] + }, + { + "race_id": "demo-senate-03", + "round": "general", + "status": "reporting", + "total_votes": 1000, + "winner_id": null, + "votes": [ + { + "candidate_id": "demo-senate-03-1", + "votes": 540 + }, + { + "candidate_id": "demo-senate-03-2", + "votes": 420 + }, + { + "candidate_id": "demo-senate-03-3", + "votes": 25 + }, + { + "candidate_id": "demo-senate-03-4", + "votes": 15 + } + ] + }, + { + "race_id": "demo-senate-04", + "round": "general", + "status": "reporting", + "total_votes": 1000, + "winner_id": null, + "votes": [ + { + "candidate_id": "demo-senate-04-1", + "votes": 510 + }, + { + "candidate_id": "demo-senate-04-2", + "votes": 490 + }, + { + "candidate_id": "demo-senate-04-3", + "votes": 0 + }, + { + "candidate_id": "demo-senate-04-4", + "votes": 0 + } + ] + }, + { + "race_id": "demo-senate-05", + "round": "general", + "status": "reporting", + "total_votes": 1000, + "winner_id": null, + "votes": [ + { + "candidate_id": "demo-senate-05-1", + "votes": 800 + }, + { + "candidate_id": "demo-senate-05-2", + "votes": 150 + }, + { + "candidate_id": "demo-senate-05-3", + "votes": 30 + }, + { + "candidate_id": "demo-senate-05-4", + "votes": 20 + } + ] + }, + { + "race_id": "demo-senate-06", + "round": "general", + "status": "reporting", + "total_votes": 1000, + "winner_id": null, + "votes": [ + { + "candidate_id": "demo-senate-06-1", + "votes": 600 + }, + { + "candidate_id": "demo-senate-06-2", + "votes": 300 + }, + { + "candidate_id": "demo-senate-06-3", + "votes": 70 + }, + { + "candidate_id": "demo-senate-06-4", + "votes": 30 + } + ] + }, + { + "race_id": "demo-senate-07", + "round": "general", + "status": "reporting", + "total_votes": 1000, + "winner_id": null, + "votes": [ + { + "candidate_id": "demo-senate-07-1", + "votes": 540 + }, + { + "candidate_id": "demo-senate-07-2", + "votes": 420 + }, + { + "candidate_id": "demo-senate-07-3", + "votes": 25 + }, + { + "candidate_id": "demo-senate-07-4", + "votes": 15 + } + ] + }, + { + "race_id": "demo-senate-08", + "round": "general", + "status": "reporting", + "total_votes": 1000, + "winner_id": null, + "votes": [ + { + "candidate_id": "demo-senate-08-1", + "votes": 510 + }, + { + "candidate_id": "demo-senate-08-2", + "votes": 490 + }, + { + "candidate_id": "demo-senate-08-3", + "votes": 0 + }, + { + "candidate_id": "demo-senate-08-4", + "votes": 0 + } + ] + }, + { + "race_id": "demo-senate-09", + "round": "general", + "status": "reporting", + "total_votes": 1000, + "winner_id": null, + "votes": [ + { + "candidate_id": "demo-senate-09-1", + "votes": 800 + }, + { + "candidate_id": "demo-senate-09-2", + "votes": 150 + }, + { + "candidate_id": "demo-senate-09-3", + "votes": 30 + }, + { + "candidate_id": "demo-senate-09-4", + "votes": 20 + } + ] + }, + { + "race_id": "demo-senate-10", + "round": "general", + "status": "reporting", + "total_votes": 1000, + "winner_id": null, + "votes": [ + { + "candidate_id": "demo-senate-10-1", + "votes": 600 + }, + { + "candidate_id": "demo-senate-10-2", + "votes": 300 + }, + { + "candidate_id": "demo-senate-10-3", + "votes": 70 + }, + { + "candidate_id": "demo-senate-10-4", + "votes": 30 + } + ] + }, + { + "race_id": "demo-senate-11", + "round": "general", + "status": "reporting", + "total_votes": 1000, + "winner_id": null, + "votes": [ + { + "candidate_id": "demo-senate-11-1", + "votes": 540 + }, + { + "candidate_id": "demo-senate-11-2", + "votes": 420 + }, + { + "candidate_id": "demo-senate-11-3", + "votes": 25 + }, + { + "candidate_id": "demo-senate-11-4", + "votes": 15 + } + ] + }, + { + "race_id": "demo-senate-12", + "round": "general", + "status": "reporting", + "total_votes": 1000, + "winner_id": null, + "votes": [ + { + "candidate_id": "demo-senate-12-1", + "votes": 510 + }, + { + "candidate_id": "demo-senate-12-2", + "votes": 490 + }, + { + "candidate_id": "demo-senate-12-3", + "votes": 0 + }, + { + "candidate_id": "demo-senate-12-4", + "votes": 0 + } + ] + }, + { + "race_id": "demo-governor-01", + "round": "general", + "status": "reporting", + "total_votes": 1000, + "winner_id": null, + "votes": [ + { + "candidate_id": "demo-governor-01-1", + "votes": 800 + }, + { + "candidate_id": "demo-governor-01-2", + "votes": 150 + }, + { + "candidate_id": "demo-governor-01-3", + "votes": 30 + }, + { + "candidate_id": "demo-governor-01-4", + "votes": 20 + } + ] + }, + { + "race_id": "demo-governor-02", + "round": "general", + "status": "reporting", + "total_votes": 1000, + "winner_id": null, + "votes": [ + { + "candidate_id": "demo-governor-02-1", + "votes": 600 + }, + { + "candidate_id": "demo-governor-02-2", + "votes": 300 + }, + { + "candidate_id": "demo-governor-02-3", + "votes": 70 + }, + { + "candidate_id": "demo-governor-02-4", + "votes": 30 + } + ] + }, + { + "race_id": "demo-governor-03", + "round": "general", + "status": "reporting", + "total_votes": 1000, + "winner_id": null, + "votes": [ + { + "candidate_id": "demo-governor-03-1", + "votes": 540 + }, + { + "candidate_id": "demo-governor-03-2", + "votes": 420 + }, + { + "candidate_id": "demo-governor-03-3", + "votes": 25 + }, + { + "candidate_id": "demo-governor-03-4", + "votes": 15 + } + ] + }, + { + "race_id": "demo-governor-04", + "round": "general", + "status": "reporting", + "total_votes": 1000, + "winner_id": null, + "votes": [ + { + "candidate_id": "demo-governor-04-1", + "votes": 510 + }, + { + "candidate_id": "demo-governor-04-2", + "votes": 490 + }, + { + "candidate_id": "demo-governor-04-3", + "votes": 0 + }, + { + "candidate_id": "demo-governor-04-4", + "votes": 0 + } + ] + }, + { + "race_id": "demo-governor-05", + "round": "general", + "status": "reporting", + "total_votes": 1000, + "winner_id": null, + "votes": [ + { + "candidate_id": "demo-governor-05-1", + "votes": 800 + }, + { + "candidate_id": "demo-governor-05-2", + "votes": 150 + }, + { + "candidate_id": "demo-governor-05-3", + "votes": 30 + }, + { + "candidate_id": "demo-governor-05-4", + "votes": 20 + } + ] + }, + { + "race_id": "demo-governor-06", + "round": "general", + "status": "reporting", + "total_votes": 1000, + "winner_id": null, + "votes": [ + { + "candidate_id": "demo-governor-06-1", + "votes": 600 + }, + { + "candidate_id": "demo-governor-06-2", + "votes": 300 + }, + { + "candidate_id": "demo-governor-06-3", + "votes": 70 + }, + { + "candidate_id": "demo-governor-06-4", + "votes": 30 + } + ] + }, + { + "race_id": "demo-governor-07", + "round": "general", + "status": "reporting", + "total_votes": 1000, + "winner_id": null, + "votes": [ + { + "candidate_id": "demo-governor-07-1", + "votes": 540 + }, + { + "candidate_id": "demo-governor-07-2", + "votes": 420 + }, + { + "candidate_id": "demo-governor-07-3", + "votes": 25 + }, + { + "candidate_id": "demo-governor-07-4", + "votes": 15 + } + ] + }, + { + "race_id": "demo-governor-08", + "round": "general", + "status": "reporting", + "total_votes": 1000, + "winner_id": null, + "votes": [ + { + "candidate_id": "demo-governor-08-1", + "votes": 510 + }, + { + "candidate_id": "demo-governor-08-2", + "votes": 490 + }, + { + "candidate_id": "demo-governor-08-3", + "votes": 0 + }, + { + "candidate_id": "demo-governor-08-4", + "votes": 0 + } + ] + }, + { + "race_id": "demo-governor-09", + "round": "general", + "status": "reporting", + "total_votes": 1000, + "winner_id": null, + "votes": [ + { + "candidate_id": "demo-governor-09-1", + "votes": 800 + }, + { + "candidate_id": "demo-governor-09-2", + "votes": 150 + }, + { + "candidate_id": "demo-governor-09-3", + "votes": 30 + }, + { + "candidate_id": "demo-governor-09-4", + "votes": 20 + } + ] + }, + { + "race_id": "demo-governor-10", + "round": "general", + "status": "reporting", + "total_votes": 1000, + "winner_id": null, + "votes": [ + { + "candidate_id": "demo-governor-10-1", + "votes": 600 + }, + { + "candidate_id": "demo-governor-10-2", + "votes": 300 + }, + { + "candidate_id": "demo-governor-10-3", + "votes": 70 + }, + { + "candidate_id": "demo-governor-10-4", + "votes": 30 + } + ] + }, + { + "race_id": "demo-governor-11", + "round": "general", + "status": "reporting", + "total_votes": 1000, + "winner_id": null, + "votes": [ + { + "candidate_id": "demo-governor-11-1", + "votes": 540 + }, + { + "candidate_id": "demo-governor-11-2", + "votes": 420 + }, + { + "candidate_id": "demo-governor-11-3", + "votes": 25 + }, + { + "candidate_id": "demo-governor-11-4", + "votes": 15 + } + ] + }, + { + "race_id": "demo-governor-12", + "round": "general", + "status": "reporting", + "total_votes": 1000, + "winner_id": null, + "votes": [ + { + "candidate_id": "demo-governor-12-1", + "votes": 510 + }, + { + "candidate_id": "demo-governor-12-2", + "votes": 490 + }, + { + "candidate_id": "demo-governor-12-3", + "votes": 0 + }, + { + "candidate_id": "demo-governor-12-4", + "votes": 0 + } + ] + } + ] +} diff --git a/data/demo/results-2.json b/data/demo/results-2.json new file mode 100644 index 0000000..01cbc4a --- /dev/null +++ b/data/demo/results-2.json @@ -0,0 +1,916 @@ +{ + "schema_version": 1, + "id": "demo-results-2", + "kind": "results", + "mode": "demo", + "label": "Synthetic replay 3: Lead changes", + "as_of": "2026-11-04T02:00:00Z", + "source": { + "name": "Fictional scoring fixtures", + "url": "fixture:product-fpol/demo", + "retrieved_at": "2026-10-01T00:00:00Z" + }, + "catalog_id": "demo-2026", + "entries": [ + { + "race_id": "demo-house-01", + "round": "general", + "status": "reporting", + "total_votes": 1000, + "winner_id": null, + "votes": [ + { + "candidate_id": "demo-house-01-1", + "votes": 800 + }, + { + "candidate_id": "demo-house-01-2", + "votes": 150 + }, + { + "candidate_id": "demo-house-01-3", + "votes": 30 + }, + { + "candidate_id": "demo-house-01-4", + "votes": 20 + } + ] + }, + { + "race_id": "demo-house-02", + "round": "general", + "status": "reporting", + "total_votes": 1000, + "winner_id": null, + "votes": [ + { + "candidate_id": "demo-house-02-1", + "votes": 600 + }, + { + "candidate_id": "demo-house-02-2", + "votes": 300 + }, + { + "candidate_id": "demo-house-02-3", + "votes": 70 + }, + { + "candidate_id": "demo-house-02-4", + "votes": 30 + } + ] + }, + { + "race_id": "demo-house-03", + "round": "general", + "status": "reporting", + "total_votes": 1000, + "winner_id": null, + "votes": [ + { + "candidate_id": "demo-house-03-1", + "votes": 540 + }, + { + "candidate_id": "demo-house-03-2", + "votes": 420 + }, + { + "candidate_id": "demo-house-03-3", + "votes": 25 + }, + { + "candidate_id": "demo-house-03-4", + "votes": 15 + } + ] + }, + { + "race_id": "demo-house-04", + "round": "general", + "status": "reporting", + "total_votes": 1000, + "winner_id": null, + "votes": [ + { + "candidate_id": "demo-house-04-1", + "votes": 490 + }, + { + "candidate_id": "demo-house-04-2", + "votes": 510 + }, + { + "candidate_id": "demo-house-04-3", + "votes": 0 + }, + { + "candidate_id": "demo-house-04-4", + "votes": 0 + } + ] + }, + { + "race_id": "demo-house-05", + "round": "general", + "status": "reporting", + "total_votes": 1000, + "winner_id": null, + "votes": [ + { + "candidate_id": "demo-house-05-1", + "votes": 800 + }, + { + "candidate_id": "demo-house-05-2", + "votes": 150 + }, + { + "candidate_id": "demo-house-05-3", + "votes": 30 + }, + { + "candidate_id": "demo-house-05-4", + "votes": 20 + } + ] + }, + { + "race_id": "demo-house-06", + "round": "general", + "status": "reporting", + "total_votes": 1000, + "winner_id": null, + "votes": [ + { + "candidate_id": "demo-house-06-1", + "votes": 600 + }, + { + "candidate_id": "demo-house-06-2", + "votes": 300 + }, + { + "candidate_id": "demo-house-06-3", + "votes": 70 + }, + { + "candidate_id": "demo-house-06-4", + "votes": 30 + } + ] + }, + { + "race_id": "demo-house-07", + "round": "general", + "status": "reporting", + "total_votes": 1000, + "winner_id": null, + "votes": [ + { + "candidate_id": "demo-house-07-1", + "votes": 540 + }, + { + "candidate_id": "demo-house-07-2", + "votes": 420 + }, + { + "candidate_id": "demo-house-07-3", + "votes": 25 + }, + { + "candidate_id": "demo-house-07-4", + "votes": 15 + } + ] + }, + { + "race_id": "demo-house-08", + "round": "general", + "status": "reporting", + "total_votes": 1000, + "winner_id": null, + "votes": [ + { + "candidate_id": "demo-house-08-1", + "votes": 490 + }, + { + "candidate_id": "demo-house-08-2", + "votes": 510 + }, + { + "candidate_id": "demo-house-08-3", + "votes": 0 + }, + { + "candidate_id": "demo-house-08-4", + "votes": 0 + } + ] + }, + { + "race_id": "demo-house-09", + "round": "general", + "status": "reporting", + "total_votes": 1000, + "winner_id": null, + "votes": [ + { + "candidate_id": "demo-house-09-1", + "votes": 800 + }, + { + "candidate_id": "demo-house-09-2", + "votes": 150 + }, + { + "candidate_id": "demo-house-09-3", + "votes": 30 + }, + { + "candidate_id": "demo-house-09-4", + "votes": 20 + } + ] + }, + { + "race_id": "demo-house-10", + "round": "general", + "status": "reporting", + "total_votes": 1000, + "winner_id": null, + "votes": [ + { + "candidate_id": "demo-house-10-1", + "votes": 600 + }, + { + "candidate_id": "demo-house-10-2", + "votes": 300 + }, + { + "candidate_id": "demo-house-10-3", + "votes": 70 + }, + { + "candidate_id": "demo-house-10-4", + "votes": 30 + } + ] + }, + { + "race_id": "demo-house-11", + "round": "general", + "status": "reporting", + "total_votes": 1000, + "winner_id": null, + "votes": [ + { + "candidate_id": "demo-house-11-1", + "votes": 540 + }, + { + "candidate_id": "demo-house-11-2", + "votes": 420 + }, + { + "candidate_id": "demo-house-11-3", + "votes": 25 + }, + { + "candidate_id": "demo-house-11-4", + "votes": 15 + } + ] + }, + { + "race_id": "demo-house-12", + "round": "general", + "status": "reporting", + "total_votes": 1000, + "winner_id": null, + "votes": [ + { + "candidate_id": "demo-house-12-1", + "votes": 490 + }, + { + "candidate_id": "demo-house-12-2", + "votes": 510 + }, + { + "candidate_id": "demo-house-12-3", + "votes": 0 + }, + { + "candidate_id": "demo-house-12-4", + "votes": 0 + } + ] + }, + { + "race_id": "demo-senate-01", + "round": "general", + "status": "reporting", + "total_votes": 1000, + "winner_id": null, + "votes": [ + { + "candidate_id": "demo-senate-01-1", + "votes": 800 + }, + { + "candidate_id": "demo-senate-01-2", + "votes": 150 + }, + { + "candidate_id": "demo-senate-01-3", + "votes": 30 + }, + { + "candidate_id": "demo-senate-01-4", + "votes": 20 + } + ] + }, + { + "race_id": "demo-senate-02", + "round": "general", + "status": "reporting", + "total_votes": 1000, + "winner_id": null, + "votes": [ + { + "candidate_id": "demo-senate-02-1", + "votes": 600 + }, + { + "candidate_id": "demo-senate-02-2", + "votes": 300 + }, + { + "candidate_id": "demo-senate-02-3", + "votes": 70 + }, + { + "candidate_id": "demo-senate-02-4", + "votes": 30 + } + ] + }, + { + "race_id": "demo-senate-03", + "round": "general", + "status": "reporting", + "total_votes": 1000, + "winner_id": null, + "votes": [ + { + "candidate_id": "demo-senate-03-1", + "votes": 540 + }, + { + "candidate_id": "demo-senate-03-2", + "votes": 420 + }, + { + "candidate_id": "demo-senate-03-3", + "votes": 25 + }, + { + "candidate_id": "demo-senate-03-4", + "votes": 15 + } + ] + }, + { + "race_id": "demo-senate-04", + "round": "general", + "status": "reporting", + "total_votes": 1000, + "winner_id": null, + "votes": [ + { + "candidate_id": "demo-senate-04-1", + "votes": 490 + }, + { + "candidate_id": "demo-senate-04-2", + "votes": 510 + }, + { + "candidate_id": "demo-senate-04-3", + "votes": 0 + }, + { + "candidate_id": "demo-senate-04-4", + "votes": 0 + } + ] + }, + { + "race_id": "demo-senate-05", + "round": "general", + "status": "reporting", + "total_votes": 1000, + "winner_id": null, + "votes": [ + { + "candidate_id": "demo-senate-05-1", + "votes": 800 + }, + { + "candidate_id": "demo-senate-05-2", + "votes": 150 + }, + { + "candidate_id": "demo-senate-05-3", + "votes": 30 + }, + { + "candidate_id": "demo-senate-05-4", + "votes": 20 + } + ] + }, + { + "race_id": "demo-senate-06", + "round": "general", + "status": "reporting", + "total_votes": 1000, + "winner_id": null, + "votes": [ + { + "candidate_id": "demo-senate-06-1", + "votes": 600 + }, + { + "candidate_id": "demo-senate-06-2", + "votes": 300 + }, + { + "candidate_id": "demo-senate-06-3", + "votes": 70 + }, + { + "candidate_id": "demo-senate-06-4", + "votes": 30 + } + ] + }, + { + "race_id": "demo-senate-07", + "round": "general", + "status": "reporting", + "total_votes": 1000, + "winner_id": null, + "votes": [ + { + "candidate_id": "demo-senate-07-1", + "votes": 540 + }, + { + "candidate_id": "demo-senate-07-2", + "votes": 420 + }, + { + "candidate_id": "demo-senate-07-3", + "votes": 25 + }, + { + "candidate_id": "demo-senate-07-4", + "votes": 15 + } + ] + }, + { + "race_id": "demo-senate-08", + "round": "general", + "status": "reporting", + "total_votes": 1000, + "winner_id": null, + "votes": [ + { + "candidate_id": "demo-senate-08-1", + "votes": 490 + }, + { + "candidate_id": "demo-senate-08-2", + "votes": 510 + }, + { + "candidate_id": "demo-senate-08-3", + "votes": 0 + }, + { + "candidate_id": "demo-senate-08-4", + "votes": 0 + } + ] + }, + { + "race_id": "demo-senate-09", + "round": "general", + "status": "reporting", + "total_votes": 1000, + "winner_id": null, + "votes": [ + { + "candidate_id": "demo-senate-09-1", + "votes": 800 + }, + { + "candidate_id": "demo-senate-09-2", + "votes": 150 + }, + { + "candidate_id": "demo-senate-09-3", + "votes": 30 + }, + { + "candidate_id": "demo-senate-09-4", + "votes": 20 + } + ] + }, + { + "race_id": "demo-senate-10", + "round": "general", + "status": "reporting", + "total_votes": 1000, + "winner_id": null, + "votes": [ + { + "candidate_id": "demo-senate-10-1", + "votes": 600 + }, + { + "candidate_id": "demo-senate-10-2", + "votes": 300 + }, + { + "candidate_id": "demo-senate-10-3", + "votes": 70 + }, + { + "candidate_id": "demo-senate-10-4", + "votes": 30 + } + ] + }, + { + "race_id": "demo-senate-11", + "round": "general", + "status": "reporting", + "total_votes": 1000, + "winner_id": null, + "votes": [ + { + "candidate_id": "demo-senate-11-1", + "votes": 540 + }, + { + "candidate_id": "demo-senate-11-2", + "votes": 420 + }, + { + "candidate_id": "demo-senate-11-3", + "votes": 25 + }, + { + "candidate_id": "demo-senate-11-4", + "votes": 15 + } + ] + }, + { + "race_id": "demo-senate-12", + "round": "general", + "status": "reporting", + "total_votes": 1000, + "winner_id": null, + "votes": [ + { + "candidate_id": "demo-senate-12-1", + "votes": 490 + }, + { + "candidate_id": "demo-senate-12-2", + "votes": 510 + }, + { + "candidate_id": "demo-senate-12-3", + "votes": 0 + }, + { + "candidate_id": "demo-senate-12-4", + "votes": 0 + } + ] + }, + { + "race_id": "demo-governor-01", + "round": "general", + "status": "reporting", + "total_votes": 1000, + "winner_id": null, + "votes": [ + { + "candidate_id": "demo-governor-01-1", + "votes": 800 + }, + { + "candidate_id": "demo-governor-01-2", + "votes": 150 + }, + { + "candidate_id": "demo-governor-01-3", + "votes": 30 + }, + { + "candidate_id": "demo-governor-01-4", + "votes": 20 + } + ] + }, + { + "race_id": "demo-governor-02", + "round": "general", + "status": "reporting", + "total_votes": 1000, + "winner_id": null, + "votes": [ + { + "candidate_id": "demo-governor-02-1", + "votes": 600 + }, + { + "candidate_id": "demo-governor-02-2", + "votes": 300 + }, + { + "candidate_id": "demo-governor-02-3", + "votes": 70 + }, + { + "candidate_id": "demo-governor-02-4", + "votes": 30 + } + ] + }, + { + "race_id": "demo-governor-03", + "round": "general", + "status": "reporting", + "total_votes": 1000, + "winner_id": null, + "votes": [ + { + "candidate_id": "demo-governor-03-1", + "votes": 540 + }, + { + "candidate_id": "demo-governor-03-2", + "votes": 420 + }, + { + "candidate_id": "demo-governor-03-3", + "votes": 25 + }, + { + "candidate_id": "demo-governor-03-4", + "votes": 15 + } + ] + }, + { + "race_id": "demo-governor-04", + "round": "general", + "status": "reporting", + "total_votes": 1000, + "winner_id": null, + "votes": [ + { + "candidate_id": "demo-governor-04-1", + "votes": 490 + }, + { + "candidate_id": "demo-governor-04-2", + "votes": 510 + }, + { + "candidate_id": "demo-governor-04-3", + "votes": 0 + }, + { + "candidate_id": "demo-governor-04-4", + "votes": 0 + } + ] + }, + { + "race_id": "demo-governor-05", + "round": "general", + "status": "reporting", + "total_votes": 1000, + "winner_id": null, + "votes": [ + { + "candidate_id": "demo-governor-05-1", + "votes": 800 + }, + { + "candidate_id": "demo-governor-05-2", + "votes": 150 + }, + { + "candidate_id": "demo-governor-05-3", + "votes": 30 + }, + { + "candidate_id": "demo-governor-05-4", + "votes": 20 + } + ] + }, + { + "race_id": "demo-governor-06", + "round": "general", + "status": "reporting", + "total_votes": 1000, + "winner_id": null, + "votes": [ + { + "candidate_id": "demo-governor-06-1", + "votes": 600 + }, + { + "candidate_id": "demo-governor-06-2", + "votes": 300 + }, + { + "candidate_id": "demo-governor-06-3", + "votes": 70 + }, + { + "candidate_id": "demo-governor-06-4", + "votes": 30 + } + ] + }, + { + "race_id": "demo-governor-07", + "round": "general", + "status": "reporting", + "total_votes": 1000, + "winner_id": null, + "votes": [ + { + "candidate_id": "demo-governor-07-1", + "votes": 540 + }, + { + "candidate_id": "demo-governor-07-2", + "votes": 420 + }, + { + "candidate_id": "demo-governor-07-3", + "votes": 25 + }, + { + "candidate_id": "demo-governor-07-4", + "votes": 15 + } + ] + }, + { + "race_id": "demo-governor-08", + "round": "general", + "status": "reporting", + "total_votes": 1000, + "winner_id": null, + "votes": [ + { + "candidate_id": "demo-governor-08-1", + "votes": 490 + }, + { + "candidate_id": "demo-governor-08-2", + "votes": 510 + }, + { + "candidate_id": "demo-governor-08-3", + "votes": 0 + }, + { + "candidate_id": "demo-governor-08-4", + "votes": 0 + } + ] + }, + { + "race_id": "demo-governor-09", + "round": "general", + "status": "reporting", + "total_votes": 1000, + "winner_id": null, + "votes": [ + { + "candidate_id": "demo-governor-09-1", + "votes": 800 + }, + { + "candidate_id": "demo-governor-09-2", + "votes": 150 + }, + { + "candidate_id": "demo-governor-09-3", + "votes": 30 + }, + { + "candidate_id": "demo-governor-09-4", + "votes": 20 + } + ] + }, + { + "race_id": "demo-governor-10", + "round": "general", + "status": "reporting", + "total_votes": 1000, + "winner_id": null, + "votes": [ + { + "candidate_id": "demo-governor-10-1", + "votes": 600 + }, + { + "candidate_id": "demo-governor-10-2", + "votes": 300 + }, + { + "candidate_id": "demo-governor-10-3", + "votes": 70 + }, + { + "candidate_id": "demo-governor-10-4", + "votes": 30 + } + ] + }, + { + "race_id": "demo-governor-11", + "round": "general", + "status": "reporting", + "total_votes": 1000, + "winner_id": null, + "votes": [ + { + "candidate_id": "demo-governor-11-1", + "votes": 540 + }, + { + "candidate_id": "demo-governor-11-2", + "votes": 420 + }, + { + "candidate_id": "demo-governor-11-3", + "votes": 25 + }, + { + "candidate_id": "demo-governor-11-4", + "votes": 15 + } + ] + }, + { + "race_id": "demo-governor-12", + "round": "general", + "status": "reporting", + "total_votes": 1000, + "winner_id": null, + "votes": [ + { + "candidate_id": "demo-governor-12-1", + "votes": 490 + }, + { + "candidate_id": "demo-governor-12-2", + "votes": 510 + }, + { + "candidate_id": "demo-governor-12-3", + "votes": 0 + }, + { + "candidate_id": "demo-governor-12-4", + "votes": 0 + } + ] + } + ] +} diff --git a/data/demo/results-3.json b/data/demo/results-3.json new file mode 100644 index 0000000..ed74bc9 --- /dev/null +++ b/data/demo/results-3.json @@ -0,0 +1,916 @@ +{ + "schema_version": 1, + "id": "demo-results-3", + "kind": "results", + "mode": "demo", + "label": "Synthetic replay 4: Races called", + "as_of": "2026-11-04T03:00:00Z", + "source": { + "name": "Fictional scoring fixtures", + "url": "fixture:product-fpol/demo", + "retrieved_at": "2026-10-01T00:00:00Z" + }, + "catalog_id": "demo-2026", + "entries": [ + { + "race_id": "demo-house-01", + "round": "general", + "status": "called", + "total_votes": 1000, + "winner_id": "demo-house-01-1", + "votes": [ + { + "candidate_id": "demo-house-01-1", + "votes": 800 + }, + { + "candidate_id": "demo-house-01-2", + "votes": 150 + }, + { + "candidate_id": "demo-house-01-3", + "votes": 30 + }, + { + "candidate_id": "demo-house-01-4", + "votes": 20 + } + ] + }, + { + "race_id": "demo-house-02", + "round": "general", + "status": "called", + "total_votes": 1000, + "winner_id": "demo-house-02-1", + "votes": [ + { + "candidate_id": "demo-house-02-1", + "votes": 600 + }, + { + "candidate_id": "demo-house-02-2", + "votes": 300 + }, + { + "candidate_id": "demo-house-02-3", + "votes": 70 + }, + { + "candidate_id": "demo-house-02-4", + "votes": 30 + } + ] + }, + { + "race_id": "demo-house-03", + "round": "general", + "status": "called", + "total_votes": 1000, + "winner_id": "demo-house-03-1", + "votes": [ + { + "candidate_id": "demo-house-03-1", + "votes": 540 + }, + { + "candidate_id": "demo-house-03-2", + "votes": 420 + }, + { + "candidate_id": "demo-house-03-3", + "votes": 25 + }, + { + "candidate_id": "demo-house-03-4", + "votes": 15 + } + ] + }, + { + "race_id": "demo-house-04", + "round": "general", + "status": "called", + "total_votes": 1000, + "winner_id": "demo-house-04-1", + "votes": [ + { + "candidate_id": "demo-house-04-1", + "votes": 510 + }, + { + "candidate_id": "demo-house-04-2", + "votes": 490 + }, + { + "candidate_id": "demo-house-04-3", + "votes": 0 + }, + { + "candidate_id": "demo-house-04-4", + "votes": 0 + } + ] + }, + { + "race_id": "demo-house-05", + "round": "general", + "status": "called", + "total_votes": 1000, + "winner_id": "demo-house-05-1", + "votes": [ + { + "candidate_id": "demo-house-05-1", + "votes": 800 + }, + { + "candidate_id": "demo-house-05-2", + "votes": 150 + }, + { + "candidate_id": "demo-house-05-3", + "votes": 30 + }, + { + "candidate_id": "demo-house-05-4", + "votes": 20 + } + ] + }, + { + "race_id": "demo-house-06", + "round": "general", + "status": "called", + "total_votes": 1000, + "winner_id": "demo-house-06-1", + "votes": [ + { + "candidate_id": "demo-house-06-1", + "votes": 600 + }, + { + "candidate_id": "demo-house-06-2", + "votes": 300 + }, + { + "candidate_id": "demo-house-06-3", + "votes": 70 + }, + { + "candidate_id": "demo-house-06-4", + "votes": 30 + } + ] + }, + { + "race_id": "demo-house-07", + "round": "general", + "status": "called", + "total_votes": 1000, + "winner_id": "demo-house-07-1", + "votes": [ + { + "candidate_id": "demo-house-07-1", + "votes": 540 + }, + { + "candidate_id": "demo-house-07-2", + "votes": 420 + }, + { + "candidate_id": "demo-house-07-3", + "votes": 25 + }, + { + "candidate_id": "demo-house-07-4", + "votes": 15 + } + ] + }, + { + "race_id": "demo-house-08", + "round": "general", + "status": "called", + "total_votes": 1000, + "winner_id": "demo-house-08-1", + "votes": [ + { + "candidate_id": "demo-house-08-1", + "votes": 510 + }, + { + "candidate_id": "demo-house-08-2", + "votes": 490 + }, + { + "candidate_id": "demo-house-08-3", + "votes": 0 + }, + { + "candidate_id": "demo-house-08-4", + "votes": 0 + } + ] + }, + { + "race_id": "demo-house-09", + "round": "general", + "status": "called", + "total_votes": 1000, + "winner_id": "demo-house-09-1", + "votes": [ + { + "candidate_id": "demo-house-09-1", + "votes": 800 + }, + { + "candidate_id": "demo-house-09-2", + "votes": 150 + }, + { + "candidate_id": "demo-house-09-3", + "votes": 30 + }, + { + "candidate_id": "demo-house-09-4", + "votes": 20 + } + ] + }, + { + "race_id": "demo-house-10", + "round": "general", + "status": "called", + "total_votes": 1000, + "winner_id": "demo-house-10-1", + "votes": [ + { + "candidate_id": "demo-house-10-1", + "votes": 600 + }, + { + "candidate_id": "demo-house-10-2", + "votes": 300 + }, + { + "candidate_id": "demo-house-10-3", + "votes": 70 + }, + { + "candidate_id": "demo-house-10-4", + "votes": 30 + } + ] + }, + { + "race_id": "demo-house-11", + "round": "general", + "status": "called", + "total_votes": 1000, + "winner_id": "demo-house-11-1", + "votes": [ + { + "candidate_id": "demo-house-11-1", + "votes": 540 + }, + { + "candidate_id": "demo-house-11-2", + "votes": 420 + }, + { + "candidate_id": "demo-house-11-3", + "votes": 25 + }, + { + "candidate_id": "demo-house-11-4", + "votes": 15 + } + ] + }, + { + "race_id": "demo-house-12", + "round": "general", + "status": "called", + "total_votes": 1000, + "winner_id": "demo-house-12-1", + "votes": [ + { + "candidate_id": "demo-house-12-1", + "votes": 510 + }, + { + "candidate_id": "demo-house-12-2", + "votes": 490 + }, + { + "candidate_id": "demo-house-12-3", + "votes": 0 + }, + { + "candidate_id": "demo-house-12-4", + "votes": 0 + } + ] + }, + { + "race_id": "demo-senate-01", + "round": "general", + "status": "called", + "total_votes": 1000, + "winner_id": "demo-senate-01-1", + "votes": [ + { + "candidate_id": "demo-senate-01-1", + "votes": 800 + }, + { + "candidate_id": "demo-senate-01-2", + "votes": 150 + }, + { + "candidate_id": "demo-senate-01-3", + "votes": 30 + }, + { + "candidate_id": "demo-senate-01-4", + "votes": 20 + } + ] + }, + { + "race_id": "demo-senate-02", + "round": "general", + "status": "called", + "total_votes": 1000, + "winner_id": "demo-senate-02-1", + "votes": [ + { + "candidate_id": "demo-senate-02-1", + "votes": 600 + }, + { + "candidate_id": "demo-senate-02-2", + "votes": 300 + }, + { + "candidate_id": "demo-senate-02-3", + "votes": 70 + }, + { + "candidate_id": "demo-senate-02-4", + "votes": 30 + } + ] + }, + { + "race_id": "demo-senate-03", + "round": "general", + "status": "called", + "total_votes": 1000, + "winner_id": "demo-senate-03-1", + "votes": [ + { + "candidate_id": "demo-senate-03-1", + "votes": 540 + }, + { + "candidate_id": "demo-senate-03-2", + "votes": 420 + }, + { + "candidate_id": "demo-senate-03-3", + "votes": 25 + }, + { + "candidate_id": "demo-senate-03-4", + "votes": 15 + } + ] + }, + { + "race_id": "demo-senate-04", + "round": "general", + "status": "called", + "total_votes": 1000, + "winner_id": "demo-senate-04-1", + "votes": [ + { + "candidate_id": "demo-senate-04-1", + "votes": 510 + }, + { + "candidate_id": "demo-senate-04-2", + "votes": 490 + }, + { + "candidate_id": "demo-senate-04-3", + "votes": 0 + }, + { + "candidate_id": "demo-senate-04-4", + "votes": 0 + } + ] + }, + { + "race_id": "demo-senate-05", + "round": "general", + "status": "called", + "total_votes": 1000, + "winner_id": "demo-senate-05-1", + "votes": [ + { + "candidate_id": "demo-senate-05-1", + "votes": 800 + }, + { + "candidate_id": "demo-senate-05-2", + "votes": 150 + }, + { + "candidate_id": "demo-senate-05-3", + "votes": 30 + }, + { + "candidate_id": "demo-senate-05-4", + "votes": 20 + } + ] + }, + { + "race_id": "demo-senate-06", + "round": "general", + "status": "called", + "total_votes": 1000, + "winner_id": "demo-senate-06-1", + "votes": [ + { + "candidate_id": "demo-senate-06-1", + "votes": 600 + }, + { + "candidate_id": "demo-senate-06-2", + "votes": 300 + }, + { + "candidate_id": "demo-senate-06-3", + "votes": 70 + }, + { + "candidate_id": "demo-senate-06-4", + "votes": 30 + } + ] + }, + { + "race_id": "demo-senate-07", + "round": "general", + "status": "called", + "total_votes": 1000, + "winner_id": "demo-senate-07-1", + "votes": [ + { + "candidate_id": "demo-senate-07-1", + "votes": 540 + }, + { + "candidate_id": "demo-senate-07-2", + "votes": 420 + }, + { + "candidate_id": "demo-senate-07-3", + "votes": 25 + }, + { + "candidate_id": "demo-senate-07-4", + "votes": 15 + } + ] + }, + { + "race_id": "demo-senate-08", + "round": "general", + "status": "called", + "total_votes": 1000, + "winner_id": "demo-senate-08-1", + "votes": [ + { + "candidate_id": "demo-senate-08-1", + "votes": 510 + }, + { + "candidate_id": "demo-senate-08-2", + "votes": 490 + }, + { + "candidate_id": "demo-senate-08-3", + "votes": 0 + }, + { + "candidate_id": "demo-senate-08-4", + "votes": 0 + } + ] + }, + { + "race_id": "demo-senate-09", + "round": "general", + "status": "called", + "total_votes": 1000, + "winner_id": "demo-senate-09-1", + "votes": [ + { + "candidate_id": "demo-senate-09-1", + "votes": 800 + }, + { + "candidate_id": "demo-senate-09-2", + "votes": 150 + }, + { + "candidate_id": "demo-senate-09-3", + "votes": 30 + }, + { + "candidate_id": "demo-senate-09-4", + "votes": 20 + } + ] + }, + { + "race_id": "demo-senate-10", + "round": "general", + "status": "called", + "total_votes": 1000, + "winner_id": "demo-senate-10-1", + "votes": [ + { + "candidate_id": "demo-senate-10-1", + "votes": 600 + }, + { + "candidate_id": "demo-senate-10-2", + "votes": 300 + }, + { + "candidate_id": "demo-senate-10-3", + "votes": 70 + }, + { + "candidate_id": "demo-senate-10-4", + "votes": 30 + } + ] + }, + { + "race_id": "demo-senate-11", + "round": "general", + "status": "called", + "total_votes": 1000, + "winner_id": "demo-senate-11-1", + "votes": [ + { + "candidate_id": "demo-senate-11-1", + "votes": 540 + }, + { + "candidate_id": "demo-senate-11-2", + "votes": 420 + }, + { + "candidate_id": "demo-senate-11-3", + "votes": 25 + }, + { + "candidate_id": "demo-senate-11-4", + "votes": 15 + } + ] + }, + { + "race_id": "demo-senate-12", + "round": "general", + "status": "called", + "total_votes": 1000, + "winner_id": "demo-senate-12-1", + "votes": [ + { + "candidate_id": "demo-senate-12-1", + "votes": 510 + }, + { + "candidate_id": "demo-senate-12-2", + "votes": 490 + }, + { + "candidate_id": "demo-senate-12-3", + "votes": 0 + }, + { + "candidate_id": "demo-senate-12-4", + "votes": 0 + } + ] + }, + { + "race_id": "demo-governor-01", + "round": "general", + "status": "called", + "total_votes": 1000, + "winner_id": "demo-governor-01-1", + "votes": [ + { + "candidate_id": "demo-governor-01-1", + "votes": 800 + }, + { + "candidate_id": "demo-governor-01-2", + "votes": 150 + }, + { + "candidate_id": "demo-governor-01-3", + "votes": 30 + }, + { + "candidate_id": "demo-governor-01-4", + "votes": 20 + } + ] + }, + { + "race_id": "demo-governor-02", + "round": "general", + "status": "called", + "total_votes": 1000, + "winner_id": "demo-governor-02-1", + "votes": [ + { + "candidate_id": "demo-governor-02-1", + "votes": 600 + }, + { + "candidate_id": "demo-governor-02-2", + "votes": 300 + }, + { + "candidate_id": "demo-governor-02-3", + "votes": 70 + }, + { + "candidate_id": "demo-governor-02-4", + "votes": 30 + } + ] + }, + { + "race_id": "demo-governor-03", + "round": "general", + "status": "called", + "total_votes": 1000, + "winner_id": "demo-governor-03-1", + "votes": [ + { + "candidate_id": "demo-governor-03-1", + "votes": 540 + }, + { + "candidate_id": "demo-governor-03-2", + "votes": 420 + }, + { + "candidate_id": "demo-governor-03-3", + "votes": 25 + }, + { + "candidate_id": "demo-governor-03-4", + "votes": 15 + } + ] + }, + { + "race_id": "demo-governor-04", + "round": "general", + "status": "called", + "total_votes": 1000, + "winner_id": "demo-governor-04-1", + "votes": [ + { + "candidate_id": "demo-governor-04-1", + "votes": 510 + }, + { + "candidate_id": "demo-governor-04-2", + "votes": 490 + }, + { + "candidate_id": "demo-governor-04-3", + "votes": 0 + }, + { + "candidate_id": "demo-governor-04-4", + "votes": 0 + } + ] + }, + { + "race_id": "demo-governor-05", + "round": "general", + "status": "called", + "total_votes": 1000, + "winner_id": "demo-governor-05-1", + "votes": [ + { + "candidate_id": "demo-governor-05-1", + "votes": 800 + }, + { + "candidate_id": "demo-governor-05-2", + "votes": 150 + }, + { + "candidate_id": "demo-governor-05-3", + "votes": 30 + }, + { + "candidate_id": "demo-governor-05-4", + "votes": 20 + } + ] + }, + { + "race_id": "demo-governor-06", + "round": "general", + "status": "called", + "total_votes": 1000, + "winner_id": "demo-governor-06-1", + "votes": [ + { + "candidate_id": "demo-governor-06-1", + "votes": 600 + }, + { + "candidate_id": "demo-governor-06-2", + "votes": 300 + }, + { + "candidate_id": "demo-governor-06-3", + "votes": 70 + }, + { + "candidate_id": "demo-governor-06-4", + "votes": 30 + } + ] + }, + { + "race_id": "demo-governor-07", + "round": "general", + "status": "called", + "total_votes": 1000, + "winner_id": "demo-governor-07-1", + "votes": [ + { + "candidate_id": "demo-governor-07-1", + "votes": 540 + }, + { + "candidate_id": "demo-governor-07-2", + "votes": 420 + }, + { + "candidate_id": "demo-governor-07-3", + "votes": 25 + }, + { + "candidate_id": "demo-governor-07-4", + "votes": 15 + } + ] + }, + { + "race_id": "demo-governor-08", + "round": "general", + "status": "called", + "total_votes": 1000, + "winner_id": "demo-governor-08-1", + "votes": [ + { + "candidate_id": "demo-governor-08-1", + "votes": 510 + }, + { + "candidate_id": "demo-governor-08-2", + "votes": 490 + }, + { + "candidate_id": "demo-governor-08-3", + "votes": 0 + }, + { + "candidate_id": "demo-governor-08-4", + "votes": 0 + } + ] + }, + { + "race_id": "demo-governor-09", + "round": "general", + "status": "called", + "total_votes": 1000, + "winner_id": "demo-governor-09-1", + "votes": [ + { + "candidate_id": "demo-governor-09-1", + "votes": 800 + }, + { + "candidate_id": "demo-governor-09-2", + "votes": 150 + }, + { + "candidate_id": "demo-governor-09-3", + "votes": 30 + }, + { + "candidate_id": "demo-governor-09-4", + "votes": 20 + } + ] + }, + { + "race_id": "demo-governor-10", + "round": "general", + "status": "called", + "total_votes": 1000, + "winner_id": "demo-governor-10-1", + "votes": [ + { + "candidate_id": "demo-governor-10-1", + "votes": 600 + }, + { + "candidate_id": "demo-governor-10-2", + "votes": 300 + }, + { + "candidate_id": "demo-governor-10-3", + "votes": 70 + }, + { + "candidate_id": "demo-governor-10-4", + "votes": 30 + } + ] + }, + { + "race_id": "demo-governor-11", + "round": "general", + "status": "called", + "total_votes": 1000, + "winner_id": "demo-governor-11-1", + "votes": [ + { + "candidate_id": "demo-governor-11-1", + "votes": 540 + }, + { + "candidate_id": "demo-governor-11-2", + "votes": 420 + }, + { + "candidate_id": "demo-governor-11-3", + "votes": 25 + }, + { + "candidate_id": "demo-governor-11-4", + "votes": 15 + } + ] + }, + { + "race_id": "demo-governor-12", + "round": "general", + "status": "called", + "total_votes": 1000, + "winner_id": "demo-governor-12-1", + "votes": [ + { + "candidate_id": "demo-governor-12-1", + "votes": 510 + }, + { + "candidate_id": "demo-governor-12-2", + "votes": 490 + }, + { + "candidate_id": "demo-governor-12-3", + "votes": 0 + }, + { + "candidate_id": "demo-governor-12-4", + "votes": 0 + } + ] + } + ] +} diff --git a/data/demo/results-4.json b/data/demo/results-4.json new file mode 100644 index 0000000..134b865 --- /dev/null +++ b/data/demo/results-4.json @@ -0,0 +1,916 @@ +{ + "schema_version": 1, + "id": "demo-results-4", + "kind": "results", + "mode": "demo", + "label": "Synthetic replay 5: Corrected totals and call", + "as_of": "2026-11-04T04:00:00Z", + "source": { + "name": "Fictional scoring fixtures", + "url": "fixture:product-fpol/demo", + "retrieved_at": "2026-10-01T00:00:00Z" + }, + "catalog_id": "demo-2026", + "entries": [ + { + "race_id": "demo-house-01", + "round": "general", + "status": "called", + "total_votes": 1000, + "winner_id": "demo-house-01-1", + "votes": [ + { + "candidate_id": "demo-house-01-1", + "votes": 800 + }, + { + "candidate_id": "demo-house-01-2", + "votes": 150 + }, + { + "candidate_id": "demo-house-01-3", + "votes": 30 + }, + { + "candidate_id": "demo-house-01-4", + "votes": 20 + } + ] + }, + { + "race_id": "demo-house-02", + "round": "general", + "status": "called", + "total_votes": 1000, + "winner_id": "demo-house-02-1", + "votes": [ + { + "candidate_id": "demo-house-02-1", + "votes": 600 + }, + { + "candidate_id": "demo-house-02-2", + "votes": 300 + }, + { + "candidate_id": "demo-house-02-3", + "votes": 70 + }, + { + "candidate_id": "demo-house-02-4", + "votes": 30 + } + ] + }, + { + "race_id": "demo-house-03", + "round": "general", + "status": "called", + "total_votes": 1000, + "winner_id": "demo-house-03-1", + "votes": [ + { + "candidate_id": "demo-house-03-1", + "votes": 540 + }, + { + "candidate_id": "demo-house-03-2", + "votes": 420 + }, + { + "candidate_id": "demo-house-03-3", + "votes": 25 + }, + { + "candidate_id": "demo-house-03-4", + "votes": 15 + } + ] + }, + { + "race_id": "demo-house-04", + "round": "general", + "status": "called", + "total_votes": 1000, + "winner_id": "demo-house-04-2", + "votes": [ + { + "candidate_id": "demo-house-04-1", + "votes": 490 + }, + { + "candidate_id": "demo-house-04-2", + "votes": 510 + }, + { + "candidate_id": "demo-house-04-3", + "votes": 0 + }, + { + "candidate_id": "demo-house-04-4", + "votes": 0 + } + ] + }, + { + "race_id": "demo-house-05", + "round": "general", + "status": "called", + "total_votes": 1000, + "winner_id": "demo-house-05-1", + "votes": [ + { + "candidate_id": "demo-house-05-1", + "votes": 800 + }, + { + "candidate_id": "demo-house-05-2", + "votes": 150 + }, + { + "candidate_id": "demo-house-05-3", + "votes": 30 + }, + { + "candidate_id": "demo-house-05-4", + "votes": 20 + } + ] + }, + { + "race_id": "demo-house-06", + "round": "general", + "status": "called", + "total_votes": 1000, + "winner_id": "demo-house-06-1", + "votes": [ + { + "candidate_id": "demo-house-06-1", + "votes": 600 + }, + { + "candidate_id": "demo-house-06-2", + "votes": 300 + }, + { + "candidate_id": "demo-house-06-3", + "votes": 70 + }, + { + "candidate_id": "demo-house-06-4", + "votes": 30 + } + ] + }, + { + "race_id": "demo-house-07", + "round": "general", + "status": "called", + "total_votes": 1000, + "winner_id": "demo-house-07-1", + "votes": [ + { + "candidate_id": "demo-house-07-1", + "votes": 540 + }, + { + "candidate_id": "demo-house-07-2", + "votes": 420 + }, + { + "candidate_id": "demo-house-07-3", + "votes": 25 + }, + { + "candidate_id": "demo-house-07-4", + "votes": 15 + } + ] + }, + { + "race_id": "demo-house-08", + "round": "general", + "status": "called", + "total_votes": 1000, + "winner_id": "demo-house-08-2", + "votes": [ + { + "candidate_id": "demo-house-08-1", + "votes": 490 + }, + { + "candidate_id": "demo-house-08-2", + "votes": 510 + }, + { + "candidate_id": "demo-house-08-3", + "votes": 0 + }, + { + "candidate_id": "demo-house-08-4", + "votes": 0 + } + ] + }, + { + "race_id": "demo-house-09", + "round": "general", + "status": "called", + "total_votes": 1000, + "winner_id": "demo-house-09-1", + "votes": [ + { + "candidate_id": "demo-house-09-1", + "votes": 800 + }, + { + "candidate_id": "demo-house-09-2", + "votes": 150 + }, + { + "candidate_id": "demo-house-09-3", + "votes": 30 + }, + { + "candidate_id": "demo-house-09-4", + "votes": 20 + } + ] + }, + { + "race_id": "demo-house-10", + "round": "general", + "status": "called", + "total_votes": 1000, + "winner_id": "demo-house-10-1", + "votes": [ + { + "candidate_id": "demo-house-10-1", + "votes": 600 + }, + { + "candidate_id": "demo-house-10-2", + "votes": 300 + }, + { + "candidate_id": "demo-house-10-3", + "votes": 70 + }, + { + "candidate_id": "demo-house-10-4", + "votes": 30 + } + ] + }, + { + "race_id": "demo-house-11", + "round": "general", + "status": "called", + "total_votes": 1000, + "winner_id": "demo-house-11-1", + "votes": [ + { + "candidate_id": "demo-house-11-1", + "votes": 540 + }, + { + "candidate_id": "demo-house-11-2", + "votes": 420 + }, + { + "candidate_id": "demo-house-11-3", + "votes": 25 + }, + { + "candidate_id": "demo-house-11-4", + "votes": 15 + } + ] + }, + { + "race_id": "demo-house-12", + "round": "general", + "status": "called", + "total_votes": 1000, + "winner_id": "demo-house-12-2", + "votes": [ + { + "candidate_id": "demo-house-12-1", + "votes": 490 + }, + { + "candidate_id": "demo-house-12-2", + "votes": 510 + }, + { + "candidate_id": "demo-house-12-3", + "votes": 0 + }, + { + "candidate_id": "demo-house-12-4", + "votes": 0 + } + ] + }, + { + "race_id": "demo-senate-01", + "round": "general", + "status": "called", + "total_votes": 1000, + "winner_id": "demo-senate-01-1", + "votes": [ + { + "candidate_id": "demo-senate-01-1", + "votes": 800 + }, + { + "candidate_id": "demo-senate-01-2", + "votes": 150 + }, + { + "candidate_id": "demo-senate-01-3", + "votes": 30 + }, + { + "candidate_id": "demo-senate-01-4", + "votes": 20 + } + ] + }, + { + "race_id": "demo-senate-02", + "round": "general", + "status": "called", + "total_votes": 1000, + "winner_id": "demo-senate-02-1", + "votes": [ + { + "candidate_id": "demo-senate-02-1", + "votes": 600 + }, + { + "candidate_id": "demo-senate-02-2", + "votes": 300 + }, + { + "candidate_id": "demo-senate-02-3", + "votes": 70 + }, + { + "candidate_id": "demo-senate-02-4", + "votes": 30 + } + ] + }, + { + "race_id": "demo-senate-03", + "round": "general", + "status": "called", + "total_votes": 1000, + "winner_id": "demo-senate-03-1", + "votes": [ + { + "candidate_id": "demo-senate-03-1", + "votes": 540 + }, + { + "candidate_id": "demo-senate-03-2", + "votes": 420 + }, + { + "candidate_id": "demo-senate-03-3", + "votes": 25 + }, + { + "candidate_id": "demo-senate-03-4", + "votes": 15 + } + ] + }, + { + "race_id": "demo-senate-04", + "round": "general", + "status": "called", + "total_votes": 1000, + "winner_id": "demo-senate-04-2", + "votes": [ + { + "candidate_id": "demo-senate-04-1", + "votes": 490 + }, + { + "candidate_id": "demo-senate-04-2", + "votes": 510 + }, + { + "candidate_id": "demo-senate-04-3", + "votes": 0 + }, + { + "candidate_id": "demo-senate-04-4", + "votes": 0 + } + ] + }, + { + "race_id": "demo-senate-05", + "round": "general", + "status": "called", + "total_votes": 1000, + "winner_id": "demo-senate-05-1", + "votes": [ + { + "candidate_id": "demo-senate-05-1", + "votes": 800 + }, + { + "candidate_id": "demo-senate-05-2", + "votes": 150 + }, + { + "candidate_id": "demo-senate-05-3", + "votes": 30 + }, + { + "candidate_id": "demo-senate-05-4", + "votes": 20 + } + ] + }, + { + "race_id": "demo-senate-06", + "round": "general", + "status": "called", + "total_votes": 1000, + "winner_id": "demo-senate-06-1", + "votes": [ + { + "candidate_id": "demo-senate-06-1", + "votes": 600 + }, + { + "candidate_id": "demo-senate-06-2", + "votes": 300 + }, + { + "candidate_id": "demo-senate-06-3", + "votes": 70 + }, + { + "candidate_id": "demo-senate-06-4", + "votes": 30 + } + ] + }, + { + "race_id": "demo-senate-07", + "round": "general", + "status": "called", + "total_votes": 1000, + "winner_id": "demo-senate-07-1", + "votes": [ + { + "candidate_id": "demo-senate-07-1", + "votes": 540 + }, + { + "candidate_id": "demo-senate-07-2", + "votes": 420 + }, + { + "candidate_id": "demo-senate-07-3", + "votes": 25 + }, + { + "candidate_id": "demo-senate-07-4", + "votes": 15 + } + ] + }, + { + "race_id": "demo-senate-08", + "round": "general", + "status": "called", + "total_votes": 1000, + "winner_id": "demo-senate-08-2", + "votes": [ + { + "candidate_id": "demo-senate-08-1", + "votes": 490 + }, + { + "candidate_id": "demo-senate-08-2", + "votes": 510 + }, + { + "candidate_id": "demo-senate-08-3", + "votes": 0 + }, + { + "candidate_id": "demo-senate-08-4", + "votes": 0 + } + ] + }, + { + "race_id": "demo-senate-09", + "round": "general", + "status": "called", + "total_votes": 1000, + "winner_id": "demo-senate-09-1", + "votes": [ + { + "candidate_id": "demo-senate-09-1", + "votes": 800 + }, + { + "candidate_id": "demo-senate-09-2", + "votes": 150 + }, + { + "candidate_id": "demo-senate-09-3", + "votes": 30 + }, + { + "candidate_id": "demo-senate-09-4", + "votes": 20 + } + ] + }, + { + "race_id": "demo-senate-10", + "round": "general", + "status": "called", + "total_votes": 1000, + "winner_id": "demo-senate-10-1", + "votes": [ + { + "candidate_id": "demo-senate-10-1", + "votes": 600 + }, + { + "candidate_id": "demo-senate-10-2", + "votes": 300 + }, + { + "candidate_id": "demo-senate-10-3", + "votes": 70 + }, + { + "candidate_id": "demo-senate-10-4", + "votes": 30 + } + ] + }, + { + "race_id": "demo-senate-11", + "round": "general", + "status": "called", + "total_votes": 1000, + "winner_id": "demo-senate-11-1", + "votes": [ + { + "candidate_id": "demo-senate-11-1", + "votes": 540 + }, + { + "candidate_id": "demo-senate-11-2", + "votes": 420 + }, + { + "candidate_id": "demo-senate-11-3", + "votes": 25 + }, + { + "candidate_id": "demo-senate-11-4", + "votes": 15 + } + ] + }, + { + "race_id": "demo-senate-12", + "round": "general", + "status": "called", + "total_votes": 1000, + "winner_id": "demo-senate-12-2", + "votes": [ + { + "candidate_id": "demo-senate-12-1", + "votes": 490 + }, + { + "candidate_id": "demo-senate-12-2", + "votes": 510 + }, + { + "candidate_id": "demo-senate-12-3", + "votes": 0 + }, + { + "candidate_id": "demo-senate-12-4", + "votes": 0 + } + ] + }, + { + "race_id": "demo-governor-01", + "round": "general", + "status": "called", + "total_votes": 1000, + "winner_id": "demo-governor-01-1", + "votes": [ + { + "candidate_id": "demo-governor-01-1", + "votes": 800 + }, + { + "candidate_id": "demo-governor-01-2", + "votes": 150 + }, + { + "candidate_id": "demo-governor-01-3", + "votes": 30 + }, + { + "candidate_id": "demo-governor-01-4", + "votes": 20 + } + ] + }, + { + "race_id": "demo-governor-02", + "round": "general", + "status": "called", + "total_votes": 1000, + "winner_id": "demo-governor-02-1", + "votes": [ + { + "candidate_id": "demo-governor-02-1", + "votes": 600 + }, + { + "candidate_id": "demo-governor-02-2", + "votes": 300 + }, + { + "candidate_id": "demo-governor-02-3", + "votes": 70 + }, + { + "candidate_id": "demo-governor-02-4", + "votes": 30 + } + ] + }, + { + "race_id": "demo-governor-03", + "round": "general", + "status": "called", + "total_votes": 1000, + "winner_id": "demo-governor-03-1", + "votes": [ + { + "candidate_id": "demo-governor-03-1", + "votes": 540 + }, + { + "candidate_id": "demo-governor-03-2", + "votes": 420 + }, + { + "candidate_id": "demo-governor-03-3", + "votes": 25 + }, + { + "candidate_id": "demo-governor-03-4", + "votes": 15 + } + ] + }, + { + "race_id": "demo-governor-04", + "round": "general", + "status": "called", + "total_votes": 1000, + "winner_id": "demo-governor-04-2", + "votes": [ + { + "candidate_id": "demo-governor-04-1", + "votes": 490 + }, + { + "candidate_id": "demo-governor-04-2", + "votes": 510 + }, + { + "candidate_id": "demo-governor-04-3", + "votes": 0 + }, + { + "candidate_id": "demo-governor-04-4", + "votes": 0 + } + ] + }, + { + "race_id": "demo-governor-05", + "round": "general", + "status": "called", + "total_votes": 1000, + "winner_id": "demo-governor-05-1", + "votes": [ + { + "candidate_id": "demo-governor-05-1", + "votes": 800 + }, + { + "candidate_id": "demo-governor-05-2", + "votes": 150 + }, + { + "candidate_id": "demo-governor-05-3", + "votes": 30 + }, + { + "candidate_id": "demo-governor-05-4", + "votes": 20 + } + ] + }, + { + "race_id": "demo-governor-06", + "round": "general", + "status": "called", + "total_votes": 1000, + "winner_id": "demo-governor-06-1", + "votes": [ + { + "candidate_id": "demo-governor-06-1", + "votes": 600 + }, + { + "candidate_id": "demo-governor-06-2", + "votes": 300 + }, + { + "candidate_id": "demo-governor-06-3", + "votes": 70 + }, + { + "candidate_id": "demo-governor-06-4", + "votes": 30 + } + ] + }, + { + "race_id": "demo-governor-07", + "round": "general", + "status": "called", + "total_votes": 1000, + "winner_id": "demo-governor-07-1", + "votes": [ + { + "candidate_id": "demo-governor-07-1", + "votes": 540 + }, + { + "candidate_id": "demo-governor-07-2", + "votes": 420 + }, + { + "candidate_id": "demo-governor-07-3", + "votes": 25 + }, + { + "candidate_id": "demo-governor-07-4", + "votes": 15 + } + ] + }, + { + "race_id": "demo-governor-08", + "round": "general", + "status": "called", + "total_votes": 1000, + "winner_id": "demo-governor-08-2", + "votes": [ + { + "candidate_id": "demo-governor-08-1", + "votes": 490 + }, + { + "candidate_id": "demo-governor-08-2", + "votes": 510 + }, + { + "candidate_id": "demo-governor-08-3", + "votes": 0 + }, + { + "candidate_id": "demo-governor-08-4", + "votes": 0 + } + ] + }, + { + "race_id": "demo-governor-09", + "round": "general", + "status": "called", + "total_votes": 1000, + "winner_id": "demo-governor-09-1", + "votes": [ + { + "candidate_id": "demo-governor-09-1", + "votes": 800 + }, + { + "candidate_id": "demo-governor-09-2", + "votes": 150 + }, + { + "candidate_id": "demo-governor-09-3", + "votes": 30 + }, + { + "candidate_id": "demo-governor-09-4", + "votes": 20 + } + ] + }, + { + "race_id": "demo-governor-10", + "round": "general", + "status": "called", + "total_votes": 1000, + "winner_id": "demo-governor-10-1", + "votes": [ + { + "candidate_id": "demo-governor-10-1", + "votes": 600 + }, + { + "candidate_id": "demo-governor-10-2", + "votes": 300 + }, + { + "candidate_id": "demo-governor-10-3", + "votes": 70 + }, + { + "candidate_id": "demo-governor-10-4", + "votes": 30 + } + ] + }, + { + "race_id": "demo-governor-11", + "round": "general", + "status": "called", + "total_votes": 1000, + "winner_id": "demo-governor-11-1", + "votes": [ + { + "candidate_id": "demo-governor-11-1", + "votes": 540 + }, + { + "candidate_id": "demo-governor-11-2", + "votes": 420 + }, + { + "candidate_id": "demo-governor-11-3", + "votes": 25 + }, + { + "candidate_id": "demo-governor-11-4", + "votes": 15 + } + ] + }, + { + "race_id": "demo-governor-12", + "round": "general", + "status": "called", + "total_votes": 1000, + "winner_id": "demo-governor-12-2", + "votes": [ + { + "candidate_id": "demo-governor-12-1", + "votes": 490 + }, + { + "candidate_id": "demo-governor-12-2", + "votes": 510 + }, + { + "candidate_id": "demo-governor-12-3", + "votes": 0 + }, + { + "candidate_id": "demo-governor-12-4", + "votes": 0 + } + ] + } + ] +} diff --git a/data/real/mi-2026-10-04.json b/data/real/mi-2026-10-04.json new file mode 100644 index 0000000..941beee --- /dev/null +++ b/data/real/mi-2026-10-04.json @@ -0,0 +1,1292 @@ +{ + "schema_version": 1, + "kind": "catalog", + "id": "mi-2026-10-04", + "mode": "real", + "label": "Michigan official list \u2022 partial US catalog", + "as_of": "2026-10-04T00:00:00Z", + "source": { + "name": "Michigan Department of State \u2014 2026 general election candidate list", + "url": "https://mi-boe.entellitrak.com/etk-mi-boe-prod/page.request.do?electionType=GEN&electionYear=2026&page=page.miboePublicReport", + "retrieved_at": "2026-10-04T00:00:00Z" + }, + "complete": false, + "races": [ + { + "id": "2026-mi-governor-general", + "office": "governor", + "state": "MI", + "round": "general", + "name": "Michigan Governor" + }, + { + "id": "2026-mi-senate-general", + "office": "senate", + "state": "MI", + "round": "general", + "name": "Michigan Senate" + }, + { + "id": "2026-mi-house-01-general", + "office": "house", + "state": "MI", + "round": "general", + "name": "Michigan House 01" + }, + { + "id": "2026-mi-house-02-general", + "office": "house", + "state": "MI", + "round": "general", + "name": "Michigan House 02" + }, + { + "id": "2026-mi-house-03-general", + "office": "house", + "state": "MI", + "round": "general", + "name": "Michigan House 03" + }, + { + "id": "2026-mi-house-04-general", + "office": "house", + "state": "MI", + "round": "general", + "name": "Michigan House 04" + }, + { + "id": "2026-mi-house-05-general", + "office": "house", + "state": "MI", + "round": "general", + "name": "Michigan House 05" + }, + { + "id": "2026-mi-house-06-general", + "office": "house", + "state": "MI", + "round": "general", + "name": "Michigan House 06" + }, + { + "id": "2026-mi-house-07-general", + "office": "house", + "state": "MI", + "round": "general", + "name": "Michigan House 07" + }, + { + "id": "2026-mi-house-08-general", + "office": "house", + "state": "MI", + "round": "general", + "name": "Michigan House 08" + }, + { + "id": "2026-mi-house-09-general", + "office": "house", + "state": "MI", + "round": "general", + "name": "Michigan House 09" + }, + { + "id": "2026-mi-house-10-general", + "office": "house", + "state": "MI", + "round": "general", + "name": "Michigan House 10" + }, + { + "id": "2026-mi-house-11-general", + "office": "house", + "state": "MI", + "round": "general", + "name": "Michigan House 11" + }, + { + "id": "2026-mi-house-12-general", + "office": "house", + "state": "MI", + "round": "general", + "name": "Michigan House 12" + }, + { + "id": "2026-mi-house-13-general", + "office": "house", + "state": "MI", + "round": "general", + "name": "Michigan House 13" + } + ], + "candidates": [ + { + "id": "2026-mi-governor-general-c01", + "race_id": "2026-mi-governor-general", + "name": "Jocelyn Benson", + "affiliation": "democrat", + "qualified": true, + "ballot_lines": [ + "Democratic" + ], + "provider_ids": {}, + "source": { + "name": "Michigan Department of State \u2014 2026 general election candidate list", + "url": "https://mi-boe.entellitrak.com/etk-mi-boe-prod/page.request.do?electionType=GEN&electionYear=2026&page=page.miboePublicReport", + "retrieved_at": "2026-10-04T00:00:00Z" + } + }, + { + "id": "2026-mi-governor-general-c02", + "race_id": "2026-mi-governor-general", + "name": "John James", + "affiliation": "republican", + "qualified": true, + "ballot_lines": [ + "Republican" + ], + "provider_ids": {}, + "source": { + "name": "Michigan Department of State \u2014 2026 general election candidate list", + "url": "https://mi-boe.entellitrak.com/etk-mi-boe-prod/page.request.do?electionType=GEN&electionYear=2026&page=page.miboePublicReport", + "retrieved_at": "2026-10-04T00:00:00Z" + } + }, + { + "id": "2026-mi-governor-general-c03", + "race_id": "2026-mi-governor-general", + "name": "Anthony Hudson", + "affiliation": "other", + "qualified": true, + "ballot_lines": [ + "Libertarian" + ], + "provider_ids": {}, + "source": { + "name": "Michigan Department of State \u2014 2026 general election candidate list", + "url": "https://mi-boe.entellitrak.com/etk-mi-boe-prod/page.request.do?electionType=GEN&electionYear=2026&page=page.miboePublicReport", + "retrieved_at": "2026-10-04T00:00:00Z" + } + }, + { + "id": "2026-mi-governor-general-c04", + "race_id": "2026-mi-governor-general", + "name": "Donna Brandenburg", + "affiliation": "other", + "qualified": true, + "ballot_lines": [ + "U.S. Taxpayers" + ], + "provider_ids": {}, + "source": { + "name": "Michigan Department of State \u2014 2026 general election candidate list", + "url": "https://mi-boe.entellitrak.com/etk-mi-boe-prod/page.request.do?electionType=GEN&electionYear=2026&page=page.miboePublicReport", + "retrieved_at": "2026-10-04T00:00:00Z" + } + }, + { + "id": "2026-mi-governor-general-c05", + "race_id": "2026-mi-governor-general", + "name": "Douglas Campbell", + "affiliation": "other", + "qualified": true, + "ballot_lines": [ + "Green" + ], + "provider_ids": {}, + "source": { + "name": "Michigan Department of State \u2014 2026 general election candidate list", + "url": "https://mi-boe.entellitrak.com/etk-mi-boe-prod/page.request.do?electionType=GEN&electionYear=2026&page=page.miboePublicReport", + "retrieved_at": "2026-10-04T00:00:00Z" + } + }, + { + "id": "2026-mi-senate-general-c01", + "race_id": "2026-mi-senate-general", + "name": "Abdul El-Sayed", + "affiliation": "democrat", + "qualified": true, + "ballot_lines": [ + "Democratic" + ], + "provider_ids": {}, + "source": { + "name": "Michigan Department of State \u2014 2026 general election candidate list", + "url": "https://mi-boe.entellitrak.com/etk-mi-boe-prod/page.request.do?electionType=GEN&electionYear=2026&page=page.miboePublicReport", + "retrieved_at": "2026-10-04T00:00:00Z" + } + }, + { + "id": "2026-mi-senate-general-c02", + "race_id": "2026-mi-senate-general", + "name": "Mike Rogers", + "affiliation": "republican", + "qualified": true, + "ballot_lines": [ + "Republican" + ], + "provider_ids": {}, + "source": { + "name": "Michigan Department of State \u2014 2026 general election candidate list", + "url": "https://mi-boe.entellitrak.com/etk-mi-boe-prod/page.request.do?electionType=GEN&electionYear=2026&page=page.miboePublicReport", + "retrieved_at": "2026-10-04T00:00:00Z" + } + }, + { + "id": "2026-mi-senate-general-c03", + "race_id": "2026-mi-senate-general", + "name": "Lydia Christensen", + "affiliation": "other", + "qualified": true, + "ballot_lines": [ + "Libertarian" + ], + "provider_ids": {}, + "source": { + "name": "Michigan Department of State \u2014 2026 general election candidate list", + "url": "https://mi-boe.entellitrak.com/etk-mi-boe-prod/page.request.do?electionType=GEN&electionYear=2026&page=page.miboePublicReport", + "retrieved_at": "2026-10-04T00:00:00Z" + } + }, + { + "id": "2026-mi-senate-general-c04", + "race_id": "2026-mi-senate-general", + "name": "Tim Long", + "affiliation": "other", + "qualified": true, + "ballot_lines": [ + "U.S. Taxpayers" + ], + "provider_ids": {}, + "source": { + "name": "Michigan Department of State \u2014 2026 general election candidate list", + "url": "https://mi-boe.entellitrak.com/etk-mi-boe-prod/page.request.do?electionType=GEN&electionYear=2026&page=page.miboePublicReport", + "retrieved_at": "2026-10-04T00:00:00Z" + } + }, + { + "id": "2026-mi-senate-general-c05", + "race_id": "2026-mi-senate-general", + "name": "Douglas P. Marsh", + "affiliation": "other", + "qualified": true, + "ballot_lines": [ + "Green" + ], + "provider_ids": {}, + "source": { + "name": "Michigan Department of State \u2014 2026 general election candidate list", + "url": "https://mi-boe.entellitrak.com/etk-mi-boe-prod/page.request.do?electionType=GEN&electionYear=2026&page=page.miboePublicReport", + "retrieved_at": "2026-10-04T00:00:00Z" + } + }, + { + "id": "2026-mi-senate-general-c06", + "race_id": "2026-mi-senate-general", + "name": "Walter P. Kristy", + "affiliation": "other", + "qualified": true, + "ballot_lines": [ + "Natural Law" + ], + "provider_ids": {}, + "source": { + "name": "Michigan Department of State \u2014 2026 general election candidate list", + "url": "https://mi-boe.entellitrak.com/etk-mi-boe-prod/page.request.do?electionType=GEN&electionYear=2026&page=page.miboePublicReport", + "retrieved_at": "2026-10-04T00:00:00Z" + } + }, + { + "id": "2026-mi-house-01-general-c01", + "race_id": "2026-mi-house-01-general", + "name": "Callie Barr", + "affiliation": "democrat", + "qualified": true, + "ballot_lines": [ + "Democratic" + ], + "provider_ids": {}, + "source": { + "name": "Michigan Department of State \u2014 2026 general election candidate list", + "url": "https://mi-boe.entellitrak.com/etk-mi-boe-prod/page.request.do?electionType=GEN&electionYear=2026&page=page.miboePublicReport", + "retrieved_at": "2026-10-04T00:00:00Z" + } + }, + { + "id": "2026-mi-house-01-general-c02", + "race_id": "2026-mi-house-01-general", + "name": "Jack Bergman", + "affiliation": "republican", + "qualified": true, + "ballot_lines": [ + "Republican" + ], + "provider_ids": {}, + "source": { + "name": "Michigan Department of State \u2014 2026 general election candidate list", + "url": "https://mi-boe.entellitrak.com/etk-mi-boe-prod/page.request.do?electionType=GEN&electionYear=2026&page=page.miboePublicReport", + "retrieved_at": "2026-10-04T00:00:00Z" + } + }, + { + "id": "2026-mi-house-01-general-c03", + "race_id": "2026-mi-house-01-general", + "name": "Arnett Satterla", + "affiliation": "other", + "qualified": true, + "ballot_lines": [ + "Libertarian" + ], + "provider_ids": {}, + "source": { + "name": "Michigan Department of State \u2014 2026 general election candidate list", + "url": "https://mi-boe.entellitrak.com/etk-mi-boe-prod/page.request.do?electionType=GEN&electionYear=2026&page=page.miboePublicReport", + "retrieved_at": "2026-10-04T00:00:00Z" + } + }, + { + "id": "2026-mi-house-01-general-c04", + "race_id": "2026-mi-house-01-general", + "name": "Doc Kovaly", + "affiliation": "other", + "qualified": true, + "ballot_lines": [ + "U.S. Taxpayers" + ], + "provider_ids": {}, + "source": { + "name": "Michigan Department of State \u2014 2026 general election candidate list", + "url": "https://mi-boe.entellitrak.com/etk-mi-boe-prod/page.request.do?electionType=GEN&electionYear=2026&page=page.miboePublicReport", + "retrieved_at": "2026-10-04T00:00:00Z" + } + }, + { + "id": "2026-mi-house-01-general-c05", + "race_id": "2026-mi-house-01-general", + "name": "LaVeta Davenport", + "affiliation": "other", + "qualified": true, + "ballot_lines": [ + "Green" + ], + "provider_ids": {}, + "source": { + "name": "Michigan Department of State \u2014 2026 general election candidate list", + "url": "https://mi-boe.entellitrak.com/etk-mi-boe-prod/page.request.do?electionType=GEN&electionYear=2026&page=page.miboePublicReport", + "retrieved_at": "2026-10-04T00:00:00Z" + } + }, + { + "id": "2026-mi-house-01-general-c06", + "race_id": "2026-mi-house-01-general", + "name": "Liz Hakola", + "affiliation": "other", + "qualified": true, + "ballot_lines": [ + "Working Class" + ], + "provider_ids": {}, + "source": { + "name": "Michigan Department of State \u2014 2026 general election candidate list", + "url": "https://mi-boe.entellitrak.com/etk-mi-boe-prod/page.request.do?electionType=GEN&electionYear=2026&page=page.miboePublicReport", + "retrieved_at": "2026-10-04T00:00:00Z" + } + }, + { + "id": "2026-mi-house-01-general-c07", + "race_id": "2026-mi-house-01-general", + "name": "Zebulon Featherly", + "affiliation": "other", + "qualified": true, + "ballot_lines": [ + "No Party Affiliation" + ], + "provider_ids": {}, + "source": { + "name": "Michigan Department of State \u2014 2026 general election candidate list", + "url": "https://mi-boe.entellitrak.com/etk-mi-boe-prod/page.request.do?electionType=GEN&electionYear=2026&page=page.miboePublicReport", + "retrieved_at": "2026-10-04T00:00:00Z" + } + }, + { + "id": "2026-mi-house-02-general-c01", + "race_id": "2026-mi-house-02-general", + "name": "Ben Ambrose", + "affiliation": "democrat", + "qualified": true, + "ballot_lines": [ + "Democratic" + ], + "provider_ids": {}, + "source": { + "name": "Michigan Department of State \u2014 2026 general election candidate list", + "url": "https://mi-boe.entellitrak.com/etk-mi-boe-prod/page.request.do?electionType=GEN&electionYear=2026&page=page.miboePublicReport", + "retrieved_at": "2026-10-04T00:00:00Z" + } + }, + { + "id": "2026-mi-house-02-general-c02", + "race_id": "2026-mi-house-02-general", + "name": "John Moolenaar", + "affiliation": "republican", + "qualified": true, + "ballot_lines": [ + "Republican" + ], + "provider_ids": {}, + "source": { + "name": "Michigan Department of State \u2014 2026 general election candidate list", + "url": "https://mi-boe.entellitrak.com/etk-mi-boe-prod/page.request.do?electionType=GEN&electionYear=2026&page=page.miboePublicReport", + "retrieved_at": "2026-10-04T00:00:00Z" + } + }, + { + "id": "2026-mi-house-02-general-c03", + "race_id": "2026-mi-house-02-general", + "name": "Charlotte Magoon", + "affiliation": "other", + "qualified": true, + "ballot_lines": [ + "Green" + ], + "provider_ids": {}, + "source": { + "name": "Michigan Department of State \u2014 2026 general election candidate list", + "url": "https://mi-boe.entellitrak.com/etk-mi-boe-prod/page.request.do?electionType=GEN&electionYear=2026&page=page.miboePublicReport", + "retrieved_at": "2026-10-04T00:00:00Z" + } + }, + { + "id": "2026-mi-house-02-general-c04", + "race_id": "2026-mi-house-02-general", + "name": "Richard Sills", + "affiliation": "other", + "qualified": true, + "ballot_lines": [ + "Natural Law" + ], + "provider_ids": {}, + "source": { + "name": "Michigan Department of State \u2014 2026 general election candidate list", + "url": "https://mi-boe.entellitrak.com/etk-mi-boe-prod/page.request.do?electionType=GEN&electionYear=2026&page=page.miboePublicReport", + "retrieved_at": "2026-10-04T00:00:00Z" + } + }, + { + "id": "2026-mi-house-03-general-c01", + "race_id": "2026-mi-house-03-general", + "name": "Hillary Scholten", + "affiliation": "democrat", + "qualified": true, + "ballot_lines": [ + "Democratic" + ], + "provider_ids": {}, + "source": { + "name": "Michigan Department of State \u2014 2026 general election candidate list", + "url": "https://mi-boe.entellitrak.com/etk-mi-boe-prod/page.request.do?electionType=GEN&electionYear=2026&page=page.miboePublicReport", + "retrieved_at": "2026-10-04T00:00:00Z" + } + }, + { + "id": "2026-mi-house-03-general-c02", + "race_id": "2026-mi-house-03-general", + "name": "Terri DeBoer", + "affiliation": "republican", + "qualified": true, + "ballot_lines": [ + "Republican" + ], + "provider_ids": {}, + "source": { + "name": "Michigan Department of State \u2014 2026 general election candidate list", + "url": "https://mi-boe.entellitrak.com/etk-mi-boe-prod/page.request.do?electionType=GEN&electionYear=2026&page=page.miboePublicReport", + "retrieved_at": "2026-10-04T00:00:00Z" + } + }, + { + "id": "2026-mi-house-03-general-c03", + "race_id": "2026-mi-house-03-general", + "name": "Joe Jock", + "affiliation": "other", + "qualified": true, + "ballot_lines": [ + "Green" + ], + "provider_ids": {}, + "source": { + "name": "Michigan Department of State \u2014 2026 general election candidate list", + "url": "https://mi-boe.entellitrak.com/etk-mi-boe-prod/page.request.do?electionType=GEN&electionYear=2026&page=page.miboePublicReport", + "retrieved_at": "2026-10-04T00:00:00Z" + } + }, + { + "id": "2026-mi-house-04-general-c01", + "race_id": "2026-mi-house-04-general", + "name": "Sean McCann", + "affiliation": "democrat", + "qualified": true, + "ballot_lines": [ + "Democratic" + ], + "provider_ids": {}, + "source": { + "name": "Michigan Department of State \u2014 2026 general election candidate list", + "url": "https://mi-boe.entellitrak.com/etk-mi-boe-prod/page.request.do?electionType=GEN&electionYear=2026&page=page.miboePublicReport", + "retrieved_at": "2026-10-04T00:00:00Z" + } + }, + { + "id": "2026-mi-house-04-general-c02", + "race_id": "2026-mi-house-04-general", + "name": "Bill Huizenga", + "affiliation": "republican", + "qualified": true, + "ballot_lines": [ + "Republican" + ], + "provider_ids": {}, + "source": { + "name": "Michigan Department of State \u2014 2026 general election candidate list", + "url": "https://mi-boe.entellitrak.com/etk-mi-boe-prod/page.request.do?electionType=GEN&electionYear=2026&page=page.miboePublicReport", + "retrieved_at": "2026-10-04T00:00:00Z" + } + }, + { + "id": "2026-mi-house-04-general-c03", + "race_id": "2026-mi-house-04-general", + "name": "Shafina Che Barnett", + "affiliation": "other", + "qualified": true, + "ballot_lines": [ + "Green" + ], + "provider_ids": {}, + "source": { + "name": "Michigan Department of State \u2014 2026 general election candidate list", + "url": "https://mi-boe.entellitrak.com/etk-mi-boe-prod/page.request.do?electionType=GEN&electionYear=2026&page=page.miboePublicReport", + "retrieved_at": "2026-10-04T00:00:00Z" + } + }, + { + "id": "2026-mi-house-05-general-c01", + "race_id": "2026-mi-house-05-general", + "name": "Christian Vukasovich", + "affiliation": "democrat", + "qualified": true, + "ballot_lines": [ + "Democratic" + ], + "provider_ids": {}, + "source": { + "name": "Michigan Department of State \u2014 2026 general election candidate list", + "url": "https://mi-boe.entellitrak.com/etk-mi-boe-prod/page.request.do?electionType=GEN&electionYear=2026&page=page.miboePublicReport", + "retrieved_at": "2026-10-04T00:00:00Z" + } + }, + { + "id": "2026-mi-house-05-general-c02", + "race_id": "2026-mi-house-05-general", + "name": "Tim Walberg", + "affiliation": "republican", + "qualified": true, + "ballot_lines": [ + "Republican" + ], + "provider_ids": {}, + "source": { + "name": "Michigan Department of State \u2014 2026 general election candidate list", + "url": "https://mi-boe.entellitrak.com/etk-mi-boe-prod/page.request.do?electionType=GEN&electionYear=2026&page=page.miboePublicReport", + "retrieved_at": "2026-10-04T00:00:00Z" + } + }, + { + "id": "2026-mi-house-05-general-c03", + "race_id": "2026-mi-house-05-general", + "name": "Ronald A Muszynski", + "affiliation": "other", + "qualified": true, + "ballot_lines": [ + "Libertarian" + ], + "provider_ids": {}, + "source": { + "name": "Michigan Department of State \u2014 2026 general election candidate list", + "url": "https://mi-boe.entellitrak.com/etk-mi-boe-prod/page.request.do?electionType=GEN&electionYear=2026&page=page.miboePublicReport", + "retrieved_at": "2026-10-04T00:00:00Z" + } + }, + { + "id": "2026-mi-house-05-general-c04", + "race_id": "2026-mi-house-05-general", + "name": "Sharon Marie Renier", + "affiliation": "other", + "qualified": true, + "ballot_lines": [ + "U.S. Taxpayers" + ], + "provider_ids": {}, + "source": { + "name": "Michigan Department of State \u2014 2026 general election candidate list", + "url": "https://mi-boe.entellitrak.com/etk-mi-boe-prod/page.request.do?electionType=GEN&electionYear=2026&page=page.miboePublicReport", + "retrieved_at": "2026-10-04T00:00:00Z" + } + }, + { + "id": "2026-mi-house-05-general-c05", + "race_id": "2026-mi-house-05-general", + "name": "Jim Bronke", + "affiliation": "other", + "qualified": true, + "ballot_lines": [ + "Green" + ], + "provider_ids": {}, + "source": { + "name": "Michigan Department of State \u2014 2026 general election candidate list", + "url": "https://mi-boe.entellitrak.com/etk-mi-boe-prod/page.request.do?electionType=GEN&electionYear=2026&page=page.miboePublicReport", + "retrieved_at": "2026-10-04T00:00:00Z" + } + }, + { + "id": "2026-mi-house-06-general-c01", + "race_id": "2026-mi-house-06-general", + "name": "Debbie Dingell", + "affiliation": "democrat", + "qualified": true, + "ballot_lines": [ + "Democratic" + ], + "provider_ids": {}, + "source": { + "name": "Michigan Department of State \u2014 2026 general election candidate list", + "url": "https://mi-boe.entellitrak.com/etk-mi-boe-prod/page.request.do?electionType=GEN&electionYear=2026&page=page.miboePublicReport", + "retrieved_at": "2026-10-04T00:00:00Z" + } + }, + { + "id": "2026-mi-house-06-general-c02", + "race_id": "2026-mi-house-06-general", + "name": "Heather Smiley", + "affiliation": "republican", + "qualified": true, + "ballot_lines": [ + "Republican" + ], + "provider_ids": {}, + "source": { + "name": "Michigan Department of State \u2014 2026 general election candidate list", + "url": "https://mi-boe.entellitrak.com/etk-mi-boe-prod/page.request.do?electionType=GEN&electionYear=2026&page=page.miboePublicReport", + "retrieved_at": "2026-10-04T00:00:00Z" + } + }, + { + "id": "2026-mi-house-06-general-c03", + "race_id": "2026-mi-house-06-general", + "name": "Timothy A. Teagan", + "affiliation": "other", + "qualified": true, + "ballot_lines": [ + "Libertarian" + ], + "provider_ids": {}, + "source": { + "name": "Michigan Department of State \u2014 2026 general election candidate list", + "url": "https://mi-boe.entellitrak.com/etk-mi-boe-prod/page.request.do?electionType=GEN&electionYear=2026&page=page.miboePublicReport", + "retrieved_at": "2026-10-04T00:00:00Z" + } + }, + { + "id": "2026-mi-house-06-general-c04", + "race_id": "2026-mi-house-06-general", + "name": "Mike Mickevicius", + "affiliation": "other", + "qualified": true, + "ballot_lines": [ + "U.S. Taxpayers" + ], + "provider_ids": {}, + "source": { + "name": "Michigan Department of State \u2014 2026 general election candidate list", + "url": "https://mi-boe.entellitrak.com/etk-mi-boe-prod/page.request.do?electionType=GEN&electionYear=2026&page=page.miboePublicReport", + "retrieved_at": "2026-10-04T00:00:00Z" + } + }, + { + "id": "2026-mi-house-06-general-c05", + "race_id": "2026-mi-house-06-general", + "name": "Clyde Shabazz", + "affiliation": "other", + "qualified": true, + "ballot_lines": [ + "Green" + ], + "provider_ids": {}, + "source": { + "name": "Michigan Department of State \u2014 2026 general election candidate list", + "url": "https://mi-boe.entellitrak.com/etk-mi-boe-prod/page.request.do?electionType=GEN&electionYear=2026&page=page.miboePublicReport", + "retrieved_at": "2026-10-04T00:00:00Z" + } + }, + { + "id": "2026-mi-house-06-general-c06", + "race_id": "2026-mi-house-06-general", + "name": "Linda Rayburn", + "affiliation": "other", + "qualified": true, + "ballot_lines": [ + "Working Class" + ], + "provider_ids": {}, + "source": { + "name": "Michigan Department of State \u2014 2026 general election candidate list", + "url": "https://mi-boe.entellitrak.com/etk-mi-boe-prod/page.request.do?electionType=GEN&electionYear=2026&page=page.miboePublicReport", + "retrieved_at": "2026-10-04T00:00:00Z" + } + }, + { + "id": "2026-mi-house-07-general-c01", + "race_id": "2026-mi-house-07-general", + "name": "William Lawrence", + "affiliation": "democrat", + "qualified": true, + "ballot_lines": [ + "Democratic" + ], + "provider_ids": {}, + "source": { + "name": "Michigan Department of State \u2014 2026 general election candidate list", + "url": "https://mi-boe.entellitrak.com/etk-mi-boe-prod/page.request.do?electionType=GEN&electionYear=2026&page=page.miboePublicReport", + "retrieved_at": "2026-10-04T00:00:00Z" + } + }, + { + "id": "2026-mi-house-07-general-c02", + "race_id": "2026-mi-house-07-general", + "name": "Tom Barrett", + "affiliation": "republican", + "qualified": true, + "ballot_lines": [ + "Republican" + ], + "provider_ids": {}, + "source": { + "name": "Michigan Department of State \u2014 2026 general election candidate list", + "url": "https://mi-boe.entellitrak.com/etk-mi-boe-prod/page.request.do?electionType=GEN&electionYear=2026&page=page.miboePublicReport", + "retrieved_at": "2026-10-04T00:00:00Z" + } + }, + { + "id": "2026-mi-house-07-general-c03", + "race_id": "2026-mi-house-07-general", + "name": "Shane Dedrick", + "affiliation": "other", + "qualified": true, + "ballot_lines": [ + "Green" + ], + "provider_ids": {}, + "source": { + "name": "Michigan Department of State \u2014 2026 general election candidate list", + "url": "https://mi-boe.entellitrak.com/etk-mi-boe-prod/page.request.do?electionType=GEN&electionYear=2026&page=page.miboePublicReport", + "retrieved_at": "2026-10-04T00:00:00Z" + } + }, + { + "id": "2026-mi-house-08-general-c01", + "race_id": "2026-mi-house-08-general", + "name": "Kristen McDonald Rivet", + "affiliation": "democrat", + "qualified": true, + "ballot_lines": [ + "Democratic" + ], + "provider_ids": {}, + "source": { + "name": "Michigan Department of State \u2014 2026 general election candidate list", + "url": "https://mi-boe.entellitrak.com/etk-mi-boe-prod/page.request.do?electionType=GEN&electionYear=2026&page=page.miboePublicReport", + "retrieved_at": "2026-10-04T00:00:00Z" + } + }, + { + "id": "2026-mi-house-08-general-c02", + "race_id": "2026-mi-house-08-general", + "name": "Thomas J. Smith", + "affiliation": "republican", + "qualified": true, + "ballot_lines": [ + "Republican" + ], + "provider_ids": {}, + "source": { + "name": "Michigan Department of State \u2014 2026 general election candidate list", + "url": "https://mi-boe.entellitrak.com/etk-mi-boe-prod/page.request.do?electionType=GEN&electionYear=2026&page=page.miboePublicReport", + "retrieved_at": "2026-10-04T00:00:00Z" + } + }, + { + "id": "2026-mi-house-08-general-c03", + "race_id": "2026-mi-house-08-general", + "name": "C. Mia Pettus", + "affiliation": "other", + "qualified": true, + "ballot_lines": [ + "Libertarian" + ], + "provider_ids": {}, + "source": { + "name": "Michigan Department of State \u2014 2026 general election candidate list", + "url": "https://mi-boe.entellitrak.com/etk-mi-boe-prod/page.request.do?electionType=GEN&electionYear=2026&page=page.miboePublicReport", + "retrieved_at": "2026-10-04T00:00:00Z" + } + }, + { + "id": "2026-mi-house-08-general-c04", + "race_id": "2026-mi-house-08-general", + "name": "Jim Casha", + "affiliation": "other", + "qualified": true, + "ballot_lines": [ + "Green" + ], + "provider_ids": {}, + "source": { + "name": "Michigan Department of State \u2014 2026 general election candidate list", + "url": "https://mi-boe.entellitrak.com/etk-mi-boe-prod/page.request.do?electionType=GEN&electionYear=2026&page=page.miboePublicReport", + "retrieved_at": "2026-10-04T00:00:00Z" + } + }, + { + "id": "2026-mi-house-08-general-c05", + "race_id": "2026-mi-house-08-general", + "name": "Kathy Goodwin", + "affiliation": "other", + "qualified": true, + "ballot_lines": [ + "Working Class" + ], + "provider_ids": {}, + "source": { + "name": "Michigan Department of State \u2014 2026 general election candidate list", + "url": "https://mi-boe.entellitrak.com/etk-mi-boe-prod/page.request.do?electionType=GEN&electionYear=2026&page=page.miboePublicReport", + "retrieved_at": "2026-10-04T00:00:00Z" + } + }, + { + "id": "2026-mi-house-09-general-c01", + "race_id": "2026-mi-house-09-general", + "name": "Ray Pooley", + "affiliation": "democrat", + "qualified": true, + "ballot_lines": [ + "Democratic" + ], + "provider_ids": {}, + "source": { + "name": "Michigan Department of State \u2014 2026 general election candidate list", + "url": "https://mi-boe.entellitrak.com/etk-mi-boe-prod/page.request.do?electionType=GEN&electionYear=2026&page=page.miboePublicReport", + "retrieved_at": "2026-10-04T00:00:00Z" + } + }, + { + "id": "2026-mi-house-09-general-c02", + "race_id": "2026-mi-house-09-general", + "name": "Lisa McClain", + "affiliation": "republican", + "qualified": true, + "ballot_lines": [ + "Republican" + ], + "provider_ids": {}, + "source": { + "name": "Michigan Department of State \u2014 2026 general election candidate list", + "url": "https://mi-boe.entellitrak.com/etk-mi-boe-prod/page.request.do?electionType=GEN&electionYear=2026&page=page.miboePublicReport", + "retrieved_at": "2026-10-04T00:00:00Z" + } + }, + { + "id": "2026-mi-house-09-general-c03", + "race_id": "2026-mi-house-09-general", + "name": "Kevin Vayko", + "affiliation": "other", + "qualified": true, + "ballot_lines": [ + "Libertarian" + ], + "provider_ids": {}, + "source": { + "name": "Michigan Department of State \u2014 2026 general election candidate list", + "url": "https://mi-boe.entellitrak.com/etk-mi-boe-prod/page.request.do?electionType=GEN&electionYear=2026&page=page.miboePublicReport", + "retrieved_at": "2026-10-04T00:00:00Z" + } + }, + { + "id": "2026-mi-house-09-general-c04", + "race_id": "2026-mi-house-09-general", + "name": "John Vlahos", + "affiliation": "other", + "qualified": true, + "ballot_lines": [ + "U.S. Taxpayers" + ], + "provider_ids": {}, + "source": { + "name": "Michigan Department of State \u2014 2026 general election candidate list", + "url": "https://mi-boe.entellitrak.com/etk-mi-boe-prod/page.request.do?electionType=GEN&electionYear=2026&page=page.miboePublicReport", + "retrieved_at": "2026-10-04T00:00:00Z" + } + }, + { + "id": "2026-mi-house-09-general-c05", + "race_id": "2026-mi-house-09-general", + "name": "Destiny Clayton", + "affiliation": "other", + "qualified": true, + "ballot_lines": [ + "Green" + ], + "provider_ids": {}, + "source": { + "name": "Michigan Department of State \u2014 2026 general election candidate list", + "url": "https://mi-boe.entellitrak.com/etk-mi-boe-prod/page.request.do?electionType=GEN&electionYear=2026&page=page.miboePublicReport", + "retrieved_at": "2026-10-04T00:00:00Z" + } + }, + { + "id": "2026-mi-house-09-general-c06", + "race_id": "2026-mi-house-09-general", + "name": "Jim Walkowicz", + "affiliation": "other", + "qualified": true, + "ballot_lines": [ + "Working Class" + ], + "provider_ids": {}, + "source": { + "name": "Michigan Department of State \u2014 2026 general election candidate list", + "url": "https://mi-boe.entellitrak.com/etk-mi-boe-prod/page.request.do?electionType=GEN&electionYear=2026&page=page.miboePublicReport", + "retrieved_at": "2026-10-04T00:00:00Z" + } + }, + { + "id": "2026-mi-house-10-general-c01", + "race_id": "2026-mi-house-10-general", + "name": "Christina Bertrand Hines", + "affiliation": "democrat", + "qualified": true, + "ballot_lines": [ + "Democratic" + ], + "provider_ids": {}, + "source": { + "name": "Michigan Department of State \u2014 2026 general election candidate list", + "url": "https://mi-boe.entellitrak.com/etk-mi-boe-prod/page.request.do?electionType=GEN&electionYear=2026&page=page.miboePublicReport", + "retrieved_at": "2026-10-04T00:00:00Z" + } + }, + { + "id": "2026-mi-house-10-general-c02", + "race_id": "2026-mi-house-10-general", + "name": "Michael Bouchard", + "affiliation": "republican", + "qualified": true, + "ballot_lines": [ + "Republican" + ], + "provider_ids": {}, + "source": { + "name": "Michigan Department of State \u2014 2026 general election candidate list", + "url": "https://mi-boe.entellitrak.com/etk-mi-boe-prod/page.request.do?electionType=GEN&electionYear=2026&page=page.miboePublicReport", + "retrieved_at": "2026-10-04T00:00:00Z" + } + }, + { + "id": "2026-mi-house-10-general-c03", + "race_id": "2026-mi-house-10-general", + "name": "Mike Saliba", + "affiliation": "other", + "qualified": true, + "ballot_lines": [ + "Libertarian" + ], + "provider_ids": {}, + "source": { + "name": "Michigan Department of State \u2014 2026 general election candidate list", + "url": "https://mi-boe.entellitrak.com/etk-mi-boe-prod/page.request.do?electionType=GEN&electionYear=2026&page=page.miboePublicReport", + "retrieved_at": "2026-10-04T00:00:00Z" + } + }, + { + "id": "2026-mi-house-10-general-c04", + "race_id": "2026-mi-house-10-general", + "name": "Kwabena Nkromo", + "affiliation": "other", + "qualified": true, + "ballot_lines": [ + "Green" + ], + "provider_ids": {}, + "source": { + "name": "Michigan Department of State \u2014 2026 general election candidate list", + "url": "https://mi-boe.entellitrak.com/etk-mi-boe-prod/page.request.do?electionType=GEN&electionYear=2026&page=page.miboePublicReport", + "retrieved_at": "2026-10-04T00:00:00Z" + } + }, + { + "id": "2026-mi-house-10-general-c05", + "race_id": "2026-mi-house-10-general", + "name": "Andrea L. Kirby", + "affiliation": "other", + "qualified": true, + "ballot_lines": [ + "Working Class" + ], + "provider_ids": {}, + "source": { + "name": "Michigan Department of State \u2014 2026 general election candidate list", + "url": "https://mi-boe.entellitrak.com/etk-mi-boe-prod/page.request.do?electionType=GEN&electionYear=2026&page=page.miboePublicReport", + "retrieved_at": "2026-10-04T00:00:00Z" + } + }, + { + "id": "2026-mi-house-11-general-c01", + "race_id": "2026-mi-house-11-general", + "name": "Jeremy Moss", + "affiliation": "democrat", + "qualified": true, + "ballot_lines": [ + "Democratic" + ], + "provider_ids": {}, + "source": { + "name": "Michigan Department of State \u2014 2026 general election candidate list", + "url": "https://mi-boe.entellitrak.com/etk-mi-boe-prod/page.request.do?electionType=GEN&electionYear=2026&page=page.miboePublicReport", + "retrieved_at": "2026-10-04T00:00:00Z" + } + }, + { + "id": "2026-mi-house-11-general-c02", + "race_id": "2026-mi-house-11-general", + "name": "Ethan Baker", + "affiliation": "republican", + "qualified": true, + "ballot_lines": [ + "Republican" + ], + "provider_ids": {}, + "source": { + "name": "Michigan Department of State \u2014 2026 general election candidate list", + "url": "https://mi-boe.entellitrak.com/etk-mi-boe-prod/page.request.do?electionType=GEN&electionYear=2026&page=page.miboePublicReport", + "retrieved_at": "2026-10-04T00:00:00Z" + } + }, + { + "id": "2026-mi-house-11-general-c03", + "race_id": "2026-mi-house-11-general", + "name": "Farid Ishac", + "affiliation": "other", + "qualified": true, + "ballot_lines": [ + "Libertarian" + ], + "provider_ids": {}, + "source": { + "name": "Michigan Department of State \u2014 2026 general election candidate list", + "url": "https://mi-boe.entellitrak.com/etk-mi-boe-prod/page.request.do?electionType=GEN&electionYear=2026&page=page.miboePublicReport", + "retrieved_at": "2026-10-04T00:00:00Z" + } + }, + { + "id": "2026-mi-house-11-general-c04", + "race_id": "2026-mi-house-11-general", + "name": "Ryan Teasdale", + "affiliation": "other", + "qualified": true, + "ballot_lines": [ + "Green" + ], + "provider_ids": {}, + "source": { + "name": "Michigan Department of State \u2014 2026 general election candidate list", + "url": "https://mi-boe.entellitrak.com/etk-mi-boe-prod/page.request.do?electionType=GEN&electionYear=2026&page=page.miboePublicReport", + "retrieved_at": "2026-10-04T00:00:00Z" + } + }, + { + "id": "2026-mi-house-11-general-c05", + "race_id": "2026-mi-house-11-general", + "name": "Anil Kumar", + "affiliation": "other", + "qualified": true, + "ballot_lines": [ + "No Party Affiliation" + ], + "provider_ids": {}, + "source": { + "name": "Michigan Department of State \u2014 2026 general election candidate list", + "url": "https://mi-boe.entellitrak.com/etk-mi-boe-prod/page.request.do?electionType=GEN&electionYear=2026&page=page.miboePublicReport", + "retrieved_at": "2026-10-04T00:00:00Z" + } + }, + { + "id": "2026-mi-house-12-general-c01", + "race_id": "2026-mi-house-12-general", + "name": "Rashida Tlaib", + "affiliation": "democrat", + "qualified": true, + "ballot_lines": [ + "Democratic" + ], + "provider_ids": {}, + "source": { + "name": "Michigan Department of State \u2014 2026 general election candidate list", + "url": "https://mi-boe.entellitrak.com/etk-mi-boe-prod/page.request.do?electionType=GEN&electionYear=2026&page=page.miboePublicReport", + "retrieved_at": "2026-10-04T00:00:00Z" + } + }, + { + "id": "2026-mi-house-12-general-c02", + "race_id": "2026-mi-house-12-general", + "name": "James D. Hooper", + "affiliation": "republican", + "qualified": true, + "ballot_lines": [ + "Republican" + ], + "provider_ids": {}, + "source": { + "name": "Michigan Department of State \u2014 2026 general election candidate list", + "url": "https://mi-boe.entellitrak.com/etk-mi-boe-prod/page.request.do?electionType=GEN&electionYear=2026&page=page.miboePublicReport", + "retrieved_at": "2026-10-04T00:00:00Z" + } + }, + { + "id": "2026-mi-house-12-general-c03", + "race_id": "2026-mi-house-12-general", + "name": "Marc Joseph Sosnowski", + "affiliation": "other", + "qualified": true, + "ballot_lines": [ + "U.S. Taxpayers" + ], + "provider_ids": {}, + "source": { + "name": "Michigan Department of State \u2014 2026 general election candidate list", + "url": "https://mi-boe.entellitrak.com/etk-mi-boe-prod/page.request.do?electionType=GEN&electionYear=2026&page=page.miboePublicReport", + "retrieved_at": "2026-10-04T00:00:00Z" + } + }, + { + "id": "2026-mi-house-12-general-c04", + "race_id": "2026-mi-house-12-general", + "name": "Brenda K. Sanders", + "affiliation": "other", + "qualified": true, + "ballot_lines": [ + "Green" + ], + "provider_ids": {}, + "source": { + "name": "Michigan Department of State \u2014 2026 general election candidate list", + "url": "https://mi-boe.entellitrak.com/etk-mi-boe-prod/page.request.do?electionType=GEN&electionYear=2026&page=page.miboePublicReport", + "retrieved_at": "2026-10-04T00:00:00Z" + } + }, + { + "id": "2026-mi-house-12-general-c05", + "race_id": "2026-mi-house-12-general", + "name": "Gary Walkowicz", + "affiliation": "other", + "qualified": true, + "ballot_lines": [ + "Working Class" + ], + "provider_ids": {}, + "source": { + "name": "Michigan Department of State \u2014 2026 general election candidate list", + "url": "https://mi-boe.entellitrak.com/etk-mi-boe-prod/page.request.do?electionType=GEN&electionYear=2026&page=page.miboePublicReport", + "retrieved_at": "2026-10-04T00:00:00Z" + } + }, + { + "id": "2026-mi-house-13-general-c01", + "race_id": "2026-mi-house-13-general", + "name": "Donavan McKinney", + "affiliation": "democrat", + "qualified": true, + "ballot_lines": [ + "Democratic" + ], + "provider_ids": {}, + "source": { + "name": "Michigan Department of State \u2014 2026 general election candidate list", + "url": "https://mi-boe.entellitrak.com/etk-mi-boe-prod/page.request.do?electionType=GEN&electionYear=2026&page=page.miboePublicReport", + "retrieved_at": "2026-10-04T00:00:00Z" + } + }, + { + "id": "2026-mi-house-13-general-c02", + "race_id": "2026-mi-house-13-general", + "name": "T.P. Nykoriak", + "affiliation": "republican", + "qualified": true, + "ballot_lines": [ + "Republican" + ], + "provider_ids": {}, + "source": { + "name": "Michigan Department of State \u2014 2026 general election candidate list", + "url": "https://mi-boe.entellitrak.com/etk-mi-boe-prod/page.request.do?electionType=GEN&electionYear=2026&page=page.miboePublicReport", + "retrieved_at": "2026-10-04T00:00:00Z" + } + }, + { + "id": "2026-mi-house-13-general-c03", + "race_id": "2026-mi-house-13-general", + "name": "Chris Dardzinski", + "affiliation": "other", + "qualified": true, + "ballot_lines": [ + "U.S. Taxpayers" + ], + "provider_ids": {}, + "source": { + "name": "Michigan Department of State \u2014 2026 general election candidate list", + "url": "https://mi-boe.entellitrak.com/etk-mi-boe-prod/page.request.do?electionType=GEN&electionYear=2026&page=page.miboePublicReport", + "retrieved_at": "2026-10-04T00:00:00Z" + } + }, + { + "id": "2026-mi-house-13-general-c04", + "race_id": "2026-mi-house-13-general", + "name": "Raelyn Light", + "affiliation": "other", + "qualified": true, + "ballot_lines": [ + "Green" + ], + "provider_ids": {}, + "source": { + "name": "Michigan Department of State \u2014 2026 general election candidate list", + "url": "https://mi-boe.entellitrak.com/etk-mi-boe-prod/page.request.do?electionType=GEN&electionYear=2026&page=page.miboePublicReport", + "retrieved_at": "2026-10-04T00:00:00Z" + } + }, + { + "id": "2026-mi-house-13-general-c05", + "race_id": "2026-mi-house-13-general", + "name": "Simone R. Coleman", + "affiliation": "other", + "qualified": true, + "ballot_lines": [ + "Working Class" + ], + "provider_ids": {}, + "source": { + "name": "Michigan Department of State \u2014 2026 general election candidate list", + "url": "https://mi-boe.entellitrak.com/etk-mi-boe-prod/page.request.do?electionType=GEN&electionYear=2026&page=page.miboePublicReport", + "retrieved_at": "2026-10-04T00:00:00Z" + } + } + ], + "notes": "Manually transcribed on 2026-10-04 (date precision; midnight UTC is a storage convention). Disqualified entries excluded. Governor tickets represented by the gubernatorial candidate. Refresh and independently reconcile before draft; other states not included." +} diff --git a/docs/data-contract.md b/docs/data-contract.md new file mode 100644 index 0000000..8d027cd --- /dev/null +++ b/docs/data-contract.md @@ -0,0 +1,63 @@ +# Snapshot and API contract, version 1 + +The backend is C++23 using Ligo HTTP and Noto/SQLite. `fpol_admin` and HTTP routes call the same domain functions. SQLite migration `001_snapshots` stores immutable JSON documents with typed metadata, catalog foreign keys and an import-time validator. This deliberately small persistence model preserves source fields and history; normalized league/draft/trade tables come with multiplayer. + +## Import format + +See the runnable documents in [data/demo](../data/demo). All documents require: + +- `schema_version: 1`, a unique `id`, `kind` (`catalog`, `ratings`, `results`), `mode` (`demo`, `real`), `label`, and `as_of`. +- `source: {name, url, retrieved_at}`. Real sources require an HTTP(S) URL. Only demo sources may use `fixture:` URLs. +- UTC timestamps in `YYYY-MM-DDTHH:MM:SSZ` form. An adapter must normalize provider offsets, never invent observation times. `as_of` is the source observation time, `retrieved_at` the collection time. Older snapshots imported later keep their earlier position. + +A catalog has a `complete` boolean, `races` and `candidates`. Each race needs `id`, `name`, `office` (`house`, `senate`, `governor`), two-letter `state`, and `round`. Each candidate needs `id`, `race_id`, `name`, `affiliation` (`democrat`, `republican`, `other`), `qualified: true`, `ballot_lines`, `provider_ids`, and `source`. IDs describe candidacies, not a person's name. Provider mappings are separate from those IDs. A provider ID may identify only one candidate. Qualification and fusion reconciliation are curator responsibilities; the validator checks structure, identities and explicit qualification, not external truth. + +Ratings have `catalog_id` and `entries: [{race_id, rating}]`, where `rating` is `safe`, `likely`, `lean`, or `toss_up`. Missing entries stay unknown. New rating editions receive new IDs; a preview references exactly one edition. No draft exists yet to enforce a league's pre-draft snapshot selection. + +Results have `catalog_id` and `entries`, each containing: + +```json +{ + "race_id": "demo-house-01", + "round": "general", + "status": "reporting", + "total_votes": 1000, + "winner_id": null, + "votes": [ + {"candidate_id": "demo-house-01-1", "votes": 510}, + {"candidate_id": "demo-house-01-2", "votes": 490}, + {"candidate_id": "demo-house-01-3", "votes": 0}, + {"candidate_id": "demo-house-01-4", "votes": 0} + ] +} +``` + +Statuses: `reporting`, `called`, `certified`, `pending_round`. Only called/certified rows have `winner_id`. `total_votes` may be null; candidate counts are nonnegative integers bounded at one billion, their sum cannot exceed the total. Totals may include nondraftable write-ins; those still belong in the vote-share denominator. An absent candidate row is unknown, not zero. Each document is a complete snapshot of its included race data, not a delta: omitted races do not inherit previous votes. Preserve per-race source timestamps as additional fields when a future adapter combines sources; the current fixtures use one observation time per document. + +Duplicate races, duplicate candidate counts, foreign-race candidates, conflicting modes, unknown references and mismatched rounds are rejected. Combine fusion ballot counts explicitly upstream, retaining original evidence. Missing or pending-round scores are returned as null. Runoff/RCV progression is a launch blocker, not inferred from reporting status. + +An identical repeat import is a no-op; conflicting content under the same ID fails. Corrections get a new ID and observation time, including call retractions. A single document insertion is atomic. Multi-file CLI imports commit each file independently in argument order, so import catalogs before dependent snapshots. Persisted history is never updated by the importer; replay requires no rollback writes. Administrators should run one importer at a time; simultaneous identical imports can produce a uniqueness error that is safe to retry. + +## Scoring and roster semantics + +`(100 × candidate_votes / total_votes + awarded_win_bonus) × race_multiplier`. + +The awarded bonus is 50 only for the explicit called/certified winner; other candidates receive zero. Multipliers are Safe 1.00, Likely 1.10, Lean 1.25, Toss-up 1.50. Integer arithmetic rounds each candidate half up to two decimals before summing. More votes for a candidate with opponents' counts fixed cannot reduce their score. Shares can still fall as opponents receive votes; corrections can lower totals or retract bonuses. + +Every office has one D, R, Other and Flex slot. Flex accepts any affiliation within that office. A candidacy may occupy only one roster slot; opponents may both be rostered. Partial rosters are valid for exploration but have no complete total. Cross-team ownership, draft locks and trade enforcement belong to the multiplayer milestone. + +`leader_preview_points` is hypothetical: only an untied leader with all catalog candidate rows present and all votes accounted for receives a hypothetical bonus, and an explicit call always takes precedence. It is separate from awarded points. A tied or incompletely reported lead has a null preview; `unavailable_leader_previews` counts gaps, and the preview subtotal only includes available previews. Missing scores do not become zero; `known_subtotal` sums only available scores and `missing_scores` reports gaps. `total` exists only when all twelve slots have valid, available scores. A complete total can still be provisional during reporting. + +## HTTP + +All responses use Ligo's `{ok: true, payload: ...}` or `{ok: false, code, message}` envelope. Invalid input is HTTP 400; roster eligibility errors are a successful validation response with `valid: false`. Unknown routes are 404. No import/write/admin route is exposed. + +| Method / path | Request | Payload | +| --- | --- | --- | +| `GET /api/health` | None | `{status: "ok"}` process liveness | +| `GET /api/rules` | None | Version, slots, multipliers, formula and rounding | +| `GET /api/catalog` | None | `{documents: [...]}`, ordered by `as_of`, then ID | +| `POST /api/capacity` | `{catalog_id, players}` | Per-office party and total shortages | +| `POST /api/score-preview` | `{catalog_id, ratings_id, results_id, roster: [{slot, candidate_id}]}` | Validation, breakdown, baseline and leader preview | + +The complete document response is intentionally suitable for this small prototype; pagination/indexed views will be needed as real replay history grows. Exact routes have no query-string normalization; the browser filters locally. Imported data becomes visible on the next request without restarting. The browser loads its catalog snapshot on page load; refresh after imports. No authentication is implemented yet; use local development until the multiplayer access controls exist. diff --git a/docs/data-coverage.md b/docs/data-coverage.md new file mode 100644 index 0000000..541799a --- /dev/null +++ b/docs/data-coverage.md @@ -0,0 +1,45 @@ +# Data feasibility — checked 2026-10-04 + +**Real-league launch is blocked on national candidate coverage, frozen ratings, and verified automatic results/calls.** The prototype is usable with synthetic data. No live provider adapter or polling service is enabled, and no historical scoring backtest is claimed. + +## Scope and evidence + +The [USPS election calendar](https://about.usps.com/postal-bulletin/2026/pb22694/html/cover_004.htm) lists November 3, 2026, with 435 House seats, 35 Senate seats and 36 state governorships. This milestone assumes the 50 states, including safe and uncontested races. Territories, nonvoting delegates and separate special House contests need an explicit scope decision before a national catalog is frozen. The [150-row matrix](../data/coverage_2026.csv) distinguishes offices without a scheduled election from missing or unverified data. It is an office/state checklist, not a verified seat-by-seat national manifest. + +The [Michigan official general-election list](https://mi-boe.entellitrak.com/etk-mi-boe-prod/page.request.do?electionType=GEN&electionYear=2026&page=page.miboePublicReport) was readable. Its 13 House races, Senate race and governor race were manually transcribed into [a 73-candidate snapshot](../data/real/mi-2026-10-04.json). Entries marked DISQ were excluded. Governor tickets use the gubernatorial candidate. Provenance dates have day precision; midnight UTC is a storage convention. The source can change: independently reconcile the transcription, ballot qualification and replacements before a draft. Empty provider mappings mean they have not been matched to any results feed. This catalog is deliberately `complete: false`. + +[FEC information](https://www.fec.gov/introduction-campaign-finance/election-results-and-voting-information/) is useful for retrospective federal results; campaign filings are not proof of general-election ballot qualification. State election offices remain the catalog authority. + +## Provider findings + +| Source | Verified evidence | Still unverified / missing | +| --- | --- | --- | +| [civicAPI documentation](https://civicapi.org/api-documentation) | Advertises free, unauthenticated real-time results and calls; documents a v2 API base | Concrete race endpoint, response schema, 2026 office/state coverage, stable candidate IDs, call provenance, timestamp and correction behavior | +| [Open America API contract](https://openamerica.io/elections/api/) | Documents a free results endpoint, 2024 replay and 22-state coverage | Live payload retrieval, individual office/seat coverage and identity reconciliation; no calls or pre-election candidate catalog | +| State election offices | Michigan catalog retrieved | National catalog and automatic feed coverage | +| Commissioner ratings import | Versioned, sourced snapshots supported | No real pre-draft ratings imported; no dated historical rating dataset verified | + +Open America documents `https://openamerica.io/elections/results.json?year=2026&office=House`; substitute Senate/Governor or year 2024. Its `final` flag concerns precinct reporting, not certification. Candidate names and parties require explicit internal-ID matching. Follow its 60-second polling guidance, conditional requests, gzip, attribution and backoff. State inclusion does not guarantee every office or uncontested race. Outside the documented 22 states, coverage is missing. Calls are missing everywhere. + +Attempts to open provider JSON endpoints through the available research tools returned no usable payload. civicAPI `/api/v2/status`, `/api/v2/races`, and the API root were exploratory guesses, **not verified endpoint contracts**. This is not evidence of a provider outage. Shell networking is restricted here. No provider's transport, election-night reliability or correction behavior has been validated end to end. + +## Capacity findings + +The C++ capacity endpoint checks 2–12 players. For **each office**, twelve teams need at least 12 Democrats, 12 Republicans, 12 Others and 48 distinct candidates overall. The total requirement reserves enough candidates for Flex after the affiliation slots are filled. These conditions are sufficient for these disjoint office/affiliation constraints; they say nothing about data coverage. + +| Imported catalog | House D / R / Other / total | Senate | Governor | +| --- | --- | --- | --- | +| Synthetic | 12 / 12 / 24 / 48 | 12 / 12 / 24 / 48 | 12 / 12 / 24 / 48 | +| Michigan partial | 13 / 13 / 36 / 62 | 1 / 1 / 4 / 6 | 1 / 1 / 3 / 5 | + +The fictional catalog supports every allowed league size. Michigan alone cannot fill two complete rosters because Senate and Governor each have only one D and one R. **Nationwide feasibility is unknown**, not disproven. Obtain the remaining qualified candidates before deciding whether the Other slots work for twelve players. Do not silently relax the roster rules. + +## Gates before multiplayer launch + +1. Reconcile every in-scope seat and qualified candidate, including fusion lines, independents, uncontested seats and replacements. Use one candidacy ID per candidate/race; preserve the original ballot labels and commissioner-assigned roster affiliation. Never draft aggregate write-in buckets. +2. Capture actual provider responses and retain their URL, retrieval time and payload. Confirm candidate-level IDs or reviewed mappings, vote denominators, timestamps, rounds, calls/retractions and corrections. A party leader or 100% precincts is never a call. +3. Obtain sourced, dated ratings for every race; freeze the selected immutable snapshot before drafting. Unrated races block that gate; they do not default to Safe. +4. Verify real capacity for the selected player count, then rehearse automatic polling and replay with outage/staleness handling. Free automatic coverage is a launch requirement. Resolve unsupported states and calls before promising a complete election-night experience. +5. Settle runoff and ranked-choice scoring policy. The current importer rejects a different round for the same catalog race and `pending_round` remains unscored. A later round must not be added to an earlier count. Do not launch affected races until provider semantics and final-round handling are specified and tested. + +Commissioner JSON imports make the foundation reviewable; they are not the planned election-night automation. If free sources cannot meet these gates, bring that concrete limitation back for a product decision rather than introducing a paid dependency or silently narrowing the game. diff --git a/docs/verification.md b/docs/verification.md new file mode 100644 index 0000000..ec49040 --- /dev/null +++ b/docs/verification.md @@ -0,0 +1,39 @@ +# Local verification — 2026-10-04 + +## Passed + +- Both `fpol_server` and `fpol_admin` compile in Debug and Release using the available **GCC 16.2.1** host compiler and C++23. +- 25 core tests pass against the production admin executable in both configurations. These cover Noto migrations, reopening SQLite, import idempotency, immutable IDs, corrections, chronological replay, mode isolation, roster eligibility, rounding, capacity, missing data, ambiguous leader previews and round mismatches. +- The real Michigan snapshot imports successfully; candidate counts match the coverage report. Demo and real data are seeded in the ignored local `var/fpol.sqlite`. +- Strict TypeScript checking and the Vite production build pass. +- Shared Modulus formatting and `git diff --check` pass. The bootstrap is unchanged from the canonical shared file. +- Server help and invalid-startup tests pass without sockets. + +## Environment limits + +The full Debug CTest run executes all 25 core tests successfully, then runs eight server tests. Two server tests pass; six cannot execute their socket-dependent paths because the sandbox rejects socket creation with `Operation not permitted`. The suite is deliberately still failing here, not skipped or reported green. HTTP success/error envelopes, real request transport, static delivery and shutdown need the normal development/CI environment. A browser interaction/visual check also remains outstanding. + +Docker's socket is inaccessible in this environment, and GCC 12 is not installed on the host. Consequently, the configured Modulus GCC 12/container workflow could not be verified here. The fallback builds local Modulus/Noto sources into ignored directories under this repository and uses already-cached Boost/OpenSSL/SQLite plus the existing local Ligo build. Release application compilation maps cached dependency configurations to Debug; this is an optimized application check, not a fully optimized dependency build or performance measurement. No shared library sources or dependency caches were modified. + +The committed Gitea workflow still runs the intended GCC 12 debug/release builds and the full HTTP suite. Run it, or the README's normal Modulus checks, before merging. + +## Reproduce these local fallback checks + +These paths are local artifacts, not portable prerequisites: + +```sh +cmake --build build/kickoff_debug +cmake --build build/kickoff_release +python3 tests/test_core.py build/kickoff_debug/fpol_admin +python3 tests/test_core.py build/kickoff_release/fpol_admin +ctest --test-dir build/kickoff_debug --output-on-failure +npm --prefix frontend run build +``` + +The local database and frontend assets are already prepared. Outside the socket-restricted sandbox, the locally built prototype can be started from this repo root: + +```sh +build/kickoff_debug/fpol_server --web-root frontend/dist +``` + +Open `http://127.0.0.1:8080`. Provider JSON transport and automatic election-night coverage are separate outstanding checks listed in [the data report](data-coverage.md). diff --git a/frontend/index.html b/frontend/index.html index b024f48..8f5e67a 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -16,26 +16,8 @@ The 2026 edition -
-
-

Friends. Friendly competition. The midterms.

-

Your picks.
Your party.
Bragging rights.

-

A fantasy-style game for our own little political league. Build a party of candidates, follow the 2026 US midterms, and see whose picks come out on top.

-

The league is taking shape. More soon.

-
- -
-
-

The idea

- On the drawing board -
-
    -
  1. Draft your party

    Pick your candidates when the league gets together for the draft.

  2. -
  3. Make your moves

    Trade with friends as the election season unfolds.

  4. -
  5. Watch it play out

    Real-world candidate performance will decide the final standings.

  6. -
-
-
+

Loading the field…

+
A game among friends.2026 US midterm elections
diff --git a/frontend/src/main.ts b/frontend/src/main.ts index aa3357b..6e08962 100644 --- a/frontend/src/main.ts +++ b/frontend/src/main.ts @@ -1 +1,172 @@ import './style.css'; + +type Source = { name: string; url: string; retrieved_at: string }; +type Race = { id: string; office: string; state: string; name: string; round: string }; +type Candidate = { id: string; race_id: string; name: string; affiliation: string; ballot_lines: string[]; source: Source }; +type Document = { id: string; kind: string; mode: 'demo' | 'real'; label: string; as_of: string; source: Source; catalog_id?: string }; +type Catalog = Document & { complete: boolean; races: Race[]; candidates: Candidate[] }; +type Ratings = Document & { entries: { race_id: string; rating: string }[] }; +type Slot = { id: string; office: string; affiliation: string }; +type Rules = { slots: Slot[]; formula: string; rounding: string; multipliers: Record }; +type Capacity = { offices: { office: string; requirements: { group: string; available: number; required: number; shortfall: number }[] }[]; sufficient_candidates: boolean; catalog_complete: boolean }; +type Score = { slot: string; candidate_id: string; points: number | null; reason?: string; vote_share?: number; win_bonus?: number; multiplier?: number; baseline_points?: number; leader_preview_points?: number | null; status?: string }; +type Preview = { valid: boolean; complete_roster: boolean; errors: string[]; scores: Score[]; missing_scores?: number; unavailable_leader_previews?: number; known_subtotal?: number; baseline_subtotal?: number; leader_preview_subtotal?: number; total: number | null }; + +const root = document.querySelector('#app')!; +let documents: Document[] = []; +let rules: Rules; +let catalog: Catalog; +let ratingId = ''; +let roster: Record = {}; +let step = 0; +let playing: ReturnType | undefined; +let previewVersion = 0; +let capacityVersion = 0; +const labels: Record = { house: 'House', senate: 'Senate', governor: 'Governor', democrat: 'Democrat', republican: 'Republican', other: 'Other', flex: 'Flex', safe: 'Safe', likely: 'Likely', lean: 'Lean', toss_up: 'Toss-up', total: 'All candidates' }; +const escape = (value: unknown) => String(value).replace(/[&<>"']/g, character => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[character]!); +const label = (value: string) => labels[value] ?? value; +const money = (value: number | null | undefined) => value == null ? '—' : value.toFixed(2); +const element = (id: string) => document.getElementById(id) as T; +const ratings = () => documents.filter(document => document.kind === 'ratings' && document.catalog_id === catalog.id) as Ratings[]; +const results = () => documents.filter(document => document.kind === 'results' && document.catalog_id === catalog.id); +const selectedRatings = () => ratings().find(rating => rating.id === ratingId); +const raceFor = (candidate: Candidate) => catalog.races.find(race => race.id === candidate.race_id)!; +const fits = (candidate: Candidate, slot: Slot) => raceFor(candidate).office === slot.office && (slot.affiliation === 'flex' || candidate.affiliation === slot.affiliation); +const options = (items: { id: string; name: string }[], selected = '') => items.map(item => ``).join(''); +function sourceLink(source: Source): string { + if (!/^https?:\/\//.test(source.url)) return 'Fictional example'; + return `Source ↗ ${escape(source.retrieved_at.slice(0, 10))}`; +} +async function api(path: string, body?: unknown): Promise { + const response = await fetch(`/api/${path}`, body === undefined ? {} : { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); + const envelope = await response.json(); + if (!response.ok || !envelope.ok) throw new Error(envelope.message ?? `Request failed (${response.status})`); + return envelope.payload as T; +} +function stopReplay() { + clearInterval(playing); + playing = undefined; + const button = document.getElementById('play'); + if (button) button.textContent = 'Play replay'; +} +function render() { + stopReplay(); + ++previewVersion; + ++capacityVersion; + const snapshots = results(); + root.innerHTML = ` +

The draft starts with a little homework.

Build your party.
Make every race count.

Try your 12 picks, explore the field, and replay a fictional election night. A game among friends, taking shape for 2026.

+

${catalog.mode === 'demo' ? 'SYNTHETIC DEMO' : catalog.complete ? 'REAL CATALOG' : 'PARTIAL REAL CATALOG'} ${catalog.mode === 'demo' ? 'Every candidate, rating, vote and call in this dataset is fictional.' : catalog.complete ? 'Candidate coverage is marked complete by its curator. Ratings and live coverage still need verification before a draft.' : 'Candidate coverage is incomplete. Verify the remaining candidates, real ratings and live results before a draft.'}

Catalog dated ${escape(catalog.as_of)} · ${sourceLink(catalog.source)}

+ +

01 / Scout your picks

The field

+
${['office', 'affiliation', 'state', 'rating'].map(filter => ``).join('')}
+
CandidateRaceParty / ballot linesFrozen ratingProvenance
+
Can everyone fill a roster?

Each office needs one D, one R, one Other and one Flex per player. Counts include all race ratings.

+
+

02 / Twelve picks. Three offices.

Your party

${catalog.mode === 'demo' ? '' : ''}

One Democrat, one Republican, one Other and one Flex in each office. Other includes independent and third-party candidates. Flex accepts any party in that office. These picks stay in this page only.

${['house', 'senate', 'governor'].map(office => `
${label(office)}${rules.slots.filter(slot => slot.office === office).map(slot => ``).join('')}
`).join('')}

+

03 / Follow the swings

Election night, on replay.

${catalog.mode === 'demo' ? 'Fictional results' : 'No verified live feed'}

${escape(rules.formula)}

${Object.entries(rules.multipliers).map(([rating, factor]) => `${label(rating)} ×${factor.toFixed(2)}`).join('')}

${escape(rules.rounding)} Ratings are selected as a single snapshot here; a real league will freeze one before the draft. A lead is not a win. Corrections can reverse awarded points.

`; + element('dataset').onchange = event => { + catalog = documents.find(document => document.id === (event.target as HTMLSelectElement).value) as Catalog; + roster = {}; + ratingId = ratings()[0]?.id ?? ''; + step = 0; + render(); + }; + for (const id of ['search', 'filter-office', 'filter-affiliation', 'filter-state', 'filter-rating']) element(id).oninput = renderCandidates; + element('players').onchange = () => void renderCapacity(); + element('clear').onclick = () => { roster = {}; renderRoster(); void renderPreview(); }; + const fill = document.getElementById('fill'); + if (fill) fill.onclick = () => { + roster = {}; + for (const slot of rules.slots) { + const eligible = catalog.candidates.filter(candidate => fits(candidate, slot) && !Object.values(roster).includes(candidate.id)); + const preferredRatings: Record = { democrat: 'toss_up', republican: 'safe', other: 'likely', flex: 'lean' }; + const preferredRating = preferredRatings[slot.affiliation]; + const match = eligible.find(candidate => selectedRatings()?.entries.some(entry => entry.race_id === candidate.race_id && entry.rating === preferredRating)) ?? eligible[0]; + if (match) roster[slot.id] = match.id; + } + renderRoster(); + void renderPreview(); + }; + element('rating-snapshot').onchange = event => { ratingId = (event.target as HTMLSelectElement).value; renderCandidates(); void renderPreview(); }; + element('timeline').oninput = event => { stopReplay(); step = Number((event.target as HTMLInputElement).value); void renderPreview(); }; + element('play').onclick = () => { + if (playing) { stopReplay(); return; } + if (step === snapshots.length - 1) step = 0; + element('play').textContent = 'Pause replay'; + void renderPreview(); + playing = setInterval(() => { + step += 1; + void renderPreview(); + if (step === snapshots.length - 1) stopReplay(); + }, 1800); + }; + renderCandidates(); + renderRoster(); + void renderCapacity(); + void renderPreview(); +} +function renderCandidates() { + const search = element('search').value.toLowerCase(); + const filters = ['office', 'affiliation', 'state', 'rating'].map(filter => element(`filter-${filter}`).value); + const ratingEntries = selectedRatings()?.entries ?? []; + const candidates = catalog.candidates.filter(candidate => { + const race = raceFor(candidate); + const rating = ratingEntries.find(entry => entry.race_id === race.id)?.rating ?? 'unrated'; + return `${candidate.name} ${race.name}`.toLowerCase().includes(search) && [race.office, candidate.affiliation, race.state, rating].every((value, index) => !filters[index] || filters[index] === value); + }); + element('candidate-count').textContent = `${candidates.length} of ${catalog.candidates.length} candidates`; + element('candidates').innerHTML = candidates.map(candidate => { + const race = raceFor(candidate); + const rating = ratingEntries.find(entry => entry.race_id === race.id)?.rating; + return `${escape(candidate.name)}${escape(race.name)}${escape(race.state)} · ${escape(label(race.office))}${escape(label(candidate.affiliation))}${escape(candidate.ballot_lines.join(' / '))}${rating ? `${escape(label(rating))} ×${rules.multipliers[rating].toFixed(2)}` : 'Missing rating'}${sourceLink(candidate.source)}`; + }).join('') || 'No candidates match these filters.'; +} +function renderRoster() { + for (const slot of rules.slots) { + const select = root.querySelector(`[data-slot="${slot.id}"]`)!; + select.innerHTML = '' + catalog.candidates.filter(candidate => fits(candidate, slot)).map(candidate => ``).join(''); + select.onchange = () => { roster[slot.id] = select.value; renderRoster(); void renderPreview(); }; + } + const filled = Object.values(roster).filter(Boolean).length; + element('roster-status').textContent = `${filled} / 12 slots filled${filled === 12 ? ' · Ready to simulate.' : ' · Fill each slot to calculate a complete total.'}`; +} +async function renderCapacity() { + const version = ++capacityVersion; + try { + const result = await api('capacity', { catalog_id: catalog.id, players: Number(element('players').value) }); + if (version !== capacityVersion) return; + element('capacity').innerHTML = `

${result.sufficient_candidates ? 'Enough candidates in this catalog.' : 'Not enough candidates in this catalog.'} ${!result.catalog_complete ? 'This is a partial catalog; these counts do not establish nationwide feasibility.' : catalog.mode === 'demo' ? 'Synthetic capacity only; this does not establish real-world feasibility.' : 'Ratings and results coverage still require separate verification.'}

${result.offices.flatMap(office => office.requirements.map(group => ``)).join('')}
OfficeGroupAvailableNeededShortfall
${label(office.office)}${label(group.group)}${group.available}${group.required}${group.shortfall}
`; + } catch (error) { if (version === capacityVersion) element('capacity').textContent = String(error); } +} +async function renderPreview() { + const version = ++previewVersion; + const snapshot = results()[step]; + element('timeline').value = String(step); + element('snapshot-label').textContent = snapshot ? `${snapshot.label} · ${snapshot.as_of}` : 'No result snapshots imported'; + element('snapshot-source').innerHTML = snapshot ? `Results: ${sourceLink(snapshot.source)}${selectedRatings() ? ` · Ratings: ${sourceLink(selectedRatings()!.source)}` : ''}` : ''; + if (!snapshot || !ratingId) { element('preview').innerHTML = '

Scoring is unavailable until both ratings and results are imported for this catalog. Missing data is not treated as zero.

'; return; } + element('preview').innerHTML = '

Calculating…

'; + try { + const preview = await api('score-preview', { catalog_id: catalog.id, ratings_id: ratingId, results_id: snapshot.id, roster: Object.entries(roster).filter(([, id]) => id).map(([slot, candidate_id]) => ({ slot, candidate_id })) }); + if (version !== previewVersion) return; + if (!preview.valid) { element('preview').innerHTML = `

${preview.errors.map(escape).join('
')}

`; return; } + const max = Math.max(preview.known_subtotal ?? 0, preview.baseline_subtotal ?? 0, 1); + element('preview').innerHTML = `
Complete roster total${money(preview.total)}${preview.total === null ? 'Needs 12 picks with available scores' : 'Counted shares + awarded bonuses'}
Known subtotal${money(preview.known_subtotal)}${preview.missing_scores ?? 0} selected scores unavailable
If current leaders win${money(preview.leader_preview_subtotal)}${preview.unavailable_leader_previews ?? 0} previews unavailable · not awarded points
No multipliers ${money(preview.baseline_subtotal)}
Race-weighted ${money(preview.known_subtotal)}
${preview.scores.map(score => ``).join('') || ''}
PickVote shareWin bonusMultiplierPointsIf leader wins
${escape(catalog.candidates.find(candidate => candidate.id === score.candidate_id)?.name)}${escape(score.slot.split(':').map(label).join(' / '))} · ${escape(score.reason ?? score.status)}${score.vote_share === undefined ? '—' : `${score.vote_share.toFixed(2)}%`}${score.win_bonus ?? '—'}${score.multiplier === undefined ? '—' : `×${score.multiplier.toFixed(2)}`}${money(score.points)}${money(score.leader_preview_points)}
Choose candidates above to see their points.
`; + } catch (error) { if (version === previewVersion) element('preview').textContent = String(error); } +} +async function start() { + try { + const [ruleData, catalogData] = await Promise.all([api('rules'), api<{ documents: Document[] }>('catalog')]); + rules = ruleData; + documents = catalogData.documents; + catalog = documents.find(document => document.kind === 'catalog' && document.mode === 'demo') as Catalog ?? documents.find(document => document.kind === 'catalog') as Catalog; + if (!catalog) { root.innerHTML = '

The field is empty.

Import a catalog with the local admin tool to start exploring. Setup instructions are in the repository README.

'; return; } + ratingId = ratings()[0]?.id ?? ''; + render(); + } catch (error) { + root.innerHTML = `

Unable to load the league.

${escape(error)}

Check that the C++ backend is running, then try again.

`; + element('retry').onclick = () => void start(); + } +} +void start(); diff --git a/frontend/src/style.css b/frontend/src/style.css index 6e85a94..a2860fd 100644 --- a/frontend/src/style.css +++ b/frontend/src/style.css @@ -1,58 +1,73 @@ -:root { - font-family: 'Avenir Next', Avenir, 'Segoe UI', sans-serif; - color: #213632; - background: #f6f4ee; - font-synthesis: none; - text-rendering: optimizeLegibility; - -webkit-font-smoothing: antialiased; - --muted: #596760; - --line: #d6dbd2; -} - +:root { font-family: 'Avenir Next', Avenir, 'Segoe UI', sans-serif; color: #213632; background: #f6f4ee; font-synthesis: none; text-rendering: optimizeLegibility; -webkit-font-smoothing: antialiased; --muted: #596760; --line: #d6dbd2; } * { box-sizing: border-box; } body { margin: 0; } -a { color: inherit; } -a:focus-visible { outline: 3px solid #9a5a21; outline-offset: 6px; } -.site-header, main, footer { width: min(1120px, calc(100% - 80px)); margin-inline: auto; } -.site-header { min-height: 112px; display: flex; align-items: center; justify-content: space-between; gap: 24px; border-bottom: 1px solid var(--line); } -.wordmark { display: inline-flex; align-items: center; gap: 14px; text-decoration: none; font-size: 17px; font-weight: 650; letter-spacing: -0.03em; } +a { color: inherit; text-underline-offset: 3px; } +:focus-visible { outline: 3px solid #98602c; outline-offset: 3px; } +.site-header, main, footer { width: min(1200px, calc(100% - 64px)); margin-inline: auto; } +.site-header { min-height: 94px; display: flex; align-items: center; justify-content: space-between; gap: 24px; border-bottom: 1px solid var(--line); } +.wordmark { display: inline-flex; align-items: center; gap: 14px; text-decoration: none; font-size: 17px; font-weight: 650; letter-spacing: -.03em; } .monogram { display: grid; place-items: center; width: 43px; height: 43px; border: 1px solid #213632; border-radius: 50%; font-family: Georgia, serif; font-size: 25px; font-weight: 400; padding-bottom: 4px; } -.edition { font-size: 12px; color: var(--muted); text-transform: uppercase; letter-spacing: 0.13em; } -.intro { padding: 75px 0 70px; } -.eyebrow { margin: 0 0 28px; color: var(--muted); font-size: 11px; font-weight: 650; text-transform: uppercase; letter-spacing: 0.12em; } -.eyebrow span { color: #a36324; margin-right: 9px; font-size: 16px; } -h1 { margin: 0; font-family: Georgia, 'Times New Roman', serif; font-size: clamp(48px, 6.5vw, 82px); font-weight: 400; line-height: 1.04; letter-spacing: -0.055em; } +.edition, .eyebrow { font-size: 11px; color: var(--muted); text-transform: uppercase; letter-spacing: .13em; } +.intro { padding: 56px 0 36px; } +.eyebrow { margin: 0 0 14px; font-weight: 650; } +h1 { margin: 0; font-family: Georgia, serif; font-size: clamp(38px, 5.5vw, 70px); font-weight: 400; line-height: 1.08; letter-spacing: -.05em; } h1 em { font-weight: 400; color: #67765e; } -.description { max-width: 520px; margin: 30px 0 26px; font-size: 17px; line-height: 1.75; color: var(--muted); } -.status { display: inline-flex; align-items: center; gap: 10px; margin: 0; padding: 12px 16px; background: #e9ece3; border-radius: 3px; font-size: 12px; line-height: 1.6; } -.status-dot { flex-shrink: 0; width: 6px; height: 6px; border-radius: 50%; background: #67765e; } -.overview { padding-top: 25px; border-top: 1px solid var(--line); } -.overview-heading { display: flex; justify-content: space-between; align-items: baseline; gap: 20px; } -.overview-heading h2 { font-size: 12px; text-transform: uppercase; letter-spacing: 0.14em; margin: 0; } -.overview-heading > span { font-size: 12px; color: var(--muted); } -.steps { display: grid; grid-template-columns: repeat(3, 1fr); gap: 42px; margin: 30px 0 62px; padding: 0; list-style: none; } -.step-number { color: #7c8973; font-family: Georgia, serif; font-size: 18px; } -.steps h3 { margin: 16px 0 8px; font-size: 17px; font-weight: 600; letter-spacing: -0.02em; } -.steps p { max-width: 285px; margin: 0; font-size: 14px; line-height: 1.7; color: var(--muted); } -footer { display: flex; justify-content: space-between; gap: 20px; padding: 24px 0 30px; border-top: 1px solid var(--line); font-size: 11px; color: var(--muted); } - -@media (min-width: 1000px) { - .intro { position: relative; } - .intro::after { content: '26'; position: absolute; top: 78px; right: 30px; font-family: Georgia, serif; font-size: 285px; line-height: 1; letter-spacing: -0.09em; color: #e3e7dc; z-index: -1; } -} - -@media (max-width: 640px) { - .site-header, main, footer { width: calc(100% - 40px); } - .site-header { min-height: 88px; } - .wordmark { font-size: 15px; gap: 10px; } - .edition { max-width: 85px; text-align: right; font-size: 10px; line-height: 1.6; } - .intro { padding: 48px 0; } - .eyebrow { max-width: 290px; line-height: 1.8; } - .description { font-size: 16px; } - .steps { grid-template-columns: 1fr; gap: 28px; margin-bottom: 42px; } - .steps li { display: grid; grid-template-columns: 28px 1fr; gap: 0 15px; } - .step-number { grid-row: span 2; padding-top: 1px; } - .steps h3 { margin-top: 0; } - .steps p { max-width: none; } - footer { flex-wrap: wrap; gap: 8px 20px; } -} +.description { max-width: 600px; margin: 22px 0 0; font-size: 17px; line-height: 1.7; color: var(--muted); } +h2 { font: 400 32px Georgia, serif; margin: 0; letter-spacing: -.02em; } +p { line-height: 1.65; } +.small, .muted, small { font-size: 12px; color: var(--muted); } +.workspace { padding: 24px; background: #e9ece3; border: 1px solid var(--line); } +.workspace > label { max-width: 510px; } +label { display: flex; flex-direction: column; gap: 7px; font-size: 12px; font-weight: 600; } +select, input { font: inherit; font-size: 14px; background: #fffefa; border: 1px solid #b3bdb0; border-radius: 4px; color: #213632; padding: 10px 12px; min-width: 0; max-width: 100%; min-height: 42px; } +select { width: 100%; } +button { font: inherit; font-size: 13px; font-weight: 600; color: #fffefa; background: #294d41; padding: 11px 17px; border: 1px solid #294d41; border-radius: 4px; cursor: pointer; min-height: 42px; } +button:hover { background: #3f6150; } +button.secondary { background: transparent; color: #294d41; } +button:disabled { opacity: .5; cursor: default; } +.notice { border-left: 3px solid #8d784f; padding: 10px 14px; background: #f6f1e4; font-size: 13px; } +.notice.real { border-color: #9b5d2d; } +.notice strong { margin-right: 6px; } +.section-nav { display: flex; flex-wrap: wrap; gap: 30px; padding: 25px 0; border-bottom: 1px solid var(--line); font-size: 13px; } +.section-nav a { text-decoration: none; } +.panel { padding: 38px 0; border-bottom: 1px solid var(--line); scroll-margin-top: 20px; } +.section-heading { display: flex; justify-content: space-between; align-items: center; gap: 20px; margin-bottom: 24px; } +.filters { display: grid; grid-template-columns: 2fr repeat(4, 1fr); gap: 12px; margin-bottom: 20px; } +.table-scroll { overflow: auto; max-height: 440px; border: 1px solid var(--line); } +table { width: 100%; border-collapse: collapse; font-size: 13px; text-align: left; } +th { font-size: 11px; text-transform: uppercase; letter-spacing: .06em; color: var(--muted); background: #e9ece3; position: sticky; top: 0; z-index: 1; white-space: nowrap; } +th, td { padding: 14px 16px; border-bottom: 1px solid var(--line); } +tbody tr:last-child td { border: 0; } +td strong { font-weight: 600; } +.cell-note { display: block; font-size: 11px; color: var(--muted); margin-top: 5px; } +.tag { display: inline-block; border: 1px solid var(--line); padding: 4px 7px; font-size: 11px; white-space: nowrap; border-radius: 3px; background: #eeefe7; } +details { margin-top: 22px; } +summary { cursor: pointer; font-size: 14px; padding: 8px 0; } +.capacity-controls { display: flex; align-items: end; gap: 22px; margin-top: 15px; } +.capacity-controls label { min-width: 145px; } +.warning { color: #914518; font-weight: 600; } +.actions { display: flex; gap: 10px; } +.roster-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 20px; margin-top: 25px; } +fieldset { margin: 0; padding: 22px; border: 1px solid var(--line); background: #fdfcf7; min-width: 0; } +legend { font: 23px Georgia, serif; padding: 0 8px; } +fieldset label + label { margin-top: 15px; } +#roster-status { font-size: 13px; } +.formula { font: 23px Georgia, serif; line-height: 1.6; } +.rating-key { display: flex; gap: 24px; flex-wrap: wrap; font-size: 13px; } +.rating-key strong { margin-left: 6px; } +.replay-controls { display: grid; grid-template-columns: 1fr 1.5fr auto; align-items: start; gap: 24px; margin-top: 25px; } +.replay-controls button { margin-top: 21px; } +input[type=range] { width: 100%; accent-color: #294d41; padding: 0; border: 0; background: transparent; } +.metrics { display: grid; grid-template-columns: repeat(3, 1fr); gap: 1px; background: var(--line); border: 1px solid var(--line); margin: 25px 0; } +.metrics > div { background: #e9ece3; padding: 22px; display: flex; flex-direction: column; gap: 10px; } +.metrics span { font-size: 12px; } +.metrics strong { font: 42px Georgia, serif; font-variant-numeric: tabular-nums; } +.metrics small { line-height: 1.5; } +.comparison { display: grid; grid-template-columns: 1fr 1fr; gap: 24px; margin: 20px 0; font-size: 12px; } +.comparison i { display: block; height: 8px; min-width: 2px; background: #8a987b; margin-top: 10px; transition: width .3s; } +.comparison > div:last-child i { background: #294d41; } +footer { display: flex; justify-content: space-between; gap: 20px; padding: 24px 0 30px; font-size: 11px; color: var(--muted); } +.loading { padding: 60px 0; } +@media (max-width: 800px) { .filters { grid-template-columns: 1fr 1fr; } .search-label { grid-column: 1 / -1; } .roster-grid { grid-template-columns: 1fr; } .replay-controls { grid-template-columns: 1fr; gap: 16px; } .replay-controls button { margin: 0; } .section-heading { align-items: start; flex-direction: column; } .metrics { grid-template-columns: 1fr; } } +@media (max-width: 500px) { .site-header, main, footer { width: calc(100% - 32px); } .edition { max-width: 90px; text-align: right; line-height: 1.6; } .wordmark { gap: 9px; font-size: 14px; } .workspace { padding: 16px; } .section-nav { gap: 15px; font-size: 12px; } .capacity-controls { align-items: stretch; flex-direction: column; gap: 0; } footer { flex-direction: column; gap: 8px; } } +@media (prefers-reduced-motion: reduce) { .comparison i { transition: none; } } diff --git a/src/admin.cpp b/src/admin.cpp new file mode 100644 index 0000000..e346302 --- /dev/null +++ b/src/admin.cpp @@ -0,0 +1,65 @@ +#include "store.hpp" + +#include +#include +#include +#include + + +namespace { + +auto execute(fpol::store &store, std::string const &action, std::vector const &files) -> boost::asio::awaitable { + co_await store.initialize(); + if (action == "import") { + for (auto const &file : files) { + auto result = co_await store.import_document(fpol::read_json(file)); + std::cout << boost::json::serialize(result) << '\n'; + } + } else { + auto request = files.empty() ? boost::json::value{boost::json::object{}} : fpol::read_json(files.front()); + auto result = co_await store.handle(action, std::move(request)); + std::cout << boost::json::serialize(result) << '\n'; + } +} + +} + +auto main(int argc, char **argv) -> int { + if (argc == 2 && std::string_view{argv[1]} == "--help") { + std::cout << "Usage: fpol_admin [--database PATH] import FILE...\n" + " fpol_admin [--database PATH] rules|catalog\n" + " fpol_admin [--database PATH] capacity|score-preview REQUEST.json\n" + "Default database: var/fpol.sqlite. Imports are atomic per file, immutable and idempotent.\n"; + return 0; + } + try { + auto index = 1; + auto path = std::filesystem::path{"var/fpol.sqlite"}; + if (index < argc && std::string_view{argv[index]} == "--database") { + if (++index == argc) { + throw std::invalid_argument("Missing database path"); + } + path = argv[index++]; + } + if (index == argc) { + throw std::invalid_argument("Missing command; use --help"); + } + auto action = std::string{argv[index++]}; + auto files = std::vector{}; + while (index < argc) { + files.emplace_back(argv[index++]); + } + if (!((action == "import" && !files.empty()) || ((action == "rules" || action == "catalog") && files.empty()) || ((action == "capacity" || action == "score-preview") && files.size() == 1))) { + throw std::invalid_argument("Invalid command or argument count; use --help"); + } + auto io = boost::asio::io_context{}; + auto store = fpol::store{io.get_executor(), path}; + auto future = boost::asio::co_spawn(io, execute(store, action, files), boost::asio::use_future); + io.run(); + future.get(); + return 0; + } catch (std::exception const &error) { + std::cerr << "Import/request failed: " << error.what() << '\n'; + return 1; + } +} diff --git a/src/domain.cpp b/src/domain.cpp new file mode 100644 index 0000000..c286811 --- /dev/null +++ b/src/domain.cpp @@ -0,0 +1,323 @@ +#include "domain.hpp" + +#include +#include +#include +#include +#include +#include +#include + + +namespace { + +auto const offices = std::array{"house", "senate", "governor"}; +auto const affiliations = std::array{"democrat", "republican", "other", "flex"}; +auto const multipliers = std::map{{"safe", 100}, {"likely", 110}, {"lean", 125}, {"toss_up", 150}}; + +auto require(bool condition, std::string const &message) -> void { + if (!condition) { + throw std::invalid_argument(message); + } +} + +auto array(boost::json::value const &value, std::string const &key) -> boost::json::array const & { + require(value.is_object() && value.as_object().contains(key) && value.at(key).is_array(), key + " must be an array"); + return value.at(key).as_array(); +} + +auto timestamp(std::string const &value) -> void { + require(std::regex_match(value, std::regex{"[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z"}), "Timestamp must use UTC YYYY-MM-DDTHH:MM:SSZ"); + auto day = std::chrono::year_month_day{std::chrono::year{std::stoi(value.substr(0, 4))}, std::chrono::month{static_cast(std::stoi(value.substr(5, 2)))}, std::chrono::day{static_cast(std::stoi(value.substr(8, 2)))}}; + require(day.ok() && std::stoi(value.substr(11, 2)) < 24 && std::stoi(value.substr(14, 2)) < 60 && std::stoi(value.substr(17, 2)) < 60, "Invalid timestamp"); +} + +auto provenance(boost::json::value const &value, std::string const &mode) -> void { + auto url = fpol::text(value, "url"); + require(url.starts_with("https://") || url.starts_with("http://") || (mode == "demo" && url.starts_with("fixture:")), "Source must be an HTTP(S) URL; fixture: is only allowed for demo data"); + fpol::text(value, "name"); + timestamp(fpol::text(value, "retrieved_at")); +} + +auto header(boost::json::value const &document) -> void { + require(fpol::integer(document, "schema_version") == 1, "Unsupported schema_version"); + auto id = fpol::text(document, "id"); + require(std::regex_match(id, std::regex{"[a-zA-Z0-9_.:-]{1,120}"}), "Invalid document ID"); + auto mode = fpol::text(document, "mode"); + require(mode == "demo" || mode == "real", "mode must be demo or real"); + fpol::text(document, "label"); + timestamp(fpol::text(document, "as_of")); + provenance(document.at("source"), mode); +} + +auto cents(std::int64_t votes, std::int64_t total, bool winner, std::int64_t multiplier) -> std::int64_t { + // Exact half-up rounding, once per candidate. Bounds on totals keep this in int64. + auto numerator = (votes * 100 + (winner ? total * 50 : 0)) * multiplier; + return (numerator + total / 2) / total; +} + +auto money(std::int64_t value) -> double { + return static_cast(value) / 100.0; +} + +} + +auto fpol::text(boost::json::value const &value, std::string const &key) -> std::string { + require(value.is_object() && value.as_object().contains(key) && value.at(key).is_string(), key + " must be a string"); + auto result = std::string{value.at(key).as_string()}; + require(!result.empty(), key + " must not be empty"); + return result; +} + +auto fpol::integer(boost::json::value const &value, std::string const &key) -> std::int64_t { + require(value.is_object() && value.as_object().contains(key) && value.at(key).is_int64(), key + " must be an integer"); + return value.at(key).as_int64(); +} + +auto fpol::read_catalog(boost::json::value const &document) -> fpol::catalog { + header(document); + require(text(document, "kind") == "catalog", "Expected catalog document"); + require(document.at("complete").is_bool(), "complete must be a boolean"); + auto result = fpol::catalog{text(document, "id"), text(document, "mode"), document.at("complete").as_bool(), {}, {}}; + auto const states = std::set{"AL", "AK", "AZ", "AR", "CA", "CO", "CT", "DE", "FL", "GA", "HI", "ID", "IL", "IN", "IA", "KS", "KY", "LA", "ME", "MD", "MA", "MI", "MN", "MS", "MO", "MT", "NE", "NV", "NH", "NJ", "NM", "NY", "NC", "ND", "OH", "OK", "OR", "PA", "RI", "SC", "SD", "TN", "TX", "UT", "VT", "VA", "WA", "WV", "WI", "WY"}; + for (auto const &item : array(document, "races")) { + auto race = fpol::race{text(item, "id"), text(item, "office"), text(item, "state"), text(item, "round")}; + text(item, "name"); + require(std::ranges::find(offices, race.office) != offices.end(), "Unknown office: " + race.office); + require(states.contains(race.state), "Unknown state: " + race.state); + require(result.races.emplace(race.id, race).second, "Duplicate race ID: " + race.id); + } + for (auto const &item : array(document, "candidates")) { + auto candidate = fpol::candidate{text(item, "id"), text(item, "race_id"), text(item, "affiliation")}; + text(item, "name"); + require(candidate.affiliation == "democrat" || candidate.affiliation == "republican" || candidate.affiliation == "other", "Unknown candidate affiliation"); + require(result.races.contains(candidate.race_id), "Unknown candidate race: " + candidate.race_id); + require(item.at("qualified").is_bool() && item.at("qualified").as_bool(), "Only individually qualified ballot candidates can be imported"); + require(!array(item, "ballot_lines").empty(), "ballot_lines must identify original party labels"); + for (auto const &line : item.at("ballot_lines").as_array()) { + require(line.is_string() && !line.as_string().empty(), "Invalid ballot line"); + } + provenance(item.at("source"), result.mode); + require(item.at("provider_ids").is_object(), "provider_ids must be an object"); + for (auto const &mapping : item.at("provider_ids").as_object()) { + require(mapping.value().is_string() && !mapping.value().as_string().empty(), "Provider IDs must be nonempty strings"); + } + require(result.candidates.emplace(candidate.id, candidate).second, "Duplicate candidate ID (merge fusion ballot lines): " + candidate.id); + } + // A provider's identifier cannot map to two different internal candidates. + auto mappings = std::set>{}; + for (auto const &item : array(document, "candidates")) { + for (auto const &mapping : item.at("provider_ids").as_object()) { + require(mappings.emplace(std::string{mapping.key()}, std::string{mapping.value().as_string()}).second, "Duplicate provider candidate mapping"); + } + } + return result; +} + +auto fpol::validate_document(boost::json::value const &document, fpol::catalog const *catalog) -> void { + header(document); + auto kind = text(document, "kind"); + if (kind == "catalog") { + read_catalog(document); + return; + } + require(kind == "ratings" || kind == "results", "kind must be catalog, ratings, or results"); + require(catalog != nullptr, "Import the referenced catalog first"); + require(text(document, "catalog_id") == catalog->id && text(document, "mode") == catalog->mode, "Snapshot and catalog identity/mode mismatch"); + auto races = std::set{}; + for (auto const &entry : array(document, "entries")) { + auto race_id = text(entry, "race_id"); + require(catalog->races.contains(race_id), "Unknown race: " + race_id); + require(races.insert(race_id).second, "Duplicate race entry: " + race_id); + if (kind == "ratings") { + require(multipliers.contains(text(entry, "rating")), "Unknown rating"); + continue; + } + require(text(entry, "round") == catalog->races.at(race_id).round, "Result round differs from the catalog; never combine rounds"); + auto status = text(entry, "status"); + require(status == "reporting" || status == "called" || status == "certified" || status == "pending_round", "Unknown result status"); + require(entry.at("total_votes").is_null() || (integer(entry, "total_votes") >= 0 && integer(entry, "total_votes") <= 1000000000), "Invalid total_votes (maximum 1 billion)"); + auto total = entry.at("total_votes").is_null() ? 0 : integer(entry, "total_votes"); + auto sum = std::int64_t{}; + auto candidates = std::set{}; + for (auto const &vote : array(entry, "votes")) { + auto id = text(vote, "candidate_id"); + require(catalog->candidates.contains(id) && catalog->candidates.at(id).race_id == race_id, "Vote candidate does not belong to race: " + id); + require(candidates.insert(id).second, "Duplicate candidate vote row; consolidate fusion lines first"); + auto count = integer(vote, "votes"); + require(count >= 0 && count <= 1000000000, "Invalid vote count"); + sum += count; + } + require(sum <= total, "Candidate votes exceed the race total"); + require(entry.at("winner_id").is_null() || entry.at("winner_id").is_string(), "winner_id must be a candidate ID or null"); + auto has_winner = !entry.at("winner_id").is_null(); + require(has_winner == (status == "called" || status == "certified"), "Only called/certified races must have a winner; reporting/pending_round must not"); + if (has_winner) { + auto winner = text(entry, "winner_id"); + require(catalog->candidates.contains(winner) && catalog->candidates.at(winner).race_id == race_id, "Winner does not belong to race"); + } + } +} + +auto fpol::rules() -> boost::json::value { + auto slots = boost::json::array{}; + for (auto const &office : offices) { + for (auto const &affiliation : affiliations) { + slots.emplace_back(boost::json::object{{"id", office + ":" + affiliation}, {"office", office}, {"affiliation", affiliation}}); + } + } + return boost::json::object{{"version", "v1"}, {"min_players", 2}, {"max_players", 12}, {"slots", slots}, {"win_bonus", 50}, {"multipliers", boost::json::object{{"safe", 1.0}, {"likely", 1.1}, {"lean", 1.25}, {"toss_up", 1.5}}}, {"formula", "(vote share percentage + awarded win bonus) × frozen race multiplier"}, {"rounding", "Round each candidate to 2 decimals, half up, then sum. Missing scores are unknown, not zero."}}; +} + +auto fpol::capacity(fpol::catalog const &catalog, std::int64_t players) -> boost::json::value { + require(players >= 2 && players <= 12, "players must be between 2 and 12"); + auto rows = boost::json::array{}; + auto sufficient = true; + for (auto const &office : offices) { + auto counts = std::map{{"democrat", 0}, {"republican", 0}, {"other", 0}, {"total", 0}}; + for (auto const &[id, candidate] : catalog.candidates) { + if (catalog.races.at(candidate.race_id).office == office) { + ++counts.at(candidate.affiliation); + ++counts.at("total"); + } + } + auto requirements = boost::json::array{}; + for (auto const &[group, count] : counts) { + auto needed = players * (group == "total" ? 4 : 1); + sufficient = sufficient && count >= needed; + requirements.emplace_back(boost::json::object{{"group", group}, {"available", count}, {"required", needed}, {"shortfall", std::max(0, needed - count)}}); + } + rows.emplace_back(boost::json::object{{"office", office}, {"requirements", requirements}}); + } + return boost::json::object{{"catalog_id", catalog.id}, {"players", players}, {"offices", rows}, {"sufficient_candidates", sufficient}, {"catalog_complete", catalog.complete}, {"candidate_supply_ready", sufficient && catalog.complete && catalog.mode == "real"}, {"note", "Capacity only. Complete, verified ratings and results coverage are separate launch gates."}}; +} + +auto fpol::score_preview(fpol::catalog const &catalog, boost::json::value const &ratings, boost::json::value const &results, boost::json::value const &request) -> boost::json::value { + validate_document(ratings, &catalog); + validate_document(results, &catalog); + require(text(ratings, "kind") == "ratings" && text(results, "kind") == "results", "Wrong snapshot kinds"); + auto selected = std::map{}; + auto taken = std::set{}; + auto errors = boost::json::array{}; + auto valid_slots = std::set{}; + for (auto const &office : offices) { + for (auto const &group : affiliations) { + valid_slots.insert(office + ":" + group); + } + } + for (auto const &selection : array(request, "roster")) { + auto slot = text(selection, "slot"); + auto id = text(selection, "candidate_id"); + if (!valid_slots.contains(slot) || !catalog.candidates.contains(id)) { + errors.emplace_back("Unknown slot or candidate: " + slot + " / " + id); + continue; + } + auto const &candidate = catalog.candidates.at(id); + auto const &race = catalog.races.at(candidate.race_id); + if (slot != race.office + ":flex" && slot != race.office + ":" + candidate.affiliation) { + errors.emplace_back("Candidate does not qualify for slot: " + slot); + } + if (!selected.emplace(slot, id).second || !taken.insert(id).second) { + errors.emplace_back("Duplicate slot or candidate: " + slot + " / " + id); + } + } + auto output = boost::json::object{{"catalog_id", catalog.id}, {"ratings_id", text(ratings, "id")}, {"results_id", text(results, "id")}, {"as_of", text(results, "as_of")}, {"valid", errors.empty()}, {"complete_roster", selected.size() == 12 && errors.empty()}, {"errors", errors}}; + if (!errors.empty()) { + output["scores"] = boost::json::array{}; + output["total"] = nullptr; + return output; + } + auto ratings_by_race = std::map{}; + for (auto const &rating : array(ratings, "entries")) { + ratings_by_race.emplace(text(rating, "race_id"), multipliers.at(text(rating, "rating"))); + } + auto results_by_race = std::map{}; + for (auto const &result : array(results, "entries")) { + results_by_race.emplace(text(result, "race_id"), &result); + } + auto scores = boost::json::array{}; + auto total_cents = std::int64_t{}; + auto baseline_cents = std::int64_t{}; + auto preview_cents = std::int64_t{}; + auto missing = std::size_t{}; + auto unavailable_previews = std::size_t{}; + for (auto const &[slot, id] : selected) { + auto const &candidate = catalog.candidates.at(id); + auto row = boost::json::object{{"slot", slot}, {"candidate_id", id}, {"points", nullptr}}; + auto reason = std::string{}; + if (!ratings_by_race.contains(candidate.race_id)) { + reason = "Missing frozen rating"; + } else if (!results_by_race.contains(candidate.race_id)) { + reason = "No result snapshot for race"; + } else { + auto const &result = *results_by_race.at(candidate.race_id); + auto total = result.at("total_votes").is_null() ? 0 : integer(result, "total_votes"); + auto votes = std::map{}; + for (auto const &vote : array(result, "votes")) { + votes.emplace(text(vote, "candidate_id"), integer(vote, "votes")); + } + if (text(result, "status") == "pending_round") { + reason = "Pending runoff or ranked-choice resolution"; + } else if (total == 0 || !votes.contains(id)) { + reason = "Vote share unavailable"; + } else { + auto maximum = std::int64_t{-1}; + auto leader = std::string{}; + auto tied = false; + auto all_candidates_present = true; + auto accounted_votes = std::int64_t{}; + for (auto const &[candidate_id, catalog_candidate] : catalog.candidates) { + if (catalog_candidate.race_id == candidate.race_id && !votes.contains(candidate_id)) { + all_candidates_present = false; + } + } + for (auto const &[candidate_id, count] : votes) { + accounted_votes += count; + if (count > maximum) { + maximum = count; + leader = candidate_id; + tied = false; + } else if (count == maximum) { + tied = true; + } + } + auto called = !result.at("winner_id").is_null(); + auto won = called && text(result, "winner_id") == id; + auto preview_available = called || (!tied && all_candidates_present && accounted_votes == total); + auto multiplier = ratings_by_race.at(candidate.race_id); + auto actual = cents(votes.at(id), total, won, multiplier); + auto baseline = cents(votes.at(id), total, won, 100); + auto preview = cents(votes.at(id), total, won || (!called && !tied && all_candidates_present && leader == id), multiplier); + total_cents += actual; + baseline_cents += baseline; + if (preview_available) { + preview_cents += preview; + } else { + ++unavailable_previews; + } + row["vote_share"] = static_cast(votes.at(id)) * 100.0 / static_cast(total); + row["multiplier"] = money(multiplier); + row["win_bonus"] = won ? 50 : 0; + row["points"] = money(actual); + row["baseline_points"] = money(baseline); + row["leader_preview_points"] = preview_available ? boost::json::value{money(preview)} : boost::json::value{}; + row["status"] = text(result, "status"); + row["leader_preview_available"] = preview_available; + } + } + if (!reason.empty()) { + ++missing; + ++unavailable_previews; + row["reason"] = reason; + } + scores.emplace_back(row); + } + output["scores"] = scores; + output["missing_scores"] = missing; + output["known_subtotal"] = money(total_cents); + output["baseline_subtotal"] = money(baseline_cents); + output["leader_preview_subtotal"] = money(preview_cents); + output["unavailable_leader_previews"] = unavailable_previews; + output["total"] = missing == 0 && selected.size() == 12 ? boost::json::value{money(total_cents)} : boost::json::value{}; + return output; +} diff --git a/src/domain.hpp b/src/domain.hpp new file mode 100644 index 0000000..5b23c6c --- /dev/null +++ b/src/domain.hpp @@ -0,0 +1,41 @@ +#pragma once + +#include +#include +#include +#include +#include + + +namespace fpol { + +struct race { + std::string id; + std::string office; + std::string state; + std::string round; +}; + +struct candidate { + std::string id; + std::string race_id; + std::string affiliation; +}; + +struct catalog { + std::string id; + std::string mode; + bool complete{}; + std::map races; + std::map candidates; +}; + +auto text(boost::json::value const &value, std::string const &key) -> std::string; +auto integer(boost::json::value const &value, std::string const &key) -> std::int64_t; +auto read_catalog(boost::json::value const &document) -> fpol::catalog; +auto validate_document(boost::json::value const &document, fpol::catalog const *catalog = nullptr) -> void; +auto rules() -> boost::json::value; +auto capacity(fpol::catalog const &catalog, std::int64_t players) -> boost::json::value; +auto score_preview(fpol::catalog const &catalog, boost::json::value const &ratings, boost::json::value const &results, boost::json::value const &request) -> boost::json::value; + +} diff --git a/src/main.cpp b/src/main.cpp index 706c4e4..6d23c26 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -16,7 +16,7 @@ auto parse_options(int argc, char **argv) -> fpol::server_options { auto options = fpol::server_options{}; for (auto index = 1; index < argc; ++index) { auto name = std::string_view{argv[index]}; - if (name != "--host" && name != "--port" && name != "--web-root") { + if (name != "--host" && name != "--port" && name != "--web-root" && name != "--database") { throw std::invalid_argument("Unknown option: " + std::string{name}); } if (++index == argc || std::string_view{argv[index]}.empty()) { @@ -25,6 +25,8 @@ auto parse_options(int argc, char **argv) -> fpol::server_options { auto value = std::string_view{argv[index]}; if (name == "--host") { options.host = value; + } else if (name == "--database") { + options.database = value; } else if (name == "--web-root") { options.web_root = value; } else { @@ -43,7 +45,7 @@ auto parse_options(int argc, char **argv) -> fpol::server_options { auto main(int argc, char **argv) -> int { if (argc == 2 && std::string_view{argv[1]} == "--help") { - std::cout << "Usage: fpol_server [--host ADDRESS] [--port PORT] [--web-root DIRECTORY]\n" + std::cout << "Usage: fpol_server [--host ADDRESS] [--port PORT] [--web-root DIRECTORY] [--database PATH]\n" "Defaults: 127.0.0.1:8080, API only. Port 0 selects an available port.\n"; return 0; } diff --git a/src/server.cpp b/src/server.cpp index 7ea4675..cd45c68 100644 --- a/src/server.cpp +++ b/src/server.cpp @@ -1,4 +1,5 @@ #include "server.hpp" +#include "store.hpp" #include #include @@ -14,9 +15,11 @@ #include #include #include +#include #include #include +#include #include @@ -114,6 +117,23 @@ auto fpol::run(fpol::server_options const &options) -> int { if (!options.web_root.empty()) { add_web_routes(router, options.web_root); } + auto store = fpol::store{io.get_executor(), options.database}; + auto initialized = boost::asio::co_spawn(io, store.initialize(), boost::asio::use_future); + io.run(); + initialized.get(); + io.restart(); + for (auto const &action : {"rules", "catalog", "capacity", "score-preview"}) { + auto method = std::string_view{action} == "rules" || std::string_view{action} == "catalog" ? "GET" : "POST"; + router.add_route(method, std::string{"/api/"} + action, [&store, action = std::string{action}](ligo::request_context, boost::json::value input) -> boost::asio::awaitable> { + try { + co_return co_await store.handle(action, std::move(input)); + } catch (std::invalid_argument const &error) { + co_return std::unexpected(ligo::detail::make_error(ligo::detail::error_invalid_argument, error.what())); + } catch (std::out_of_range const &error) { + co_return std::unexpected(ligo::detail::make_error(ligo::detail::error_invalid_argument, error.what())); + } + }); + } auto http_options = ligo::http::server_options{}; http_options.on_error = [](modulus::error error) { modulus::log_warn(error.describe()); }; auto server = ligo::http::server{io, {.host = options.host, .port = options.port}, std::move(router), std::move(http_options)}; diff --git a/src/server.hpp b/src/server.hpp index f3ac206..4877bd9 100644 --- a/src/server.hpp +++ b/src/server.hpp @@ -11,6 +11,7 @@ struct server_options { std::string host{"127.0.0.1"}; std::uint16_t port{8080}; std::filesystem::path web_root{}; + std::filesystem::path database{"var/fpol.sqlite"}; }; auto run(fpol::server_options const &options) -> int; diff --git a/src/store.cpp b/src/store.cpp new file mode 100644 index 0000000..6d7e8a8 --- /dev/null +++ b/src/store.cpp @@ -0,0 +1,122 @@ +#include "store.hpp" +#include "domain.hpp" + +#include +#include +#include + + +namespace { + +auto database_path(std::filesystem::path const &path) -> std::string { + if (path.has_parent_path()) { + std::filesystem::create_directories(path.parent_path()); + } + return path.string(); +} + +template +auto checked(std::expected result) -> VALUE { + if (!result) { + throw std::runtime_error(result.error().describe()); + } + if constexpr (!std::is_void_v) { + return std::move(*result); + } +} + +auto find(boost::json::array const &documents, std::string const &id, std::string const &kind) -> boost::json::value const & { + for (auto const &document : documents) { + if (fpol::text(document, "id") == id && fpol::text(document, "kind") == kind) { + return document; + } + } + throw std::invalid_argument("Unknown " + kind + " snapshot: " + id); +} + +auto create_schema(noto::connection &connection) -> boost::asio::awaitable> { + auto result = co_await connection.async_execute("CREATE TABLE documents (id TEXT PRIMARY KEY, kind TEXT NOT NULL CHECK(kind IN ('catalog','ratings','results')), " + "catalog_id TEXT REFERENCES documents(id), as_of TEXT NOT NULL, body TEXT NOT NULL)"); + if (!result) { + co_return std::unexpected(result.error()); + } + co_return std::expected{}; +} + +} + +fpol::store::store(boost::asio::any_io_executor executor, std::filesystem::path const &path) + : connection_{executor, noto::file_connection_config(database_path(path))} {} + +auto fpol::store::initialize() -> boost::asio::awaitable { + checked(co_await connection_.async_connect()); + checked(co_await connection_.async_set_foreign_keys(true)); + checked(co_await connection_.async_set_busy_timeout(std::chrono::milliseconds{5000})); + auto migrator = noto::migrator{connection_}; + auto migrations = std::vector{{"001_snapshots", "Immutable catalog, rating and result snapshots", "001-v1", create_schema, {}}}; + checked(co_await migrator.async_migrate(std::move(migrations))); +} + +auto fpol::store::documents() -> boost::asio::awaitable { + auto result = checked(co_await connection_.async_execute("SELECT body FROM documents ORDER BY as_of, id")); + auto documents = boost::json::array{}; + for (auto const &row : result.rows) { + documents.emplace_back(boost::json::parse(std::get(row.at(0).value))); + } + co_return documents; +} + +auto fpol::store::import_document(boost::json::value document) -> boost::asio::awaitable { + auto all = co_await documents(); + auto kind = fpol::text(document, "kind"); + auto id = fpol::text(document, "id"); + auto catalog = fpol::catalog{}; + if (kind != "catalog") { + catalog = fpol::read_catalog(find(all, fpol::text(document, "catalog_id"), "catalog")); + } + fpol::validate_document(document, kind == "catalog" ? nullptr : &catalog); + for (auto const &existing : all) { + if (fpol::text(existing, "id") == id) { + if (existing != document) { + throw std::invalid_argument("Snapshot IDs are immutable; use a new ID for a correction: " + id); + } + co_return boost::json::object{{"id", id}, {"imported", false}}; + } + } + // A single parameterized INSERT is atomic. There are no update/delete import paths. + auto parameters = std::vector{id, kind, + kind == "catalog" ? noto::sqlite_value{nullptr} : noto::sqlite_value{catalog.id}, + fpol::text(document, "as_of"), boost::json::serialize(document)}; + checked(co_await connection_.async_execute("INSERT INTO documents(id,kind,catalog_id,as_of,body) VALUES (?,?,?,?,?)", std::move(parameters))); + co_return boost::json::object{{"id", id}, {"imported", true}}; +} + +auto fpol::store::handle(std::string action, boost::json::value request) -> boost::asio::awaitable { + if (action == "rules") { + co_return fpol::rules(); + } + auto all = co_await documents(); + if (action == "catalog") { + co_return boost::json::object{{"documents", all}}; + } + auto catalog = fpol::read_catalog(find(all, fpol::text(request, "catalog_id"), "catalog")); + if (action == "capacity") { + co_return fpol::capacity(catalog, fpol::integer(request, "players")); + } + if (action == "score-preview") { + co_return fpol::score_preview(catalog, find(all, fpol::text(request, "ratings_id"), "ratings"), find(all, fpol::text(request, "results_id"), "results"), request); + } + throw std::invalid_argument("Unknown action: " + action); +} + +auto fpol::read_json(std::filesystem::path const &path) -> boost::json::value { + auto stream = std::ifstream{path, std::ios::binary}; + if (!stream) { + throw std::invalid_argument("Cannot read JSON file: " + path.string()); + } + auto contents = std::string{std::istreambuf_iterator{stream}, std::istreambuf_iterator{}}; + if (stream.bad()) { + throw std::runtime_error("Failed reading JSON file: " + path.string()); + } + return boost::json::parse(contents); +} diff --git a/src/store.hpp b/src/store.hpp new file mode 100644 index 0000000..e41a2d4 --- /dev/null +++ b/src/store.hpp @@ -0,0 +1,24 @@ +#pragma once + +#include +#include +#include +#include + + +namespace fpol { + +class store { +public: + store(boost::asio::any_io_executor executor, std::filesystem::path const &path); + auto initialize() -> boost::asio::awaitable; + auto import_document(boost::json::value document) -> boost::asio::awaitable; + auto handle(std::string action, boost::json::value request) -> boost::asio::awaitable; +private: + auto documents() -> boost::asio::awaitable; + noto::connection connection_; +}; + +auto read_json(std::filesystem::path const &path) -> boost::json::value; + +} diff --git a/tests/test_all.py b/tests/test_all.py new file mode 100644 index 0000000..ac6a3c4 --- /dev/null +++ b/tests/test_all.py @@ -0,0 +1,11 @@ +"""One suite entry point for the shared Modulus/Conan check target.""" +import pathlib +import subprocess +import sys + +ROOT = pathlib.Path(__file__).resolve().parent +status = 0 +for script, executable in (("test_core.py", sys.argv[1]), ("test_server.py", sys.argv[2])): + result = subprocess.run([sys.executable, str(ROOT / script), executable], check=False) + status = status or result.returncode +sys.exit(status) diff --git a/tests/test_core.py b/tests/test_core.py new file mode 100644 index 0000000..9843cd5 --- /dev/null +++ b/tests/test_core.py @@ -0,0 +1,241 @@ +"""Exercise the production C++ domain and Noto persistence without HTTP sockets.""" +import copy +import contextlib +import json +import pathlib +import sqlite3 +import subprocess +import sys +import tempfile +import unittest + +ADMIN = pathlib.Path(sys.argv.pop(1)).resolve() +DEMO = pathlib.Path(__file__).resolve().parents[1] / "data" / "demo" + +class core_tests(unittest.TestCase): + def setUp(self): + self.temporary = tempfile.TemporaryDirectory(prefix="fpol_core_") + self.addCleanup(self.temporary.cleanup) + self.root = pathlib.Path(self.temporary.name) + self.database = self.root / "league.sqlite" + self.counter = 0 + self.catalog = json.loads((DEMO / "catalog.json").read_text()) + self.ratings = json.loads((DEMO / "ratings.json").read_text()) + self.results = json.loads((DEMO / "results-3.json").read_text()) + self.request = json.loads((DEMO / "preview.json").read_text()) + self.run_admin("import", DEMO / "catalog.json", DEMO / "ratings.json", *sorted(DEMO.glob("results-*.json"))) + def run_admin(self, *arguments, succeeds=True): + result = subprocess.run([str(ADMIN), "--database", str(self.database), *map(str, arguments)], capture_output=True, text=True, timeout=10) + if succeeds: + self.assertEqual(result.returncode, 0, result.stderr) + return [json.loads(line) for line in result.stdout.splitlines()][-1] + self.assertNotEqual(result.returncode, 0, result.stdout) + return result.stderr + def file(self, value): + self.counter += 1 + path = self.root / f"input-{self.counter}.json" + path.write_text(json.dumps(value)) + return path + def preview(self, request=None): + return self.run_admin("score-preview", self.file(request or self.request)) + def scenario(self, votes, rating="toss_up", winner=None, status="reporting", total=None): + race_id = "demo-house-01" + ratings = copy.deepcopy(self.ratings) + ratings["id"] = f"ratings-{self.counter}" + ratings["entries"] = [{"race_id": race_id, "rating": rating}] + results = copy.deepcopy(self.results) + results["id"] = f"results-{self.counter}" + results["entries"] = [{"race_id": race_id, "round": "general", "status": status, + "total_votes": sum(votes) if total is None else total, + "winner_id": f"{race_id}-{winner}" if winner else None, + "votes": [{"candidate_id": f"{race_id}-{index + 1}", "votes": count} for index, count in enumerate(votes)]}] + self.run_admin("import", self.file(ratings), self.file(results)) + request = {"catalog_id": self.catalog["id"], "ratings_id": ratings["id"], "results_id": results["id"], + "roster": [{"slot": "house:democrat", "candidate_id": f"{race_id}-1"}]} + return self.preview(request)["scores"][0] + def test_rules_and_twelve_slot_roster(self): + rules = self.run_admin("rules") + self.assertEqual(len(rules["slots"]), 12) + result = self.preview() + self.assertTrue(result["complete_roster"]) + self.assertEqual(len(result["scores"]), 12) + self.assertEqual(result["missing_scores"], 0) + # Each office: 151.50 toss-up winner + 15 safe loser + 7.70 + 1.88. + self.assertEqual(result["total"], 528.24) + self.assertEqual(result["baseline_subtotal"], 373.5) + def test_capacity_all_player_counts(self): + for players in range(2, 13): + result = self.run_admin("capacity", self.file({"catalog_id": self.catalog["id"], "players": players})) + self.assertTrue(result["sufficient_candidates"]) + self.assertFalse(result["candidate_supply_ready"]) + for players in (0, 1, 13, 2.5, "12"): + self.run_admin("capacity", self.file({"catalog_id": self.catalog["id"], "players": players}), succeeds=False) + def test_party_and_flex_capacity_are_both_required(self): + catalog = copy.deepcopy(self.catalog) + catalog["id"] = "short-catalog" + # Exactly 12 D, 12 R, 12 Other per office: every party passes, Flex cannot. + catalog["candidates"] = [candidate for candidate in catalog["candidates"] if not candidate["id"].endswith("-4")] + self.run_admin("import", self.file(catalog)) + result = self.run_admin("capacity", self.file({"catalog_id": catalog["id"], "players": 12})) + self.assertFalse(result["sufficient_candidates"]) + for office in result["offices"]: + for requirement in office["requirements"]: + self.assertEqual(requirement["shortfall"], 12 if requirement["group"] == "total" else 0) + def test_safe_tossup_winner_and_loser_examples(self): + self.assertEqual(self.scenario([800, 200, 0, 0], "safe", 1, "called")["points"], 130) + self.assertEqual(self.scenario([510, 490, 0, 0], winner=1, status="called")["points"], 151.5) + self.assertEqual(self.scenario([490, 510, 0, 0], winner=2, status="called")["points"], 73.5) + def test_lead_is_only_hypothetical_until_called(self): + row = self.scenario([510, 490, 0, 0]) + self.assertEqual(row["points"], 76.5) + self.assertEqual(row["win_bonus"], 0) + self.assertEqual(row["leader_preview_points"], 151.5) + row = self.scenario([500, 500, 0, 0]) + self.assertIsNone(row["leader_preview_points"]) + self.assertFalse(row["leader_preview_available"]) + def test_rounding_is_per_candidate_half_up(self): + row = self.scenario([1, 31, 0, 0], "safe") + self.assertEqual(row["vote_share"], 3.125) + self.assertEqual(row["points"], 3.13) + row = self.scenario([1, 2, 0, 0]) + self.assertEqual(row["points"], 50) + def test_unaccounted_votes_do_not_create_a_leader_preview(self): + row = self.scenario([400, 100, 0, 0], total=1000) + self.assertEqual(row["points"], 60) + self.assertIsNone(row["leader_preview_points"]) + self.assertFalse(row["leader_preview_available"]) + def test_older_snapshot_import_keeps_observation_order(self): + self.results["id"] = "late-import-of-old-results" + self.results["as_of"] = "2026-11-04T01:30:00Z" + self.run_admin("import", self.file(self.results)) + snapshots = [document["id"] for document in self.run_admin("catalog")["documents"] if document["kind"] == "results"] + self.assertEqual(snapshots, ["demo-results-0", "demo-results-1", self.results["id"], "demo-results-2", "demo-results-3", "demo-results-4"]) + def test_more_own_votes_never_reduce_points(self): + previous = -1 + for votes in (1, 2, 49, 50, 51, 100, 999, 999999000): + row = self.scenario([votes, 1000, 0, 0]) + self.assertGreaterEqual(row["points"], previous) + previous = row["points"] + def test_missing_votes_and_ratings_are_unknown(self): + self.request["results_id"] = "demo-results-0" + result = self.preview() + self.assertIsNone(result["total"]) + self.assertEqual(result["missing_scores"], 12) + self.assertTrue(all(row["points"] is None for row in result["scores"])) + self.ratings["id"] = "no-ratings" + self.ratings["entries"] = [] + self.run_admin("import", self.file(self.ratings)) + self.request["ratings_id"] = "no-ratings" + self.assertEqual(self.preview()["missing_scores"], 12) + def test_missing_candidate_votes_not_assumed_zero(self): + self.results["id"] = "missing-candidate" + self.results["entries"][3]["votes"] = self.results["entries"][3]["votes"][1:] + self.run_admin("import", self.file(self.results)) + self.request["results_id"] = self.results["id"] + self.assertEqual(self.preview()["missing_scores"], 1) + def test_partial_roster_has_no_complete_total(self): + self.request["roster"] = self.request["roster"][:1] + result = self.preview() + self.assertTrue(result["valid"]) + self.assertFalse(result["complete_roster"]) + self.assertIsNone(result["total"]) + self.assertEqual(result["known_subtotal"], 151.5) + def test_duplicates_and_ineligible_slots_rejected(self): + invalid = [ + [self.request["roster"][0], self.request["roster"][0]], + [{"slot": "house:flex", "candidate_id": "demo-house-04-1"}, self.request["roster"][0]], + [{"slot": "senate:democrat", "candidate_id": "demo-house-04-1"}], + [{"slot": "house:republican", "candidate_id": "demo-house-04-1"}], + [{"slot": "house:other", "candidate_id": "unknown"}], + ] + for roster in invalid: + request = dict(self.request, roster=roster) + result = self.preview(request) + self.assertFalse(result["valid"]) + self.assertIsNone(result["total"]) + def test_opposing_candidates_allowed_but_each_candidate_only_once(self): + self.request["roster"] = [{"slot": "house:democrat", "candidate_id": "demo-house-04-1"}, {"slot": "house:republican", "candidate_id": "demo-house-04-2"}] + self.assertTrue(self.preview()["valid"]) + def test_corrections_are_reversible_and_history_survives_reopen(self): + original = self.preview() + self.request["results_id"] = "demo-results-4" + corrected = self.preview() + self.assertLess(corrected["total"], original["total"]) + self.request["results_id"] = "demo-results-3" + self.assertEqual(self.preview(), original) + with contextlib.closing(sqlite3.connect(self.database)) as database: + self.assertEqual(database.execute("SELECT COUNT(*) FROM documents").fetchone()[0], 7) + self.assertEqual(database.execute("SELECT COUNT(*) FROM _noto_schema_migrations").fetchone()[0], 1) + def test_idempotency_and_frozen_ratings(self): + self.assertFalse(self.run_admin("import", DEMO / "ratings.json")["imported"]) + self.ratings["entries"][0]["rating"] = "toss_up" + self.assertIn("immutable", self.run_admin("import", self.file(self.ratings), succeeds=False)) + # Object-key order does not change document identity. + reordered = dict(reversed(list(self.catalog.items()))) + self.assertFalse(self.run_admin("import", self.file(reordered))["imported"]) + def test_rounds_never_combined_and_pending_round_unscored(self): + self.results["id"] = "bad-round" + self.results["entries"][0]["round"] = "runoff" + self.assertIn("round", self.run_admin("import", self.file(self.results), succeeds=False)) + row = self.scenario([600, 400, 0, 0], status="pending_round") + self.assertIsNone(row["points"]) + self.assertIn("resolution", row["reason"]) + def test_invalid_result_imports_leave_no_partial_document(self): + entries = [ + {"total_votes": -1}, {"total_votes": 1000000001}, {"total_votes": 1}, + {"total_votes": None}, {"status": "final"}, {"winner_id": "demo-senate-01-1"}, + {"winner_id": None}, {"status": "reporting"}, + {"votes": [{"candidate_id": "demo-house-01-1", "votes": -1}]}, + {"votes": [{"candidate_id": "demo-house-01-1", "votes": 1.5}]}, + {"votes": [{"candidate_id": "unknown", "votes": 1}]}, + ] + for changes in entries: + result = copy.deepcopy(self.results) + result["id"] = f"bad-result-{self.counter}" + result["entries"][0].update(changes) + self.run_admin("import", self.file(result), succeeds=False) + self.assertEqual(len(self.run_admin("catalog")["documents"]), 7) + def test_duplicate_races_votes_and_unknown_ratings_rejected(self): + self.results["id"] = "duplicate-race" + self.results["entries"].append(self.results["entries"][0]) + self.run_admin("import", self.file(self.results), succeeds=False) + self.results["entries"].pop() + self.results["entries"][0]["votes"].append(self.results["entries"][0]["votes"][0]) + self.assertIn("Duplicate", self.run_admin("import", self.file(self.results), succeeds=False)) + self.ratings["id"] = "bad-rating" + self.ratings["entries"][0]["rating"] = "certain" + self.run_admin("import", self.file(self.ratings), succeeds=False) + def test_fusion_lines_do_not_duplicate_candidates(self): + self.catalog["id"] = "fusion" + self.catalog["candidates"][0]["ballot_lines"].append("Another ballot party") + self.run_admin("import", self.file(self.catalog)) + capacity = self.run_admin("capacity", self.file({"catalog_id": "fusion", "players": 12})) + self.assertEqual(capacity["offices"][0]["requirements"][0]["available"], 12) + self.catalog["id"] = "duplicate-candidate" + self.catalog["candidates"].append(self.catalog["candidates"][0]) + self.run_admin("import", self.file(self.catalog), succeeds=False) + def test_disqualified_candidates_and_ambiguous_provider_ids_rejected(self): + self.catalog["id"] = "not-qualified" + self.catalog["candidates"][0]["qualified"] = False + self.run_admin("import", self.file(self.catalog), succeeds=False) + self.catalog["candidates"][0]["qualified"] = True + self.catalog["candidates"][1]["provider_ids"] = self.catalog["candidates"][0]["provider_ids"] + self.assertIn("provider", self.run_admin("import", self.file(self.catalog), succeeds=False)) + def test_demo_and_real_snapshots_cannot_mix(self): + self.ratings["id"] = "mixed-mode" + self.ratings["mode"] = "real" + self.ratings["source"]["url"] = "https://example.org/ratings" + self.assertIn("mismatch", self.run_admin("import", self.file(self.ratings), succeeds=False)) + def test_timestamp_validation(self): + for stamp in ("yesterday", "2026-02-30T00:00:00Z", "2026-10-01T24:00:00Z", "2026-10-01T00:00:00-05:00"): + self.catalog["as_of"] = stamp + self.run_admin("import", self.file(self.catalog), succeeds=False) + def test_malformed_requests_and_unknown_snapshots(self): + for request in ({}, dict(self.request, catalog_id="missing"), dict(self.request, results_id="missing"), dict(self.request, ratings_id="demo-results-1"), dict(self.request, roster="invalid")): + self.run_admin("score-preview", self.file(request), succeeds=False) + def test_missing_catalog_dependency(self): + self.ratings["catalog_id"] = "not-imported" + self.run_admin("import", self.file(self.ratings), succeeds=False) + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_server.py b/tests/test_server.py index 66f3f3e..e725784 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -18,7 +18,7 @@ def running_server(*arguments, stop_signal=signal.SIGTERM): with tempfile.TemporaryDirectory(prefix="fpol_test_") as directory: log_path = pathlib.Path(directory) / "server.log" with log_path.open("w") as log: - process = subprocess.Popen([str(SERVER), "--port", "0", *map(str, arguments)], stdout=log, stderr=log) + process = subprocess.Popen([str(SERVER), "--port", "0", "--database", str(pathlib.Path(directory) / "league.sqlite"), *map(str, arguments)], stdout=log, stderr=log) try: deadline = time.monotonic() + 10 while time.monotonic() < deadline: @@ -44,10 +44,10 @@ def running_server(*arguments, stop_signal=signal.SIGTERM): if return_code != 0: raise AssertionError(f"Server exited with {return_code}:\n{log_path.read_text()}") -def request(port, path, method="GET"): +def request(port, path, method="GET", body=None): connection = http.client.HTTPConnection("127.0.0.1", port, timeout=3) try: - connection.request(method, path) + connection.request(method, path, body=json.dumps(body) if body is not None else None, headers={"Content-Type": "application/json"}) response = connection.getresponse() return response.status, response.getheader("Content-Type"), response.read() finally: @@ -66,6 +66,30 @@ class server_tests(unittest.TestCase): self.assertEqual(status, 404) self.assertEqual(json.loads(body)["code"], "not_found") self.assertEqual(request(port, "/api/health", "POST")[0], 404) + def test_catalog_rules_and_validation_routes(self): + with running_server() as port: + self.assertEqual(json.loads(request(port, "/api/catalog")[2])["payload"], {"documents": []}) + self.assertEqual(len(json.loads(request(port, "/api/rules")[2])["payload"]["slots"]), 12) + status, _, body = request(port, "/api/capacity", "POST", {"catalog_id": "missing", "players": 12}) + self.assertEqual(status, 400) + self.assertEqual(json.loads(body)["code"], "invalid_argument") + self.assertEqual(request(port, "/api/score-preview", "POST", {})[0], 400) + self.assertEqual(request(port, "/api/capacity")[0], 404) + def test_seeded_catalog_capacity_and_scoring(self): + demo = pathlib.Path(__file__).resolve().parents[1] / "data" / "demo" + with tempfile.TemporaryDirectory(prefix="fpol_api_") as directory: + database = pathlib.Path(directory) / "league.sqlite" + result = subprocess.run([str(SERVER.with_name("fpol_admin")), "--database", str(database), "import", str(demo / "catalog.json"), str(demo / "ratings.json"), str(demo / "results-3.json")], capture_output=True, text=True, timeout=10) + self.assertEqual(result.returncode, 0, result.stderr) + with running_server("--database", database) as port: + catalog = json.loads(request(port, "/api/catalog")[2])["payload"] + self.assertEqual(len(catalog["documents"]), 3) + status, _, body = request(port, "/api/capacity", "POST", json.loads((demo / "capacity.json").read_text())) + self.assertEqual(status, 200) + self.assertTrue(json.loads(body)["payload"]["sufficient_candidates"]) + status, _, body = request(port, "/api/score-preview", "POST", json.loads((demo / "preview.json").read_text())) + self.assertEqual(status, 200) + self.assertEqual(json.loads(body)["payload"]["total"], 528.24) def test_static_assets_and_route_boundaries(self): with tempfile.TemporaryDirectory(prefix="fpol_web_") as directory: root = pathlib.Path(directory) diff --git a/tools/generate_demo.py b/tools/generate_demo.py new file mode 100644 index 0000000..7ebad4a --- /dev/null +++ b/tools/generate_demo.py @@ -0,0 +1,57 @@ +"""Regenerate fictional examples. Never use these as real election data.""" +import json +import pathlib + +ROOT = pathlib.Path(__file__).resolve().parents[1] / "data" / "demo" +SOURCE = {"name": "Fictional scoring fixtures", "url": "fixture:product-fpol/demo", "retrieved_at": "2026-10-01T00:00:00Z"} + +def save(name, document): + (ROOT / name).write_text(json.dumps(document, indent=2) + "\n") + +def header(kind, identity, label, date): + return {"schema_version": 1, "id": identity, "kind": kind, "mode": "demo", "label": label, + "as_of": date, "source": SOURCE.copy()} + +def main(): + ROOT.mkdir(parents=True, exist_ok=True) + catalog = header("catalog", "demo-2026", "Fictional league • 144 candidates", "2026-10-01T00:00:00Z") + catalog.update(complete=True, races=[], candidates=[]) + ratings = header("ratings", "demo-ratings-v1", "Frozen fictional ratings", "2026-10-02T00:00:00Z") + ratings.update(catalog_id=catalog["id"], entries=[]) + states = ["AZ", "GA", "MI", "PA", "NC", "TX", "OH", "WI", "NV", "FL", "ME", "CO"] + names = ["Avery", "Blair", "Casey", "Drew"] + roster = [] + for office in ("house", "senate", "governor"): + for index, state in enumerate(states, start=1): + race_id = f"demo-{office}-{index:02}" + catalog["races"].append({"id": race_id, "office": office, "state": state, "round": "general", + "name": f"Fictional {state} {office.title()} race {index}"}) + ratings["entries"].append({"race_id": race_id, "rating": ["safe", "likely", "lean", "toss_up"][(index - 1) % 4]}) + for number, affiliation in enumerate(("democrat", "republican", "other", "other")): + identity = f"{race_id}-{number + 1}" + catalog["candidates"].append({"id": identity, "race_id": race_id, "name": f"{names[number]} {office.title()} {index:02}", + "affiliation": affiliation, "qualified": True, "ballot_lines": [affiliation.title()], + "provider_ids": {"fictional": identity}, "source": SOURCE.copy()}) + for slot, race_number, candidate_number in (("democrat", 4, 1), ("republican", 1, 2), ("other", 2, 3), ("flex", 3, 4)): + roster.append({"slot": f"{office}:{slot}", "candidate_id": f"demo-{office}-{race_number:02}-{candidate_number}"}) + save("catalog.json", catalog) + save("ratings.json", ratings) + for step, label in enumerate(("No votes yet", "Early reporting", "Lead changes", "Races called", "Corrected totals and call")): + result = header("results", f"demo-results-{step}", f"Synthetic replay {step + 1}: {label}", f"2026-11-04T0{step}:00:00Z") + result.update(catalog_id=catalog["id"], entries=[]) + for index, race in enumerate(catalog["races"]): + distribution = ([800, 150, 30, 20] if index % 4 == 0 else [600, 300, 70, 30] if index % 4 == 1 else + [540, 420, 25, 15] if index % 4 == 2 else [510, 490, 0, 0]) + if index % 4 == 3 and step in (2, 4): + distribution = [490, 510, 0, 0] + distribution = [0, 0, 0, 0] if step == 0 else distribution + winner = None if step < 3 else f"{race['id']}-{1 if distribution[0] > distribution[1] else 2}" + result["entries"].append({"race_id": race["id"], "round": "general", "status": "called" if step >= 3 else "reporting", + "total_votes": sum(distribution), "winner_id": winner, + "votes": [{"candidate_id": f"{race['id']}-{number + 1}", "votes": count} for number, count in enumerate(distribution)]}) + save(f"results-{step}.json", result) + save("preview.json", {"catalog_id": catalog["id"], "ratings_id": ratings["id"], "results_id": "demo-results-3", "roster": roster}) + save("capacity.json", {"catalog_id": catalog["id"], "players": 12}) + +if __name__ == "__main__": + main()