diff --git a/src/lib/baseball/__tests__/action-baseline.test.ts b/src/lib/baseball/__tests__/action-baseline.test.ts index 7f669f54e..d9be23afc 100644 --- a/src/lib/baseball/__tests__/action-baseline.test.ts +++ b/src/lib/baseball/__tests__/action-baseline.test.ts @@ -25,11 +25,17 @@ import { // --------------------------------------------------------------------------- // A tiny in-memory Supabase-shaped stub. Each .from(table) returns a chainable // query that resolves to the rows seeded for that table. Only the methods the -// helper uses are implemented (select/eq/order/limit/maybeSingle). +// helper uses are implemented (select/eq/in/order/range/limit/maybeSingle). +// The canned sets stay far under the 1000-row page size, so the shared stat +// read's pagination loop (loadEngineStatRows -> fetchAllRowsResult) terminates +// after one page. // --------------------------------------------------------------------------- function makeClient(tables: { baseball_coach_insights?: Array<{ id: string; team_id: string; metadata: unknown }>; baseball_player_stats?: Array>; + baseball_games?: Array>; + baseball_box_score_batting?: Array>; + baseball_box_score_pitching?: Array>; }): BaselineClient { return { from(table: string) { @@ -43,12 +49,19 @@ function makeClient(tables: { rows = rows.filter((r) => r[col] === val); return api; }, + in(col: string, vals: unknown[]) { + rows = rows.filter((r) => vals.includes(r[col])); + 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 }); }, @@ -177,4 +190,45 @@ describe('buildActionOutcomeSeed — the unified ledger seed', () => { expect(seed.outcome_baseline_value).toBeNull(); expect(seed.outcome_verdict).toBe('insufficient_sample'); }); + + it('computes the baseline from CANONICAL box-score rows, dropping the same player\'s legacy GAME rows (#379 precedence)', async () => { + // Legacy game rows scream strikeouts (k_rate 1.0); canonical box-score rows + // for the same player show k_rate 3/27 = 1/9. Under the #379 rule the + // canonical layer owns the game context outright — the seed must equal the + // canonical-only figure, not a blend (blend would be 15/39 ≈ 0.385). + const legacyGameRows = Array.from({ length: 3 }).map((_, i) => ({ + id: `lg${i}`, + team_id: 'team-1', + player_id: 'p1', + stat_type: 'game', + session_date: `2026-04-0${i + 1}`, + at_bats: 4, + hits: 0, + strikeouts: 4, + walks: 0, + })); + const client = makeClient({ + baseball_player_stats: legacyGameRows, + baseball_games: [{ id: 'g1', team_id: 'team-1', game_date: '2026-05-20' }], + baseball_box_score_batting: Array.from({ length: 3 }).map((_, i) => ({ + id: `bb${i}`, + team_id: 'team-1', + game_id: 'g1', + player_id: 'p1', + ab: 8, + h: 3, + doubles: 0, + triples: 0, + hr: 0, + bb: 1, + k: 1, + hbp: 0, + sf: 0, + })), + }); + const seed = await buildActionOutcomeSeed(client, 'team-1', 'p1', 'k_rate'); + expect(seed.outcome_metric).toBe('k_rate'); + expect(seed.outcome_baseline_value).toBeCloseTo(3 / 27, 5); + expect(seed.outcome_verdict).toBeNull(); + }); }); diff --git a/src/lib/baseball/__tests__/ai-policy-enforcement.test.ts b/src/lib/baseball/__tests__/ai-policy-enforcement.test.ts index 73ca72cba..83df305f6 100644 --- a/src/lib/baseball/__tests__/ai-policy-enforcement.test.ts +++ b/src/lib/baseball/__tests__/ai-policy-enforcement.test.ts @@ -71,7 +71,8 @@ function candidate(overrides: Partial = {}): BaseballI confidence_reason: 'Adequate sample, low variance', }, source_refs: [ - { table: 'baseball_player_stats', column: 'two_strike_chase_pct', sample_n: 40, visibility: 'staff_only' }, + // #379: fixture mirrors production — loaders cite the canonical table. + { table: 'baseball_box_score_batting', column: 'two_strike_chase_pct', sample_n: 40, visibility: 'staff_only' }, ], }, ...overrides, diff --git a/src/lib/baseball/__tests__/engine-run-helm-lifting.test.ts b/src/lib/baseball/__tests__/engine-run-helm-lifting.test.ts index 84c366e16..1f24b3554 100644 --- a/src/lib/baseball/__tests__/engine-run-helm-lifting.test.ts +++ b/src/lib/baseball/__tests__/engine-run-helm-lifting.test.ts @@ -135,3 +135,21 @@ describe('runBaseballEngineCore — lift data reads helm_lifting_* (unified), no expect(player!.metrics.lift_rpe_avg?.value).toBe(8); }); }); + +// #811 residual: BaseballV10EngineInputs.now (consumed by importQualityGenerator's +// 14-day recency window) must carry the SAME deterministic nowIso the run was +// invoked with — not the real wall clock — so a fixed-clock engine run stays +// fixed-clock end-to-end, matching the threading #811 already pinned for +// loadAllPlayerMetrics/mergeV10PlayerMetrics/mergeEventPlayerMetrics above. +describe('runBaseballEngineCore — threads its nowIso into BaseballV10EngineInputs.now (#811 residual)', () => { + it('passes the run nowIso through to engineInputs.now, not the real wall clock', async () => { + capturedInputs = null; + const tables = baseTables(); + const fake = createFakeSupabase({ user: { id: 'user-1' }, tables }); + + const result = await runEngine(fake); + expect(result.success).toBe(true); + expect(capturedInputs).not.toBeNull(); + expect(capturedInputs!.now).toBe(NOW); + }); +}); diff --git a/src/lib/baseball/__tests__/engine-stat-rows.test.ts b/src/lib/baseball/__tests__/engine-stat-rows.test.ts new file mode 100644 index 000000000..d7972c2ea --- /dev/null +++ b/src/lib/baseball/__tests__/engine-stat-rows.test.ts @@ -0,0 +1,213 @@ +// ============================================================================= +// Unit tests for the shared engine stat-row read (#379 Phase 4b). +// +// loadEngineStatRows is the ONE place the CoachHelm engine (engine-run.ts, +// outcome-sweep.ts, action-baseline.ts) reads per-session stat rows. These pin +// the precedence rule the three callers now share: +// +// 1. A player with ANY canonical box-score row gets game-context rows +// EXCLUSIVELY from baseball_box_score_batting/_pitching (normalized onto +// the loader shape, source-table tagged); their legacy stat_type='game' +// rows leave the pool — never blended, so a game seeded into BOTH layers +// (the #827 demo seed does exactly that) is never counted twice. +// 2. Legacy PRACTICE/other rows always survive (practice carve-out — the +// canonical layers have no practice-session concept yet). +// 3. A player with zero canonical rows keeps their full legacy history +// (legacy fallback — never a regression to "engine sees nothing"). +// 4. Canonical-side read failures degrade ALL-OR-NOTHING back to the legacy +// pool; a legacy read failure surfaces as the callers' hard error. +// ============================================================================= + +import { describe, it, expect } from 'vitest'; +import { loadEngineStatRows } from '@/lib/baseball/coachhelm/engine-stat-rows'; + +const TEAM = 'team-1'; + +type Row = Record; + +/** + * Minimal chainable Supabase fake (same shape as the outcome-sweep test's): + * every .from(table) returns a thenable builder resolving that table's canned + * rows; per-table errors can be injected to exercise the degrade paths. The + * canned sets are far under the 1000-row page size, so fetchAllRowsResult's + * pagination loop terminates after one page. + */ +function makeClient(tables: Record, errorTables: Set = new Set()) { + return { + from(table: string) { + const data = tables[table] ?? []; + const fail = errorTables.has(table); + const builder: Record = { + select: () => builder, + eq: () => builder, + in: () => builder, + order: () => builder, + range: () => builder, + then(resolve: (v: { data: unknown; error: unknown }) => unknown) { + return resolve( + fail ? { data: null, error: { message: `${table} read failed` } } : { data, error: null }, + ); + }, + }; + return builder; + }, + }; +} + +const legacyGame = (id: string, playerId: string, over: Row = {}): Row => ({ + id, + player_id: playerId, + stat_type: 'game', + session_date: '2026-03-01', + at_bats: 4, + hits: 1, + strikeouts: 2, + walks: 0, + ...over, +}); + +const legacyPractice = (id: string, playerId: string): Row => ({ + id, + player_id: playerId, + stat_type: 'practice', + session_date: '2026-03-02', + at_bats: 10, + hits: 5, + strikeouts: 1, + walks: 0, +}); + +const boxBatting = (id: string, playerId: string, gameId: string): Row => ({ + id, + game_id: gameId, + player_id: playerId, + ab: 5, + h: 2, + doubles: 1, + triples: 0, + hr: 0, + bb: 1, + k: 1, + hbp: 0, + sf: 0, +}); + +const boxPitching = (id: string, playerId: string, gameId: string): Row => ({ + id, + game_id: gameId, + player_id: playerId, + ip: 5, + er: 2, + bb: 1, + k: 6, + pitch_count: 78, + strikes: 50, +}); + +describe('loadEngineStatRows — #379 precedence rule', () => { + it('replaces a box-score player\'s legacy GAME rows with normalized canonical rows (never blended)', async () => { + const client = makeClient({ + baseball_player_stats: [legacyGame('lg-1', 'p1'), legacyGame('lg-2', 'p1')], + baseball_games: [{ id: 'g1', game_date: '2026-04-01' }], + baseball_box_score_batting: [boxBatting('bb-1', 'p1', 'g1')], + }); + + const { data, error } = await loadEngineStatRows(client, TEAM, ['p1']); + expect(error).toBeNull(); + expect(data).not.toBeNull(); + + // Only the canonical row survives for p1's game context. + expect(data).toHaveLength(1); + const row = data![0]!; + expect(row.id).toBe('bb-1'); + expect(row.stat_type).toBe('game'); + // The joined game date became the row's session_date. + expect(row.session_date).toBe('2026-04-01'); + // Source-table provenance is tagged so loader source_refs cite the REAL table. + expect(row.hittingSourceTable).toBe('baseball_box_score_batting'); + }); + + it('keeps legacy PRACTICE rows for a box-score player (practice carve-out)', async () => { + const client = makeClient({ + baseball_player_stats: [legacyGame('lg-1', 'p1'), legacyPractice('lp-1', 'p1')], + baseball_games: [{ id: 'g1', game_date: '2026-04-01' }], + baseball_box_score_pitching: [boxPitching('bp-1', 'p1', 'g1')], + }); + + const { data } = await loadEngineStatRows(client, TEAM, ['p1']); + const ids = data!.map((r) => r.id).sort(); + expect(ids).toEqual(['bp-1', 'lp-1']); // canonical game + legacy practice; legacy game dropped + expect(data!.find((r) => r.id === 'bp-1')?.pitchingSourceTable).toBe('baseball_box_score_pitching'); + }); + + it('keeps the FULL legacy history for a player with zero canonical rows (legacy fallback), alongside a canonical teammate', async () => { + const client = makeClient({ + baseball_player_stats: [ + legacyGame('lg-p1', 'p1'), + legacyGame('lg-p2', 'p2'), + legacyPractice('lp-p2', 'p2'), + ], + baseball_games: [{ id: 'g1', game_date: '2026-04-01' }], + baseball_box_score_batting: [boxBatting('bb-p1', 'p1', 'g1')], + }); + + const { data } = await loadEngineStatRows(client, TEAM, ['p1', 'p2']); + const ids = data!.map((r) => r.id).sort(); + // p1: canonical only. p2: untouched legacy game + practice rows. + expect(ids).toEqual(['bb-p1', 'lg-p2', 'lp-p2']); + }); + + it('resolves session_date null (excluded from date-scoped windows, still counted) when the game id is unknown', async () => { + const client = makeClient({ + baseball_player_stats: [], + baseball_games: [], // no game rows resolvable + baseball_box_score_batting: [boxBatting('bb-1', 'p1', 'g-missing')], + }); + + const { data } = await loadEngineStatRows(client, TEAM, ['p1']); + expect(data).toHaveLength(1); + expect(data![0]!.session_date).toBeNull(); + }); + + it('degrades ALL-OR-NOTHING to the legacy pool when any canonical-side read fails', async () => { + const client = makeClient( + { + baseball_player_stats: [legacyGame('lg-1', 'p1')], + baseball_games: [{ id: 'g1', game_date: '2026-04-01' }], + baseball_box_score_batting: [boxBatting('bb-1', 'p1', 'g1')], + }, + new Set(['baseball_box_score_pitching']), // one canonical read fails + ); + + const { data, error } = await loadEngineStatRows(client, TEAM, ['p1']); + expect(error).toBeNull(); + // Pre-migration behavior exactly: the legacy row, no canonical rows — a + // partial blend (batting ok, pitching failed) could double count. + expect(data!.map((r) => r.id)).toEqual(['lg-1']); + }); + + it('surfaces a legacy read failure as the hard error (callers\' pre-existing failure path)', async () => { + const client = makeClient( + { baseball_box_score_batting: [boxBatting('bb-1', 'p1', 'g1')] }, + new Set(['baseball_player_stats']), + ); + + const { data, error } = await loadEngineStatRows(client, TEAM, ['p1']); + expect(data).toBeNull(); + expect(error).not.toBeNull(); + }); + + it('returns an empty pool without querying when no player ids are given', async () => { + let queried = false; + const client = { + from() { + queried = true; + throw new Error('should not query'); + }, + }; + const { data, error } = await loadEngineStatRows(client, TEAM, []); + expect(data).toEqual([]); + expect(error).toBeNull(); + expect(queried).toBe(false); + }); +}); 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 f226ed998..fc06dedde 100644 --- a/src/lib/baseball/__tests__/outcome-sweep-insight-resolve.test.ts +++ b/src/lib/baseball/__tests__/outcome-sweep-insight-resolve.test.ts @@ -33,6 +33,9 @@ function makeClient(opts: { stats: Array>; signals: Array>; updates: UpdateCall[]; + /** Canonical layer (#379) — omitted -> empty, i.e. the legacy-fallback path. */ + games?: Array>; + boxBatting?: Array>; }) { function from(table: string) { const state: { isUpdate: boolean; payload: Record | null } = { @@ -46,7 +49,11 @@ function makeClient(opts: { ? opts.stats : table === 'baseball_signals' ? opts.signals - : []; + : table === 'baseball_games' + ? (opts.games ?? []) + : table === 'baseball_box_score_batting' + ? (opts.boxBatting ?? []) + : []; const builder: Record = { select: () => builder, eq: () => builder, @@ -118,6 +125,49 @@ describe('sweepActionOutcomes — improved action resolves its source insight', expect(insightUpdate?.payload).not.toHaveProperty('status'); }); + it('measures the after-window from CANONICAL box-score rows, never blended with the same player\'s legacy game rows (#379)', async () => { + const actions = [ + { + id: 'act-3', + player_id: 'p3', + created_at: '2026-01-01T00:00:00.000Z', + outcome_metric: 'k_rate', + outcome_baseline_value: 0.4, + outcome_observed_value: null, + signal_id: 'sig-3', + status: 'open', + }, + ]; + // Legacy game rows in the after-window scream strikeouts (k_rate 1.0). If + // they were retained/blended alongside the canonical rows, the observed + // value would be (2+40)/(24+40) ≈ 0.656 -> 'regressed'. Canonical-only is + // 2/24 ≈ 0.083 -> 'improved'. The verdict proves which pool was measured. + const stats = [ + { id: 'lg1', player_id: 'p3', stat_type: 'game', session_date: '2026-03-02', at_bats: 20, walks: 0, strikeouts: 20, hits: 0 }, + { id: 'lg2', player_id: 'p3', stat_type: 'game', session_date: '2026-03-06', at_bats: 20, walks: 0, strikeouts: 20, hits: 0 }, + ]; + const games = [ + { id: 'g1', game_date: '2026-03-01' }, + { id: 'g2', game_date: '2026-03-05' }, + ]; + const boxBatting = [ + { id: 'bb1', game_id: 'g1', player_id: 'p3', ab: 10, h: 4, doubles: 0, triples: 0, hr: 0, bb: 2, k: 1, hbp: 0, sf: 0 }, + { id: 'bb2', game_id: 'g2', player_id: 'p3', ab: 10, h: 3, doubles: 0, triples: 0, hr: 0, bb: 2, k: 1, hbp: 0, sf: 0 }, + ]; + const signals = [{ id: 'sig-3', dedupe_key: 'two_strike_chase:p3' }]; + const updates: UpdateCall[] = []; + const client = makeClient({ actions, stats, signals, updates, games, boxBatting }); + + const res = await sweepActionOutcomes(client, TEAM); + expect(res.measured).toBe(1); + + const actionUpdate = updates.find((u) => u.table === 'baseball_actions'); + expect(actionUpdate?.payload.outcome_verdict).toBe('improved'); + expect(actionUpdate?.payload.outcome_observed_value).toBeCloseTo(2 / 24, 5); + // After-window sample counts the two canonical box-score sessions. + expect(actionUpdate?.payload.outcome_sample_n).toBe(2); + }); + it('resolves nothing when the verdict is not improved', async () => { // Same action but the after-window shows k_rate climbing (regressed) — no // insight resolution should fire. diff --git a/src/lib/baseball/__tests__/signal-from-insight.test.ts b/src/lib/baseball/__tests__/signal-from-insight.test.ts index e79354153..f529ef689 100644 --- a/src/lib/baseball/__tests__/signal-from-insight.test.ts +++ b/src/lib/baseball/__tests__/signal-from-insight.test.ts @@ -52,16 +52,18 @@ function candidate( value: 41, unit: 'percent', sample_n: sampleN, - source: 'baseball_player_stats.strikeouts', + source: 'baseball_box_score_batting.k', }, ], recommended_action: 'Two-strike approach reps', confidence_reason: 'Moderate sample, single-metric.', }, source_refs: [ + // #379: fixtures mirror production provenance — loaders now cite the + // canonical box-score table for game-context refs. { - table: 'baseball_player_stats', - column: 'strikeouts', + table: 'baseball_box_score_batting', + column: 'k', sample_n: sampleN, confidence: 0.72, label: 'Last 12 games (box score)', @@ -142,7 +144,7 @@ describe('signalFromInsight — provenance + traceability', () => { // citation to the insight is first, then the underlying table ref. expect(refs[0]?.source_table).toBe('baseball_coach_insights'); expect(refs[0]?.source_id).toBe('insight-123'); - expect(refs.some((r) => r.source_table === 'baseball_player_stats')).toBe(true); + expect(refs.some((r) => r.source_table === 'baseball_box_score_batting')).toBe(true); // never empty — every real signal is source-backed. expect(refs.length).toBeGreaterThan(0); }); diff --git a/src/lib/baseball/coachhelm/action-baseline.ts b/src/lib/baseball/coachhelm/action-baseline.ts index c76ba331d..ab87c3e0f 100644 --- a/src/lib/baseball/coachhelm/action-baseline.ts +++ b/src/lib/baseball/coachhelm/action-baseline.ts @@ -52,14 +52,12 @@ import 'server-only'; // * No schema change: outcome columns come from migration 20260624000210. // ============================================================================= -import { - loadPlayerMetrics, - type BoxScoreRow, -} from '@/lib/coachhelm/baseball/loaders'; +import { loadPlayerMetrics } from '@/lib/coachhelm/baseball/loaders'; import { isBaseballMetricId, type BaseballMetricId, } from '@/lib/coachhelm/baseball/metrics/registry'; +import { loadEngineStatRows } from '@/lib/baseball/coachhelm/engine-stat-rows'; import { parseSignalSourceRefs } from '@/lib/types/baseball-signals'; import type { BaseballActionOutcomeVerdict } from '@/lib/types/baseball-coachhelm-v10'; @@ -71,12 +69,6 @@ export type BaselineClient = { from: (table: string) => any; }; -// The same stat projection the sweep + insight path read, kept in lockstep so a -// baseline captured at conversion is computed identically to the observed value -// the sweep later measures (apples-to-apples did-it-move). -const STAT_SELECT = - 'id, player_id, stat_type, session_date, at_bats, hits, doubles, triples, home_runs, walks, strikeouts, innings_pitched, earned_runs, walks_allowed, strikeouts_thrown, exit_velocity, pitch_velocity'; - // ----------------------------------------------------------------------------- // Generator / signal_type -> the primary per-player metric it diagnoses. // @@ -212,27 +204,16 @@ export async function buildActionOutcomeSeed( ): Promise { if (!playerId || !targetMetric) return UNMEASURABLE_SEED; - // SINGLE-PLAYER read — bounded by design, NOT paginated. A baseline is the one - // subject player's CURRENT value at conversion time; even a multi-season career - // is far under the PostgREST 1000-row cap (one row per game/session), so the - // most-recent page is the full relevant history. We order newest-first and cap - // at the true server max (1000) — the prior `.limit(2000)` was misleading since - // PostgREST silently caps every response at 1000 regardless. If per-player rows - // ever realistically approach 1000, switch this to fetchAllRowsResult like the - // multi-player sweep read. - const { data: statRows } = await supabase - .from('baseball_player_stats') - .select(STAT_SELECT) - .eq('team_id', teamId) - .eq('player_id', playerId) - .order('session_date', { ascending: false }) - .order('id', { ascending: false }) - .limit(1000); - - const loaded = loadPlayerMetrics( - playerId, - (statRows ?? []) as unknown as BoxScoreRow[], - ); + // #379 Phase 4b: the baseline is computed over the SAME reconciled stat-row + // pool the sweep + engine run read (canonical box-score rows preferred, + // legacy fallback, practice carve-out — see loadEngineStatRows), so a + // baseline captured at conversion is computed identically to the observed + // value the sweep later measures (apples-to-apples did-it-move). The shared + // read paginates past the PostgREST 1000-row cap, replacing this path's old + // single-page `.limit(1000)` read. + const { data: statRows } = await loadEngineStatRows(supabase, teamId, [playerId]); + + const loaded = loadPlayerMetrics(playerId, statRows ?? []); const baselineValue = loaded.metrics[targetMetric]?.value ?? null; return { diff --git a/src/lib/baseball/coachhelm/engine-run.ts b/src/lib/baseball/coachhelm/engine-run.ts index 3f5ff80d9..e45e25a55 100644 --- a/src/lib/baseball/coachhelm/engine-run.ts +++ b/src/lib/baseball/coachhelm/engine-run.ts @@ -63,6 +63,7 @@ import { type BoxScoreRow, type ScheduleEventRow, } from '@/lib/coachhelm/baseball/loaders'; +import { loadEngineStatRows } from '@/lib/baseball/coachhelm/engine-stat-rows'; import { mergeV10PlayerMetrics, type ReadinessRow, @@ -303,25 +304,13 @@ export async function runBaseballEngineCore( return emptyResult({ success: true, signalsExpired: expired }); } - // Box scores: a full-roster season can exceed the PostgREST 1000-row cap, so the - // old `.limit(2000)` silently truncated to the most-recent 1000 (PostgREST caps - // every response at max_rows = 1000) — better than the unordered event reads, but - // still a partial season. We paginate the full set, keeping newest-first ordering - // with a stable tiebreak on `id` so page boundaries are deterministic when many - // rows share a session_date. - const { data: statRows, error: statsErr } = await fetchAllRowsResult( - (from, to) => - db - .from('baseball_player_stats') - .select( - 'id, player_id, stat_type, session_date, at_bats, hits, doubles, triples, home_runs, walks, strikeouts, innings_pitched, earned_runs, walks_allowed, strikeouts_thrown, exit_velocity, pitch_velocity', - ) - .eq('team_id', teamId) - .in('player_id', playerIds) - .order('session_date', { ascending: false }) - .order('id', { ascending: true }) - .range(from, to), - ); + // Box scores (#379 Phase 4b): the shared engine stat-row read — canonical + // baseball_box_score_batting/_pitching rows (normalized onto the loader + // shape, source-table tagged) reconciled over the legacy flat rows per the + // precedence rule, with the same pagination past the PostgREST 1000-row cap + // and stable ordering the direct read used. Identical to the sweep + + // baseline reads, keeping baseline/observed apples-to-apples. + const { data: statRows, error: statsErr } = await loadEngineStatRows(db, teamId, playerIds); if (statsErr) return emptyResult({ error: 'Could not load box-score stats.' }); const horizonIso = new Date(Date.parse(nowIso) + EVENT_LOOKAHEAD_DAYS * 86400_000).toISOString(); @@ -553,7 +542,7 @@ export async function runBaseballEngineCore( // which silently aged out as real time passed with zero code changes. const boxScorePlayers = loadAllPlayerMetrics( playerIds, - (statRows ?? []) as unknown as BoxScoreRow[], + (statRows ?? []) as BoxScoreRow[], nowIso, ); const players = boxScorePlayers.map((p) => @@ -684,6 +673,12 @@ export async function runBaseballEngineCore( const engineInputs: BaseballV10EngineInputs = { players, events, + // #811 residual: importQualityGenerator's 14-day recency window now reads + // this instead of the real wall clock, so a deterministic engine run + // (fixed nowIso, seeded import runs) never silently drifts as real time + // carries the window past a test's fixture dates — the same class of bug + // #811 already fixed for the box-score/lift/readiness/catching loaders. + now: nowIso, importRuns: (importRunRows ?? []) as ImportRunSummary[], videoCoverage, defaultRankingContext, diff --git a/src/lib/baseball/coachhelm/engine-stat-rows.ts b/src/lib/baseball/coachhelm/engine-stat-rows.ts new file mode 100644 index 000000000..c9a1ab364 --- /dev/null +++ b/src/lib/baseball/coachhelm/engine-stat-rows.ts @@ -0,0 +1,208 @@ +import 'server-only'; + +// ============================================================================= +// src/lib/baseball/coachhelm/engine-stat-rows.ts +// +// #379 Phase 4b — the ONE place the CoachHelm baseball engine reads per-session +// stat rows. Centralizes the three engine callers' (engine-run.ts, +// outcome-sweep.ts, action-baseline.ts) previously-duplicated direct +// `baseball_player_stats` reads and reconciles them onto the CANONICAL +// box-score layer per the #379 precedence rule. This mirrors +// `src/lib/baseball/read-models/legacy-stat-adapters.ts`, but at ROW grain — +// the engine's loaders need per-session rows (dispersion, rolling windows, +// after-window filtering), not the adapter's pre-reconciled season aggregates: +// +// 1. GAME context — a player with ANY canonical box-score row +// (`baseball_box_score_batting` / `_pitching`) gets their game-context +// rows EXCLUSIVELY from the canonical layer, normalized via loaders.ts's +// `normalizeBoxScoreBattingRow` / `normalizeBoxScorePitchingRow` so every +// source_ref the loaders emit cites the REAL table. That player's legacy +// `stat_type='game'` rows are dropped from the pool — never blended, so +// the same game can never be counted twice (the #827 demo seed +// intentionally writes BOTH layers for the same games). +// 2. PRACTICE carve-out — legacy non-game rows (practice/other) always pass +// through unchanged: the canonical layers have no practice-session +// concept yet (same permanent carve-out as the shared adapter). +// 3. LEGACY FALLBACK — a player with zero canonical rows keeps their full +// legacy row set, so a team that never re-imported through the box-score +// pipeline does not regress from "engine sees old numbers" to "engine +// sees nothing". +// +// Known, accepted caveat (documented, not silent): a legacy GAME row's +// `exit_velocity` / `pitch_velocity` sensor scalars are dropped along with the +// row for box-score players (rule 1) — the canonical box-score tables carry no +// velocity columns, and per #379 design rule 4 the canonical velocity source +// is the elite event layer (loaders.ts `eventDerived`), never a legacy scalar. +// Practice-row sensor scalars still flow through the carve-out. +// +// Error semantics (parity with the pre-#379 callers): +// * Legacy read failure -> { data: null, error } (engine-run treats this as +// a hard failure exactly as before; sweep/baseline degrade to empty). +// * ANY canonical-side read failure (games/batting/pitching) -> all-or- +// nothing fallback to the legacy rows alone — i.e. exactly the +// pre-migration behavior, never a partial blend that could double count a +// two-way player whose batting read succeeded but pitching read failed. +// +// This module is the engine's grandfathered legacy-fallback read (see +// src/lib/baseball/stat-layer-manifest.ts) — it retires only when Phase 5 +// retires the legacy writer and the fallback tier has nothing left to serve. +// ============================================================================= + +import { + normalizeBoxScoreBattingRow, + normalizeBoxScorePitchingRow, + type BoxScoreRow, +} from '@/lib/coachhelm/baseball/loaders'; +import { fetchAllRowsResult } from '@/lib/supabase/fetch-all-rows'; + +// 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 the three callers. RLS still applies to the RLS client. +export type EngineStatRowsClient = { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + from: (table: string) => any; +}; + +/** + * The legacy stat projection all three engine callers historically read — + * kept in lockstep with the loaders' `BoxScoreRow` shape so baseline capture, + * the outcome sweep, and the engine run all measure identically + * (apples-to-apples did-it-move). + */ +const LEGACY_STAT_SELECT = + 'id, player_id, stat_type, session_date, at_bats, hits, doubles, triples, home_runs, walks, strikeouts, innings_pitched, earned_runs, walks_allowed, strikeouts_thrown, exit_velocity, pitch_velocity'; + +/** Canonical box-score projections — exactly what the normalizers consume. */ +const BOX_BATTING_SELECT = 'id, game_id, player_id, ab, h, doubles, triples, hr, bb, k, hbp, sf'; +const BOX_PITCHING_SELECT = 'id, game_id, player_id, ip, er, bb, k, pitch_count, strikes'; + +interface BoxBattingRow { + id: string; + game_id: string; + player_id: string; + ab: number; + h: number; + doubles: number; + triples: number; + hr: number; + bb: number; + k: number; + hbp: number | null; + sf: number; +} + +interface BoxPitchingRow { + id: string; + game_id: string; + player_id: string; + ip: number; + er: number; + bb: number; + k: number; + pitch_count: number | null; + strikes: number | null; +} + +interface GameRow { + id: string; + game_date: string | null; +} + +const isLegacyGameRow = (r: BoxScoreRow): boolean => (r.stat_type ?? '').toLowerCase() === 'game'; + +/** + * Load the reconciled per-session stat-row pool for the given players. + * + * Every read is team-scoped and paginated past the PostgREST 1000-row cap with + * a stable order (the legacy read keeps its original newest-first + * session_date + id-asc tiebreak; canonical reads page on id) so page + * boundaries are deterministic. Box-score rows carry no date of their own, so + * `session_date` comes from the joined `baseball_games.game_date`; a row whose + * game has no resolvable date gets `session_date: null` and is excluded from + * date-scoped aggregates (rolling windows, after-window filters) exactly as a + * legacy row missing `session_date` always was — counted in totals, never + * silently placed in time. + */ +export async function loadEngineStatRows( + db: EngineStatRowsClient, + teamId: string, + playerIds: string[], +): Promise<{ data: BoxScoreRow[] | null; error: { message: string; code?: string | null } | null }> { + if (playerIds.length === 0) return { data: [], error: null }; + + const [legacyRes, gamesRes, battingRes, pitchingRes] = await Promise.all([ + fetchAllRowsResult((from, to) => + db + .from('baseball_player_stats') + .select(LEGACY_STAT_SELECT) + .eq('team_id', teamId) + .in('player_id', playerIds) + .order('session_date', { ascending: false }) + .order('id', { ascending: true }) + .range(from, to), + ), + fetchAllRowsResult((from, to) => + db + .from('baseball_games') + .select('id, game_date') + .eq('team_id', teamId) + .order('id', { ascending: true }) + .range(from, to), + ), + fetchAllRowsResult((from, to) => + db + .from('baseball_box_score_batting') + .select(BOX_BATTING_SELECT) + .eq('team_id', teamId) + .in('player_id', playerIds) + .order('id', { ascending: true }) + .range(from, to), + ), + fetchAllRowsResult((from, to) => + db + .from('baseball_box_score_pitching') + .select(BOX_PITCHING_SELECT) + .eq('team_id', teamId) + .in('player_id', playerIds) + .order('id', { ascending: true }) + .range(from, to), + ), + ]); + + // Legacy read failure is the callers' pre-existing hard-failure path. + if (legacyRes.error) return { data: null, error: legacyRes.error }; + const legacyRows = (legacyRes.data ?? []) as BoxScoreRow[]; + + // Canonical-side failure degrades ALL-OR-NOTHING to the legacy pool — the + // exact pre-migration behavior. A partial blend (batting ok, pitching + // failed) could double count a two-way player, so we never take one side. + if (gamesRes.error || battingRes.error || pitchingRes.error) { + return { data: legacyRows, error: null }; + } + + const gameDateById = new Map(); + for (const g of (gamesRes.data ?? []) as GameRow[]) { + gameDateById.set(g.id, g.game_date ?? null); + } + + const normalized: BoxScoreRow[] = [ + ...((battingRes.data ?? []) as BoxBattingRow[]).map((r) => + normalizeBoxScoreBattingRow(r, gameDateById.get(r.game_id) ?? null), + ), + ...((pitchingRes.data ?? []) as BoxPitchingRow[]).map((r) => + normalizeBoxScorePitchingRow(r, gameDateById.get(r.game_id) ?? null), + ), + ]; + if (normalized.length === 0) return { data: legacyRows, error: null }; + + // Precedence rule 1: canonical game rows own the game context for any player + // who has them; that player's legacy game rows leave the pool. Rules 2 + 3: + // everything else (practice/other rows; whole rosters with no canonical + // history) passes through untouched. + const boxScorePlayerIds = new Set(normalized.map((r) => r.player_id)); + const retainedLegacy = legacyRows.filter( + (r) => !boxScorePlayerIds.has(r.player_id) || !isLegacyGameRow(r), + ); + + return { data: [...normalized, ...retainedLegacy], error: null }; +} diff --git a/src/lib/baseball/coachhelm/outcome-sweep.ts b/src/lib/baseball/coachhelm/outcome-sweep.ts index fbc0ec623..884094bde 100644 --- a/src/lib/baseball/coachhelm/outcome-sweep.ts +++ b/src/lib/baseball/coachhelm/outcome-sweep.ts @@ -42,7 +42,7 @@ import { isBaseballMetricId, type BaseballMetricId, } from '@/lib/coachhelm/baseball/metrics/registry'; -import { fetchAllRowsResult } from '@/lib/supabase/fetch-all-rows'; +import { loadEngineStatRows } from '@/lib/baseball/coachhelm/engine-stat-rows'; import type { BaseballActionOutcomeVerdict } from '@/lib/types/baseball-coachhelm-v10'; // A minimally-typed client so the sweep runs against either the RLS server @@ -63,11 +63,6 @@ const MIN_AFTER_SAMPLE = 2; // near zero never flips the verdict. const MOVEMENT_DEADBAND = 0.005; -// Stat columns the engine loaders need (kept in lockstep with the loaders' read -// shape so postgame, the manual sweep, and the cron all measure identically). -const STAT_SELECT = - 'id, player_id, stat_type, session_date, at_bats, hits, doubles, triples, home_runs, walks, strikeouts, innings_pitched, earned_runs, walks_allowed, strikeouts_thrown, exit_velocity, pitch_velocity'; - export interface OutcomeSweepStats { /** Open metric-targeted actions considered (across all statuses swept). */ evaluated: number; @@ -148,24 +143,15 @@ export async function sweepActionOutcomes( if (todo.length === 0) return { evaluated: actions.length, measured: 0 }; const playerIds = Array.from(new Set(todo.map((a) => a.player_id!).filter(Boolean))); - // A busy multi-player pool (every open action's subject, season-wide) easily - // exceeds the PostgREST 1000-row cap. The old `.limit(4000)` was silently - // capped to 1000 (PostgREST max_rows), so older sessions for the back of the - // pool fell out of the after-window and their actions read 'too_early' forever. - // Paginate the full set with a stable newest-first order (session_date desc, - // id asc tiebreak) so page boundaries are deterministic — identical to the - // engine-run box-score read, keeping baseline/observed apples-to-apples. - const { data: statRows } = await fetchAllRowsResult((from, to) => - supabase - .from('baseball_player_stats') - .select(STAT_SELECT) - .eq('team_id', teamId) - .in('player_id', playerIds) - .order('session_date', { ascending: false }) - .order('id', { ascending: true }) - .range(from, to), - ); - const pool = (statRows ?? []) as BoxScoreRow[]; + // #379 Phase 4b: the pool comes from the shared engine stat-row read — + // canonical box-score rows (normalized onto the loader shape, source-table + // tagged) reconciled over the legacy flat rows per the precedence rule, with + // the same pagination + stable ordering the direct read used (the old + // `.limit(4000)` was silently capped at PostgREST's 1000 max_rows). Identical + // to the engine-run + baseline reads, keeping baseline/observed + // apples-to-apples. + const { data: statRows } = await loadEngineStatRows(supabase, teamId, playerIds); + const pool: BoxScoreRow[] = statRows ?? []; // Index the pool by player once so the per-action after-window filter is cheap. const byPlayer = new Map(); diff --git a/src/lib/baseball/stat-layer-manifest.ts b/src/lib/baseball/stat-layer-manifest.ts index d408a2fab..59ba063d3 100644 --- a/src/lib/baseball/stat-layer-manifest.ts +++ b/src/lib/baseball/stat-layer-manifest.ts @@ -196,49 +196,35 @@ export const GRANDFATHERED_CONSUMERS: GrandfatheredStatLayerConsumer[] = [ group: 'coachhelm-engine', status: 'pending migration', 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.', + '#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 — after Phase 4b (engine-stat-rows.ts) that is practice-effectiveness.ts plus the legacy-fallback/practice-carve-out rows the engine callers still receive. 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', group: 'coachhelm-engine', status: 'pending migration', - note: 'Cites baseball_player_stats as a source_ref table on generated insight rows.', + note: + '#379 Phase 4b: the ONLY remaining cite is practiceEffectivenessGenerator\'s before/after-measurement source ref — honest today because actions/practice-effectiveness.ts still assembles those inputs from baseball_player_stats practice rows (the canonical layers have no practice-session shape; see that action\'s entry above). Migrates together with that action, not before. importQualityGenerator\'s Date.now() window was separately fixed to a caller-supplied nowIso (#811 residual, no table coupling).', }, { - path: 'src/lib/coachhelm/baseball/generators/index.ts', + path: 'src/lib/baseball/coachhelm/engine-stat-rows.ts', group: 'coachhelm-engine', status: 'pending migration', - note: 'Falls back to baseball_player_stats as the default source-ref table label.', + note: + '#379 Phase 4b: the ONE consolidated stat-row read for the CoachHelm engine (engine-run.ts, outcome-sweep.ts, action-baseline.ts — all migrated off their direct reads onto this). Prefers canonical baseball_box_score_batting/_pitching rows (normalized + source-table tagged via loaders.ts) and reads baseball_player_stats ONLY as (a) the practice carve-out and (b) the legacy-fallback tier for players with zero canonical rows — the design\'s "one place allowed to do it". Retires when Phase 5 retires the legacy writer and the fallback tier has nothing left to serve.', }, { path: 'src/lib/coachhelm/baseball/effectiveness/engine.ts', group: 'coachhelm-engine', status: 'pending migration', - note: 'Cites baseball_player_stats as the source table for effectiveness-tracking source refs.', - }, - { - path: 'src/lib/baseball/coachhelm/outcome-sweep.ts', - group: 'coachhelm-engine', - status: 'pending migration', - note: 'Reads baseball_player_stats to sweep for outcome evidence after an action.', - }, - { - path: 'src/lib/baseball/coachhelm/engine-run.ts', - group: 'coachhelm-engine', - status: 'pending migration', - note: 'Reads baseball_player_stats as part of an engine run pass.', - }, - { - path: 'src/lib/baseball/coachhelm/action-baseline.ts', - group: 'coachhelm-engine', - status: 'pending migration', - note: 'Reads baseball_player_stats to compute the pre-action baseline metric.', + note: + '#379 Phase 4b reviewed, deliberately NOT migrated: its before/after source ref cites baseball_player_stats because actions/practice-effectiveness.ts (its only feeder) still computes MeasurementPoints from that table\'s practice/game rows. Re-pointing the cite before the feeder migrates would be dishonest provenance. Blocked on the same practice-session-shape open question as practice-effectiveness.ts.', }, { path: 'src/lib/baseball/operational-rule-engine.ts', group: 'coachhelm-engine', status: 'pending migration', - note: 'Declares baseball_player_stats as a sourceType for the deterministic operational-signal rules.', + note: + '#379 Phase 4b reviewed, deliberately NOT migrated: player_cold_streak\'s sourceTypes/source-ref label describes the facts operational-signals.ts loads, and that action\'s recent-game rolling window remains a real baseball_player_stats read (its season baseline moves canonical separately, in the Phase 2 chunk). The label follows the data — it updates when the feeder\'s remaining legacy read does.', }, // --- Pages / components ----------------------------------------------------- @@ -280,31 +266,29 @@ export const GRANDFATHERED_CONSUMERS: GrandfatheredStatLayerConsumer[] = [ path: 'src/lib/baseball/__tests__/action-baseline.test.ts', group: 'test', status: 'pending migration', - note: 'Exercises action-baseline.ts against a fake baseball_player_stats table; mirrors production until that file migrates.', + note: + '#379 Phase 4b: pins BOTH tiers of the shared engine stat-row read behind buildActionOutcomeSeed — the legacy-fallback baseline (fake baseball_player_stats rows, no canonical tables seeded) and the canonical-preferred baseline (box-score rows win, legacy game rows dropped). The legacy fixtures are the fallback pin, not staleness; retires with engine-stat-rows.ts.', }, { path: 'src/lib/baseball/__tests__/engine-run-coach-triage.test.ts', group: 'test', status: 'pending migration', - note: 'Exercises runBaseballEngineCore (#473 coach-triage skip) against a fake baseball_player_stats table; mirrors engine-run.ts until that file migrates.', + note: + '#379 Phase 4b: exercises runBaseballEngineCore (#473 coach-triage skip) against a fake baseball_player_stats table — now the legacy-FALLBACK tier of the shared engine stat-row read (no canonical tables seeded), plus candidate fixtures citing legacy-era provenance. Retires with engine-stat-rows.ts.', }, { - path: 'src/lib/baseball/__tests__/ai-policy-enforcement.test.ts', + path: 'src/lib/baseball/__tests__/engine-stat-rows.test.ts', group: 'test', status: 'pending migration', - note: 'Fixture source_ref table name mirrors production usage.', + note: + '#379 Phase 4b: unit tests for engine-stat-rows.ts above — pins the precedence rule (canonical rows replace legacy game rows, practice carve-out, legacy fallback, all-or-nothing canonical degrade), so it necessarily seeds fake baseball_player_stats rows. Retires with engine-stat-rows.ts.', }, { path: 'src/lib/baseball/__tests__/outcome-sweep-insight-resolve.test.ts', group: 'test', status: 'pending migration', - note: 'Fake table-name switch mirrors outcome-sweep.ts reading baseball_player_stats.', - }, - { - path: 'src/lib/baseball/__tests__/signal-from-insight.test.ts', - group: 'test', - status: 'pending migration', - note: 'Fixture source_table / source value mirrors production signal provenance.', + note: + '#379 Phase 4b: fake table-name switch pins BOTH tiers of the sweep\'s shared stat-row read — legacy-fallback after-windows (existing tests) and the canonical-preferred, never-blended after-window (new test). Retires with engine-stat-rows.ts.', }, { path: 'src/lib/coachhelm/baseball/engine-v10.test.ts', diff --git a/src/lib/coachhelm/baseball/engine.ts b/src/lib/coachhelm/baseball/engine.ts index 05ab02f5b..5564f76ae 100644 --- a/src/lib/coachhelm/baseball/engine.ts +++ b/src/lib/coachhelm/baseball/engine.ts @@ -54,6 +54,18 @@ import { /** Everything the full V10 run needs (a superset of the base inputs). */ export interface BaseballV10EngineInputs extends BaseballEngineInputs { + /** + * ISO instant the run considers "now" for every rolling-window calculation + * downstream (#811 residual). Defaults to `new Date().toISOString()` inside + * importQualityGenerator itself when omitted, so a non-engine caller that + * never sets this field keeps its existing real-time behavior unchanged — + * only runBaseballEngineCore's deterministic path threads its own nowIso + * through here. Mirrors the nowIso already threaded through + * loadAllPlayerMetrics/mergeV10PlayerMetrics/mergeEventPlayerMetrics for the + * same reason: a fixed-clock test must never silently drift as real time + * carries a Date.now()-based window past the seeded fixture dates. + */ + now?: string; /** Practice-effectiveness inputs the action assembled (one per evaluated block). */ practiceEffectiveness?: PracticeEffectivenessInput[]; /** Recent import-run summaries to evaluate for data-quality flags. */ @@ -110,7 +122,7 @@ export function generateAllBaseballCandidates( candidates.push(...practiceEffectivenessGenerator(pe)); } if (inputs.importRuns && inputs.importRuns.length > 0) { - candidates.push(...importQualityGenerator(inputs.importRuns)); + candidates.push(...importQualityGenerator(inputs.importRuns, inputs.now)); } if (inputs.videoCoverage) { candidates.push(...videoEvidenceGenerator(inputs.videoCoverage)); diff --git a/src/lib/coachhelm/baseball/generators/index.ts b/src/lib/coachhelm/baseball/generators/index.ts index a8b626e06..6e5a46da1 100644 --- a/src/lib/coachhelm/baseball/generators/index.ts +++ b/src/lib/coachhelm/baseball/generators/index.ts @@ -122,7 +122,12 @@ export function driver(m: LoadedMetric): DiagnosisDriver { value: m.value ?? 0, unit: mapUnit(getBaseballMetricUnit(m.metric)), sample_n: m.sample_n, - source: m.source_refs[0]?.label ?? m.source_refs[0]?.table ?? 'baseball_player_stats', + // Last-resort fallback only — the loaders attach at least one source ref to + // every metric they emit, and (#379) those refs cite the REAL table a row + // came from (canonical box-score or legacy flat). A hardcoded table name + // here could misattribute the layer, so the defensive default is a neutral + // human-readable label instead. + source: m.source_refs[0]?.label ?? m.source_refs[0]?.table ?? 'box score', }; } diff --git a/src/lib/coachhelm/baseball/generators/v10.test.ts b/src/lib/coachhelm/baseball/generators/v10.test.ts new file mode 100644 index 000000000..942182928 --- /dev/null +++ b/src/lib/coachhelm/baseball/generators/v10.test.ts @@ -0,0 +1,68 @@ +/** + * Unit tests for generators/v10.ts's importQualityGenerator — the #811 + * residual. #811 threaded the deterministic engine run's nowIso through every + * OTHER rolling-window loader (loadLiftMetrics / loadReadinessMetrics / + * loadPlayerMetrics' workload calc / loadCatchingMetrics) but explicitly left + * this generator's 14-day recency window on raw `Date.now()`, because + * `BaseballV10EngineInputs` had no `now` field for generators at all yet. This + * pins that the window is now computed from the caller-supplied `nowIso`, so a + * deterministic engine run (fixed nowIso + seeded import runs) can never + * silently drift as real time carries a `Date.now()`-based window past the + * fixture dates — the exact bug class #811 fixed everywhere else. + */ + +import { describe, it, expect } from 'vitest'; +import { importQualityGenerator } from './v10'; +import type { ImportRunSummary } from '../loaders-v10'; + +// Deliberately far from the real wall clock — if the generator ever regresses +// to Date.now() internally, every test below flips (a 2020 date is nowhere +// near "the last 14 days" of the real 2026+ clock). +const NOW = '2020-06-30T12:00:00.000Z'; + +function run(overrides: Partial = {}): ImportRunSummary { + return { + id: 'run-1', + import_type: 'box_score', + source_label: 'April CSV', + status: 'validated', + row_count: 20, + warning_count: 6, // 30% warning rate, above the 15% threshold + error_count: 0, + created_at: NOW, + ...overrides, + }; +} + +describe('importQualityGenerator — nowIso threading (#811 residual)', () => { + it('flags a run inside the 14-day window measured from the supplied nowIso, even though that window is nowhere near the real wall clock', () => { + const insideWindow = run({ created_at: '2020-06-20T00:00:00.000Z' }); // 10 days before NOW + const out = importQualityGenerator([insideWindow], NOW); + expect(out).toHaveLength(1); + expect(out[0]?.generator).toBe('import_quality'); + }); + + it('excludes a run older than 14 days from the supplied nowIso — proves the cutoff is NOW-14d, not Date.now()-14d', () => { + const outsideWindow = run({ created_at: '2020-06-10T00:00:00.000Z' }); // 20 days before NOW + const out = importQualityGenerator([outsideWindow], NOW); + expect(out).toHaveLength(0); + }); + + it('defaults to the real wall clock when nowIso is omitted, so non-engine callers keep their existing real-time behavior unchanged', () => { + const freshRun = run({ created_at: new Date().toISOString() }); + const out = importQualityGenerator([freshRun]); // no nowIso arg at all + expect(out).toHaveLength(1); + }); + + it('still flags a FAILED run inside the window regardless of warning rate', () => { + const failedRun = run({ + created_at: '2020-06-25T00:00:00.000Z', + status: 'failed', + warning_count: 0, + error_count: 0, + }); + const out = importQualityGenerator([failedRun], NOW); + expect(out).toHaveLength(1); + expect(out[0]?.title).toBe('Import failed'); + }); +}); diff --git a/src/lib/coachhelm/baseball/generators/v10.ts b/src/lib/coachhelm/baseball/generators/v10.ts index 9438aad6a..c9cd5e130 100644 --- a/src/lib/coachhelm/baseball/generators/v10.ts +++ b/src/lib/coachhelm/baseball/generators/v10.ts @@ -289,6 +289,11 @@ export function practiceEffectivenessGenerator( const refs: BaseballInsightSourceRef[] = [ { table: 'baseball_practices', column: 'focus', visibility: 'team', sample_n: input.afterSampleN, label: `Focus: ${label}`, confidence }, + // #379: this cite deliberately stays on the legacy flat table — the + // before/after measurement is assembled by actions/practice-effectiveness.ts, + // which still reads baseball_player_stats practice rows (the canonical + // layers have no practice-session shape yet; see stat-layer-manifest.ts). + // It migrates together with that action, not before. { table: 'baseball_player_stats', column: 'before/after focus metric', visibility: 'staff_only', sample_n: input.afterSampleN, label: 'Before/after measurement', confidence }, ]; @@ -359,12 +364,24 @@ function tooEarlyCard(input: PracticeEffectivenessInput): BaseballInsightCandida // infer them). // ============================================================================= -export function importQualityGenerator(runs: ImportRunSummary[]): BaseballInsightCandidate[] { +/** + * @param nowIso ISO instant the 14-day recency window is computed from. + * Defaults to `new Date().toISOString()` (the real wall clock) so every + * non-engine caller keeps its existing real-time behavior unchanged. + * `runBaseballEngineCore` threads its own deterministic `nowIso` through + * `BaseballV10EngineInputs.now` for this exact param (#811 residual — this + * generator was the one rolling window #811 explicitly left as raw + * `Date.now()`, since `BaseballV10EngineInputs` had no `now` field yet). + */ +export function importQualityGenerator( + runs: ImportRunSummary[], + nowIso: string = new Date().toISOString(), +): BaseballInsightCandidate[] { const out: BaseballInsightCandidate[] = []; const threshold = getBaseballMetricThreshold('import_warning_rate'); // Only look at recently-touched runs (last 14 days) so we don't re-flag old // history; the action passes a bounded set, but we guard here too. - const cutoff = Date.now() - 14 * 86400_000; + const cutoff = Date.parse(nowIso) - 14 * 86400_000; for (const run of runs) { if (run.created_at && Date.parse(run.created_at) < cutoff) continue; const total = Math.max(1, run.row_count);