From 13aaf01a94cfdc8474975263782aa6dbd56e8428 Mon Sep 17 00:00:00 2001 From: Fable Integrator Date: Wed, 15 Jul 2026 13:07:56 -0400 Subject: [PATCH 1/2] baseball(engine): wire event-derived velocity into engine-run/outcome-sweep/action-baseline (#852 residual) Box-score-migrated players had NO velocity metrics: their legacy exit_velocity/pitch_velocity scalar is dropped alongside superseded legacy GAME rows (engine-stat-rows.ts rule 1), and the canonical box-score tables carry no velocity columns at all. loaders.ts's eventDerived hook (#851) already threaded a per-field event-layer override into loadPlayerMetrics, but nothing called it. Adds src/lib/baseball/coachhelm/engine-event-derived.ts: a team-scoped, paginated read of baseball_pitch_events/baseball_batted_ball_events (#813 superseded-row filter) plus a pure per-player reducer that reuses elite-stat-events.ts's real buildHitterMetrics/buildPitcherMetrics + loaders.ts's eventDerivedVelocityFromMetrics -- never a second, drifting "average exit velocity" implementation. All-or-nothing degrade on read failure, mirroring engine-stat-rows.ts's own honesty rule. Wires it into all three engine callers: - engine-run.ts: full-history event pool -> loadAllPlayerMetrics. - outcome-sweep.ts: event rows filtered to the SAME per-action after-window as the box-score read, so a pre-action event never counts toward did-it-move measurement. - action-baseline.ts: full-history event pool -> the baseline capture. Tests: pure aggregation (mixed hitter/pitcher, zero-event absence, supersede filter, all-or-nothing degrade) plus per-caller wiring tests (event wins over legacy scalar for the same player; a zero-event player keeps their legacy velocity; event-read failure degrades every player to legacy). Extends stat-layer-manifest.ts's grandfathered-consumer allowlist for the new fixture files (legacy baseball_player_stats rows are the fallback pin, not staleness). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01H9QAYqFTKsXGsVw6wXYssa --- .../action-baseline-event-velocity.test.ts | 102 +++++++ .../__tests__/action-baseline.test.ts | 9 + .../__tests__/engine-event-derived.test.ts | 268 ++++++++++++++++++ .../engine-run-event-velocity.test.ts | 161 +++++++++++ .../outcome-sweep-event-velocity.test.ts | 153 ++++++++++ .../outcome-sweep-insight-resolve.test.ts | 6 + src/lib/baseball/coachhelm/action-baseline.ts | 17 +- .../coachhelm/engine-event-derived.ts | 218 ++++++++++++++ src/lib/baseball/coachhelm/engine-run.ts | 21 ++ src/lib/baseball/coachhelm/outcome-sweep.ts | 33 ++- src/lib/baseball/stat-layer-manifest.ts | 21 ++ 11 files changed, 1007 insertions(+), 2 deletions(-) create mode 100644 src/lib/baseball/__tests__/action-baseline-event-velocity.test.ts create mode 100644 src/lib/baseball/__tests__/engine-event-derived.test.ts create mode 100644 src/lib/baseball/__tests__/engine-run-event-velocity.test.ts create mode 100644 src/lib/baseball/__tests__/outcome-sweep-event-velocity.test.ts create mode 100644 src/lib/baseball/coachhelm/engine-event-derived.ts diff --git a/src/lib/baseball/__tests__/action-baseline-event-velocity.test.ts b/src/lib/baseball/__tests__/action-baseline-event-velocity.test.ts new file mode 100644 index 000000000..2ba8f2196 --- /dev/null +++ b/src/lib/baseball/__tests__/action-baseline-event-velocity.test.ts @@ -0,0 +1,102 @@ +// ============================================================================= +// #852 residual: buildActionOutcomeSeed must thread event-derived velocity +// into its baseline capture -- a box-score-migrated player's baseline must +// read the elite event layer (never a legacy scalar) per #379 design rule 4, +// while a zero-event player's legacy scalar keeps seeding the ledger exactly +// as before. +// ============================================================================= + +import { describe, it, expect } from 'vitest'; +import { buildActionOutcomeSeed, type BaselineClient } from '@/lib/baseball/coachhelm/action-baseline'; + +const TEAM = 'team-1'; + +/** + * Minimal in-memory Supabase-shaped stub with REAL filtering (eq/in/is mutate + * the row set), matching action-baseline.test.ts's established style. + */ +function makeClient(tables: { + baseball_player_stats?: Array>; + baseball_games?: Array>; + baseball_box_score_batting?: Array>; + baseball_box_score_pitching?: Array>; + baseball_pitch_events?: Array>; + baseball_batted_ball_events?: Array>; +}): BaselineClient { + return { + from(table: string) { + let rows: Array> = + (tables as Record>>)[table] ?? []; + const api = { + select() { + return api; + }, + eq(col: string, val: unknown) { + rows = rows.filter((r) => r[col] === val); + return api; + }, + in(col: string, vals: unknown[]) { + rows = rows.filter((r) => vals.includes(r[col])); + return api; + }, + is(col: string, val: unknown) { + rows = rows.filter((r) => r[col] === val); + return api; + }, + order() { + return api; + }, + limit() { + return Promise.resolve({ data: rows, error: null }); + }, + range() { + return Promise.resolve({ data: rows, error: null }); + }, + maybeSingle() { + return Promise.resolve({ data: rows[0] ?? null, error: null }); + }, + }; + return api; + }, + }; +} + +describe('buildActionOutcomeSeed — #852 event-derived velocity wiring', () => { + it('captures the baseline from event-derived avg exit velocity, WINNING over the legacy scalar for the same player', async () => { + const client = makeClient({ + baseball_player_stats: [ + { + id: 's1', team_id: TEAM, player_id: 'p1', stat_type: 'game', session_date: '2026-04-01', + at_bats: 4, hits: 1, walks: 0, strikeouts: 1, exit_velocity: 80, + }, + ], + baseball_batted_ball_events: [ + { id: 'bb1', team_id: TEAM, batter_id: 'p1', exit_velocity: 100, measured_at: '2026-04-01T00:00:00.000Z', superseded_by_run_id: null }, + { id: 'bb2', team_id: TEAM, batter_id: 'p1', exit_velocity: 104, measured_at: '2026-04-02T00:00:00.000Z', superseded_by_run_id: null }, + ], + }); + + const seed = await buildActionOutcomeSeed(client, TEAM, 'p1', 'avg_exit_velocity'); + expect(seed.outcome_metric).toBe('avg_exit_velocity'); + // (100 + 104) / 2 = 102, NOT the legacy scalar of 80. + expect(seed.outcome_baseline_value).toBe(102); + expect(seed.outcome_verdict).toBeNull(); + }); + + it('falls back to the legacy exit-velocity scalar for a player with zero event rows', async () => { + const client = makeClient({ + baseball_player_stats: [ + { + id: 's2', team_id: TEAM, player_id: 'p2', stat_type: 'game', session_date: '2026-04-01', + at_bats: 4, hits: 1, walks: 0, strikeouts: 1, exit_velocity: 90, + }, + ], + baseball_batted_ball_events: [], + }); + + const seed = await buildActionOutcomeSeed(client, TEAM, 'p2', 'avg_exit_velocity'); + expect(seed.outcome_metric).toBe('avg_exit_velocity'); + expect(seed.outcome_baseline_value).toBe(90); + expect(seed.outcome_verdict).toBeNull(); + }); +}); diff --git a/src/lib/baseball/__tests__/action-baseline.test.ts b/src/lib/baseball/__tests__/action-baseline.test.ts index 1b07d9d5b..145aa7288 100644 --- a/src/lib/baseball/__tests__/action-baseline.test.ts +++ b/src/lib/baseball/__tests__/action-baseline.test.ts @@ -53,6 +53,15 @@ function makeClient(tables: { rows = rows.filter((r) => vals.includes(r[col])); return api; }, + // #852 residual: buildActionOutcomeSeed now also reads + // baseball_pitch_events/baseball_batted_ball_events (event-derived + // velocity) via the #813 superseded-row filter. Mirrors .eq()'s exact + // equality semantics so a fixture row can opt into (or out of) the + // supersede filter by setting `superseded_by_run_id` explicitly. + is(col: string, val: unknown) { + rows = rows.filter((r) => r[col] === val); + return api; + }, order() { return api; }, diff --git a/src/lib/baseball/__tests__/engine-event-derived.test.ts b/src/lib/baseball/__tests__/engine-event-derived.test.ts new file mode 100644 index 000000000..44bf990ea --- /dev/null +++ b/src/lib/baseball/__tests__/engine-event-derived.test.ts @@ -0,0 +1,268 @@ +// ============================================================================= +// Unit tests for the #852 residual velocity-coverage fix. +// +// loaders.ts's `eventDerived` hook (#851) already threaded exit/pitch velocity +// overrides into loadPlayerMetrics/loadAllPlayerMetrics, but nothing called it +// -- box-score-migrated players (whose legacy exit_velocity/pitch_velocity +// scalar is dropped alongside their superseded legacy GAME rows) had NO +// velocity metric at all. engine-event-derived.ts is the missing wire. These +// pin: +// 1. eventDerivedVelocityForPlayer resolves a hitter's avg exit velocity from +// their OWN batted-ball events and a pitcher's avg pitch velocity from +// their OWN pitch events (independently -- a two-way player gets both). +// 2. buildEventDerivedByPlayer only populates players with at least one +// event row (honest absence for a zero-event player, never a fabricated +// zero) -- the caller's per-player legacy fallback is what serves them. +// 3. loadEngineEventRows respects the #813 superseded-row filter and +// degrades ALL-OR-NOTHING (data: null) when either table's read fails. +// ============================================================================= + +import { describe, it, expect } from 'vitest'; +import { + loadEngineEventRows, + eventDerivedVelocityForPlayer, + buildEventDerivedByPlayer, +} from '@/lib/baseball/coachhelm/engine-event-derived'; +import type { + BaseballPitchEvent, + BaseballBattedBallEvent, +} from '@/lib/types/baseball-stat-events'; + +const TEAM = 'team-1'; + +function pitchEvent(overrides: Partial & { id: string }): BaseballPitchEvent { + return { + team_id: TEAM, + game_id: null, + practice_id: null, + plate_appearance_id: null, + pitcher_id: null, + batter_id: null, + catcher_id: null, + data_context: 'official_game', + pitch_number: null, + pitch_type: null, + pitch_type_classified: null, + pitch_call: null, + pitch_result: null, + velocity: null, + spin_rate: null, + spin_axis: null, + spin_efficiency: null, + seam_orientation: null, + induced_vertical_break: null, + horizontal_break: null, + release_height: null, + release_side: null, + extension: null, + plate_height: null, + plate_side: null, + zone: null, + intended_location: null, + miss_distance: null, + is_swing: null, + is_whiff: null, + is_chase: null, + is_called_strike: null, + is_in_zone: null, + count_state: null, + batter_handedness: null, + video_id: null, + external_pitch_id: null, + import_run_id: null, + source_id: null, + trust_tier: 'official', + visibility: 'staff_only', + measured_at: '2026-04-01T00:00:00.000Z', + created_at: '2026-04-01T00:00:00.000Z', + ...overrides, + }; +} + +function battedBall( + overrides: Partial & { id: string }, +): BaseballBattedBallEvent { + return { + team_id: TEAM, + game_id: null, + practice_id: null, + plate_appearance_id: null, + pitch_event_id: null, + batter_id: null, + pitcher_id: null, + data_context: 'official_game', + exit_velocity: null, + launch_angle: null, + spray_angle: null, + distance: null, + hang_time: null, + batted_ball_type: null, + field_zone: null, + is_hard_hit: null, + is_barrel: null, + is_sweet_spot: null, + result: null, + pitch_type: null, + video_id: null, + external_event_id: null, + import_run_id: null, + source_id: null, + trust_tier: 'official', + visibility: 'staff_only', + measured_at: '2026-04-01T00:00:00.000Z', + created_at: '2026-04-01T00:00:00.000Z', + ...overrides, + }; +} + +describe('eventDerivedVelocityForPlayer — per-player hitter/pitcher aggregation', () => { + it("resolves a hitter's avg exit velocity from their OWN batted-ball events only", () => { + const battedBalls = [ + battedBall({ id: 'bb1', batter_id: 'p1', exit_velocity: 90 }), + battedBall({ id: 'bb2', batter_id: 'p1', exit_velocity: 94 }), + // A different player's batted ball must not leak into p1's average. + battedBall({ id: 'bb3', batter_id: 'p2', exit_velocity: 60 }), + ]; + const out = eventDerivedVelocityForPlayer('p1', [], battedBalls); + expect(out.avgExitVelocity).toEqual({ value: 92, sampleSize: 2 }); + // No max-velocity event metric exists yet (honest gap, matches loaders.ts). + expect(out.maxExitVelocity).toBeNull(); + expect(out.avgPitchVelocity).toBeNull(); + expect(out.maxPitchVelocity).toBeNull(); + }); + + it("resolves a pitcher's avg pitch velocity from their OWN pitches only", () => { + const pitches = [ + pitchEvent({ id: 'p1a', pitcher_id: 'p9', velocity: 88 }), + pitchEvent({ id: 'p1b', pitcher_id: 'p9', velocity: 92 }), + // A different pitcher's pitch must not leak into p9's average. + pitchEvent({ id: 'p2a', pitcher_id: 'p8', velocity: 70 }), + ]; + const out = eventDerivedVelocityForPlayer('p9', pitches, []); + expect(out.avgPitchVelocity).toEqual({ value: 90, sampleSize: 2 }); + expect(out.maxPitchVelocity).toBeNull(); + expect(out.avgExitVelocity).toBeNull(); + }); + + it('a two-way player gets BOTH sides independently from their own rows on each side', () => { + const pitches = [pitchEvent({ id: 'pt1', pitcher_id: 'p1', velocity: 91 })]; + const battedBalls = [battedBall({ id: 'bb1', batter_id: 'p1', exit_velocity: 95 })]; + const out = eventDerivedVelocityForPlayer('p1', pitches, battedBalls); + expect(out.avgExitVelocity).toEqual({ value: 95, sampleSize: 1 }); + expect(out.avgPitchVelocity).toEqual({ value: 91, sampleSize: 1 }); + }); + + it('a player with zero matching rows on either side returns all-null (honest absence)', () => { + const out = eventDerivedVelocityForPlayer('ghost', [], []); + expect(out).toEqual({ + avgExitVelocity: null, + maxExitVelocity: null, + avgPitchVelocity: null, + maxPitchVelocity: null, + }); + }); +}); + +describe('buildEventDerivedByPlayer — team-wide map', () => { + it('populates only players with at least one event row; a zero-event player is absent (legacy fallback keeps serving them)', () => { + const pitches = [pitchEvent({ id: 'pt1', pitcher_id: 'p2', velocity: 89 })]; + const battedBalls = [battedBall({ id: 'bb1', batter_id: 'p1', exit_velocity: 93 })]; + + const map = buildEventDerivedByPlayer(['p1', 'p2', 'p3'], pitches, battedBalls); + + expect(map.p1?.avgExitVelocity).toEqual({ value: 93, sampleSize: 1 }); + expect(map.p2?.avgPitchVelocity).toEqual({ value: 89, sampleSize: 1 }); + // p3 has no event rows at all -- absent from the map entirely. + expect(map.p3).toBeUndefined(); + }); + + it('returns an empty map when no player has any event rows', () => { + const map = buildEventDerivedByPlayer(['p1', 'p2'], [], []); + expect(map).toEqual({}); + }); +}); + +// ----------------------------------------------------------------------------- +// loadEngineEventRows — the DB-fetch layer (pagination + #813 supersede filter +// + all-or-nothing degrade). +// ----------------------------------------------------------------------------- + +type Row = Record; + +/** + * Same minimal chainable fake shape as engine-stat-rows.test.ts's, plus a REAL + * (not no-op) `.is()` so the #813 supersede-filter test below is an honest + * assertion rather than a smoke test. + */ +function makeClient(tables: Record, errorTables: Set = new Set()) { + return { + from(table: string) { + let rows = tables[table] ?? []; + const fail = errorTables.has(table); + const builder: Record = { + select: () => builder, + eq: () => builder, + is: (col: string, val: unknown) => { + rows = rows.filter((r) => r[col] === val); + return builder; + }, + order: () => builder, + range: () => + Promise.resolve( + fail ? { data: null, error: { message: `${table} read failed` } } : { data: rows, error: null }, + ), + }; + return builder; + }, + }; +} + +describe('loadEngineEventRows', () => { + it('respects the #813 superseded-row filter (only the current row powers the engine)', async () => { + // Plain rows (not the strict BaseballPitchEvent factory) -- the fake + // client's tables are untyped Row[], and `superseded_by_run_id` isn't on + // the hand-written type (a real DB column the query filters on but the + // engine never reads back), so a loose row literal is the honest fixture + // shape here. + const client = makeClient({ + baseball_pitch_events: [ + { id: 'pt-old', team_id: TEAM, pitcher_id: 'p1', velocity: 70, superseded_by_run_id: 'run-1' }, + { id: 'pt-current', team_id: TEAM, pitcher_id: 'p1', velocity: 92, superseded_by_run_id: null }, + ], + baseball_batted_ball_events: [], + }); + + const { data, error } = await loadEngineEventRows(client, TEAM); + expect(error).toBeNull(); + expect(data).not.toBeNull(); + expect(data!.pitches.map((p) => p.id)).toEqual(['pt-current']); + }); + + it('degrades ALL-OR-NOTHING (data: null) when either table read fails', async () => { + const client = makeClient( + { + baseball_pitch_events: [{ id: 'pt1', team_id: TEAM, superseded_by_run_id: null }], + baseball_batted_ball_events: [], + }, + new Set(['baseball_batted_ball_events']), + ); + + const { data, error } = await loadEngineEventRows(client, TEAM); + expect(data).toBeNull(); + expect(error).not.toBeNull(); + }); + + it('returns an empty pool without querying when no team id is given', async () => { + let queried = false; + const client = { + from() { + queried = true; + throw new Error('should not query'); + }, + }; + const { data, error } = await loadEngineEventRows(client, ''); + expect(data).toEqual({ pitches: [], battedBalls: [] }); + expect(error).toBeNull(); + expect(queried).toBe(false); + }); +}); diff --git a/src/lib/baseball/__tests__/engine-run-event-velocity.test.ts b/src/lib/baseball/__tests__/engine-run-event-velocity.test.ts new file mode 100644 index 000000000..81c61c51d --- /dev/null +++ b/src/lib/baseball/__tests__/engine-run-event-velocity.test.ts @@ -0,0 +1,161 @@ +// ============================================================================= +// #852 residual: runBaseballEngineCore must thread event-derived velocity into +// loadAllPlayerMetrics so a box-score-migrated player isn't left with NO +// velocity metric (the elite event layer wins over a legacy scalar per #379 +// design rule 4; a zero-event player keeps their legacy scalar unchanged). +// ============================================================================= + +import { describe, it, expect, vi } from 'vitest'; +import { createFakeSupabase, type FakeSupabase } from '@/test/fixtures/fake-supabase'; +import { DEFAULT_AI_POLICY } from '@/lib/baseball/ai-policy'; +import type { BaseballInsightCandidate } from '@/lib/coachhelm/baseball/generators'; +import type { BaseballV10EngineInputs } from '@/lib/coachhelm/baseball/engine'; + +const NOW = '2026-06-30T12:00:00.000Z'; +const TEAM_ID = 'team-1'; +const ORG_ID = 'org-1'; +const MIXED_PLAYER = 'player-mixed'; // has legacy exit_velocity AND event batted-balls +const LEGACY_ONLY_PLAYER = 'player-legacy-only'; // legacy exit_velocity, zero events + +let capturedInputs: BaseballV10EngineInputs | null = null; + +vi.mock('@/lib/coachhelm/baseball/engine', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + generateAllBaseballCandidates: vi.fn((inputs: BaseballV10EngineInputs) => { + capturedInputs = inputs; + return [] as BaseballInsightCandidate[]; + }), + }; +}); + +import { runBaseballEngineCore, type EngineRunClient } from '@/lib/baseball/coachhelm/engine-run'; + +function baseTables() { + return { + baseball_team_members: [ + { team_id: TEAM_ID, player_id: MIXED_PLAYER }, + { team_id: TEAM_ID, player_id: LEGACY_ONLY_PLAYER }, + ], + // Legacy box-score rows: BOTH players have a legacy exit_velocity scalar. + baseball_player_stats: [ + { + id: 'lg-mixed-1', team_id: TEAM_ID, player_id: MIXED_PLAYER, stat_type: 'game', + session_date: '2026-04-01', at_bats: 4, hits: 1, walks: 0, strikeouts: 1, + exit_velocity: 80, // legacy scalar the event source should OUTRANK + }, + { + id: 'lg-legacy-1', team_id: TEAM_ID, player_id: LEGACY_ONLY_PLAYER, stat_type: 'game', + session_date: '2026-04-01', at_bats: 4, hits: 1, walks: 0, strikeouts: 1, + exit_velocity: 85, // the ONLY source for this player -- must survive + }, + ], + baseball_events: [], + baseball_teams: [{ id: TEAM_ID, organization_id: ORG_ID }], + helm_lifting_athletes: [] as Array>, + helm_lifting_readiness_checkins: [] as Array>, + baseball_lift_sessions: [], + baseball_lift_set_results: [], + baseball_import_runs: [], + // Event layer: ONLY the mixed player has batted-ball events. + baseball_pitch_events: [] as Array>, + baseball_batted_ball_events: [ + { + id: 'bb-1', team_id: TEAM_ID, batter_id: MIXED_PLAYER, pitcher_id: null, + data_context: 'official_game', exit_velocity: 100, superseded_by_run_id: null, + measured_at: '2026-04-05T00:00:00.000Z', trust_tier: 'official', visibility: 'staff_only', + }, + { + id: 'bb-2', team_id: TEAM_ID, batter_id: MIXED_PLAYER, pitcher_id: null, + data_context: 'official_game', exit_velocity: 104, superseded_by_run_id: null, + measured_at: '2026-04-06T00:00:00.000Z', trust_tier: 'official', visibility: 'staff_only', + }, + ], + baseball_catching_events: [], + baseball_fielding_events: [], + baseball_baserunning_events: [], + baseball_video_events: [], + baseball_coach_insights: [] as Array>, + baseball_signals: [] as Array>, + baseball_ai_audit: [] as Array>, + }; +} + +async function runEngine(fake: FakeSupabase) { + return runBaseballEngineCore(fake as unknown as EngineRunClient, { + teamId: TEAM_ID, + coachId: 'coach-1', + createdByUserId: 'user-1', + policy: DEFAULT_AI_POLICY, + nowIso: NOW, + }); +} + +describe('runBaseballEngineCore — #852 event-derived velocity wiring', () => { + it('event-derived avg exit velocity WINS over the legacy scalar for a box-score-migrated player', async () => { + capturedInputs = null; + const fake = createFakeSupabase({ user: { id: 'user-1' }, tables: baseTables() }); + + const result = await runEngine(fake); + expect(result.success).toBe(true); + expect(capturedInputs).not.toBeNull(); + + const mixed = capturedInputs!.players.find((p) => p.playerId === MIXED_PLAYER); + expect(mixed).toBeDefined(); + // (100 + 104) / 2 = 102, NOT the legacy scalar of 80. + expect(mixed!.metrics.avg_exit_velocity?.value).toBe(102); + expect(mixed!.metrics.avg_exit_velocity?.source_refs[0]?.table).toBe('baseball_batted_ball_events'); + }); + + it('a zero-event player keeps their legacy exit-velocity scalar unchanged (honest fallback)', async () => { + capturedInputs = null; + const fake = createFakeSupabase({ user: { id: 'user-1' }, tables: baseTables() }); + + const result = await runEngine(fake); + expect(result.success).toBe(true); + expect(capturedInputs).not.toBeNull(); + + const legacyOnly = capturedInputs!.players.find((p) => p.playerId === LEGACY_ONLY_PLAYER); + expect(legacyOnly).toBeDefined(); + expect(legacyOnly!.metrics.avg_exit_velocity?.value).toBe(85); + expect(legacyOnly!.metrics.avg_exit_velocity?.source_refs[0]?.table).toBe('baseball_player_stats'); + }); + + it('degrades ALL-OR-NOTHING to legacy scalars for every player when the event read fails', async () => { + capturedInputs = null; + const tables = baseTables(); + const fake = createFakeSupabase({ user: { id: 'user-1' }, tables }); + // Force the batted-ball read to error by deleting the table key entirely + // is not enough (the fixture defaults missing tables to [] with no error), + // so we monkey-patch the fake's `from` to inject an error for this one + // table -- the smallest surface that exercises the degrade path without + // hand-rolling a whole second fake client. `baseball_batted_ball_events` + // is ALSO read by the pre-existing "deepened event catalog" fetch further + // down runBaseballEngineCore (a `.gte('measured_at', ...)`-shaped query, + // no `.is()`), so the stub must satisfy BOTH call shapes. + const realFrom = fake.from.bind(fake); + const erroringBuilder: Record = { + select: () => erroringBuilder, + eq: () => erroringBuilder, + is: () => erroringBuilder, + gte: () => erroringBuilder, + order: () => erroringBuilder, + range: () => Promise.resolve({ data: null, error: { message: 'boom' } }), + }; + (fake as unknown as { from: typeof fake.from }).from = (table: string) => { + if (table === 'baseball_batted_ball_events') return erroringBuilder as never; + return realFrom(table); + }; + + const result = await runEngine(fake); + expect(result.success).toBe(true); + expect(capturedInputs).not.toBeNull(); + + // The mixed player, who WOULD have event data, falls all the way back to + // their legacy scalar -- never a partial/blended result. + const mixed = capturedInputs!.players.find((p) => p.playerId === MIXED_PLAYER); + expect(mixed!.metrics.avg_exit_velocity?.value).toBe(80); + expect(mixed!.metrics.avg_exit_velocity?.source_refs[0]?.table).toBe('baseball_player_stats'); + }); +}); diff --git a/src/lib/baseball/__tests__/outcome-sweep-event-velocity.test.ts b/src/lib/baseball/__tests__/outcome-sweep-event-velocity.test.ts new file mode 100644 index 000000000..97ee9ebc3 --- /dev/null +++ b/src/lib/baseball/__tests__/outcome-sweep-event-velocity.test.ts @@ -0,0 +1,153 @@ +// ============================================================================= +// #852 residual: sweepActionOutcomes must thread event-derived velocity into +// its per-action `loadPlayerMetrics` call, scoped to the SAME after-window +// honesty rule as the box-score read (measured strictly after the action's +// created_at) -- a pre-action batted-ball event must never count toward +// "did it move" measurement. +// ============================================================================= + +import { describe, it, expect } from 'vitest'; +import { sweepActionOutcomes } from '@/lib/baseball/coachhelm/outcome-sweep'; + +const TEAM = 'team-1'; + +interface UpdateCall { + table: string; + payload: Record; +} + +/** + * Minimal chainable Supabase fake with REAL filtering (eq/in/is mutate the + * in-scope row set) so the after-window date filter inside sweepActionOutcomes + * is exercised honestly, not just smoke-tested. + */ +function makeClient(opts: { + actions: Array>; + stats: Array>; + battedBallEvents?: Array>; + pitchEvents?: Array>; + updates: UpdateCall[]; +}) { + function from(table: string) { + let rows: Array> = + table === 'baseball_actions' + ? opts.actions + : table === 'baseball_player_stats' + ? opts.stats + : table === 'baseball_batted_ball_events' + ? (opts.battedBallEvents ?? []) + : table === 'baseball_pitch_events' + ? (opts.pitchEvents ?? []) + : []; + const state: { isUpdate: boolean; payload: Record | null } = { + isUpdate: false, + payload: null, + }; + const builder: Record = { + select: () => builder, + eq: (col: string, val: unknown) => { + rows = rows.filter((r) => r[col] === val); + return builder; + }, + in: (col: string, vals: unknown[]) => { + rows = rows.filter((r) => vals.includes(r[col])); + return builder; + }, + is: (col: string, val: unknown) => { + rows = rows.filter((r) => r[col] === val); + return builder; + }, + order: () => builder, + limit: () => builder, + range: () => builder, + update(payload: Record) { + state.isUpdate = true; + state.payload = payload; + return builder; + }, + then(resolve: (v: { data: unknown; error: null }) => unknown) { + if (state.isUpdate && state.payload) { + opts.updates.push({ table, payload: state.payload }); + return resolve({ data: null, error: null }); + } + return resolve({ data: rows, error: null }); + }, + }; + return builder; + } + return { from } as unknown as Parameters[0]; +} + +describe('sweepActionOutcomes — #852 event-derived velocity wiring', () => { + it('measures avg_exit_velocity from event-derived data, WINNING over the after-window legacy scalar for the same player', async () => { + const actions = [ + { + id: 'act-1', + team_id: TEAM, + player_id: 'p1', + created_at: '2026-01-01T00:00:00.000Z', + outcome_metric: 'avg_exit_velocity', + outcome_baseline_value: 80, + outcome_observed_value: null, + signal_id: null, + status: 'open', + }, + ]; + // Legacy after-window box-score row would give avg_exit_velocity = 80. + const stats = [ + { + id: 's1', team_id: TEAM, player_id: 'p1', stat_type: 'game', session_date: '2026-03-01', + at_bats: 4, hits: 1, walks: 0, strikeouts: 1, exit_velocity: 80, + }, + ]; + const battedBallEvents = [ + { id: 'bb1', team_id: TEAM, batter_id: 'p1', exit_velocity: 100, measured_at: '2026-03-01T00:00:00.000Z', superseded_by_run_id: null }, + { id: 'bb2', team_id: TEAM, batter_id: 'p1', exit_velocity: 104, measured_at: '2026-03-05T00:00:00.000Z', superseded_by_run_id: null }, + // BEFORE the action's created_at -- must be EXCLUDED by the after-window + // filter. If wrongly included, the average would shift far from 102. + { id: 'bb-before', team_id: TEAM, batter_id: 'p1', exit_velocity: 20, measured_at: '2025-12-01T00:00:00.000Z', superseded_by_run_id: null }, + ]; + const updates: UpdateCall[] = []; + const client = makeClient({ actions, stats, battedBallEvents, updates }); + + const res = await sweepActionOutcomes(client, TEAM); + expect(res.measured).toBe(1); + + const actionUpdate = updates.find((u) => u.table === 'baseball_actions'); + // (100 + 104) / 2 = 102 -- the event-derived value, NOT the legacy 80, and + // NOT skewed by the pre-action bb-before row. + expect(actionUpdate?.payload.outcome_observed_value).toBe(102); + expect(actionUpdate?.payload.outcome_sample_n).toBe(2); + }); + + it('falls back to the legacy exit-velocity scalar for a player with zero event rows', async () => { + const actions = [ + { + id: 'act-2', + team_id: TEAM, + player_id: 'p2', + created_at: '2026-01-01T00:00:00.000Z', + outcome_metric: 'avg_exit_velocity', + outcome_baseline_value: 70, + outcome_observed_value: null, + signal_id: null, + status: 'open', + }, + ]; + const stats = [ + { + id: 's2', team_id: TEAM, player_id: 'p2', stat_type: 'game', session_date: '2026-03-01', + at_bats: 4, hits: 1, walks: 0, strikeouts: 1, exit_velocity: 90, + }, + ]; + const updates: UpdateCall[] = []; + // No batted-ball events at all for p2 -- honest legacy fallback. + const client = makeClient({ actions, stats, battedBallEvents: [], updates }); + + const res = await sweepActionOutcomes(client, TEAM); + expect(res.measured).toBe(1); + + const actionUpdate = updates.find((u) => u.table === 'baseball_actions'); + expect(actionUpdate?.payload.outcome_observed_value).toBe(90); + }); +}); diff --git a/src/lib/baseball/__tests__/outcome-sweep-insight-resolve.test.ts b/src/lib/baseball/__tests__/outcome-sweep-insight-resolve.test.ts index ae4bb152a..ddc44eef3 100644 --- a/src/lib/baseball/__tests__/outcome-sweep-insight-resolve.test.ts +++ b/src/lib/baseball/__tests__/outcome-sweep-insight-resolve.test.ts @@ -58,6 +58,12 @@ function makeClient(opts: { select: () => builder, eq: () => builder, in: () => builder, + // #852 residual: the outcome sweep now also reads + // baseball_pitch_events/baseball_batted_ball_events (event-derived + // velocity) via the #813 superseded-row filter. Neither table is seeded + // by these fixtures (falls to `[]` in the `data` lookup above), so this + // is a pass-through -- it only needs to exist so the call doesn't throw. + is: () => builder, order: () => builder, limit: () => builder, // The stats read now paginates via fetchAllRowsResult (ends on .range). diff --git a/src/lib/baseball/coachhelm/action-baseline.ts b/src/lib/baseball/coachhelm/action-baseline.ts index ab87c3e0f..2c12b415c 100644 --- a/src/lib/baseball/coachhelm/action-baseline.ts +++ b/src/lib/baseball/coachhelm/action-baseline.ts @@ -58,6 +58,10 @@ import { type BaseballMetricId, } from '@/lib/coachhelm/baseball/metrics/registry'; import { loadEngineStatRows } from '@/lib/baseball/coachhelm/engine-stat-rows'; +import { + loadEngineEventRows, + eventDerivedVelocityForPlayer, +} from '@/lib/baseball/coachhelm/engine-event-derived'; import { parseSignalSourceRefs } from '@/lib/types/baseball-signals'; import type { BaseballActionOutcomeVerdict } from '@/lib/types/baseball-coachhelm-v10'; @@ -213,7 +217,18 @@ export async function buildActionOutcomeSeed( // single-page `.limit(1000)` read. const { data: statRows } = await loadEngineStatRows(supabase, teamId, [playerId]); - const loaded = loadPlayerMetrics(playerId, statRows ?? []); + // #852 residual: event-derived velocity over the SAME full-history pool the + // box-score baseline above reads (no after-window here -- a baseline is + // "the player's current value at conversion time", matching the box-score + // read's own no-date-filter semantics). ALL-OR-NOTHING: an event-read + // failure (eventRows null) falls back to the legacy scalar for every + // velocity field, never a partial blend (mirrors loadEngineStatRows). + const { data: eventRows } = await loadEngineEventRows(supabase, teamId); + const eventDerived = eventRows + ? eventDerivedVelocityForPlayer(playerId, eventRows.pitches, eventRows.battedBalls) + : null; + + const loaded = loadPlayerMetrics(playerId, statRows ?? [], undefined, eventDerived); const baselineValue = loaded.metrics[targetMetric]?.value ?? null; return { diff --git a/src/lib/baseball/coachhelm/engine-event-derived.ts b/src/lib/baseball/coachhelm/engine-event-derived.ts new file mode 100644 index 000000000..64b263525 --- /dev/null +++ b/src/lib/baseball/coachhelm/engine-event-derived.ts @@ -0,0 +1,218 @@ +import 'server-only'; + +// ============================================================================= +// src/lib/baseball/coachhelm/engine-event-derived.ts +// +// #852 residual — closes the velocity coverage gap engine-stat-rows.ts (#379 +// Phase 4b) opened: a box-score-migrated player's legacy GAME rows (the only +// place `exit_velocity` / `pitch_velocity` scalars ever lived) are dropped for +// any date the canonical box-score layer now covers, and the canonical +// box-score tables carry no velocity columns at all. Per #379 design rule 4, +// the canonical velocity source is the elite EVENT layer, never a legacy +// scalar — loaders.ts's `eventDerived` hook (#851) already threads that +// per-field override into `loadPlayerMetrics` / `loadAllPlayerMetrics`, but +// nothing called it, so every migrated player's velocity metrics silently +// went dark. This module is that missing wire: it reads the event-grain +// tables and reduces them to the `EventDerivedVelocityInput` the loaders hook +// expects, reusing `elite-stat-events.ts`'s REAL aggregators (buildHitterMetrics +// / buildPitcherMetrics) so the honesty-gated average here is byte-identical +// to what the Stats Center already shows a coach -- never a second, drifting +// "average exit velocity" implementation. +// +// TWO LAYERS, mirroring loaders.ts's own split: +// 1. loadEngineEventRows (impure) -- team-scoped read of +// baseball_pitch_events / baseball_batted_ball_events, paginated past the +// PostgREST 1000-row cap (fetchAllRowsResult) with the #813 superseded-row +// filter (`superseded_by_run_id IS NULL` -- only the CURRENT value powers +// the engine, matching engine-run.ts's existing deepened-catalog read and +// elite-stat-events.ts's own getEliteStatEvents). No date window here -- +// callers apply their OWN honesty window (e.g. outcome-sweep's +// after-window) by filtering the returned rows before aggregating, the +// same way loadEngineStatRows returns the full box-score history and lets +// each caller decide how much of it to use. +// 2. buildEventDerivedByPlayer / eventDerivedVelocityForPlayer (pure) -- +// groups rows by player and calls buildHitterMetrics / buildPitcherMetrics +// + eventDerivedVelocityFromMetrics to produce the per-player velocity +// input. Fully unit-testable without a DB (fixed-clock friendly -- none +// of this depends on the wall clock). +// +// ALL-OR-NOTHING (mirrors loadEngineStatRows's own honesty rule): if EITHER +// event table fails to read, `loadEngineEventRows` returns `data: null` and a +// caller must treat that as "no event-derived data for anyone this run" -- +// never apply it to some players and not others depending on which table +// happened to fail. Falling back to `{}` (an empty eventDerivedByPlayer map) +// degrades every player to their legacy scalar for every velocity field, +// exactly the pre-#852-fix behavior -- never a partial event/legacy blend. +// ============================================================================= + +import { fetchAllRowsResult } from '@/lib/supabase/fetch-all-rows'; +import { + buildHitterMetrics, + buildPitcherMetrics, +} from '@/lib/baseball/read-models/elite-stat-events'; +import { + eventDerivedVelocityFromMetrics, + type EventDerivedVelocityInput, +} from '@/lib/coachhelm/baseball/loaders'; +import type { + BaseballPitchEvent, + BaseballBattedBallEvent, + BaseballDataContext, +} from '@/lib/types/baseball-stat-events'; + +// A minimally-typed client so this runs against the RLS server client or the +// service-role admin client (both expose `.from`) -- the same loose-client +// pattern as loadEngineStatRows / the three engine callers. +export type EngineEventRowsClient = { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + from: (table: string) => any; +}; + +// The event layer doesn't carry a "this is the competitive record" default the +// way elite-stat-events.ts's team/player Stats Center reads do; the engine +// wants every context's velocity signal (a bullpen session's pitch velocity is +// still an honest measurement), so this is only ever used as the metric +// factory's provenance fallback when a row's own `data_context` is missing. +const FALLBACK_CONTEXT: BaseballDataContext = 'official_game'; + +export interface EngineEventRows { + pitches: BaseballPitchEvent[]; + battedBalls: BaseballBattedBallEvent[]; +} + +/** + * Load the team's pitch + batted-ball event rows for velocity aggregation. + * + * Paginated past the PostgREST 1000-row cap (fetchAllRowsResult) with a stable + * `id` order, and scoped to the #813 CURRENT rows only + * (`superseded_by_run_id IS NULL`) -- a corrected import must never let a + * stale, superseded pitch/batted-ball row into the engine's velocity average. + * + * No player-id filter: mirrors engine-run.ts's existing deepened-event-catalog + * read of these same two tables (team-scoped, ungated by player), which keeps + * this a single shared shape every caller (engine-run / outcome-sweep / + * action-baseline) can reuse without an `.or()` multi-column player filter. + * + * ALL-OR-NOTHING: a failure on EITHER table returns `data: null` so a caller + * degrades every player to their legacy scalar this run, never a partial + * blend (see module docblock). + */ +export async function loadEngineEventRows( + db: EngineEventRowsClient, + teamId: string, +): Promise<{ data: EngineEventRows | null; error: { message: string; code?: string | null } | null }> { + if (!teamId) return { data: { pitches: [], battedBalls: [] }, error: null }; + + const [pitchRes, bbRes] = await Promise.all([ + fetchAllRowsResult((from, to) => + db + .from('baseball_pitch_events') + .select('*') + .eq('team_id', teamId) + .is('superseded_by_run_id', null) + .order('id', { ascending: true }) + .range(from, to), + ), + fetchAllRowsResult((from, to) => + db + .from('baseball_batted_ball_events') + .select('*') + .eq('team_id', teamId) + .is('superseded_by_run_id', null) + .order('id', { ascending: true }) + .range(from, to), + ), + ]); + + if (pitchRes.error || bbRes.error) { + return { data: null, error: pitchRes.error ?? bbRes.error }; + } + + return { + data: { + pitches: (pitchRes.data ?? []) as BaseballPitchEvent[], + battedBalls: (bbRes.data ?? []) as BaseballBattedBallEvent[], + }, + error: null, + }; +} + +const EMPTY_VELOCITY: EventDerivedVelocityInput = { + avgExitVelocity: null, + maxExitVelocity: null, + avgPitchVelocity: null, + maxPitchVelocity: null, +}; + +/** + * Pure aggregation: given ALREADY-SCOPED pitch/batted-ball rows (the caller + * decides the window -- full history, or an after-window subset), build the + * EventDerivedVelocityInput for ONE player. + * + * Reuses elite-stat-events.ts's real aggregators: + * - a player's batted balls AS A BATTER -> buildHitterMetrics's + * 'avg_exit_velocity' metric (pitches array is irrelevant to that metric, + * so an empty array is passed -- we only read this one metricKey out). + * - a player's pitches AS A PITCHER -> buildPitcherMetrics's 'avg_velocity' + * metric (battedBalls array is likewise irrelevant to that metric). + * The two metric arrays are concatenated and handed to + * `eventDerivedVelocityFromMetrics`, which independently resolves each of the + * four velocity fields (max_* stay null -- there is no max-velocity event + * metric yet, matching loaders.ts's own honest gap). + */ +export function eventDerivedVelocityForPlayer( + playerId: string, + pitches: BaseballPitchEvent[], + battedBalls: BaseballBattedBallEvent[], +): EventDerivedVelocityInput { + const battedBallsAsBatter = battedBalls.filter((b) => b.batter_id === playerId); + const pitchesAsPitcher = pitches.filter((p) => p.pitcher_id === playerId); + if (battedBallsAsBatter.length === 0 && pitchesAsPitcher.length === 0) { + return EMPTY_VELOCITY; + } + const hitter = buildHitterMetrics(playerId, [], battedBallsAsBatter, FALLBACK_CONTEXT); + const pitcher = buildPitcherMetrics(playerId, pitchesAsPitcher, [], FALLBACK_CONTEXT); + return eventDerivedVelocityFromMetrics([...hitter.metrics, ...pitcher.metrics]); +} + +/** + * Build the `eventDerivedByPlayer` map `loadAllPlayerMetrics` consumes, for + * every id in `playerIds` that has at least one pitch/batted-ball row in the + * (already-scoped) pool. A player with zero event rows is simply absent from + * the map -- `loadPlayerMetrics` falls back to their legacy scalar for every + * velocity field, unchanged (honest absence, never a fabricated zero). + */ +export function buildEventDerivedByPlayer( + playerIds: string[], + pitches: BaseballPitchEvent[], + battedBalls: BaseballBattedBallEvent[], +): Record { + const battedByBatter = new Map(); + for (const bb of battedBalls) { + if (!bb.batter_id) continue; + const list = battedByBatter.get(bb.batter_id); + if (list) list.push(bb); + else battedByBatter.set(bb.batter_id, [bb]); + } + const pitchesByPitcher = new Map(); + for (const p of pitches) { + if (!p.pitcher_id) continue; + const list = pitchesByPitcher.get(p.pitcher_id); + if (list) list.push(p); + else pitchesByPitcher.set(p.pitcher_id, [p]); + } + + const out: Record = {}; + for (const pid of playerIds) { + const bbRows = battedByBatter.get(pid) ?? []; + const pRows = pitchesByPitcher.get(pid) ?? []; + if (bbRows.length === 0 && pRows.length === 0) continue; + const hitter = buildHitterMetrics(pid, [], bbRows, FALLBACK_CONTEXT); + const pitcher = buildPitcherMetrics(pid, pRows, [], FALLBACK_CONTEXT); + const v = eventDerivedVelocityFromMetrics([...hitter.metrics, ...pitcher.metrics]); + if (v.avgExitVelocity || v.avgPitchVelocity || v.maxExitVelocity || v.maxPitchVelocity) { + out[pid] = v; + } + } + return out; +} diff --git a/src/lib/baseball/coachhelm/engine-run.ts b/src/lib/baseball/coachhelm/engine-run.ts index e45e25a55..af2f78944 100644 --- a/src/lib/baseball/coachhelm/engine-run.ts +++ b/src/lib/baseball/coachhelm/engine-run.ts @@ -61,9 +61,14 @@ import type { VideoCoverageInput } from '@/lib/coachhelm/baseball/generators/v10 import { loadAllPlayerMetrics, type BoxScoreRow, + type EventDerivedVelocityInput, type ScheduleEventRow, } from '@/lib/coachhelm/baseball/loaders'; import { loadEngineStatRows } from '@/lib/baseball/coachhelm/engine-stat-rows'; +import { + loadEngineEventRows, + buildEventDerivedByPlayer, +} from '@/lib/baseball/coachhelm/engine-event-derived'; import { mergeV10PlayerMetrics, type ReadinessRow, @@ -313,6 +318,21 @@ export async function runBaseballEngineCore( const { data: statRows, error: statsErr } = await loadEngineStatRows(db, teamId, playerIds); if (statsErr) return emptyResult({ error: 'Could not load box-score stats.' }); + // #852 residual: event-derived avg/max exit + pitch velocity, per player. + // A box-score-migrated player's legacy exit_velocity/pitch_velocity scalar + // is dropped alongside their superseded legacy GAME rows (loadEngineStatRows + // rule 1) and the canonical box-score tables carry no velocity columns at + // all -- without this, those players had NO velocity metric whatsoever. Per + // #379 design rule 4, the elite event layer (never a legacy scalar) is the + // canonical velocity source. ALL-OR-NOTHING: an event-read failure leaves + // eventDerivedByPlayer EMPTY, so every player degrades to their legacy + // scalar this run -- never a partial event/legacy blend. + const { data: engineEventRows, error: eventRowsErr } = await loadEngineEventRows(db, teamId); + const eventDerivedByPlayer: Record = + !eventRowsErr && engineEventRows + ? buildEventDerivedByPlayer(playerIds, engineEventRows.pitches, engineEventRows.battedBalls) + : {}; + const horizonIso = new Date(Date.parse(nowIso) + EVENT_LOOKAHEAD_DAYS * 86400_000).toISOString(); const { data: eventRows } = await db .from('baseball_events') @@ -544,6 +564,7 @@ export async function runBaseballEngineCore( playerIds, (statRows ?? []) as BoxScoreRow[], nowIso, + eventDerivedByPlayer, ); const players = boxScorePlayers.map((p) => mergeEventPlayerMetrics( diff --git a/src/lib/baseball/coachhelm/outcome-sweep.ts b/src/lib/baseball/coachhelm/outcome-sweep.ts index 884094bde..c7214d6f6 100644 --- a/src/lib/baseball/coachhelm/outcome-sweep.ts +++ b/src/lib/baseball/coachhelm/outcome-sweep.ts @@ -43,6 +43,11 @@ import { type BaseballMetricId, } from '@/lib/coachhelm/baseball/metrics/registry'; import { loadEngineStatRows } from '@/lib/baseball/coachhelm/engine-stat-rows'; +import { + loadEngineEventRows, + eventDerivedVelocityForPlayer, + type EngineEventRows, +} from '@/lib/baseball/coachhelm/engine-event-derived'; import type { BaseballActionOutcomeVerdict } from '@/lib/types/baseball-coachhelm-v10'; // A minimally-typed client so the sweep runs against either the RLS server @@ -162,6 +167,14 @@ export async function sweepActionOutcomes( else byPlayer.set(r.player_id, [r]); } + // #852 residual: event-derived velocity, scoped to the SAME after-window + // honesty rule as the box-score pool below (measured strictly AFTER the + // action's created_at). ALL-OR-NOTHING: an event-read failure leaves + // eventRows null, so every action's `eventDerivedForPlayer` below resolves + // to no event data (legacy scalar fallback for every player this pass) -- + // never a partial event/legacy blend (mirrors loadEngineStatRows's own rule). + const { data: eventRows }: { data: EngineEventRows | null } = await loadEngineEventRows(supabase, teamId); + const nowIso = new Date().toISOString(); let measured = 0; // Signal ids whose linked action's target metric IMPROVED this pass — used to @@ -185,7 +198,25 @@ export async function sweepActionOutcomes( // still gates on the resulting sample (honest, just less precise). playerRows; - const loaded = loadPlayerMetrics(a.player_id!, afterRows); + // Event rows get the SAME after-window filter (measured_at strictly after + // created_at) so an event-derived velocity metric is apples-to-apples with + // the box-score after-window above -- a pre-action pitch/batted-ball must + // never count toward "did it move" measurement. + const afterPitches = eventRows + ? createdAt + ? eventRows.pitches.filter((p) => !!p.measured_at && p.measured_at > createdAt) + : eventRows.pitches + : []; + const afterBattedBalls = eventRows + ? createdAt + ? eventRows.battedBalls.filter((b) => !!b.measured_at && b.measured_at > createdAt) + : eventRows.battedBalls + : []; + const eventDerivedForPlayer = eventRows + ? eventDerivedVelocityForPlayer(a.player_id!, afterPitches, afterBattedBalls) + : null; + + const loaded = loadPlayerMetrics(a.player_id!, afterRows, nowIso, eventDerivedForPlayer); const lm = loaded.metrics[metric]; const observed = lm?.value ?? null; const afterSampleN = lm?.sample_n ?? 0; diff --git a/src/lib/baseball/stat-layer-manifest.ts b/src/lib/baseball/stat-layer-manifest.ts index 2800810f2..638b4c692 100644 --- a/src/lib/baseball/stat-layer-manifest.ts +++ b/src/lib/baseball/stat-layer-manifest.ts @@ -317,6 +317,27 @@ export const GRANDFATHERED_CONSUMERS: GrandfatheredStatLayerConsumer[] = [ note: '#379 Phase 4a: pins loaders.ts\'s legacy-fallback behavior (an unmigrated caller keeps citing baseball_player_stats verbatim in source_refs when no eventDerived/source-table input is supplied) alongside the NEW event-derived-override and box-score-normalization tests — so this legitimately still references the deprecated table by design, not staleness. Retires once loaders.ts drops the legacy-table fallback entirely (tracked on loaders.ts\'s own manifest entry above).', }, + { + path: 'src/lib/baseball/__tests__/engine-run-event-velocity.test.ts', + group: 'test', + status: 'pending migration', + note: + '#852 residual: pins runBaseballEngineCore threading event-derived velocity (engine-event-derived.ts) into loadAllPlayerMetrics — asserts event-derived avg exit velocity WINS over a legacy baseball_player_stats exit_velocity scalar for the same player, and that a zero-event player keeps their legacy scalar (plus the all-or-nothing degrade on an event-read failure). The legacy fixture rows are the fallback pin, not staleness; retires with loaders.ts\'s legacy-scalar fallback.', + }, + { + path: 'src/lib/baseball/__tests__/outcome-sweep-event-velocity.test.ts', + group: 'test', + status: 'pending migration', + note: + '#852 residual: pins sweepActionOutcomes threading event-derived velocity into its per-action after-window loadPlayerMetrics call — event-derived avg exit velocity wins over the legacy baseball_player_stats scalar for the same player, and a pre-action event is excluded by the after-window filter. The legacy fixture row is the fallback pin, not staleness; retires with loaders.ts\'s legacy-scalar fallback.', + }, + { + path: 'src/lib/baseball/__tests__/action-baseline-event-velocity.test.ts', + group: 'test', + status: 'pending migration', + note: + '#852 residual: pins buildActionOutcomeSeed threading event-derived velocity into its baseline capture — event-derived avg exit velocity wins over the legacy baseball_player_stats scalar for the same player; a zero-event player\'s legacy scalar still seeds the baseline. The legacy fixture row is the fallback pin, not staleness; retires with loaders.ts\'s legacy-scalar fallback.', + }, { path: 'src/app/baseball/actions/__tests__/imports-registry.test.ts', group: 'test', From db196b902301c7074f7730c16436ab228e27b2e2 Mon Sep 17 00:00:00 2001 From: Fable Integrator Date: Wed, 15 Jul 2026 18:23:44 -0400 Subject: [PATCH 2/2] fix(baseball): bound velocity event read to player scope + fix sampleSize honesty (PR #864 fix-first) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two adversarial-review criticals on #864: 1. buildActionOutcomeSeed (action-baseline.ts) fired a TEAM-WIDE, unbounded, player-unscoped read of the entire pitch/batted-ball event history on every coach "convert to action" click, just to resolve ONE player's velocity scalar. loadEngineEventRows now takes an optional `playerIds` scope (`.in('pitcher_id'|'batter_id', playerIds)`, mirroring loadEngineStatRows's own `.in('player_id', playerIds)` idiom) — the single-player caller passes `[playerId]`; engine-run/outcome-sweep now pass their own already-computed roster/todo player-id lists instead of reading the whole team's history. 2. avg_exit_velocity's sampleSize was `bbCount` (every batted ball) instead of the count of rows that actually carried a non-null exit_velocity reading — inflating the honesty gate for any team whose batted-ball capture doesn't always log a radar reading. Fixed to `battedBalls.filter(b => b.exit_velocity != null).length`, and applied the same fix to the sibling avg_launch_angle metric (identical bug, same line shape). Pitcher avg_velocity was already correct. Tests: pin the DB-level player scoping (loadEngineEventRows + a buildActionOutcomeSeed integration check), and pin the sampleSize fix (10 batted balls / 4 readings -> sampleSize 4; independent launch_angle gating; hard_hit_rate's bbCount-based denominator unaffected). Co-Authored-By: Claude Fable 5 --- .../action-baseline-event-velocity.test.ts | 46 ++++++++++--- .../__tests__/engine-event-derived.test.ts | 64 ++++++++++++++++++- .../engine-run-event-velocity.test.ts | 5 +- src/lib/baseball/coachhelm/action-baseline.ts | 6 +- .../coachhelm/engine-event-derived.ts | 61 ++++++++++++------ src/lib/baseball/coachhelm/engine-run.ts | 2 +- src/lib/baseball/coachhelm/outcome-sweep.ts | 6 +- .../__tests__/elite-stat-events.test.ts | 40 ++++++++++++ .../baseball/read-models/elite-stat-events.ts | 16 ++++- 9 files changed, 210 insertions(+), 36 deletions(-) diff --git a/src/lib/baseball/__tests__/action-baseline-event-velocity.test.ts b/src/lib/baseball/__tests__/action-baseline-event-velocity.test.ts index 2ba8f2196..d68643192 100644 --- a/src/lib/baseball/__tests__/action-baseline-event-velocity.test.ts +++ b/src/lib/baseball/__tests__/action-baseline-event-velocity.test.ts @@ -15,14 +15,17 @@ const TEAM = 'team-1'; * Minimal in-memory Supabase-shaped stub with REAL filtering (eq/in/is mutate * the row set), matching action-baseline.test.ts's established style. */ -function makeClient(tables: { - baseball_player_stats?: Array>; - baseball_games?: Array>; - baseball_box_score_batting?: Array>; - baseball_box_score_pitching?: Array>; - baseball_pitch_events?: Array>; - baseball_batted_ball_events?: Array>; -}): BaselineClient { +function makeClient( + tables: { + baseball_player_stats?: Array>; + baseball_games?: Array>; + baseball_box_score_batting?: Array>; + baseball_box_score_pitching?: Array>; + baseball_pitch_events?: Array>; + baseball_batted_ball_events?: Array>; + }, + inCalls?: Array<{ table: string; col: string; vals: unknown[] }>, +): BaselineClient { return { from(table: string) { let rows: Array> = @@ -36,6 +39,7 @@ function makeClient(tables: { return api; }, in(col: string, vals: unknown[]) { + inCalls?.push({ table, col, vals }); rows = rows.filter((r) => vals.includes(r[col])); return api; }, @@ -99,4 +103,30 @@ describe('buildActionOutcomeSeed — #852 event-derived velocity wiring', () => expect(seed.outcome_baseline_value).toBe(90); expect(seed.outcome_verdict).toBeNull(); }); + + it('scopes the event read to ONLY the subject player — never a team-wide unbounded scan on a single "convert to action" click', async () => { + const inCalls: Array<{ table: string; col: string; vals: unknown[] }> = []; + const client = makeClient( + { + baseball_player_stats: [ + { + id: 's3', team_id: TEAM, player_id: 'p3', stat_type: 'game', session_date: '2026-04-01', + at_bats: 4, hits: 1, walks: 0, strikeouts: 1, exit_velocity: 75, + }, + ], + baseball_batted_ball_events: [ + { id: 'bb-p3', team_id: TEAM, batter_id: 'p3', exit_velocity: 100, measured_at: '2026-04-01T00:00:00.000Z', superseded_by_run_id: null }, + // A teammate's batted ball -- must never enter p3's baseline read. + { id: 'bb-other', team_id: TEAM, batter_id: 'p-other', exit_velocity: 50, measured_at: '2026-04-01T00:00:00.000Z', superseded_by_run_id: null }, + ], + }, + inCalls, + ); + + const seed = await buildActionOutcomeSeed(client, TEAM, 'p3', 'avg_exit_velocity'); + expect(seed.outcome_baseline_value).toBe(100); // NOT (100+50)/2 -- p-other never enters the pool. + + const bbeScope = inCalls.find((c) => c.table === 'baseball_batted_ball_events' && c.col === 'batter_id'); + expect(bbeScope?.vals).toEqual(['p3']); + }); }); diff --git a/src/lib/baseball/__tests__/engine-event-derived.test.ts b/src/lib/baseball/__tests__/engine-event-derived.test.ts index 44bf990ea..838fddac4 100644 --- a/src/lib/baseball/__tests__/engine-event-derived.test.ts +++ b/src/lib/baseball/__tests__/engine-event-derived.test.ts @@ -192,7 +192,9 @@ type Row = Record; /** * Same minimal chainable fake shape as engine-stat-rows.test.ts's, plus a REAL * (not no-op) `.is()` so the #813 supersede-filter test below is an honest - * assertion rather than a smoke test. + * assertion rather than a smoke test, and a REAL `.in()` so the player-id + * scoping test below actually exercises the DB-side filter, not just the + * pure aggregation layer. */ function makeClient(tables: Record, errorTables: Set = new Set()) { return { @@ -206,6 +208,10 @@ function makeClient(tables: Record, errorTables: Set = ne rows = rows.filter((r) => r[col] === val); return builder; }, + in: (col: string, vals: unknown[]) => { + rows = rows.filter((r) => vals.includes(r[col])); + return builder; + }, order: () => builder, range: () => Promise.resolve( @@ -266,3 +272,59 @@ describe('loadEngineEventRows', () => { expect(queried).toBe(false); }); }); + +// ----------------------------------------------------------------------------- +// loadEngineEventRows — player-id scoping (unbounded-read fix). +// +// buildActionOutcomeSeed (action-baseline.ts) resolves ONE player's velocity +// scalar on every coach "convert to action" click; it must never fire a +// team-wide, unbounded scan of the whole pitch/batted-ball history to do so. +// These pin that the optional `playerIds` param actually bounds the DB read +// (not just the pure aggregation downstream), mirroring loadEngineStatRows's +// own `.in('player_id', playerIds)` scoping. +// ----------------------------------------------------------------------------- +describe('loadEngineEventRows — player-id scoping (unbounded-read fix)', () => { + it('scopes the pitch read to pitcher_id IN playerIds and the batted-ball read to batter_id IN playerIds — other players never enter the pool', async () => { + const client = makeClient({ + baseball_pitch_events: [ + { id: 'pt-p1', team_id: TEAM, pitcher_id: 'p1', velocity: 90, superseded_by_run_id: null }, + { id: 'pt-p2', team_id: TEAM, pitcher_id: 'p2', velocity: 70, superseded_by_run_id: null }, + ], + baseball_batted_ball_events: [ + { id: 'bb-p1', team_id: TEAM, batter_id: 'p1', exit_velocity: 100, superseded_by_run_id: null }, + { id: 'bb-p2', team_id: TEAM, batter_id: 'p2', exit_velocity: 60, superseded_by_run_id: null }, + ], + }); + + const { data, error } = await loadEngineEventRows(client, TEAM, ['p1']); + expect(error).toBeNull(); + expect(data!.pitches.map((p) => p.id)).toEqual(['pt-p1']); + expect(data!.battedBalls.map((b) => b.id)).toEqual(['bb-p1']); + }); + + it('returns an empty pool WITHOUT querying when playerIds is an explicit empty array', async () => { + let queried = false; + const client = { + from() { + queried = true; + throw new Error('should not query'); + }, + }; + const { data, error } = await loadEngineEventRows(client, TEAM, []); + expect(data).toEqual({ pitches: [], battedBalls: [] }); + expect(error).toBeNull(); + expect(queried).toBe(false); + }); + + it('omitting playerIds keeps the team-wide read (explicit opt-in only — no behavior change for a caller that truly needs every player)', async () => { + const client = makeClient({ + baseball_pitch_events: [ + { id: 'pt-p1', team_id: TEAM, pitcher_id: 'p1', velocity: 90, superseded_by_run_id: null }, + { id: 'pt-p2', team_id: TEAM, pitcher_id: 'p2', velocity: 70, superseded_by_run_id: null }, + ], + baseball_batted_ball_events: [], + }); + const { data } = await loadEngineEventRows(client, TEAM); + expect(data!.pitches.map((p) => p.id).sort()).toEqual(['pt-p1', 'pt-p2']); + }); +}); diff --git a/src/lib/baseball/__tests__/engine-run-event-velocity.test.ts b/src/lib/baseball/__tests__/engine-run-event-velocity.test.ts index 81c61c51d..3f612c9eb 100644 --- a/src/lib/baseball/__tests__/engine-run-event-velocity.test.ts +++ b/src/lib/baseball/__tests__/engine-run-event-velocity.test.ts @@ -133,11 +133,14 @@ describe('runBaseballEngineCore — #852 event-derived velocity wiring', () => { // hand-rolling a whole second fake client. `baseball_batted_ball_events` // is ALSO read by the pre-existing "deepened event catalog" fetch further // down runBaseballEngineCore (a `.gte('measured_at', ...)`-shaped query, - // no `.is()`), so the stub must satisfy BOTH call shapes. + // no `.is()`), so the stub must satisfy BOTH call shapes. `.in()` is the + // player-id scope loadEngineEventRows now applies (velocity-read + // unbounded-scan fix) -- must be stubbed too. const realFrom = fake.from.bind(fake); const erroringBuilder: Record = { select: () => erroringBuilder, eq: () => erroringBuilder, + in: () => erroringBuilder, is: () => erroringBuilder, gte: () => erroringBuilder, order: () => erroringBuilder, diff --git a/src/lib/baseball/coachhelm/action-baseline.ts b/src/lib/baseball/coachhelm/action-baseline.ts index 2c12b415c..f6ce08fb7 100644 --- a/src/lib/baseball/coachhelm/action-baseline.ts +++ b/src/lib/baseball/coachhelm/action-baseline.ts @@ -223,7 +223,11 @@ export async function buildActionOutcomeSeed( // read's own no-date-filter semantics). ALL-OR-NOTHING: an event-read // failure (eventRows null) falls back to the legacy scalar for every // velocity field, never a partial blend (mirrors loadEngineStatRows). - const { data: eventRows } = await loadEngineEventRows(supabase, teamId); + // Scoped to THIS ONE player -- a single "convert to action" click must + // never fire a team-wide, unbounded scan of the whole pitch/batted-ball + // history just to resolve one player's velocity scalar (mirrors the + // box-score read's own `[playerId]` scope immediately above). + const { data: eventRows } = await loadEngineEventRows(supabase, teamId, [playerId]); const eventDerived = eventRows ? eventDerivedVelocityForPlayer(playerId, eventRows.pitches, eventRows.battedBalls) : null; diff --git a/src/lib/baseball/coachhelm/engine-event-derived.ts b/src/lib/baseball/coachhelm/engine-event-derived.ts index 64b263525..074989694 100644 --- a/src/lib/baseball/coachhelm/engine-event-derived.ts +++ b/src/lib/baseball/coachhelm/engine-event-derived.ts @@ -25,11 +25,16 @@ import 'server-only'; // PostgREST 1000-row cap (fetchAllRowsResult) with the #813 superseded-row // filter (`superseded_by_run_id IS NULL` -- only the CURRENT value powers // the engine, matching engine-run.ts's existing deepened-catalog read and -// elite-stat-events.ts's own getEliteStatEvents). No date window here -- -// callers apply their OWN honesty window (e.g. outcome-sweep's -// after-window) by filtering the returned rows before aggregating, the -// same way loadEngineStatRows returns the full box-score history and lets -// each caller decide how much of it to use. +// elite-stat-events.ts's own getEliteStatEvents). Also bounded by an +// OPTIONAL `playerIds` scope (`.in('pitcher_id'|'batter_id', playerIds)`) +// -- a single "convert to action" click only ever needs ONE player's +// rows, so action-baseline.ts passes `[playerId]` rather than forcing a +// team-wide scan; engine-run/outcome-sweep pass their own +// roster/todo-derived id lists. No date window here -- callers apply +// their OWN honesty window (e.g. outcome-sweep's after-window) by +// filtering the returned rows before aggregating, the same way +// loadEngineStatRows returns the full box-score history (for its own +// player scope) and lets each caller decide how much of it to use. // 2. buildEventDerivedByPlayer / eventDerivedVelocityForPlayer (pure) -- // groups rows by player and calls buildHitterMetrics / buildPitcherMetrics // + eventDerivedVelocityFromMetrics to produce the per-player velocity @@ -88,10 +93,17 @@ export interface EngineEventRows { * (`superseded_by_run_id IS NULL`) -- a corrected import must never let a * stale, superseded pitch/batted-ball row into the engine's velocity average. * - * No player-id filter: mirrors engine-run.ts's existing deepened-event-catalog - * read of these same two tables (team-scoped, ungated by player), which keeps - * this a single shared shape every caller (engine-run / outcome-sweep / - * action-baseline) can reuse without an `.or()` multi-column player filter. + * `playerIds`, when passed, bounds the read to exactly the players the caller + * needs -- mirrors loadEngineStatRows's own `.in('player_id', playerIds)` + * scoping (this read's box-score sibling). `eventDerivedVelocityForPlayer` / + * `buildEventDerivedByPlayer` only ever read a pitch row via its + * `pitcher_id` and a batted-ball row via its `batter_id`, so that is exactly + * what each table is filtered on -- a single coach "convert to action" click + * (ONE player) must never fire a team-wide, unbounded scan of the entire + * pitch/batted-ball history just to resolve that one player's velocity + * scalar. Omit `playerIds` (or pass `undefined`) for a genuinely team-wide + * read (engine-run / outcome-sweep already compute their own roster/todo + * player-id list and now pass it through here too). * * ALL-OR-NOTHING: a failure on EITHER table returns `data: null` so a caller * degrades every player to their legacy scalar this run, never a partial @@ -100,28 +112,35 @@ export interface EngineEventRows { export async function loadEngineEventRows( db: EngineEventRowsClient, teamId: string, + playerIds?: string[], ): Promise<{ data: EngineEventRows | null; error: { message: string; code?: string | null } | null }> { if (!teamId) return { data: { pitches: [], battedBalls: [] }, error: null }; + // An explicitly empty scope list means "no players to resolve" -- honestly + // return nothing rather than querying (mirrors loadEngineStatRows's own + // `playerIds.length === 0` short-circuit). + if (playerIds && playerIds.length === 0) { + return { data: { pitches: [], battedBalls: [] }, error: null }; + } const [pitchRes, bbRes] = await Promise.all([ - fetchAllRowsResult((from, to) => - db + fetchAllRowsResult((from, to) => { + let q = db .from('baseball_pitch_events') .select('*') .eq('team_id', teamId) - .is('superseded_by_run_id', null) - .order('id', { ascending: true }) - .range(from, to), - ), - fetchAllRowsResult((from, to) => - db + .is('superseded_by_run_id', null); + if (playerIds) q = q.in('pitcher_id', playerIds); + return q.order('id', { ascending: true }).range(from, to); + }), + fetchAllRowsResult((from, to) => { + let q = db .from('baseball_batted_ball_events') .select('*') .eq('team_id', teamId) - .is('superseded_by_run_id', null) - .order('id', { ascending: true }) - .range(from, to), - ), + .is('superseded_by_run_id', null); + if (playerIds) q = q.in('batter_id', playerIds); + return q.order('id', { ascending: true }).range(from, to); + }), ]); if (pitchRes.error || bbRes.error) { diff --git a/src/lib/baseball/coachhelm/engine-run.ts b/src/lib/baseball/coachhelm/engine-run.ts index af2f78944..0c26ef1af 100644 --- a/src/lib/baseball/coachhelm/engine-run.ts +++ b/src/lib/baseball/coachhelm/engine-run.ts @@ -327,7 +327,7 @@ export async function runBaseballEngineCore( // canonical velocity source. ALL-OR-NOTHING: an event-read failure leaves // eventDerivedByPlayer EMPTY, so every player degrades to their legacy // scalar this run -- never a partial event/legacy blend. - const { data: engineEventRows, error: eventRowsErr } = await loadEngineEventRows(db, teamId); + const { data: engineEventRows, error: eventRowsErr } = await loadEngineEventRows(db, teamId, playerIds); const eventDerivedByPlayer: Record = !eventRowsErr && engineEventRows ? buildEventDerivedByPlayer(playerIds, engineEventRows.pitches, engineEventRows.battedBalls) diff --git a/src/lib/baseball/coachhelm/outcome-sweep.ts b/src/lib/baseball/coachhelm/outcome-sweep.ts index c7214d6f6..676295c59 100644 --- a/src/lib/baseball/coachhelm/outcome-sweep.ts +++ b/src/lib/baseball/coachhelm/outcome-sweep.ts @@ -173,7 +173,11 @@ export async function sweepActionOutcomes( // eventRows null, so every action's `eventDerivedForPlayer` below resolves // to no event data (legacy scalar fallback for every player this pass) -- // never a partial event/legacy blend (mirrors loadEngineStatRows's own rule). - const { data: eventRows }: { data: EngineEventRows | null } = await loadEngineEventRows(supabase, teamId); + const { data: eventRows }: { data: EngineEventRows | null } = await loadEngineEventRows( + supabase, + teamId, + playerIds, + ); const nowIso = new Date().toISOString(); let measured = 0; diff --git a/src/lib/baseball/read-models/__tests__/elite-stat-events.test.ts b/src/lib/baseball/read-models/__tests__/elite-stat-events.test.ts index 9c9d178ee..09db9f330 100644 --- a/src/lib/baseball/read-models/__tests__/elite-stat-events.test.ts +++ b/src/lib/baseball/read-models/__tests__/elite-stat-events.test.ts @@ -204,6 +204,46 @@ describe('buildHitterMetrics — batted-ball quality', () => { }); }); +describe('buildHitterMetrics — avg_exit_velocity / avg_launch_angle sampleSize honesty', () => { + it('a player with 10 batted balls but only 4 exit-velo readings reports sampleSize 4, NOT 10 (a hand-charted at-bat with no radar gun must never inflate the gate)', () => { + const battedBalls = [ + ...Array.from({ length: 4 }, () => bbe({ exit_velocity: 95 })), + // 6 hand-charted batted balls with no radar reading at all. + ...Array.from({ length: 6 }, () => bbe({ exit_velocity: null })), + ]; + const model = buildHitterMetrics('p1', [], battedBalls, 'official_game'); + const m = metric(model, 'avg_exit_velocity')!; + expect(m.value).toBe(95); + expect(m.sampleSize).toBe(4); + }); + + it('avg_launch_angle independently counts only rows with a non-null launch_angle', () => { + const battedBalls = [ + bbe({ exit_velocity: 90, launch_angle: 12 }), + bbe({ exit_velocity: 92, launch_angle: 18 }), + // Exit velocity logged, launch angle not -- the two fields gate independently. + bbe({ exit_velocity: 88, launch_angle: null }), + ]; + const model = buildHitterMetrics('p1', [], battedBalls, 'official_game'); + expect(metric(model, 'avg_exit_velocity')!.sampleSize).toBe(3); + expect(metric(model, 'avg_launch_angle')!.sampleSize).toBe(2); + expect(metric(model, 'avg_launch_angle')!.value).toBeCloseTo(15); + }); + + it('a hard-hit-rate/barrel-rate/gb-rate denominator still uses the FULL batted-ball count (bbCount), unaffected by this sampleSize fix', () => { + const battedBalls = [ + bbe({ is_hard_hit: true, exit_velocity: 95 }), + bbe({ is_hard_hit: false, exit_velocity: null }), + bbe({ is_hard_hit: false, exit_velocity: null }), + ]; + const model = buildHitterMetrics('p1', [], battedBalls, 'official_game'); + // hard_hit_rate's denominator is every batted ball, radar-read or not. + expect(metric(model, 'hard_hit_rate')!.sampleSize).toBe(3); + // but avg_exit_velocity only counts the ones with an actual reading. + expect(metric(model, 'avg_exit_velocity')!.sampleSize).toBe(1); + }); +}); + describe('honest confidence + provenance', () => { it('never returns high on a thin sample', () => { const thin = buildHitterMetrics('p1', Array.from({ length: 5 }, () => pitch({ is_in_zone: false, is_swing: true })), [], 'official_game'); diff --git a/src/lib/baseball/read-models/elite-stat-events.ts b/src/lib/baseball/read-models/elite-stat-events.ts index 2044d9e36..339521fec 100644 --- a/src/lib/baseball/read-models/elite-stat-events.ts +++ b/src/lib/baseball/read-models/elite-stat-events.ts @@ -744,11 +744,23 @@ export function buildHitterMetrics( ), scalarMetric( { metricKey: 'avg_exit_velocity', metricGroup: 'hitting', label: 'Avg Exit Velo', unit: 'mph', higherIsBetter: true, threshold: SCALAR_THRESHOLD, fallbackContext }, - avgOf(battedBalls.map((b) => b.exit_velocity)), bbCount, bbProv, + // sampleSize is the count of rows that ACTUALLY carried a velocity + // reading (exit_velocity is nullable — a hand-charted at-bat with no + // radar gun logs the batted ball with no exit_velocity), never bbCount + // (every batted ball, radar-read or not). A player with 10 batted balls + // but only 4 exit-velo readings must report sampleSize 4, not 10 — + // gateSample() honesty depends on it. + avgOf(battedBalls.map((b) => b.exit_velocity)), + battedBalls.filter((b) => b.exit_velocity != null).length, + bbProv, ), scalarMetric( { metricKey: 'avg_launch_angle', metricGroup: 'hitting', label: 'Avg Launch Angle', unit: 'deg', higherIsBetter: true, threshold: SCALAR_THRESHOLD, fallbackContext }, - avgOf(battedBalls.map((b) => b.launch_angle)), bbCount, bbProv, + // Same non-null-reading rule as avg_exit_velocity above — launch_angle + // is independently nullable per row. + avgOf(battedBalls.map((b) => b.launch_angle)), + battedBalls.filter((b) => b.launch_angle != null).length, + bbProv, ), ];