Files
vyndr/tests/unit/challengerMappings.test.js
T
builtbykev 927e867a23 Layer 3 Step 3: Tier-1 mappings live; Tier-2 nomination harness
PHASE 0 GATE — historical out-of-sample testing is NOT available, and the reason
matters. statcast_aggregates is overwritten nightly by design (Layer 1 is a full
re-pull upsert), so it holds season-TO-DATE numbers with no point-in-time
history. Classifying a player for a 15 July game using today's aggregate would
feed the model games from 15-21 July — look-ahead leakage, and the resulting
"out-of-sample" verdict would be worthless. The harness therefore reads the
archetype vector RETAINED at grade time (Session 70's instrument) and runs
FORWARD-ACCRUAL, not historical. Reported rather than worked around.

CANONICAL NAMES ASSERTED. Every mapping references the axis keys the classifier
actually emits, and a test walks both maps against BATTER_AXES / PITCHER_AXES.
A key that does not exist would look active and never fire — a mapping that
appears wired while silently doing nothing is the exact failure this guards.

TIER 1 IS LIVE, tautological and directional: PUNCHOUT/WHIFF raises strikeouts;
SINKER/SEAM lowers home runs allowed and FLY BALL/ELEVATOR raises them (a ball
on the ground cannot leave the park); SURGEON ARM/PINPOINT lowers walks allowed;
SLUGGER/BOMBER raises total bases and home runs; TECHNICIAN/SURGEON raises hits
and lowers strikeouts; GRINDER/SNIPER raises walks. Each adjusts only its named
stat, mirrors exactly on the under side, and leaves an average player untouched.

SPEED IS HONESTLY ABSENT. BURNER/stolen-bases has no axis to key on — SB is a
statsapi field that never reached the aggregate store, so Layer 2 shelved it.
The mapping is an empty object rather than an invented one.

THE TIER-2 HARNESS tests MARGINAL CONTRIBUTION, not correlation. A ground-ball
arm obviously correlates with fewer home runs; the question is whether the
archetype explains the PROJECTION'S RESIDUAL (outcome minus p_win). If the
projection already knows it, the residual carries no signal and the mapping is
rejected as redundant — that hurdle is what catches double-counting. The split
is by DATE, never random, because rows from one game share a pitcher, a park and
a lineup and would leak across a random split. Direction is validated from the
held-out data and a contradicted sign is REJECTED, never silently flipped to
whatever the data says, which would be fitting noise.

LIFECYCLE ENCODED — nominated, live, claimed. A mapping that survives runs live
and is measured; only the quantified public claim waits for the ledger. Nothing
sits dark.

One fixture bug worth recording: my first synthetic generator aliased the
carrier selector against the outcome draw and manufactured a 0.038 effect where
the generator had put zero. The harness rejected it correctly — it just gave the
sign reason instead of the redundancy reason, which is how I found it. The draw
now uses a coprime modulus.

Real candidate run end to end, GROUND-BALL to hits-allowed: INSUFFICIENT, 0 of
200 settled rows, because no settled row carries p_win yet (Session 70's
instrument starts recording at the next new lock). That is the correct verdict
and the expected one.

Tests 3654 passed / 296 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 00:30:16 -04:00

173 lines
7.7 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 72 — TIER-1 MAPPINGS LIVE + the TIER-2 nomination harness.
============================================================ */
const ch = require('../../src/services/challengerProjection');
const axes = require('../../src/services/archetypeAxes');
const harness = require('../../src/services/mappingHarness');
// ── 1. NO SILENT NO-OPS ──────────────────────────────────────────────────
describe('every mapping key is a CANONICAL emitted axis', () => {
it('batter mappings reference only real batter axes', () => {
const bad = [];
for (const [stat, m] of Object.entries(ch.BATTER_MAP)) {
for (const k of Object.keys(m)) if (!(k in axes.BATTER_AXES)) bad.push(`${stat}.${k}`);
}
// A key that does not exist would look active and never fire.
expect(bad).toEqual([]);
});
it('pitcher mappings reference only real pitcher axes', () => {
const bad = [];
for (const [stat, m] of Object.entries(ch.PITCHER_MAP)) {
for (const k of Object.keys(m)) if (!(k in axes.PITCHER_AXES)) bad.push(`${stat}.${k}`);
}
expect(bad).toEqual([]);
});
});
// ── 2. TIER 1 FIRES, WITH THE RIGHT SIGN ─────────────────────────────────
const PITCHER = (o) => axes.classifyPlayer({
role: 'pitcher', role_detail: 'starter', sample_ip: 80,
k_pct: 22, bb_pct: 8, chase_pct: 30, gb_pct: 42, fb_pct: 26, barrel_pct: 8, ...o,
});
const BATTER = (o) => axes.classifyPlayer({
role: 'batter', sample_pa: 300,
k_pct: 22, bb_pct: 8, chase_pct: 30, barrel_pct: 7, avg_launch_angle: 14, sweet_spot_pct: 33, ...o,
});
const move = (cls, stat, dir = 'over', p = 0.5) =>
ch.adjust({ pWin: p, direction: dir, statType: stat, classification: cls }).delta;
describe('TIER 1 — tautological mappings, live and directional', () => {
it('WHIFF / PUNCHOUT arm → strikeouts UP', () => {
expect(move(PITCHER({ k_pct: 31 }), 'strikeouts')).toBeGreaterThan(0);
});
it('SINKER / SEAM arm → home runs allowed DOWN', () => {
expect(move(PITCHER({ gb_pct: 55 }), 'home_runs_allowed')).toBeLessThan(0);
});
it('FLY BALL / ELEVATOR arm → home runs allowed UP', () => {
expect(move(PITCHER({ fb_pct: 36 }), 'home_runs_allowed')).toBeGreaterThan(0);
});
it('SURGEON ARM / PINPOINT → walks allowed DOWN', () => {
expect(move(PITCHER({ bb_pct: 5 }), 'walks_allowed')).toBeLessThan(0);
});
it('SLUGGER / BOMBER → total bases UP and home runs UP', () => {
const slugger = BATTER({ barrel_pct: 14 });
expect(move(slugger, 'total_bases')).toBeGreaterThan(0);
expect(move(slugger, 'home_runs')).toBeGreaterThan(0);
});
it('TECHNICIAN / SURGEON → hits UP and strikeouts DOWN', () => {
const contact = BATTER({ k_pct: 12 });
expect(move(contact, 'hits')).toBeGreaterThan(0);
expect(move(contact, 'strikeouts')).toBeLessThan(0);
});
it('GRINDER / SNIPER → walks UP', () => {
expect(move(BATTER({ chase_pct: 21 }), 'walks')).toBeGreaterThan(0);
});
it('an average player is untouched on every Tier-1 stat', () => {
for (const s of ['strikeouts', 'home_runs_allowed', 'walks_allowed']) {
expect(move(PITCHER({}), s)).toBe(0);
}
for (const s of ['total_bases', 'home_runs', 'hits', 'walks']) {
expect(move(BATTER({}), s)).toBe(0);
}
});
it('every Tier-1 mapping mirrors exactly on the UNDER side', () => {
const p = PITCHER({ k_pct: 31 });
expect(move(p, 'strikeouts', 'under', 0.5)).toBeCloseTo(-move(p, 'strikeouts', 'over', 0.5), 3);
});
});
describe('SPEED mappings are honestly absent, not faked', () => {
it('stolen_bases has no axis to key on — Layer 2 shelved speed', () => {
// SB is a statsapi field that never reached the aggregate store, so a
// BURNER axis does not exist. The mapping is empty rather than invented.
expect(ch.BATTER_MAP.stolen_bases).toEqual({});
expect(move(BATTER({}), 'stolen_bases')).toBe(0);
});
});
// ── 3. THE TIER-2 HARNESS ────────────────────────────────────────────────
/** Rows where the archetype carries REAL residual signal the projection missed. */
function rows({ n = 300, effect = 0.10, withRate = 0.4, stat = 'hits_allowed', flipInTest = false }) {
const out = [];
for (let i = 0; i < n; i++) {
const day = 1 + Math.floor(i / 10); // ~30 dates
const isTest = day > 18;
const has = i % Math.round(1 / withRate) === 0;
const eff = has ? (flipInTest && isTest ? -effect : effect) : 0;
// p_win fixed at .5; hit rate shifted by the effect for archetype-carriers.
// The draw uses a modulus COPRIME with the carrier selector — otherwise the
// two alias and the fixture manufactures an effect the generator never put
// there (which is exactly what happened first time, and the harness caught).
const y = ((i * 37) % 101) / 101 < 0.5 + eff ? 'hit' : 'miss';
out.push({
stat, p_win: 0.5, outcome: y,
game_date: `2026-06-${String(day).padStart(2, '0')}`,
archetype_vector: has ? { vector: { ground_ball: { tier: 'hi', label: 'SINKER' } } } : { vector: {} },
});
}
return out;
}
const CAND = { axis: 'ground_ball', stat: 'hits_allowed', sign: -1, role: 'pitcher' };
describe('Tier-2 harness — marginal, out-of-sample, direction-validated', () => {
it('INSUFFICIENT below the row floor — never a nomination on thin data', () => {
const r = harness.evaluateMapping(rows({ n: 50 }), CAND);
expect(r.verdict).toBe('INSUFFICIENT');
expect(r.reason).toMatch(/need 200 settled rows/);
});
it('REJECTS a redundant mapping — the projection already knows it', () => {
// Zero residual difference between carriers and non-carriers.
const r = harness.evaluateMapping(rows({ effect: 0 }), CAND);
expect(r.verdict).toBe('REJECT');
expect(r.reason).toMatch(/redundant/);
});
it('REJECTS an overfit mapping — effect in fit, gone/flipped out-of-sample', () => {
const r = harness.evaluateMapping(rows({ effect: 0.12, flipInTest: true }), { ...CAND, sign: +1 });
expect(r.verdict).toBe('REJECT');
});
it('NEVER silently flips a contradicted sign', () => {
const r = harness.evaluateMapping(rows({ effect: 0.10 }), { ...CAND, sign: -1 });
if (r.verdict === 'REJECT') expect(r.reason).toMatch(/contradicts the candidate sign/);
expect(r.verdict).not.toBe('NOMINATE'); // the data says +, the candidate says
});
it('NOMINATES a mapping with real out-of-sample residual signal', () => {
const r = harness.evaluateMapping(rows({ effect: 0.10 }), { ...CAND, sign: +1 });
expect(r.verdict).toBe('NOMINATE');
expect(Math.abs(r.test_effect)).toBeGreaterThanOrEqual(harness.MIN_EFFECT);
expect(r.direction_holds).toBe(true);
});
it('splits by DATE, never randomly — same-game rows would leak', () => {
const s = harness.splitByDate(rows({ n: 300 }));
const fitDates = new Set(s.fit.map((r) => r.game_date));
const testDates = new Set(s.test.map((r) => r.game_date));
for (const d of testDates) expect(fitDates.has(d)).toBe(false);
});
it('reads the archetype RETAINED at grade time, never a re-classification', () => {
const src = require('fs').readFileSync(require.resolve('../../src/services/mappingHarness'), 'utf8');
expect(src).toMatch(/archetype_vector/);
expect(src).toMatch(/look-ahead leakage/);
});
});
describe('lifecycle — nothing sits dark', () => {
it('nominated → live → claimed, and only the CLAIM is withheld', () => {
expect(harness.LIFECYCLE).toEqual(['nominated', 'live', 'claimed']);
});
});