diff --git a/src/routes/streaks.js b/src/routes/streaks.js index b78d6dc..23ea823 100644 --- a/src/routes/streaks.js +++ b/src/routes/streaks.js @@ -1,22 +1,41 @@ /** - * /api/streaks/:sport (Session 23) + * /api/streaks/:sport (Session 23; Session 60 night2/B — the lens). * - * Computed player streaks from cached game logs. NO API calls — reads - * warm Redis logs and runs the pure streaks engine over them. Supports - * `?stat=points` to narrow to one category, and `?limit=N`. + * Computed player streaks + form heat from cached game logs, every row + * interpreted through the VYNDR lens: what the streak was built against, + * tonight's matchup (opponent + MLB opposing SP w/ ERA), difficulty, and a + * one-line read. NO paid API calls — warm Redis + the free ESPN/statsapi + * caches only. Where a snapshot grade exists for a streaking player's + * category, the grade letter rides along (the slate already shows locked + * grades publicly — consistent, not a leak). * * Response: { sport, stat, streaks: [...], source: 'computed' } - * - * An empty `streaks` array is a valid, non-error state — the platform - * leans on the other layers (schedule, game lines, props) when no logs - * are warm yet. + * An empty array is a valid, non-error state. */ const express = require('express'); const streaksService = require('../services/streaksService'); +const { applyLens } = require('../services/streakLens'); const { loadRosterLogs } = require('../services/rosterLogs'); +const { nameKey } = require('../utils/playerName'); const { createRateLimit } = require('../middleware/rateLimit'); +// Lens context reads are best-effort: a dead cache or slow upstream must +// NEVER hang the public route — the lens just says less. Timer is unref'd +// so it can't keep the process (or Jest) alive. +function withTimeout(promise, ms) { + return Promise.race([ + promise, + new Promise((resolve) => { + const t = setTimeout(() => resolve(null), ms); + if (t.unref) t.unref(); + }), + ]).catch(() => null); +} + +const LENS_BUDGET_MS = 1500; +const inTest = () => process.env.NODE_ENV === 'test'; + const router = express.Router(); // Session 32 — public throttle (60/min; pure engine over cached logs). router.use(createRateLimit({ windowMs: 60_000, max: 60 })); @@ -25,6 +44,45 @@ const MISSION_HEADER = { 'X-VYNDR-Mission': 'Streaks are the heartbeat' }; const SUPPORTED = new Set(['nba', 'wnba', 'mlb', 'nfl', 'soccer']); +function todayET() { + return new Intl.DateTimeFormat('en-CA', { + timeZone: 'America/New_York', year: 'numeric', month: '2-digit', day: '2-digit', + }).format(new Date()); +} + +/** Tonight's context for the lens — best-effort, time-bounded, degrades to + * less context rather than hanging. Skipped under NODE_ENV=test (the lens + * builder itself is unit-tested; route tests exercise engine + shape). */ +async function lensContext(sport) { + const ctx = { scheduleGames: [], pitcherGames: [] }; + if (inTest()) return ctx; + const { cacheGet } = require('../utils/redis'); + const sched = await withTimeout(cacheGet(`schedule:${sport}:${todayET()}`), LENS_BUDGET_MS); + if (Array.isArray(sched)) ctx.scheduleGames = sched; + if (sport === 'mlb') { + // Warm in prod (the slate's /pitchers endpoint fills the same caches); + // hard-capped so a cold cache costs at most the budget, never a hang. + const { getProbablePitchers } = require('../services/probablePitchers'); + const pg = await withTimeout(getProbablePitchers(todayET()), LENS_BUDGET_MS); + if (Array.isArray(pg)) ctx.pitcherGames = pg; + } + return ctx; +} + +/** Grade letters for streaking players (snapshot cache; public data). */ +async function gradeJoin(sport) { + if (inTest()) return {}; + try { + const { cacheGet } = require('../utils/redis'); + const env = await withTimeout(cacheGet(`grades:${sport}`), LENS_BUDGET_MS); + const map = {}; + for (const g of (env && env.grades) || []) { + map[nameKey(g.player || g.player_name)] = map[nameKey(g.player || g.player_name)] || g.grade; + } + return map; + } catch { return {}; } +} + router.get('/:sport', async (req, res) => { const sport = String(req.params.sport || '').toLowerCase(); if (!SUPPORTED.has(sport)) { @@ -35,8 +93,18 @@ router.get('/:sport', async (req, res) => { try { const roster = await loadRosterLogs(sport); - const streaks = streaksService.computeStreaks(roster, sport, { stat, limit }); - return res.set(MISSION_HEADER).json({ sport, stat, streaks, source: 'computed' }); + const streaks = streaksService.computeStreaks(roster, sport, { stat }); + // Session 60 — form heat (hot hitters/sluggers/shooters) joins the feed. + const heat = streaksService.computeFormHeat(roster, sport, {}) + .filter((h) => stat === 'all' || h.category === stat); + let rows = [...streaks, ...heat]; + if (limit > 0) rows = rows.slice(0, limit); + + // THE LENS — no raw streak renders alone. + const [ctx, grades] = await Promise.all([lensContext(sport), gradeJoin(sport)]); + rows = applyLens(rows, ctx).map((r) => ({ ...r, grade: grades[nameKey(r.player)] || null })); + + return res.set(MISSION_HEADER).json({ sport, stat, streaks: rows, source: 'computed' }); } catch (err) { console.error(`[streaks/${sport}]`, err.message); return res.set(MISSION_HEADER).json({ sport, stat, streaks: [], source: 'computed' }); diff --git a/src/services/playerIntelService.js b/src/services/playerIntelService.js index 3cf9999..aef3181 100644 --- a/src/services/playerIntelService.js +++ b/src/services/playerIntelService.js @@ -130,6 +130,15 @@ async function resolvePlayerStats(name, sport, opts = {}) { season: mlbSeasonRows(res.season, res.group), last10: mlbLast10Rows(res.last10, res.group), splits: [], + // Session 60 (night2/B) — the RAW flattened game log, most-recent + // first, for the streaks/hot-list roster blob. Free: the adapter + // already fetched it for this resolve; nothing extra is called. + rawLog: (res.last10 || []) + .map((r) => ({ date: r.date || null, opponent: r.opponent || null, isHome: r.isHome ?? null, ...(r.stat || {}) })) + .reverse(), + seasonRaw: res.season || null, + group: res.group || null, + playerId: res.id ?? null, }; } if (sp === 'nba' || sp === 'wnba') { diff --git a/src/services/snapshotService.js b/src/services/snapshotService.js index 5472626..b8537de 100644 --- a/src/services/snapshotService.js +++ b/src/services/snapshotService.js @@ -169,6 +169,28 @@ async function pushTickerItems(events, deps) { await deps.cacheSet('ticker:items', merged, TICKER_TTL); } +// Session 60 (night2/B) — accumulate slate players' game logs into the +// roster blob the streaks/hot-list engines read. Merge by nameKey (newer +// entry wins), cap the blob, 72h TTL (a player off the slate for 3 days +// ages out — honest churn, not a leak). +const ROSTERLOGS_TTL = 72 * 3600; +const ROSTERLOGS_CAP = 300; + +async function mergeRosterLogs(sport, entries, deps) { + if (!entries || entries.length === 0) return; + try { + const key = `rosterlogs:${sport}`; + const existing = await deps.cacheGet(key); + const byKey = new Map(); + for (const e of Array.isArray(existing) ? existing : []) byKey.set(nameKey(e.name), e); + for (const e of entries) byKey.set(nameKey(e.name), e); // fresh resolve wins + const merged = [...byKey.values()].slice(-ROSTERLOGS_CAP); + await deps.cacheSet(key, merged, ROSTERLOGS_TTL); + } catch (e) { + console.warn(`[snapshot] rosterlogs merge failed for ${sport}:`, e.message); + } +} + const ACTIVE_SPORTS = ['mlb', 'nba', 'wnba', 'soccer']; /** @@ -271,6 +293,14 @@ async function runSnapshot(sport, opts = {}) { // (statsapi/ESPN), never guessed. Feeds the ledger team/opponent columns // and the slate join guard (a prop only attaches to its own game). const teamByPlayer = {}; + // Session 60 (night2/B) — THE STREAKS PRODUCER. The aggregator (streaks + + // hot lists) starved because its data producers were all external and + // unarmed (tank01-prefetch via n8n, the offline Python grading flow). + // The stats resolve above already fetched each slate player's game log — + // accumulate it into the `rosterlogs:{sport}` blob rosterLogs.loadRosterLogs + // reads FIRST. Zero extra API calls; the pipeline now feeds its own + // free layer. + const logEntries = []; await mapLimit(players, STATS_CONCURRENCY, async (player) => { try { const stats = await deps.resolveStats(player, sp); @@ -278,9 +308,20 @@ async function runSnapshot(sport, opts = {}) { const c = deps.classify(sp, stats.classifierInput || {}); archByPlayer[player] = c.primary ? c.primary.name : null; if (stats.team) teamByPlayer[player] = stats.team; + if (Array.isArray(stats.rawLog) && stats.rawLog.length > 0) { + logEntries.push({ + name: normalizeName(player).display || player, + playerId: stats.playerId ?? null, + team: stats.team || null, + group: stats.group || null, + seasonRaw: stats.seasonRaw || null, + games: stats.rawLog, + }); + } } } catch { /* graceful — no badge */ } }); + await mergeRosterLogs(sp, logEntries, deps); const enriched = graded.map((g) => ({ ...g, diff --git a/src/services/streakLens.js b/src/services/streakLens.js new file mode 100644 index 0000000..31e32b3 --- /dev/null +++ b/src/services/streakLens.js @@ -0,0 +1,118 @@ +'use strict'; + +/** + * The VYNDR Lens (Session 60, night2/B) — THE RULE: no raw streak ever + * renders alone. Every streak/hot-list row carries: + * 1. what it was BUILT against (the streak-window opponents — real, from + * the game log), + * 2. tonight's matchup (opponent + MLB opposing probable SP w/ ERA, from + * the free schedule/pitcher caches), + * 3. difficulty vs the streak's diet (MLB: SP ERA vs league ~4.00), + * 4. a one-line read composing the above. + * + * Interpreted data is the FREE layer; the grade on it stays paid. Every + * field is real or absent — when we can't rate the matchup we say less, + * we never invent. Pure module: callers supply tonight's context. + */ + +// League-average SP ERA bands (MLB ~4.00 in the modern run environment). +const ERA_STEP_UP = 3.4; // facing a SP at/below this = harder than average +const ERA_STEP_DOWN = 4.6; // at/above this = softer than average + +const token = (name) => String(name || '').toLowerCase().replace(/[^a-z0-9 ]/g, '').trim(); +const mascot = (name) => { const t = token(name).split(' ').filter(Boolean); return t.length ? t[t.length - 1] : ''; }; +const teamsMatch = (a, b) => { + if (!a || !b) return false; + return token(a) === token(b) || (mascot(a) !== '' && mascot(a) === mascot(b)); +}; + +/** + * Find tonight's game + opponent for a team from the cached schedule + * (`schedule:{sport}:{date}` shape: [{ homeTeam:{name,abbreviation}, awayTeam, gameTime }]). + */ +function tonightFor(team, scheduleGames) { + if (!team || !Array.isArray(scheduleGames)) return null; + for (const g of scheduleGames) { + const home = g.homeTeam || {}; + const away = g.awayTeam || {}; + if (teamsMatch(team, home.name) || teamsMatch(team, home.abbreviation)) { + return { opponent: away.name || away.abbreviation || null, opponentAbbr: away.abbreviation || null, isHome: true, gameTime: g.gameTime || null }; + } + if (teamsMatch(team, away.name) || teamsMatch(team, away.abbreviation)) { + return { opponent: home.name || home.abbreviation || null, opponentAbbr: home.abbreviation || null, isHome: false, gameTime: g.gameTime || null }; + } + } + return null; +} + +/** + * Tonight's opposing probable SP for a team, from the probable-pitchers + * games ([{ home: {team,pitcher,era}, away: {...} }]). The OPPOSING side. + */ +function opposingPitcherFor(team, pitcherGames) { + if (!team || !Array.isArray(pitcherGames)) return null; + for (const g of pitcherGames) { + if (g && g.home && teamsMatch(team, g.home.team)) return g.away && g.away.pitcher ? g.away : null; + if (g && g.away && teamsMatch(team, g.away.team)) return g.home && g.home.pitcher ? g.home : null; + } + return null; +} + +function eraDifficulty(era) { + const e = Number(era); + if (!Number.isFinite(e) || e <= 0) return null; + if (e <= ERA_STEP_UP) return 'step up'; + if (e >= ERA_STEP_DOWN) return 'step down'; + return 'neutral'; +} + +const isPitcherRow = (row) => row && (row.type === 'k_streak' || row.type === 'qs_streak'); + +/** + * Build the lens for one streak/heat row. ctx = { scheduleGames, pitcherGames }. + * Returns { builtVs, matchup, difficulty, read } — every field real or null. + */ +function buildLens(row, ctx = {}) { + const lens = { builtVs: null, matchup: null, difficulty: null, read: null }; + if (!row) return lens; + + const opps = Array.isArray(row.opponents) ? row.opponents.filter(Boolean) : []; + if (opps.length > 0) lens.builtVs = opps.slice(0, 4); + + const tonight = tonightFor(row.team, ctx.scheduleGames); + if (tonight && tonight.opponent) { + lens.matchup = `${tonight.isHome ? 'vs' : '@'} ${tonight.opponent}`; + // MLB batter lens: the opposing probable SP is the matchup that matters. + if (String(row.sport).toLowerCase() === 'mlb' && !isPitcherRow(row)) { + const sp = opposingPitcherFor(row.team, ctx.pitcherGames); + if (sp && sp.pitcher) { + lens.matchup += ` · SP ${sp.pitcher}${sp.era != null ? ` (${sp.era} ERA)` : ''}`; + lens.difficulty = eraDifficulty(sp.era); + } + } + } + + // The one-line read: composed only from parts we actually have. + const bits = [row.description]; + if (lens.builtVs) bits.push(`built vs ${lens.builtVs.join(', ')}`); + if (lens.matchup) { + if (lens.difficulty === 'step up') bits.push(`tonight ${lens.matchup} — a step up`); + else if (lens.difficulty === 'step down') bits.push(`tonight ${lens.matchup} — a softer spot`); + else bits.push(`tonight ${lens.matchup}`); + } + lens.read = bits.join('; '); + return lens; +} + +/** Attach the lens to every row (in place shape: { ...row, lens }). */ +function applyLens(rows, ctx = {}) { + return (rows || []).map((r) => ({ ...r, lens: buildLens(r, ctx) })); +} + +module.exports = { + buildLens, + applyLens, + tonightFor, + opposingPitcherFor, + __internals: { eraDifficulty, teamsMatch, ERA_STEP_UP, ERA_STEP_DOWN }, +}; diff --git a/src/services/streaksService.js b/src/services/streaksService.js index 2ee558e..d8e3d8d 100644 --- a/src/services/streaksService.js +++ b/src/services/streaksService.js @@ -191,7 +191,7 @@ function computePlayerStreaks(player, sport, opts = {}) { continue; } const run = consecutiveRun(games, spec.value, spec.threshold); - if (run >= MIN_STREAK) found.push(makeStreak(player, sport, spec, run)); + if (run >= MIN_STREAK) found.push(makeStreak(player, sport, spec, run, undefined, games)); } // Collapse tiered specs (e.g. 25+ and 20+ points) to one entry per @@ -210,7 +210,12 @@ function computePlayerStreaks(player, sport, opts = {}) { return Array.from(best.values()).map(({ _collapse, ...rest }) => rest); } -function makeStreak(player, sport, spec, run, rateValue) { +function makeStreak(player, sport, spec, run, rateValue, games) { + // Session 60 (night2/B) — the opponents the streak was BUILT against + // (unique, streak-window only). Lens fuel: "built vs OAK, LAA, SEA". + const opponents = Array.isArray(games) + ? [...new Set(games.slice(0, run).map((g) => g && g.opponent).filter(Boolean))] + : []; return { sport, player: player.name || player.player || null, @@ -222,6 +227,7 @@ function makeStreak(player, sport, spec, run, rateValue) { currentStreak: run, rate: rateValue ?? null, description: describe(spec, run), + opponents, active: true, _collapse: spec.collapse || spec.key, // internal — stripped before return }; @@ -245,9 +251,114 @@ function computeStreaks(players, sport, opts = {}) { return all; } +// ---- Session 60 (night2/B): FORM HEAT — 7-day rate vs baseline ---------- +// Hot hitters (AVG), hot sluggers (SLG/ISO), hot shooters (FG%). Rates are +// computed the correct way (Σmakes/Σattempts over the window, NOT a mean of +// per-game rates). Baseline = the player's season rate when the roster blob +// carries one, else the pre-window games — labeled accordingly, never faked. + +const fmt3 = (n) => { + const s = n.toFixed(3); + return s.startsWith('0.') ? s.slice(1) : s; // .412, not 0.412 +}; + +function windowSplit(games, now, windowDays = 7) { + const cutoff = (now || Date.now()) - windowDays * 86_400_000; + const dated = games.filter((g) => g && g.date && Number.isFinite(new Date(g.date).getTime())); + if (dated.length === 0) return { recent: games.slice(0, 5), rest: games.slice(5) }; + return { + recent: dated.filter((g) => new Date(g.date).getTime() >= cutoff), + rest: dated.filter((g) => new Date(g.date).getTime() < cutoff), + }; +} + +const sumOf = (rows, ...keys) => rows.reduce((acc, r) => acc + num(r, ...keys), 0); + +// Rate over a set of games: { made, att, rate|null }. +function ratio(rows, madeKeys, attKeys) { + const made = sumOf(rows, ...madeKeys); + const att = sumOf(rows, ...attKeys); + return { made, att, rate: att > 0 ? made / att : null }; +} + +const HEAT_SPECS = { + mlb: [ + { + type: 'hot_hitter', category: 'hits', label: 'AVG', + made: ['hits', 'H', 'h'], att: ['atBats', 'ab', 'AB'], + seasonKey: 'avg', minAtt: 15, minDelta: 0.05, fmt: fmt3, + line: (r, b, src) => `hitting ${fmt3(r)} over the last 7 days (${src} ${fmt3(b)})`, + }, + { + type: 'hot_slugger', category: 'total_bases', label: 'SLG', + made: ['totalBases', 'TB', 'total_bases'], att: ['atBats', 'ab', 'AB'], + seasonKey: 'slg', minAtt: 15, minDelta: 0.09, fmt: fmt3, + line: (r, b, src) => `slugging ${fmt3(r)} over the last 7 days (${src} ${fmt3(b)})`, + }, + ], + wnba: [ + { + type: 'hot_shooter', category: 'points', label: 'FG%', + made: ['fgm', 'field_goals_made', 'fieldGoalsMade'], att: ['fga', 'field_goals_attempted', 'fieldGoalsAttempted'], + seasonKey: 'fgPct', minAtt: 20, minDelta: 0.05, fmt: (n) => `${Math.round(n * 100)}%`, + line: (r, b, src) => `shooting ${Math.round(r * 100)}% over the last 7 days (${src} ${Math.round(b * 100)}%)`, + }, + ], +}; +HEAT_SPECS.nba = HEAT_SPECS.wnba; + +/** + * Form-heat rows in the streak shape (they merge into the same feed). + * players = [{ name, playerId, team, games, seasonRaw? }] — games + * most-recent-first with date fields. + */ +function computeFormHeat(players, sport, opts = {}) { + const specs = HEAT_SPECS[String(sport || '').toLowerCase()] || []; + if (specs.length === 0 || !Array.isArray(players)) return []; + const out = []; + for (const p of players) { + const games = Array.isArray(p?.games) ? p.games.slice() : []; + if (opts.chronological) games.reverse(); + if (games.length === 0) continue; + const { recent, rest } = windowSplit(games, opts.now, opts.windowDays || 7); + for (const spec of specs) { + const cur = ratio(recent, spec.made, spec.att); + if (cur.rate == null || cur.att < spec.minAtt) continue; + // Baseline: season rate from the blob when present, else prior games. + let baseline = null; + let baselineSrc = 'season'; + const seasonVal = p.seasonRaw && Number(p.seasonRaw[spec.seasonKey]); + if (Number.isFinite(seasonVal) && seasonVal > 0) baseline = seasonVal; + else { + const prior = ratio(rest, spec.made, spec.att); + if (prior.rate != null && prior.att >= spec.minAtt) { baseline = prior.rate; baselineSrc = 'prior stretch'; } + } + if (baseline == null || cur.rate - baseline < spec.minDelta) continue; + out.push({ + sport: String(sport).toLowerCase(), + player: p.name || p.player || null, + playerId: p.playerId ?? p.id ?? null, + team: p.team || null, + type: spec.type, + category: spec.category, + threshold: null, + currentStreak: recent.length, + rate: Math.round(cur.rate * 1000) / 1000, + baseline: Math.round(baseline * 1000) / 1000, + description: spec.line(cur.rate, baseline, baselineSrc), + opponents: [...new Set(recent.map((g) => g && g.opponent).filter(Boolean))], + active: true, + }); + } + } + out.sort((a, b) => (b.rate - b.baseline) - (a.rate - a.baseline)); + return opts.limit && opts.limit > 0 ? out.slice(0, opts.limit) : out; +} + module.exports = { computeStreaks, computePlayerStreaks, + computeFormHeat, specsFor, - __internals: { consecutiveRun, rateOverWindow, nba, mlb, nfl, soccer, MIN_STREAK }, + __internals: { consecutiveRun, rateOverWindow, windowSplit, ratio, nba, mlb, nfl, soccer, MIN_STREAK, HEAT_SPECS }, }; diff --git a/tests/unit/streakLens.test.js b/tests/unit/streakLens.test.js new file mode 100644 index 0000000..1efaf14 --- /dev/null +++ b/tests/unit/streakLens.test.js @@ -0,0 +1,126 @@ +// Session 60 (night2/B) — the VYNDR Lens + form heat. THE RULE: no raw +// streak renders alone; every row carries built-vs, tonight's matchup, +// difficulty, and a one-line read — all real or absent, never invented. + +const { buildLens, applyLens, tonightFor, opposingPitcherFor, __internals } = require('../../src/services/streakLens'); +const { computeFormHeat } = require('../../src/services/streaksService'); + +const SCHEDULE = [ + { + homeTeam: { name: 'Detroit Tigers', abbreviation: 'DET' }, + awayTeam: { name: 'Tampa Bay Rays', abbreviation: 'TB' }, + gameTime: '2026-07-11T23:10:00Z', + }, +]; +const PITCHERS = [ + { + home: { team: 'Detroit Tigers', pitcher: 'Tarik Skubal', era: 2.41 }, + away: { team: 'Tampa Bay Rays', pitcher: 'Ryan Pepiot', era: 3.9 }, + }, +]; + +describe('streakLens — tonight resolution', () => { + test('finds the opponent + home/away for a team by mascot token', () => { + const t = tonightFor('Tampa Bay Rays', SCHEDULE); + expect(t.opponent).toBe('Detroit Tigers'); + expect(t.isHome).toBe(false); + }); + + test('resolves the OPPOSING probable SP (not the player-team SP)', () => { + const sp = opposingPitcherFor('Tampa Bay Rays', PITCHERS); + expect(sp.pitcher).toBe('Tarik Skubal'); + expect(sp.era).toBe(2.41); + }); +}); + +describe('streakLens — the one-line read', () => { + const ROW = { + sport: 'mlb', player: 'Brandon Lowe', team: 'Tampa Bay Rays', + type: 'hit_streak', category: 'hits', currentStreak: 7, + description: '7-game hit streak', opponents: ['Oakland Athletics', 'Los Angeles Angels'], + }; + + test('composes streak + built-vs + tonight + difficulty', () => { + const lens = buildLens(ROW, { scheduleGames: SCHEDULE, pitcherGames: PITCHERS }); + expect(lens.builtVs).toEqual(['Oakland Athletics', 'Los Angeles Angels']); + expect(lens.matchup).toBe('@ Detroit Tigers · SP Tarik Skubal (2.41 ERA)'); + expect(lens.difficulty).toBe('step up'); // 2.41 ERA ≤ 3.4 + expect(lens.read).toContain('7-game hit streak'); + expect(lens.read).toContain('built vs Oakland Athletics'); + expect(lens.read).toContain('a step up'); + }); + + test('says LESS when context is missing — never invents', () => { + const lens = buildLens({ ...ROW, opponents: [] }, {}); + expect(lens.builtVs).toBeNull(); + expect(lens.matchup).toBeNull(); + expect(lens.difficulty).toBeNull(); + expect(lens.read).toBe('7-game hit streak'); + }); + + test('pitcher streaks do not get an opposing-SP difficulty read', () => { + const lens = buildLens( + { ...ROW, type: 'k_streak', description: '3-game 7+ K streak' }, + { scheduleGames: SCHEDULE, pitcherGames: PITCHERS }, + ); + expect(lens.matchup).toBe('@ Detroit Tigers'); // no SP line for pitchers + expect(lens.difficulty).toBeNull(); + }); + + test('ERA difficulty bands', () => { + expect(__internals.eraDifficulty(2.9)).toBe('step up'); + expect(__internals.eraDifficulty(4.0)).toBe('neutral'); + expect(__internals.eraDifficulty(5.2)).toBe('step down'); + expect(__internals.eraDifficulty(null)).toBeNull(); + }); + + test('applyLens attaches a lens to every row', () => { + const rows = applyLens([ROW, { ...ROW, player: 'X' }], { scheduleGames: SCHEDULE, pitcherGames: PITCHERS }); + expect(rows).toHaveLength(2); + expect(rows.every((r) => r.lens && r.lens.read)).toBe(true); + }); +}); + +describe('computeFormHeat — hot hitters / sluggers (correct rate math)', () => { + const day = (n) => new Date(Date.now() - n * 86_400_000).toISOString().slice(0, 10); + // 7-day window: 12-for-24 (.500); season .270 → hot hitter. + const hotGames = [ + { date: day(1), opponent: 'Boston Red Sox', hits: 3, atBats: 4, totalBases: 7 }, + { date: day(2), opponent: 'Boston Red Sox', hits: 2, atBats: 4, totalBases: 5 }, + { date: day(3), opponent: 'New York Yankees', hits: 2, atBats: 4, totalBases: 2 }, + { date: day(4), opponent: 'New York Yankees', hits: 2, atBats: 4, totalBases: 6 }, + { date: day(5), opponent: 'New York Yankees', hits: 1, atBats: 4, totalBases: 1 }, + { date: day(6), opponent: 'Baltimore Orioles', hits: 2, atBats: 4, totalBases: 4 }, + ]; + + test('flags a hot hitter vs his SEASON average with the honest line', () => { + const rows = computeFormHeat( + [{ name: 'Hot Guy', team: 'Tampa Bay Rays', games: hotGames, seasonRaw: { avg: '0.270', slg: '0.420' } }], + 'mlb', + ); + const hitter = rows.find((r) => r.type === 'hot_hitter'); + expect(hitter).toBeTruthy(); + expect(hitter.rate).toBe(0.5); // 12/24, Σ/Σ not mean-of-rates + expect(hitter.description).toContain('.500 over the last 7 days'); + expect(hitter.description).toContain('season .270'); + const slugger = rows.find((r) => r.type === 'hot_slugger'); + expect(slugger).toBeTruthy(); // 25 TB / 24 AB ≈ 1.042 SLG vs .420 + expect(slugger.opponents).toContain('Boston Red Sox'); + }); + + test('small samples are refused (minAtt), not extrapolated', () => { + const rows = computeFormHeat( + [{ name: 'Two AB Guy', games: [{ date: day(1), hits: 2, atBats: 2 }], seasonRaw: { avg: '0.250' } }], + 'mlb', + ); + expect(rows).toEqual([]); + }); + + test('no season baseline + no prior games → no claim', () => { + const rows = computeFormHeat( + [{ name: 'No Baseline', games: hotGames, seasonRaw: null }], + 'mlb', + ); + expect(rows).toEqual([]); // all games in-window, nothing to trend against + }); +});