RinkView

Engineering

How this is built, and why the numbers can be checked

Every figure below is read live from /api/meta/ — the shipped fit file and the persisted ingest — or measured live by the contract check further down. Nothing here is a number retyped into a paragraph.

Model card →API contractCalibration reportData provenanceSource on GitHub ↗

Model evidence

The strongest thing in the repo, first — because it is the thing a hockey analyst should demand before reading anything else.

Reading /api/meta/…

Shrinkage and intervals

Every rate in the evaluator is shrunken, every estimate carries a 90% interval, and the parameters below are the ones this deployment actually persisted at ingest.

Reading /api/meta/…

Architecture

One stateless container in production; two processes in development. No CORS configuration anywhere, in either mode.

data/raw/*.json[.gz]      archived NHL play-by-play + shift charts
      │
      ▼  scripts/validate_raw_data.py   (hard gates, non-zero exit)
analytics/etl.py          pure-Pandas transform: clean, normalize,
                          distance/angle/xG/strength
      │
      ▼  manage.py ingest  (idempotent, per-game rebuild)
analytics/models.py       Game / Player / ShotEvent / PlayerGameStat
analytics/evaluator.py    + shift-accurate season & pair rows, EB
                          shrinkage, bootstrap replicates  (ALL at ingest)
      │
      ▼
analytics/api.py          read-only JSON, deterministic, 1 h cacheable
      │
      ▼  same origin in prod; Next server proxy in dev
frontend/src/lib/         framework-free chart engine + pure transforms
frontend/src/components/  thin React shells that mount them

The load-bearing choice: nothing statistical runs per request. τ estimation, shrinkage, the 500-replicate bootstrap and every pair row are computed once at ingest and persisted; the API recombines stored rows. That is what makes every endpoint deterministic, cacheable for an hour, and cheap enough to serve from a scale-to-zero container — and it is why a scenario projection returns in milliseconds rather than resampling on the hot path.

Source-to-screen lineage

Each hop, and the thing to open if you do not believe it.

StageWhat happensWhereHow to check it
SourceNHL API play-by-play + shift charts, archived verbatimdata/raw/game_<id>.json[.gz], players.jsondata/PROVENANCE.md — mirror repo + commit hash, byte-identity gate
ValidationParse, dedupe gameIds, on-rink coordinates, 82 games per team, goal events reconciled against the official final scorescripts/validate_raw_data.pyNon-zero exit on any hard failure; results dated in data/PROVENANCE.md
TransformFenwick filter, team-id mapping, rink-orientation normalization, distance/angle, xG, strength state from shift overlapsanalytics/etl.py, analytics/stints.pyDirty rows are counted and dropped, never patched; tests/test_etl.py
ModelxG coefficients loaded at import from the versioned fit fileanalytics/xg_model.json (manage.py fit_xg)Held-out calibration, asserted in CI (TestFittedXgModel)
EstimateShift-accurate season/pair on-ice rates, EB shrinkage, bootstrap replicates — all at ingest, none per requestanalytics/evaluator.py → PlayerSeasonOnIce, PairSeasonOnIce, BootstrapReplicateAgg, EvaluatorMetaτ, seed, B and floors returned in every response's method block
ServeRead-only JSON, deterministic, cacheable for an houranalytics/api.py (contract: docs/API.md)The live contract check on this page
RenderFramework-free chart/transform modules mounted by thin React shellsfrontend/src/lib/ → frontend/src/components/node:test suites over every pure module

Ingestion

A finished, archived season — so the honest schedule is 'on demand and on every build', not a cron that pretends to be live.

Reading /api/meta/…

  • manage.py ingest is idempotent and per-game: re-running rebuilds each game's rows in place, so a partial or repeated run cannot double-count. Real files always beat synthetic ones.
  • Cadence: on demand locally; on every CI run (a two-club, 160-game smoke against a throwaway database) and once in full (all 1,312 games, ~3 minutes) in the Docker boot check, so the full path is exercised before an image ships.
  • Dirty rows are counted and dropped, never patched. The 55 rows the league ingest drops were verified individually: 22 goalie attempts (absent from the skaters-only roster by design) and 33 penalty shots, whose no-skaters-on-ice situation code the 5v5 parser correctly rejects.
  • Live-season use would need one addition, and it is a scheduler, not a rewrite: the per-game rebuild already makes incremental ingest safe, and every estimate is derived from persisted per-game rows.

Schema choices

Why the tables are shaped the way they are.

  • Events, then aggregates. ShotEvent is the grain; every team, player, pair and season figure is derived from it. Nothing is stored that cannot be rebuilt by re-running ingest.
  • On-ice rows are spell-scoped(PlayerSeasonOnIce, PairSeasonOnIcecarry a team). A traded player therefore appears in each club's matrix with only that club's minutes, instead of silently importing another team's context.
  • Uniqueness is a database constraint, not a convention. One row per player-game, per player-team-strength season, per pair-team-strength season, per bootstrap replicate — so a double ingest fails loudly instead of inflating a rate.
  • Indexes follow the access pattern: (team, strength) on every on-ice and replicate table, because every evaluator endpoint filters on exactly that pair.
  • Pre-computed uncertainty is first-class. BootstrapReplicateAgg persists replicate aggregates and EvaluatorMeta persists τ, the seed, the floors and the strength-agreement rate — so the method block served with every response is a record of what actually ran, not a description of what was supposed to.
  • SQLite on purpose. The dataset is a finished season, read-only in production, and fits in the image; a database server would add an operational dependency and buy nothing. The ORM layer is standard Django, so the move is a settings change.

Validation

Hard gates over the raw data, run before anything is believed.

  • Every game file parses; gameIds are unique (each game is on two clubs' schedules and is stored once).
  • Exactly 82 games per team, 32 teams, 1,312 unique games league-wide.
  • Every non-null shot coordinate is on the rink (|x| ≤ 100, |y| ≤ 42.5); null-coordinate rows are counted, then dropped as dirty.
  • Goal events reconcile with the official final score archived in the mirror — in all 1,312 games, with the shootout case handled explicitly.
  • The derived 5v5 strength state is checked against the shot-level situationCode, with the agreement rate reported above and gated at 95%.
  • The original 82 Utah files stay uncompressed and byte-identical, guarded by the extractor's byte-identity gate.

scripts/validate_raw_data.py exits non-zero on any hard failure; results are dated in data/PROVENANCE.md.

API contract — checked live, now

Documented in the API contract, linked above. Demonstrated here: every endpoint this deployment claims to serve, called from your browser on page load.

Reading /api/meta/…

  • Every endpoint is GET-only, read-only and deterministic — the dataset is a finished season — so responses carry public, max-age=3600 and are safe for shared caches.
  • Bad input returns 400 with a readable sentence, not a 500 and not a silent default: an unknown sort key names the legal ones; a sub-floor player is refused with the floor and the reason.
  • Unknown teams return an empty games list rather than an error — a club with no ingested games is a data state, not a failure.
  • Monitoring: /healthz is a no-DB liveness probe with no-store; Cloud Run request logs and container metrics cover the rest. This page is the deliberate extra — a contract check a reviewer can run without credentials.

Deployment and test status

One command to ship; one workflow that refuses to ship something broken.

Deployment

  • One stateless container on Cloud Run, scale to zero. next build with NEXT_OUTPUT=export emits static HTML; Django + WhiteNoise serve it from the same origin as /api/*, so the browser never makes a cross-origin call and there is no CORS configuration to get wrong.
  • Hashed assets under /_next/static/* are immutable; exported pages get a short cache because they are rebuilt per image.
  • Auto-deploy via GitHub Actions with Workload Identity Federation locked to this one repository — keyless, no long-lived service-account secret.
  • In development the same code runs as two processes: Next proxies /api/* to Django server-side. Both modes, no CORS.

Tests in CI

  • Backend (pytest):ETL, models, ingest, the evaluator's statistics, the API contract, serving, and the xG calibration assertions — a calibration regression fails the build.
  • Ingest smoke: migrations plus a real two-club ingest against a throwaway database, end to end through readers, ETL, evaluator and bootstrap.
  • Frontend (node:test): every pure module in src/lib/— chart configs, transforms, the answer and sensitivity logic, the demo's verdict sentences — with no test framework dependency at all. Plus lint and a production build.
  • Docker: both images build, the full 1,312-game ingest runs, and the API container boots.

Honest limitations, and the defence of each statistical choice, are kept in the method notes and the model card rather than softened here — both served by this app, and the source is on GitHub. Want the product rather than the plumbing? Take the 90-second demo →