Build the conditioning registry, and a probe so "proven" stops drifting

The order opens with "two proven clusters live". They are not proven -- the
proven set is empty -- and this is the fourth consecutive order to start from
a stronger claim than the measurements support. Correcting that in prose four
times has not worked, so this session adds scripts/proven-status.js, which
recomputes the answer from the ledger: hits LOSES (-0.096, CI excluding zero),
total_bases INCONCLUSIVE (+0.004), strikeouts INCONCLUSIVE (+0.259 at n=57).
It deliberately reports sample readiness separately from recorded verdicts, so
"n>=500" can never again be read as "passed".

A counting error worth recording. The first read of the top-volume archetype
said BOMBER x hits was 641 rows -- gate-ready. It is 287. model_snapshots
holds one row per prop PER SNAPSHOT CYCLE, so joining it to ledger_entries
counts each ledger row once per cycle it appeared in. Deduping on the ledger
row id gives the true figure, and my own status script had the same bug until
it was fixed. That is the difference between running the gate and being short
by 213.

So no archetype x stat combination reaches the gate. BOMBER x hits at 287 is
the closest; pitcher archetypes are untestable at 58 settled strikeout rows
across all of them, so the pitcher half of this order could not be run.

The registry is built: recordConditioning keys archetype x underlying-skill x
interaction x status with measured lift, and the skill tag is MANDATORY and
enforced -- untagged entries are refused, and PROVEN without sufficient
evidence is refused. validatedSkills() returns the coherent profile as it
stands, which is {} for every archetype, by design.

BOMBER x hits conditioning was tested across the order's categories and every
result is underpowered: arsenal (barrel x breaking share) incremental +0.043,
batted-ball (launch x pitcher GB) +0.001, contact quality -0.020 and -0.015,
K x K -0.063. Within BOMBER the counter still leads on hits, 0.218 to 0.160,
consistent with the closed pooled negative.

One bug fixed mid-run: fromStatcastRow maps percentage and raw fields only and
does not carry pitch_mix, so the arsenal category first reported n=0 for every
row -- it was measuring nothing rather than failing. Without catching it,
"arsenal doesn't matter" would have been recorded from a column that was never
populated.

On defense: I looked for a derivable proxy before calling it unsourceable, and
there isn't one. We ingest no fielding data at all, and opposing pitchers'
hits-allowed conflates pitching with defense, so it would validate the wrong
skill. It needs Savant's fielding endpoint -- free, same host as the five
feeds already ingested -- and it is not sourced here, because sourcing it to
test at n=282 would answer nothing.

Nothing proved, so nothing was recalibrated and nothing shipped.

