diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..d022d7c
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,15 @@
+.git
+.github
+.mypy_cache
+.pytest_cache
+.ruff_cache
+.venv
+.repro-venv
+.repro-v3-venv
+__pycache__
+*.egg-info
+build
+dist
+output
+reports
+runtime
diff --git a/.gitignore b/.gitignore
index 077d4a5..e188b6b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -5,6 +5,9 @@ __pycache__/
.pytest_cache/
.ruff_cache/
.mypy_cache/
+runtime/
+output/
+.playwright-cli/
*.egg-info/
build/
dist/
diff --git a/AGENTS.md b/AGENTS.md
index d813013..c0659e7 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -1,4 +1,4 @@
-# Universal Sports Engine Rules
+# Waterboy Engine Rules
- Keep the simulation truth independent from rendering.
- Preserve deterministic replay: identical versions, inputs, and seed must produce identical events.
diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index ac83dce..c62dc1e 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -1,4 +1,4 @@
-# Architecture
+# Waterboy Architecture
## Decision
@@ -17,7 +17,11 @@ Kimi build:
## Layers
```text
-CLI / SDK / domain-neutral bridge
+Web UI / versioned HTTP API / CLI / SDK / domain-neutral bridge
+ |
+Projection service, weekly board, hypothetical-matchup orchestration
+ |
+Normalized point-in-time games, SQLite provenance store, bounded feed clients
|
Champion governance, recursive calibration, adaptive weights, drift quarantine
|
@@ -32,6 +36,34 @@ League packs own scoring, timing, possession/inning/drive/shift semantics, and
reference anchors. The kernel contains no player, ball, puck, inning, possession,
or scoreboard type.
+## Sports-data and projection boundary
+
+The production application adds a domain adapter outside the neutral kernel:
+
+1. Read-only source adapters normalize public scoreboards or a protected local
+ historical database into `GameRecord`.
+2. `SportsDataStore` records every normalized observation together with source URL,
+ observation time, ingest run, and payload SHA-256.
+3. `ProjectionEngine` estimates recency-weighted, opponent-adjusted attack and
+ defense ratings, shrinks them toward league priors, and derives score parameters.
+4. The existing analytical F0 engine produces deterministic, reproducible score
+ distributions from those parameters.
+5. `SportsEngineService` exposes scheduled weekly games and hypothetical pairings
+ without giving the web layer direct database or simulation-kernel access.
+
+Final games are observations, not projections. In-progress games show their
+observed score and intentionally withhold a pregame projection. Sparse or stale
+history remains visible in the projection evidence status instead of being hidden.
+
+## Application boundary
+
+FastAPI validates and bounds all external inputs, emits a versioned `/api/v1`
+surface, and serves a static no-build dashboard. The browser does not evaluate
+server-provided HTML. Security headers are applied globally, API responses are
+non-cacheable, and remote binding requires an explicit operator flag. A remote
+deployment must add TLS, authentication, rate limiting, and network policy at the
+reverse proxy as described in `PRODUCTION_RUNBOOK.md`.
+
## Fidelity
- F0: analytical/batch score distribution (implemented; no event path).
diff --git a/AUTONOMOUS_IMPROVEMENT.md b/AUTONOMOUS_IMPROVEMENT.md
index 5254d8b..3d48625 100644
--- a/AUTONOMOUS_IMPROVEMENT.md
+++ b/AUTONOMOUS_IMPROVEMENT.md
@@ -60,16 +60,16 @@ and an untouched test audit after the champion is frozen.
## Commands
```powershell
-.\.venv\Scripts\sports-engine.exe auto-improve `
+.\.venv\Scripts\waterboy.exe auto-improve `
--csv data\point-in-time\historical.csv `
--config AUTONOMY_CONFIG.json `
--output reports\improvement-run-001
-.\.venv\Scripts\sports-engine.exe simulate-adaptive `
+.\.venv\Scripts\waterboy.exe simulate-adaptive `
--bundle reports\improvement-run-001\champion_bundle.json `
--league nba --seed 42 --home-strength 1.0 --away-strength 1.0
-.\.venv\Scripts\sports-engine.exe adapt-online `
+.\.venv\Scripts\waterboy.exe adapt-online `
--csv data\point-in-time\shadow.csv `
--config AUTONOMY_CONFIG.json `
--bundle reports\improvement-run-001\champion_bundle.json `
diff --git a/DATA_PIPELINE_LINEAGE.md b/DATA_PIPELINE_LINEAGE.md
new file mode 100644
index 0000000..65d5f29
--- /dev/null
+++ b/DATA_PIPELINE_LINEAGE.md
@@ -0,0 +1,59 @@
+# Data Pipeline Lineage
+
+## Boundary
+
+Dummy is a protected source. Waterboy never imports Dummy modules,
+writes to Dummy's checkout, migrates its database, or controls its processes. The
+only reuse is a clean-room implementation of the required sports-history flow and
+a query-only copy of eligible game rows into a target-owned database.
+
+## Component map
+
+| Capability observed in Dummy | Waterboy implementation | Boundary |
+|---|---|---|
+| Normalized sports history | `data.models.GameRecord` | New target-owned contract |
+| SQLite sports history | `data.store.SportsDataStore` | New schema and provenance columns |
+| Historical league rows | `data.pipeline.DummyHistoryImporter` | Source opened `mode=ro` plus `query_only` |
+| Public score refresh | `data.espn.EspnScoreboardClient` | Bounded GET, timeout, retry, cache, payload hash |
+| Sports ingestion orchestration | `data.pipeline.SportsDataPipeline` | Target-owned league aliases and ingest runs |
+| Board/model consumption | `projection.ProjectionEngine` and `service.SportsEngineService` | No Dummy runtime dependency |
+
+## Copied records
+
+The importer selects only sports-history fields needed to reconstruct completed
+games: source record identifier, league, UTC start time, home/away team IDs and
+names, scores, and final status. It normalizes league aliases and rejects malformed
+or same-team records that violate the target contract.
+
+Each copied observation receives an immutable provenance reference to the protected
+source database and source row. The target database can be discarded and rebuilt
+without altering the source.
+
+## Explicit exclusions
+
+The implementation does not copy or activate:
+
+- prediction-market, portfolio, brokerage, order, or execution paths;
+- credentials, tokens, user data, environment files, logs, or runtime settings;
+- model claims, profitability artifacts, settled-market evidence, or readiness
+ labels;
+- Dummy code imports, database migrations, background jobs, or process controls;
+- odds feeds, betting lines, injury data, private providers, or restricted data.
+
+## Current public refresh
+
+Current-week schedules and scores are normalized from a public, read-only
+scoreboard endpoint. The client records the exact request URL, retrieval timestamp,
+and SHA-256 of the raw payload. Requests are bounded by timeout, retry count,
+response size, date span, and a local cache. College endpoints that reject a date
+range fall back to bounded daily requests.
+
+Provider availability is not guaranteed, and provider terms govern use. A fetch
+failure stays visible as an ingest error and never becomes synthetic current data.
+
+## Verification
+
+Automated tests create temporary source and destination databases, verify that the
+source bytes remain unchanged, exercise normalization and aliases, and inspect the
+HTTP/API projection contracts. Operationally, recheck `git status` in Dummy after
+an import and confirm it is clean.
diff --git a/DATA_RIGHTS_AND_PROVENANCE.md b/DATA_RIGHTS_AND_PROVENANCE.md
index 7e45ca7..ac17c5c 100644
--- a/DATA_RIGHTS_AND_PROVENANCE.md
+++ b/DATA_RIGHTS_AND_PROVENANCE.md
@@ -1,22 +1,42 @@
# Data Rights and Provenance
-## Local build inputs
+## Waterboy runtime inputs
-No licensed event feed, tracking feed, private injury feed, sportsbook feed, or
-proprietary roster database is present in this repository.
+No licensed tracking feed, private injury feed, sportsbook feed, brokerage feed,
+or proprietary roster database is present in this repository.
-The shipped engine uses:
+The application can use:
- deterministic synthetic events generated by the versioned league packs;
- transparent league-level reference anchors for broad output sanity checks;
-- official rule-source links as provenance pointers for the minimal rule manifests.
+- official rule-source links as provenance pointers for the minimal rule manifests;
+- keyless, unauthenticated, read-only ESPN public scoreboard responses for schedules,
+ team identities, game state, and final scores;
+- an optional local copy of sports history from Dummy, opened with SQLite `mode=ro`
+ and `PRAGMA query_only=ON`.
The reference anchors are approximate research fixtures. They are not an ingested,
point-in-time dataset and are labeled `provisional_reference_anchor` everywhere.
+## Runtime provenance contract
+
+Every normalized game stores its source, source URL, receipt time, and a SHA-256
+hash of the observed payload. Scheduled rows do not receive synthetic observed
+scores. In-progress scores remain labeled as observations; the pregame-only model
+is withheld on those cards.
+
+The public HTTP cache and local SQLite database live under `runtime/`, which is
+gitignored. The repository does not redistribute provider payloads or the local
+Dummy history database.
+
+The adapter sends bounded GET requests with a descriptive user agent, response-size
+limit, timeout, retries, and a short local cache. It has no login, credential,
+write, odds, wagering, or automation-bypass behavior. Operators remain responsible
+for evaluating provider terms and data rights for their deployment.
+
## Prohibited promotion
-These inputs cannot support a claim of held-out player, team, game, season, spatial,
-or counterfactual accuracy. Such claims require separately licensed data manifests,
-earliest-available timestamps, revisions, train/calibration/test boundaries, and
-independent reproduction.
+These inputs cannot by themselves support a claim of held-out player, game, spatial,
+or counterfactual accuracy. Such claims require frozen point-in-time manifests,
+earliest-available timestamps, revisions, chronological train/calibration/test
+boundaries, prospective scoring, and independent reproduction.
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..fa21e25
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,25 @@
+FROM python:3.12-slim
+
+ENV PYTHONDONTWRITEBYTECODE=1 \
+ PYTHONUNBUFFERED=1 \
+ WATERBOY_DATA_PATH=/data/sports.db \
+ WATERBOY_CACHE_DIR=/data/http_cache
+
+WORKDIR /app
+
+RUN groupadd --system sports && useradd --system --gid sports --home-dir /app sports
+
+COPY pyproject.toml README.md LICENSE ./
+COPY src ./src
+
+RUN python -m pip install --no-cache-dir . && \
+ mkdir -p /data && chown -R sports:sports /app /data
+
+USER sports
+VOLUME ["/data"]
+EXPOSE 8765
+
+HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
+ CMD python -c "import json,urllib.request; data=json.load(urllib.request.urlopen('http://127.0.0.1:8765/api/v1/health',timeout=3)); raise SystemExit(0 if data['status']=='healthy' else 1)"
+
+CMD ["waterboy", "serve", "--host", "0.0.0.0", "--port", "8765", "--allow-remote", "--data", "/data/sports.db", "--cache", "/data/http_cache"]
diff --git a/KNOWN_LIMITATIONS.md b/KNOWN_LIMITATIONS.md
index 9a25c40..9192c9b 100644
--- a/KNOWN_LIMITATIONS.md
+++ b/KNOWN_LIMITATIONS.md
@@ -1,8 +1,18 @@
# Known Limitations
-- The stalled remote Kimi repository was not exported; this is an independent local reconstruction.
+- Waterboy's current score projections use team scoring history, recency, opponent adjustment,
+ and league priors; they do not yet ingest rosters, injuries, starting lineups,
+ probable pitchers, coaching changes, travel, officials, or forecast weather.
+- The public scoreboard is an operational schedule/final-score source, not a
+ licensed tracking feed or a predictive-accuracy certification dataset.
+- Some imported historical leagues are stale. Those ratings decay toward league
+ priors and surface `sparse_or_stale_team_history` rather than silently presenting
+ old form as current.
+- In-progress scores are displayed as observations. The current projection model is
+ pregame-only and is withheld rather than mislabeled as a live win-probability model.
- F1 event paths are calibrated only to broad reference anchors, not licensed held-out data.
-- Player identities, rosters, coaching policies, officials, injuries, fatigue, venues, and weather are contract surfaces rather than calibrated models.
+- Player identities, rosters, coaching policies, officials, injuries, fatigue, and
+ weather are contract surfaces rather than calibrated models.
- F2 spatial trajectories and F3 physics/rendering are intentionally not fabricated without tracking evidence.
- Rule manifests cover complete-game flow but are not a complete executable transcription of every official rulebook edge case.
- Generic F1 games resolve a winner; competition-specific regular-season tie and
@@ -19,4 +29,5 @@
- Windows multi-process calls require an importable script or CLI entrypoint; an
interactive/stdin caller receives an explicit error and should use F0 or one worker.
- Counterfactual pairing reduces simulation noise but does not by itself establish causality.
-- No live feed, wagering, sportsbook, trading, or capital interface exists.
+- A read-only public scoreboard adapter exists. No wagering, sportsbook, trading,
+ brokerage, or capital interface exists.
diff --git a/PRODUCTION_RUNBOOK.md b/PRODUCTION_RUNBOOK.md
new file mode 100644
index 0000000..9b63a92
--- /dev/null
+++ b/PRODUCTION_RUNBOOK.md
@@ -0,0 +1,122 @@
+# Production Runbook
+
+## Scope
+
+This runbook operates Waterboy 0.4 as a read-only projection and
+simulation service. It does not provide wagering, trading, or order execution.
+
+## Bootstrap
+
+Python 3.12 is the supported runtime.
+
+```powershell
+py -3.12 -m venv .venv
+.\.venv\Scripts\python.exe -m pip install --upgrade pip
+.\.venv\Scripts\python.exe -m pip install -e ".[dev]"
+```
+
+Create the target-owned history store without writing to Dummy:
+
+```powershell
+.\.venv\Scripts\waterboy.exe import-dummy-history
+.\.venv\Scripts\waterboy.exe refresh-data --days 7
+```
+
+The importer opens Dummy with SQLite URI `mode=ro` and enables
+`PRAGMA query_only=ON`. The destination defaults to
+`runtime/universal_sports_engine/sports.db`.
+
+## Local operation
+
+```powershell
+.\.venv\Scripts\waterboy.exe serve --host 127.0.0.1 --port 8765
+```
+
+Health check:
+
+```powershell
+Invoke-RestMethod http://127.0.0.1:8765/api/v1/health
+```
+
+Refresh and weekly projection checks:
+
+```powershell
+.\.venv\Scripts\waterboy.exe refresh-data --days 7
+.\.venv\Scripts\waterboy.exe project-week --simulations 500
+.\.venv\Scripts\waterboy.exe simulate-matchup --league nfl --away KC --home PHI
+```
+
+## Container
+
+```powershell
+docker build -t waterboy:0.4.0 .
+docker run --read-only --tmpfs /tmp -p 127.0.0.1:8765:8765 `
+ -v waterboy-data:/data waterboy:0.4.0
+```
+
+The image runs as a non-root user and stores mutable state only below `/data`.
+
+## Remote deployment controls
+
+The application refuses a non-loopback bind unless `--allow-remote` is present.
+That flag is an acknowledgement, not a security layer. Before remote exposure:
+
+- terminate TLS at an authenticated reverse proxy;
+- restrict refresh endpoints to an operator identity;
+- apply request-size, request-rate, and concurrency limits;
+- restrict egress to approved data-source hosts;
+- persist `/data` on encrypted storage;
+- collect access, application, refresh, and health logs;
+- alert on failed refreshes, stale league data, elevated latency, and disk growth;
+- keep at least one tested database backup outside the serving host.
+
+Do not expose the Uvicorn process directly to the public internet.
+
+## Data refresh
+
+Refresh is idempotent at the normalized game identifier and records an ingest run
+for auditability. A partial source failure is explicit in the command/API result;
+previously stored observations remain available.
+
+Recommended cadence:
+
+- in season: every 15 minutes while games are active, hourly otherwise;
+- out of season: daily;
+- after deploy: one forced refresh followed by `/api/v1/health`.
+
+Respect provider terms, cache controls, rate limits, and permitted-use boundaries.
+This repository does not grant data redistribution rights.
+
+## Backup and restore
+
+Stop the serving process or use SQLite's online backup API before copying the
+database. Preserve the database, its checksum, UTC timestamp, application version,
+and configuration together. After restoration:
+
+1. start on a non-production port;
+2. call `/api/v1/health`;
+3. run `PRAGMA integrity_check` through an approved SQLite operator tool;
+4. compare row counts and latest observation times;
+5. run one deterministic hypothetical matchup twice and compare results.
+
+## Incident behavior
+
+If a feed fails, retain the last valid observations, surface stale evidence, and
+investigate; never manufacture a current score. If integrity checks fail, remove
+the instance from service and restore a verified backup. If forecasts behave
+unexpectedly, preserve the database and seed, reproduce locally, and keep the
+software/evidence boundary explicit.
+
+## Release gate
+
+Before deployment, require:
+
+```powershell
+.\.venv\Scripts\ruff.exe check .
+.\.venv\Scripts\mypy.exe src scripts
+.\.venv\Scripts\pytest.exe -q
+git diff --check
+```
+
+Also build and inspect a wheel, exercise the browser at desktop and mobile widths,
+and validate the container when Docker is available.
diff --git a/README.md b/README.md
index a48d8f7..530e8e0 100644
--- a/README.md
+++ b/README.md
@@ -1,176 +1,214 @@
-# Universal Sports Engine
+# Waterboy
-### Deterministic simulation. Recursive calibration. Auditable evolution.
+### Every league. Any matchup. One auditable simulation engine.
-A high-throughput, multi-league sports research engine with exact replay,
-self-tuning analytical ensembles, drift-aware online calibration, and
-content-addressed champion governance.
+Waterboy is a production-capable sports intelligence command center for
+current-week score projections and hypothetical games. It combines source-backed
+history, deterministic Monte Carlo, visible uncertainty, exact replay, and seven
+isolated league packs behind one fast interface.
-[](./STATUS.md)
+[](./STATUS.md)
+[](./reports/RELEASE_EVIDENCE_V4.md)
+[](#seven-leagues-one-engine)
[](./pyproject.toml)
-[](#seven-leagues-one-kernel)
-[](./reports/RELEASE_EVIDENCE.md)
-[](./LICENSE)
+[](./LICENSE)
-**Research release:** mechanics, integrity, autonomy, and performance are verified.
-Real-world predictive supremacy is not claimed without licensed point-in-time data.
+**Built to make simulation powerful, legible, and falsifiable.**
----
+
-## Why it is different
+## What Waterboy does
-Universal Sports Engine treats every simulation and model improvement as evidence,
-not theater.
-
-- **Exact by construction** — immutable event streams, deterministic random namespaces,
- SHA-256 event chains, and complete result-envelope verification.
-- **Fast at two fidelities** — analytical F0 distributions for massive Monte Carlo and
- event-level F1 paths for interpretable game flow.
-- **Autonomously adaptive** — bounded population search, multiplicative expert weighting,
- self-calibrating intervals, champion/challenger gates, and prospective shadow updates.
-- **Fail-closed** — drift alerts quarantine adaptive simulation; failed challengers remain
- in the ledger; existing checkpoints are never silently overwritten.
-- **Portable** — dependency-free runtime, clean Python SDK and CLI, deterministic JSON
- replay, and a domain-neutral stochastic-control boundary.
-
-## Verified release snapshot
+| Surface | Capability |
+|---|---|
+| Current-week command center | Organizes scheduled, live, and final games across seven leagues |
+| Score projections | Decimal team scores, totals, margins, win/tie probabilities, and 80% intervals |
+| Winner signal | Places an accessible `✓` immediately beside the higher-probability team |
+| Matchup Lab | Simulates any two teams at home or a neutral site with 5K–50K draws |
+| Team intelligence | Recency weighting, opponent adjustment, prior shrinkage, and freshness penalties |
+| Evidence | Source URL, observation time, payload hash, sample size, data-through time, and limitations |
+| Replay | Same model, state, inputs, and seed reproduce the same simulated world |
+| Operations | Versioned API, CLI, SQLite provenance store, security headers, and non-root container |
-| Dimension | Version 0.3 evidence |
-|---|---:|
-| Supported leagues | 7 |
-| Tests | 67 passed |
-| Negative and mechanism controls | 49 passed, 0 failed |
-| Randomized stress | 14,000 games / 2,003,304 events / 0 failures |
-| F0 throughput | 111K–127K games/second |
-| F1 single-worker throughput | 347–1,011 games/second |
-| F1 four-worker scaling | 1.42×–1.82× |
-| Recursive autonomy arena | 4 promotions / 3 guarded retentions |
-| Calibration and shadow weight changes | 7 / 4 |
-| Shadow drift alerts | 0 |
-
-Machine-readable results live in
-[release evidence](./reports/RELEASE_EVIDENCE.md),
-[performance evidence](./reports/PERFORMANCE_AUDIT_V3_FINAL.json),
-[stress evidence](./reports/STRESS_AUDIT_V3.json), and the
-[autonomy audit](./reports/AUTONOMY_AUDIT_V3.json).
-
-## Architecture
+## The Waterboy pipeline
```mermaid
-flowchart TD
- CLI["CLI / Python SDK"] --> GOV["Champion governance"]
- GOV --> CAL["Recursive calibration + online weights"]
- CAL --> F0["F0 analytical distributions"]
- GOV --> F1["F1 event simulation"]
- F0 --> PACKS["Seven isolated league packs"]
- F1 --> PACKS
- PACKS --> CORE["Deterministic event / rule / RNG kernel"]
- CORE --> PROOF["Replay + hashes + evidence bundles"]
- PROOF --> RED["Stress, controls, drift, rollback"]
+flowchart LR
+ A["Read-only schedules and historical scores"] --> B["Normalized game contract timestamp + source + SHA-256"]
+ B --> C["Provenance SQLite isolated target-owned store"]
+ C --> D["Team-state intelligence recency + opponent adjustment"]
+ D --> E["Posterior parameter worlds shrinkage + uncertainty"]
+ E --> F{"Fidelity router"}
+ F -->|"F0"| G["Mass analytical simulation"]
+ F -->|"F1"| H["Event-level game paths"]
+ G --> I["Decimal projections probabilities + intervals"]
+ H --> J["Replayable event chain result-envelope hash"]
+ I --> K["Current Week + Matchup Lab"]
+ J --> K
```
-The neutral kernel contains no player, inning, possession, ball, puck, or scoreboard
-type. Sport semantics remain inside league packs; model governance remains outside
-simulation truth.
+Waterboy does not hide source quality behind a confidence color. Sparse or stale
+history is explicitly labeled and shrunk toward transparent league priors.
+In-progress scores remain observations; the pregame model is withheld instead of
+being mislabeled as a live forecast.
-## Seven leagues, one kernel
+## Showcase snapshot
-| Family | League packs | Primary F1 unit |
+| Dimension | Verified local evidence |
+|---|---:|
+| Historical bootstrap | 179,383 valid games copied query-only from Dummy |
+| Current seven-day board | 107 games: 92 MLB and 15 WNBA on 2026-07-26 |
+| Scheduled pregame projections | 94 |
+| League packs | 7 |
+| Hypothetical simulation depth | Up to 50,000 draws |
+| Current-board latency | Approximately 1.1 seconds at 500 draws/game |
+| Automated tests | 74 passed, zero warnings |
+| Negative/mechanism controls | 49 passed, zero failures |
+| Reference validation | 7/7 provisional league anchors |
+| Browser validation | Desktop + mobile, zero console errors/warnings |
+
+This snapshot demonstrates working software and operational data flow. It does
+not establish predictive supremacy or profitability; those require independently
+governed prospective, point-in-time evaluation.
+
+## Seven leagues, one engine
+
+| UI category | Internal pack | Family |
|---|---|---|
-| Bat and ball | MLB | Pitch / plate appearance / inning |
-| Court invasion | NBA, WNBA, NCAA MBB | Possession / period / game |
-| Gridiron | NFL, NCAA Football | Play / drive / game |
-| Ice invasion | NHL | Event / shift / period |
+| MLB | `mlb` | Bat and ball |
+| NFL | `nfl` | Gridiron |
+| NCAAF | `ncaaf` | Gridiron |
+| NBA | `nba` | Court invasion |
+| WNBA / WBA | `wnba` | Court invasion |
+| NCAAM | `ncaambb` | Court invasion |
+| NHL | `nhl` | Ice invasion |
+
+Each pack owns its rules, clocks, phases, scoring, stochastic transitions, and
+reference anchors. Sport semantics never leak into the domain-neutral event core.
-Each pack owns its rules, scoring, clocks, stochastic transitions, and provisional
-reference anchors. One sport is never treated as a rescaled copy of another.
+## Quick start
-## Recursive improvement engine
+```powershell
+py -3.12 -m venv .venv
+.\.venv\Scripts\python.exe -m pip install -e ".[dev]"
-```mermaid
-flowchart LR
- T["Point-in-time train"] --> S["Bounded population search"]
- S --> C["Chronological calibration"]
- C --> W["Expert reweighting + interval adaptation"]
- W --> G{"All gates pass?"}
- G -- Yes --> P["Promote + freeze checkpoint"]
- G -- No --> R["Retain incumbent + record failure"]
- P --> H["Prospective shadow outcomes"]
- R --> H
- H --> D{"Drift?"}
- D -- Healthy --> W
- D -- Alert --> Q["Quarantine / recalibrate / rollback"]
+# Query-only historical bootstrap; Dummy is never modified.
+.\.venv\Scripts\waterboy.exe import-dummy-history
+
+# Refresh the rolling seven-day board.
+.\.venv\Scripts\waterboy.exe refresh-data --days 7
+
+# Start Waterboy.
+.\.venv\Scripts\waterboy.exe serve
```
-Profiles, ensembles, adaptive states, champion bundles, and promotion ledgers all
-carry independent content hashes. Promoted bundles embed the complete incumbent
-model and state, so rollback is self-contained rather than merely documented.
+Open [http://127.0.0.1:8765](http://127.0.0.1:8765).
-Read the full [autonomous improvement protocol](./AUTONOMOUS_IMPROVEMENT.md).
+The legacy `sports-engine` command and `universal_sports_engine` Python namespace
+remain supported for replay and integration compatibility. New integrations should
+use the `waterboy` command or `python -m waterboy`.
-## Quick start
+## Build any matchup
```powershell
-python -m venv .venv
-.\.venv\Scripts\python.exe -m pip install -e ".[dev]"
+.\.venv\Scripts\waterboy.exe simulate-matchup `
+ --league nfl `
+ --home KC `
+ --away PHI `
+ --simulations 50000 `
+ --seed 20260726
+```
-.\.venv\Scripts\sports-engine.exe list-leagues
-.\.venv\Scripts\sports-engine.exe simulate --league nba --seed 42
-.\.venv\Scripts\sports-engine.exe simulate-fast `
- --league nfl --seed 42 --home-strength 1.0 --away-strength 1.0
+```json
+{
+ "projected_home_score": 24.8,
+ "projected_away_score": 22.1,
+ "home_win_probability": 0.557,
+ "away_win_probability": 0.431,
+ "tie_probability": 0.012,
+ "confidence_label": "HIGH_DATA_COVERAGE"
+}
```
-### Run recursive calibration
+The example shape illustrates the API contract; actual values depend on the
+selected teams, point-in-time history, model version, and seed.
-```powershell
-.\.venv\Scripts\sports-engine.exe auto-improve `
- --csv data\point-in-time\historical.csv `
- --config AUTONOMY_CONFIG.json `
- --output reports\improvement-run-001
-
-.\.venv\Scripts\sports-engine.exe verify-autonomy `
- --bundle reports\improvement-run-001\champion_bundle.json `
- --ledger reports\improvement-run-001\improvement_ledger.jsonl
+## API
+
+| Method | Path | Purpose |
+|---|---|---|
+| `GET` | `/api/v1/health` | Engine and database integrity |
+| `GET` | `/api/v1/leagues` | League catalog and aliases |
+| `GET` | `/api/v1/leagues/{league}/teams` | Current team picker |
+| `GET` | `/api/v1/week` | Rolling current-week projection board |
+| `POST` | `/api/v1/simulations/hypothetical` | Reproducible matchup simulation |
+| `POST` | `/api/v1/data/refresh` | Bounded public schedule refresh |
+
+OpenAPI is available at `/docs`.
+
+## Determinism and governance
+
+```mermaid
+flowchart TD
+ T["Point-in-time training"] --> S["Bounded population search"]
+ S --> C["Chronological calibration"]
+ C --> W["Expert weights + interval adaptation"]
+ W --> G{"All promotion gates pass?"}
+ G -->|"Yes"| P["Freeze content-addressed champion"]
+ G -->|"No"| R["Retain incumbent + record failure"]
+ P --> O["Prospective shadow outcomes"]
+ R --> O
+ O --> D{"Drift detected?"}
+ D -->|"No"| W
+ D -->|"Yes"| Q["Quarantine, recalibrate, or roll back"]
```
-### Simulate an adaptive champion
+Event streams are immutable and hash-chained. Result envelopes commit to league,
+score, seed, event chain, and evidence metadata. Search cannot mutate rules,
+evaluators, split boundaries, tests, or quality gates.
+
+## Container
```powershell
-.\.venv\Scripts\sports-engine.exe simulate-adaptive `
- --bundle reports\improvement-run-001\champion_bundle.json `
- --league nba --seed 42 --home-strength 1.0 --away-strength 1.0
+docker build -t waterboy:0.4.0 .
+docker run --read-only --tmpfs /tmp `
+ -p 127.0.0.1:8765:8765 `
+ -v waterboy-data:/data `
+ waterboy:0.4.0
```
-## Evidence and documentation
+The image runs as a non-root user. Remote binding is explicit and must sit behind
+an authenticated TLS reverse proxy with rate limits and network policy.
+
+## Documentation
| Document | Purpose |
|---|---|
-| [Architecture](./ARCHITECTURE.md) | Kernel, fidelity, and governance boundaries |
+| [Architecture](./ARCHITECTURE.md) | Kernel, application, data, and fidelity boundaries |
+| [Production runbook](./PRODUCTION_RUNBOOK.md) | Bootstrap, deployment, refresh, backup, and incident response |
+| [Pipeline lineage](./DATA_PIPELINE_LINEAGE.md) | Exact Dummy-to-Waterboy reuse and exclusion boundary |
+| [Data rights and provenance](./DATA_RIGHTS_AND_PROVENANCE.md) | Source, caching, and permitted-use responsibilities |
+| [Known limitations](./KNOWN_LIMITATIONS.md) | What Waterboy does not currently model or prove |
+| [Version 0.4 evidence](./reports/RELEASE_EVIDENCE_V4.md) | Current release and browser verification |
| [Autonomous improvement](./AUTONOMOUS_IMPROVEMENT.md) | Search, weighting, drift, promotion, and rollback |
-| [Quality gates](./QUALITY_GATES.yaml) | Machine-readable non-negotiable release rules |
-| [Release evidence](./reports/RELEASE_EVIDENCE.md) | Current validation and reproducibility snapshot |
-| [Upgrade audit](./reports/AUTONOMOUS_RECURSIVE_UPGRADE_V3.md) | Version 0.3 implementation and results |
-| [Known limitations](./KNOWN_LIMITATIONS.md) | Exact boundaries of demonstrated capability |
-| [Prior art](./PRIOR_ART_REGISTRY.md) | Primary references and defensible distinctions |
## Evidence boundary
-The bundled autonomy arena is deliberately synthetic. It proves that the engine can
-discover, reject, promote, reweight, checkpoint, reload, monitor, and roll back models;
-it does **not** turn synthetic outcomes into a claim of real-world accuracy.
+Waterboy is a sophisticated, working simulation platform—not a magic oracle.
+Its current projections omit live roster, injury, starting-lineup, probable-pitcher,
+coaching, travel, official, and forecast-weather inputs. F2 spatial calibration
+and F3 physics remain blocked pending licensed evidence rather than being fabricated.
-Licensed point-in-time game, roster, injury, event, and tracking data are required
-before held-out predictive certification. F2 spatial agents and F3 physics remain
-blocked rather than fabricated.
-
----
+No wagering, sportsbook, brokerage, order, or capital-execution interface exists.
-**Build measurable worlds. Preserve every decision. Believe only what survives.**
+### Waterboy
+
+**Carry the data. Run the worlds. Show the evidence.**
diff --git a/STATUS.md b/STATUS.md
index 7799431..be78262 100644
--- a/STATUS.md
+++ b/STATUS.md
@@ -2,52 +2,53 @@
## Executive determination
-**VERSION 0.3 ADAPTIVE AUDITED PROVISIONAL MULTI-LEAGUE ENGINE COMPLETE**
-
-Engineering scope is complete for deterministic F0/F1 simulation, portable replay,
-counterfactual branching, batch execution, seasons, tournaments, seven league
-packs, validation, negative controls, fidelity gating, CLI, and the domain-neutral
-future-adaptation bridge.
-
-The audit added strict typing, full result-envelope integrity, temporal and phase
-invariants, confidence-aware reference gates, distributional historical evaluation,
-current rule-source manifests, randomized stress testing, and a distinct analytical
-F0 path.
-
-Version 0.3 adds bounded recursive analytical calibration, deterministic population
-search, online expert weighting, adaptive interval coverage, drift quarantine,
-content-addressed champion checkpoints, rollback lineage, and chained promotion
-ledgers. The seven-league synthetic autonomy arena was exactly reproducible, adjusted
-all seven expert pools, promoted four challengers, and correctly retained three
-incumbents under preregistered guardrails.
-
-Certification is intentionally blocked at held-out real-world calibration because
-the Kimi remote workspace was not exported and no licensed point-in-time event or
-tracking dataset exists in the local handoff.
-
-## Recovery note
-
-The prior remote run stopped during Stage 5 expansion. Its recovery agents remained
-pending for over an hour, and the Kimi memory quota expired before the repository
-was exported. This local build reconstructs the captured architecture and completes
-the executable scope without claiming to possess the lost remote artifacts.
-
-## Verified release evidence
-
-- Static analysis: strict `mypy` and `ruff` passed.
-- Integration/replay suite: 62 tests passed.
-- Reference-anchor run: 500 games per league, 3,500 total; all seven remained
- `provisional_reference_anchor`.
-- Negative and mechanism controls: 49 executed, zero failures.
-- Randomized stress: 14,000 games and 2,003,304 verified events, zero failures.
-- Performance: F0 exceeded 137,000 games/second in every league; single-worker F1
- ranged from 372 to 1,204 games/second with full result-envelope hashing.
-- At two workers, F1 throughput improved 2.18x to 4.99x versus the version 0.1
- evidence benchmark, depending on league.
-- Clean wheel reproduction: package installed in a new virtual environment and
- independently ran league discovery and an NHL simulation.
-- Kimi desktop process: stopped. The separate Kimi CLI process was preserved.
-- Recursive autonomy audit: seven leagues, eight generations each, 224 candidates
- per generation wave, four promotions, three guarded retentions, seven weight
- adjustments, exact bundle reload reproduction, four further prospective-shadow
- weight changes, zero drift alerts, and exact shadow-checkpoint reproduction.
+**VERSION 0.4 PRODUCTION APPLICATION IMPLEMENTED; REAL-WORLD PREDICTIVE
+CERTIFICATION REMAINS PROVISIONAL**
+
+Waterboy now includes a provenance-backed sports-data layer, read-only
+historical import, bounded current-scoreboard ingestion, a deterministic
+recency/opponent-adjusted projection model, current-week boards, hypothetical
+matchups, a versioned HTTP API, and a responsive seven-league dashboard.
+
+Supported user-facing categories are MLB, NFL, NCAAF, NBA, WNBA (`wba` alias),
+NCAAM (`ncaam`/`ncaamb` aliases), and NHL. Projected scores are displayed to one
+decimal place and the higher-probability team is marked with a check.
+
+## Local operational snapshot
+
+On 2026-07-26, the protected Dummy sports-history database was read through a
+query-only SQLite connection and copied into the target repository's independent
+runtime store:
+
+- 179,383 valid historical game rows across seven league packs.
+- 107 unique games on the live seven-day board after a bounded public refresh.
+- 94 scheduled games received pregame projections; live games displayed observed
+ scores and withheld pregame projections.
+- The weekly board completed in approximately 1.1 seconds at the smoke-test
+ simulation tier.
+
+This snapshot is time-sensitive. Operators must run `refresh-data` to obtain a
+new current-week view.
+
+## Evidence boundary
+
+The deterministic kernel, replay envelope, league packs, API contracts, ingestion
+controls, and UI flows are testable software claims. Forecast quality is a
+different claim. The application reports intervals, sample counts, data-through
+times, sparse/stale evidence states, and limitations, but it is not certified as
+best-in-class or profitable.
+
+No licensed point-in-time event/tracking dataset or independent prospective
+held-out campaign is included. F2 tracking calibration, F3 physics/rendering, live
+injury/roster/weather context, betting, and order execution remain outside scope.
+See `KNOWN_LIMITATIONS.md`.
+
+## Operations
+
+The server binds to localhost by default. Remote binding is an explicit action and
+requires an authenticated TLS reverse proxy, rate limits, monitoring, backups, and
+network policy. See `PRODUCTION_RUNBOOK.md` and `DATA_PIPELINE_LINEAGE.md`.
+
+Historic version 0.3 evidence remains preserved in `reports/RELEASE_EVIDENCE.md`.
+Version 0.4 verification is recorded separately in
+`reports/RELEASE_EVIDENCE_V4.md`.
diff --git a/docs/assets/waterboy-dashboard.png b/docs/assets/waterboy-dashboard.png
new file mode 100644
index 0000000..d6ca870
Binary files /dev/null and b/docs/assets/waterboy-dashboard.png differ
diff --git a/pyproject.toml b/pyproject.toml
index 3a9d857..5abd25b 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -3,24 +3,37 @@ requires = ["setuptools>=77"]
build-backend = "setuptools.build_meta"
[project]
-name = "universal-sports-engine"
-version = "0.3.0"
-description = "Deterministic multi-league sports simulation and counterfactual research engine"
+name = "waterboy-sports-engine"
+version = "0.4.0"
+description = "Waterboy: deterministic multi-league sports simulation and projection engine"
readme = "README.md"
requires-python = ">=3.12"
license = "MIT"
-authors = [{ name = "Universal Sports Engine Research Program" }]
-dependencies = []
+authors = [{ name = "Waterboy Research Program" }]
+dependencies = [
+ "fastapi>=0.116,<1",
+ "pydantic>=2.9,<3",
+ "uvicorn>=0.35,<1",
+]
[project.optional-dependencies]
-dev = ["mypy>=1.16", "pytest>=9.0", "ruff>=0.12"]
+dev = [
+ "httpx>=0.28,<1",
+ "mypy>=1.16",
+ "pytest>=9.0",
+ "ruff>=0.12",
+]
[project.scripts]
+waterboy = "universal_sports_engine.cli:main"
sports-engine = "universal_sports_engine.cli:main"
[tool.setuptools.packages.find]
where = ["src"]
+[tool.setuptools.package-data]
+universal_sports_engine = ["web/static/*"]
+
[tool.pytest.ini_options]
addopts = "-ra --strict-markers"
testpaths = ["tests"]
diff --git a/reports/RELEASE_EVIDENCE_V4.md b/reports/RELEASE_EVIDENCE_V4.md
new file mode 100644
index 0000000..07df2b2
--- /dev/null
+++ b/reports/RELEASE_EVIDENCE_V4.md
@@ -0,0 +1,69 @@
+# Waterboy Version 0.4 Release Evidence
+
+## Scope
+
+This record covers the production application added around the existing
+deterministic simulation kernel: data ingestion and provenance, current-week
+projections, hypothetical matchups, HTTP API, dashboard, packaging, and deployment.
+
+## Verification result
+
+The final local release gate completed on 2026-07-26:
+
+| Gate | Result |
+|---|---|
+| Ruff | PASS |
+| Strict mypy (`src`, `scripts`) | PASS, 43 source files |
+| Pytest | PASS, 74 tests, zero warnings |
+| Patch whitespace (`git diff --check`) | PASS |
+| Browser, 1600x1000 and 1440x900 | PASS, showcase, weekly board, and matchup lab |
+| Browser, 390x844 | PASS, responsive weekly board |
+| Browser console | PASS, zero errors and zero warnings |
+| Wheel build | PASS, `waterboy_sports_engine-0.4.0-py3-none-any.whl` |
+| Wheel isolated install | PASS, CLI import and packaged dashboard assets |
+| Container build | NOT RUN, Docker is not installed on the validation host |
+
+The wheel SHA-256 was
+`470e3344ddc1a457f883a85784f96ecaa492ded78e0a79ee4d38270d821dcfe5`.
+Four required static assets and both Waterboy compatibility modules were inspected
+inside the wheel before an independent Python 3.12 installation. The new
+`waterboy` command, legacy `sports-engine` command, `waterboy` package, and original
+`universal_sports_engine` package all passed isolated smoke checks.
+
+Historic version 0.3 kernel evidence remains in `RELEASE_EVIDENCE.md`; it is not
+rewritten here.
+
+## Operational smoke snapshot
+
+On 2026-07-26, a query-only import copied 179,383 valid historical game rows from
+the protected local Dummy database into the target-owned runtime store. A bounded
+seven-day public refresh produced 107 unique games, of which 94 scheduled games
+received pregame projections. In-progress games showed observed scores and
+withheld pregame projections.
+
+This is a time-sensitive operational snapshot, not held-out forecast validation.
+
+## Deterministic release bundle
+
+`scripts/build_release_evidence.py` ran with 500 reference games per league, a
+100-game benchmark tier, seed `20260726`, and two workers:
+
+- seven of seven league reports returned `provisional_reference_anchor`;
+- 49 negative/mechanism controls ran with zero failures;
+- 14 F0/F1 benchmark rows were emitted;
+- release reports and a content-addressed source manifest were written to the local
+ ignored `output/release-evidence-v4` directory.
+
+## UI contract
+
+Scheduled projection cards and hypothetical results display decimal scores with
+one fractional digit. The team with the higher simulated win probability has an
+accessible `✓` immediately beside its name. Final and in-progress observations
+remain integer scores and are labeled as observed rather than projected.
+
+## Required evidence boundary
+
+Passing code, replay, API, browser, and package tests supports software readiness.
+It does not establish forecast superiority, profitability, or real-world
+certification. The model remains provisional until independently governed,
+prospective, point-in-time calibration evidence exists.
diff --git a/scripts/build_release_evidence.py b/scripts/build_release_evidence.py
index 1a4381b..327ab1d 100644
--- a/scripts/build_release_evidence.py
+++ b/scripts/build_release_evidence.py
@@ -37,6 +37,7 @@ def _source_manifest(root: Path) -> tuple[tuple[str, str], ...]:
root / "tests",
root / "scripts",
root / "data",
+ root / "docs",
root / "dummy_bridge",
)
files = tuple(
@@ -55,7 +56,14 @@ def _source_manifest(root: Path) -> tuple[tuple[str, str], ...]:
for path in root.iterdir()
if path.is_file()
and (
- path.name in {".gitattributes", ".gitignore", "LICENSE"}
+ path.name
+ in {
+ ".dockerignore",
+ ".gitattributes",
+ ".gitignore",
+ "Dockerfile",
+ "LICENSE",
+ }
or path.suffix in {".json", ".md", ".toml", ".yaml"}
)
)
diff --git a/src/universal_sports_engine/__init__.py b/src/universal_sports_engine/__init__.py
index e1c433a..4efe776 100644
--- a/src/universal_sports_engine/__init__.py
+++ b/src/universal_sports_engine/__init__.py
@@ -1,4 +1,4 @@
-"""Universal Sports Engine public API."""
+"""Waterboy public API under the original import-compatible namespace."""
from universal_sports_engine.analytical import (
AdaptiveAnalyticalResult,
@@ -14,7 +14,7 @@
simulate_many,
)
-__version__ = "0.3.0"
+__version__ = "0.4.0"
__all__ = [
"AdaptiveAnalyticalResult",
diff --git a/src/universal_sports_engine/autotune.py b/src/universal_sports_engine/autotune.py
index 44ae19b..bfd1113 100644
--- a/src/universal_sports_engine/autotune.py
+++ b/src/universal_sports_engine/autotune.py
@@ -31,7 +31,7 @@
from universal_sports_engine.core.rng import normal
from universal_sports_engine.reference import ANCHORS
-type JsonValue = None | bool | int | float | str | list[JsonValue] | dict[str, JsonValue]
+type JsonValue = bool | int | float | str | list[JsonValue] | dict[str, JsonValue] | None
@dataclass(frozen=True, slots=True)
diff --git a/src/universal_sports_engine/cli.py b/src/universal_sports_engine/cli.py
index 636e3b5..22b8258 100644
--- a/src/universal_sports_engine/cli.py
+++ b/src/universal_sports_engine/cli.py
@@ -5,6 +5,7 @@
import argparse
import json
from dataclasses import asdict
+from datetime import date
from pathlib import Path
from universal_sports_engine.accuracy import (
@@ -32,12 +33,16 @@
from universal_sports_engine.core.types import GameResult
from universal_sports_engine.league_packs.registry import league_identities, league_ids
from universal_sports_engine.season import round_robin_schedule, simulate_season
+from universal_sports_engine.settings import DEFAULT_CACHE_DIR, DEFAULT_DATA_PATH
from universal_sports_engine.simulation import counterfactual_game, simulate_game
from universal_sports_engine.validation import validate_all, validate_league
def _parser() -> argparse.ArgumentParser:
- parser = argparse.ArgumentParser(prog="sports-engine")
+ parser = argparse.ArgumentParser(
+ prog="waterboy",
+ description="Waterboy deterministic multi-league sports simulation engine",
+ )
subparsers = parser.add_subparsers(dest="command", required=True)
subparsers.add_parser("list-leagues")
@@ -106,6 +111,51 @@ def _parser() -> argparse.ArgumentParser:
counterfactual.add_argument("--seed", required=True, type=int)
counterfactual.add_argument("--home-strength", required=True, type=float)
counterfactual.add_argument("--away-strength", required=True, type=float)
+
+ serve = subparsers.add_parser("serve")
+ serve.add_argument("--host", default="127.0.0.1")
+ serve.add_argument("--port", default=8765, type=int)
+ serve.add_argument("--data", default=DEFAULT_DATA_PATH, type=Path)
+ serve.add_argument("--cache", default=DEFAULT_CACHE_DIR, type=Path)
+ serve.add_argument("--allow-remote", action="store_true")
+
+ refresh = subparsers.add_parser("refresh-data")
+ refresh.add_argument("--data", default=DEFAULT_DATA_PATH, type=Path)
+ refresh.add_argument("--cache", default=DEFAULT_CACHE_DIR, type=Path)
+ refresh.add_argument("--start", type=date.fromisoformat)
+ refresh.add_argument("--days", default=7, type=int)
+ refresh.add_argument("--history-days", default=0, type=int)
+ refresh.add_argument("--force", action="store_true")
+
+ import_dummy = subparsers.add_parser("import-dummy-history")
+ import_dummy.add_argument("--data", default=DEFAULT_DATA_PATH, type=Path)
+ import_dummy.add_argument("--cache", default=DEFAULT_CACHE_DIR, type=Path)
+ import_dummy.add_argument(
+ "--source",
+ default=Path(r"C:\src\engine\dummy\runtime\autonomy\sports_history.db"),
+ type=Path,
+ )
+
+ week = subparsers.add_parser("project-week")
+ week.add_argument("--data", default=DEFAULT_DATA_PATH, type=Path)
+ week.add_argument("--cache", default=DEFAULT_CACHE_DIR, type=Path)
+ week.add_argument("--start", type=date.fromisoformat)
+ week.add_argument("--days", default=7, type=int)
+ week.add_argument("--league")
+ week.add_argument("--simulations", default=2_000, type=int)
+ week.add_argument("--seed", default=20_260_726, type=int)
+
+ matchup = subparsers.add_parser("simulate-matchup")
+ matchup.add_argument("--data", default=DEFAULT_DATA_PATH, type=Path)
+ matchup.add_argument("--cache", default=DEFAULT_CACHE_DIR, type=Path)
+ matchup.add_argument("--league", required=True)
+ matchup.add_argument("--home", required=True)
+ matchup.add_argument("--away", required=True)
+ matchup.add_argument("--home-name")
+ matchup.add_argument("--away-name")
+ matchup.add_argument("--neutral-site", action="store_true")
+ matchup.add_argument("--simulations", default=10_000, type=int)
+ matchup.add_argument("--seed", default=20_260_726, type=int)
return parser
@@ -288,6 +338,75 @@ def main() -> None:
"away_score_delta": paired.away_score_delta,
"shared_seed": paired.shared_seed,
}
+ elif args.command == "serve":
+ from universal_sports_engine.web_app import run_server
+
+ run_server(
+ host=args.host,
+ port=args.port,
+ data_path=args.data,
+ cache_dir=args.cache,
+ allow_remote=args.allow_remote,
+ )
+ return
+ elif args.command in {
+ "refresh-data",
+ "import-dummy-history",
+ "project-week",
+ "simulate-matchup",
+ }:
+ from universal_sports_engine.service import SportsEngineService
+
+ service = SportsEngineService.from_paths(args.data, args.cache)
+ if args.command == "refresh-data":
+ if args.history_days:
+ ingest_results = service.pipeline.refresh_history(
+ lookback_days=args.history_days,
+ force=args.force,
+ )
+ output = {
+ "status": (
+ "ok"
+ if all(result.status == "ok" for result in ingest_results)
+ else "partial"
+ ),
+ "results": [asdict(result) for result in ingest_results],
+ "remote_writes": 0,
+ }
+ else:
+ output = service.refresh_current_week(
+ start=args.start,
+ days=args.days,
+ force=args.force,
+ )
+ elif args.command == "import-dummy-history":
+ output = {
+ "status": "ok",
+ "source_mode": "sqlite_query_only",
+ "results": [
+ asdict(result)
+ for result in service.pipeline.import_dummy_history(args.source)
+ ],
+ }
+ elif args.command == "project-week":
+ output = service.week(
+ start=args.start,
+ days=args.days,
+ league=args.league,
+ simulations=args.simulations,
+ seed=args.seed,
+ )
+ else:
+ output = service.hypothetical(
+ league=args.league,
+ home_team_id=args.home,
+ away_team_id=args.away,
+ home_team_name=args.home_name,
+ away_team_name=args.away_name,
+ neutral_site=args.neutral_site,
+ simulations=args.simulations,
+ seed=args.seed,
+ )
else:
raise AssertionError(f"Unhandled command: {args.command}")
print(json.dumps(output, indent=2, sort_keys=True))
diff --git a/src/universal_sports_engine/data/__init__.py b/src/universal_sports_engine/data/__init__.py
new file mode 100644
index 0000000..85dc39b
--- /dev/null
+++ b/src/universal_sports_engine/data/__init__.py
@@ -0,0 +1,13 @@
+"""Read-only public sports data ingestion and local persistence."""
+
+from universal_sports_engine.data.models import GameRecord, IngestResult, TeamRecord
+from universal_sports_engine.data.pipeline import SportsDataPipeline
+from universal_sports_engine.data.store import SportsDataStore
+
+__all__ = [
+ "GameRecord",
+ "IngestResult",
+ "SportsDataPipeline",
+ "SportsDataStore",
+ "TeamRecord",
+]
diff --git a/src/universal_sports_engine/data/espn.py b/src/universal_sports_engine/data/espn.py
new file mode 100644
index 0000000..537bc62
--- /dev/null
+++ b/src/universal_sports_engine/data/espn.py
@@ -0,0 +1,312 @@
+"""Keyless, read-only ESPN scoreboard adapter.
+
+The normalized contract is derived from Dummy's proven sports intake boundary,
+but this module is standalone and contains no market, sportsbook, execution, or
+credential behavior.
+"""
+
+from __future__ import annotations
+
+import hashlib
+import json
+import time
+from collections.abc import Callable
+from datetime import UTC, date, datetime
+from pathlib import Path
+from typing import Any, cast
+from urllib.error import HTTPError, URLError
+from urllib.parse import urlencode
+from urllib.request import Request, urlopen
+
+from universal_sports_engine.data.models import GameRecord
+from universal_sports_engine.league_packs.registry import normalize_league_id
+
+LEAGUE_TO_ESPN: dict[str, tuple[str, str]] = {
+ "mlb": ("baseball", "mlb"),
+ "nba": ("basketball", "nba"),
+ "wnba": ("basketball", "wnba"),
+ "ncaambb": ("basketball", "mens-college-basketball"),
+ "nfl": ("football", "nfl"),
+ "ncaaf": ("football", "college-football"),
+ "nhl": ("hockey", "nhl"),
+}
+
+USER_AGENT = (
+ "WaterboySportsEngine/0.4 "
+ "(read-only public schedule research; https://github.com/ObtuseAI/waterboy)"
+)
+MAX_RESPONSE_BYTES = 32 * 1024 * 1024
+Transport = Callable[[str, float], bytes]
+
+
+class ScoreboardFeedError(RuntimeError):
+ """A public feed failed or returned an invalid payload."""
+
+
+def _utc_now() -> datetime:
+ return datetime.now(UTC)
+
+
+def _as_dict(value: object) -> dict[str, Any]:
+ return cast(dict[str, Any], value) if isinstance(value, dict) else {}
+
+
+def _as_list(value: object) -> list[Any]:
+ return value if isinstance(value, list) else []
+
+
+def _integer_score(value: object) -> int | None:
+ if value is None or isinstance(value, bool):
+ return None
+ try:
+ numeric = float(str(value))
+ except ValueError:
+ return None
+ return int(numeric) if numeric >= 0 and numeric.is_integer() else None
+
+
+def _season_from(event: dict[str, Any], start_time: str) -> int | None:
+ season = _as_dict(event.get("season")).get("year")
+ try:
+ return int(str(season)) if season is not None else None
+ except (TypeError, ValueError):
+ try:
+ return datetime.fromisoformat(start_time.replace("Z", "+00:00")).year
+ except ValueError:
+ return None
+
+
+def scoreboard_url(league: str) -> str:
+ normalized = normalize_league_id(league)
+ try:
+ sport, espn_league = LEAGUE_TO_ESPN[normalized]
+ except KeyError as exc:
+ raise ValueError(f"ESPN scoreboard is not configured for league: {league!r}") from exc
+ return f"https://site.api.espn.com/apis/site/v2/sports/{sport}/{espn_league}/scoreboard"
+
+
+def parse_scoreboard(
+ league: str,
+ payload: dict[str, Any],
+ *,
+ observed_at: str,
+ source_url: str,
+) -> tuple[GameRecord, ...]:
+ """Normalize a scoreboard payload while rejecting incomplete competitions."""
+
+ normalized = normalize_league_id(league)
+ games: list[GameRecord] = []
+ for raw_event in _as_list(payload.get("events")):
+ event = _as_dict(raw_event)
+ event_id = str(event.get("id") or "").strip()
+ start_time = str(event.get("date") or "").strip()
+ competitions = _as_list(event.get("competitions"))
+ if not event_id or not start_time or not competitions:
+ continue
+ competition = _as_dict(competitions[0])
+ home: dict[str, Any] | None = None
+ away: dict[str, Any] | None = None
+ for raw_competitor in _as_list(competition.get("competitors")):
+ competitor = _as_dict(raw_competitor)
+ if competitor.get("homeAway") == "home":
+ home = competitor
+ elif competitor.get("homeAway") == "away":
+ away = competitor
+ if home is None or away is None:
+ continue
+ home_team = _as_dict(home.get("team"))
+ away_team = _as_dict(away.get("team"))
+ home_id = str(home_team.get("abbreviation") or home_team.get("id") or "").upper()
+ away_id = str(away_team.get("abbreviation") or away_team.get("id") or "").upper()
+ if not home_id or not away_id or home_id == away_id:
+ continue
+ state = str(
+ _as_dict(_as_dict(competition.get("status")).get("type")).get("state") or "pre"
+ ).lower()
+ status = {"pre": "scheduled", "in": "in_progress", "post": "final"}.get(
+ state, "scheduled"
+ )
+ home_score = _integer_score(home.get("score")) if status != "scheduled" else None
+ away_score = _integer_score(away.get("score")) if status != "scheduled" else None
+ if status == "final" and (home_score is None or away_score is None):
+ status = "unresolved"
+ venue_data = _as_dict(competition.get("venue"))
+ canonical_event = json.dumps(event, sort_keys=True, separators=(",", ":")).encode()
+ games.append(
+ GameRecord(
+ game_id=f"espn:{normalized}:{event_id}",
+ league=normalized,
+ season=_season_from(event, start_time),
+ start_time=start_time,
+ status=status,
+ home_team_id=home_id,
+ away_team_id=away_id,
+ home_name=str(
+ home_team.get("displayName")
+ or home_team.get("shortDisplayName")
+ or home_id
+ ),
+ away_name=str(
+ away_team.get("displayName")
+ or away_team.get("shortDisplayName")
+ or away_id
+ ),
+ home_score=home_score,
+ away_score=away_score,
+ neutral_site=bool(competition.get("neutralSite", False)),
+ venue=str(venue_data.get("fullName")) if venue_data.get("fullName") else None,
+ source="espn_public_scoreboard",
+ source_url=source_url,
+ observed_at=observed_at,
+ payload_hash=hashlib.sha256(canonical_event).hexdigest(),
+ )
+ )
+ return tuple(games)
+
+
+def _default_transport(url: str, timeout_seconds: float) -> bytes:
+ request = Request(
+ url,
+ headers={"Accept": "application/json", "User-Agent": USER_AGENT},
+ method="GET",
+ )
+ with urlopen(request, timeout=timeout_seconds) as response:
+ content_length = response.headers.get("Content-Length")
+ if content_length is not None and int(content_length) > MAX_RESPONSE_BYTES:
+ raise ScoreboardFeedError("Scoreboard response exceeds the configured size limit")
+ body = cast(bytes, response.read(MAX_RESPONSE_BYTES + 1))
+ if len(body) > MAX_RESPONSE_BYTES:
+ raise ScoreboardFeedError("Scoreboard response exceeds the configured size limit")
+ return body
+
+
+class EspnScoreboardClient:
+ """Bounded public GET client with deterministic on-disk response caching."""
+
+ def __init__(
+ self,
+ cache_dir: Path,
+ *,
+ cache_ttl_seconds: int = 900,
+ timeout_seconds: float = 20.0,
+ max_retries: int = 2,
+ transport: Transport | None = None,
+ ) -> None:
+ self.cache_dir = cache_dir
+ self.cache_dir.mkdir(parents=True, exist_ok=True)
+ self.cache_ttl_seconds = cache_ttl_seconds
+ self.timeout_seconds = timeout_seconds
+ self.max_retries = max_retries
+ self.transport = transport or _default_transport
+
+ def _cache_path(self, url: str) -> Path:
+ return self.cache_dir / f"{hashlib.sha256(url.encode()).hexdigest()}.json"
+
+ def _cached(self, path: Path) -> tuple[dict[str, Any], str] | None:
+ try:
+ modified_at = path.stat().st_mtime
+ age = time.time() - modified_at
+ if age > self.cache_ttl_seconds:
+ return None
+ value = json.loads(path.read_text(encoding="utf-8"))
+ except (OSError, ValueError, json.JSONDecodeError):
+ return None
+ document = _as_dict(value)
+ if (
+ document.get("cache_version") == 1
+ and isinstance(document.get("observed_at"), str)
+ and isinstance(document.get("payload"), dict)
+ ):
+ return _as_dict(document["payload"]), str(document["observed_at"])
+ if document:
+ legacy_observed_at = datetime.fromtimestamp(modified_at, UTC).isoformat()
+ return document, legacy_observed_at
+ return None
+
+ def _write_cache(
+ self,
+ path: Path,
+ payload: dict[str, Any],
+ observed_at: str,
+ ) -> None:
+ temporary = path.with_suffix(".tmp")
+ temporary.write_text(
+ json.dumps(
+ {
+ "cache_version": 1,
+ "observed_at": observed_at,
+ "payload": payload,
+ },
+ separators=(",", ":"),
+ sort_keys=True,
+ ),
+ encoding="utf-8",
+ )
+ temporary.replace(path)
+
+ def games(
+ self,
+ league: str,
+ start: date,
+ end: date,
+ *,
+ force: bool = False,
+ ) -> tuple[GameRecord, ...]:
+ if end < start:
+ raise ValueError(f"Scoreboard end precedes start: {start} > {end}")
+ if (end - start).days > 31:
+ raise ValueError("One scoreboard request may cover at most 32 days")
+ base_url = scoreboard_url(league)
+ parameters = {
+ "dates": (
+ f"{start:%Y%m%d}"
+ if start == end
+ else f"{start:%Y%m%d}-{end:%Y%m%d}"
+ ),
+ "limit": "1000",
+ }
+ request_url = f"{base_url}?{urlencode(parameters)}"
+ cache_path = self._cache_path(request_url)
+ cached = None if force else self._cached(cache_path)
+ if cached is None:
+ body: bytes | None = None
+ last_error: Exception | None = None
+ for attempt in range(self.max_retries + 1):
+ try:
+ body = self.transport(request_url, self.timeout_seconds)
+ break
+ except (HTTPError, URLError, TimeoutError, OSError) as exc:
+ last_error = exc
+ if attempt < self.max_retries:
+ time.sleep(0.5 * (2**attempt))
+ if body is None:
+ if start != end:
+ merged: dict[str, GameRecord] = {}
+ cursor = start
+ while cursor <= end:
+ for game in self.games(league, cursor, cursor, force=force):
+ merged[game.game_id] = game
+ cursor = date.fromordinal(cursor.toordinal() + 1)
+ return tuple(
+ sorted(merged.values(), key=lambda game: (game.start_time, game.game_id))
+ )
+ raise ScoreboardFeedError(
+ f"ESPN scoreboard request failed after retries: {type(last_error).__name__}"
+ ) from last_error
+ try:
+ parsed = json.loads(body)
+ except (UnicodeDecodeError, json.JSONDecodeError) as exc:
+ raise ScoreboardFeedError("ESPN scoreboard returned invalid JSON") from exc
+ payload = _as_dict(parsed)
+ if not payload:
+ raise ScoreboardFeedError("ESPN scoreboard returned an empty object")
+ observed_at = _utc_now().isoformat()
+ self._write_cache(cache_path, payload, observed_at)
+ else:
+ payload, observed_at = cached
+ return parse_scoreboard(
+ league,
+ payload,
+ observed_at=observed_at,
+ source_url=request_url,
+ )
diff --git a/src/universal_sports_engine/data/models.py b/src/universal_sports_engine/data/models.py
new file mode 100644
index 0000000..dffca2d
--- /dev/null
+++ b/src/universal_sports_engine/data/models.py
@@ -0,0 +1,54 @@
+"""Typed records shared by sports data sources, storage, and projection."""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+
+
+@dataclass(frozen=True, slots=True)
+class GameRecord:
+ """One source-backed game observation.
+
+ Scores are populated only for completed or in-progress games. Scheduled
+ records never receive synthetic scores in the data layer.
+ """
+
+ game_id: str
+ league: str
+ season: int | None
+ start_time: str
+ status: str
+ home_team_id: str
+ away_team_id: str
+ home_name: str
+ away_name: str
+ home_score: int | None
+ away_score: int | None
+ neutral_site: bool
+ venue: str | None
+ source: str
+ source_url: str
+ observed_at: str
+ payload_hash: str
+
+
+@dataclass(frozen=True, slots=True)
+class TeamRecord:
+ league: str
+ team_id: str
+ display_name: str
+ games: int
+ last_game_at: str | None
+
+
+@dataclass(frozen=True, slots=True)
+class IngestResult:
+ source: str
+ league: str
+ window_start: str
+ window_end: str
+ fetched: int
+ stored: int
+ status: str
+ observed_at: str
+ error: str | None = None
diff --git a/src/universal_sports_engine/data/pipeline.py b/src/universal_sports_engine/data/pipeline.py
new file mode 100644
index 0000000..7c54e2d
--- /dev/null
+++ b/src/universal_sports_engine/data/pipeline.py
@@ -0,0 +1,255 @@
+"""Standalone sports data pipeline with an optional read-only Dummy bootstrap."""
+
+from __future__ import annotations
+
+import hashlib
+import json
+import sqlite3
+from collections.abc import Iterable
+from contextlib import closing
+from datetime import UTC, date, datetime, timedelta
+from pathlib import Path
+
+from universal_sports_engine.data.espn import EspnScoreboardClient
+from universal_sports_engine.data.models import GameRecord, IngestResult
+from universal_sports_engine.data.store import SportsDataStore
+from universal_sports_engine.league_packs.registry import league_ids, normalize_league_id
+
+DUMMY_LEAGUE_ALIASES = {"ncaamb": "ncaambb"}
+
+
+def _utc_now_iso() -> str:
+ return datetime.now(UTC).isoformat()
+
+
+def _chunks(start: date, end: date, days: int) -> Iterable[tuple[date, date]]:
+ cursor = start
+ while cursor <= end:
+ chunk_end = min(end, cursor + timedelta(days=days - 1))
+ yield cursor, chunk_end
+ cursor = chunk_end + timedelta(days=1)
+
+
+class SportsDataPipeline:
+ """Orchestrate isolated, fail-soft league ingestion runs."""
+
+ def __init__(self, store: SportsDataStore, client: EspnScoreboardClient) -> None:
+ self.store = store
+ self.client = client
+
+ def refresh_window(
+ self,
+ start: date,
+ end: date,
+ *,
+ leagues: tuple[str, ...] | None = None,
+ force: bool = False,
+ ) -> tuple[IngestResult, ...]:
+ selected = leagues or league_ids()
+ results: list[IngestResult] = []
+ for requested in selected:
+ league = normalize_league_id(requested)
+ observed_at = _utc_now_iso()
+ try:
+ games = self.client.games(league, start, end, force=force)
+ stored = self.store.upsert_games(games)
+ result = IngestResult(
+ source="espn_public_scoreboard",
+ league=league,
+ window_start=start.isoformat(),
+ window_end=end.isoformat(),
+ fetched=len(games),
+ stored=stored,
+ status="ok",
+ observed_at=observed_at,
+ )
+ except Exception as exc:
+ result = IngestResult(
+ source="espn_public_scoreboard",
+ league=league,
+ window_start=start.isoformat(),
+ window_end=end.isoformat(),
+ fetched=0,
+ stored=0,
+ status="error",
+ observed_at=observed_at,
+ error=f"{type(exc).__name__}: {str(exc)[:240]}",
+ )
+ self.store.record_ingest(result)
+ results.append(result)
+ return tuple(results)
+
+ def refresh_current_week(
+ self,
+ *,
+ start: date | None = None,
+ days: int = 7,
+ force: bool = False,
+ ) -> tuple[IngestResult, ...]:
+ if not 1 <= days <= 14:
+ raise ValueError(f"Current-week days must be within [1, 14]: {days}")
+ window_start = start or datetime.now(UTC).date()
+ return self.refresh_window(
+ window_start,
+ window_start + timedelta(days=days - 1),
+ force=force,
+ )
+
+ def refresh_history(
+ self,
+ *,
+ lookback_days: int = 180,
+ chunk_days: int = 28,
+ leagues: tuple[str, ...] | None = None,
+ force: bool = False,
+ ) -> tuple[IngestResult, ...]:
+ if not 1 <= lookback_days <= 1_095:
+ raise ValueError(f"History lookback must be within [1, 1095]: {lookback_days}")
+ if not 1 <= chunk_days <= 32:
+ raise ValueError(f"History chunk size must be within [1, 32]: {chunk_days}")
+ end = datetime.now(UTC).date()
+ start = end - timedelta(days=lookback_days)
+ results: list[IngestResult] = []
+ for chunk_start, chunk_end in _chunks(start, end, chunk_days):
+ results.extend(
+ self.refresh_window(
+ chunk_start,
+ chunk_end,
+ leagues=leagues,
+ force=force,
+ )
+ )
+ return tuple(results)
+
+ def import_dummy_history(
+ self,
+ source_database: Path,
+ *,
+ batch_size: int = 2_000,
+ ) -> tuple[IngestResult, ...]:
+ """Copy sports rows through a query-only connection.
+
+ The Dummy database is never migrated, vacuumed, checkpointed, or
+ modified. All normalized writes go only to this engine's store.
+ """
+
+ if batch_size <= 0:
+ raise ValueError(f"batch_size must be positive: {batch_size}")
+ if not source_database.is_file():
+ raise FileNotFoundError(f"Dummy sports history database is missing: {source_database}")
+ observed_at = _utc_now_iso()
+ counts: dict[str, int] = {}
+ with closing(
+ sqlite3.connect(
+ f"file:{source_database.as_posix()}?mode=ro",
+ uri=True,
+ timeout=5,
+ )
+ ) as connection:
+ connection.row_factory = sqlite3.Row
+ connection.execute("PRAGMA query_only=ON")
+ cursor = connection.execute(
+ """
+ SELECT game_id, league, season, start_time, status, home, away,
+ home_score, away_score, venue, neutral, source,
+ provenance_url, result_available_at, received_at, as_of, extra
+ FROM games ORDER BY league, start_time, game_id
+ """
+ )
+ while True:
+ rows = cursor.fetchmany(batch_size)
+ if not rows:
+ break
+ normalized_rows: list[GameRecord] = []
+ for row in rows:
+ if not row["home"] or not row["away"] or not row["start_time"]:
+ continue
+ home_team_id = str(row["home"]).upper()
+ away_team_id = str(row["away"]).upper()
+ if home_team_id == away_team_id:
+ continue
+ league = DUMMY_LEAGUE_ALIASES.get(
+ str(row["league"]), normalize_league_id(str(row["league"]))
+ )
+ if league not in league_ids():
+ continue
+ raw_status = str(row["status"]).lower()
+ status = {
+ "post": "final",
+ "final": "final",
+ "in": "in_progress",
+ "in_progress": "in_progress",
+ "scheduled": "scheduled",
+ "pre": "scheduled",
+ }.get(raw_status, "unresolved")
+ source = str(row["source"] or "dummy_history")
+ external_id = str(row["game_id"])
+ identity = f"{source}:{league}:{external_id}"
+ extra = str(row["extra"] or "")
+ content = json.dumps(
+ {
+ "game_id": external_id,
+ "league": league,
+ "start_time": row["start_time"],
+ "status": status,
+ "home": row["home"],
+ "away": row["away"],
+ "home_score": row["home_score"],
+ "away_score": row["away_score"],
+ "extra": extra,
+ },
+ sort_keys=True,
+ separators=(",", ":"),
+ ).encode()
+ normalized_rows.append(
+ GameRecord(
+ game_id=(
+ f"espn:{league}:{external_id}"
+ if source == "espn"
+ else hashlib.sha256(identity.encode()).hexdigest()
+ ),
+ league=league,
+ season=int(row["season"]) if row["season"] is not None else None,
+ start_time=str(row["start_time"]),
+ status=status,
+ home_team_id=home_team_id,
+ away_team_id=away_team_id,
+ home_name=str(row["home"]),
+ away_name=str(row["away"]),
+ home_score=(
+ int(row["home_score"]) if row["home_score"] is not None else None
+ ),
+ away_score=(
+ int(row["away_score"]) if row["away_score"] is not None else None
+ ),
+ neutral_site=bool(row["neutral"]),
+ venue=str(row["venue"]) if row["venue"] else None,
+ source=f"dummy_read_only:{source}",
+ source_url=str(row["provenance_url"] or "local-read-only-import"),
+ observed_at=str(
+ row["received_at"]
+ or row["result_available_at"]
+ or row["as_of"]
+ or observed_at
+ ),
+ payload_hash=hashlib.sha256(content).hexdigest(),
+ )
+ )
+ counts[league] = counts.get(league, 0) + 1
+ self.store.upsert_games(normalized_rows)
+ results: list[IngestResult] = []
+ for league in league_ids():
+ count = counts.get(league, 0)
+ result = IngestResult(
+ source="dummy_sports_history_read_only_import",
+ league=league,
+ window_start="historical",
+ window_end="historical",
+ fetched=count,
+ stored=count,
+ status="ok",
+ observed_at=observed_at,
+ )
+ self.store.record_ingest(result)
+ results.append(result)
+ return tuple(results)
diff --git a/src/universal_sports_engine/data/store.py b/src/universal_sports_engine/data/store.py
new file mode 100644
index 0000000..a73313d
--- /dev/null
+++ b/src/universal_sports_engine/data/store.py
@@ -0,0 +1,312 @@
+"""SQLite-backed local sports history with explicit source provenance."""
+
+from __future__ import annotations
+
+import sqlite3
+from collections.abc import Iterable
+from contextlib import closing
+from pathlib import Path
+from typing import Any, cast
+
+from universal_sports_engine.data.models import GameRecord, IngestResult, TeamRecord
+from universal_sports_engine.league_packs.registry import normalize_league_id
+
+SCHEMA_VERSION = 1
+
+
+class SportsDataStore:
+ """Small, concurrency-safe local history database.
+
+ Connections are short lived so FastAPI workers and refresh commands do not
+ share mutable sqlite connection state.
+ """
+
+ def __init__(self, path: Path) -> None:
+ self.path = path
+ self.path.parent.mkdir(parents=True, exist_ok=True)
+ self.initialize()
+
+ def _connect(self, *, read_only: bool = False) -> sqlite3.Connection:
+ if read_only:
+ connection = sqlite3.connect(
+ f"file:{self.path.as_posix()}?mode=ro",
+ uri=True,
+ timeout=5,
+ )
+ else:
+ connection = sqlite3.connect(self.path, timeout=10)
+ connection.row_factory = sqlite3.Row
+ connection.execute("PRAGMA busy_timeout=5000")
+ connection.execute("PRAGMA foreign_keys=ON")
+ return connection
+
+ def initialize(self) -> None:
+ with closing(self._connect()) as connection:
+ connection.execute("PRAGMA journal_mode=WAL")
+ connection.executescript(
+ """
+ CREATE TABLE IF NOT EXISTS metadata(
+ key TEXT PRIMARY KEY,
+ value TEXT NOT NULL
+ );
+ CREATE TABLE IF NOT EXISTS games(
+ game_id TEXT PRIMARY KEY,
+ league TEXT NOT NULL,
+ season INTEGER,
+ start_time TEXT NOT NULL,
+ status TEXT NOT NULL,
+ home_team_id TEXT NOT NULL,
+ away_team_id TEXT NOT NULL,
+ home_name TEXT NOT NULL,
+ away_name TEXT NOT NULL,
+ home_score INTEGER,
+ away_score INTEGER,
+ neutral_site INTEGER NOT NULL DEFAULT 0,
+ venue TEXT,
+ source TEXT NOT NULL,
+ source_url TEXT NOT NULL,
+ observed_at TEXT NOT NULL,
+ payload_hash TEXT NOT NULL,
+ CHECK(home_team_id <> away_team_id),
+ CHECK(home_score IS NULL OR home_score >= 0),
+ CHECK(away_score IS NULL OR away_score >= 0)
+ );
+ CREATE INDEX IF NOT EXISTS ix_games_league_start
+ ON games(league, start_time);
+ CREATE INDEX IF NOT EXISTS ix_games_league_status
+ ON games(league, status, start_time);
+ CREATE TABLE IF NOT EXISTS ingest_runs(
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ source TEXT NOT NULL,
+ league TEXT NOT NULL,
+ window_start TEXT NOT NULL,
+ window_end TEXT NOT NULL,
+ fetched INTEGER NOT NULL,
+ stored INTEGER NOT NULL,
+ status TEXT NOT NULL,
+ observed_at TEXT NOT NULL,
+ error TEXT
+ );
+ CREATE INDEX IF NOT EXISTS ix_ingest_source_league
+ ON ingest_runs(source, league, id DESC);
+ """
+ )
+ connection.execute(
+ """
+ INSERT INTO metadata(key, value) VALUES('schema_version', ?)
+ ON CONFLICT(key) DO UPDATE SET value=excluded.value
+ """,
+ (str(SCHEMA_VERSION),),
+ )
+ connection.commit()
+
+ def upsert_games(self, games: Iterable[GameRecord]) -> int:
+ rows = tuple(games)
+ if not rows:
+ return 0
+ values = [
+ (
+ game.game_id,
+ game.league,
+ game.season,
+ game.start_time,
+ game.status,
+ game.home_team_id,
+ game.away_team_id,
+ game.home_name,
+ game.away_name,
+ game.home_score,
+ game.away_score,
+ int(game.neutral_site),
+ game.venue,
+ game.source,
+ game.source_url,
+ game.observed_at,
+ game.payload_hash,
+ )
+ for game in rows
+ ]
+ with closing(self._connect()) as connection:
+ connection.executemany(
+ """
+ INSERT INTO games(
+ game_id, league, season, start_time, status,
+ home_team_id, away_team_id, home_name, away_name,
+ home_score, away_score, neutral_site, venue,
+ source, source_url, observed_at, payload_hash
+ ) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
+ ON CONFLICT(game_id) DO UPDATE SET
+ season=excluded.season,
+ start_time=excluded.start_time,
+ status=excluded.status,
+ home_team_id=excluded.home_team_id,
+ away_team_id=excluded.away_team_id,
+ home_name=excluded.home_name,
+ away_name=excluded.away_name,
+ home_score=excluded.home_score,
+ away_score=excluded.away_score,
+ neutral_site=excluded.neutral_site,
+ venue=excluded.venue,
+ source=excluded.source,
+ source_url=excluded.source_url,
+ observed_at=excluded.observed_at,
+ payload_hash=excluded.payload_hash
+ """,
+ values,
+ )
+ connection.commit()
+ return len(rows)
+
+ def record_ingest(self, result: IngestResult) -> None:
+ with closing(self._connect()) as connection:
+ connection.execute(
+ """
+ INSERT INTO ingest_runs(
+ source, league, window_start, window_end, fetched, stored,
+ status, observed_at, error
+ ) VALUES(?,?,?,?,?,?,?,?,?)
+ """,
+ (
+ result.source,
+ result.league,
+ result.window_start,
+ result.window_end,
+ result.fetched,
+ result.stored,
+ result.status,
+ result.observed_at,
+ result.error,
+ ),
+ )
+ connection.commit()
+
+ @staticmethod
+ def _game(row: sqlite3.Row) -> GameRecord:
+ return GameRecord(
+ game_id=str(row["game_id"]),
+ league=str(row["league"]),
+ season=int(row["season"]) if row["season"] is not None else None,
+ start_time=str(row["start_time"]),
+ status=str(row["status"]),
+ home_team_id=str(row["home_team_id"]),
+ away_team_id=str(row["away_team_id"]),
+ home_name=str(row["home_name"]),
+ away_name=str(row["away_name"]),
+ home_score=int(row["home_score"]) if row["home_score"] is not None else None,
+ away_score=int(row["away_score"]) if row["away_score"] is not None else None,
+ neutral_site=bool(row["neutral_site"]),
+ venue=str(row["venue"]) if row["venue"] is not None else None,
+ source=str(row["source"]),
+ source_url=str(row["source_url"]),
+ observed_at=str(row["observed_at"]),
+ payload_hash=str(row["payload_hash"]),
+ )
+
+ def games_between(
+ self,
+ start_time: str,
+ end_time: str,
+ *,
+ league: str | None = None,
+ ) -> tuple[GameRecord, ...]:
+ query = "SELECT * FROM games WHERE start_time >= ? AND start_time < ?"
+ parameters: list[object] = [start_time, end_time]
+ if league is not None:
+ query += " AND league = ?"
+ parameters.append(normalize_league_id(league))
+ query += " ORDER BY start_time, league, game_id"
+ with closing(self._connect(read_only=True)) as connection:
+ rows = connection.execute(query, parameters).fetchall()
+ return tuple(self._game(row) for row in rows)
+
+ def completed_games(
+ self,
+ league: str,
+ *,
+ before: str | None = None,
+ limit: int = 10_000,
+ ) -> tuple[GameRecord, ...]:
+ normalized = normalize_league_id(league)
+ query = (
+ "SELECT * FROM games WHERE league=? AND status='final' "
+ "AND home_score IS NOT NULL AND away_score IS NOT NULL"
+ )
+ parameters: list[object] = [normalized]
+ if before is not None:
+ query += " AND start_time < ?"
+ parameters.append(before)
+ query += " ORDER BY start_time DESC LIMIT ?"
+ parameters.append(max(1, limit))
+ with closing(self._connect(read_only=True)) as connection:
+ rows = connection.execute(query, parameters).fetchall()
+ return tuple(reversed(tuple(self._game(row) for row in rows)))
+
+ def teams(self, league: str) -> tuple[TeamRecord, ...]:
+ normalized = normalize_league_id(league)
+ with closing(self._connect(read_only=True)) as connection:
+ rows = connection.execute(
+ """
+ WITH appearances AS (
+ SELECT league, home_team_id AS team_id, home_name AS display_name,
+ start_time
+ FROM games WHERE league=?
+ UNION ALL
+ SELECT league, away_team_id AS team_id, away_name AS display_name,
+ start_time
+ FROM games WHERE league=?
+ ),
+ latest AS (
+ SELECT league, team_id, MAX(start_time) AS last_game_at,
+ COUNT(*) AS games
+ FROM appearances GROUP BY league, team_id
+ )
+ SELECT latest.league, latest.team_id, appearances.display_name,
+ latest.games, latest.last_game_at
+ FROM latest
+ JOIN appearances
+ ON appearances.league=latest.league
+ AND appearances.team_id=latest.team_id
+ AND appearances.start_time=latest.last_game_at
+ GROUP BY latest.league, latest.team_id
+ ORDER BY appearances.display_name, latest.team_id
+ """,
+ (normalized, normalized),
+ ).fetchall()
+ return tuple(
+ TeamRecord(
+ league=str(row["league"]),
+ team_id=str(row["team_id"]),
+ display_name=str(row["display_name"]),
+ games=int(row["games"]),
+ last_game_at=str(row["last_game_at"]) if row["last_game_at"] else None,
+ )
+ for row in rows
+ )
+
+ def health(self) -> dict[str, Any]:
+ with closing(self._connect(read_only=True)) as connection:
+ integrity = str(connection.execute("PRAGMA quick_check").fetchone()[0])
+ game_rows = connection.execute(
+ """
+ SELECT league, COUNT(*) AS games,
+ SUM(CASE WHEN status='final' THEN 1 ELSE 0 END) AS finals,
+ MAX(observed_at) AS last_observed_at
+ FROM games GROUP BY league ORDER BY league
+ """
+ ).fetchall()
+ latest_ingest = connection.execute(
+ """
+ SELECT source, league, status, observed_at, error
+ FROM ingest_runs ORDER BY id DESC LIMIT 1
+ """
+ ).fetchone()
+ return {
+ "database": self.path.name,
+ "integrity": integrity,
+ "leagues": [dict(cast(Iterable[tuple[str, Any]], row)) for row in game_rows],
+ "latest_ingest": (
+ dict(cast(Iterable[tuple[str, Any]], latest_ingest))
+ if latest_ingest is not None
+ else None
+ ),
+ }
diff --git a/src/universal_sports_engine/league_packs/common.py b/src/universal_sports_engine/league_packs/common.py
index 62aaa5f..6ff30d1 100644
--- a/src/universal_sports_engine/league_packs/common.py
+++ b/src/universal_sports_engine/league_packs/common.py
@@ -17,7 +17,7 @@
payload,
)
-ENGINE_VERSION = "0.3.0"
+ENGINE_VERSION = "0.4.0"
@dataclass(frozen=True, slots=True)
diff --git a/src/universal_sports_engine/league_packs/registry.py b/src/universal_sports_engine/league_packs/registry.py
index 2bbc96e..14c3c7f 100644
--- a/src/universal_sports_engine/league_packs/registry.py
+++ b/src/universal_sports_engine/league_packs/registry.py
@@ -15,6 +15,20 @@
Simulator = Callable[[int, float, float], GameResult]
+LEAGUE_ALIASES = {
+ "wba": "wnba",
+ "ncaam": "ncaambb",
+ "ncaamb": "ncaambb",
+ "ncaa-m": "ncaambb",
+}
+
+
+def normalize_league_id(league: str) -> str:
+ """Normalize supported public aliases to the stable internal league ID."""
+
+ normalized = league.strip().lower()
+ return LEAGUE_ALIASES.get(normalized, normalized)
+
def league_identities() -> tuple[LeagueIdentity, ...]:
"""Return every supported league in stable order."""
@@ -31,7 +45,7 @@ def league_ids() -> tuple[str, ...]:
def simulator_for(league: str) -> Simulator:
"""Resolve a simulator or fail explicitly with the supported set."""
- normalized = league.strip().lower()
+ normalized = normalize_league_id(league)
if normalized == "mlb":
return simulate_mlb
if normalized in BASKETBALL_CONFIGS:
diff --git a/src/universal_sports_engine/projection.py b/src/universal_sports_engine/projection.py
new file mode 100644
index 0000000..771f2f8
--- /dev/null
+++ b/src/universal_sports_engine/projection.py
@@ -0,0 +1,493 @@
+"""Team-aware probabilistic score projections and hypothetical matchups."""
+
+from __future__ import annotations
+
+import hashlib
+import json
+import math
+import statistics
+from dataclasses import asdict, dataclass
+from datetime import UTC, datetime
+
+from universal_sports_engine.analytical import simulate_analytical
+from universal_sports_engine.core.rng import normal
+from universal_sports_engine.data.models import GameRecord
+from universal_sports_engine.data.store import SportsDataStore
+from universal_sports_engine.league_packs.registry import normalize_league_id
+from universal_sports_engine.reference import ANCHORS, ReferenceAnchor
+
+PROJECTION_MODEL_VERSION = "hierarchical-recency-opponent-adjusted-v1"
+
+
+@dataclass(frozen=True, slots=True)
+class ProjectionConfig:
+ half_life_days: float
+ prior_games: float
+ posterior_strength_sd: float
+ minimum_effective_games: float
+
+
+PROJECTION_CONFIGS = {
+ "mlb": ProjectionConfig(35.0, 12.0, 0.10, 8.0),
+ "nba": ProjectionConfig(55.0, 10.0, 0.08, 8.0),
+ "wnba": ProjectionConfig(45.0, 8.0, 0.09, 6.0),
+ "ncaambb": ProjectionConfig(65.0, 10.0, 0.11, 8.0),
+ "nfl": ProjectionConfig(120.0, 5.0, 0.12, 4.0),
+ "ncaaf": ProjectionConfig(110.0, 5.0, 0.14, 4.0),
+ "nhl": ProjectionConfig(55.0, 10.0, 0.10, 8.0),
+}
+
+
+@dataclass(frozen=True, slots=True)
+class TeamRating:
+ team_id: str
+ offense: float
+ defense: float
+ effective_games: float
+ games: int
+ uncertainty: float
+ last_game_at: str | None
+ data_age_days: float | None
+ status: str
+
+
+@dataclass(frozen=True, slots=True)
+class ScoreInterval:
+ low: int
+ high: int
+
+
+@dataclass(frozen=True, slots=True)
+class MatchupProjection:
+ projection_id: str
+ model_version: str
+ league: str
+ home_team_id: str
+ away_team_id: str
+ home_team_name: str
+ away_team_name: str
+ neutral_site: bool
+ simulations: int
+ seed: int
+ projected_home_score: float
+ projected_away_score: float
+ projected_total: float
+ projected_margin: float
+ home_win_probability: float
+ away_win_probability: float
+ tie_probability: float
+ home_score_interval_80: ScoreInterval
+ away_score_interval_80: ScoreInterval
+ total_interval_80: ScoreInterval
+ home_rating: TeamRating
+ away_rating: TeamRating
+ data_games: int
+ data_through: str | None
+ source_counts: tuple[tuple[str, int], ...]
+ confidence_label: str
+ evidence_status: str
+ generated_at: str
+ limitations: tuple[str, ...]
+
+
+@dataclass(frozen=True, slots=True)
+class ProjectionContext:
+ league: str
+ as_of: datetime
+ history: tuple[GameRecord, ...]
+ ratings: dict[str, TeamRating]
+
+
+@dataclass(slots=True)
+class _Accumulator:
+ weighted_for: float = 0.0
+ weighted_against: float = 0.0
+ total_weight: float = 0.0
+ games: int = 0
+ last_game_at: str | None = None
+
+
+def _parse_time(value: str) -> datetime | None:
+ try:
+ parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
+ except ValueError:
+ return None
+ if parsed.tzinfo is None:
+ return None
+ return parsed.astimezone(UTC)
+
+
+def _clamp(value: float, low: float, high: float) -> float:
+ return max(low, min(high, value))
+
+
+def _quantile(values: tuple[int, ...], fraction: float) -> int:
+ if not values:
+ raise ValueError("Quantile input cannot be empty")
+ ordered = sorted(values)
+ index = round((len(ordered) - 1) * fraction)
+ return ordered[index]
+
+
+def _rating_weight(game_time: datetime, as_of: datetime, half_life_days: float) -> float:
+ age_days = max(0.0, (as_of - game_time).total_seconds() / 86_400.0)
+ return math.exp(-math.log(2.0) * age_days / half_life_days)
+
+
+def _neutral_rating(team_id: str) -> TeamRating:
+ return TeamRating(
+ team_id=team_id,
+ offense=1.0,
+ defense=1.0,
+ effective_games=0.0,
+ games=0,
+ uncertainty=1.0,
+ last_game_at=None,
+ data_age_days=None,
+ status="cold_start_reference_only",
+ )
+
+
+class TeamStrengthEstimator:
+ """Recency weighted, opponent-adjusted attack and defense estimator.
+
+ Ratios shrink to the league prior before four fixed-point opponent
+ adjustment passes. This prevents tiny or stale samples from appearing
+ more certain than the underlying observations support.
+ """
+
+ def estimate(
+ self,
+ league: str,
+ games: tuple[GameRecord, ...],
+ *,
+ as_of: datetime,
+ ) -> dict[str, TeamRating]:
+ normalized = normalize_league_id(league)
+ config = PROJECTION_CONFIGS[normalized]
+ anchor = ANCHORS[normalized]
+ usable: list[tuple[GameRecord, datetime, float]] = []
+ for game in games:
+ home_score = game.home_score
+ away_score = game.away_score
+ if home_score is None or away_score is None:
+ continue
+ game_time = _parse_time(game.start_time)
+ if game_time is None or game_time >= as_of:
+ continue
+ weight = _rating_weight(game_time, as_of, config.half_life_days)
+ if weight < 0.0001:
+ continue
+ usable.append((game, game_time, weight))
+ team_ids = {
+ team_id
+ for game, _game_time, _weight in usable
+ for team_id in (game.home_team_id, game.away_team_id)
+ }
+ if not team_ids:
+ return {}
+ offense = dict.fromkeys(team_ids, 1.0)
+ defense = dict.fromkeys(team_ids, 1.0)
+ final_accumulators: dict[str, _Accumulator] = {}
+ for _iteration in range(4):
+ accumulators = {team_id: _Accumulator() for team_id in team_ids}
+ for game, _game_time, weight in usable:
+ home_score = game.home_score
+ away_score = game.away_score
+ if home_score is None or away_score is None:
+ continue
+ home = accumulators[game.home_team_id]
+ away = accumulators[game.away_team_id]
+ home_expected_base = anchor.mean_home_score
+ away_expected_base = anchor.mean_away_score
+ home_for_ratio = float(home_score) / max(
+ 0.1, home_expected_base * defense[game.away_team_id]
+ )
+ away_for_ratio = float(away_score) / max(
+ 0.1, away_expected_base * defense[game.home_team_id]
+ )
+ home_against_ratio = float(away_score) / max(
+ 0.1, away_expected_base * offense[game.away_team_id]
+ )
+ away_against_ratio = float(home_score) / max(
+ 0.1, home_expected_base * offense[game.home_team_id]
+ )
+ home.weighted_for += weight * home_for_ratio
+ home.weighted_against += weight * home_against_ratio
+ home.total_weight += weight
+ home.games += 1
+ home.last_game_at = max(home.last_game_at or "", game.start_time)
+ away.weighted_for += weight * away_for_ratio
+ away.weighted_against += weight * away_against_ratio
+ away.total_weight += weight
+ away.games += 1
+ away.last_game_at = max(away.last_game_at or "", game.start_time)
+ for team_id, accumulator in accumulators.items():
+ denominator = config.prior_games + accumulator.total_weight
+ offense[team_id] = _clamp(
+ (config.prior_games + accumulator.weighted_for) / denominator,
+ 0.55,
+ 1.65,
+ )
+ defense[team_id] = _clamp(
+ (config.prior_games + accumulator.weighted_against) / denominator,
+ 0.55,
+ 1.65,
+ )
+ final_accumulators = accumulators
+ ratings: dict[str, TeamRating] = {}
+ for team_id in sorted(team_ids):
+ accumulator = final_accumulators[team_id]
+ last_time = _parse_time(accumulator.last_game_at or "")
+ age_days = (
+ max(0.0, (as_of - last_time).total_seconds() / 86_400.0)
+ if last_time is not None
+ else None
+ )
+ sample_uncertainty = math.sqrt(
+ config.prior_games / (config.prior_games + accumulator.total_weight)
+ )
+ stale_penalty = (
+ min(0.35, max(0.0, age_days - config.half_life_days) / 365.0)
+ if age_days is not None
+ else 0.35
+ )
+ uncertainty = _clamp(sample_uncertainty + stale_penalty, 0.08, 1.0)
+ status = (
+ "historical_team_form_provisional"
+ if accumulator.total_weight >= config.minimum_effective_games
+ and (age_days is None or age_days <= config.half_life_days * 2.0)
+ else "sparse_or_stale_team_history"
+ )
+ ratings[team_id] = TeamRating(
+ team_id=team_id,
+ offense=offense[team_id],
+ defense=defense[team_id],
+ effective_games=round(accumulator.total_weight, 3),
+ games=accumulator.games,
+ uncertainty=round(uncertainty, 4),
+ last_game_at=accumulator.last_game_at,
+ data_age_days=round(age_days, 1) if age_days is not None else None,
+ status=status,
+ )
+ return ratings
+
+
+class ProjectionEngine:
+ """Generate deterministic posterior-predictive score distributions."""
+
+ def __init__(self, store: SportsDataStore) -> None:
+ self.store = store
+ self.estimator = TeamStrengthEstimator()
+
+ def _history(
+ self,
+ league: str,
+ as_of: datetime,
+ ) -> tuple[tuple[GameRecord, ...], dict[str, TeamRating]]:
+ history = self.store.completed_games(league, before=as_of.isoformat(), limit=10_000)
+ return history, self.estimator.estimate(league, history, as_of=as_of)
+
+ def prepare_context(
+ self,
+ league: str,
+ *,
+ as_of: datetime | None = None,
+ ) -> ProjectionContext:
+ normalized = normalize_league_id(league)
+ decision_time = (as_of or datetime.now(UTC)).astimezone(UTC)
+ history, ratings = self._history(normalized, decision_time)
+ return ProjectionContext(normalized, decision_time, history, ratings)
+
+ def project(
+ self,
+ league: str,
+ home_team_id: str,
+ away_team_id: str,
+ *,
+ home_team_name: str | None = None,
+ away_team_name: str | None = None,
+ neutral_site: bool = False,
+ simulations: int = 5_000,
+ seed: int = 20_260_726,
+ as_of: datetime | None = None,
+ context: ProjectionContext | None = None,
+ ) -> MatchupProjection:
+ normalized = normalize_league_id(league)
+ if normalized not in ANCHORS:
+ raise ValueError(f"Unsupported projection league: {league!r}")
+ home_id = home_team_id.strip().upper()
+ away_id = away_team_id.strip().upper()
+ if not home_id or not away_id or home_id == away_id:
+ raise ValueError("Hypothetical matchups require two distinct team IDs")
+ if not 100 <= simulations <= 50_000:
+ raise ValueError(f"simulations must be within [100, 50000]: {simulations}")
+ if seed < 0:
+ raise ValueError(f"seed must be non-negative: {seed}")
+ decision_time = (as_of or datetime.now(UTC)).astimezone(UTC)
+ if context is None:
+ history, ratings = self._history(normalized, decision_time)
+ else:
+ if context.league != normalized:
+ raise ValueError(
+ f"Projection context league mismatch: {context.league} != {normalized}"
+ )
+ decision_time = context.as_of
+ history, ratings = context.history, context.ratings
+ home_rating = ratings.get(home_id, _neutral_rating(home_id))
+ away_rating = ratings.get(away_id, _neutral_rating(away_id))
+ anchor = ANCHORS[normalized]
+ home_base, away_base = _site_bases(anchor, neutral_site)
+ expected_home = home_base * home_rating.offense * away_rating.defense
+ expected_away = away_base * away_rating.offense * home_rating.defense
+ home_strength = _clamp(expected_home / anchor.mean_home_score, 0.25, 2.0)
+ away_strength = _clamp(expected_away / anchor.mean_away_score, 0.25, 2.0)
+ config = PROJECTION_CONFIGS[normalized]
+ home_scores: list[int] = []
+ away_scores: list[int] = []
+ for index in range(simulations):
+ draw_seed = seed + index
+ home_parameter = normal(
+ draw_seed,
+ math.log(home_strength),
+ config.posterior_strength_sd * home_rating.uncertainty,
+ normalized,
+ home_id,
+ "home_strength_posterior",
+ )
+ away_parameter = normal(
+ draw_seed,
+ math.log(away_strength),
+ config.posterior_strength_sd * away_rating.uncertainty,
+ normalized,
+ away_id,
+ "away_strength_posterior",
+ )
+ result = simulate_analytical(
+ normalized,
+ draw_seed,
+ _clamp(math.exp(home_parameter), 0.25, 2.0),
+ _clamp(math.exp(away_parameter), 0.25, 2.0),
+ )
+ home_scores.append(result.home_score)
+ away_scores.append(result.away_score)
+ home_tuple = tuple(home_scores)
+ away_tuple = tuple(away_scores)
+ totals = tuple(
+ home + away for home, away in zip(home_tuple, away_tuple, strict=True)
+ )
+ home_wins = sum(home > away for home, away in zip(home_tuple, away_tuple, strict=True))
+ away_wins = sum(away > home for home, away in zip(home_tuple, away_tuple, strict=True))
+ ties = simulations - home_wins - away_wins
+ source_counts: dict[str, int] = {}
+ for game in history:
+ source_counts[game.source] = source_counts.get(game.source, 0) + 1
+ data_through = max((game.start_time for game in history), default=None)
+ confidence = _confidence_label(home_rating, away_rating)
+ evidence_status = (
+ "historical_form_provisional_not_calibrated"
+ if confidence != "LOW"
+ else "reference_anchor_or_sparse_history_only"
+ )
+ identity_payload = {
+ "model_version": PROJECTION_MODEL_VERSION,
+ "league": normalized,
+ "home": home_id,
+ "away": away_id,
+ "neutral_site": neutral_site,
+ "simulations": simulations,
+ "seed": seed,
+ "as_of": decision_time.isoformat(),
+ "home_rating": asdict(home_rating),
+ "away_rating": asdict(away_rating),
+ }
+ projection_id = hashlib.sha256(
+ json.dumps(identity_payload, sort_keys=True, separators=(",", ":")).encode()
+ ).hexdigest()
+ return MatchupProjection(
+ projection_id=projection_id,
+ model_version=PROJECTION_MODEL_VERSION,
+ league=normalized,
+ home_team_id=home_id,
+ away_team_id=away_id,
+ home_team_name=home_team_name or home_id,
+ away_team_name=away_team_name or away_id,
+ neutral_site=neutral_site,
+ simulations=simulations,
+ seed=seed,
+ projected_home_score=round(statistics.fmean(home_tuple), 1),
+ projected_away_score=round(statistics.fmean(away_tuple), 1),
+ projected_total=round(statistics.fmean(totals), 1),
+ projected_margin=round(
+ statistics.fmean(
+ home - away
+ for home, away in zip(home_tuple, away_tuple, strict=True)
+ ),
+ 1,
+ ),
+ home_win_probability=round(home_wins / simulations, 4),
+ away_win_probability=round(away_wins / simulations, 4),
+ tie_probability=round(ties / simulations, 4),
+ home_score_interval_80=ScoreInterval(
+ _quantile(home_tuple, 0.10), _quantile(home_tuple, 0.90)
+ ),
+ away_score_interval_80=ScoreInterval(
+ _quantile(away_tuple, 0.10), _quantile(away_tuple, 0.90)
+ ),
+ total_interval_80=ScoreInterval(
+ _quantile(totals, 0.10), _quantile(totals, 0.90)
+ ),
+ home_rating=home_rating,
+ away_rating=away_rating,
+ data_games=len(history),
+ data_through=data_through,
+ source_counts=tuple(sorted(source_counts.items())),
+ confidence_label=confidence,
+ evidence_status=evidence_status,
+ generated_at=decision_time.isoformat(),
+ limitations=(
+ "Projection is a simulation, not a guarantee or certified real-world forecast.",
+ "Roster, injury, player, coaching, weather, and travel inputs are not yet modeled.",
+ "Confidence describes data coverage and freshness, not demonstrated accuracy.",
+ ),
+ )
+
+ def project_game(
+ self,
+ game: GameRecord,
+ *,
+ simulations: int = 5_000,
+ seed: int = 20_260_726,
+ as_of: datetime | None = None,
+ context: ProjectionContext | None = None,
+ ) -> MatchupProjection:
+ return self.project(
+ game.league,
+ game.home_team_id,
+ game.away_team_id,
+ home_team_name=game.home_name,
+ away_team_name=game.away_name,
+ neutral_site=game.neutral_site,
+ simulations=simulations,
+ seed=seed,
+ as_of=as_of,
+ context=context,
+ )
+
+
+def _site_bases(anchor: ReferenceAnchor, neutral_site: bool) -> tuple[float, float]:
+ if not neutral_site:
+ return anchor.mean_home_score, anchor.mean_away_score
+ neutral = (anchor.mean_home_score + anchor.mean_away_score) / 2.0
+ return neutral, neutral
+
+
+def _confidence_label(home: TeamRating, away: TeamRating) -> str:
+ maximum_uncertainty = max(home.uncertainty, away.uncertainty)
+ if maximum_uncertainty <= 0.55 and all(
+ rating.status == "historical_team_form_provisional" for rating in (home, away)
+ ):
+ return "HIGH_DATA_COVERAGE"
+ if maximum_uncertainty <= 0.8 and all(rating.games > 0 for rating in (home, away)):
+ return "MEDIUM_DATA_COVERAGE"
+ return "LOW"
diff --git a/src/universal_sports_engine/service.py b/src/universal_sports_engine/service.py
new file mode 100644
index 0000000..5131433
--- /dev/null
+++ b/src/universal_sports_engine/service.py
@@ -0,0 +1,214 @@
+"""Application service for schedules, projections, and hypothetical games."""
+
+from __future__ import annotations
+
+import hashlib
+from dataclasses import asdict
+from datetime import UTC, date, datetime, time, timedelta
+from pathlib import Path
+from typing import Any
+
+from universal_sports_engine.data.espn import EspnScoreboardClient
+from universal_sports_engine.data.models import GameRecord
+from universal_sports_engine.data.pipeline import SportsDataPipeline
+from universal_sports_engine.data.store import SportsDataStore
+from universal_sports_engine.league_packs.registry import (
+ league_identities,
+ normalize_league_id,
+)
+from universal_sports_engine.projection import ProjectionEngine
+
+LEAGUE_PRESENTATION = {
+ "mlb": {"short_name": "MLB", "accent": "coral"},
+ "nfl": {"short_name": "NFL", "accent": "amber"},
+ "ncaaf": {"short_name": "NCAAF", "accent": "gold"},
+ "nba": {"short_name": "NBA", "accent": "violet"},
+ "wnba": {"short_name": "WNBA", "accent": "rose"},
+ "ncaambb": {"short_name": "NCAAM", "accent": "cyan"},
+ "nhl": {"short_name": "NHL", "accent": "ice"},
+}
+LEAGUE_ORDER = ("mlb", "nfl", "ncaaf", "nba", "wnba", "ncaambb", "nhl")
+NON_CLUB_TEAM_IDS = {"mlb": {"AL", "NL"}}
+
+
+def _stable_game_seed(base_seed: int, game_id: str) -> int:
+ digest = hashlib.blake2b(game_id.encode(), digest_size=8, person=b"USE-WEEK").digest()
+ return base_seed + int.from_bytes(digest, "big") % 1_000_000_000
+
+
+def _logical_key(game: GameRecord) -> tuple[str, str, str, str]:
+ return game.league, game.start_time, game.home_team_id, game.away_team_id
+
+
+def _record_priority(game: GameRecord) -> tuple[int, int, str]:
+ return (
+ int(game.source == "espn_public_scoreboard"),
+ {"final": 3, "in_progress": 2, "scheduled": 1}.get(game.status, 0),
+ game.observed_at,
+ )
+
+
+def _deduplicate(games: tuple[GameRecord, ...]) -> tuple[GameRecord, ...]:
+ selected: dict[tuple[str, str, str, str], GameRecord] = {}
+ for game in games:
+ key = _logical_key(game)
+ incumbent = selected.get(key)
+ if incumbent is None or _record_priority(game) > _record_priority(incumbent):
+ selected[key] = game
+ return tuple(sorted(selected.values(), key=lambda game: (game.start_time, game.game_id)))
+
+
+class SportsEngineService:
+ """Cohesive, framework-neutral facade used by the CLI and web API."""
+
+ def __init__(
+ self,
+ store: SportsDataStore,
+ pipeline: SportsDataPipeline,
+ projections: ProjectionEngine,
+ ) -> None:
+ self.store = store
+ self.pipeline = pipeline
+ self.projections = projections
+
+ @classmethod
+ def from_paths(cls, data_path: Path, cache_dir: Path) -> SportsEngineService:
+ store = SportsDataStore(data_path)
+ client = EspnScoreboardClient(cache_dir)
+ return cls(store, SportsDataPipeline(store, client), ProjectionEngine(store))
+
+ def leagues(self) -> list[dict[str, Any]]:
+ identities = {identity.league_id: identity for identity in league_identities()}
+ output: list[dict[str, Any]] = []
+ for league in LEAGUE_ORDER:
+ identity = identities[league]
+ output.append(
+ {
+ **asdict(identity),
+ **LEAGUE_PRESENTATION[league],
+ "aliases": {
+ "wnba": ["wba"],
+ "ncaambb": ["ncaam", "ncaamb"],
+ }.get(league, []),
+ }
+ )
+ return output
+
+ def teams(self, league: str) -> list[dict[str, Any]]:
+ normalized = normalize_league_id(league)
+ excluded = NON_CLUB_TEAM_IDS.get(normalized, set())
+ return [
+ asdict(team)
+ for team in self.store.teams(normalized)
+ if team.team_id not in excluded
+ ]
+
+ def refresh_current_week(
+ self,
+ *,
+ start: date | None = None,
+ days: int = 7,
+ force: bool = False,
+ ) -> dict[str, Any]:
+ results = self.pipeline.refresh_current_week(start=start, days=days, force=force)
+ return {
+ "status": "ok" if all(result.status == "ok" for result in results) else "partial",
+ "results": [asdict(result) for result in results],
+ "remote_writes": 0,
+ "data_contract": "read_only_public_scoreboard",
+ }
+
+ def week(
+ self,
+ *,
+ start: date | None = None,
+ days: int = 7,
+ league: str | None = None,
+ simulations: int = 2_000,
+ seed: int = 20_260_726,
+ ) -> dict[str, Any]:
+ if not 1 <= days <= 14:
+ raise ValueError(f"days must be within [1, 14]: {days}")
+ if not 100 <= simulations <= 20_000:
+ raise ValueError(f"simulations must be within [100, 20000]: {simulations}")
+ window_start = start or datetime.now(UTC).date()
+ window_end = window_start + timedelta(days=days)
+ start_iso = datetime.combine(window_start, time.min, UTC).isoformat()
+ end_iso = datetime.combine(window_end, time.min, UTC).isoformat()
+ normalized_league = normalize_league_id(league) if league else None
+ games = _deduplicate(
+ self.store.games_between(start_iso, end_iso, league=normalized_league)
+ )
+ contexts = {
+ league_id: self.projections.prepare_context(league_id)
+ for league_id in sorted({game.league for game in games if game.status == "scheduled"})
+ }
+ rows: list[dict[str, Any]] = []
+ for game in games:
+ projection = None
+ if game.status == "scheduled":
+ projection = self.projections.project_game(
+ game,
+ simulations=simulations,
+ seed=_stable_game_seed(seed, game.game_id),
+ context=contexts[game.league],
+ )
+ rows.append(
+ {
+ "game": asdict(game),
+ "projection": asdict(projection) if projection is not None else None,
+ }
+ )
+ by_league = {
+ league_id: sum(row["game"]["league"] == league_id for row in rows)
+ for league_id in LEAGUE_ORDER
+ }
+ return {
+ "window": {
+ "start": window_start.isoformat(),
+ "end_exclusive": window_end.isoformat(),
+ "days": days,
+ "label": "rolling_current_week",
+ },
+ "generated_at": datetime.now(UTC).isoformat(),
+ "games": rows,
+ "counts_by_league": by_league,
+ "simulations_per_projection": simulations,
+ "evidence_status": "simulation_outputs_not_certified_forecasts",
+ }
+
+ def hypothetical(
+ self,
+ *,
+ league: str,
+ home_team_id: str,
+ away_team_id: str,
+ home_team_name: str | None = None,
+ away_team_name: str | None = None,
+ neutral_site: bool = False,
+ simulations: int = 10_000,
+ seed: int = 20_260_726,
+ ) -> dict[str, Any]:
+ projection = self.projections.project(
+ league,
+ home_team_id,
+ away_team_id,
+ home_team_name=home_team_name,
+ away_team_name=away_team_name,
+ neutral_site=neutral_site,
+ simulations=simulations,
+ seed=seed,
+ )
+ return asdict(projection)
+
+ def health(self) -> dict[str, Any]:
+ database = self.store.health()
+ return {
+ "status": "healthy" if database["integrity"] == "ok" else "degraded",
+ "engine_name": "Waterboy",
+ "engine_version": "0.4.0",
+ "database": database,
+ "execution_authority": False,
+ "remote_writes": False,
+ "supported_leagues": list(LEAGUE_ORDER),
+ }
diff --git a/src/universal_sports_engine/settings.py b/src/universal_sports_engine/settings.py
new file mode 100644
index 0000000..fcbfddb
--- /dev/null
+++ b/src/universal_sports_engine/settings.py
@@ -0,0 +1,6 @@
+"""Runtime path defaults shared by the CLI and web application."""
+
+from pathlib import Path
+
+DEFAULT_DATA_PATH = Path("runtime/universal_sports_engine/sports.db")
+DEFAULT_CACHE_DIR = Path("runtime/universal_sports_engine/http_cache")
diff --git a/src/universal_sports_engine/simulation.py b/src/universal_sports_engine/simulation.py
index 80a60ee..e6d035d 100644
--- a/src/universal_sports_engine/simulation.py
+++ b/src/universal_sports_engine/simulation.py
@@ -65,7 +65,7 @@ def simulate_many(league: str, games: int, seed: int, workers: int) -> tuple[Gam
if os.name == "nt" and (not main_file or main_file.startswith("<")):
raise ParallelExecutionUnavailableError(
"Windows process workers require an importable __main__ file; "
- "run through sports-engine, pytest, or a script guarded by "
+ "run through waterboy, pytest, or a script guarded by "
"if __name__ == '__main__'"
)
tasks = ((league, seed + index) for index in range(games))
diff --git a/src/universal_sports_engine/web/static/app.js b/src/universal_sports_engine/web/static/app.js
new file mode 100644
index 0000000..540e673
--- /dev/null
+++ b/src/universal_sports_engine/web/static/app.js
@@ -0,0 +1,439 @@
+"use strict";
+
+const state = {
+ leagues: [],
+ week: null,
+ activeLeague: "all",
+};
+
+const byId = (id) => document.getElementById(id);
+
+function element(tag, className, text) {
+ const node = document.createElement(tag);
+ if (className) node.className = className;
+ if (text !== undefined) node.textContent = text;
+ return node;
+}
+
+function formatPercent(value) {
+ return `${Math.round(Number(value || 0) * 100)}%`;
+}
+
+function formatScore(value, projected) {
+ if (value === undefined || value === null) return "—";
+ return projected ? Number(value).toFixed(1) : String(value);
+}
+
+function shortDate(value) {
+ return new Intl.DateTimeFormat(undefined, {
+ weekday: "short",
+ month: "short",
+ day: "numeric",
+ }).format(new Date(value));
+}
+
+function calendarDate(value) {
+ return new Intl.DateTimeFormat(undefined, {
+ weekday: "short",
+ month: "short",
+ day: "numeric",
+ }).format(new Date(`${value}T12:00:00`));
+}
+
+function gameTime(value) {
+ return new Intl.DateTimeFormat(undefined, {
+ hour: "numeric",
+ minute: "2-digit",
+ }).format(new Date(value));
+}
+
+async function api(path, options = {}) {
+ const response = await fetch(path, {
+ headers: { "Content-Type": "application/json", ...(options.headers || {}) },
+ ...options,
+ });
+ if (!response.ok) {
+ let message = `${response.status} ${response.statusText}`;
+ try {
+ const payload = await response.json();
+ message = typeof payload.detail === "string" ? payload.detail : JSON.stringify(payload.detail);
+ } catch (_error) {
+ // Keep the HTTP status when the error body is not JSON.
+ }
+ throw new Error(message);
+ }
+ return response.json();
+}
+
+async function loadHealth() {
+ const pill = byId("health-pill");
+ try {
+ const health = await api("/api/v1/health");
+ pill.classList.add(health.status === "healthy" ? "is-healthy" : "is-error");
+ byId("health-text").textContent = health.status === "healthy" ? "System healthy" : "System degraded";
+ } catch (_error) {
+ pill.classList.add("is-error");
+ byId("health-text").textContent = "System unavailable";
+ }
+}
+
+async function loadLeagues() {
+ state.leagues = await api("/api/v1/leagues");
+ const strip = byId("league-strip");
+ for (const league of state.leagues) {
+ const button = element("button", "league-chip");
+ button.type = "button";
+ button.dataset.league = league.league_id;
+ button.append(element("span", "", league.short_name));
+ const count = element("b", "", "0");
+ count.id = `count-${league.league_id}`;
+ button.append(count);
+ strip.append(button);
+ }
+ const simLeague = byId("sim-league");
+ for (const league of state.leagues) {
+ const option = element("option", "", `${league.short_name} · ${league.display_name}`);
+ option.value = league.league_id;
+ simLeague.append(option);
+ }
+ simLeague.value = "mlb";
+ await loadTeams("mlb");
+}
+
+async function loadWeek() {
+ byId("schedule-loading").hidden = false;
+ byId("schedule-empty").hidden = true;
+ byId("schedule-board").replaceChildren();
+ try {
+ state.week = await api("/api/v1/week?days=7&simulations=2000");
+ const projected = state.week.games.filter((row) => row.projection).length;
+ byId("games-count").textContent = String(state.week.games.length);
+ byId("projection-count").textContent = String(projected);
+ byId("week-title").textContent =
+ `${calendarDate(state.week.window.start)} — ` +
+ calendarDate(state.week.window.end_exclusive);
+ byId("updated-text").textContent =
+ `Generated ${new Intl.DateTimeFormat(undefined, { hour: "numeric", minute: "2-digit" }).format(new Date(state.week.generated_at))}`;
+ byId("count-all").textContent = String(state.week.games.length);
+ for (const [league, count] of Object.entries(state.week.counts_by_league)) {
+ const target = byId(`count-${league}`);
+ if (target) target.textContent = String(count);
+ }
+ renderSchedule();
+ } catch (error) {
+ renderScheduleError(error);
+ } finally {
+ byId("schedule-loading").hidden = true;
+ }
+}
+
+function renderScheduleError(error) {
+ const empty = byId("schedule-empty");
+ empty.hidden = false;
+ empty.querySelector("h3").textContent = "Schedule unavailable";
+ empty.querySelector("p").textContent = error.message;
+}
+
+function renderSchedule() {
+ const board = byId("schedule-board");
+ board.replaceChildren();
+ if (!state.week) return;
+ const rows = state.week.games.filter(
+ (row) => state.activeLeague === "all" || row.game.league === state.activeLeague,
+ );
+ byId("schedule-empty").hidden = rows.length > 0;
+ if (!rows.length) return;
+ const grouped = new Map();
+ for (const row of rows) {
+ const key = row.game.start_time.slice(0, 10);
+ if (!grouped.has(key)) grouped.set(key, []);
+ grouped.get(key).push(row);
+ }
+ for (const [day, games] of grouped.entries()) {
+ const section = element("section", "day-group");
+ section.append(element("h3", "day-heading", shortDate(`${day}T12:00:00Z`)));
+ const grid = element("div", "game-grid");
+ for (const row of games) grid.append(buildGameCard(row));
+ section.append(grid);
+ board.append(section);
+ }
+}
+
+function buildGameCard(row) {
+ const game = row.game;
+ const projection = row.projection;
+ const card = element("article", "game-card");
+ const top = element("div", "card-topline");
+ const league = state.leagues.find((item) => item.league_id === game.league);
+ top.append(element("span", "league-tag", league ? league.short_name : game.league.toUpperCase()));
+ top.append(element("span", "", `${gameTime(game.start_time)}${game.neutral_site ? " · Neutral" : ""}`));
+ card.append(top);
+
+ const observed = game.status === "final" || game.status === "in_progress";
+ const homeScore = observed ? game.home_score : projection?.projected_home_score;
+ const awayScore = observed ? game.away_score : projection?.projected_away_score;
+ const homeWin = game.status === "final"
+ ? Number(game.home_score > game.away_score)
+ : projection?.home_win_probability;
+ const awayWin = game.status === "final"
+ ? Number(game.away_score > game.home_score)
+ : projection?.away_win_probability;
+ const homeProjectedWinner = Boolean(
+ projection && projection.home_win_probability > projection.away_win_probability,
+ );
+ const awayProjectedWinner = Boolean(
+ projection && projection.away_win_probability > projection.home_win_probability,
+ );
+ card.append(teamRow(
+ game.home_name,
+ game.home_team_id,
+ formatScore(homeScore, Boolean(projection)),
+ homeWin,
+ "Home",
+ homeProjectedWinner,
+ ));
+ card.append(teamRow(
+ game.away_name,
+ game.away_team_id,
+ formatScore(awayScore, Boolean(projection)),
+ awayWin,
+ "Away",
+ awayProjectedWinner,
+ ));
+
+ const meta = element("div", "projection-meta");
+ if (game.status === "final") {
+ meta.append(element("span", "final-label", "FINAL"));
+ meta.append(element("span", "", game.venue || "Venue unavailable"));
+ } else if (game.status === "in_progress") {
+ meta.append(element("span", "final-label", "LIVE · OBSERVED SCORE"));
+ meta.append(element("span", "", "Pregame-only model withheld"));
+ } else if (projection) {
+ meta.append(element(
+ "span",
+ "",
+ `Total ${formatScore(projection.projected_total, true)} · 80% ${projection.total_interval_80.low}–${projection.total_interval_80.high}`,
+ ));
+ const badge = element("span", "coverage-badge", projection.confidence_label.replaceAll("_", " "));
+ if (projection.confidence_label === "LOW") badge.classList.add("is-low");
+ meta.append(badge);
+ } else {
+ meta.append(element("span", "", "Projection unavailable"));
+ }
+ card.append(meta);
+ return card;
+}
+
+function teamRow(name, id, score, probability, side, projectedWinner) {
+ const row = element("div", "team-row");
+ const team = element("div", "team-name");
+ const nameLine = element("strong", "", name);
+ if (projectedWinner) {
+ const check = element("span", "winner-check", "✓");
+ check.title = "Projected winner";
+ check.setAttribute("aria-label", "projected winner");
+ nameLine.append(" ", check);
+ }
+ team.append(nameLine);
+ team.append(element("small", "", `${side} · ${id}`));
+ row.append(team);
+ row.append(element("span", "team-win", probability === undefined ? "—" : formatPercent(probability)));
+ row.append(element("span", "team-score", score === undefined || score === null ? "—" : String(score)));
+ return row;
+}
+
+async function loadTeams(league) {
+ const home = byId("home-team");
+ const away = byId("away-team");
+ home.disabled = true;
+ away.disabled = true;
+ home.replaceChildren(element("option", "", "Loading teams…"));
+ away.replaceChildren(element("option", "", "Loading teams…"));
+ try {
+ const teams = await api(`/api/v1/leagues/${encodeURIComponent(league)}/teams`);
+ home.replaceChildren();
+ away.replaceChildren();
+ if (teams.length < 2) {
+ home.append(element("option", "", "Refresh history to load teams"));
+ away.append(element("option", "", "Refresh history to load teams"));
+ return;
+ }
+ for (const [index, team] of teams.entries()) {
+ const homeOption = element("option", "", `${team.display_name} (${team.team_id})`);
+ homeOption.value = team.team_id;
+ homeOption.dataset.name = team.display_name;
+ home.append(homeOption);
+ const awayOption = homeOption.cloneNode(true);
+ away.append(awayOption);
+ if (index === 1) away.value = team.team_id;
+ }
+ home.disabled = false;
+ away.disabled = false;
+ } catch (error) {
+ home.replaceChildren(element("option", "", error.message));
+ away.replaceChildren(element("option", "", error.message));
+ }
+}
+
+function renderLabResult(result) {
+ const root = byId("lab-result");
+ root.replaceChildren();
+ const shell = element("div", "result-shell");
+ shell.append(element("span", "result-kicker", `${result.league.toUpperCase()} · ${result.simulations.toLocaleString()} simulations`));
+ const matchup = element("div", "result-matchup");
+ const homeProjectedWinner = result.home_win_probability > result.away_win_probability;
+ const awayProjectedWinner = result.away_win_probability > result.home_win_probability;
+ matchup.append(resultTeam(
+ result.home_team_name,
+ result.projected_home_score,
+ result.home_score_interval_80,
+ homeProjectedWinner,
+ ));
+ matchup.append(element("span", "result-vs", result.neutral_site ? "NEUTRAL" : "VS"));
+ matchup.append(resultTeam(
+ result.away_team_name,
+ result.projected_away_score,
+ result.away_score_interval_80,
+ awayProjectedWinner,
+ ));
+ shell.append(matchup);
+
+ const bar = element("div", "probability-bar");
+ const homeBar = element("div", "probability-home");
+ const awayBar = element("div", "probability-away");
+ const decisive = Math.max(0.0001, result.home_win_probability + result.away_win_probability);
+ homeBar.style.width = `${(result.home_win_probability / decisive) * 100}%`;
+ awayBar.style.width = `${(result.away_win_probability / decisive) * 100}%`;
+ bar.append(homeBar, awayBar);
+ shell.append(bar);
+ const legend = element("div", "probability-legend");
+ legend.append(
+ element("span", "", `${formatPercent(result.home_win_probability)} home win`),
+ element("span", "", `${formatPercent(result.away_win_probability)} away win`),
+ );
+ shell.append(legend);
+
+ const stats = element("div", "result-stats");
+ stats.append(
+ resultStat(formatScore(result.projected_total, true), "Projected total"),
+ resultStat(
+ `${result.projected_margin > 0 ? "+" : ""}${formatScore(result.projected_margin, true)}`,
+ "Home margin",
+ ),
+ resultStat(result.confidence_label.replaceAll("_", " "), "Data coverage"),
+ );
+ shell.append(stats);
+ shell.append(element(
+ "p",
+ "result-evidence",
+ `${result.evidence_status.replaceAll("_", " ")} · Data through ${result.data_through ? shortDate(result.data_through) : "not available"}. ${result.limitations[0]}`,
+ ));
+ root.append(shell);
+}
+
+function resultTeam(name, score, interval, projectedWinner) {
+ const team = element("div", "result-team");
+ team.append(element("strong", "", formatScore(score, true)));
+ const nameLine = element("span", "", name);
+ if (projectedWinner) {
+ const check = element("span", "winner-check", "✓");
+ check.title = "Projected winner";
+ check.setAttribute("aria-label", "projected winner");
+ nameLine.append(" ", check);
+ }
+ team.append(nameLine);
+ team.append(element("small", "", `80% range ${interval.low}–${interval.high}`));
+ return team;
+}
+
+function resultStat(value, label) {
+ const node = element("div");
+ node.append(element("strong", "", value), element("small", "", label));
+ return node;
+}
+
+function wireEvents() {
+ document.querySelectorAll(".mode-tab").forEach((button) => {
+ button.addEventListener("click", () => {
+ document.querySelectorAll(".mode-tab").forEach((item) => item.classList.remove("is-active"));
+ button.classList.add("is-active");
+ document.querySelectorAll(".view-panel").forEach((panel) => { panel.hidden = true; });
+ byId(button.dataset.view).hidden = false;
+ });
+ });
+
+ byId("league-strip").addEventListener("click", (event) => {
+ const button = event.target.closest(".league-chip");
+ if (!button) return;
+ document.querySelectorAll(".league-chip").forEach((item) => item.classList.remove("is-active"));
+ button.classList.add("is-active");
+ state.activeLeague = button.dataset.league;
+ renderSchedule();
+ });
+
+ byId("sim-league").addEventListener("change", (event) => loadTeams(event.target.value));
+
+ byId("refresh-button").addEventListener("click", async () => {
+ const button = byId("refresh-button");
+ button.disabled = true;
+ button.textContent = "Refreshing…";
+ try {
+ const result = await api("/api/v1/data/refresh", {
+ method: "POST",
+ body: JSON.stringify({ days: 7, force: true }),
+ });
+ button.textContent = result.status === "ok" ? "Data refreshed" : "Partial refresh";
+ await loadWeek();
+ await loadTeams(byId("sim-league").value);
+ } catch (error) {
+ button.textContent = `Refresh failed: ${error.message}`;
+ } finally {
+ setTimeout(() => {
+ button.disabled = false;
+ button.textContent = "↻ Refresh data";
+ }, 1800);
+ }
+ });
+
+ byId("simulator-form").addEventListener("submit", async (event) => {
+ event.preventDefault();
+ const button = byId("simulate-button");
+ const home = byId("home-team");
+ const away = byId("away-team");
+ if (home.value === away.value) {
+ byId("lab-result").replaceChildren(element("div", "lab-placeholder", "Choose two different teams."));
+ return;
+ }
+ button.disabled = true;
+ button.textContent = "Running simulations…";
+ try {
+ const result = await api("/api/v1/simulations/hypothetical", {
+ method: "POST",
+ body: JSON.stringify({
+ league: byId("sim-league").value,
+ home_team_id: home.value,
+ away_team_id: away.value,
+ home_team_name: home.selectedOptions[0]?.dataset.name || home.value,
+ away_team_name: away.selectedOptions[0]?.dataset.name || away.value,
+ neutral_site: byId("neutral-site").checked,
+ simulations: Number(byId("simulations").value),
+ seed: Number(byId("seed").value),
+ }),
+ });
+ renderLabResult(result);
+ } catch (error) {
+ byId("lab-result").replaceChildren(element("div", "lab-placeholder", error.message));
+ } finally {
+ button.disabled = false;
+ button.textContent = "Run matchup simulation";
+ }
+ });
+}
+
+async function start() {
+ wireEvents();
+ await Promise.all([loadHealth(), loadLeagues()]);
+ await loadWeek();
+}
+
+start().catch((error) => renderScheduleError(error));
diff --git a/src/universal_sports_engine/web/static/favicon.svg b/src/universal_sports_engine/web/static/favicon.svg
new file mode 100644
index 0000000..91174c0
--- /dev/null
+++ b/src/universal_sports_engine/web/static/favicon.svg
@@ -0,0 +1,4 @@
+
diff --git a/src/universal_sports_engine/web/static/index.html b/src/universal_sports_engine/web/static/index.html
new file mode 100644
index 0000000..fd8fd20
--- /dev/null
+++ b/src/universal_sports_engine/web/static/index.html
@@ -0,0 +1,230 @@
+
+
+
+
+
+
+
+ Waterboy · Sports Simulation Intelligence
+
+
+
+
+
+ Skip to content
+
+
+ W
+
+ Waterboy
+ Sports Intelligence
+
+
+
+
+
+ Checking system
+
+
+
+
+
+
+
+
+
Waterboy · deterministic sports intelligence
+
Every league. Any matchup.
+
+ One elite command center for current-week score distributions and
+ hypothetical games—powered by provenance-backed history, exact replay,
+ visible uncertainty, and up to 50,000 simulations per matchup.
+