diff --git a/src/routes/internal.js b/src/routes/internal.js index d960e91..e968e90 100644 --- a/src/routes/internal.js +++ b/src/routes/internal.js @@ -629,4 +629,27 @@ router.get('/diagnose-refusals', async (req, res) => { } }); +/** + * GET /api/internal/feature-coverage (Connect-layers Step 0) + * + * READ-ONLY input check: before wiring any layer into the grade, measure + * whether its inputs are actually populated on real props. Writes nothing. + * + * ?sport=mlb&sample=60 + */ +router.get('/feature-coverage', async (req, res) => { + try { + const { coverage } = require('../services/featureCoverage'); + const out = await coverage({ + sport: req.query.sport || 'mlb', + sample: parseInt(req.query.sample, 10) || undefined, + concurrency: parseInt(req.query.concurrency, 10) || undefined, + }); + res.set('Cache-Control', 'no-store'); + return res.json({ ok: true, ...out }); + } catch (err) { + return res.status(500).json({ ok: false, error: err && err.message }); + } +}); + module.exports = router; diff --git a/src/services/featureCoverage.js b/src/services/featureCoverage.js new file mode 100644 index 0000000..5e484ec --- /dev/null +++ b/src/services/featureCoverage.js @@ -0,0 +1,142 @@ +'use strict'; + +/** + * featureCoverage — Order: CONNECT PROJECTION LAYERS, Step 0. READ-ONLY. + * + * Before wiring ANY layer into the grade, measure whether its inputs are + * actually populated on real props. A layer wired onto sparse inputs does not + * degrade gracefully by default — `Number(null) === 0` turns a missing + * opportunity into "zero opportunity", which is a fabricated input, not an + * absent one. + * + * Reports, per feature, over a real slate: + * populated / total, and the population rate, split by stat_type — because a + * feature can be 100% present for batters and 0% for pitchers, and a pooled + * number would hide exactly that. + */ + +const DEFAULT_SAMPLE = 60; +const DEFAULT_CONCURRENCY = 5; + +// The opportunity/usage inputs the order is about, plus the projection inputs +// they would sit alongside (so the report shows relative coverage, not an +// isolated number that looks fine until you compare it). +const TRACKED = Object.freeze([ + 'ab_per_game', // MLB "usage" — season atBats / games + 'rest_days', + 'l5_avg', + 'l20_avg', + 'l10_stddev', + 'opp_rank_stat', + 'minutes_per_game', // NBA/WNBA usage equivalent + 'usage_rate', + 'game_count_in_7d', +]); + +async function mapLimit(items, limit, fn) { + const out = new Array(items.length); + let cursor = 0; + const workers = Array.from({ length: Math.max(1, limit) }, async () => { + for (;;) { + const idx = cursor; + if (idx >= items.length) return; + cursor += 1; + out[idx] = await fn(items[idx], idx); + } + }); + await Promise.all(workers); + return out; +} + +/** Strict: a feature counts as populated only when it is a finite number. */ +const populated = (v) => Number.isFinite(Number(v)) && v !== null && v !== ''; + +async function coverage(opts = {}) { + const sport = String(opts.sport || 'mlb').toLowerCase(); + const sample = Math.max(1, Math.min(300, opts.sample || DEFAULT_SAMPLE)); + const concurrency = Math.max(1, Math.min(10, opts.concurrency || DEFAULT_CONCURRENCY)); + + const getOdds = opts.getOdds || require('./oddsService').getOdds; + const getFeatures = opts.getFeatures || require('./intelligence/featureCache').getFeatures; + const isModelBook = opts.isModelBook || require('../config/bookRoles').isModelBook; + + const odds = await getOdds(sport); + const rows = (odds && odds.props) || []; + + const seen = new Set(); + const unique = []; + for (const p of rows) { + if (!p || !p.player || !p.stat_type || p.line == null) continue; + if (!isModelBook(p.book)) continue; + const k = `${p.player}::${p.stat_type}::${p.line}`; + if (seen.has(k)) continue; + seen.add(k); + unique.push(p); + } + const batch = unique.slice(0, sample); + + const feats = await mapLimit(batch, concurrency, async (p) => { + try { + const f = await getFeatures({ + player: p.player, stat_type: p.stat_type, sport, + home_team: p.home_team, away_team: p.away_team, game_time: p.game_time, + }); + return { p, f: f || {} }; + } catch (err) { + return { p, f: {}, error: (err && err.message) || String(err) }; + } + }); + + const overall = {}; + const byStat = {}; + const distinctValuesPerPlayer = {}; // is the feature prop-specific or per-player constant? + for (const key of TRACKED) overall[key] = 0; + + for (const { p, f } of feats) { + const stat = String(p.stat_type || '?'); + byStat[stat] = byStat[stat] || { n: 0 }; + byStat[stat].n += 1; + for (const key of TRACKED) { + const ok = populated(f[key]); + if (ok) overall[key] += 1; + byStat[stat][key] = (byStat[stat][key] || 0) + (ok ? 1 : 0); + } + if (populated(f.ab_per_game)) { + const pk = String(p.player).toLowerCase(); + distinctValuesPerPlayer[pk] = distinctValuesPerPlayer[pk] || new Set(); + distinctValuesPerPlayer[pk].add(Math.round(Number(f.ab_per_game) * 1000)); + } + } + + const n = batch.length || 1; + const rate = (v) => Math.round((1000 * v) / n) / 10; + + // A per-player CONSTANT cannot separate that player's props from each other. + // If ab_per_game takes one value across every prop a player has, it can only + // shift all of his props together — which is a very different thing from a + // per-prop opportunity signal, and worth knowing before wiring it. + const multiPropPlayers = Object.values(distinctValuesPerPlayer).filter((s) => s.size > 0); + const playersWithVaryingValue = multiPropPlayers.filter((s) => s.size > 1).length; + + return { + read_only: true, + sport, + generated_at: new Date().toISOString(), + sampled: batch.length, + unique_gradeable: unique.length, + coverage: Object.fromEntries(TRACKED.map((k) => [k, { populated: overall[k], pct: rate(overall[k]) }])), + by_stat: Object.fromEntries(Object.entries(byStat).map(([stat, v]) => [stat, { + n: v.n, + ...Object.fromEntries(TRACKED.map((k) => [k, v.n ? Math.round((1000 * (v[k] || 0)) / v.n) / 10 : 0])), + }])), + ab_per_game_shape: { + players_with_value: multiPropPlayers.length, + players_where_it_varies_across_their_props: playersWithVaryingValue, + note: playersWithVaryingValue === 0 + ? 'CONSTANT per player across all of that player\'s props — it can only move all of a player\'s props together, not separate them.' + : 'varies across a player\'s props', + }, + }; +} + +module.exports = { coverage, __internals: { TRACKED, populated, mapLimit } };