'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: {}, }); 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 }, 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. */ function adjust({ pWin, direction, statType, classification } = {}) { 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'); if (!classification || !classification.sufficient) return identical('archetype_absent_or_thin'); const vector = classification.vector || {}; const stat = String(statType || '').toLowerCase(); const map = (classification.role === 'pitcher' ? PITCHER_MAP : BATTER_MAP)[stat]; if (!map) 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; 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_for_stat'); 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. */ function attachChallenger(grades, classifyFor) { return (grades || []).map((g) => { if (!g) return g; const cls = typeof classifyFor === 'function' ? classifyFor(g.player || g.player_name, g.stat_type || g.stat) : null; const out = adjust({ pWin: g.p_win, direction: g.direction, statType: g.stat_type || g.stat, classification: cls, }); return { ...g, p_win_challenger: out.p_win_challenger, challenger_delta: out.delta, challenger_adjustments: out.adjustments.length ? out.adjustments : null, challenger_version: CHALLENGER_VERSION, challenger_reason: out.reason, }; }); } module.exports = { adjust, attachChallenger, CHALLENGER_VERSION, NUDGE, MAX_TOTAL_NUDGE, BATTER_MAP, PITCHER_MAP, __internals: { toLogOdds, fromLogOdds, clamp, num }, };