Files
product-fpol/docs/data-contract.md
khodowany 78b2c383f1
ci / frontend (push) Successful in 9m13s
ci / backend (push) Failing after 6m40s
Game Features
2026-10-04 23:20:06 -05:00

7.5 KiB
Raw Permalink Blame History

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 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:

{
  "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.