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:
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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')
|
||||
|
||||
Reference in New Issue
Block a user