Split infield defence out, and pre-register the DRIVER/CATALYST/SINKER batch

Every slot in this batch is far below the gate -- DRIVER x hits 39, CATALYST
under 22, SINKER not yet gradeable at all -- so per the order's own sample rule
these are CANDIDATE-pending-accumulation, not tested-and-failed. Testing them
now would produce noise and burn cumulative-correction budget on it.

What IS deliverable is the input SINKER's theory needs, and it turned out to be
free. The OAA feed already carries each fielder's position, so infield-only
defence is derivable from data ingested yesterday: 1B/2B/3B/SS summed
separately from the outfield. Team-total OAA is the wrong unit for a
ground-ball pitcher -- he lives on the infield converting grounders and his
outfielders are close to irrelevant to him, so averaging them in dilutes
exactly the signal. On a real team the split shows a +15 infield inside a +2
team total, which is the dilution made visible. Under three measured fielders
in a unit is absent rather than zero, same rule as everywhere else.

The three theories are now PRE-REGISTERED in the registry with their mechanism
and the skill each would validate, marked CANDIDATE. That is the point of
writing them down before the sample exists: the claim is on the record with a
date and cannot be quietly reshaped into whatever the numbers turn out to
support once they arrive.

Two of them are input-blocked rather than sample-blocked, and the distinction
matters because waiting will not fix them. DRIVER's RBI theory needs
baserunner state and CATALYST's runs theory needs both baserunner state and
batting order; we ingest neither, and player_role_profiles is empty. So RBI
does not unblock on DRIVER -- it unblocks on ingesting lineup context, which
is a sourcing question, not an accumulation one. SINKER is the only one of the
three whose inputs are now ready.

Premise note: no registry re-adjudication demoted anything last session. The
proven set was empty, zero features were demoted, nothing was recalibrated,
and nothing is published -- node scripts/proven-status.js confirms it in one
command.

