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. -[![Version](https://img.shields.io/badge/version-0.3.0-7c3aed?style=for-the-badge)](./STATUS.md) +[![Version](https://img.shields.io/badge/version-0.4.0-b9ff5a?style=for-the-badge&labelColor=0b1215)](./STATUS.md) +[![Tests](https://img.shields.io/badge/tests-74%20passing-74e8ff?style=for-the-badge&labelColor=0b1215)](./reports/RELEASE_EVIDENCE_V4.md) +[![Leagues](https://img.shields.io/badge/leagues-7-f4f3ed?style=for-the-badge&labelColor=0b1215)](#seven-leagues-one-engine) [![Python](https://img.shields.io/badge/python-3.12%2B-3776ab?style=for-the-badge&logo=python&logoColor=white)](./pyproject.toml) -[![Leagues](https://img.shields.io/badge/leagues-7-0f766e?style=for-the-badge)](#seven-leagues-one-kernel) -[![Tests](https://img.shields.io/badge/tests-67%20passing-16a34a?style=for-the-badge)](./reports/RELEASE_EVIDENCE.md) -[![License](https://img.shields.io/badge/license-MIT-111827?style=for-the-badge)](./LICENSE) +[![License](https://img.shields.io/badge/license-MIT-f4f3ed?style=for-the-badge&labelColor=0b1215)](./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.**
---- +![Waterboy dashboard](./docs/assets/waterboy-dashboard.png) -## 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 + + + + + + +
+ + + + 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. +

+
+
+
+ + Games loaded +
+
+ + Projected +
+
+ 7 + League packs +
+
+
+ +
+
+
+

Inside the engine

+

From schedule to simulation—without the black box.

+
+

+ Waterboy turns source-backed game history into transparent team state, + then routes it through a deterministic posterior simulation stack. +

+
+ +
+
+ 01 + Read-only data + Schedules, scores, timestamps, and payload hashes +
+ +
+ 02 + Team intelligence + Recency, opponent adjustment, shrinkage, and freshness +
+ +
+ 03 + Posterior worlds + Deterministic score distributions with replayable seeds +
+ +
+ 04 + Decision clarity + Decimal scores, winner marks, intervals, and evidence labels +
+
+ +
+
+ 179K+ + historical-game bootstrap snapshot +
+
+ 7 + isolated league rule packs +
+
+ 50K + simulations per hypothetical matchup +
+
+ Exact + same-input, same-seed replay +
+
+
+ + + +
+
+
+

Rolling seven-day board

+

Current week

+
+

Loading schedule…

+
+ +
+ +
+ +
+ + Building score distributions… +
+ +
+
+ + +
+ + + + diff --git a/src/universal_sports_engine/web/static/styles.css b/src/universal_sports_engine/web/static/styles.css new file mode 100644 index 0000000..1922845 --- /dev/null +++ b/src/universal_sports_engine/web/static/styles.css @@ -0,0 +1,660 @@ +:root { + --ink: #f4f3ed; + --muted: #9ca7ad; + --dim: #66727a; + --surface: #11191d; + --surface-raised: #172227; + --surface-soft: #1d2a30; + --line: rgba(255, 255, 255, 0.10); + --accent: #b9ff5a; + --accent-soft: rgba(185, 255, 90, 0.12); + --cyan: #74e8ff; + --danger: #ff7b69; + --radius: 18px; + font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + color: var(--ink); + background: #0b1215; + font-synthesis: none; +} + +* { box-sizing: border-box; } +html { scroll-behavior: smooth; } +body { + margin: 0; + min-width: 320px; + min-height: 100vh; + background: + radial-gradient(circle at 72% -10%, rgba(116, 232, 255, 0.12), transparent 31rem), + radial-gradient(circle at 12% 24%, rgba(185, 255, 90, 0.06), transparent 25rem), + #0b1215; +} + +button, select, input { font: inherit; } +button, a { -webkit-tap-highlight-color: transparent; } +a { color: inherit; } + +.skip-link { + position: fixed; + left: 1rem; + top: -4rem; + z-index: 50; + padding: .7rem 1rem; + border-radius: .5rem; + color: #091014; + background: var(--accent); +} +.skip-link:focus { top: 1rem; } + +.topbar { + position: sticky; + top: 0; + z-index: 30; + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + min-height: 76px; + padding: .75rem max(1.25rem, calc((100vw - 1220px) / 2)); + border-bottom: 1px solid var(--line); + background: rgba(11, 18, 21, .88); + backdrop-filter: blur(18px); +} + +.brand { + display: flex; + align-items: center; + gap: .75rem; + color: var(--ink); + text-decoration: none; +} +.brand-mark { + display: grid; + place-items: center; + width: 42px; + height: 42px; + border-radius: 12px; + color: #0b1215; + background: var(--accent); + font-weight: 900; + font-size: 1.3rem; + box-shadow: 0 0 28px rgba(185, 255, 90, .18); +} +.brand strong, .brand small { display: block; } +.brand strong { font-size: .98rem; letter-spacing: .01em; } +.brand small { + margin-top: .08rem; + color: var(--muted); + font-size: .7rem; + letter-spacing: .14em; + text-transform: uppercase; +} +.topbar-actions { display: flex; align-items: center; gap: .75rem; } +.health-pill { + display: inline-flex; + align-items: center; + gap: .55rem; + color: var(--muted); + font-size: .78rem; +} +.status-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--dim); + box-shadow: 0 0 0 4px rgba(102, 114, 122, .12); +} +.health-pill.is-healthy .status-dot { + background: var(--accent); + box-shadow: 0 0 0 4px rgba(185, 255, 90, .12); +} +.health-pill.is-error .status-dot { + background: var(--danger); + box-shadow: 0 0 0 4px rgba(255, 123, 105, .12); +} + +.button { + display: inline-flex; + align-items: center; + justify-content: center; + gap: .45rem; + min-height: 42px; + padding: .7rem 1rem; + border: 1px solid var(--line); + border-radius: 11px; + color: var(--ink); + background: transparent; + cursor: pointer; + font-weight: 700; + font-size: .82rem; + transition: transform .15s ease, border-color .15s ease, background .15s ease; +} +.button:hover { transform: translateY(-1px); border-color: rgba(255,255,255,.25); } +.button:disabled { cursor: wait; opacity: .55; transform: none; } +.button-quiet { background: rgba(255,255,255,.035); } +.button-primary { + min-height: 50px; + border-color: var(--accent); + color: #0a120c; + background: var(--accent); + box-shadow: 0 12px 34px rgba(185, 255, 90, .14); +} + +main { + width: min(1220px, calc(100% - 2.5rem)); + margin: 0 auto; +} +.hero { + display: grid; + grid-template-columns: minmax(0, 1.5fr) minmax(330px, .9fr); + align-items: end; + gap: 3rem; + min-height: 390px; + padding: 5rem 0 3.3rem; +} +.eyebrow { + margin: 0 0 .8rem; + color: var(--accent); + font-size: .68rem; + font-weight: 800; + letter-spacing: .16em; + text-transform: uppercase; +} +h1, h2, h3, p { margin-top: 0; } +h1 { + max-width: 760px; + margin-bottom: 1.15rem; + font-size: clamp(3.1rem, 7vw, 6.4rem); + line-height: .88; + letter-spacing: -.065em; +} +h1 span { color: var(--cyan); } +.hero-copy { + max-width: 650px; + margin-bottom: 0; + color: var(--muted); + font-size: clamp(.98rem, 1.4vw, 1.12rem); + line-height: 1.65; +} +.hero-metrics { + display: grid; + grid-template-columns: repeat(3, 1fr); + overflow: hidden; + border: 1px solid var(--line); + border-radius: var(--radius); + background: rgba(17, 25, 29, .8); +} +.hero-metrics article { + min-height: 120px; + padding: 1.3rem; + border-right: 1px solid var(--line); +} +.hero-metrics article:last-child { border-right: 0; } +.hero-metrics span, .hero-metrics small { display: block; } +.hero-metrics span { + margin-bottom: .9rem; + font-size: clamp(1.8rem, 3vw, 2.7rem); + font-weight: 850; + letter-spacing: -.04em; +} +.hero-metrics small { color: var(--muted); font-size: .72rem; line-height: 1.35; } + +.showcase { + margin-bottom: 4.5rem; + padding: 2rem; + border: 1px solid var(--line); + border-radius: 24px; + background: + linear-gradient(135deg, rgba(185, 255, 90, .055), transparent 42%), + linear-gradient(315deg, rgba(116, 232, 255, .05), transparent 38%), + rgba(17, 25, 29, .76); + box-shadow: 0 28px 90px rgba(0, 0, 0, .2); +} +.showcase-heading { + display: grid; + grid-template-columns: minmax(0, 1.35fr) minmax(260px, .65fr); + align-items: end; + gap: 2rem; + margin-bottom: 2rem; +} +.showcase-heading h2 { max-width: 760px; } +.showcase-heading > p { + margin-bottom: .2rem; + color: var(--muted); + line-height: 1.6; +} +.engine-flow { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr) auto) minmax(0, 1fr); + align-items: stretch; + gap: .65rem; +} +.engine-flow article { + min-height: 155px; + padding: 1rem; + border: 1px solid var(--line); + border-radius: 15px; + background: rgba(7, 13, 16, .5); +} +.engine-flow article span, +.engine-flow article strong, +.engine-flow article small { display: block; } +.engine-flow article span { + margin-bottom: 1.15rem; + color: var(--accent); + font-size: .62rem; + font-weight: 850; + letter-spacing: .14em; +} +.engine-flow article strong { margin-bottom: .55rem; font-size: .92rem; } +.engine-flow article small { color: var(--muted); font-size: .68rem; line-height: 1.5; } +.engine-flow i { + align-self: center; + color: var(--dim); + font-style: normal; +} +.proof-grid { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: .65rem; + margin-top: .65rem; +} +.proof-grid article { + padding: 1rem; + border-radius: 14px; + background: rgba(255,255,255,.025); +} +.proof-grid strong, .proof-grid span { display: block; } +.proof-grid strong { + margin-bottom: .25rem; + color: var(--cyan); + font-size: 1.45rem; + letter-spacing: -.04em; +} +.proof-grid span { color: var(--muted); font-size: .65rem; line-height: 1.35; } + +.mode-tabs { + display: flex; + align-items: center; + gap: .2rem; + border-bottom: 1px solid var(--line); +} +.mode-tab { + position: relative; + padding: 1rem .9rem; + border: 0; + color: var(--muted); + background: transparent; + cursor: pointer; + font-weight: 750; + font-size: .85rem; +} +.mode-tab::after { + position: absolute; + left: .9rem; + right: .9rem; + bottom: -1px; + height: 2px; + content: ""; + background: transparent; +} +.mode-tab.is-active { color: var(--ink); } +.mode-tab.is-active::after { background: var(--accent); } +.api-link { + margin-left: auto; + color: var(--muted); + font-size: .78rem; + text-decoration: none; +} +.api-link:hover { color: var(--ink); } + +.view-panel { padding: 3rem 0 5rem; } +.section-heading { + display: flex; + align-items: end; + justify-content: space-between; + gap: 1rem; + margin-bottom: 1.8rem; +} +h2 { + margin-bottom: 0; + font-size: clamp(1.9rem, 3.4vw, 3rem); + letter-spacing: -.045em; +} +.updated-text { margin-bottom: .2rem; color: var(--muted); font-size: .78rem; } + +.league-strip { + display: flex; + gap: .55rem; + overflow-x: auto; + padding-bottom: .8rem; + scrollbar-width: thin; +} +.league-chip { + display: flex; + align-items: center; + gap: .65rem; + flex: 0 0 auto; + padding: .65rem .7rem .65rem .9rem; + border: 1px solid var(--line); + border-radius: 999px; + color: var(--muted); + background: rgba(255,255,255,.025); + cursor: pointer; + font-size: .76rem; + font-weight: 700; +} +.league-chip b { + display: grid; + place-items: center; + min-width: 23px; + height: 23px; + padding: 0 .35rem; + border-radius: 999px; + color: var(--muted); + background: rgba(255,255,255,.07); + font-size: .66rem; +} +.league-chip:hover, .league-chip.is-active { + border-color: rgba(185,255,90,.42); + color: var(--ink); + background: var(--accent-soft); +} +.league-chip.is-active b { color: #10180f; background: var(--accent); } + +.loading-state, .empty-state { + display: flex; + align-items: center; + justify-content: center; + gap: .8rem; + min-height: 270px; + color: var(--muted); +} +.loader { + width: 18px; + height: 18px; + border: 2px solid var(--line); + border-top-color: var(--accent); + border-radius: 50%; + animation: spin .8s linear infinite; +} +@keyframes spin { to { transform: rotate(360deg); } } +.empty-state { flex-direction: column; text-align: center; } +.empty-state h3 { margin-bottom: .3rem; } +.empty-state p { max-width: 460px; color: var(--muted); line-height: 1.5; } +.empty-icon { color: var(--accent); font-size: 2.5rem; } + +.day-group { margin-top: 2rem; } +.day-heading { + display: flex; + align-items: center; + gap: .8rem; + margin-bottom: .85rem; + color: var(--muted); + font-size: .68rem; + font-weight: 800; + letter-spacing: .14em; + text-transform: uppercase; +} +.day-heading::after { flex: 1; height: 1px; content: ""; background: var(--line); } +.game-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: .75rem; +} +.game-card { + position: relative; + overflow: hidden; + min-height: 245px; + padding: 1rem; + border: 1px solid var(--line); + border-radius: 15px; + background: linear-gradient(145deg, rgba(29,42,48,.85), rgba(17,25,29,.92)); + transition: transform .16s ease, border-color .16s ease; +} +.game-card:hover { transform: translateY(-2px); border-color: rgba(255,255,255,.2); } +.card-topline { + display: flex; + justify-content: space-between; + gap: .75rem; + color: var(--muted); + font-size: .67rem; + font-weight: 750; + letter-spacing: .05em; + text-transform: uppercase; +} +.league-tag { color: var(--accent); } +.team-row { + display: grid; + grid-template-columns: minmax(0, 1fr) auto auto; + align-items: center; + gap: .65rem; + padding: .8rem 0; + border-bottom: 1px solid var(--line); +} +.team-row:first-of-type { margin-top: .55rem; } +.team-name { min-width: 0; } +.team-name strong, .team-name small { display: block; } +.team-name strong { overflow: hidden; font-size: .88rem; text-overflow: ellipsis; white-space: nowrap; } +.team-name small { margin-top: .2rem; color: var(--dim); font-size: .62rem; text-transform: uppercase; } +.winner-check { + color: var(--accent); + font-size: .88em; + font-weight: 950; + text-shadow: 0 0 14px rgba(185, 255, 90, .35); +} +.team-win { color: var(--muted); font-size: .72rem; font-variant-numeric: tabular-nums; } +.team-score { min-width: 2.5rem; text-align: right; font-size: 1.65rem; font-weight: 850; letter-spacing: -.05em; } +.projection-meta { + display: flex; + justify-content: space-between; + gap: .7rem; + padding-top: .9rem; + color: var(--muted); + font-size: .64rem; +} +.coverage-badge { + padding: .18rem .45rem; + border-radius: 999px; + color: var(--cyan); + background: rgba(116,232,255,.08); + white-space: nowrap; +} +.coverage-badge.is-low { color: #ffc06b; background: rgba(255,192,107,.08); } +.final-label { color: var(--accent); } + +.lab-layout { + display: grid; + grid-template-columns: minmax(380px, .8fr) minmax(0, 1.2fr); + gap: 1rem; +} +.simulator-form, .lab-result { + min-height: 470px; + padding: 1.35rem; + border: 1px solid var(--line); + border-radius: var(--radius); + background: rgba(17,25,29,.78); +} +.field label { + display: block; + margin: 0 0 .42rem; + color: var(--muted); + font-size: .69rem; + font-weight: 750; + letter-spacing: .04em; +} +select, input { + width: 100%; + min-height: 47px; + padding: .7rem .8rem; + border: 1px solid var(--line); + border-radius: 10px; + outline: none; + color: var(--ink); + background: var(--surface-soft); +} +select:focus, input:focus { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); } +.matchup-fields { + display: grid; + grid-template-columns: 1fr auto 1fr; + align-items: end; + gap: .6rem; + margin: 1rem 0; +} +.versus-mark { + display: grid; + place-items: center; + width: 38px; + height: 47px; + color: var(--dim); + font-size: .66rem; + font-weight: 900; +} +.sim-options { + display: grid; + grid-template-columns: 1fr 1fr; + gap: .8rem; + margin-bottom: 1rem; +} +.toggle { + display: flex; + align-items: center; + gap: .6rem; + grid-column: 1 / -1; + color: var(--muted); + cursor: pointer; + font-size: .75rem; +} +.toggle input { position: absolute; opacity: 0; width: 1px; height: 1px; } +.toggle span { + position: relative; + width: 38px; + height: 22px; + border-radius: 999px; + background: var(--surface-soft); + border: 1px solid var(--line); +} +.toggle span::after { + position: absolute; + top: 3px; + left: 3px; + width: 14px; + height: 14px; + border-radius: 50%; + content: ""; + background: var(--muted); + transition: transform .15s ease, background .15s ease; +} +.toggle input:checked + span::after { transform: translateX(16px); background: var(--accent); } +.simulator-form .button-primary { width: 100%; margin-top: .2rem; } +.form-note { margin: 1rem 0 0; color: var(--dim); font-size: .68rem; line-height: 1.5; } +.lab-result { display: grid; place-items: center; } +.lab-placeholder { max-width: 390px; color: var(--muted); text-align: center; } +.lab-placeholder > span { color: var(--accent); font-size: 3rem; } +.lab-placeholder h3 { margin: .8rem 0 .4rem; color: var(--ink); } +.lab-placeholder p { line-height: 1.55; } +.result-shell { width: 100%; align-self: stretch; display: flex; flex-direction: column; } +.result-kicker { color: var(--accent); font-size: .66rem; font-weight: 800; letter-spacing: .13em; text-transform: uppercase; } +.result-matchup { + display: grid; + grid-template-columns: 1fr auto 1fr; + align-items: center; + gap: 1rem; + margin: 1rem 0 1.4rem; + text-align: center; +} +.result-team strong, .result-team span, .result-team small { display: block; } +.result-team strong { font-size: 2.8rem; letter-spacing: -.06em; } +.result-team span { margin-top: .3rem; font-weight: 750; } +.result-team .winner-check { display: inline; margin: 0 0 0 .2rem; } +.result-team small { margin-top: .25rem; color: var(--muted); font-size: .68rem; } +.result-vs { color: var(--dim); font-size: .72rem; font-weight: 800; } +.probability-bar { + display: flex; + height: 10px; + overflow: hidden; + border-radius: 999px; + background: var(--surface-soft); +} +.probability-home { background: var(--accent); } +.probability-away { background: var(--cyan); } +.probability-legend { + display: flex; + justify-content: space-between; + margin-top: .55rem; + color: var(--muted); + font-size: .68rem; +} +.result-stats { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: .55rem; + margin-top: 1.3rem; +} +.result-stats div { + padding: .8rem; + border: 1px solid var(--line); + border-radius: 10px; + background: rgba(255,255,255,.02); +} +.result-stats strong, .result-stats small { display: block; } +.result-stats strong { margin-bottom: .25rem; font-size: 1rem; } +.result-stats small { color: var(--muted); font-size: .61rem; } +.result-evidence { + margin: auto 0 0; + padding-top: 1rem; + color: var(--dim); + font-size: .64rem; + line-height: 1.45; +} + +footer { + display: flex; + justify-content: space-between; + gap: 1rem; + width: min(1220px, calc(100% - 2.5rem)); + margin: 0 auto; + padding: 1.5rem 0 2.3rem; + border-top: 1px solid var(--line); + color: var(--dim); + font-size: .68rem; +} +footer p { margin: 0; } +footer a { color: var(--muted); } + +[hidden] { display: none !important; } + +@media (max-width: 960px) { + .hero { grid-template-columns: 1fr; min-height: auto; padding-top: 4rem; } + .hero-metrics { max-width: 620px; } + .showcase-heading { grid-template-columns: 1fr; } + .engine-flow { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .engine-flow i { display: none; } + .game-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .lab-layout { grid-template-columns: 1fr; } +} + +@media (max-width: 640px) { + .topbar { min-height: 66px; } + .health-pill { display: none; } + .button-quiet { padding: .6rem .7rem; } + main, footer { width: min(100% - 1.5rem, 1220px); } + .hero { padding: 3.2rem 0 2.5rem; gap: 2rem; } + h1 { font-size: clamp(3rem, 15vw, 4.6rem); } + .hero-metrics article { min-height: 95px; padding: 1rem .7rem; } + .hero-metrics small { font-size: .6rem; } + .showcase { margin-bottom: 2.8rem; padding: 1rem; border-radius: 18px; } + .engine-flow, .proof-grid { grid-template-columns: 1fr; } + .engine-flow article { min-height: auto; } + .section-heading { align-items: start; flex-direction: column; } + .game-grid { grid-template-columns: 1fr; } + .matchup-fields { grid-template-columns: 1fr; } + .versus-mark { width: auto; height: 20px; } + .result-matchup { gap: .5rem; } + .result-team strong { font-size: 2.15rem; } + .result-stats { grid-template-columns: 1fr; } + footer { flex-direction: column; } +} + +@media (prefers-reduced-motion: reduce) { + *, *::before, *::after { scroll-behavior: auto !important; animation-duration: .001ms !important; } +} diff --git a/src/universal_sports_engine/web_app.py b/src/universal_sports_engine/web_app.py new file mode 100644 index 0000000..4984000 --- /dev/null +++ b/src/universal_sports_engine/web_app.py @@ -0,0 +1,172 @@ +"""FastAPI application and local production server.""" + +from __future__ import annotations + +import os +from collections.abc import Awaitable, Callable +from datetime import date +from pathlib import Path +from typing import Any + +from fastapi import FastAPI, Query, Request +from fastapi.exceptions import RequestValidationError +from fastapi.responses import JSONResponse, Response +from fastapi.staticfiles import StaticFiles +from pydantic import BaseModel, ConfigDict, Field + +from universal_sports_engine.service import SportsEngineService +from universal_sports_engine.settings import DEFAULT_CACHE_DIR, DEFAULT_DATA_PATH + + +class HypotheticalRequest(BaseModel): + model_config = ConfigDict(extra="forbid", str_strip_whitespace=True) + + league: str = Field(min_length=2, max_length=16) + home_team_id: str = Field(min_length=1, max_length=64) + away_team_id: str = Field(min_length=1, max_length=64) + home_team_name: str | None = Field(default=None, max_length=120) + away_team_name: str | None = Field(default=None, max_length=120) + neutral_site: bool = False + simulations: int = Field(default=10_000, ge=100, le=50_000) + seed: int = Field(default=20_260_726, ge=0, le=2_147_483_647) + + +class RefreshRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + start: date | None = None + days: int = Field(default=7, ge=1, le=14) + force: bool = False + + +def _runtime_path(primary_name: str, legacy_name: str, fallback: Path) -> Path: + value = os.environ.get(primary_name) or os.environ.get(legacy_name) + return Path(value) if value else fallback + + +def create_app( + *, + service: SportsEngineService | None = None, + data_path: Path | None = None, + cache_dir: Path | None = None, +) -> FastAPI: + application_service = service or SportsEngineService.from_paths( + data_path + or _runtime_path("WATERBOY_DATA_PATH", "USE_DATA_PATH", DEFAULT_DATA_PATH), + cache_dir + or _runtime_path("WATERBOY_CACHE_DIR", "USE_CACHE_DIR", DEFAULT_CACHE_DIR), + ) + app = FastAPI( + title="Waterboy API", + version="0.4.0", + description=( + "Read-only public schedule ingestion, deterministic score distributions, " + "and hypothetical matchup simulation." + ), + ) + app.state.service = application_service + + @app.middleware("http") + async def security_headers( + request: Request, + call_next: Callable[[Request], Awaitable[Response]], + ) -> Response: + response = await call_next(request) + response.headers["X-Content-Type-Options"] = "nosniff" + response.headers["X-Frame-Options"] = "DENY" + response.headers["Referrer-Policy"] = "no-referrer" + response.headers["Permissions-Policy"] = "camera=(), microphone=(), geolocation=()" + response.headers["Content-Security-Policy"] = ( + "default-src 'self'; script-src 'self'; style-src 'self'; " + "img-src 'self' data:; connect-src 'self'; frame-ancestors 'none'; " + "base-uri 'self'; form-action 'self'" + ) + if request.url.path.startswith("/api/"): + response.headers["Cache-Control"] = "no-store" + return response + + @app.exception_handler(ValueError) + async def value_error_handler(_request: Request, exc: ValueError) -> JSONResponse: + return JSONResponse(status_code=422, content={"detail": str(exc)}) + + @app.exception_handler(RequestValidationError) + async def validation_error_handler( + _request: Request, exc: RequestValidationError + ) -> JSONResponse: + return JSONResponse(status_code=422, content={"detail": exc.errors()}) + + @app.get("/api/v1/health") + def health() -> dict[str, Any]: + return application_service.health() + + @app.get("/api/v1/leagues") + def leagues() -> list[dict[str, Any]]: + return application_service.leagues() + + @app.get("/api/v1/leagues/{league}/teams") + def teams(league: str) -> list[dict[str, Any]]: + return application_service.teams(league) + + @app.get("/api/v1/week") + def week( + start: date | None = None, + days: int = Query(default=7, ge=1, le=14), + league: str | None = None, + simulations: int = Query(default=2_000, ge=100, le=20_000), + seed: int = Query(default=20_260_726, ge=0, le=2_147_483_647), + ) -> dict[str, Any]: + return application_service.week( + start=start, + days=days, + league=league, + simulations=simulations, + seed=seed, + ) + + @app.post("/api/v1/data/refresh") + def refresh(request: RefreshRequest) -> dict[str, Any]: + return application_service.refresh_current_week( + start=request.start, + days=request.days, + force=request.force, + ) + + @app.post("/api/v1/simulations/hypothetical") + def hypothetical(request: HypotheticalRequest) -> dict[str, Any]: + return application_service.hypothetical(**request.model_dump()) + + static_dir = Path(__file__).with_name("web") / "static" + app.mount("/", StaticFiles(directory=static_dir, html=True), name="dashboard") + return app + + +def run_server( + *, + host: str = "127.0.0.1", + port: int = 8765, + data_path: Path | None = None, + cache_dir: Path | None = None, + allow_remote: bool = False, +) -> None: + """Run the local dashboard with a production ASGI server.""" + + if not 1 <= port <= 65_535: + raise ValueError(f"port must be within [1, 65535]: {port}") + if host not in {"127.0.0.1", "localhost", "::1"} and not allow_remote: + raise ValueError( + "The built-in server is localhost-only; deploy behind an authenticated " + "reverse proxy and pass --allow-remote for remote access" + ) + import uvicorn + + application = create_app(data_path=data_path, cache_dir=cache_dir) + uvicorn.run( + application, + host=host, + port=port, + access_log=True, + server_header=False, + proxy_headers=False, + timeout_keep_alive=5, + limit_concurrency=100, + ) diff --git a/src/waterboy/__init__.py b/src/waterboy/__init__.py new file mode 100644 index 0000000..0428fc5 --- /dev/null +++ b/src/waterboy/__init__.py @@ -0,0 +1,27 @@ +"""Public Waterboy package with compatibility exports from the proven kernel.""" + +from universal_sports_engine import ( + AdaptiveAnalyticalResult, + AnalyticalResult, + __version__, + counterfactual_game, + simulate_analytical, + simulate_ensemble_analytical, + simulate_game, + simulate_many, + simulate_many_analytical, + simulate_profile_analytical, +) + +__all__ = [ + "AdaptiveAnalyticalResult", + "AnalyticalResult", + "__version__", + "counterfactual_game", + "simulate_analytical", + "simulate_ensemble_analytical", + "simulate_game", + "simulate_many", + "simulate_many_analytical", + "simulate_profile_analytical", +] diff --git a/src/waterboy/__main__.py b/src/waterboy/__main__.py new file mode 100644 index 0000000..6f095b8 --- /dev/null +++ b/src/waterboy/__main__.py @@ -0,0 +1,6 @@ +"""Run the Waterboy command-line interface.""" + +from universal_sports_engine.cli import main + +if __name__ == "__main__": + main() diff --git a/tests/test_core.py b/tests/test_core.py index b9f8d24..557af8b 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -25,6 +25,7 @@ ) from universal_sports_engine.league_packs.common import ENGINE_VERSION from universal_sports_engine.simulation import simulate_game +from waterboy import __version__ as waterboy_version def test_random_draws_are_label_addressed_and_repeatable() -> None: @@ -36,7 +37,7 @@ def test_random_draws_are_label_addressed_and_repeatable() -> None: def test_package_and_evidence_versions_match() -> None: - assert __version__ == ENGINE_VERSION == "0.3.0" + assert waterboy_version == __version__ == ENGINE_VERSION == "0.4.0" def test_rng_rejects_invalid_inputs() -> None: diff --git a/tests/test_projection_service_web.py b/tests/test_projection_service_web.py new file mode 100644 index 0000000..5eb9149 --- /dev/null +++ b/tests/test_projection_service_web.py @@ -0,0 +1,171 @@ +from __future__ import annotations + +import asyncio +import hashlib +from datetime import UTC, date, datetime, timedelta +from pathlib import Path + +import httpx + +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.projection import ProjectionEngine +from universal_sports_engine.service import SportsEngineService +from universal_sports_engine.web_app import create_app + + +def _game( + game_id: str, + start_time: str, + *, + home: str = "ALP", + away: str = "BET", + home_score: int | None = None, + away_score: int | None = None, +) -> GameRecord: + final = home_score is not None and away_score is not None + return GameRecord( + game_id=game_id, + league="nba", + season=2026, + start_time=start_time, + status="final" if final else "scheduled", + home_team_id=home, + away_team_id=away, + home_name="Alphas" if home == "ALP" else "Betas", + away_name="Betas" if away == "BET" else "Alphas", + home_score=home_score, + away_score=away_score, + neutral_site=False, + venue="Test Arena", + source="fixture", + source_url="https://example.test", + observed_at="2026-07-26T00:00:00+00:00", + payload_hash=hashlib.sha256(game_id.encode()).hexdigest(), + ) + + +def _service(tmp_path: Path) -> SportsEngineService: + store = SportsDataStore(tmp_path / "sports.db") + history = [] + start = datetime(2026, 6, 1, tzinfo=UTC) + for index in range(24): + game_time = (start + timedelta(days=index)).isoformat() + if index % 2: + history.append( + _game( + f"h{index}", + game_time, + home="BET", + away="ALP", + home_score=101, + away_score=119, + ) + ) + else: + history.append( + _game(f"h{index}", game_time, home_score=121, away_score=99) + ) + history.append(_game("upcoming", "2026-07-27T00:00:00+00:00")) + store.upsert_games(history) + client = EspnScoreboardClient( + tmp_path / "cache", + transport=lambda _url, _timeout: b'{"events":[]}', + ) + return SportsEngineService( + store, + SportsDataPipeline(store, client), + ProjectionEngine(store), + ) + + +def test_projection_is_reproducible_and_team_aware(tmp_path: Path) -> None: + service = _service(tmp_path) + as_of = datetime(2026, 7, 26, tzinfo=UTC) + first = service.projections.project( + "nba", + "ALP", + "BET", + simulations=1_000, + seed=42, + as_of=as_of, + ) + second = service.projections.project( + "nba", + "ALP", + "BET", + simulations=1_000, + seed=42, + as_of=as_of, + ) + assert first == second + assert first.projected_home_score > first.projected_away_score + assert first.home_win_probability > first.away_win_probability + assert first.data_games == 24 + assert first.evidence_status == "historical_form_provisional_not_calibrated" + + +def test_service_week_and_hypothetical_alias(tmp_path: Path) -> None: + service = _service(tmp_path) + week = service.week(start=date(2026, 7, 26), simulations=100) + assert len(week["games"]) == 1 + assert week["games"][0]["projection"]["home_team_id"] == "ALP" + cold = service.hypothetical( + league="wba", + home_team_id="ONE", + away_team_id="TWO", + simulations=100, + seed=7, + ) + assert cold["league"] == "wnba" + assert cold["confidence_label"] == "LOW" + + +def test_web_api_and_dashboard_security_headers(tmp_path: Path) -> None: + async def exercise_api() -> None: + transport = httpx.ASGITransport(app=create_app(service=_service(tmp_path))) + async with httpx.AsyncClient( + transport=transport, + base_url="http://testserver", + ) as client: + dashboard = await client.get("/") + assert dashboard.status_code == 200 + assert "Every league" in dashboard.text + assert dashboard.headers["x-frame-options"] == "DENY" + assert "default-src 'self'" in dashboard.headers["content-security-policy"] + + health = await client.get("/api/v1/health") + assert health.status_code == 200 + assert health.json()["engine_name"] == "Waterboy" + assert health.json()["execution_authority"] is False + assert health.json()["database"]["database"] == "sports.db" + assert str(tmp_path) not in health.text + week = await client.get("/api/v1/week?start=2026-07-26&simulations=100") + assert week.status_code == 200 + assert len(week.json()["games"]) == 1 + matchup = await client.post( + "/api/v1/simulations/hypothetical", + json={ + "league": "nba", + "home_team_id": "ALP", + "away_team_id": "BET", + "simulations": 100, + "seed": 12, + }, + ) + assert matchup.status_code == 200 + assert matchup.json()["model_version"].startswith("hierarchical") + rejected = await client.post( + "/api/v1/simulations/hypothetical", + json={ + "league": "nba", + "home_team_id": "ALP", + "away_team_id": "ALP", + "simulations": 100, + }, + ) + assert rejected.status_code == 422 + + asyncio.run(exercise_api()) diff --git a/tests/test_sports_data.py b/tests/test_sports_data.py new file mode 100644 index 0000000..cdf0707 --- /dev/null +++ b/tests/test_sports_data.py @@ -0,0 +1,149 @@ +from __future__ import annotations + +import json +import sqlite3 +from datetime import date +from pathlib import Path +from urllib.error import HTTPError + +from universal_sports_engine.data.espn import EspnScoreboardClient, parse_scoreboard +from universal_sports_engine.data.pipeline import SportsDataPipeline +from universal_sports_engine.data.store import SportsDataStore + + +def _payload() -> dict[str, object]: + return { + "events": [ + { + "id": "401", + "date": "2026-07-26T19:10:00Z", + "season": {"year": 2026}, + "competitions": [ + { + "neutralSite": False, + "venue": {"fullName": "Research Field"}, + "status": {"type": {"state": "pre"}}, + "competitors": [ + { + "homeAway": "home", + "team": { + "id": "1", + "abbreviation": "HOM", + "displayName": "Home Club", + }, + }, + { + "homeAway": "away", + "team": { + "id": "2", + "abbreviation": "AWY", + "displayName": "Away Club", + }, + }, + ], + } + ], + } + ] + } + + +def test_espn_parser_preserves_schedule_truth_and_provenance() -> None: + games = parse_scoreboard( + "mlb", + _payload(), + observed_at="2026-07-26T12:00:00+00:00", + source_url="https://example.test/scoreboard", + ) + assert len(games) == 1 + game = games[0] + assert game.game_id == "espn:mlb:401" + assert game.status == "scheduled" + assert game.home_score is None and game.away_score is None + assert game.home_name == "Home Club" + assert game.source == "espn_public_scoreboard" + assert len(game.payload_hash) == 64 + + +def test_espn_client_cache_avoids_repeat_network_reads(tmp_path: Path) -> None: + calls: list[str] = [] + + def transport(url: str, _timeout: float) -> bytes: + calls.append(url) + return json.dumps(_payload()).encode() + + client = EspnScoreboardClient(tmp_path / "cache", transport=transport) + first = client.games("mlb", date(2026, 7, 26), date(2026, 7, 27)) + second = client.games("mlb", date(2026, 7, 26), date(2026, 7, 27)) + assert first == second + assert len(calls) == 1 + + +def test_espn_client_falls_back_to_bounded_daily_reads(tmp_path: Path) -> None: + calls: list[str] = [] + + def transport(url: str, _timeout: float) -> bytes: + calls.append(url) + if "dates=20260726-20260727" in url: + raise HTTPError(url, 400, "date range rejected", None, None) + return json.dumps(_payload()).encode() + + client = EspnScoreboardClient( + tmp_path / "cache", + max_retries=0, + transport=transport, + ) + games = client.games("ncaambb", date(2026, 7, 26), date(2026, 7, 27)) + assert len(games) == 1 + assert len(calls) == 3 + assert all("dates=20260726-20260727" not in url for url in calls[1:]) + + +def test_store_and_read_only_dummy_import(tmp_path: Path) -> None: + source = tmp_path / "dummy.db" + connection = sqlite3.connect(source) + connection.execute( + """ + CREATE TABLE games( + game_id TEXT, league TEXT, season INTEGER, start_time TEXT, status TEXT, + home TEXT, away TEXT, home_score INTEGER, away_score INTEGER, + venue TEXT, neutral INTEGER, source TEXT, provenance_url TEXT, + result_available_at TEXT, received_at TEXT, as_of TEXT, extra TEXT + ) + """ + ) + connection.execute( + """ + INSERT INTO games VALUES( + 'g1','ncaamb',2026,'2026-03-01T18:00:00Z','final', + 'AAA','BBB',81,72,'Arena',0,'espn','https://example.test/g1', + '2026-03-01T21:00:00Z','2026-03-01T21:01:00Z',NULL,'{}' + ) + """ + ) + connection.execute( + """ + INSERT INTO games VALUES( + 'g2','ncaamb',2026,'2026-03-02T18:00:00Z','final', + 'SAME','SAME',70,68,'Arena',0,'espn','https://example.test/g2', + '2026-03-02T21:00:00Z','2026-03-02T21:01:00Z',NULL,'{}' + ) + """ + ) + connection.commit() + connection.close() + before = source.read_bytes() + + store = SportsDataStore(tmp_path / "target.db") + pipeline = SportsDataPipeline( + store, + EspnScoreboardClient(tmp_path / "cache", transport=lambda _url, _timeout: b"{}"), + ) + results = pipeline.import_dummy_history(source) + assert source.read_bytes() == before + assert sum(result.stored for result in results) == 1 + games = store.completed_games("ncaam") + assert len(games) == 1 + assert games[0].league == "ncaambb" + assert games[0].home_score == 81 + assert {team.team_id for team in store.teams("ncaambb")} == {"AAA", "BBB"}