diff --git a/src/routes/internal.js b/src/routes/internal.js index cc024c4..6ad2f73 100644 --- a/src/routes/internal.js +++ b/src/routes/internal.js @@ -601,4 +601,28 @@ router.get('/ranking-delta', async (req, res) => { } }); +/** + * GET /api/internal/diagnose-refusals (25-cap + 72% refusal, Part 1) + * + * READ-ONLY diagnosis: runs the real grade path over a real slate, categorises + * every refusal, and measures per-grade cost so we know what raising the cap + * would actually cost. Writes nothing. + * + * ?sport=mlb&sample=60&concurrency=5 + */ +router.get('/diagnose-refusals', async (req, res) => { + try { + const { diagnose } = require('../services/refusalDiagnostics'); + const out = await diagnose({ + 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/refusalDiagnostics.js b/src/services/refusalDiagnostics.js new file mode 100644 index 0000000..ca39f66 --- /dev/null +++ b/src/services/refusalDiagnostics.js @@ -0,0 +1,221 @@ +'use strict'; + +/** + * refusalDiagnostics — Order: THE 25-CAP + 72% REFUSAL, Part 1. READ-ONLY. + * + * Runs the REAL grade path over a REAL slate and categorises every refusal. + * Writes nothing — no cache, no ledger, no snapshot. It reproduces + * `gradeSlateService.dedupeProps` exactly (MODEL_BOOKS, first-row-wins) and + * calls `analyzeViaEngine1` the same way, so what it measures is what the + * pipeline actually does rather than a re-implementation of it. + * + * BUCKETS — the order's four, plus one it did not anticipate: + * + * (a) FALSE-THRESHOLD data exists, a grading threshold rejected it + * (b) FETCHABLE-GAP data exists somewhere, not wired to the grade path + * (c) ARCHETYPE-GAP the prop cannot be classified + * (d) GENUINE-ABSENCE no history exists → CORRECT refusal, keep refusing + * (e) POLICY-SUPPRESSION *** NOT A DATA GAP *** + * + * (e) is the finding that changes how this order should be read. The + * 2026-07-19 betting-logic audit deliberately refuses rare-event 0.5 markets + * (doubles / triples / HR / SB) on the juiced under, and any over-juiced price + * — and it sets the SAME `insufficient_data: true` flag as a genuine data gap. + * Counting those as a data problem would send us hunting for data that is not + * missing, and "fixing" them would re-introduce bets we removed on purpose. + * + * (b) vs (d) is separated by asking the stats layer directly whether the player + * has ANY game log: no log at all → genuine absence; a log that exists while + * the grade path still found no projection → a wiring gap, not an absence. + */ + +const DEFAULT_SAMPLE = 60; +const DEFAULT_CONCURRENCY = 5; + +/** Bounded-concurrency map (mirrors gradeSlateService's own helper). */ +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; +} + +/** Reproduce gradeSlateService.dedupeProps — MODEL books, first row wins. */ +function uniqueGradeable(props, isModelBook) { + const seen = new Set(); + const out = []; + for (const p of props || []) { + 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); + out.push(p); + } + return out; +} + +/** + * Does this player have ANY usable stat history? This is what separates a + * FETCHABLE-GAP (b) from a GENUINE-ABSENCE (d) — and the distinction decides + * whether there is anything to fix at all. + */ +async function probeHistory(player, sport, statType, deps) { + try { + const getStatRows = deps.getStatRows + || require('./intelligence/featureCache').getStatRows; + const rows = await getStatRows(player, sport, statType); + if (!Array.isArray(rows)) return { rows: 0, withStat: 0 }; + const withStat = rows.filter((r) => r && r[statType] != null).length; + return { rows: rows.length, withStat }; + } catch { + return { rows: 0, withStat: 0, probe_failed: true }; + } +} + +/** + * The Part-1 report. All deps injectable so tests never touch the network. + */ +async function diagnose(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 analyze = opts.analyze || require('./intelligence/analyzeViaEngine1').analyzeViaEngine1; + const isModelBook = opts.isModelBook || require('../config/bookRoles').isModelBook; + + const startedAt = Date.now(); + const odds = await getOdds(sport); + const allRows = (odds && odds.props) || []; + const unique = uniqueGradeable(allRows, isModelBook); + const batch = unique.slice(0, sample); + + const latencies = []; + const results = await mapLimit(batch, concurrency, async (p) => { + const t = Date.now(); + let res = null; + let threw = null; + try { + res = await analyze({ + player: p.player, stat_type: p.stat_type, line: p.line, sport, + direction: 'over', book: p.book, + over_odds: p.over_odds ?? null, under_odds: p.under_odds ?? null, + home_team: p.home_team, away_team: p.away_team, game_time: p.game_time, + }); + } catch (err) { threw = (err && err.message) || String(err); } + latencies.push(Date.now() - t); + return { p, res, threw }; + }); + + const buckets = {}; + const suppressedReasons = {}; + const refusalSummaries = {}; + const byStat = {}; + let graded = 0; + const noProjection = []; + + const bump = (o, k) => { o[k] = (o[k] || 0) + 1; }; + + for (const { p, res, threw } of results) { + const statKey = String(p.stat_type || '?'); + byStat[statKey] = byStat[statKey] || { graded: 0, refused: 0, suppressed: 0 }; + if (threw) { bump(buckets, 'x_THREW'); byStat[statKey].refused += 1; continue; } + if (res && res.grade && !res.insufficient_data) { + graded += 1; byStat[statKey].graded += 1; continue; + } + if (res && res.suppressed) { + bump(buckets, 'e_POLICY_SUPPRESSION'); + bump(suppressedReasons, res.suppressed_reason || 'unknown'); + byStat[statKey].suppressed += 1; + continue; + } + // Everything else claims "no projection". Whether that is (b) or (d) is + // decided by probing the stats layer, not by assuming. + bump(buckets, 'no_projection_PENDING_SPLIT'); + byStat[statKey].refused += 1; + const s = (res && res.reasoning && res.reasoning.summary) || '(no summary)'; + bump(refusalSummaries, s.slice(0, 160)); + noProjection.push(p); + } + + // (b) vs (d): probe history for the no-projection refusals. + const probes = await mapLimit(noProjection.slice(0, 40), concurrency, + (p) => probeHistory(p.player, sport, p.stat_type, opts)); + let fetchableGap = 0; + let genuineAbsence = 0; + let probeFailed = 0; + const fetchableExamples = []; + probes.forEach((h, i) => { + if (!h) return; + if (h.probe_failed) { probeFailed += 1; return; } + if (h.withStat > 0) { + fetchableGap += 1; + if (fetchableExamples.length < 8) { + fetchableExamples.push({ + player: noProjection[i].player, stat: noProjection[i].stat_type, + log_rows: h.rows, rows_with_stat: h.withStat, + }); + } + } else genuineAbsence += 1; + }); + + const n = batch.length || 1; + const sorted = [...latencies].sort((a, b) => a - b); + const sum = latencies.reduce((a, b) => a + b, 0); + const pct = (v) => Math.round((1000 * v) / n) / 10; + + return { + read_only: true, + sport, + generated_at: new Date().toISOString(), + slate: { + rows_in_feed: allRows.length, + unique_gradeable_props: unique.length, + sampled: batch.length, + // The number the order is really about: the cap vs what exists. + current_cap: 25, + capped_out: Math.max(0, unique.length - 25), + }, + outcome: { + graded, graded_pct: pct(graded), + ...Object.fromEntries(Object.entries(buckets).map(([k, v]) => [k, v])), + buckets_pct: Object.fromEntries(Object.entries(buckets).map(([k, v]) => [k, pct(v)])), + }, + // (e) — deliberate, correct, NOT a data gap. + policy_suppression_reasons: suppressedReasons, + // (b) vs (d) — the only split that says whether there is anything to fix. + no_projection_split: { + probed: probes.length, + b_fetchable_gap: fetchableGap, + d_genuine_absence: genuineAbsence, + probe_failed: probeFailed, + fetchable_examples: fetchableExamples, + }, + refusal_summaries: refusalSummaries, + by_stat: byStat, + cost: { + n: latencies.length, + mean_ms: Math.round(sum / (latencies.length || 1)), + median_ms: sorted[Math.floor(sorted.length / 2)] ?? null, + p90_ms: sorted[Math.floor(0.9 * sorted.length)] ?? null, + max_ms: sorted[sorted.length - 1] ?? null, + serial_total_s: Math.round(sum / 100) / 10, + est_wall_s_at_concurrency: Math.round(sum / (concurrency * 100)) / 10, + run_wall_s: Math.round((Date.now() - startedAt) / 100) / 10, + }, + }; +} + +module.exports = { + diagnose, + __internals: { uniqueGradeable, probeHistory, mapLimit, DEFAULT_SAMPLE, DEFAULT_CONCURRENCY }, +};