Files
vyndr/tests/unit/parkBaseGameContext.test.js
T
builtbykev f33091ddb8 Layer 3 Step 4b: public park base, source-pluggable, plus game-level capture
PHASE 0 — the settle path sees a player's game-log line, not the game. It knows
date and teams, never venue or final totals. But the grain is far cheaper than
per-prop or even per-game: ONE statsapi schedule call per game DATE returns
every game that day with venue, linescore and scoring plays. Fifteen games, one
call, verified live.

PUBLIC BASE — the ingestion was already done. The static FanGraphs table from
Session 15 is the public base; this converts its 100-indexed values into the
multipliers the composable architecture wants (Coors 128 becomes 1.28) rather
than ingesting a second copy of a number we already hold.

It is labelled COMMODITY in the code, not just in a comment. Every resolution
carries a provenance record, and the public one reads proprietary: false with
the note "Commodity: a public number. Not a VYNDR derivation." The proprietary
label exists but belongs only to the self-derived version, and only once it
beats this base on the instrument. A surface rendering a park effect can state
which it is rather than implying the flattering one.

Honest-absent where even the PUBLIC number is thin: a relocated club in a
temporary venue gets no factor, because a public number for a park with one
season behind it is no more trustworthy than ours would be.

SOURCE-PLUGGABLE is the architectural point. resolveParkBase() is the only
accessor, public and derived return identical shapes, and callers never branch
on source — so when self-derived factors clear their floor they swap into the
same slot with nothing downstream to rewrite. A derived source with no factor
available returns absent rather than silently falling back to public, because a
silent fallback would make a proprietary claim out of a commodity number.

GAME-LEVEL CAPTURE starts now because it cannot start retroactively. Game grain,
deduped on game_id, never copied onto prop rows — a game's totals belong to the
game, and duplicating them per prop is how one fact starts disagreeing with
itself. Every field is tied to a named future derivation: venue for park
factors, runs for the run environment, HR totals for HR factors. Nothing else is
stored. Only Final games are captured, since an in-progress total is not a
result, and a game with no scoring plays reports HR as absent rather than zero.
HR totals come from scoring plays, which is complete because every home run
scores at least the batter.

The accrual target is stated rather than promised: 150 home games per venue at
roughly 81 per season means about two seasons before a self-derived factor can
be nominated, and accrualStatus() reports live progress per venue so the wait is
measurable.

Induced: Coors home runs +0.061 for the hitter and identically +0.061 for the
pitcher's home-runs-allowed at the same park, mirrored on the under; San
Francisco negative; Tampa flagged weather-N/A with its factor still applying;
the Athletics' temporary venue absent; strikeouts untouched. A real 2025-07-19
capture produced 15 games across 15 venues, 12 with HR totals, zero duplicate
game ids.

Migration 035. Induce with POST /api/internal/gamectx/:date, progress at
/gamectx/accrual. Tests 3688 passed / 298 suites, web build exit 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VCNgGSt5qvcLxaeQqa7Zpj
2026-07-21 01:20:29 -04:00

179 lines
8.4 KiB
JavaScript

