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
+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');
});
});