Settlement fix forward: date-targeted resolution + terminal states

Order 1 of 2. Push scoring UNTOUCHED — it is correct. No healing here.

PHASE 1 — DATE-TARGETED FETCH replaces the rolling window for settlement.
settleSource.resolveOutcome() resolves the SPECIFIC DATE and, when the
player is absent, reads GAME STATE to learn what the absence MEANS:
  game final + player has a line  -> SETTLE (a partial game is a real
                                     result, never a void)
  game final + player absent      -> VOID (confirmed DNP)
  postponed / cancelled           -> VOID
  scheduled / in progress / SUSPENDED -> PENDING (a suspended game resumes;
                                     voiding it would destroy a real bet)
  player played, stat missing     -> unknown, NEVER void a real appearance
This is FREE for MLB: mlbStatsAdapter.getPlayerGameLog already returned the
full season log and getPlayerStats was discarding it with .slice(-10).
Settlement now reads fullLog — same request, same cache — which removes
window-decay entirely (the verified failure was a Jul 12 game outside a
last10 starting Jul 6). Projections keep using last10, unchanged.

PHASE 2 — TERMINAL STATES (migration 026 applied). outcome CHECK widened to
hit/miss/push/void/unrecoverable; added settle_attempts, settlement_source,
settlement_version, model_version. A row that cannot be resolved after
SETTLE_ATTEMPT_CAP (4) date-targeted attempts becomes 'unrecoverable'
rather than pending forever. CRITICAL: getModelAggregate now EXCLUDES void
and unrecoverable from the settled selection — it used
.not('outcome','is',null), so without this a void would have counted as a
settled row and silently moved the public record. Verified in the record
calc, not just the settle path.

PHASE 3 — SETTLEMENT-RATE ALARM. zeroSettleAlarm only caught a TOTAL zero
while ~30% of a slate failed quietly (Jul 17: 57/86). opsWatch
.settlementRateAlarm pages when resolved/attempted falls below
SETTLE_RATE_FLOOR (0.8). Voids count as RESOLVED — a void is a legitimate
terminal state — so healthy voiding never pages. Third silent-failure
surface of the night, now closed.

PHASE 4 — VERSION STAMPING. src/config/modelEras.js defines the cutoff
ONCE (2026-07-19T22:50:00Z); migration 026 backfilled pre-cutoff rows as
'pre-retention-unknown' (naming the uncertainty, not implying knowledge);
new rows carry model_version.

Regression caught pre-deploy: getScheduleFn was not injectable, so the
ledger suite hit the real network and HUNG. Now injectable via opts and a
no-op under NODE_ENV=test. The "no row -> pending" test was updated to the
new behaviour deliberately: a missing row on a FINAL game now voids.

