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
This commit is contained in:
Kev
2026-07-21 00:30:16 -04:00
parent 56fee267c9
commit 927e867a23
4 changed files with 374 additions and 1 deletions
+13
View File
@@ -59,10 +59,23 @@ const BATTER_MAP = Object.freeze({
stolen_bases: {}, stolen_bases: {},
}); });
/**
* TIER 1 — TAUTOLOGICAL. The archetype IS the stat tendency, so these are safe
* in DIRECTION without an empirical test and run live from day one. They are
* still MEASURED like everything else; only a quantified accuracy claim waits.
* Keys below are the CANONICAL axis keys the classifier emits — a key that does
* not exist would be a silent no-op, so `challengerMappings.test.js` asserts
* every one against BATTER_AXES / PITCHER_AXES.
*/
const PITCHER_MAP = Object.freeze({ const PITCHER_MAP = Object.freeze({
strikeouts: { strikeout: +1, chase: +1, velocity: +1, contact_allowed: -1 }, strikeouts: { strikeout: +1, chase: +1, velocity: +1, contact_allowed: -1 },
pitcher_strikeouts: { strikeout: +1, chase: +1, velocity: +1, contact_allowed: -1 }, pitcher_strikeouts: { strikeout: +1, chase: +1, velocity: +1, contact_allowed: -1 },
hits_allowed: { strikeout: -1, contact_allowed: +1, ground_ball: -1 }, hits_allowed: { strikeout: -1, contact_allowed: +1, ground_ball: -1 },
// Tier 1: a ground-ball arm allows fewer home runs; a fly-ball arm more.
// The most tautological pair in the set — a ball on the ground cannot leave
// the park.
home_runs_allowed: { ground_ball: -1, fly_ball: +1, contact_allowed: +1 },
home_runs: { ground_ball: -1, fly_ball: +1, contact_allowed: +1 },
earned_runs: { contact_allowed: +1, wild: +1, strikeout: -1 }, earned_runs: { contact_allowed: +1, wild: +1, strikeout: -1 },
outs_recorded: { control: +1, ground_ball: +1, wild: -1 }, outs_recorded: { control: +1, ground_ball: +1, wild: -1 },
innings_pitched: { control: +1, ground_ball: +1, wild: -1 }, innings_pitched: { control: +1, ground_ball: +1, wild: -1 },
+188
View File
@@ -0,0 +1,188 @@
'use strict';
/**
* TIER-2 MAPPING NOMINATION HARNESS (Layer 3, Step 3).
*
* Decides whether a candidate archetype→stat mapping earns the right to run
* live. Three hurdles, all of which a plausible-but-worthless mapping fails:
*
* 1. MARGINAL CONTRIBUTION, not raw correlation. The question is never "does
* this archetype correlate with this stat" — a ground-ball arm obviously
* correlates with fewer home runs. The question is whether it adds signal
* BEYOND what the projection already captures. We score the projection's
* RESIDUAL (outcome p_win) against the archetype: if the projection
* already knows it, the residual carries no signal and the mapping is
* REDUNDANT → rejected. This is the hurdle that catches double-counting.
*
* 2. OUT-OF-SAMPLE. Fit the effect on one period, measure it on a held-out
* period the fit never touched. A mapping that only works in-sample is
* overfit → rejected. The split is by DATE, never random: rows from one
* game leak into each other, so a random split would leak.
*
* 3. DIRECTION VALIDATED FROM DATA. A correct pairing with a flipped sign is
* worse than no mapping at all. If the held-out effect contradicts the
* candidate's assumed sign, it is rejected — never silently flipped to
* whatever the data says, because that is fitting noise.
*
* Under all three: MIN_ROWS. Below it the verdict is INSUFFICIENT, never a
* nomination — the same honest-absent rule the rest of the system runs on.
*/
const MIN_ROWS = Number(process.env.MAPPING_MIN_ROWS) || 200;
const MIN_HELD_OUT = Number(process.env.MAPPING_MIN_HELD_OUT) || 60;
/** The held-out residual effect must clear this to be a real lean, not drift. */
const MIN_EFFECT = 0.02;
const num = (v) => {
if (v == null || v === '') return null;
const n = typeof v === 'number' ? v : Number(v);
return Number.isFinite(n) ? n : null;
};
/**
* Split by DATE, never randomly. Rows from the same game share a pitcher, a
* park and a lineup; a random split puts correlated rows on both sides and
* leaks. `fitFrac` of the distinct dates (earliest first) fit; the rest test.
*/
function splitByDate(rows, fitFrac = 0.6) {
const dates = [...new Set(rows.map((r) => String(r.game_date)))].sort();
if (dates.length < 2) return { fit: [], test: [], dates, split: null };
const cut = Math.max(1, Math.floor(dates.length * fitFrac));
const fitDates = new Set(dates.slice(0, cut));
return {
fit: rows.filter((r) => fitDates.has(String(r.game_date))),
test: rows.filter((r) => !fitDates.has(String(r.game_date))),
dates,
split: dates[cut - 1],
};
}
/**
* meanResidual(rows) — average of (outcome p_win).
* Positive = the projection UNDER-estimated these props; negative = over. This
* is the projection's own error, which is exactly what a new signal has to
* explain to be worth anything.
*/
function meanResidual(rows) {
const vals = rows
.map((r) => {
const p = num(r.p_win);
const y = r.outcome === 'hit' ? 1 : r.outcome === 'miss' ? 0 : null;
return p == null || y == null ? null : y - p;
})
.filter((v) => v != null);
if (!vals.length) return null;
return vals.reduce((a, b) => a + b, 0) / vals.length;
}
/**
* evaluateMapping(rows, candidate) — the verdict.
*
* rows: settled records carrying { p_win, outcome, game_date, stat, archetype_vector }
* candidate: { axis, stat, sign, role } sign = +1 (raises the stat) / -1 (lowers)
*
* Returns { verdict, reason, ... } where verdict is one of:
* NOMINATE — marginal, out-of-sample, direction confirmed → runs live
* REJECT — redundant, or the held-out effect contradicts the sign
* INSUFFICIENT — not enough rows to say anything (honest-absent)
*/
function evaluateMapping(rows, candidate = {}) {
const { axis, stat, sign = 1 } = candidate;
const base = {
axis, stat, sign, min_rows: MIN_ROWS, evaluated_at: null,
};
const relevant = (rows || []).filter((r) => {
if (String(r.stat || '').toLowerCase() !== String(stat || '').toLowerCase()) return false;
if (num(r.p_win) == null) return false;
return r.outcome === 'hit' || r.outcome === 'miss';
});
if (relevant.length < MIN_ROWS) {
return { ...base, verdict: 'INSUFFICIENT', rows: relevant.length,
reason: `need ${MIN_ROWS} settled rows for ${stat}, have ${relevant.length}` };
}
// Split into HAS-the-archetype vs does not, using the vector retained at
// grade time — never a re-classification from today's stats, which would be
// look-ahead leakage.
const hasAxis = (r) => {
const v = r.archetype_vector;
if (!v || typeof v !== 'object') return false;
const vec = v.vector || v;
return Boolean(vec && vec[axis]);
};
const { fit, test, split, dates } = splitByDate(relevant);
if (test.length < MIN_HELD_OUT) {
return { ...base, verdict: 'INSUFFICIENT', rows: relevant.length, held_out: test.length,
reason: `held-out period too small (${test.length} < ${MIN_HELD_OUT})` };
}
const fitWith = fit.filter(hasAxis);
const fitWithout = fit.filter((r) => !hasAxis(r));
const testWith = test.filter(hasAxis);
const testWithout = test.filter((r) => !hasAxis(r));
if (fitWith.length < 20 || testWith.length < 20) {
return { ...base, verdict: 'INSUFFICIENT', rows: relevant.length,
with_axis_fit: fitWith.length, with_axis_test: testWith.length,
reason: 'too few rows carrying the archetype to separate its effect' };
}
// The EFFECT is the difference in the projection's residual between props
// whose player carries the archetype and props whose player does not. If the
// projection already accounts for the trait, both residuals sit at the same
// place and the effect is ~0 → redundant.
const fitEffect = meanResidual(fitWith) - meanResidual(fitWithout);
const testEffect = meanResidual(testWith) - meanResidual(testWithout);
const expectedSign = Math.sign(sign) || 1;
const directionHolds = Math.sign(testEffect) === expectedSign;
const bigEnough = Math.abs(testEffect) >= MIN_EFFECT;
// In-sample effect present but held-out effect gone = overfit.
const survivesOOS = bigEnough && directionHolds;
const out = {
...base,
rows: relevant.length,
split_date: split, dates: dates.length,
fit_rows: fit.length, test_rows: test.length,
with_axis_fit: fitWith.length, with_axis_test: testWith.length,
fit_effect: Math.round(fitEffect * 1000) / 1000,
test_effect: Math.round(testEffect * 1000) / 1000,
direction_holds: directionHolds,
};
if (!bigEnough) {
return { ...out, verdict: 'REJECT',
reason: `held-out effect ${out.test_effect} is below ${MIN_EFFECT} — the projection already captures it (redundant)` };
}
if (!directionHolds) {
// Never silently flip: a sign that only reverses out-of-sample is noise.
return { ...out, verdict: 'REJECT',
reason: `held-out effect ${out.test_effect} contradicts the candidate sign ${expectedSign}` };
}
return { ...out, verdict: 'NOMINATE', survivesOOS,
reason: `adds ${out.test_effect} residual signal out-of-sample with the expected sign` };
}
/**
* LIFECYCLE — nothing sits dark.
* nominated : survived the harness → RUNS LIVE as a challenger mapping,
* adjusting real projections and measured by the instrument.
* live : running and accruing settled volume.
* claimed : ONLY after the live ledger shows it beats the market. This is
* the only stage that is withheld, and only the CLAIM is.
*/
const LIFECYCLE = Object.freeze(['nominated', 'live', 'claimed']);
module.exports = {
evaluateMapping,
splitByDate,
meanResidual,
LIFECYCLE,
MIN_ROWS,
MIN_HELD_OUT,
MIN_EFFECT,
};
+172
View File
@@ -0,0 +1,172 @@
/* ============================================================
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']);
});
});
+1 -1
View File
File diff suppressed because one or more lines are too long