Files
vyndr/scripts/champion-vs-fair-baseline.js
T
builtbykev 929fd81940 Repair the champion: it was reading ten games, not a season
PHASE 0 — the defect is real past the peek. Against a FAIR point-in-time
baseline (each player's rate over games strictly before that date, >=10
prior games, box scores back to 05-01), the served champion LOSES on all
four stats, three of four CIs excluding zero:

  hits  0.00251 vs 0.00774  CI [-0.0074,-0.0011]
  TB    0.00393 vs 0.00619  CI [-0.0055,-0.0003]
  rbi   0.02481 vs 0.03133  CI [-0.0153,-0.0005]
  runs  0.00181 vs 0.00683  CI [-0.0114,+0.0008]

PHASE 1 — the cause is the WINDOW, not the weights. estimateProbability
builds its base rate as the frequency over every row it is handed, and
featureCache.getStatRows handed it res.last10. So the "season rate" was a
TEN-GAME rate, and 0.4 of the forecast was the last five OF THOSE TEN. The
0.40 recency weight costs resolution on all four stats (-0.00086,
-0.00107, -0.00562, -0.00365). Nudges are mixed and small -- harmful on
hits and rbi, marginally helpful on TB and runs -- so they are left alone.

PHASE 2 — two lines, no new data, no extra API call, because fullLog was
already fetched by the same adapter call that produced last10:
getStatRows now reads fullLog, and RECENCY_WEIGHT goes 0.40 -> 0.20.

  hits  0.00251 -> 0.00817  (tripled; now above the fair baseline)
  TB    0.00393 -> 0.00734  (above baseline; vs old CI [0.0020,0.0067])
  rbi   0.02481 -> 0.02727  (still below baseline, CI includes zero)
  runs  0.00181 -> 0.00436  (still below baseline, CI includes zero)

Gate stated exactly: hits and TB now exceed the fair baseline on the point
estimate; rbi and runs remain below but EVERY CI now includes zero, so no
stat reliably loses to a frequency table. That is a tie on rbi/runs, not a
win, and it is reported as one. Only TB's improvement over the old
champion is CI-confirmed; the rest are directional.

STALE-FIT GATE: CALIBRATION_DEPLOYED is now EMPTY. The low-param maps were
fitted on the retired forecast and fromLedger cannot rescue them -- settled
ledger rows still carry OLD p_win, so refitting today would refit the
retired forecast. Nothing is served calibrated until dates settle under
the repaired champion, and the favourite-longshot bias must be re-measured
rather than assumed to survive. The shadow duel is void.

PHASE 3 — the hits factor lift is NOT re-measured, and cannot be yet: it
needs settled rows produced BY the repaired champion, which ships in this
commit. Replaying would score the factors against a reconstruction rather
than the served forecast. Deferred, explicitly. The factors remain wired
and transmitting; only their lift is unquantified on the new baseline.

PHASE 4 — standing flag, and it is large: EVERY factor verdict in this
programme, every null and every THEATER, was measured against a champion
worse than a frequency table. Signal added to noise reads as noise. Prior
verdicts may deserve re-audit. Logged, not re-run.

Re-queued not built: rbi lineup-slot / RISP opportunity through the
two-part gate, now landing on a repaired champion.

Serving-path change by design; the byte-identical invariant inverted and
all four stats move. Nine frozen model modules verified unchanged. No
Bonferroni slot -- resolution accounting on the champion's own knobs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W1sivYNqY2TS5ftykmHBU9
2026-08-07 03:28:33 -04:00

