927e867a23
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
173 lines
7.7 KiB
JavaScript
173 lines
7.7 KiB
JavaScript
/* ============================================================
|
||
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']);
|
||
});
|
||
});
|