diff --git a/src/services/gradeSlateService.js b/src/services/gradeSlateService.js index 923ab8e..beb4f0e 100644 --- a/src/services/gradeSlateService.js +++ b/src/services/gradeSlateService.js @@ -58,7 +58,14 @@ const { isModelBook } = require('../config/bookRoles'); // statsapi is free and unlimited. Concurrency stays at 5 — one variable at a time. // // Env-tunable so the ceiling can move without a deploy: GRADE_SLATE_LIMIT. -const NO_PREGRADE = Object.freeze({ identity() {}, admission() {}, dedupe() {}, gradeLoop() {} }); +// MUST implement EVERY recorder method. A missing one throws inside the grade +// loop, and gradeAndCacheSlate's catch turns that into {written:false,count:0} +// — i.e. a silently empty slate for every caller without a recorder. +const NO_PREGRADE = Object.freeze({ + binding() {}, schedule() {}, roster() {}, + identity() {}, admission() {}, dedupe() {}, gradeLoop() {}, + gradeStarted() {}, firstOnGraded() {}, +}); const DEFAULT_LIMIT = Number(process.env.GRADE_SLATE_LIMIT) > 0 ? Number(process.env.GRADE_SLATE_LIMIT) @@ -201,17 +208,31 @@ function propositionEventKey(p) { return `legacy:${date}:${away}@${home}`; } -function dedupeProps(props, limit) { +function dedupeProps(props, limit, stats) { const seen = new Set(); const out = []; - for (const p of props || []) { - if (!p || !p.player || !p.stat_type || p.line == null) continue; - if (!isModelBook(p.book)) continue; + // DIAGNOSTIC COUNTERS ONLY. Incremented on the SAME branches the filter + // already takes, in the same order, so the counts describe the real code path + // rather than a second implementation of it. `stats` is optional; without it + // this function is byte-identical in behaviour. + const bump = (k) => { if (stats) stats[k] = (stats[k] || 0) + 1; }; + const list = props || []; + let examined = 0; + for (const p of list) { + examined += 1; + if (!p || !p.player || !p.stat_type || p.line == null) { bump('dropped_invalid_fields'); continue; } + if (!isModelBook(p.book)) { bump('dropped_non_model_book'); continue; } const key = `${propositionEventKey(p)}::${p.player}::${p.stat_type}::${p.line}`; - if (seen.has(key)) continue; + if (seen.has(key)) { bump('duplicate_identity_removed'); continue; } seen.add(key); out.push(p); - if (out.length >= limit) break; + if (out.length >= limit) { if (stats) stats.capped = true; break; } + } + if (stats) { + stats.input_count = list.length; + stats.output_count = out.length; + stats.model_book_eligible = out.length + (stats.duplicate_identity_removed || 0); + stats.not_examined = list.length - examined; } return out; } @@ -382,8 +403,9 @@ async function gradeAndCacheSlate(sport, props, opts = {}) { + `${gate.rejected.length} rejected ${JSON.stringify(gate.reasons)}`); } let unique; + const dedupeStats = {}; try { - unique = dedupeProps(gate.admitted, limit); + unique = dedupeProps(gate.admitted, limit, dedupeStats); } catch (eDed) { pregrade.dedupe({ started: true, completed: false, threw: true, input_count: gate.admitted.length, error: eDed && eDed.message }); throw eDed; @@ -393,11 +415,24 @@ async function gradeAndCacheSlate(sport, props, opts = {}) { input_count: gate.admitted.length, output_count: unique.length, removed_count: gate.admitted.length - unique.length, + // Per-reason accounting straight off the filter's own branches. + dropped_invalid_fields: dedupeStats.dropped_invalid_fields || 0, + dropped_non_model_book: dedupeStats.dropped_non_model_book || 0, + duplicate_identity_removed: dedupeStats.duplicate_identity_removed || 0, + model_book_eligible: dedupeStats.model_book_eligible || 0, + non_model_book: dedupeStats.dropped_non_model_book || 0, + capped: !!dedupeStats.capped, + not_examined: dedupeStats.not_examined || 0, }); pregrade.gradeLoop({ candidate_count: unique.length, reached: unique.length > 0 }); if (unique.length === 0) return { written: false, count: 0 }; - const graded = (await mapLimit(unique, concurrency, (p) => gradeBestSide(grade, p, sport, opts))) + const graded = (await mapLimit(unique, concurrency, (p) => { + // Proof that the loop actually began. `reached` above is derived from a + // count and cannot show this. + pregrade.gradeStarted(); + return gradeBestSide(grade, p, sport, opts); + })) .filter(Boolean) .sort((a, b) => (Number(b.confidence) || 0) - (Number(a.confidence) || 0)); diff --git a/src/services/ops/acquisitionTrace.js b/src/services/ops/acquisitionTrace.js index c70c0d5..8ad9b68 100644 --- a/src/services/ops/acquisitionTrace.js +++ b/src/services/ops/acquisitionTrace.js @@ -237,15 +237,29 @@ async function history(sport, deps = {}) { const PREGRADE_PREFIX = 'ops:pregrade:'; const PREGRADE_OUTCOME = Object.freeze({ + // Progress states — the boundary moves forward as each is proven. READY_FOR_GRADING: 'READY_FOR_GRADING', + GRADE_LOOP_STARTED: 'GRADE_LOOP_STARTED', + FIRST_GRADE_CALLBACK_OBSERVED: 'FIRST_GRADE_CALLBACK_OBSERVED', + // Refusals — the cohort was deliberately emptied, and by which filter. + IDENTITY_ALL_UNRESOLVED: 'IDENTITY_ALL_UNRESOLVED', ALL_REJECTED: 'ALL_REJECTED', - IDENTITY_STAGE_ERROR: 'IDENTITY_STAGE_ERROR', + DEDUPE_ALL_NON_MODEL_BOOK: 'DEDUPE_ALL_NON_MODEL_BOOK', + DEDUPE_ALL_INVALID_FIELDS: 'DEDUPE_ALL_INVALID_FIELDS', + DEDUPE_EMPTY_OTHER: 'DEDUPE_EMPTY_OTHER', + // Exceptions — each names its own stage, never folded into a refusal. + SCHEDULE_STAGE_ERROR: 'SCHEDULE_STAGE_ERROR', + ROSTER_STAGE_ERROR: 'ROSTER_STAGE_ERROR', + BINDING_STAGE_ERROR: 'BINDING_STAGE_ERROR', + EVENT_IDENTITY_STAGE_ERROR: 'EVENT_IDENTITY_STAGE_ERROR', ADMISSION_STAGE_ERROR: 'ADMISSION_STAGE_ERROR', DEDUPE_STAGE_ERROR: 'DEDUPE_STAGE_ERROR', - DEDUPE_EMPTY: 'DEDUPE_EMPTY', NO_INPUT_PROPS: 'NO_INPUT_PROPS', OTHER_PREGRADING_ERROR: 'OTHER_PREGRADING_ERROR', INCOMPLETE: 'INCOMPLETE', + // Reconciliation: acquisition CONTINUED but no downstream trace exists. This + // is an OBSERVABILITY failure, never evidence that the pipeline failed. + OBSERVABILITY_GAP: 'OBSERVABILITY_GAP', }); function pregradeKey(sport) { @@ -262,6 +276,9 @@ function beginPregrade({ attemptId, sport, trigger, codeSha, processStartedAt, p process_generation: processStartedAt || null, started_at: now || new Date().toISOString(), input_props_count: Number.isFinite(propsCount) ? propsCount : null, + binding: null, + schedule: null, + roster: null, identity: null, admission: null, dedupe: null, @@ -278,10 +295,21 @@ function beginPregrade({ attemptId, sport, trigger, codeSha, processStartedAt, p function pregradeRecorder(trace) { const t = trace || {}; return { + binding(info) { t.binding = { ...info, error: sanitize(info && info.error) }; }, + schedule(info) { t.schedule = { ...info, error: sanitize(info && info.error) }; }, + roster(info) { t.roster = { ...info, error: sanitize(info && info.error) }; }, identity(info) { t.identity = { ...info, error: sanitize(info && info.error) }; }, admission(info) { t.admission = { ...info, error: sanitize(info && info.error) }; }, dedupe(info) { t.dedupe = { ...info, error: sanitize(info && info.error) }; }, - gradeLoop(info) { t.grade_loop = { ...info }; }, + gradeLoop(info) { t.grade_loop = { ...t.grade_loop, ...info }; }, + // CONTROL-FLOW FACTS ONLY. `reached` is derived from a candidate count and + // does NOT prove grading began; these two do. No model output is recorded. + gradeStarted() { + t.grade_loop = { ...t.grade_loop, entered: true, first_gradebestside_started: true }; + }, + firstOnGraded() { + t.grade_loop = { ...t.grade_loop, first_ongraded_observed: true }; + }, }; } @@ -292,21 +320,66 @@ function pregradeRecorder(trace) { */ function classifyPregrade(trace) { const t = trace || {}; - if (t.identity && t.identity.threw) return PREGRADE_OUTCOME.IDENTITY_STAGE_ERROR; + const gl = t.grade_loop || {}; + // EXCEPTIONS FIRST, each naming its own stage. An exception is never folded + // into a refusal, and a refusal is never reported as an exception. + if (t.binding && t.binding.threw) return PREGRADE_OUTCOME.BINDING_STAGE_ERROR; + if (t.schedule && t.schedule.threw) return PREGRADE_OUTCOME.SCHEDULE_STAGE_ERROR; + if (t.roster && t.roster.threw) return PREGRADE_OUTCOME.ROSTER_STAGE_ERROR; + if (t.identity && t.identity.threw) return PREGRADE_OUTCOME.EVENT_IDENTITY_STAGE_ERROR; if (t.admission && t.admission.threw) return PREGRADE_OUTCOME.ADMISSION_STAGE_ERROR; if (t.dedupe && t.dedupe.threw) return PREGRADE_OUTCOME.DEDUPE_STAGE_ERROR; if (t.input_props_count === 0) return PREGRADE_OUTCOME.NO_INPUT_PROPS; - if (t.grade_loop && t.grade_loop.reached) return PREGRADE_OUTCOME.READY_FOR_GRADING; + // PROGRESS, strongest proof first. `reached` alone is only a candidate count. + if (gl.first_ongraded_observed) return PREGRADE_OUTCOME.FIRST_GRADE_CALLBACK_OBSERVED; + if (gl.first_gradebestside_started) return PREGRADE_OUTCOME.GRADE_LOOP_STARTED; + if (gl.reached) return PREGRADE_OUTCOME.READY_FOR_GRADING; + // REFUSALS, naming the filter that emptied the cohort. if (t.admission && t.admission.completed && t.admission.admitted_count === 0) { + // Name the CAUSE when identity explains it, rather than the symptom. + if (t.identity && t.identity.completed && t.identity.total > 0 && t.identity.canonical === 0) { + return PREGRADE_OUTCOME.IDENTITY_ALL_UNRESOLVED; + } return PREGRADE_OUTCOME.ALL_REJECTED; } if (t.dedupe && t.dedupe.completed && t.dedupe.output_count === 0 && t.admission && t.admission.admitted_count > 0) { - return PREGRADE_OUTCOME.DEDUPE_EMPTY; + const d = t.dedupe; + if (d.dropped_non_model_book === d.input_count) return PREGRADE_OUTCOME.DEDUPE_ALL_NON_MODEL_BOOK; + if (d.dropped_invalid_fields === d.input_count) return PREGRADE_OUTCOME.DEDUPE_ALL_INVALID_FIELDS; + return PREGRADE_OUTCOME.DEDUPE_EMPTY_OTHER; } return PREGRADE_OUTCOME.OTHER_PREGRADING_ERROR; } +/** + * TRACE COMPLETENESS INVARIANT. + * + * If acquisition recorded FINAL NONZERO + CONTINUED, a downstream state must + * exist. Its ABSENCE is an observability failure and must never be read as + * "the pipeline failed" — that inference is exactly what this programme got + * wrong twice. + */ +function reconcilePregrade(acqTrace, pgTrace) { + const a = acqTrace || null; + const continued = !!(a && a.final === FINAL.NONZERO && a.outcome === OUTCOME.CONTINUED); + if (!continued) return { applicable: false, complete: null, outcome: null }; + if (!pgTrace) { + return { applicable: true, complete: false, outcome: PREGRADE_OUTCOME.OBSERVABILITY_GAP }; + } + if (pgTrace.snapshot_attempt_id !== a.snapshot_attempt_id) { + return { applicable: true, complete: false, outcome: PREGRADE_OUTCOME.OBSERVABILITY_GAP, reason: 'attempt_id_mismatch' }; + } + const undecided = !pgTrace.outcome + || pgTrace.outcome === PREGRADE_OUTCOME.INCOMPLETE + || pgTrace.outcome === PREGRADE_OUTCOME.OTHER_PREGRADING_ERROR; + return { + applicable: true, + complete: !undecided, + outcome: undecided ? PREGRADE_OUTCOME.OBSERVABILITY_GAP : pgTrace.outcome, + }; +} + function finishPregrade(trace, { now } = {}) { const t = trace; t.outcome = classifyPregrade(t); @@ -328,6 +401,7 @@ async function pregradeHistory(sport, deps = {}) { module.exports = { TRIGGER, SOURCE_OUTCOME, FINAL, OUTCOME, PREGRADE_OUTCOME, pregradeKey, beginPregrade, pregradeRecorder, classifyPregrade, finishPregrade, + reconcilePregrade, persistPregrade, pregradeHistory, HISTORY_CAP, TTL_SECONDS, KEY_PREFIX, key, sanitize, newAttemptId, diff --git a/src/services/snapshotService.js b/src/services/snapshotService.js index 0c171b7..7de5945 100644 --- a/src/services/snapshotService.js +++ b/src/services/snapshotService.js @@ -516,6 +516,10 @@ async function runSnapshot(sport, opts = {}) { try { const binder = deps.gameBinder || require('./gameBinder'); const b = await binder.attachGameTimes(sp, props, { gradedAt: ts }); + pgRec.binding({ + started: true, completed: true, threw: false, + bound: b.bound, unresolved: b.unresolved, already_had: b.alreadyHad, + }); console.log(`[snapshot] game binding ${sp}: ${b.bound} bound, ${b.alreadyHad} already had times, ${b.unresolved} UNRESOLVED, ${b.ambiguous} ambiguous(doubleheader)`); // CANONICAL EVENT IDENTITY (MLB). The bound game_time above is what makes @@ -532,10 +536,19 @@ async function runSnapshot(sport, opts = {}) { const mlbAdapter = deps.mlbAdapter || require('./adapters/mlbStatsAdapter'); const dates = [...new Set(props.map((p) => p && p.game_date).filter(Boolean))]; const games = []; - for (const d of dates) { - // eslint-disable-next-line no-await-in-loop - const g = await mlbAdapter.getScheduleWithPitchers(d); - if (Array.isArray(g)) games.push(...g); + pgRec.schedule({ started: true, completed: false, threw: false, dates_requested: dates.length }); + try { + for (const d of dates) { + // eslint-disable-next-line no-await-in-loop + const g = await mlbAdapter.getScheduleWithPitchers(d); + if (Array.isArray(g)) games.push(...g); + } + } catch (eSch) { + // Record and RETHROW: the existing identity catch below still handles + // it exactly as before. Without this a schedule outage was reported as + // an event-identity error. + pgRec.schedule({ started: true, completed: false, threw: true, dates_requested: dates.length, error: eSch && eSch.message }); + throw eSch; } // PARTICIPANT EVIDENCE. The prop's own team fields cannot police this: // when the provider nests a player under the wrong event, those fields @@ -545,6 +558,10 @@ async function runSnapshot(sport, opts = {}) { // 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. + pgRec.schedule({ + started: true, completed: true, threw: false, + dates_requested: dates.length, games: games.length, + }); const slateDate = dates.length === 1 ? dates[0] : null; let playerTeams = null; let evidenceDateValid = false; @@ -555,11 +572,17 @@ async function runSnapshot(sport, opts = {}) { }); playerTeams = built.index; evidenceDateValid = evid.evidenceIsDateValid(built.stats, slateDate); + pgRec.roster({ + started: true, completed: true, threw: false, + players: built.stats.players, teams: built.stats.teams, + failed: built.stats.failed || 0, evidence_date_valid: evidenceDateValid, + }); console.log(`[snapshot] participant evidence ${sp}: ${built.stats.players} players` + ` across ${built.stats.teams} rosters as of ${slateDate || 'unscoped'}` + `${built.stats.failed ? `, ${built.stats.failed} unreadable` : ''}` + ` · date-valid=${evidenceDateValid}`); } catch (e3) { + pgRec.roster({ started: true, completed: false, threw: true, error: e3 && e3.message }); console.warn(`[snapshot] participant evidence unavailable for ${sp}:`, e3.message); } // Without date-valid evidence a conflict can only be UNRESOLVED, never @@ -589,6 +612,7 @@ async function runSnapshot(sport, opts = {}) { }); } } catch (e) { + pgRec.binding({ started: true, completed: false, threw: true, error: e && e.message }); console.warn(`[snapshot] game binding failed for ${sp} (rows without a real game time will be skipped):`, e.message); } @@ -698,6 +722,9 @@ async function runSnapshot(sport, opts = {}) { } } + // Records whether the hook existed at all, so `first_ongraded_observed:false` + // can be told apart from "no collector was attached". + pgRec.gradeLoop({ ongraded_hook_attached: !!collector }); await deps.gradeAndCacheSlate(sp, props, { pregrade: pgRec, factorContext, @@ -710,8 +737,12 @@ async function runSnapshot(sport, opts = {}) { source: (odds && odds.provider) || 'odds-api', now: deps.now, cacheSet: async (_k, v) => { envelope = v; }, - onGraded: collector ? collector.onGraded : undefined, + // Wrapped ONLY when a collector exists, so the downstream + // `typeof opts.onGraded === 'function'` branch is exactly as before. The + // wrapper records a control-flow fact and delegates unchanged. + onGraded: collector ? ((base, sides) => { pgRec.firstOnGraded(); return collector.onGraded(base, sides); }) : undefined, onPublished: collector ? collector.onPublished : undefined, + }); // Persist the pre-grading stage trace. Best-effort at the call site: a // telemetry failure must never fail a healthy slate. diff --git a/tests/unit/pregradeCoverage.test.js b/tests/unit/pregradeCoverage.test.js new file mode 100644 index 0000000..f3612f4 --- /dev/null +++ b/tests/unit/pregradeCoverage.test.js @@ -0,0 +1,337 @@ +'use strict'; + +/** + * PATH COVERAGE — acquisition success to the first real onGraded callback. + * + * Every branch in that corridor must explain itself. Twice now this programme + * has read an absence as a conclusion: first "MLB exited at acquisition" (from + * a missing retention record), then "all props rejected at admission" (from an + * empty collector). Both were wrong. So the rule here is that a MISSING trace + * is an OBSERVABILITY_GAP, never a pipeline verdict. + */ + +const fs = require('fs'); +const path = require('path'); +const acq = require('../../src/services/ops/acquisitionTrace'); +const snap = require('../../src/services/snapshotService'); +const { __internals } = require('../../src/services/gradeSlateService'); +const { MODEL_BOOKS } = require('../../src/config/bookRoles'); +const ROOT = path.resolve(__dirname, '..', '..'); +const BOOK = [...MODEL_BOOKS][0]; + +jest.setTimeout(20000); + +const P = (n, over = {}) => Array.from({ length: n }, (_, i) => ({ + player: `P${i}`, stat_type: 'hits', line: 0.5 + i, sport: 'mlb', book: BOOK, + home_team: 'Reds', away_team: 'Cardinals', game_date: '2026-08-28', ...over, +})); + +const identityOk = (counts = {}) => ({ + buildPlayerTeamIndex: async () => ({ index: {}, stats: { players: 12, teams: 2, failed: 0 } }), + evidenceIsDateValid: () => true, + attachEventIdentity: () => ({ total: 4, canonical: 4, unresolved: 0, unsupported: 0, impossible: 0, reasons: {}, ...counts }), +}); + +/** Runs the REAL runSnapshot corridor; grading is stubbed unless asked for. */ +async function corridor(over = {}) { + const pg = []; const a = []; + await snap.runSnapshot('mlb', { + getOdds: async () => ({ sport: 'mlb', props: P(4), provider: 'propline', source: 'cache' }), + gradeAndCacheSlate: async () => ({ written: false, count: 0 }), + gameBinder: { attachGameTimes: async () => ({ bound: 4, unresolved: 0, alreadyHad: 0 }) }, + mlbAdapter: { getScheduleWithPitchers: async () => [{ gamePk: 1 }, { gamePk: 2 }] }, + eventIdentity: identityOk(), + notify: async () => {}, sleep: async () => {}, retryDelayMs: 0, + now: () => '2026-08-28T14:00:00.000Z', scheduledHourUtc: 14, processStartedAt: 'proc-1', + retention: null, + ledger: { recordPipelineGrades: async () => {}, captureClosing: async () => {}, gameDateFor: () => '2026-08-28' }, + captureBookPrices: async () => ({}), buildEspnIndex: async () => ({}), + refreshTeamStats: async () => ({ captured: 0 }), + cacheGet: async () => null, cacheSet: async () => {}, + persistAcquisitionTrace: async (t) => a.push(t), + persistPregradeTrace: async (t) => pg.push(t), + ...over, + }); + return { pg: pg[0], acq: a[0] }; +} + +describe('UPSTREAM MLB STAGES', () => { + test('SCHEDULE FETCH ERROR names the schedule stage, not identity', async () => { + const { pg } = await corridor({ mlbAdapter: { getScheduleWithPitchers: async () => { throw new Error('statsapi 503'); } } }); + expect(pg.schedule.threw).toBe(true); + expect(pg.schedule.error).toMatch(/statsapi 503/); + expect(pg.outcome).toBe(acq.PREGRADE_OUTCOME.SCHEDULE_STAGE_ERROR); + expect(pg.outcome).not.toBe(acq.PREGRADE_OUTCOME.EVENT_IDENTITY_STAGE_ERROR); + }); + + test('SCHEDULE SUCCESS records dates requested and game count', async () => { + const { pg } = await corridor(); + expect(pg.schedule.completed).toBe(true); + expect(pg.schedule.dates_requested).toBe(1); + expect(pg.schedule.games).toBe(2); + }); + + test('ROSTER INDEX ERROR is visible — its catch used to swallow silently', async () => { + const { pg } = await corridor({ + eventIdentity: { ...identityOk(), buildPlayerTeamIndex: async () => { throw new Error('roster 500'); } }, + }); + expect(pg.roster.threw).toBe(true); + expect(pg.roster.error).toMatch(/roster 500/); + expect(pg.outcome).toBe(acq.PREGRADE_OUTCOME.ROSTER_STAGE_ERROR); + }); + + test('ROSTER SUCCESS records the indexed counts', async () => { + const { pg } = await corridor(); + expect(pg.roster.completed).toBe(true); + expect(pg.roster.players).toBe(12); + expect(pg.roster.teams).toBe(2); + expect(pg.roster.evidence_date_valid).toBe(true); + }); + + test('BINDING ERROR names the binding stage', async () => { + const { pg } = await corridor({ gameBinder: { attachGameTimes: async () => { throw new Error('binder boom'); } } }); + expect(pg.binding.threw).toBe(true); + expect(pg.outcome).toBe(acq.PREGRADE_OUTCOME.BINDING_STAGE_ERROR); + }); + + test('BINDING SUCCESS records bound / unresolved / already-had', async () => { + const { pg } = await corridor({ gameBinder: { attachGameTimes: async () => ({ bound: 3, unresolved: 1, alreadyHad: 0 }) } }); + expect(pg.binding).toMatchObject({ completed: true, bound: 3, unresolved: 1, already_had: 0 }); + }); + + test('EVENT IDENTITY ERROR is recorded and the existing fallback is preserved', async () => { + const { pg } = await corridor({ + eventIdentity: { ...identityOk(), attachEventIdentity: () => { throw new Error('identity boom'); } }, + }); + expect(pg.identity.threw).toBe(true); + expect(pg.outcome).toBe(acq.PREGRADE_OUTCOME.EVENT_IDENTITY_STAGE_ERROR); + // The existing catch keeps the slate alive — props stay on legacy identity. + const src = fs.readFileSync(path.join(ROOT, 'src/services/snapshotService.js'), 'utf8'); + expect(src).toMatch(/Identity is additive: a failure leaves props on legacy identity/); + }); + + test('IDENTITY ALL UNRESOLVED reports truthful status counts', async () => { + const { pg } = await corridor({ + eventIdentity: identityOk({ total: 4, canonical: 0, unresolved: 4, impossible: 0, reasons: { no_team_match: 4 } }), + gradeAndCacheSlate: async (sp, props, o) => { + o.pregrade.admission({ started: true, completed: true, threw: false, input_count: 4, admitted_count: 0, rejected_count: 4, rejection_reason_counts: { EVENT_UNRESOLVED: 4 } }); + return { written: false, count: 0 }; + }, + }); + expect(pg.identity).toMatchObject({ completed: true, total: 4, canonical: 0, unresolved: 4 }); + expect(pg.identity.reasons.no_team_match).toBe(4); + // Names the CAUSE, not just the symptom. + expect(pg.outcome).toBe(acq.PREGRADE_OUTCOME.IDENTITY_ALL_UNRESOLVED); + }); +}); + +describe('DEDUPE IS A FILTER — exact drop accounting off its own branches', () => { + const d = (rows, limit = 100) => { const st = {}; const out = __internals.dedupeProps(rows, limit, st); return { out, st }; }; + + test('behaviour is byte-identical with and without the stats object', () => { + const rows = [...P(3), ...P(2, { book: 'nope' })]; + expect(__internals.dedupeProps(rows, 100).length).toBe(d(rows).out.length); + }); + + test('ALL dropped by MODEL BOOK', () => { + const { out, st } = d(P(5, { book: 'not_a_model_book' })); + expect(out).toHaveLength(0); + expect(st.dropped_non_model_book).toBe(5); + expect(st.dropped_invalid_fields || 0).toBe(0); + }); + + test('ALL dropped by MISSING REQUIRED FIELDS', () => { + const { out, st } = d(P(4).map((p) => ({ ...p, player: null }))); + expect(out).toHaveLength(0); + expect(st.dropped_invalid_fields).toBe(4); + expect(st.dropped_non_model_book || 0).toBe(0); + }); + + test('only TRUE duplicates counted as duplicates', () => { + const one = P(1); + const { out, st } = d([...one, ...one, ...one]); + expect(out).toHaveLength(1); + expect(st.duplicate_identity_removed).toBe(2); + expect(st.dropped_non_model_book || 0).toBe(0); + }); + + test('counts reconcile to the input exactly', () => { + const rows = [...P(2), ...P(2), ...P(1, { book: 'x' }), { player: null }]; + const { out, st } = d(rows); + const accounted = out.length + + (st.dropped_invalid_fields || 0) + (st.dropped_non_model_book || 0) + + (st.duplicate_identity_removed || 0) + (st.not_examined || 0); + expect(accounted).toBe(rows.length); + expect(st.input_count).toBe(rows.length); + }); + + test('MODEL-book eligibility is countable for the cohort', () => { + const { st } = d([...P(3), ...P(2, { book: 'nope' })]); + expect(st.model_book_eligible).toBe(3); + expect(st.dropped_non_model_book).toBe(2); + }); + + test('the cap records not_examined rather than pretending it saw everything', () => { + const { out, st } = d(P(10), 4); + expect(out).toHaveLength(4); + expect(st.capped).toBe(true); + expect(st.not_examined).toBe(6); + }); +}); + +describe('THE GRADING BOUNDARY', () => { + const realGrade = () => require('../../src/services/gradeSlateService').gradeAndCacheSlate; + + test('candidates > 0 but loop NOT entered is distinct from loop entered', () => { + const t = acq.beginPregrade({ attemptId: 'a', sport: 'mlb', propsCount: 4 }); + acq.pregradeRecorder(t).gradeLoop({ candidate_count: 4, reached: true }); + expect(acq.finishPregrade(t, {}).outcome).toBe(acq.PREGRADE_OUTCOME.READY_FOR_GRADING); + expect(t.grade_loop.first_gradebestside_started).toBeUndefined(); + }); + + test('FIRST gradeBestSide START is proven through the REAL loop', async () => { + const t = acq.beginPregrade({ attemptId: 'a', sport: 'mlb', propsCount: 3 }); + await realGrade()('mlb', P(3), { + pregrade: acq.pregradeRecorder(t), cacheSet: async () => {}, grade: async () => null, + }); + expect(t.grade_loop.entered).toBe(true); + expect(t.grade_loop.first_gradebestside_started).toBe(true); + expect(acq.finishPregrade(t, {}).outcome).toBe(acq.PREGRADE_OUTCOME.GRADE_LOOP_STARTED); + }); + + test('FIRST onGraded is proven separately from gradeBestSide starting', async () => { + const t = acq.beginPregrade({ attemptId: 'a', sport: 'mlb', propsCount: 3 }); + const rec = acq.pregradeRecorder(t); + await realGrade()('mlb', P(3), { + pregrade: rec, cacheSet: async () => {}, grade: async () => null, + onGraded: () => { rec.firstOnGraded(); }, + }); + expect(t.grade_loop.first_gradebestside_started).toBe(true); + expect(t.grade_loop.first_ongraded_observed).toBe(true); + expect(acq.finishPregrade(t, {}).outcome).toBe(acq.PREGRADE_OUTCOME.FIRST_GRADE_CALLBACK_OBSERVED); + }); + + test('gradeBestSide starting WITHOUT onGraded is its own state', async () => { + const t = acq.beginPregrade({ attemptId: 'a', sport: 'mlb', propsCount: 3 }); + await realGrade()('mlb', P(3), { + pregrade: acq.pregradeRecorder(t), cacheSet: async () => {}, grade: async () => null, + }); + expect(t.grade_loop.first_gradebestside_started).toBe(true); + expect(t.grade_loop.first_ongraded_observed).toBeUndefined(); + expect(acq.finishPregrade(t, {}).outcome).not.toBe(acq.PREGRADE_OUTCOME.FIRST_GRADE_CALLBACK_OBSERVED); + }); + + test('runSnapshot wraps onGraded ONLY when a collector exists', () => { + const src = fs.readFileSync(path.join(ROOT, 'src/services/snapshotService.js'), 'utf8'); + expect(src).toMatch(/onGraded: collector \? \(\(base, sides\) => \{ pgRec\.firstOnGraded\(\); return collector\.onGraded\(base, sides\); \}\) : undefined/); + expect(src).toMatch(/ongraded_hook_attached: !!collector/); + }); + + test('no model output is recorded — control-flow facts only', () => { + const t = acq.beginPregrade({ attemptId: 'a', sport: 'mlb' }); + const rec = acq.pregradeRecorder(t); + rec.gradeStarted(); rec.firstOnGraded(); + const blob = JSON.stringify(t); + for (const forbidden of ['p_win', 'grade', 'confidence', 'projection', 'edge', 'side']) { + expect(blob).not.toMatch(new RegExp(`"${forbidden}"`)); + } + }); +}); + +describe('TRACE COMPLETENESS INVARIANT', () => { + const acqOk = { snapshot_attempt_id: 'acq_1', final: acq.FINAL.NONZERO, outcome: acq.OUTCOME.CONTINUED }; + + test('acquisition CONTINUED + NO downstream trace = OBSERVABILITY_GAP, not failure', () => { + const r = acq.reconcilePregrade(acqOk, null); + expect(r.applicable).toBe(true); + expect(r.complete).toBe(false); + expect(r.outcome).toBe(acq.PREGRADE_OUTCOME.OBSERVABILITY_GAP); + }); + + test('a mismatched attempt id is also a gap, never a verdict', () => { + const r = acq.reconcilePregrade(acqOk, { snapshot_attempt_id: 'other', outcome: acq.PREGRADE_OUTCOME.ALL_REJECTED }); + expect(r.outcome).toBe(acq.PREGRADE_OUTCOME.OBSERVABILITY_GAP); + expect(r.reason).toBe('attempt_id_mismatch'); + }); + + test('an undecided downstream state is a gap, not a conclusion', () => { + for (const o of [undefined, acq.PREGRADE_OUTCOME.INCOMPLETE, acq.PREGRADE_OUTCOME.OTHER_PREGRADING_ERROR]) { + const r = acq.reconcilePregrade(acqOk, { snapshot_attempt_id: 'acq_1', outcome: o }); + expect(r.outcome).toBe(acq.PREGRADE_OUTCOME.OBSERVABILITY_GAP); + } + }); + + test('a decided downstream state reconciles complete', () => { + const r = acq.reconcilePregrade(acqOk, { snapshot_attempt_id: 'acq_1', outcome: acq.PREGRADE_OUTCOME.ALL_REJECTED }); + expect(r.complete).toBe(true); + expect(r.outcome).toBe(acq.PREGRADE_OUTCOME.ALL_REJECTED); + }); + + test('a stubbed/short-circuited grader that records nothing IS a gap', async () => { + // Proves the invariant fires rather than inventing a verdict from silence. + const { pg, acq: a } = await corridor(); + expect(acq.reconcilePregrade(a, pg).outcome).toBe(acq.PREGRADE_OUTCOME.OBSERVABILITY_GAP); + }); + + test('it does not apply when acquisition did not continue', () => { + const r = acq.reconcilePregrade({ snapshot_attempt_id: 'a', final: acq.FINAL.THREW, outcome: acq.OUTCOME.EARLY_RETURN_ODDS_ERROR }, null); + expect(r.applicable).toBe(false); + }); + + test('a real corridor run produces a decided state and the SAME attempt id', async () => { + // The REAL grader, so admission/dedupe/grade-loop actually record. With a + // stubbed grader nothing records and the reconciliation correctly reports a + // gap — which is the invariant working, not a decided state. + const { pg, acq: a } = await corridor({ + gradeAndCacheSlate: require('../../src/services/gradeSlateService').gradeAndCacheSlate, + grade: async () => null, + }); + expect(pg.snapshot_attempt_id).toBe(a.snapshot_attempt_id); + const r = acq.reconcilePregrade(a, pg); + expect(r.complete).toBe(true); + expect(r.outcome).not.toBe(acq.PREGRADE_OUTCOME.OBSERVABILITY_GAP); + }); +}); + +describe('THE NO-OP RECORDER MUST IMPLEMENT EVERY METHOD', () => { + // A missing method throws inside the grade loop, and the enclosing catch + // turns that into {written:false,count:0} — a silently empty slate for every + // caller that passes no recorder. This shipped once; it must not again. + test('NO_PREGRADE covers the full recorder surface', async () => { + const real = acq.pregradeRecorder(acq.beginPregrade({ attemptId: 'a', sport: 'mlb' })); + const src = fs.readFileSync(path.join(ROOT, 'src/services/gradeSlateService.js'), 'utf8'); + const i = src.indexOf('const NO_PREGRADE'); + const block = src.slice(i, src.indexOf('\n});', i) + 4); + for (const m of Object.keys(real)) { + expect(block).toContain(`${m}(`); + } + }); + + test('grading works with NO recorder passed at all', async () => { + const res = await require('../../src/services/gradeSlateService') + .gradeAndCacheSlate('mlb', P(2), { cacheSet: async () => {}, grade: async () => null }); + // Refusals produce no grades, but it must NOT fail with a recorder error. + expect(res.error).toBeUndefined(); + }); +}); + +describe('ZERO NEW EXTERNAL CALLS', () => { + test('the observer adds no provider or schedule call', () => { + const snapSrc = fs.readFileSync(path.join(ROOT, 'src/services/snapshotService.js'), 'utf8'); + // getScheduleWithPitchers is called exactly where the product already called it. + expect((snapSrc.match(/getScheduleWithPitchers\(/g) || []).length).toBe(2); + expect((snapSrc.match(/buildPlayerTeamIndex\(/g) || []).length).toBe(1); + const traceSrc = fs.readFileSync(path.join(ROOT, 'src/services/ops/acquisitionTrace.js'), 'utf8'); + const code = traceSrc.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:])\/\/.*$/gm, '$1') + .replace(/s\.replace\([\s\S]*?\);/g, ''); + for (const f of ['fetch(', 'axios', 'getSchedule', 'getProps', 'getOdds', 'oddsService']) { + expect(code).not.toContain(f); + } + }); + + test('a trace-store failure never changes the product outcome', async () => { + const { pg } = await corridor({ persistPregradeTrace: async () => { throw new Error('redis down'); } }); + expect(pg).toBeUndefined(); // nothing stored… + // …and the run still completed: no throw escaped runSnapshot. + }); +}); diff --git a/tests/unit/pregradeTrace.test.js b/tests/unit/pregradeTrace.test.js index 81e7f84..f305b70 100644 --- a/tests/unit/pregradeTrace.test.js +++ b/tests/unit/pregradeTrace.test.js @@ -48,8 +48,11 @@ describe('THE STAGE MATRIX', () => { expect(trace.admission.rejected_count).toBe(0); expect(trace.dedupe.completed).toBe(true); expect(trace.dedupe.output_count).toBe(5); - expect(trace.grade_loop).toEqual({ candidate_count: 5, reached: true }); - expect(trace.outcome).toBe(acq.PREGRADE_OUTCOME.READY_FOR_GRADING); + expect(trace.grade_loop).toMatchObject({ candidate_count: 5, reached: true }); + // The loop now proves it ENTERED, which is strictly stronger than a + // candidate count, so the terminal state advances past READY_FOR_GRADING. + expect(trace.grade_loop.first_gradebestside_started).toBe(true); + expect(trace.outcome).toBe(acq.PREGRADE_OUTCOME.GRADE_LOOP_STARTED); }); test('ALL_REJECTED — with EXACT reason accounting', async () => { @@ -74,7 +77,7 @@ describe('THE STAGE MATRIX', () => { expect(trace.admission.admitted_count).toBe(4); expect(trace.admission.rejected_count).toBe(3); expect(trace.admission.rejection_reason_counts.EVENT_PLAYER_TEAM_CONTRADICTION).toBe(3); - expect(trace.outcome).toBe(acq.PREGRADE_OUTCOME.READY_FOR_GRADING); + expect(trace.outcome).toBe(acq.PREGRADE_OUTCOME.GRADE_LOOP_STARTED); }); test('DEDUPE_EMPTY — admitted > 0 but dedupe filters to zero, NOT ALL_REJECTED', async () => { @@ -84,7 +87,9 @@ describe('THE STAGE MATRIX', () => { expect(trace.admission.admitted_count).toBe(5); expect(trace.dedupe.input_count).toBe(5); expect(trace.dedupe.output_count).toBe(0); - expect(trace.outcome).toBe(acq.PREGRADE_OUTCOME.DEDUPE_EMPTY); + // Now names WHICH filter emptied it, not merely that it emptied. + expect(trace.dedupe.dropped_non_model_book).toBe(5); + expect(trace.outcome).toBe(acq.PREGRADE_OUTCOME.DEDUPE_ALL_NON_MODEL_BOOK); expect(trace.outcome).not.toBe(acq.PREGRADE_OUTCOME.ALL_REJECTED); }); @@ -126,7 +131,9 @@ describe('THE STAGE MATRIX', () => { await snap2.runSnapshot('mlb', { getOdds: async () => ({ sport: 'mlb', props: props(2), provider: 'propline', source: 'cache' }), gradeAndCacheSlate: async () => ({ written: false, count: 0 }), - eventIdentity: { buildPlayerTeamIndex: async () => { throw new Error('roster down'); }, + // Roster succeeds so the IDENTITY throw is the one under test; roster now + // has its own terminal state and would otherwise (correctly) outrank it. + eventIdentity: { buildPlayerTeamIndex: async () => ({ index: {}, stats: { players: 1, teams: 1, failed: 0 } }), evidenceIsDateValid: () => false, attachEventIdentity: () => { throw new Error('statsapi shape changed'); } }, mlbAdapter: { getScheduleWithPitchers: async () => [] }, @@ -141,14 +148,14 @@ describe('THE STAGE MATRIX', () => { expect(pg).toHaveLength(1); expect(pg[0].identity).toBeTruthy(); expect(pg[0].identity.threw).toBe(true); - expect(pg[0].identity.error).toMatch(/statsapi shape changed|roster down/); - expect(pg[0].outcome).toBe(acq.PREGRADE_OUTCOME.IDENTITY_STAGE_ERROR); + expect(pg[0].identity.error).toMatch(/statsapi shape changed/); + expect(pg[0].outcome).toBe(acq.PREGRADE_OUTCOME.EVENT_IDENTITY_STAGE_ERROR); }); - test('IDENTITY_STAGE_ERROR outranks everything downstream', async () => { + test('EVENT_IDENTITY_STAGE_ERROR outranks everything downstream', async () => { const t = acq.beginPregrade({ attemptId: 'a', sport: 'mlb', propsCount: 3 }); acq.pregradeRecorder(t).identity({ started: true, completed: false, threw: true, error: 'statsapi down' }); - expect(acq.finishPregrade(t, {}).outcome).toBe(acq.PREGRADE_OUTCOME.IDENTITY_STAGE_ERROR); + expect(acq.finishPregrade(t, {}).outcome).toBe(acq.PREGRADE_OUTCOME.EVENT_IDENTITY_STAGE_ERROR); }); test('an empty collector alone can NEVER be classified', () => { @@ -225,7 +232,12 @@ describe('THE OBSERVER DOES NOT TOUCH THE PROPS', () => { test('the recorder default is a frozen no-op, so existing callers are identical', () => { const src = fs.readFileSync(path.join(ROOT, 'src/services/gradeSlateService.js'), 'utf8'); - expect(src).toMatch(/const NO_PREGRADE = Object\.freeze\(\{ identity\(\) \{\}, admission\(\) \{\}, dedupe\(\) \{\}, gradeLoop\(\) \{\} \}\)/); + // Now multi-line and covering the full recorder surface; match the shape, + // not the exact one-line form. + expect(src).toMatch(/const NO_PREGRADE = Object\.freeze\(\{/); + for (const m of ['identity', 'admission', 'dedupe', 'gradeLoop', 'gradeStarted', 'firstOnGraded']) { + expect(src).toMatch(new RegExp(`${m}\\(\\) \\{\\}`)); + } expect(src).toMatch(/const pregrade = opts\.pregrade \|\| NO_PREGRADE;/); }); }); @@ -309,7 +321,7 @@ describe('THE TRACE IS ACTUALLY PERSISTED BY THE REAL PATH', () => { expect(pgStored[0].snapshot_attempt_id).toBe(acqStored[0].snapshot_attempt_id); expect(pgStored[0].input_props_count).toBe(3); expect(pgStored[0].admission.input_count).toBe(3); - expect(pgStored[0].outcome).toBe(acq.PREGRADE_OUTCOME.READY_FOR_GRADING); + expect(pgStored[0].outcome).toBe(acq.PREGRADE_OUTCOME.GRADE_LOOP_STARTED); }); }); diff --git a/tests/unit/retentionIdentity.test.js b/tests/unit/retentionIdentity.test.js index 6519d1a..c362716 100644 --- a/tests/unit/retentionIdentity.test.js +++ b/tests/unit/retentionIdentity.test.js @@ -106,7 +106,9 @@ describe('CANONICAL EVENT AVAILABILITY BY ROW CLASS', () => { // wraps it in try/catch to record and rethrow, which changed `const gate =` // into `gate = `. The invariant is that admission runs on (props, sport). expect(gs).toMatch(/gate = admitForGrading\(props, sport\)/); - expect(gs).toMatch(/dedupeProps\(gate\.admitted, limit\)/); + // The call gained an optional diagnostic stats object; match the call, not + // its exact arity. + expect(gs).toMatch(/dedupeProps\(gate\.admitted, limit/); expect(gs).not.toMatch(/dedupeProps\(gate\.rejected/); });