Files
vyndr/src/services/playerIntelService.js
T
builtbykev f5156dd16d Un-claim CLV on the public profile; un-fabricate player-page FORM
Two Truth-Law fixes found by auditing the product logged-out.

FIX 1 — /u/[handle] claimed a "CLV-verified record" with "closing-line
value included" while ZERO closing-line value renders there. Verified
live: GET /api/profiles/vyndr returns beat_close_pct null (gated behind
CLV_CAPTURE_RELIABLE, unset while C4 is open). Eight instances found —
two of them (the OG + portrait "CLV-VERIFIED RECORD · 30D" eyebrows)
only by the post-removal residual sweep; two more printed the claim in
exactly the no-record branch.

Copy now describes what the page shows. The gated CLV-VERIFIED badge and
the BEAT CLOSE figure are removed from the public profile, OG card and
portrait card. DISPLAY ONLY: beat_close_pct, clvCaptureReliable() and
the whole CLV data path are untouched, and the earned directional badge
stays Analyst+Desk. The claim returns when CLV genuinely renders here.

Also fixes the doubled "· VYNDR · VYNDR" title (layout's '%s · VYNDR'
template already supplies the suffix); verified on composed output by
serving the build and reading the real HTML, not on source.

FIX 2 — the player page's FORM was `70 + 4 × (count of tonight's graded
props)`. Nothing on the HTTP path ever sets stats.form, so that fallback
WAS the live number: Josh Bell's "74" is 70 + 4×1 prop, confirmed
against his live payload. MATCHUP was gradeFromForm(that number), with a
hardcoded 'B' on the no-archetype branch — both fabricated letters with
no opponent input on the path. Systemic: buildIntel is the unconditional
path for every player and sport.

FORM and MATCHUP now render "—" (kind 'plain', so no bar width or colour
is computed off a null). gradeFromForm is deleted and the prop count is
no longer passed into buildIntel. computeFormScore's hardcoded 75 now
returns undefined. Induced across MLB/NBA/WNBA: all render cleanly, and
real values (USAGE 3.6 AB/G, REST B2B) still render.

Neither form value feeds the grade — engine1 reads raw l5_avg/l20_avg
against the line and never a form key; buildIntelFields decorates the
already-graded object. Grade inputs are byte-identical.

Held (needs a per-sport headline-stat design call): a real player-level
form metric + label disambiguation.

Tests 3491 passed / 289 suites, web build exit 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VCNgGSt5qvcLxaeQqa7Zpj
2026-07-20 17:05:04 -04:00

355 lines
15 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.
/**
* playerIntelService — aggregates a player's intelligence payload for the
* /player/:name profile page and the stats API (Session 42).
*
* Sources, all best-effort + graceful (the page must render even when every
* upstream is cold):
* - archetypeService.classify → PRIMARY/SECONDARY archetype + DNA blend
* - grades:{sport} cache → this player's graded props for tonight
* - caller-supplied season/last10/splits/gradeHistory (wired by the route
* from the stats adapters; empty when unavailable)
*
* Pure-ish: I/O is only the grades-cache read, and that's injectable for tests.
*/
const { classify } = require('./archetypeService');
const { normalizeName, nameKey } = require('../utils/playerName');
const toNum = (v) => {
const n = parseFloat(v);
return Number.isNaN(n) ? 0 : n;
};
const fmt3 = (v) => {
const s = String(v == null ? '' : v);
return s.startsWith('0.') ? s.slice(1) : s; // ".282" baseball style
};
/**
* Sanitize a player-name URL param (Montgomery's note). Decode, strip anything
* that isn't a letter/number/space or name punctuation (. - '), collapse
* whitespace, cap length. Defends the cache key + any downstream lookups.
*/
function sanitizePlayerName(raw) {
let decoded = String(raw == null ? '' : raw);
try { decoded = decodeURIComponent(decoded); } catch { /* malformed % — use raw */ }
const cleaned = decoded
.replace(/[^\p{L}\p{N}\s.'-]/gu, '')
.replace(/\s+/g, ' ')
.trim()
.slice(0, 60);
// Session 46 — normalize periods/suffix for display ("A.J. Ewing" → "AJ Ewing").
return normalizeName(cleaned).display;
}
// Session 46 — match by the normalized name key so name variants resolve to the
// same player (snapshot grades, profile lookups).
const normName = (n) => nameKey(n);
async function loadPlayerGrades(sport, name, cacheGetFn) {
const env = await cacheGetFn(`grades:${sport}`);
const grades = env && Array.isArray(env.grades) ? env.grades : [];
const target = normName(name);
if (!target) return [];
return grades.filter((g) => normName(g.player_name || g.player) === target);
}
// ── MLB raw-stat normalization (Session 43) ─────────────────────────
// Maps the raw statsapi.mlb.com season object into (a) classifier input and
// (b) the profile's display rows + last-10 log.
function mapMlbHitter(s) {
const pa = toNum(s.plateAppearances) || toNum(s.atBats);
return {
avg: toNum(s.avg), hr: toNum(s.homeRuns), rbi: toNum(s.rbi), sb: toNum(s.stolenBases),
ops: toNum(s.ops), runs: toNum(s.runs), doubles: toNum(s.doubles),
k_rate: pa > 0 ? (toNum(s.strikeOuts) / pa) * 100 : 0,
};
}
function mapMlbPitcher(s) {
const ip = toNum(s.inningsPitched);
const gs = toNum(s.gamesStarted);
return {
era: toNum(s.era), whip: toNum(s.whip),
k9: toNum(s.strikeoutsPer9Inn) || (ip > 0 ? (toNum(s.strikeOuts) / ip) * 9 : 0),
ip_per_start: gs > 0 ? ip / gs : 0,
saves: toNum(s.saves),
role: gs > 0 ? 'SP' : toNum(s.saves) > 0 ? 'CL' : 'RP',
};
}
function mlbSeasonRows(s, group) {
if (group === 'pitching') {
return [
{ k: 'ERA', v: String(s.era ?? '—') },
{ k: 'K', v: String(s.strikeOuts ?? '—') },
{ k: 'IP', v: String(s.inningsPitched ?? '—') },
{ k: 'WHIP', v: String(s.whip ?? '—') },
{ k: 'GS', v: String(s.gamesStarted ?? '—') },
];
}
return [
{ k: 'AVG', v: fmt3(s.avg) || '—' },
{ k: 'HR', v: String(s.homeRuns ?? '—') },
{ k: 'RBI', v: String(s.rbi ?? '—') },
{ k: 'OPS', v: fmt3(s.ops) || '—' },
{ k: 'GP', v: String(s.gamesPlayed ?? '—') },
];
}
function mlbLast10Rows(log, group) {
return (log || []).slice(-10).reverse().map((g) => {
const st = g.stat || {};
const summary = group === 'pitching'
? `${st.inningsPitched ?? '0'} IP · ${st.strikeOuts ?? 0} K`
: `${st.hits ?? 0}-${st.atBats ?? 0} · ${st.homeRuns ?? 0} HR`;
return { d: String(g.date || '').slice(5), opp: g.opponent ? String(g.opponent).slice(0, 3).toUpperCase() : '', stat: summary };
});
}
/**
* Resolve a player's REAL stats by sport (Session 43). Returns a normalized
* bundle or { found:false }. Best-effort + never throws — the profile renders
* regardless. Adapters are injectable for tests (opts.mlbAdapter/nbaClient).
*/
async function resolvePlayerStats(name, sport, opts = {}) {
const sp = String(sport || 'nba').toLowerCase();
try {
if (sp === 'mlb') {
const mlb = opts.mlbAdapter || require('./adapters/mlbStatsAdapter');
// Wave 1 — thread the prop's game team hint so a namesake collision
// (two "James Wood") resolves to the RIGHT player, and a team that isn't
// a participant of the prop's game is dropped (never a foreign tag).
const res = await mlb.getPlayerStats(name, undefined, { teamHint: opts.teamHint });
if (!res || !res.found) return { found: false };
const classifierInput = res.group === 'pitching' ? mapMlbPitcher(res.season) : mapMlbHitter(res.season);
// Session 48 — real VYNDR INTELLIGENCE for the player profile: usage (AB/G)
// + rest (days off between the two most recent games; 0 = B2B). The
// classify() scorer ignores these extra keys; buildIntel reads them.
Object.assign(classifierInput, mlbProfileIntel(res));
return {
found: true,
team: res.team || '',
classifierInput,
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') {
// PRIMARY: the Python nba_api service (nbaStatsClient) — often offline in
// prod. FALLBACK: ESPN's free public stats (espnStatsAdapter). Either way,
// a miss degrades quietly to found:false (no badge, never a crash).
const nba = opts.nbaClient || require('./nbaStatsClient');
let data = await nba.getSeasonAvg(name).catch(() => null);
if (!data || typeof data !== 'object' || !toNum(data.ppg ?? data.points)) {
const espn = opts.espnStats || require('./adapters/espnStatsAdapter');
const e = await espn.getSeasonAverages(name, sp).catch(() => ({ found: false }));
if (e && e.found && e.classifierInput) {
const ci = e.classifierInput;
const season = [
{ k: 'PPG', v: String(ci.ppg ?? '—') }, { k: 'RPG', v: String(ci.rpg ?? '—') },
{ k: 'APG', v: String(ci.apg ?? '—') }, { k: 'BLK', v: String(ci.bpg ?? '—') },
];
// Session 60 (5.3) — WNBA/NBA parity: minutes-based usage on the
// profile (the basketball equivalent of AB/G). Only when the feed
// carries minutes — absent beats invented.
const mpg = Number(ci.mpg ?? ci.min ?? ci.minutes);
const extra = Number.isFinite(mpg) && mpg > 0 ? { usage: `${Math.round(mpg)} min` } : {};
if (extra.usage) season.push({ k: 'MIN', v: String(Math.round(mpg)) });
// Wave 2A — the REAL ESPN athlete id (headshot CDN) surfaces from the
// adapter. Absent → no id → monogram. Never guessed.
return { found: true, team: e.team || '', classifierInput: { ...ci, pos: e.position, ...extra }, season, last10: [], splits: [], espnId: e.espnId ?? null };
}
return { found: false };
}
const classifierInput = {
ppg: toNum(data.ppg ?? data.points), rpg: toNum(data.rpg ?? data.rebounds), apg: toNum(data.apg ?? data.assists),
bpg: toNum(data.bpg ?? data.blocks), spg: toNum(data.spg ?? data.steals),
threes: toNum(data.threes ?? data.fg3m), usg: toNum(data.usg ?? data.usage), pos: data.pos || data.position,
};
const season = [
{ k: 'PPG', v: String(classifierInput.ppg) }, { k: 'RPG', v: String(classifierInput.rpg) },
{ k: 'APG', v: String(classifierInput.apg) }, { k: 'BLK', v: String(classifierInput.bpg) },
];
return { found: true, team: data.team || '', classifierInput, season, last10: [], splits: [] };
}
} catch (err) {
console.warn('[playerIntel] resolvePlayerStats failed:', name, sp, err.message);
}
return { found: false };
}
/**
* Profile VYNDR INTELLIGENCE bits from real MLB stats (Session 48):
* usage = AB/G (the MLB usage equivalent), rest = days off between the two most
* recent games ("B2B"/"Xd rest"). Returns {} when unavailable.
*/
function mlbProfileIntel(res) {
const out = {};
const ab = parseFloat(res.season && res.season.atBats);
const gp = parseFloat(res.season && (res.season.gamesPlayed ?? res.season.gamesStarted));
if (Number.isFinite(ab) && Number.isFinite(gp) && gp > 0) {
out.usage = `${Math.round((ab / gp) * 10) / 10} AB/G`;
}
const dated = Array.isArray(res.last10) ? res.last10.filter((g) => g && g.date) : [];
if (dated.length >= 2) {
const gap = Math.round((new Date(dated[dated.length - 1].date) - new Date(dated[dated.length - 2].date)) / 86_400_000);
if (Number.isFinite(gap) && gap >= 1 && gap <= 14) {
const off = gap - 1;
out.rest = off === 0 ? 'B2B' : `${off}d rest`;
}
}
return out;
}
/**
* Derive the VYNDR Intelligence metric row from whatever we have.
*
* Session 65 — UN-FABRICATE (Truth Law: if it renders a number, it comes from
* real data or it does not render).
* - FORM used to fall back to `70 + 4 × (count of tonight's graded props)`,
* capped at 92. Nothing on the HTTP path ever sets `stats.form`, so that
* fallback WAS the live value: Josh Bell's "74" was 70 + 4×1 graded prop —
* a number carrying zero information about the player. Absent now renders
* absent.
* - MATCHUP used to be `gradeFromForm(form)` (i.e. the same prop count) with a
* hardcoded 'B' when no archetype resolved. There is no opponent input on
* this path at all, so BOTH branches were fabricated letters. Absent now
* renders absent.
* A real form metric is a separate, held design call (per-sport headline stat);
* it lands as `stats.form` / `stats.matchup` and lights these tiles back up.
* Tiles emit kind 'plain' when absent so the page prints "—" and draws no
* progress bar — no math, colour or bar width is computed off a null.
*/
function buildIntel(stats, arch) {
const clamp = (n, lo, hi) => Math.max(lo, Math.min(hi, n));
const form = stats.form != null && Number.isFinite(Number(stats.form))
? clamp(Math.round(Number(stats.form)), 0, 100)
: null;
// Session 48 — prefer a preformatted usage string (e.g. "3.6 AB/G"); rest
// shows the real value or "—" (no more bogus "+0%").
const usage = stats.usage || (stats.usg != null ? `${stats.usg}%` : stats.k9 != null ? `${stats.k9} K/9` : '—');
const matchup = typeof stats.matchup === 'string' && stats.matchup ? stats.matchup : null;
return [
form != null
? { label: 'FORM', kind: 'form', value: String(form), score: `${form}%`, color: form >= 85 ? '#00ffb8' : form >= 70 ? '#00D4A0' : '#FFB347' }
: { label: 'FORM', kind: 'plain', value: '—', color: '#7A7A8E' },
{ label: 'USAGE', kind: 'plain', value: usage, color: '#e8e8f0' },
matchup
? { label: 'MATCHUP', kind: 'grade', value: matchup, color: '#00D4A0' }
: { label: 'MATCHUP', kind: 'plain', value: '—', color: '#7A7A8E' },
{ label: 'REST', kind: 'plain', value: stats.rest || '—', color: stats.rest ? '#00D4A0' : '#7A7A8E' },
];
}
/**
* Build the full player intelligence payload.
* opts: { cacheGet, stats, season, last10, splits, gradeHistory, injury, team }
*/
async function getPlayerIntel(name, sport, opts = {}) {
const cacheGetFn = opts.cacheGet || require('../utils/redis').cacheGet;
const clean = sanitizePlayerName(name);
const sp = String(sport || 'nba').toLowerCase();
// 1. Real season stats from the sport adapter (Session 43). Injectable for
// tests via opts.resolveStats; opts.stats short-circuits to caller-supplied.
let resolved;
if (opts.stats) {
resolved = { found: Object.keys(opts.stats).length > 0, classifierInput: opts.stats, season: opts.season || [], last10: opts.last10 || [], splits: opts.splits || [], team: opts.team || '' };
} else {
const resolver = opts.resolveStats || resolvePlayerStats;
resolved = await resolver(clean, sp, opts);
}
const realStats = resolved.classifierInput || {};
// 2. Archetype, now classified from REAL stats when we have them.
const archetype = classify(sp, realStats);
// 3. Tonight's graded props from the slate cache.
let props = [];
try {
props = await loadPlayerGrades(sp, clean, cacheGetFn);
} catch {
props = []; // cold/broken cache must not 500 the page
}
const activeProps = props.map((p) => ({
stat: p.stat_type || p.stat,
line: p.line,
side: String(p.direction || 'over').toLowerCase() === 'under' ? 'U' : 'O',
grade: p.grade,
confidence: p.confidence != null ? `${p.confidence}%` : null,
}));
const team = resolved.team || (props[0] && (props[0].team || props[0].team_abbr)) || opts.team || '';
return {
player: clean,
sport: sp,
team,
found: !!resolved.found || props.length > 0,
archetype,
propDNA: archetype.primary ? archetype.primary.propDNA : { reliable: [], volatile: [] },
education: archetype.primary ? archetype.primary.education : '',
season: resolved.season || opts.season || [],
last10: resolved.last10 || opts.last10 || [],
splits: resolved.splits || opts.splits || [],
gradeHistory: opts.gradeHistory || [],
activeProps,
// Session 65 — prop count is deliberately NOT passed any more: it used to
// manufacture the FORM number. Intel is a function of real stats only.
intel: buildIntel(realStats, archetype),
injury: opts.injury || null,
};
}
/**
* Tonight's leaders for a sport — top graded props by confidence, optionally
* filtered to a single stat. Reads the same grades:{sport} cache.
*/
async function getLeaders(sport, opts = {}) {
const cacheGetFn = opts.cacheGet || require('../utils/redis').cacheGet;
const sp = String(sport || 'nba').toLowerCase();
const stat = opts.stat ? String(opts.stat).toLowerCase() : null;
const limit = Math.max(1, Math.min(50, Number(opts.limit) || 10));
let grades = [];
try {
const env = await cacheGetFn(`grades:${sp}`);
grades = env && Array.isArray(env.grades) ? env.grades : [];
} catch {
grades = [];
}
return grades
.filter((g) => !stat || String(g.stat_type || g.stat || '').toLowerCase() === stat)
.sort((a, b) => (Number(b.confidence) || 0) - (Number(a.confidence) || 0))
.slice(0, limit)
.map((g) => ({
player: g.player_name || g.player,
team: g.team || g.team_abbr || '',
stat: g.stat_type || g.stat,
line: g.line,
side: String(g.direction || 'over').toLowerCase() === 'under' ? 'U' : 'O',
grade: g.grade,
confidence: g.confidence,
}));
}
module.exports = {
sanitizePlayerName,
getPlayerIntel,
getLeaders,
resolvePlayerStats,
_internals: { normName, loadPlayerGrades, buildIntel, mapMlbHitter, mapMlbPitcher, mlbSeasonRows, mlbLast10Rows },
};