Files
vyndr/src/services/challengerProjection.js
T
builtbykev 9ebd77b68e Build the matchup/platoon axis: three joins fixed, axis now FIRES
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
2026-08-02 01:11:05 -04:00

345 lines
15 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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 },
};