Layer 3 Step 1: wire the measurement instrument
Step 0 found we have been flying without one. p_win lives only in model_snapshots, which has 1,000 rows and ZERO settled outcomes; the closing line lives only in closing_captures, which carries no link to a result; and ledger_entries, the row that actually settles, carries no probability at all. So "is the projection calibrated" and "does it beat the market" have never been answerable — the entire measurable universe was 35 rows recovered by a lossy in-memory join. PHASE 0 — closing coverage verified BEFORE reuse, because an instrument built on a partial close measures a biased subset. closing_captures holds 70,254 rows of which 13,364 are usable, and the 56,890 refusals are candidates we never graded plus one-sided prices — not refusals of our props. Coverage on graded props since capture started is 83/83, 100%. Safe to reuse, with the honest caveat that capture only began 2026-07-20. THE FOUR-TUPLE NOW LANDS ON ONE ROW. ledger_entries gains p_win, fair_prob_lock, archetype_vector and projection_locked_at at LOCK time, and closing_prob plus closing_captured_at from the append-only capture store. The join is the whole point: calibration is p_win against outcome, market-comparison is p_win against the close, and both become plain SQL on one record instead of a join that silently drops 90% of the rows. p_win and the archetype vector are IMMUTABLE — written once at lock via the existing ignoreDuplicates upsert, never re-derived at settle. A re-derivation would measure a projection we never made. The archetype is stored as the VECTOR, not the label. "Did archetype-awareness help?" can only be answered against the axes that were live at grade time, and a single text column cannot express a blend. A grade with no archetype stores null rather than an empty object. HONEST-ABSENT BOTH WAYS. A past game with no usable capture is marked market_unavailable_reason and never given an imputed line; calibration still scores on those rows, only market-comparison is absent. And a game that has not started yet is NOT declared closeless — a close can still arrive, and premature absence is as dishonest as imputation in the other direction. One bug caught before it shipped: the scheduler hook iterated a SPORTS identifier that does not exist in that scope. Inside its try/catch it would have thrown ReferenceError every tick and silently never run — the instrument would have looked wired and captured nothing. Now iterates cadence.ALL_SPORTS. The baseline accrues FORWARD. Historical p_win and closes are gone, discarded before this existed. Calibration and market-comparison stay honest-absent until volume accrues. Tests 3614 passed / 294 suites, web build exit 0. Migration 033 applied. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VCNgGSt5qvcLxaeQqa7Zpj
This commit is contained in:
@@ -183,6 +183,22 @@ function oddsForSide(prop, side) {
|
||||
* Skips anything without a real grade or without a captured line — the
|
||||
* ledger never holds a fabricated market value or a refused read.
|
||||
*/
|
||||
/**
|
||||
* The Layer-2 blend as it stood at grade time. Stored as the VECTOR, not a
|
||||
* label: "did archetype-awareness help?" can only be answered against the axes
|
||||
* that were live, and a single text column cannot express a blend. Null (not
|
||||
* an empty object) when the grade carried no archetype — honest absence.
|
||||
*/
|
||||
function archetypeVectorOf(g) {
|
||||
if (!g || typeof g !== 'object') return null;
|
||||
if (g.archetype_axes && typeof g.archetype_axes === 'object') return g.archetype_axes;
|
||||
if (Array.isArray(g.archetype_blend) && g.archetype_blend.length) {
|
||||
return { blend: g.archetype_blend, primary: g.archetype || null };
|
||||
}
|
||||
if (g.archetype) return { blend: [], primary: g.archetype };
|
||||
return null;
|
||||
}
|
||||
|
||||
function rowsFromSnapshot(sport, grades, oddsProps, nowIso) {
|
||||
const sp = String(sport || '').toLowerCase();
|
||||
let skippedUnbound = 0;
|
||||
@@ -224,6 +240,17 @@ function rowsFromSnapshot(sport, grades, oddsProps, nowIso) {
|
||||
grade: g.grade,
|
||||
edge: numOrNull(g.edge_pct),
|
||||
confidence: numOrNull(g.confidence),
|
||||
// Session 70 — THE INSTRUMENT. p_win is the projection we ACTUALLY made,
|
||||
// captured at lock and never re-derived at settle: a re-derivation would
|
||||
// measure a projection that never happened. fair_prob_lock is the market
|
||||
// at the same instant, so lock-vs-close movement is attributable.
|
||||
// archetype_vector is the Layer-2 blend as it stood at grade time, so
|
||||
// calibration can be sliced BY archetype later — a text label could not
|
||||
// attribute anything.
|
||||
p_win: numOrNull(g.p_win),
|
||||
fair_prob_lock: numOrNull(g.fair_prob),
|
||||
archetype_vector: archetypeVectorOf(g),
|
||||
projection_locked_at: gradedTs,
|
||||
model_value: numOrNull(g.projection),
|
||||
graded_at: gradedTs,
|
||||
// Session 64 — stamp the model era on every NEW row. Pre-cutoff rows are
|
||||
@@ -262,6 +289,63 @@ async function recordPipelineGrades(sport, grades, oddsProps, opts = {}) {
|
||||
return { written };
|
||||
}
|
||||
|
||||
/**
|
||||
* attachClosingProb(sport, opts) — the MARKET half of the instrument.
|
||||
*
|
||||
* Reads the append-only `closing_captures` (100% coverage on graded props, and
|
||||
* the only store with real provenance) and writes the de-vigged closing
|
||||
* probability onto the matching ledger row. Write-once: a row that already has
|
||||
* a `closing_prob` is never rewritten, so the FIRST true close is the one that
|
||||
* survives — the same lock-wall discipline the grade itself follows.
|
||||
*
|
||||
* A row with no usable capture gets `market_unavailable_reason`, NOT a guessed
|
||||
* price. Calibration (p_win vs outcome) still works on those rows; only
|
||||
* market-comparison is absent, and it says so.
|
||||
*/
|
||||
async function attachClosingProb(sport, opts = {}) {
|
||||
if (!opts.sb && !isConfigured()) return { skipped: 'supabase not configured', updated: 0 };
|
||||
const sb = opts.sb || defaultClient();
|
||||
const sp = String(sport || '').toLowerCase();
|
||||
|
||||
const { data: rows, error } = await sb.from('ledger_entries')
|
||||
.select('id, player_key, stat, side, game_date')
|
||||
.is('user_id', null).eq('sport', sp)
|
||||
.is('closing_prob', null).is('market_unavailable_reason', null)
|
||||
.limit(opts.limit || 2000);
|
||||
if (error) return { updated: 0, error: error.message };
|
||||
if (!rows || !rows.length) return { updated: 0, absent: 0 };
|
||||
|
||||
const { data: caps } = await sb.from('closing_captures')
|
||||
.select('player_key, stat, side, game_date, fair_prob, captured_at, missed_reason')
|
||||
.eq('sport', sp).limit(50000);
|
||||
|
||||
// Latest usable capture per identity = the TRUE close.
|
||||
const best = new Map();
|
||||
for (const c of caps || []) {
|
||||
if (c.missed_reason || c.fair_prob == null) continue;
|
||||
const k = `${c.player_key}|${c.stat}|${c.side}|${c.game_date}`;
|
||||
const prev = best.get(k);
|
||||
if (!prev || String(c.captured_at) > String(prev.captured_at)) best.set(k, c);
|
||||
}
|
||||
|
||||
let updated = 0; let absent = 0;
|
||||
// A close can still ARRIVE for a game that has not started. Marking today's
|
||||
// rows market-unavailable would be premature absence — as dishonest in the
|
||||
// other direction as imputing one. Only a past game can be declared closeless.
|
||||
const cutoff = opts.beforeDate || todayET();
|
||||
for (const r of rows) {
|
||||
const hit = best.get(`${r.player_key}|${r.stat}|${r.side}|${r.game_date}`);
|
||||
if (!hit && String(r.game_date) >= String(cutoff)) continue; // still capturable
|
||||
const patch = hit
|
||||
? { closing_prob: hit.fair_prob, closing_captured_at: hit.captured_at }
|
||||
: { market_unavailable_reason: 'no_usable_close' };
|
||||
const { error: e } = await sb.from('ledger_entries').update(patch).eq('id', r.id);
|
||||
if (e) continue;
|
||||
if (hit) updated += 1; else absent += 1;
|
||||
}
|
||||
return { updated, absent, candidates: rows.length };
|
||||
}
|
||||
|
||||
/**
|
||||
* Overwrite closing_line/closing_odds on today's UNSETTLED rows from the
|
||||
* current (real) odds feed. Runs on every snapshot; the last capture before
|
||||
@@ -801,6 +885,7 @@ function accuracyBucketsFromAgg(agg) {
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
attachClosingProb,
|
||||
recordPipelineGrades,
|
||||
captureClosing,
|
||||
settleLedger,
|
||||
|
||||
@@ -286,6 +286,21 @@ function startSnapshotScheduler(opts = {}) {
|
||||
}
|
||||
} catch (e) { console.warn('[harness] nightly run failed:', e.message); }
|
||||
|
||||
// Session 70 — THE MEASUREMENT INSTRUMENT. Attach the de-vigged CLOSING
|
||||
// probability to locked rows from the append-only closing_captures, so
|
||||
// p_win, the close, the archetype vector and the outcome all land on ONE
|
||||
// joinable record. Runs beside the settle pass; write-once, and a past game
|
||||
// with no usable capture is marked market-unavailable rather than imputed.
|
||||
try {
|
||||
const led = opts.ledgerService || require('./services/ledgerService');
|
||||
for (const sp of cadence.ALL_SPORTS) {
|
||||
const r = await led.attachClosingProb(sp, {});
|
||||
if (r && (r.updated || r.absent)) {
|
||||
console.log(`[instrument] ${sp} — closes attached ${r.updated}, market-unavailable ${r.absent}`);
|
||||
}
|
||||
}
|
||||
} catch (e) { console.warn('[instrument] closing attach failed:', e.message); }
|
||||
|
||||
// Session 68 — LAYER 1 MECHANISM DATA. Nightly full re-pull of the
|
||||
// ~1,350-row Statcast aggregate set at STATCAST_HOUR_UTC. Backfill and
|
||||
// refresh are the SAME call, upserted on the natural key, so the job is
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
/* ============================================================
|
||||
Session 70 — THE MEASUREMENT INSTRUMENT.
|
||||
|
||||
Calibration needs p_win beside the outcome. Market-comparison needs p_win
|
||||
beside the close. Both need them ON THE SAME ROW. Before this, p_win lived
|
||||
only in a table that never settles and the close lived only in a table with
|
||||
no probability — so neither question was answerable.
|
||||
============================================================ */
|
||||
|
||||
const ledger = require('../../src/services/ledgerService');
|
||||
|
||||
const GRADE = {
|
||||
player: 'Josh Bell', stat_type: 'hits', direction: 'over', grade: 'B',
|
||||
gradedAt: { line: 0.5, odds: -210, timestamp: '2026-07-21T03:00:00.000Z' },
|
||||
p_win: 0.757, fair_prob: 0.633, edge_pct: 12, confidence: 63,
|
||||
archetype_axes: { blend: [{ label: 'BOMBER', axis: 'POWER', tier: 'elite' }] },
|
||||
};
|
||||
const PROP = { player: 'Josh Bell', stat_type: 'hits', game_time: '2026-07-21T23:05:00Z', book: 'betmgm', over_odds: -210, under_odds: 170 };
|
||||
|
||||
/** rowsFromSnapshot is internal; exercise it through the public writer. */
|
||||
async function rowFor(grade, prop = PROP) {
|
||||
let captured = null;
|
||||
const sb = { from: () => ({ upsert: async (rows) => { captured = rows; return { error: null }; } }) };
|
||||
await ledger.recordPipelineGrades('mlb', [grade], [prop], { sb, now: () => '2026-07-21T03:00:00.000Z' });
|
||||
return captured && captured[0];
|
||||
}
|
||||
|
||||
describe('lock-time capture — the projection we ACTUALLY made', () => {
|
||||
it('writes p_win, the market at lock, and the archetype VECTOR onto the row', async () => {
|
||||
const r = await rowFor(GRADE);
|
||||
expect(r.p_win).toBe(0.757);
|
||||
expect(r.fair_prob_lock).toBe(0.633);
|
||||
expect(r.archetype_vector).toEqual(GRADE.archetype_axes);
|
||||
expect(r.projection_locked_at).toBe('2026-07-21T03:00:00.000Z');
|
||||
});
|
||||
|
||||
it('lands them on the SAME record as the outcome — the join is the point', async () => {
|
||||
const r = await rowFor(GRADE);
|
||||
// One row carries identity + p_win + the settle target. Calibration and
|
||||
// market-comparison are then plain SQL, not a lossy in-memory join.
|
||||
for (const f of ['player_key', 'stat', 'line', 'side', 'game_date', 'p_win']) {
|
||||
expect(r[f]).toBeDefined();
|
||||
}
|
||||
});
|
||||
|
||||
it('stores the VECTOR, not a label — a label cannot attribute anything', async () => {
|
||||
const withBlend = await rowFor({ ...GRADE, archetype_axes: undefined, archetype_blend: [{ archetype: 'BOMBER', weight: 1 }], archetype: 'BOMBER' });
|
||||
expect(withBlend.archetype_vector.blend).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('a grade with no archetype stores NULL, not an empty object', async () => {
|
||||
const r = await rowFor({ ...GRADE, archetype_axes: undefined, archetype_blend: undefined, archetype: undefined });
|
||||
expect(r.archetype_vector).toBeNull();
|
||||
});
|
||||
|
||||
it('a grade with no p_win stores NULL, never 0', async () => {
|
||||
const r = await rowFor({ ...GRADE, p_win: undefined, fair_prob: undefined });
|
||||
expect(r.p_win).toBeNull();
|
||||
expect(r.fair_prob_lock).toBeNull();
|
||||
});
|
||||
|
||||
it('is IMMUTABLE — the writer never overwrites an existing lock', async () => {
|
||||
// recordPipelineGrades upserts with ignoreDuplicates, so a re-run cannot
|
||||
// rewrite p_win. Re-deriving at settle would measure a projection we never
|
||||
// made.
|
||||
let opts = null;
|
||||
const sb = { from: () => ({ upsert: async (_r, o) => { opts = o; return { error: null }; } }) };
|
||||
await ledger.recordPipelineGrades('mlb', [GRADE], [PROP], { sb });
|
||||
expect(opts.ignoreDuplicates).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('attachClosingProb — the market half', () => {
|
||||
const makeSb = ({ rows, caps }) => {
|
||||
const updates = [];
|
||||
return {
|
||||
updates,
|
||||
from: (t) => ({
|
||||
select: () => ({
|
||||
is: function () { return this; }, eq: function () { return this; },
|
||||
limit: async () => ({ data: t === 'ledger_entries' ? rows : caps, error: null }),
|
||||
}),
|
||||
update: (patch) => ({ eq: async (_c, id) => { updates.push({ id, patch }); return { error: null }; } }),
|
||||
}),
|
||||
};
|
||||
};
|
||||
const ROW = { id: 'r1', player_key: 'josh bell', stat: 'hits', side: 'over', game_date: '2026-07-20' };
|
||||
|
||||
it('writes the LATEST usable capture as the true close', async () => {
|
||||
const sb = makeSb({
|
||||
rows: [ROW],
|
||||
caps: [
|
||||
{ player_key: 'josh bell', stat: 'hits', side: 'over', game_date: '2026-07-20', fair_prob: 0.60, captured_at: '2026-07-20T20:00:00Z' },
|
||||
{ player_key: 'josh bell', stat: 'hits', side: 'over', game_date: '2026-07-20', fair_prob: 0.64, captured_at: '2026-07-20T22:50:00Z' },
|
||||
],
|
||||
});
|
||||
const out = await ledger.attachClosingProb('mlb', { sb, beforeDate: '2026-07-21' });
|
||||
expect(out.updated).toBe(1);
|
||||
expect(sb.updates[0].patch.closing_prob).toBe(0.64); // the later one
|
||||
});
|
||||
|
||||
it('IGNORES refused captures — a missed_reason is not a close', async () => {
|
||||
const sb = makeSb({
|
||||
rows: [ROW],
|
||||
caps: [{ player_key: 'josh bell', stat: 'hits', side: 'over', game_date: '2026-07-20', fair_prob: 0.7, captured_at: 'x', missed_reason: 'one_sided_price' }],
|
||||
});
|
||||
const out = await ledger.attachClosingProb('mlb', { sb, beforeDate: '2026-07-21' });
|
||||
expect(out.updated).toBe(0);
|
||||
expect(sb.updates[0].patch.market_unavailable_reason).toBe('no_usable_close');
|
||||
});
|
||||
|
||||
it('NEVER imputes a closing line — absent is marked, not filled', async () => {
|
||||
const sb = makeSb({ rows: [ROW], caps: [] });
|
||||
await ledger.attachClosingProb('mlb', { sb, beforeDate: '2026-07-21' });
|
||||
expect(sb.updates[0].patch.closing_prob).toBeUndefined();
|
||||
expect(sb.updates[0].patch.market_unavailable_reason).toBe('no_usable_close');
|
||||
});
|
||||
|
||||
it('does NOT declare a future game closeless — a close can still arrive', async () => {
|
||||
// Premature absence is as dishonest as imputation, in the other direction.
|
||||
const sb = makeSb({ rows: [{ ...ROW, game_date: '2026-07-25' }], caps: [] });
|
||||
const out = await ledger.attachClosingProb('mlb', { sb, beforeDate: '2026-07-21' });
|
||||
expect(sb.updates).toHaveLength(0);
|
||||
expect(out.updated).toBe(0);
|
||||
});
|
||||
|
||||
it('only considers rows that have no close yet (write-once)', () => {
|
||||
const src = require('fs').readFileSync(require.resolve('../../src/services/ledgerService'), 'utf8');
|
||||
const fn = src.slice(src.indexOf('async function attachClosingProb'));
|
||||
expect(fn).toMatch(/\.is\('closing_prob', null\)/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('what stays measurable when the market is absent', () => {
|
||||
it('calibration needs only p_win + outcome; market-comparison needs the close', () => {
|
||||
// Encoded as a contract check: the three fields are independent columns, so
|
||||
// a row missing the close still scores for calibration.
|
||||
const src = require('fs').readFileSync(require.resolve('../../src/services/ledgerService'), 'utf8');
|
||||
expect(src).toMatch(/market_unavailable_reason/);
|
||||
expect(src).toMatch(/p_win: numOrNull\(g\.p_win\)/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('scheduler wiring', () => {
|
||||
const src = require('fs').readFileSync(require.resolve('../../src/snapshotScheduler'), 'utf8');
|
||||
it('runs the close attach on every configured sport', () => {
|
||||
expect(src).toContain('attachClosingProb');
|
||||
expect(src).toMatch(/for \(const sp of cadence\.ALL_SPORTS\)/);
|
||||
});
|
||||
});
|
||||
+1
-1
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user