7.5 KiB
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. All documents require:
schema_version: 1, a uniqueid,kind(catalog,ratings,results),mode(demo,real),label, andas_of.source: {name, url, retrieved_at}. Real sources require an HTTP(S) URL. Only demo sources may usefixture:URLs.- UTC timestamps in
YYYY-MM-DDTHH:MM:SSZform. An adapter must normalize provider offsets, never invent observation times.as_ofis the source observation time,retrieved_atthe 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:
{
"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.
Compatibility with league scoring
The APIs and immutable documents above are the original v1 development simulator. Their pending_round preview behavior remains unchanged for replay compatibility. They are available over HTTP only with --allow-demo; CLI imports/previews remain available to the local operator.
Private leagues use the application API and separate count/outcome observations. Their frozen rule version is v2-november-share: November first-round/first-choice share continues to score during a pending runoff, and only the eventual winner bonus follows later-round outcomes. Do not use a v1 preview total as a v2 league standing. Commissioners can import a sourced ratings document through POST /api/league/ratings while a league is forming; it uses the existing immutable ratings schema and must reference that league's selected catalog.