7b25d97891
Verified state going in: parkBase, weatherMod and platoonSplits were called by nothing, and env_multiplier was non-null on zero rows across four orders. The adjusters were correct in isolation and starved of inputs. This gives them their inputs and changes none of their internal logic — the five adjuster files are byte-identical after this commit. PHASE 0 GATE — all three inputs are available at snapshot build, and the two join keys already existed. Venue: always, on every schedule game object. First-pitch: always, gameTime on the same object. Opposing-pitcher hand: present once the probable is declared, via the pitchers endpoint's pitcherId joined to statsapi handedness — 15 of 15 games declared this afternoon, though morning locks precede declaration and those props honest-absent on platoon, correctly. The batter-handedness join (statcast bats) and the MLBAM id were already on each grade from earlier sessions. environmentContext.js is the wiring, kept separate from the adjusters so they stay pure. It fetches once per snapshot: the schedule (team to venue, gameTime), probable pitchers (team to opposing pitcher id), one batched handedness call, one Open-Meteo forecast per home park, and batter splits per graded hitter. Park coordinates for 30 parks live here as public geometry, the same class as the dome list and centre-field bearings already in weatherMod, rather than inside an adjuster. Everything is best-effort: a missing venue drops park and weather, an undeclared pitcher drops platoon, and any fetch failure degrades that prop to archetype-only rather than breaking the pipeline the adjusters are measured inside. attachChallenger becomes async and takes a per-grade contextFor that returns the environment coefficient (park_base x weather_mod, composed) and the matchup (platoon). Point-in-time holds: the weather is a forecast for first pitch fetched now, and the split is the hitter's line entering the game — neither reads a settle-time value. Attribution is independent. env_multiplier, env_park_base, env_weather_mod and env_weather_state land in their own ledger columns, and challenger_adjustments keeps every axis — archetype, environment, matchup — as a separate entry, so when volume accrues each of the four can be measured for its own marginal contribution rather than as one blended delta. The combined move stays bounded, tested on the worst case: a Coors slugger with wind out and a favourable platoon, all at once, still moves under 12 percent, because every layer is capped and the total nudge is clamped. Stacking leans, it does not compound into a re-forecast. Non-MLB honest-absents entirely — park, weather and platoon are MLB-only today, so a WNBA prop gets no environment and no matchup. The champion is untouched throughout: p_win is read, never written, the served snapshot payload is still the enriched object, and a test confirms p_win passes through byte-for-byte while the challenger moves. Tests 3741 passed / 301 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
280 lines
12 KiB
JavaScript
280 lines
12 KiB
JavaScript
'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: {},
|
||
});
|
||
|
||
/**
|
||
* 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({
|
||
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 },
|
||
// 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 },
|
||
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.
|
||
*/
|
||
/**
|
||
* Compose an ENVIRONMENT multiplier into the same log-odds space the archetype
|
||
* nudges use. A multiplier of 1.0 is a no-op; >1 leans toward the over.
|
||
*
|
||
* Park is the BASE environment; weather will multiply onto it next order
|
||
* (`env = park_base × weather_mod × …`) with no change here — that is why the
|
||
* park layer emits a coefficient rather than a nudge.
|
||
*
|
||
* `Math.log(env)` converts a multiplicative environment into an additive
|
||
* log-odds term, which is the correct composition: two independent 5% effects
|
||
* become 1.05 × 1.05, not +5% +5%.
|
||
*/
|
||
const ENV_SCALE = Number(process.env.ENV_NUDGE_SCALE) || 1.0;
|
||
const MAX_ENV_NUDGE = 0.30;
|
||
function envNudge(env, dirSign) {
|
||
const e = num(env);
|
||
if (e == null || e <= 0 || e === 1) return 0;
|
||
const raw = Math.log(e) * ENV_SCALE * dirSign;
|
||
return clamp(raw, -MAX_ENV_NUDGE, MAX_ENV_NUDGE);
|
||
}
|
||
|
||
function adjust({ pWin, direction, statType, classification, environment, matchup } = {}) {
|
||
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');
|
||
const envPresent = num(environment && environment.multiplier) != null
|
||
&& num(environment.multiplier) !== 1;
|
||
// MATCHUP (platoon) is deliberately its OWN signal, not folded into the
|
||
// environment coefficient: park and weather describe the stadium, platoon
|
||
// describes this hitter against this pitcher's hand. Keeping them separate is
|
||
// what lets the instrument attribute them independently.
|
||
const matchupPresent = num(matchup && matchup.multiplier) != null
|
||
&& num(matchup.multiplier) !== 1;
|
||
if ((!classification || !classification.sufficient) && !envPresent && !matchupPresent) {
|
||
return identical('archetype_absent_or_thin');
|
||
}
|
||
|
||
const vector = (classification && classification.vector) || {};
|
||
const stat = String(statType || '').toLowerCase();
|
||
const role = classification && classification.role === 'pitcher' ? 'pitcher' : 'batter';
|
||
const map = ((classification && classification.sufficient)
|
||
? (role === 'pitcher' ? PITCHER_MAP : BATTER_MAP)[stat]
|
||
: null) || {};
|
||
if (!Object.keys(map).length && !envPresent && !matchupPresent) 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;
|
||
|
||
// ── MATCHUP (platoon) — independent of the environment ─────────────────
|
||
const mMult = num(matchup && matchup.multiplier);
|
||
if (mMult != null && mMult !== 1) {
|
||
const n = envNudge(mMult, dirSign);
|
||
if (n) {
|
||
total += n;
|
||
adjustments.push({
|
||
axis: 'matchup', label: matchup.label || 'PLATOON',
|
||
tier: 'matchup', nudge: Math.round(n * 1000) / 1000,
|
||
multiplier: Math.round(mMult * 1000) / 1000,
|
||
batter_hand: matchup.batter_hand ?? null,
|
||
pitcher_hand: matchup.pitcher_hand ?? null,
|
||
observed_pa: matchup.observed_pa ?? null,
|
||
observed_weight: matchup.observed_weight ?? null,
|
||
});
|
||
}
|
||
}
|
||
|
||
// ── ENVIRONMENT (park now; weather composes onto it next order) ────────
|
||
// Applied even when no archetype axis fires: a park effect is real whether or
|
||
// not the player is distinctive. `environment` is the COMPOSED multiplier.
|
||
const envMult = num(environment && environment.multiplier);
|
||
if (envMult != null && envMult !== 1) {
|
||
const n = envNudge(envMult, dirSign);
|
||
if (n) {
|
||
total += n;
|
||
adjustments.push({
|
||
axis: 'environment', label: (environment.label || 'PARK'),
|
||
tier: 'env', nudge: Math.round(n * 1000) / 1000,
|
||
multiplier: Math.round(envMult * 1000) / 1000,
|
||
venue: environment.venue || null,
|
||
weather_na: environment.weather_na ?? null,
|
||
});
|
||
}
|
||
}
|
||
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_or_environment');
|
||
|
||
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.
|
||
*/
|
||
async function attachChallenger(grades, classifyFor, contextFor) {
|
||
const out = [];
|
||
for (const g of grades || []) {
|
||
if (!g) { out.push(g); continue; }
|
||
const cls = typeof classifyFor === 'function'
|
||
? classifyFor(g.player || g.player_name, g.stat_type || g.stat)
|
||
: null;
|
||
// Session 77 — environment (park × weather) and matchup (platoon) come from
|
||
// the wiring, per grade. Both optional; a resolver failure degrades that
|
||
// grade to archetype-only rather than breaking the map.
|
||
let ctx = { environment: null, matchup: null };
|
||
if (typeof contextFor === 'function') {
|
||
try { ctx = (await contextFor(g)) || ctx; } catch { /* honest-absent */ }
|
||
}
|
||
const res = adjust({
|
||
pWin: g.p_win,
|
||
direction: g.direction,
|
||
statType: g.stat_type || g.stat,
|
||
classification: cls,
|
||
environment: ctx.environment,
|
||
matchup: ctx.matchup,
|
||
});
|
||
out.push({
|
||
...g,
|
||
p_win_challenger: res.p_win_challenger,
|
||
challenger_delta: res.delta,
|
||
challenger_adjustments: res.adjustments.length ? res.adjustments : null,
|
||
challenger_version: CHALLENGER_VERSION,
|
||
challenger_reason: res.reason,
|
||
// Independent, attributable retention (Session 75 ledger columns). The
|
||
// per-axis breakdown also lives in challenger_adjustments, but these
|
||
// top-level fields keep environment measurable on its own.
|
||
env_multiplier: ctx.environment ? ctx.environment.multiplier : null,
|
||
env_park_base: ctx.environment ? ctx.environment.park_base : null,
|
||
env_weather_mod: ctx.environment ? ctx.environment.weather_mod : null,
|
||
env_weather_state: ctx.environment ? ctx.environment.weather_state : null,
|
||
});
|
||
}
|
||
return out;
|
||
}
|
||
|
||
module.exports = {
|
||
adjust,
|
||
attachChallenger,
|
||
CHALLENGER_VERSION,
|
||
NUDGE,
|
||
MAX_TOTAL_NUDGE,
|
||
BATTER_MAP,
|
||
PITCHER_MAP,
|
||
__internals: { toLogOdds, fromLogOdds, clamp, num },
|
||
};
|