a011ae79fe
Found by inducing the real job on the server, not by review: the first chunk wrote, the second failed with 'ON CONFLICT DO UPDATE command cannot affect row a second time'. A player can legitimately appear in BOTH the batter and the pitcher feeds — two-way players, position players who pitch, pitchers who bat — so (sport, season, source_id) collapsed two real profiles into one key and a single batch hit the same row twice. Ohtani has a real batter profile and a real pitcher profile. Merging them would invent one player out of two genuinely different sets of measurements, so role goes in the primary key rather than one profile winning. Migration 031 applied; conflict target updated; a two-way case is now a test. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VCNgGSt5qvcLxaeQqa7Zpj
78 lines
3.2 KiB
SQL
78 lines
3.2 KiB
SQL
-- 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',
|
|
|
|
-- ROLE is in the key: two-way players have a real batter profile AND a
|
|
-- real pitcher profile (see migration 031).
|
|
primary key (sport, season, source_id, role)
|
|
);
|
|
|
|
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);
|