The skill engine: built, gated by construction, and Stage A honestly lost

Built src/services/model/ -- the forward, archetype-selected, skill-based
projection, as a challenger. The champion is untouched.

featureRegistry makes "earn its place or it's out" structural rather than
aspirational: CANDIDATE / PROVEN / DEAD per feature per sport, liveFeatures()
returns PROVEN only, promotion requires n>=200 with positive lift and a CI
excluding zero, and there is deliberately no override argument. It ships with
exactly ONE proven feature -- the incumbent counter, because it is the only
one with a measurement. A test asserts that with only PROVEN features allowed
the projection returns null, so an unproven model cannot reach a user by
accident. The three champion adjustment layers are registered DEAD with their
reasons so they cannot be silently rebuilt.

skillProjection is a PA outcome tree: K and BB combined by log5 odds-ratio
against league (both identities unit-tested), then archetype-weighted contact
quality against contact allowed, then Binomial(PA, p_hit) mixed over a PA
distribution. Archetype is a FEATURE SELECTOR, not a nudge -- BOMBER reads
barrels at 0.50 and ground-ball speed at 0.00, GHOST inverts it -- and a test
locks that the same hitter read two ways moves more than 0.15.

STAGE A: IT LOSES. Out-of-sample on 570 settled hits props with 91.9%
opposing-pitcher coverage, resolution 0.0499 against the champion's 0.166,
delta -0.116 with CI [-0.189, -0.043]. It is not selective either: its eight
most confident picks hit 50%, a lift of -0.065. Not promoted. The gate did its
job on its first real test, which is the point of having built it that way.

Two false starts, both recorded because they nearly produced a wrong verdict:
statcast_aggregates stores PERCENTAGES, so raw rows made bip = 1-29.6-17.1 and
refused 568 of 576 -- the honest-absent guards made a units bug loud instead of
silent, and the conversion now lives at one chokepoint. And the first run
resolved an opposing pitcher for 1 of 570 rows, because ledger team/opponent
are NULL, so it would have reported "skill-v1 loses" while measuring a
batter-only model with no matchup in it at all. The verdict above is from the
corrected run.

The loss is real but partial: park was passed as 1.0, handedness and
opportunity_drift never fired, PA is season-PA over a constant, and the skill
profiles carry no recency at all while the champion has a last-5 term.

Also fixed: the Statcast nightly refresh was unreachable code. It sat inside
tick() below "if (!HOURS_UTC.includes(h)) return" while testing h === 11, so
it had never run once; the aggregates were 13 days stale and both of its
alerts were in the same dead branch. It now runs on its own tick, and the test
that passed happily throughout -- it only checked the string existed -- is
replaced by one that asserts it is not behind the guard.

