Files
vyndr/tests/unit/environmentContext.test.js
T
builtbykev 7b25d97891 Wire the four dormant adjusters live — pure input-wiring
Verified state going in: parkBase, weatherMod and platoonSplits were called by
nothing, and env_multiplier was non-null on zero rows across four orders. The
adjusters were correct in isolation and starved of inputs. This gives them their
inputs and changes none of their internal logic — the five adjuster files are
byte-identical after this commit.

PHASE 0 GATE — all three inputs are available at snapshot build, and the two
join keys already existed. Venue: always, on every schedule game object.
First-pitch: always, gameTime on the same object. Opposing-pitcher hand:
present once the probable is declared, via the pitchers endpoint's pitcherId
joined to statsapi handedness — 15 of 15 games declared this afternoon, though
morning locks precede declaration and those props honest-absent on platoon,
correctly. The batter-handedness join (statcast bats) and the MLBAM id were
already on each grade from earlier sessions.

environmentContext.js is the wiring, kept separate from the adjusters so they
stay pure. It fetches once per snapshot: the schedule (team to venue, gameTime),
probable pitchers (team to opposing pitcher id), one batched handedness call,
one Open-Meteo forecast per home park, and batter splits per graded hitter. Park
coordinates for 30 parks live here as public geometry, the same class as the
dome list and centre-field bearings already in weatherMod, rather than inside an
adjuster. Everything is best-effort: a missing venue drops park and weather, an
undeclared pitcher drops platoon, and any fetch failure degrades that prop to
archetype-only rather than breaking the pipeline the adjusters are measured
inside.

attachChallenger becomes async and takes a per-grade contextFor that returns the
environment coefficient (park_base x weather_mod, composed) and the matchup
(platoon). Point-in-time holds: the weather is a forecast for first pitch fetched
now, and the split is the hitter's line entering the game — neither reads a
settle-time value.

Attribution is independent. env_multiplier, env_park_base, env_weather_mod and
env_weather_state land in their own ledger columns, and challenger_adjustments
keeps every axis — archetype, environment, matchup — as a separate entry, so
when volume accrues each of the four can be measured for its own marginal
contribution rather than as one blended delta.

The combined move stays bounded, tested on the worst case: a Coors slugger with
wind out and a favourable platoon, all at once, still moves under 12 percent,
because every layer is capped and the total nudge is clamped. Stacking leans, it
does not compound into a re-forecast.

Non-MLB honest-absents entirely — park, weather and platoon are MLB-only today,
so a WNBA prop gets no environment and no matchup.

The champion is untouched throughout: p_win is read, never written, the served
snapshot payload is still the enriched object, and a test confirms p_win passes
through byte-for-byte while the challenger moves.

Tests 3741 passed / 301 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 21:38:51 -04:00

