Session B (night2): streaks/hot-list engine — producer + form heat + THE LENS
RESURRECT verdict: the Session-23 engine existed, was pure, tested, and
mounted — it starved because every producer was external and unarmed
(tank01-prefetch via n8n, offline Python flow). The snapshot pipeline is
now the producer: each run merges slate players' real game logs (already
fetched for archetypes — zero extra calls) into rosterlogs:{sport}.
- computeFormHeat: hot hitters (7d AVG), hot sluggers (SLG), hot shooters
(FG%) — correct sum/sum rate math, season baseline else prior stretch,
min-sample refusals, never extrapolated.
- streakLens: no raw streak renders alone — built-vs opponents, tonight's
matchup + opposing SP w/ ERA, step-up/step-down difficulty, one-line
read. Absent context = say less, never invent.
- /api/streaks/:sport: heat merged into the feed, lens applied from cached
schedule + pitchers (time-bounded, can never hang the route), snapshot
grade letters joined.
- ACCEPTANCE (live statsapi, real 2026 logs): 32 rows found — Turang
12-gm on-base, Reynolds 8-gm on-base, Pratt 7-gm on-base + 3-gm
multi-hit, Meidroth 5-gm on-base, Cortes 5-gm on-base.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
+78
-10
@@ -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' });
|
||||
|
||||
Reference in New Issue
Block a user