Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 61 additions & 1 deletion src/lib/baseball/__tests__/action-baseline.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Record<string, unknown>>;
baseball_games?: Array<Record<string, unknown>>;
baseball_box_score_batting?: Array<Record<string, unknown>>;
baseball_box_score_pitching?: Array<Record<string, unknown>>;
}): BaselineClient {
return {
from(table: string) {
Expand All @@ -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 });
},
Expand Down Expand Up @@ -177,4 +190,51 @@ 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). Each
// legacy row shares its calendar day with one canonical game below (#379
// is (player, date)-scoped, not player-only), so all three are dropped.
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: Array.from({ length: 3 }).map((_, i) => ({
id: `g${i}`,
team_id: 'team-1',
game_date: `2026-04-0${i + 1}`,
})),
baseball_box_score_batting: Array.from({ length: 3 }).map((_, i) => ({
id: `bb${i}`,
team_id: 'team-1',
game_id: `g${i}`,
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();
});
});
3 changes: 2 additions & 1 deletion src/lib/baseball/__tests__/ai-policy-enforcement.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,8 @@ function candidate(overrides: Partial<BaseballInsightCandidate> = {}): 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,
Expand Down
18 changes: 18 additions & 0 deletions src/lib/baseball/__tests__/engine-run-helm-lifting.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
275 changes: 275 additions & 0 deletions src/lib/baseball/__tests__/engine-stat-rows.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,275 @@
// =============================================================================
// 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 legacy stat_type='game' row is dropped ONLY when that SAME player
// has a canonical box-score row (baseball_box_score_batting/_pitching,
// normalized onto the loader shape, source-table tagged) whose resolved
// game date matches that row's session_date (same calendar day) —
// scoped by (player, date), never by player alone. A game seeded into
// BOTH layers on the same day (the #827 demo seed does exactly that) is
// never counted twice; a legacy game on a date with NO canonical
// coverage (e.g. games logged before box-score import started) stays in
// the pool untouched.
// 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"). This
// also holds per-date: a player's legacy games on uncovered dates are
// the row-grain version of the same fallback.
// 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<string, unknown>;

/**
* 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<string, Row[]>, errorTables: Set<string> = new Set()) {
return {
from(table: string) {
const data = tables[table] ?? [];
const fail = errorTables.has(table);
const builder: Record<string, unknown> = {
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({
// Both legacy rows fall on the SAME date as the canonical game below —
// full same-day overlap, so both are superseded.
baseball_player_stats: [
legacyGame('lg-1', 'p1', { session_date: '2026-04-01' }),
legacyGame('lg-2', 'p1', { session_date: '2026-04-01' }),
],
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({
// lg-1 shares its date with the canonical game (dropped); the practice
// row is never in scope for the game-date exclusion regardless.
baseball_player_stats: [
legacyGame('lg-1', 'p1', { session_date: '2026-04-01' }),
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: [
// p1's legacy game shares its date with p1's canonical game (dropped).
legacyGame('lg-p1', 'p1', { session_date: '2026-04-01' }),
// p2 has no canonical rows at all — kept regardless of date.
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('mixed coverage: a player\'s legacy-only games (no canonical date match) survive alongside their canonical games', async () => {
const client = makeClient({
baseball_player_stats: [
// Legacy-only: none of these dates has canonical coverage for p1.
legacyGame('lg-1', 'p1', { session_date: '2026-01-01' }),
legacyGame('lg-2', 'p1', { session_date: '2026-01-08' }),
legacyGame('lg-3', 'p1', { session_date: '2026-01-15' }),
],
baseball_games: [
{ id: 'g1', game_date: '2026-04-01' },
{ id: 'g2', game_date: '2026-04-08' },
],
baseball_box_score_batting: [
boxBatting('bb-1', 'p1', 'g1'),
boxBatting('bb-2', 'p1', 'g2'),
],
});

const { data, error } = await loadEngineStatRows(client, TEAM, ['p1']);
expect(error).toBeNull();
// 3 legacy-only + 2 canonical = 5 rows; nothing here collides on date, so
// the player-scoped bug (dropping ALL legacy games once ANY canonical row
// exists) would have wrongly shrunk this to 2.
const ids = data!.map((r) => r.id).sort();
Comment on lines +182 to +201

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Test fake silently skips team-scoping on canonical reads

The fake client's .eq() and .in() are no-ops (eq: () => builder, in: () => builder), so no test in this file verifies that the canonical queries carry .eq('team_id', teamId). If that filter were accidentally removed from engine-stat-rows.ts, every test here would still pass while the production read would cross team boundaries.

The action-baseline.test.ts fake does filter on eq, and the schema + RLS enforce isolation in production — so there's no current data leakage — but the gap means a future refactor of the canonical read chain could silently drop the team_id predicate without a test failure. Adding a per-table row-count assertion after seeding rows for a second team (with a different team_id) would close the gap without restructuring the rest of the test suite.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/lib/baseball/__tests__/engine-stat-rows.test.ts
Line: 182-201

Comment:
**Test fake silently skips team-scoping on canonical reads**

The fake client's `.eq()` and `.in()` are no-ops (`eq: () => builder`, `in: () => builder`), so no test in this file verifies that the canonical queries carry `.eq('team_id', teamId)`. If that filter were accidentally removed from `engine-stat-rows.ts`, every test here would still pass while the production read would cross team boundaries.

The `action-baseline.test.ts` fake *does* filter on `eq`, and the schema + RLS enforce isolation in production — so there's no current data leakage — but the gap means a future refactor of the canonical read chain could silently drop the `team_id` predicate without a test failure. Adding a per-table row-count assertion after seeding rows for a second team (with a different `team_id`) would close the gap without restructuring the rest of the test suite.

How can I resolve this? If you propose a fix, please make it concise.

Fix in Claude Code

expect(ids).toEqual(['bb-1', 'bb-2', 'lg-1', 'lg-2', 'lg-3']);
});

it('same-day collision: a legacy game row IS dropped when it lands on a canonically-covered date', async () => {
const client = makeClient({
baseball_player_stats: [
// Same calendar day as the canonical game below — superseded.
legacyGame('lg-same-day', 'p1', { session_date: '2026-04-01' }),
// A different day with no canonical coverage — survives.
legacyGame('lg-other-day', 'p1', { session_date: '2026-01-01' }),
],
baseball_games: [{ id: 'g1', game_date: '2026-04-01' }],
baseball_box_score_batting: [boxBatting('bb-1', 'p1', 'g1')],
});

const { data } = await loadEngineStatRows(client, TEAM, ['p1']);
const ids = data!.map((r) => r.id).sort();
expect(ids).toEqual(['bb-1', 'lg-other-day']);
});

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);
});
});
Loading
Loading