Files
vyndr/tests/unit/challengerProjection.test.js
T
builtbykev 3ac91c3d96 Layer 3 Step 4: derived park factors, composable for weather
PHASE 0 GATE — the answer is BOTH, and the important half was already here.
A STATIC FanGraphs park-factor table has existed since Session 15
(src/data/parkFactors.js) and computeFeatures already consumes it, so park is
not a new idea in this codebase. What was missing is OUR derivation. I nearly
built a second source of truth before finding it; the new service lives at
src/services/parkFactors.js and the two are deliberately distinct.

That discovery changes the point of this order rather than just its scope. If
the champion already sees a park factor, adding one to the challenger risks
double-counting — which is exactly the redundancy the Session-72 harness exists
to catch. So park ships as a NOMINATED CHALLENGER whose job is to be tested for
marginal contribution, not as an assumed improvement. Checked and worth noting:
the static table reaches computeFeatures but NOT probabilityEstimator, so it
does not currently touch p_win at all.

DERIVATION, not ingestion. statsapi gives every game with venue, linescore and
scoringPlays in one call per date range — and since every home run scores at
least the batter, HR totals are fully recoverable from scoring plays. Derived
from 5,055 real games across 2022-2025: Coors tops the run environment at
1.099, Dodger Stadium tops home runs at 1.106, Oracle Park and PNC suppress
them at 0.923 and 0.917. Eighteen parks cleared the floor, eighteen did not and
are honestly absent.

COMPOSABLE BY CONSTRUCTION — the architectural point. Park emits a multiplier
around 1.0, never an additive nudge, because weather has to modulate it next
order: effective = park_base x weather_mod. Additive terms do not compose
correctly (a 5% park and an 8% wind are 1.05 x 1.08, not +13%), and the
challenger converts the multiplier to log-odds so stacking stays correct. A
test multiplies a placeholder weather term onto the park base to prove the shape
composes with no rearchitecting.

DIRECTIONAL BY PROP-OWNER: home_runs and home_runs_allowed both key off hr_base
in the same direction, because the sign lives in the STAT, not the park. Coors
inflates the hitter's home run prop and the pitcher's home-runs-allowed prop
identically.

THREE HONEST STATES, deliberately distinct. Absent (thin sample, adjust
nothing), present (adjust), and weather_na for domes — where the park factor
STILL APPLIES because a dome has a real run environment, and the flag exists so
next order's weather modulation correctly does nothing there. N/A is not absent;
conflating them would either drop a valid park factor or apply wind indoors.

Structural breaks: a season deviating past the threshold starts a new regime
only if the FOLLOWING season confirms it — one odd year is noise, two
consecutive years on the same side is a rebuilt park. Only post-break seasons
are used, so a humidor or moved wall cannot be diluted by the stadium that
preceded it. Factors regress toward neutral by sample size, so a two-season park
cannot assert a Coors-sized coefficient, and fine conditioning stays unavailable
until its own larger floor.

Tests 3669 passed / 297 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:06:17 -04:00

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', () => {
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));
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', () => {
const out = 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/);
});
});