b2e4c6c4fb
The refinement was right. Naming the individual reliever failed; the same
question at the grain the chain needs passes, and it transmits more than
anything else measured in this chain.
WHY IT WAS WORTH RE-ASKING: last session's null (the pen is on average no
softer, +0.0010 on 35,760 PAs) does NOT rule this out, and treating it as
though it did would have been the error. An average washing out is fully
consistent with quality VARIATION mattering. It does -- actual arm quality
moves the hit rate monotonically across quartiles, 0.2244 / 0.2293 /
0.2410 / 0.2501, a 2.57pp spread, larger than the whole times-through-
the-order effect.
CLUSTER UNIT CORRECTED, THEN CHECKED RATHER THAN ARGUED. Last session
refused Link 2 partly as team-borne (30 bullpens, the park ceiling). My
first re-check was that 76% of pen-quality variance is within-team -- but
that is a statement about TREATMENT variance, not about where errors
correlate, and stopping there would have been picking the convenient
answer. Measured the actual thing: ICC of prediction error by team =
0.0261, design effect 1.41, SEs inflated ~19%. So the verdict was run
three ways:
unclustered CI [-0.0067,-0.0010] excludes zero
team-clustered (30) CI [-0.0086,-0.0003] excludes zero (below the
40-cluster floor -- indicative, not a pass)
design-effect adjusted CI [-0.0072,-0.0005] excludes zero
QUALITY GRAIN PROVES on the concentrated elevated-early-exit subset:
n=501 team-games, 426 clusters, MAE 0.0294 -> 0.0260, delta -0.0034, CI
[-0.0063,-0.0005] at 110 cumulative tests. Pooled also proves, so it is
not a subset artefact.
ARCHETYPE GRAIN DOES NOT: 0.5669 vs a 0.5309 modal-guess baseline,
corrected interval [-0.1073,+0.0268] spans zero. Two grains tested, one
earned a place -- penQuality.js exposes no archetype and a test asserts
it.
WHAT LINK 3 RECEIVES, which is the number that actually matters -- not
the MAE gain but realized outcome separation, prediction strictly
point-in-time:
predicted BEST pen 167 games 2,044 PAs hit rate 0.2231 +/-0.0180
predicted WORST pen 167 games 1,799 PAs hit rate 0.2501 +/-0.0200
2.70pp separated, intervals non-overlapping, capturing nearly all the
2.57pp available at the quartile grain. Caveat stated not buried: the
tercile cut is chosen in-sample; the prediction driving it is not.
BUILT: penQuality.js + 9 tests. Abstains below 5 prior club games and 40
arm appearances -- a league-average stand-in would assert "this is an
ordinary bullpen", which is a claim, and usually the wrong one for exactly
the clubs whose pens just turned over.
Link 3 is unblocked on a proven Link 2 at the quality grain only. Not run
here; this order scopes to building and gating Link 2.
Parallel track logged unchanged: TB n=948 pooled, BOMBER x TB 340, short
by 160.
Counter and frozen clusters byte-identical.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W1sivYNqY2TS5ftykmHBU9
102 lines
4.3 KiB
JavaScript
102 lines
4.3 KiB
JavaScript
'use strict';
|
|
|
|
/**
|
|
* penQuality — the PROVEN half of Link 2.
|
|
*
|
|
* Link 2 was asked twice. Naming the individual reliever failed on merit (17.2%
|
|
* accuracy — wrong five times in six), because managers mix and match and the
|
|
* individual genuinely is noise. Asked at the COARSE grain the chain actually
|
|
* needs, it proves: predicted pen quality separates a realized 2.70pp hit-rate
|
|
* difference between the pens we call best and worst.
|
|
*
|
|
* The archetype grain did NOT prove (0.567 vs a 0.531 modal-guess baseline,
|
|
* corrected interval spanning zero) and is deliberately absent from this module.
|
|
* Two grains were tested; one earned a place.
|
|
*
|
|
* ── POINT-IN-TIME ON BOTH SIDES ──────────────────────────────────────────
|
|
* An arm's quality is his allowed-hit-rate over appearances strictly BEFORE the
|
|
* game in question, and a club's pen forecast comes only from its prior games.
|
|
* The target is which KNOWN-quality arms appeared — never how they happened to
|
|
* pitch that night, which would be scoring against the answer.
|
|
*
|
|
* ── ABSTAIN, NEVER IMPUTE ────────────────────────────────────────────────
|
|
* An arm below the appearance floor has no readable quality, and a club without
|
|
* enough prior games has no readable pen. Both return null. A league-average
|
|
* stand-in would assert "this is an ordinary bullpen", which is a claim, and
|
|
* usually the wrong one for exactly the clubs whose pens have just turned over.
|
|
*/
|
|
|
|
const { knownNumber } = require('../../utils/known');
|
|
|
|
/** Appearances before an arm's quality is readable at all. */
|
|
const MIN_ARM_PA = 40;
|
|
/** Prior games before a club's pen is readable at all. */
|
|
const MIN_PRIOR_GAMES = 5;
|
|
|
|
const mean = (xs) => (xs.length ? xs.reduce((a, b) => a + b, 0) / xs.length : null);
|
|
|
|
/**
|
|
* One arm's quality from his prior line. Null below the floor — a 12-batter
|
|
* sample is not a scouting report.
|
|
*/
|
|
function armQuality(prior) {
|
|
const n = knownNumber(prior && prior.pa);
|
|
const h = knownNumber(prior && prior.hits);
|
|
if (n === null || h === null || n < MIN_ARM_PA) return null;
|
|
return h / n;
|
|
}
|
|
|
|
/**
|
|
* The pen a hitter's later plate appearances will face.
|
|
*
|
|
* @param {Array} priorGames [{ quality }] this club's prior relief outings
|
|
* @returns {object|null} null when unreadable — never a league-average guess.
|
|
*/
|
|
function projectPen(priorGames) {
|
|
const qs = (priorGames || []).map((g) => knownNumber(g && g.quality)).filter((v) => v !== null);
|
|
if (qs.length < MIN_PRIOR_GAMES) {
|
|
return null;
|
|
}
|
|
return {
|
|
readable: true,
|
|
quality: round4(mean(qs)),
|
|
games_read: qs.length,
|
|
// Stated so a consumer cannot mistake this for a reliever-identity claim.
|
|
grain: 'pen_quality',
|
|
individual_arm_refused: 'naming the specific reliever did not prove (17.2% accuracy) — managers mix and match',
|
|
archetype_refused: 'the archetype grain did not prove at the corrected bar',
|
|
};
|
|
}
|
|
|
|
/**
|
|
* The measured relationship between pen quality and hit rate, for a consumer
|
|
* that wants the consequence rather than the input. Anchored on the observed
|
|
* league mean; the slope is the measured tercile separation, not a fitted
|
|
* parameter, and the effect is bounded because it was measured over a range.
|
|
*/
|
|
const LEAGUE_PEN_QUALITY = 0.2261;
|
|
const HIT_RATE_PER_QUALITY = 0.87; // 2.70pp realized over a 0.031 quality gap
|
|
const MAX_SHIFT = 0.03;
|
|
|
|
function hitRateShift(penQuality) {
|
|
const q = knownNumber(penQuality);
|
|
if (q === null) return null; // absent stays absent
|
|
const raw = (q - LEAGUE_PEN_QUALITY) * HIT_RATE_PER_QUALITY;
|
|
return round4(Math.max(-MAX_SHIFT, Math.min(MAX_SHIFT, raw)));
|
|
}
|
|
|
|
/** A checkable sentence, or nothing. */
|
|
function explain(pen) {
|
|
if (!pen || !pen.readable) return null;
|
|
const d = pen.quality - LEAGUE_PEN_QUALITY;
|
|
if (Math.abs(d) < 0.005) return `bullpen reads league-average over ${pen.games_read} prior games`;
|
|
return `bullpen reads ${d > 0 ? 'weaker' : 'stronger'} than league over ${pen.games_read} prior games`;
|
|
}
|
|
|
|
const round4 = (v) => (v == null || !Number.isFinite(v) ? null : Math.round(v * 10000) / 10000);
|
|
|
|
module.exports = {
|
|
armQuality, projectPen, hitRateShift, explain,
|
|
MIN_ARM_PA, MIN_PRIOR_GAMES, LEAGUE_PEN_QUALITY, MAX_SHIFT,
|
|
};
|