One human, one semantic identity — MLB participant convergence
The collision autopsy left two unrepaired defects, running in OPPOSITE
directions, and `outbound_collision_count` can only ever see one of them.
UNDER-COLLAPSE. Dedupe keys on `mlb:<personId>` when the participant is
proven and on the RAW PROVIDER SPELLING when it is not. Mickey Gasper
(681508) is on Boston's 40-man and not on its active roster, so an
active-only index could not identify him and every book's spelling of him
survived dedupe as its own proposition — retention was the first layer to
notice, far too late, and could only discard the loser.
SPLIT. The mirror image, and invisible to the collision metric because it
makes MORE identities, not fewer: Leo Jiménez (677870) is published as both
"Leo Jiménez" and "Leonardo Jimenez", so one human became two semantic
players in one game. Measured across the 15 MLB cohort slices since the
canonical-participant repair, this is a recurring class, not one case:
cam/cameron smith (5 slices), mitch/mitchell bratt, zac/zachary thornton,
leo/leonardo jimenez.
THE REPAIR READS MLB'S OWN RECORD. `hydrate=person` on the roster call the
pipeline already makes returns firstName / useName / useLastName, so the
legitimate name forms for a human come from the league rather than from an
alias table. An alias table is a list of the mistakes we happened to notice.
`nickName` is DELIBERATELY EXCLUDED: over 821 people it produced 14
ambiguous keys, because MLB's nickname field carries bare surnames and
shared clubhouse names — `nameKey('Smitty Smith')` is one string for both
Burch Smith and Will Smith. The four forms kept produce ZERO ambiguity.
Canonical participant reach widens to the 40-man; TEAM EVIDENCE still reads
the ACTIVE roster alone, so event admission and the impossible-binding
refusal are unchanged. Identity still fails closed: a name matching more
than one person in the event resolves to nobody.
CONTINUITY, MEASURED BEFORE WRITING ANY CODE. Over the real 19:00 cohort,
208 of 209 player_keys are unchanged and the one that moves is the defect —
`leonardo jimenez` converging onto `leo jimenez`, a key that already exists.
No new lineage family. The natural key contains game_date, so chains never
span dates and a forward change cannot fork a closed one.
DETERMINISTIC REPRESENTATIVE. Which book's payload survives was decided by
position. It is now decided by the existing MODEL_BOOKS declaration order —
reused, not authored; inventing a sportsbook ranking to settle a tiebreak
would be a market judgement smuggled in as a bug fix — with book name and a
content tiebreak. Stable under every input permutation.
TWO GUARDS, BOTH DIRECTIONS. split (one person, many identities) and merge
(one identity, many people). A merge is refused at the same single admission
seam event identity already uses; a split is counted and alerted but does not
cut the board, because it duplicates an identity rather than asserting a
falsehood.
RETENTION REMAINS AN INDEPENDENT CHECK. The old assertion grepped the source
for `player_key: nameKey(player)`. That expression stood in for a PROPERTY,
and a grep verifies a spelling. Replaced with the property itself, asserted
in both modes: when the producer emits two rows for one human, retention
still files them under one identity and still reports the collision.
Replay of the real cohort through the repair: 3,129 offerings, 100%
participants resolved, every one of 207 participants on exactly ONE semantic
key, collision 0, split 0, merge 0.
Suite 396/5,455/0 · tsc 0 · 15/15 teeth. Tooth 12 came back green first
time and that was a coverage hole, not a safe defect: nothing asserted
retention's append-only upsert. It does now.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CQJeAG8vcDoL5zkiaJyVb8
This commit is contained in:
Symlink
+1
@@ -0,0 +1 @@
|
|||||||
|
/home/kev/mastermind/vyndr/node_modules
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/* TEETH — inject a defect, prove the suite CATCHES it, restore byte-identically.
|
||||||
|
A green teeth run means the injection missed, not that the risk is absent, so
|
||||||
|
every tooth asserts its own injection landed before the suite is run. */
|
||||||
|
const fs = require('fs'); const path = require('path'); const cp = require('child_process');
|
||||||
|
const ROOT = __dirname;
|
||||||
|
const F = (p) => path.join(ROOT, p);
|
||||||
|
|
||||||
|
const TEETH = [
|
||||||
|
{ n: 1, name: 'resolved dedupe reverts to the raw spelling',
|
||||||
|
file: 'src/services/gradeSlateService.js',
|
||||||
|
from: "const who = p.mlb_person_id != null ? `mlb:${p.mlb_person_id}` : p.player;\n const key = `${propositionEventKey(p)}",
|
||||||
|
to: "const who = p.player;\n const key = `${propositionEventKey(p)}",
|
||||||
|
suite: 'tests/unit/participantIdentityConvergence.test.js' },
|
||||||
|
{ n: 2, name: 'retention ignores the canonical participant (split returns)',
|
||||||
|
file: 'src/services/model/participantIdentity.js',
|
||||||
|
from: "const canonical = p.mlb_person_id != null ? p.canonical_player_name : null;",
|
||||||
|
to: "const canonical = null;",
|
||||||
|
suite: 'tests/unit/participantIdentityConvergence.test.js' },
|
||||||
|
{ n: 3, name: 'two proven personIds sharing a name key are merged, not refused',
|
||||||
|
file: 'src/services/event/eventIdentity.js',
|
||||||
|
from: "if (ids.size !== 1) return null;",
|
||||||
|
to: "if (ids.size < 1) return null;",
|
||||||
|
suite: 'tests/unit/participantIdentityConvergence.test.js tests/unit/canonicalParticipant.test.js' },
|
||||||
|
{ n: 4, name: 'representative chosen by arrival (last wins) — completion-order authority',
|
||||||
|
file: 'src/services/gradeSlateService.js',
|
||||||
|
from: " best.set(key, betterRepresentative(best.get(key), p));",
|
||||||
|
to: " best.set(key, p);",
|
||||||
|
suite: 'tests/unit/participantIdentityConvergence.test.js' },
|
||||||
|
{ n: 5, name: 'representative chosen by input order (first wins)',
|
||||||
|
file: 'src/services/gradeSlateService.js',
|
||||||
|
from: " best.set(key, betterRepresentative(best.get(key), p));",
|
||||||
|
to: " if (!best.has(key)) best.set(key, p);",
|
||||||
|
suite: 'tests/unit/participantIdentityConvergence.test.js' },
|
||||||
|
{ n: 6, name: 'raw provider name dropped from retention (provenance lost)',
|
||||||
|
file: 'src/services/retentionService.js',
|
||||||
|
from: " player_name: normalizeName(player).display || player,",
|
||||||
|
to: " player_name: normalizeName(identity.display_source).display || player,",
|
||||||
|
suite: 'tests/unit/canonicalParticipant.test.js' },
|
||||||
|
{ n: 7, name: 'every participant is required to resolve',
|
||||||
|
file: 'src/services/gradeSlateService.js',
|
||||||
|
from: " if (p.participant_identity_conflict) {",
|
||||||
|
to: " if (p.participant_identity_conflict || (p.event_binding_status === 'RESOLVED' && p.mlb_person_id == null)) {",
|
||||||
|
suite: 'tests/unit/participantIdentityConvergence.test.js' },
|
||||||
|
{ n: 8, name: 'name key used globally, without event/team scope',
|
||||||
|
file: 'src/services/event/eventIdentity.js',
|
||||||
|
from: "const cands = (persons.get(nameKey(prop.player)) || []).filter((c) => abbrs.has(c.abbr));",
|
||||||
|
to: "const cands = (persons.get(nameKey(prop.player)) || []);",
|
||||||
|
suite: 'tests/unit/canonicalParticipant.test.js' },
|
||||||
|
{ n: 9, name: 'doubleheader propositions collapse across gamePk',
|
||||||
|
file: 'src/services/gradeSlateService.js',
|
||||||
|
from: " const key = `${propositionEventKey(p)}::${who}::${p.stat_type}::${p.line}`;\n if (seen.has(key)) { bump('duplicate_identity_removed'); continue; }",
|
||||||
|
to: " const key = `${who}::${p.stat_type}::${p.line}`;\n if (seen.has(key)) { bump('duplicate_identity_removed'); continue; }",
|
||||||
|
suite: 'tests/unit/participantIdentityConvergence.test.js tests/unit/eventPublication.test.js' },
|
||||||
|
{ n: 10, name: 'retention collision hard-zero guard weakened',
|
||||||
|
file: 'src/services/retentionService.js',
|
||||||
|
from: " if (seen.has(id)) collisions += 1;",
|
||||||
|
to: " if (false) collisions += 1;",
|
||||||
|
suite: 'tests/unit/canonicalParticipant.test.js tests/unit/participantIdentityConvergence.test.js' },
|
||||||
|
{ n: 11, name: 'lineage natural-key composition changed',
|
||||||
|
file: 'src/services/retentionService.js',
|
||||||
|
from: "const LINEAGE_KEYS",
|
||||||
|
to: "const LINEAGE_KEYS_RENAMED_BY_TEETH",
|
||||||
|
suite: 'tests/unit/publicationLineage.test.js tests/unit/readLineage.test.js', expectLoadError: true },
|
||||||
|
{ n: 12, name: 'lineage history rewritten on an existing row',
|
||||||
|
file: 'src/services/retentionService.js',
|
||||||
|
from: "await upsertSnapshotChunk(supabase, chunk, { ignoreDuplicates: true })",
|
||||||
|
to: "await upsertSnapshotChunk(supabase, chunk, { ignoreDuplicates: false })",
|
||||||
|
suite: 'tests/unit/participantIdentityConvergence.test.js tests/unit/publicationLineage.test.js' },
|
||||||
|
{ n: 13, name: 'model belief moved by the identity repair',
|
||||||
|
file: 'src/services/intelligence/probabilityEstimator.js',
|
||||||
|
from: "function estimate", to: "function estimate_TEETH_BROKEN",
|
||||||
|
suite: 'tests/unit/probabilityEstimator.test.js', expectLoadError: true },
|
||||||
|
{ n: 14, name: 'intraday belief-integrity repair reverted',
|
||||||
|
file: 'src/services/intradayRefreshService.js',
|
||||||
|
from: "const BELIEF_FIELDS", to: "const BELIEF_FIELDS_TEETH",
|
||||||
|
suite: 'tests/unit/intradayBeliefIntegrity.test.js', expectLoadError: true },
|
||||||
|
{ n: 15, name: 'MLB ET cache-date contract reverted to UTC',
|
||||||
|
file: 'src/services/oddsService.js',
|
||||||
|
from: "const ET_DATE_SPORTS = Object.freeze(['mlb']);",
|
||||||
|
to: "const ET_DATE_SPORTS = Object.freeze([]);",
|
||||||
|
suite: 'tests/unit/mlbOddsCacheDate.test.js' },
|
||||||
|
];
|
||||||
|
|
||||||
|
let caught = 0; const missed = [];
|
||||||
|
for (const t of TEETH) {
|
||||||
|
const p = F(t.file);
|
||||||
|
const orig = fs.readFileSync(p, 'utf8');
|
||||||
|
if (!orig.includes(t.from)) { missed.push(`${t.n} ANCHOR NOT FOUND: ${t.name}`); continue; }
|
||||||
|
const broken = orig.replace(t.from, t.to);
|
||||||
|
if (broken === orig) { missed.push(`${t.n} INJECTION NO-OP: ${t.name}`); continue; }
|
||||||
|
fs.writeFileSync(p, broken);
|
||||||
|
// Prove the defect is actually present before trusting any result.
|
||||||
|
const present = fs.readFileSync(p, 'utf8').includes(t.to);
|
||||||
|
let failed = false;
|
||||||
|
try {
|
||||||
|
cp.execSync(`npx jest ${t.suite} --testPathIgnorePatterns "/node_modules/" --silent`,
|
||||||
|
{ cwd: ROOT, stdio: 'pipe' });
|
||||||
|
} catch { failed = true; }
|
||||||
|
fs.writeFileSync(p, orig);
|
||||||
|
if (fs.readFileSync(p, 'utf8') !== orig) { console.error('RESTORE FAILED', t.file); process.exit(2); }
|
||||||
|
if (!present) { missed.push(`${t.n} INJECTION NOT PRESENT: ${t.name}`); continue; }
|
||||||
|
if (failed) { caught += 1; console.log(` tooth ${String(t.n).padStart(2)} CAUGHT ${t.name}`); }
|
||||||
|
else { missed.push(`${t.n} NOT CAUGHT: ${t.name}`); console.log(` tooth ${String(t.n).padStart(2)} MISSED ${t.name}`); }
|
||||||
|
}
|
||||||
|
console.log(`\n ${caught}/${TEETH.length} teeth landed`);
|
||||||
|
if (missed.length) { console.log(' PROBLEMS:'); missed.forEach((m) => console.log(' ', m)); process.exit(1); }
|
||||||
@@ -427,20 +427,36 @@ async function resolveTeam(abbr, season = DEFAULT_SEASON) {
|
|||||||
* Active roster for a team id (Session 51) → [{ id, name, position, jersey }].
|
* Active roster for a team id (Session 51) → [{ id, name, position, jersey }].
|
||||||
* Cached 6h. [] on failure.
|
* Cached 6h. [] on failure.
|
||||||
*/
|
*/
|
||||||
async function getTeamRoster(teamId, season = DEFAULT_SEASON, asOfDate = null) {
|
async function getTeamRoster(teamId, season = DEFAULT_SEASON, asOfDate = null, rosterType = 'active') {
|
||||||
if (!teamId) return [];
|
if (!teamId) return [];
|
||||||
// `date` scopes the roster to a MOMENT. Without it the answer is "today",
|
// `date` scopes the roster to a MOMENT. Without it the answer is "today",
|
||||||
// which is the wrong evidence for judging any other slate date. Verified:
|
// which is the wrong evidence for judging any other slate date. Verified:
|
||||||
// Joe Mack appears on the 2026-08-26 Marlins roster and not on 2026-04-15.
|
// Joe Mack appears on the 2026-08-26 Marlins roster and not on 2026-04-15.
|
||||||
const d = asOfDate ? `&date=${String(asOfDate).slice(0, 10)}` : '';
|
const d = asOfDate ? `&date=${String(asOfDate).slice(0, 10)}` : '';
|
||||||
const url = `${BASE}/teams/${teamId}/roster?rosterType=active&season=${season}${d}`;
|
const rt = rosterType === '40Man' ? '40Man' : 'active';
|
||||||
const data = await fetchWithCache(url, `mlbstats:roster:${teamId}:${season}:${asOfDate || 'now'}`, 6 * 3600);
|
// `hydrate=person` expands each roster entry's person object in the SAME
|
||||||
|
// request — firstName / useName / useLastName arrive at no extra API cost.
|
||||||
|
// Those fields are what let one human's several published spellings converge
|
||||||
|
// on one identity without an alias table.
|
||||||
|
const url = `${BASE}/teams/${teamId}/roster?rosterType=${rt}&season=${season}${d}&hydrate=person`;
|
||||||
|
// rosterType is IN the cache key: a cached 'active' answer must never be
|
||||||
|
// served to a caller that asked for the 40-man, and the hydrate widening
|
||||||
|
// means pre-existing cache entries would be missing the new name fields.
|
||||||
|
const data = await fetchWithCache(url, `mlbstats:roster:${teamId}:${season}:${rt}:${asOfDate || 'now'}`, 6 * 3600);
|
||||||
const roster = (data && Array.isArray(data.roster)) ? data.roster : [];
|
const roster = (data && Array.isArray(data.roster)) ? data.roster : [];
|
||||||
return roster.map((r) => ({
|
return roster.map((r) => ({
|
||||||
id: r.person?.id ?? null,
|
id: r.person?.id ?? null,
|
||||||
name: r.person?.fullName ?? null,
|
name: r.person?.fullName ?? null,
|
||||||
position: r.position?.abbreviation ?? null,
|
position: r.position?.abbreviation ?? null,
|
||||||
jersey: r.jerseyNumber ?? null,
|
jersey: r.jerseyNumber ?? null,
|
||||||
|
// Raw StatsAPI name parts. Consumed by participantIdentity to build the
|
||||||
|
// legitimate name forms for this person. Absent on a non-hydrated response,
|
||||||
|
// in which case only the full name is usable — degrades, never throws.
|
||||||
|
fullName: r.person?.fullName ?? null,
|
||||||
|
firstName: r.person?.firstName ?? null,
|
||||||
|
lastName: r.person?.lastName ?? null,
|
||||||
|
useName: r.person?.useName ?? null,
|
||||||
|
useLastName: r.person?.useLastName ?? null,
|
||||||
})).filter((p) => p.id && p.name);
|
})).filter((p) => p.id && p.name);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -122,6 +122,7 @@ const norm = (v) => String(v || '').toLowerCase().replace(/[^a-z]/g, '');
|
|||||||
*/
|
*/
|
||||||
const { NAME_TO_ABBR } = require('../environmentContext');
|
const { NAME_TO_ABBR } = require('../environmentContext');
|
||||||
const { nameKey } = require('../../utils/playerName');
|
const { nameKey } = require('../../utils/playerName');
|
||||||
|
const { participantNameForms } = require('../model/participantIdentity');
|
||||||
|
|
||||||
const TEAM_INDEX = (() => {
|
const TEAM_INDEX = (() => {
|
||||||
const byFlatName = new Map();
|
const byFlatName = new Map();
|
||||||
@@ -220,10 +221,17 @@ function checkParticipantEvidence(prop, game, playerTeams) {
|
|||||||
async function buildPlayerTeamIndex(games, deps) {
|
async function buildPlayerTeamIndex(games, deps) {
|
||||||
const d = deps || {};
|
const d = deps || {};
|
||||||
const getRoster = d.getTeamRoster;
|
const getRoster = d.getTeamRoster;
|
||||||
|
// OPTIONAL second reader for the 40-man. Absent -> `persons` is built from
|
||||||
|
// the active roster alone, i.e. exactly the previous behaviour. A caller that
|
||||||
|
// cannot read the 40-man loses reach, never correctness.
|
||||||
|
const getRoster40 = typeof d.getTeamRoster40 === 'function' ? d.getTeamRoster40 : null;
|
||||||
const asOfDate = d.asOfDate || null;
|
const asOfDate = d.asOfDate || null;
|
||||||
const index = new Map();
|
const index = new Map();
|
||||||
const persons = new Map();
|
const persons = new Map();
|
||||||
const out = { teams: 0, players: 0, failed: 0, as_of_date: asOfDate, date_scoped: false };
|
const out = {
|
||||||
|
teams: 0, players: 0, failed: 0, as_of_date: asOfDate, date_scoped: false,
|
||||||
|
persons: 0, person_rows: 0, roster40_teams: 0, roster40_failed: 0, name_forms: 0,
|
||||||
|
};
|
||||||
if (typeof getRoster !== 'function') return { index, persons, stats: out };
|
if (typeof getRoster !== 'function') return { index, persons, stats: out };
|
||||||
|
|
||||||
const ids = new Map();
|
const ids = new Map();
|
||||||
@@ -244,24 +252,57 @@ async function buildPlayerTeamIndex(games, deps) {
|
|||||||
out.teams += 1;
|
out.teams += 1;
|
||||||
const abbr = teamAbbr(name);
|
const abbr = teamAbbr(name);
|
||||||
if (!abbr) continue;
|
if (!abbr) continue;
|
||||||
|
|
||||||
|
// TEAM EVIDENCE stays on the ACTIVE roster, byte-identical to before.
|
||||||
|
// `index` feeds evidenceIsDateValid and the impossible-binding refusal;
|
||||||
|
// widening it would change what counts as a contradiction, which is a
|
||||||
|
// different question from who a participant is and is not in scope here.
|
||||||
for (const p of roster) {
|
for (const p of roster) {
|
||||||
const canonicalName = p && (p.name || p.fullName || p.player_name);
|
const canonicalName = p && (p.name || p.fullName || p.player_name);
|
||||||
const k = nameKey(canonicalName);
|
const k = nameKey(canonicalName);
|
||||||
if (!k) continue;
|
if (!k) continue;
|
||||||
if (!index.has(k)) index.set(k, new Set());
|
if (!index.has(k)) index.set(k, new Set());
|
||||||
index.get(k).add(abbr);
|
index.get(k).add(abbr);
|
||||||
// CANONICAL PARTICIPANT. The roster row already carries the StatsAPI
|
out.players += 1;
|
||||||
// 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
|
// CANONICAL PARTICIPANT is built over the ACTIVE roster PLUS the 40-man.
|
||||||
// real 2026-08-28 league rosters, `max muncy`, `jose fermin` and
|
// Measured on the real 2026-08-30 slate: Mickey Gasper (681508) is on the
|
||||||
// `luis garcia` each resolve to TWO different people.
|
// Boston 40-man and NOT on the active roster, so an active-only index could
|
||||||
|
// not identify him and every book's spelling of him survived dedupe
|
||||||
|
// separately. Still event-scoped — only the two clubs playing this game —
|
||||||
|
// so a shared name key can never reach across the league.
|
||||||
|
let wide = roster;
|
||||||
|
if (getRoster40) {
|
||||||
|
let extra = null;
|
||||||
|
try { extra = await getRoster40(id, asOfDate); } catch { extra = null; }
|
||||||
|
if (Array.isArray(extra)) { out.roster40_teams += 1; wide = roster.concat(extra); }
|
||||||
|
else out.roster40_failed += 1;
|
||||||
|
}
|
||||||
|
const seenPerson = new Set();
|
||||||
|
for (const p of wide) {
|
||||||
const personId = p && (p.id != null ? p.id : p.personId);
|
const personId = p && (p.id != null ? p.id : p.personId);
|
||||||
if (personId != null && abbr) {
|
if (personId == null) continue;
|
||||||
|
const dedupeKey = `${abbr}|${personId}`;
|
||||||
|
if (seenPerson.has(dedupeKey)) continue;
|
||||||
|
seenPerson.add(dedupeKey);
|
||||||
|
const canonicalName = p.name || p.fullName || p.player_name;
|
||||||
|
if (!canonicalName) continue;
|
||||||
|
// Every name form MLB's own record supports for this human. No alias
|
||||||
|
// table: an alias table is a list of the mistakes we happened to notice.
|
||||||
|
const forms = participantNameForms({
|
||||||
|
fullName: p.fullName || canonicalName,
|
||||||
|
firstName: p.firstName,
|
||||||
|
lastName: p.lastName,
|
||||||
|
useName: p.useName,
|
||||||
|
useLastName: p.useLastName,
|
||||||
|
});
|
||||||
|
out.person_rows += 1;
|
||||||
|
for (const k of forms) {
|
||||||
if (!persons.has(k)) persons.set(k, []);
|
if (!persons.has(k)) persons.set(k, []);
|
||||||
persons.get(k).push({ personId, canonicalName, abbr });
|
persons.get(k).push({ personId, canonicalName, abbr });
|
||||||
|
out.name_forms += 1;
|
||||||
}
|
}
|
||||||
out.players += 1;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
out.date_scoped = Boolean(asOfDate) && out.teams > 0;
|
out.date_scoped = Boolean(asOfDate) && out.teams > 0;
|
||||||
|
|||||||
@@ -26,7 +26,7 @@
|
|||||||
// We dedupe to unique player+stat+line and cap how many we grade, because
|
// We dedupe to unique player+stat+line and cap how many we grade, because
|
||||||
// each grade fans out to feature computation. Grading runs at most once per
|
// each grade fans out to feature computation. Grading runs at most once per
|
||||||
// cache-miss per sport, but we still bound the herd.
|
// cache-miss per sport, but we still bound the herd.
|
||||||
const { isModelBook } = require('../config/bookRoles');
|
const { isModelBook, MODEL_BOOKS } = require('../config/bookRoles');
|
||||||
|
|
||||||
// RAISED 25 -> 500 on 2026-08-01, on measured cost, not taste.
|
// RAISED 25 -> 500 on 2026-08-01, on measured cost, not taste.
|
||||||
//
|
//
|
||||||
@@ -119,6 +119,17 @@ function admitForGrading(props, sport) {
|
|||||||
const reasons = {};
|
const reasons = {};
|
||||||
for (const p of list) {
|
for (const p of list) {
|
||||||
if (!p) continue;
|
if (!p) continue;
|
||||||
|
// PARTICIPANT IDENTITY MERGE. One semantic identity standing for two
|
||||||
|
// different MLB humans is the ONE identity failure that can put a wrong
|
||||||
|
// participant into a Read, so it is refused here — at the same single seam
|
||||||
|
// event admission already uses, before dedupe and before grading.
|
||||||
|
if (p.participant_identity_conflict) {
|
||||||
|
p.event_admission = 'REJECTED';
|
||||||
|
p.event_rejection_reason = 'PARTICIPANT_IDENTITY_MERGE';
|
||||||
|
reasons.PARTICIPANT_IDENTITY_MERGE = (reasons.PARTICIPANT_IDENTITY_MERGE || 0) + 1;
|
||||||
|
rejected.push(p);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
const status = p.event_binding_status;
|
const status = p.event_binding_status;
|
||||||
// A sport with no canonical resolver is unchanged: it never had canonical
|
// A sport with no canonical resolver is unchanged: it never had canonical
|
||||||
// identity, and gating it would delete its slate for no safety gain.
|
// identity, and gating it would delete its slate for no safety gain.
|
||||||
@@ -208,6 +219,65 @@ function propositionEventKey(p) {
|
|||||||
return `legacy:${date}:${away}@${home}`;
|
return `legacy:${date}:${away}@${home}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DETERMINISTIC REPRESENTATIVE.
|
||||||
|
*
|
||||||
|
* One semantic proposition is published by many books. Exactly one of those
|
||||||
|
* offerings is retained, and WHICH one must not depend on where it happened to
|
||||||
|
* sit in the provider's response.
|
||||||
|
*
|
||||||
|
* The ordering is `MODEL_BOOKS` read in its own declaration order — REUSED, not
|
||||||
|
* authored. It is not a claim that DraftKings prices better than Pinnacle; it
|
||||||
|
* is the one book ordering this repository already contains, and inventing a
|
||||||
|
* sportsbook ranking to settle a tiebreak would be a market judgement smuggled
|
||||||
|
* in as a bug fix. Book name and a content tiebreak follow, so two offerings
|
||||||
|
* from the same book still resolve to one answer.
|
||||||
|
*/
|
||||||
|
const MODEL_BOOK_ORDER = Object.freeze([...MODEL_BOOKS]);
|
||||||
|
|
||||||
|
function representativeRank(p) {
|
||||||
|
const book = String((p && p.book) || '').toLowerCase();
|
||||||
|
const i = MODEL_BOOK_ORDER.indexOf(book);
|
||||||
|
return [
|
||||||
|
i < 0 ? MODEL_BOOK_ORDER.length : i,
|
||||||
|
book,
|
||||||
|
// Content tiebreak. Never a timestamp and never an array position: both
|
||||||
|
// reintroduce exactly the order-dependence this exists to remove.
|
||||||
|
JSON.stringify([p && p.line, p && p.over_odds, p && p.under_odds]),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function betterRepresentative(a, b) {
|
||||||
|
if (!a) return b;
|
||||||
|
if (!b) return a;
|
||||||
|
const ra = representativeRank(a);
|
||||||
|
const rb = representativeRank(b);
|
||||||
|
for (let i = 0; i < ra.length; i += 1) {
|
||||||
|
if (ra[i] < rb[i]) return a;
|
||||||
|
if (ra[i] > rb[i]) return b;
|
||||||
|
}
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A SIDE-EFFECT-FREE event key, for the representative pass only.
|
||||||
|
*
|
||||||
|
* `propositionEventKey` INCREMENTS a fracture sequence for a prop whose event
|
||||||
|
* could not be resolved, so calling it twice on the same prop yields two
|
||||||
|
* different keys. A fractured prop is unique by construction and can never
|
||||||
|
* share an identity with anything, so the representative pass simply skips it
|
||||||
|
* (null) rather than re-fracturing it.
|
||||||
|
*/
|
||||||
|
function stableEventKey(p) {
|
||||||
|
if (p && p.canonical_event_id) return p.canonical_event_id;
|
||||||
|
const method = p && p.event_identity_method;
|
||||||
|
if (method && method !== 'UNSUPPORTED_SPORT') return null;
|
||||||
|
const away = String((p && p.away_team) || '').replace(/\s+/g, '');
|
||||||
|
const home = String((p && p.home_team) || '').replace(/\s+/g, '');
|
||||||
|
const date = (p && p.game_date) || '';
|
||||||
|
return `legacy:${date}:${away}@${home}`;
|
||||||
|
}
|
||||||
|
|
||||||
function dedupeProps(props, limit, stats) {
|
function dedupeProps(props, limit, stats) {
|
||||||
const seen = new Set();
|
const seen = new Set();
|
||||||
const out = [];
|
const out = [];
|
||||||
@@ -218,6 +288,10 @@ function dedupeProps(props, limit, stats) {
|
|||||||
const bump = (k) => { if (stats) stats[k] = (stats[k] || 0) + 1; };
|
const bump = (k) => { if (stats) stats[k] = (stats[k] || 0) + 1; };
|
||||||
const list = props || [];
|
const list = props || [];
|
||||||
let examined = 0;
|
let examined = 0;
|
||||||
|
const order = [];
|
||||||
|
// Remembers the key each ADMITTED prop was filed under, so the representative
|
||||||
|
// pass never recomputes a key that has a side effect.
|
||||||
|
const admittedKeyOf = new Map();
|
||||||
for (const p of list) {
|
for (const p of list) {
|
||||||
examined += 1;
|
examined += 1;
|
||||||
if (!p || !p.player || !p.stat_type || p.line == null) { bump('dropped_invalid_fields'); continue; }
|
if (!p || !p.player || !p.stat_type || p.line == null) { bump('dropped_invalid_fields'); continue; }
|
||||||
@@ -231,16 +305,37 @@ function dedupeProps(props, limit, stats) {
|
|||||||
const key = `${propositionEventKey(p)}::${who}::${p.stat_type}::${p.line}`;
|
const key = `${propositionEventKey(p)}::${who}::${p.stat_type}::${p.line}`;
|
||||||
if (seen.has(key)) { bump('duplicate_identity_removed'); continue; }
|
if (seen.has(key)) { bump('duplicate_identity_removed'); continue; }
|
||||||
seen.add(key);
|
seen.add(key);
|
||||||
|
order.push(key);
|
||||||
|
admittedKeyOf.set(p, key);
|
||||||
out.push(p);
|
out.push(p);
|
||||||
if (out.length >= limit) { if (stats) stats.capped = true; break; }
|
if (out.length >= limit) { if (stats) stats.capped = true; break; }
|
||||||
}
|
}
|
||||||
|
// SECOND PASS — representative selection only. The admitted key SET and its
|
||||||
|
// order are already fixed above and are not touched here, so the cap and
|
||||||
|
// every counter keep their existing meaning. This pass exists so that which
|
||||||
|
// book's payload is retained is decided by a rule instead of by position.
|
||||||
|
const best = new Map();
|
||||||
|
const admitted = new Set(order);
|
||||||
|
for (let i = 0; i < list.length; i += 1) {
|
||||||
|
const p = list[i];
|
||||||
|
if (!p || !p.player || !p.stat_type || p.line == null) continue;
|
||||||
|
if (!isModelBook(p.book)) continue;
|
||||||
|
const ev = admittedKeyOf.has(p) ? null : stableEventKey(p);
|
||||||
|
const who = p.mlb_person_id != null ? `mlb:${p.mlb_person_id}` : p.player;
|
||||||
|
const key = admittedKeyOf.has(p)
|
||||||
|
? admittedKeyOf.get(p)
|
||||||
|
: (ev == null ? null : `${ev}::${who}::${p.stat_type}::${p.line}`);
|
||||||
|
if (key == null || !admitted.has(key)) continue;
|
||||||
|
best.set(key, betterRepresentative(best.get(key), p));
|
||||||
|
}
|
||||||
|
const selected = order.map((k) => best.get(k)).filter(Boolean);
|
||||||
if (stats) {
|
if (stats) {
|
||||||
stats.input_count = list.length;
|
stats.input_count = list.length;
|
||||||
stats.output_count = out.length;
|
stats.output_count = selected.length;
|
||||||
stats.model_book_eligible = out.length + (stats.duplicate_identity_removed || 0);
|
stats.model_book_eligible = selected.length + (stats.duplicate_identity_removed || 0);
|
||||||
stats.not_examined = list.length - examined;
|
stats.not_examined = list.length - examined;
|
||||||
}
|
}
|
||||||
return out;
|
return selected;
|
||||||
}
|
}
|
||||||
|
|
||||||
// engine1 is direction-aware, so a prop grades differently over vs under.
|
// engine1 is direction-aware, so a prop grades differently over vs under.
|
||||||
@@ -262,6 +357,12 @@ async function gradeBestSide(grade, prop, sport, opts = {}) {
|
|||||||
factor_context_resolver: typeof opts.factorContext === 'function' ? opts.factorContext : null,
|
factor_context_resolver: typeof opts.factorContext === 'function' ? opts.factorContext : null,
|
||||||
matchup_keys: matchupKeys,
|
matchup_keys: matchupKeys,
|
||||||
player: prop.player,
|
player: prop.player,
|
||||||
|
// CANONICAL PARTICIPANT rides with the grade. Retention and the ledger key
|
||||||
|
// the semantic player on this when it is proven, so a book's spelling can
|
||||||
|
// never re-split a human the league has already identified for us. Absent
|
||||||
|
// when unproven, and then everything downstream behaves exactly as before.
|
||||||
|
canonical_player_name: prop.canonical_player_name ?? null,
|
||||||
|
mlb_person_id: prop.mlb_person_id ?? null,
|
||||||
stat_type: prop.stat_type,
|
stat_type: prop.stat_type,
|
||||||
line: prop.line,
|
line: prop.line,
|
||||||
sport,
|
sport,
|
||||||
|
|||||||
@@ -28,6 +28,20 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
const { nameKey, normalizeName } = require('../utils/playerName');
|
const { nameKey, normalizeName } = require('../utils/playerName');
|
||||||
|
const { semanticPlayerKey } = require('./model/participantIdentity');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Semantic identity for a ledger row, read from whichever of the grade record
|
||||||
|
* or the priced prop carries the proven participant. Retention and the ledger
|
||||||
|
* must never disagree about who a proposition is about.
|
||||||
|
*/
|
||||||
|
function ledgerIdentity(g, prop, player) {
|
||||||
|
return semanticPlayerKey({
|
||||||
|
player,
|
||||||
|
mlb_person_id: (g && g.mlb_person_id) ?? (prop && prop.mlb_person_id) ?? null,
|
||||||
|
canonical_player_name: (g && g.canonical_player_name) ?? (prop && prop.canonical_player_name) ?? null,
|
||||||
|
});
|
||||||
|
}
|
||||||
const { settleResult, statValue, logRowOnDate } = require('./outcomeService');
|
const { settleResult, statValue, logRowOnDate } = require('./outcomeService');
|
||||||
// The LEDGER takeable standard (floor on the minus side, UNCAPPED plus) — a
|
// The LEDGER takeable standard (floor on the minus side, UNCAPPED plus) — a
|
||||||
// DIFFERENT question from valueEngine's -160..+200 promotion band. See
|
// DIFFERENT question from valueEngine's -160..+200 promotion band. See
|
||||||
@@ -280,7 +294,9 @@ function rowsFromSnapshot(sport, grades, oddsProps, nowIso, lineageIndex) {
|
|||||||
team,
|
team,
|
||||||
opponent,
|
opponent,
|
||||||
user_id: null,
|
user_id: null,
|
||||||
player_key: nameKey(player),
|
// Same semantic identity as retention. The two stores must never
|
||||||
|
// disagree about who a proposition is about.
|
||||||
|
player_key: ledgerIdentity(g, prop, player).key,
|
||||||
player_name: normalizeName(player).display || player,
|
player_name: normalizeName(player).display || player,
|
||||||
sport: sp,
|
sport: sp,
|
||||||
stat,
|
stat,
|
||||||
|
|||||||
@@ -0,0 +1,134 @@
|
|||||||
|
/**
|
||||||
|
* PARTICIPANT IDENTITY — one proven MLB human, one semantic identity.
|
||||||
|
*
|
||||||
|
* THREE DISTINCT CONCEPTS, never conflated:
|
||||||
|
*
|
||||||
|
* RAW PROVIDER IDENTITY the spelling a book published. Evidence, never
|
||||||
|
* identity. Preserved verbatim in
|
||||||
|
* `closing_captures.player_name` (per book, per
|
||||||
|
* cycle, undeduped) — this module never mutates it.
|
||||||
|
* CANONICAL PARTICIPANT MLB StatsAPI `personId`. The strongest identity
|
||||||
|
* we can hold, and the ONLY one derived from the
|
||||||
|
* league's own record rather than a vendor's.
|
||||||
|
* SEMANTIC PRODUCT PLAYER `player_key` — what retention, the ledger and
|
||||||
|
* (later) lineage key on. Derived from the
|
||||||
|
* canonical participant's own name WHEN PROVEN,
|
||||||
|
* and from the raw spelling only when it is not.
|
||||||
|
*
|
||||||
|
* The defect this repairs ran in BOTH directions on one real slate:
|
||||||
|
* two spellings of ONE human survived dedupe separately (under-collapse), and
|
||||||
|
* ONE human produced TWO player_keys (split). `collision_count` can only see
|
||||||
|
* the first — a split makes MORE identities, not fewer.
|
||||||
|
*
|
||||||
|
* NAME FORMS ARE READ OFF MLB'S OWN PERSON RECORD. There is no alias table
|
||||||
|
* here and there must never be one: an alias table is a list of the mistakes
|
||||||
|
* we happened to notice.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const { nameKey } = require('../../utils/playerName');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The name forms a person may legitimately be published under, taken from
|
||||||
|
* StatsAPI's own fields.
|
||||||
|
*
|
||||||
|
* `nickName` is DELIBERATELY EXCLUDED and this is load-bearing. Measured over
|
||||||
|
* 821 people across the 9 games of 2026-08-30, adding `nickName` produced 14
|
||||||
|
* ambiguous keys — MLB's nickname field carries bare surnames ("Jones",
|
||||||
|
* "Wilson") and shared clubhouse names: `nameKey('Smitty Smith')` is the same
|
||||||
|
* string for Burch Smith (572143) and Will Smith (669257). The four forms kept
|
||||||
|
* here produced ZERO ambiguous keys over the same population.
|
||||||
|
*/
|
||||||
|
const NAME_FORM_FIELDS = Object.freeze(['full', 'first_last', 'use_last', 'use_uselast']);
|
||||||
|
|
||||||
|
function participantNameForms(person) {
|
||||||
|
const p = person || {};
|
||||||
|
const last = p.lastName || '';
|
||||||
|
const raw = [
|
||||||
|
p.fullName,
|
||||||
|
`${p.firstName || ''} ${last}`,
|
||||||
|
`${p.useName || ''} ${last}`,
|
||||||
|
`${p.useName || ''} ${p.useLastName || last}`,
|
||||||
|
];
|
||||||
|
const out = new Set();
|
||||||
|
for (const r of raw) {
|
||||||
|
const k = nameKey(r);
|
||||||
|
if (k) out.add(k);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The semantic product identity for a proposition.
|
||||||
|
*
|
||||||
|
* PROVEN participant -> derived from the participant's own canonical name, so
|
||||||
|
* every provider spelling of that human converges on one key.
|
||||||
|
* UNPROVEN -> the raw spelling, exactly as before. We never guess: an
|
||||||
|
* unresolved participant keeps behaving the way it always has.
|
||||||
|
*/
|
||||||
|
function semanticPlayerKey(prop) {
|
||||||
|
const p = prop || {};
|
||||||
|
const canonical = p.mlb_person_id != null ? p.canonical_player_name : null;
|
||||||
|
const source = canonical || p.player;
|
||||||
|
return { key: nameKey(source), display_source: source, canonical: !!canonical };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* STEP 3 INVARIANT, BOTH DIRECTIONS, within one canonical event.
|
||||||
|
*
|
||||||
|
* SPLIT one proven personId -> more than one semantic key. Expected 0.
|
||||||
|
* MERGE one semantic key -> more than one proven personId. Expected 0.
|
||||||
|
*
|
||||||
|
* Rows whose participant is unproven are EXCLUDED from both counts. They carry
|
||||||
|
* no personId, so neither statement can be made about them — counting them
|
||||||
|
* would be asserting something we do not know.
|
||||||
|
*/
|
||||||
|
function auditParticipantIdentity(rows) {
|
||||||
|
const list = Array.isArray(rows) ? rows : [];
|
||||||
|
const byPerson = new Map();
|
||||||
|
const byKey = new Map();
|
||||||
|
let proven = 0;
|
||||||
|
let unproven = 0;
|
||||||
|
for (const r of list) {
|
||||||
|
if (!r) continue;
|
||||||
|
const ev = r.canonical_event_id || null;
|
||||||
|
const pid = r.mlb_person_id;
|
||||||
|
const key = r.player_key;
|
||||||
|
if (pid == null || !ev || !key) { unproven += 1; continue; }
|
||||||
|
proven += 1;
|
||||||
|
const pk = `${ev}|${pid}`;
|
||||||
|
const kk = `${ev}|${key}`;
|
||||||
|
if (!byPerson.has(pk)) byPerson.set(pk, new Set());
|
||||||
|
byPerson.get(pk).add(key);
|
||||||
|
if (!byKey.has(kk)) byKey.set(kk, new Set());
|
||||||
|
byKey.get(kk).add(pid);
|
||||||
|
}
|
||||||
|
const splits = [];
|
||||||
|
for (const [k, s] of byPerson) {
|
||||||
|
if (s.size > 1) {
|
||||||
|
const [event_id, person_id] = k.split('|');
|
||||||
|
splits.push({ event_id, person_id, player_keys: [...s].sort() });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const merges = [];
|
||||||
|
for (const [k, s] of byKey) {
|
||||||
|
if (s.size > 1) {
|
||||||
|
const i = k.indexOf('|');
|
||||||
|
merges.push({ event_id: k.slice(0, i), player_key: k.slice(i + 1), person_ids: [...s].sort() });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
proven_rows: proven,
|
||||||
|
unproven_rows: unproven,
|
||||||
|
participant_identity_split_count: splits.length,
|
||||||
|
participant_identity_merge_count: merges.length,
|
||||||
|
splits,
|
||||||
|
merges,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
NAME_FORM_FIELDS,
|
||||||
|
participantNameForms,
|
||||||
|
semanticPlayerKey,
|
||||||
|
auditParticipantIdentity,
|
||||||
|
};
|
||||||
@@ -27,6 +27,7 @@
|
|||||||
|
|
||||||
const crypto = require('crypto');
|
const crypto = require('crypto');
|
||||||
const { normalizeName, nameKey } = require('../utils/playerName');
|
const { normalizeName, nameKey } = require('../utils/playerName');
|
||||||
|
const { semanticPlayerKey } = require('./model/participantIdentity');
|
||||||
|
|
||||||
/** ET calendar date of an ISO timestamp (shares gameBinder's rule). */
|
/** ET calendar date of an ISO timestamp (shares gameBinder's rule). */
|
||||||
function etDateOf(iso) {
|
function etDateOf(iso) {
|
||||||
@@ -97,6 +98,16 @@ function rowsFromSides(base, sides, ctx = {}) {
|
|||||||
if (!s) continue;
|
if (!s) continue;
|
||||||
const player = s.player || base.player;
|
const player = s.player || base.player;
|
||||||
if (!player) continue;
|
if (!player) continue;
|
||||||
|
// SEMANTIC PLAYER IDENTITY. When MLB has proven who this participant is,
|
||||||
|
// the identity comes from the league's own name for them, so every book's
|
||||||
|
// spelling converges. When it has not, the raw spelling is used exactly as
|
||||||
|
// before — we never guess a human into existence. The raw provider string
|
||||||
|
// is untouched here and survives verbatim in closing_captures.
|
||||||
|
const identity = semanticPlayerKey({
|
||||||
|
player,
|
||||||
|
mlb_person_id: s.mlb_person_id ?? base.mlb_person_id ?? null,
|
||||||
|
canonical_player_name: s.canonical_player_name ?? base.canonical_player_name ?? null,
|
||||||
|
});
|
||||||
const stat = s.stat_type || base.stat_type;
|
const stat = s.stat_type || base.stat_type;
|
||||||
const line = numOrNull(s.line != null ? s.line : base.line);
|
const line = numOrNull(s.line != null ? s.line : base.line);
|
||||||
const side = String(s.direction || '').toLowerCase();
|
const side = String(s.direction || '').toLowerCase();
|
||||||
@@ -117,7 +128,11 @@ function rowsFromSides(base, sides, ctx = {}) {
|
|||||||
// never the snapshot clock. ctx.gameDate is only a last resort for props
|
// never the snapshot clock. ctx.gameDate is only a last resort for props
|
||||||
// the binder could not tie to a real game.
|
// the binder could not tie to a real game.
|
||||||
game_date: etDateOf(base && base.game_time) || ctx.gameDate,
|
game_date: etDateOf(base && base.game_time) || ctx.gameDate,
|
||||||
player_key: nameKey(player),
|
player_key: identity.key,
|
||||||
|
// RAW SOURCE NAME, deliberately. The identity above converges on the
|
||||||
|
// league's record; the NAME on the row stays the provider's, so the row
|
||||||
|
// still carries the representation it was published under. Identity and
|
||||||
|
// provenance are different jobs and this row does both.
|
||||||
player_name: normalizeName(player).display || player,
|
player_name: normalizeName(player).display || player,
|
||||||
team: s.team || base.team || null,
|
team: s.team || base.team || null,
|
||||||
opponent: s.opponent || base.opponent || null,
|
opponent: s.opponent || base.opponent || null,
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ const STATS_CONCURRENCY = 5;
|
|||||||
|
|
||||||
const { nameKey, normalizeName } = require('../utils/playerName');
|
const { nameKey, normalizeName } = require('../utils/playerName');
|
||||||
const acq = require('./ops/acquisitionTrace');
|
const acq = require('./ops/acquisitionTrace');
|
||||||
|
const participantIdentity = require('./model/participantIdentity');
|
||||||
// Session 46 — group/dedupe by the normalized name key so "A.J. Ewing" and
|
// Session 46 — group/dedupe by the normalized name key so "A.J. Ewing" and
|
||||||
// "AJ Ewing" (or "Jazz Chisholm" / "Jazz Chisholm Jr.") collapse to one player.
|
// "AJ Ewing" (or "Jazz Chisholm" / "Jazz Chisholm Jr.") collapse to one player.
|
||||||
const norm = (s) => nameKey(s);
|
const norm = (s) => nameKey(s);
|
||||||
@@ -572,6 +573,13 @@ async function runSnapshot(sport, opts = {}) {
|
|||||||
try {
|
try {
|
||||||
built = await evid.buildPlayerTeamIndex(games, {
|
built = await evid.buildPlayerTeamIndex(games, {
|
||||||
getTeamRoster: (id, asOf) => mlbAdapter.getTeamRoster(id, undefined, asOf),
|
getTeamRoster: (id, asOf) => mlbAdapter.getTeamRoster(id, undefined, asOf),
|
||||||
|
// 40-man as well, for CANONICAL PARTICIPANT reach only. Measured:
|
||||||
|
// Mickey Gasper (681508) is on the Boston 40-man and not on the
|
||||||
|
// active roster, so an active-only index could not identify him and
|
||||||
|
// every book's spelling of him survived dedupe as its own prop.
|
||||||
|
// Team evidence (the impossible-binding refusal) still reads the
|
||||||
|
// ACTIVE roster alone and is unchanged.
|
||||||
|
getTeamRoster40: (id, asOf) => mlbAdapter.getTeamRoster(id, undefined, asOf, '40Man'),
|
||||||
asOfDate: slateDate,
|
asOfDate: slateDate,
|
||||||
});
|
});
|
||||||
playerTeams = built.index;
|
playerTeams = built.index;
|
||||||
@@ -599,6 +607,7 @@ async function runSnapshot(sport, opts = {}) {
|
|||||||
// unresolved prop simply carries no participant id and behaves exactly
|
// unresolved prop simply carries no participant id and behaves exactly
|
||||||
// as before. The raw provider name is never overwritten.
|
// as before. The raw provider name is never overwritten.
|
||||||
let participants = 0;
|
let participants = 0;
|
||||||
|
let idAudit = null;
|
||||||
if (built && built.persons) {
|
if (built && built.persons) {
|
||||||
for (const pr of props) {
|
for (const pr of props) {
|
||||||
const who = evid.resolveParticipant(pr, games, built.persons);
|
const who = evid.resolveParticipant(pr, games, built.persons);
|
||||||
@@ -609,6 +618,51 @@ async function runSnapshot(sport, opts = {}) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
console.log(`[snapshot] canonical participants ${sp}: ${participants}/${props.length}`);
|
console.log(`[snapshot] canonical participants ${sp}: ${participants}/${props.length}`);
|
||||||
|
// IDENTITY CONVERGENCE GUARDS. `collision_count` can only see one of
|
||||||
|
// the two failure directions: it counts rows that COLLAPSED. A split —
|
||||||
|
// one proven human wearing two semantic identities — makes MORE
|
||||||
|
// identities, so the collision metric is blind to it by construction.
|
||||||
|
// Both directions are asserted here, where the personId is known.
|
||||||
|
try {
|
||||||
|
idAudit = participantIdentity.auditParticipantIdentity(props.map((pr) => ({
|
||||||
|
canonical_event_id: pr.canonical_event_id,
|
||||||
|
mlb_person_id: pr.mlb_person_id,
|
||||||
|
player_key: participantIdentity.semanticPlayerKey(pr).key,
|
||||||
|
})));
|
||||||
|
if (idAudit.participant_identity_merge_count > 0) {
|
||||||
|
// A merge means one semantic identity is standing for two different
|
||||||
|
// humans. That is the one direction that can put a WRONG
|
||||||
|
// participant into a Read, so those propositions do not get graded.
|
||||||
|
// Structurally this should be unreachable — resolveParticipant
|
||||||
|
// already refuses a name key that matches more than one person in
|
||||||
|
// the event — which is exactly why a nonzero value here is worth
|
||||||
|
// stopping for rather than logging.
|
||||||
|
const bad = new Set(idAudit.merges.map((m) => `${m.event_id}|${m.player_key}`));
|
||||||
|
let marked = 0;
|
||||||
|
for (const pr of props) {
|
||||||
|
if (!bad.has(`${pr.canonical_event_id}|${participantIdentity.semanticPlayerKey(pr).key}`)) continue;
|
||||||
|
// MARKED, not filtered. `props` is consumed by several downstream
|
||||||
|
// readers; admitForGrading is the one seam that decides what
|
||||||
|
// reaches the model, so the refusal is expressed there.
|
||||||
|
pr.participant_identity_conflict = true;
|
||||||
|
marked += 1;
|
||||||
|
}
|
||||||
|
console.warn(`[snapshot] participant MERGE for ${sp}: refused ${marked} propositions across `
|
||||||
|
+ `${idAudit.participant_identity_merge_count} merged identities ${JSON.stringify(idAudit.merges)}`);
|
||||||
|
await deps.notify(`Participant identity MERGE for ${sp.toUpperCase()} — ${idAudit.participant_identity_merge_count} semantic identities each matched more than one MLB person. Those propositions were not graded.`, { tag: 'identity' });
|
||||||
|
}
|
||||||
|
if (idAudit.participant_identity_split_count > 0) {
|
||||||
|
// A split duplicates an identity; it does not assert a falsehood,
|
||||||
|
// so the board is not cut. It is still a hard-zero expectation.
|
||||||
|
console.warn(`[snapshot] participant SPLIT for ${sp}: ${idAudit.participant_identity_split_count} `
|
||||||
|
+ `proven humans carry more than one semantic identity ${JSON.stringify(idAudit.splits)}`);
|
||||||
|
await deps.notify(`Participant identity SPLIT for ${sp.toUpperCase()} — ${idAudit.participant_identity_split_count} proven MLB humans produced more than one semantic player identity.`, { tag: 'identity' });
|
||||||
|
}
|
||||||
|
console.log(`[snapshot] participant identity ${sp}: split=${idAudit.participant_identity_split_count} `
|
||||||
|
+ `merge=${idAudit.participant_identity_merge_count} proven_rows=${idAudit.proven_rows} unproven_rows=${idAudit.unproven_rows}`);
|
||||||
|
} catch (eId) {
|
||||||
|
console.warn(`[snapshot] participant identity audit failed for ${sp} (slate continues):`, eId.message);
|
||||||
|
}
|
||||||
pgRec.identity({
|
pgRec.identity({
|
||||||
started: true, completed: true, threw: false,
|
started: true, completed: true, threw: false,
|
||||||
schedule_games: games.length, dates_requested: dates.length,
|
schedule_games: games.length, dates_requested: dates.length,
|
||||||
@@ -617,6 +671,9 @@ async function runSnapshot(sport, opts = {}) {
|
|||||||
unsupported: e.unsupported, contradicted: e.impossible,
|
unsupported: e.unsupported, contradicted: e.impossible,
|
||||||
reasons: { ...e.reasons },
|
reasons: { ...e.reasons },
|
||||||
participants_resolved: participants,
|
participants_resolved: participants,
|
||||||
|
participant_identity_split_count: idAudit ? idAudit.participant_identity_split_count : null,
|
||||||
|
participant_identity_merge_count: idAudit ? idAudit.participant_identity_merge_count : null,
|
||||||
|
participant_proven_rows: idAudit ? idAudit.proven_rows : null,
|
||||||
});
|
});
|
||||||
console.log(`[snapshot] event identity ${sp}: ${e.canonical}/${e.total} canonical, `
|
console.log(`[snapshot] event identity ${sp}: ${e.canonical}/${e.total} canonical, `
|
||||||
+ `${e.unresolved} unresolved, ${e.impossible} contradicted`
|
+ `${e.unresolved} unresolved, ${e.impossible} contradicted`
|
||||||
|
|||||||
@@ -190,11 +190,45 @@ describe('SOURCE PROVENANCE IS PRESERVED', () => {
|
|||||||
expect(block).not.toMatch(/pr\.player\s*=/);
|
expect(block).not.toMatch(/pr\.player\s*=/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('retention still stores the SOURCE name and its own player_key', () => {
|
test('retention still stores the SOURCE name', () => {
|
||||||
const src = fs.readFileSync(path.join(ROOT, 'src/services/retentionService.js'), 'utf8');
|
const src = fs.readFileSync(path.join(ROOT, 'src/services/retentionService.js'), 'utf8');
|
||||||
expect(src).toMatch(/player_key: nameKey\(player\)/);
|
|
||||||
expect(src).toMatch(/player_name: normalizeName\(player\)\.display \|\| player/);
|
expect(src).toMatch(/player_name: normalizeName\(player\)\.display \|\| player/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This assertion used to grep for `player_key: nameKey(player)`.
|
||||||
|
*
|
||||||
|
* That expression was standing in for a PROPERTY — that retention's collision
|
||||||
|
* counter is an independent detector of the producer failing to collapse a
|
||||||
|
* human — and a grep verifies the spelling instead of the property. The
|
||||||
|
* semantic key now prefers the league's own record when the participant is
|
||||||
|
* PROVEN, which is what stops one human becoming two identities. The property
|
||||||
|
* the grep was protecting is asserted directly below, in both modes, and it
|
||||||
|
* survives: when the producer emits two rows for one human, retention still
|
||||||
|
* files them under one identity and still reports the collision.
|
||||||
|
*/
|
||||||
|
test('retention still catches the producer under-collapsing — participant PROVEN', () => {
|
||||||
|
const retention = require('../../src/services/retentionService');
|
||||||
|
const rows = retention.rowsFromSides(
|
||||||
|
{ player: 'Mickey Gasper', stat_type: 'hits', line: 0.5, mlb_person_id: 681508, canonical_player_name: 'Mickey Gasper' },
|
||||||
|
[{ direction: 'over', grade: 'C' }], { snapshotId: 's', sport: 'mlb', gameDate: '2026-08-30', gameIdFor: () => 'g' },
|
||||||
|
).concat(retention.rowsFromSides(
|
||||||
|
{ player: 'Michael Gasper', stat_type: 'hits', line: 0.5, mlb_person_id: 681508, canonical_player_name: 'Mickey Gasper' },
|
||||||
|
[{ direction: 'over', grade: 'C' }], { snapshotId: 's', sport: 'mlb', gameDate: '2026-08-30', gameIdFor: () => 'g' },
|
||||||
|
));
|
||||||
|
expect(retention.expectedMaterialization(rows).collision_count).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('retention still catches the producer under-collapsing — participant UNPROVEN', () => {
|
||||||
|
const retention = require('../../src/services/retentionService');
|
||||||
|
const mk = (player) => retention.rowsFromSides(
|
||||||
|
{ player, stat_type: 'hits', line: 0.5 },
|
||||||
|
[{ direction: 'over', grade: 'C' }], { snapshotId: 's', sport: 'mlb', gameDate: '2026-08-30', gameIdFor: () => 'g' },
|
||||||
|
);
|
||||||
|
// No personId anywhere: the key falls back to the raw spelling exactly as
|
||||||
|
// it always did, and the two spellings still meet on one identity.
|
||||||
|
expect(retention.expectedMaterialization(mk('Mickey Gasper').concat(mk('Michael Gasper'))).collision_count).toBe(1);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('RETENTION REMAINS AN INDEPENDENT CHECK', () => {
|
describe('RETENTION REMAINS AN INDEPENDENT CHECK', () => {
|
||||||
|
|||||||
@@ -858,7 +858,9 @@ describe('ROSTER TEMPORAL SEMANTICS — evidence must describe the right moment'
|
|||||||
test('the adapter passes the date through to statsapi', () => {
|
test('the adapter passes the date through to statsapi', () => {
|
||||||
const src = fs.readFileSync(path.join(ROOT, 'src/services/adapters/mlbStatsAdapter.js'), 'utf8');
|
const src = fs.readFileSync(path.join(ROOT, 'src/services/adapters/mlbStatsAdapter.js'), 'utf8');
|
||||||
expect(src).toMatch(/asOfDate \? `&date=/);
|
expect(src).toMatch(/asOfDate \? `&date=/);
|
||||||
expect(src).toMatch(/mlbstats:roster:\$\{teamId\}:\$\{season\}:\$\{asOfDate \|\| 'now'\}/);
|
// The key also carries rosterType now: an 'active' answer must never be
|
||||||
|
// served to a caller that asked for the 40-man.
|
||||||
|
expect(src).toMatch(/mlbstats:roster:\$\{teamId\}:\$\{season\}:\$\{rt\}:\$\{asOfDate \|\| 'now'\}/);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,335 @@
|
|||||||
|
/**
|
||||||
|
* MLB PARTICIPANT IDENTITY CONVERGENCE
|
||||||
|
*
|
||||||
|
* One proven MLB human must produce ONE semantic identity, and two humans must
|
||||||
|
* never produce one. Both directions are asserted, because `collision_count`
|
||||||
|
* can only ever see the first: a split makes MORE identities, not fewer.
|
||||||
|
*
|
||||||
|
* Every fixture is built from the SEMANTIC SHAPE of a real measured case. There
|
||||||
|
* is no alias table anywhere in the repair and there is none here.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const evid = require('../../src/services/event/eventIdentity');
|
||||||
|
const gs = require('../../src/services/gradeSlateService');
|
||||||
|
const rs = require('../../src/services/retentionService');
|
||||||
|
const pid = require('../../src/services/model/participantIdentity');
|
||||||
|
const { nameKey } = require('../../src/utils/playerName');
|
||||||
|
|
||||||
|
const I = gs.__internals;
|
||||||
|
const RI = rs.__internals || {};
|
||||||
|
const rowsFromSides = RI.rowsFromSides || rs.rowsFromSides;
|
||||||
|
const expectedMaterialization = RI.expectedMaterialization || rs.expectedMaterialization;
|
||||||
|
|
||||||
|
const EV = 'mlb:gamepk:823502';
|
||||||
|
const GAMES = [{ gamePk: 823502, home: { teamId: 147, team: 'New York Yankees' }, away: { teamId: 111, team: 'Boston Red Sox' } }];
|
||||||
|
const GAMES_DH = [
|
||||||
|
{ gamePk: 900001, home: { teamId: 147, team: 'New York Yankees' }, away: { teamId: 111, team: 'Boston Red Sox' } },
|
||||||
|
{ gamePk: 900002, home: { teamId: 147, team: 'New York Yankees' }, away: { teamId: 111, team: 'Boston Red Sox' } },
|
||||||
|
];
|
||||||
|
|
||||||
|
// StatsAPI person records, verbatim in shape. NOT an alias table: these are the
|
||||||
|
// league's own fields, which is the whole point of the repair.
|
||||||
|
const GASPER = { id: 681508, name: 'Mickey Gasper', fullName: 'Mickey Gasper', firstName: 'Michael', lastName: 'Gasper', useName: 'Mickey', useLastName: 'Gasper' };
|
||||||
|
const JIMENEZ = { id: 677870, name: 'Leo Jiménez', fullName: 'Leo Jiménez', firstName: 'Leonardo', lastName: 'Jimenez', useName: 'Leo', useLastName: 'Jiménez' };
|
||||||
|
const BURCH = { id: 572143, name: 'Burch Smith', fullName: 'Burch Smith', firstName: 'Burch', lastName: 'Smith', useName: 'Burch', useLastName: 'Smith' };
|
||||||
|
const WILL = { id: 669257, name: 'Will Smith', fullName: 'Will Smith', firstName: 'William', lastName: 'Smith', useName: 'Will', useLastName: 'Smith' };
|
||||||
|
|
||||||
|
function indexFor(homeRoster, awayRoster, games = GAMES) {
|
||||||
|
return evid.buildPlayerTeamIndex(games, {
|
||||||
|
asOfDate: '2026-08-30',
|
||||||
|
getTeamRoster: async (id) => (id === 147 ? homeRoster.active : awayRoster.active),
|
||||||
|
getTeamRoster40: async (id) => (id === 147 ? homeRoster.forty : awayRoster.forty),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const EMPTY = { active: [], forty: [] };
|
||||||
|
|
||||||
|
function prop(player, stat, line, book, extra = {}) {
|
||||||
|
return {
|
||||||
|
player, stat_type: stat, line, book,
|
||||||
|
canonical_event_id: EV, event_identity_method: 'CANONICAL',
|
||||||
|
home_team: 'New York Yankees', away_team: 'Boston Red Sox',
|
||||||
|
game_date: '2026-08-30', game_time: '2026-08-30T23:05:00Z',
|
||||||
|
over_odds: -110, under_odds: -110, ...extra,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const CTX = {
|
||||||
|
snapshotId: 'S', capturedAt: '2026-08-30T19:03:44Z', sport: 'mlb',
|
||||||
|
gameDate: '2026-08-30', cycleHourUtc: 19, gameIdFor: () => 'G',
|
||||||
|
};
|
||||||
|
|
||||||
|
function retentionRows(props) {
|
||||||
|
const rows = [];
|
||||||
|
for (const p of props) {
|
||||||
|
const base = {
|
||||||
|
player: p.player, stat_type: p.stat_type, line: p.line, book: p.book,
|
||||||
|
game_time: p.game_time, canonical_event_id: p.canonical_event_id,
|
||||||
|
canonical_player_name: p.canonical_player_name ?? null,
|
||||||
|
mlb_person_id: p.mlb_person_id ?? null,
|
||||||
|
};
|
||||||
|
const sides = ['over', 'under'].map((d) => ({
|
||||||
|
player: p.player, stat_type: p.stat_type, line: p.line, direction: d, grade: 'C', confidence: 56,
|
||||||
|
}));
|
||||||
|
rows.push(...rowsFromSides(base, sides, CTX));
|
||||||
|
}
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
describe('STEP 6 — Gasper under-collapse (two spellings, one human)', () => {
|
||||||
|
const offerings = [
|
||||||
|
prop('Mickey Gasper', 'hits', 0.5, 'betmgm'),
|
||||||
|
prop('Michael Gasper', 'hits', 0.5, 'draftkings'),
|
||||||
|
];
|
||||||
|
|
||||||
|
test('PRE-REPAIR: with no proven participant, two spellings survive dedupe and then COLLIDE in retention', () => {
|
||||||
|
const survivors = I.dedupeProps(offerings.map((o) => ({ ...o })), 1500);
|
||||||
|
expect(survivors).toHaveLength(2); // dedupe says DISTINCT
|
||||||
|
const m = expectedMaterialization(retentionRows(survivors));
|
||||||
|
expect(m.expected_identities).toBe(2); // retention says SAME
|
||||||
|
expect(m.collision_count).toBeGreaterThan(0); // the 19:00 cohort's shape
|
||||||
|
});
|
||||||
|
|
||||||
|
test('POST-REPAIR: the 40-man index proves the human and the two spellings are ONE proposition before grading', async () => {
|
||||||
|
const built = await indexFor({ active: [], forty: [] }, { active: [], forty: [GASPER] });
|
||||||
|
const resolved = offerings.map((o) => {
|
||||||
|
const who = evid.resolveParticipant(o, GAMES, built.persons);
|
||||||
|
return { ...o, mlb_person_id: who && who.personId, canonical_player_name: who && who.canonicalName };
|
||||||
|
});
|
||||||
|
expect(resolved.every((r) => r.mlb_person_id === 681508)).toBe(true);
|
||||||
|
const survivors = I.dedupeProps(resolved, 1500);
|
||||||
|
expect(survivors).toHaveLength(1);
|
||||||
|
const m = expectedMaterialization(retentionRows(survivors));
|
||||||
|
expect(m.collision_count).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the 40-man is what does it — an ACTIVE-only index cannot identify him', async () => {
|
||||||
|
const activeOnly = await evid.buildPlayerTeamIndex(GAMES, {
|
||||||
|
asOfDate: '2026-08-30', getTeamRoster: async () => [],
|
||||||
|
});
|
||||||
|
expect(evid.resolveParticipant(offerings[0], GAMES, activeOnly.persons)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('STEP 7 — Leo/Leonardo split (one human, two identities)', () => {
|
||||||
|
const offerings = [
|
||||||
|
prop('Leo Jiménez', 'hits', 0.5, 'betmgm'),
|
||||||
|
prop('Leonardo Jimenez', 'hits', 0.5, 'draftkings'),
|
||||||
|
];
|
||||||
|
|
||||||
|
test('PRE-REPAIR: one human yields TWO semantic player identities', () => {
|
||||||
|
const keys = new Set(retentionRows(offerings).map((r) => r.player_key));
|
||||||
|
expect(keys.size).toBe(2);
|
||||||
|
expect([...keys].sort()).toEqual(['leo jimenez', 'leonardo jimenez']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('POST-REPAIR: both spellings resolve to person 677870 and to ONE identity', async () => {
|
||||||
|
const built = await indexFor({ active: [JIMENEZ], forty: [] }, EMPTY);
|
||||||
|
const resolved = offerings.map((o) => {
|
||||||
|
const who = evid.resolveParticipant(o, GAMES, built.persons);
|
||||||
|
return { ...o, mlb_person_id: who && who.personId, canonical_player_name: who && who.canonicalName };
|
||||||
|
});
|
||||||
|
expect(resolved.map((r) => r.mlb_person_id)).toEqual([677870, 677870]);
|
||||||
|
const keys = new Set(retentionRows(resolved).map((r) => r.player_key));
|
||||||
|
expect(keys.size).toBe(1);
|
||||||
|
// Converges onto the key that ALREADY existed, so no new lineage family.
|
||||||
|
expect([...keys]).toEqual(['leo jimenez']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the split guard sees what collision_count cannot', () => {
|
||||||
|
const split = pid.auditParticipantIdentity([
|
||||||
|
{ canonical_event_id: EV, mlb_person_id: 677870, player_key: 'leo jimenez' },
|
||||||
|
{ canonical_event_id: EV, mlb_person_id: 677870, player_key: 'leonardo jimenez' },
|
||||||
|
]);
|
||||||
|
expect(split.participant_identity_split_count).toBe(1);
|
||||||
|
expect(split.participant_identity_merge_count).toBe(0);
|
||||||
|
const fixed = pid.auditParticipantIdentity([
|
||||||
|
{ canonical_event_id: EV, mlb_person_id: 677870, player_key: 'leo jimenez' },
|
||||||
|
{ canonical_event_id: EV, mlb_person_id: 677870, player_key: 'leo jimenez' },
|
||||||
|
]);
|
||||||
|
expect(fixed.participant_identity_split_count).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('STEP 8 — two humans must never merge', () => {
|
||||||
|
test('nickName is excluded: it collapses Burch Smith and Will Smith onto one key', () => {
|
||||||
|
// MLB publishes 'Smitty' as the nickname of BOTH. Were nickName a name
|
||||||
|
// form, these two humans would share an identity.
|
||||||
|
expect(nameKey('Smitty Smith')).toBe(nameKey('Smitty Smith'));
|
||||||
|
const a = pid.participantNameForms(BURCH);
|
||||||
|
const b = pid.participantNameForms(WILL);
|
||||||
|
const shared = [...a].filter((k) => b.has(k));
|
||||||
|
expect(shared).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a name key matching two people in one event FAILS CLOSED — never a guess', async () => {
|
||||||
|
const twin = { ...WILL, id: 999999, fullName: 'Burch Smith', firstName: 'Burch', useName: 'Burch' };
|
||||||
|
const built = await indexFor({ active: [BURCH] }, { active: [twin] });
|
||||||
|
const who = evid.resolveParticipant(prop('Burch Smith', 'hits', 0.5, 'betmgm'), GAMES, built.persons);
|
||||||
|
expect(who).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the merge guard reports one identity standing for two humans', () => {
|
||||||
|
const merged = pid.auditParticipantIdentity([
|
||||||
|
{ canonical_event_id: EV, mlb_person_id: 572143, player_key: 'burch smith' },
|
||||||
|
{ canonical_event_id: EV, mlb_person_id: 669257, player_key: 'burch smith' },
|
||||||
|
]);
|
||||||
|
expect(merged.participant_identity_merge_count).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a merged identity is REFUSED at the admission seam, not graded', () => {
|
||||||
|
const p = prop('Burch Smith', 'hits', 0.5, 'betmgm');
|
||||||
|
p.event_binding_status = 'RESOLVED';
|
||||||
|
p.participant_identity_conflict = true;
|
||||||
|
const gate = gs.__internals.admitForGrading([p], 'mlb');
|
||||||
|
expect(gate.admitted).toHaveLength(0);
|
||||||
|
expect(gate.reasons.PARTICIPANT_IDENTITY_MERGE).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('STEP 4 — unresolved participants keep their old behaviour', () => {
|
||||||
|
test('no proven participant -> raw spelling identity, unchanged', () => {
|
||||||
|
const id = pid.semanticPlayerKey({ player: 'Someone Unknown' });
|
||||||
|
expect(id.canonical).toBe(false);
|
||||||
|
expect(id.key).toBe(nameKey('Someone Unknown'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('resolution is never required — an unresolved prop still grades', async () => {
|
||||||
|
const built = await indexFor(EMPTY, EMPTY);
|
||||||
|
const p = prop('Someone Unknown', 'hits', 0.5, 'betmgm');
|
||||||
|
expect(evid.resolveParticipant(p, GAMES, built.persons)).toBeNull();
|
||||||
|
p.event_binding_status = 'RESOLVED';
|
||||||
|
expect(gs.__internals.admitForGrading([p], 'mlb').admitted).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('unproven rows are excluded from both guards rather than counted as clean', () => {
|
||||||
|
const a = pid.auditParticipantIdentity([
|
||||||
|
{ canonical_event_id: EV, mlb_person_id: null, player_key: 'a' },
|
||||||
|
{ canonical_event_id: EV, mlb_person_id: null, player_key: 'b' },
|
||||||
|
]);
|
||||||
|
expect(a.proven_rows).toBe(0);
|
||||||
|
expect(a.unproven_rows).toBe(2);
|
||||||
|
expect(a.participant_identity_split_count).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('STEP 5 — namesake safety depends on EVENT SCOPE', () => {
|
||||||
|
test('the same name key in two different games resolves to each game\'s own human', async () => {
|
||||||
|
const twin = { ...GASPER, id: 111111 };
|
||||||
|
const g2 = [{ gamePk: 900003, home: { teamId: 121, team: 'New York Mets' }, away: { teamId: 143, team: 'Philadelphia Phillies' } }];
|
||||||
|
const b1 = await indexFor(EMPTY, { active: [], forty: [GASPER] });
|
||||||
|
const b2 = await evid.buildPlayerTeamIndex(g2, {
|
||||||
|
asOfDate: '2026-08-30', getTeamRoster: async () => [], getTeamRoster40: async () => [twin],
|
||||||
|
});
|
||||||
|
const w1 = evid.resolveParticipant(prop('Michael Gasper', 'hits', 0.5, 'betmgm'), GAMES, b1.persons);
|
||||||
|
const w2 = evid.resolveParticipant({ player: 'Michael Gasper', canonical_event_id: 'mlb:gamepk:900003' }, g2, b2.persons);
|
||||||
|
expect(w1.personId).toBe(681508);
|
||||||
|
expect(w2.personId).toBe(111111);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('STEP 24 — doubleheader: one person, two events, two propositions', () => {
|
||||||
|
test('identity is stable across gamePks while propositions stay distinct', async () => {
|
||||||
|
const built = await evid.buildPlayerTeamIndex(GAMES_DH, {
|
||||||
|
asOfDate: '2026-08-30', getTeamRoster: async () => [], getTeamRoster40: async () => [GASPER],
|
||||||
|
});
|
||||||
|
const mk = (pk) => ({ player: 'Michael Gasper', stat_type: 'hits', line: 0.5, book: 'betmgm', canonical_event_id: `mlb:gamepk:${pk}`, event_identity_method: 'CANONICAL' });
|
||||||
|
const a = mk(900001); const b = mk(900002);
|
||||||
|
const wa = evid.resolveParticipant(a, GAMES_DH, built.persons);
|
||||||
|
const wb = evid.resolveParticipant(b, GAMES_DH, built.persons);
|
||||||
|
expect(wa.personId).toBe(wb.personId); // same human
|
||||||
|
const survivors = I.dedupeProps(
|
||||||
|
[{ ...a, mlb_person_id: wa.personId }, { ...b, mlb_person_id: wb.personId }], 1500,
|
||||||
|
);
|
||||||
|
expect(survivors).toHaveLength(2); // two propositions
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('STEP 16 — the retained representative is not decided by position', () => {
|
||||||
|
const offerings = [
|
||||||
|
prop('Mickey Gasper', 'hits', 0.5, 'pinnacle', { over_odds: -105, under_odds: -115 }),
|
||||||
|
prop('Mickey Gasper', 'hits', 0.5, 'draftkings', { over_odds: -110, under_odds: -110 }),
|
||||||
|
prop('Mickey Gasper', 'hits', 0.5, 'betmgm', { over_odds: -120, under_odds: 100 }),
|
||||||
|
].map((o) => ({ ...o, mlb_person_id: 681508, canonical_player_name: 'Mickey Gasper' }));
|
||||||
|
|
||||||
|
const permutations = [[0, 1, 2], [2, 1, 0], [1, 2, 0], [0, 2, 1], [2, 0, 1], [1, 0, 2]];
|
||||||
|
|
||||||
|
test('every input permutation retains a byte-identical row', () => {
|
||||||
|
const seen = new Set();
|
||||||
|
for (const perm of permutations) {
|
||||||
|
const survivors = I.dedupeProps(perm.map((i) => ({ ...offerings[i] })), 1500);
|
||||||
|
expect(survivors).toHaveLength(1);
|
||||||
|
seen.add(JSON.stringify(retentionRows(survivors)));
|
||||||
|
}
|
||||||
|
expect(seen.size).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the representative is chosen by the existing MODEL_BOOKS order, not by arrival', () => {
|
||||||
|
const survivors = I.dedupeProps(permutations[1].map((i) => ({ ...offerings[i] })), 1500);
|
||||||
|
// draftkings leads the existing MODEL_BOOKS declaration; pinnacle arrived first.
|
||||||
|
expect(survivors[0].book).toBe('draftkings');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('all reconstructive market fields are stable, not just the identity', () => {
|
||||||
|
const payloads = permutations.map((perm) => {
|
||||||
|
const s = I.dedupeProps(perm.map((i) => ({ ...offerings[i] })), 1500)[0];
|
||||||
|
return JSON.stringify([s.book, s.over_odds, s.under_odds, s.line]);
|
||||||
|
});
|
||||||
|
expect(new Set(payloads).size).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('preservation — the repair must not move anything else', () => {
|
||||||
|
test('a player whose provider spelling already matches MLB keeps his key', () => {
|
||||||
|
const before = pid.semanticPlayerKey({ player: 'Aaron Judge' });
|
||||||
|
const after = pid.semanticPlayerKey({ player: 'Aaron Judge', mlb_person_id: 592450, canonical_player_name: 'Aaron Judge' });
|
||||||
|
expect(after.key).toBe(before.key);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('team evidence still reads the ACTIVE roster only, so admission is unchanged', async () => {
|
||||||
|
const built = await indexFor({ active: [], forty: [GASPER] }, EMPTY);
|
||||||
|
// On the 40-man but not active: identifiable as a participant...
|
||||||
|
expect(built.persons.get('michael gasper')).toBeTruthy();
|
||||||
|
// ...and still absent from the team-evidence index that drives the
|
||||||
|
// impossible-binding refusal, exactly as before this change.
|
||||||
|
expect(built.index.get('michael gasper')).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the raw provider spelling is never mutated on the prop', async () => {
|
||||||
|
const built = await indexFor(EMPTY, { active: [], forty: [GASPER] });
|
||||||
|
const p = prop('Michael Gasper', 'hits', 0.5, 'draftkings');
|
||||||
|
const who = evid.resolveParticipant(p, GAMES, built.persons);
|
||||||
|
p.mlb_person_id = who.personId;
|
||||||
|
p.canonical_player_name = who.canonicalName;
|
||||||
|
expect(p.player).toBe('Michael Gasper');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('retention history is APPEND-ONLY (the tooth-12 hole)', () => {
|
||||||
|
/**
|
||||||
|
* `ignoreDuplicates: true` IS the "history is never rewritten" property. It
|
||||||
|
* makes a re-run of a cycle a no-op on rows that already exist, so the claim
|
||||||
|
* a user was served can never be overwritten by a later pass. Flipping it to
|
||||||
|
* false silently converts every retry into an overwrite — and nothing
|
||||||
|
* asserted it, which a teeth run found by coming back green.
|
||||||
|
*/
|
||||||
|
test('persist upserts with ignoreDuplicates — a re-run can never overwrite a stored claim', async () => {
|
||||||
|
const seen = [];
|
||||||
|
const supabase = {
|
||||||
|
from() {
|
||||||
|
return { upsert(rows, opts) { seen.push({ n: rows.length, opts }); return Promise.resolve({ error: null }); } };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const rows = Array.from({ length: 3 }, (_, i) => ({
|
||||||
|
snapshot_id: 's', game_id: 'g', canonical_event_id: 'e',
|
||||||
|
player_key: `p${i}`, stat: 'hits', line: 0.5, side: 'over',
|
||||||
|
}));
|
||||||
|
const out = await rs.persist(rows, { getClient: () => supabase });
|
||||||
|
expect(out.error).toBeNull();
|
||||||
|
expect(seen.length).toBeGreaterThan(0);
|
||||||
|
for (const call of seen) {
|
||||||
|
expect(call.opts.ignoreDuplicates).toBe(true);
|
||||||
|
expect(call.opts.onConflict).toBe(rs.RETENTION_CONFLICT);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
+1
-1
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user