Setup
This commit is contained in:
@@ -17,3 +17,4 @@ frontend/dist/
|
||||
.env.*
|
||||
!.env.example
|
||||
*.local
|
||||
var/
|
||||
|
||||
+14
-7
@@ -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 $<$<CXX_COMPILER_ID:GNU,Clang>:-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 $<$<CXX_COMPILER_ID:GNU,Clang>:-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 $<TARGET_FILE:fpol_server>
|
||||
COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/tests/test_all.py $<TARGET_FILE:fpol_admin> $<TARGET_FILE:fpol_server>
|
||||
)
|
||||
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})
|
||||
|
||||
@@ -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.
|
||||
|
||||
+4
-2
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"catalog_id": "demo-2026",
|
||||
"players": 12
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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).
|
||||
+2
-20
@@ -16,26 +16,8 @@
|
||||
<span class="edition">The 2026 edition</span>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<section class="intro" aria-labelledby="page-title">
|
||||
<p class="eyebrow"><span aria-hidden="true">★</span> Friends. Friendly competition. The midterms.</p>
|
||||
<h1 id="page-title">Your picks.<br />Your party.<br /><em>Bragging rights.</em></h1>
|
||||
<p class="description">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.</p>
|
||||
<p class="status"><span class="status-dot" aria-hidden="true"></span> The league is taking shape. More soon.</p>
|
||||
</section>
|
||||
|
||||
<section class="overview" aria-labelledby="overview-title">
|
||||
<div class="overview-heading">
|
||||
<h2 id="overview-title">The idea</h2>
|
||||
<span>On the drawing board</span>
|
||||
</div>
|
||||
<ol class="steps">
|
||||
<li><span class="step-number" aria-hidden="true">01</span><h3>Draft your party</h3><p>Pick your candidates when the league gets together for the draft.</p></li>
|
||||
<li><span class="step-number" aria-hidden="true">02</span><h3>Make your moves</h3><p>Trade with friends as the election season unfolds.</p></li>
|
||||
<li><span class="step-number" aria-hidden="true">03</span><h3>Watch it play out</h3><p>Real-world candidate performance will decide the final standings.</p></li>
|
||||
</ol>
|
||||
</section>
|
||||
</main>
|
||||
<main id="app"><p class="loading" role="status">Loading the field…</p></main>
|
||||
<noscript>This prototype needs JavaScript to browse candidates and calculate scores.</noscript>
|
||||
|
||||
<footer><span>A game among friends.</span><span>2026 US midterm elections</span></footer>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
|
||||
@@ -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<string, number> };
|
||||
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<HTMLElement>('#app')!;
|
||||
let documents: Document[] = [];
|
||||
let rules: Rules;
|
||||
let catalog: Catalog;
|
||||
let ratingId = '';
|
||||
let roster: Record<string, string> = {};
|
||||
let step = 0;
|
||||
let playing: ReturnType<typeof setInterval> | undefined;
|
||||
let previewVersion = 0;
|
||||
let capacityVersion = 0;
|
||||
const labels: Record<string, string> = { 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 = <T extends HTMLElement = HTMLElement>(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 => `<option value="${escape(item.id)}" ${item.id === selected ? 'selected' : ''}>${escape(item.name)}</option>`).join('');
|
||||
function sourceLink(source: Source): string {
|
||||
if (!/^https?:\/\//.test(source.url)) return '<span class="muted">Fictional example</span>';
|
||||
return `<a href="${escape(source.url)}" target="_blank" rel="noopener noreferrer">Source ↗</a> <span class="muted">${escape(source.retrieved_at.slice(0, 10))}</span>`;
|
||||
}
|
||||
async function api<T>(path: string, body?: unknown): Promise<T> {
|
||||
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 = `
|
||||
<section class="intro"><p class="eyebrow">The draft starts with a little homework.</p><h1>Build your party.<br><em>Make every race count.</em></h1><p class="description">Try your 12 picks, explore the field, and replay a fictional election night. A game among friends, taking shape for 2026.</p></section>
|
||||
<section class="workspace" aria-label="Data selection"><label>Candidate catalog<select id="dataset">${options(documents.filter(item => item.kind === 'catalog').map(item => ({ id: item.id, name: item.label })), catalog.id)}</select></label><p class="notice ${catalog.mode}"><strong>${catalog.mode === 'demo' ? 'SYNTHETIC DEMO' : catalog.complete ? 'REAL CATALOG' : 'PARTIAL REAL CATALOG'}</strong> ${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.'}</p><p class="small">Catalog dated ${escape(catalog.as_of)} · ${sourceLink(catalog.source)}</p></section>
|
||||
<nav class="section-nav" aria-label="Prototype sections"><a href="#field">01 / The field</a><a href="#party">02 / Your party</a><a href="#night">03 / Election night</a></nav>
|
||||
<section id="field" class="panel"><div class="section-heading"><div><p class="eyebrow">01 / Scout your picks</p><h2>The field</h2></div><span id="candidate-count" class="small"></span></div>
|
||||
<div class="filters"><label class="search-label">Search candidates<input id="search" type="search" placeholder="Name or race"></label>${['office', 'affiliation', 'state', 'rating'].map(filter => `<label>${filter === 'affiliation' ? 'Party' : label(filter)}<select id="filter-${filter}"><option value="">All ${filter === 'affiliation' ? 'parties' : filter + 's'}</option>${filter === 'state' ? options([...new Set(catalog.races.map(race => race.state))].sort().map(state => ({ id: state, name: state }))) : options((filter === 'office' ? ['house', 'senate', 'governor'] : filter === 'affiliation' ? ['democrat', 'republican', 'other'] : ['safe', 'likely', 'lean', 'toss_up', 'unrated']).map(id => ({ id, name: label(id) })))}</select></label>`).join('')}</div>
|
||||
<div class="table-scroll"><table><thead><tr><th>Candidate</th><th>Race</th><th>Party / ballot lines</th><th>Frozen rating</th><th>Provenance</th></tr></thead><tbody id="candidates"></tbody></table></div>
|
||||
<details><summary>Can everyone fill a roster?</summary><div class="capacity-controls"><label>League size<select id="players">${options(Array.from({ length: 11 }, (_, index) => ({ id: String(index + 2), name: `${index + 2} players` })), '12')}</select></label><p class="small">Each office needs one D, one R, one Other and one Flex per player. Counts include all race ratings.</p></div><div id="capacity" aria-live="polite"></div></details>
|
||||
</section>
|
||||
<section id="party" class="panel"><div class="section-heading"><div><p class="eyebrow">02 / Twelve picks. Three offices.</p><h2>Your party</h2></div><div class="actions">${catalog.mode === 'demo' ? '<button id="fill">Try an example roster</button>' : ''}<button id="clear" class="secondary">Clear picks</button></div></div><p class="small">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.</p><div class="roster-grid">${['house', 'senate', 'governor'].map(office => `<fieldset><legend>${label(office)}</legend>${rules.slots.filter(slot => slot.office === office).map(slot => `<label>${label(slot.affiliation)}<select data-slot="${escape(slot.id)}" aria-label="${label(office)} ${label(slot.affiliation)}"></select></label>`).join('')}</fieldset>`).join('')}</div><p id="roster-status" role="status"></p></section>
|
||||
<section id="night" class="panel"><div class="section-heading"><div><p class="eyebrow">03 / Follow the swings</p><h2>Election night, on replay.</h2></div><span class="tag">${catalog.mode === 'demo' ? 'Fictional results' : 'No verified live feed'}</span></div><p class="formula">${escape(rules.formula)}</p><div class="rating-key">${Object.entries(rules.multipliers).map(([rating, factor]) => `<span>${label(rating)} <strong>×${factor.toFixed(2)}</strong></span>`).join('')}</div><p class="small">${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.</p><div class="replay-controls"><label>Rating snapshot<select id="rating-snapshot" ${ratings().length ? '' : 'disabled'}>${ratings().length ? options(ratings().map(item => ({ id: item.id, name: item.label })), ratingId) : '<option>No ratings imported</option>'}</select></label><label class="timeline">Result snapshot<input id="timeline" type="range" min="0" max="${Math.max(0, snapshots.length - 1)}" value="${step}" ${snapshots.length ? '' : 'disabled'}><span id="snapshot-label" class="small"></span></label><button id="play" ${snapshots.length > 1 ? '' : 'disabled'}>Play replay</button></div><p id="snapshot-source" class="small"></p><div id="preview" aria-live="polite"></div></section>`;
|
||||
element<HTMLSelectElement>('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<string, string> = { 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<HTMLInputElement>('search').value.toLowerCase();
|
||||
const filters = ['office', 'affiliation', 'state', 'rating'].map(filter => element<HTMLSelectElement>(`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 `<tr><td><strong>${escape(candidate.name)}</strong></td><td>${escape(race.name)}<span class="cell-note">${escape(race.state)} · ${escape(label(race.office))}</span></td><td>${escape(label(candidate.affiliation))}<span class="cell-note">${escape(candidate.ballot_lines.join(' / '))}</span></td><td><span class="tag">${rating ? `${escape(label(rating))} ×${rules.multipliers[rating].toFixed(2)}` : 'Missing rating'}</span></td><td class="small">${sourceLink(candidate.source)}</td></tr>`;
|
||||
}).join('') || '<tr><td colspan="5">No candidates match these filters.</td></tr>';
|
||||
}
|
||||
function renderRoster() {
|
||||
for (const slot of rules.slots) {
|
||||
const select = root.querySelector<HTMLSelectElement>(`[data-slot="${slot.id}"]`)!;
|
||||
select.innerHTML = '<option value="">Choose a candidate</option>' + catalog.candidates.filter(candidate => fits(candidate, slot)).map(candidate => `<option value="${escape(candidate.id)}" ${roster[slot.id] === candidate.id ? 'selected' : ''} ${Object.entries(roster).some(([id, pick]) => id !== slot.id && pick === candidate.id) ? 'disabled' : ''}>${escape(candidate.name)} · ${escape(raceFor(candidate).state)}</option>`).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>('capacity', { catalog_id: catalog.id, players: Number(element<HTMLSelectElement>('players').value) });
|
||||
if (version !== capacityVersion) return;
|
||||
element('capacity').innerHTML = `<p class="notice"><strong>${result.sufficient_candidates ? 'Enough candidates in this catalog.' : 'Not enough candidates in this catalog.'}</strong> ${!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.'}</p><div class="table-scroll"><table><thead><tr><th>Office</th><th>Group</th><th>Available</th><th>Needed</th><th>Shortfall</th></tr></thead><tbody>${result.offices.flatMap(office => office.requirements.map(group => `<tr><td>${label(office.office)}</td><td>${label(group.group)}</td><td>${group.available}</td><td>${group.required}</td><td class="${group.shortfall ? 'warning' : ''}">${group.shortfall}</td></tr>`)).join('')}</tbody></table></div>`;
|
||||
} catch (error) { if (version === capacityVersion) element('capacity').textContent = String(error); }
|
||||
}
|
||||
async function renderPreview() {
|
||||
const version = ++previewVersion;
|
||||
const snapshot = results()[step];
|
||||
element<HTMLInputElement>('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 = '<p class="notice">Scoring is unavailable until both ratings and results are imported for this catalog. Missing data is not treated as zero.</p>'; return; }
|
||||
element('preview').innerHTML = '<p role="status">Calculating…</p>';
|
||||
try {
|
||||
const preview = await api<Preview>('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 = `<p class="notice warning">${preview.errors.map(escape).join('<br>')}</p>`; return; }
|
||||
const max = Math.max(preview.known_subtotal ?? 0, preview.baseline_subtotal ?? 0, 1);
|
||||
element('preview').innerHTML = `<div class="metrics"><div><span>Complete roster total</span><strong>${money(preview.total)}</strong><small>${preview.total === null ? 'Needs 12 picks with available scores' : 'Counted shares + awarded bonuses'}</small></div><div><span>Known subtotal</span><strong>${money(preview.known_subtotal)}</strong><small>${preview.missing_scores ?? 0} selected scores unavailable</small></div><div><span>If current leaders win</span><strong>${money(preview.leader_preview_subtotal)}</strong><small>${preview.unavailable_leader_previews ?? 0} previews unavailable · not awarded points</small></div></div><div class="comparison" aria-label="Scoring comparison"><div><span>No multipliers ${money(preview.baseline_subtotal)}</span><i style="width:${(preview.baseline_subtotal ?? 0) / max * 100}%"></i></div><div><span>Race-weighted ${money(preview.known_subtotal)}</span><i style="width:${(preview.known_subtotal ?? 0) / max * 100}%"></i></div></div><div class="table-scroll"><table><thead><tr><th>Pick</th><th>Vote share</th><th>Win bonus</th><th>Multiplier</th><th>Points</th><th>If leader wins</th></tr></thead><tbody>${preview.scores.map(score => `<tr><td><strong>${escape(catalog.candidates.find(candidate => candidate.id === score.candidate_id)?.name)}</strong><span class="cell-note">${escape(score.slot.split(':').map(label).join(' / '))} · ${escape(score.reason ?? score.status)}</span></td><td>${score.vote_share === undefined ? '—' : `${score.vote_share.toFixed(2)}%`}</td><td>${score.win_bonus ?? '—'}</td><td>${score.multiplier === undefined ? '—' : `×${score.multiplier.toFixed(2)}`}</td><td><strong>${money(score.points)}</strong></td><td>${money(score.leader_preview_points)}</td></tr>`).join('') || '<tr><td colspan="6">Choose candidates above to see their points.</td></tr>'}</tbody></table></div>`;
|
||||
} catch (error) { if (version === previewVersion) element('preview').textContent = String(error); }
|
||||
}
|
||||
async function start() {
|
||||
try {
|
||||
const [ruleData, catalogData] = await Promise.all([api<Rules>('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 = '<section class="intro"><h1>The field is empty.</h1><p>Import a catalog with the local admin tool to start exploring. Setup instructions are in the repository README.</p></section>'; return; }
|
||||
ratingId = ratings()[0]?.id ?? '';
|
||||
render();
|
||||
} catch (error) {
|
||||
root.innerHTML = `<section class="intro"><h1>Unable to load the league.</h1><p role="alert">${escape(error)}</p><p>Check that the C++ backend is running, then try again.</p><button id="retry">Retry</button></section>`;
|
||||
element('retry').onclick = () => void start();
|
||||
}
|
||||
}
|
||||
void start();
|
||||
|
||||
+69
-54
@@ -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; } }
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
#include "store.hpp"
|
||||
|
||||
#include <iostream>
|
||||
#include <boost/asio/co_spawn.hpp>
|
||||
#include <boost/asio/io_context.hpp>
|
||||
#include <boost/asio/use_future.hpp>
|
||||
|
||||
|
||||
namespace {
|
||||
|
||||
auto execute(fpol::store &store, std::string const &action, std::vector<std::string> const &files) -> boost::asio::awaitable<void> {
|
||||
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<std::string>{};
|
||||
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;
|
||||
}
|
||||
}
|
||||
+323
@@ -0,0 +1,323 @@
|
||||
#include "domain.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <chrono>
|
||||
#include <cmath>
|
||||
#include <regex>
|
||||
#include <set>
|
||||
#include <stdexcept>
|
||||
|
||||
|
||||
namespace {
|
||||
|
||||
auto const offices = std::array<std::string, 3>{"house", "senate", "governor"};
|
||||
auto const affiliations = std::array<std::string, 4>{"democrat", "republican", "other", "flex"};
|
||||
auto const multipliers = std::map<std::string, std::int64_t>{{"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<unsigned>(std::stoi(value.substr(5, 2)))}, std::chrono::day{static_cast<unsigned>(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<double>(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<std::string>{"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<std::pair<std::string, std::string>>{};
|
||||
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<std::string>{};
|
||||
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<std::string>{};
|
||||
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<std::string, std::int64_t>{{"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<std::int64_t>(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<std::string, std::string>{};
|
||||
auto taken = std::set<std::string>{};
|
||||
auto errors = boost::json::array{};
|
||||
auto valid_slots = std::set<std::string>{};
|
||||
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<std::string, std::int64_t>{};
|
||||
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<std::string, boost::json::value const *>{};
|
||||
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<std::string, std::int64_t>{};
|
||||
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<double>(votes.at(id)) * 100.0 / static_cast<double>(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;
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
#include <map>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
#include <boost/json.hpp>
|
||||
|
||||
|
||||
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<std::string, fpol::race> races;
|
||||
std::map<std::string, fpol::candidate> 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;
|
||||
|
||||
}
|
||||
+4
-2
@@ -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;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
#include "server.hpp"
|
||||
#include "store.hpp"
|
||||
|
||||
#include <chrono>
|
||||
#include <csignal>
|
||||
@@ -14,9 +15,11 @@
|
||||
#include <boost/asio/co_spawn.hpp>
|
||||
#include <boost/asio/io_context.hpp>
|
||||
#include <boost/asio/signal_set.hpp>
|
||||
#include <boost/asio/use_future.hpp>
|
||||
#include <boost/json/object.hpp>
|
||||
|
||||
#include <ligo.hpp>
|
||||
#include <ligo/detail/errors.hpp>
|
||||
#include <modulus.hpp>
|
||||
|
||||
|
||||
@@ -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<std::expected<boost::json::value, modulus::error>> {
|
||||
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)};
|
||||
|
||||
@@ -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;
|
||||
|
||||
+122
@@ -0,0 +1,122 @@
|
||||
#include "store.hpp"
|
||||
#include "domain.hpp"
|
||||
|
||||
#include <fstream>
|
||||
#include <iterator>
|
||||
#include <stdexcept>
|
||||
|
||||
|
||||
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 <typename VALUE>
|
||||
auto checked(std::expected<VALUE, modulus::error> result) -> VALUE {
|
||||
if (!result) {
|
||||
throw std::runtime_error(result.error().describe());
|
||||
}
|
||||
if constexpr (!std::is_void_v<VALUE>) {
|
||||
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<std::expected<void, modulus::error>> {
|
||||
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<void, modulus::error>{};
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
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<void> {
|
||||
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<noto::migration>{{"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<boost::json::array> {
|
||||
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<std::string>(row.at(0).value)));
|
||||
}
|
||||
co_return documents;
|
||||
}
|
||||
|
||||
auto fpol::store::import_document(boost::json::value document) -> boost::asio::awaitable<boost::json::value> {
|
||||
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<noto::sqlite_value>{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<boost::json::value> {
|
||||
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<char>{stream}, std::istreambuf_iterator<char>{}};
|
||||
if (stream.bad()) {
|
||||
throw std::runtime_error("Failed reading JSON file: " + path.string());
|
||||
}
|
||||
return boost::json::parse(contents);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
#pragma once
|
||||
|
||||
#include <filesystem>
|
||||
#include <boost/asio/awaitable.hpp>
|
||||
#include <boost/json.hpp>
|
||||
#include <noto.hpp>
|
||||
|
||||
|
||||
namespace fpol {
|
||||
|
||||
class store {
|
||||
public:
|
||||
store(boost::asio::any_io_executor executor, std::filesystem::path const &path);
|
||||
auto initialize() -> boost::asio::awaitable<void>;
|
||||
auto import_document(boost::json::value document) -> boost::asio::awaitable<boost::json::value>;
|
||||
auto handle(std::string action, boost::json::value request) -> boost::asio::awaitable<boost::json::value>;
|
||||
private:
|
||||
auto documents() -> boost::asio::awaitable<boost::json::array>;
|
||||
noto::connection connection_;
|
||||
};
|
||||
|
||||
auto read_json(std::filesystem::path const &path) -> boost::json::value;
|
||||
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
+27
-3
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user