/* ============================================================
Session 74 — PUBLIC PARK BASE (source-pluggable) + GAME-CONTEXT CAPTURE.
============================================================ */
const pb = require('../../src/services/parkBase');
const pf = require('../../src/services/parkFactors');
const gc = require('../../src/services/gameContext');
const ch = require('../../src/services/challengerProjection');
describe('public base — commodity, and labelled as such', () => {
it('converts the 100-indexed public table to a multiplier', () => {
const col = pb.resolveParkBase({ teamAbbr: 'COL' });
expect(col.state).toBe('present');
expect(col.hr_base).toBe(1.28); // 128 → 1.28
expect(col.run_base).toBe(1.2);
});
it('NEVER claims the public number is proprietary', () => {
const col = pb.resolveParkBase({ teamAbbr: 'COL' });
expect(col.provenance.proprietary).toBe(false);
expect(col.provenance.label).toBe('PUBLIC PARK FACTOR');
expect(col.provenance.note).toMatch(/Not a VYNDR derivation/);
});
it('a pitcher park reads below 1', () => {
expect(pb.resolveParkBase({ teamAbbr: 'SF' }).hr_base).toBeLessThan(1);
});
it('HONEST-ABSENT where even the PUBLIC factor is thin/unstable', () => {
// A relocated club in a temporary venue: a public number here is no more
// trustworthy than ours would be.
for (const t of ['ATH', 'SAC']) {
const r = pb.resolveParkBase({ teamAbbr: t });
expect(r.state).toBe('absent');
expect(r.hr_base).toBeNull();
expect(r.reason).toMatch(/thin\/unstable/);
}
});
it('unknown team → absent, never a neutral guess', () => {
expect(pb.resolveParkBase({ teamAbbr: 'ZZZ' }).state).toBe('absent');
expect(pb.resolveParkBase({}).state).toBe('absent');
});
it('flags domes weather_na while the park factor STILL applies', () => {
const tb = pb.resolveParkBase({ teamAbbr: 'TB' });
expect(tb.weather_na).toBe(true);
expect(tb.state).toBe('present'); // N/A is not absent
});
});
describe('SOURCE-PLUGGABLE — same slot, no rearchitecting', () => {
const derived = { state: 'present', hr_base: 1.11, run_base: 1.05, venue: 'Coors Field', weather_na: false, games: 162, seasons: 4 };
it('self-derived swaps into the same shape and IS proprietary', () => {
const d = pb.resolveParkBase({ teamAbbr: 'COL', source: 'derived', derived });
expect(d.state).toBe('present');
expect(d.hr_base).toBe(1.11);
expect(d.provenance.proprietary).toBe(true);
expect(d.provenance.label).toBe('VYNDR PARK FACTOR');
// identical keys to the public answer → callers never branch on source
const p = pb.resolveParkBase({ teamAbbr: 'COL' });
expect(Object.keys(d)).toEqual(expect.arrayContaining(Object.keys(p)));
});
it('derived-but-unavailable is absent, never silently falls back', () => {
const r = pb.resolveParkBase({ teamAbbr: 'COL', source: 'derived', derived: null });
expect(r.state).toBe('absent');
expect(r.reason).toMatch(/self-derived factor not available/);
});
});
describe('composes into the challenger, directional by prop-owner', () => {
const env = (b, stat) => ({ multiplier: pf.parkMultiplier({ factor: b, statType: stat }), label: b.provenance.label, venue: b.venue, weather_na: b.weather_na });
const col = pb.resolveParkBase({ teamAbbr: 'COL' });
it('hitter HR and pitcher HR-allowed move the SAME way at the same park', () => {
const h = ch.adjust({ pWin: 0.5, direction: 'over', statType: 'home_runs', environment: env(col, 'home_runs') });
const p = ch.adjust({ pWin: 0.5, direction: 'over', statType: 'home_runs_allowed', environment: env(col, 'home_runs_allowed') });
expect(h.delta).toBeGreaterThan(0);
expect(p.delta).toBe(h.delta);
});
it('mirrors on the under and does nothing for an unrelated stat', () => {
const over = ch.adjust({ pWin: 0.5, direction: 'over', statType: 'home_runs', environment: env(col, 'home_runs') });
const under = ch.adjust({ pWin: 0.5, direction: 'under', statType: 'home_runs', environment: env(col, 'home_runs') });
expect(under.delta).toBeCloseTo(-over.delta, 3);
expect(ch.adjust({ pWin: 0.5, direction: 'over', statType: 'strikeouts', environment: env(col, 'strikeouts') }).delta).toBe(0);
});
it('an absent park adjusts nothing', () => {
const ath = pb.resolveParkBase({ teamAbbr: 'ATH' });
expect(ch.adjust({ pWin: 0.5, direction: 'over', statType: 'home_runs', environment: env(ath, 'home_runs') }).delta).toBe(0);
});
});
describe('game-context capture — game grain, named purpose, finals only', () => {
const payload = {
dates: [{
date: '2025-07-19',
games: [
{ gamePk: 777094, season: '2025', status: { detailedState: 'Final' },
venue: { id: 14, name: 'Rogers Centre' },
teams: { home: { team: { name: 'Toronto Blue Jays' } }, away: { team: { name: 'New York Yankees' } } },
linescore: { teams: { home: { runs: 5 }, away: { runs: 4 } } },
scoringPlays: [
{ result: { event: 'Home Run' }, about: { halfInning: 'bottom' } },
{ result: { event: 'Home Run' }, about: { halfInning: 'top' } },
{ result: { event: 'Single' }, about: { halfInning: 'top' } },
] },
{ gamePk: 777095, season: '2025', status: { detailedState: 'In Progress' },
venue: { id: 15, name: 'Other Park' }, linescore: { teams: { home: { runs: 2 }, away: { runs: 1 } } } },
],
}],
};
it('one row per game, keyed for dedupe', () => {
const rows = gc.rowsFromSchedule(payload);
expect(rows).toHaveLength(1); // the in-progress game is excluded
expect(rows[0].game_id).toBe('mlb:777094');
expect(new Set(rows.map((r) => r.game_id)).size).toBe(rows.length);
});
it('EXCLUDES non-final games — in-progress totals are not results', () => {
expect(gc.rowsFromSchedule(payload).every((r) => r.status === 'Final')).toBe(true);
});
it('captures exactly the NAMED-PURPOSE fields', () => {
const r = gc.rowsFromSchedule(payload)[0];
expect(r.venue_id).toBe(14); // → self-derived park factors
expect(r.total_runs).toBe(9); // → run-environment factors
expect(r.total_hr).toBe(2); // → HR park factors
expect(r.home_hr).toBe(1);
expect(r.away_hr).toBe(1);
// No speculative fields hoarded "just in case".
expect(Object.keys(r).sort()).toEqual([
'away_hr', 'away_runs', 'away_team', 'game_date', 'game_id', 'home_hr',
'home_runs', 'home_team', 'season', 'sport', 'status', 'total_hr',
'total_runs', 'venue_id', 'venue_name',
]);
});
it('HR totals come from scoring plays — every HR scores, so none are missed', () => {
const r = gc.rowsFromSchedule(payload)[0];
expect(r.total_hr).toBe(2); // the Single is not counted
});
it('a game with no scoring plays reports HR as ABSENT, not zero', () => {
const noPlays = { dates: [{ date: '2025-07-19', games: [{ gamePk: 1, status: { detailedState: 'Final' }, venue: { id: 1 }, linescore: { teams: { home: { runs: 0 }, away: { runs: 0 } } } }] }] };
expect(gc.rowsFromSchedule(noPlays)[0].total_hr).toBeNull();
});
it('upserts on game_id — capture is idempotent per date', async () => {
let opts = null;
const sb = { from: () => ({ upsert: async (_r, o) => { opts = o; return { error: null }; } }) };
await gc.captureDate('2025-07-19', { sb, fetchJson: async () => payload });
expect(opts.onConflict).toBe('game_id');
});
it('one call per DATE — not per game, not per prop', () => {
expect(gc.SCHEDULE_URL('2025-07-19')).toMatch(/startDate=2025-07-19&endDate=2025-07-19/);
expect(gc.SCHEDULE_URL('2025-07-19')).toMatch(/hydrate=linescore,venue,scoringplays/);
});
});
describe('accrual target — "later" is measurable', () => {
it('states the per-venue floor and the season estimate', async () => {
const sb = { from: () => ({ select: () => ({ eq: () => ({ limit: async () => ({ data: [
...Array(120).fill({ venue_id: 14, venue_name: 'Rogers Centre' }),
...Array(160).fill({ venue_id: 19, venue_name: 'Coors Field' }),
], error: null }) }) }) }) };
const s = await gc.accrualStatus({ sb });
expect(s.min_games_per_venue).toBe(pf.MIN_GAMES);
expect(s.venues_ready).toBe(1); // Coors has 160 ≥ 150
expect(s.seasons_needed_estimate).toBeGreaterThan(1); // ~2 seasons
expect(s.venues.find((v) => v.venue_id === 14).remaining).toBe(30);
});
});