4,221 tests green (335 suites); web build exit 0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W1sivYNqY2TS5ftykmHBU9
This commit is contained in:
Kev
2026-08-03 21:49:51 -04:00
parent 9538e11198
commit ac1361486e
6 changed files with 468 additions and 5 deletions
+66 -5
View File
@@ -50,6 +50,10 @@ const SB_KEY = process.env.SUPABASE_SERVICE_ROLE_KEY || process.env.SUPABASE_SER
const PAGE = 1000;
const GAMES_SO_FAR = Number(process.env.STAGEA_GAMES_SO_FAR || 103);
const STAT = process.env.CLUSTER_STAT || 'total_bases';
/** Restrict every test to ONE archetype — the pooled result can hide an
* archetype-conditional effect entirely (the pitcher strata showed opposite
* signs cancelling to near-zero when pooled). */
const ARCH = process.env.CLUSTER_ARCHETYPE || null;
const r4 = (v) => (v == null || !Number.isFinite(v) ? null : Math.round(v * 10000) / 10000);
const mean = (a) => (a.length ? a.reduce((x, y) => x + y, 0) / a.length : null);
@@ -194,7 +198,8 @@ async function opponentByPlayerDate(players) {
const SOLO = ['batter_barrel_pct', 'batter_hard_hit_pct', 'batter_exit_velo',
'batter_launch_angle', 'batter_k_pct', 'batter_bb_pct',
'pitcher_k_pct', 'pitcher_hard_hit_allowed'];
'pitcher_k_pct', 'pitcher_hard_hit_allowed',
'pitcher_gb_pct', 'pitcher_fb_pct', 'pitcher_breaking_share'];
/**
* PER-STAT INTERACTION SETS — the total_bases conditioning map RE-WEIGHTED, not
@@ -204,8 +209,8 @@ const SOLO = ['batter_barrel_pct', 'batter_hard_hit_pct', 'batter_exit_velo',
* and RBI through another (does anyone happen to be on base when it does).
*/
const STAT_INTERACTIONS = {
total_bases: ['launch_x_exit_velo', 'exitvelo_x_pitcher_suppression', 'barrel_x_power_archetype', 'batterK_x_pitcherK'],
hits: ['launch_x_exit_velo', 'exitvelo_x_pitcher_suppression', 'batterK_x_pitcherK'],
total_bases: ['launch_x_exit_velo', 'exitvelo_x_pitcher_suppression', 'barrel_x_power_archetype', 'batterK_x_pitcherK', 'launch_x_pitcher_gb', 'barrel_x_breaking_share'],
hits: ['launch_x_exit_velo', 'exitvelo_x_pitcher_suppression', 'batterK_x_pitcherK', 'launch_x_pitcher_gb', 'barrel_x_breaking_share'],
// HOME RUNS are the purest barrel stat: the ball must be hit hard AND at the
// right angle, and the pitcher must be the kind who allows that combination.
home_runs: ['launch_x_exit_velo', 'barrel_x_power_archetype', 'exitvelo_x_pitcher_suppression'],
@@ -238,6 +243,18 @@ const INTERACTIONS = [
mechanism: 'ARCHETYPE-CONDITIONAL. Barrels convert to extra bases for hitters whose lane is power; for a speed/contact profile the same barrel rate is a rarer event on a swing built for something else. This is Discipline 2 stated as a testable interaction. NOTE: it is currently UNTESTABLE — statcast rows carry no archetype label, and the barrel-relative proxy is an exact linear function of barrel_pct, so controlling for both components is rank-deficient. It needs a real archetype classification joined in.',
build: (r) => r.batter_barrel_pct * r.archetype_power,
},
{
key: 'launch_x_pitcher_gb',
components: ['batter_launch_angle', 'pitcher_gb_pct'],
mechanism: 'PITCHER BATTED-BALL TYPE. A ground-ball arm takes the air away, and a hitter whose value lives in the air needs the air. An air hitter against a sinkerballer and a ground-ball hitter against a fly-ball arm are both mismatches that neither factor states alone.',
build: (r) => r.batter_launch_angle * r.pitcher_gb_pct,
},
{
key: 'barrel_x_breaking_share',
components: ['batter_barrel_pct', 'pitcher_breaking_share'],
mechanism: 'ARSENAL MATCHUP. Barrel rate is far more a fastball skill than a breaking-ball skill, so a power bat facing a breaking-heavy arm should convert less of it. The pitch mix is already ingested, so this costs nothing to test.',
build: (r) => r.batter_barrel_pct * r.pitcher_breaking_share,
},
{
key: 'batterK_x_pitcherK',
components: ['batter_k_pct', 'pitcher_k_pct'],
@@ -246,6 +263,26 @@ const INTERACTIONS = [
},
];
/**
* Breaking-ball share of a pitcher's mix, from `pitch_mix` already ingested.
* Sliders/curves/sweepers/cutters vs fastballs — absent mix -> null, never 0.
*/
function breakingShare(mix) {
if (!mix || typeof mix !== 'object') return null;
const rows = Array.isArray(mix) ? mix : Object.values(mix);
let breaking = 0; let total = 0;
for (const p of rows) {
if (!p) continue;
const type = String(p.type || p.pitch_type || '').toUpperCase();
const usage = knownNumber(p.usage_pct ?? p.usage ?? p.pct);
if (!type || usage === null || usage < 0) continue;
total += usage;
if (['SL', 'CU', 'KC', 'ST', 'SV', 'FC', 'SC'].includes(type)) breaking += usage;
}
if (total <= 0) return null;
return breaking / total;
}
/** Latest settled game date in the pull — used to detect that the profile
* freeze now sits AFTER the data, i.e. no clean out-of-sample window exists. */
function clean0Max(rows) {
@@ -260,7 +297,12 @@ async function main() {
const freezeDate = statcast.reduce((mx, r) => (String(r.updated_at) > mx ? String(r.updated_at) : mx), '').slice(0, 10);
const batters = new Map(); const pitchersById = new Map();
for (const r of statcast) {
if (r.role === 'pitcher' && r.source_id != null) pitchersById.set(Number(r.source_id), sk.fromStatcastRow(r));
// pitch_mix is NOT part of fromStatcastRow's output (it maps pct/raw fields
// only), so it must be attached explicitly — without it the arsenal category
// silently measures nothing and reports n=0.
if (r.role === 'pitcher' && r.source_id != null) {
pitchersById.set(Number(r.source_id), { ...sk.fromStatcastRow(r), pitch_mix: r.pitch_mix });
}
if (r.player_key && r.role === 'batter') {
const prev = batters.get(r.player_key);
if (!prev || Number(r.sample_pa || 0) > Number(prev.rawPa || 0)) {
@@ -315,7 +357,9 @@ async function main() {
console.error(`[debug] matches in sample=${sampleKeys.filter((k) => batters.has(k)).length}/5`);
}
const allowed = reg.candidateFeaturesForStat('mlb', STAT);
const rows = [];
const rowsAll = [];
const rows = rowsAll;
for (const r of clean) {
const bat = batters.get(r.player_key);
if (!bat) continue;
@@ -350,9 +394,25 @@ async function main() {
pitcher_hard_hit_allowed: pit ? knownRate(pit.hard_hit_pct) : null,
archetype_power: archetypePower,
archetype: arch,
// ── CONDITIONING CATEGORIES (this order) ──────────────────────────
// PITCHER BATTED-BALL TYPE: a ground-ball arm suppresses air contact, so
// it should matter differently to a hitter whose value is in the air.
pitcher_gb_pct: pit ? knownRate(pit.gb_pct) : null,
pitcher_fb_pct: pit ? knownRate(pit.fb_pct) : null,
// ARSENAL: breaking-ball share, from the pitch mix already ingested. A
// power hitter's barrel rate is a fastball skill far more than a
// breaking-ball skill, so the mix should condition it.
pitcher_breaking_share: pit && pit.pitch_mix ? breakingShare(pit.pitch_mix) : null,
// DEFENSE: NOT DERIVABLE from what we ingest — see the report. Recorded as
// null rather than proxied by something that is really pitching quality.
team_defense: null,
});
}
// ARCHETYPE RESTRICTION — applied AFTER building rows so coverage is visible.
const archRows = ARCH ? rowsAll.filter((r) => String(r.archetype || '').toUpperCase() === ARCH) : rowsAll;
rows.length = 0; rows.push(...archRows);
// Bonferroni denominator = every test in this family (solo + interaction).
const TESTS = SOLO.length + INTERACTIONS.length;
@@ -414,6 +474,7 @@ async function main() {
console.log(JSON.stringify({
stat: STAT,
archetype_restriction: ARCH || 'none (pooled)',
VALIDITY: contaminated
? 'CONTAMINATED / DIRECTIONAL ONLY — statcast_aggregates now carries a single as-of date (' + freezeDate + ') that is AFTER the settled games, so season profiles contain the games being predicted. These are NOT gate verdicts. statcast_history (new) makes point-in-time possible from tomorrow.'
: `CLEAN out-of-sample: profiles frozen ${freezeDate}; only game_date > ${freezeDate} scored`,