Files
vyndr/src/routes/snapshot.js
T
builtbykev 6c97f59546 WNBA truth correction + THE p_win FLIP (live, rollback armed)
PART A -- WNBA TRUTH CORRECTION (no behaviour change).
WNBA does not "abstain" and is not "anti-predictive". The -0.12 that
produced those words was NBA-template machinery run on WNBA data -- WNBA
has never had its own archetypes, variables, conditions or calibration,
which is precisely the "sport stubbed in on another sport's template"
CLAUDE.md forbids. That is an UNBUILT MODEL'S EXPECTED FAILURE, not a
verdict on the sport; reading it as a verdict would quietly retire a sport
we never actually attempted. Its own build is QUEUED, after MLB.

The guard CODE is unchanged -- FORECAST_RANKED_SPORTS = {'mlb'} and the
inheritance test are correct live safety either way. Only the meaning is
corrected, and generalised into the doctrine-as-a-gate: a sport ranks on
p_win ONLY once its OWN model is built and shown to predict (calibration
AND resolution on its own holdout). Others are held out as NOT-BUILT,
never as failed. Re-labelled across gradeRanking, snapshot route, tests,
MASTER-PLAN and the challenger report.

PART B -- THE FLIP, gated on a full-slate re-run.

The re-run found something better than a bigger sample. An induced
snapshot graded 7 props: gradeAndCacheSlate runs with DEFAULT_LIMIT = 25
and ~72% of those refuse for insufficient_data, while 546 props are
gradeable. So 8 props IS the board, structurally -- not a small sample of
it. Logged as its own finding; the cap is a separate order.

For a statistically meaningful delta I used 11 real historical boards
(n=328, board sizes 14-57): 79.9% of rows move, mean 5.16 places per
board, TOP READ CHANGES ON 9 OF 11 BOARDS. The re-ordering holds at real
board size. Query committed.

FLIPPED:
- rankGrades drops its edge key (safe for every sport: removes a
  non-predictive tiebreak without putting p_win in front).
- selectTopGrades leads on forecast_rank, edge key removed.
- flattenToEdgeBoard sorts on forecastRank, not edge -- this board had
  edge as its PRIMARY key, so the whole mobile board was ordered by a
  quantity measured not to predict.
- forecast_rank threaded onto strip props.

Sports whose model is not built supply no forecast_rank, so their boards
fall through to the unchanged grade chain -- the fallback is the guard.

ROLLBACK ARMED: boards sort by forecast_rank WHEN PRESENT, so
FORECAST_RANK=0 reverts every surface on the next response -- no deploy,
no client release.

Edge is still computed, stored, carried and displayed as a labelled
diagnostic. Retired from ranking, not deleted.

Eight superseded tests updated to strictly stronger INVERSE properties --
they now fail if edge is ever re-introduced as a ranking key, which the
originals could not detect.

Gates: 4,045 tests / 323 suites green; next build exit 0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QJs13VsyiSKYQP6rj3NNmc
2026-08-01 01:55:43 -04:00

