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:
Kev
2026-08-01 02:34:33 -04:00
parent ecdc644621
commit 3e78217678
2 changed files with 165 additions and 0 deletions
+23
View File
@@ -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;
+142
View File
@@ -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 } };