Counter and frozen clusters byte-identical. 4,238 tests green (337 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-04 15:28:16 -04:00
parent ff037e40c2
commit 52a3142c5f
3 changed files with 83 additions and 2 deletions
+21 -1
View File
@@ -357,17 +357,32 @@ function indexFielding(rows) {
* measured fielders would otherwise look better merely for being measured more.
* A team with no measured fielders is ABSENT, never 0.
*/
const INFIELD_POS = Object.freeze(new Set(['1B', '2B', '3B', 'SS']));
const OUTFIELD_POS = Object.freeze(new Set(['LF', 'CF', 'RF']));
function aggregateTeamDefense(rows) {
const acc = new Map();
for (const r of rows || []) {
const team = r.display_team_name;
const oaa = numOrNull(r.outs_above_average);
if (!team || oaa == null) continue;
const cur = acc.get(team) || { oaa_sum: 0, fielders: 0, diff_sum: 0, diff_n: 0 };
const cur = acc.get(team) || {
oaa_sum: 0, fielders: 0, diff_sum: 0, diff_n: 0,
inf_sum: 0, inf_n: 0, of_sum: 0, of_n: 0,
};
cur.oaa_sum += oaa;
cur.fielders += 1;
const d = numOrNull(String(r.diff_success_rate_formatted || '').replace('%', ''));
if (d != null) { cur.diff_sum += d; cur.diff_n += 1; }
// ── INFIELD / OUTFIELD SPLIT ──────────────────────────────────────────
// Team-total OAA is the wrong unit for half the questions we ask of it. A
// ground-ball pitcher lives or dies on the INFIELD converting grounders;
// his team's outfielders are close to irrelevant to him, and averaging them
// in dilutes exactly the signal. The same free feed already carries the
// position, so the split costs nothing.
const pos = String(r.primary_pos_formatted || '').toUpperCase();
if (INFIELD_POS.has(pos)) { cur.inf_sum += oaa; cur.inf_n += 1; }
else if (OUTFIELD_POS.has(pos)) { cur.of_sum += oaa; cur.of_n += 1; }
acc.set(team, cur);
}
const out = new Map();
@@ -378,6 +393,11 @@ function aggregateTeamDefense(rows) {
oaa_mean: v.oaa_sum / v.fielders,
success_diff_mean: v.diff_n > 0 ? v.diff_sum / v.diff_n : null,
fielders: v.fielders,
// ABSENT, not zero, when a unit has too few measured fielders to call.
infield_oaa_sum: v.inf_n >= 3 ? v.inf_sum : null,
infield_fielders: v.inf_n,
outfield_oaa_sum: v.of_n >= 3 ? v.of_sum : null,
outfield_fielders: v.of_n,
});
}
return out;
+60 -1
View File
@@ -336,6 +336,57 @@ const SKILLS = Object.freeze({
const conditioning = [];
/**
* PRE-REGISTERED CONDITIONING — hypotheses declared BEFORE their sample exists.
*
* Every slot in this batch is far below the gate (DRIVER x hits 39, CATALYST
* <22, SINKER not yet gradeable). Testing them now would produce noise; leaving
* them undeclared until the data arrives would let the hypothesis be shaped by
* the data once it does. So they are written down first, with the mechanism and
* the skill they would validate, and marked CANDIDATE.
*
* That is what makes the eventual test honest rather than a fit: the claim is on
* the record with a date, and it cannot be quietly revised into whatever the
* numbers turn out to support.
*/
const PRE_REGISTERED = [
// ── DRIVER (run producer, lineup 3/4/5) ────────────────────────────────
{
sport: 'mlb', archetype: 'DRIVER', stat: 'rbi', skill: 'POWER',
interaction: 'power_x_on_base_ahead',
mechanism: 'An RBI is power TIMES opportunity: the same swing drives in one run or three depending on who is standing on base. DRIVER is the archetype where that context IS the signal, which is why RBI has resisted every context-free model so far.',
blocked_on: 'BASERUNNER STATE IS NOT INGESTED. We hold no on-base-ahead data at all, so this cannot be tested at any sample — it is input-blocked, not sample-blocked.',
},
{
sport: 'mlb', archetype: 'DRIVER', stat: 'rbi', skill: 'OPPORTUNITY',
interaction: 'power_x_lineup_position',
mechanism: 'Batting 3rd/4th/5th is a systematically different RBI opportunity than batting 8th, independent of skill.',
blocked_on: 'BATTING ORDER IS NOT INGESTED (player_role_profiles / lineup_role_profiles are 0 rows).',
},
// ── CATALYST (table-setter, leadoff) ───────────────────────────────────
{
sport: 'mlb', archetype: 'CATALYST', stat: 'runs', skill: 'SPEED',
interaction: 'speed_x_on_base_x_power_behind',
mechanism: "A leadoff hitter's runs are mostly not his own doing: he gets on, and the power behind him drives him in. Runs scored is the least self-contained stat in the batter cluster.",
blocked_on: 'Requires both baserunner state and lineup order — neither ingested.',
},
// ── SINKER (ground-ball arm) — the one that is INPUT-READY ─────────────
{
sport: 'mlb', archetype: 'SINKER', stat: 'outs', skill: 'CONTACT',
interaction: 'gb_rate_x_infield_defense',
mechanism: "A sinkerballer's outs are converted by the infield, not by him. His ground-ball rate only becomes outs if the four men behind him can range to it — so his result is a joint product of his contact management and their defence, and neither factor states it alone. This is the OPPOSITE mechanism to a WHIFF arm, whose strikeouts need no fielder at all.",
blocked_on: null, // infield OAA is derived and ready; only sample is missing
},
];
for (const p of PRE_REGISTERED) {
conditioning.push({
sport: p.sport, archetype: p.archetype, stat: p.stat,
interaction: p.interaction, skill: p.skill, status: STATUS.CANDIDATE,
lift: null, evidence: null, mechanism: p.mechanism, blocked_on: p.blocked_on,
pre_registered: true,
});
}
/**
* Record a conditioning result. `skill` MUST name the underlying skill the
* interaction validates — an untagged proven entry cannot contribute to a
@@ -382,6 +433,14 @@ function validatedSkills(sport, archetype) {
/** Test-only: restore the declared statuses so suites cannot leak into each other. */
function __reset() {
conditioning.length = 0;
for (const p of PRE_REGISTERED) {
conditioning.push({
sport: p.sport, archetype: p.archetype, stat: p.stat,
interaction: p.interaction, skill: p.skill, status: STATUS.CANDIDATE,
lift: null, evidence: null, mechanism: p.mechanism, blocked_on: p.blocked_on,
pre_registered: true,
});
}
statVerdicts.clear();
byKey.clear();
for (const f of FEATURES) byKey.set(`${f.sport}|${f.key}`, { ...f, evidence: f.evidence || null, history: [] });
@@ -391,6 +450,6 @@ module.exports = {
STATUS, MIN_PROMOTION_N, BASE_ALPHA,
allFeatures, liveFeatures, candidateFeatures, statusOf, isLive,
recordStatVerdict, statusForStat, liveFeaturesForStat, candidateFeaturesForStat,
SKILLS, recordConditioning, conditioningFor, validatedSkills,
SKILLS, recordConditioning, conditioningFor, validatedSkills, PRE_REGISTERED,
isSufficient, promote, demote, summary, __reset,
};
+2
View File
@@ -268,6 +268,8 @@ async function refreshSeason(opts = {}) {
as_of_date: asOfD, sport, season, team,
oaa_sum: v.oaa_sum, oaa_mean: v.oaa_mean,
success_diff_mean: v.success_diff_mean, fielders: v.fielders,
infield_oaa_sum: v.infield_oaa_sum, infield_fielders: v.infield_fielders,
outfield_oaa_sum: v.outfield_oaa_sum, outfield_fielders: v.outfield_fielders,
source: 'statcast_oaa',
}));
const { error } = await sb.from('team_defense')