528cb1a6d0
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
274 lines
9.5 KiB
JavaScript
274 lines
9.5 KiB
JavaScript
'use strict';
|
|
|
|
/**
|
|
* STATCAST AGGREGATE SERVICE (Layer 1) — derivation + join + store.
|
|
*
|
|
* The AGGREGATE DERIVATION and IDENTITY BRIDGE boxes of the mechanism-data
|
|
* pattern (docs/MECHANISM-DATA.md). Pure where it can be, injectable
|
|
* everywhere, so tests never touch the network or the database.
|
|
*
|
|
* BACKFILL and REFRESH are the SAME operation: a full re-pull of the ~1,300-row
|
|
* aggregate set, upserted on (sport, season, source_id). That makes the nightly
|
|
* job idempotent and self-healing — a missed night self-corrects on the next
|
|
* run, with no incremental "who played today" bookkeeping to drift out of sync.
|
|
* At this volume the simple thing is also the robust thing.
|
|
*
|
|
* HONESTY RULES:
|
|
* - A metric the feed did not carry is ABSENT (null), never 0.
|
|
* - A player below the minimum sample keeps his row but is marked
|
|
* `sufficient: false` — thin is not the same as missing, and Layer 2 must
|
|
* be able to tell them apart.
|
|
* - A player we cannot join to our own roster still stores (player_key null)
|
|
* and joins later. Storing him is not a claim about him.
|
|
* - `updated_at` is written every run. Freshness is a truth property; the
|
|
* staleness alarm reads it.
|
|
*/
|
|
|
|
const { nameKey } = require('../utils/playerName');
|
|
const statcast = require('./adapters/statcastAdapter');
|
|
|
|
/** Minimum samples for a mechanism claim. Below these the row is stored but
|
|
* flagged insufficient — the explicit gate that prevents a 12-PA sample from
|
|
* being classified as confidently as a 400-PA one (the FLEX-at-weight-1.0
|
|
* failure, structurally prevented). */
|
|
const MIN_PA = Number(process.env.STATCAST_MIN_PA) || 50;
|
|
const MIN_IP = Number(process.env.STATCAST_MIN_IP) || 10;
|
|
|
|
const num = (v) => {
|
|
if (v == null || v === '') return null;
|
|
const n = typeof v === 'number' ? v : Number(v);
|
|
return Number.isFinite(n) ? n : null;
|
|
};
|
|
|
|
/**
|
|
* buildRows(season, feeds, opts) — PURE. Merges the five feed indexes into one
|
|
* row per player per role. No I/O.
|
|
*/
|
|
function buildRows(season, feeds, opts = {}) {
|
|
const sport = opts.sport || 'mlb';
|
|
const now = opts.now || new Date().toISOString();
|
|
const rows = [];
|
|
|
|
// ---- BATTERS -----------------------------------------------------------
|
|
const batterIds = new Set([...feeds.batterDiscipline.keys(), ...feeds.batterBattedBall.keys()]);
|
|
for (const id of batterIds) {
|
|
const d = feeds.batterDiscipline.get(id);
|
|
const b = feeds.batterBattedBall.get(id);
|
|
const m = { ...(b ? b.metrics : {}), ...(d ? d.metrics : {}) };
|
|
const name = (d && d.name) || (b && b.name) || null;
|
|
const pa = num(m.pa);
|
|
rows.push({
|
|
sport,
|
|
season,
|
|
source_id: id,
|
|
player_key: name ? nameKey(name) : null,
|
|
player_name: name,
|
|
role: 'batter',
|
|
throws: null,
|
|
bats: null, // roster join fills this; absent otherwise (never guessed)
|
|
sample_pa: pa,
|
|
sample_ip: null,
|
|
sample_bip: num(m.bip),
|
|
k_pct: num(m.k_pct),
|
|
bb_pct: num(m.bb_pct),
|
|
whiff_pct: num(m.whiff_pct),
|
|
swing_pct: num(m.swing_pct),
|
|
chase_pct: num(m.chase_pct),
|
|
barrel_pct: num(m.barrel_pct) ?? num(m.barrel_pct_bb),
|
|
hard_hit_pct: num(m.hard_hit_pct),
|
|
avg_exit_velo: num(m.avg_exit_velo),
|
|
max_exit_velo: num(m.max_exit_velo),
|
|
avg_launch_angle: num(m.avg_launch_angle),
|
|
sweet_spot_pct: num(m.sweet_spot_pct) ?? num(m.sweet_spot_pct_bb),
|
|
ev95_pct: num(m.ev95_pct),
|
|
arm_angle: null,
|
|
gb_pct: null,
|
|
fb_pct: null,
|
|
ld_pct: null,
|
|
pitch_mix: null,
|
|
metrics: m,
|
|
updated_at: now,
|
|
_sufficient: pa != null && pa >= MIN_PA,
|
|
});
|
|
}
|
|
|
|
// ---- PITCHERS ----------------------------------------------------------
|
|
const pitcherIds = new Set([
|
|
...feeds.pitcherDiscipline.keys(),
|
|
...feeds.pitcherBattedBall.keys(),
|
|
...feeds.pitchMix.keys(),
|
|
]);
|
|
for (const id of pitcherIds) {
|
|
const d = feeds.pitcherDiscipline.get(id);
|
|
const b = feeds.pitcherBattedBall.get(id);
|
|
const mix = feeds.pitchMix.get(id);
|
|
const m = { ...(b ? b.metrics : {}), ...(d ? d.metrics : {}) };
|
|
const name = (d && d.name) || (mix && mix.name) || (b && b.name) || null;
|
|
const ip = num(m.ip);
|
|
rows.push({
|
|
sport,
|
|
season,
|
|
source_id: id,
|
|
player_key: name ? nameKey(name) : null,
|
|
player_name: name,
|
|
role: 'pitcher',
|
|
// Handedness comes free on the movement feed — the ONLY feed that carries
|
|
// it. Absent when a pitcher has thrown too few tracked pitches to appear.
|
|
throws: (mix && mix.throws) || null,
|
|
bats: null,
|
|
sample_pa: null,
|
|
sample_ip: ip,
|
|
sample_bip: num(m.bip),
|
|
k_pct: num(m.k_pct),
|
|
bb_pct: num(m.bb_pct),
|
|
whiff_pct: num(m.whiff_pct),
|
|
swing_pct: num(m.swing_pct),
|
|
chase_pct: num(m.chase_pct),
|
|
barrel_pct: num(m.barrel_pct_allowed),
|
|
hard_hit_pct: num(m.hard_hit_pct_allowed),
|
|
avg_exit_velo: num(m.avg_exit_velo),
|
|
max_exit_velo: num(m.max_exit_velo),
|
|
avg_launch_angle: num(m.avg_launch_angle),
|
|
sweet_spot_pct: num(m.sweet_spot_pct_bb),
|
|
ev95_pct: num(m.ev95_pct),
|
|
arm_angle: num(m.arm_angle),
|
|
gb_pct: num(m.gb_pct),
|
|
fb_pct: num(m.fb_pct),
|
|
ld_pct: num(m.ld_pct),
|
|
pitch_mix: mix && mix.pitches && mix.pitches.length ? mix.pitches : null,
|
|
metrics: m,
|
|
updated_at: now,
|
|
_sufficient: ip != null && ip >= MIN_IP,
|
|
});
|
|
}
|
|
|
|
return rows;
|
|
}
|
|
|
|
/** Strip the internal flag before the row reaches Postgres. */
|
|
function toDbRow(r) {
|
|
const { _sufficient, ...rest } = r;
|
|
return rest;
|
|
}
|
|
|
|
/**
|
|
* refreshSeason(opts) — THE JOB. Backfill and nightly refresh are the same call.
|
|
*
|
|
* opts: { season, sport, supabase, fetchSeason, now, chunkSize }
|
|
* Returns a summary — never throws on a partial failure; a broken feed degrades
|
|
* to fewer rows, which is honest, rather than to a wrong number.
|
|
*/
|
|
async function refreshSeason(opts = {}) {
|
|
const season = opts.season || statcast.DEFAULT_SEASON;
|
|
const sport = opts.sport || 'mlb';
|
|
const started = opts.now || new Date().toISOString();
|
|
|
|
const fetchSeason = opts.fetchSeason || statcast.fetchSeason;
|
|
const feeds = await fetchSeason(season, opts);
|
|
const rows = buildRows(season, feeds, { sport, now: started });
|
|
|
|
const summary = {
|
|
ok: true,
|
|
sport,
|
|
season,
|
|
started_at: started,
|
|
feed_counts: feeds.counts || null,
|
|
rows: rows.length,
|
|
batters: rows.filter((r) => r.role === 'batter').length,
|
|
pitchers: rows.filter((r) => r.role === 'pitcher').length,
|
|
sufficient: rows.filter((r) => r._sufficient).length,
|
|
thin: rows.filter((r) => !r._sufficient).length,
|
|
joined: rows.filter((r) => r.player_key).length,
|
|
unjoined: rows.filter((r) => !r.player_key).length,
|
|
with_handedness: rows.filter((r) => r.throws).length,
|
|
written: 0,
|
|
};
|
|
|
|
if (!rows.length) {
|
|
// Every feed empty = the source failed, NOT "there is no mechanism data".
|
|
// Refuse to write, so a bad night can never blank a good table.
|
|
summary.ok = false;
|
|
summary.reason = 'no rows from source — refusing to write';
|
|
return summary;
|
|
}
|
|
|
|
const sb = opts.supabase || getServiceClient();
|
|
if (!sb) {
|
|
summary.ok = false;
|
|
summary.reason = 'no supabase client (env not configured)';
|
|
return summary;
|
|
}
|
|
|
|
// Chunked upsert — idempotent on the natural key, so re-running is a no-op
|
|
// beyond refreshing values and `updated_at`.
|
|
const chunk = opts.chunkSize || 500;
|
|
for (let i = 0; i < rows.length; i += chunk) {
|
|
const batch = rows.slice(i, i + chunk).map(toDbRow);
|
|
const { error } = await sb
|
|
.from('statcast_aggregates')
|
|
.upsert(batch, { onConflict: 'sport,season,source_id' });
|
|
if (error) {
|
|
summary.ok = false;
|
|
summary.reason = `upsert failed: ${error.message}`;
|
|
return summary;
|
|
}
|
|
summary.written += batch.length;
|
|
}
|
|
|
|
return summary;
|
|
}
|
|
|
|
function getServiceClient() {
|
|
try {
|
|
return require('../utils/supabase').getSupabaseServiceClient();
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* getFreshness(opts) — how current is the mechanism tier?
|
|
* Reads the newest `updated_at`. Used by the staleness alarm and the status
|
|
* probe. Returns { updated_at, age_hours, rows } or nulls — never a guess.
|
|
*/
|
|
async function getFreshness(opts = {}) {
|
|
const sb = opts.supabase || getServiceClient();
|
|
if (!sb) return { updated_at: null, age_hours: null, rows: null };
|
|
const sport = opts.sport || 'mlb';
|
|
const { data, error } = await sb
|
|
.from('statcast_aggregates')
|
|
.select('updated_at')
|
|
.eq('sport', sport)
|
|
.order('updated_at', { ascending: false })
|
|
.limit(1);
|
|
if (error || !data || !data.length) return { updated_at: null, age_hours: null, rows: null };
|
|
const updated = data[0].updated_at;
|
|
const nowMs = opts.nowMs || Date.now();
|
|
const ageH = (nowMs - new Date(updated).getTime()) / 3_600_000;
|
|
return { updated_at: updated, age_hours: Math.round(ageH * 10) / 10 };
|
|
}
|
|
|
|
/**
|
|
* isStale(freshness, maxAgeHours) — PURE. The alarm predicate.
|
|
*
|
|
* NEVER-REFRESHED (updated_at null) is NOT stale — it is "not built yet", a
|
|
* different condition with a different fix. Conflating them would page on a
|
|
* fresh install and teach the operator to ignore the alarm.
|
|
*/
|
|
const MAX_AGE_HOURS = Number(process.env.STATCAST_MAX_AGE_HOURS) || 48;
|
|
function isStale(freshness, maxAgeHours = MAX_AGE_HOURS) {
|
|
if (!freshness || freshness.updated_at == null) return false;
|
|
return Number.isFinite(freshness.age_hours) && freshness.age_hours > maxAgeHours;
|
|
}
|
|
|
|
module.exports = {
|
|
refreshSeason,
|
|
buildRows,
|
|
getFreshness,
|
|
isStale,
|
|
MIN_PA,
|
|
MIN_IP,
|
|
MAX_AGE_HOURS,
|
|
__internals: { toDbRow, num, getServiceClient },
|
|
};
|