Step 0 input check: read-only feature-coverage probe
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', a fabricated input rather than an absent one. Reports population per feature, 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. Also reports whether ab_per_game varies across a player's own props -- a per-player constant can only move all of a player's props together, which is a very different thing from a per-prop opportunity signal. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QJs13VsyiSKYQP6rj3NNmc
This commit is contained in:
@@ -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;
|
module.exports = router;
|
||||||
|
|||||||
@@ -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 } };
|
||||||
Reference in New Issue
Block a user