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
This commit is contained in:
@@ -226,27 +226,45 @@ function adjust({ pWin, direction, statType, classification, environment, matchu
|
||||
*
|
||||
* The champion field (`p_win`) is NEVER written here. Read-only by design.
|
||||
*/
|
||||
function attachChallenger(grades, classifyFor) {
|
||||
return (grades || []).map((g) => {
|
||||
if (!g) return g;
|
||||
async function attachChallenger(grades, classifyFor, contextFor) {
|
||||
const out = [];
|
||||
for (const g of grades || []) {
|
||||
if (!g) { out.push(g); continue; }
|
||||
const cls = typeof classifyFor === 'function'
|
||||
? classifyFor(g.player || g.player_name, g.stat_type || g.stat)
|
||||
: null;
|
||||
const out = adjust({
|
||||
// Session 77 — environment (park × weather) and matchup (platoon) come from
|
||||
// the wiring, per grade. Both optional; a resolver failure degrades that
|
||||
// grade to archetype-only rather than breaking the map.
|
||||
let ctx = { environment: null, matchup: null };
|
||||
if (typeof contextFor === 'function') {
|
||||
try { ctx = (await contextFor(g)) || ctx; } catch { /* honest-absent */ }
|
||||
}
|
||||
const res = adjust({
|
||||
pWin: g.p_win,
|
||||
direction: g.direction,
|
||||
statType: g.stat_type || g.stat,
|
||||
classification: cls,
|
||||
environment: ctx.environment,
|
||||
matchup: ctx.matchup,
|
||||
});
|
||||
return {
|
||||
out.push({
|
||||
...g,
|
||||
p_win_challenger: out.p_win_challenger,
|
||||
challenger_delta: out.delta,
|
||||
challenger_adjustments: out.adjustments.length ? out.adjustments : null,
|
||||
p_win_challenger: res.p_win_challenger,
|
||||
challenger_delta: res.delta,
|
||||
challenger_adjustments: res.adjustments.length ? res.adjustments : null,
|
||||
challenger_version: CHALLENGER_VERSION,
|
||||
challenger_reason: out.reason,
|
||||
};
|
||||
});
|
||||
challenger_reason: res.reason,
|
||||
// Independent, attributable retention (Session 75 ledger columns). The
|
||||
// per-axis breakdown also lives in challenger_adjustments, but these
|
||||
// top-level fields keep environment measurable on its own.
|
||||
env_multiplier: ctx.environment ? ctx.environment.multiplier : null,
|
||||
env_park_base: ctx.environment ? ctx.environment.park_base : null,
|
||||
env_weather_mod: ctx.environment ? ctx.environment.weather_mod : null,
|
||||
env_weather_state: ctx.environment ? ctx.environment.weather_state : null,
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* ENVIRONMENT + MATCHUP CONTEXT (Session 77) — the WIRING that feeds the
|
||||
* dormant adjusters. It changes NONE of their internal logic: it fetches the
|
||||
* inputs they need (venue, first-pitch, weather forecast, opposing-pitcher
|
||||
* hand, batter splits) and calls parkBase / weatherMod / platoonSplits /
|
||||
* composeEnvironment exactly as they are.
|
||||
*
|
||||
* Everything here is best-effort and HONEST-ABSENT: a missing venue → no park
|
||||
* or weather; an undeclared pitcher → no platoon. Nothing is fabricated, and a
|
||||
* failure anywhere degrades that prop to no-adjustment, never a crash — the
|
||||
* adjusters are measured inside the pipeline and must not be able to break it.
|
||||
*
|
||||
* POINT-IN-TIME: the weather is a FORECAST for first pitch fetched now (at
|
||||
* projection time), and the platoon split is the hitter's line entering the
|
||||
* game. Neither reads a settle-time value — no lookahead.
|
||||
*/
|
||||
|
||||
const parkBase = require('./parkBase');
|
||||
const weatherMod = require('./weatherMod');
|
||||
const platoon = require('./platoonSplits');
|
||||
|
||||
/**
|
||||
* Park coordinates by team abbr — PUBLIC GEOMETRY, same class as the dome list
|
||||
* and the centre-field bearings in weatherMod. Lives in the WIRING, not the
|
||||
* adjuster, so the adjusters stay pure. A team missing here gets no weather (its
|
||||
* park factor still applies).
|
||||
*/
|
||||
const PARK_COORDS = Object.freeze({
|
||||
ARI: [33.4455, -112.0667], ATL: [33.8907, -84.4677], BAL: [39.2839, -76.6217],
|
||||
BOS: [42.3467, -71.0972], CHC: [41.9484, -87.6553], CWS: [41.83, -87.6339],
|
||||
CIN: [39.0975, -84.5069], CLE: [41.4962, -81.6852], COL: [39.7559, -104.9942],
|
||||
DET: [42.339, -83.0485], HOU: [29.7573, -95.3555], KC: [39.0517, -94.4803],
|
||||
LAA: [33.8003, -117.8827], LAD: [34.0739, -118.24], MIA: [25.7781, -80.2197],
|
||||
MIL: [43.028, -87.9712], MIN: [44.9817, -93.2776], NYM: [40.7571, -73.8458],
|
||||
NYY: [40.8296, -73.9262], ATH: [38.5806, -121.5136], PHI: [39.9061, -75.1665],
|
||||
PIT: [40.4469, -80.0057], SD: [32.7073, -117.157], SF: [37.7786, -122.3893],
|
||||
SEA: [47.5914, -122.3325], STL: [38.6226, -90.1928], TB: [27.7683, -82.6534],
|
||||
TEX: [32.7473, -97.0847], TOR: [43.6414, -79.3894], WSH: [38.873, -77.0074],
|
||||
});
|
||||
|
||||
/** Full team name → abbr, so a schedule/stats "Los Angeles Dodgers" resolves to
|
||||
* the abbr the coords + park tables key on. */
|
||||
const NAME_TO_ABBR = Object.freeze({
|
||||
'arizona diamondbacks': 'ARI', 'atlanta braves': 'ATL', 'baltimore orioles': 'BAL',
|
||||
'boston red sox': 'BOS', 'chicago cubs': 'CHC', 'chicago white sox': 'CWS',
|
||||
'cincinnati reds': 'CIN', 'cleveland guardians': 'CLE', 'colorado rockies': 'COL',
|
||||
'detroit tigers': 'DET', 'houston astros': 'HOU', 'kansas city royals': 'KC',
|
||||
'los angeles angels': 'LAA', 'los angeles dodgers': 'LAD', 'miami marlins': 'MIA',
|
||||
'milwaukee brewers': 'MIL', 'minnesota twins': 'MIN', 'new york mets': 'NYM',
|
||||
'new york yankees': 'NYY', 'athletics': 'ATH', 'oakland athletics': 'ATH',
|
||||
'philadelphia phillies': 'PHI', 'pittsburgh pirates': 'PIT', 'san diego padres': 'SD',
|
||||
'san francisco giants': 'SF', 'seattle mariners': 'SEA', 'st. louis cardinals': 'STL',
|
||||
'tampa bay rays': 'TB', 'texas rangers': 'TEX', 'toronto blue jays': 'TOR',
|
||||
'washington nationals': 'WSH',
|
||||
});
|
||||
|
||||
const abbrOf = (team) => {
|
||||
if (!team) return null;
|
||||
const s = String(team).trim();
|
||||
if (/^[A-Z]{2,3}$/.test(s)) return s.toUpperCase();
|
||||
return NAME_TO_ABBR[s.toLowerCase()] || null;
|
||||
};
|
||||
|
||||
const num = (v) => {
|
||||
if (v == null || v === '') return null;
|
||||
const n = typeof v === 'number' ? v : Number(v);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
};
|
||||
|
||||
/** Injectable JSON GET — tests never hit the network. */
|
||||
async function fetchJson(url, opts = {}) {
|
||||
if (opts.fetchJson) return opts.fetchJson(url);
|
||||
const res = await require('axios').get(url, { timeout: 20_000, headers: { Accept: 'application/json' } });
|
||||
return res.data;
|
||||
}
|
||||
|
||||
const DEFAULT_SEASON = Number(process.env.STATCAST_SEASON) || 2026;
|
||||
|
||||
/**
|
||||
* buildContext(sport, deps) — fetches everything ONCE per snapshot and returns
|
||||
* a resolver. All fetches are optional; whatever fails becomes honest-absent.
|
||||
*
|
||||
* Returns { contextFor(grade), stats } where contextFor gives
|
||||
* { environment, matchup } for one grade, ready to hand to attachChallenger.
|
||||
*/
|
||||
async function buildContext(sport, deps = {}) {
|
||||
const sp = String(sport || 'mlb').toLowerCase();
|
||||
if (sp !== 'mlb') {
|
||||
// Park/weather/platoon are MLB-only today. Everything else honest-absents.
|
||||
return { contextFor: () => ({ environment: null, matchup: null }), stats: { sport: sp, applicable: false } };
|
||||
}
|
||||
const season = deps.season || DEFAULT_SEASON;
|
||||
|
||||
// ── 1. schedule: team → { venue, gameTime, homeAbbr, awayAbbr } ─────────
|
||||
const gameByTeam = new Map();
|
||||
try {
|
||||
const sched = deps.schedule || await fetchJson(`${deps.origin || ''}/api/schedule/mlb`, deps).catch(() => null)
|
||||
|| await fetchJson('https://statsapi.mlb.com/api/v1/schedule?sportId=1&hydrate=venue', deps);
|
||||
const games = (sched && (sched.games || (sched.dates || []).flatMap((d) => d.games))) || [];
|
||||
for (const g of games) {
|
||||
const homeAbbr = abbrOf(g.homeTeam?.abbreviation || g.homeTeam?.name || g.teams?.home?.team?.name);
|
||||
const awayAbbr = abbrOf(g.awayTeam?.abbreviation || g.awayTeam?.name || g.teams?.away?.team?.name);
|
||||
const gameTime = g.gameTime || g.gameDate || g.date || null;
|
||||
const venue = g.venue?.name || g.venue || null;
|
||||
const rec = { homeAbbr, awayAbbr, gameTime, venue };
|
||||
if (homeAbbr) gameByTeam.set(homeAbbr, rec);
|
||||
if (awayAbbr) gameByTeam.set(awayAbbr, rec);
|
||||
}
|
||||
} catch { /* honest-absent everywhere below */ }
|
||||
|
||||
// ── 2. probable pitchers: team → { oppPitcherId } ───────────────────────
|
||||
// A team's OPPOSING pitcher is the other side's probable.
|
||||
const oppPitcherByTeam = new Map();
|
||||
const pitcherIds = new Set();
|
||||
try {
|
||||
const pp = deps.pitchers || await fetchJson(`${deps.origin || ''}/api/schedule/mlb/pitchers`, deps).catch(() => null);
|
||||
for (const g of (pp && pp.games) || []) {
|
||||
const homeAbbr = abbrOf(g.home?.team);
|
||||
const awayAbbr = abbrOf(g.away?.team);
|
||||
const homePid = num(g.home?.pitcherId);
|
||||
const awayPid = num(g.away?.pitcherId);
|
||||
if (homeAbbr && awayPid) { oppPitcherByTeam.set(homeAbbr, awayPid); pitcherIds.add(awayPid); }
|
||||
if (awayAbbr && homePid) { oppPitcherByTeam.set(awayAbbr, homePid); pitcherIds.add(homePid); }
|
||||
}
|
||||
} catch { /* platoon honest-absents */ }
|
||||
|
||||
// ── 3. pitcher handedness: ONE batched statsapi call ────────────────────
|
||||
const handById = new Map();
|
||||
if (pitcherIds.size) {
|
||||
try {
|
||||
const ids = [...pitcherIds].join(',');
|
||||
const people = deps.people || await fetchJson(`https://statsapi.mlb.com/api/v1/people?personIds=${ids}`, deps);
|
||||
for (const p of (people && people.people) || []) {
|
||||
const code = p.pitchHand?.code;
|
||||
if (code) handById.set(num(p.id), String(code).toUpperCase());
|
||||
}
|
||||
} catch { /* platoon honest-absents */ }
|
||||
}
|
||||
|
||||
// ── 4. weather forecast: ONE Open-Meteo call per HOME park ──────────────
|
||||
const homeAbbrs = new Set([...gameByTeam.values()].map((r) => r.homeAbbr).filter(Boolean));
|
||||
const forecastByHome = new Map();
|
||||
await Promise.all([...homeAbbrs].map(async (homeAbbr) => {
|
||||
const coords = PARK_COORDS[homeAbbr];
|
||||
if (!coords) return; // no coords → no weather (park still applies)
|
||||
const rec = gameByTeam.get(homeAbbr);
|
||||
try {
|
||||
const payload = await fetchJson(weatherMod.FORECAST_URL(coords[0], coords[1]), deps);
|
||||
const f = weatherMod.pickHour(payload, rec && rec.gameTime);
|
||||
if (f) forecastByHome.set(homeAbbr, f);
|
||||
} catch { /* forecast_absent */ }
|
||||
}));
|
||||
|
||||
// ── 5. batter splits: per unique batter, bounded, best-effort ───────────
|
||||
// Only fetched for players who have a graded prop AND a resolvable id.
|
||||
const splitsById = new Map();
|
||||
const fetchSplits = async (playerId) => {
|
||||
if (playerId == null || splitsById.has(playerId)) return;
|
||||
try {
|
||||
const payload = await fetchJson(platoon.SPLITS_URL(playerId, season), deps);
|
||||
splitsById.set(playerId, platoon.parseSplits(payload));
|
||||
} catch { splitsById.set(playerId, null); }
|
||||
};
|
||||
|
||||
const stats = {
|
||||
sport: sp, applicable: true,
|
||||
games: gameByTeam.size / 2, venues_with_weather: forecastByHome.size,
|
||||
pitchers_with_hand: handById.size, opp_declared: oppPitcherByTeam.size,
|
||||
};
|
||||
|
||||
/**
|
||||
* contextFor(grade) — { environment, matchup } for one graded prop.
|
||||
* grade must carry: team (player's real team), statType, direction, bats,
|
||||
* playerId. Anything missing → that half is null.
|
||||
*/
|
||||
const contextFor = async (grade) => {
|
||||
const teamAbbr = abbrOf(grade && grade.team);
|
||||
const game = teamAbbr ? gameByTeam.get(teamAbbr) : null;
|
||||
const stat = grade && (grade.stat_type || grade.stat);
|
||||
|
||||
// ENVIRONMENT = park_base × weather_mod (composable coefficient).
|
||||
let environment = null;
|
||||
if (game && game.homeAbbr) {
|
||||
const park = parkBase.resolveParkBase({ teamAbbr: game.homeAbbr, venueName: game.venue });
|
||||
const wx = weatherMod.weatherMod({
|
||||
forecast: forecastByHome.get(game.homeAbbr) || null,
|
||||
teamAbbr: game.homeAbbr, venueName: game.venue,
|
||||
statType: stat, weatherNa: park.weather_na,
|
||||
});
|
||||
const env = weatherMod.composeEnvironment({ park, weather: wx, statType: stat });
|
||||
if (env.multiplier !== 1) {
|
||||
environment = {
|
||||
multiplier: env.multiplier, label: env.label, venue: env.venue,
|
||||
weather_na: env.weather_na, park_base: env.park_base,
|
||||
weather_mod: env.weather_mod, weather_state: env.weather_state,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// MATCHUP = platoon split, hitter hand + opposing-SP hand.
|
||||
let matchup = null;
|
||||
const batterHand = grade && grade.bats;
|
||||
const oppPid = teamAbbr ? oppPitcherByTeam.get(teamAbbr) : null;
|
||||
const pitcherHand = oppPid != null ? handById.get(oppPid) : null;
|
||||
if (batterHand && pitcherHand && grade.playerId != null) {
|
||||
await fetchSplits(grade.playerId);
|
||||
const splits = splitsById.get(grade.playerId);
|
||||
if (splits) {
|
||||
const e = platoon.platoonEstimate({ splits, batterHand, pitcherHand, statType: stat });
|
||||
if (e.multiplier !== 1) {
|
||||
matchup = {
|
||||
multiplier: e.multiplier, label: 'PLATOON',
|
||||
batter_hand: e.batter_hand, pitcher_hand: e.pitcher_hand,
|
||||
observed_pa: e.observed_pa, observed_weight: e.observed_weight,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { environment, matchup };
|
||||
};
|
||||
|
||||
return { contextFor, stats, _internals: { gameByTeam, forecastByHome, handById, oppPitcherByTeam } };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
buildContext,
|
||||
abbrOf,
|
||||
PARK_COORDS,
|
||||
NAME_TO_ABBR,
|
||||
};
|
||||
@@ -530,9 +530,30 @@ async function runSnapshot(sport, opts = {}) {
|
||||
const row = rowsByKey.get(nameKey(playerName || ''));
|
||||
return row ? axes.classifyPlayer(row) : null;
|
||||
};
|
||||
withChallenger = challenger.attachChallenger(enriched, classifyFor);
|
||||
// Session 77 — attach environment (park × weather) + matchup (platoon)
|
||||
// per grade. The batter hand rides on the statcast row; the rest is
|
||||
// fetched once here. Best-effort: a context failure leaves archetype-only.
|
||||
let contextFor = null;
|
||||
try {
|
||||
const envCtx = deps.environmentContext || require('./environmentContext');
|
||||
const ctx = await envCtx.buildContext(sp, {
|
||||
origin: process.env.BACKEND_SELF_ORIGIN || 'http://localhost:3000',
|
||||
});
|
||||
// Enrich each grade with the hitter hand the platoon estimate needs
|
||||
// (statcast_aggregates.bats, already loaded above).
|
||||
const handOf = (name) => {
|
||||
const row = rowsByKey.get(nameKey(name || ''));
|
||||
return row ? row.bats : null;
|
||||
};
|
||||
contextFor = (g) => ctx.contextFor({ ...g, bats: g.bats || handOf(g.player || g.player_name) });
|
||||
console.log(`[env] ${sp} — ${ctx.stats.games || 0} games, ${ctx.stats.venues_with_weather || 0} weather, ${ctx.stats.opp_declared || 0} opp-SP declared`);
|
||||
} catch (e) { console.warn(`[env] ${sp} context skipped:`, e.message); }
|
||||
|
||||
withChallenger = await challenger.attachChallenger(enriched, classifyFor, contextFor);
|
||||
const moved = withChallenger.filter((g) => g.challenger_delta).length;
|
||||
console.log(`[challenger] ${sp} — ${moved}/${withChallenger.length} grades adjusted by archetype`);
|
||||
const envMoved = withChallenger.filter((g) => g.env_multiplier != null).length;
|
||||
const platoonMoved = withChallenger.filter((g) => (g.challenger_adjustments || []).some((a) => a.axis === 'matchup')).length;
|
||||
console.log(`[challenger] ${sp} — ${moved}/${withChallenger.length} adjusted (env ${envMoved}, platoon ${platoonMoved})`);
|
||||
}
|
||||
} catch (e) {
|
||||
// The challenger must NEVER break the pipeline it is measured inside.
|
||||
|
||||
Reference in New Issue
Block a user