Layer 1: Statcast mechanism-data ingestion (backfill + nightly refresh)
The data foundation for the archetype and projection layers, built as the pattern every sport inherits. Layers 2 and 3 are not touched. PHASE 0 GATE — both match rates measured live, both 100%. Batters 40/40; PITCHERS 66/66 across five real rosters (CLE, DET, MIN, NYY, LAD) joined by MLBAM id against the 713-pitcher Savant feed. Zero honest-absent on identity, because the join is an integer both systems use natively — and the snapshot pipeline already stores it per graded row. SOURCE — five Baseball Savant CSV leaderboards, free and public, pulled with axios and the CSV parser savantAdapter already runs in prod. pybaseball is deliberately NOT used: it is an MIT wrapper over these same URLs, and adding it would reintroduce a Python runtime in a stack where the existing Python service is already offline. min=1 on every feed, not Savant's default min=q, so the long tail arrives and OUR minimum-sample gate decides what is thin — explicit and testable rather than silently dropped upstream. Measured: 1,354 rows per season (604 batters, 750 pitchers), all five feeds in about five seconds. Pitcher mechanism includes arm angle, GB/FB/LD, chase and whiff; batters get exit velo, launch angle, barrel and hard-hit, chase and z-swing. Handedness rides in free on the movement feed (677 pitchers); batter handedness stays absent pending a roster join rather than being guessed. BACKFILL AND REFRESH ARE THE SAME CALL — a full re-pull upserted on (sport, season, source_id). Idempotent and self-healing: a missed night self-corrects on the next run, with no incremental who-played bookkeeping to drift out of sync. At 1,354 rows the simple thing is also the robust one. HONESTY RULES, each with a test: a metric the feed did not carry is null and never 0; a thin sample is STORED and flagged rather than dropped or inflated, because thin and missing are different claims; an unjoined player is stored with a null player_key and joins later; and if every feed comes back empty the job REFUSES to write, so a bad night can never blank a good table. Freshness is treated as a truth property. updated_at on every row, and the scheduler pages on a failed run AND on silent staleness — a job that stops being scheduled never produces a failure, so staleness has to alarm on its own. Never-built is deliberately not stale: different condition, different fix, and paging on a fresh install teaches the operator to ignore the alarm. Nightly at STATCAST_HOUR_UTC (default 11 UTC, after every game is final), kill switch STATCAST=0, and induce-able at POST /api/internal/statcast/refresh with a freshness probe at /statcast/status — we verify a refresh by running it, not by waiting for the slot. Migration 030 applied. Promoted columns for the classification-critical metrics plus a metrics JSONB carrying every raw field, so Layer 2 can reach something we did not promote without a re-ingest. Raw per-pitch stays out of Postgres on purpose: one season is ~0.85 GB against a 500 MB plan ceiling, and it is re-pullable from the free source if Layer 3 ever needs it. Pattern documented in docs/MECHANISM-DATA.md for NBA tracking and NFL Next Gen. Tests 3581 passed / 292 suites, web build exit 0. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VCNgGSt5qvcLxaeQqa7Zpj
This commit is contained in:
@@ -0,0 +1,75 @@
|
||||
-- Layer 1 — MECHANISM DATA (Session 68).
|
||||
-- Per-player-per-season Statcast aggregates. The HOT tier of the two-tier
|
||||
-- mechanism pattern: small, indexed, joined at classification time. Raw
|
||||
-- per-pitch stays out of Postgres on purpose (one season ~0.85 GB vs this
|
||||
-- table's ~2,300 rows) — see docs/MECHANISM-DATA.md.
|
||||
--
|
||||
-- The table is SPORT-SCOPED from day one: NBA tracking and NFL Next Gen land
|
||||
-- in the same shape with a different `sport` and their own source ids.
|
||||
|
||||
create table if not exists statcast_aggregates (
|
||||
sport text not null default 'mlb',
|
||||
season int not null,
|
||||
-- Source-native player id (MLB: MLBAM). The join key — an integer both
|
||||
-- systems use natively, which is why the measured match rate is 100%.
|
||||
source_id bigint not null,
|
||||
-- Our canonical key. Nullable ON PURPOSE: a player Statcast has but we have
|
||||
-- never graded still gets stored, and joins later.
|
||||
player_key text,
|
||||
player_name text,
|
||||
role text not null check (role in ('batter','pitcher')),
|
||||
-- Handedness: pitchers from the movement feed, batters from the roster join.
|
||||
-- NULL = honest-absent, never guessed.
|
||||
throws text,
|
||||
bats text,
|
||||
|
||||
-- Sample size — the gate for every downstream claim.
|
||||
sample_pa numeric,
|
||||
sample_ip numeric,
|
||||
sample_bip numeric,
|
||||
|
||||
-- Plate discipline / contact quality (shared vocabulary, both roles).
|
||||
k_pct numeric,
|
||||
bb_pct numeric,
|
||||
whiff_pct numeric,
|
||||
swing_pct numeric,
|
||||
chase_pct numeric,
|
||||
barrel_pct numeric,
|
||||
hard_hit_pct numeric,
|
||||
|
||||
-- Batter mechanism.
|
||||
avg_exit_velo numeric,
|
||||
max_exit_velo numeric,
|
||||
avg_launch_angle numeric,
|
||||
sweet_spot_pct numeric,
|
||||
ev95_pct numeric,
|
||||
|
||||
-- Pitcher mechanism.
|
||||
arm_angle numeric,
|
||||
gb_pct numeric,
|
||||
fb_pct numeric,
|
||||
ld_pct numeric,
|
||||
pitch_mix jsonb,
|
||||
|
||||
-- Everything else the feeds carry, verbatim. Layer 2/3 can reach a field we
|
||||
-- did not promote to a column WITHOUT a re-ingest.
|
||||
metrics jsonb not null default '{}'::jsonb,
|
||||
|
||||
-- Freshness is a TRUTH property: the staleness alarm reads this, and no
|
||||
-- surface may present a stale aggregate as current.
|
||||
updated_at timestamptz not null default now(),
|
||||
source text not null default 'baseball_savant',
|
||||
|
||||
primary key (sport, season, source_id)
|
||||
);
|
||||
|
||||
create index if not exists statcast_agg_player_key_idx on statcast_aggregates (sport, season, player_key);
|
||||
create index if not exists statcast_agg_role_idx on statcast_aggregates (sport, season, role);
|
||||
create index if not exists statcast_agg_updated_idx on statcast_aggregates (updated_at desc);
|
||||
|
||||
alter table statcast_aggregates enable row level security;
|
||||
|
||||
-- Commodity public data: readable by anyone, written ONLY by the service role
|
||||
-- (the pipeline). Same posture as the public ledger rows.
|
||||
drop policy if exists statcast_agg_read on statcast_aggregates;
|
||||
create policy statcast_agg_read on statcast_aggregates for select using (true);
|
||||
Reference in New Issue
Block a user