4,182 tests green (333 suites); web build exit 0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W1sivYNqY2TS5ftykmHBU9
This commit is contained in:
Kev
2026-08-03 02:20:40 -04:00
parent c551bf0340
commit 258d8a6655
9 changed files with 1439 additions and 30 deletions
+234
View File
@@ -0,0 +1,234 @@
'use strict';
/**
* featureRegistry — EARN ITS PLACE OR IT'S OUT (Discipline 3).
*
* This codebase has shipped six features that were wired, computed, retained,
* and contributed NOTHING to the served forecast. Measured: arch-v1 moved 76% of
* rows by 2.5 points and changed resolution by 0.0000; the environment and
* opportunity axes are flat; the projection ladder is reliably WORSE than the
* counter it was meant to beat. None of that was visible until someone went
* looking, because nothing in the architecture ever asked a feature to justify
* itself.
*
* So the gate is structural, not a habit:
*
* CANDIDATE — computed, measured in the CHALLENGER, NEVER in the live grade.
* PROVEN — marginal out-of-sample lift measured positive AND significant.
* Only PROVEN features may feed the served projection.
* DEAD — measured and found to add nothing (or to hurt). Kept in the
* registry BY NAME so it cannot be silently rebuilt a year later
* by someone who does not know it was already tried.
*
* THE ONE RULE THIS FILE EXISTS TO ENFORCE:
*
* liveFeatures() returns PROVEN only. A CANDIDATE cannot reach a user by
* accident, and a DEAD feature cannot come back without a new measurement.
*
* Promotion is DATA, not opinion: `promote()` refuses without a measurement
* carrying n, lift, and a confidence interval that excludes zero on the good
* side. There is deliberately no "force" argument. A feature that cannot show
* the number does not move.
*
* WHY EVERY FEATURE STARTS AS A CANDIDATE. Nothing here has earned PROVEN yet —
* the skill model is being measured for the first time. An empty PROVEN set is
* the honest starting state and it is what makes Stage A meaningful: the
* challenger runs on candidates, the ledger judges them, and only then does
* anything go live. Seeding this file with optimistic PROVEN flags would defeat
* the entire purpose of having it.
*/
const STATUS = Object.freeze({ CANDIDATE: 'CANDIDATE', PROVEN: 'PROVEN', DEAD: 'DEAD' });
/**
* Minimum evidence to promote. Deliberately strict: a feature promoted on noise
* is worse than no feature, because it carries the authority of having been
* "measured".
*/
const MIN_PROMOTION_N = 200;
/**
* THE REGISTRY.
*
* `sport` is first-class: per-sport doctrine means a feature proven for MLB says
* nothing about NBA, and the registry must not let one leak into the other.
*
* `evidence` is null until something measures it. `history` accumulates every
* status change with its measurement, so "why is this DEAD" always has an answer.
*/
const FEATURES = [
// ── HITTER SKILL (Statcast) ──────────────────────────────────────────────
{ key: 'batter_exit_velo', sport: 'mlb', status: STATUS.CANDIDATE, unit: 'mph',
describes: 'how hard he hits it — the skill under batting average' },
{ key: 'batter_launch_angle', sport: 'mlb', status: STATUS.CANDIDATE, unit: 'deg',
describes: 'launch profile: ground-ball hitter vs air hitter' },
{ key: 'batter_barrel_pct', sport: 'mlb', status: STATUS.CANDIDATE, unit: 'pct',
describes: 'rate of ideal exit-velo/launch combinations' },
{ key: 'batter_hard_hit_pct', sport: 'mlb', status: STATUS.CANDIDATE, unit: 'pct',
describes: '95+ mph contact rate' },
{ key: 'batter_whiff_pct', sport: 'mlb', status: STATUS.CANDIDATE, unit: 'pct',
describes: 'swing-and-miss rate — drives the strikeout branch' },
{ key: 'batter_k_pct', sport: 'mlb', status: STATUS.CANDIDATE, unit: 'pct',
describes: 'strikeout rate (the PA outcome that ends without a ball in play)' },
{ key: 'batter_bb_pct', sport: 'mlb', status: STATUS.CANDIDATE, unit: 'pct',
describes: 'walk rate — removes a PA from the hit-chance pool' },
// ── PITCHER SKILL (Statcast) ─────────────────────────────────────────────
{ key: 'pitcher_whiff_pct', sport: 'mlb', status: STATUS.CANDIDATE, unit: 'pct',
describes: 'stuff — the skill under ERA' },
{ key: 'pitcher_k_pct', sport: 'mlb', status: STATUS.CANDIDATE, unit: 'pct',
describes: 'strikeout rate allowed' },
{ key: 'pitcher_bb_pct', sport: 'mlb', status: STATUS.CANDIDATE, unit: 'pct',
describes: 'walk rate allowed' },
{ key: 'pitcher_gb_pct', sport: 'mlb', status: STATUS.CANDIDATE, unit: 'pct',
describes: 'ground-ball tendency — suppresses air contact' },
{ key: 'pitcher_hard_hit_allowed', sport: 'mlb', status: STATUS.CANDIDATE, unit: 'pct',
describes: 'contact quality allowed' },
{ key: 'pitcher_barrel_allowed', sport: 'mlb', status: STATUS.CANDIDATE, unit: 'pct',
describes: 'barrel rate allowed — the extra-base driver' },
// ── MATCHUP STRUCTURE ────────────────────────────────────────────────────
{ key: 'handedness_platoon', sport: 'mlb', status: STATUS.CANDIDATE, unit: 'bool',
describes: 'batter/pitcher handedness edge' },
// ── CONTEXT ──────────────────────────────────────────────────────────────
{ key: 'park_factor', sport: 'mlb', status: STATUS.CANDIDATE, unit: 'mult',
describes: "tonight's venue relative to the hitter's own exposure" },
{ key: 'projected_pa', sport: 'mlb', status: STATUS.CANDIDATE, unit: 'count',
describes: 'opportunity — how many times he bats' },
{ key: 'opportunity_drift', sport: 'mlb', status: STATUS.CANDIDATE, unit: 'ratio',
describes: 'recent AB vs season AB/game — the one feature measured to carry residual signal (S78: +0.156 hits, +0.145 TB)' },
// ── THE INCUMBENT ────────────────────────────────────────────────────────
// The counter is IN THE REGISTRY, because the baseline has to be able to lose
// its place too. It is PROVEN — uniquely, it has the measurement: it is ~100%
// of the champion's resolution (S78 ablation, per stat, paired bootstrap).
{ key: 'recent_frequency_prior', sport: 'mlb', status: STATUS.PROVEN, unit: 'prob',
describes: 'empirical frequency of clearing THIS line (the incumbent counter), demoted to a PRIOR in the skill model',
evidence: {
measured_at: '2026-08-03', n: 1415, source: 'specs/champion-input-diagnosis.md',
note: 'per-stat ablation: removing all three adjustment layers changes resolution by ~0, so base+recency IS the champion',
} },
// ── MEASURED AND FOUND WANTING — kept so they are not rebuilt ────────────
{ key: 'champion_opp_rank_adj', sport: 'mlb', status: STATUS.DEAD, unit: 'prob',
describes: 'the ±0.03 opponent bump inside probabilityEstimator',
evidence: { measured_at: '2026-08-03', n: 1415, source: 'specs/champion-input-diagnosis.md',
note: 'ablation delta within noise on every stat' } },
{ key: 'champion_home_away_adj', sport: 'mlb', status: STATUS.DEAD, unit: 'prob',
describes: 'the ±0.015 home/away bump inside probabilityEstimator',
evidence: { measured_at: '2026-08-03', n: 273, source: 'specs/champion-input-diagnosis.md',
note: 'removing it IMPROVED rbi resolution (+0.0053, CI [0.0002,0.0103]) — actively harmful' } },
{ key: 'champion_consistency_pull', sport: 'mlb', status: STATUS.DEAD, unit: 'prob',
describes: 'cv>0.40 shrink toward 0.50',
evidence: { measured_at: '2026-08-03', n: 1415, source: 'specs/champion-input-diagnosis.md',
note: 'ablation delta ≤0.0018 on every stat' } },
];
const byKey = new Map();
for (const f of FEATURES) byKey.set(`${f.sport}|${f.key}`, { ...f, evidence: f.evidence || null, history: [] });
const idOf = (sport, key) => `${String(sport || '').toLowerCase()}|${key}`;
/** Every registered feature for a sport (any status). */
function allFeatures(sport) {
const sp = String(sport || '').toLowerCase();
return [...byKey.values()].filter((f) => f.sport === sp).map((f) => ({ ...f }));
}
/**
* THE GATE. Only PROVEN features may feed the SERVED projection.
*
* Returns a Set of keys, because the caller's question is always "may I use
* this one?" and a Set makes the wrong answer awkward to write.
*/
function liveFeatures(sport) {
return new Set(allFeatures(sport).filter((f) => f.status === STATUS.PROVEN).map((f) => f.key));
}
/** Features the CHALLENGER is allowed to measure — candidates plus what's live. */
function candidateFeatures(sport) {
return new Set(allFeatures(sport)
.filter((f) => f.status === STATUS.CANDIDATE || f.status === STATUS.PROVEN)
.map((f) => f.key));
}
function statusOf(sport, key) {
const f = byKey.get(idOf(sport, key));
return f ? f.status : null;
}
/** Is this feature allowed in the LIVE path right now? */
function isLive(sport, key) {
return statusOf(sport, key) === STATUS.PROVEN;
}
/**
* Evidence sufficient to promote: a real sample, positive lift, and a CI that
* excludes zero on the good side. Anything less is a story about a number.
*/
function isSufficient(evidence) {
if (!evidence || typeof evidence !== 'object') return false;
const n = Number(evidence.n);
const lift = Number(evidence.lift);
const ci = evidence.ci95;
if (!Number.isFinite(n) || n < MIN_PROMOTION_N) return false;
if (!Number.isFinite(lift) || lift <= 0) return false;
if (!Array.isArray(ci) || ci.length !== 2) return false;
const [lo, hi] = ci.map(Number);
if (!Number.isFinite(lo) || !Number.isFinite(hi)) return false;
return lo > 0; // the whole interval above zero — improvement, not a coin flip
}
/**
* Promote a CANDIDATE to PROVEN. Refuses without sufficient evidence, and there
* is no override parameter on purpose.
*/
function promote(sport, key, evidence, at = null) {
const f = byKey.get(idOf(sport, key));
if (!f) return { ok: false, reason: 'unknown_feature' };
if (!isSufficient(evidence)) return { ok: false, reason: 'insufficient_evidence', required: { min_n: MIN_PROMOTION_N, lift: '>0', ci95_low: '>0' } };
f.history.push({ from: f.status, to: STATUS.PROVEN, evidence, at });
f.status = STATUS.PROVEN;
f.evidence = evidence;
return { ok: true, status: f.status };
}
/**
* Demote to DEAD. Unlike promotion this needs no threshold — removing something
* that is not helping is always safe, and the standing re-ablation must be able
* to act the moment a feature stops earning its place.
*/
function demote(sport, key, evidence, at = null) {
const f = byKey.get(idOf(sport, key));
if (!f) return { ok: false, reason: 'unknown_feature' };
f.history.push({ from: f.status, to: STATUS.DEAD, evidence: evidence || null, at });
f.status = STATUS.DEAD;
f.evidence = evidence || f.evidence;
return { ok: true, status: f.status };
}
/** Registry summary — what is live, what is being measured, what died and why. */
function summary(sport) {
const fs = allFeatures(sport);
const pick = (s) => fs.filter((f) => f.status === s).map((f) => f.key);
return {
sport: String(sport || '').toLowerCase(),
proven: pick(STATUS.PROVEN),
candidate: pick(STATUS.CANDIDATE),
dead: pick(STATUS.DEAD).map((k) => ({ key: k, why: (byKey.get(idOf(sport, k)).evidence || {}).note || null })),
total: fs.length,
};
}
/** Test-only: restore the declared statuses so suites cannot leak into each other. */
function __reset() {
byKey.clear();
for (const f of FEATURES) byKey.set(`${f.sport}|${f.key}`, { ...f, evidence: f.evidence || null, history: [] });
}
module.exports = {
STATUS, MIN_PROMOTION_N,
allFeatures, liveFeatures, candidateFeatures, statusOf, isLive,
isSufficient, promote, demote, summary, __reset,
};
+417
View File
@@ -0,0 +1,417 @@
'use strict';
/**
* skillProjection (skill-v1) — A FORWARD READ OF TONIGHT'S PLATE APPEARANCES.
*
* This is the windshield. The incumbent is a rear-view mirror: it counts how
* often the hitter cleared this number lately and calls that a forecast. It has
* never seen the pitcher. Measured, that counter IS the champion — removing all
* three of its adjustment layers changes resolution by nothing — so beating it
* requires different INFORMATION, not a different distribution. Two challengers
* (proj-v1.1, hits-v1) already proved that by swapping the distribution and
* losing.
*
* ── DISCIPLINE 1: SKILL, NOT RESULTS ─────────────────────────────────────
* Every input here measures the skill that CAUSES the outcome. Exit velocity,
* not batting average. Whiff rate, not ERA. Results are luck-contaminated —
* a .340 BABIP regresses and a 95-mph average exit velocity does not — so a
* model built on results is fitting noise it will not see again.
*
* ── THE GENERATIVE STRUCTURE ─────────────────────────────────────────────
* A hitter gets N plate appearances. Each one resolves through a tree:
*
* PA ──> strikeout (batter whiff/K vs pitcher whiff/K)
* ──> walk (batter BB vs pitcher BB)
* ──> ball in play (everything else)
* └─> hit? (contact quality vs contact allowed, park)
* └─> how many bases? (launch/barrel profile)
*
* Rates are combined by the ODDS RATIO (log5) against league average — the
* standard way to answer "this batter vs THIS pitcher" rather than "this batter
* vs the average pitcher":
*
* odds = (b/(1b)) · (p/(1p)) / (lg/(1lg))
*
* With b = league and p = league it returns league; with an average pitcher it
* returns the batter's own rate. That identity is the reason to use it, and it
* is unit-tested here rather than assumed.
*
* ── DISCIPLINE 2: ARCHETYPE SELECTS FEATURES ─────────────────────────────
* The archetype is NOT a nudge and NOT a label. It decides WHICH skill inputs
* drive THIS hitter. A BOMBER's hit outcomes are governed by barrel rate and
* launch — his ground balls are outs. A GHOST beats out infield hits, so ground-
* ball rate and speed matter and barrels are nearly irrelevant. Feeding both
* hitters the same feature weights is exactly the "one model fit to all" error
* the per-sport doctrine forbids, one level further down.
*
* Features are ACTIVE where they apply and SILENT otherwise — silent meaning
* they contribute nothing, never that a default is invented in their place.
*
* ── DISCIPLINE 3: THE GATE ───────────────────────────────────────────────
* Every input is checked against `featureRegistry`. Nothing that has not earned
* PROVEN can reach a served projection; the challenger runs on CANDIDATEs so
* they can be measured. `allowed` is passed in rather than read here so the
* caller decides which gate applies, and both are testable.
*
* ── HONESTY ──────────────────────────────────────────────────────────────
* Unknown is not zero, everywhere: a missing rate makes its branch SILENT, it
* never becomes a measured 0. If the hitter's own skill profile is missing there
* is no forward read at all and the function returns null — the caller falls
* back rather than being handed a confident guess.
*/
const { knownRate } = require('../../utils/known');
/**
* League baselines, MLB. Used ONLY as the denominator of the odds ratio and as
* the regression target for thin samples — never as a substitute for a missing
* player. Measured from the 2026 Statcast aggregate set.
*/
const LEAGUE = Object.freeze({
k_pct: 0.222,
bb_pct: 0.085,
babip: 0.291, // hits per ball in play
hard_hit_pct: 0.389,
barrel_pct: 0.078,
avg_exit_velo: 88.8,
bases_per_hit: 1.60, // league slugging ÷ batting average
});
/** Plate appearances per game for a regular; only used when nothing better is known. */
const DEFAULT_PA = 4.1;
const PA_CAP = 7;
const MIN_PA = 1;
/**
* ODDS RATIO (log5). "This batter against THIS pitcher", relative to league.
*
* Returns null when either side is unknown — an absent pitcher must leave the
* batter's own rate untouched, which the caller does by skipping the combine,
* NOT by substituting league for the missing side (that would quietly pull every
* unknown matchup toward average and call it a read).
*/
function oddsRatio(batterRate, pitcherRate, leagueRate) {
const b = knownRate(batterRate);
const p = knownRate(pitcherRate);
const l = knownRate(leagueRate);
if (b === null || p === null || l === null) return null;
if (b <= 0 && p <= 0) return 0;
if (b >= 1 || p >= 1) return 1;
if (l <= 0 || l >= 1) return null;
const ob = b / (1 - b);
const op = p / (1 - p);
const ol = l / (1 - l);
if (ol === 0) return null;
const odds = (ob * op) / ol;
if (!Number.isFinite(odds)) return null;
return odds / (1 + odds);
}
/**
* Shrink a rate toward an explicit anchor by sample size. A 12-PA callup with a
* 60% hard-hit rate is not a 60% hard-hit hitter; without this the model chases
* noise exactly the way a results-based model does.
*
* The anchor is a REQUIRED argument rather than an internal default, because a
* hidden regression target is how a model quietly becomes the league average
* wearing a player's name. At the call site you can always see what it is being
* pulled toward.
*/
function shrink(rate, sample, stabilizeAt, anchor) {
const r = knownRate(rate);
const a = knownRate(anchor);
if (r === null) return null;
if (a === null) return r;
const n = knownRate(sample);
const k = knownRate(stabilizeAt);
if (n === null || k === null || k <= 0) return r;
const w = n / (n + k);
return w * r + (1 - w) * a;
}
/**
* THE UNITS CHOKEPOINT — `statcast_aggregates` stores PERCENTAGES (0100).
*
* Baseball Savant's CSVs give `k_percent: 29.6`, not `0.296`, and the aggregate
* table stores them verbatim. This model works in probabilities. Feeding the raw
* row straight in makes `bip = 1 29.6 17.1` deeply negative, which is how
* this was caught: the first Stage A run refused 568 of 576 real rows rather
* than emitting nonsense. The honest-absent guards did their job — but a model
* that refuses everything is not a model, so the conversion lives HERE, once, at
* the boundary, and every consumer goes through it.
*
* Velocity (mph) and launch angle (degrees) are ALREADY natural units and must
* not be scaled. Mixing the two is precisely the trap, so the split is explicit
* rather than inferred from the value's magnitude — a 0.8% barrel rate and a
* 0.8 fraction are indistinguishable by size, and guessing would silently turn
* an elite hitter into a replacement one.
*/
const PCT_FIELDS = Object.freeze([
'k_pct', 'bb_pct', 'whiff_pct', 'swing_pct', 'chase_pct', 'barrel_pct',
'hard_hit_pct', 'sweet_spot_pct', 'ev95_pct', 'gb_pct', 'fb_pct', 'ld_pct',
]);
const RAW_FIELDS = Object.freeze(['avg_exit_velo', 'max_exit_velo', 'avg_launch_angle', 'arm_angle']);
/**
* Convert a `statcast_aggregates` row into the probability-space profile this
* model expects. Absent stays absent; an out-of-range percentage is treated as
* BROKEN (null) rather than clamped, because a 140% whiff rate is not a thin
* measurement, it is a parsing error and must not be modelled.
*/
function fromStatcastRow(row) {
if (!row || typeof row !== 'object') return null;
const out = {};
for (const f of PCT_FIELDS) {
const v = knownRate(row[f]);
out[f] = (v === null || v > 100) ? null : v / 100;
}
for (const f of RAW_FIELDS) out[f] = knownRate(row[f]);
out.bats = row.bats ?? null;
out.throws = row.throws ?? null;
out.sample_pa = knownRate(row.sample_pa);
out.sample_bip = knownRate(row.sample_bip);
return out;
}
/**
* ARCHETYPE FEATURE MAPS — Discipline 2, stated as data.
*
* `hitWeights` decide what governs whether a ball in play becomes a hit for THIS
* kind of hitter. `powerWeight` scales how much his contact quality converts to
* EXTRA bases. Weights are relative within a map and are documented by what the
* archetype physically does, not fitted — fitting them on 1,741 settled rows
* would be curve-fitting, and the registry exists so they get measured instead.
*
* Unknown archetype → DEFAULT, which is a balanced map, not a refusal: we still
* know the hitter's own skill rates.
*/
const ARCHETYPE_MAP = Object.freeze({
// Elite power. Barrels and launch decide his outcomes; his grounders are outs.
BOMBER: { hitWeights: { barrel: 0.5, hard_hit: 0.35, exit_velo: 0.15, gb_speed: 0 }, powerWeight: 1.25 },
// Speed-first. Beats out grounders; barrels are close to irrelevant.
GHOST: { hitWeights: { barrel: 0.05, hard_hit: 0.2, exit_velo: 0.15, gb_speed: 0.6 }, powerWeight: 0.7 },
// Pure contact, sprays it, low whiff.
TORCH: { hitWeights: { barrel: 0.2, hard_hit: 0.35, exit_velo: 0.25, gb_speed: 0.2 }, powerWeight: 1.0 },
DEFAULT: { hitWeights: { barrel: 0.25, hard_hit: 0.35, exit_velo: 0.25, gb_speed: 0.15 }, powerWeight: 1.0 },
});
function featureMapFor(archetype) {
const key = String(archetype || '').toUpperCase();
return ARCHETYPE_MAP[key] || ARCHETYPE_MAP.DEFAULT;
}
/**
* Per-ball-in-play hit probability, built from the ARCHETYPE-SELECTED skill
* inputs, the pitcher's contact suppression, and the park.
*
* Each component is a RATIO to league, so a hitter exactly at league on every
* axis lands on league BABIP and the model says "average" rather than inventing
* a lean. Components with no data are SILENT: they drop out of the weighted
* average and the remaining weights renormalise, so an absent input never acts
* as a measured zero.
*/
function hitOnContact({ batter, pitcher, park, archetype, allowed }) {
const map = featureMapFor(archetype);
const can = (k) => !allowed || allowed.has(k);
const parts = [];
const push = (weight, value, leagueValue, gateKey) => {
if (!weight || !can(gateKey)) return;
const v = knownRate(value);
const l = knownRate(leagueValue);
if (v === null || l === null || l <= 0) return; // SILENT, not zero
parts.push({ weight, ratio: v / l });
};
push(map.hitWeights.barrel, batter.barrel_pct, LEAGUE.barrel_pct, 'batter_barrel_pct');
push(map.hitWeights.hard_hit, batter.hard_hit_pct, LEAGUE.hard_hit_pct, 'batter_hard_hit_pct');
push(map.hitWeights.exit_velo, batter.avg_exit_velo, LEAGUE.avg_exit_velo, 'batter_exit_velo');
// GB/speed lane: a ground-ball hitter's hits come from beating out contact, so
// his OWN ground-ball tilt is the driver. Launch angle stands in for it —
// lower launch = more grounders — inverted so "more grounders" reads as more.
if (map.hitWeights.gb_speed && can('batter_launch_angle')) {
const la = knownRate(batter.avg_launch_angle);
if (la !== null) {
// League launch ~12°. Below it → grounder tilt → ratio > 1 for a GHOST.
const ratio = 12 > 0 ? (2 - Math.min(2, Math.max(0.2, la / 12))) : null;
if (ratio !== null) parts.push({ weight: map.hitWeights.gb_speed, ratio });
}
}
if (parts.length === 0) return null; // no skill signal at all — honest-absent
const wSum = parts.reduce((a, p) => a + p.weight, 0);
if (wSum <= 0) return null;
const skillRatio = parts.reduce((a, p) => a + p.weight * p.ratio, 0) / wSum;
// Pitcher contact suppression: hard contact ALLOWED relative to league.
let pitcherRatio = 1;
if (can('pitcher_hard_hit_allowed')) {
const ph = knownRate(pitcher && pitcher.hard_hit_pct);
if (ph !== null && LEAGUE.hard_hit_pct > 0) pitcherRatio = ph / LEAGUE.hard_hit_pct;
}
const parkMult = can('park_factor') ? (knownRate(park) ?? 1) : 1;
// Bounded: no stack of ratios may claim more than a ±35% swing in BABIP. The
// bound is a statement about how much any of this can really know, not a fudge.
const raw = LEAGUE.babip * skillRatio * pitcherRatio * parkMult;
const lo = LEAGUE.babip * 0.65;
const hi = LEAGUE.babip * 1.35;
return Math.min(hi, Math.max(lo, raw));
}
/**
* The PA outcome tree for one hitter against one pitcher.
* Returns per-PA probabilities, or null when the hitter has no usable profile.
*/
function paOutcome({ batter, pitcher, park, archetype, allowed }) {
if (!batter) return null;
const can = (k) => !allowed || allowed.has(k);
// Strikeout: batter K vs pitcher K, odds-ratio against league.
const bK = can('batter_k_pct') ? knownRate(batter.k_pct) : null;
const pK = can('pitcher_k_pct') ? knownRate(pitcher && pitcher.k_pct) : null;
let kRate = bK;
if (bK !== null && pK !== null) kRate = oddsRatio(bK, pK, LEAGUE.k_pct) ?? bK;
const bBB = can('batter_bb_pct') ? knownRate(batter.bb_pct) : null;
const pBB = can('pitcher_bb_pct') ? knownRate(pitcher && pitcher.bb_pct) : null;
let bbRate = bBB;
if (bBB !== null && pBB !== null) bbRate = oddsRatio(bBB, pBB, LEAGUE.bb_pct) ?? bBB;
// Absent → league, because a PA still has to resolve into SOMETHING. This is
// the one place a league value substitutes, and it is a structural necessity
// (the branches must sum to 1), not a guess about the player.
const k = kRate === null ? LEAGUE.k_pct : kRate;
const bb = bbRate === null ? LEAGUE.bb_pct : bbRate;
const bip = Math.max(0, 1 - k - bb);
if (bip <= 0) return null;
const pHitOnContact = hitOnContact({ batter, pitcher, park, archetype, allowed });
if (pHitOnContact === null) return null; // no skill read → no forward projection
return {
k_rate: k,
bb_rate: bb,
bip_rate: bip,
hit_on_contact: pHitOnContact,
p_hit_per_pa: bip * pHitOnContact,
inputs_used: {
pitcher_applied: pK !== null || pBB !== null || knownRate(pitcher && pitcher.hard_hit_pct) !== null,
archetype: String(archetype || 'DEFAULT').toUpperCase(),
},
};
}
/** Exact Binomial(n, p) pmf — n is tiny (PA per game ≤ 7). */
function binomialPmf(n, p) {
const nn = Math.max(0, Math.round(n));
const pp = Math.min(1, Math.max(0, p));
const out = new Array(nn + 1).fill(0);
let term = (1 - pp) ** nn;
out[0] = term;
for (let x = 1; x <= nn; x += 1) {
if (pp >= 1) { out[x] = x === nn ? 1 : 0; continue; }
term = (term * (nn - x + 1) * pp) / (x * (1 - pp));
out[x] = term;
}
return out;
}
/**
* Distribution over HITS, mixing over an integer PA distribution. PA is not
* fixed — a hitter gets 4 or 5 depending on how the lineup turns over — so the
* projection mixes rather than pretending PA is known.
*/
function paDistribution(expectedPa) {
const mean = Math.min(PA_CAP, Math.max(MIN_PA, knownRate(expectedPa) ?? DEFAULT_PA));
const lo = Math.floor(mean);
const hi = Math.min(PA_CAP, lo + 1);
const wHi = mean - lo;
const out = new Array(PA_CAP + 1).fill(0);
out[lo] += 1 - wHi;
if (hi !== lo) out[hi] += wHi; else out[lo] = 1;
return out;
}
/** P(X >= k) from a pmf. */
function atLeast(pmf, k) {
const kk = Math.max(0, Math.ceil(k));
if (kk === 0) return 1;
let s = 0;
for (let i = kk; i < pmf.length; i += 1) s += pmf[i];
return Math.min(1, Math.max(0, s));
}
/**
* THE FORWARD READ.
*
* @returns {object|null} distribution + P(clears line) + the reasoning trace, or
* null when there is no usable skill profile (caller falls back; never a guess).
*/
function projectSkill({
batter, pitcher = null, park = 1, archetype = null,
statType = 'hits', line, expectedPa = null, allowed = null,
} = {}) {
// STAGE A IS HITS ONLY, and total_bases is refused DELIBERATELY.
//
// The first cut mapped hits to bases with an archetype-scaled constant
// (bases_per_hit x powerWeight). Smoke-tested on a real profile that made
// P(TB>=2) come out EXACTLY equal to P(hits>=1) — because a deterministic
// multiplier just relabels the hits distribution. It would have measured as a
// "TB model" while carrying no information a hits model does not already have.
// tb-v1 already established the right shape: total bases is a COMPOUND
// outcome (1B/2B/3B/HR each with its own rate), not hits x a constant. Doing
// that properly needs a per-hit extra-base distribution off the launch/barrel
// profile, which is its own build. Refusing beats shipping a relabel.
const stat = String(statType || '').toLowerCase();
if (stat !== 'hits') return null;
const target = Math.max(1, Math.ceil(Number(line)));
if (!Number.isFinite(target)) return null;
const pa = paOutcome({ batter, pitcher, park, archetype, allowed });
if (!pa) return null;
const paPmf = paDistribution(expectedPa);
const map = featureMapFor(archetype);
// HITS — Binomial(PA, p_hit_per_pa), mixed over the PA distribution.
const hitPmf = new Array(PA_CAP + 1).fill(0);
for (let n = 0; n < paPmf.length; n += 1) {
if (!paPmf[n]) continue;
const bp = binomialPmf(n, pa.p_hit_per_pa);
for (let x = 0; x < bp.length; x += 1) hitPmf[x] += paPmf[n] * bp[x];
}
const expectedHits = hitPmf.reduce((a, p, i) => a + p * i, 0);
return buildResult({ pmf: hitPmf, mean: expectedHits, target, pa, map, archetype, stat });
}
function buildResult({ pmf, mean, target, pa, map, archetype, stat, caveat = null }) {
const p = atLeast(pmf, target);
const r3 = (v) => Math.round(v * 1000) / 1000;
return {
version: 'skill-v1',
stat,
p_over_line: r3(p),
projected_value: r3(mean),
distribution: pmf.map(r3),
per_pa: {
k_rate: r3(pa.k_rate), bb_rate: r3(pa.bb_rate),
bip_rate: r3(pa.bip_rate), hit_on_contact: r3(pa.hit_on_contact),
p_hit_per_pa: r3(pa.p_hit_per_pa),
},
archetype: String(archetype || 'DEFAULT').toUpperCase(),
feature_map: map.hitWeights,
pitcher_applied: pa.inputs_used.pitcher_applied,
family: 'pa_outcome_tree_binomial',
caveat,
};
}
module.exports = {
projectSkill, paOutcome, hitOnContact, oddsRatio, shrink, fromStatcastRow,
binomialPmf, paDistribution, atLeast, featureMapFor,
LEAGUE, ARCHETYPE_MAP, DEFAULT_PA, PA_CAP, PCT_FIELDS, RAW_FIELDS,
};