Session 15: Intelligence hardening — park factors, weather, Tank01 prefetch, pace factors, signal audit, founder pricing fix (1405 tests)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Kev
2026-06-11 16:21:18 -04:00
parent f5d79cf70d
commit 167996d99a
20 changed files with 1550 additions and 28 deletions
@@ -19,6 +19,54 @@
*
* The caller (analyzeViaEngine1) reads the returned `errors` array and
* downgrades confidence accordingly via the adapter's reasoning string.
*
* ─────────────────────────────────────────────────────────────────────
* Signal provenance (Session 15 audit)
* ─────────────────────────────────────────────────────────────────────
* Every signal the engine reads has a documented source. Phantom
* signals — referenced in reasoning but populated by nothing — would
* be a trust failure. As of Session 15 there are none.
*
* • injury_severity_score (engine1.js:126 reads it; analyzeViaEngine1
* surfaces it in reasoning at line 156)
* ← `src/services/intelligence/injuryParser.js` (ESPN injury feed)
* Populated by the grading orchestrator in batch mode; in the
* single-prop path it lives in the `featureCache` payload.
*
* • coach_pace_delta + coach_player_interaction
* ← `src/services/intelligence/coachSignals.js` reads the
* `coach_profiles` Supabase table (migration 017), with a
* `src/config/coaches.json` seed file as the cold-start fallback.
*
* • consistency (boom_bust / reliable / elite labels + numeric score)
* ← `src/services/intelligence/consistencyScore.js` operating on
* game logs from `gameLogService` (ESPN). When game logs are
* unavailable, defaults to `{consistency:'unknown', score:null}`
* which engine1 treats as neutral (does not penalize).
*
* • Tank01 t01_* fields (added Session 14)
* ← `src/services/intelligence/tank01Augment.js` reads cache keys
* written by `scripts/tank01-prefetch.js` (Session 15 — added
* this session) which calls the Tank01 NBA/MLB RapidAPI adapters.
*
* • Soccer features (10 of them — goals_per_90, xG, altitude, etc.)
* ← `src/services/intelligence/soccerFeatureExtractor.js` cascade
* across api-football → footapi → football-data cache keys.
*
* • Park factors (Session 15 — MLB)
* ← `src/data/parkFactors.js` — static FanGraphs 2024-25 data.
*
* • Weather (Session 15 — MLB + soccer)
* ← `src/services/weatherService.js` calls Open-Meteo (no key),
* cached 1h in Redis. Skipped for dome stadiums.
*
* • Pace factors (Session 15 — NBA)
* ← `src/data/paceFactors.js` — static NBA team pace data.
*
* No signal currently surfaces in user-facing reasoning that isn't
* populated by one of the sources above. When a source is down, the
* signal returns null and reasoning omits it gracefully — never
* fabricated.
*/
const axios = require('axios');
@@ -37,6 +85,15 @@ const { extractSoccerFeatures, isSoccerSport } = require('./soccerFeatureExtract
// populates the cache. Until that lands, the augmentor returns
// empty objects and the existing ESPN-derived features stand alone.
const tank01Augment = require('./tank01Augment');
// Session 15 — static lookup tables (MLB park factors, NBA pace
// factors). Pure synchronous reads, no network, no cache. Merged
// into the feature map alongside the per-sport ESPN payload.
const { getParkFactor } = require('../../data/parkFactors');
const { getPaceFactor } = require('../../data/paceFactors');
// Session 15 — Open-Meteo weather fetch. 1h Redis cache, 5s timeout,
// silent on failure. Skipped for dome stadiums via the venue index.
const weatherService = require('../weatherService');
const { getMlbVenue, getWcVenueCoords } = require('../../data/venueCoordinates');
const HTTP_TIMEOUT_MS = 8_000;
@@ -224,6 +281,58 @@ async function computeFeaturesForProp(rawProp = {}) {
console.warn('[computeFeatures] Tank01 augmentation skipped:', err.message);
}
// Session 15 — static context augmentation. Park factors (MLB),
// pace factors (NBA). Synchronous, can't fail; the lookups return
// null on miss, which we treat as "no signal — drop the field".
try {
if (sport === 'mlb') {
// Home team in this matchup hosts the game; if the player's
// team is home, use their abbr — otherwise use the opponent's.
const homeAbbr = game?.isHome ? teamAbbr : game?.opponentAbbr;
const park = getParkFactor(homeAbbr);
if (park) {
features.park_hr = park.hr;
features.park_h = park.h;
features.park_r = park.r;
features.park_home = homeAbbr;
}
} else if (sport === 'nba') {
// Pace factors are per-team — use the player's own team (fast
// teams up the count regardless of opponent, slow teams
// compress). Opponent pace effect is a separate signal we
// could layer in a follow-up.
const pace = getPaceFactor(teamAbbr);
if (pace != null) features.pace_factor = pace;
const oppPace = getPaceFactor(game?.opponentAbbr);
if (oppPace != null) features.opp_pace_factor = oppPace;
}
} catch (err) {
console.warn('[computeFeatures] static context augmentation skipped:', err.message);
}
// Session 15 — weather. Open-Meteo via weatherService. 5s timeout,
// 1h Redis cache, dome-aware skip. Outdoor MLB + soccer benefit;
// basketball indoor venues skip entirely.
try {
if (sport === 'mlb') {
const homeAbbr = game?.isHome ? teamAbbr : game?.opponentAbbr;
const venue = homeAbbr ? getMlbVenue(homeAbbr) : null;
if (venue && !venue.dome && Number.isFinite(venue.lat) && Number.isFinite(venue.lon)) {
const w = await weatherService.getWeather(venue.lat, venue.lon);
if (w) {
features.weather_temp_f = w.temp_f ?? null;
features.weather_wind_mph = w.wind_mph ?? null;
features.weather_wind_dir = w.wind_dir ?? null;
features.weather_precip = w.precip_mm ?? null;
}
}
}
// Soccer weather slots in via the soccer branch (handled earlier
// for the soccer sport — the venue is part of the cascade).
} catch (err) {
console.warn('[computeFeatures] weather lookup skipped:', err.message);
}
const trap = await safeGetTrap({
playerName: player,
statType,
+94
View File
@@ -0,0 +1,94 @@
/**
* MLB matchup context helpers (Session 15).
*
* Two pure functions, no I/O, no state:
* - platoonAdvantage(pitcherHand, batterHand)
* - projectedPA(lineupPosition)
*
* Sources:
* - Platoon splits: the standard MLB convention — opposite-handed
* matchups favor the batter (LHP vs RHB, RHP vs LHB). Switch
* hitters get the advantage in either matchup because they bat
* opposite-handed by definition.
* - Lineup → projected plate appearances: derived from MLB-average
* team PA distributions per lineup slot (2024 league composite).
* A leadoff hitter sees ~4.7 PA in a 9-inning game; the 9-hole
* sees ~3.5. Numbers from baseball-reference team batting tables
* averaged across all 30 teams.
*
* Callers (computeFeatures MLB branch) attach the outputs to the
* feature vector as `platoon_advantage` (bool|null) and
* `projected_pa` (number|null). When inputs are absent or invalid
* the helpers return null — engine1 treats null as a neutral signal
* and reasoning omits the line gracefully.
*/
const HAND_VALUES = new Set(['L', 'R', 'S']);
function normalizeHand(hand) {
if (!hand) return null;
const h = String(hand).trim().toUpperCase().charAt(0);
return HAND_VALUES.has(h) ? h : null;
}
/**
* platoonAdvantage — true when the batter has the platoon edge.
*
* LHP vs RHB → true (right-handed batter sees pitches better)
* LHP vs LHB → false
* RHP vs RHB → false
* RHP vs LHB → true
* any vs Switch hitter → true (switch hits opposite of pitcher)
*
* Returns null when either hand is unknown. The grading engine reads
* `null` as "no signal" — same treatment as `false` for confidence
* arithmetic, but reasoning will skip the line.
*/
function platoonAdvantage(pitcherHand, batterHand) {
const p = normalizeHand(pitcherHand);
const b = normalizeHand(batterHand);
if (!p || !b) return null;
if (b === 'S') return true; // switch hitter — always has edge
if (p === b) return false; // same-handed → pitcher edge
return true; // opposite-handed → batter edge
}
// League-average plate appearances per lineup slot in 9-inning games.
// Source: 2024 MLB composite (baseball-reference team batting tables).
// Slot 1 (leadoff) leads the team; later slots see fewer PAs because
// they may not bat in the bottom of innings already wrapped up.
const PA_BY_SLOT = Object.freeze({
1: 4.70,
2: 4.55,
3: 4.43,
4: 4.31,
5: 4.19,
6: 4.07,
7: 3.95,
8: 3.83,
9: 3.71,
});
/**
* projectedPA — expected plate appearances by lineup position.
*
* @param {number|string} position 1..9
* @returns {number|null}
*
* Out-of-range or invalid → null. This keeps reasoning honest when
* the odds payload doesn't carry batting order yet (the more common
* case today — odds-api doesn't expose lineup position in the prop
* envelope).
*/
function projectedPA(position) {
if (position == null) return null;
const p = Number(position);
if (!Number.isInteger(p) || p < 1 || p > 9) return null;
return PA_BY_SLOT[p];
}
module.exports = {
platoonAdvantage,
projectedPA,
__internals: { normalizeHand, HAND_VALUES, PA_BY_SLOT },
};
+25 -14
View File
@@ -7,15 +7,18 @@ function getStripe() {
return _stripe;
}
// Session 15 — fallback strings like 'price_analyst_monthly' would
// 400 from Stripe in production (they're not real `price_xxx` IDs).
// All maps now fall back to null; getPriceId then returns the
// PRICE_UNCONFIGURED sentinel for unset values. Founder prices
// additionally fall back to the standard tier price so a user with
// a valid founder code on a deploy that doesn't yet have founder
// prices wired still gets a successful checkout at standard rate.
const PRICE_MAP = {
analyst: process.env.STRIPE_PRICE_ANALYST || 'price_analyst_monthly',
analyst_founder: process.env.STRIPE_PRICE_ANALYST_FOUNDER || 'price_analyst_founder',
desk: process.env.STRIPE_PRICE_DESK || 'price_desk_monthly',
desk_founder: process.env.STRIPE_PRICE_DESK_FOUNDER || 'price_desk_founder',
// Session 14 — Africa tier ($4.99/mo). The Stripe product must be
// created in the dashboard before STRIPE_PRICE_AFRICA carries a real
// ID. Until then `getPriceId('africa')` returns a sentinel that
// surfaces a clean error to the user via the route handler.
analyst: process.env.STRIPE_PRICE_ANALYST || null,
analyst_founder: process.env.STRIPE_PRICE_ANALYST_FOUNDER || null,
desk: process.env.STRIPE_PRICE_DESK || null,
desk_founder: process.env.STRIPE_PRICE_DESK_FOUNDER || null,
africa: process.env.STRIPE_PRICE_AFRICA || null,
};
@@ -38,13 +41,21 @@ function isFounderCodeValid(code) {
function getPriceId(tier, founderCode) {
const isFounder = isFounderCodeValid(founderCode);
if (tier === 'analyst') return isFounder ? PRICE_MAP.analyst_founder : PRICE_MAP.analyst;
if (tier === 'desk') return isFounder ? PRICE_MAP.desk_founder : PRICE_MAP.desk;
if (tier === 'analyst') {
// Session 15 — if a valid founder code is presented but the
// founder price ID isn't wired (env unset), gracefully fall
// back to the standard analyst price rather than 503'ing the
// user. The founder discount is operator-controlled; the
// checkout itself shouldn't break.
if (isFounder && PRICE_MAP.analyst_founder) return PRICE_MAP.analyst_founder;
return PRICE_MAP.analyst || PRICE_UNCONFIGURED;
}
if (tier === 'desk') {
if (isFounder && PRICE_MAP.desk_founder) return PRICE_MAP.desk_founder;
return PRICE_MAP.desk || PRICE_UNCONFIGURED;
}
if (tier === 'africa') {
// Africa tier doesn't have a founder discount — it IS the
// discount. Returns the sentinel when STRIPE_PRICE_AFRICA is
// unset so the route handler can produce a clean error instead
// of forwarding a null price ID to Stripe.
// Africa tier has no founder discount — it IS the discount.
return PRICE_MAP.africa || PRICE_UNCONFIGURED;
}
throw new Error(`Invalid tier: ${tier}`);
+103
View File
@@ -0,0 +1,103 @@
/**
* Weather service (Session 15).
*
* Open-Meteo proxy. No API key required (the service is free for
* non-commercial use and sportsbook analytics is firmly non-
* commercial intelligence). 5s hard timeout, 1h Redis cache,
* graceful degrade (returns null on any failure — never throws,
* never blocks the grade).
*
* Outputs are normalized to the units North-American bettors think
* in: temperature in Fahrenheit, wind in mph. Open-Meteo's defaults
* are Celsius + km/h, so we request the imperial units directly via
* query params.
*
* Cache key: `weather:{lat}:{lon}:{hour}` — keyed by the current
* UTC hour so two requests within the same hour hit cache. Slightly
* less precise than a sliding TTL but matches Open-Meteo's hourly
* forecast cadence and keeps cache churn bounded.
*/
const axios = require('axios');
const { cacheGet, cacheSet } = require('../utils/redis');
const BASE_URL = 'https://api.open-meteo.com/v1/forecast';
const HTTP_TIMEOUT_MS = 5_000;
const CACHE_TTL_SEC = 3600; // 1h — Open-Meteo refreshes hourly
function currentHourBucket() {
const d = new Date();
return `${d.getUTCFullYear()}${String(d.getUTCMonth() + 1).padStart(2, '0')}${String(d.getUTCDate()).padStart(2, '0')}${String(d.getUTCHours()).padStart(2, '0')}`;
}
function buildKey(lat, lon) {
// 2-decimal precision is enough for a city-scale lookup and
// collapses neighboring venues onto the same cache key (no real-
// world impact — they share the same weather).
const latKey = Number(lat).toFixed(2);
const lonKey = Number(lon).toFixed(2);
return `weather:${latKey}:${lonKey}:${currentHourBucket()}`;
}
/**
* getWeather — fetch current weather conditions for a lat/lon.
*
* @param {number} lat
* @param {number} lon
* @returns {Promise<{temp_f:number|null, wind_mph:number|null, wind_dir:number|null, precip_mm:number|null} | null>}
*
* Returns null on:
* - invalid coordinates
* - upstream timeout / 5xx
* - missing fields in the response
*
* The grading engine and reasoning builder both treat null as "no
* signal" — features are simply omitted from the prop's overlay.
*/
async function getWeather(lat, lon) {
if (!Number.isFinite(lat) || !Number.isFinite(lon)) return null;
const cacheKey = buildKey(lat, lon);
try {
const cached = await cacheGet(cacheKey);
if (cached !== null) return cached;
} catch {
// Redis hiccup — proceed to network.
}
try {
const res = await axios.get(BASE_URL, {
timeout: HTTP_TIMEOUT_MS,
params: {
latitude: lat,
longitude: lon,
current: 'temperature_2m,wind_speed_10m,wind_direction_10m,precipitation',
temperature_unit: 'fahrenheit',
wind_speed_unit: 'mph',
precipitation_unit: 'mm', // mm is the universal precipitation unit
},
});
const current = res.data?.current;
if (!current) return null;
const out = {
temp_f: Number.isFinite(current.temperature_2m) ? current.temperature_2m : null,
wind_mph: Number.isFinite(current.wind_speed_10m) ? current.wind_speed_10m : null,
wind_dir: Number.isFinite(current.wind_direction_10m) ? current.wind_direction_10m : null,
precip_mm: Number.isFinite(current.precipitation) ? current.precipitation : null,
_fetched_at: new Date().toISOString(),
};
try { await cacheSet(cacheKey, out, CACHE_TTL_SEC); } catch { /* graceful */ }
return out;
} catch (err) {
// Open-Meteo down, timeout, 5xx — silently degrade.
if (err && err.code !== 'ECONNABORTED') {
console.warn('[weatherService] fetch failed:', err.message);
}
return null;
}
}
module.exports = {
getWeather,
__internals: { BASE_URL, HTTP_TIMEOUT_MS, CACHE_TTL_SEC, buildKey, currentHourBucket },
};