Files
vyndr/scripts/prove-runs-rbi.js
T
builtbykev f61ec6b391 Read integrity, as-of context, and the shadow matchup resolve (A1-A7)
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>
2026-08-11 22:49:56 -04:00

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) };