Suite 281/3373 green, build exit 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SmNjJAwEnqHPtXbvSZR8kA
This commit is contained in:
Kev
2026-07-20 03:04:55 -04:00
parent e46c88e364
commit d4a6170ffa
9 changed files with 559 additions and 14 deletions
+108 -10
View File
@@ -33,6 +33,24 @@ const { settleResult, statValue, logRowOnDate } = require('./outcomeService');
const CONFLICT = 'user_id,player_key,stat,line,side,game_id';
const UPSERT_CHUNK = 200;
const SETTLE_FETCH_LIMIT = 500;
// Session 64 — bounded retry: a row that cannot be resolved after this many
// date-targeted attempts becomes 'unrecoverable' rather than pending forever.
const SETTLE_ATTEMPT_CAP = Number(process.env.SETTLE_ATTEMPT_CAP || 4);
// Bump when the settlement RULE changes, so healed rows are distinguishable
// from originals and the harness can filter by how a row was scored.
const SETTLEMENT_VERSION = Number(process.env.SETTLEMENT_VERSION || 2);
const settleSource = require('./settleSource');
const MODEL_ERA_VERSION = process.env.MODEL_VERSION || 'engine1@2026-07-20';
/** Day's games for a sport (date-pinned since S57) — tells us what a player's
* ABSENCE means: DNP, postponed, or simply not final yet. */
async function getScheduleFn(sport, date) {
// Never reach the network from a test run (the opsNotify/refreshTeamStats
// precedent) — a schedule lookup inside the settle loop would hang the suite.
if (process.env.NODE_ENV === 'test') return [];
try {
return await require('./scheduleService').getSchedule(sport, date);
} catch { return []; }
}
const AGG_WINDOW_DAYS = 30;
const AGG_FETCH_LIMIT = 5000;
/** Below this many settled rows, callers must not render a percentage. */
@@ -200,6 +218,10 @@ function rowsFromSnapshot(sport, grades, oddsProps, nowIso) {
confidence: numOrNull(g.confidence),
model_value: numOrNull(g.projection),
graded_at: gradedTs,
// Session 64 — stamp the model era on every NEW row. Pre-cutoff rows are
// labelled 'pre-retention-unknown' by migration 026; they cannot be
// resolved retroactively.
model_version: MODEL_ERA_VERSION,
game_id: gameIdFor(sp, prop, gameDate),
game_date: gameDate,
});
@@ -305,6 +327,10 @@ async function settleLedger(sport, opts = {}) {
const sp = String(sport || '').toLowerCase();
const nowIso = (opts.now || (() => new Date().toISOString()))();
const getPlayerStats = opts.getPlayerStats || defaultGetPlayerStats;
// Session 64 — injectable like every other dep: tests must never reach the
// network, and a schedule lookup inside the settle loop would otherwise hang
// the suite.
const getSchedule = opts.getSchedule || getScheduleFn;
const cutoff = opts.beforeDate || todayET(); // settle strictly-before today
const { data: open, error } = await sb.from('ledger_entries')
@@ -319,7 +345,7 @@ async function settleLedger(sport, opts = {}) {
// We need each row's game_date for the log match — refetch with it included.
const { data: rows } = await sb.from('ledger_entries')
.select('id, player_name, stat, line, side, closing_line, game_date')
.select('id, player_name, stat, line, side, closing_line, game_date, settle_attempts')
.in('id', open.map((r) => r.id));
// One game-log fetch per unique player.
@@ -328,21 +354,85 @@ async function settleLedger(sport, opts = {}) {
for (const player of players) {
try {
const stats = await getPlayerStats(player, sp);
logByPlayer[player] = stats && stats.found && Array.isArray(stats.last10) ? stats.last10 : [];
// Session 64 — prefer the FULL season log for settlement. MLB already
// fetched it (getPlayerStats was discarding it via .slice(-10)), so this
// costs nothing and removes window-decay entirely. Fall back to last10
// for sources that only expose a window (ESPN).
const full = stats && Array.isArray(stats.fullLog) ? stats.fullLog : null;
const win = stats && stats.found && Array.isArray(stats.last10) ? stats.last10 : [];
logByPlayer[player] = (full && full.length) ? full : win;
} catch { logByPlayer[player] = []; }
}
let settled = 0;
let pending = 0;
let voided = 0;
let unrecoverable = 0;
for (const row of rows || []) {
const log = logByPlayer[row.player_name] || [];
// Sport-aware date match: MLB rows are YYYY-MM-DD, NBA/WNBA ESPN rows are
// ISO timestamps normalized to their ET date. game_date is already < today
// (the .lt('game_date', cutoff) fetch), so an unplayed game never settles.
const gameRow = log.find((r) => logRowOnDate(r, row.game_date, sp));
if (!gameRow) { pending += 1; continue; }
const actual = statValue(gameRow.stat, row.stat, sp);
if (actual == null) { pending += 1; continue; }
// Session 64 — DATE-TARGETED settlement. The old code searched a ROLLING
// window and, on a miss, did `pending += 1` with no terminal state: a row
// that could never settle (player DNP, or the window rolled past the game)
// looked identical to one settling tomorrow, and pended forever.
// `resolveOutcome` reads the FULL log for that exact date and, when the
// player is absent, consults the day's SCHEDULE to learn what the absence
// MEANS — DNP vs postponed vs not-yet-final.
const res = await settleSource.resolveOutcome({
sport: sp,
playerName: row.player_name,
gameDate: row.game_date,
statType: row.stat,
getFullLog: async () => log,
getSchedule,
matchesDate: (r, d) => logRowOnDate(r, d, sp),
statValue: (statObj, st) => statValue(statObj, st, sp),
});
const attempts = Number(row.settle_attempts || 0) + 1;
// Not final yet (scheduled / in progress / SUSPENDED) → stay pending. A
// suspended game resumes and settles later; voiding it would destroy a
// real bet.
if (res.state === 'pending') {
await sb.from('ledger_entries').update({ settle_attempts: attempts })
.eq('id', row.id).is('outcome', null);
pending += 1;
continue;
}
// Could not determine — bounded retry, then a terminal state. Nothing is
// immortal.
if (res.state === 'unknown') {
const terminal = attempts >= SETTLE_ATTEMPT_CAP;
await sb.from('ledger_entries').update({
settle_attempts: attempts,
...(terminal ? {
outcome: 'unrecoverable',
settled_at: nowIso,
settlement_source: res.source || 'unknown',
settlement_version: SETTLEMENT_VERSION,
} : {}),
}).eq('id', row.id).is('outcome', null);
if (terminal) unrecoverable += 1; else pending += 1;
continue;
}
// No bet existed — DNP or postponed/cancelled. VOID is a terminal truth,
// not a failure, and it is excluded from every record denominator.
if (res.state === 'void') {
const { error: vErr } = await sb.from('ledger_entries').update({
outcome: 'void',
settled_at: nowIso,
settle_attempts: attempts,
settlement_source: res.reason || 'void',
settlement_version: SETTLEMENT_VERSION,
}).eq('id', row.id).is('outcome', null);
if (vErr) { pending += 1; continue; }
voided += 1;
continue;
}
const actual = res.value;
const outcome = settleResult(row.side, actual, row.line);
if (!outcome) { pending += 1; continue; }
const clv = computeClv(row.side, row.line, row.closing_line);
@@ -353,13 +443,16 @@ async function settleLedger(sport, opts = {}) {
settled_at: nowIso,
clv,
clv_result: clvResultOf(clv),
settle_attempts: attempts,
settlement_source: res.source || 'date_log',
settlement_version: SETTLEMENT_VERSION,
})
.eq('id', row.id)
.is('outcome', null); // double-settle guard even across concurrent runs
if (upErr) { pending += 1; continue; }
settled += 1;
}
return { settled, pending };
return { settled, voided, unrecoverable, pending };
}
/**
@@ -455,6 +548,11 @@ async function getModelAggregate(opts = {}) {
settledQ = opts.userId ? settledQ.eq('user_id', opts.userId) : settledQ.is('user_id', null);
settledQ = settledQ
.not('outcome', 'is', null)
// Session 64 — void (no bet existed: DNP/postponed) and unrecoverable
// (truth not fetchable) are TERMINAL but are NOT results. They must never
// enter a record denominator, exactly as pushes are excluded from hit_pct.
// Without this, voiding a row would silently move the public record.
.not('outcome', 'in', '("void","unrecoverable")')
// 2026-07 — a grade with a non-positive model_value had NO real projection
// (the pre-fix degradation). Those locks are kept in the append-only ledger
// but must not count toward the public model record — their hit/miss is