175 lines
8.1 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
'use strict';
/**
* PHASES 0-1 — is the champion really worse than a frequency table?
*
* The prior comparison used a leave-one-out baseline that saw the evaluation
* window. This one does not: for every prop, the naive forecast is that player's
* rate of clearing THAT LINE over games strictly BEFORE that date — the same
* temporal discipline the champion is held to. If the champion still loses, the
* defect is real and not an artefact of the peek.
*
* Then the champion's own knobs are ablated. Its core is
*
* p = 0.6 * season_frequency + 0.4 * last5_frequency
*
* plus a +/-0.03 opponent nudge, a +/-0.015 home nudge, and a cv pull. Each is
* tested for whether it COSTS resolution. This is accounting on the champion's
* existing knobs, not a causal claim, so no Bonferroni slot.
*/
require('dotenv').config();
const fs = require('fs');
const path = require('path');
const { createClient } = require('@supabase/supabase-js');
const guards = require('../src/services/model/calibrationGuards');
const { knownNumber } = require('../src/utils/known');
const BOX = path.join(process.cwd(), '.seq-cache', 'batting-lines.json');
const STATS = ['hits', 'total_bases', 'rbi', 'runs'];
const FIELD = { hits: (b) => b.hits, total_bases: (b) => b.totalBases, rbi: (b) => b.rbi, runs: (b) => b.runs };
const mean = (xs) => (xs.length ? xs.reduce((a, b) => a + b, 0) / xs.length : null);
/** Games a player needs before we will read his own rate at all. */
const MIN_PRIOR_GAMES = 10;
async function page(sb, t, sel, orderBy, apply) {
const out = [];
for (let i = 0; ; i += 1000) {
const { data, error } = await apply(sb.from(t).select(sel)).order(orderBy, { ascending: true }).range(i, i + 999);
if (error) throw new Error(`${t}: ${error.message}`);
if (!data || !data.length) break;
out.push(...data);
if (data.length < 1000) break;
}
return out;
}
const isPreGame = (c, g) => {
const et = new Date(new Date(c).getTime() - 4 * 3600 * 1000);
const d = et.toISOString().slice(0, 10);
return d < g || (d === g && et.getUTCHours() < 19);
};
function makeRnd(seed) { let s = seed >>> 0; return () => { s ^= s << 13; s >>>= 0; s ^= s >>> 17; s ^= s << 5; s >>>= 0; return s / 4294967296; }; }
function resolutionOf(rows, key) {
const base = mean(rows.map((r) => r.won));
let res = 0;
for (let k = 0; k < 10; k += 1) {
const lo = k / 10; const hi = (k + 1) / 10;
const sl = rows.filter((r) => r[key] >= lo && (hi >= 1 ? r[key] <= 1 : r[key] < hi));
if (!sl.length) continue;
res += (sl.length / rows.length) * (mean(sl.map((x) => x.won)) - base) ** 2;
}
return res;
}
/** Paired date-block bootstrap on a resolution difference (a b). */
function dateBlockResCI(rows, a, b, seed) {
const byDate = new Map();
for (const r of rows) { if (!byDate.has(r.date)) byDate.set(r.date, []); byDate.get(r.date).push(r); }
const keys = [...byDate.keys()]; const rnd = makeRnd(seed); const d = [];
for (let it = 0; it < 3000; it += 1) {
const s = [];
for (let i = 0; i < keys.length; i += 1) s.push(...byDate.get(keys[Math.floor(rnd() * keys.length)]));
d.push(resolutionOf(s, a) - resolutionOf(s, b));
}
d.sort((x, y) => x - y);
return { ci: [r5(d[Math.floor(d.length * 0.025)]), r5(d[Math.floor(d.length * 0.975)])], date_blocks: keys.length };
}
(async () => {
const sb = createClient(process.env.SUPABASE_URL, process.env.SUPABASE_SERVICE_ROLE_KEY || process.env.SUPABASE_SERVICE_KEY, { auth: { persistSession: false } });
const lines = JSON.parse(fs.readFileSync(BOX, 'utf8')).lines;
// Per-player, date-ordered history. The ONLY source of the naive forecast.
const hist = new Map();
for (const [k, b] of Object.entries(lines)) {
const [date, key] = k.split('|');
if (!hist.has(key)) hist.set(key, []);
hist.get(key).push({ date, b });
}
for (const v of hist.values()) v.sort((x, y) => x.date.localeCompare(y.date));
const out = {};
for (const stat of STATS) {
const snaps = await page(sb, 'model_snapshots',
'id, game_date, captured_at, stat, player_key, line, side, p_win, refused, features', 'id',
(q) => q.eq('sport', 'mlb').eq('stat', stat));
const picked = new Map();
for (const r of snaps) {
if (!isPreGame(r.captured_at, r.game_date) || r.refused || knownNumber(r.p_win) === null) continue;
const k = [r.game_date, r.player_key, r.line].join('|');
const prev = picked.get(k);
if (!prev || knownNumber(r.p_win) > knownNumber(prev.p_win)) picked.set(k, r);
}
guards.assertPickedSideDedup([...picked.values()].map((r) => ({ propKey: [r.game_date, r.player_key, r.line].join('|'), side: r.side, p: knownNumber(r.p_win) })));
const rows = [];
for (const r of picked.values()) {
const b = lines[`${r.game_date}|${r.player_key}`]; const L = knownNumber(r.line);
if (!b || L === null || !r.side) continue;
const v = knownNumber(FIELD[stat](b)); if (v === null) continue;
const isUnder = String(r.side).toLowerCase() === 'under';
const won = (isUnder ? !(v > L) : (v > L)) ? 1 : 0;
// ── THE FAIR COMPETITOR: strictly prior games only. ──
const prior = (hist.get(r.player_key) || []).filter((g) => g.date < r.game_date);
if (prior.length < MIN_PRIOR_GAMES) continue;
const vals = prior.map((g) => knownNumber(FIELD[stat](g.b))).filter((x) => x !== null);
if (vals.length < MIN_PRIOR_GAMES) continue;
const season = vals.filter((x) => x > L).length / vals.length;
const last5 = vals.slice(-5);
const recent = last5.filter((x) => x > L).length / last5.length;
const f = r.features || {};
const homeAdj = f.home_away === 1.0 ? 0.015 : f.home_away === 0.0 ? -0.015 : 0;
const oppR = knownNumber(f.opp_rank_stat);
const oppAdj = oppR === null ? 0 : (oppR >= 0.70 ? 0.03 : (oppR <= 0.30 ? -0.03 : 0));
const flip = (p) => Math.max(0.01, Math.min(0.99, isUnder ? 1 - p : p));
const blend = (w) => flip(0.6 === null ? season : (1 - w) * season + w * recent);
rows.push({
date: r.game_date, won,
champion: knownNumber(r.p_win),
// Reconstructions, all point-in-time.
season_only: flip(season),
w40: flip(0.6 * season + 0.4 * recent), // the current blend
w20: flip(0.8 * season + 0.2 * recent),
w60: flip(0.4 * season + 0.6 * recent),
w40_nudged: flip(Math.max(0.01, Math.min(0.99, 0.6 * season + 0.4 * recent + oppAdj + homeAdj))),
season_nudged: flip(Math.max(0.01, Math.min(0.99, season + oppAdj + homeAdj))),
});
}
if (rows.length < 100) { out[stat] = { n: rows.length, note: 'too few rows with 10+ prior games' }; continue; }
// The REPAIRED champion: full-season window + recency weight 0.20, which is
// exactly what the code change produces.
for (const r of rows) r.repaired = r.w20;
const keys = ['champion', 'repaired', 'season_only', 'w20', 'w40', 'w60', 'w40_nudged', 'season_nudged'];
const res = Object.fromEntries(keys.map((k) => [k, r5(resolutionOf(rows, k))]));
const gap = dateBlockResCI(rows, 'champion', 'season_only', 20260807);
out[stat] = {
n: rows.length,
dates: new Set(rows.map((r) => r.date)).size,
resolution: res,
champion_minus_fair_baseline: r5(res.champion - res.season_only),
ci_champion_minus_fair: gap.ci,
date_blocks: gap.date_blocks,
champion_loses_fairly: res.champion < res.season_only,
best_variant: keys.reduce((a, k) => (res[k] > res[a] ? k : a), keys[0]),
recency_cost: r5(res.w40 - res.season_only),
nudge_cost: r5(res.w40_nudged - res.w40),
REPAIRED_vs_fair: r5(res.repaired - res.season_only),
REPAIRED_ci: dateBlockResCI(rows, 'repaired', 'season_only', 20260807).ci,
REPAIRED_vs_old_champion: r5(res.repaired - res.champion),
REPAIRED_beats_old_ci: dateBlockResCI(rows, 'repaired', 'champion', 20260807).ci,
};
}
console.log(JSON.stringify(out, null, 2));
process.exit(0);
})().catch((e) => { console.error('FAILED:', e.message); process.exit(1); });
const r5 = (v) => (v == null || !Number.isFinite(v) ? null : Math.round(v * 100000) / 100000);