Layer 3 Step 2: archetype-aware CHALLENGER, measured not claimed

The champion (probabilityEstimator -> p_win) keeps serving and grading users,
completely unchanged. The challenger is a second probability computed from the
same inputs at the same instant, landing on the same ledger row so it joins to
the same outcome and the same close. Identical conditions, one difference —
the only clean A/B.

NOTHING IS CLAIMED. Running a challenger is honest beta; asserting it is better
before the settled ledger says so is not. Promotion stays a later decision gated
on Brier and calibration over sufficient segmented volume.

INTERPRETABLE, NOT A RE-ESTIMATION. The challenger is the champion's probability
adjusted by the Layer-2 axes, applied in log-odds space so a nudge cannot push
past 0 or 1 and means the same thing at p=0.5 as at p=0.9. Every deviation is
attributable to a named axis and a signed nudge, stored as
challenger_adjustments, and the total is capped at 0.45 log-odds — a lean on a
real signal, never a re-forecast. Only mechanically obvious stat/axis
relationships are mapped; a speculative mapping would be the same guessing this
layer exists to replace.

IDENTICAL WHERE THERE IS NO SIGNAL, by construction. An unremarkable player, a
thin sample, an unmapped stat or a missing classification all return the
champion's probability byte-for-byte with an empty adjustment list and a stated
reason. The experiment therefore differs only where archetype-awareness could
possibly help or hurt, with no dilution from rows the treatment never touched.

Induced on real players. Judge home runs over: 0.42 -> 0.447, via BOMBER +0.22
and WHIFF RISK -0.11 — two real opposing signals netting positive. The same prop
under mirrors it exactly to -0.027. Judge strikeouts: delta exactly 0, because
WHIFF RISK and GRINDER cancel — an honest "no lean" with both signals still
recorded. Skubal strikeouts over: 0.60 -> 0.702 via WHIFF, TRAPDOOR and CANNON
all aligned; his hits-allowed goes the other way, 0.50 -> 0.392, because a
strikeout arm makes hits less likely. Josh Bell and a 12-PA sample are
untouched.

Isolation is structural: adjust() is pure, the champion field is read and never
written, the served snapshot payload is still the untouched champion object, and
a challenger failure is caught so it can never break the pipeline it is measured
inside. Statcast aggregates load once per snapshot run rather than per prop, so
grade-time I/O stays at zero.

