Files
vyndr/src/services/streaksService.js
T
builtbykev d3637e7abd S6 (a1): display — the full picture under the grammar
- specs/ROW-GRAMMAR.md: the row grammar law (slot order, color law, mark
  law, mobile stacking, no-truncation), locked by tests/unit/rowGrammar.
  StatStrip violations fixed: MovementChip before the grade (market
  context before model output); ViabilityChips after the archetype
  (identity is one contiguous run).
- Line-movement sparklines: intradayRefreshService.trackHistory captures
  real {t,line} points per grade (seeded with the lock, deduped when
  flat, capped 24) inside the snapshot write-back; StatStrip.LineSparkline
  renders at >=3 points (green toward / amber against / dim flat).
- Last-10 dot strips: services/last10Dots (streaksService accessors) ->
  /api/snapshot/:sport attaches last10_dots from rosterlogs:{sport};
  StatStrip.DotStrip renders vs the LOCKED line, newest first.
- CLV distribution: getModelAggregate emits clv_distribution (7 signed
  buckets, outliers clamped) only past the centralized n>=20 gate;
  ledger MODEL header renders the bar strip.
- Global search: SearchModal (cmd-K via GlobalHosts + window.__search),
  players via /api/players/search per sport + static lib/teams.js
  (soccer deliberately absent); Nav search icon + Search first in the
  mobile More sheet. Explore tab untouched.
- Landing LCP: fade-up floored at opacity .6 (hero h1 contentful on
  first frame, S33 visible-floor rule) + IBM Plex Mono preload:false
  (4 decorative font files off the slow-4G critical path).

2654 -> 2698 tests (226 suites) green; web build exit 0.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 20:08:24 -04:00

