Per-archetype re-audit: no slot reaches 500, and the replication unit
decided everything The premise does not hold. prove-hit-factors.js has no date filter anywhere in it and pages the full table -- there was never a window to widen. Full clean history is 1,266 rows, not 2,715. platoon was not "proved" last session, it was explicitly held on 4.5%-median-contaminated season-to-date splits, and pitcher_contact_profile was demoted. The proven set going in was one factor, not three. STEP 1: no archetype slot reaches n>=500 on full history. Best is BOMBER at 408, and BOMBER is the most common archetype on the board. GHOST 173, BRUSH 64, DRIVER 43, CATALYST 16. These are confirmed genuinely short, not artifacts. STEP 2 is where the real finding is. park_hits initially PROVED at 619 rows across 45 games -- but those games only ever visited 14 distinct park values. A park effect is replicated across parks, and unmodelled park heterogeneity is confounded with the thing being estimated. Each factor is now clustered on the coarser of the game and the entity its treatment rides on. That flipped two verdicts and confirms Kev's causal-correctness thesis from a new direction: defense_by_direction has 442 hitter-team units of replication where crude team defense has 26. The correct atom is not just more accurate, it is the only one measurable at all. park_hits (14) and defense (26) can never be validated however long the ledger runs -- the same ceiling as park dimensions, reached independently. Also fixed a bar I got wrong last session: I transplanted the 500-row floor onto clusters, which refused a factor with 1,059 rows over 85 games while answering neither question. Two floors now -- rows>=500 for a stable estimate, clusters>=40 for a trustworthy interval. Not a lowered bar: park_hits and defense are still refused. PROVEN: defense_by_direction only, pooled, [-0.0054,-0.0012] at 99 tests. It stays POOLED-ONLY -- no per-archetype reasoning wired, nothing grandfathered. The card must not say "GHOST: defence matchup strong" because we have not earned that sentence. The predicted fingerprint did not appear either: BOMBER -0.0036 vs GHOST -0.0024, the opposite direction, both noise-dominated. Recorded so it is not claimed later. RESCALE: NOT READY. One proven factor worth -0.0031 Brier. Rescaling on that is relabelling. 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
This commit is contained in:
@@ -54,12 +54,14 @@ const FACTORS = [
|
||||
{
|
||||
key: 'defense_by_direction',
|
||||
needs: ['spray_multiplier'],
|
||||
entity: (r) => `${r.player_key}|${r.opp}`,
|
||||
mechanism: 'CAUSALLY-CORRECT DEFENCE. Where the hitter puts the ball (pull/straight/oppo x ground/air) crossed with the OAA of the fielders actually standing in those zones, joined by handedness. Team-average failed the gate because it averages in five fielders who will never touch his ball.',
|
||||
apply: (r) => r.spray_multiplier,
|
||||
},
|
||||
{
|
||||
key: 'defense',
|
||||
needs: ['team_defense'],
|
||||
entity: (r) => r.opp,
|
||||
mechanism: 'A ball in play becomes a hit or an out partly by who is standing behind the pitcher. Should matter most where contact stays in the park.',
|
||||
// More outs converted above average -> fewer hits.
|
||||
apply: (r) => 1 - Math.max(-0.12, Math.min(0.12, r.team_defense / 250)),
|
||||
@@ -67,12 +69,14 @@ const FACTORS = [
|
||||
{
|
||||
key: 'pitcher_contact_profile',
|
||||
needs: ['pitcher_hard_hit_allowed'],
|
||||
entity: (r) => r.starter_id,
|
||||
mechanism: 'A contact-allowing arm concedes better contact than a bat-misser; hit probability should follow the quality of contact he permits.',
|
||||
apply: (r) => 1 + Math.max(-0.15, Math.min(0.15, (r.pitcher_hard_hit_allowed - 0.389) * 1.2)),
|
||||
},
|
||||
{
|
||||
key: 'park_hits',
|
||||
needs: ['park_factor'],
|
||||
entity: (r) => r.park_factor,
|
||||
mechanism: 'Some parks turn outs into hits without producing runs — big outfields, high walls, deep gaps.',
|
||||
apply: (r) => r.park_factor,
|
||||
caveat: 'STAT_BASE maps hits -> run_base, so this is a RUN factor standing in for a HITS factor. A park that converts outs to hits without scoring is invisible to it.',
|
||||
@@ -80,12 +84,14 @@ const FACTORS = [
|
||||
{
|
||||
key: 'platoon_severity',
|
||||
needs: ['platoon_severity_mult'],
|
||||
entity: (r) => r.player_key,
|
||||
mechanism: "CAUSALLY-CORRECT PLATOON. The advantage is worth only what THIS hitter's measured split is worth, shrunk toward league by the smaller side's PA and refused outright below a floor. Flat handedness applies the same boost to a 63-point split and to none.",
|
||||
apply: (r) => r.platoon_severity_mult,
|
||||
},
|
||||
{
|
||||
key: 'platoon',
|
||||
needs: ['platoon_edge'],
|
||||
entity: (r) => r.player_key,
|
||||
mechanism: 'Handedness advantage — a hitter facing the opposite hand sees the ball better and hits it harder.',
|
||||
apply: (r) => (r.platoon_edge > 0 ? 1.06 : 0.96),
|
||||
},
|
||||
@@ -128,7 +134,7 @@ async function main() {
|
||||
for (const s of snaps) archOf.set(`${s.player_key}|${s.game_date}`, s.archetype);
|
||||
|
||||
const led = await page(sb, 'ledger_entries',
|
||||
'id, player_key, player_name, line, side, outcome, game_date, p_win, quarantine_reason, env_park_base',
|
||||
'id, game_id, player_key, player_name, line, side, outcome, game_date, p_win, quarantine_reason, env_park_base',
|
||||
(q) => q.eq('sport', 'mlb').is('user_id', null).eq('stat', 'hits')
|
||||
.in('outcome', ['hit', 'miss']).not('p_win', 'is', null));
|
||||
const clean = led.filter((r) => !(r.quarantine_reason || '').startsWith('nontakeable_book'));
|
||||
@@ -184,6 +190,15 @@ async function main() {
|
||||
loss.kept += 1;
|
||||
rows.push({
|
||||
id: r.id,
|
||||
// Errors are correlated WITHIN a game — shared starter, park, weather and
|
||||
// the game's own randomness — so the interval must be clustered on it.
|
||||
// Three of these factors (pitcher profile, team defence, park) are also
|
||||
// CONSTANT across every hitter facing that starter, which makes row
|
||||
// resampling straightforwardly wrong for them.
|
||||
cluster: r.game_id,
|
||||
opp: faced,
|
||||
starter_id: starterId != null ? Number(starterId) : null,
|
||||
player_key: r.player_key,
|
||||
archetype: archOf.get(`${r.player_key}|${r.game_date}`) || null,
|
||||
won: r.outcome === 'hit' ? 1 : 0,
|
||||
baseline,
|
||||
@@ -219,15 +234,43 @@ async function main() {
|
||||
const mc = await tl.recordAndCount(store, FACTORS.flatMap((f) =>
|
||||
ARCHS.map((a) => ({ sport: 'mlb', stat: 'hits', archetype: a === 'ALL' ? null : a, interaction: `factor:${f.key}`, target: 'outcome' }))));
|
||||
|
||||
// STEP 1 — FULL-HISTORY SAMPLE AUDIT PER SLOT, before any gating.
|
||||
const audit = [];
|
||||
for (const f of FACTORS) {
|
||||
for (const arch of ARCHS) {
|
||||
const slot = arch === 'ALL' ? rows : rows.filter((r) => String(r.archetype || '').toUpperCase() === arch);
|
||||
const usable = slot.filter((r) => f.needs.every((k) => knownNumber(r[k]) !== null));
|
||||
audit.push({
|
||||
factor: f.key,
|
||||
archetype: arch,
|
||||
rows: usable.length,
|
||||
games: new Set(usable.map((r) => r.cluster).filter(Boolean)).size,
|
||||
players: new Set(usable.map((r) => r.player_key)).size,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const results = [];
|
||||
for (const arch of ARCHS) {
|
||||
const slot = arch === 'ALL' ? rows : rows.filter((r) => String(r.archetype || '').toUpperCase() === arch);
|
||||
for (const f of FACTORS) {
|
||||
const usable = slot.filter((r) => f.needs.every((k) => knownNumber(r[k]) !== null));
|
||||
// A park effect is replicated across PARKS, not across games: 619 rows in
|
||||
// 45 games still only ever saw ~23 ballparks, and unmodelled park
|
||||
// heterogeneity is confounded with the very thing being estimated. So the
|
||||
// cluster is the COARSER of the game and the entity the treatment rides on.
|
||||
const ents = f.entity ? new Set(usable.map((r) => String(f.entity(r)))) : null;
|
||||
const games = new Set(usable.map((r) => String(r.cluster)));
|
||||
const useEntity = ents && ents.size < games.size;
|
||||
const paired = usable.map((r) => {
|
||||
const mult = f.apply(r);
|
||||
const cond = mult === null ? null : Math.min(0.99, Math.max(0.01, r.baseline * mult));
|
||||
return { baseline: r.baseline, conditioned: cond, won: r.won };
|
||||
return {
|
||||
baseline: r.baseline,
|
||||
conditioned: cond,
|
||||
won: r.won,
|
||||
cluster: useEntity ? `e:${f.entity(r)}` : r.cluster,
|
||||
};
|
||||
});
|
||||
const v = fg.adjudicate(paired, {
|
||||
factor: f.key, archetype: arch, stat: 'hits',
|
||||
@@ -235,6 +278,10 @@ async function main() {
|
||||
});
|
||||
results.push({
|
||||
archetype: arch, factor: f.key, n: v.movement.n,
|
||||
clusters: v.improvement ? v.improvement.effective_n : null,
|
||||
cluster_unit: useEntity ? 'treatment_entity' : 'game',
|
||||
distinct_games: games.size,
|
||||
distinct_entities: ents ? ents.size : null,
|
||||
mean_abs_shift: v.movement.mean_abs_shift,
|
||||
brier_delta: v.improvement ? v.improvement.brier_delta : null,
|
||||
ci: v.improvement ? v.improvement.ci : null,
|
||||
@@ -249,6 +296,7 @@ async function main() {
|
||||
console.log(JSON.stringify({
|
||||
baseline: "each row scored against the player's OWN leave-one-out base rate — the honest 'he's due' null",
|
||||
total_rows: rows.length,
|
||||
slot_audit: audit,
|
||||
clean_settled_rows_available: clean.length,
|
||||
row_loss: loss,
|
||||
cumulative_bonferroni: mc,
|
||||
|
||||
Reference in New Issue
Block a user