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>
318 lines
14 KiB
JavaScript
318 lines
14 KiB
JavaScript
#!/usr/bin/env node
|
|
'use strict';
|
|
|
|
/**
|
|
* prove-runs-rbi — THE CONTEXT-HEAVY STATS, WHERE INFLATION IS EASIEST.
|
|
*
|
|
* A large share of both stats is genuinely outside the hitter's control: a run
|
|
* needs someone behind you, an RBI needs someone in front of you. The job is to
|
|
* prove the HITTER-CONTROLLABLE part above the archetype's own base rate and
|
|
* grade the rest honestly as base-rate — which is the CORRECT answer for a
|
|
* context stat, not a failure to find something.
|
|
*
|
|
* ── THE NULL IS THE ARCHETYPE'S BASE RATE ────────────────────────────────
|
|
* Deliberately, and per the order: these base rates are spread and
|
|
* context-inflated, so beating "hitters like him" is the only meaningful bar. A
|
|
* per-player leave-one-out rate is not available here — 935 RBI rows over 344
|
|
* players is ~2.7 rows each, and estimating a personal rate from two rows would
|
|
* be inventing one. Leave-one-out is applied at the ARCHETYPE level so a row
|
|
* never contributes to its own baseline.
|
|
*
|
|
* ── INPUTS RECONSTRUCTED RATHER THAN DECLARED MISSING ────────────────────
|
|
* `lineup_context` only starts 2026-08-04 (ingest began last week) while settled
|
|
* rows run from 07-31, so only 187 of 617 runs rows join to a batting order.
|
|
* That would be input-blocked — except the play-by-play cache covers 05-01
|
|
* onward, and the batting order IS the order batters first appear. Reach-base
|
|
* skill and lineup power behind are derived from the same cache, point-in-time.
|
|
*
|
|
* SUPABASE_URL=... node scripts/prove-runs-rbi.js
|
|
*/
|
|
|
|
require('dotenv').config();
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const { createClient } = require('@supabase/supabase-js');
|
|
const fg = require('../src/services/model/factorGate');
|
|
const tl = require('../src/services/model/testLedger');
|
|
const sk = require('../src/services/model/skillProjection');
|
|
const { knownNumber, knownRate } = require('../src/utils/known');
|
|
const { nameKey } = require('../src/utils/playerName');
|
|
const { paginate } = require('../src/utils/safePaginate');
|
|
const { uniqueKeyFor } = require('../src/utils/tableKeys');
|
|
|
|
const SB_URL = process.env.SUPABASE_URL;
|
|
const SB_KEY = process.env.SUPABASE_SERVICE_ROLE_KEY || process.env.SUPABASE_SERVICE_KEY;
|
|
const CACHE = process.env.SEQ_OUT || path.join(process.cwd(), '.seq-cache', 'sequences.json');
|
|
const PAGE = 1000;
|
|
const ARCHS = (process.env.RR_ARCHETYPES || 'ALL,BOMBER,GHOST,BRUSH,DRIVER').split(',');
|
|
|
|
const HIT = new Set(['single', 'double', 'triple', 'home_run']);
|
|
const ONBASE = new Set(['single', 'double', 'triple', 'home_run', 'walk', 'hit_by_pitch', 'intent_walk']);
|
|
const PA_EVENT = new Set([...HIT, 'field_out', 'strikeout', 'grounded_into_double_play', 'force_out',
|
|
'field_error', 'fielders_choice', 'fielders_choice_out', 'double_play', 'sac_fly', 'pop_out',
|
|
'line_out', 'fly_out', 'strikeout_double_play', 'walk', 'hit_by_pitch', 'intent_walk']);
|
|
|
|
const mean = (xs) => (xs.length ? xs.reduce((a, b) => a + b, 0) / xs.length : null);
|
|
|
|
// ── THE SAFE WALK (Fix A2/A2b) ────────────────────────────────────────────
|
|
// This script used to walk pages with `.range()` and NO ORDER BY. Measured on
|
|
// production, that returned the correct row COUNT and the wrong ROWS: up to
|
|
// 33.6% of a read came back twice while an equal share never came back at all,
|
|
// so `rows.length` looked perfect while a fifth of the sample was missing.
|
|
//
|
|
// `pageSafe` routes every read through `src/utils/safePaginate`: a stable ORDER
|
|
// BY on the table's real UNIQUE key — single OR composite, looked up from
|
|
// `src/utils/tableKeys` rather than assumed — a runtime tuple-uniqueness check,
|
|
// and a THROWN error instead of a silent stop. The old unordered helper is gone
|
|
// rather than left beside it, because a dead broken helper is an invitation.
|
|
async function pageSafe(sb, table, select, apply, key = uniqueKeyFor(table)) {
|
|
return paginate(() => apply(sb.from(table).select(select)),
|
|
{ key, pageSize: PAGE, label: `${table}` });
|
|
}
|
|
|
|
/** THE MEASURED READS — main() and the harness call the same functions. */
|
|
const READS = {
|
|
opportunity: (sb) => pageSafe(sb, 'hitter_opportunity', '*', (q) => q.eq('sport', 'mlb')),
|
|
};
|
|
|
|
/**
|
|
* Reconstruct, point-in-time, from play-by-play:
|
|
* order[date|nameKey] the hitter's batting slot that game
|
|
* behind[date|nameKey] mean barrel-ish power of the three slots after him
|
|
* onbase[nameKey] his reach-base rate over PRIOR games only
|
|
*/
|
|
function reconstruct(barrelByKey) {
|
|
const { games } = JSON.parse(fs.readFileSync(CACHE, 'utf8'));
|
|
games.sort((a, b) => String(a.date).localeCompare(String(b.date)) || a.gamePk - b.gamePk);
|
|
|
|
const order = new Map();
|
|
const behind = new Map();
|
|
const onbaseNow = new Map(); // running totals, folded in AFTER each game
|
|
const onbasePrior = new Map(); // snapshot used for that game's rows
|
|
|
|
for (const g of games) {
|
|
for (const half of ['top', 'bottom']) {
|
|
const pas = g.pas.filter((p) => p.half === half && PA_EVENT.has(p.event));
|
|
if (!pas.length) continue;
|
|
|
|
// The batting order IS the order batters first appear.
|
|
const seen = [];
|
|
const seenSet = new Set();
|
|
for (const p of pas) {
|
|
if (!seenSet.has(p.batter)) { seenSet.add(p.batter); seen.push(p); }
|
|
if (seen.length >= 9) break;
|
|
}
|
|
const slots = seen.map((p) => ({ id: p.batter, key: nameKey(p.batter_name || '') }));
|
|
|
|
for (let i = 0; i < slots.length; i += 1) {
|
|
const k = `${g.date}|${slots[i].key}`;
|
|
order.set(k, i + 1);
|
|
// Power BEHIND him — the hitters who would drive him in.
|
|
const nxt = [1, 2, 3].map((d) => slots[(i + d) % slots.length])
|
|
.map((s) => (s ? knownRate(barrelByKey.get(s.key)) : null))
|
|
.filter((v) => v !== null);
|
|
if (nxt.length) behind.set(k, mean(nxt));
|
|
const prior = onbaseNow.get(slots[i].key);
|
|
if (prior && prior.pa >= 60) onbasePrior.set(k, prior.ob / prior.pa);
|
|
}
|
|
for (const p of pas) {
|
|
const key = nameKey(p.batter_name || '');
|
|
const cur = onbaseNow.get(key) || { pa: 0, ob: 0 };
|
|
cur.pa += 1; cur.ob += ONBASE.has(p.event) ? 1 : 0;
|
|
onbaseNow.set(key, cur);
|
|
}
|
|
}
|
|
}
|
|
return { order, behind, onbase: onbasePrior };
|
|
}
|
|
|
|
/** RBI and RUNS have different causal stories, so different factors. */
|
|
const FACTORS = {
|
|
rbi: [
|
|
{
|
|
key: 'risp_opportunity',
|
|
needs: ['risp_share'],
|
|
entity: (r) => r.player_key,
|
|
mechanism: 'HOW OFTEN HE BATS WITH RUNNERS IN SCORING POSITION. Half of an RBI is opportunity, and this is the ingested measure of it.',
|
|
apply: (r) => 1 + Math.max(-0.20, Math.min(0.20, (r.risp_share - 0.22) * 1.6)),
|
|
},
|
|
{
|
|
key: 'extra_base_skill',
|
|
needs: ['barrel_pct'],
|
|
entity: (r) => r.player_key,
|
|
mechanism: 'The other half — having batted with runners on, can he drive them in.',
|
|
apply: (r) => 1 + Math.max(-0.20, Math.min(0.20, (r.barrel_pct - 0.078) * 1.8)),
|
|
},
|
|
{
|
|
key: 'risp_x_extra_base',
|
|
needs: ['risp_share', 'barrel_pct'],
|
|
entity: (r) => r.player_key,
|
|
mechanism: 'THE CAUSALLY-CORRECT COMPOUND: opportunity AND the power to convert it. Neither half alone is an RBI.',
|
|
apply: (r) => (1 + Math.max(-0.20, Math.min(0.20, (r.risp_share - 0.22) * 1.6)))
|
|
* (1 + Math.max(-0.20, Math.min(0.20, (r.barrel_pct - 0.078) * 1.8))),
|
|
},
|
|
],
|
|
runs: [
|
|
{
|
|
key: 'reach_base',
|
|
needs: ['onbase'],
|
|
entity: (r) => r.player_key,
|
|
mechanism: 'You cannot score without first reaching base. The most hitter-controllable component of a run.',
|
|
apply: (r) => 1 + Math.max(-0.25, Math.min(0.25, (r.onbase - 0.318) * 2.2)),
|
|
},
|
|
{
|
|
key: 'lineup_power_behind',
|
|
needs: ['power_behind'],
|
|
entity: (r) => `${r.game_id}|${r.batting_order}`,
|
|
mechanism: 'Who bats after him — the hitters who would drive him in. Pure context, and the part he does not control.',
|
|
apply: (r) => 1 + Math.max(-0.20, Math.min(0.20, (r.power_behind - 0.078) * 1.8)),
|
|
},
|
|
{
|
|
key: 'reach_x_power_behind',
|
|
needs: ['onbase', 'power_behind'],
|
|
entity: (r) => r.player_key,
|
|
mechanism: 'THE CAUSALLY-CORRECT COMPOUND: reach base AND have someone behind you who can drive you in.',
|
|
apply: (r) => (1 + Math.max(-0.25, Math.min(0.25, (r.onbase - 0.318) * 2.2)))
|
|
* (1 + Math.max(-0.20, Math.min(0.20, (r.power_behind - 0.078) * 1.8))),
|
|
},
|
|
],
|
|
};
|
|
|
|
async function main() {
|
|
const sb = createClient(SB_URL, SB_KEY, { auth: { persistSession: false } });
|
|
|
|
const statcast = await pageSafe(sb, 'statcast_aggregates', '*', (q) => q.eq('sport', 'mlb').eq('role', 'batter'));
|
|
const batByKey = new Map();
|
|
const barrelByKey = new Map();
|
|
for (const r of statcast) {
|
|
if (!r.player_key) continue;
|
|
const prof = sk.fromStatcastRow(r);
|
|
batByKey.set(r.player_key, prof);
|
|
if (prof.barrel_pct != null) barrelByKey.set(r.player_key, prof.barrel_pct);
|
|
}
|
|
|
|
const oppRows = await pageSafe(sb, 'hitter_opportunity', '*', (q) => q.eq('sport', 'mlb'));
|
|
const oppByKey = new Map();
|
|
for (const r of oppRows) {
|
|
const prev = oppByKey.get(r.player_key);
|
|
if (!prev || String(r.as_of_date) > String(prev.as_of_date)) oppByKey.set(r.player_key, r);
|
|
}
|
|
|
|
const recon = reconstruct(barrelByKey);
|
|
|
|
const out = { generated_note: 'null is the ARCHETYPE base rate, leave-one-out' };
|
|
|
|
for (const stat of ['rbi', 'runs']) {
|
|
const snaps = await pageSafe(sb, 'model_snapshots', 'id, player_key, game_date, archetype',
|
|
(q) => q.eq('sport', 'mlb').eq('stat', stat).not('archetype', 'is', null));
|
|
const archOf = new Map();
|
|
for (const s of snaps) archOf.set(`${s.player_key}|${s.game_date}`, s.archetype);
|
|
|
|
const led = await pageSafe(sb, 'ledger_entries',
|
|
'id, game_id, player_key, player_name, line, side, outcome, game_date, p_win, quarantine_reason',
|
|
(q) => q.eq('sport', 'mlb').is('user_id', null).eq('stat', stat).in('outcome', ['hit', 'miss']));
|
|
const clean = led.filter((r) => !(r.quarantine_reason || '').startsWith('nontakeable_book')
|
|
&& knownNumber(r.line) === 0.5);
|
|
|
|
// Archetype-level leave-one-out base rate — the context-inflated null.
|
|
const byArch = new Map();
|
|
for (const r of clean) {
|
|
const a = String(archOf.get(`${r.player_key}|${r.game_date}`) || 'UNLABELLED').toUpperCase();
|
|
const cur = byArch.get(a) || { n: 0, w: 0 };
|
|
cur.n += 1; cur.w += r.outcome === 'hit' ? 1 : 0;
|
|
byArch.set(a, cur);
|
|
}
|
|
|
|
const rows = [];
|
|
const loss = { no_archetype_base: 0, kept: 0 };
|
|
for (const r of clean) {
|
|
const a = String(archOf.get(`${r.player_key}|${r.game_date}`) || 'UNLABELLED').toUpperCase();
|
|
const ab = byArch.get(a);
|
|
if (!ab || ab.n < 4) { loss.no_archetype_base += 1; continue; }
|
|
const baseline = (ab.w - (r.outcome === 'hit' ? 1 : 0)) / (ab.n - 1);
|
|
const bat = batByKey.get(r.player_key);
|
|
const opp = oppByKey.get(r.player_key);
|
|
const okey = `${r.game_date}|${r.player_key}`;
|
|
rows.push({
|
|
archetype: a,
|
|
player_key: r.player_key,
|
|
game_id: r.game_id,
|
|
cluster: r.game_id,
|
|
baseline,
|
|
won: r.outcome === 'hit' ? 1 : 0,
|
|
risp_share: opp ? knownNumber(opp.risp_share) : null,
|
|
barrel_pct: bat ? knownRate(bat.barrel_pct) : null,
|
|
onbase: recon.onbase.has(okey) ? recon.onbase.get(okey) : null,
|
|
power_behind: recon.behind.has(okey) ? recon.behind.get(okey) : null,
|
|
batting_order: recon.order.get(okey) ?? null,
|
|
});
|
|
loss.kept += 1;
|
|
}
|
|
|
|
const mc = await tl.recordAndCount(tl.supabaseStore(sb), FACTORS[stat].flatMap((f) =>
|
|
ARCHS.map((a) => ({
|
|
sport: 'mlb', stat, archetype: a === 'ALL' ? null : a,
|
|
interaction: `factor:${f.key}`, target: 'outcome',
|
|
}))));
|
|
|
|
const audit = [];
|
|
const results = [];
|
|
for (const arch of ARCHS) {
|
|
const slot = arch === 'ALL' ? rows : rows.filter((r) => r.archetype === arch);
|
|
for (const f of FACTORS[stat]) {
|
|
const usable = slot.filter((r) => f.needs.every((k) => knownNumber(r[k]) !== null));
|
|
const ents = new Set(usable.map((r) => String(f.entity(r))));
|
|
const games = new Set(usable.map((r) => String(r.cluster)));
|
|
if (arch === 'ALL') {
|
|
audit.push({ factor: f.key, archetype: arch, rows: usable.length, games: games.size, entities: ents.size });
|
|
}
|
|
const useEntity = ents.size < games.size;
|
|
const paired = usable.map((r) => {
|
|
const m = f.apply(r);
|
|
return {
|
|
baseline: r.baseline,
|
|
conditioned: m === null ? null : Math.min(0.99, Math.max(0.01, r.baseline * m)),
|
|
won: r.won,
|
|
cluster: useEntity ? `e:${f.entity(r)}` : r.cluster,
|
|
};
|
|
});
|
|
const v = fg.adjudicate(paired, { factor: f.key, archetype: arch, stat, cumulativeTests: mc.cumulative_tests });
|
|
results.push({
|
|
archetype: arch, factor: f.key, n: v.movement.n,
|
|
clusters: v.improvement ? v.improvement.effective_n : null,
|
|
clustered_on: useEntity ? 'treatment_entity' : 'game',
|
|
distinct_games: games.size,
|
|
mean_abs_shift: v.movement.mean_abs_shift,
|
|
brier_delta: v.improvement ? v.improvement.brier_delta : null,
|
|
ci: v.improvement ? v.improvement.ci : null,
|
|
verdict: v.verdict,
|
|
});
|
|
}
|
|
}
|
|
|
|
out[stat] = {
|
|
clean_rows_line_0_5: clean.length,
|
|
rows_built: rows.length,
|
|
row_loss: loss,
|
|
distinct_games: new Set(rows.map((r) => r.cluster)).size,
|
|
archetype_base_rates: Object.fromEntries([...byArch.entries()]
|
|
.sort((a, b) => b[1].n - a[1].n)
|
|
.map(([a, v]) => [a, { n: v.n, base_rate: Math.round((v.w / v.n) * 10000) / 10000 }])),
|
|
cumulative_tests: mc.cumulative_tests,
|
|
input_audit: audit,
|
|
results,
|
|
proven: results.filter((r) => r.verdict === 'PROVES'),
|
|
};
|
|
}
|
|
|
|
console.log(JSON.stringify(out, null, 2));
|
|
process.exit(0);
|
|
}
|
|
|
|
if (require.main === module) {
|
|
main().catch((e) => { console.error(e); process.exit(1); });
|
|
}
|
|
|
|
// Exported so the read-integrity harness measures THE REAL FUNCTION.
|
|
module.exports = { READS: (typeof READS !== 'undefined' ? READS : undefined), READS_LEDGER: (typeof READS_LEDGER !== 'undefined' ? READS_LEDGER : undefined) };
|