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:
@@ -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