d4a6170ffa
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
135 lines
5.0 KiB
JavaScript
135 lines
5.0 KiB
JavaScript
/**
|
|
* 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');
|
|
});
|
|
});
|