Files
vyndr/src/services/gradeSlateService.js
T
builtbykev 43f65d30cb Wire the three proven hits factors pre-grade: transmission proven, gain
inconclusive

THE BUG THIS NEARLY SHIPPED AS A FINDING. The first audit reported 0
factors fired on all 1,140 rows. Not a result -- my paging helper ordered
by `id`, and batter_spray, team_defense, platoon_splits and
statcast_aggregates have composite primary keys with NO id column. The
query errored, the loop broke on error, and four fully-populated tables
read as empty. hitsFactorContext.js -- the PRODUCTION loader -- had the
identical defect, so live wiring would have loaded nothing and served
unadjusted while logging success. Third occurrence of this class in one
session. Both loaders now order by a real column and THROW rather than
degrade. The Phase 2 gate is what caught it: no resolution number was
quoted until transmission was proved.

PHASE 1 — pipeline is now base -> FACTORS -> CALIBRATE -> GRADE. Context
built in snapshotService BEFORE gradeAndCacheSlate (was line 640+, grade
at 454), threaded per prop, applied to p_over before p_win is set with
p_win_prefactor and a full trace retained. Hits only. Coverage 859/1140
rows (75%): 474 with all three factors, 256 two, 129 one, 281 none.

PHASE 2 — TRANSMISSION PROVEN, 12/12 sign-correct, 4/4 per factor, each
applied IN ISOLATION. My first table compared each factor's expected sign
against the COMPOSITE change and showed 3 false failures -- with three
factors firing the net can oppose any single member; that was a flaw in
the test, not the wiring. Two under-side rows confirm the flip is handled:
a factor raising p(over) correctly lowers p_win. Switch hitters (Bailey,
Bell, Rocchio) took no spray adjustment while their other factors fired
normally -- the refusal is selective, not a blanket skip.

PHASE 3/4 — both maps refit on the factor-adjusted forecast; the
shadow-duel baseline is VOID and restarts, since it accumulated against a
different forecast. Point-in-time, 765 held-out rows:

  reliability 0.00795 -> 0.00828
  RESOLUTION  0.00229 -> 0.00345   (variance explained 0.93% -> 1.39%)
  Brier       0.25398 -> 0.25305   delta -0.00093  CI [-0.00225,+0.00002]

Resolution rose 51% relative. The CI TOUCHES ZERO on 4 eval dates, so the
composition does NOT earn a proven keep -- three isolated passes did not
grant a composed pass. INCONCLUSIVE, reported as such. The gain is far
below the sum of the isolated effects, which is expected: all three run
through the same pitcher-batter confrontation and share signal.

PHASE 5 — 1.39% of variance is still far below what band separation
needs. The pivot was correct and incomplete: the plumbing defect was real
and is fixed, three proven factors reach the served number for the first
time, and transmission alone did not buy grade separation. Next arc is
factor STRENGTH and BREADTH, not more plumbing.

PHASE 6 — rbi anomaly logged, not chased: 14.51% variance explained vs
hits 1.03%, on the stat we do not serve corrected and which has no proven
factors. Either the biggest lever on the board or a mirage; it deserves
its own order.

The byte-identical invariant INVERTED for hits by design. All 13 frozen
non-hits modules verified unchanged, probabilityEstimator included -- the
factors ride outside it. No new Bonferroni slot; the composed OOS claim is
reported with its CI and not claimed as a pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W1sivYNqY2TS5ftykmHBU9
2026-08-07 02:53:48 -04:00

231 lines
11 KiB
JavaScript

