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:
+108
-10
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user