371 lines
17 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Streaks engine (Session 23).
*
* Computes player streaks from cached game-log data. Everything analyzed
* through VYNDR's lens — not "Wemby 31 PPG" but "Wemby on a 4-game 28+
* scoring streak." A streak is a CONSECUTIVE run of recent games meeting
* a threshold; we count from the most recent game backward and stop at
* the first miss.
*
* Pure & deterministic. `computePlayerStreaks` operates on one player's
* game array; `computeStreaks` fans out across a roster and returns a
* flat, sorted, optionally stat-filtered list. NO API calls live here —
* the route layer supplies cached logs.
*
* Game logs are expected MOST-RECENT-FIRST (index 0 = latest). Pass
* `{ chronological: true }` to reverse oldest-first input.
*/
// ---- defensive numeric field reader -------------------------------------
function num(row, ...keys) {
if (!row) return 0;
for (const k of keys) {
if (row[k] !== undefined && row[k] !== null && row[k] !== '') {
const n = Number(row[k]);
if (Number.isFinite(n)) return n;
}
}
return 0;
}
// NBA/WNBA stat accessors — tolerate the several field spellings the
// Python stats service and Tank01 use.
const nba = {
points: (r) => num(r, 'points', 'pts', 'PTS'),
rebounds: (r) => num(r, 'rebounds', 'reb', 'REB', 'totReb'),
assists: (r) => num(r, 'assists', 'ast', 'AST'),
threes: (r) => num(r, 'threes', 'threes_made', 'fg3m', 'tptfgm', 'threePointersMade'),
blocks: (r) => num(r, 'blocks', 'blk', 'BLK'),
steals: (r) => num(r, 'steals', 'stl', 'STL'),
fgPct: (r) => {
const pct = num(r, 'fg_pct', 'fgPct', 'fieldGoalPct');
if (pct > 0) return pct > 1 ? pct / 100 : pct; // accept 01 or 0100
const m = num(r, 'fgm', 'field_goals_made');
const a = num(r, 'fga', 'field_goals_attempted');
return a > 0 ? m / a : 0;
},
};
nba.pra = (r) => nba.points(r) + nba.rebounds(r) + nba.assists(r);
nba.doubleCount = (r) =>
[nba.points(r), nba.rebounds(r), nba.assists(r), nba.steals(r), nba.blocks(r)]
.filter((v) => v >= 10).length;
const mlb = {
hits: (r) => num(r, 'hits', 'H', 'h'),
homeRuns: (r) => num(r, 'homeRuns', 'home_runs', 'HR', 'hr'),
stolenBases: (r) => num(r, 'stolenBases', 'stolen_bases', 'SB', 'sb'),
rbi: (r) => num(r, 'rbi', 'RBI'),
walks: (r) => num(r, 'walks', 'baseOnBalls', 'BB', 'bb'),
hbp: (r) => num(r, 'hitByPitch', 'hbp', 'HBP'),
totalBases: (r) => num(r, 'totalBases', 'total_bases', 'TB'),
strikeouts: (r) => num(r, 'strikeOuts', 'strikeouts', 'pitcherK', 'K', 'so'),
inningsPitched: (r) => num(r, 'inningsPitched', 'ip', 'IP'),
earnedRuns: (r) => num(r, 'earnedRuns', 'er', 'ER'),
// S6 (A1 board) — last10Dots reads these too (same source of truth for
// every stat-field spelling; don't duplicate accessors elsewhere).
runs: (r) => num(r, 'runs', 'R'),
doubles: (r) => num(r, 'doubles', '2B'),
triples: (r) => num(r, 'triples', '3B'),
outs: (r) => num(r, 'outs'),
};
mlb.onBase = (r) => mlb.hits(r) + mlb.walks(r) + mlb.hbp(r);
mlb.isQualityStart = (r) => mlb.inningsPitched(r) >= 6 && mlb.earnedRuns(r) <= 3;
const nfl = {
passTd: (r) => num(r, 'passTD', 'passing_touchdowns', 'pass_td'),
rushTd: (r) => num(r, 'rushTD', 'rushing_touchdowns', 'rush_td'),
recTd: (r) => num(r, 'recTD', 'receiving_touchdowns', 'rec_td'),
rushYds:(r) => num(r, 'rushYds', 'rushing_yards', 'rush_yards'),
recYds: (r) => num(r, 'recYds', 'receiving_yards', 'rec_yards'),
ints: (r) => num(r, 'interceptions', 'int', 'passInt'),
};
nfl.anyTd = (r) => nfl.passTd(r) + nfl.rushTd(r) + nfl.recTd(r);
const soccer = {
goals: (r) => num(r, 'goals', 'G'),
assists: (r) => num(r, 'assists', 'A'),
shotsOnTarget: (r) => num(r, 'shotsOnTarget', 'shots_on_target', 'sot'),
goalsConceded: (r) => num(r, 'goalsConceded', 'goals_conceded', 'ga'),
minutes: (r) => num(r, 'minutes', 'min', 'MIN'),
};
// ---- streak specs -------------------------------------------------------
// Each spec: { key, category, threshold, label, value, mode }.
// value(row) → number; the game counts toward the streak when value >= threshold.
// mode 'consecutive' (default) counts the run from the latest game.
// mode 'rate' marks "hot" when the mean over the last `window` games >= threshold.
const SPECS = {
nba: [
{ key: 'points_25', category: 'points', collapse: 'points', threshold: 25, label: '25+ pts', value: nba.points },
{ key: 'points_20', category: 'points', collapse: 'points', threshold: 20, label: '20+ pts', value: nba.points },
{ key: 'assists_8', category: 'assists', collapse: 'assists', threshold: 8, label: '8+ ast', value: nba.assists },
{ key: 'assists_6', category: 'assists', collapse: 'assists', threshold: 6, label: '6+ ast', value: nba.assists },
{ key: 'rebounds_10',category: 'rebounds', collapse: 'rebounds', threshold: 10, label: '10+ reb', value: nba.rebounds },
{ key: 'rebounds_8', category: 'rebounds', collapse: 'rebounds', threshold: 8, label: '8+ reb', value: nba.rebounds },
{ key: 'threes_4', category: 'threes', collapse: 'threes', threshold: 4, label: '4+ threes', value: nba.threes },
{ key: 'threes_3', category: 'threes', collapse: 'threes', threshold: 3, label: '3+ threes', value: nba.threes },
{ key: 'blocks_2', category: 'blocks', threshold: 2, label: '2+ blk', value: nba.blocks },
{ key: 'steals_2', category: 'steals', threshold: 2, label: '2+ stl', value: nba.steals },
{ key: 'pra_40', category: 'pra', threshold: 40, label: '40+ PRA', value: nba.pra },
{ key: 'double_double', category: 'all', threshold: 2, label: 'double-double', value: nba.doubleCount, noun: 'double-double' },
{ key: 'triple_double', category: 'all', threshold: 3, label: 'triple-double', value: nba.doubleCount, noun: 'triple-double' },
{ key: 'hot_shooter', category: 'points', threshold: 0.5, label: 'hot shooter (FG% > 50%)', value: nba.fgPct, mode: 'rate', window: 5 },
],
// WNBA shares NBA's stat layout (no PRA/triple-double headline emphasis,
// but the specs are harmless if a player never hits them).
wnba: null, // filled below = nba minus the rate spec quirks
mlb: [
{ key: 'hit_streak', category: 'hits', collapse: 'hits', threshold: 1, label: 'hit', value: mlb.hits },
{ key: 'multi_hit', category: 'hits', collapse: 'hits', threshold: 2, label: 'multi-hit', value: mlb.hits },
{ key: 'hr_streak', category: 'home_runs', threshold: 1, label: 'HR', value: mlb.homeRuns },
{ key: 'sb_streak', category: 'stolen_bases', threshold: 1, label: 'SB', value: mlb.stolenBases },
{ key: 'rbi_streak', category: 'rbis', threshold: 1, label: 'RBI', value: mlb.rbi },
{ key: 'onbase_streak',category: 'on_base', threshold: 1, label: 'on-base', value: mlb.onBase },
{ key: 'tb_streak', category: 'total_bases', threshold: 2, label: '2+ total bases', value: mlb.totalBases },
{ key: 'k_streak', category: 'strikeouts', threshold: 7, label: '7+ K', value: mlb.strikeouts },
{ key: 'qs_streak', category: 'strikeouts', threshold: 1, label: 'quality start', value: (r) => (mlb.isQualityStart(r) ? 1 : 0) },
],
nfl: [
{ key: 'td_streak', category: 'touchdowns', threshold: 1, label: 'TD', value: nfl.anyTd },
{ key: 'multi_td', category: 'touchdowns', threshold: 2, label: 'multi-TD', value: nfl.anyTd },
{ key: 'rush_100', category: 'rushing_yards', threshold: 100, label: '100-yd rushing', value: nfl.rushYds },
{ key: 'rec_100', category: 'receiving_yards', threshold: 100, label: '100-yd receiving', value: nfl.recYds },
{ key: 'clean_qb', category: 'interceptions', threshold: 1, label: 'INT-free', value: (r) => (nfl.ints(r) === 0 ? 1 : 0) },
],
soccer: [
{ key: 'goal_streak', category: 'goals', threshold: 1, label: 'goal', value: soccer.goals },
{ key: 'assist_streak', category: 'assists', threshold: 1, label: 'assist', value: soccer.assists },
{ key: 'sot_streak', category: 'shots', threshold: 1, label: 'shot-on-target', value: soccer.shotsOnTarget },
{ key: 'clean_sheet', category: 'saves', threshold: 1, label: 'clean sheet',
value: (r) => (soccer.minutes(r) > 0 && soccer.goalsConceded(r) === 0 ? 1 : 0) },
],
};
SPECS.wnba = SPECS.nba;
function specsFor(sport) {
return SPECS[String(sport || '').toLowerCase()] || [];
}
// ---- core streak math ---------------------------------------------------
function consecutiveRun(games, valueFn, threshold) {
let run = 0;
for (const g of games) {
if (valueFn(g) >= threshold) run += 1;
else break;
}
return run;
}
function rateOverWindow(games, valueFn, window) {
const slice = games.slice(0, window);
if (slice.length < window) return { value: 0, count: slice.length };
const sum = slice.reduce((acc, g) => acc + valueFn(g), 0);
return { value: sum / slice.length, count: slice.length };
}
/**
* Minimum run length to surface a streak. A "1-game streak" is just a
* stat line, not a streak — require at least 2 to count as VYNDR signal,
* except double/triple-double which are notable at any length >= 2.
*/
const MIN_STREAK = 2;
function describe(spec, run) {
if (spec.noun) return `${run}-game ${spec.noun} streak`;
if (spec.mode === 'rate') return spec.label;
return `${run}-game ${spec.label} streak`;
}
/**
* All streaks for ONE player. Returns the strongest streak per stat
* CATEGORY (so a player with a 20+ and a 25+ points streak surfaces only
* the more impressive one) — keeps the feed signal-dense.
*/
function computePlayerStreaks(player, sport, opts = {}) {
const specs = specsFor(sport);
let games = Array.isArray(player?.games) ? player.games.slice() : [];
if (opts.chronological) games.reverse();
if (games.length === 0) return [];
const found = [];
for (const spec of specs) {
if (spec.mode === 'rate') {
const { value, count } = rateOverWindow(games, spec.value, spec.window);
if (count >= spec.window && value >= spec.threshold) {
found.push(makeStreak(player, sport, spec, spec.window, value));
}
continue;
}
const run = consecutiveRun(games, spec.value, spec.threshold);
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
// collapse group — prefer the MORE IMPRESSIVE streak (higher threshold),
// tie-broken by the longer run. Non-tiered specs each have a unique
// collapse key, so they pass through untouched.
const best = new Map();
for (const s of found) {
const cur = best.get(s._collapse);
if (!cur ||
s.threshold > cur.threshold ||
(s.threshold === cur.threshold && s.currentStreak > cur.currentStreak)) {
best.set(s._collapse, s);
}
}
return Array.from(best.values()).map(({ _collapse, ...rest }) => rest);
}
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,
playerId: player.playerId ?? player.id ?? null,
team: player.team || null,
type: spec.key,
category: spec.category,
threshold: spec.threshold,
currentStreak: run,
rate: rateValue ?? null,
description: describe(spec, run),
opponents,
active: true,
_collapse: spec.collapse || spec.key, // internal — stripped before return
};
}
/**
* Fan out across a roster. `players` = [{ name, playerId, team, games }].
* Returns a flat list sorted by streak length desc, optionally narrowed
* to a single stat category and capped at `limit`.
*/
function computeStreaks(players, sport, opts = {}) {
if (!Array.isArray(players)) return [];
const stat = opts.stat && opts.stat !== 'all' ? String(opts.stat).toLowerCase() : null;
let all = [];
for (const p of players) {
all = all.concat(computePlayerStreaks(p, sport, opts));
}
if (stat) all = all.filter((s) => s.category === stat);
all.sort((a, b) => b.currentStreak - a.currentStreak);
if (opts.limit && opts.limit > 0) all = all.slice(0, opts.limit);
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, windowSplit, ratio, nba, mlb, nfl, soccer, MIN_STREAK, HEAT_SPECS },
};