Migration 034. Tests 3634 passed / 295 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-20 23:53:32 -04:00
parent 80f7100fc3
commit f2da9dd7e8
5 changed files with 385 additions and 2 deletions
+178
View File
@@ -0,0 +1,178 @@
'use strict';
/**
* CHALLENGER PROJECTION (Layer 3, Step 2) — archetype-aware, measured not claimed.
*
* The CHAMPION (`probabilityEstimator` → `p_win`) keeps serving and grading
* users, completely unchanged. This computes a SECOND probability from the same
* inputs at the same instant, retained beside the champion and joined to the
* same outcome and the same close, so the ledger can decide which is better.
*
* NOTHING HERE IS CLAIMED. Running a challenger is honest beta; asserting it is
* better before the settled ledger says so is not. Promotion is a separate,
* later decision gated on Brier + calibration over sufficient segmented volume.
*
* ── WHY INTERPRETABLE, NOT A RE-ESTIMATION ───────────────────────────────
* The challenger is the champion's probability ADJUSTED by the Layer-2 axes,
* never a black-box re-derivation. That buys three things:
* 1. Every difference is attributable to a named axis and a signed nudge —
* we can see exactly what the archetype changed and where.
* 2. Where the archetype is absent or unremarkable the challenger is
* BYTE-IDENTICAL to the champion, so the A/B differs only where
* archetype-awareness could possibly help or hurt. That is the clean
* experiment: no dilution from rows the treatment never touched.
* 3. A bad adjustment is removable without touching the base projection.
*
* ── ISOLATION ────────────────────────────────────────────────────────────
* The champion is read, never written. `adjust()` is pure: same inputs → same
* output, no shared state, no feedback. A contaminated A/B measures nothing.
*
* ── THE ADJUSTMENT ───────────────────────────────────────────────────────
* Applied in LOG-ODDS space, so a nudge cannot push a probability past 0 or 1
* and the same nudge means the same thing at p=0.5 and p=0.9 (an additive
* probability bump does neither). Magnitudes are deliberately SMALL: this is a
* lean on a real signal, not a re-forecast.
*/
const CHALLENGER_VERSION = 'arch-v1';
/** Log-odds nudges. `elite` = the Layer-2 p90 tier, `hi` = p75. Capped, and the
* total is clamped, so no stack of axes can run away with the projection. */
const NUDGE = Object.freeze({ elite: 0.22, hi: 0.11 });
const MAX_TOTAL_NUDGE = 0.45; // ≈ 10 pts at p=0.5 — a lean, never a re-forecast
/**
* Which axis speaks to which stat, and in which direction.
* `+1` = the trait makes the stat MORE likely, `-1` = less likely.
* Only relationships that are mechanically obvious are encoded — a speculative
* mapping would be the same guessing this whole layer exists to replace.
*/
const BATTER_MAP = Object.freeze({
home_runs: { power: +1, launch: +1, swing_miss: -1, contact: 0 },
total_bases: { power: +1, launch: +1, swing_miss: -1 },
hits: { contact: +1, line_drive: +1, swing_miss: -1, power: 0 },
rbi: { power: +1 },
runs: { patience: +1 },
doubles: { line_drive: +1, power: +1 },
strikeouts: { swing_miss: +1, contact: -1, aggression: +1, patience: -1 },
walks: { patience: +1, aggression: -1 },
stolen_bases: {},
});
const PITCHER_MAP = Object.freeze({
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 },
earned_runs: { contact_allowed: +1, wild: +1, strikeout: -1 },
outs_recorded: { control: +1, ground_ball: +1, wild: -1 },
innings_pitched: { control: +1, ground_ball: +1, wild: -1 },
walks_allowed: { wild: +1, control: -1 },
});
const clamp = (v, lo, hi) => (v < lo ? lo : v > hi ? hi : v);
const toLogOdds = (p) => Math.log(p / (1 - p));
const fromLogOdds = (l) => 1 / (1 + Math.exp(-l));
function num(v) {
if (v == null || v === '') return null;
const n = typeof v === 'number' ? v : Number(v);
return Number.isFinite(n) ? n : null;
}
/**
* adjust({ pWin, direction, statType, classification }) — PURE.
*
* Returns { p_win_challenger, delta, adjustments[], reason }.
* When there is nothing to say, `p_win_challenger === pWin` EXACTLY and
* `adjustments` is empty — the challenger is the champion on those rows, by
* construction, and the comparison stays clean.
*/
function adjust({ pWin, direction, statType, classification } = {}) {
const p = num(pWin);
const identical = (reason) => ({
p_win_challenger: p, delta: 0, adjustments: [], reason, version: CHALLENGER_VERSION,
});
if (p == null || p <= 0 || p >= 1) return identical('no_champion_probability');
if (!classification || !classification.sufficient) return identical('archetype_absent_or_thin');
const vector = classification.vector || {};
const stat = String(statType || '').toLowerCase();
const map = (classification.role === 'pitcher' ? PITCHER_MAP : BATTER_MAP)[stat];
if (!map) return identical('stat_not_mapped');
// Direction: a trait that raises the stat raises P(over) and lowers P(under).
const dirSign = String(direction || 'over').toLowerCase() === 'under' ? -1 : 1;
const adjustments = [];
let total = 0;
for (const [axisKey, sign] of Object.entries(map)) {
if (!sign) continue;
const hit = vector[axisKey];
// null = measured and unremarkable, or no data. Either way: no signal, no
// nudge. Only a DISTINCTIVE trait (>= p75) moves anything.
if (!hit) continue;
const mag = NUDGE[hit.tier] || 0;
if (!mag) continue;
const signed = mag * sign * dirSign;
total += signed;
adjustments.push({ axis: axisKey, label: hit.label, tier: hit.tier, nudge: Math.round(signed * 1000) / 1000 });
}
if (!adjustments.length) return identical('no_distinctive_axis_for_stat');
const capped = clamp(total, -MAX_TOTAL_NUDGE, MAX_TOTAL_NUDGE);
const challenger = clamp(fromLogOdds(toLogOdds(p) + capped), 0.01, 0.99);
const rounded = Math.round(challenger * 1000) / 1000;
return {
p_win_challenger: rounded,
delta: Math.round((rounded - p) * 1000) / 1000,
adjustments,
capped: capped !== total,
reason: null,
version: CHALLENGER_VERSION,
};
}
/**
* attachChallenger(grades, classifyFor) — map a slate's grades to the same
* grades plus challenger fields. `classifyFor(playerName, statType)` returns a
* Layer-2 classification or null; injected so this never does its own I/O and
* tests stay hermetic.
*
* The champion field (`p_win`) is NEVER written here. Read-only by design.
*/
function attachChallenger(grades, classifyFor) {
return (grades || []).map((g) => {
if (!g) return g;
const cls = typeof classifyFor === 'function'
? classifyFor(g.player || g.player_name, g.stat_type || g.stat)
: null;
const out = adjust({
pWin: g.p_win,
direction: g.direction,
statType: g.stat_type || g.stat,
classification: cls,
});
return {
...g,
p_win_challenger: out.p_win_challenger,
challenger_delta: out.delta,
challenger_adjustments: out.adjustments.length ? out.adjustments : null,
challenger_version: CHALLENGER_VERSION,
challenger_reason: out.reason,
};
});
}
module.exports = {
adjust,
attachChallenger,
CHALLENGER_VERSION,
NUDGE,
MAX_TOTAL_NUDGE,
BATTER_MAP,
PITCHER_MAP,
__internals: { toLogOdds, fromLogOdds, clamp, num },
};
+7
View File
@@ -248,6 +248,13 @@ function rowsFromSnapshot(sport, grades, oddsProps, nowIso) {
// calibration can be sliced BY archetype later — a text label could not // calibration can be sliced BY archetype later — a text label could not
// attribute anything. // attribute anything.
p_win: numOrNull(g.p_win), p_win: numOrNull(g.p_win),
// Session 71 — the CHALLENGER, retained beside the champion on the SAME
// row so both join to the same outcome and the same close. The champion
// is what served the user; this is measured, never served.
p_win_challenger: numOrNull(g.p_win_challenger),
challenger_delta: numOrNull(g.challenger_delta),
challenger_adjustments: g.challenger_adjustments || null,
challenger_version: g.challenger_version || null,
fair_prob_lock: numOrNull(g.fair_prob), fair_prob_lock: numOrNull(g.fair_prob),
archetype_vector: archetypeVectorOf(g), archetype_vector: archetypeVectorOf(g),
projection_locked_at: gradedTs, projection_locked_at: gradedTs,
+50 -1
View File
@@ -206,6 +206,32 @@ const ACTIVE_SPORTS = ['mlb', 'nba', 'wnba', 'soccer'];
* opts (all injectable): getOdds, gradeAndCacheSlate, resolveStats, classify, * opts (all injectable): getOdds, gradeAndCacheSlate, resolveStats, classify,
* cacheGet, cacheSet, now, nowMs. * cacheGet, cacheSet, now, nowMs.
*/ */
/**
* Statcast aggregates for the season, indexed by our player key. One read per
* snapshot run (~1,350 rows / 5 MB), reused for every grade — the alternative
* is a per-prop lookup inside a tight grading loop.
*/
async function loadStatcastRows(sport) {
try {
const sb = require('../utils/supabase').getSupabaseServiceClient();
if (!sb) return null;
const { data, error } = await sb.from('statcast_aggregates')
.select('*').eq('sport', sport).limit(5000);
if (error || !data) return null;
const map = new Map();
for (const r of data) {
if (!r.player_key) continue;
// A two-way player has two rows; the one with the larger sample is the
// profile his props are about far more often than not.
const prev = map.get(r.player_key);
const size = Number(r.sample_pa || r.sample_ip || 0);
const prevSize = prev ? Number(prev.sample_pa || prev.sample_ip || 0) : -1;
if (!prev || size > prevSize) map.set(r.player_key, r);
}
return map;
} catch { return null; }
}
async function runSnapshot(sport, opts = {}) { async function runSnapshot(sport, opts = {}) {
const sp = String(sport || '').toLowerCase(); const sp = String(sport || '').toLowerCase();
const deps = { const deps = {
@@ -490,6 +516,29 @@ async function runSnapshot(sport, opts = {}) {
// Session 64 — retention persists HERE, after enrichment, so archetype/team/ // Session 64 — retention persists HERE, after enrichment, so archetype/team/
// opponent are populated. Feature values were captured at grade time and are // opponent are populated. Feature values were captured at grade time and are
// NOT touched by the merge (mergeEnrichment only fills the three null fields). // NOT touched by the merge (mergeEnrichment only fills the three null fields).
// Session 71 — CHAMPION / CHALLENGER. The challenger is computed here, where
// the archetype resolve already happened, so grade-time I/O stays at zero.
// `enriched` (champion p_win) is READ, never written: the serving projection
// is untouched, and the challenger rides alongside it to the ledger.
let withChallenger = enriched;
try {
const challenger = deps.challenger || require('./challengerProjection');
const axes = deps.archetypeAxes || require('./archetypeAxes');
const rowsByKey = await (deps.loadStatcast || loadStatcastRows)(sp);
if (rowsByKey && rowsByKey.size) {
const classifyFor = (playerName) => {
const row = rowsByKey.get(nameKey(playerName || ''));
return row ? axes.classifyPlayer(row) : null;
};
withChallenger = challenger.attachChallenger(enriched, classifyFor);
const moved = withChallenger.filter((g) => g.challenger_delta).length;
console.log(`[challenger] ${sp}${moved}/${withChallenger.length} grades adjusted by archetype`);
}
} catch (e) {
// The challenger must NEVER break the pipeline it is measured inside.
console.warn(`[challenger] ${sp} skipped:`, e.message);
}
await persistRetention(enriched); await persistRetention(enriched);
// Line deltas vs the previous snapshot's locked lines. // Line deltas vs the previous snapshot's locked lines.
@@ -523,7 +572,7 @@ async function runSnapshot(sport, opts = {}) {
// Best-effort: the ledger must never break the snapshot. // Best-effort: the ledger must never break the snapshot.
let ledgerWritten = 0; let ledgerWritten = 0;
try { try {
const rec = await deps.ledger.recordPipelineGrades(sp, enriched, props, { now: deps.now }); const rec = await deps.ledger.recordPipelineGrades(sp, withChallenger, props, { now: deps.now });
ledgerWritten = rec.written || 0; ledgerWritten = rec.written || 0;
await deps.ledger.captureClosing(sp, props); await deps.ledger.captureClosing(sp, props);
} catch (e) { } catch (e) {
+149
View File
@@ -0,0 +1,149 @@
/* ============================================================
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_for_stat'],
['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/);
});
});
+1 -1
View File
File diff suppressed because one or more lines are too long