diff --git a/src/services/challengerProjection.js b/src/services/challengerProjection.js index ed35f06..347e4cb 100644 --- a/src/services/challengerProjection.js +++ b/src/services/challengerProjection.js @@ -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 = { diff --git a/src/services/environmentContext.js b/src/services/environmentContext.js new file mode 100644 index 0000000..0b9ac14 --- /dev/null +++ b/src/services/environmentContext.js @@ -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, +}; diff --git a/src/services/snapshotService.js b/src/services/snapshotService.js index c6c799f..21c7141 100644 --- a/src/services/snapshotService.js +++ b/src/services/snapshotService.js @@ -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. diff --git a/tests/unit/challengerProjection.test.js b/tests/unit/challengerProjection.test.js index d5eda90..fa3fa6f 100644 --- a/tests/unit/challengerProjection.test.js +++ b/tests/unit/challengerProjection.test.js @@ -108,12 +108,12 @@ describe('isolation — the champion is never touched', () => { expect(a).toEqual(b); }); - it('attachChallenger preserves p_win byte-for-byte on every grade', () => { + it('attachChallenger preserves p_win byte-for-byte on every grade', async () => { const grades = [ { player: 'Aaron Judge', stat_type: 'home_runs', direction: 'over', p_win: 0.42, grade: 'B' }, { player: 'Josh Bell', stat_type: 'home_runs', direction: 'over', p_win: 0.33, grade: 'C' }, ]; - const out = ch.attachChallenger(grades, (n) => (n === 'Aaron Judge' ? JUDGE : BELL)); + const out = await ch.attachChallenger(grades, (n) => (n === 'Aaron Judge' ? JUDGE : BELL)); expect(out[0].p_win).toBe(0.42); expect(out[1].p_win).toBe(0.33); expect(out[0].p_win_challenger).toBeGreaterThan(0.42); // moved @@ -121,8 +121,8 @@ describe('isolation — the champion is never touched', () => { expect(out[1].challenger_delta).toBe(0); }); - it('stamps a version so a future adjustment is distinguishable', () => { - const out = ch.attachChallenger([{ player: 'x', stat_type: 'hits', p_win: 0.5 }], () => null); + it('stamps a version so a future adjustment is distinguishable', async () => { + const out = await ch.attachChallenger([{ player: 'x', stat_type: 'hits', p_win: 0.5 }], () => null); expect(out[0].challenger_version).toBe(ch.CHALLENGER_VERSION); }); }); diff --git a/tests/unit/environmentContext.test.js b/tests/unit/environmentContext.test.js new file mode 100644 index 0000000..373e417 --- /dev/null +++ b/tests/unit/environmentContext.test.js @@ -0,0 +1,151 @@ +/* ============================================================ + 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); + }); +});