diff --git a/src/services/adapters/statcastAdapter.js b/src/services/adapters/statcastAdapter.js index c0ab163..508f738 100644 --- a/src/services/adapters/statcastAdapter.js +++ b/src/services/adapters/statcastAdapter.js @@ -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; diff --git a/src/services/model/featureRegistry.js b/src/services/model/featureRegistry.js index a1f450d..2830ee3 100644 --- a/src/services/model/featureRegistry.js +++ b/src/services/model/featureRegistry.js @@ -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, }; diff --git a/src/services/statcastAggregateService.js b/src/services/statcastAggregateService.js index 30a36a5..b99ff44 100644 --- a/src/services/statcastAggregateService.js +++ b/src/services/statcastAggregateService.js @@ -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')