9ebd77b68e
The axis was already wired and firing on 0/634 prod rows. Three separate
absences kept it silent, and all three are now joined:
1. oppPitcherByTeam 0 -> the self-origin /api/schedule/mlb/pitchers route
returned nothing in prod. Added the statsapi probable-pitcher hydrate as
a fallback, mirroring the one the schedule step already uses. 29/30
team-sides, one free request.
2. handById 0 -> follows from (1); the batched people call now has ids.
3. bats 0/120 -> batter hand rode ONLY on statcast aggregate rows, which do
not cover the slate. The season player list we ALREADY fetch and cache
carries batSide on 1342/1342, so this is a join, not a fetch.
Switch-hitters ('S') are preserved as-is; platoonSplits decides what to
do with them, not the map.
Verified end-to-end against the live API: opp_declared 29,
pitchers_with_hand 29, batters_with_hand 1342, and a real read --
multiplier 0.966, L vs R, 287 observed PA, weight 0.324 -- composing
alongside environment in one challenger.
FALLBACK LADDER, and a deliberate deviation from the order. Shipped tier:
`batter_own_split` (the hitter's OWN vs-L/vs-R line, regressed toward HIS
OWN overall rate), labelled on every adjustment.
`league_generic` is deliberately NOT implemented. platoonSplits already
handles thin evidence by regressing toward the hitter's own rate, which
covers the thin case per-player; its own doc-comment argues a hitter with
no split evidence should get NO adjustment. A league split applied to such
a hitter models the LEAGUE, not the player -- the doctrine breach the order
itself names in the same step. Adding it would have produced more firing
rows and a weaker signal.
`archetype_x_archetype` is scoped, not built: it needs the opposing
starter classified per game, which is real work and a separate order. The
tier vocabulary is in place for it.
Honest-absent on every join: no starter, no pitcher hand, or no batter hand
-> NO matchup adjustment, never a fabricated neutral. A neutral multiplier
produces no adjustment row at all.
Holdout committed (scripts/matchup-axis-holdout.sql), filtered to
matchup-carrying rows, and it keeps MATCHUP'S OWN nudge visible rather than
only the combined challenger -- arch-v1 composes four axes into one
p_win_challenger, so a combined-only view could not tell which axis earned
the movement, or which one is dragging.
Champion p_win, ranking, calibration, the armed invariant and the two
accruing verdicts are untouched.
Gates: 4,093 tests / 328 suites green; next build exit 0.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QJs13VsyiSKYQP6rj3NNmc
345 lines
15 KiB
JavaScript
345 lines
15 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);
|
||
}
|
||
|
||
/**
|
||
* OPPORTUNITY nudge (2026-08-01) — the drift ratio, in the same log-odds space
|
||
* as the environment and matchup nudges.
|
||
*
|
||
* `opportunity_drift` is recent-5 AB/G over the player's OWN season AB/G, so
|
||
* 1.0 is "exactly his baseline" = no signal = no nudge, exactly like a park
|
||
* multiplier of 1.0.
|
||
*
|
||
* DELIBERATELY SMALLER-CAPPED THAN THE ENVIRONMENT AXIS. Drift is a PROXY for
|
||
* tonight's batting order, not a measurement of it, and a 5-game window is
|
||
* noisy: a rest day, a pinch-hit appearance or a blowout can halve it without
|
||
* any change in role. `MAX_OPPORTUNITY_NUDGE` keeps a noisy proxy from
|
||
* outvoting the measured axes. A drift below the deadband is treated as noise
|
||
* and ignored outright.
|
||
*/
|
||
const OPPORTUNITY_SCALE = Number(process.env.OPPORTUNITY_NUDGE_SCALE) || 1.0;
|
||
const MAX_OPPORTUNITY_NUDGE = 0.15;
|
||
const OPPORTUNITY_DEADBAND = 0.10; // ignore drift within +/-10% of baseline
|
||
|
||
function opportunityNudge(drift, dirSign) {
|
||
const d = num(drift);
|
||
// Undefined drift MUST be a no-op. `Number(null) === 0` would read as
|
||
// "zero at-bats", the strongest possible fade, invented from missing data.
|
||
if (d == null || d <= 0) return 0;
|
||
if (Math.abs(d - 1) < OPPORTUNITY_DEADBAND) return 0;
|
||
const raw = Math.log(d) * OPPORTUNITY_SCALE * dirSign;
|
||
return clamp(raw, -MAX_OPPORTUNITY_NUDGE, MAX_OPPORTUNITY_NUDGE);
|
||
}
|
||
|
||
function adjust({ pWin, direction, statType, classification, environment, matchup, opportunity } = {}) {
|
||
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;
|
||
// Opportunity can stand ALONE: a prop with no archetype, no park and no
|
||
// platoon but a real role change is still a prop the challenger has something
|
||
// to say about. Without this the axis would be silently gated off on exactly
|
||
// the thin-classification rows it is most likely to help.
|
||
const oppPresent = opportunityNudge(opportunity && opportunity.drift, 1) !== 0;
|
||
if ((!classification || !classification.sufficient) && !envPresent && !matchupPresent && !oppPresent) {
|
||
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 && !oppPresent) 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;
|
||
|
||
// ── OPPORTUNITY (drift) — a PROXY for tonight's batting order ───────────
|
||
const oppNudge = opportunityNudge(opportunity && opportunity.drift, dirSign);
|
||
if (oppNudge) {
|
||
total += oppNudge;
|
||
const d = num(opportunity.drift);
|
||
adjustments.push({
|
||
axis: 'opportunity',
|
||
label: d > 1 ? 'ROLE UP' : 'ROLE DOWN',
|
||
tier: 'opportunity',
|
||
nudge: Math.round(oppNudge * 1000) / 1000,
|
||
drift: Math.round(d * 1000) / 1000,
|
||
recent_ab_per_game: opportunity.recent_ab_per_game ?? null,
|
||
season_ab_per_game: opportunity.season_ab_per_game ?? null,
|
||
// Never let a consumer mistake this for the real thing.
|
||
is_proxy: true,
|
||
proxy_for: 'confirmed_batting_order',
|
||
});
|
||
}
|
||
|
||
// ── 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',
|
||
// Which rung of the fallback ladder produced this read.
|
||
tier: matchup.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,
|
||
// Opportunity rides ON THE GRADE (analyzeViaEngine1 attaches it from the
|
||
// feature vector it already built), so this adds zero I/O to a loop that
|
||
// runs over hundreds of props. `ctx.opportunity` can override for tests.
|
||
opportunity: ctx.opportunity || {
|
||
drift: g.opportunity_drift,
|
||
recent_ab_per_game: g.recent_ab_per_game ?? null,
|
||
season_ab_per_game: g.ab_per_game ?? null,
|
||
},
|
||
});
|
||
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,
|
||
opportunityNudge,
|
||
MAX_OPPORTUNITY_NUDGE,
|
||
OPPORTUNITY_DEADBAND,
|
||
NUDGE,
|
||
MAX_TOTAL_NUDGE,
|
||
BATTER_MAP,
|
||
PITCHER_MAP,
|
||
__internals: { toLogOdds, fromLogOdds, clamp, num },
|
||
};
|