Session 48: Name normalization at every layer + usage field (2156 tests)
Trace-first: the normalizer functions were correct (S47) but raw names still
flowed through paths that skipped them. Fixed each leaking path.
- 2a (source chokepoint): snapshotService.runSnapshot normalizes each grade's
player to the de-dotted display AND dedupes to one grade per nameKey|stat
(highest confidence) before writing grades:{sport} + snapshot:latest. Every
consumer (GameCard, Explore, leaders, profile) now gets clean merged names.
- 2b: buildPlayerStripsFromProps dedupes a player's props by stat (graded >
awaiting) → one row per stat (kills "Ks 5.5 AND Ks 3.5" variant dupes).
- 2c: scan tonightsPlayers grid groups by nameKey, displays normalized name.
- 3: profile VYNDR INTELLIGENCE "+0%"/"—" was buildIntel's defaults (separate
from the grade card's buildIntelFields, which already works). resolvePlayerStats
now attaches real usage (AB/G) + rest (B2B/Xd); buildIntel renders them; REST
default is now "—".
Backend 2149 -> 2156 tests (+7), 181 suites. Web build clean (exit 0).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -119,6 +119,10 @@ async function resolvePlayerStats(name, sport, opts = {}) {
|
||||
const res = await mlb.getPlayerStats(name);
|
||||
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 || '',
|
||||
@@ -164,18 +168,43 @@ async function resolvePlayerStats(name, sport, opts = {}) {
|
||||
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. */
|
||||
function buildIntel(stats, arch, propCount) {
|
||||
const clamp = (n, lo, hi) => Math.max(lo, Math.min(hi, n));
|
||||
// Form: lean on last-10 vs season if provided, else a neutral baseline that
|
||||
// scales gently with the strongest graded prop's confidence proxy.
|
||||
const form = stats.form != null ? clamp(Math.round(stats.form), 0, 100) : 70 + clamp(propCount * 4, 0, 22);
|
||||
const usage = stats.usg != null ? `${stats.usg}%` : stats.k9 != null ? `${stats.k9} K/9` : '—';
|
||||
// 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` : '—');
|
||||
return [
|
||||
{ label: 'FORM', kind: 'form', value: String(form), score: `${clamp(form, 0, 100)}%`, color: form >= 85 ? '#00ffb8' : form >= 70 ? '#00D4A0' : '#FFB347' },
|
||||
{ label: 'USAGE', kind: 'plain', value: usage, color: '#e8e8f0' },
|
||||
{ label: 'MATCHUP', kind: 'grade', value: arch.primary ? gradeFromForm(form) : 'B', color: '#00D4A0' },
|
||||
{ label: 'REST', kind: 'plain', value: stats.rest || '+0%', color: '#00D4A0' },
|
||||
{ label: 'REST', kind: 'plain', value: stats.rest || '—', color: '#00D4A0' },
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ const DELTA_NOISE = 0.5; // ignore movements smaller than this
|
||||
const DELTA_MOVE = 1.0; // ticker MOVE threshold
|
||||
const STATS_CONCURRENCY = 5;
|
||||
|
||||
const { nameKey } = require('../utils/playerName');
|
||||
const { nameKey, normalizeName } = require('../utils/playerName');
|
||||
// Session 46 — group/dedupe by the normalized name key so "A.J. Ewing" and
|
||||
// "AJ Ewing" (or "Jazz Chisholm" / "Jazz Chisholm Jr.") collapse to one player.
|
||||
const norm = (s) => nameKey(s);
|
||||
@@ -200,8 +200,23 @@ async function runSnapshot(sport, opts = {}) {
|
||||
now: deps.now,
|
||||
cacheSet: async (_k, v) => { envelope = v; },
|
||||
});
|
||||
const graded = (envelope && Array.isArray(envelope.grades)) ? envelope.grades : [];
|
||||
if (graded.length === 0) return { sport: sp, status: 'skipped', reason: 'no grades', gradeCount: 0 };
|
||||
const rawGraded = (envelope && Array.isArray(envelope.grades)) ? envelope.grades : [];
|
||||
if (rawGraded.length === 0) return { sport: sp, status: 'skipped', reason: 'no grades', gradeCount: 0 };
|
||||
|
||||
// Session 48 — normalize player display names + dedupe variant grades at the
|
||||
// SOURCE so every consumer (GameCard, Explore, leaders, profile) gets clean,
|
||||
// merged names. PropLine sends "Matt"/"Matthew", "A.J."/"AJ", "(STL)" tags as
|
||||
// separate players; collapse to ONE grade per normalized player + stat (keep
|
||||
// the highest-confidence; rawGraded is already confidence-desc).
|
||||
const dedup = new Map();
|
||||
for (const g of rawGraded) {
|
||||
const disp = normalizeName(g.player || g.player_name).display || g.player || g.player_name || '';
|
||||
const k = `${nameKey(disp)}|${String(g.stat_type || g.stat || '').toLowerCase()}`;
|
||||
const cur = { ...g, player: disp, player_name: disp };
|
||||
const prev = dedup.get(k);
|
||||
if (!prev || (Number(g.confidence) || 0) > (Number(prev.confidence) || 0)) dedup.set(k, cur);
|
||||
}
|
||||
const graded = [...dedup.values()];
|
||||
|
||||
// Archetype per unique player (pure math once we have stats). Best-effort —
|
||||
// a missing stat line → no badge (not a fallback archetype).
|
||||
|
||||
Reference in New Issue
Block a user