152 lines
7.2 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* ============================================================
Session 77 — WIRING the dormant adjusters. Pure input-wiring; the adjusters'
internal logic is not touched. Proves environment + matchup reach the grade.
============================================================ */
const ec = require('../../src/services/environmentContext');
const ch = require('../../src/services/challengerProjection');
const SCHEDULE = { games: [
{ homeTeam: { abbreviation: 'COL' }, awayTeam: { abbreviation: 'LAD' }, gameTime: '2026-07-21T20:10Z', venue: 'Coors Field' },
{ homeTeam: { abbreviation: 'TB' }, awayTeam: { abbreviation: 'NYY' }, gameTime: '2026-07-21T23:05Z', venue: 'Tropicana Field' },
] };
const PITCHERS = { games: [
{ home: { team: 'Colorado Rockies', pitcherId: 111 }, away: { team: 'Los Angeles Dodgers', pitcherId: 222 } },
{ home: { team: 'Tampa Bay Rays', pitcherId: 333 }, away: { team: 'New York Yankees', pitcherId: 444 } },
] };
const PEOPLE = { people: [
{ id: 111, pitchHand: { code: 'R' } }, { id: 222, pitchHand: { code: 'L' } },
{ id: 333, pitchHand: { code: 'R' } }, { id: 444, pitchHand: { code: 'R' } },
] };
// A Coors hitter (LAD) facing the Rockies' RHP; deep vs-RHP power split.
const SPLITS_LAD = { stats: [{ splits: [
{ split: { code: 'vl' }, stat: { plateAppearances: 120, avg: '.250', slg: '.420' } },
{ split: { code: 'vr' }, stat: { plateAppearances: 420, avg: '.250', slg: '.560' } },
] }] };
// Route each URL to its fixture.
const fetchJson = async (url) => {
if (url.includes('/pitchers')) return PITCHERS;
if (url.includes('/api/schedule/mlb')) return SCHEDULE;
if (url.includes('statsapi') && url.includes('/schedule')) return SCHEDULE;
if (url.includes('personIds=')) return PEOPLE;
if (url.includes('open-meteo')) return { hourly: {
time: ['2026-07-21T20:00'], temperature_2m: [88], wind_speed_10m: [14], wind_direction_10m: [185], precipitation_probability: [0],
} };
if (url.includes('statSplits')) return SPLITS_LAD;
return {};
};
async function build() {
return ec.buildContext('mlb', { fetchJson,
schedule: SCHEDULE, pitchers: PITCHERS, people: PEOPLE });
}
describe('team resolution', () => {
it('maps full names and abbreviations to the coord key', () => {
expect(ec.abbrOf('Los Angeles Dodgers')).toBe('LAD');
expect(ec.abbrOf('COL')).toBe('COL');
expect(ec.abbrOf('Nobody')).toBeNull();
expect(ec.abbrOf(null)).toBeNull();
});
it('has coordinates for all 30 parks', () => {
expect(Object.keys(ec.PARK_COORDS)).toHaveLength(30);
});
});
describe('environment reaches the grade', () => {
it('a Coors prop gets env_multiplier > 1 (park × weather)', async () => {
const ctx = await build();
const c = await ctx.contextFor({ team: 'LAD', stat_type: 'home_runs', direction: 'over', playerId: 9, bats: 'R' });
expect(c.environment).not.toBeNull();
expect(c.environment.multiplier).toBeGreaterThan(1);
expect(c.environment.park_base).toBeGreaterThan(1); // Coors
expect(c.environment.weather_mod).toBeGreaterThan(1); // wind out-ish, warm
});
it('a DOME prop keeps its park factor but weather stands down', async () => {
const ctx = await build();
const c = await ctx.contextFor({ team: 'NYY', stat_type: 'home_runs', direction: 'over', playerId: 8, bats: 'R' });
// Tampa's park factor may compose to != 1, but the weather mod is neutral.
if (c.environment) {
expect(c.environment.weather_mod).toBe(1);
expect(c.environment.weather_state).toBe('dome_na');
}
});
it('a prop for a team not on the slate gets NO environment', async () => {
const ctx = await build();
const c = await ctx.contextFor({ team: 'SEA', stat_type: 'home_runs', direction: 'over', playerId: 7, bats: 'R' });
expect(c.environment).toBeNull();
});
it('never fabricates a venue — a grade with no team is absent', async () => {
const ctx = await build();
const c = await ctx.contextFor({ team: null, stat_type: 'home_runs', direction: 'over', playerId: 6, bats: 'R' });
expect(c.environment).toBeNull();
});
});
describe('matchup (platoon) reaches the grade', () => {
it('fires with a declared opposing pitcher + known hitter hand', async () => {
const ctx = await build();
// LAD hitter, opposing pitcher 111 is RHP → uses the deep vs-RHP power split.
const c = await ctx.contextFor({ team: 'LAD', stat_type: 'home_runs', direction: 'over', playerId: 9, bats: 'R' });
expect(c.matchup).not.toBeNull();
expect(c.matchup.pitcher_hand).toBe('R');
expect(c.matchup.observed_pa).toBe(420);
});
it('honest-absents when the hitter hand is unknown', async () => {
const ctx = await build();
const c = await ctx.contextFor({ team: 'LAD', stat_type: 'home_runs', direction: 'over', playerId: 9, bats: null });
expect(c.matchup).toBeNull();
});
it('honest-absents when no opposing pitcher is declared', async () => {
const ctx = await ec.buildContext('mlb', { fetchJson, schedule: SCHEDULE, pitchers: { games: [] }, people: PEOPLE });
const c = await ctx.contextFor({ team: 'LAD', stat_type: 'home_runs', direction: 'over', playerId: 9, bats: 'R' });
expect(c.matchup).toBeNull();
});
});
describe('non-MLB honest-absents entirely', () => {
it('wnba returns no environment or matchup', async () => {
const ctx = await ec.buildContext('wnba', { fetchJson });
const c = await ctx.contextFor({ team: 'LV', stat_type: 'points', direction: 'over' });
expect(c.environment).toBeNull();
expect(c.matchup).toBeNull();
});
});
describe('the combined adjustment stays BOUNDED', () => {
it('archetype + environment + platoon do not compound into an over-adjust', async () => {
const axes = require('../../src/services/archetypeAxes');
const slugger = axes.classifyPlayer({ role: 'batter', sample_pa: 400, barrel_pct: 20, k_pct: 20, chase_pct: 30, avg_launch_angle: 18, sweet_spot_pct: 40 });
// Coors + wind-out + a favourable platoon, all at once, on a slugger.
const env = { multiplier: 1.28, label: 'PARK × WEATHER', park_base: 1.15, weather_mod: 1.11 };
const matchup = { multiplier: 1.14, label: 'PLATOON', pitcher_hand: 'R' };
const r = ch.adjust({ pWin: 0.5, direction: 'over', statType: 'home_runs', classification: slugger, environment: env, matchup });
// Every layer is capped, and the total nudge is clamped — the combined move
// must stay a lean, not a 40% swing.
expect(Math.abs(r.delta)).toBeLessThan(0.12);
// and all three are independently recorded for attribution
const axesSeen = r.adjustments.map((a) => a.axis);
expect(axesSeen).toContain('environment');
expect(axesSeen).toContain('matchup');
});
});
describe('async attachChallenger still preserves the champion', () => {
it('passes environment + matchup through, p_win untouched', async () => {
const out = await ch.attachChallenger(
[{ player: 'X', stat_type: 'home_runs', direction: 'over', p_win: 0.5 }],
() => null,
async () => ({ environment: { multiplier: 1.2, park_base: 1.2, weather_mod: 1, weather_state: 'present' }, matchup: null }),
);
expect(out[0].p_win).toBe(0.5); // champion untouched
expect(out[0].env_multiplier).toBe(1.2); // retained attributably
expect(out[0].p_win_challenger).toBeGreaterThan(0.5);
});
});