178 lines
9.5 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use strict';
/**
* GET /api/snapshot/:sport (Session 45) — the latest pre-graded slate.
*
* Public, cache-only read of `snapshot:{sport}:latest` (enriched grades with
* archetype + gradedAt, plus line deltas). Falls back to the `grades:{sport}`
* envelope when no snapshot has run yet. NEVER triggers a snapshot (that's the
* internal cron's job) — so it can't drain the PropLine quota.
*/
const express = require('express');
const { createRateLimit } = require('../middleware/rateLimit');
const { cacheGet } = require('../utils/redis');
const { nameKey } = require('../utils/playerName');
// S6 (A1 board) — ●●○●● last-10 vs tonight's locked line, computed from the
// rosterlogs blob the snapshot pipeline already writes. Pure, cache-only.
const { indexRosterLogs, attachLast10Dots } = require('../services/last10Dots');
// Session 67 — the model price never leaves the server for an unentitled
// viewer. This endpoint is PUBLIC, so the Session-66 gate on /api/analyze was
// being bypassed here on every graded row. Same layer as the CLV gate.
const { stripModelPrice, gateItemizedGrades, liveLockedSummary, freeSample, entitledToItemizedGrades } = require('../utils/snapshotGating');
const { rankByForecast, gradeKey, ranksOnForecast } = require('../utils/gradeRanking');
const { resolveTierFromRequest } = require('../utils/requestTier');
const router = express.Router();
router.use(createRateLimit({ windowMs: 60_000, max: 60 }));
// Session 55 — overlay settled outcomes (self-learning loop) onto the grades so
// a completed prop can render "✅ HIT (2)" / "❌ MISS". Keyed by player+stat+line+side.
function outcomeIndex(log) {
const map = {};
for (const o of Array.isArray(log) ? log : []) {
const side = String(o.side || 'O').toUpperCase() === 'U' ? 'U' : 'O';
map[`${nameKey(o.player)}|${String(o.stat).toLowerCase()}|${o.line}|${side}`] = o;
}
return map;
}
function attachOutcomes(grades, index) {
if (!index || Object.keys(index).length === 0) return grades;
return grades.map((g) => {
const side = String(g.direction || 'over').toLowerCase() === 'under' ? 'U' : 'O';
const o = index[`${nameKey(g.player || g.player_name)}|${String(g.stat_type || g.stat || '').toLowerCase()}|${g.line}|${side}`];
return o ? { ...g, outcome: { result: o.result, actual: o.actual } } : g;
});
}
// Session 57 (Phase 0) — GET /api/snapshot/summary: the HeartbeatBar's honest
// data source. Cheap cache-only Redis reads; total graded props in the current
// snapshots + the latest pipeline run time. Registered BEFORE /:sport or
// Express captures "summary" as a sport.
const SUMMARY_SPORTS = ['nba', 'wnba', 'mlb', 'soccer'];
router.get('/summary', async (req, res) => {
try {
const reads = await Promise.all(SUMMARY_SPORTS.map(async (sp) => {
const snap = await cacheGet(`snapshot:${sp}:latest`);
if (snap && Array.isArray(snap.grades)) {
return {
sport: sp,
graded: snap.grades.length,
updated_at: snap.updated_at || null,
refreshed_at: snap.refreshed_at || snap.updated_at || null,
};
}
const env = await cacheGet(`grades:${sp}`);
return {
sport: sp,
graded: env && Array.isArray(env.grades) ? env.grades.length : 0,
updated_at: (env && env.updated_at) || null,
refreshed_at: (env && (env.refreshed_at || env.updated_at)) || null,
};
}));
const graded = reads.reduce((n, r) => n + r.graded, 0);
// ISO timestamps sort lexicographically — the max is the latest run.
// `updated_at` = last grade LOCK (5×/day, intentionally stable). `refreshed_at`
// = last freshness heartbeat (grade lock OR intraday refresh, every ~20 min).
// The SYNC badge measures against refreshed_at; updated_at is exposed so the
// UI can distinguish "grades locked at X" from "lines synced at Y".
const updated_at = reads.map((r) => r.updated_at).filter(Boolean).sort().pop() || null;
const refreshed_at = reads.map((r) => r.refreshed_at).filter(Boolean).sort().pop() || updated_at;
const sports = {};
for (const r of reads) sports[r.sport] = r.graded;
// Session 58 (Task 5) — the SYNC badge thresholds key off the pipeline's
// EXPECTED cadence, not a flat 5 minutes: normal < 1.5x, amber ≥ 1.5x,
// STALE red ≥ 3x. Default = the 5h max cron gap; when Phase 2.5's
// intraday refresh ships, drop the env value and the badge goes live
// with zero UI changes.
const expected_interval_s = Number(process.env.SNAPSHOT_EXPECTED_INTERVAL) > 0
? Number(process.env.SNAPSHOT_EXPECTED_INTERVAL) : 18000;
res.set('Cache-Control', 'public, max-age=30');
return res.json({ graded, updated_at, refreshed_at, sports, expected_interval_s });
} catch (err) {
console.error('[snapshot/summary]', err.message);
return res.status(200).json({ graded: 0, updated_at: null, sports: {} });
}
});
router.get('/:sport', async (req, res) => {
const sport = String(req.params.sport || '').toLowerCase();
try {
// Tier is resolved from the bearer token when one is present; anonymous
// and free callers get the market legs only. Because the response now
// VARIES by entitlement, the shared `public` cache directive below is
// downgraded to `private` for authenticated callers — a CDN must never
// hand a paid payload to an anonymous viewer.
const tier = await resolveTierFromRequest(req);
// BUILD 1 CORRECTED (2026-07-31) — ITEMIZED GRADES ARE PAID, LIVE AND SETTLED.
// The earlier resolution-flip freed settled grades, which made the free tier a
// ONE-DAY-DELAYED FEED of the whole product. Order still matters: strip the model
// PRICE first (S67), then withhold judgment on EVERY itemized grade.
// FORECAST RANK (Order: rank on p_win, 2026-08-01) — stamped BEFORE the
// model-price strip, so every tier receives the CORRECT ORDER without the
// paid values. Same precedent as topGradedService: `p_win` is stripped for
// unentitled callers, so a client cannot rank on it; an ordinal can travel
// where the magnitude cannot.
//
// ADDITIVE ONLY IN THIS ORDER. Nothing sorts by it yet — the live ordering
// is byte-identical until the flip is reviewed against the recorded delta.
// It leaks ordering, not magnitude, which is the same trade already made
// and accepted for the top-graded board.
const stampForecastRank = (grades) => {
if (!Array.isArray(grades) || grades.length === 0) return grades;
// ARMED ROLLBACK. The boards sort by `forecast_rank` WHEN PRESENT, so
// setting FORECAST_RANK=0 reverts every surface to the incumbent order on
// the next response — no deploy, no code change, no client release.
if (String(process.env.FORECAST_RANK || '1') === '0') return grades;
// PER-SPORT DOCTRINE, enforced as a gate: a sport ranks on p_win only
// once its OWN model is built and shown to predict. MLB is the only one
// that has passed; the rest are held out as NOT-BUILT, not as failed.
// (WNBA's -0.12 came from NBA-template machinery on WNBA data — an
// unbuilt model's expected failure, not a verdict on the sport.)
if (!ranksOnForecast(sport)) return grades;
const ranked = rankByForecast(grades);
const pos = new Map();
ranked.forEach((g, i) => pos.set(gradeKey(g), i + 1));
return grades.map((g) => {
const r = pos.get(gradeKey(g));
return r == null ? g : { ...g, forecast_rank: r };
});
};
const gate = (grades) => gateItemizedGrades(stripModelPrice(stampForecastRank(grades), tier), tier);
// Free proof, none of it itemizing the nightly slate:
// - the tease: AGGREGATE count + tier shape, computed from the ungated rows and
// never joined back to one, so nobody can tell WHICH prop is the A
// - the sample: a CAPPED, day-rotated handful of resolved calls for texture
const unentitled = !entitledToItemizedGrades(tier);
const dayKey = new Date().toISOString().slice(0, 10);
const teaseFor = (grades) => (unentitled ? liveLockedSummary(grades) : null);
const sampleFor = (grades) => (unentitled ? freeSample(grades, dayKey) : null);
const cacheHeader = req.headers.authorization ? 'private, max-age=30' : 'public, max-age=30';
const [snap, outcomeLog, rosterBlob] = await Promise.all([
cacheGet(`snapshot:${sport}:latest`),
cacheGet(`outcomes:${sport}:log`),
cacheGet(`rosterlogs:${sport}`),
]);
const idx = outcomeIndex(outcomeLog);
const roster = indexRosterLogs(rosterBlob);
const enrich = (grades) => attachLast10Dots(attachOutcomes(grades, idx), roster, sport);
if (snap && Array.isArray(snap.grades)) {
res.set('Cache-Control', cacheHeader);
const enrichedSnap = enrich(snap.grades);
return res.json({ sport, updated_at: snap.updated_at, refreshed_at: snap.refreshed_at || snap.updated_at || null, grades: gate(enrichedSnap), deltas: snap.deltas || [], live_locked: teaseFor(enrichedSnap), free_sample: sampleFor(enrichedSnap) });
}
// Fallback: the grades envelope (no deltas yet).
const env = await cacheGet(`grades:${sport}`);
const grades = env && Array.isArray(env.grades) ? env.grades : [];
res.set('Cache-Control', cacheHeader);
const enrichedEnv = enrich(grades);
return res.json({ sport, updated_at: env && env.updated_at, grades: gate(enrichedEnv), deltas: [], live_locked: teaseFor(enrichedEnv), free_sample: sampleFor(enrichedEnv) });
} catch (err) {
console.error('[snapshot]', err.message);
return res.status(200).json({ sport, grades: [], deltas: [] });
}
});
module.exports = router;