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:
Kev
2026-06-19 02:42:13 -04:00
parent 78db55d499
commit 91b03c4044
10 changed files with 259 additions and 15 deletions
+77
View File
@@ -0,0 +1,77 @@
// Session 48 — name normalization applied at every layer (snapshot grades,
// game-card strips, scan grid).
const fs = require('fs');
const path = require('path');
const WEB = path.join(__dirname, '..', '..', 'web', 'src');
const snap = require('../../src/services/snapshotService');
const slate = require('../../web/src/lib/slateAdapter');
function memCache() {
const store = {};
return { store, cacheGet: async (k) => (k in store ? store[k] : null), cacheSet: async (k, v) => { store[k] = v; } };
}
describe('Phase 2a — snapshot normalizes + dedupes grades at the source', () => {
const baseGrades = [
{ player: 'Matthew Liberatore', stat_type: 'strikeouts', line: 5.5, direction: 'over', grade: 'A', confidence: 80 },
{ player: 'Matt Liberatore', stat_type: 'strikeouts', line: 3.5, direction: 'over', grade: 'B', confidence: 60 },
{ player: 'A.J. Ewing', stat_type: 'hits', line: 1.5, direction: 'over', grade: 'B+', confidence: 70 },
{ player: 'AJ Ewing', stat_type: 'hits', line: 1.5, direction: 'over', grade: 'C', confidence: 55 },
];
const deps = (cache) => ({
getOdds: async () => ({ sport: 'mlb', props: [{ player: 'x', stat_type: 'hits', line: 1.5 }], provider: 'test' }),
gradeAndCacheSlate: async (_s, _p, opts) => { await opts.cacheSet('grades:x', { grades: baseGrades }); return { written: true, count: baseGrades.length }; },
resolveStats: async () => ({ found: false }),
classify: require('../../src/services/archetypeService').classify,
cacheGet: cache.cacheGet, cacheSet: cache.cacheSet,
now: () => '2026-06-19T20:00:00Z', nowMs: () => 1,
});
it('writes ONE grade per normalized player+stat (highest confidence), names normalized', async () => {
const cache = memCache();
await snap.runSnapshot('mlb', deps(cache));
const grades = cache.store['snapshot:mlb:latest'].grades;
// Liberatore: one strikeouts grade (the A/conf80), Ewing: one hits grade (B+/conf70)
expect(grades).toHaveLength(2);
const lib = grades.find((g) => g.stat_type === 'strikeouts');
expect(lib.grade).toBe('A');
expect(lib.player).toBe('Matthew Liberatore'); // normalized display
const ew = grades.find((g) => g.stat_type === 'hits');
expect(ew.player).toBe('AJ Ewing'); // de-dotted
});
});
describe('Phase 2b — game-card strips show one row per stat', () => {
it('collapses variant prop dupes into one strip, one row per stat', () => {
const strips = slate.buildPlayerStripsFromProps([
{ player: 'Matt Liberatore', stat_type: 'strikeouts', line: 5.5 },
{ player: 'Matthew Liberatore', stat_type: 'strikeouts', line: 3.5 },
{ player: 'Matthew Liberatore', stat_type: 'hits_allowed', line: 4.5 },
], {}, {});
expect(strips).toHaveLength(1);
// strikeouts deduped to one row; hits_allowed separate
const ks = strips[0].props.filter((p) => p.stat === 'Ks');
expect(ks).toHaveLength(1);
expect(strips[0].props).toHaveLength(2);
});
it('prefers the graded prop over an awaiting one for the same stat', () => {
const gradeIndex = slate.indexGrades([{ player: 'AJ Ewing', stat_type: 'hits', line: 1.5, direction: 'over', grade: 'A', gradedAt: { line: 1.5, timestamp: '2026-06-19T18:00:00Z' } }]);
const strips = slate.buildPlayerStripsFromProps([
{ player: 'A.J. Ewing', stat_type: 'hits', line: 1.5 }, // matches grade
{ player: 'AJ Ewing', stat_type: 'hits', line: 1.5 }, // variant, ungraded dup
], gradeIndex, {});
expect(strips).toHaveLength(1);
expect(strips[0].props).toHaveLength(1);
expect(strips[0].props[0].grade).toBe('A'); // kept the graded one
});
});
describe('Phase 2c — scan player grid groups by nameKey', () => {
const src = fs.readFileSync(path.join(WEB, 'app', 'scan', 'page.tsx'), 'utf8');
it('groups tonightsPlayers by nameKey + displays normalized', () => {
expect(src).toContain('nameKey(p.player)');
expect(src).toContain('normalizeName(p.player).display');
});
});
+46
View File
@@ -0,0 +1,46 @@
// Session 48 — Phase 3: player profile VYNDR INTELLIGENCE shows real usage
// (AB/G) + rest, not "—"/"+0%".
const svc = require('../../src/services/playerIntelService');
const judgeAdapter = {
async getPlayerStats() {
return {
found: true, group: 'hitting', team: 'New York Yankees',
season: { homeRuns: 34, atBats: 330, gamesPlayed: 92, avg: '.288', ops: '1.012', rbi: 87 },
last10: [
{ date: '2026-06-14', stat: { totalBases: 2 } },
{ date: '2026-06-16', stat: { totalBases: 4 } }, // gap 2 → 1 day off
{ date: '2026-06-18', stat: { totalBases: 3 } },
],
};
},
};
describe('resolvePlayerStats attaches usage + rest for MLB', () => {
it('computes AB/G usage and rest from the game log', async () => {
const r = await svc.resolvePlayerStats('Aaron Judge', 'mlb', { mlbAdapter: judgeAdapter });
expect(r.classifierInput.usage).toMatch(/AB\/G$/);
expect(r.classifierInput.usage).toBe(`${Math.round((330 / 92) * 10) / 10} AB/G`);
expect(r.classifierInput.rest).toBe('1d rest');
});
});
describe('getPlayerIntel surfaces real usage + rest (no "—"/"+0%")', () => {
it('USAGE shows AB/G, REST shows a real value', async () => {
const r = await svc.getPlayerIntel('Aaron Judge', 'mlb', {
cacheGet: async () => null,
resolveStats: (name, sport) => svc.resolvePlayerStats(name, sport, { mlbAdapter: judgeAdapter }),
});
const usage = r.intel.find((m) => m.label === 'USAGE');
const rest = r.intel.find((m) => m.label === 'REST');
expect(usage.value).toMatch(/AB\/G$/);
expect(rest.value).toBe('1d rest');
expect(rest.value).not.toBe('+0%');
});
it('REST falls back to "—" not "+0%" when no data', async () => {
const r = await svc.getPlayerIntel('Nobody', 'nba', { cacheGet: async () => null, resolveStats: async () => ({ found: false }) });
expect(r.intel.find((m) => m.label === 'REST').value).toBe('—');
});
});