From 81dd1d88811311812e573410504291a41d421a50 Mon Sep 17 00:00:00 2001 From: Fable Integrator Date: Wed, 15 Jul 2026 04:15:28 -0400 Subject: [PATCH] =?UTF-8?q?fix(baseball):=20CoachHelm=20engine=20loaders/r?= =?UTF-8?q?egistry=20=E2=80=94=20#379=20Phase=204a=20source-table=20+=20ev?= =?UTF-8?q?ent-derived=20wiring?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit loaders.ts now honestly threads stat-layer provenance instead of hardcoding baseball_player_stats everywhere, without changing any existing caller's output: - Per-row hittingSourceTable/pitchingSourceTable tags (set by the new normalizeBoxScoreBattingRow/normalizeBoxScorePitchingRow helpers) let a future caller that has normalized rows from the canonical baseball_box_score_batting/_pitching tables get an honest source_refs citation instead of the legacy default. - An optional eventDerived input (built via eventDerivedVelocityFromMetrics from elite-stat-events.ts's DerivedMetric[] output) makes exit/pitch velocity metrics prefer real event-grain data over the legacy scalar columns, per-field, whenever it exists — never blended, never fabricated. - Both inputs are additive/optional appended AFTER the existing nowIso param (preserved exactly, including its threading from #811), so engine-run.ts/outcome-sweep.ts/action-baseline.ts/practice-effectiveness.ts (Phase 4b/2, not touched here) keep working byte-for-byte unchanged — verified by running their existing test suites. registry.ts's header doc comment no longer asserts baseball_player_stats/ baseball_player_aggregates as the metrics' only source, since loaders.ts can now source some fields canonically; the file has zero remaining references to the deprecated tables, so its stat-layer-manifest.ts grandfathered entry is removed. registry.role-visibility.test.ts's source_refs fixture switches to a canonical table name for the same reason and is also removed from the manifest. engine-v10.test.ts gains new coverage for the loaders behavior above (still legitimately cites baseball_player_stats to pin the legacy-fallback path, so its manifest entry is kept, with an updated note). Pre-existing, unrelated drift found but NOT fixed here (confirmed via git-stash A/B on the base branch, present before this change): the stat-layer-contract test already fails on 3 files outside this chunk (practice-effectiveness.test.ts, player-today-self-scope.test.ts, player-today-honest-loop.test.ts reference deprecated tables without a manifest entry) and 1 stale entry (insights.ts, already migrated by #819, never removed from the manifest). Flagged for a separate fix — out of scope for the loaders/registry file list. Co-Authored-By: Claude Fable 5 --- src/lib/baseball/stat-layer-manifest.ts | 18 +- src/lib/coachhelm/baseball/engine-v10.test.ts | 152 ++++++++- src/lib/coachhelm/baseball/loaders.ts | 289 ++++++++++++++++-- .../metrics/registry.role-visibility.test.ts | 4 +- .../coachhelm/baseball/metrics/registry.ts | 18 +- 5 files changed, 434 insertions(+), 47 deletions(-) diff --git a/src/lib/baseball/stat-layer-manifest.ts b/src/lib/baseball/stat-layer-manifest.ts index 40436542d..d408a2fab 100644 --- a/src/lib/baseball/stat-layer-manifest.ts +++ b/src/lib/baseball/stat-layer-manifest.ts @@ -195,13 +195,8 @@ export const GRANDFATHERED_CONSUMERS: GrandfatheredStatLayerConsumer[] = [ path: 'src/lib/coachhelm/baseball/loaders.ts', group: 'coachhelm-engine', status: 'pending migration', - note: 'Loads baseball_player_stats rows as the input series for the V10 metrics registry below.', - }, - { - path: 'src/lib/coachhelm/baseball/metrics/registry.ts', - group: 'coachhelm-engine', - status: 'pending migration', - note: 'Derives K-rate, BB-rate, AVG, SLG, OBP, ERA, exit/pitch velocity metrics from baseball_player_stats / baseball_player_aggregates columns.', + note: + '#379 Phase 4a: still the input-series loader for the V10 metrics registry, and still cites baseball_player_stats as the DEFAULT/fallback source table for any caller that has not migrated its fetch — engine-run.ts, outcome-sweep.ts, action-baseline.ts, practice-effectiveness.ts (a Phase 4b/2 concern). It now ALSO accepts, additively: (1) a per-row hittingSourceTable/pitchingSourceTable tag a migrated caller sets after normalizing baseball_box_score_batting/_pitching rows (normalizeBoxScoreBattingRow/normalizeBoxScorePitchingRow), which the loader cites verbatim in its source_refs instead of the legacy table; (2) an optional eventDerived input (eventDerivedVelocityFromMetrics) that sources avg exit/pitch velocity from elite-stat-events.ts, winning over the legacy exit_velocity/pitch_velocity scalar per field when present. Retires from this list once every caller has migrated and the legacy-table fallback path is dead code.', }, { path: 'src/lib/coachhelm/baseball/generators/v10.ts', @@ -315,13 +310,8 @@ export const GRANDFATHERED_CONSUMERS: GrandfatheredStatLayerConsumer[] = [ path: 'src/lib/coachhelm/baseball/engine-v10.test.ts', group: 'test', status: 'pending migration', - note: 'Fixture source_refs table mirrors generators/v10.ts.', - }, - { - path: 'src/lib/coachhelm/baseball/metrics/registry.role-visibility.test.ts', - group: 'test', - status: 'pending migration', - note: 'Fixture table name mirrors registry.ts visibility-ref shape.', + 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/app/baseball/actions/__tests__/imports-registry.test.ts', diff --git a/src/lib/coachhelm/baseball/engine-v10.test.ts b/src/lib/coachhelm/baseball/engine-v10.test.ts index a9a984b6d..6a74d920e 100644 --- a/src/lib/coachhelm/baseball/engine-v10.test.ts +++ b/src/lib/coachhelm/baseball/engine-v10.test.ts @@ -19,6 +19,14 @@ import { getBaseballMetricThreshold, } from './metrics/registry'; import type { LoadedMetric, LoadedPlayerMetrics } from './loaders'; +import { + loadPlayerMetrics, + loadAllPlayerMetrics, + normalizeBoxScoreBattingRow, + normalizeBoxScorePitchingRow, + eventDerivedVelocityFromMetrics, + type BoxScoreRow, +} from './loaders'; import { pitcherCommandDecayComposite, hitterTranslationGapComposite, @@ -27,6 +35,7 @@ import { import { readinessGenerator } from './generators/v10'; import { scoreBaseballCandidate, rankBaseballCandidates } from './ranking'; import type { BaseballInsightCandidate } from './generators'; +import type { DerivedMetric } from '@/lib/baseball/read-models/elite-stat-events'; // ---- helpers ---------------------------------------------------------------- @@ -44,7 +53,9 @@ function metric( target_n: 10, confidence, confidence_factors: { sample_adequacy: 1, recency: 1, variance: 0, factors_measured: false }, - source_refs: [{ table: 'baseball_player_stats', visibility: 'staff_only', sample_n, confidence }], + // #379: fixture cites the canonical box-score table now that loaders.ts + // sources migrated callers from it — see stat-layer-manifest.ts. + source_refs: [{ table: 'baseball_box_score_batting', visibility: 'staff_only', sample_n, confidence }], }; } @@ -219,3 +230,142 @@ describe('multi-factor ranking', () => { expect(ranked[0]!.rankScore).toBeGreaterThanOrEqual(ranked[1]!.rankScore); }); }); + +// ---- 6. #379 reconciliation — loaders.ts source-table + event-derived ----- +// wiring. Every case here is additive/backward-compatible: an unmigrated +// caller (no 4th/5th arg, no hittingSourceTable/pitchingSourceTable tag) gets +// EXACTLY today's legacy behavior; the new inputs only change output when a +// caller opts in. + +function derivedMetric(overrides: Partial): DerivedMetric { + return { + metricKey: 'avg_exit_velocity', + metricGroup: 'hitting', + label: 'Avg Exit Velo', + value: 90, + unit: 'mph', + higherIsBetter: true, + sampleSize: 12, + confidence: 'high', + trustTier: 'official', + dataContext: 'official_game', + sourceId: null, + ...overrides, + }; +} + +describe('loaders #379 reconciliation — source-table + event-derived wiring', () => { + const legacyRows: BoxScoreRow[] = [ + { + id: 'r1', + player_id: 'p1', + stat_type: 'game', + session_date: '2026-04-01', + at_bats: 4, + hits: 2, + walks: 1, + strikeouts: 1, + hit_by_pitch: 0, + sacrifice_flies: 0, + doubles: 0, + triples: 0, + home_runs: 0, + exit_velocity: 90, + }, + { + id: 'r2', + player_id: 'p1', + stat_type: 'game', + session_date: '2026-04-08', + at_bats: 4, + hits: 1, + walks: 0, + strikeouts: 2, + hit_by_pitch: 0, + sacrifice_flies: 0, + doubles: 0, + triples: 0, + home_runs: 0, + exit_velocity: 94, + }, + ]; + + it('an unmigrated caller (no eventDerived, no source-table tag) keeps citing the legacy table exactly as before', () => { + const loaded = loadPlayerMetrics('p1', legacyRows); + expect(loaded.metrics.avg_exit_velocity?.value).toBeCloseTo(92); + expect(loaded.metrics.avg_exit_velocity?.source_refs[0]?.table).toBe('baseball_player_stats'); + expect(loaded.metrics.max_exit_velocity?.value).toBe(94); + expect(loaded.metrics.max_exit_velocity?.source_refs[0]?.table).toBe('baseball_player_stats'); + }); + + it('an event-derived value WINS over the legacy scalar per-field, never blended, never fabricated for the untouched field', () => { + const loaded = loadPlayerMetrics('p1', legacyRows, undefined, { + avgExitVelocity: { value: 91, sampleSize: 5 }, + }); + expect(loaded.metrics.avg_exit_velocity?.value).toBe(91); + expect(loaded.metrics.avg_exit_velocity?.sample_n).toBe(5); + expect(loaded.metrics.avg_exit_velocity?.source_refs[0]?.table).toBe('baseball_batted_ball_events'); + // max_exit_velocity had no event input → still the legacy fallback. + expect(loaded.metrics.max_exit_velocity?.value).toBe(94); + expect(loaded.metrics.max_exit_velocity?.source_refs[0]?.table).toBe('baseball_player_stats'); + }); + + it('threads a normalized box-score row\'s real source table into the emitted source_refs (batting + pitching)', () => { + const battingRow = normalizeBoxScoreBattingRow( + { id: 'bb1', player_id: 'p2', ab: 4, h: 2, doubles: 1, triples: 0, hr: 0, bb: 1, k: 1, hbp: 0, sf: 0 }, + '2026-04-01', + ); + const hitter = loadPlayerMetrics('p2', [battingRow]); + expect(hitter.metrics.batting_avg?.value).toBeCloseTo(0.5); + expect(hitter.metrics.batting_avg?.source_refs[0]?.table).toBe('baseball_box_score_batting'); + + const pitchingRow = normalizeBoxScorePitchingRow( + { id: 'bp1', player_id: 'p3', ip: 5, bb: 2, k: 6, er: 2, pitch_count: 80, strikes: 52 }, + '2026-04-02', + ); + const pitcher = loadPlayerMetrics('p3', [pitchingRow]); + expect(pitcher.metrics.era?.value).toBeCloseTo(3.6); + expect(pitcher.metrics.era?.source_refs[0]?.table).toBe('baseball_box_score_pitching'); + }); + + it('eventDerivedVelocityFromMetrics pulls avg exit/pitch velocity and leaves max fields null (no event max metric exists yet)', () => { + const out = eventDerivedVelocityFromMetrics([ + derivedMetric({ metricKey: 'avg_exit_velocity', value: 92, sampleSize: 10 }), + derivedMetric({ metricKey: 'avg_velocity', metricGroup: 'pitching', value: 88, sampleSize: 40 }), + derivedMetric({ metricKey: 'chase_rate', value: 0.3, sampleSize: 20 }), + ]); + expect(out.avgExitVelocity).toEqual({ value: 92, sampleSize: 10 }); + expect(out.avgPitchVelocity).toEqual({ value: 88, sampleSize: 40 }); + expect(out.maxExitVelocity).toBeNull(); + expect(out.maxPitchVelocity).toBeNull(); + }); + + it('eventDerivedVelocityFromMetrics returns null fields when no matching metric key exists (honest absence, never fabricated)', () => { + const out = eventDerivedVelocityFromMetrics([derivedMetric({ metricKey: 'chase_rate', value: 0.3 })]); + expect(out.avgExitVelocity).toBeNull(); + expect(out.avgPitchVelocity).toBeNull(); + }); + + it('loadAllPlayerMetrics preserves nowIso positionally and threads per-player event overrides additively', () => { + const rows: BoxScoreRow[] = [ + { + id: 'pr1', + player_id: 'p4', + stat_type: 'game', + session_date: '2026-06-01', + innings_pitched: 5, + pitches_thrown: 70, + walks_allowed: 1, + strikeouts_thrown: 6, + earned_runs: 1, + }, + ]; + const nowIso = '2026-06-03T00:00:00.000Z'; // within the 7-day rolling window + const [p4] = loadAllPlayerMetrics(['p4'], rows, nowIso, { + p4: { avgPitchVelocity: { value: 89, sampleSize: 3 } }, + }); + expect(p4!.metrics.rolling_pitch_count?.value).toBe(70); + expect(p4!.metrics.avg_pitch_velocity?.value).toBe(89); + expect(p4!.metrics.avg_pitch_velocity?.source_refs[0]?.table).toBe('baseball_pitch_events'); + }); +}); diff --git a/src/lib/coachhelm/baseball/loaders.ts b/src/lib/coachhelm/baseball/loaders.ts index 63a356768..e325bebcb 100644 --- a/src/lib/coachhelm/baseball/loaders.ts +++ b/src/lib/coachhelm/baseball/loaders.ts @@ -22,6 +22,36 @@ * * Pure module: no DB access here (the action fetches rows and passes them in), * so this is unit-testable and reusable. + * + * --- #379 reconciliation (stat-layer-architecture) --------------------------- + * This loader's row shape historically mirrored `baseball_player_stats` (the + * DEPRECATED flat/aggregate layer) 1:1 — see + * `src/lib/baseball/stat-layer-manifest.ts`. It now honestly threads + * provenance for two migrations already landed elsewhere in the codebase, + * WITHOUT changing any math for callers that haven't migrated yet: + * + * 1. `BoxScoreRow.hittingSourceTable` / `.pitchingSourceTable` — an OPTIONAL + * per-row tag a caller sets when it normalizes rows from the canonical + * box-score tables (`baseball_box_score_batting` / `_pitching`, see + * `normalizeBoxScoreBattingRow` / `normalizeBoxScorePitchingRow` below) + * instead of the legacy flat table. Every `source_refs` table-name string + * this loader emits reflects the REAL table the row came from; callers + * that don't set the tag keep citing `baseball_player_stats` exactly as + * before (the honest default for the still-grandfathered callers). + * 2. An optional `eventDerived` input (per player) carrying exit/pitch + * velocity scalars already aggregated from `elite-stat-events.ts`'s + * event-grain read model. Per design rule #4 (event-derived fields are + * sourced from the event layer when a matching row exists, never + * fabricated from a legacy scalar), when a field is present here it WINS + * over the legacy `exit_velocity`/`pitch_velocity` box-score columns; when + * absent (or the caller passes nothing at all — the current, unmigrated + * callers) the loader falls back to the original legacy-scalar + * computation unchanged. + * + * Neither change alters a single existing call site's output: both inputs are + * additive and optional, so this is the "depends on the shared adapter and + * elite-stat-events.ts already being stable" groundwork for Phase 4b, not a + * behavior change for Phase 4a's own callers. */ import type { @@ -33,6 +63,7 @@ import type { BaseballInsightSourceRef, BaseballSourceVisibility, } from '@/lib/types/baseball-coachhelm'; +import type { DerivedMetric } from '@/lib/baseball/read-models/elite-stat-events'; import { getBaseballMetricFidelity, getBaseballMetricUnit, @@ -40,12 +71,24 @@ import { type BaseballMetricUnit, } from './metrics/registry'; +/** The legacy flat-table name every source ref cites unless tagged otherwise. */ +const LEGACY_STAT_TABLE = 'baseball_player_stats'; +/** Canonical event-grain tables the event-derived velocity fields cite. */ +const EXIT_VELOCITY_EVENT_TABLE = 'baseball_batted_ball_events'; +const PITCH_VELOCITY_EVENT_TABLE = 'baseball_pitch_events'; + // ----------------------------------------------------------------------------- // Input row shapes (a subset of baseball_player_stats we actually read). Kept // local + structural so the loaders don't couple to the full generated row. // ----------------------------------------------------------------------------- -/** One box-score / session row from baseball_player_stats. */ +/** + * One box-score / session row. Historically 1:1 with `baseball_player_stats` + * (the legacy flat table); a caller may ALSO build one of these from a + * canonical `baseball_box_score_batting` / `_pitching` row (see the + * `normalizeBoxScore*Row` helpers below) — the shape is source-agnostic so the + * math in `loadPlayerMetrics` never has to know which table a row came from. + */ export interface BoxScoreRow { id: string; player_id: string; @@ -63,6 +106,13 @@ export interface BoxScoreRow { strikeouts?: number | null; hit_by_pitch?: number | null; sacrifice_flies?: number | null; + /** + * The real table this row's HITTING columns came from. Optional — omit for + * a legacy `baseball_player_stats` row (the default every existing caller + * gets); set to `'baseball_box_score_batting'` via + * {@link normalizeBoxScoreBattingRow} once a caller has migrated its fetch. + */ + hittingSourceTable?: string; // pitching innings_pitched?: number | null; @@ -71,12 +121,131 @@ export interface BoxScoreRow { strikeouts_thrown?: number | null; pitches_thrown?: number | null; strikes_thrown?: number | null; + /** Same idea as {@link hittingSourceTable}, for this row's PITCHING columns. */ + pitchingSourceTable?: string; - // sensors (optional) + // sensors (optional) — legacy-only scalars; superseded by `eventDerived` + // (see loadPlayerMetrics) whenever the elite event-grain layer has a value. exit_velocity?: number | null; pitch_velocity?: number | null; } +/** + * Normalize one `baseball_box_score_batting` row into the loader's row shape. + * `gameDate` should be the joined `baseball_games.game_date` (box-score rows + * don't carry a date themselves) — pass null if unknown; the loader treats a + * null `session_date` as "excluded from date-scoped aggregates" exactly as it + * already does for a legacy row missing `session_date`. + */ +export function normalizeBoxScoreBattingRow( + row: { + id: string; + player_id: string; + ab: number; + h: number; + doubles: number; + triples: number; + hr: number; + bb: number; + k: number; + hbp: number | null; + sf: number; + }, + gameDate: string | null, +): BoxScoreRow { + return { + id: row.id, + player_id: row.player_id, + stat_type: 'game', + session_date: gameDate, + at_bats: row.ab, + hits: row.h, + doubles: row.doubles, + triples: row.triples, + home_runs: row.hr, + walks: row.bb, + strikeouts: row.k, + hit_by_pitch: row.hbp, + sacrifice_flies: row.sf, + hittingSourceTable: 'baseball_box_score_batting', + }; +} + +/** Normalize one `baseball_box_score_pitching` row into the loader's row shape. */ +export function normalizeBoxScorePitchingRow( + row: { + id: string; + player_id: string; + ip: number; + bb: number; + k: number; + er: number; + pitch_count: number | null; + strikes: number | null; + }, + gameDate: string | null, +): BoxScoreRow { + return { + id: row.id, + player_id: row.player_id, + stat_type: 'game', + session_date: gameDate, + innings_pitched: row.ip, + earned_runs: row.er, + walks_allowed: row.bb, + strikeouts_thrown: row.k, + pitches_thrown: row.pitch_count, + strikes_thrown: row.strikes, + pitchingSourceTable: 'baseball_box_score_pitching', + }; +} + +/** One event-derived scalar (value + the sample it was built from). */ +export interface EventDerivedMetricValue { + value: number; + sampleSize: number; +} + +/** + * Per-player event-derived velocity inputs, sourced from + * `elite-stat-events.ts`'s aggregated `DerivedMetric[]` output (see + * {@link eventDerivedVelocityFromMetrics}). Every field is independently + * null-safe: a field left `null`/absent means no matching event-grain data + * exists for that player, and `loadPlayerMetrics` falls back to the legacy + * box-score scalar for THAT field only — never a fabricated value. + */ +export interface EventDerivedVelocityInput { + avgExitVelocity?: EventDerivedMetricValue | null; + maxExitVelocity?: EventDerivedMetricValue | null; + avgPitchVelocity?: EventDerivedMetricValue | null; + maxPitchVelocity?: EventDerivedMetricValue | null; +} + +/** + * Pull the velocity scalars `loadPlayerMetrics` wants out of + * `elite-stat-events.ts`'s per-player `DerivedMetric[]` bundle. Today the + * event layer only publishes AVERAGE exit/pitch velocity (`avg_exit_velocity` + * on the hitter bundle, `avg_velocity` on the pitcher bundle) — there is no + * `max_*` event metric yet, so those fields resolve to `null` here and + * `loadPlayerMetrics` keeps using the legacy box-score scalar for the max + * fields until the event layer grows one (honest, not a regression: there is + * currently no canonical max-velocity source at all). + */ +export function eventDerivedVelocityFromMetrics( + metrics: DerivedMetric[], +): EventDerivedVelocityInput { + const find = (metricKey: string): EventDerivedMetricValue | null => { + const m = metrics.find((x) => x.metricKey === metricKey && x.value != null && x.sampleSize > 0); + return m && m.value != null ? { value: m.value, sampleSize: m.sampleSize } : null; + }; + return { + avgExitVelocity: find('avg_exit_velocity'), + maxExitVelocity: null, + avgPitchVelocity: find('avg_velocity'), + maxPitchVelocity: null, + }; +} + // ----------------------------------------------------------------------------- // Recalibrated sample targets — baseball roster reality, NOT golf rounds. // ----------------------------------------------------------------------------- @@ -277,6 +446,19 @@ function ref( return { table, column, visibility, sample_n, label }; } +/** + * Honest source-table for a set of hitting/pitching rows: the first row's + * explicit tag (see {@link BoxScoreRow.hittingSourceTable}) if a caller has + * migrated its fetch onto the canonical box-score tables, else the legacy + * flat table every unmigrated caller still reads today. + */ +function hittingSourceTableFor(rows: BoxScoreRow[]): string { + return rows.find((r) => r.hittingSourceTable)?.hittingSourceTable ?? LEGACY_STAT_TABLE; +} +function pitchingSourceTableFor(rows: BoxScoreRow[]): string { + return rows.find((r) => r.pitchingSourceTable)?.pitchingSourceTable ?? LEGACY_STAT_TABLE; +} + /** stddev of a numeric series (population), or undefined for <2 points. */ function stddev(values: number[]): number | undefined { const xs = values.filter((v) => Number.isFinite(v)); @@ -299,11 +481,18 @@ function stddev(values: number[]): number | undefined { * scores: a player must not read team-level diagnostic insights unless a coach * explicitly marks the resulting insight player_visible. The action enforces the * strictest-source rule. + * + * `eventDerived` (optional, appended AFTER `nowIso` so existing callers are + * unaffected) lets a caller hand in exit/pitch velocity scalars already + * aggregated from `elite-stat-events.ts`; see {@link EventDerivedVelocityInput} + * and {@link eventDerivedVelocityFromMetrics}. Omit it (the default) to get + * EXACTLY today's legacy-scalar behavior. */ export function loadPlayerMetrics( playerId: string, rows: BoxScoreRow[], nowIso: string = new Date().toISOString(), + eventDerived?: EventDerivedVelocityInput | null, ): LoadedPlayerMetrics { const mine = rows.filter((r) => r.player_id === playerId); const hitRows = mine.filter(hasHitting); @@ -348,24 +537,28 @@ export function loadPlayerMetrics( const pa = ab + bb + hbp + sf; const n = hitRows.length; const dates = hitRows.map((r) => r.session_date).filter((d): d is string => !!d); + // The real table these hitting rows came from — legacy for every + // unmigrated caller today, `baseball_box_score_batting` once a caller + // normalizes onto the canonical layer (see normalizeBoxScoreBattingRow). + const hitTable = hittingSourceTableFor(hitRows); if (pa > 0) { - add('k_rate', k / pa, n, [ref('baseball_player_stats', 'strikeouts', 'staff_only', n, 'K rate over sessions')]); - add('bb_rate', bb / pa, n, [ref('baseball_player_stats', 'walks', 'staff_only', n, 'BB rate over sessions')]); + add('k_rate', k / pa, n, [ref(hitTable, 'strikeouts', 'staff_only', n, 'K rate over sessions')]); + add('bb_rate', bb / pa, n, [ref(hitTable, 'walks', 'staff_only', n, 'BB rate over sessions')]); } if (bb > 0) { - add('hitter_k_bb_ratio', k / bb, n, [ref('baseball_player_stats', 'strikeouts,walks', 'staff_only', n, 'Hitter K/BB')]); + add('hitter_k_bb_ratio', k / bb, n, [ref(hitTable, 'strikeouts,walks', 'staff_only', n, 'Hitter K/BB')]); } else if (k > 0) { // No walks at all → undefined ratio; surface as a thin, honest signal. - add('hitter_k_bb_ratio', null, n, [ref('baseball_player_stats', 'strikeouts,walks', 'staff_only', n, 'Hitter K/BB (no walks)')]); + add('hitter_k_bb_ratio', null, n, [ref(hitTable, 'strikeouts,walks', 'staff_only', n, 'Hitter K/BB (no walks)')]); } if (ab > 0) { - add('batting_avg', h / ab, n, [ref('baseball_player_stats', 'hits,at_bats', 'staff_only', n, 'Batting average')]); + add('batting_avg', h / ab, n, [ref(hitTable, 'hits,at_bats', 'staff_only', n, 'Batting average')]); const tb = h + doubles + 2 * triples + 3 * hr; - add('slugging_pct', tb / ab, n, [ref('baseball_player_stats', 'hits,doubles,triples,home_runs', 'staff_only', n, 'Slugging (proxy)')]); + add('slugging_pct', tb / ab, n, [ref(hitTable, 'hits,doubles,triples,home_runs', 'staff_only', n, 'Slugging (proxy)')]); } if (pa > 0) { - add('on_base_pct', (h + bb + hbp) / pa, n, [ref('baseball_player_stats', 'hits,walks,hit_by_pitch', 'staff_only', n, 'On-base (proxy)')]); + add('on_base_pct', (h + bb + hbp) / pa, n, [ref(hitTable, 'hits,walks,hit_by_pitch', 'staff_only', n, 'On-base (proxy)')]); } // Two-strike chase PROXY: per-game K-rate variance is the only box-score @@ -383,13 +576,18 @@ export function loadPlayerMetrics( 'two_strike_chase_pct', k / pa, n, - [ref('baseball_player_stats', 'strikeouts (per-game variance)', 'staff_only', n, 'Two-strike chase proxy')], + [ref(hitTable, 'strikeouts (per-game variance)', 'staff_only', n, 'Two-strike chase proxy')], sd != null ? { stddev: sd, stddev_scale: 12, round_dates: dates } : undefined, ); } } // ---- Game vs practice (avg delta) ---- + // Practice-context is a PERMANENT legacy carve-out (canonical layers have no + // practice-session concept yet — see docs/baseball/stats-architecture.md / + // legacy-stat-adapters.ts's module doc) — this metric always blends a + // (possibly-canonical) game side with an always-legacy practice side, so the + // ref honestly cites the legacy table regardless of the game rows' source. if (gameHitRows.length >= 1 && practiceHitRows.length >= 1) { const avg = (rs: BoxScoreRow[]) => { const ab = rs.reduce((s, r) => s + num(r.at_bats), 0); @@ -401,20 +599,38 @@ export function loadPlayerMetrics( if (g != null && p != null) { const n = Math.min(gameHitRows.length, practiceHitRows.length); add('game_practice_avg_delta', g - p, n, [ - ref('baseball_player_stats', 'hits,at_bats (game vs practice)', 'staff_only', n, 'Game − practice avg'), + ref(LEGACY_STAT_TABLE, 'hits,at_bats (game vs practice)', 'staff_only', n, 'Game − practice avg'), ]); } } // ---- Contact quality (sensor optional) ---- + // Event-derived FIRST (per design rule #4: event-grain data, when it + // exists, wins over a legacy scalar — never blended, never fabricated); + // fall back to the legacy `exit_velocity` box-score column per field + // independently when no matching event-grain aggregate was handed in. const evRows = hitRows.filter((r) => r.exit_velocity != null); - if (evRows.length > 0) { + const eventAvgEv = eventDerived?.avgExitVelocity ?? null; + if (eventAvgEv) { + add('avg_exit_velocity', eventAvgEv.value, eventAvgEv.sampleSize, [ + ref(EXIT_VELOCITY_EVENT_TABLE, 'exit_velocity', 'staff_only', eventAvgEv.sampleSize, 'Avg exit velocity (event-derived)'), + ]); + } else if (evRows.length > 0) { const evs = evRows.map((r) => num(r.exit_velocity)); const n = evRows.length; const dates = evRows.map((r) => r.session_date).filter((d): d is string => !!d); const sd = stddev(evs); - add('avg_exit_velocity', evs.reduce((a, b) => a + b, 0) / n, n, [ref('baseball_player_stats', 'exit_velocity', 'staff_only', n, 'Avg exit velocity')], sd != null ? { stddev: sd, stddev_scale: 8, round_dates: dates } : undefined); - add('max_exit_velocity', Math.max(...evs), n, [ref('baseball_player_stats', 'exit_velocity', 'staff_only', n, 'Max exit velocity')]); + add('avg_exit_velocity', evs.reduce((a, b) => a + b, 0) / n, n, [ref(hittingSourceTableFor(evRows), 'exit_velocity', 'staff_only', n, 'Avg exit velocity')], sd != null ? { stddev: sd, stddev_scale: 8, round_dates: dates } : undefined); + } + const eventMaxEv = eventDerived?.maxExitVelocity ?? null; + if (eventMaxEv) { + add('max_exit_velocity', eventMaxEv.value, eventMaxEv.sampleSize, [ + ref(EXIT_VELOCITY_EVENT_TABLE, 'exit_velocity', 'staff_only', eventMaxEv.sampleSize, 'Max exit velocity (event-derived)'), + ]); + } else if (evRows.length > 0) { + const evs = evRows.map((r) => num(r.exit_velocity)); + const n = evRows.length; + add('max_exit_velocity', Math.max(...evs), n, [ref(hittingSourceTableFor(evRows), 'exit_velocity', 'staff_only', n, 'Max exit velocity')]); } // ---- Pitching command + stuff ---- @@ -427,26 +643,40 @@ export function loadPlayerMetrics( const strikes = pitchRows.reduce((s, r) => s + num(r.strikes_thrown), 0); const n = pitchRows.length; const dates = pitchRows.map((r) => r.session_date).filter((d): d is string => !!d); + const pitchTable = pitchingSourceTableFor(pitchRows); if (bbA > 0) { - add('pitcher_k_bb_ratio', kT / bbA, n, [ref('baseball_player_stats', 'strikeouts_thrown,walks_allowed', 'staff_only', n, 'Pitcher K/BB')]); + add('pitcher_k_bb_ratio', kT / bbA, n, [ref(pitchTable, 'strikeouts_thrown,walks_allowed', 'staff_only', n, 'Pitcher K/BB')]); } else if (kT > 0) { - add('pitcher_k_bb_ratio', null, n, [ref('baseball_player_stats', 'strikeouts_thrown,walks_allowed', 'staff_only', n, 'Pitcher K/BB (no walks)')]); + add('pitcher_k_bb_ratio', null, n, [ref(pitchTable, 'strikeouts_thrown,walks_allowed', 'staff_only', n, 'Pitcher K/BB (no walks)')]); } if (ip > 0) { - add('walks_per_inning', bbA / ip, n, [ref('baseball_player_stats', 'walks_allowed,innings_pitched', 'staff_only', n, 'Walks/inning')]); - add('era', (er * 9) / ip, n, [ref('baseball_player_stats', 'earned_runs,innings_pitched', 'staff_only', n, 'ERA')]); + add('walks_per_inning', bbA / ip, n, [ref(pitchTable, 'walks_allowed,innings_pitched', 'staff_only', n, 'Walks/inning')]); + add('era', (er * 9) / ip, n, [ref(pitchTable, 'earned_runs,innings_pitched', 'staff_only', n, 'ERA')]); } if (pitches > 0) { - add('strike_pct', strikes / pitches, n, [ref('baseball_player_stats', 'strikes_thrown,pitches_thrown', 'staff_only', n, 'Strike %')]); + add('strike_pct', strikes / pitches, n, [ref(pitchTable, 'strikes_thrown,pitches_thrown', 'staff_only', n, 'Strike %')]); } const pvRows = pitchRows.filter((r) => r.pitch_velocity != null); - if (pvRows.length > 0) { + const eventAvgPv = eventDerived?.avgPitchVelocity ?? null; + if (eventAvgPv) { + add('avg_pitch_velocity', eventAvgPv.value, eventAvgPv.sampleSize, [ + ref(PITCH_VELOCITY_EVENT_TABLE, 'pitch_velocity', 'staff_only', eventAvgPv.sampleSize, 'Avg pitch velocity (event-derived)'), + ]); + } else if (pvRows.length > 0) { const pvs = pvRows.map((r) => num(r.pitch_velocity)); const sd = stddev(pvs); - add('avg_pitch_velocity', pvs.reduce((a, b) => a + b, 0) / pvs.length, pvRows.length, [ref('baseball_player_stats', 'pitch_velocity', 'staff_only', pvRows.length, 'Avg pitch velocity')], sd != null ? { stddev: sd, stddev_scale: 3, round_dates: dates } : undefined); - add('max_pitch_velocity', Math.max(...pvs), pvRows.length, [ref('baseball_player_stats', 'pitch_velocity', 'staff_only', pvRows.length, 'Max pitch velocity')]); + add('avg_pitch_velocity', pvs.reduce((a, b) => a + b, 0) / pvs.length, pvRows.length, [ref(pitchingSourceTableFor(pvRows), 'pitch_velocity', 'staff_only', pvRows.length, 'Avg pitch velocity')], sd != null ? { stddev: sd, stddev_scale: 3, round_dates: dates } : undefined); + } + const eventMaxPv = eventDerived?.maxPitchVelocity ?? null; + if (eventMaxPv) { + add('max_pitch_velocity', eventMaxPv.value, eventMaxPv.sampleSize, [ + ref(PITCH_VELOCITY_EVENT_TABLE, 'pitch_velocity', 'staff_only', eventMaxPv.sampleSize, 'Max pitch velocity (event-derived)'), + ]); + } else if (pvRows.length > 0) { + const pvs = pvRows.map((r) => num(r.pitch_velocity)); + add('max_pitch_velocity', Math.max(...pvs), pvRows.length, [ref(pitchingSourceTableFor(pvRows), 'pitch_velocity', 'staff_only', pvRows.length, 'Max pitch velocity')]); } // ---- Workload (rolling window = last 7 days of GAME pitching) ---- @@ -459,10 +689,10 @@ export function loadPlayerMetrics( // non-zero count exists (otherwise the workload generator falls back to // innings, which is always present for a pitcher). if (recentPitches > 0) { - add('rolling_pitch_count', recentPitches, recent.length, [ref('baseball_player_stats', 'pitches_thrown (7d)', 'staff_only', recent.length, 'Rolling 7-day pitches')]); + add('rolling_pitch_count', recentPitches, recent.length, [ref(pitchTable, 'pitches_thrown (7d)', 'staff_only', recent.length, 'Rolling 7-day pitches')]); } if (recentInnings > 0) { - add('rolling_innings', recentInnings, recent.length, [ref('baseball_player_stats', 'innings_pitched (7d)', 'staff_only', recent.length, 'Rolling 7-day innings')]); + add('rolling_innings', recentInnings, recent.length, [ref(pitchTable, 'innings_pitched (7d)', 'staff_only', recent.length, 'Rolling 7-day innings')]); } } } @@ -475,13 +705,20 @@ export function loadPlayerMetrics( }; } -/** Load metrics for many players at once from a shared row pool. */ +/** + * Load metrics for many players at once from a shared row pool. + * + * `eventDerivedByPlayer` (optional, appended AFTER `nowIso`) mirrors + * `loadPlayerMetrics`'s `eventDerived` param, keyed by player id. Omit it to + * get exactly today's legacy-scalar behavior for every player. + */ export function loadAllPlayerMetrics( playerIds: string[], rows: BoxScoreRow[], nowIso: string = new Date().toISOString(), + eventDerivedByPlayer?: Record, ): LoadedPlayerMetrics[] { - return playerIds.map((pid) => loadPlayerMetrics(pid, rows, nowIso)); + return playerIds.map((pid) => loadPlayerMetrics(pid, rows, nowIso, eventDerivedByPlayer?.[pid])); } // ----------------------------------------------------------------------------- diff --git a/src/lib/coachhelm/baseball/metrics/registry.role-visibility.test.ts b/src/lib/coachhelm/baseball/metrics/registry.role-visibility.test.ts index 559914475..426362391 100644 --- a/src/lib/coachhelm/baseball/metrics/registry.role-visibility.test.ts +++ b/src/lib/coachhelm/baseball/metrics/registry.role-visibility.test.ts @@ -71,7 +71,9 @@ const PLAYER_ELIGIBLE: BaseballMetricId[] = [ ]; function ref(visibility: BaseballInsightSourceRef['visibility']): BaseballInsightSourceRef { - return { table: 'baseball_player_stats', visibility, sample_n: 30 }; + // #379: fixture cites the canonical box-score table now that loaders.ts + // sources migrated callers from it — see stat-layer-manifest.ts. + return { table: 'baseball_box_score_batting', visibility, sample_n: 30 }; } function loadedMetric(metric: BaseballMetricId, value: number, vis: BaseballInsightSourceRef['visibility']): LoadedMetric { diff --git a/src/lib/coachhelm/baseball/metrics/registry.ts b/src/lib/coachhelm/baseball/metrics/registry.ts index 9c1f4dea7..9194fbb02 100644 --- a/src/lib/coachhelm/baseball/metrics/registry.ts +++ b/src/lib/coachhelm/baseball/metrics/registry.ts @@ -14,11 +14,19 @@ * pitch velocity is NOT an improvement; a drop in two-strike chase rate IS. * * Unlike golf (where a `golf_metrics` DB table is the runtime source of truth), - * baseball has no metrics table — these literals ARE the source of truth. They - * are derived from box-score / stat-event columns on `baseball_player_stats` - * and `baseball_player_aggregates`, NOT from pitch-level tracking data we do not - * have. Box-score derivation is honest but coarse (see loaders.ts confidence - * recalibration), so several metrics are explicitly labelled `proxy`. + * baseball has no metrics table — these literals ARE the source of truth. + * + * #379 reconciliation note: these ids are derived from box-score/season and + * event-grain columns (the CANONICAL read layers documented in + * `docs/baseball/stats-architecture.md`) whenever a caller has migrated its + * fetch onto them; `loaders.ts` transparently falls back to the legacy + * flat/aggregate layer's box-score-style columns for callers that haven't + * migrated yet (see `src/lib/baseball/stat-layer-manifest.ts` for the current + * migration backlog — `loaders.ts` itself stays grandfathered there until + * every caller feeds it canonical rows). Either way the derivation is honest + * but coarse (no true pitch-level tracking backs most of these ids yet — see + * loaders.ts's confidence recalibration), so several metrics are explicitly + * labelled `proxy`. */ export const BASEBALL_METRIC_IDS = [