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:
Kev
2026-08-27 16:50:50 -04:00
parent 352016790a
commit 8c6aef1e12
6 changed files with 357 additions and 22 deletions
+11 -4
View File
@@ -65,12 +65,15 @@ async function main() {
// PARTICIPANT EVIDENCE — the same index production builds.
const built = await E.buildPlayerTeamIndex(games, {
getTeamRoster: async (id) => {
const r = await (await fetch(`https://statsapi.mlb.com/api/v1/teams/${id}/roster?rosterType=active`)).json();
asOfDate: DATE,
getTeamRoster: async (id, asOf) => {
const d = asOf ? `&date=${asOf}` : '';
const r = await (await fetch(`https://statsapi.mlb.com/api/v1/teams/${id}/roster?rosterType=active${d}`)).json();
return (r.roster || []).map((x) => ({ id: x.person && x.person.id, name: x.person && x.person.fullName }));
},
});
const playerTeams = built.index;
const evidenceDateValid = E.evidenceIsDateValid(built.stats, DATE);
// Reconstruct prop shapes. One row per (player, stat, line, book) — the side
// is not part of the dedupe key, so both sides collapse to the same prop.
@@ -92,7 +95,7 @@ async function main() {
}
// The schedule has one game per pair on a non-doubleheader date, so teams
// alone resolve; no game_time is needed and none is reconstructed.
const ident = E.attachEventIdentity(SPORT, props, games, playerTeams);
const ident = E.attachEventIdentity(SPORT, props, games, playerTeams, evidenceDateValid);
const oldSet = new Set(props.map(oldKey));
const newSet = new Set(props.map(newKey));
@@ -112,7 +115,11 @@ async function main() {
console.log(`participant evidence : ${built.stats.players} players / ${built.stats.teams} rosters`);
console.log(`canonical event resolution : ${ident.canonical}/${ident.total}`
+ ` (unresolved ${ident.unresolved}, unsupported ${ident.unsupported})`);
console.log(`IMPOSSIBLE-BINDING refusals : ${ident.impossible}`);
console.log(`evidence date-valid : ${evidenceDateValid} (as of ${DATE})`);
console.log(`CONTRADICTED bindings : ${ident.impossible}`);
const gate = require('../src/services/gradeSlateService').__internals.admitForGrading(props, SPORT);
console.log(`ADMITTED to grading : ${gate.admitted.length}`);
console.log(`REJECTED before grading : ${gate.rejected.length} ${JSON.stringify(gate.reasons)}`);
if (Object.keys(ident.reasons).length) console.log(` unresolved reasons : ${JSON.stringify(ident.reasons)}`);
console.log(`distinct events : ${new Set(props.map((p) => p.canonical_event_id).filter(Boolean)).size}`);
console.log('');