7b25d97891
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
150 lines
7.1 KiB
JavaScript
150 lines
7.1 KiB
JavaScript
/* ============================================================
|
|
Session 71 — CHAMPION / CHALLENGER.
|
|
|
|
The champion serves and grades users, unchanged. The challenger runs live and
|
|
is measured. These lock the three properties that make the A/B worth running:
|
|
isolation, interpretability, and identity-where-there-is-no-signal.
|
|
============================================================ */
|
|
|
|
const ch = require('../../src/services/challengerProjection');
|
|
const axes = require('../../src/services/archetypeAxes');
|
|
|
|
const JUDGE = axes.classifyPlayer({
|
|
role: 'batter', bats: 'R', sample_pa: 261, k_pct: 27.6, bb_pct: 16.1, chase_pct: 25.8,
|
|
barrel_pct: 21.7, hard_hit_pct: 57.3, avg_launch_angle: 14.6, sweet_spot_pct: 33.6,
|
|
});
|
|
const BELL = axes.classifyPlayer({
|
|
role: 'batter', bats: 'S', sample_pa: 387, k_pct: 21.7, bb_pct: 7.5, chase_pct: 30.6,
|
|
barrel_pct: 10.3, hard_hit_pct: 43.4, avg_launch_angle: 13.9, sweet_spot_pct: 33.8,
|
|
});
|
|
const SKUBAL = axes.classifyPlayer({
|
|
role: 'pitcher', role_detail: 'starter', throws: 'L', sample_ip: 82.2, k_pct: 30.5,
|
|
bb_pct: 3.4, chase_pct: 36.7, barrel_pct: 6.7, gb_pct: 49, fb_pct: 22.6, arm_angle: 46.9,
|
|
pitch_mix: [{ type: 'FF', velo: 96.7 }],
|
|
});
|
|
const THIN = axes.classifyPlayer({ role: 'batter', sample_pa: 12, barrel_pct: 30 });
|
|
|
|
describe('identical where there is no signal — the clean-experiment property', () => {
|
|
it.each([
|
|
['unremarkable player', BELL, 'no_distinctive_axis_or_environment'],
|
|
['thin sample', THIN, 'archetype_absent_or_thin'],
|
|
['no classification', null, 'archetype_absent_or_thin'],
|
|
])('%s → challenger === champion, exactly', (_n, cls, reason) => {
|
|
const r = ch.adjust({ pWin: 0.42, direction: 'over', statType: 'home_runs', classification: cls });
|
|
expect(r.p_win_challenger).toBe(0.42);
|
|
expect(r.delta).toBe(0);
|
|
expect(r.adjustments).toEqual([]);
|
|
expect(r.reason).toBe(reason);
|
|
});
|
|
|
|
it('an unmapped stat leaves the champion untouched — no speculative mapping', () => {
|
|
const r = ch.adjust({ pWin: 0.5, direction: 'over', statType: 'nonsense_stat', classification: JUDGE });
|
|
expect(r.p_win_challenger).toBe(0.5);
|
|
expect(r.reason).toBe('stat_not_mapped');
|
|
});
|
|
|
|
it('no champion probability → nothing to adjust', () => {
|
|
for (const p of [null, undefined, 0, 1]) {
|
|
expect(ch.adjust({ pWin: p, direction: 'over', statType: 'hits', classification: JUDGE }).reason)
|
|
.toBe('no_champion_probability');
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('interpretable — every difference is attributable', () => {
|
|
it('Judge HR: BOMBER lifts, WHIFF RISK offsets, and both are named', () => {
|
|
const r = ch.adjust({ pWin: 0.42, direction: 'over', statType: 'home_runs', classification: JUDGE });
|
|
const labels = r.adjustments.map((a) => a.label);
|
|
expect(labels).toContain('BOMBER');
|
|
expect(labels).toContain('WHIFF RISK');
|
|
expect(r.adjustments.find((a) => a.label === 'BOMBER').nudge).toBeGreaterThan(0);
|
|
expect(r.adjustments.find((a) => a.label === 'WHIFF RISK').nudge).toBeLessThan(0);
|
|
expect(r.p_win_challenger).toBeGreaterThan(0.42);
|
|
});
|
|
|
|
it('Skubal strikeouts: WHIFF + TRAPDOOR + CANNON all push the same way', () => {
|
|
const r = ch.adjust({ pWin: 0.6, direction: 'over', statType: 'strikeouts', classification: SKUBAL });
|
|
expect(r.p_win_challenger).toBeGreaterThan(0.6);
|
|
expect(r.adjustments.every((a) => a.nudge > 0)).toBe(true);
|
|
});
|
|
|
|
it('a strikeout arm makes hits-allowed LESS likely — sign is per stat', () => {
|
|
const r = ch.adjust({ pWin: 0.5, direction: 'over', statType: 'hits_allowed', classification: SKUBAL });
|
|
expect(r.p_win_challenger).toBeLessThan(0.5);
|
|
});
|
|
|
|
it('opposing signals can cancel to exactly zero — an honest "no lean"', () => {
|
|
const r = ch.adjust({ pWin: 0.55, direction: 'over', statType: 'strikeouts', classification: JUDGE });
|
|
expect(r.delta).toBe(0);
|
|
expect(r.adjustments.length).toBe(2); // both still recorded
|
|
});
|
|
});
|
|
|
|
describe('direction and bounds', () => {
|
|
it('UNDER mirrors OVER exactly', () => {
|
|
const over = ch.adjust({ pWin: 0.42, direction: 'over', statType: 'home_runs', classification: JUDGE });
|
|
const under = ch.adjust({ pWin: 0.58, direction: 'under', statType: 'home_runs', classification: JUDGE });
|
|
expect(under.delta).toBeCloseTo(-over.delta, 3);
|
|
});
|
|
|
|
it('log-odds space keeps every output a valid probability', () => {
|
|
for (const p of [0.02, 0.5, 0.98]) {
|
|
const r = ch.adjust({ pWin: p, direction: 'over', statType: 'strikeouts', classification: SKUBAL });
|
|
expect(r.p_win_challenger).toBeGreaterThan(0);
|
|
expect(r.p_win_challenger).toBeLessThan(1);
|
|
}
|
|
});
|
|
|
|
it('the total nudge is capped — a stack of axes cannot re-forecast', () => {
|
|
const r = ch.adjust({ pWin: 0.5, direction: 'over', statType: 'strikeouts', classification: SKUBAL });
|
|
expect(Math.abs(r.delta)).toBeLessThan(0.12); // a lean, not a re-forecast
|
|
});
|
|
});
|
|
|
|
describe('isolation — the champion is never touched', () => {
|
|
it('adjust() is pure: same input, same output, no shared state', () => {
|
|
const a = ch.adjust({ pWin: 0.42, direction: 'over', statType: 'home_runs', classification: JUDGE });
|
|
const b = ch.adjust({ pWin: 0.42, direction: 'over', statType: 'home_runs', classification: JUDGE });
|
|
expect(a).toEqual(b);
|
|
});
|
|
|
|
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 = 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
|
|
expect(out[1].p_win_challenger).toBe(0.33); // identical
|
|
expect(out[1].challenger_delta).toBe(0);
|
|
});
|
|
|
|
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);
|
|
});
|
|
});
|
|
|
|
describe('retention + serving contract', () => {
|
|
const fs = require('fs');
|
|
it('the ledger retains the challenger beside the champion on one row', () => {
|
|
const src = fs.readFileSync(require.resolve('../../src/services/ledgerService'), 'utf8');
|
|
expect(src).toMatch(/p_win_challenger: numOrNull\(g\.p_win_challenger\)/);
|
|
expect(src).toMatch(/challenger_adjustments/);
|
|
});
|
|
|
|
it('the snapshot writes the CHALLENGER-carrying grades but serves `enriched`', () => {
|
|
const src = fs.readFileSync(require.resolve('../../src/services/snapshotService'), 'utf8');
|
|
expect(src).toMatch(/recordPipelineGrades\(sp, withChallenger, props/);
|
|
// the served snapshot payload is still the untouched champion object
|
|
expect(src).toMatch(/grades: enriched/);
|
|
});
|
|
|
|
it('a challenger failure can never break the pipeline it is measured inside', () => {
|
|
const src = fs.readFileSync(require.resolve('../../src/services/snapshotService'), 'utf8');
|
|
expect(src).toMatch(/\[challenger\] \$\{sp\} skipped/);
|
|
});
|
|
});
|