Path coverage: every branch from acquisition to the first grade callback
Audited the corridor rather than trusting the recorder. Nine upstream operations
sit between acquisition success and gradeAndCacheSlate — game binding, per-date
schedule fetch, roster index, attachEventIdentity, book-price capture, team-stat
refresh, hits-factor context, matchup keys — each with its own catch. NONE of
them early-returns, so the corridor always reaches the grader; but seven of them
were SILENT, and two could be misattributed.
COVERAGE WAS INCOMPLETE. Closed:
* BINDING — bound / unresolved / already-had, and its throw
* SCHEDULE — dates requested, game count, and its throw. A schedule outage
previously surfaced as an EVENT IDENTITY error because it lands in that
catch; it now records SCHEDULE_STAGE_ERROR and rethrows unchanged.
* ROSTER — indexed players/teams/failed and evidence-date-validity, and its
throw. Its catch only console.warn'd, so this stage was entirely invisible.
* DEDUPE per-reason accounting off the filter's OWN branches: invalid fields /
non-model book / duplicate identity / capped / not examined, plus
model-book eligibility. Counts reconcile to the input exactly.
* GRADE BOUNDARY — `reached` is derived from a candidate count and proves
nothing. GRADE_LOOP_ENTERED, FIRST_GRADEBESTSIDE_STARTED and
FIRST_ONGRADED_OBSERVED are now separate control-flow facts. No model
output is recorded; a test greps for p_win/grade/confidence/edge/side.
Terminal states now name the stage: SCHEDULE_STAGE_ERROR, ROSTER_STAGE_ERROR,
BINDING_STAGE_ERROR, EVENT_IDENTITY_STAGE_ERROR, ADMISSION_STAGE_ERROR,
DEDUPE_STAGE_ERROR, IDENTITY_ALL_UNRESOLVED, ALL_REJECTED,
DEDUPE_ALL_NON_MODEL_BOOK, DEDUPE_ALL_INVALID_FIELDS, DEDUPE_EMPTY_OTHER,
READY_FOR_GRADING, GRADE_LOOP_STARTED, FIRST_GRADE_CALLBACK_OBSERVED.
TRACE COMPLETENESS INVARIANT. `reconcilePregrade` — acquisition NONZERO +
CONTINUED with no correlated downstream state is an OBSERVABILITY_GAP, never a
pipeline verdict. This programme has twice read an absence as a conclusion
("MLB exited at acquisition", "all props rejected at admission"); both were
wrong. Now it is a typed state with tests.
dedupeProps takes an OPTIONAL stats object and increments on the branches it
already takes, in the same order — reused, never reimplemented. Without the
object it is byte-identical; a test asserts that.
A PRODUCTION-BREAKING BUG CAUGHT BY THE FULL SUITE: the frozen no-op recorder
did not implement gradeStarted/firstOnGraded, so any caller without a recorder
threw inside the grade loop — and gradeAndCacheSlate's catch turned that into
{written:false,count:0}. Every slate would have graded NOTHING, silently. Fixed,
NO_PREGRADE now covers the full recorder surface, and a test asserts it does.
Exception semantics unchanged throughout: every added catch records and RETHROWS
the identical error. Admission rules, dedupe predicates, MODEL_BOOKS, event
identity, gameBinder, gradeBestSide and its arguments: 0 changed lines.
eventIdentity, oddsService, retentionService, gameBinder, bookRoles,
analyzeViaEngine1, probabilityEstimator, snapshotScheduler and ledgerService:
UNCHANGED. Zero new external calls — the only diff hit is the existing
getScheduleWithPitchers line re-indented into its own try.
Twelve teeth, injections verified present, against a green baseline of 89:
upstream catch silent (1) · missing trace as failure (1) · admission exception
as ALL_REJECTED (2) · non-model-book as duplicate (3) · dedupe-empty as
rejection (1) · falsely says grading started (6) · onGraded unrecorded (1) ·
sport overwrite (2) · intraday overwrite (1) · different attempt id (3) ·
observer adds an external call (1) · store failure changes outcome (2).
Restored byte-identically; teeth 3/4/6 re-run after the NO_PREGRADE fix.
The first teeth pass ran against a baseline the finer states had invalidated;
six superseded assertions were updated first and the run repeated.
388 suites / 5,269 tests pass. web tsc exit 0. Lineage stays OFF.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CQJeAG8vcDoL5zkiaJyVb8
This commit is contained in:
@@ -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));
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user