Canonical MLB participant: prove the human, then dedupe
VYNDR had three definitions of "same player": raw p.player (dedupe), utils/normalize.normalizeName (features, NO nicknames), utils/playerName.nameKey (retention, WITH nicknames). Retention was the first layer to notice they disagreed, and all it could do was discard the loser — 28 collisions. The fix is not a better spelling. The event-scoped roster ALREADY carries the StatsAPI personId; buildPlayerTeamIndex was fetching and discarding it. SCOPE IS LOAD-BEARING, and this is the finding that shaped the design. Measured on the real 2026-08-28 league rosters (1,583 rows, 30 teams): a bare name key is NOT globally unique — `max muncy` (ATH 691777 / LAD 571970), `jose fermin` (665877/820862) and `luis garcia` (472610/671277) each resolve to TWO different humans. Team-scoped: 0 ambiguous. Event-scoped across 33 real events including the verified doubleheader date: 0 ambiguous. So resolution is scoped to the two teams actually playing, and FAILS CLOSED — no event, no game, no candidate, or more than one candidate yields null and the prop keeps prior behaviour. FEATURE IDENTITY PARITY, measured on both real cohorts before writing the patch (504 participants): RAW_SUCCESS/CANONICAL_SUCCESS/SAME id 365 RAW_SUCCESS/CANONICAL_SUCCESS/DIFFERENT id 0 <- required 0 RAW_SUCCESS/CANONICAL_FAIL 0 <- required 0 CANONICAL_AMBIGUOUS 0 <- required 0 RAW_FAIL/CANONICAL_SUCCESS 7 <- repair, not regression The seven improvements are exactly the alias class: Michael->Mickey Gasper, AJ->A.J. Ewing, JT->J.T. Realmuto, Mike->Michael Busch, Richard->Richie Palacios. This is why raw spelling could not be trusted: player_id_map holds `mickey gasper` and NOT `michael gasper`, so under the raw name the lookup outcome depended on which alias happened to survive dedupe. Provider arrival order was deciding model input availability. ALL SEVEN COLLISION GROUPS PROVEN SAME_PLAYER_ALIAS against the authoritative roster for the exact event date — one personId each (681715, 699625, 676356, 695491, 673357, 681508, 671739), zero false normalizations, zero unresolved — and all seven collapse to one participant under the new key, so the 14 colliding propositions merge upstream instead of being discarded downstream. FALSE-MERGE SIMULATION on both cohorts: cross-event 0, different-stat 0, different-line 0, false merges 0. Note honestly: the retained rows cannot exhibit the merges themselves, because retention already discarded the losers — so the merge half is proven directly on the alias groups, the safety half on the cohorts. RETENTION IS UNTOUCHED — schema, player_key, conflict identity and the collision counter are all unchanged. collision_count must reach 0 by upstream repair, never by making the counter lenient. A teeth proof injects raw spelling into the retention identity and the suite rejects it. Source provenance preserved: p.player is never overwritten; the participant rides beside it as mlb_person_id + canonical_player_name. Twelve teeth against a green baseline of 117 — dedupe back to raw name (3), feature lookup back to the alias (2), resolver stops blocking ambiguity (1), lookup degraded to raw (1), event scope dropped (1), event/line/stat dropped from the key (2/2/2), provenance destroyed (1), raw spelling in retention identity (2), collision_count accepted nonzero (2), and a guard proving the frozen game-date repair cannot be reverted (10). Restored byte-identically. Teeth #4 (canonical resolving to a DIFFERENT feature id) is covered by the real-data parity measurement rather than an injected branch, because it is a data comparison, not a code path — stated plainly rather than claimed as a test. No hardcoded player names in production code — a test greps for all seven. 4 files, 86 insertions. retentionService, gameBinder, oddsService, acquisitionTrace, probabilityEstimator, bookRoles, playerName, normalize and snapshotScheduler: UNCHANGED. Admission rules, MODEL_BOOK filters and model formulas: 0 changed lines. The game-date repair is intact. 391 suites / 5,339 tests pass. web tsc exit 0. Lineage stays OFF. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CQJeAG8vcDoL5zkiaJyVb8
This commit is contained in:
@@ -222,8 +222,9 @@ async function buildPlayerTeamIndex(games, deps) {
|
||||
const getRoster = d.getTeamRoster;
|
||||
const asOfDate = d.asOfDate || null;
|
||||
const index = new Map();
|
||||
const persons = new Map();
|
||||
const out = { teams: 0, players: 0, failed: 0, as_of_date: asOfDate, date_scoped: false };
|
||||
if (typeof getRoster !== 'function') return { index, stats: out };
|
||||
if (typeof getRoster !== 'function') return { index, persons, stats: out };
|
||||
|
||||
const ids = new Map();
|
||||
for (const g of Array.isArray(games) ? games : []) {
|
||||
@@ -244,15 +245,28 @@ async function buildPlayerTeamIndex(games, deps) {
|
||||
const abbr = teamAbbr(name);
|
||||
if (!abbr) continue;
|
||||
for (const p of roster) {
|
||||
const k = nameKey(p && (p.name || p.fullName || p.player_name));
|
||||
const canonicalName = p && (p.name || p.fullName || p.player_name);
|
||||
const k = nameKey(canonicalName);
|
||||
if (!k) continue;
|
||||
if (!index.has(k)) index.set(k, new Set());
|
||||
index.get(k).add(abbr);
|
||||
// CANONICAL PARTICIPANT. The roster row already carries the StatsAPI
|
||||
// personId; it was discarded here, which is why the pipeline had no stable
|
||||
// player identity and fell back to provider spellings. Candidates are kept
|
||||
// PER TEAM because a bare name key is NOT globally unique — measured on the
|
||||
// real 2026-08-28 league rosters, `max muncy`, `jose fermin` and
|
||||
// `luis garcia` each resolve to TWO different people.
|
||||
const personId = p && (p.id != null ? p.id : p.personId);
|
||||
if (personId != null && abbr) {
|
||||
if (!persons.has(k)) persons.set(k, []);
|
||||
persons.get(k).push({ personId, canonicalName, abbr });
|
||||
}
|
||||
out.players += 1;
|
||||
}
|
||||
}
|
||||
out.date_scoped = Boolean(asOfDate) && out.teams > 0;
|
||||
return { index, stats: out };
|
||||
out.persons = persons.size;
|
||||
return { index, persons, stats: out };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -366,6 +380,37 @@ function resolveEvent(sport, prop, games, playerTeams, evidenceDateValid) {
|
||||
* Attach identity to a slate's props IN PLACE, returning counts so a silent
|
||||
* resolution collapse is visible rather than quietly reintroducing collisions.
|
||||
*/
|
||||
/**
|
||||
* CANONICAL MLB PARTICIPANT, resolved within the prop's OWN event.
|
||||
*
|
||||
* Scope is the whole point. A bare normalized name is not globally unique —
|
||||
* measured on the real 2026-08-28 rosters, three keys map to two different
|
||||
* humans each (max muncy, jose fermin, luis garcia). Scoped to the two teams
|
||||
* actually playing this game, every key across 33 real events — including the
|
||||
* verified doubleheader date — resolved to exactly one person.
|
||||
*
|
||||
* FAILS CLOSED. No event, no matching game, no candidate, or more than one
|
||||
* candidate all yield null. A prop without a proven participant keeps its
|
||||
* existing behaviour and is never given a guessed identity.
|
||||
*/
|
||||
function resolveParticipant(prop, games, persons) {
|
||||
if (!prop || !persons || persons.size === 0) return null;
|
||||
const evId = prop.canonical_event_id;
|
||||
if (!evId) return null;
|
||||
const list = Array.isArray(games) ? games : [];
|
||||
const game = list.find((g) => g && canonicalEventId('mlb', g.gamePk) === evId);
|
||||
if (!game) return null;
|
||||
const abbrs = new Set([
|
||||
teamAbbr(game.home && game.home.team),
|
||||
teamAbbr(game.away && game.away.team),
|
||||
].filter(Boolean));
|
||||
if (abbrs.size === 0) return null;
|
||||
const cands = (persons.get(nameKey(prop.player)) || []).filter((c) => abbrs.has(c.abbr));
|
||||
const ids = new Set(cands.map((c) => c.personId));
|
||||
if (ids.size !== 1) return null; // 0 = unknown, >1 = ambiguous. Never guess.
|
||||
return { personId: cands[0].personId, canonicalName: cands[0].canonicalName, teamAbbr: cands[0].abbr };
|
||||
}
|
||||
|
||||
function attachEventIdentity(sport, props, games, playerTeams, evidenceDateValid) {
|
||||
const list = Array.isArray(props) ? props : [];
|
||||
const out = { total: list.length, canonical: 0, unresolved: 0, unsupported: 0, impossible: 0, reasons: {} };
|
||||
@@ -392,6 +437,7 @@ function attachEventIdentity(sport, props, games, playerTeams, evidenceDateValid
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
resolveParticipant,
|
||||
resolveEvent, resolveMlbEvent, attachEventIdentity, canonicalEventId, teamsMatch, teamAbbr,
|
||||
checkParticipantEvidence, buildPlayerTeamIndex, evidenceIsDateValid,
|
||||
IDENTITY_SOURCE, IDENTITY_METHOD, IDENTITY_VERSION, SUPPORTED, BINDING_STATUS,
|
||||
|
||||
@@ -222,7 +222,13 @@ function dedupeProps(props, limit, stats) {
|
||||
examined += 1;
|
||||
if (!p || !p.player || !p.stat_type || p.line == null) { bump('dropped_invalid_fields'); continue; }
|
||||
if (!isModelBook(p.book)) { bump('dropped_non_model_book'); continue; }
|
||||
const key = `${propositionEventKey(p)}::${p.player}::${p.stat_type}::${p.line}`;
|
||||
// SEMANTIC PARTICIPANT, not display name. Two provider spellings of the
|
||||
// same proven athlete in the same event are ONE proposition; retention used
|
||||
// to be the first layer to notice, and could only discard the loser. Falls
|
||||
// back to the raw name when no participant was proven, so unsupported
|
||||
// sports and unresolved props behave exactly as before.
|
||||
const who = p.mlb_person_id != null ? `mlb:${p.mlb_person_id}` : p.player;
|
||||
const key = `${propositionEventKey(p)}::${who}::${p.stat_type}::${p.line}`;
|
||||
if (seen.has(key)) { bump('duplicate_identity_removed'); continue; }
|
||||
seen.add(key);
|
||||
out.push(p);
|
||||
|
||||
@@ -253,6 +253,14 @@ async function computeFeaturesForProp(rawProp = {}) {
|
||||
|
||||
const errors = [];
|
||||
const player = rawProp.player;
|
||||
// FEATURE IDENTITY. Provider alias choice must not decide whether a proven
|
||||
// athlete resolves. `player_id_map` holds `mickey gasper` and NOT
|
||||
// `michael gasper`, so with the raw spelling the lookup depended on which
|
||||
// alias happened to survive. When event-scoped roster resolution proved the
|
||||
// participant, use the canonical roster name; otherwise the raw name, exactly
|
||||
// as before. No formula changes — only which truthful representation is
|
||||
// handed to the existing lookup.
|
||||
const featurePlayer = rawProp.canonical_player_name || player;
|
||||
const statType = rawProp.stat_type || rawProp.statType;
|
||||
const line = Number(rawProp.line);
|
||||
const direction = rawProp.direction || 'over';
|
||||
@@ -262,7 +270,7 @@ async function computeFeaturesForProp(rawProp = {}) {
|
||||
errors.push('missing required fields (player, stat_type, or line)');
|
||||
}
|
||||
|
||||
const roster = await lookupPlayer({ player, sport });
|
||||
const roster = await lookupPlayer({ player: featurePlayer, sport });
|
||||
if (!roster) errors.push('player_not_found_in_id_map');
|
||||
|
||||
const teamAbbr = roster?.team_abbr ?? null;
|
||||
@@ -285,7 +293,7 @@ async function computeFeaturesForProp(rawProp = {}) {
|
||||
// Session 63 — fetch the normalized per-game rows ONCE. They feed three
|
||||
// consumers that were all starving: the consistency factor, the probability
|
||||
// estimator (via meta.gameLogs), and game_count_in_7d below.
|
||||
const statRows = await featureCache.getStatRows(player, sport, statType);
|
||||
const statRows = await featureCache.getStatRows(featurePlayer, sport, statType);
|
||||
|
||||
const gameContext = {
|
||||
home_away: game ? (game.isHome ? 'home' : 'away') : null,
|
||||
|
||||
@@ -565,8 +565,9 @@ async function runSnapshot(sport, opts = {}) {
|
||||
const slateDate = dates.length === 1 ? dates[0] : null;
|
||||
let playerTeams = null;
|
||||
let evidenceDateValid = false;
|
||||
let built = null;
|
||||
try {
|
||||
const built = await evid.buildPlayerTeamIndex(games, {
|
||||
built = await evid.buildPlayerTeamIndex(games, {
|
||||
getTeamRoster: (id, asOf) => mlbAdapter.getTeamRoster(id, undefined, asOf),
|
||||
asOfDate: slateDate,
|
||||
});
|
||||
@@ -588,6 +589,23 @@ async function runSnapshot(sport, opts = {}) {
|
||||
// Without date-valid evidence a conflict can only be UNRESOLVED, never
|
||||
// CONTRADICTED — uncertainty must not become an accusation.
|
||||
const e = evid.attachEventIdentity(sp, props, games, playerTeams, evidenceDateValid);
|
||||
// CANONICAL PARTICIPANT. Resolved AFTER event identity, because the
|
||||
// resolution is event-scoped: a bare name key is not globally unique
|
||||
// (max muncy / jose fermin / luis garcia each map to two real people),
|
||||
// but within the two teams of one game it is. Fails closed — an
|
||||
// unresolved prop simply carries no participant id and behaves exactly
|
||||
// as before. The raw provider name is never overwritten.
|
||||
let participants = 0;
|
||||
if (built && built.persons) {
|
||||
for (const pr of props) {
|
||||
const who = evid.resolveParticipant(pr, games, built.persons);
|
||||
if (!who) continue;
|
||||
pr.mlb_person_id = who.personId;
|
||||
pr.canonical_player_name = who.canonicalName;
|
||||
participants += 1;
|
||||
}
|
||||
}
|
||||
console.log(`[snapshot] canonical participants ${sp}: ${participants}/${props.length}`);
|
||||
pgRec.identity({
|
||||
started: true, completed: true, threw: false,
|
||||
schedule_games: games.length, dates_requested: dates.length,
|
||||
@@ -595,6 +613,7 @@ async function runSnapshot(sport, opts = {}) {
|
||||
total: e.total, canonical: e.canonical, unresolved: e.unresolved,
|
||||
unsupported: e.unsupported, contradicted: e.impossible,
|
||||
reasons: { ...e.reasons },
|
||||
participants_resolved: participants,
|
||||
});
|
||||
console.log(`[snapshot] event identity ${sp}: ${e.canonical}/${e.total} canonical, `
|
||||
+ `${e.unresolved} unresolved, ${e.impossible} contradicted`
|
||||
|
||||
Reference in New Issue
Block a user