8ab6557faa
Nothing in this failed, which is what makes it the most instructive
negative so far. Link 1 proved (MAE 3.22 -> 2.80 batters faced). Link 2's
quality grain proved (2.70pp of realized separation). Both point-in-time,
both past cumulative correction. Their product is 0.37pp and detecting it
would take 52 seasons.
FRAMING CORRECTION: the order says Link 2 proved you can't predict the
reliever. Half true -- the INDIVIDUAL grain failed at 17.2%, but the
QUALITY grain PROVED. Pen-season-quality is a measured predictor here, not
a fallback after a failure.
TWO OF THREE SPECIFIED INPUTS COULD NOT BE USED HONESTLY. Pen archetype
did not prove (0.5669 vs a 0.5309 modal baseline, interval spanning zero)
so building it in would chain on an unproven link. And hitter
approach-identity -- "fastball-hunter", "finesse-vulnerable" -- does not
exist in this registry; MLB batter archetypes are BOMBER/GHOST/TORCH/
BRUSH/DRIVER/FLEX/ALPHA/HYBRID/CATALYST. Inventing one to condition on is
the fabrication the gate exists to catch. A power/contact split derived
from the sequence data was tested as a SEPARATE gated addition instead;
neither half proved.
GATE on the concentrated subset, 114 cumulative tests:
early-exit x WEAK pen n=1931 brier -0.0001 CI [-0.0014,+0.0010] NOT_PROVEN
early-exit x STRONG pen n=2574 brier 0.0000 CI [-0.0011,+0.0010] THEATER
all early-exit later ABs n=6869 brier -0.0001 CI [-0.0007,+0.0005] NOT_PROVEN
pooled all later ABs n=17891 brier 0.0000 CI [-0.0004,+0.0003] THEATER
Not pooled-diluted -- the concentrated subset was gated alone and is no
better.
THE CEILING, which explains it. The descriptive pass found the predicted
direction (+0.74pp weak pen, -0.79pp strong pen). The magnitude is the
problem and it is structural:
P(faces pen | early-exit flagged) 0.8075
P(faces pen | starter goes deep) 0.7149
exposure the flag actually buys 0.0925
hit-rate swing across pen quality 0.0394
MAX JUSTIFIABLE ADJUSTMENT 0.00365
actually applied 0.01930 -> 5.3x over-movement
A hitter's 3rd/4th plate appearance is ALREADY against the bullpen 71% of
the time when the starter is projected to go deep. Link 1 lifts it to 81%
-- nine points of extra exposure, not a change of opponent. The 5.3x
over-movement is precisely why the mirror subset reads THEATER rather than
as a small true effect.
A correctly-scaled version is not detectable either: 0.37pp is 0.37 SE at
n=1,931; the corrected bar needs n=168,488, an 87x shortfall, ~52 seasons.
STRUCTURALLY CLOSED, not sample-blocked. Waiting does not fix it.
NOT WIRED, and the self-check deliberately not wired either -- flagging
line-divergence on an adjustment measured as absent would advertise an
edge we just showed does not exist, which is fabricated reasoning one
layer up.
THE LESSON: link-by-link validation guarantees each link is real. It does
not guarantee the chain transmits anything. Size the multiplicative
structure BEFORE building -- one exposure term of 0.09 reduces a genuine
3.94pp signal to noise and no downstream care recovers it.
Link 3 confirmed skipped. Parallel track logged unchanged: TB n=948
pooled, BOMBER x TB 340, short by 160.
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
211 lines
9.0 KiB
JavaScript
211 lines
9.0 KiB
JavaScript
#!/usr/bin/env node
|
|
'use strict';
|
|
|
|
/**
|
|
* THE COLLAPSED SEQUENCE EDGE — Link 1 x pen-season-quality, on later at-bats.
|
|
*
|
|
* Link 3 is correctly skipped: reliever IDENTITY did not prove and is genuine
|
|
* baseball unpredictability. But Link 2's QUALITY grain DID prove, so pen quality
|
|
* here is a measured predictor rather than a fallback.
|
|
*
|
|
* ── THE MECHANICAL CEILING, MEASURED FIRST ───────────────────────────────
|
|
* A hitter's third or fourth plate appearance is ALREADY against the bullpen
|
|
* 70-73% of the time even when the starter is projected to go deep. An elevated
|
|
* early-exit flag lifts that to only 77-83%. So Link 1 buys roughly TEN POINTS
|
|
* of extra pen exposure, not a switch from starter to pen — and any adjustment
|
|
* built on it is bounded at about a tenth of the starter-versus-pen quality gap.
|
|
* That ceiling is a property of baseball, not of the model, and it is the reason
|
|
* the deltas below are small before anything is even fitted.
|
|
*
|
|
* ── WHAT IS ADJUSTED, AND WHAT IS REFUSED ────────────────────────────────
|
|
* The order specifies pen-quality x pen-ARCHETYPE x hitter-APPROACH. Two of
|
|
* those three cannot be used honestly:
|
|
*
|
|
* pen archetype did NOT prove (0.5669 vs a 0.5309 modal baseline, corrected
|
|
* interval spanning zero). Building it into the adjustment
|
|
* would be chaining on an unproven link.
|
|
* hitter approach "fastball-hunter" / "finesse-vulnerable" identities do not
|
|
* exist in this registry. MLB batter archetypes are BOMBER /
|
|
* GHOST / TORCH / BRUSH / DRIVER / FLEX / ALPHA / HYBRID /
|
|
* CATALYST. Inventing an identity to condition on would be
|
|
* fabricating the very thing the gate exists to catch.
|
|
*
|
|
* So the adjustment uses the PROVEN component alone, and a hitter split derived
|
|
* from the sequence data itself (power vs contact by home-run rate) is tested as
|
|
* a SEPARATE gated addition rather than assumed into the main effect.
|
|
*
|
|
* node scripts/collapsed-sequence-edge.js
|
|
*/
|
|
|
|
require('dotenv').config();
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const fg = require('../src/services/model/factorGate');
|
|
const tl = require('../src/services/model/testLedger');
|
|
const pq = require('../src/services/model/penQuality');
|
|
const { createClient } = require('@supabase/supabase-js');
|
|
|
|
const CACHE = process.env.SEQ_OUT || path.join(process.cwd(), '.seq-cache', 'sequences.json');
|
|
const HIT = new Set(['single', 'double', 'triple', 'home_run']);
|
|
const PA = new Set(['single', 'double', 'triple', 'home_run', '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']);
|
|
|
|
const MIN_ARM_PA = 40;
|
|
const MIN_PRIOR_GAMES = 5;
|
|
const MIN_HITTER_PA = 60;
|
|
const MIN_PRIOR_STARTS = 3;
|
|
const EARLY_FLAG_BF = 22;
|
|
const LEAGUE_BF = 21.56;
|
|
|
|
const mean = (xs) => (xs.length ? xs.reduce((a, b) => a + b, 0) / xs.length : null);
|
|
|
|
function build() {
|
|
const { games } = JSON.parse(fs.readFileSync(CACHE, 'utf8'));
|
|
games.sort((a, b) => String(a.date).localeCompare(String(b.date)) || a.gamePk - b.gamePk);
|
|
|
|
const arm = new Map();
|
|
const bat = new Map(); // hitter -> { n, h, hr }
|
|
const penHist = new Map();
|
|
const startHist = new Map();
|
|
const rows = [];
|
|
|
|
for (const g of games) {
|
|
for (const side of ['home', 'away']) {
|
|
const team = g[side].abbr || g[side].team;
|
|
const st = (g[side].arms || []).find((a) => a.started);
|
|
if (!team || !st) continue;
|
|
const half = side === 'home' ? 'top' : 'bottom';
|
|
const pas = g.pas.filter((p) => p.half === half && PA.has(p.event));
|
|
|
|
const ps = startHist.get(st.id) || [];
|
|
let predBf = null;
|
|
if (ps.length >= MIN_PRIOR_STARTS) {
|
|
const w = ps.length / (ps.length + 5);
|
|
predBf = w * mean(ps) + (1 - w) * LEAGUE_BF;
|
|
}
|
|
const hist = penHist.get(team) || [];
|
|
const pen = pq.projectPen(hist.map((q) => ({ quality: q })));
|
|
|
|
const seen = new Map();
|
|
for (const p of pas) {
|
|
const k = p.batter;
|
|
seen.set(k, (seen.get(k) || 0) + 1);
|
|
const paNum = seen.get(k);
|
|
const b = bat.get(k);
|
|
// knownRate abstain: no readable hitter, starter or pen -> no row at all.
|
|
if (paNum < 3 || predBf === null || !pen || !b || b.n < MIN_HITTER_PA) continue;
|
|
rows.push({
|
|
gamePk: g.gamePk,
|
|
cluster: g.gamePk,
|
|
batter: k,
|
|
paNum,
|
|
early: predBf <= EARLY_FLAG_BF,
|
|
pen_quality: pen.quality,
|
|
hitter_base: b.h / b.n,
|
|
hitter_hr_rate: b.hr / b.n,
|
|
won: HIT.has(p.event) ? 1 : 0,
|
|
});
|
|
}
|
|
|
|
const faced = [];
|
|
for (const p of pas.filter((x) => x.pitcher !== st.id)) {
|
|
const h = arm.get(p.pitcher);
|
|
if (h && h.n >= MIN_ARM_PA) faced.push(h.h / h.n);
|
|
}
|
|
if (faced.length) penHist.set(team, hist.concat([mean(faced)]));
|
|
if (st.bf != null) startHist.set(st.id, ps.concat([st.bf]));
|
|
for (const p of pas) {
|
|
const c = arm.get(p.pitcher) || { n: 0, h: 0, k: 0 };
|
|
c.n += 1; c.h += HIT.has(p.event) ? 1 : 0; c.k += p.event === 'strikeout' ? 1 : 0;
|
|
arm.set(p.pitcher, c);
|
|
}
|
|
for (const p of pas) {
|
|
const c = bat.get(p.batter) || { n: 0, h: 0, hr: 0 };
|
|
c.n += 1; c.h += HIT.has(p.event) ? 1 : 0; c.hr += p.event === 'home_run' ? 1 : 0;
|
|
bat.set(p.batter, c);
|
|
}
|
|
}
|
|
}
|
|
return rows;
|
|
}
|
|
|
|
/** The adjustment: the hitter's own rate, shifted by the PROVEN pen signal. */
|
|
const adjust = (r) => {
|
|
const shift = pq.hitRateShift(r.pen_quality);
|
|
if (shift === null) return null;
|
|
return Math.max(0.01, Math.min(0.99, r.hitter_base + shift));
|
|
};
|
|
|
|
(async () => {
|
|
const all = build();
|
|
const qs = all.map((r) => r.pen_quality).sort((a, b) => a - b);
|
|
const weakCut = qs[Math.floor(qs.length * 2 / 3)];
|
|
const strongCut = qs[Math.floor(qs.length / 3)];
|
|
|
|
const subsets = {
|
|
// The order's concentrated subset.
|
|
concentrated_early_x_weak_pen: all.filter((r) => r.early && r.pen_quality >= weakCut),
|
|
// The mirror, where the descriptive pass suggested the larger movement.
|
|
mirror_early_x_strong_pen: all.filter((r) => r.early && r.pen_quality <= strongCut),
|
|
// Every later at-bat with an early-exit flag, both directions of pen quality.
|
|
all_early_exit_later_abs: all.filter((r) => r.early),
|
|
pooled_all_later_abs: all,
|
|
};
|
|
|
|
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), Object.keys(subsets).map((k) => ({
|
|
sport: 'mlb', stat: 'hits', archetype: null,
|
|
interaction: `collapsed_sequence:${k}`, target: 'later_ab_outcome',
|
|
})));
|
|
cumulative = mc.cumulative_tests;
|
|
} catch { /* offline */ }
|
|
|
|
const gate = (rs, label) => fg.adjudicate(
|
|
rs.map((r) => ({ cluster: r.cluster, baseline: r.hitter_base, conditioned: adjust(r), won: r.won }))
|
|
.filter((r) => r.conditioned !== null),
|
|
{ factor: label, stat: 'hits', cumulativeTests: cumulative },
|
|
);
|
|
|
|
const results = {};
|
|
for (const [k, rs] of Object.entries(subsets)) results[k] = gate(rs, k);
|
|
|
|
// Hitter split as a SEPARATE gated addition — never assumed into the main effect.
|
|
const conc = subsets.concentrated_early_x_weak_pen;
|
|
const hrs = conc.map((r) => r.hitter_hr_rate).sort((a, b) => a - b);
|
|
const hrCut = hrs[Math.floor(hrs.length / 2)];
|
|
const bySplit = {
|
|
power_hitters: gate(conc.filter((r) => r.hitter_hr_rate >= hrCut), 'concentrated_power'),
|
|
contact_hitters: gate(conc.filter((r) => r.hitter_hr_rate < hrCut), 'concentrated_contact'),
|
|
};
|
|
|
|
console.log(JSON.stringify({
|
|
later_at_bats_readable: all.length,
|
|
cumulative_tests: cumulative,
|
|
subset_sizes: Object.fromEntries(Object.entries(subsets).map(([k, v]) => [k, v.length])),
|
|
gate: Object.fromEntries(Object.entries(results).map(([k, v]) => [k, {
|
|
n: v.movement.n,
|
|
clusters: v.improvement ? v.improvement.effective_n : null,
|
|
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,
|
|
}])),
|
|
hitter_split_separate_gate: Object.fromEntries(Object.entries(bySplit).map(([k, v]) => [k, {
|
|
n: v.movement.n, brier_delta: v.improvement ? v.improvement.brier_delta : null,
|
|
ci: v.improvement ? v.improvement.ci : null, verdict: v.verdict,
|
|
}])),
|
|
refused: {
|
|
pen_archetype: 'did not prove at the corrected bar — excluded from the adjustment',
|
|
hitter_approach_identity: 'SPRAY / fastball-hunter identities do not exist in this registry',
|
|
link3_per_reliever: 'SKIPPED — reliever identity is genuine baseball unpredictability',
|
|
},
|
|
}, null, 2));
|
|
process.exit(0);
|
|
})();
|