Session 46: Grade card intel + name normalization + pitchers (2122 tests)
Three focused P1 fixes on the Session-45 snapshot model.
- Grade card intel ROOT CAUSE: gameLogService is NBA/WNBA-only (offline Python),
so MLB props never got l5_avg/l20_avg and buildIntelFields returned {}. Wired
MLB game logs into featureCache.gameLogFeatures via mlbStatsAdapter.getPlayerStats
(pure mlbGameLogFeatures + MLB stat_type->field map). buildIntelFields gained
playerStats/projection fallbacks for partial intel.
- Player name normalization: src/utils/playerName.js (+ web/src/lib copy):
normalizeName -> {display,key}. Strips periods, de-dots suffix, accent-folds
the key. Applied in snapshotService grouping, slateAdapter grade index +
player-strip merge (variants collapse, longest name shown), and
playerIntelService. "A.J. Ewing"/"AJ Ewing" + "Jazz Chisholm"/"Jr." now merge.
- MLB starting pitchers: new GET /api/schedule/:sport/pitchers (probablePitchers
service wrapping mlbStatsAdapter.getScheduleWithPitchers + best-effort ERA).
Slate fetches it, builds a team->pitcher map (full name + mascot match),
attaches pitchers to MLB GameCardData. + Next proxy.
Backend 2100 -> 2122 tests (+22), 176 suites. Web build clean (exit 0).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,71 @@
|
||||
// Session 46 — Phase 1: MLB game-log features (root cause of the empty grade
|
||||
// card intel) + buildIntelFields resilience.
|
||||
|
||||
const { __internals: fc } = require('../../src/services/intelligence/featureCache');
|
||||
const { __internals: eng } = require('../../src/services/intelligence/analyzeViaEngine1');
|
||||
|
||||
const judgeStats = {
|
||||
found: true, group: 'hitting',
|
||||
season: { totalBases: 180, homeRuns: 34, hits: 95, rbi: 87, gamesPlayed: 92 },
|
||||
last10: [
|
||||
{ stat: { totalBases: 2, homeRuns: 0 } }, { stat: { totalBases: 4, homeRuns: 1 } },
|
||||
{ stat: { totalBases: 1, homeRuns: 0 } }, { stat: { totalBases: 3, homeRuns: 1 } },
|
||||
{ stat: { totalBases: 0, homeRuns: 0 } }, { stat: { totalBases: 5, homeRuns: 1 } },
|
||||
{ stat: { totalBases: 2, homeRuns: 0 } }, { stat: { totalBases: 3, homeRuns: 1 } },
|
||||
{ stat: { totalBases: 1, homeRuns: 0 } }, { stat: { totalBases: 4, homeRuns: 1 } },
|
||||
],
|
||||
};
|
||||
|
||||
describe('mlbGameLogFeatures (root-cause fix)', () => {
|
||||
it('derives l5/l10/l20 averages for an MLB stat from the real game log', () => {
|
||||
const f = fc.mlbGameLogFeatures(judgeStats, 'total_bases');
|
||||
expect(f.l5_avg).toBeGreaterThan(0);
|
||||
expect(f.l10_avg).toBeGreaterThan(0);
|
||||
// season per-game = 180 / 92 ≈ 1.96
|
||||
expect(f.l20_avg).toBeCloseTo(180 / 92, 2);
|
||||
});
|
||||
|
||||
it('returns {} for an unfound player or unmapped stat (graceful)', () => {
|
||||
expect(fc.mlbGameLogFeatures({ found: false }, 'hits')).toEqual({});
|
||||
expect(fc.mlbGameLogFeatures(judgeStats, 'not_a_stat')).toEqual({});
|
||||
});
|
||||
|
||||
it('mlbStatValue maps stat_type → MLB game-log field', () => {
|
||||
expect(fc.mlbStatValue({ homeRuns: 2 }, 'home_runs')).toBe(2);
|
||||
expect(fc.mlbStatValue({ totalBases: 3 }, 'total_bases')).toBe(3);
|
||||
expect(fc.mlbStatValue({}, 'home_runs')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildIntelFields — feature-populated + resilient', () => {
|
||||
it('produces season + last10 + form from MLB-derived features', () => {
|
||||
const f = fc.mlbGameLogFeatures(judgeStats, 'total_bases');
|
||||
const intel = eng.buildIntelFields(f);
|
||||
expect(intel.season_avg).toBeDefined();
|
||||
expect(intel.last10_avg).toBeDefined();
|
||||
expect(intel.form).toBeDefined();
|
||||
});
|
||||
|
||||
it('falls back to playerStats when the feature vector is empty', () => {
|
||||
const intel = eng.buildIntelFields({}, { playerStats: { season_avg: 26.9, last10_avg: 28.4, form: 92, usage: '31%' } });
|
||||
expect(intel.season_avg).toBe(26.9);
|
||||
expect(intel.last10_avg).toBe(28.4);
|
||||
expect(intel.form).toBe(92);
|
||||
expect(intel.usage).toBe('31%');
|
||||
});
|
||||
|
||||
it('falls back to the model projection for season when nothing else exists', () => {
|
||||
const intel = eng.buildIntelFields({}, { projection: 1.9 });
|
||||
expect(intel.season_avg).toBe(1.9);
|
||||
});
|
||||
|
||||
it('still returns {} when there is genuinely nothing (backward compatible)', () => {
|
||||
expect(eng.buildIntelFields({})).toEqual({});
|
||||
});
|
||||
|
||||
it('produces partial output (matchup only) when only opp rank exists', () => {
|
||||
const intel = eng.buildIntelFields({ opp_rank_stat: 0.8 });
|
||||
expect(intel.matchup_grade).toBe('A');
|
||||
expect(intel.season_avg).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -9,12 +9,12 @@ describe('sanitizePlayerName', () => {
|
||||
it('decodes URL encoding and keeps name punctuation', () => {
|
||||
expect(svc.sanitizePlayerName('Luka%20Doncic')).toBe('Luka Doncic');
|
||||
expect(svc.sanitizePlayerName("De'Aaron Fox")).toBe("De'Aaron Fox");
|
||||
expect(svc.sanitizePlayerName('Ronald Acuna Jr.')).toBe('Ronald Acuna Jr.');
|
||||
expect(svc.sanitizePlayerName('Ronald Acuna Jr.')).toBe('Ronald Acuna Jr'); // S46: suffix de-dotted
|
||||
});
|
||||
|
||||
it('strips injection / control characters', () => {
|
||||
expect(svc.sanitizePlayerName('Luka<script>')).toBe('Lukascript');
|
||||
expect(svc.sanitizePlayerName('a/../../etc/passwd')).toBe('a....etcpasswd');
|
||||
expect(svc.sanitizePlayerName('a/../../etc/passwd')).toBe('aetcpasswd'); // S46: periods stripped
|
||||
expect(svc.sanitizePlayerName('x'.repeat(200)).length).toBe(60);
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
// Session 46 — Phase 2: player name normalization.
|
||||
|
||||
const be = require('../../src/utils/playerName');
|
||||
const fe = require('../../web/src/lib/playerName');
|
||||
const slate = require('../../web/src/lib/slateAdapter');
|
||||
const intel = require('../../src/services/playerIntelService');
|
||||
|
||||
describe('normalizeName', () => {
|
||||
it('collapses period variants to one key', () => {
|
||||
expect(be.nameKey('A.J. Ewing')).toBe(be.nameKey('AJ Ewing'));
|
||||
expect(be.normalizeName('A.J. Ewing').display).toBe('AJ Ewing');
|
||||
});
|
||||
it('collapses suffix variants to one key', () => {
|
||||
expect(be.nameKey('Jazz Chisholm Jr.')).toBe(be.nameKey('Jazz Chisholm'));
|
||||
expect(be.normalizeName('Jazz Chisholm Jr.').display).toBe('Jazz Chisholm Jr');
|
||||
});
|
||||
it('keeps the accent in the display but folds it in the key', () => {
|
||||
const r = be.normalizeName('Ronald Acuña Jr.');
|
||||
expect(r.display).toBe('Ronald Acuña Jr');
|
||||
expect(r.key).toBe('ronald acuna');
|
||||
expect(be.nameKey('Ronald Acuna')).toBe(r.key);
|
||||
});
|
||||
it('backend + frontend copies agree', () => {
|
||||
for (const n of ['A.J. Ewing', 'Jazz Chisholm Jr.', 'Ronald Acuña Jr.', 'Shohei Ohtani']) {
|
||||
expect(fe.nameKey(n)).toBe(be.nameKey(n));
|
||||
expect(fe.normalizeName(n).display).toBe(be.normalizeName(n).display);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('sanitizePlayerName normalizes periods/suffix', () => {
|
||||
it('strips periods for display', () => {
|
||||
expect(intel.sanitizePlayerName('A.J. Ewing')).toBe('AJ Ewing');
|
||||
expect(intel.sanitizePlayerName('Jazz%20Chisholm%20Jr.')).toBe('Jazz Chisholm Jr');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildPlayerStripsFromProps merges name variants', () => {
|
||||
it('merges "A.J. Ewing" + "AJ Ewing" into one strip', () => {
|
||||
const strips = slate.buildPlayerStripsFromProps(
|
||||
[
|
||||
{ player: 'A.J. Ewing', stat_type: 'hits', line: 1.5 },
|
||||
{ player: 'AJ Ewing', stat_type: 'total_bases', line: 1.5 },
|
||||
],
|
||||
{}, {},
|
||||
);
|
||||
expect(strips).toHaveLength(1);
|
||||
expect(strips[0].props).toHaveLength(2);
|
||||
});
|
||||
it('displays the longer name variant', () => {
|
||||
const strips = slate.buildPlayerStripsFromProps(
|
||||
[
|
||||
{ player: 'Jazz Chisholm', stat_type: 'hits', line: 1.5 },
|
||||
{ player: 'Jazz Chisholm Jr', stat_type: 'runs', line: 0.5 },
|
||||
],
|
||||
{}, {},
|
||||
);
|
||||
expect(strips).toHaveLength(1);
|
||||
expect(strips[0].player).toBe('Jazz Chisholm Jr');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
// Session 46 — Phase 3: MLB starting pitchers.
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const WEB = path.join(__dirname, '..', '..', 'web', 'src');
|
||||
const svc = require('../../src/services/probablePitchers');
|
||||
const slate = require('../../web/src/lib/slateAdapter');
|
||||
|
||||
const scheduleGames = [
|
||||
{ home: { team: 'Philadelphia Phillies', probablePitcher: { id: 1, name: 'Zack Wheeler' } }, away: { team: 'Atlanta Braves', probablePitcher: { id: 2, name: 'Spencer Strider' } } },
|
||||
{ home: { team: 'New York Yankees', probablePitcher: null }, away: { team: 'Boston Red Sox', probablePitcher: { id: 3, name: 'Brayan Bello' } } },
|
||||
];
|
||||
|
||||
describe('shapePitcherGames', () => {
|
||||
it('extracts pitcher names + team, drops games with no probables', () => {
|
||||
const shaped = svc.shapePitcherGames(scheduleGames);
|
||||
expect(shaped).toHaveLength(2);
|
||||
expect(shaped[0].away.pitcher).toBe('Spencer Strider');
|
||||
expect(shaped[0].home.pitcher).toBe('Zack Wheeler');
|
||||
expect(svc.shapePitcherGames([{ home: {}, away: {} }])).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getProbablePitchers (injected adapter)', () => {
|
||||
it('returns shaped games with best-effort ERA', async () => {
|
||||
const games = await svc.getProbablePitchers('2026-06-18', {
|
||||
mlbAdapter: { getScheduleWithPitchers: async () => scheduleGames },
|
||||
eraLookup: async (id) => (id === 1 ? 2.89 : id === 2 ? 3.21 : null),
|
||||
});
|
||||
const phi = games[0];
|
||||
expect(phi.home.era).toBe(2.89);
|
||||
expect(phi.away.era).toBe(3.21);
|
||||
});
|
||||
it('degrades to [] when the adapter throws', async () => {
|
||||
const games = await svc.getProbablePitchers('x', { mlbAdapter: { getScheduleWithPitchers: async () => { throw new Error('down'); } } });
|
||||
expect(games).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('slateAdapter pitcher mapping', () => {
|
||||
const shaped = svc.shapePitcherGames(scheduleGames).map((g) => ({
|
||||
home: { ...g.home, era: 2.89 }, away: { ...g.away, era: 3.21 },
|
||||
}));
|
||||
it('builds a team→pitcher map and resolves a game by team name', () => {
|
||||
const map = slate.buildPitcherMap(shaped);
|
||||
const p = slate.pitchersForGameTeams('Atlanta Braves', 'Philadelphia Phillies', map);
|
||||
expect(p.away.name).toBe('Spencer Strider');
|
||||
expect(p.away.era).toBe('3.21');
|
||||
expect(p.home.name).toBe('Zack Wheeler');
|
||||
});
|
||||
it('matches by mascot when the full name differs slightly', () => {
|
||||
const map = slate.buildPitcherMap(shaped);
|
||||
const p = slate.pitchersForGameTeams('Braves', 'Phillies', map);
|
||||
expect(p.away.name).toBe('Spencer Strider');
|
||||
});
|
||||
it('returns undefined when no pitchers match', () => {
|
||||
expect(slate.pitchersForGameTeams('Dodgers', 'Giants', slate.buildPitcherMap(shaped))).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Slate wires MLB pitchers', () => {
|
||||
const src = fs.readFileSync(path.join(WEB, 'components', 'Slate.tsx'), 'utf8');
|
||||
it('fetches /api/schedule/mlb/pitchers and attaches pitchers for MLB', () => {
|
||||
expect(src).toContain('/api/schedule/mlb/pitchers');
|
||||
expect(src).toContain('buildPitcherMap');
|
||||
expect(src).toContain('pitchersForGameTeams');
|
||||
});
|
||||
});
|
||||
@@ -15,9 +15,9 @@ describe('snapshot overlay adapter', () => {
|
||||
|
||||
it('indexes grades + deltas for lookup', () => {
|
||||
const gi = a.indexGrades(grades);
|
||||
expect(gi['aaronjudge|total_bases'].grade).toBe('A+');
|
||||
expect(gi['aaron judge|total_bases'].grade).toBe('A+');
|
||||
const di = a.indexDeltas(deltas);
|
||||
expect(di['aaronjudge|total_bases|O'].direction).toBe('toward');
|
||||
expect(di['aaron judge|total_bases|O'].direction).toBe('toward');
|
||||
});
|
||||
|
||||
it('overlays grades onto game props → playerStrips (name once, archetype, gradedAt, delta)', () => {
|
||||
|
||||
Reference in New Issue
Block a user