d8bf7765db
READ-ONLY. src/ and web/ untouched; 4,159 tests still green. WHAT THE CHAMPION IS. probabilityEstimator is five lines of arithmetic: the empirical frequency of (stat > THIS line) over the game log, blended 0.6/0.4 with the last-5 frequency, then +/-0.03 opponent, +/-0.015 home/away, a cv>0.40 pull toward 0.50, and a clamp to [0.10, 0.95]. It reads three features. featureCache retains a dozen more that p_win never touches. THE ABLATION IS EXACT, NOT A REFIT. Every adjustment is closed-form from stored features and the consistency step is linear, so each layer subtracts algebraically out of the stored p_win -- no re-estimation, no re-fetch, no lookahead possible. Per stat, paired bootstrap: removing ALL THREE adjustments changes resolution by NOTHING on every stat hits -0.0059 total_bases -0.0015 rbi +0.0106 runs +0.0130 walks +0.0008 and rbi's home/away is mildly HARMFUL (+0.0053, CI excludes zero). So ~100% of the champion's resolution is base+recency: how often this player has cleared this number lately. Everything else is decoration. A CORRECTION. Pooled, the champion resolves 0.46; per stat it is 0.196 (hits) to 0.499 (rbi). Pooling stats with different base rates inflates correlation, so 0.46 should not be quoted as the champion's resolution. Last session's paired differences remain valid; only the absolute level was inflated. THE BIGGEST LOSS IS NOT A MISSING FEATURE -- IT IS THE CLAMP. 358 of 1,741 settled rows (20.6%) sit on the boundary, so the model emits a constant there and cannot rank a fifth of the book at all. And that constant hides two opposite failures: 0.900 covers home_runs-under truly winning 99.5% (9.5pts under-confident) next to hits-under truly winning 51.9% (38.1pts over- confident). PROB_CEIL=0.95 makes the 99.5% case inexpressible. Global over-prediction is +3.5pts, +7.6 on total_bases. None of this needs new data. ONE REAL MISSING-WEIGHTING LEAD: opportunity_drift, residual corr +0.156 on hits and +0.145 on total_bases -- it REPEATS across independent stats, unlike the weather hits on TB which sit inside the expected false-positive count (70 tests at alpha .05 expects 3-4). And we already compute it: arch-v1's opportunity axis uses it and extracts nothing (delta +0.0001). Wrong implementation, not a missing feature -- opportunity must scale the rate, not nudge the probability. ARCHETYPE IS UNMEASURABLE, NOT REFUTED. Only 2 of 41 labels (BOMBER, GHOST) reach n>=40 settled rows and every mean residual straddles zero. That is "we have not measured it", and it does not license acting in either direction. Why every challenger has failed is now legible: the ladder and hits-v1 REPLACE the frequency question with a fitted distribution; the environment axis adds inputs the champion ignores. Asking the frequency question at the traded line is the thing that works. Flagged, not fixed: model_snapshots.outcome is NULL on all 22,032 rows -- the retention table built for exactly this replay was never settled, so labels had to be joined from ledger_entries. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W1sivYNqY2TS5ftykmHBU9
375 lines
16 KiB
JavaScript
375 lines
16 KiB
JavaScript
#!/usr/bin/env node
|
||
'use strict';
|
||
|
||
/**
|
||
* champion-ablation — WHERE DOES THE CHAMPION'S RESOLUTION ACTUALLY COME FROM?
|
||
*
|
||
* Convergent evidence says the problem is INPUTS, not shape: hits-v1 refuted,
|
||
* the ladder reliably worse (−0.030), arch-v1 moving 76% of rows to exactly zero
|
||
* effect, contact/environment/opportunity all CI-includes-zero. Every one of
|
||
* those changed the DISTRIBUTION or added a NUDGE. None changed the information.
|
||
* So before building a sixth thing, decompose the champion.
|
||
*
|
||
* THE CHAMPION IS FIVE LINES OF ARITHMETIC (probabilityEstimator):
|
||
*
|
||
* base = empirical frequency of stat > line over the game log
|
||
* weighted = 0.6·base + 0.4·(same frequency over the last 5)
|
||
* p = weighted + oppAdj(±0.03) + homeAdj(±0.015)
|
||
* if cv>0.40: p = 0.9·p + 0.05 (volatile → pull toward 0.50)
|
||
* p_over = clamp(p, 0.10, 0.95); p_win = side==='under' ? 1−p_over : p_over
|
||
*
|
||
* THE ABLATION IS EXACT, NOT A REFIT. Every adjustment is a closed-form function
|
||
* of stored features, and the consistency step is linear, so each layer can be
|
||
* removed analytically from the stored p_win:
|
||
*
|
||
* f(x) = 0.9x + 0.05 ⟹ f(a+b) = f(a) + 0.9b
|
||
*
|
||
* so subtracting an adjustment is subtracting k·adj with k = 0.9 when the
|
||
* consistency pull fired and 1 when it did not. Nothing is re-estimated, no
|
||
* model is refit, and no game log is re-fetched — which also means no lookahead
|
||
* is even possible here.
|
||
*
|
||
* WHAT CANNOT BE ABLATED SEPARATELY, STATED PLAINLY: `base` and `recency` are
|
||
* recoverable only as their blend (`weighted`), because the stored feature
|
||
* vector holds AVERAGES (l5_avg/l20_avg), not frequencies-over-the-line. So the
|
||
* base/recency split is reported as ONE block. That is a real limit of this
|
||
* measurement, not an oversight.
|
||
*
|
||
* CLAMPED ROWS ARE EXCLUDED from the ablation: at p_over ∈ {0.10, 0.95} the
|
||
* inversion is ambiguous, and guessing the pre-clamp value would be fabrication.
|
||
* Their count is reported.
|
||
*
|
||
* SECOND MEASUREMENT — THE MISSING-FEATURE TEST. featureCache computes and
|
||
* RETAINS far more than the champion reads (park_*, weather_*, rest_days,
|
||
* opportunity_drift, ab_per_game, l5/l10/l20 avgs). If any of those correlates
|
||
* with the champion's RESIDUAL (won − p_win), that is signal sitting unused on
|
||
* disk — a MISSING FEATURE. If none do, that is evidence for AT CEILING with
|
||
* respect to everything we currently compute.
|
||
*
|
||
* SUPABASE_URL=... node scripts/champion-ablation.js
|
||
*/
|
||
|
||
require('dotenv').config();
|
||
const { createClient } = require('@supabase/supabase-js');
|
||
|
||
const SB_URL = process.env.SUPABASE_URL;
|
||
const SB_KEY = process.env.SUPABASE_SERVICE_ROLE_KEY || process.env.SUPABASE_SERVICE_KEY;
|
||
const PAGE = 1000;
|
||
|
||
const CV_VOLATILE_THRESHOLD = 0.40;
|
||
const PROB_FLOOR = 0.10;
|
||
const PROB_CEIL = 0.95;
|
||
const clamp = (p) => Math.max(PROB_FLOOR, Math.min(PROB_CEIL, p));
|
||
|
||
function corr(xs, ys) {
|
||
const n = xs.length;
|
||
if (n < 3) return null;
|
||
const mx = xs.reduce((a, b) => a + b, 0) / n;
|
||
const my = ys.reduce((a, b) => a + b, 0) / n;
|
||
let sxy = 0; let sxx = 0; let syy = 0;
|
||
for (let i = 0; i < n; i += 1) {
|
||
const dx = xs[i] - mx; const dy = ys[i] - my;
|
||
sxy += dx * dy; sxx += dx * dx; syy += dy * dy;
|
||
}
|
||
if (sxx <= 0 || syy <= 0) return null;
|
||
return sxy / Math.sqrt(sxx * syy);
|
||
}
|
||
const r4 = (v) => (v == null || !Number.isFinite(v) ? null : Math.round(v * 10000) / 10000);
|
||
const num = (v) => {
|
||
if (v == null || v === '' || typeof v === 'boolean' || typeof v === 'object') return null;
|
||
const n = Number(v);
|
||
return Number.isFinite(n) ? n : null;
|
||
};
|
||
|
||
/** Deterministic xorshift32 — a measurement that changes between runs is not one. */
|
||
function makeRnd(seed) {
|
||
let s = seed >>> 0;
|
||
return () => { s ^= s << 13; s >>>= 0; s ^= s >>> 17; s ^= s << 5; s >>>= 0; return s / 4294967296; };
|
||
}
|
||
|
||
/** Paired bootstrap on a DIFFERENCE of resolutions (same rows → same resample). */
|
||
function bootstrapDiff(rows, keyA, keyB, iters = 3000, seed = 20260803) {
|
||
if (rows.length < 30) return null;
|
||
const rnd = makeRnd(seed);
|
||
const n = rows.length;
|
||
const diffs = [];
|
||
for (let it = 0; it < iters; it += 1) {
|
||
const ys = []; const a = []; const b = [];
|
||
for (let i = 0; i < n; i += 1) {
|
||
const r = rows[Math.floor(rnd() * n)];
|
||
ys.push(r.won); a.push(r[keyA]); b.push(r[keyB]);
|
||
}
|
||
const ca = corr(a, ys); const cb = corr(b, ys);
|
||
if (ca == null || cb == null) continue;
|
||
diffs.push(ca - cb);
|
||
}
|
||
if (diffs.length < 100) return null;
|
||
diffs.sort((x, y) => x - y);
|
||
const q = (p) => r4(diffs[Math.floor(p * (diffs.length - 1))]);
|
||
const ci = [q(0.025), q(0.975)];
|
||
return {
|
||
point: r4(corr(rows.map((r) => r[keyA]), rows.map((r) => r.won))
|
||
- corr(rows.map((r) => r[keyB]), rows.map((r) => r.won))),
|
||
ci95: ci,
|
||
ci_excludes_zero: ci[0] > 0 || ci[1] < 0,
|
||
};
|
||
}
|
||
|
||
/** Bootstrap CI on a single correlation (for the residual-signal test). */
|
||
function bootstrapCorr(rows, key, seed = 20260804, iters = 3000) {
|
||
const usable = rows.filter((r) => r[key] != null);
|
||
if (usable.length < 40) return { n: usable.length, corr: null, ci95: null, ci_excludes_zero: false };
|
||
const rnd = makeRnd(seed);
|
||
const n = usable.length;
|
||
const vals = [];
|
||
for (let it = 0; it < iters; it += 1) {
|
||
const xs = []; const ys = [];
|
||
for (let i = 0; i < n; i += 1) {
|
||
const r = usable[Math.floor(rnd() * n)];
|
||
xs.push(r[key]); ys.push(r.residual);
|
||
}
|
||
const c = corr(xs, ys);
|
||
if (c != null) vals.push(c);
|
||
}
|
||
if (vals.length < 100) return { n, corr: null, ci95: null, ci_excludes_zero: false };
|
||
vals.sort((a, b) => a - b);
|
||
const q = (p) => r4(vals[Math.floor(p * (vals.length - 1))]);
|
||
const ci = [q(0.025), q(0.975)];
|
||
return {
|
||
n,
|
||
corr: r4(corr(usable.map((r) => r[key]), usable.map((r) => r.residual))),
|
||
ci95: ci,
|
||
ci_excludes_zero: ci[0] > 0 || ci[1] < 0,
|
||
};
|
||
}
|
||
|
||
async function page(sb, table, select, apply) {
|
||
const out = [];
|
||
for (let from = 0; ; from += PAGE) {
|
||
let q = sb.from(table).select(select);
|
||
q = apply(q).range(from, from + PAGE - 1);
|
||
const { data, error } = await q;
|
||
if (error) throw error;
|
||
if (!data || data.length === 0) break;
|
||
out.push(...data);
|
||
if (data.length < PAGE) break;
|
||
}
|
||
return out;
|
||
}
|
||
|
||
const propKey = (r) => `${r.player_key}|${r.stat}|${Number(r.line)}|${String(r.side).toLowerCase()}|${r.game_date}`;
|
||
|
||
/**
|
||
* OUTCOMES COME FROM THE LEDGER, NOT FROM RETENTION.
|
||
*
|
||
* `model_snapshots.outcome` is NULL on all 22,032 rows — the retention table
|
||
* that exists so a different model can be replayed against the same conditions
|
||
* stores the features but was never settled. So the labels are joined from
|
||
* `ledger_entries` on (player_key, stat, line, side, game_date), which is the
|
||
* same identity the ledger's own dedupe constraint uses. Flagged, not fixed —
|
||
* this run is read-only.
|
||
*/
|
||
async function fetchAll(sb) {
|
||
const snaps = await page(sb, 'model_snapshots',
|
||
'player_key, stat, line, side, game_date, p_win, features, quarantine_reason, captured_at, archetype',
|
||
(q) => q.eq('sport', 'mlb').not('p_win', 'is', null).not('features', 'is', null));
|
||
const led = await page(sb, 'ledger_entries', 'player_key, stat, line, side, game_date, outcome, quarantine_reason',
|
||
(q) => q.eq('sport', 'mlb').is('user_id', null).in('outcome', ['hit', 'miss']));
|
||
|
||
const outcomeBy = new Map();
|
||
for (const r of led) {
|
||
if ((r.quarantine_reason || '').startsWith('nontakeable_book')) continue;
|
||
outcomeBy.set(propKey(r), r.outcome);
|
||
}
|
||
return snaps
|
||
.map((r) => ({ ...r, outcome: outcomeBy.get(propKey(r)) || null }))
|
||
.filter((r) => r.outcome === 'hit' || r.outcome === 'miss');
|
||
}
|
||
|
||
function build(rows) {
|
||
// One row per prop — the EARLIEST capture is the lock. Multiple snapshot
|
||
// cycles per day would otherwise weight a prop by how often it was re-graded.
|
||
const byProp = new Map();
|
||
for (const r of rows) {
|
||
if ((r.quarantine_reason || '').startsWith('nontakeable_book')) continue;
|
||
const k = `${r.player_key}|${r.stat}|${r.line}|${r.side}|${r.game_date}`;
|
||
const prev = byProp.get(k);
|
||
if (!prev || String(r.captured_at) < String(prev.captured_at)) byProp.set(k, r);
|
||
}
|
||
|
||
const out = [];
|
||
let clamped = 0;
|
||
for (const r of byProp.values()) {
|
||
const f = r.features || {};
|
||
const pWin = num(r.p_win);
|
||
if (pWin == null) continue;
|
||
const under = String(r.side || '').toLowerCase() === 'under';
|
||
const pOver = under ? 1 - pWin : pWin;
|
||
|
||
// Clamped → the pre-clamp value is unrecoverable. Excluded, counted.
|
||
if (pOver <= PROB_FLOOR + 1e-9 || pOver >= PROB_CEIL - 1e-9) { clamped += 1; continue; }
|
||
|
||
const rank = num(f.opp_rank_stat);
|
||
const oppAdj = rank == null ? 0 : (rank >= 0.70 ? 0.03 : rank <= 0.30 ? -0.03 : 0);
|
||
const ha = num(f.home_away);
|
||
const homeAdj = ha === 1 ? 0.015 : ha === 0 ? -0.015 : 0;
|
||
const sd = num(f.l10_stddev); const l20 = num(f.l20_avg);
|
||
const cv = (sd != null && sd > 0 && l20 != null && l20 > 0) ? sd / l20 : null;
|
||
const consistencyFired = cv != null && cv > CV_VOLATILE_THRESHOLD;
|
||
const k = consistencyFired ? 0.9 : 1;
|
||
|
||
// p_over (unclamped) = f(weighted + oppAdj + homeAdj); f linear ⟹ exact removal.
|
||
const noOpp = pOver - k * oppAdj;
|
||
const noHome = pOver - k * homeAdj;
|
||
const noAdj = pOver - k * oppAdj - k * homeAdj; // = f(weighted)
|
||
// Removing the consistency pull: invert f on the whole thing.
|
||
const noCons = consistencyFired ? (pOver - 0.05) / 0.9 : pOver;
|
||
// base+recency block alone, with every adjustment off.
|
||
const weighted = consistencyFired ? (noAdj - 0.05) / 0.9 : noAdj;
|
||
|
||
const flip = (p) => (under ? 1 - clamp(p) : clamp(p));
|
||
const won = r.outcome === 'hit' ? 1 : 0;
|
||
out.push({
|
||
stat: r.stat,
|
||
won,
|
||
full: pWin,
|
||
no_opp: flip(noOpp),
|
||
no_home: flip(noHome),
|
||
no_consistency: flip(noCons),
|
||
no_adjustments: flip(noAdj),
|
||
weighted_only: flip(weighted),
|
||
residual: won - pWin,
|
||
archetype: r.archetype || null,
|
||
// Features the champion NEVER reads — the missing-feature candidates.
|
||
l5_avg: num(f.l5_avg), l10_avg: num(f.l10_avg), l20_avg: num(f.l20_avg),
|
||
ab_per_game: num(f.ab_per_game), recent_ab_per_game: num(f.recent_ab_per_game),
|
||
opportunity_drift: num(f.opportunity_drift), rest_days: num(f.rest_days),
|
||
game_count_in_7d: num(f.game_count_in_7d),
|
||
park_h: num(f.park_h), park_hr: num(f.park_hr), park_r: num(f.park_r),
|
||
weather_temp_f: num(f.weather_temp_f), weather_wind_mph: num(f.weather_wind_mph),
|
||
weather_precip: num(f.weather_precip),
|
||
// Features it DOES read — controls for the same test.
|
||
opp_rank_stat: num(f.opp_rank_stat), home_away: num(f.home_away),
|
||
l10_stddev: num(f.l10_stddev),
|
||
});
|
||
}
|
||
return { rows: out, clamped };
|
||
}
|
||
|
||
const ABLATIONS = [
|
||
['no_opp', 'opponent (opp_rank_stat, ±0.03)'],
|
||
['no_home', 'home/away (±0.015)'],
|
||
['no_consistency', 'consistency pull (cv>0.40 → toward 0.50)'],
|
||
['no_adjustments', 'ALL THREE adjustments (leaves base+recency)'],
|
||
];
|
||
|
||
const UNUSED = ['l5_avg', 'l10_avg', 'l20_avg', 'ab_per_game', 'recent_ab_per_game',
|
||
'opportunity_drift', 'rest_days', 'game_count_in_7d', 'park_h', 'park_hr', 'park_r',
|
||
'weather_temp_f', 'weather_wind_mph', 'weather_precip'];
|
||
const USED = ['opp_rank_stat', 'home_away', 'l10_stddev'];
|
||
|
||
function ablateStat(rows, label) {
|
||
const ys = rows.map((r) => r.won);
|
||
const full = r4(corr(rows.map((r) => r.full), ys));
|
||
const abl = {};
|
||
for (const [key, name] of ABLATIONS) {
|
||
const bs = bootstrapDiff(rows, key, 'full');
|
||
abl[name] = {
|
||
resolution_without: r4(corr(rows.map((r) => r[key]), ys)),
|
||
// NEGATIVE delta = removing it HURT = the feature carries signal.
|
||
delta_from_removal: bs ? bs.point : null,
|
||
ci95: bs ? bs.ci95 : null,
|
||
carries_signal: bs ? (bs.ci_excludes_zero && bs.point < 0) : null,
|
||
};
|
||
}
|
||
return { stat: label, n: rows.length, base_rate: r4(ys.reduce((a, b) => a + b, 0) / ys.length), resolution_full: full, ablations: abl };
|
||
}
|
||
|
||
function residualStat(rows, label) {
|
||
const scan = (keys, seedBase) => {
|
||
const out = {};
|
||
keys.forEach((k, i) => {
|
||
const res = bootstrapCorr(rows, k, 20260804 + i + seedBase);
|
||
out[k] = res;
|
||
});
|
||
return out;
|
||
};
|
||
return {
|
||
stat: label,
|
||
n: rows.length,
|
||
unused_features: scan(UNUSED, 0),
|
||
used_features_control: scan(USED, 500),
|
||
};
|
||
}
|
||
|
||
async function main() {
|
||
if (!SB_URL || !SB_KEY) throw new Error('SUPABASE_URL / service key required');
|
||
const sb = createClient(SB_URL, SB_KEY, { auth: { persistSession: false } });
|
||
const { rows, clamped } = build(await fetchAll(sb));
|
||
|
||
const counts = {};
|
||
for (const r of rows) counts[r.stat] = (counts[r.stat] || 0) + 1;
|
||
const stats = Object.entries(counts).filter(([, n]) => n >= 60).map(([s]) => s)
|
||
.sort((a, b) => counts[b] - counts[a]);
|
||
|
||
const perStat = stats.map((s) => ablateStat(rows.filter((r) => r.stat === s), s));
|
||
const residual = stats.map((s) => residualStat(rows.filter((r) => r.stat === s), s));
|
||
|
||
// ── ARCHETYPE ON TRIAL ────────────────────────────────────────────────
|
||
// The champion reads NO archetype feature at all, so it cannot be ablated out
|
||
// of it. The fair test is whether archetype explains what the champion GETS
|
||
// WRONG: if a given archetype's rows are systematically mispriced, archetype
|
||
// carries prop signal the model is missing (wrong IMPLEMENTATION). If every
|
||
// archetype's mean residual straddles zero, archetype carries no prop signal.
|
||
const archetypeTest = stats.map((st) => {
|
||
const rs = rows.filter((r) => r.stat === st && r.archetype);
|
||
const groups = {};
|
||
for (const r of rs) (groups[r.archetype] = groups[r.archetype] || []).push(r.residual);
|
||
const out = {};
|
||
for (const [name, vals] of Object.entries(groups)) {
|
||
if (vals.length < 40) continue;
|
||
const rnd = makeRnd(20260805);
|
||
const means = [];
|
||
for (let it = 0; it < 3000; it += 1) {
|
||
let sum = 0;
|
||
for (let i = 0; i < vals.length; i += 1) sum += vals[Math.floor(rnd() * vals.length)];
|
||
means.push(sum / vals.length);
|
||
}
|
||
means.sort((a, b) => a - b);
|
||
const ci = [r4(means[Math.floor(0.025 * (means.length - 1))]), r4(means[Math.floor(0.975 * (means.length - 1))])];
|
||
out[name] = {
|
||
n: vals.length,
|
||
mean_residual: r4(vals.reduce((a, b) => a + b, 0) / vals.length),
|
||
ci95: ci,
|
||
systematically_mispriced: ci[0] > 0 || ci[1] < 0,
|
||
};
|
||
}
|
||
return { stat: st, archetypes: out };
|
||
});
|
||
|
||
// Any unused feature with a CI excluding zero, anywhere → a missing-feature lead.
|
||
const leads = [];
|
||
for (const rs of residual) {
|
||
for (const [k, v] of Object.entries(rs.unused_features)) {
|
||
if (v.ci_excludes_zero) leads.push({ stat: rs.stat, feature: k, corr: v.corr, ci95: v.ci95, n: v.n });
|
||
}
|
||
}
|
||
|
||
console.log(JSON.stringify({
|
||
measurement: 'EXACT ANALYTIC ABLATION of the champion, on the REPAIRED settled set. No refit, no re-fetch, no lookahead.',
|
||
limits: {
|
||
base_recency_not_separable: 'stored features hold AVERAGES, not frequencies-over-line; reported as one block',
|
||
clamped_rows_excluded: clamped,
|
||
},
|
||
total_rows: rows.length,
|
||
per_stat_ablation: perStat,
|
||
residual_signal_test: residual,
|
||
archetype_test: archetypeTest,
|
||
multiple_comparisons_note: 'The residual scan runs 14 unused features x 5 stats = 70 tests at alpha .05, so ~3-4 CI-excludes-zero results are EXPECTED BY CHANCE. Treat a single hit as noise; only a feature repeating across independent stats is evidence.',
|
||
missing_feature_leads: leads,
|
||
}, null, 2));
|
||
process.exit(0);
|
||
}
|
||
|
||
main().catch((e) => { console.error(e); process.exit(1); });
|