Event admission gate: an unresolved or contradicted game does not earn a Read
A fractured identity keeps two bad records from merging. It does not make an unknown game true. Until now a prop with an unresolved or verified-impossible event still continued into grading under that synthetic key; it no longer does. - event_binding_status contract: RESOLVED / UNRESOLVED / AMBIGUOUS / CONTRADICTED / UNSUPPORTED. CONTRADICTED and UNRESOLVED stay distinct — one means we know the association is wrong, the other that we do not know. - ONE admission gate (gradeSlateService.admitForGrading), before dedupe and before grading. Admission requires RESOLVED *and* a canonical_event_id; a legacy derived game_id can never satisfy it. Rejected props are returned, not discarded, so retention keeps them as evidence. - Roster evidence is now DATE-SCOPED. statsapi honours ?date= and it changes the answer (Joe Mack is on the 2026-08-26 Marlins roster, absent on 2026-04-15). Evidence that does not describe the slate's date can only yield UNRESOLVED, never CONTRADICTED — uncertainty must not become an accusation. - A mis-nested market is REFUSED, never re-bound. Knowing Joe Mack is a Marlin does not license moving a provider record into the Marlins game; that would invent provenance. Measured on the real 2026-08-26 slate: 2,878 props -> 2,874 admitted, 4 rejected (EVENT_PLAYER_TEAM_CONTRADICTION), each player's correct game still resolving. Doubleheader 824514/824478 both remain independently RESOLVED and admitted. No change to probability, projection, side, grade, confidence, ranking, normalization or calibration. Lineage remains disabled. Suite 380/5,061/0 from the release worktree; web tsc exit 0. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CQJeAG8vcDoL5zkiaJyVb8
This commit is contained in:
@@ -427,10 +427,14 @@ async function resolveTeam(abbr, season = DEFAULT_SEASON) {
|
||||
* Active roster for a team id (Session 51) → [{ id, name, position, jersey }].
|
||||
* Cached 6h. [] on failure.
|
||||
*/
|
||||
async function getTeamRoster(teamId, season = DEFAULT_SEASON) {
|
||||
async function getTeamRoster(teamId, season = DEFAULT_SEASON, asOfDate = null) {
|
||||
if (!teamId) return [];
|
||||
const url = `${BASE}/teams/${teamId}/roster?rosterType=active&season=${season}`;
|
||||
const data = await fetchWithCache(url, `mlbstats:roster:${teamId}:${season}`, 6 * 3600);
|
||||
// `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:
|
||||
// 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 url = `${BASE}/teams/${teamId}/roster?rosterType=active&season=${season}${d}`;
|
||||
const data = await fetchWithCache(url, `mlbstats:roster:${teamId}:${season}:${asOfDate || 'now'}`, 6 * 3600);
|
||||
const roster = (data && Array.isArray(data.roster)) ? data.roster : [];
|
||||
return roster.map((r) => ({
|
||||
id: r.person?.id ?? null,
|
||||
|
||||
@@ -49,12 +49,42 @@ const IDENTITY_METHOD = Object.freeze({
|
||||
|
||||
const SUPPORTED = Object.freeze(['mlb']);
|
||||
|
||||
/**
|
||||
* -- EVENT BINDING STATUS --------------------------------------------------
|
||||
* The normalised admission contract. `event_identity_method` says HOW we tried;
|
||||
* this says WHETHER the result may be used.
|
||||
*
|
||||
* RESOLVED a canonical event was established -> may grade
|
||||
* UNRESOLVED we could not establish one -> diagnostics only
|
||||
* AMBIGUOUS several events fit, none provable -> diagnostics only
|
||||
* CONTRADICTED verified player/event conflict -> diagnostics only
|
||||
* UNSUPPORTED the sport has no resolver -> unchanged legacy path
|
||||
*
|
||||
* CONTRADICTED and UNRESOLVED are deliberately distinct. One means we know the
|
||||
* association is wrong; the other means we do not know. Collapsing them would
|
||||
* let a data gap masquerade as a detected lie.
|
||||
*/
|
||||
const BINDING_STATUS = Object.freeze({
|
||||
RESOLVED: 'RESOLVED',
|
||||
UNRESOLVED: 'UNRESOLVED',
|
||||
AMBIGUOUS: 'AMBIGUOUS',
|
||||
CONTRADICTED: 'CONTRADICTED',
|
||||
UNSUPPORTED: 'UNSUPPORTED',
|
||||
});
|
||||
|
||||
/** Namespaced so two sports' numeric ids can never collide. */
|
||||
function canonicalEventId(sport, nativeId) {
|
||||
if (!sport || nativeId === null || nativeId === undefined || nativeId === '') return null;
|
||||
return `${String(sport).toLowerCase()}:gamepk:${nativeId}`;
|
||||
}
|
||||
|
||||
const STATUS_FOR_REASON = Object.freeze({
|
||||
ambiguous_no_game_time: BINDING_STATUS.AMBIGUOUS,
|
||||
ambiguous_no_schedule_time: BINDING_STATUS.AMBIGUOUS,
|
||||
ambiguous_time_too_far: BINDING_STATUS.AMBIGUOUS,
|
||||
impossible_binding: BINDING_STATUS.CONTRADICTED,
|
||||
});
|
||||
|
||||
const refusal = (method, reason) => Object.freeze({
|
||||
canonical_event_id: null,
|
||||
event_identity_source: IDENTITY_SOURCE.UNKNOWN,
|
||||
@@ -62,6 +92,9 @@ const refusal = (method, reason) => Object.freeze({
|
||||
event_identity_version: IDENTITY_VERSION,
|
||||
event_occurrence: null,
|
||||
reason: reason || null,
|
||||
event_binding_status: method === IDENTITY_METHOD.UNSUPPORTED_SPORT
|
||||
? BINDING_STATUS.UNSUPPORTED
|
||||
: (STATUS_FOR_REASON[reason] || BINDING_STATUS.UNRESOLVED),
|
||||
});
|
||||
|
||||
const norm = (v) => String(v || '').toLowerCase().replace(/[^a-z]/g, '');
|
||||
@@ -187,8 +220,9 @@ function checkParticipantEvidence(prop, game, playerTeams) {
|
||||
async function buildPlayerTeamIndex(games, deps) {
|
||||
const d = deps || {};
|
||||
const getRoster = d.getTeamRoster;
|
||||
const asOfDate = d.asOfDate || null;
|
||||
const index = new Map();
|
||||
const out = { teams: 0, players: 0, failed: 0 };
|
||||
const out = { teams: 0, players: 0, failed: 0, as_of_date: asOfDate, date_scoped: false };
|
||||
if (typeof getRoster !== 'function') return { index, stats: out };
|
||||
|
||||
const ids = new Map();
|
||||
@@ -200,7 +234,11 @@ async function buildPlayerTeamIndex(games, deps) {
|
||||
}
|
||||
for (const [id, name] of ids) {
|
||||
let roster = null;
|
||||
try { roster = await getRoster(id); } catch { roster = null; }
|
||||
// asOfDate is passed through so the roster describes the SLATE'S DATE, not
|
||||
// "now". Verified against statsapi: Joe Mack is on the Marlins roster for
|
||||
// 2026-08-26 and absent from 2026-04-15, so the parameter genuinely changes
|
||||
// the answer rather than being decorative.
|
||||
try { roster = await getRoster(id, asOfDate); } catch { roster = null; }
|
||||
if (!Array.isArray(roster)) { out.failed += 1; continue; }
|
||||
out.teams += 1;
|
||||
const abbr = teamAbbr(name);
|
||||
@@ -213,8 +251,28 @@ async function buildPlayerTeamIndex(games, deps) {
|
||||
out.players += 1;
|
||||
}
|
||||
}
|
||||
out.date_scoped = Boolean(asOfDate) && out.teams > 0;
|
||||
return { index, stats: out };
|
||||
}
|
||||
|
||||
/**
|
||||
* -- TEMPORAL VALIDITY OF PARTICIPANT EVIDENCE ---------------------------
|
||||
* A roster is a statement about a MOMENT. Using today's roster to judge a
|
||||
* materially older slate would reject props on evidence that describes the
|
||||
* wrong time — a player traded last week was not on that team in April.
|
||||
*
|
||||
* So evidence may CONTRADICT only when it is date-scoped to the slate being
|
||||
* judged. Otherwise the strongest honest answer is UNRESOLVED: we could not
|
||||
* establish the association, not "we proved it wrong".
|
||||
*
|
||||
* `evidenceIsDateValid` is what separates those two verdicts.
|
||||
*/
|
||||
function evidenceIsDateValid(stats, gameDate) {
|
||||
if (!stats || !gameDate) return false;
|
||||
if (!stats.date_scoped) return false;
|
||||
return String(stats.as_of_date) === String(gameDate);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve ONE prop to a canonical MLB event.
|
||||
*
|
||||
@@ -228,7 +286,7 @@ async function buildPlayerTeamIndex(games, deps) {
|
||||
* @param {object} prop needs home_team, away_team, optionally game_time
|
||||
* @param {Array} games mlbStatsAdapter.getScheduleWithPitchers() output
|
||||
*/
|
||||
function resolveMlbEvent(prop, games, playerTeams) {
|
||||
function resolveMlbEvent(prop, games, playerTeams, evidenceDateValid) {
|
||||
const p = prop || {};
|
||||
const list = Array.isArray(games) ? games.filter((g) => g && g.gamePk != null) : [];
|
||||
if (list.length === 0) return refusal(IDENTITY_METHOD.UNRESOLVED, 'no_schedule');
|
||||
@@ -242,6 +300,11 @@ function resolveMlbEvent(prop, games, playerTeams) {
|
||||
const possible = matches.filter((g) => !checkParticipantEvidence(prop, g, playerTeams));
|
||||
if (possible.length === 0) {
|
||||
const ev = checkParticipantEvidence(prop, matches[0], playerTeams);
|
||||
// Evidence that does not describe THIS date cannot prove a contradiction.
|
||||
// Uncertainty must not be promoted into an accusation.
|
||||
if (evidenceDateValid === false) {
|
||||
return refusal(IDENTITY_METHOD.UNRESOLVED, 'participant_evidence_not_date_valid');
|
||||
}
|
||||
return Object.freeze({
|
||||
canonical_event_id: null,
|
||||
event_identity_source: IDENTITY_SOURCE.UNKNOWN,
|
||||
@@ -249,6 +312,7 @@ function resolveMlbEvent(prop, games, playerTeams) {
|
||||
event_identity_version: IDENTITY_VERSION,
|
||||
event_occurrence: null,
|
||||
reason: 'impossible_binding',
|
||||
event_binding_status: BINDING_STATUS.CONTRADICTED,
|
||||
evidence: ev || null,
|
||||
});
|
||||
}
|
||||
@@ -284,6 +348,7 @@ function resolveMlbEvent(prop, games, playerTeams) {
|
||||
// Self-describing occurrence, straight from the source. Never inferred.
|
||||
event_occurrence: chosen.gameNumber ?? null,
|
||||
reason: null,
|
||||
event_binding_status: BINDING_STATUS.RESOLVED,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -291,28 +356,30 @@ function resolveMlbEvent(prop, games, playerTeams) {
|
||||
* Sport-neutral entry point. Only MLB resolves; everything else is
|
||||
* UNSUPPORTED_SPORT, which is a truthful state and not a failure.
|
||||
*/
|
||||
function resolveEvent(sport, prop, games, playerTeams) {
|
||||
function resolveEvent(sport, prop, games, playerTeams, evidenceDateValid) {
|
||||
const sp = String(sport || '').toLowerCase();
|
||||
if (!SUPPORTED.includes(sp)) return refusal(IDENTITY_METHOD.UNSUPPORTED_SPORT, `sport_${sp || 'unknown'}`);
|
||||
return resolveMlbEvent(prop, games, playerTeams);
|
||||
return resolveMlbEvent(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.
|
||||
*/
|
||||
function attachEventIdentity(sport, props, games, playerTeams) {
|
||||
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: {} };
|
||||
for (const p of list) {
|
||||
if (!p) continue;
|
||||
const r = resolveEvent(sport, p, games, playerTeams);
|
||||
const r = resolveEvent(sport, p, games, playerTeams, evidenceDateValid);
|
||||
// Declared on EVERY prop, resolved or not.
|
||||
p.canonical_event_id = r.canonical_event_id;
|
||||
p.event_identity_source = r.event_identity_source;
|
||||
p.event_identity_method = r.event_identity_method;
|
||||
p.event_identity_version = r.event_identity_version;
|
||||
p.event_occurrence = r.event_occurrence;
|
||||
p.event_binding_status = r.event_binding_status;
|
||||
p.event_binding_reason = r.reason || null;
|
||||
if (r.event_identity_method === IDENTITY_METHOD.CANONICAL) out.canonical += 1;
|
||||
else if (r.event_identity_method === IDENTITY_METHOD.UNSUPPORTED_SPORT) out.unsupported += 1;
|
||||
else {
|
||||
@@ -326,6 +393,6 @@ function attachEventIdentity(sport, props, games, playerTeams) {
|
||||
|
||||
module.exports = {
|
||||
resolveEvent, resolveMlbEvent, attachEventIdentity, canonicalEventId, teamsMatch, teamAbbr,
|
||||
checkParticipantEvidence, buildPlayerTeamIndex,
|
||||
IDENTITY_SOURCE, IDENTITY_METHOD, IDENTITY_VERSION, SUPPORTED,
|
||||
checkParticipantEvidence, buildPlayerTeamIndex, evidenceIsDateValid,
|
||||
IDENTITY_SOURCE, IDENTITY_METHOD, IDENTITY_VERSION, SUPPORTED, BINDING_STATUS,
|
||||
};
|
||||
|
||||
@@ -77,6 +77,55 @@ const DEFAULT_TTL = 7200; // 2 hours — matches the spec's grades-cache TTL.
|
||||
// and before the limit — which makes the graded set byte-identical to what it
|
||||
// was before the widening. This gate lifts only when the MLB calibration is
|
||||
// re-run on the consensus ruler and v2 is promoted.
|
||||
/**
|
||||
* -- EVENT ADMISSION GATE ------------------------------------------------
|
||||
* THE single seam where an MLB prop earns the right to be graded.
|
||||
*
|
||||
* A fractured identity keeps two bad records from merging. It does NOT make an
|
||||
* unknown game true. A VYNDR Read depends on an event world — participant,
|
||||
* opponent, role, game — so a prop whose game cannot be established has no
|
||||
* trustworthy context to grade against, and one whose game is verifiably wrong
|
||||
* has worse than none.
|
||||
*
|
||||
* RESOLVED + canonical_event_id -> admitted
|
||||
* UNRESOLVED / AMBIGUOUS -> rejected, diagnostics only
|
||||
* CONTRADICTED -> rejected, diagnostics only
|
||||
* UNSUPPORTED (no resolver) -> admitted; unchanged legacy path
|
||||
*
|
||||
* A legacy derived game_id can NEVER satisfy this gate — separating two real
|
||||
* games is exactly what it cannot do.
|
||||
*
|
||||
* Rejected props are RETURNED, not discarded, so retention can keep them as
|
||||
* evidence. They simply never become a graded candidate, a selected Read, a
|
||||
* Redis Read, or a Ledger Read.
|
||||
*
|
||||
* This is the ONLY admission check. It sits before dedupe and before grading —
|
||||
* the earliest point where identity is known and the latest before cost is
|
||||
* incurred.
|
||||
*/
|
||||
function admitForGrading(props, sport) {
|
||||
const list = Array.isArray(props) ? props : [];
|
||||
const admitted = [];
|
||||
const rejected = [];
|
||||
const reasons = {};
|
||||
for (const p of list) {
|
||||
if (!p) continue;
|
||||
const status = p.event_binding_status;
|
||||
// A sport with no canonical resolver is unchanged: it never had canonical
|
||||
// identity, and gating it would delete its slate for no safety gain.
|
||||
if (!status || status === 'UNSUPPORTED') { admitted.push(p); continue; }
|
||||
if (status === 'RESOLVED' && p.canonical_event_id) { admitted.push(p); continue; }
|
||||
const why = status === 'CONTRADICTED'
|
||||
? 'EVENT_PLAYER_TEAM_CONTRADICTION'
|
||||
: (status === 'AMBIGUOUS' ? 'EVENT_AMBIGUOUS' : 'EVENT_UNRESOLVED');
|
||||
p.event_admission = 'REJECTED';
|
||||
p.event_rejection_reason = why;
|
||||
reasons[why] = (reasons[why] || 0) + 1;
|
||||
rejected.push(p);
|
||||
}
|
||||
return { admitted, rejected, reasons, sport: String(sport || '').toLowerCase() };
|
||||
}
|
||||
|
||||
/**
|
||||
* WHAT DUPLICATES IS THIS FUNCTION SUPPOSED TO REMOVE?
|
||||
*
|
||||
@@ -300,7 +349,14 @@ async function gradeAndCacheSlate(sport, props, opts = {}) {
|
||||
}
|
||||
|
||||
try {
|
||||
const unique = dedupeProps(props, limit);
|
||||
// ADMISSION BEFORE DEDUPE: a prop that cannot name its game never reaches
|
||||
// the grader, and a contradicted one never reaches it either.
|
||||
const gate = admitForGrading(props, sport);
|
||||
if (gate.rejected.length > 0) {
|
||||
console.log(`[grade-slate] ${sport} event admission: ${gate.admitted.length} admitted, `
|
||||
+ `${gate.rejected.length} rejected ${JSON.stringify(gate.reasons)}`);
|
||||
}
|
||||
const unique = dedupeProps(gate.admitted, limit);
|
||||
if (unique.length === 0) return { written: false, count: 0 };
|
||||
|
||||
const graded = (await mapLimit(unique, concurrency, (p) => gradeBestSide(grade, p, sport, opts)))
|
||||
@@ -321,5 +377,5 @@ async function gradeAndCacheSlate(sport, props, opts = {}) {
|
||||
|
||||
module.exports = {
|
||||
gradeAndCacheSlate,
|
||||
__internals: { dedupeProps, gradeBestSide, mapLimit, DEFAULT_LIMIT, DEFAULT_TTL, DEFAULT_CONCURRENCY },
|
||||
__internals: { dedupeProps, admitForGrading, gradeBestSide, mapLimit, DEFAULT_LIMIT, DEFAULT_TTL, DEFAULT_CONCURRENCY },
|
||||
};
|
||||
|
||||
@@ -456,20 +456,31 @@ async function runSnapshot(sport, opts = {}) {
|
||||
// ARE the wrong event's teams. The player's real team has to come from
|
||||
// a source that cannot be wrong the same way — the slate's rosters.
|
||||
// Best-effort: no index means no refusals, i.e. the previous behaviour.
|
||||
// The roster is fetched AS OF THE SLATE'S DATE, not "now". statsapi
|
||||
// honours a `date` parameter and it genuinely changes the answer, so
|
||||
// the evidence describes the moment being judged.
|
||||
const slateDate = dates.length === 1 ? dates[0] : null;
|
||||
let playerTeams = null;
|
||||
let evidenceDateValid = false;
|
||||
try {
|
||||
const built = await evid.buildPlayerTeamIndex(games, {
|
||||
getTeamRoster: (id) => mlbAdapter.getTeamRoster(id),
|
||||
getTeamRoster: (id, asOf) => mlbAdapter.getTeamRoster(id, undefined, asOf),
|
||||
asOfDate: slateDate,
|
||||
});
|
||||
playerTeams = built.index;
|
||||
evidenceDateValid = evid.evidenceIsDateValid(built.stats, slateDate);
|
||||
console.log(`[snapshot] participant evidence ${sp}: ${built.stats.players} players`
|
||||
+ ` across ${built.stats.teams} rosters${built.stats.failed ? `, ${built.stats.failed} unreadable` : ''}`);
|
||||
+ ` across ${built.stats.teams} rosters as of ${slateDate || 'unscoped'}`
|
||||
+ `${built.stats.failed ? `, ${built.stats.failed} unreadable` : ''}`
|
||||
+ ` · date-valid=${evidenceDateValid}`);
|
||||
} catch (e3) {
|
||||
console.warn(`[snapshot] participant evidence unavailable for ${sp}:`, e3.message);
|
||||
}
|
||||
const e = evid.attachEventIdentity(sp, props, games, playerTeams);
|
||||
// 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);
|
||||
console.log(`[snapshot] event identity ${sp}: ${e.canonical}/${e.total} canonical, `
|
||||
+ `${e.unresolved} unresolved, ${e.impossible} impossible-binding refusals`
|
||||
+ `${e.unresolved} unresolved, ${e.impossible} contradicted`
|
||||
+ `${Object.keys(e.reasons).length ? ' ' + JSON.stringify(e.reasons) : ''}`);
|
||||
} catch (e2) {
|
||||
// Identity is additive: a failure leaves props on legacy identity, the
|
||||
|
||||
Reference in New Issue
Block a user