Layer 3 Step 4b: public park base, source-pluggable, plus game-level capture
PHASE 0 — the settle path sees a player's game-log line, not the game. It knows date and teams, never venue or final totals. But the grain is far cheaper than per-prop or even per-game: ONE statsapi schedule call per game DATE returns every game that day with venue, linescore and scoring plays. Fifteen games, one call, verified live. PUBLIC BASE — the ingestion was already done. The static FanGraphs table from Session 15 is the public base; this converts its 100-indexed values into the multipliers the composable architecture wants (Coors 128 becomes 1.28) rather than ingesting a second copy of a number we already hold. It is labelled COMMODITY in the code, not just in a comment. Every resolution carries a provenance record, and the public one reads proprietary: false with the note "Commodity: a public number. Not a VYNDR derivation." The proprietary label exists but belongs only to the self-derived version, and only once it beats this base on the instrument. A surface rendering a park effect can state which it is rather than implying the flattering one. Honest-absent where even the PUBLIC number is thin: a relocated club in a temporary venue gets no factor, because a public number for a park with one season behind it is no more trustworthy than ours would be. SOURCE-PLUGGABLE is the architectural point. resolveParkBase() is the only accessor, public and derived return identical shapes, and callers never branch on source — so when self-derived factors clear their floor they swap into the same slot with nothing downstream to rewrite. A derived source with no factor available returns absent rather than silently falling back to public, because a silent fallback would make a proprietary claim out of a commodity number. GAME-LEVEL CAPTURE starts now because it cannot start retroactively. Game grain, deduped on game_id, never copied onto prop rows — a game's totals belong to the game, and duplicating them per prop is how one fact starts disagreeing with itself. Every field is tied to a named future derivation: venue for park factors, runs for the run environment, HR totals for HR factors. Nothing else is stored. Only Final games are captured, since an in-progress total is not a result, and a game with no scoring plays reports HR as absent rather than zero. HR totals come from scoring plays, which is complete because every home run scores at least the batter. The accrual target is stated rather than promised: 150 home games per venue at roughly 81 per season means about two seasons before a self-derived factor can be nominated, and accrualStatus() reports live progress per venue so the wait is measurable. Induced: Coors home runs +0.061 for the hitter and identically +0.061 for the pitcher's home-runs-allowed at the same park, mirrored on the under; San Francisco negative; Tampa flagged weather-N/A with its factor still applying; the Athletics' temporary venue absent; strikeouts untouched. A real 2025-07-19 capture produced 15 games across 15 venues, 12 with HR totals, zero duplicate game ids. Migration 035. Induce with POST /api/internal/gamectx/:date, progress at /gamectx/accrual. Tests 3688 passed / 298 suites, web build exit 0. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VCNgGSt5qvcLxaeQqa7Zpj
This commit is contained in:
@@ -0,0 +1,143 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* PARK BASE — the SOURCE-PLUGGABLE environment coefficient (Layer 3, Step 4b).
|
||||
*
|
||||
* One slot, two possible sources, identical output shape:
|
||||
*
|
||||
* 'public' — the static FanGraphs table (src/data/parkFactors.js, S15).
|
||||
* COMMODITY. A stable public number anyone can look up.
|
||||
* 'derived' — our own multi-season derivation (src/services/parkFactors.js,
|
||||
* S73). Not yet nominated: it needs the game-level accrual that
|
||||
* `gameContext` starts collecting this session.
|
||||
*
|
||||
* The pluggability is the point. When the self-derived factors clear their
|
||||
* sample floor and beat the public base on the instrument, they swap into this
|
||||
* same slot — no rearchitecting downstream, because everything above consumes
|
||||
* `resolveParkBase()` and never a source directly.
|
||||
*
|
||||
* ── THE HONESTY LINE ─────────────────────────────────────────────────────
|
||||
* The public base is COMMODITY. It must never be marketed or described as a
|
||||
* proprietary park factor — it is a number anyone can read off FanGraphs. The
|
||||
* proprietary claim is reserved for the self-derived, mechanism-conditioned
|
||||
* version, and only once it beats this base on the settled ledger.
|
||||
* `PROVENANCE` below carries that label so any surface rendering a park effect
|
||||
* can state which it is.
|
||||
*/
|
||||
|
||||
const publicTable = require('../data/parkFactors');
|
||||
|
||||
const PROVENANCE = Object.freeze({
|
||||
public: {
|
||||
source: 'fangraphs_public',
|
||||
label: 'PUBLIC PARK FACTOR',
|
||||
proprietary: false,
|
||||
note: 'Commodity: a public number. Not a VYNDR derivation.',
|
||||
},
|
||||
derived: {
|
||||
source: 'vyndr_derived',
|
||||
label: 'VYNDR PARK FACTOR',
|
||||
proprietary: true,
|
||||
note: 'Self-derived from captured game context. Nominated only after it beats the public base.',
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Parks whose PUBLIC factor is itself thin or unstable — new builds, relocated
|
||||
* clubs, temporary venues. A public number for a park with one season behind it
|
||||
* is no more trustworthy than our own would be, so it is HONEST-ABSENT rather
|
||||
* than emitted with false confidence.
|
||||
*
|
||||
* ATH: the Athletics' temporary Sacramento venue (2025–). SAC/LV as the club
|
||||
* moves. Extend this list when a park opens or a club relocates — the cost of a
|
||||
* missing entry is a confident factor for a stadium nobody has data on.
|
||||
*/
|
||||
const UNSTABLE_PUBLIC = Object.freeze(new Set(['ATH', 'OAK', 'SAC', 'LV']));
|
||||
|
||||
/** Domes + retractable roofs — reused from the derived service so the two
|
||||
* sources agree on which venues weather cannot touch. */
|
||||
const { DOME_VENUES } = require('./parkFactors');
|
||||
const DOME_TEAMS = Object.freeze(new Set(['TB', 'TOR', 'ARI', 'HOU', 'MIL', 'TEX', 'MIA', 'SEA']));
|
||||
|
||||
const num = (v) => {
|
||||
if (v == null || v === '') return null;
|
||||
const n = typeof v === 'number' ? v : Number(v);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
};
|
||||
|
||||
/** The public table is indexed to 100. The composable architecture wants a
|
||||
* multiplier around 1.0, so 128 → 1.28. */
|
||||
const toMultiplier = (indexed) => {
|
||||
const n = num(indexed);
|
||||
return n == null || n <= 0 ? null : Math.round((n / 100) * 1000) / 1000;
|
||||
};
|
||||
|
||||
/**
|
||||
* resolveParkBase({ teamAbbr, venueName, source, derived }) — the ONE accessor.
|
||||
*
|
||||
* Returns the same record shape whichever source answers, so callers never
|
||||
* branch on provenance:
|
||||
* { state, hr_base, run_base, venue, weather_na, provenance, source }
|
||||
* with `state` one of 'present' | 'absent' — and `weather_na` orthogonal to
|
||||
* both, exactly as the S73 derivation defined it (a dome's park factor still
|
||||
* applies; the flag only tells the weather arc to stand down).
|
||||
*/
|
||||
function resolveParkBase({ teamAbbr, venueName, source = 'public', derived = null } = {}) {
|
||||
const abbr = String(teamAbbr || '').toUpperCase();
|
||||
const weatherNa = DOME_TEAMS.has(abbr) || (venueName ? DOME_VENUES.has(venueName) : false);
|
||||
|
||||
const absent = (reason, prov) => ({
|
||||
state: 'absent', hr_base: null, run_base: null,
|
||||
venue: venueName || abbr || null, weather_na: weatherNa,
|
||||
provenance: prov, source: prov.source, reason,
|
||||
});
|
||||
|
||||
// ── self-derived, when it has been nominated ──────────────────────────
|
||||
if (source === 'derived') {
|
||||
const prov = PROVENANCE.derived;
|
||||
if (!derived || derived.state !== 'present') {
|
||||
return absent('self-derived factor not available for this venue', prov);
|
||||
}
|
||||
return {
|
||||
state: 'present',
|
||||
hr_base: num(derived.hr_base),
|
||||
run_base: num(derived.run_base),
|
||||
venue: derived.venue || venueName || null,
|
||||
weather_na: derived.weather_na ?? weatherNa,
|
||||
provenance: prov, source: prov.source,
|
||||
games: derived.games ?? null, seasons: derived.seasons ?? null,
|
||||
regime_start: derived.regime_start ?? null,
|
||||
reason: null,
|
||||
};
|
||||
}
|
||||
|
||||
// ── public (commodity) base ───────────────────────────────────────────
|
||||
const prov = PROVENANCE.public;
|
||||
if (!abbr) return absent('no team/venue to resolve', prov);
|
||||
if (UNSTABLE_PUBLIC.has(abbr)) {
|
||||
// Even the PUBLIC number is thin here — a new or temporary park. Emitting
|
||||
// it would dress a shaky number as a settled one.
|
||||
return absent('public factor is itself thin/unstable for this venue', prov);
|
||||
}
|
||||
const row = publicTable.getParkFactor(abbr);
|
||||
if (!row) return absent('no public factor for this team', prov);
|
||||
|
||||
const hr = toMultiplier(row.hr);
|
||||
const run = toMultiplier(row.r);
|
||||
if (hr == null && run == null) return absent('public factor unparseable', prov);
|
||||
|
||||
return {
|
||||
state: 'present',
|
||||
hr_base: hr, run_base: run,
|
||||
venue: venueName || abbr, weather_na: weatherNa,
|
||||
provenance: prov, source: prov.source, reason: null,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
resolveParkBase,
|
||||
PROVENANCE,
|
||||
UNSTABLE_PUBLIC,
|
||||
DOME_TEAMS,
|
||||
__internals: { toMultiplier, num },
|
||||
};
|
||||
Reference in New Issue
Block a user