6452926732
Two fixes in the weather path, and the second was hiding behind the first. The scalar weather_mod cannot express a hit-TYPE conversion at all -- wind out and warm turning fly balls into extra bases, and cold heavy air turning them into outs, collapse to the same number once multiplied -- so the raw temperature, wind speed and wind direction are now retained alongside it. And the old guard only kept the environment when the multiplier was not 1, which silently discarded the forecast for every ordinary night. That is the majority of games, and precisely the rows a hit-type model would need in order to learn what ordinary looks like. Platoon severity is built and measured at n=452, which is 48 rows short of the gate: CANDIDATE_PENDING, neither proven nor theatre. It moves less than flat platoon (0.021 against 0.026), consistent with the pattern, and its Brier point estimate is favourable but the corrected interval still spans zero. Worth naming: the refusal costs sample, and that is the design working. Flat platoon scores 741 rows because it will happily apply a boost to anyone; severity scores 452 because the other 289 are hitters whose split we cannot actually read at 60 plate appearances on the short side. Buying those rows back by shrinking instead of refusing would have produced a number indistinguishable from a measured league-average split, which is a different claim from the one the data supports. Park dimensions are ingested and verified in production across fifteen venues, joined by the venue the game is actually at rather than inferred from the home team -- neutral-site and international games break that assumption without surfacing an error. The park-and-weather-to-hit-type atom is NOT built. Its inputs landed this session and carry a single as_of date, so testing it on total_bases would be scoring games with inputs that postdate them. Building it now would produce something plausible rather than something proven. Proven factors for hits remain pitcher_contact_profile and defense_by_direction. 4,307 tests green (344 suites); web build exit 0. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W1sivYNqY2TS5ftykmHBU9
350 lines
16 KiB
JavaScript
350 lines
16 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));
|
||
|
||
// MIGRATED to the shared guard (2026-08-02). `Number(null) === 0` has produced
|
||
// at least SIX separate defects in this codebase, including one in a module
|
||
// written the same week its author documented the trap — per-module vigilance
|
||
// has demonstrably failed. Semantics are byte-identical to the local copy this
|
||
// replaces, so no output changes; the point is that there is now ONE rule.
|
||
const { knownNumber } = require('../utils/known');
|
||
const num = knownNumber;
|
||
|
||
/**
|
||
* 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,
|
||
// The RAW forecast rides through to the ledger — a hit-type conversion
|
||
// needs wind direction, which no scalar coefficient can carry.
|
||
wx_forecast: ctx.environment ? (ctx.environment.wx_forecast || null) : 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 },
|
||
};
|