Session 47: Name normalization + grade intel + ticker polish (2149 tests)

- Name normalization completed: NICKNAMES table (Matt↔Matthew, Mike↔Michael...)
  resolved in nameKey, parenthetical team-tag strip "(STL)", verified accent-fold
  (Iván/Ivan, José/Jose). Slate strip now DISPLAYS the normalized de-dotted name
  ("AJ Ewing" not "A.J. Ewing") via buildPlayerStripsFromProps.
- Complete MLB VYNDR INTELLIGENCE: mlbGameLogFeatures derives rest_days (days off
  between latest games; 0=B2B) + ab_per_game (usage). buildIntelFields renders
  usage as "X AB/G", rest as B2B/Xd, matchup from bvp_advantage fallback.
- Ticker SCAN dedup: pushTickerItems keeps one SCAN per sport (sport field or
  text-prefix parse for legacy); MOVE/GRADE preserved; cap 50.
- BOMBER threshold prorated for mid-season (hr>=15 strong / >=10 mod) so June
  sluggers classify BOMBER not FLEX/DRIVER.

Backend 2122 -> 2149 tests (+27), 179 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 01:45:36 -04:00
parent c8fc9f577e
commit 78db55d499
13 changed files with 346 additions and 32 deletions
+53
View File
@@ -0,0 +1,53 @@
// Session 47 — Phase 2: complete VYNDR INTELLIGENCE for MLB (rest + usage).
const { __internals: fc } = require('../../src/services/intelligence/featureCache');
const { __internals: eng } = require('../../src/services/intelligence/analyzeViaEngine1');
const judge = (dates) => ({
found: true, group: 'hitting',
season: { totalBases: 180, atBats: 330, gamesPlayed: 92 },
last10: [
{ date: dates[0], stat: { totalBases: 2 } },
{ date: dates[1], stat: { totalBases: 4 } },
{ date: dates[2], stat: { totalBases: 3 } },
],
});
describe('mlbGameLogFeatures — rest + usage', () => {
it('computes rest_days from the two most recent game dates', () => {
const f = fc.mlbGameLogFeatures(judge(['2026-06-15', '2026-06-16', '2026-06-18']), 'total_bases');
expect(f.rest_days).toBe(1); // 06-16 → 06-18 (gap 2 = 1 day off)
});
it('marks back-to-back as 0', () => {
const f = fc.mlbGameLogFeatures(judge(['2026-06-15', '2026-06-17', '2026-06-18']), 'total_bases');
expect(f.rest_days).toBe(0);
});
it('computes ab_per_game (MLB usage equivalent)', () => {
const f = fc.mlbGameLogFeatures(judge(['2026-06-15', '2026-06-16', '2026-06-18']), 'total_bases');
expect(f.ab_per_game).toBeCloseTo(330 / 92, 2);
});
});
describe('buildIntelFields — all four MLB fields', () => {
it('renders usage as AB/G and rest from MLB features', () => {
const f = fc.mlbGameLogFeatures(judge(['2026-06-15', '2026-06-16', '2026-06-18']), 'total_bases');
const intel = eng.buildIntelFields(f);
expect(intel.usage).toMatch(/AB\/G$/);
expect(intel.rest).toBe('1d rest');
expect(intel.season_avg).toBeDefined();
});
it('renders B2B for zero rest', () => {
expect(eng.buildIntelFields({ rest_days: 0 }).rest).toBe('B2B');
});
it('produces all four fields for a complete feature set', () => {
const intel = eng.buildIntelFields({ l20_avg: 1.9, l10_avg: 2.1, l5_avg: 2.4, ab_per_game: 3.6, opp_rank_stat: 0.7, rest_days: 1 });
expect(intel.season_avg).toBeDefined();
expect(intel.form).toBeDefined();
expect(intel.usage).toBe('3.6 AB/G');
expect(intel.matchup_grade).toBe('A');
expect(intel.rest).toBe('1d rest');
});
it('derives matchup from batter-vs-pitcher edge when no opp rank', () => {
expect(eng.buildIntelFields({ bvp_advantage: 0.1 }).matchup_grade).toBe('A');
});
});
+72
View File
@@ -0,0 +1,72 @@
// Session 47 — Phase 1: complete name normalization (accents, nicknames, parens,
// display de-dotting).
const be = require('../../src/utils/playerName');
const fe = require('../../web/src/lib/playerName');
const slate = require('../../web/src/lib/slateAdapter');
describe('accent folding', () => {
it('"Iván Herrera" and "Ivan Herrera" share a key', () => {
expect(be.nameKey('Iván Herrera')).toBe(be.nameKey('Ivan Herrera'));
expect(be.nameKey('Iván Herrera')).toBe('ivan herrera');
});
it('"José Caballero" and "Jose Caballero" share a key', () => {
expect(be.nameKey('José Caballero')).toBe(be.nameKey('Jose Caballero'));
});
it('keeps the accent in display', () => {
expect(be.normalizeName('Iván Herrera').display).toBe('Iván Herrera');
});
});
describe('nickname resolution', () => {
it('"Matt Liberatore" === "Matthew Liberatore"', () => {
expect(be.nameKey('Matt Liberatore')).toBe(be.nameKey('Matthew Liberatore'));
expect(be.nameKey('Matt Liberatore')).toBe('matthew liberatore');
});
it('"Mike Massey" === "Michael Massey"', () => {
expect(be.nameKey('Mike Massey')).toBe(be.nameKey('Michael Massey'));
});
it('does not touch the last name', () => {
// "Matt" first name resolves; a player whose LAST name is Matt-like is unaffected
expect(be.nameKey('John Matthews')).toBe('john matthews');
});
});
describe('parenthetical team tags', () => {
it('strips "(STL)" from display and key', () => {
expect(be.normalizeName('Jose Fermin (STL)').display).toBe('Jose Fermin');
expect(be.nameKey('Jose Fermin (STL)')).toBe(be.nameKey('Jose Fermin'));
});
});
describe('display de-dotting', () => {
it('"A.J. Ewing" display shows "AJ Ewing"', () => {
expect(be.normalizeName('A.J. Ewing').display).toBe('AJ Ewing');
});
});
describe('frontend + backend agree', () => {
it.each(['Iván Herrera', 'Matt Liberatore', 'Jose Fermin (STL)', 'A.J. Ewing', 'Ronald Acuña Jr.', 'Mike Massey'])('%s', (n) => {
expect(fe.nameKey(n)).toBe(be.nameKey(n));
expect(fe.normalizeName(n).display).toBe(be.normalizeName(n).display);
});
});
describe('buildPlayerStripsFromProps merges variants + de-dots display', () => {
it('merges nickname + accent + period variants into one strip', () => {
const strips = slate.buildPlayerStripsFromProps([
{ player: 'Matt Liberatore', stat_type: 'strikeouts', line: 5.5 },
{ player: 'Matthew Liberatore', stat_type: 'hits_allowed', line: 5.5 },
{ player: 'Iván Herrera', stat_type: 'hits', line: 1.5 },
{ player: 'Ivan Herrera', stat_type: 'total_bases', line: 1.5 },
], {}, {});
expect(strips).toHaveLength(2);
expect(strips[0].props).toHaveLength(2);
});
it('display name is de-dotted', () => {
const strips = slate.buildPlayerStripsFromProps([
{ player: 'A.J. Ewing', stat_type: 'hits', line: 1.5 },
], {}, {});
expect(strips[0].player).toBe('AJ Ewing');
});
});
+47
View File
@@ -0,0 +1,47 @@
// Session 47 — Phase 3: ticker keeps only the latest SCAN per sport.
const svc = require('../../src/services/snapshotService');
function memCache(initial) {
const store = { 'ticker:items': initial || null };
return { store, cacheGet: async (k) => store[k] ?? null, cacheSet: async (k, v) => { store[k] = v; } };
}
describe('pushTickerItems — SCAN dedup', () => {
it('replaces a prior SCAN for the same sport (only one MLB SCAN)', async () => {
const cache = memCache([
{ tag: 'SCAN', sport: 'mlb', text: 'MLB slate scanned · 20 props graded' },
{ tag: 'MOVE', text: 'Judge o2.5 → o3.5 ▲+1' },
]);
await svc.pushTickerItems([{ tag: 'SCAN', sport: 'mlb', text: 'MLB slate scanned · 25 props graded' }], cache);
const items = cache.store['ticker:items'];
expect(items.filter((e) => e.tag === 'SCAN' && e.sport === 'mlb')).toHaveLength(1);
expect(items.find((e) => e.tag === 'SCAN').text).toContain('25 props');
});
it('preserves MOVE/GRADE events and other sports', async () => {
const cache = memCache([
{ tag: 'MOVE', text: 'move 1' },
{ tag: 'A+', text: 'BOMBER graded A+' },
{ tag: 'SCAN', sport: 'nba', text: 'NBA slate scanned · 10 props graded' },
]);
await svc.pushTickerItems([{ tag: 'SCAN', sport: 'mlb', text: 'MLB slate scanned · 25 props graded' }], cache);
const items = cache.store['ticker:items'];
expect(items.find((e) => e.tag === 'MOVE')).toBeTruthy();
expect(items.find((e) => e.tag === 'A+')).toBeTruthy();
expect(items.find((e) => e.tag === 'SCAN' && e.sport === 'nba')).toBeTruthy();
});
it('dedupes legacy SCAN items that lack a sport field (parse from text)', async () => {
const cache = memCache([{ tag: 'SCAN', text: 'MLB slate scanned · 20 props graded' }]);
await svc.pushTickerItems([{ tag: 'SCAN', sport: 'mlb', text: 'MLB slate scanned · 25 props graded' }], cache);
expect(cache.store['ticker:items'].filter((e) => e.tag === 'SCAN')).toHaveLength(1);
});
it('stays capped at 50 items', async () => {
const many = Array.from({ length: 60 }, (_, i) => ({ tag: 'MOVE', text: `m${i}` }));
const cache = memCache(many);
await svc.pushTickerItems([{ tag: 'SCAN', sport: 'mlb', text: 'scanned' }], cache);
expect(cache.store['ticker:items'].length).toBeLessThanOrEqual(50);
});
});