'use strict';
/**
* Grade-slate writer (Session 32).
*
* Closes the content pipeline. The grading engine (engine1 via
* analyzeViaEngine1) grades props on demand, but nothing ever persisted a
* sport's graded slate. `contentTemplateService.collectSlateData` reads a
* `grades:{sport}` cache "when present" — without a writer it was always
* empty, so slate/POTD content degraded to lines/schedule and never reached
* `dataLevel: 'full'`.
*
* This service grades a sport's freshly-fetched props and writes the
* `grades:{sport}` cache in the exact shape contentTemplateService expects.
* It is wired fire-and-forget into `oddsService.recordDownstream` so it runs
* on a cache MISS (≈hourly) WITHOUT holding the odds HTTP response — content
* endpoints read the grades cache independently and asynchronously.
*
* The legacy grade shape (player_name?/player, stat_type, line, direction,
* grade, confidence, edge_pct, reasoning.summary) is already what
* `contentTemplateService.normalizeGrade` reads — so no field remapping is
* needed at the write boundary.
*/
// Cost bounds: the odds slate carries one row per player+stat+line+book.
// We dedupe to unique player+stat+line and cap how many we grade, because
// each grade fans out to feature computation. Grading runs at most once per
// cache-miss per sport, but we still bound the herd.
const { isModelBook } = require('../config/bookRoles');
// RAISED 25 -> 500 on 2026-08-01, on measured cost, not taste.
//
// The 25 dated from Session 32 and was a herd guard written before anyone had
// measured what a grade costs. Measured on a real prod slate (n=80):
// mean 721ms/prop, median 666ms, p90 1024ms
// -> ~72s for 500 props at concurrency 5.
// Both callers tolerate that: the snapshot cron runs 5x/day, and
// oddsService.recordDownstream is fire-and-forget and never holds an HTTP
// response.
//
// What it was costing: the live MLB slate carries 585 unique gradeable props.
// The cap graded 25 of them and silently discarded 560 — 95.7% of the product.
//
// RAISED 500 -> 1500 on 2026-08-03, and this one is about SAMPLE, not display.
//
// Measured on the live board (internal/diagnose-refusals, n=300 sample):
// unique gradeable props 1,244 · graded after suppression/refusal ~70%
// So a 500 cap grades ~334 and discards ~744 — and because dedupeProps takes
// FIRST-ROW-WINS IN FEED ORDER, what survives is decided by feed position, not
// by value. Pitcher props are ~2.6% of the feed, so the cap was handing us SIX
// strikeout props a slate against 32 available. At six a slate, the gate's
// n>=500 is three months away for every pitcher stat, and the entire
// prove-it programme is blocked on an arbitrary truncation.
//
// Cost, measured not guessed: 721ms/prop at concurrency 5 -> ~179s for the full
// 1,244. Both callers tolerate it (the snapshot cron runs 5x/day;
// recordDownstream is fire-and-forget and never holds an HTTP response), and
// statsapi is free and unlimited. Concurrency stays at 5 — one variable at a time.
//
// Env-tunable so the ceiling can move without a deploy: GRADE_SLATE_LIMIT.
const DEFAULT_LIMIT = Number(process.env.GRADE_SLATE_LIMIT) > 0
? Number(process.env.GRADE_SLATE_LIMIT)
: 1500;
// Unchanged at 5 deliberately: raising the cap already multiplies total load by
// 20x, and concurrency is the knob that decides how hard we hit statsapi at
// once. One variable at a time.
const DEFAULT_CONCURRENCY = 5;
const DEFAULT_TTL = 7200; // 2 hours — matches the spec's grades-cache TTL.
// Collapse the multi-book prop rows to one entry per gradeable prop.
//
// ORDER ZERO GATE (2026-08-01). `normalizeProps` now emits every DISPLAY book so
// the surfaces can shop lines, which means DFS pick'em and exchange rows arrive
// here for the first time. The MODEL must not eat them: it has never been
// measured against those books, and a fixed-payout DFS number is not a market
// price at all. So we re-filter to MODEL_BOOKS BEFORE the first-row-wins pick
// and before the limit — which makes the graded set byte-identical to what it
// was before the widening. This gate lifts only when the MLB calibration is
// re-run on the consensus ruler and v2 is promoted.
function dedupeProps(props, limit) {
const seen = new Set();
const out = [];
for (const p of props || []) {
if (!p || !p.player || !p.stat_type || p.line == null) continue;
if (!isModelBook(p.book)) continue;
const key = `${p.player}::${p.stat_type}::${p.line}`;
if (seen.has(key)) continue;
seen.add(key);
out.push(p);
if (out.length >= limit) break;
}
return out;
}
// engine1 is direction-aware, so a prop grades differently over vs under.
// Grade both sides and keep the higher-confidence verdict — that's the
// side the engine actually favors.
async function gradeBestSide(grade, prop, sport, opts = {}) {
// PIPELINE ORDER: the factor context must reach the engine BEFORE it grades,
// because factors adjust the forecast the grade is read from. It was
// previously computed downstream of the grade it should inform.
const factorContext = typeof opts.factorContext === 'function'
? opts.factorContext(prop, sport) : null;
const base = {
factor_context: factorContext,
player: prop.player,
stat_type: prop.stat_type,
line: prop.line,
sport,
book: prop.book,
// Session 62 (A1-S1) — real book odds ride into the engine so the
// quarter-Kelly sizing can compute from actual prices (or not at all).
over_odds: prop.over_odds ?? null,
under_odds: prop.under_odds ?? null,
// Session 64 (Order 1.6) — carry the BOUND game through to grading so
// opponent/home-away features resolve against the RIGHT game. Without this
// the grader fell back to ESPN's dateless "today", which at the late slots
// is the previous day's card — binding yesterday's opponent into
// opp_rank_stat and the home/away factor.
game_date: prop.game_date ?? null,
game_time: prop.game_time ?? null,
home_team: prop.home_team ?? null,
away_team: prop.away_team ?? null,
};
const sides = await Promise.all([
Promise.resolve()
.then(() => grade({ ...base, direction: 'over' }))
.catch(() => null),
Promise.resolve()
.then(() => grade({ ...base, direction: 'under' }))
.catch(() => null),
]);
// Session 64 — RETENTION HOOK. Fires with BOTH sides, graded AND refused,
// before any filtering. Refusals never reach the slate or the ledger, so this
// is the only point where "the gate refused this prop" is observable — and a
// gate that refuses winners is invisible without it. Best-effort: a retention
// collector must never affect grading.
if (typeof opts.onGraded === 'function') {
try { opts.onGraded(base, sides); } catch { /* never breaks the slate */ }
}
// Session 58 (work-order 1.5) — a refused read (insufficient_data /
// no grade) never enters the graded slate: no hollow rows in the grades
// cache, the snapshot, or the ledger.
const cands = sides.filter((s) => s && s.grade && !s.insufficient_data);
if (cands.length === 0) return null;
const winner = cands.reduce((a, b) => ((Number(b.confidence) || 0) > (Number(a.confidence) || 0) ? b : a));
// Strip the internal retention fields so they never reach a cache or payload.
delete winner._features;
delete winner._grade_11;
// CARRY THE GAME (2026-08-01). The legacy grade shape drops home/away, so by
// the time the challenger runs, nothing on the grade says WHICH GAME it is —
// measured: `team` was null on 416/416 stored grades, so the park/weather
// resolver could never find a venue and the environment axis fired on ZERO
// rows while all 14 weather forecasts sat resolved and unused.
//
// The park depends on the GAME, not on the player's roster team, so binding
// the game directly is both the fix and the more correct join: it does not
// depend on a stats-resolve that can legitimately fail.
if (winner.home_team == null && prop.home_team != null) winner.home_team = prop.home_team;
if (winner.away_team == null && prop.away_team != null) winner.away_team = prop.away_team;
return winner;
}
// Run an async mapper over items with a bounded concurrency.
async function mapLimit(items, concurrency, fn) {
const results = new Array(items.length);
let cursor = 0;
async function worker() {
while (cursor < items.length) {
const i = cursor++;
results[i] = await fn(items[i], i);
}
}
const pool = Array.from({ length: Math.min(concurrency, items.length) }, () => worker());
await Promise.all(pool);
return results;
}
/**
* Grade a sport's props and write the `grades:{sport}` cache.
*
* @param {string} sport
* @param {Array} props normalized odds props (oddsNormalizer shape)
* @param {Object} [opts]
* @param {Function} [opts.grade] grader (default analyzeViaEngine1)
* @param {Function} [opts.cacheSet] cache writer (default redis.cacheSet)
* @param {string} [opts.source] provider tag ('propline' | 'odds-api')
* @param {number} [opts.limit] max unique props graded
* @param {number} [opts.ttl] cache TTL seconds
* @param {Function} [opts.now] timestamp source (testable)
* @returns {Promise<{written:boolean,count:number,error?:string}>}
*/
async function gradeAndCacheSlate(sport, props, opts = {}) {
const grade = opts.grade || require('./intelligence/analyzeViaEngine1').analyzeViaEngine1;
const cacheSet = opts.cacheSet || require('../utils/redis').cacheSet;
const source = opts.source || 'odds-api';
const limit = opts.limit || DEFAULT_LIMIT;
const ttl = opts.ttl || DEFAULT_TTL;
const concurrency = opts.concurrency || DEFAULT_CONCURRENCY;
const now = opts.now || (() => new Date().toISOString());
if (!Array.isArray(props) || props.length === 0) {
return { written: false, count: 0 };
}
try {
const unique = dedupeProps(props, limit);
if (unique.length === 0) return { written: false, count: 0 };
const graded = (await mapLimit(unique, concurrency, (p) => gradeBestSide(grade, p, sport, opts)))
.filter(Boolean)
.sort((a, b) => (Number(b.confidence) || 0) - (Number(a.confidence) || 0));
if (graded.length === 0) return { written: false, count: 0 };
const envelope = { grades: graded, updated_at: now(), source };
await cacheSet(`grades:${sport}`, envelope, ttl);
return { written: true, count: graded.length };
} catch (e) {
// Best-effort — slate grading must never break odds delivery.
console.warn('[gradeSlateService] grade slate failed:', e.message);
return { written: false, count: 0, error: e.message };
}
}
module.exports = {
gradeAndCacheSlate,
__internals: { dedupeProps, gradeBestSide, mapLimit, DEFAULT_LIMIT, DEFAULT_TTL, DEFAULT_CONCURRENCY },
};