'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 }, };