Statcast: role belongs in the key (two-way players)

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
This commit is contained in:
Kev
2026-07-20 21:49:53 -04:00
parent 528cb1a6d0
commit a011ae79fe
3 changed files with 22 additions and 3 deletions
+6 -1
View File
@@ -206,7 +206,12 @@ async function refreshSeason(opts = {}) {
const batch = rows.slice(i, i + chunk).map(toDbRow); const batch = rows.slice(i, i + chunk).map(toDbRow);
const { error } = await sb const { error } = await sb
.from('statcast_aggregates') .from('statcast_aggregates')
.upsert(batch, { onConflict: 'sport,season,source_id' }); // ROLE is part of the key: a two-way player has a real batter profile AND
// a real pitcher profile, and merging them would invent one player out of
// two genuinely different sets of measurements. (Found by inducing the
// job: without role, one batch hit the same row twice and Postgres
// refused the chunk.)
.upsert(batch, { onConflict: 'sport,season,source_id,role' });
if (error) { if (error) {
summary.ok = false; summary.ok = false;
summary.reason = `upsert failed: ${error.message}`; summary.reason = `upsert failed: ${error.message}`;
@@ -60,7 +60,9 @@ create table if not exists statcast_aggregates (
updated_at timestamptz not null default now(), updated_at timestamptz not null default now(),
source text not null default 'baseball_savant', source text not null default 'baseball_savant',
primary key (sport, season, source_id) -- 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_player_key_idx on statcast_aggregates (sport, season, player_key);
+13 -1
View File
@@ -131,7 +131,7 @@ describe('refreshSeason — the job', () => {
let opts = null; let opts = null;
const sb = { from: () => ({ upsert: async (_b, o) => { opts = o; return { error: null }; } }) }; const sb = { from: () => ({ upsert: async (_b, o) => { opts = o; return { error: null }; } }) };
await svc.refreshSeason({ supabase: sb, fetchSeason: async () => feeds({ batter: [BELL] }) }); await svc.refreshSeason({ supabase: sb, fetchSeason: async () => feeds({ batter: [BELL] }) });
expect(opts.onConflict).toBe('sport,season,source_id'); expect(opts.onConflict).toBe('sport,season,source_id,role');
}); });
it('REFUSES to write when every feed is empty — a bad night cannot blank a good table', async () => { it('REFUSES to write when every feed is empty — a bad night cannot blank a good table', async () => {
@@ -148,6 +148,18 @@ describe('refreshSeason — the job', () => {
expect(sb.calls[0][0]._sufficient).toBeUndefined(); expect(sb.calls[0][0]._sufficient).toBeUndefined();
}); });
it('keys two-way players by ROLE — one player, two real profiles', async () => {
// Ohtani appears in both the batter and the pitcher feeds. Without role in
// the key, one upsert batch hits the same row twice and Postgres refuses
// the whole chunk — found by inducing the real job, not by review.
const OHTANI_B = { 'last_name, first_name': 'Ohtani, Shohei', player_id: '660271', pa: '400' };
const OHTANI_P = { 'last_name, first_name': 'Ohtani, Shohei', player_id: '660271', p_formatted_ip: '40' };
const rows = svc.buildRows(2026, feeds({ batter: [OHTANI_B], pitcher: [OHTANI_P] }), {});
expect(rows).toHaveLength(2);
expect(new Set(rows.map((r) => r.source_id)).size).toBe(1);
expect(new Set(rows.map((r) => r.role))).toEqual(new Set(['batter', 'pitcher']));
});
it('reports the join rate — the honest-absent rate for the mechanism tier', async () => { it('reports the join rate — the honest-absent rate for the mechanism tier', async () => {
const out = await svc.refreshSeason({ const out = await svc.refreshSeason({
supabase: okClient(), supabase: okClient(),