'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 }, };