Skip to content
Closed
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
56 changes: 55 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,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();
});
});
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);
});
});
213 changes: 213 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,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<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({
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);
});
});
Loading
Loading