Session 32: Grades pipeline + NFL/NHL wiring + rate limiting + audit cleanup (1718 tests)
- gradeSlateService writes grades:{sport} cache (closes content pipeline →
dataLevel full); fire-and-forget from oddsService.recordDownstream, gated
by shouldGradeSlate (off in test, GRADE_SLATE_ON_FETCH override)
- NFL/NHL wired: oddsService SPORT_KEYS/SPORT_MARKETS (correct the-odds-api
keys americanfootball_nfl/icehockey_nhl), proplineAdapter MARKETS, NHL
MARKET_MAP keys to avoid silent-zero
- rate limiting mounted on 8 public cached routers (odds/parlay 30/min,
rest 60/min)
- jsonlLogger writes to temp under test (no more dirtied tracked artifact);
5MB pipeline test given 20s timeout
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -15,8 +15,11 @@
|
||||
const express = require('express');
|
||||
const bookComparison = require('../services/bookComparisonService');
|
||||
const { cacheGet } = require('../utils/redis');
|
||||
const { createRateLimit } = require('../middleware/rateLimit');
|
||||
|
||||
const router = express.Router();
|
||||
// Session 32 — public throttle (60/min; reads cached odds props).
|
||||
router.use(createRateLimit({ windowMs: 60_000, max: 60 }));
|
||||
|
||||
const MISSION_HEADER = { 'X-VYNDR-Mission': 'Never leave money on the table' };
|
||||
|
||||
|
||||
@@ -17,8 +17,11 @@ const express = require('express');
|
||||
const template = require('../services/contentTemplateService');
|
||||
const formatter = require('../services/contentFormatter');
|
||||
const { cacheGet } = require('../utils/redis');
|
||||
const { createRateLimit } = require('../middleware/rateLimit');
|
||||
|
||||
const router = express.Router();
|
||||
// Session 32 — public throttle (60/min; reads from cache).
|
||||
router.use(createRateLimit({ windowMs: 60_000, max: 60 }));
|
||||
|
||||
const MISSION_HEADER = { 'X-VYNDR-Mission': 'Every post is a free ad' };
|
||||
const SUPPORTED = new Set(['nba', 'wnba', 'mlb', 'soccer', 'nfl', 'nhl']);
|
||||
|
||||
@@ -31,8 +31,11 @@ const express = require('express');
|
||||
const nbaAdapter = require('../services/adapters/tank01NbaAdapter');
|
||||
const mlbAdapter = require('../services/adapters/tank01MlbAdapter');
|
||||
const scheduleService = require('../services/scheduleService');
|
||||
const { createRateLimit } = require('../middleware/rateLimit');
|
||||
|
||||
const router = express.Router();
|
||||
// Session 32 — public throttle (60/min; Tank01, cached).
|
||||
router.use(createRateLimit({ windowMs: 60_000, max: 60 }));
|
||||
|
||||
const MISSION_HEADER = { 'X-VYNDR-Mission': 'Lines keep the slate alive' };
|
||||
|
||||
|
||||
@@ -11,8 +11,11 @@
|
||||
const express = require('express');
|
||||
const hotListService = require('../services/hotListService');
|
||||
const { loadRosterLogs } = require('../services/rosterLogs');
|
||||
const { createRateLimit } = require('../middleware/rateLimit');
|
||||
|
||||
const router = express.Router();
|
||||
// Session 32 — public throttle (60/min; pure engine over cached logs).
|
||||
router.use(createRateLimit({ windowMs: 60_000, max: 60 }));
|
||||
|
||||
const MISSION_HEADER = { 'X-VYNDR-Mission': 'Hot right now' };
|
||||
|
||||
|
||||
@@ -11,8 +11,11 @@
|
||||
|
||||
const express = require('express');
|
||||
const lineSnapshots = require('../services/lineSnapshotService');
|
||||
const { createRateLimit } = require('../middleware/rateLimit');
|
||||
|
||||
const router = express.Router();
|
||||
// Session 32 — public throttle (60/min; Redis snapshots).
|
||||
router.use(createRateLimit({ windowMs: 60_000, max: 60 }));
|
||||
|
||||
const MISSION_HEADER = { 'X-VYNDR-Mission': 'The market confirms the grade' };
|
||||
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
const express = require('express');
|
||||
const { getOdds } = require('../services/oddsService');
|
||||
const { MARKET_MAP, ALLOWED_BOOKS } = require('../utils/oddsNormalizer');
|
||||
const { createRateLimit } = require('../middleware/rateLimit');
|
||||
|
||||
const router = express.Router();
|
||||
// Session 32 — public throttle. /odds hits PropLine/odds-api upstream on a
|
||||
// cache miss, so it gets the tighter 30/min bucket. Independent per-IP
|
||||
// bucket scoped to this router (createRateLimit allocates its own Map).
|
||||
router.use(createRateLimit({ windowMs: 60_000, max: 30 }));
|
||||
|
||||
const VALID_STAT_TYPES = new Set(Object.values(MARKET_MAP));
|
||||
const VALID_BOOKS = ALLOWED_BOOKS;
|
||||
|
||||
@@ -14,8 +14,11 @@
|
||||
|
||||
const express = require('express');
|
||||
const parlayService = require('../services/parlayService');
|
||||
const { createRateLimit } = require('../middleware/rateLimit');
|
||||
|
||||
const router = express.Router();
|
||||
// Session 32 — public throttle. Parlay math is computation-heavy → 30/min.
|
||||
router.use(createRateLimit({ windowMs: 60_000, max: 30 }));
|
||||
|
||||
const MISSION_HEADER = { 'X-VYNDR-Mission': 'Catch the legs that fight each other' };
|
||||
|
||||
|
||||
@@ -22,8 +22,11 @@
|
||||
const express = require('express');
|
||||
const scheduleService = require('../services/scheduleService');
|
||||
const { SPORT_CONFIG } = require('../config/sports');
|
||||
const { createRateLimit } = require('../middleware/rateLimit');
|
||||
|
||||
const router = express.Router();
|
||||
// Session 32 — public throttle (60/min; ESPN is free but be respectful).
|
||||
router.use(createRateLimit({ windowMs: 60_000, max: 60 }));
|
||||
|
||||
const MISSION_HEADER = { 'X-VYNDR-Mission': 'The slate is never empty' };
|
||||
|
||||
|
||||
@@ -15,8 +15,11 @@
|
||||
const express = require('express');
|
||||
const streaksService = require('../services/streaksService');
|
||||
const { loadRosterLogs } = require('../services/rosterLogs');
|
||||
const { createRateLimit } = require('../middleware/rateLimit');
|
||||
|
||||
const router = express.Router();
|
||||
// Session 32 — public throttle (60/min; pure engine over cached logs).
|
||||
router.use(createRateLimit({ windowMs: 60_000, max: 60 }));
|
||||
|
||||
const MISSION_HEADER = { 'X-VYNDR-Mission': 'Streaks are the heartbeat' };
|
||||
|
||||
|
||||
@@ -47,8 +47,8 @@ const MARKETS = {
|
||||
nba: ['player_points', 'player_rebounds', 'player_assists', 'player_threes', 'player_blocks', 'player_steals'],
|
||||
wnba: ['player_points', 'player_rebounds', 'player_assists', 'player_threes'],
|
||||
mlb: ['batter_hits', 'batter_home_runs', 'batter_total_bases', 'batter_rbis', 'batter_stolen_bases', 'pitcher_strikeouts'],
|
||||
nfl: [],
|
||||
nhl: [],
|
||||
nfl: ['player_pass_yds', 'player_rush_yds', 'player_reception_yds', 'player_receptions', 'player_anytime_td', 'player_pass_tds'],
|
||||
nhl: ['player_goals', 'player_shots_on_goal', 'goalie_saves'],
|
||||
ncaab: ['player_points', 'player_rebounds', 'player_assists'],
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
'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 DEFAULT_LIMIT = 25;
|
||||
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.
|
||||
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;
|
||||
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) {
|
||||
const base = {
|
||||
player: prop.player,
|
||||
stat_type: prop.stat_type,
|
||||
line: prop.line,
|
||||
sport,
|
||||
book: prop.book,
|
||||
};
|
||||
const sides = await Promise.all([
|
||||
Promise.resolve()
|
||||
.then(() => grade({ ...base, direction: 'over' }))
|
||||
.catch(() => null),
|
||||
Promise.resolve()
|
||||
.then(() => grade({ ...base, direction: 'under' }))
|
||||
.catch(() => null),
|
||||
]);
|
||||
const cands = sides.filter(Boolean);
|
||||
if (cands.length === 0) return null;
|
||||
return cands.reduce((a, b) => ((Number(b.confidence) || 0) > (Number(a.confidence) || 0) ? b : a));
|
||||
}
|
||||
|
||||
// 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)))
|
||||
.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 },
|
||||
};
|
||||
@@ -50,6 +50,12 @@ const SPORT_KEYS = {
|
||||
// array with a friendly message in that case.
|
||||
wnba: 'basketball_wnba',
|
||||
mlb: 'baseball_mlb',
|
||||
// Session 32 — NFL + NHL. odds-api keys per the-odds-api sports list.
|
||||
// Off-season returns an empty events array; the route layer surfaces an
|
||||
// empty slate (never a crash). NFL props normalize through the MARKET_MAP
|
||||
// keys added in Session 31; NHL keys were added alongside this wiring.
|
||||
nfl: 'americanfootball_nfl',
|
||||
nhl: 'icehockey_nhl',
|
||||
// Soccer (Session 7j) — odds-api sport keys verified against
|
||||
// https://the-odds-api.com/sports-odds-data/sports-apis.html
|
||||
soccer_wc: 'soccer_fifa_world_cup',
|
||||
@@ -106,6 +112,24 @@ const MLB_MARKETS = [
|
||||
'pitcher_strikeouts',
|
||||
'pitcher_outs',
|
||||
];
|
||||
// Session 32 — NFL + NHL market lists. NFL keys mirror the MARKET_MAP
|
||||
// entries added Session 31 (both _yds and _yards spellings normalize). NHL
|
||||
// keys were added to MARKET_MAP alongside this wiring so they don't
|
||||
// silently normalize to zero in-season.
|
||||
const NFL_MARKETS = [
|
||||
'player_pass_yds',
|
||||
'player_pass_tds',
|
||||
'player_rush_yds',
|
||||
'player_reception_yds',
|
||||
'player_receptions',
|
||||
'player_anytime_td',
|
||||
];
|
||||
const NHL_MARKETS = [
|
||||
'player_goals',
|
||||
'player_shots_on_goal',
|
||||
'player_assists',
|
||||
'goalie_saves',
|
||||
];
|
||||
const SOCCER_MARKETS = [
|
||||
'player_goals',
|
||||
'player_shots_on_target',
|
||||
@@ -129,6 +153,8 @@ const SPORT_MARKETS = Object.freeze({
|
||||
nba: buildMarketString(NBA_MARKETS),
|
||||
wnba: buildMarketString(WNBA_MARKETS),
|
||||
mlb: buildMarketString(MLB_MARKETS),
|
||||
nfl: buildMarketString(NFL_MARKETS),
|
||||
nhl: buildMarketString(NHL_MARKETS),
|
||||
ncaab: buildMarketString(NBA_MARKETS), // NCAAB markets mirror NBA
|
||||
// Every soccer league code shares the same market set.
|
||||
...Object.fromEntries(
|
||||
@@ -283,7 +309,7 @@ function parseQuota(headers) {
|
||||
// Best-effort post-fetch processing shared by both providers (PropLine +
|
||||
// odds-api): line movement, scratch cascade, and rolling line snapshots.
|
||||
// Never throws — a failure here must not break the odds response.
|
||||
async function recordDownstream(sport, props) {
|
||||
async function recordDownstream(sport, props, provider = 'odds-api') {
|
||||
let movements = [];
|
||||
let scratchedPlayers = [];
|
||||
try {
|
||||
@@ -298,9 +324,34 @@ async function recordDownstream(sport, props) {
|
||||
} catch (e) {
|
||||
console.warn('[VYNDR] Movement/cascade detection error:', e.message);
|
||||
}
|
||||
|
||||
// Session 32 — grade the slate into the `grades:{sport}` cache that
|
||||
// contentTemplateService reads, closing the content pipeline. Fire-and-
|
||||
// forget: grading fans out to feature computation and must NOT hold the
|
||||
// odds HTTP response — content endpoints read the grades cache later and
|
||||
// independently. Errors are self-contained inside the service.
|
||||
if (shouldGradeSlate()) {
|
||||
require('./gradeSlateService')
|
||||
.gradeAndCacheSlate(sport, props, { source: provider })
|
||||
.catch((e) => console.warn('[VYNDR] slate grading error:', e.message));
|
||||
}
|
||||
|
||||
return { movements, scratchedPlayers };
|
||||
}
|
||||
|
||||
// Auto-grade the slate on a fresh odds fetch by default (closes the content
|
||||
// pipeline). Skipped under the test env so its background feature-computation
|
||||
// fan-out doesn't pollute call-count assertions in the odds integration
|
||||
// tests; `GRADE_SLATE_ON_FETCH` is the explicit operator override
|
||||
// ('1' forces on even in test, '0' is the production kill-switch if the
|
||||
// feature-compute cost ever needs to be shed).
|
||||
function shouldGradeSlate() {
|
||||
const flag = process.env.GRADE_SLATE_ON_FETCH;
|
||||
if (flag === '1') return true;
|
||||
if (flag === '0') return false;
|
||||
return process.env.NODE_ENV !== 'test';
|
||||
}
|
||||
|
||||
async function getOdds(sport) {
|
||||
const redis = getRedisClient();
|
||||
const apiKey = process.env.ODDS_API_KEY;
|
||||
@@ -335,7 +386,7 @@ async function getOdds(sport) {
|
||||
const now = new Date().toISOString();
|
||||
const cacheData = { updated_at: now, props: pl.props, spreads: pl.spreads || [], provider: 'propline' };
|
||||
await redis.set(cacheKey, JSON.stringify(cacheData), 'EX', CACHE_TTL);
|
||||
const { movements, scratchedPlayers } = await recordDownstream(sport, pl.props);
|
||||
const { movements, scratchedPlayers } = await recordDownstream(sport, pl.props, 'propline');
|
||||
return {
|
||||
sport,
|
||||
updated_at: now,
|
||||
@@ -389,7 +440,7 @@ async function getOdds(sport) {
|
||||
await redis.set(cacheKey, JSON.stringify(cacheData), 'EX', CACHE_TTL);
|
||||
|
||||
// Line movement + cascade + snapshots (best-effort; shared helper).
|
||||
const { movements, scratchedPlayers } = await recordDownstream(sport, props);
|
||||
const { movements, scratchedPlayers } = await recordDownstream(sport, props, 'odds-api');
|
||||
|
||||
return {
|
||||
sport,
|
||||
@@ -458,4 +509,6 @@ module.exports = {
|
||||
// Session 22 — exposed for tests that exercise env-driven TTL
|
||||
// resolution without re-loading the module.
|
||||
getConfiguredCacheTTL,
|
||||
// Session 32 — slate auto-grade gate (exposed for tests).
|
||||
shouldGradeSlate,
|
||||
};
|
||||
|
||||
@@ -13,9 +13,18 @@
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
|
||||
const ROOT = path.join(process.cwd(), 'data', 'training');
|
||||
// Under test, write to a throwaway temp dir instead of the tracked
|
||||
// data/training corpus — otherwise every `npm test` appends resolution rows
|
||||
// to data/training/resolutions-YYYY-MM.jsonl and dirties the git tree
|
||||
// (Session 31 audit finding; fixed Session 32). `TRAINING_DATA_DIR` lets
|
||||
// operators override the location explicitly.
|
||||
const ROOT = process.env.TRAINING_DATA_DIR
|
||||
|| (process.env.NODE_ENV === 'test'
|
||||
? path.join(os.tmpdir(), 'vyndr-training-test')
|
||||
: path.join(process.cwd(), 'data', 'training'));
|
||||
let initialized = false;
|
||||
|
||||
function ensureDir() {
|
||||
|
||||
@@ -71,6 +71,13 @@ const MARKET_MAP = {
|
||||
player_goals_conceded: 'goals_conceded',
|
||||
player_passes: 'passes',
|
||||
team_clean_sheet: 'clean_sheet',
|
||||
|
||||
// NHL (Session 32) — added alongside the NFL/NHL sport-key wiring so NHL
|
||||
// props don't silently normalize to zero in-season (same silent-failure
|
||||
// class as the NFL gap Session 31 closed). player_goals/player_assists
|
||||
// are shared with soccer/NBA — sport context discriminates downstream.
|
||||
player_shots_on_goal: 'shots_on_goal',
|
||||
goalie_saves: 'saves',
|
||||
};
|
||||
|
||||
function normalizeProps(eventsWithOdds) {
|
||||
|
||||
Reference in New Issue
Block a user