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:
Kev
2026-07-20 22:58:30 -04:00
parent 063e9fb3f7
commit c5580f333e
4 changed files with 251 additions and 1 deletions
+85
View File
@@ -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,