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
+35
View File
@@ -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 };
+6
View File
@@ -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
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
+44
View File
@@ -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,
+149
View File
@@ -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,
};
+15 -1
View File
@@ -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.`, {
+41 -3
View File
@@ -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' });
});
});
+27
View File
@@ -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);
});
});
+134
View File
@@ -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');
});
});