f61ec6b391
Seven orders of measurement-first repair. The served grade does not move. A0/A1 — the unordered page walk returned the right COUNT and the wrong ROWS: 410-617 of 2,490 duplicated with an equal number never returned, while rows.length matched the server exactly. safePaginate orders on a real unique key, verifies the tuple at runtime, and THROWS on a query error instead of treating it as end-of-data. Both hits PROVES are withdrawn: they were drawn through that reader, and defense_by_direction's distinct-n was likely below the gate floor all along. A2/A2b — rolled across every reader: 11 FAIL -> 0. Composite keys pulled from pg_index (the context tables are dated-composite and had no single unique column). The unordered helper is deleted, not parked. A3 — ledgerService and retentionService defaulted the SAME env var to DIFFERENT versions, so no ledger row ever carried the marker eligibility requires. One source now. model_snapshots settlement moved onto the cron: 15,484 -> 28,894 settled, repaired-champion 0 -> 7,556. A4 — hitsFactorContext takes an as-of cutoff. Refusal over reconstruction: no row at-or-before the date means the factor does not apply, never the nearest row. Live path unchanged, proven 400/400 on real rows. A5 — factor_inputs freezes what the factor READ, never the multiplier, so an audit can recompute and check. It also recorded the finding: the three hits factors have NEVER fired. prop.opponent and prop.opposing_pitcher are read by the resolver and written by nothing. A6/A7 — matchupKeys resolves those keys from the posted lineup plus the schedule's probable pitchers, and fires the factors into a SHADOW freeze: 248 fires on 308 props, 245 of which would move the grade. The served forecast is untouched. specs/a8-shadow-factor-gate.md pre-registers the test that decides whether they ever go live. Nothing is turned on. CALIBRATION_DEPLOYED stays []. Both verdicts stay withdrawn. 4,772 tests / 371 suites green, web build exit 0, read-integrity harness 34/34. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
236 lines
13 KiB
JavaScript
236 lines
13 KiB
JavaScript
#!/usr/bin/env node
|
|
'use strict';
|
|
|
|
/**
|
|
* Phases 0, 2, 3 and 4 — replay the factor wiring on settled hits rows.
|
|
*
|
|
* Transmission is proved MECHANICALLY before any resolution number is quoted,
|
|
* because "resolution went up" is exactly what a subtle bug also prints.
|
|
*/
|
|
|
|
require('dotenv').config();
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const { paginate } = require('../src/utils/safePaginate');
|
|
const { uniqueKeyFor } = require('../src/utils/tableKeys');
|
|
const { createClient } = require('@supabase/supabase-js');
|
|
const hf = require('../src/services/model/hitsFactors');
|
|
const lp = require('../src/services/model/lowParamCalibrator');
|
|
const guards = require('../src/services/model/calibrationGuards');
|
|
const { knownNumber } = require('../src/utils/known');
|
|
const { nameKey } = require('../src/utils/playerName');
|
|
|
|
const BOX = path.join(process.cwd(), '.seq-cache', 'batting-lines.json');
|
|
const SEQ = path.join(process.cwd(), '.seq-cache', 'sequences.json');
|
|
const mean = (xs) => (xs.length ? xs.reduce((a, b) => a + b, 0) / xs.length : null);
|
|
|
|
// FIX A4 — the context walks here ordered by a NON-UNIQUE prefix
|
|
// ('player_key' / 'team'), which leaves the trailing key columns tied and lets
|
|
// pages overlap. They now order by the table's real unique tuple.
|
|
async function page(sb, t, s, f, key) {
|
|
return paginate(() => f(sb.from(t).select(s)),
|
|
{ key: key || uniqueKeyFor(t), pageSize: 1000, label: `factor-wiring-audit:${t}` });
|
|
}
|
|
|
|
/**
|
|
* AS-OF. `FWA_AS_OF=YYYY-MM-DD` bounds every dated context table to rows at or
|
|
* before that date, so an audit of a past slate cannot join a profile built from
|
|
* games played after it. Unset = today = the live read, unchanged.
|
|
*
|
|
* REFUSAL OVER RECONSTRUCTION: an entity with no row at or before the cutoff is
|
|
* simply absent from the index, so its factor does not apply. Never the nearest
|
|
* row — a substituted row is a plausible wrong value wearing a date.
|
|
*/
|
|
const AS_OF = process.env.FWA_AS_OF || null;
|
|
const dated = (q) => (AS_OF ? q.lte('as_of_date', AS_OF) : q);
|
|
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 decompose(rows, bins = 10) {
|
|
const base = mean(rows.map((r) => r.won));
|
|
const unc = base * (1 - base);
|
|
let rel = 0; let res = 0;
|
|
for (let k = 0; k < bins; k += 1) {
|
|
const lo = k / bins; const hi = (k + 1) / bins;
|
|
const sl = rows.filter((r) => r.p >= lo && (hi >= 1 ? r.p <= 1 : r.p < hi));
|
|
if (!sl.length) continue;
|
|
const w = sl.length / rows.length;
|
|
rel += w * (mean(sl.map((r) => r.p)) - mean(sl.map((r) => r.won))) ** 2;
|
|
res += w * (mean(sl.map((r) => r.won)) - base) ** 2;
|
|
}
|
|
return { base_rate: r4(base), reliability: r5(rel), resolution: r5(res), uncertainty: r5(unc), share: r4(res / unc) };
|
|
}
|
|
|
|
(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;
|
|
|
|
// Factor inputs.
|
|
const [spray, defense, platoon, statcast] = await Promise.all([
|
|
page(sb, 'batter_spray', '*', (q) => dated(q.eq('sport', 'mlb'))),
|
|
page(sb, 'team_defense', '*', (q) => dated(q.eq('sport', 'mlb'))),
|
|
page(sb, 'platoon_splits', '*', (q) => dated(q.eq('sport', 'mlb'))),
|
|
// `statcast_aggregates` keeps ONE as-of date, so it cannot answer an as-of
|
|
// question; the dated path reads the retained history instead.
|
|
AS_OF
|
|
? page(sb, 'statcast_history', 'player_key, source_id, role, bats, throws, hard_hit_pct, as_of_date',
|
|
(q) => q.eq('sport', 'mlb').lte('as_of_date', AS_OF))
|
|
: page(sb, 'statcast_aggregates', 'player_key, source_id, role, bats, throws, hard_hit_pct',
|
|
(q) => q.eq('sport', 'mlb')),
|
|
]);
|
|
const latest = (rows, k) => { const m = new Map(); for (const r of rows) { const key = r[k]; if (!key) continue; const p = m.get(key); if (!p || String(r.as_of_date) > String(p.as_of_date)) m.set(key, r); } return m; };
|
|
const sprayBy = latest(spray, 'player_key'); const defBy = latest(defense, 'team'); const platBy = latest(platoon, 'player_key');
|
|
const batBy = new Map(); const pitBy = new Map();
|
|
// On the dated path several as-of rows per player are in scope; collapse to
|
|
// the latest WITHIN the bound, keyed on (source_id, role) so a two-way player
|
|
// keeps both profiles. No-op on the live path (one row per player).
|
|
const statcastLatest = AS_OF ? (() => {
|
|
const m = new Map();
|
|
for (const r of statcast) {
|
|
const k = `${r.source_id}|${r.role}`;
|
|
const prev = m.get(k);
|
|
if (!prev || String(r.as_of_date) > String(prev.as_of_date)) m.set(k, r);
|
|
}
|
|
return [...m.values()];
|
|
})() : statcast;
|
|
for (const r of statcastLatest) { if (!r.player_key) continue; (r.role === 'pitcher' ? pitBy : batBy).set(r.player_key, r); }
|
|
const frac = (v) => { const n = knownNumber(v); return n === null ? null : (n > 1 ? n / 100 : n); };
|
|
|
|
// Opponent + starter per (player,date) from the sequence cache.
|
|
const { games } = JSON.parse(fs.readFileSync(SEQ, 'utf8'));
|
|
const oppOf = new Map(); const spOf = new Map();
|
|
for (const g of games) {
|
|
for (const side of ['home', 'away']) {
|
|
const opp = g[side === 'home' ? 'away' : 'home'];
|
|
const st = (g[side].arms || []).find((a) => a.started);
|
|
const half = side === 'home' ? 'top' : 'bottom';
|
|
for (const pa of g.pas.filter((p) => p.half === half)) {
|
|
const k = `${g.date}|${nameKey(pa.batter_name || '')}`;
|
|
if (!oppOf.has(k)) { oppOf.set(k, g[side].team); if (st) spOf.set(k, st.name); }
|
|
}
|
|
}
|
|
}
|
|
|
|
const snaps = await page(sb, 'model_snapshots', 'id, game_date, captured_at, stat, player_key, player_name, line, side, p_win, refused',
|
|
(q) => q.eq('sport', 'mlb').eq('stat', 'hits'));
|
|
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 = []; const transmission = []; const unreadable = [];
|
|
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(b.hits); if (v === null) continue;
|
|
const over = v > L;
|
|
const won = (String(r.side).toLowerCase() === 'under' ? !over : over) ? 1 : 0;
|
|
|
|
const key = r.player_key;
|
|
const bat = batBy.get(key);
|
|
const oppTeam = oppOf.get(`${r.game_date}|${key}`);
|
|
const def = oppTeam ? (defBy.get(oppTeam) || defBy.get(String(oppTeam).split(' ').pop())) : null;
|
|
const spName = spOf.get(`${r.game_date}|${key}`);
|
|
const pit = spName ? pitBy.get(nameKey(spName)) : null;
|
|
const sp = platBy.get(key);
|
|
const ctx = {
|
|
spray: sprayBy.get(key) || null,
|
|
positionOaa: def && def.position_oaa ? def.position_oaa : null,
|
|
bats: bat && bat.bats ? String(bat.bats)[0] : null,
|
|
throws: pit && pit.throws ? String(pit.throws)[0] : null,
|
|
pitcherHardHit: pit ? frac(pit.hard_hit_pct) : null,
|
|
platoonSplits: sp ? { vl: { pa: sp.vl_pa, atBats: sp.vl_ab, hits: sp.vl_hits }, vr: { pa: sp.vr_pa, atBats: sp.vr_ab, hits: sp.vr_hits } } : null,
|
|
};
|
|
// The engine adjusts p_over then flips for unders; replay that exactly.
|
|
const pOverRaw = String(r.side).toLowerCase() === 'under' ? 1 - knownNumber(r.p_win) : knownNumber(r.p_win);
|
|
const adj = hf.adjustProbability(pOverRaw, ctx);
|
|
const pAfter = adj.factors_fired > 0
|
|
? (String(r.side).toLowerCase() === 'under' ? 1 - adj.p_adjusted : adj.p_adjusted)
|
|
: knownNumber(r.p_win);
|
|
|
|
rows.push({ date: r.game_date, p_before: knownNumber(r.p_win), p: pAfter, won, fired: adj.factors_fired, applied: adj.applied });
|
|
|
|
// TRANSMISSION IS TESTED PER FACTOR, IN ISOLATION.
|
|
// Comparing one factor's expected sign against the COMPOSITE p_win change is
|
|
// wrong: with three factors firing, two pulling down and one up, the net can
|
|
// oppose any single member and look like a defect when nothing is broken.
|
|
// So each factor is applied ALONE to the same base and its own sign checked.
|
|
const isUnder = String(r.side).toLowerCase() === 'under';
|
|
for (const a of adj.applied) {
|
|
if (transmission.filter((t) => t.factor === a.factor).length >= 4) continue;
|
|
if (Math.abs(a.multiplier - 1) < 0.03) continue;
|
|
const solo = { spray: null, positionOaa: null, bats: ctx.bats, throws: null, pitcherHardHit: null, platoonSplits: null };
|
|
if (a.factor === 'defense_by_direction') { solo.spray = ctx.spray; solo.positionOaa = ctx.positionOaa; }
|
|
if (a.factor === 'pitcher_contact_profile') solo.pitcherHardHit = ctx.pitcherHardHit;
|
|
if (a.factor === 'platoon_severity') { solo.platoonSplits = ctx.platoonSplits; solo.throws = ctx.throws; }
|
|
const one = hf.adjustProbability(pOverRaw, solo);
|
|
if (one.factors_fired !== 1) continue;
|
|
const soloWin = isUnder ? 1 - one.p_adjusted : one.p_adjusted;
|
|
transmission.push({
|
|
factor: a.factor, player: r.player_name, date: r.game_date,
|
|
expected: a.multiplier > 1 ? 'raise p(over)' : 'lower p(over)', multiplier: a.multiplier,
|
|
side: r.side, p_before: knownNumber(r.p_win), p_after_solo: r4(soloWin),
|
|
sign_correct: isUnder
|
|
? ((a.multiplier > 1) === (soloWin < knownNumber(r.p_win)))
|
|
: ((a.multiplier > 1) === (soloWin > knownNumber(r.p_win))),
|
|
});
|
|
}
|
|
if (adj.skipped.some((s) => /switch hitter/.test(s.reason || '')) && unreadable.length < 4) {
|
|
unreadable.push({ player: r.player_name, reason: 'switch hitter — spray side unreadable', p_before: knownNumber(r.p_win), p_after: pAfter, moved_by_spray: false });
|
|
}
|
|
}
|
|
|
|
// ── PHASE 4: OOS, point-in-time ──
|
|
rows.sort((a, b) => a.date.localeCompare(b.date));
|
|
const dates = [...new Set(rows.map((r) => r.date))].sort();
|
|
const perDate = new Map(); for (const r of rows) perDate.set(r.date, (perDate.get(r.date) || 0) + 1);
|
|
let acc = 0; let cut = dates[dates.length - 1];
|
|
for (const d of dates) { acc += perDate.get(d); if (acc >= rows.length * 0.45) { cut = d; break; } }
|
|
const fit = rows.filter((r) => r.date < cut); const ev = rows.filter((r) => r.date >= cut);
|
|
|
|
const mBefore = lp.fitPlatt(fit.map((r) => ({ p: r.p_before, won: r.won, date: r.date })));
|
|
const mAfter = lp.fitPlatt(fit.map((r) => ({ p: r.p, won: r.won, date: r.date })));
|
|
const evBefore = ev.map((r) => ({ ...r, p: mBefore && !mBefore.refused ? lp.applyPlatt(mBefore, r.p_before) : r.p_before })).filter((r) => r.p != null);
|
|
const evAfter = ev.map((r) => ({ ...r, p: mAfter && !mAfter.refused ? lp.applyPlatt(mAfter, r.p) : r.p })).filter((r) => r.p != null);
|
|
|
|
const bBefore = guards.safeBrier(evBefore.map((r) => r.p), evBefore.map((r) => r.won));
|
|
const bAfter = guards.safeBrier(evAfter.map((r) => r.p), evAfter.map((r) => r.won));
|
|
|
|
const byDate = new Map();
|
|
for (let i = 0; i < evAfter.length; i += 1) { const d = evAfter[i].date; if (!byDate.has(d)) byDate.set(d, []); byDate.get(d).push({ a: evAfter[i].p, b: evBefore[i] ? evBefore[i].p : null, won: evAfter[i].won }); }
|
|
const keys = [...byDate.keys()]; const rnd = makeRnd(20260807); const diffs = [];
|
|
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)]));
|
|
const u = s.filter((x) => x.b != null);
|
|
if (!u.length) continue;
|
|
diffs.push(guards.safeBrier(u.map((x) => x.a), u.map((x) => x.won)) - guards.safeBrier(u.map((x) => x.b), u.map((x) => x.won)));
|
|
}
|
|
diffs.sort((a, b) => a - b);
|
|
|
|
console.log(JSON.stringify({
|
|
coverage: { rows: rows.length, any_factor_fired: rows.filter((r) => r.fired > 0).length,
|
|
by_count: [0, 1, 2, 3].map((k) => ({ factors: k, n: rows.filter((r) => r.fired === k).length })) },
|
|
PHASE_2_transmission: transmission,
|
|
PHASE_2_unreadable_static: unreadable,
|
|
PHASE_4: {
|
|
split_at: cut, fit_n: fit.length, eval_n: ev.length, eval_dates: keys.length,
|
|
resolution_before: decompose(evBefore), resolution_after: decompose(evAfter),
|
|
brier_before: r5(bBefore), brier_after: r5(bAfter), brier_delta: r5(bAfter - bBefore),
|
|
brier_ci_date_block: diffs.length ? [r5(diffs[Math.floor(diffs.length * 0.025)]), r5(diffs[Math.floor(diffs.length * 0.975)])] : null,
|
|
},
|
|
}, null, 2));
|
|
process.exit(0);
|
|
})().catch((e) => { console.error(e); process.exit(1); });
|
|
|
|
const r5 = (v) => (v == null || !Number.isFinite(v) ? null : Math.round(v * 100000) / 100000);
|
|
const r4 = (v) => (v == null || !Number.isFinite(v) ? null : Math.round(v * 10000) / 10000);
|