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:
@@ -0,0 +1,35 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* MODEL ERAS — the cutoff, defined ONCE (Session 64).
|
||||
*
|
||||
* `ledger_entries` mixes grades from before and after the 2026-07-19 fix that
|
||||
* revived the probability layer (p_win/ev_pct/model_odds/value were absent on
|
||||
* 100% of live grades before it) and widened the grade range. There is no
|
||||
* per-row version in that table and the eras cannot be separated retroactively,
|
||||
* so EVERY consumer must apply the same boundary rather than inventing its own.
|
||||
*
|
||||
* A backtest that mixes model eras is worthless — that is the whole reason this
|
||||
* constant exists in one place.
|
||||
*
|
||||
* Going forward, rows carry a real `model_version` (from
|
||||
* retentionService.MODEL_VERSION) and `model_snapshots` has clean per-row
|
||||
* versioning from day one. The harness should read OUTCOMES from
|
||||
* ledger_entries but VERSIONING from model_snapshots.
|
||||
*/
|
||||
|
||||
/** Rows graded at or after this are the post-fix era. */
|
||||
const ERA_CUTOFF_ISO = '2026-07-19T22:50:00Z';
|
||||
|
||||
/** What pre-cutoff rows are labelled. Names the uncertainty; never implies
|
||||
* we know which model produced them. */
|
||||
const UNKNOWN_ERA = 'pre-retention-unknown';
|
||||
|
||||
function isPostFix(gradedAt) {
|
||||
if (!gradedAt) return false;
|
||||
const t = new Date(gradedAt).getTime();
|
||||
if (Number.isNaN(t)) return false;
|
||||
return t >= new Date(ERA_CUTOFF_ISO).getTime();
|
||||
}
|
||||
|
||||
module.exports = { ERA_CUTOFF_ISO, UNKNOWN_ERA, isPostFix };
|
||||
@@ -339,6 +339,12 @@ async function getPlayerStats(name, season = DEFAULT_SEASON, opts = {}) {
|
||||
group,
|
||||
season: seasonStat,
|
||||
last10: (log || []).slice(-10),
|
||||
// Session 64 — the FULL season log, already fetched above. Settlement
|
||||
// needs a specific DATE, and a rolling window silently loses games as it
|
||||
// rolls (verified: a Jul 12 game outside a last10 starting Jul 6). This
|
||||
// costs nothing extra — same request, same cache. Projections keep using
|
||||
// last10; only settlement reads fullLog.
|
||||
fullLog: log || [],
|
||||
};
|
||||
} catch (err) {
|
||||
console.warn('[mlbStats] getPlayerStats failed:', name, err.message);
|
||||
|
||||
+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
|
||||
|
||||
@@ -236,8 +236,52 @@ function retentionZeroWriteAlarm(results = [], written = {}) {
|
||||
return { alarm, reason, detail };
|
||||
}
|
||||
|
||||
/**
|
||||
* SETTLEMENT-RATE ALARM (Session 64, Order 1 Phase 3).
|
||||
*
|
||||
* `zeroSettleAlarm` only fires on a TOTAL zero. The diagnostic found 29 of 86
|
||||
* MLB rows (Jul 17) and 28 of 103 (Jul 18) quietly failing to settle while the
|
||||
* pass "succeeded" — the third silent-failure surface of the night. A slate
|
||||
* that mostly settles but persistently drops a third of its rows is broken.
|
||||
*
|
||||
* Resolved = settled + voided + unrecoverable (every TERMINAL state). A void is
|
||||
* a legitimate resolution — the row is no longer immortal — so it counts toward
|
||||
* the rate even though it is excluded from the record.
|
||||
*
|
||||
* Pure. Caller supplies settle results; this only decides.
|
||||
*/
|
||||
const SETTLE_RATE_FLOOR = Number(process.env.SETTLE_RATE_FLOOR || 0.8);
|
||||
|
||||
function settlementRateAlarm(results = [], opts = {}) {
|
||||
const floor = Number.isFinite(opts.floor) ? opts.floor : SETTLE_RATE_FLOOR;
|
||||
const detail = [];
|
||||
let alarm = false;
|
||||
for (const r of results || []) {
|
||||
if (!r || r.error || r.skipped) continue;
|
||||
const settled = Number(r.settled) || 0;
|
||||
const voided = Number(r.voided) || 0;
|
||||
const unrec = Number(r.unrecoverable) || 0;
|
||||
const pending = Number(r.pending) || 0;
|
||||
const resolved = settled + voided + unrec;
|
||||
const attempted = resolved + pending;
|
||||
if (attempted === 0) continue; // nothing due — never page
|
||||
const rate = resolved / attempted;
|
||||
const bad = rate < floor;
|
||||
if (bad) alarm = true;
|
||||
detail.push({ sport: r.sport, attempted, resolved, pending, rate: Math.round(rate * 100) / 100, bad });
|
||||
}
|
||||
const reason = alarm
|
||||
? detail.filter((d) => d.bad)
|
||||
.map((d) => `${String(d.sport || '?').toUpperCase()} resolved ${d.resolved}/${d.attempted} (${Math.round(d.rate * 100)}%), ${d.pending} still pending`)
|
||||
.join('; ')
|
||||
: null;
|
||||
return { alarm, reason, detail, floor };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
retentionZeroWriteAlarm,
|
||||
settlementRateAlarm,
|
||||
SETTLE_RATE_FLOOR,
|
||||
createFailureTracker,
|
||||
isBadSnapshotResult,
|
||||
zeroSettleAlarm,
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* SETTLEMENT SOURCE — date-targeted game resolution (Session 64, Order 1).
|
||||
*
|
||||
* THE BUG THIS REPLACES: settlement looked for a game inside the player's
|
||||
* ROLLING last-N window. Two mechanisms silently produced eternal pending:
|
||||
* 1. the player DNP'd, so no row for that date exists at all; and
|
||||
* 2. the window rolled past the game (verified live: Cooper Pratt's Jul 12
|
||||
* game fell out of a last10 that now starts Jul 6).
|
||||
* Both hit one line — `if (!gameRow) { pending += 1; continue; }` — with no
|
||||
* terminal state, so a row that can NEVER settle looked exactly like one
|
||||
* settling tomorrow.
|
||||
*
|
||||
* THE FIX: resolve the SPECIFIC DATE, and read GAME STATE — not merely whether
|
||||
* the player appears.
|
||||
*
|
||||
* Cost note: for MLB this is FREE. `mlbStatsAdapter.getPlayerGameLog` already
|
||||
* returns the player's FULL SEASON log and `getPlayerStats` was discarding it
|
||||
* with `.slice(-10)`. Settlement now reads the full log (same URL, same cache,
|
||||
* no extra request). Projections keep using last10 — unchanged.
|
||||
*
|
||||
* States returned:
|
||||
* settled — the game is final and the player has a line → score it
|
||||
* void — no bet existed: confirmed DNP, or postponed/cancelled
|
||||
* pending — not final yet (scheduled / in progress / suspended). Retry.
|
||||
* unknown — we could not determine state; caller applies the retry cap
|
||||
*
|
||||
* A partial game (injured/ejected but with a line) is a REAL result and settles
|
||||
* normally — it is never a void.
|
||||
*/
|
||||
|
||||
const FINAL_HINTS = ['final', 'completed', 'post', 'game over'];
|
||||
const VOID_HINTS = ['postponed', 'cancelled', 'canceled', 'forfeit'];
|
||||
const NOT_FINAL_HINTS = ['scheduled', 'pre', 'warmup', 'in progress', 'live', 'delayed', 'suspended', 'in'];
|
||||
|
||||
function textOf(...vals) {
|
||||
return vals.filter(Boolean).map((v) => String(v).toLowerCase()).join(' ');
|
||||
}
|
||||
|
||||
/** Classify one schedule entry's state. Order matters: an explicitly void
|
||||
* state must win over a "final"-looking wrapper. */
|
||||
function classifyGameState(game) {
|
||||
const t = textOf(
|
||||
game && game.status,
|
||||
game && game.detailedState,
|
||||
game && game.abstractGameState,
|
||||
game && game.state,
|
||||
game && game.statusText,
|
||||
);
|
||||
if (!t) return 'unknown';
|
||||
if (VOID_HINTS.some((h) => t.includes(h))) return 'void';
|
||||
// 'suspended' must not be read as final even though it can pair with other
|
||||
// words — a suspended game resumes and settles later.
|
||||
if (t.includes('suspended')) return 'not_final';
|
||||
if (FINAL_HINTS.some((h) => t.includes(h))) return 'final';
|
||||
if (NOT_FINAL_HINTS.some((h) => t.includes(h))) return 'not_final';
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
/**
|
||||
* Given the day's games for a sport, decide what the ABSENCE of a player row
|
||||
* means. Pure — the caller supplies the schedule.
|
||||
* - any game still unplayed/suspended → 'pending' (do NOT void; it may settle)
|
||||
* - every relevant game void → 'void'
|
||||
* - at least one final, none pending → 'void' (confirmed DNP)
|
||||
* - nothing known → 'unknown'
|
||||
*/
|
||||
function absenceMeaning(games) {
|
||||
const list = Array.isArray(games) ? games : [];
|
||||
if (list.length === 0) return 'unknown';
|
||||
const states = list.map(classifyGameState);
|
||||
if (states.some((s) => s === 'not_final')) return 'pending';
|
||||
if (states.every((s) => s === 'void')) return 'void';
|
||||
if (states.some((s) => s === 'final')) return 'void'; // played without them = DNP
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
/** Find the player's row for an exact date in a full game log. */
|
||||
function rowForDate(log, gameDate, matchesDate) {
|
||||
const rows = Array.isArray(log) ? log : [];
|
||||
return rows.find((r) => matchesDate(r, gameDate)) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve one ledger row's outcome source.
|
||||
*
|
||||
* @param {Object} args
|
||||
* - sport, playerName, gameDate, statType
|
||||
* - getFullLog(playerName, sport) → [{date, stat}] (full season, not a window)
|
||||
* - getSchedule(sport, gameDate) → [{status,…}]
|
||||
* - matchesDate(row, gameDate) → bool
|
||||
* - statValue(statObj, statType, sport) → number|null
|
||||
* @returns {Promise<{state:string, value:number|null, reason:string|null, source:string}>}
|
||||
*/
|
||||
async function resolveOutcome(args = {}) {
|
||||
const {
|
||||
sport, playerName, gameDate, statType,
|
||||
getFullLog, getSchedule, matchesDate, statValue,
|
||||
} = args;
|
||||
|
||||
let log = [];
|
||||
try {
|
||||
log = (await getFullLog(playerName, sport)) || [];
|
||||
} catch {
|
||||
log = [];
|
||||
}
|
||||
|
||||
const row = rowForDate(log, gameDate, matchesDate);
|
||||
if (row) {
|
||||
const value = statValue(row.stat, statType, sport);
|
||||
if (value == null) {
|
||||
// The player PLAYED but this stat isn't in the box row — a mapping gap,
|
||||
// not a void. Never void a real appearance.
|
||||
return { state: 'unknown', value: null, reason: 'stat_not_in_box_row', source: 'date_log' };
|
||||
}
|
||||
return { state: 'settled', value, reason: null, source: 'date_log' };
|
||||
}
|
||||
|
||||
// No row for that date — the absence only means something once we know
|
||||
// whether the games were actually played.
|
||||
let games = [];
|
||||
try {
|
||||
games = (await getSchedule(sport, gameDate)) || [];
|
||||
} catch {
|
||||
games = [];
|
||||
}
|
||||
const meaning = absenceMeaning(games);
|
||||
if (meaning === 'pending') {
|
||||
return { state: 'pending', value: null, reason: 'game_not_final', source: 'schedule' };
|
||||
}
|
||||
if (meaning === 'void') {
|
||||
const allVoid = games.length > 0 && games.map(classifyGameState).every((s) => s === 'void');
|
||||
return {
|
||||
state: 'void',
|
||||
value: null,
|
||||
reason: allVoid ? 'game_postponed_or_cancelled' : 'player_dnp',
|
||||
source: 'schedule',
|
||||
};
|
||||
}
|
||||
return { state: 'unknown', value: null, reason: 'state_undetermined', source: 'schedule' };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
resolveOutcome,
|
||||
absenceMeaning,
|
||||
classifyGameState,
|
||||
rowForDate,
|
||||
};
|
||||
@@ -175,7 +175,21 @@ function startSnapshotScheduler(opts = {}) {
|
||||
try {
|
||||
ledgerResults = await settleLedgers();
|
||||
const n = ledgerResults.reduce((t, r) => t + (r.settled || 0), 0);
|
||||
console.log(`[ledger] settle pass — ${n} entries settled (outcome + CLV)`);
|
||||
const nv = ledgerResults.reduce((t, r) => t + (r.voided || 0), 0);
|
||||
const nu = ledgerResults.reduce((t, r) => t + (r.unrecoverable || 0), 0);
|
||||
console.log(`[ledger] settle pass — ${n} settled, ${nv} voided, ${nu} unrecoverable (outcome + CLV)`);
|
||||
// Session 64 — SETTLEMENT-RATE ALARM. zeroSettleAlarm only catches a
|
||||
// TOTAL zero; the diagnostic found ~30% of a slate quietly failing while
|
||||
// the pass "succeeded". A persistently low resolution rate is a broken
|
||||
// pipe, not a quiet night.
|
||||
try {
|
||||
const sr = opsWatch.settlementRateAlarm(ledgerResults);
|
||||
if (sr.alarm) {
|
||||
await notify(`Settlement rate BELOW FLOOR at ${h}:00 UTC — ${sr.reason}. Rows that cannot resolve are pending, not scored.`, {
|
||||
title: 'VYNDR settlement', priority: 'high', tags: ['rotating_light'],
|
||||
});
|
||||
}
|
||||
} catch { /* alarm evaluation must never break the tick */ }
|
||||
} catch (e) {
|
||||
console.warn('[ledger] settle run failed:', e.message);
|
||||
await notify(`Ledger settlement THREW at ${h}:00 UTC — ${e.message}. The public record did not advance.`, {
|
||||
|
||||
@@ -177,17 +177,55 @@ describe('settleLedger — outcome + CLV vs the real result', () => {
|
||||
expect(byOutcome[1]).toMatchObject({ outcome: 'miss', actual_value: 1, clv: -1, clv_result: 'faded' });
|
||||
});
|
||||
|
||||
test('no game-log row for the date → stays pending (never guesses)', async () => {
|
||||
// Session 64 — BEHAVIOUR CHANGED ON PURPOSE. A missing game-log row used to
|
||||
// mean "pending forever". It now depends on what the day's games actually did.
|
||||
test('no row + game FINAL → VOID (confirmed DNP), no longer immortal', async () => {
|
||||
const sb = fakeSb();
|
||||
sb._state.selectResults = [
|
||||
[{ id: 'r1' }],
|
||||
[{ id: 'r1', player_name: 'Aaron Judge', stat: 'home_runs', line: 0.5, side: 'over', closing_line: null, game_date: '2026-07-09' }],
|
||||
];
|
||||
const getPlayerStats = async () => ({ found: true, last10: [{ date: '2026-07-08', stat: { homeRuns: 2 } }] });
|
||||
const res = await ledger.settleLedger('mlb', { sb, getPlayerStats, now: () => NOW, beforeDate: '2026-07-10' });
|
||||
const res = await ledger.settleLedger('mlb', {
|
||||
sb, getPlayerStats, now: () => NOW, beforeDate: '2026-07-10',
|
||||
getSchedule: async () => [{ status: 'Final' }],
|
||||
});
|
||||
expect(res.voided).toBe(1);
|
||||
expect(res.settled).toBe(0);
|
||||
expect(sb._calls.updates[0].values).toMatchObject({ outcome: 'void', settlement_source: 'player_dnp' });
|
||||
});
|
||||
|
||||
test('no row + game NOT FINAL → stays pending, never voided', async () => {
|
||||
const sb = fakeSb();
|
||||
sb._state.selectResults = [
|
||||
[{ id: 'r1' }],
|
||||
[{ id: 'r1', player_name: 'Aaron Judge', stat: 'home_runs', line: 0.5, side: 'over', closing_line: null, game_date: '2026-07-09' }],
|
||||
];
|
||||
const getPlayerStats = async () => ({ found: true, last10: [{ date: '2026-07-08', stat: { homeRuns: 2 } }] });
|
||||
const res = await ledger.settleLedger('mlb', {
|
||||
sb, getPlayerStats, now: () => NOW, beforeDate: '2026-07-10',
|
||||
getSchedule: async () => [{ status: 'Suspended' }],
|
||||
});
|
||||
expect(res.pending).toBe(1);
|
||||
expect(sb._calls.updates).toHaveLength(0);
|
||||
expect(res.voided).toBe(0);
|
||||
// only the attempt counter is touched — no outcome written
|
||||
expect(sb._calls.updates[0].values).toMatchObject({ settle_attempts: 1 });
|
||||
expect(sb._calls.updates[0].values.outcome).toBeUndefined();
|
||||
});
|
||||
|
||||
test('undetermined state becomes UNRECOVERABLE at the retry cap', async () => {
|
||||
const sb = fakeSb();
|
||||
sb._state.selectResults = [
|
||||
[{ id: 'r1' }],
|
||||
[{ id: 'r1', player_name: 'Aaron Judge', stat: 'home_runs', line: 0.5, side: 'over', closing_line: null, game_date: '2026-07-09', settle_attempts: 3 }],
|
||||
];
|
||||
const getPlayerStats = async () => ({ found: true, last10: [] });
|
||||
const res = await ledger.settleLedger('mlb', {
|
||||
sb, getPlayerStats, now: () => NOW, beforeDate: '2026-07-10',
|
||||
getSchedule: async () => [], // nothing knowable
|
||||
});
|
||||
expect(res.unrecoverable).toBe(1);
|
||||
expect(sb._calls.updates[0].values).toMatchObject({ outcome: 'unrecoverable' });
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -282,3 +282,30 @@ describe('retentionZeroWriteAlarm (Session 64)', () => {
|
||||
expect(r.alarm).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('settlementRateAlarm (Session 64)', () => {
|
||||
const opsWatch = require('../../src/services/opsWatch');
|
||||
|
||||
test('pages when a slate resolves below the floor', () => {
|
||||
const r = opsWatch.settlementRateAlarm([{ sport: 'mlb', settled: 57, voided: 0, unrecoverable: 0, pending: 29 }]);
|
||||
expect(r.alarm).toBe(true);
|
||||
expect(r.reason).toMatch(/MLB resolved 57\/86/);
|
||||
});
|
||||
|
||||
test('VOIDS count as resolved — a void is a legitimate terminal state', () => {
|
||||
const r = opsWatch.settlementRateAlarm([{ sport: 'mlb', settled: 57, voided: 29, unrecoverable: 0, pending: 0 }]);
|
||||
expect(r.alarm).toBe(false);
|
||||
});
|
||||
|
||||
test('quiet on a healthy pass', () => {
|
||||
expect(opsWatch.settlementRateAlarm([{ sport: 'wnba', settled: 70, voided: 0, unrecoverable: 0, pending: 0 }]).alarm).toBe(false);
|
||||
});
|
||||
|
||||
test('nothing due never pages', () => {
|
||||
expect(opsWatch.settlementRateAlarm([{ sport: 'nba', settled: 0, pending: 0 }]).alarm).toBe(false);
|
||||
});
|
||||
|
||||
test('skipped/errored sports are ignored, not counted as failure', () => {
|
||||
expect(opsWatch.settlementRateAlarm([{ sport: 'soccer', skipped: 'not configured' }]).alarm).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
/**
|
||||
* Session 64 Order 1 — date-targeted settlement + terminal states.
|
||||
*
|
||||
* The bug: a lookup miss did `pending += 1` with no terminal state, so a row
|
||||
* that could NEVER settle (DNP, or the rolling window passed the game) was
|
||||
* indistinguishable from one settling tomorrow. These lock the distinctions.
|
||||
*/
|
||||
const src = require('../../src/services/settleSource');
|
||||
|
||||
const matchesDate = (r, d) => r.date === d;
|
||||
const statValue = (s, st) => (s && s[st] != null ? Number(s[st]) : null);
|
||||
const base = {
|
||||
sport: 'mlb', playerName: 'X', gameDate: '2026-07-17', statType: 'doubles',
|
||||
matchesDate, statValue,
|
||||
};
|
||||
|
||||
describe('classifyGameState', () => {
|
||||
test('final states', () => {
|
||||
expect(src.classifyGameState({ status: 'Final' })).toBe('final');
|
||||
expect(src.classifyGameState({ state: 'post' })).toBe('final');
|
||||
});
|
||||
test('postponed/cancelled are void', () => {
|
||||
expect(src.classifyGameState({ detailedState: 'Postponed' })).toBe('void');
|
||||
expect(src.classifyGameState({ status: 'Cancelled' })).toBe('void');
|
||||
});
|
||||
test('SUSPENDED is NOT final — it resumes and must stay pending', () => {
|
||||
expect(src.classifyGameState({ detailedState: 'Suspended' })).toBe('not_final');
|
||||
});
|
||||
test('scheduled / in progress are not final', () => {
|
||||
expect(src.classifyGameState({ status: 'Scheduled' })).toBe('not_final');
|
||||
expect(src.classifyGameState({ status: 'In Progress' })).toBe('not_final');
|
||||
});
|
||||
test('unknown when there is nothing to read', () => {
|
||||
expect(src.classifyGameState({})).toBe('unknown');
|
||||
});
|
||||
});
|
||||
|
||||
describe('absenceMeaning — what a missing player row MEANS', () => {
|
||||
test('any unplayed game → pending (never void a bet that may still settle)', () => {
|
||||
expect(src.absenceMeaning([{ status: 'Final' }, { status: 'Suspended' }])).toBe('pending');
|
||||
});
|
||||
test('all games postponed → void', () => {
|
||||
expect(src.absenceMeaning([{ status: 'Postponed' }])).toBe('void');
|
||||
});
|
||||
test('game final but player absent → void (confirmed DNP)', () => {
|
||||
expect(src.absenceMeaning([{ status: 'Final' }])).toBe('void');
|
||||
});
|
||||
test('no schedule → unknown, not a guess', () => {
|
||||
expect(src.absenceMeaning([])).toBe('unknown');
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveOutcome', () => {
|
||||
test('player has a line on that date → SETTLED with the real value', async () => {
|
||||
const r = await src.resolveOutcome({
|
||||
...base,
|
||||
getFullLog: async () => [{ date: '2026-07-17', stat: { doubles: 1 } }],
|
||||
getSchedule: async () => [{ status: 'Final' }],
|
||||
});
|
||||
expect(r.state).toBe('settled');
|
||||
expect(r.value).toBe(1);
|
||||
});
|
||||
|
||||
test('a ZERO value still settles (0 is a real result, not absence)', async () => {
|
||||
const r = await src.resolveOutcome({
|
||||
...base,
|
||||
getFullLog: async () => [{ date: '2026-07-17', stat: { doubles: 0 } }],
|
||||
getSchedule: async () => [{ status: 'Final' }],
|
||||
});
|
||||
expect(r.state).toBe('settled');
|
||||
expect(r.value).toBe(0);
|
||||
});
|
||||
|
||||
test('DNP on a final game → VOID', async () => {
|
||||
const r = await src.resolveOutcome({
|
||||
...base,
|
||||
getFullLog: async () => [{ date: '2026-07-16', stat: { doubles: 0 } }],
|
||||
getSchedule: async () => [{ status: 'Final' }],
|
||||
});
|
||||
expect(r.state).toBe('void');
|
||||
expect(r.reason).toBe('player_dnp');
|
||||
});
|
||||
|
||||
test('postponed game → VOID with the postponed reason', async () => {
|
||||
const r = await src.resolveOutcome({
|
||||
...base,
|
||||
getFullLog: async () => [],
|
||||
getSchedule: async () => [{ status: 'Postponed' }],
|
||||
});
|
||||
expect(r.state).toBe('void');
|
||||
expect(r.reason).toBe('game_postponed_or_cancelled');
|
||||
});
|
||||
|
||||
test('SUSPENDED game stays PENDING — never voided', async () => {
|
||||
const r = await src.resolveOutcome({
|
||||
...base,
|
||||
getFullLog: async () => [],
|
||||
getSchedule: async () => [{ status: 'Suspended' }],
|
||||
});
|
||||
expect(r.state).toBe('pending');
|
||||
});
|
||||
|
||||
test('a game OUTSIDE a rolling window still settles from the full log', async () => {
|
||||
// The exact live failure: Jul 12 game, window now starts Jul 6.
|
||||
const full = Array.from({ length: 40 }, (_, i) => ({
|
||||
date: `2026-07-${String(i + 1).padStart(2, '0')}`, stat: { doubles: i % 3 },
|
||||
}));
|
||||
const r = await src.resolveOutcome({
|
||||
...base, gameDate: '2026-07-12',
|
||||
getFullLog: async () => full,
|
||||
getSchedule: async () => [{ status: 'Final' }],
|
||||
});
|
||||
expect(r.state).toBe('settled');
|
||||
});
|
||||
|
||||
test('player PLAYED but the stat is missing from the box row → unknown, NEVER void', async () => {
|
||||
const r = await src.resolveOutcome({
|
||||
...base,
|
||||
getFullLog: async () => [{ date: '2026-07-17', stat: { hits: 2 } }],
|
||||
getSchedule: async () => [{ status: 'Final' }],
|
||||
});
|
||||
expect(r.state).toBe('unknown');
|
||||
expect(r.reason).toBe('stat_not_in_box_row');
|
||||
});
|
||||
|
||||
test('a throwing source degrades to unknown, never throws', async () => {
|
||||
const r = await src.resolveOutcome({
|
||||
...base,
|
||||
getFullLog: async () => { throw new Error('net'); },
|
||||
getSchedule: async () => { throw new Error('net'); },
|
||||
});
|
||||
expect(r.state).toBe('unknown');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user