e4dae0e6b0
The causal insight is right -- the game is a sequence and the matchup does shift mid-game. The direction is backwards, measured on 93,663 plate appearances from 1,238 games pulled free from statsapi. LINK 1 PROVES. Starter batters-faced, point-in-time from his own prior starts only, clustered on the pitcher: MAE 3.2226 -> 2.7990, delta -0.4236, CI [-0.6006,-0.2731] at 0.9995 corrected for 107 tests, 1,706 starts across 204 pitchers. It finds the tail the chain needed -- early exits are a 23.2% base rate, model-flagged starts are 34.0% early, lift +10.8pp. Scope correction inside Link 1: the order specifies fatigue x GAME SCRIPT, but game script is not available at grade time -- whether he gets hit tonight is the thing being projected, not an input to it. Only the workload half is measured; the in-game half is recorded as a live feature, out of scope, rather than quietly folded in. LINK 2 DOES NOT PROVE, twice over. Model accuracy 17.2% vs an 8.6% baseline -- doubling it sounds good and is not, since naming a specific arm is wrong five times in six. And structurally the entity is the BULLPEN: 39,629 post-starter plate appearances across 30 clubs is 30 readings, below the 40-cluster floor, the same permanent ceiling as park geometry and team defence. LINK 3 NOT RUN, per the order's own rule. THE PREMISE IS REFUTED, and this chains on nothing so it was safe to measure: vs STARTER n=48,492 hit rate 0.2444 +/-0.0038 vs BULLPEN n=35,760 hit rate 0.2373 +/-0.0044 The pen is 0.7pp HARDER. The specific effect the chain exists to exploit -- early exit making later at-bats softer -- is +0.0010 on 35,760 PAs. A well-powered null, not a sample problem. What IS real is times through the order: TTO1 0.2351 -> TTO2 0.2515 -> TTO3 0.2518. A starter does decay as the lineup sees him again, but that advantage is SURRENDERED when he leaves, not extended -- the pen is harder than his second and third time through. A modern bullpen is a queue of fresh specialists throwing one inning each; there is no tiring arm to punish. So the insight survives inverted, and Link 1 stays valuable for the opposite reason it was built: a likely early hook predicts the hitter LOSES his third-time-through look (0.2518 -> 0.2373 on that PA). The mispricing is on hitters who get an EXTRA look at a starter going deep. BUILT: predictionGate.js + tests -- the two-part gate for a continuous prediction. factorGate binarises outcomes for Brier, which would destroy a target like batters faced. Same discipline, same THEATER verdict, real scale. PRE-REGISTERED NOT RUN: Link 2' using a PA-weighted bullpen AGGREGATE rather than a named arm. Recorded rather than substituted in -- running Link 3 on a swapped-in Link 2 is the assumed-link failure the order forbids. Given the premise result its expected value is now low. PARALLEL TRACK logged: total_bases n=948 pooled, BOMBER x TB 340, short by 160. Sample-readiness only, not a verdict. Counter and frozen clusters byte-identical. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W1sivYNqY2TS5ftykmHBU9
139 lines
5.5 KiB
JavaScript
139 lines
5.5 KiB
JavaScript
#!/usr/bin/env node
|
|
'use strict';
|
|
|
|
/**
|
|
* LINK 1 — does a starter's exit point predict, before the game?
|
|
*
|
|
* Target: batters faced by the starter, because that is what decides how many of
|
|
* a hitter's plate appearances come against him rather than the pen.
|
|
*
|
|
* ── WHAT A PRE-GAME PREDICTOR CAN AND CANNOT SEE ─────────────────────────
|
|
* The order specifies fatigue profile x GAME SCRIPT ("getting hit -> pulled
|
|
* early"). Game script is not available when a prop is graded: whether he gets
|
|
* hit tonight is the thing we are trying to project, not an input to it. Using
|
|
* it would be reading the answer.
|
|
*
|
|
* So the honest pre-game form of Link 1 is the fatigue and workload half alone —
|
|
* the starter's own history, strictly truncated to starts BEFORE the game being
|
|
* predicted. That is measured here. The in-game half is a LIVE feature, not a
|
|
* grade-time one, and it is recorded as out of scope rather than quietly folded
|
|
* in.
|
|
*
|
|
* Baseline: the league mean batters faced — the naive "a starter goes about six"
|
|
* null this must beat to be worth anything.
|
|
*
|
|
* node scripts/link1-pull-timing.js
|
|
*/
|
|
|
|
require('dotenv').config();
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const pg = require('../src/services/model/predictionGate');
|
|
const tl = require('../src/services/model/testLedger');
|
|
const { createClient } = require('@supabase/supabase-js');
|
|
|
|
const CACHE = process.env.SEQ_OUT || path.join(process.cwd(), '.seq-cache', 'sequences.json');
|
|
/** Starts needed before we will read a pitcher's own history at all. */
|
|
const MIN_PRIOR = 3;
|
|
/** Shrinkage: how many prior starts before his own mean carries half the weight. */
|
|
const STABILIZE = 5;
|
|
/** A start at or under this many batters faced is an EARLY EXIT — the edge case. */
|
|
const EARLY_BF = 20;
|
|
|
|
const mean = (xs) => (xs.length ? xs.reduce((a, b) => a + b, 0) / xs.length : null);
|
|
|
|
function main() {
|
|
const { games } = JSON.parse(fs.readFileSync(CACHE, 'utf8'));
|
|
|
|
// Every starter-game, in chronological order.
|
|
const starts = [];
|
|
for (const g of games) {
|
|
for (const side of ['home', 'away']) {
|
|
const s = (g[side].arms || []).find((a) => a.started);
|
|
if (!s || s.bf == null) continue;
|
|
starts.push({ date: g.date, gamePk: g.gamePk, pitcher: s.id, name: s.name, bf: s.bf, outs: s.outs, pitches: s.pitches });
|
|
}
|
|
}
|
|
starts.sort((a, b) => String(a.date).localeCompare(String(b.date)) || a.gamePk - b.gamePk);
|
|
|
|
const leagueMean = mean(starts.map((s) => s.bf));
|
|
|
|
// POINT-IN-TIME: each start is predicted only from starts strictly before it.
|
|
const history = new Map();
|
|
const rows = [];
|
|
for (const s of starts) {
|
|
const prior = history.get(s.pitcher) || [];
|
|
if (prior.length >= MIN_PRIOR) {
|
|
const own = mean(prior.map((p) => p.bf));
|
|
const w = prior.length / (prior.length + STABILIZE);
|
|
rows.push({
|
|
pitcher: s.pitcher,
|
|
name: s.name,
|
|
cluster: s.pitcher, // his starts are not independent readings
|
|
baseline: leagueMean,
|
|
prediction: w * own + (1 - w) * leagueMean,
|
|
actual: s.bf,
|
|
prior_starts: prior.length,
|
|
});
|
|
}
|
|
history.set(s.pitcher, prior.concat([s]));
|
|
}
|
|
|
|
return { starts, leagueMean, rows };
|
|
}
|
|
|
|
(async () => {
|
|
const { starts, leagueMean, rows } = main();
|
|
|
|
let cumulative = 1;
|
|
try {
|
|
const sb = createClient(process.env.SUPABASE_URL,
|
|
process.env.SUPABASE_SERVICE_ROLE_KEY || process.env.SUPABASE_SERVICE_KEY,
|
|
{ auth: { persistSession: false } });
|
|
const mc = await tl.recordAndCount(tl.supabaseStore(sb), [
|
|
{ sport: 'mlb', stat: 'starter_bf', archetype: null, interaction: 'link1:pull_timing', target: 'actual_exit' },
|
|
]);
|
|
cumulative = mc.cumulative_tests;
|
|
} catch { /* offline: reported below */ }
|
|
|
|
const verdict = pg.adjudicate(rows, {
|
|
link: 'link1_pull_timing',
|
|
loss: 'absolute',
|
|
cumulativeTests: cumulative,
|
|
});
|
|
|
|
// Does it find the EARLY EXITS specifically? That is where the edge lives —
|
|
// being right about a median start is worth nothing to this chain.
|
|
const early = rows.filter((r) => r.actual <= EARLY_BF);
|
|
const late = rows.filter((r) => r.actual > EARLY_BF);
|
|
const predEarly = rows.filter((r) => r.prediction <= EARLY_BF + 2);
|
|
const hitRate = predEarly.length
|
|
? predEarly.filter((r) => r.actual <= EARLY_BF).length / predEarly.length : null;
|
|
const baseEarlyRate = rows.length ? early.length / rows.length : null;
|
|
|
|
console.log(JSON.stringify({
|
|
link: 'LINK 1 — starter pull timing',
|
|
starter_games_total: starts.length,
|
|
league_mean_bf: round2(leagueMean),
|
|
gated_rows: rows.length,
|
|
distinct_pitchers: new Set(rows.map((r) => r.pitcher)).size,
|
|
cumulative_tests: cumulative,
|
|
verdict,
|
|
early_exit_analysis: {
|
|
definition: `actual batters faced <= ${EARLY_BF}`,
|
|
early_exits: early.length,
|
|
normal_starts: late.length,
|
|
base_rate_of_early_exit: round4(baseEarlyRate),
|
|
flagged_early_by_model: predEarly.length,
|
|
of_those_actually_early: round4(hitRate),
|
|
lift_over_base_rate: hitRate !== null && baseEarlyRate !== null ? round4(hitRate - baseEarlyRate) : null,
|
|
note: 'the chain needs the EARLY tail, not the median start',
|
|
},
|
|
scope_note: 'GAME SCRIPT is deliberately excluded — whether he gets hit tonight is the thing being projected, not an input available at grade time',
|
|
}, null, 2));
|
|
process.exit(0);
|
|
})();
|
|
|
|
const round2 = (v) => (v == null ? null : Math.round(v * 100) / 100);
|
|
const round4 = (v) => (v == null ? null : Math.round(v * 10000) / 10000);
|