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' });
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user