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:
@@ -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