# MECHANISM DATA — the Layer-1 pattern every sport inherits Layer 1 of the archetype+projection build. It lands **mechanism data** — how a player actually does what he does — and keeps it current. It does **not** classify (Layer 2) or project (Layer 3). MLB is the first instance. NBA tracking and NFL Next Gen slot into the same four boxes with a different adapter and a different source id. ## The pattern ``` SOURCE ADAPTER free/public first · absent-not-zero · injectable fetch │ no new runtime deps · one file per sport ▼ IDENTITY BRIDGE source-native id ↔ our player_key │ the match rate is MEASURED and REPORTED, never assumed — │ the unmatched rate IS the honest-absent rate ▼ AGGREGATE pure, re-runnable, explicit MIN-SAMPLE gates DERIVATION thin ≠ missing: both stored, distinguishable ▼ HOT STORE small, indexed, upserted on the natural key (Supabase) + updated_at, because freshness is a truth property ``` ### The five rules that make it a pattern 1. **Backfill and refresh are the same call.** A full re-pull upserted on the natural key is idempotent and self-healing: a missed night self-corrects on the next run. No incremental "who played today" bookkeeping to drift out of sync. Only viable because the aggregate grain is small — which is the point of aggregating. 2. **Aggregate grain, not raw.** One season of raw MLB per-pitch is ~0.85 GB in Postgres against a 500 MB plan ceiling; the aggregate set is ~1,350 rows. Raw stays retrievable from the free source if Layer 3 ever needs it. 3. **Absent is absent.** A metric the feed did not carry is `null`, never `0`. A player below the minimum sample is stored and flagged, not dropped and not inflated. A player we cannot join is stored with a null `player_key` and joins later — storing him is not a claim about him. 4. **Refuse to write nothing.** If every feed returns empty that is a source failure, not "there is no mechanism data". The job refuses the write so a bad night can never blank a good table. 5. **Freshness is monitored.** `updated_at` on every row; the scheduler pages on a failed run AND on silent staleness — a job that stops being scheduled never produces a failure, so staleness must alarm on its own. **Never-built is not stale**: different condition, different fix, and paging on a fresh install teaches the operator to ignore the alarm. ## MLB instance - **Adapter** `src/services/adapters/statcastAdapter.js` — five Baseball Savant CSV leaderboards (free, public, no key). Direct `axios` + the existing CSV parser; **pybaseball is deliberately not used** — it is a Python wrapper over these same URLs, and the stack's Python service is already offline in prod. - **Service** `src/services/statcastAggregateService.js` — `buildRows` (pure), `refreshSeason` (the job), `getFreshness` / `isStale` (the alarm predicates). - **Store** `statcast_aggregates` (migration 030), PK `(sport, season, source_id)`. Promoted columns for the classification-critical metrics + a `metrics` JSONB carrying every raw field, so Layer 2/3 can reach something we did not promote **without a re-ingest**. - **Schedule** nightly at `STATCAST_HOUR_UTC` (default 11 UTC ≈ 7 AM ET, after every game is final). Kill switch `STATCAST=0`. - **Induce** `POST /api/internal/statcast/refresh` · **probe** `GET /api/internal/statcast/status` (internal key). We verify a refresh by running it, never by waiting for the slot. ### Measured (2026-07-20) | | | |---|---| | Batter match rate | **100%** (40/40 real players) | | Pitcher match rate | **100%** (66/66 real roster pitchers) | | Rows per season | **1,354** (604 batters + 750 pitchers) | | Join rate | **1,354 / 1,354** | | Handedness present | 677 pitchers (from the movement feed) | | Sufficient / thin | 998 / 356 at PA≥50, IP≥10 | | Pull time | ~5 s for all five feeds | ## Adding a sport 1. Write `src/services/adapters/{sport}Adapter.js` returning the same shape: indexes keyed by source id, metrics strictly parsed. 2. Confirm and **report** the identity match rate before building on it. 3. Extend `buildRows` with the sport's role vocabulary and its minimum-sample gates. 4. Add the scheduler hour and the induce endpoint. Nothing else changes: the store, the alarm and the upsert semantics are shared. ## Commodity, not moat Raw Statcast is public — every competitor can pull the same numbers in about a second. Ingesting it is table stakes. The edge is Layer 2 (which mechanism signals define an archetype, and where the boundaries sit), Layer 3 (projections built on them), and the settled ledger that proves whether any of it predicts anything. **Having the data is not having an edge. Having it plus an attributed record is.**