Session 54: Audit cleanup — name edges + polish (2255 tests)
P1 name edge cases (BOTH playerName.js copies, kept identical): - normalizeName strips hyphens (display+key): "Jung-hoo Lee" === "Jung Hoo Lee". - nameKey strips single-letter MIDDLE tokens: "Josh H Smith" === "Josh Smith" (keeps first+last; real middle names + collapsed initials untouched). - richie -> richard added to NICKNAMES. P2 polish: - Team Hub names normalized at the source (teamService.getTeamHub) so "J.C. Escarra" renders as "JC Escarra" like the dashboard. - snapshotService dedup keeps the highest-confidence GRADE but the richest DISPLAY (accented "José" over "Jose") so prop rows match the pitcher line. - correlationWarning names the game: "2 legs from the same game (NYY @ BOS)". Backend 2246 -> 2255 tests (+9), 194 suites. Web build clean (exit 0). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
+26
-2
@@ -4,8 +4,32 @@
|
||||
2026-06-19
|
||||
|
||||
## Current Phase
|
||||
SHIP BUILD v53.0 — Social preview fix: clean OG/Twitter meta + dynamically
|
||||
generated 1200x630 OG image.
|
||||
SHIP BUILD v54.0 — Audit cleanup: name edge cases (hyphen / middle-initial /
|
||||
richie), Team Hub name normalization, accent-keeping dedup, parlay copy.
|
||||
|
||||
## Session 54 (2026-06-19) — SHIPPED ✅ AUDIT CLEANUP
|
||||
|
||||
Backend 2246 → **2255 tests** (+9), 194 suites. Web build clean (exit 0).
|
||||
|
||||
### Phase 1 — name edge cases (BOTH playerName.js copies, kept identical)
|
||||
- **Hyphens** stripped in `normalizeName` display+key (`.replace(/-/g,' ')`) so
|
||||
"Jung-hoo Lee" === "Jung Hoo Lee".
|
||||
- **Middle single-letter tokens** stripped in `nameKey` (keep first + last) so
|
||||
"Josh H Smith" === "Josh Smith". Guarded: real middle names ("Juan Carlos
|
||||
Smith") and collapsed first initials ("JC Escarra") are untouched.
|
||||
- **`richie: 'richard'`** added to NICKNAMES.
|
||||
|
||||
### Phase 2 — polish
|
||||
- **Team Hub names normalized** at the source: `teamService.getTeamHub` now maps
|
||||
every roster player (MLB + snapshot-fallback) through `normalizeName().display`
|
||||
→ "J.C. Escarra" renders as "JC Escarra", matching the dashboard.
|
||||
- **Accent-keeping dedup** (`snapshotService`): when collapsing variant grades,
|
||||
the GRADE picked is still highest-confidence, but the DISPLAY now prefers the
|
||||
accented variant ("José" over "Jose") so prop rows match the pitcher line.
|
||||
- **Parlay copy**: same-game (different-team) warning now names the game —
|
||||
"⚠ 2 legs from the same game (NYY @ BOS) — correlated".
|
||||
|
||||
## Session 53 (2026-06-19) — SHIPPED ✅ SOCIAL PREVIEW FIX
|
||||
|
||||
## Session 53 (2026-06-19) — SHIPPED ✅ SOCIAL PREVIEW FIX
|
||||
|
||||
|
||||
@@ -533,6 +533,19 @@ snapshot, locked to the line, and read from cache.
|
||||
- **Name micro-fix:** `playerName.js` `collapseInitials` merges "J C" → "JC"
|
||||
(display + key) so space-separated initials dedupe.
|
||||
|
||||
## Name Edge Cases + Audit Polish (Session 54 — non-obvious)
|
||||
- `normalizeName` now also **strips hyphens** to spaces ("Jung-hoo" → "Jung hoo")
|
||||
in display+key; `nameKey` **strips single-letter MIDDLE tokens** ("Josh H Smith"
|
||||
→ "josh smith") — keeps first (may be a collapsed initial like "jc") + last, so
|
||||
real middle names ("Juan Carlos Smith") and first initials are NOT dropped.
|
||||
`richie: 'richard'` added to NICKNAMES. Both copies kept identical.
|
||||
- **Accent-keep dedup** (`snapshotService`): the variant-grade collapse keeps the
|
||||
highest-CONFIDENCE grade but the richest DISPLAY (prefers accented "José" over
|
||||
"Jose", then longer). That's why prop rows now match the accented pitcher line.
|
||||
`hasAccent` uses a charCode>127 check (NOT a regex with literal control bytes).
|
||||
- **Team Hub names** are normalized in `teamService.getTeamHub` (the source), not
|
||||
the page — every `/api/team/:abbr` consumer gets `normalizeName().display`.
|
||||
|
||||
## Parlay Lab (Session 50 — non-obvious)
|
||||
- **Correlation model** lives in `src/services/parlayService.js` (ADDED to the
|
||||
S28 categorical matrix — both coexist). `correlationScore(l1,l2)` is numeric +
|
||||
|
||||
@@ -296,7 +296,10 @@ function correlationWarning(legs) {
|
||||
if (worst) return `⚠ ${worst.count} legs from ${worst.team} — high correlation`;
|
||||
for (let i = 0; i < list.length; i += 1) {
|
||||
for (let j = i + 1; j < list.length; j += 1) {
|
||||
if (sameVal(list[i].game, list[j].game)) return '⚠ 2 legs from the same game — correlated';
|
||||
if (sameVal(list[i].game, list[j].game)) {
|
||||
const g = list[i].game;
|
||||
return g ? `⚠ 2 legs from the same game (${g}) — correlated` : '⚠ 2 legs from the same game — correlated';
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
|
||||
@@ -213,15 +213,30 @@ async function runSnapshot(sport, opts = {}) {
|
||||
// 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).
|
||||
// Session 54 — also keep the RICHEST display per player (prefer the accented
|
||||
// variant: "José" over "Jose", then the longer string) so the prop rows match
|
||||
// the accented pitcher line. The GRADE picked is still the highest-confidence.
|
||||
const hasAccent = (s) => [...String(s)].some((c) => c.charCodeAt(0) > 127);
|
||||
const richerDisplay = (a, b) => {
|
||||
if (!b) return a;
|
||||
if (hasAccent(a) !== hasAccent(b)) return hasAccent(a) ? a : b;
|
||||
return a.length >= b.length ? a : b;
|
||||
};
|
||||
const dedup = new Map();
|
||||
const bestDisplay = 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 pk = nameKey(disp);
|
||||
bestDisplay.set(pk, richerDisplay(disp, bestDisplay.get(pk)));
|
||||
const k = `${pk}|${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()];
|
||||
const graded = [...dedup.values()].map((g) => {
|
||||
const disp = bestDisplay.get(nameKey(g.player)) || g.player;
|
||||
return { ...g, player: disp, player_name: disp };
|
||||
});
|
||||
|
||||
// Archetype per unique player (pure math once we have stats). Best-effort —
|
||||
// a missing stat line → no badge (not a fallback archetype).
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
* Everything is injectable so the whole build is unit-testable with no network.
|
||||
*/
|
||||
|
||||
const { nameKey } = require('../utils/playerName');
|
||||
const { nameKey, normalizeName } = require('../utils/playerName');
|
||||
|
||||
const HUB_TTL = 15 * 60; // expensive to build; 15-min cache
|
||||
const ROSTER_CONCURRENCY = 8;
|
||||
@@ -83,7 +83,7 @@ async function getTeamHub(sport, abbr, opts = {}) {
|
||||
const env = await cacheGet(`grades:${sp}`).catch(() => null);
|
||||
const byPlayer = {};
|
||||
for (const g of (env && env.grades) || []) {
|
||||
const disp = g.player || g.player_name;
|
||||
const disp = normalizeName(g.player || g.player_name).display || g.player || g.player_name;
|
||||
const k = nameKey(disp);
|
||||
if (!byPlayer[k]) byPlayer[k] = { player: disp, archetype: g.archetype ? { primary: g.archetype } : null, position: null, stats: [], props: [], propCount: 0 };
|
||||
byPlayer[k].props.push({ stat: statLabel(g.stat_type || g.stat), line: g.line, side: sideChar(g.direction), grade: g.grade, gradedAt: g.gradedAt || null });
|
||||
@@ -124,7 +124,7 @@ async function getTeamHub(sport, abbr, opts = {}) {
|
||||
archetype = c.primary ? { primary: c.primary.name } : null;
|
||||
}
|
||||
return {
|
||||
player: p.name,
|
||||
player: normalizeName(p.name).display || p.name,
|
||||
position: p.position,
|
||||
jersey: p.jersey,
|
||||
archetype,
|
||||
|
||||
@@ -38,6 +38,7 @@ const NICKNAMES = {
|
||||
jim: 'james', jimmy: 'james', ray: 'raymond', fred: 'frederick',
|
||||
kenny: 'kenneth', sam: 'samuel', pat: 'patrick', greg: 'gregory',
|
||||
steve: 'steven', tim: 'timothy', frank: 'francis', mickey: 'michael',
|
||||
richie: 'richard',
|
||||
};
|
||||
|
||||
// Collapse adjacent single-letter words: "J C Escarra" → "JC Escarra",
|
||||
@@ -50,6 +51,7 @@ function normalizeName(raw) {
|
||||
const display = collapseInitials(String(raw == null ? '' : raw)
|
||||
.replace(/\s*\([^)]*\)\s*/g, ' ') // strip parenthetical team tags "(STL)"
|
||||
.replace(/\./g, '') // strip dots: "A.J." → "AJ", "Jr." → "Jr"
|
||||
.replace(/-/g, ' ') // "Jung-hoo" → "Jung hoo" (matches "Jung Hoo")
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim());
|
||||
const folded = display.normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase();
|
||||
@@ -63,8 +65,13 @@ function normalizeName(raw) {
|
||||
*/
|
||||
function nameKey(raw) {
|
||||
const { key } = normalizeName(raw);
|
||||
const parts = key.split(/\s+/).filter(Boolean);
|
||||
let parts = key.split(/\s+/).filter(Boolean);
|
||||
if (parts.length >= 2 && NICKNAMES[parts[0]]) parts[0] = NICKNAMES[parts[0]];
|
||||
// Strip single-letter MIDDLE tokens ("josh h smith" → "josh smith"); keep the
|
||||
// first (may be a collapsed initial like "jc") and last token.
|
||||
if (parts.length >= 3) {
|
||||
parts = parts.filter((p, i, arr) => i === 0 || i === arr.length - 1 || p.length > 1);
|
||||
}
|
||||
return parts.join(' ');
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
// Session 54 — audit cleanup: name edge cases (hyphen, middle initial, richie)
|
||||
// + accent-keeping dedup + correlation copy.
|
||||
|
||||
const be = require('../../src/utils/playerName');
|
||||
const fe = require('../../web/src/lib/playerName');
|
||||
const parlay = require('../../src/services/parlayService');
|
||||
|
||||
describe('Phase 1 — name edge cases', () => {
|
||||
it('merges hyphenated vs spaced ("Jung-hoo Lee" === "Jung Hoo Lee")', () => {
|
||||
expect(be.nameKey('Jung-hoo Lee')).toBe(be.nameKey('Jung Hoo Lee'));
|
||||
});
|
||||
it('strips middle initials ("Josh H Smith" === "Josh Smith")', () => {
|
||||
expect(be.nameKey('Josh H Smith')).toBe(be.nameKey('Josh Smith'));
|
||||
});
|
||||
it('resolves richie ("Richie Palacios" === "Richard Palacios")', () => {
|
||||
expect(be.nameKey('Richie Palacios')).toBe(be.nameKey('Richard Palacios'));
|
||||
});
|
||||
it('frontend + backend copies agree on the new cases', () => {
|
||||
for (const n of ['Jung-hoo Lee', 'Jung Hoo Lee', 'Josh H Smith', 'Richie Palacios', 'José Soriano', 'JC Escarra']) {
|
||||
expect(fe.nameKey(n)).toBe(be.nameKey(n));
|
||||
expect(fe.normalizeName(n).display).toBe(be.normalizeName(n).display);
|
||||
}
|
||||
});
|
||||
|
||||
// Guard against over-merging distinct players.
|
||||
it('does not strip real middle names ("Juan Carlos Smith" kept)', () => {
|
||||
expect(be.nameKey('Juan Carlos Smith')).toBe('juan carlos smith');
|
||||
});
|
||||
it('keeps distinct players distinct', () => {
|
||||
expect(be.nameKey('Aaron Judge')).not.toBe(be.nameKey('Aaron Nola'));
|
||||
});
|
||||
it('display keeps accents ("José Soriano")', () => {
|
||||
expect(be.normalizeName('José Soriano').display).toBe('José Soriano');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Phase 2 — correlation copy includes the game id', () => {
|
||||
it('same-game (different teams) warning names the game', () => {
|
||||
const w = parlay.correlationWarning([
|
||||
{ player: 'A', team: 'NYY', game: 'NYY @ BOS' },
|
||||
{ player: 'B', team: 'BOS', game: 'NYY @ BOS' },
|
||||
]);
|
||||
expect(w).toBe('⚠ 2 legs from the same game (NYY @ BOS) — correlated');
|
||||
});
|
||||
});
|
||||
@@ -64,7 +64,7 @@ describe('correlationWarning', () => {
|
||||
});
|
||||
it('flags same-game legs on different teams', () => {
|
||||
expect(svc.correlationWarning([{ game: 'g', team: 'A', player: 'x' }, { game: 'g', team: 'B', player: 'y' }]))
|
||||
.toBe('⚠ 2 legs from the same game — correlated');
|
||||
.toBe('⚠ 2 legs from the same game (g) — correlated');
|
||||
});
|
||||
it('null when all legs are independent', () => {
|
||||
expect(svc.correlationWarning([{ game: 'g1', team: 'A', player: 'x' }, { game: 'g2', team: 'B', player: 'y' }])).toBeNull();
|
||||
|
||||
@@ -63,6 +63,17 @@ describe('getTeamHub (MLB, injected)', () => {
|
||||
expect(await svc.getTeamHub('mlb', 'ZZZ', { mlbAdapter, cacheGet: cache.cacheGet, cacheSet: cache.cacheSet })).toBeNull();
|
||||
});
|
||||
|
||||
it('normalizes roster player display names (no dots) — Session 54', async () => {
|
||||
const dotted = {
|
||||
...mlbAdapter,
|
||||
async getTeamRoster() { return [{ id: 99, name: 'J.C. Escarra', position: 'C' }]; },
|
||||
async getSeasonAverages() { return null; },
|
||||
};
|
||||
const cache = memCache({ 'grades:mlb': { grades: [] } });
|
||||
const hub = await svc.getTeamHub('mlb', 'NYY', { mlbAdapter: dotted, cacheGet: cache.cacheGet, cacheSet: cache.cacheSet });
|
||||
expect(hub.roster[0].player).toBe('JC Escarra');
|
||||
});
|
||||
|
||||
it('caches the assembled hub (writes teamhub:{sport}:{abbr})', async () => {
|
||||
const cache = memCache({ 'grades:mlb': gradesEnv });
|
||||
await svc.getTeamHub('mlb', 'NYY', { mlbAdapter, cacheGet: cache.cacheGet, cacheSet: cache.cacheSet });
|
||||
|
||||
+1
-1
File diff suppressed because one or more lines are too long
@@ -18,6 +18,7 @@ const NICKNAMES = {
|
||||
jim: 'james', jimmy: 'james', ray: 'raymond', fred: 'frederick',
|
||||
kenny: 'kenneth', sam: 'samuel', pat: 'patrick', greg: 'gregory',
|
||||
steve: 'steven', tim: 'timothy', frank: 'francis', mickey: 'michael',
|
||||
richie: 'richard',
|
||||
};
|
||||
|
||||
// Collapse adjacent single-letter words: "J C Escarra" → "JC Escarra".
|
||||
@@ -29,6 +30,7 @@ function normalizeName(raw) {
|
||||
const display = collapseInitials(String(raw == null ? '' : raw)
|
||||
.replace(/\s*\([^)]*\)\s*/g, ' ')
|
||||
.replace(/\./g, '')
|
||||
.replace(/-/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim());
|
||||
const folded = display.normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase();
|
||||
@@ -38,8 +40,13 @@ function normalizeName(raw) {
|
||||
|
||||
function nameKey(raw) {
|
||||
const { key } = normalizeName(raw);
|
||||
const parts = key.split(/\s+/).filter(Boolean);
|
||||
let parts = key.split(/\s+/).filter(Boolean);
|
||||
if (parts.length >= 2 && NICKNAMES[parts[0]]) parts[0] = NICKNAMES[parts[0]];
|
||||
// Strip single-letter MIDDLE tokens ("josh h smith" → "josh smith"); keep the
|
||||
// first (may be a collapsed initial like "jc") and last token.
|
||||
if (parts.length >= 3) {
|
||||
parts = parts.filter((p, i, arr) => i === 0 || i === arr.length - 1 || p.length > 1);
|
||||
}
|
||||
return parts.join(' ');
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user