Sessions 5-7a: 955 tests, deployment ready

This commit is contained in:
Kev
2026-06-08 18:35:13 -04:00
parent 06b82624a2
commit 1fa04dc776
371 changed files with 49366 additions and 955 deletions
@@ -0,0 +1,154 @@
/**
* Per-grade-tier accuracy tracking.
*
* Every resolution increments counters for (sport, grade, period).
* The "all_time" period is the canonical record; "last_30d" and
* "last_7d" are derived views recomputed by a periodic refresh job
* (n8n). We update all three counters on every resolve so callers can
* read instant values without rolling a window themselves.
*
* BASELINE LOCK:
* After a (sport, grade, 'all_time') accumulates 100 decisive
* resolutions (hits + misses, not push/void), the hit rate at that
* moment is locked as `baseline_hit_rate` and `baseline_locked`
* flips to true. Future accuracy compares to the baseline to detect
* drift.
*
* EXPECTED HIT RATES (from spec):
* A+ ≥ 65%, A ≥ 60%, A- ≥ 58%, B+ ≥ 55%, B ≥ 53%, B- ≥ 51%
* C+ ≈ 50%, C ≈ 48%, C- ≈ 45%, D ≈ 40%, F ≈ 35%
*/
const { getSupabaseServiceClient } = require('../../utils/supabase');
const BASELINE_LOCK_AT = 100;
const PERIODS = ['all_time', 'last_30d', 'last_7d'];
const EXPECTED_HIT_RATES = Object.freeze({
'A+': 0.65, 'A': 0.60, 'A-': 0.58,
'B+': 0.55, 'B': 0.53, 'B-': 0.51,
'C+': 0.50, 'C': 0.48, 'C-': 0.45,
'D': 0.40, 'F': 0.35,
});
function computeHitRate(hit, miss) {
const denom = hit + miss;
return denom > 0 ? hit / denom : null;
}
async function fetchRow(supabase, sport, grade, period) {
const { data, error } = await supabase
.from('accuracy_tracking')
.select('*')
.eq('sport', sport)
.eq('grade', grade)
.eq('period', period)
.maybeSingle();
if (error) {
console.warn('[accuracy] fetch failed:', error.message);
return null;
}
return data;
}
async function upsertRow(supabase, row) {
const { error } = await supabase
.from('accuracy_tracking')
.upsert(row, { onConflict: 'sport,grade,period' });
if (error) console.warn('[accuracy] upsert failed:', error.message);
}
async function recordResolution(sport, grade, result) {
if (!sport || !grade || !result) return;
const supabase = getSupabaseServiceClient();
for (const period of PERIODS) {
const existing = await fetchRow(supabase, sport, grade, period) || {
sport, grade, period,
total_graded: 0, total_hit: 0, total_miss: 0, total_push: 0, total_void: 0,
hit_rate: null, baseline_hit_rate: null, baseline_locked: false,
};
existing.total_graded += 1;
if (result === 'hit') existing.total_hit += 1;
else if (result === 'miss') existing.total_miss += 1;
else if (result === 'push') existing.total_push += 1;
else if (result === 'void') existing.total_void += 1;
existing.hit_rate = computeHitRate(existing.total_hit, existing.total_miss);
if (
period === 'all_time'
&& !existing.baseline_locked
&& (existing.total_hit + existing.total_miss) >= BASELINE_LOCK_AT
) {
existing.baseline_hit_rate = existing.hit_rate;
existing.baseline_locked = true;
}
existing.last_updated = new Date().toISOString();
await upsertRow(supabase, existing);
}
}
async function getAccuracy(sport, grade, period = 'all_time') {
const supabase = getSupabaseServiceClient();
const row = await fetchRow(supabase, sport, grade, period);
if (!row) {
return {
sport, grade, period,
hit_rate: null,
baseline: null,
expected: EXPECTED_HIT_RATES[grade] ?? null,
total: 0,
delta: null,
locked: false,
};
}
const total = row.total_hit + row.total_miss;
const delta = row.baseline_hit_rate != null && row.hit_rate != null
? row.hit_rate - row.baseline_hit_rate
: null;
return {
sport, grade, period,
hit_rate: row.hit_rate,
baseline: row.baseline_hit_rate,
expected: EXPECTED_HIT_RATES[grade] ?? null,
total,
delta,
locked: !!row.baseline_locked,
};
}
async function getAllAccuracy(sport, period = 'all_time') {
const grades = Object.keys(EXPECTED_HIT_RATES);
const out = [];
for (const grade of grades) out.push(await getAccuracy(sport, grade, period));
return out;
}
async function isBaselineLocked(sport, grade) {
const supabase = getSupabaseServiceClient();
const row = await fetchRow(supabase, sport, grade, 'all_time');
return !!row?.baseline_locked;
}
async function getAccuracyDashboard() {
const supabase = getSupabaseServiceClient();
const { data, error } = await supabase
.from('accuracy_tracking')
.select('*')
.eq('period', 'all_time');
if (error) {
console.warn('[accuracy] dashboard query failed:', error.message);
return [];
}
return data || [];
}
module.exports = {
recordResolution,
getAccuracy,
getAllAccuracy,
isBaselineLocked,
getAccuracyDashboard,
EXPECTED_HIT_RATES,
BASELINE_LOCK_AT,
__internals: { computeHitRate, PERIODS },
};
+151
View File
@@ -0,0 +1,151 @@
/**
* Closing Line Value (CLV) tracking.
*
* CLV measures how much edge we found vs the market close. Beating the
* close consistently is the canonical signal of real edge, regardless
* of any individual prop's outcome. This is how we prove (to ourselves
* and to users) that VYNDR's grades are doing something real.
*
* Computation:
* - For OVER: CLV = closing_line - graded_line
* We graded a line at 25.5, close was 27.5 → we saw the over was
* too cheap before the market did → +2.0 CLV.
* - For UNDER: CLV = graded_line - closing_line
* We graded under 25.5, close was 23.5 → +2.0 CLV.
*
* Closing lines come from oddspapi via the resolution poller, stored in
* closing_lines (migration 016). The match key is
* (game_id, player_espn_id OR player_name, stat_type)
* so a graded prop without a captured close returns null — not zero.
*/
const { getSupabaseServiceClient } = require('../../utils/supabase');
function rawCLV(direction, gradedLine, closingLine) {
// Guard against null/undefined first — Number(null) === 0 is finite,
// which would silently produce a 0-based CLV instead of "unknown."
if (gradedLine == null || closingLine == null) return null;
const g = Number(gradedLine);
const c = Number(closingLine);
if (!Number.isFinite(g) || !Number.isFinite(c)) return null;
return direction === 'over' ? c - g : g - c;
}
async function fetchGrade(gradeId) {
const supabase = getSupabaseServiceClient();
const { data, error } = await supabase
.from('grade_history')
.select('id, game_id, sport, player_id, player_name, stat_type, line, direction, clv')
.eq('id', gradeId)
.maybeSingle();
if (error) {
console.warn('[clv] grade lookup failed:', error.message);
return null;
}
return data;
}
async function fetchClosingLine(grade) {
const supabase = getSupabaseServiceClient();
let query = supabase
.from('closing_lines')
.select('id, pinnacle_line')
.eq('game_id', grade.game_id)
.eq('stat_type', grade.stat_type);
// Prefer ID match (canonical), fall back to name match.
query = grade.player_id
? query.eq('player_espn_id', grade.player_id)
: query.eq('player_name', grade.player_name);
const { data, error } = await query.maybeSingle();
if (error) {
console.warn('[clv] closing line lookup failed:', error.message);
return null;
}
return data;
}
async function persistCLV(gradeId, clv, closingLineId) {
const supabase = getSupabaseServiceClient();
const { error } = await supabase
.from('grade_history')
.update({ clv, closing_line_id: closingLineId || null })
.eq('id', gradeId);
if (error) console.warn('[clv] persist failed:', error.message);
}
async function computeCLV(gradeId) {
const grade = await fetchGrade(gradeId);
if (!grade) return null;
const closing = await fetchClosingLine(grade);
if (!closing) {
return {
gradeId,
clv: null,
graded_line: Number(grade.line),
closing_line: null,
direction: grade.direction,
sport: grade.sport,
reason: 'no_closing_line',
};
}
const clv = rawCLV(grade.direction, grade.line, closing.pinnacle_line);
if (clv != null) await persistCLV(gradeId, clv, closing.id);
return {
gradeId,
clv,
graded_line: Number(grade.line),
closing_line: Number(closing.pinnacle_line),
direction: grade.direction,
sport: grade.sport,
};
}
async function batchComputeCLV(gradeIds) {
const out = [];
for (const id of gradeIds) {
try { out.push(await computeCLV(id)); }
catch (err) {
console.warn('[clv] batch entry failed:', id, err.message);
out.push({ gradeId: id, clv: null, error: err.message });
}
}
return out;
}
async function getCLVSummary(sport, period = 'all_time') {
const supabase = getSupabaseServiceClient();
let query = supabase
.from('grade_history')
.select('clv')
.eq('sport', sport)
.not('clv', 'is', null);
if (period === 'last_30d') {
const since = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString();
query = query.gte('graded_at', since);
} else if (period === 'last_7d') {
const since = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString();
query = query.gte('graded_at', since);
}
const { data, error } = await query;
if (error) {
console.warn('[clv] summary query failed:', error.message);
return { avg_clv: null, median_clv: null, positive_rate: null, total: 0 };
}
if (!data || data.length === 0) {
return { avg_clv: null, median_clv: null, positive_rate: null, total: 0 };
}
const values = data.map((r) => Number(r.clv)).filter((v) => Number.isFinite(v));
const avg = values.reduce((a, b) => a + b, 0) / values.length;
const sorted = [...values].sort((a, b) => a - b);
const mid = Math.floor(sorted.length / 2);
const median = sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
const positive = values.filter((v) => v > 0).length;
return {
avg_clv: avg,
median_clv: median,
positive_rate: positive / values.length,
total: values.length,
};
}
module.exports = { computeCLV, batchComputeCLV, getCLVSummary, rawCLV };
+91
View File
@@ -0,0 +1,91 @@
/**
* Coach system + pace signal.
*
* Two signals exposed:
* coach_pace_delta: coach's career pace MINUS current team's pace,
* scaled by tenure (longer tenure = stronger
* adjustment).
* coach_player_interaction: magnitude of system shift when the primary
* player is OUT vs IN. Drives suppression for
* role players when the star sits.
*
* Profiles live in `coach_profiles` (migration 017). On first read for a
* team we check the table; if empty, fall back to the seed file at
* src/config/coaches.json so launch isn't blocked on a fully populated
* table.
*/
const path = require('path');
const fs = require('fs');
const { getSupabaseServiceClient } = require('../../utils/supabase');
let seedCache = null;
function loadSeed() {
if (seedCache !== null) return seedCache;
try {
const raw = JSON.parse(fs.readFileSync(path.join(__dirname, '..', '..', 'config', 'coaches.json'), 'utf8'));
seedCache = { coaches: raw.coaches || [] };
} catch {
seedCache = { coaches: [] };
}
return seedCache;
}
function tenureAdjustment(games) {
// Linear ramp to 1.0 over ~40 games — a coach inheriting a roster needs
// time before the system actually drifts toward their preference.
const g = Number(games) || 0;
return Math.min(1.0, Math.max(0, g / 40));
}
async function getCoachProfile(sport, teamAbbr) {
const supabase = getSupabaseServiceClient();
const { data, error } = await supabase
.from('coach_profiles')
.select('coach_name, team, sport, career_avg_pace, current_team_pace, tenure_games, primary_player, system_style, without_primary_style, without_primary_pace_delta')
.eq('team', teamAbbr)
.eq('sport', sport)
.maybeSingle();
if (error) {
console.warn('[coachSignals] profile lookup failed:', error.message);
}
if (data) return data;
// Fall back to the seed file — same shape, different home.
const seed = loadSeed();
return seed.coaches.find((c) => c.team === teamAbbr && c.sport === sport) || null;
}
async function getCoachImpact(sport, teamAbbr, gameContext = {}) {
const profile = await getCoachProfile(sport, teamAbbr);
if (!profile) return null;
const career = Number(profile.career_avg_pace);
const team = Number(profile.current_team_pace);
const paceDelta = Number.isFinite(career) && Number.isFinite(team) ? career - team : null;
const tenureAdj = tenureAdjustment(profile.tenure_games);
const adjustedPaceDelta = paceDelta != null ? paceDelta * tenureAdj : null;
// Primary-player status comes from the caller — usually injuryParser told
// them whether the star is OUT/DOUBTFUL.
const primaryStatus = gameContext.primary_player_status ?? 'unknown';
const systemOverride = primaryStatus === 'out' || primaryStatus === 'doubtful'
? profile.without_primary_style
: null;
const withoutPrimaryShift = primaryStatus === 'out'
? Number(profile.without_primary_pace_delta) || 0
: 0;
return {
coach_name: profile.coach_name,
system_style: profile.system_style ?? null,
primary_player: profile.primary_player ?? null,
pace_delta: paceDelta,
tenure_adjustment: tenureAdj,
adjusted_pace_delta: adjustedPaceDelta,
primary_player_status: primaryStatus,
system_override: systemOverride,
without_primary_pace_shift: withoutPrimaryShift,
};
}
module.exports = { getCoachImpact, getCoachProfile, tenureAdjustment, loadSeed };
@@ -0,0 +1,66 @@
/**
* Consistency score — how predictable is this player for this stat?
*
* cv = stddev / mean
*
* Coefficient of variation collapses sample-size differences and lets us
* compare a 25-point scorer with low variance to a 12-point scorer with
* the same absolute variance. Lower cv = more reliable.
*
* The consistency score modifies Engine 2's confidence. An "elite"
* consistency player gets a tighter projection range; a "boom_bust"
* player gets a wider one.
*/
const gameLogService = require('./gameLogService');
function statFromGameLog(row, statType) {
if (!row) return null;
switch (statType) {
case 'pts_reb_ast':
return (Number(row.points) || 0) + (Number(row.rebounds) || 0) + (Number(row.assists) || 0);
case 'pts_reb':
return (Number(row.points) || 0) + (Number(row.rebounds) || 0);
case 'pts_ast':
return (Number(row.points) || 0) + (Number(row.assists) || 0);
case 'reb_ast':
return (Number(row.rebounds) || 0) + (Number(row.assists) || 0);
case 'stl_blk':
return (Number(row.steals) || 0) + (Number(row.blocks) || 0);
default: {
const v = Number(row[statType]);
return Number.isFinite(v) ? v : null;
}
}
}
function classify(cv) {
if (cv < 0.15) return { consistency: 'elite', score: 1.0 };
if (cv < 0.30) return { consistency: 'reliable', score: 0.7 };
if (cv < 0.50) return { consistency: 'volatile', score: 0.4 };
return { consistency: 'boom_bust', score: 0.1 };
}
function statsFor(values) {
const clean = values.filter((v) => Number.isFinite(v));
if (clean.length < 2) return null;
const mean = clean.reduce((a, b) => a + b, 0) / clean.length;
if (mean === 0) return null;
const variance = clean.reduce((s, v) => s + (v - mean) ** 2, 0) / (clean.length - 1);
const stddev = Math.sqrt(variance);
return { mean, stddev, cv: stddev / Math.abs(mean), games: clean.length };
}
async function getConsistency(input = {}) {
const { playerName, sport, statType, gameLogs: providedLogs } = input;
const logs = providedLogs || await gameLogService.getGameLogs(playerName, sport, 20);
if (!logs || logs.length < 2) {
return { consistency: 'unknown', score: null, games: logs?.length ?? 0 };
}
const values = logs.map((row) => statFromGameLog(row, statType)).filter((v) => v != null);
const s = statsFor(values);
if (!s) return { consistency: 'unknown', score: null, games: values.length };
return { ...s, ...classify(s.cv) };
}
module.exports = { getConsistency, classify, statsFor, statFromGameLog };
+183
View File
@@ -0,0 +1,183 @@
/**
* Engine 1 — rule-based grading on the v6b feature vector.
*
* Engine 1 is deterministic. Same inputs always produce the same grade.
* That predictability is intentional: when Engine 2 (LLM, non-deterministic)
* disagrees with Engine 1, the disagreement itself is a signal we surface
* to users — and a stable reference point makes that signal meaningful.
*
* Grade scale (11 steps): F, D, C-, C, C+, B-, B, B+, A-, A, A+
* Start at C (neutral); positive signals push UP, negative push DOWN.
*
* Factors carry the top 3 contributors out so Engine 2 sees them in its
* prompt and the UI can render a "why this grade" tooltip.
*/
const GRADE_SCALE = ['F', 'D', 'C-', 'C', 'C+', 'B-', 'B', 'B+', 'A-', 'A', 'A+'];
const NEUTRAL_INDEX = 3; // 'C'
const GRADE_TO_CONFIDENCE = {
'A+': 1.00,
'A': 0.90,
'A-': 0.80,
'B+': 0.65,
'B': 0.55,
'B-': 0.45,
'C+': 0.35,
'C': 0.25,
'C-': 0.20,
'D': 0.15,
'F': 0.10,
};
function clampIndex(idx) {
return Math.max(0, Math.min(GRADE_SCALE.length - 1, idx));
}
function indexToGrade(idx) {
return GRADE_SCALE[clampIndex(Math.round(idx))];
}
// Each factor produces a delta (positive or negative) plus a label that
// lands in the top-N list. We track magnitude for sorting so the UI can
// surface "this matters most" honestly.
function computeFactors(input) {
const { features = {}, trap = {}, consistency = {}, prop } = input;
const factors = [];
const line = Number(prop?.line);
const direction = prop?.direction;
const overWeighted = direction === 'over';
// Recent form vs the line.
if (Number.isFinite(features.l5_avg) && Number.isFinite(line) && line > 0) {
const delta = (features.l5_avg - line) / line; // fractional gap
if (overWeighted) {
if (delta >= 0.15) factors.push({ label: 'l5_hot_vs_line', delta: 1.0, magnitude: Math.abs(delta) });
else if (delta <= -0.15) factors.push({ label: 'l5_cold_vs_line', delta: -1.0, magnitude: Math.abs(delta) });
} else {
// For UNDER props the signs flip.
if (delta <= -0.15) factors.push({ label: 'l5_under_friendly', delta: 1.0, magnitude: Math.abs(delta) });
else if (delta >= 0.15) factors.push({ label: 'l5_hot_vs_under', delta: -1.0, magnitude: Math.abs(delta) });
}
}
// Trend confirmation from L20.
if (Number.isFinite(features.l20_avg) && Number.isFinite(line) && line > 0) {
const delta20 = (features.l20_avg - line) / line;
if (overWeighted && delta20 > 0) factors.push({ label: 'l20_over_line', delta: 1.0, magnitude: Math.abs(delta20) });
else if (!overWeighted && delta20 < 0) factors.push({ label: 'l20_under_line', delta: 1.0, magnitude: Math.abs(delta20) });
}
// Consistency.
const cLabel = consistency.consistency;
if (cLabel === 'elite' || cLabel === 'reliable') {
factors.push({ label: `consistency_${cLabel}`, delta: 1.0, magnitude: consistency.score ?? 0.7 });
} else if (cLabel === 'boom_bust') {
factors.push({ label: 'consistency_boom_bust', delta: -1.0, magnitude: 0.9 });
}
// Opponent rank (0..1 scale where 1.0 = worst defense, easiest matchup).
if (Number.isFinite(features.opp_rank_stat)) {
if (features.opp_rank_stat >= 0.70) {
const adj = overWeighted ? 1.0 : -1.0;
factors.push({ label: 'weak_opponent_defense', delta: adj, magnitude: features.opp_rank_stat });
} else if (features.opp_rank_stat <= 0.30) {
const adj = overWeighted ? -1.0 : 1.0;
factors.push({ label: 'top_opponent_defense', delta: adj, magnitude: 1 - features.opp_rank_stat });
}
}
// Home / away.
if (features.home_away === 1.0) {
factors.push({ label: 'home_game', delta: 0.5, magnitude: 0.5 });
} else if (features.home_away === 0.0 && features.opp_rank_stat != null && features.opp_rank_stat <= 0.15) {
factors.push({ label: 'away_vs_top5_defense', delta: -0.5, magnitude: 0.7 });
}
// Rest / fatigue.
if (features.rest_days >= 2) factors.push({ label: 'rested_2plus', delta: 0.5, magnitude: 0.5 });
if (features.rest_days === 0) factors.push({ label: 'back_to_back', delta: -0.5, magnitude: 0.7 });
if ((features.game_count_in_7d ?? 0) >= 4) factors.push({ label: 'heavy_workload_7d', delta: -0.5, magnitude: 0.6 });
// Coach pace.
if (Number.isFinite(features.coach_pace_delta) && Math.abs(features.coach_pace_delta) > 0.5) {
const sign = overWeighted ? Math.sign(features.coach_pace_delta) : -Math.sign(features.coach_pace_delta);
factors.push({ label: 'coach_pace_delta', delta: 0.5 * sign, magnitude: Math.abs(features.coach_pace_delta) / 5 });
}
// Ref pace.
if (Number.isFinite(features.ref_pace_adjustment) && Math.abs(features.ref_pace_adjustment) > 0.1) {
const sign = overWeighted ? Math.sign(features.ref_pace_adjustment) : -Math.sign(features.ref_pace_adjustment);
factors.push({ label: 'ref_pace_adjustment', delta: 0.5 * sign, magnitude: Math.abs(features.ref_pace_adjustment) });
}
// Ref foul tendency — a high-foul crew puts FT-heavy scorers at the line
// more often. We treat the magnitude as a binary boost for scoring props.
if (Number.isFinite(features.ref_foul_adjustment)) {
if (features.ref_foul_adjustment > 0.5) {
factors.push({ label: 'ref_foul_high', delta: overWeighted ? 0.5 : -0.5, magnitude: features.ref_foul_adjustment });
} else if (features.ref_foul_adjustment < -0.5) {
factors.push({ label: 'ref_foul_low', delta: overWeighted ? -0.5 : 0.5, magnitude: Math.abs(features.ref_foul_adjustment) });
}
}
// Opponent injury severity — 2-3+ starters out means a thinner rotation
// and easier matchup. Always lifts an OVER, never matters for UNDER.
if (Number.isFinite(features.injury_severity_score) && overWeighted) {
if (features.injury_severity_score >= 3) {
factors.push({ label: 'opp_3plus_starters_out', delta: 1.0, magnitude: 1.0 });
} else if (features.injury_severity_score >= 2) {
factors.push({ label: 'opp_2_starters_out', delta: 0.5, magnitude: 0.7 });
}
}
// Playoff experience — rookies in playoffs are volatile (downgrade);
// veterans handle the spotlight better (upgrade). Only meaningful in
// playoff games (season_type >= 2 in our config).
if (Number.isFinite(features.career_playoff_games) && features.season_type >= 2) {
if (features.career_playoff_games === 0) {
factors.push({ label: 'rookie_in_playoffs', delta: -0.5, magnitude: 0.8 });
} else if (features.career_playoff_games > 30) {
factors.push({ label: 'veteran_in_playoffs', delta: 0.5, magnitude: 0.6 });
}
}
// Trap composite — the big lever.
if (Number.isFinite(trap.composite) && trap.composite > 0.5) {
factors.push({ label: 'trap_composite_high', delta: -1.0, magnitude: trap.composite });
}
return factors;
}
function gradeFromFactors(factors) {
let idx = NEUTRAL_INDEX;
for (const f of factors) idx += f.delta;
idx = clampIndex(Math.round(idx));
return { grade: GRADE_SCALE[idx], confidence: GRADE_TO_CONFIDENCE[GRADE_SCALE[idx]] ?? 0.25 };
}
function topFactorLabels(factors, n = 3) {
return [...factors]
.sort((a, b) => Math.abs(b.delta * b.magnitude) - Math.abs(a.delta * a.magnitude))
.slice(0, n)
.map((f) => f.label);
}
function gradeProp(input) {
const factors = computeFactors(input);
const { grade, confidence } = gradeFromFactors(factors);
return {
grade,
confidence,
top_factors: topFactorLabels(factors, 3),
all_factors: factors.map((f) => f.label),
};
}
module.exports = {
gradeProp,
GRADE_SCALE,
GRADE_TO_CONFIDENCE,
__internals: { computeFactors, gradeFromFactors, topFactorLabels, indexToGrade, NEUTRAL_INDEX },
};
+267
View File
@@ -0,0 +1,267 @@
/**
* Engine 2 — LLM analysis layer on top of Engine 1 grades.
*
* Engine 2 doesn't REPLACE Engine 1. It runs after Engine 1 produces a
* grade for an A/B-tier prop, applies natural-language reasoning over the
* full feature vector + trap signals, and either agrees or disagrees.
* Disagreement is itself a signal — surface it in the UI so users can
* see when our two systems diverge.
*
* Architecture choices:
* - Async + non-blocking. Engine 1 returns immediately; Engine 2 fills
* in 5-30 seconds later via the queue.
* - Queue is in-memory (Map keyed by gradeId). On process restart we
* lose the queue, which is acceptable — n8n can re-queue from
* grade_history WHERE engine2_analyzed_at IS NULL.
* - Only A/B-tier props qualify. C/D/F grades skip Engine 2 entirely;
* they're already flagged as low-confidence and don't need narrative.
* - Prompt is GENERIC — no 'VYNDR' brand string. The model has no idea
* who we are. That keeps our system prompt out of any provider's
* training/QA pipeline.
*/
const openRouter = require('../adapters/openRouterAdapter');
const { getSupabaseServiceClient } = require('../../utils/supabase');
const BATCH_SIZE = Number(process.env.ENGINE2_BATCH_SIZE) || 10;
const ENABLED = String(process.env.ENGINE2_ENABLED || 'true').toLowerCase() !== 'false';
// Grades that qualify for Engine 2 analysis. C/D/F skip.
const ELIGIBLE_GRADES = new Set(['A+', 'A', 'A-', 'B+', 'B', 'B-']);
const VALID_GRADES = new Set([
'A+', 'A', 'A-', 'B+', 'B', 'B-', 'C+', 'C', 'C-', 'D', 'F', null,
]);
// In-process FIFO queue. Map preserves insertion order — values carry the
// context needed to build the prompt without re-querying upstream.
const queue = new Map();
const SYSTEM_MESSAGE = (
"You are a sports analytics engine analyzing player prop bets. "
+ "Respond ONLY with valid JSON. No preamble, no markdown, no explanation "
+ "outside the JSON structure. If you cannot analyze this prop, respond "
+ 'with { "grade": null, "reason": "insufficient data" }.'
);
function buildPrompt(ctx) {
const features = ctx.features || {};
const trapSignals = ctx.trap?.signals || {};
const recent = ctx.recentGames || [];
const featureLines = Object.entries(features)
.map(([k, v]) => {
if (typeof v === 'number') {
return `${k}: ${Number.isInteger(v) ? v : v.toFixed(2)}`;
}
return `${k}: ${v}`;
})
.join('\n');
const activeTraps = Object.entries(trapSignals)
.filter(([, s]) => s?.active && s?.score > 0)
.map(([name, s]) => `- ${name}: ${s.score.toFixed(2)} (${s.explanation})`)
.join('\n') || 'none';
const recentLines = recent
.map((g) => ` ${g.date}: ${g.value} vs ${g.opponent}${g.home ? ' (home)' : ''}`)
.join('\n') || ' (no recent games)';
return [
`PLAYER: ${ctx.player_name} (${ctx.team || 'unknown'})`,
`SPORT: ${ctx.sport}`,
`PROP: ${ctx.direction} ${ctx.line} ${ctx.stat_type}`,
`GAME: ${ctx.away_team || '?'} @ ${ctx.home_team || '?'}, ${ctx.game_date || '?'}`,
'',
'FEATURES:',
featureLines || ' (no features computed)',
'',
`ENGINE 1 GRADE: ${ctx.engine1_grade} (${(ctx.engine1_factors || []).slice(0, 3).join(', ') || 'no factors'})`,
'',
'TRAP SIGNALS:',
activeTraps,
`Trap composite: ${(ctx.trap?.composite ?? 0).toFixed(2)} (${ctx.trap?.recommendation || 'unknown'})`,
'',
`CONSISTENCY: ${ctx.consistency?.consistency || 'unknown'} (cv=${(ctx.consistency?.cv ?? 0).toFixed(2)}, score=${(ctx.consistency?.score ?? 0).toFixed(2)})`,
'',
...(ctx.probability && Number.isFinite(ctx.probability.p_over) ? [
`PROBABILITY: P(Over) = ${ctx.probability.p_over.toFixed(2)} | P(Under) = ${(1 - ctx.probability.p_over).toFixed(2)}`,
`Components: ${
Object.entries(ctx.probability.components || {})
.filter(([, v]) => Number.isFinite(Number(v)))
.map(([k, v]) => `${k}=${Number(v).toFixed(2)}`)
.join(', ') || 'none'
}`,
'',
] : []),
'RECENT PERFORMANCE:',
recentLines,
'',
'Analyze this prop and respond with:',
'{',
' "grade": "A+/A/A-/B+/B/B-/C+/C/C-/D/F",',
' "confidence": 0.0-1.0,',
' "agrees_with_engine1": true/false,',
' "narrative": "2-3 sentence analysis",',
' "trap_concern": "specific trap risk if any, or null",',
' "key_factor": "single most important factor"',
'}',
].join('\n');
}
// Four-strategy parser. The model is supposed to return raw JSON, but
// "supposed to" is doing a lot of work — we layer fallbacks so a chatty
// model doesn't make us drop the whole analysis. Strategy 4 (regex field
// extraction) is the last-ditch — at least we capture the grade.
function parseResponse(raw) {
if (!raw || typeof raw !== 'string') return null;
// 1. Direct parse.
try {
const j = JSON.parse(raw.trim());
if (j && typeof j === 'object') return j;
} catch { /* fall through */ }
// 2. Markdown fenced block.
const fence = raw.match(/```(?:json)?\s*([\s\S]*?)```/i);
if (fence?.[1]) {
try {
const j = JSON.parse(fence[1].trim());
if (j && typeof j === 'object') return j;
} catch { /* fall through */ }
}
// 3. First {...} block.
const obj = raw.match(/\{[\s\S]*\}/);
if (obj) {
try {
const j = JSON.parse(obj[0]);
if (j && typeof j === 'object') return j;
} catch { /* fall through */ }
}
// 4. Field-level regex extraction — last resort. We at least want the
// grade letter; the narrative becomes a flag string so the row is
// distinguishable from a model that returned valid JSON.
const gradeMatch = raw.match(/["']?grade["']?\s*[:=]\s*["']?([A-F][+-]?)/i);
if (gradeMatch) {
const confMatch = raw.match(/["']?confidence["']?\s*[:=]\s*([\d.]+)/i);
const conf = confMatch ? parseFloat(confMatch[1]) : NaN;
return {
grade: gradeMatch[1].toUpperCase(),
confidence: Number.isFinite(conf) && conf >= 0 && conf <= 1 ? conf : 0.5,
narrative: 'Extracted from malformed response',
agrees_with_engine1: null,
key_factor: null,
trap_concern: null,
};
}
return null;
}
function validateAnalysis(parsed) {
if (!parsed) return null;
// Allow the explicit "I can't" response.
if (parsed.grade === null) return { grade: null, reason: parsed.reason || 'insufficient data' };
if (!VALID_GRADES.has(parsed.grade)) return null;
const confidence = Number(parsed.confidence);
if (!Number.isFinite(confidence) || confidence < 0 || confidence > 1) return null;
const narrative = typeof parsed.narrative === 'string' ? parsed.narrative.slice(0, 500) : null;
if (!narrative || narrative.length === 0) return null;
return {
grade: parsed.grade,
confidence,
narrative,
agrees_with_engine1: !!parsed.agrees_with_engine1,
trap_concern: typeof parsed.trap_concern === 'string' ? parsed.trap_concern.slice(0, 300) : null,
key_factor: typeof parsed.key_factor === 'string' ? parsed.key_factor.slice(0, 200) : null,
};
}
function queueAnalysis(gradeId, propContext) {
if (!ENABLED) return;
if (!gradeId || !propContext) return;
if (!ELIGIBLE_GRADES.has(propContext.engine1_grade)) return;
// De-dupe by gradeId — re-queuing on retry is fine; we just overwrite.
queue.set(gradeId, propContext);
}
function getQueueSize() {
return queue.size;
}
function clearQueue() {
queue.clear();
}
async function persistResult(gradeId, analysis, modelUsed, latencyMs) {
const supabase = getSupabaseServiceClient();
const patch = {
engine2_grade: analysis.grade,
engine2_confidence: analysis.confidence,
engine2_narrative: analysis.narrative,
engine2_agrees: analysis.agrees_with_engine1,
engine2_key_factor: analysis.key_factor,
engine2_trap_concern: analysis.trap_concern,
engine2_model: modelUsed,
engine2_latency_ms: latencyMs,
engine2_analyzed_at: new Date().toISOString(),
};
const { error } = await supabase.from('grade_history').update(patch).eq('id', gradeId);
if (error) {
console.warn('[engine2] persist failed for', gradeId, error.message);
}
}
async function analyzeOne(gradeId, propContext) {
const userPrompt = buildPrompt(propContext);
const result = await openRouter.analyze(SYSTEM_MESSAGE, userPrompt);
if (!result) return { ok: false, reason: 'openrouter unavailable' };
const parsed = parseResponse(result.response);
const analysis = validateAnalysis(parsed);
if (!analysis) return { ok: false, reason: 'parse/validate failed' };
if (analysis.grade === null) return { ok: false, reason: analysis.reason };
await persistResult(gradeId, analysis, result.modelUsed, result.latencyMs);
return { ok: true, analysis, modelUsed: result.modelUsed, latencyMs: result.latencyMs };
}
async function processQueue() {
if (!ENABLED) return { processed: 0, succeeded: 0, failed: 0 };
let processed = 0;
let succeeded = 0;
let failed = 0;
for (const [gradeId, ctx] of queue.entries()) {
if (processed >= BATCH_SIZE) break;
queue.delete(gradeId);
processed += 1;
try {
const res = await analyzeOne(gradeId, ctx);
if (res.ok) succeeded += 1; else failed += 1;
} catch (err) {
console.warn('[engine2] analyze threw for', gradeId, err.message);
failed += 1;
}
}
return { processed, succeeded, failed, remaining: queue.size };
}
module.exports = {
queueAnalysis,
processQueue,
getQueueSize,
clearQueue,
__internals: {
buildPrompt,
parseResponse,
validateAnalysis,
analyzeOne,
persistResult,
queue,
SYSTEM_MESSAGE,
ELIGIBLE_GRADES,
VALID_GRADES,
BATCH_SIZE,
},
};
+268
View File
@@ -0,0 +1,268 @@
/**
* Feature cache — the central feature-vector builder for every prop.
*
* Philosophy: features are OMITTED when the underlying data source is
* unavailable, never zeroed. Engine 2 handles variable-length feature
* sets; a zero would lie to the model about what we actually know.
*
* Per-feature TTL categories (Redis):
* game_log: 4h — game logs refresh once per night
* team: 24h — opponent stats are daily
* coach: 30d — coach profiles are rare to change
* ref: 12h — assignments published morning of game day
* injury: 2h — injuries change at shootaround
* line: 2m — line state changes constantly during the day
* context: none — computed on demand (home/away, rest days)
*
* Cache key: features:{sport}:{playerId}:{statType}:{gameId}
* The full vector is cached for 2 minutes so repeated calls during the
* same grading cycle don't recompute. After 2 minutes, individual
* features get refreshed from their own caches.
*/
const { cacheGet, cacheSet } = require('../../utils/redis');
const { getTeamStats, getOpponentRank } = require('./teamStatsCache');
const { getRefImpact } = require('./refSignals');
const { getCoachImpact } = require('./coachSignals');
const { roleValue } = require('./lineupSignals');
const { getTeamInjuries } = require('./injuryParser');
const { getLineMovement } = require('./lineMovement');
const gameLogs = require('./gameLogService');
const VECTOR_TTL_SECONDS = 120;
function avg(values) {
const clean = values.filter((v) => Number.isFinite(v));
if (clean.length === 0) return null;
return clean.reduce((a, b) => a + b, 0) / clean.length;
}
function stddev(values) {
const clean = values.filter((v) => Number.isFinite(v));
if (clean.length < 2) return null;
const mean = avg(clean);
const sq = clean.reduce((sum, v) => sum + (v - mean) ** 2, 0);
return Math.sqrt(sq / (clean.length - 1));
}
// Extract a stat value from a single game-log entry by stat type. Game-log
// rows out of the Python service are keyed by stat name (points,
// rebounds, etc.) and combo stats need to be summed at read time.
function statFromGameLog(row, statType) {
if (!row) return null;
switch (statType) {
case 'pts_reb_ast': {
const s = (Number(row.points) || 0) + (Number(row.rebounds) || 0) + (Number(row.assists) || 0);
return s;
}
case 'pts_reb':
return (Number(row.points) || 0) + (Number(row.rebounds) || 0);
case 'pts_ast':
return (Number(row.points) || 0) + (Number(row.assists) || 0);
case 'reb_ast':
return (Number(row.rebounds) || 0) + (Number(row.assists) || 0);
case 'stl_blk':
return (Number(row.steals) || 0) + (Number(row.blocks) || 0);
default: {
const v = Number(row[statType]);
return Number.isFinite(v) ? v : null;
}
}
}
function daysBetween(aIso, bIso) {
const ms = new Date(aIso).getTime() - new Date(bIso).getTime();
if (!Number.isFinite(ms)) return null;
return Math.floor(ms / (1000 * 60 * 60 * 24));
}
async function gameLogFeatures(playerName, sport, statType) {
const logs = await gameLogs.getGameLogs(playerName, sport, 20);
if (!logs || logs.length === 0) return {};
const valuesAll = logs.map((row) => statFromGameLog(row, statType)).filter((v) => v != null);
const l5 = valuesAll.slice(0, 5);
const l20 = valuesAll;
const l10 = valuesAll.slice(0, 10);
const out = {};
const m5 = avg(l5);
const m20 = avg(l20);
const s10 = stddev(l10);
if (m5 != null) out.l5_avg = m5;
if (m20 != null) out.l20_avg = m20;
if (s10 != null) out.l10_stddev = s10;
// Career playoff games is a separate endpoint.
const cp = await gameLogs.getCareerPlayoffGames(playerName, sport);
if (Number.isFinite(cp)) out.career_playoff_games = cp;
return out;
}
async function teamFeatures(sport, opponentAbbr, statType) {
const out = {};
if (!opponentAbbr) return out;
const oppStats = await getTeamStats(sport, opponentAbbr);
if (oppStats) {
if (Number.isFinite(oppStats.pace)) out.pace_factor = oppStats.pace;
if (Number.isFinite(oppStats.pace)) out.team_pace = oppStats.pace;
}
const rank = await getOpponentRank(sport, opponentAbbr, statType);
if (rank != null) out.opp_rank_stat = rank;
return out;
}
function contextFeatures(gameContext = {}) {
const out = {};
if (gameContext.home_away === 'home') out.home_away = 1.0;
else if (gameContext.home_away === 'away') out.home_away = 0.0;
if (Number.isFinite(gameContext.rest_days)) out.rest_days = gameContext.rest_days;
if (Number.isFinite(gameContext.game_count_in_7d)) out.game_count_in_7d = gameContext.game_count_in_7d;
if (gameContext.season_type != null) out.season_type = gameContext.season_type;
if (Number.isFinite(gameContext.game_in_series)) out.game_in_series = gameContext.game_in_series;
if (Number.isFinite(gameContext.season_phase)) out.season_phase = gameContext.season_phase;
return out;
}
async function injuryFeatures(sport, teamId, knownStarterIds = []) {
const out = {};
if (!teamId) return out;
const list = await getTeamInjuries(sport, teamId);
if (!list || list.length === 0) {
out.injury_severity_score = 0;
return out;
}
const starterSet = new Set(knownStarterIds.map(String));
const missingStarters = list.filter(
(i) => starterSet.has(i.playerId) && (i.status === 'OUT' || i.status === 'DOUBTFUL')
);
out.injury_severity_score = Math.min(5, missingStarters.length);
// Teammate-absence bump: a league-average constant when we don't have
// with/without splits for this player. Engine 2 can replace this with
// a learned value over time.
if (missingStarters.length > 0) out.teammate_absence_bump = 0.05 * missingStarters.length;
return out;
}
async function lineFeatures(gameId, playerName, statType) {
const lm = await getLineMovement(gameId, playerName, statType);
if (!lm) return {};
return { line_delta: lm.movement };
}
async function refFeatures(gameId) {
const impact = await getRefImpact(gameId);
if (!impact) return {};
const out = {};
if (Number.isFinite(impact.pace_impact)) out.ref_pace_adjustment = impact.pace_impact;
if (Number.isFinite(impact.foul_adjustment)) out.ref_foul_adjustment = impact.foul_adjustment;
return out;
}
async function coachFeatures(sport, teamAbbr, gameContext = {}) {
const impact = await getCoachImpact(sport, teamAbbr, gameContext);
if (!impact) return {};
const out = {};
if (Number.isFinite(impact.adjusted_pace_delta)) out.coach_pace_delta = impact.adjusted_pace_delta;
if (Number.isFinite(impact.without_primary_pace_shift)) {
out.coach_player_interaction = impact.without_primary_pace_shift;
}
return out;
}
function lineupFeatures(role) {
if (!role) return {};
return { lineup_ball_handler_role: roleValue(role) };
}
// Top-level: build the full vector. Each sub-call is independent so a
// failure in one (e.g. ref assignments not yet published) just omits its
// feature and the rest of the vector is still useful.
async function getFeatures(input = {}) {
const {
playerId,
playerName,
statType,
sport,
teamAbbr,
opponentAbbr,
teamId,
opponentTeamId,
gameId,
gameContext,
role,
knownStarterIds = [],
} = input;
const cacheKey = `features:${sport}:${playerId}:${statType}:${gameId}`;
const cached = await cacheGet(cacheKey);
if (cached) return cached;
const [gl, team, ctx, injury, line, ref, coach, lineup] = await Promise.all([
gameLogFeatures(playerName, sport, statType),
teamFeatures(sport, opponentAbbr, statType),
Promise.resolve(contextFeatures(gameContext)),
injuryFeatures(sport, teamId, knownStarterIds),
lineFeatures(gameId, playerName, statType),
refFeatures(gameId),
coachFeatures(sport, teamAbbr, gameContext),
Promise.resolve(lineupFeatures(role)),
]);
const features = { ...gl, ...team, ...ctx, ...injury, ...line, ...ref, ...coach, ...lineup };
const FEATURE_NAMES = [
'l5_avg', 'l20_avg', 'l10_stddev', 'career_playoff_games',
'opp_rank_stat', 'pace_factor', 'team_pace',
'home_away', 'rest_days', 'game_count_in_7d', 'season_type', 'game_in_series', 'season_phase',
'teammate_absence_bump', 'primary_stat_suppression', 'injury_severity_score',
'line_delta',
'ref_pace_adjustment', 'ref_foul_adjustment',
'coach_pace_delta', 'coach_player_interaction',
'lineup_ball_handler_role',
];
const available = FEATURE_NAMES.filter((n) => features[n] != null);
const missing = FEATURE_NAMES.filter((n) => features[n] == null);
const payload = {
features,
meta: {
computed_at: new Date().toISOString(),
features_available: available,
features_missing: missing,
},
};
await cacheSet(cacheKey, payload, VECTOR_TTL_SECONDS);
return payload;
}
async function clearCache(cacheKey) {
// Hook for tests + manual invalidation.
const { cacheDel } = require('../../utils/redis');
return cacheDel(cacheKey);
}
function getCacheStats() {
return { ttlSeconds: VECTOR_TTL_SECONDS };
}
module.exports = {
getFeatures,
clearCache,
getCacheStats,
// Internal helpers exported for unit tests + Engine 2 reuse.
__internals: {
gameLogFeatures,
teamFeatures,
contextFeatures,
injuryFeatures,
lineFeatures,
refFeatures,
coachFeatures,
lineupFeatures,
statFromGameLog,
avg,
stddev,
daysBetween,
},
};
@@ -0,0 +1,87 @@
/**
* Game-log service — fetches recent player game logs.
*
* Primary path: the Python FastAPI service at PYTHON_SERVICE_URL (default
* http://localhost:8000). Its /stats/last-n and /wnba/stats/last-n
* endpoints return per-game stat rows.
*
* Secondary path: not implemented in this session. If the Python service
* is unreachable, we return null and let the feature cache omit the
* features that depend on game logs. A flaky stats backend should NOT
* generate fake feature values.
*/
const axios = require('axios');
const { cacheGet, cacheSet } = require('../../utils/redis');
const PYTHON_BASE = process.env.PYTHON_SERVICE_URL || 'http://localhost:8000';
const CACHE_TTL_SECONDS = 4 * 60 * 60; // 4h — game logs change once per night
const HTTP_TIMEOUT_MS = 15_000;
function pythonPath(sport) {
switch (sport) {
case 'nba': return '/stats/last-n';
case 'wnba': return '/wnba/stats/last-n';
default: return null;
}
}
async function getGameLogs(playerName, sport, count = 20) {
const path = pythonPath(sport);
if (!path) return null;
const cacheKey = `gamelogs:${sport}:${playerName}:${count}`;
const cached = await cacheGet(cacheKey);
if (cached) return cached;
try {
const res = await axios.get(`${PYTHON_BASE}${path}`, {
params: { player: playerName, n: count },
timeout: HTTP_TIMEOUT_MS,
});
const games = res.data?.games || res.data?.results || [];
if (!Array.isArray(games) || games.length === 0) return null;
await cacheSet(cacheKey, games, CACHE_TTL_SECONDS);
return games;
} catch (err) {
// Python service down or returning 404 — return null, caller omits.
if (err?.response?.status !== 404) {
console.warn(`[gameLog] fetch failed for ${playerName}:`, err?.message);
}
return null;
}
}
// Career playoff games — approximated from the season-avg endpoint's career
// summary, if present. If the Python service doesn't surface this, return
// null and let the caller skip the feature.
async function getCareerPlayoffGames(playerName, sport) {
if (sport !== 'nba' && sport !== 'wnba') return null;
try {
const res = await axios.get(`${PYTHON_BASE}/stats/season-avg`, {
params: { player: playerName, season: 'career' },
timeout: HTTP_TIMEOUT_MS,
});
const games = res.data?.career_playoff_games;
return Number.isFinite(Number(games)) ? Number(games) : null;
} catch {
return null;
}
}
// with/without analysis — compare a player's stats when a specific teammate
// is in vs out. Requires the Python service to expose this; if not, the
// feature falls back to a league-average bump (caller's choice).
async function getWithWithoutStats(playerName, sport, statType, teammateName) {
if (sport !== 'nba' && sport !== 'wnba') return null;
try {
const res = await axios.get(`${PYTHON_BASE}/stats/with-without`, {
params: { player: playerName, stat_type: statType, teammate: teammateName },
timeout: HTTP_TIMEOUT_MS,
});
return res.data || null;
} catch {
return null;
}
}
module.exports = { getGameLogs, getCareerPlayoffGames, getWithWithoutStats };
@@ -0,0 +1,303 @@
/**
* Grading pipeline orchestrator.
*
* Called by n8n at 10:30 AM, 1 PM, 4 PM, 6 PM ET (and on demand from the
* /api/grading/pipeline endpoint). For one sport per call, it:
*
* 1. Pulls today's scoreboard from the sport config's ESPN endpoint.
* We do NOT call SharpAPI for the slate — only for player props per
* game. Scoreboard is the source of truth for which games exist.
* 2. For each game, fetches player props via SharpAPI.
* 3. For each prop, builds a feature vector + trap composite +
* consistency score, then asks Engine 1 to grade.
* 4. Persists the grade to grade_history.
* 5. Queues A/B-tier grades for Engine 2.
* 6. Drains the Engine 2 queue (best-effort, one batch).
*
* Failure semantics:
* - SharpAPI down → 0 props graded, summary still returns.
* - Per-prop error → log + skip, other props continue.
* - Engine 2 queue failure → does not affect Engine 1 grades that
* are already in the database.
*/
const axios = require('axios');
const { getSportConfig } = require('../../config/sports');
const { getSupabaseServiceClient } = require('../../utils/supabase');
const featureCache = require('./featureCache');
const trapDetection = require('./trapDetection');
const consistencyScore = require('./consistencyScore');
const engine1 = require('./engine1');
const engine2 = require('./engine2');
const gameLogService = require('./gameLogService');
const probabilityEstimator = require('./probabilityEstimator');
const sharpApi = require('../adapters/sharpApiAdapter');
const HTTP_TIMEOUT_MS = 15_000;
async function fetchTodaysGames(sportCfg) {
try {
const res = await axios.get(sportCfg.espnScoreboard, { timeout: HTTP_TIMEOUT_MS });
const events = res.data?.events || [];
return events.map((ev) => {
const comp = ev?.competitions?.[0];
const teams = (comp?.competitors || []).reduce((acc, t) => {
const role = t?.homeAway === 'home' ? 'home' : 'away';
acc[role] = { id: t?.id, abbr: t?.team?.abbreviation, name: t?.team?.displayName };
return acc;
}, {});
return {
gameId: String(ev.id),
gameDate: ev?.date,
home: teams.home,
away: teams.away,
state: ev?.status?.type?.state,
};
});
} catch (err) {
console.warn('[orchestrator] scoreboard fetch failed:', err.message);
return [];
}
}
async function buildPropContext(prop, game, sport) {
// Determine whether this prop's player is on home or away team. We
// don't have a roster lookup at this point of the pipeline; the orchestrator
// treats prop.team (if SharpAPI provides) as the canonical, falling back
// to "unknown" for home_away.
const team = prop.team || prop.teamAbbr;
const isHome = team && game.home?.abbr === team;
const opponentAbbr = isHome ? game.away?.abbr : game.home?.abbr;
return {
playerId: prop.playerId || prop.player_id || null,
playerName: prop.player,
statType: prop.statType || prop.stat_type,
sport,
line: Number(prop.line),
direction: prop.direction || 'over',
teamAbbr: team,
opponentAbbr,
gameId: game.gameId,
gameContext: {
home_away: team ? (isHome ? 'home' : 'away') : null,
},
};
}
async function gradeProp(prop, game, sport) {
const ctx = await buildPropContext(prop, game, sport);
// Feature vector — every signal computed in 6b.
const featurePayload = await featureCache.getFeatures({
playerId: ctx.playerId,
playerName: ctx.playerName,
statType: ctx.statType,
sport: ctx.sport,
teamAbbr: ctx.teamAbbr,
opponentAbbr: ctx.opponentAbbr,
gameId: ctx.gameId,
gameContext: ctx.gameContext,
});
const features = featurePayload?.features || {};
// Trap detector — uses features + lineMovement snapshots already in DB.
const trap = await trapDetection.getTrapScore({
playerName: ctx.playerName,
statType: ctx.statType,
sport: ctx.sport,
gameId: ctx.gameId,
gameContext: ctx.gameContext,
features,
odds: { playerLine: ctx.line, consensus: prop.consensus },
});
// Consistency — Engine 2 uses this verbatim in its prompt.
let consistency = { consistency: 'unknown', score: null, games: 0 };
let gameLogs = null;
try {
gameLogs = await gameLogService.getGameLogs(ctx.playerName, ctx.sport, 20);
if (gameLogs && gameLogs.length) {
consistency = await consistencyScore.getConsistency({
playerName: ctx.playerName,
sport: ctx.sport,
statType: ctx.statType,
gameLogs,
});
}
} catch (err) {
console.warn('[orchestrator] consistency failed for', ctx.playerName, err.message);
}
// P(Over) — quantile-based probability from game logs. We pass the same
// game logs to the estimator that consistency uses, so both views agree
// on the same data window. Null if no logs (Python service down).
let probability = { p_over: null, p_under: null, components: {}, reason: 'no_logs' };
if (gameLogs && gameLogs.length) {
probability = probabilityEstimator.estimateProbability({
gameLogs,
line: ctx.line,
statType: ctx.statType,
features,
});
}
// Engine 1 — rule-based, deterministic.
const result = engine1.gradeProp({
features,
trap,
consistency,
prop: { line: ctx.line, direction: ctx.direction },
});
return { ctx, features, trap, consistency, probability, engine1Result: result };
}
async function persistGrade(graded, prop, sport) {
const supabase = getSupabaseServiceClient();
const { ctx, engine1Result, trap, consistency, features, probability } = graded;
const row = {
player_id: ctx.playerId,
player_name: ctx.playerName,
sport,
stat_type: ctx.statType,
line: ctx.line,
direction: ctx.direction,
grade: engine1Result.grade,
projection: Number.isFinite(features.l5_avg) ? features.l5_avg : null,
// modeled_prob is the implied probability from Engine 1's grade tier;
// p_over is the quantile-based probability from game logs. Both useful
// — the former for grade-vs-line edge math, the latter for UI display.
modeled_prob: Number.isFinite(engine1Result?.confidence) ? engine1Result.confidence : null,
implied_prob: null,
p_over: Number.isFinite(probability?.p_over) ? probability.p_over : null,
// factors drive the weight adjuster: each resolved prop's factors get
// nudged based on hit/miss outcome. Stored as JSONB so we can also
// surface them in the UI "why this grade" tooltip.
factors: Array.isArray(engine1Result?.all_factors)
? engine1Result.all_factors
: (Array.isArray(engine1Result?.top_factors) ? engine1Result.top_factors : null),
game_date: new Date().toISOString().slice(0, 10),
game_id: ctx.gameId,
};
const { data, error } = await supabase.from('grade_history').insert(row).select('id').single();
if (error) {
console.warn('[orchestrator] grade_history insert failed:', error.message);
return null;
}
// Hand the gradeId + full context to engine2 so it can build a prompt.
engine2.queueAnalysis(data.id, {
player_name: ctx.playerName,
team: ctx.teamAbbr,
sport,
direction: ctx.direction,
line: ctx.line,
stat_type: ctx.statType,
home_team: prop._home,
away_team: prop._away,
game_date: row.game_date,
engine1_grade: engine1Result.grade,
engine1_factors: engine1Result.top_factors,
features,
trap,
consistency,
probability,
recentGames: [],
});
return data.id;
}
async function gradeProps(props, game, sport) {
const out = [];
for (const prop of props) {
try {
const graded = await gradeProp(prop, game, sport);
const gradeId = await persistGrade(graded, { ...prop, _home: game.home?.name, _away: game.away?.name }, sport);
out.push({ gradeId, grade: graded.engine1Result.grade, prop });
} catch (err) {
console.warn('[orchestrator] gradeProp failed for', prop?.player, err.message);
}
}
return out;
}
async function runPipeline(sport, options = {}) {
const start = Date.now();
let sportCfg;
try { sportCfg = getSportConfig(sport); }
catch (err) { return { error: err.message, sport, games_processed: 0, props_graded: 0, duration_ms: Date.now() - start }; }
const games = await fetchTodaysGames(sportCfg);
if (games.length === 0) {
return { sport, games_processed: 0, props_graded: 0, engine2_queued: 0, errors: 0, duration_ms: Date.now() - start };
}
let propsGraded = 0;
let errors = 0;
let engine2Queued = 0;
for (const game of games) {
let props;
try {
props = await sharpApi.getPlayerProps(sport, game.gameId);
} catch (err) {
console.warn('[orchestrator] sharpApi failed for', game.gameId, err.message);
errors += 1;
continue;
}
if (!Array.isArray(props) || props.length === 0) continue;
const before = engine2.getQueueSize();
const graded = await gradeProps(props, game, sport);
propsGraded += graded.length;
engine2Queued += engine2.getQueueSize() - before;
}
// Drain the Engine 2 queue with a bounded loop. Each processQueue()
// call handles ENGINE2_BATCH_SIZE items, so for slates of ~50+ A/B
// grades one call would leave most of the queue parked. Cap at 5
// iterations (≈50 props per pipeline run with default batch size)
// — beyond that, the next pipeline cycle picks up the remainder.
let engine2Summary = { processed: 0, succeeded: 0, failed: 0, remaining: engine2.getQueueSize() };
if (!options.skipEngine2) {
const MAX_DRAIN_ITERS = 5;
let drainIters = 0;
const totals = { processed: 0, succeeded: 0, failed: 0 };
while (engine2.getQueueSize() > 0 && drainIters < MAX_DRAIN_ITERS) {
const round = await engine2.processQueue();
totals.processed += round.processed || 0;
totals.succeeded += round.succeeded || 0;
totals.failed += round.failed || 0;
drainIters += 1;
// If a round processes 0 items, the queue is stuck (likely
// disabled or all calls failing) — break early instead of looping.
if ((round.processed || 0) === 0) break;
}
engine2Summary = { ...totals, remaining: engine2.getQueueSize(), iterations: drainIters };
}
return {
sport,
games_processed: games.length,
props_graded: propsGraded,
engine2_queued: engine2Queued,
engine2_summary: engine2Summary,
errors,
duration_ms: Date.now() - start,
};
}
function getEngineStatus() {
return {
engine2_queue_size: engine2.getQueueSize(),
adapters_configured: {
sharp_api: sharpApi.configured(),
open_router: require('../adapters/openRouterAdapter').configured(),
},
};
}
module.exports = {
runPipeline,
gradeProps,
gradeProp,
getEngineStatus,
__internals: { fetchTodaysGames, buildPropContext, persistGrade },
};
+157
View File
@@ -0,0 +1,157 @@
/**
* ESPN injury parser.
*
* Two data paths:
* 1. ESPN team-injuries endpoint:
* https://site.api.espn.com/apis/site/v2/sports/{sport}/{league}/teams/{teamId}/injuries
* 2. Injury info embedded in scoreboard / summary responses under
* events[i].competitions[0].competitors[t].injuries
*
* We expose three callers:
* getTeamInjuries(sport, teamId) — primary fetch + cache
* getGameInjuries(sport, gameId, espnSummary?) — convenience reading
* the summary JSON the resolution path already loads, so we don't
* refetch
* isPlayerOut / getMissingStarters — derived helpers
*
* Cache: Redis, 2-hour TTL — injuries can change at shootaround on
* game day so we deliberately don't go longer.
*/
const axios = require('axios');
const { cacheGet, cacheSet } = require('../../utils/redis');
const { createLimiter, createCircuitBreaker } = require('../../utils/rateLimiter');
const HTTP_TIMEOUT_MS = 10_000;
const CACHE_TTL_SECONDS = 2 * 60 * 60;
// ESPN's team-injuries endpoint takes a sport/league path. We resolve the
// league portion off the same SPORT_CONFIG used by the resolution poller
// rather than maintaining a parallel map.
const ESPN_BASE = 'https://site.api.espn.com/apis/site/v2/sports';
const SPORT_PATH = Object.freeze({
nba: 'basketball/nba',
wnba: 'basketball/wnba',
mlb: 'baseball/mlb',
nfl: 'football/nfl',
nhl: 'hockey/nhl',
ncaab: 'basketball/mens-college-basketball',
ncaafb: 'football/college-football',
});
const limiter = createLimiter({ tokensPerInterval: 6, interval: 60_000 });
const breaker = createCircuitBreaker({ failureThreshold: 3, resetTimeout: 60_000 });
const STATUS_CANON = (status) => {
if (!status) return 'UNKNOWN';
const upper = String(status).toUpperCase();
if (upper.includes('OUT')) return 'OUT';
if (upper.includes('DOUBTFUL')) return 'DOUBTFUL';
if (upper.includes('QUESTIONABLE')) return 'QUESTIONABLE';
if (upper.includes('PROBABLE')) return 'PROBABLE';
if (upper.includes('DAY-TO-DAY') || upper.includes('DAY_TO_DAY') || upper.includes('DTD')) return 'DAY_TO_DAY';
return upper;
};
function normalizeInjuryEntry(entry) {
// ESPN payloads vary — entries may carry the player at `.athlete` or be
// flat with `.name` / `.id`. Try both shapes.
const player = entry?.athlete ?? entry;
return {
playerId: String(player?.id ?? entry?.id ?? ''),
playerName: player?.displayName ?? player?.fullName ?? entry?.name ?? null,
status: STATUS_CANON(entry?.status ?? entry?.type?.description ?? entry?.details?.type),
detail: entry?.details?.detail ?? entry?.shortComment ?? entry?.longComment ?? null,
};
}
async function getTeamInjuries(sport, teamId) {
const path = SPORT_PATH[sport];
if (!path) return [];
const cacheKey = `injuries:${sport}:${teamId}`;
const cached = await cacheGet(cacheKey);
if (cached) return cached;
await limiter.waitForToken();
try {
const data = await breaker.call(async () => {
const res = await axios.get(`${ESPN_BASE}/${path}/teams/${teamId}/injuries`, {
timeout: HTTP_TIMEOUT_MS,
validateStatus: (s) => (s >= 200 && s < 300) || s === 404,
});
// ESPN returns 404 for teams with no current injuries on some sports
// — that's a clean "no injuries", not an error.
if (res.status === 404) return { injuries: [] };
return res.data;
});
const raw = data?.injuries || data?.athletes || [];
const normalized = (Array.isArray(raw) ? raw : []).map(normalizeInjuryEntry).filter((e) => e.playerName);
await cacheSet(cacheKey, normalized, CACHE_TTL_SECONDS);
return normalized;
} catch (err) {
if (err?.code !== 'CIRCUIT_OPEN') {
console.warn(`[injuries] fetch failed for ${sport}/${teamId}:`, err?.message);
}
return [];
}
}
function extractGameInjuries(espnSummary) {
// espnSummary is the JSON from /summary?event={id}. Some sports nest
// injuries under competitions[0].competitors[t].injuries; others under
// a top-level injuries[] array. We try both.
const out = { home: [], away: [] };
const comp = espnSummary?.header?.competitions?.[0] ?? espnSummary?.competitions?.[0];
if (comp?.competitors) {
for (const team of comp.competitors) {
const bucket = team?.homeAway === 'home' ? 'home' : 'away';
const list = team?.injuries || [];
for (const e of list) {
const normalized = normalizeInjuryEntry(e);
if (normalized.playerName) out[bucket].push(normalized);
}
}
}
if (Array.isArray(espnSummary?.injuries)) {
for (const e of espnSummary.injuries) {
const normalized = normalizeInjuryEntry(e);
if (!normalized.playerName) continue;
const bucket = e?.team === 'home' ? 'home' : 'away';
out[bucket].push(normalized);
}
}
return out;
}
async function getGameInjuries(sport, gameId, espnSummary) {
if (espnSummary) return extractGameInjuries(espnSummary);
// Without a summary in hand, we'd need both team IDs from the scoreboard
// — defer to the caller to pass espnSummary so we don't multiply ESPN
// requests.
return { home: [], away: [] };
}
async function isPlayerOut(sport, teamId, playerId) {
const list = await getTeamInjuries(sport, teamId);
const match = list.find((i) => i.playerId === String(playerId));
if (!match) return false;
return match.status === 'OUT' || match.status === 'DOUBTFUL';
}
// starterIds is an iterable of ESPN player IDs known to start for this team
// (resolved upstream from player_id_map or yesterday's box score).
async function getMissingStarters(sport, teamId, starterIds) {
const injuries = await getTeamInjuries(sport, teamId);
const starterSet = new Set([...starterIds].map(String));
return injuries.filter(
(i) => starterSet.has(i.playerId) && (i.status === 'OUT' || i.status === 'DOUBTFUL')
);
}
module.exports = {
getTeamInjuries,
getGameInjuries,
isPlayerOut,
getMissingStarters,
__internals: { limiter, breaker, normalizeInjuryEntry, STATUS_CANON },
};
+134
View File
@@ -0,0 +1,134 @@
/**
* Line movement signals built on top of line_snapshots.
*
* Two derived signals power the trap detector:
*
* reverseLineMovement
* The line moved AGAINST where the public is betting. If the public is
* hammering OVER but the line drops (toward UNDER), sharp money is on
* the under and the over is a trap.
*
* juiceDegradation
* The line didn't move but the vig on one side got worse (e.g. -110 →
* -130). Books are charging more for the same number — that side is
* the trap.
*
* Both signals require at least two snapshots. If snapshots are missing we
* return null so trap detection can mark the signal "inactive" instead of
* scoring zero (which would dilute the composite).
*/
const { getSupabaseServiceClient } = require('../../utils/supabase');
const { oddsToImplied } = require('../../utils/odds');
async function fetchSnapshots(gameId, playerName, statType) {
const supabase = getSupabaseServiceClient();
const { data, error } = await supabase
.from('line_snapshots')
.select('line, over_odds, under_odds, consensus_median, snapshot_at')
.eq('game_id', gameId)
.eq('stat_type', statType)
.eq('player_name', playerName)
.order('snapshot_at', { ascending: true });
if (error) {
console.warn('[lineMovement] snapshot lookup failed:', error.message);
return [];
}
return data || [];
}
async function getLineMovement(gameId, playerName, statType) {
const snaps = await fetchSnapshots(gameId, playerName, statType);
if (snaps.length < 2) return null;
const open = snaps[0];
const close = snaps[snaps.length - 1];
const movement = Number(close.line) - Number(open.line);
const overJuiceOpen = Number(open.over_odds);
const overJuiceClose = Number(close.over_odds);
const underJuiceOpen = Number(open.under_odds);
const underJuiceClose = Number(close.under_odds);
return {
opening_line: Number(open.line),
current_line: Number(close.line),
movement,
direction: movement > 0 ? 'up' : movement < 0 ? 'down' : 'flat',
opening_over_odds: Number.isFinite(overJuiceOpen) ? overJuiceOpen : null,
current_over_odds: Number.isFinite(overJuiceClose) ? overJuiceClose : null,
opening_under_odds: Number.isFinite(underJuiceOpen) ? underJuiceOpen : null,
current_under_odds: Number.isFinite(underJuiceClose) ? underJuiceClose : null,
juice_change_over: Number.isFinite(overJuiceClose - overJuiceOpen) ? overJuiceClose - overJuiceOpen : null,
juice_change_under: Number.isFinite(underJuiceClose - underJuiceOpen) ? underJuiceClose - underJuiceOpen : null,
snapshots_count: snaps.length,
first_seen: open.snapshot_at,
last_seen: close.snapshot_at,
};
}
// publicBetPct is the public-money percentage on the OVER (0-100). If we
// don't have it, we estimate from odds movement direction: when the over
// got more expensive (smaller positive / bigger negative), the public was
// on the over.
async function reverseLineMovement(gameId, playerName, statType, publicBetPct) {
const lm = await getLineMovement(gameId, playerName, statType);
if (!lm) return null;
// Estimate public side if not provided.
let publicSide;
if (Number.isFinite(publicBetPct)) {
publicSide = publicBetPct >= 50 ? 'over' : 'under';
} else if (Number.isFinite(lm.juice_change_over)) {
// If over juice got worse (became more negative), book is shading away
// from over — public was on over.
publicSide = lm.juice_change_over < 0 ? 'over' : 'under';
} else {
return null;
}
// Line movement direction tells us where sharp money went.
const lineDirection = lm.movement > 0 ? 'over' : lm.movement < 0 ? 'under' : 'flat';
if (lineDirection === 'flat') return null;
const isReverse = publicSide !== lineDirection;
if (!isReverse) return { score: 0, isReverse: false, publicSide, lineDirection };
// Magnitude normalized to typical movement (1 point is meaningful for
// basketball points; everything bigger gets capped at 1.0).
const magnitude = Math.min(Math.abs(lm.movement), 1.0);
const publicWeight = Number.isFinite(publicBetPct)
? Math.max(0.5, Math.abs(publicBetPct - 50) / 50)
: 0.6;
return {
score: Math.min(1.0, magnitude * publicWeight),
isReverse: true,
publicSide,
lineDirection,
movement: lm.movement,
};
}
async function juiceDegradation(gameId, playerName, statType) {
const lm = await getLineMovement(gameId, playerName, statType);
if (!lm) return null;
// Only meaningful when the line itself barely moved — if both line and
// juice shifted, that's regular line movement, captured by RLM instead.
if (Math.abs(lm.movement) > 0.5) return { score: 0, applicable: false };
const overShift = Number.isFinite(lm.juice_change_over) ? lm.juice_change_over : 0;
const underShift = Number.isFinite(lm.juice_change_under) ? lm.juice_change_under : 0;
// Worst-side degradation: the side whose implied-prob increase is bigger
// is the one the books are pulling money to.
const overImpliedShift = (oddsToImplied(lm.current_over_odds) ?? 0) - (oddsToImplied(lm.opening_over_odds) ?? 0);
const underImpliedShift = (oddsToImplied(lm.current_under_odds) ?? 0) - (oddsToImplied(lm.opening_under_odds) ?? 0);
const worstSide = overImpliedShift >= underImpliedShift ? 'over' : 'under';
// Normalize to a 20-cent (e.g. -110 → -130) max move.
const magnitude = Math.max(Math.abs(overShift), Math.abs(underShift));
return {
score: Math.min(1.0, magnitude / 20),
applicable: true,
worstSide,
overShift,
underShift,
};
}
module.exports = { getLineMovement, reverseLineMovement, juiceDegradation, fetchSnapshots };
@@ -0,0 +1,80 @@
/**
* Lineup / role signals.
*
* Two derived inputs:
* getProjectedStarters: from ESPN summary (post-game or pregame) or
* yesterday's box score as a fallback. The poller already caches the
* summary; we just walk it.
* getLineupRole: maps a player to 'primary_handler' | 'secondary' |
* 'role_player' based on usage signals. For now this is a coarse
* heuristic driven by usage_rate; the feature cache pulls a finer
* value once Engine 2 surfaces per-player usage.
*/
function rolesFromBoxScore(boxScore) {
const home = [];
const away = [];
const teams = boxScore?.boxscore?.players || [];
for (let i = 0; i < teams.length; i += 1) {
const team = teams[i];
const bucket = i === 0 ? home : away;
const athletes = team?.statistics?.[0]?.athletes || [];
for (const a of athletes) {
if (!a?.starter) continue;
const id = a?.athlete?.id || a?.id;
const name = a?.athlete?.displayName || a?.athlete?.fullName;
if (!id || !name) continue;
bucket.push({
playerId: String(id),
name,
position: a?.athlete?.position?.abbreviation ?? null,
});
}
}
return { home, away };
}
async function getProjectedStarters(sport, gameId, espnSummary) {
if (!espnSummary) return { home: [], away: [] };
const lineup = rolesFromBoxScore(espnSummary);
// Add 'role' annotation — first starter on each side defaults to primary
// handler. Once usage data is available we refine; for now this is the
// ESPN-listed starting order.
for (const side of ['home', 'away']) {
lineup[side] = lineup[side].map((p, idx) => ({
...p,
role: idx === 0 ? 'primary_handler' : idx <= 2 ? 'secondary' : 'role_player',
}));
}
return lineup;
}
// Coarse classification from a precomputed usage rate (0-1). Caller has
// the rate via teamStatsCache or the Python game-log service.
function classifyByUsage(usageRate) {
const u = Number(usageRate);
if (!Number.isFinite(u)) return 'role_player';
if (u >= 0.28) return 'primary_handler';
if (u >= 0.18) return 'secondary';
return 'role_player';
}
function roleValue(role) {
if (role === 'primary_handler') return 1.0;
if (role === 'secondary') return 0.5;
return 0.0;
}
async function getLineupRole(_sport, _teamAbbr, _playerId, usageRate) {
// Until usage rates feed in, the caller passes one explicitly. If they
// don't, classifyByUsage returns 'role_player' (the safe default).
return classifyByUsage(usageRate);
}
module.exports = {
getProjectedStarters,
getLineupRole,
classifyByUsage,
roleValue,
rolesFromBoxScore,
};
@@ -0,0 +1,125 @@
/**
* P(Over) — estimated probability that the player goes over the line.
*
* This is the *quantile-based* probability we surface to users ("73%
* chance over") and feed into Engine 2's prompt. It is NOT the implied
* probability from the book — that's odds-derived and includes vig. This
* one is from the player's actual distribution.
*
* Formula layers:
* 1. Base — empirical frequency of stat > line across the sample
* 2. Recency — last 5 games weighted 2× to capture trend
* 3. Opponent — bump for weak D, fade for top D (uses 0..1 opp_rank_stat)
* 4. Home / away — +1.5% / -1.5%
* 5. Consistency — volatile players get pulled toward 0.50
*
* Clamp at [0.10, 0.95] — we never claim certainty in either direction.
*/
const CV_VOLATILE_THRESHOLD = 0.40;
const PROB_FLOOR = 0.10;
const PROB_CEIL = 0.95;
function statFromRow(row, statType) {
if (!row) return null;
switch (statType) {
case 'pts_reb_ast':
return (Number(row.points) || 0) + (Number(row.rebounds) || 0) + (Number(row.assists) || 0);
case 'pts_reb':
return (Number(row.points) || 0) + (Number(row.rebounds) || 0);
case 'pts_ast':
return (Number(row.points) || 0) + (Number(row.assists) || 0);
case 'reb_ast':
return (Number(row.rebounds) || 0) + (Number(row.assists) || 0);
case 'stl_blk':
return (Number(row.steals) || 0) + (Number(row.blocks) || 0);
default: {
const v = Number(row[statType]);
return Number.isFinite(v) ? v : null;
}
}
}
function frequencyOver(values, line) {
const decisive = values.filter((v) => v !== line); // push games don't count
if (decisive.length === 0) return null;
const over = decisive.filter((v) => v > line).length;
return over / decisive.length;
}
function clamp(p) {
return Math.max(PROB_FLOOR, Math.min(PROB_CEIL, p));
}
function estimateProbability({ gameLogs = [], line, statType, features = {} } = {}) {
if (!Array.isArray(gameLogs) || gameLogs.length === 0 || !Number.isFinite(Number(line))) {
return { p_over: null, p_under: null, components: {}, reason: 'insufficient_data' };
}
const numericLine = Number(line);
const values = gameLogs.map((r) => statFromRow(r, statType)).filter((v) => v != null);
if (values.length === 0) {
return { p_over: null, p_under: null, components: {}, reason: 'no_stat_values' };
}
const base = frequencyOver(values, numericLine);
if (base == null) return { p_over: null, p_under: null, components: {}, reason: 'all_pushes' };
// Recency: last 5 games count 2× in a weighted blend.
const recent = values.slice(0, Math.min(5, values.length));
const recencyRate = frequencyOver(recent, numericLine);
const weighted = recencyRate != null
? 0.6 * base + 0.4 * recencyRate
: base;
let p = weighted;
// Opponent adjustment using 0..1 normalized rank.
// opp_rank_stat ≥ 0.70 → weak defense, bump toward over
// opp_rank_stat ≤ 0.30 → strong defense, fade
const oppAdj = (() => {
const r = Number(features.opp_rank_stat);
if (!Number.isFinite(r)) return 0;
if (r >= 0.70) return +0.03;
if (r <= 0.30) return -0.03;
return 0;
})();
p += oppAdj;
const homeAdj = features.home_away === 1.0 ? +0.015 : features.home_away === 0.0 ? -0.015 : 0;
p += homeAdj;
// Consistency pull: volatile players are uncertain — drag p toward 0.50.
const cv = Number(features.l10_stddev) > 0 && Number(features.l20_avg) > 0
? Number(features.l10_stddev) / Number(features.l20_avg)
: null;
const consistencyAdj = (() => {
if (!Number.isFinite(cv)) return null;
if (cv > CV_VOLATILE_THRESHOLD) {
// p' = p * 0.9 + 0.5 * 0.1
const before = p;
p = p * 0.9 + 0.05;
return p - before;
}
return 0;
})();
const pOver = clamp(p);
return {
p_over: pOver,
p_under: 1 - pOver,
components: {
base,
recency: recencyRate,
weighted,
opp_adjustment: oppAdj,
home_adjustment: homeAdj,
consistency_adjustment: consistencyAdj,
cv,
},
};
}
module.exports = {
estimateProbability,
__internals: { statFromRow, frequencyOver, clamp, CV_VOLATILE_THRESHOLD, PROB_FLOOR, PROB_CEIL },
};
+115
View File
@@ -0,0 +1,115 @@
/**
* Referee impact signal.
*
* Game-day ref assignments live in `game_ref_assignments` (migration 017).
* Per-referee tendencies live in `ref_profiles`, populated by the
* Sports-Reference scraper (scripts/scrape-sports-reference.js).
*
* Crew impact is computed by averaging the three refs' profiles. If any
* profile is missing we still return a partial impact (averaging only the
* available refs) — the feature cache decides whether to surface the
* feature or omit it based on coverage.
*/
const { getSupabaseServiceClient } = require('../../utils/supabase');
const LOOPBACK_IPS = new Set(['127.0.0.1', '::1', '::ffff:127.0.0.1']);
async function getRefAssignment(gameId) {
const supabase = getSupabaseServiceClient();
const { data, error } = await supabase
.from('game_ref_assignments')
.select('ref1_name, ref2_name, ref3_name, ref_crew_avg_fouls, ref_crew_pace_impact')
.eq('game_id', gameId)
.maybeSingle();
if (error) {
console.warn('[refSignals] assignment lookup failed:', error.message);
return null;
}
return data || null;
}
async function getRefProfiles(refNames) {
const named = refNames.filter(Boolean);
if (named.length === 0) return [];
const supabase = getSupabaseServiceClient();
const { data, error } = await supabase
.from('ref_profiles')
.select('ref_name, avg_fouls_per_game, avg_free_throws_per_game, pace_impact, home_whistle_bias')
.in('ref_name', named);
if (error) {
console.warn('[refSignals] profile lookup failed:', error.message);
return [];
}
return data || [];
}
function average(values) {
const clean = values.filter((v) => Number.isFinite(v));
if (clean.length === 0) return null;
return clean.reduce((a, b) => a + b, 0) / clean.length;
}
async function getRefImpact(gameId) {
const assignment = await getRefAssignment(gameId);
if (!assignment) return null;
const crew = [assignment.ref1_name, assignment.ref2_name, assignment.ref3_name].filter(Boolean);
if (crew.length === 0) return null;
// If precomputed crew values exist on the assignment row (scraper wrote
// them), prefer those — they were derived from the same profiles but
// baked at assignment time. Note: Number(null) === 0 is finite, so guard
// explicitly against null/undefined before going through Number().
const hasFouls = assignment.ref_crew_avg_fouls != null && Number.isFinite(Number(assignment.ref_crew_avg_fouls));
const hasPace = assignment.ref_crew_pace_impact != null && Number.isFinite(Number(assignment.ref_crew_pace_impact));
if (hasFouls || hasPace) {
return {
crew,
avg_fouls: assignment.ref_crew_avg_fouls,
pace_impact: assignment.ref_crew_pace_impact,
foul_adjustment: assignment.ref_crew_avg_fouls,
home_bias: null,
profilesUsed: crew.length,
};
}
const profiles = await getRefProfiles(crew);
return {
crew,
avg_fouls: average(profiles.map((p) => p.avg_fouls_per_game)),
pace_impact: average(profiles.map((p) => p.pace_impact)),
foul_adjustment: average(profiles.map((p) => p.avg_free_throws_per_game)),
home_bias: average(profiles.map((p) => p.home_whistle_bias)),
profilesUsed: profiles.length,
};
}
// Manual entry endpoint helper — the route module (not built here) calls
// this when ops POSTs an assignment.
async function setRefAssignment(gameId, sport, gameDate, refs) {
const supabase = getSupabaseServiceClient();
// Pull profiles synchronously to precompute crew impact at insert time so
// downstream reads don't need a join.
const profiles = await getRefProfiles(refs);
const avgFouls = average(profiles.map((p) => p.avg_fouls_per_game));
const paceImpact = average(profiles.map((p) => p.pace_impact));
const { error } = await supabase
.from('game_ref_assignments')
.upsert({
game_id: gameId,
sport,
game_date: gameDate,
ref1_name: refs[0] || null,
ref2_name: refs[1] || null,
ref3_name: refs[2] || null,
ref_crew_avg_fouls: avgFouls,
ref_crew_pace_impact: paceImpact,
}, { onConflict: 'game_id' });
if (error) {
console.warn('[refSignals] assignment upsert failed:', error.message);
return { ok: false, error: error.message };
}
return { ok: true, avg_fouls: avgFouls, pace_impact: paceImpact };
}
module.exports = { getRefImpact, getRefAssignment, getRefProfiles, setRefAssignment, LOOPBACK_IPS };
+231
View File
@@ -0,0 +1,231 @@
/**
* Team stats cache — daily refresh, Redis-backed.
*
* Source priority for each sport:
* nba / wnba / ncaab : ESPN team statistics endpoint
* mlb : ESPN + MLB Stats API team totals
* nfl / ncaafb : ESPN + CFBD talent composite (college)
* nhl : ESPN team statistics endpoint
*
* The cache key is `team_stats:{sport}:{teamAbbr}` with a 24h TTL. The
* refresh function (called from n8n or app startup) walks every team in
* the sport and writes one cache entry per team. Rate-limited at 1
* request per 2 seconds to be respectful to ESPN.
*
* Per-team payload normalizes into a uniform shape; values not available
* for a sport are simply omitted (mirrors the feature-cache philosophy).
*
* {
* offensive_rating, defensive_rating, pace, opponent_ppg,
* team_fg_pct, team_3pt_pct, team_ft_rate,
* opponent_fg_pct, opponent_3pt_pct,
* team_k_rate, // MLB only
* defensive_rank, // 1-N (1 = best D)
* by_stat: { points: { allowed: N, rank: 1-30 }, ... }
* }
*/
const axios = require('axios');
const { cacheGet, cacheSet } = require('../../utils/redis');
const { createLimiter, createCircuitBreaker } = require('../../utils/rateLimiter');
const ESPN_BASE = 'https://site.api.espn.com/apis/site/v2/sports';
const CACHE_TTL_SECONDS = 24 * 60 * 60;
const HTTP_TIMEOUT_MS = 10_000;
const SPORT_PATH = Object.freeze({
nba: 'basketball/nba',
wnba: 'basketball/wnba',
mlb: 'baseball/mlb',
nfl: 'football/nfl',
nhl: 'hockey/nhl',
ncaab: 'basketball/mens-college-basketball',
ncaafb: 'football/college-football',
});
const limiter = createLimiter({ tokensPerInterval: 30, interval: 60_000 }); // 1/2s
const breaker = createCircuitBreaker({ failureThreshold: 3, resetTimeout: 60_000 });
function teamCacheKey(sport, teamAbbr) {
return `team_stats:${sport}:${String(teamAbbr).toUpperCase()}`;
}
// Pull a numeric value out of ESPN's labeled statistics arrays. ESPN
// returns categories with .stats[] of { name, value, displayValue, abbreviation }.
function pickStat(categoryStats, name) {
if (!Array.isArray(categoryStats)) return null;
const match = categoryStats.find(
(s) =>
(s?.name || '').toLowerCase() === name.toLowerCase()
|| (s?.abbreviation || '').toLowerCase() === name.toLowerCase()
);
if (!match) return null;
const v = Number(match.value);
return Number.isFinite(v) ? v : null;
}
function flattenTeamStats(payload) {
// ESPN returns: { team, season, splits: [...], stats: [...] } depending on
// endpoint. Most commonly: payload.results.stats[]/categories[] for
// /teams/{id}/statistics
const buckets = payload?.results?.stats || payload?.stats || [];
const all = [];
for (const b of buckets) {
if (Array.isArray(b?.stats)) all.push(...b.stats);
if (Array.isArray(b?.splits)) {
for (const split of b.splits) {
if (Array.isArray(split?.stats)) all.push(...split.stats);
}
}
}
return all;
}
function normalizeBasketball(payload) {
const all = flattenTeamStats(payload);
return {
offensive_rating: pickStat(all, 'offensiveRating') ?? pickStat(all, 'oRtg'),
defensive_rating: pickStat(all, 'defensiveRating') ?? pickStat(all, 'dRtg'),
pace: pickStat(all, 'pace'),
opponent_ppg: pickStat(all, 'avgPointsAgainst') ?? pickStat(all, 'oppPPG'),
team_fg_pct: pickStat(all, 'fieldGoalPct'),
team_3pt_pct: pickStat(all, 'threePointFieldGoalPct') ?? pickStat(all, 'threePtPct'),
team_ft_rate: pickStat(all, 'freeThrowAttemptRate'),
opponent_fg_pct: pickStat(all, 'opponentFieldGoalPct'),
opponent_3pt_pct: pickStat(all, 'opponentThreePointFieldGoalPct'),
};
}
function normalizeMlb(payload) {
const all = flattenTeamStats(payload);
return {
team_k_rate: pickStat(all, 'strikeOutRate') ?? pickStat(all, 'strikeoutsPerNine'),
opponent_ppg: pickStat(all, 'runsAgainst'),
};
}
function normalizeFootball(payload) {
const all = flattenTeamStats(payload);
return {
offensive_rating: pickStat(all, 'totalPoints'),
defensive_rating: pickStat(all, 'pointsAgainst'),
opponent_ppg: pickStat(all, 'avgPointsAgainst'),
};
}
function normalize(sport, payload) {
switch (sport) {
case 'nba':
case 'wnba':
case 'ncaab':
return normalizeBasketball(payload);
case 'mlb':
return normalizeMlb(payload);
case 'nfl':
case 'ncaafb':
return normalizeFootball(payload);
case 'nhl':
default:
return flattenTeamStats(payload).reduce((acc, s) => {
if (s?.name && Number.isFinite(Number(s.value))) acc[s.name] = Number(s.value);
return acc;
}, {});
}
}
async function fetchTeamStatsRaw(sport, teamId) {
const path = SPORT_PATH[sport];
if (!path) return null;
await limiter.waitForToken();
return breaker.call(async () => {
const res = await axios.get(`${ESPN_BASE}/${path}/teams/${teamId}/statistics`, {
timeout: HTTP_TIMEOUT_MS,
});
return res.data;
});
}
async function listTeams(sport) {
const path = SPORT_PATH[sport];
if (!path) return [];
await limiter.waitForToken();
const res = await axios.get(`${ESPN_BASE}/${path}/teams`, { timeout: HTTP_TIMEOUT_MS });
const groups = res.data?.sports?.[0]?.leagues?.[0]?.teams || [];
return groups
.map((t) => t?.team)
.filter(Boolean)
.map((t) => ({ id: String(t.id), abbr: t.abbreviation, name: t.displayName }));
}
async function refreshTeamStats(sport) {
const teams = await listTeams(sport);
// Two-pass: fetch every team's stats first, then rank across the league
// so we can normalize opponent rank to 0..1. A raw defensive_rating
// means different things across sports (NBA ~100-120, NHL ~2.5-3.5
// goals/game), so the cache stores both: raw + normalized.
const fetched = [];
let captured = 0;
let errored = 0;
for (const team of teams) {
try {
const raw = await fetchTeamStatsRaw(sport, team.id);
if (!raw) { errored += 1; continue; }
const stats = normalize(sport, raw);
fetched.push({ team, stats });
captured += 1;
} catch (err) {
if (err?.code !== 'CIRCUIT_OPEN') {
console.warn(`[teamStats] ${sport}/${team.abbr} failed: ${err?.message}`);
}
errored += 1;
}
}
// Rank teams by defensive_rating ascending (lower allowed = better D).
// Then map each team's rank to [0, 1] — 0 = best D (hardest matchup),
// 1 = worst D (easiest matchup). The feature cache uses this directly.
const withDef = fetched.filter((f) => Number.isFinite(Number(f.stats.defensive_rating)));
withDef.sort((a, b) => Number(a.stats.defensive_rating) - Number(b.stats.defensive_rating));
const total = withDef.length;
for (let i = 0; i < withDef.length; i += 1) {
withDef[i].stats.defensive_rank_normalized = total > 1 ? i / (total - 1) : 0.5;
}
for (const { team, stats } of fetched) {
await cacheSet(
teamCacheKey(sport, team.abbr),
{ ...stats, team_id: team.id, team_name: team.name },
CACHE_TTL_SECONDS,
);
}
return { captured, errored, total: teams.length };
}
async function getTeamStats(sport, teamAbbr) {
return cacheGet(teamCacheKey(sport, teamAbbr));
}
// Returns the opponent's normalized defensive rank on a 0..1 scale.
// 0.0 = best defense in the league (hardest matchup)
// 1.0 = worst defense (easiest matchup)
// Comparable across sports — NBA, NHL, NFL all collapse to the same
// scale even though their raw defensive_rating values differ by orders
// of magnitude. Returns null when we have no cache entry yet.
async function getOpponentRank(sport, teamAbbr, _statType) {
const stats = await getTeamStats(sport, teamAbbr);
if (!stats) return null;
if (Number.isFinite(Number(stats.defensive_rank_normalized))) {
return Number(stats.defensive_rank_normalized);
}
// Backward-compat: if the cache predates the normalization upgrade, we
// can't normalize a single-team read in isolation — return null and
// let the feature cache omit the feature rather than emit a raw value.
return null;
}
module.exports = {
refreshTeamStats,
getTeamStats,
getOpponentRank,
__internals: { listTeams, normalize, teamCacheKey, limiter, breaker },
};
+259
View File
@@ -0,0 +1,259 @@
/**
* Trap detection — 7 independent signals → one composite trap score.
*
* reverse_line_movement — sharp money moved AGAINST public side
* historical_hit_rate_paradox — high hit-rate AND line moving against them
* new_context_trap — first game in a new context (playoffs G1)
* recency_inflation — L5 dramatically above L20 (chasing hot)
* juice_degradation — vig got worse while line stayed flat
* teammate_return_trap — key teammate returning from injury
* line_consensus_divergence — one book's line ≠ the consensus
*
* Composite formula:
* composite = average(active_signal_scores)
*
* Only ACTIVE signals (the ones with enough data to compute) average in.
* A null/inactive signal does NOT dilute the score — this prevents thin
* data from producing an artificially-low trap score on new deployments.
*
* < 0.25 → proceed
* < 0.50 → caution
* ≥ 0.50 → avoid
*/
const { reverseLineMovement, juiceDegradation, getLineMovement } = require('./lineMovement');
const { getSupabaseServiceClient } = require('../../utils/supabase');
function inactive(reason) {
return { score: 0, active: false, explanation: reason };
}
// Normalize player names for matching across data sources. ParlayAPI may
// emit "Brunson, Jalen" while ESPN emits "Jalen Brunson" — strip case,
// punctuation, suffixes, and collapse whitespace so equivalence works.
function normalizeName(name) {
if (!name) return '';
return String(name)
.normalize('NFD')
.replace(/[̀-ͯ]/g, '')
.toLowerCase()
.replace(/\b(jr|sr|ii|iii|iv|v)\.?\b/g, '')
.replace(/[^a-z\s]/g, ' ')
.replace(/\s+/g, ' ')
.trim();
}
// Detect whether a key teammate transitioned from OUT in the recent past
// to AVAILABLE now. Called by the orchestrator (Section 2) before invoking
// the trap detector — the orchestrator owns the injury-history context.
// priorInjuriesByGame is an array of injury snapshots (most recent first).
// Each entry: array of { playerId, status }. Returns the highest-usage
// teammate that has flipped from OUT/DOUBTFUL to PROBABLE/active, or null.
function detectReturningTeammate(currentInjuries, priorInjuriesByGame, usageMap = {}) {
if (!Array.isArray(priorInjuriesByGame) || priorInjuriesByGame.length === 0) return null;
const currentOutIds = new Set(
(currentInjuries || [])
.filter((i) => i.status === 'OUT' || i.status === 'DOUBTFUL')
.map((i) => String(i.playerId)),
);
// A player was "previously out" if they appeared as OUT/DOUBTFUL in any
// of the last 1-3 games' snapshots.
const priorOutIds = new Set();
for (const snap of priorInjuriesByGame.slice(0, 3)) {
for (const inj of snap || []) {
if (inj.status === 'OUT' || inj.status === 'DOUBTFUL') {
priorOutIds.add(String(inj.playerId));
}
}
}
let best = null;
for (const id of priorOutIds) {
if (currentOutIds.has(id)) continue;
const usage = Number(usageMap[id]) || 0;
if (!best || usage > best.usage) best = { playerId: id, usage };
}
return best;
}
// 1. Reverse line movement.
async function signalReverseLineMovement(input) {
const { gameId, playerName, statType, publicBetPct } = input;
if (!gameId || !playerName || !statType) return inactive('missing inputs');
const r = await reverseLineMovement(gameId, playerName, statType, publicBetPct);
if (!r) return inactive('not enough snapshots');
if (!r.isReverse) return { score: 0, active: true, explanation: 'line moved with public' };
return {
score: r.score,
active: true,
explanation: `line moved toward ${r.lineDirection} while public was on ${r.publicSide}`,
};
}
// 2. Historical hit-rate paradox — high hit rate AND line moving against the
// player. Uses resolution_results history. Confidence-scaled: thin history
// gets a proportional penalty.
async function signalHistoricalHitRateParadox(input) {
const { playerName, statType, sport, gameId } = input;
if (!playerName || !statType || !sport) return inactive('missing inputs');
const supabase = getSupabaseServiceClient();
const { data, error } = await supabase
.from('resolution_results')
.select('result, direction, line')
.eq('sport', sport)
.eq('stat_type', statType)
.eq('player_name', playerName);
if (error || !data || data.length < 10) {
return inactive(`only ${data?.length ?? 0} historical resolves`);
}
const hits = data.filter((r) => r.result === 'hit').length;
const hitRate = hits / data.length;
const lm = gameId ? await getLineMovement(gameId, playerName, statType) : null;
if (!lm) return inactive('no line movement context');
// "Against direction" — if the player generally bets OVER and the line
// moves DOWN, that's a trap; flip for UNDER.
const directionGuess = data.filter((r) => r.direction === 'over').length >= data.length / 2 ? 'over' : 'under';
const againstDirection = (directionGuess === 'over' && lm.movement < 0)
|| (directionGuess === 'under' && lm.movement > 0);
if (!againstDirection) return { score: 0, active: true, explanation: 'line moving with the player\'s usual side' };
const confidence = Math.min(data.length / 20, 1.0);
const score = Math.min(1.0, hitRate * Math.abs(lm.movement)) * confidence;
return {
score,
active: true,
explanation: `hit rate ${(hitRate * 100).toFixed(0)}% (${data.length} resolves) but line moved ${lm.movement} against ${directionGuess}`,
};
}
// 3. New context trap — first game in a context where stats may not transfer.
function signalNewContextTrap(input) {
const { gameContext = {} } = input;
let flags = 0;
const reasons = [];
if (gameContext.game_in_series === 1) { flags += 1; reasons.push('series_g1'); }
if (gameContext.first_playoff_game) { flags += 1; reasons.push('first_playoff_game'); }
if (gameContext.new_opponent_in_series) { flags += 1; reasons.push('new_opponent_in_series'); }
if (gameContext.new_venue) { flags += 1; reasons.push('new_venue'); }
if (flags === 0) return inactive('no context flags');
return {
score: flags / 4,
active: true,
explanation: `new context: ${reasons.join(', ')}`,
};
}
// 4. Recency inflation — L5 dramatically above L20.
function signalRecencyInflation(input) {
const f = input.features || {};
const l5 = Number(f.l5_avg);
const l20 = Number(f.l20_avg);
if (!Number.isFinite(l5) || !Number.isFinite(l20) || l20 <= 0) {
return inactive('l5_avg or l20_avg missing');
}
const ratio = (l5 - l20) / l20;
if (ratio <= 0) return { score: 0, active: true, explanation: 'L5 not hotter than L20' };
return {
score: Math.min(1.0, ratio),
active: true,
explanation: `L5 (${l5.toFixed(1)}) ${(ratio * 100).toFixed(0)}% above L20 (${l20.toFixed(1)})`,
};
}
// 5. Juice degradation — vig got worse while the line stayed flat.
async function signalJuiceDegradation(input) {
const { gameId, playerName, statType } = input;
if (!gameId || !playerName || !statType) return inactive('missing inputs');
const r = await juiceDegradation(gameId, playerName, statType);
if (!r) return inactive('not enough snapshots');
if (!r.applicable) return inactive('line moved too much for juice signal');
return { score: r.score, active: true, explanation: `juice worsening on ${r.worstSide}` };
}
// 6. Teammate return trap — key teammate returning → suppression.
function signalTeammateReturnTrap(input) {
const { gameContext = {} } = input;
const returning = gameContext.returning_teammate_usage_rate;
if (!Number.isFinite(returning) || returning <= 0) return inactive('no returning teammate');
return {
score: Math.min(1.0, returning * 0.5),
active: true,
explanation: `teammate returning with ${(returning * 100).toFixed(0)}% usage`,
};
}
// 7. Line consensus divergence — one book's line differs from the consensus.
function signalLineConsensusDivergence(input) {
const { odds = {} } = input;
const consensus = odds.consensus;
const playerLine = Number(odds.playerLine);
if (!consensus || !Number.isFinite(playerLine)) return inactive('no consensus or player line');
const median = Number(consensus.median);
if (!Number.isFinite(median)) return inactive('consensus median missing');
// Standard deviation across books; fall back to a 0.5 floor so even a
// tight consensus produces a meaningful divisor.
const stddev = Math.max(Number(consensus.stddev) || 0.5, 0.5);
const score = Math.min(1.0, Math.abs(playerLine - median) / stddev);
return {
score,
active: true,
explanation: `player line ${playerLine} vs consensus median ${median} (σ=${stddev})`,
};
}
const SIGNALS = [
['reverse_line_movement', signalReverseLineMovement],
['historical_hit_rate_paradox', signalHistoricalHitRateParadox],
['new_context_trap', signalNewContextTrap],
['recency_inflation', signalRecencyInflation],
['juice_degradation', signalJuiceDegradation],
['teammate_return_trap', signalTeammateReturnTrap],
['line_consensus_divergence', signalLineConsensusDivergence],
];
function recommend(composite) {
if (composite >= 0.5) return 'avoid';
if (composite >= 0.25) return 'caution';
return 'proceed';
}
async function getTrapScore(input = {}) {
const signals = {};
for (const [name, fn] of SIGNALS) {
try {
const result = await fn(input);
signals[name] = result;
} catch (err) {
signals[name] = { score: 0, active: false, explanation: `error: ${err?.message || 'unknown'}` };
}
}
const activeScores = Object.values(signals)
.filter((s) => s.active)
.map((s) => s.score);
const composite = activeScores.length === 0
? 0
: activeScores.reduce((a, b) => a + b, 0) / activeScores.length;
return {
composite,
signals,
active_count: activeScores.length,
recommendation: recommend(composite),
};
}
module.exports = {
getTrapScore,
normalizeName,
detectReturningTeammate,
__internals: {
signalReverseLineMovement,
signalHistoricalHitRateParadox,
signalNewContextTrap,
signalRecencyInflation,
signalJuiceDegradation,
signalTeammateReturnTrap,
signalLineConsensusDivergence,
recommend,
},
};
+201
View File
@@ -0,0 +1,201 @@
/**
* Engine 1 weight adjustment — the learning loop.
*
* Every resolved prop nudges Engine 1's factor weights in the direction
* the outcome implies. Factors that contributed to a winning grade get a
* small boost; factors behind a losing grade get pulled down. Each nudge
* is tiny on purpose:
* - max ±0.5% per resolution
* - weights clamped to [0.1, 5.0]
* - versioned per (sport, stat_type, factor_name) for rollback
* - skipped entirely until 20+ resolutions exist for the sport
* (don't overfit a small sample)
*
* Every adjustment writes a new row in engine1_weights — the table is
* append-only. To recall a factor's current weight, we read the latest
* version. Rolling back means inserting a new row whose weight equals
* an older version's weight.
*/
const { getSupabaseServiceClient } = require('../../utils/supabase');
const LEARNING_RATE = 0.005;
const MIN_WEIGHT = 0.1;
const MAX_WEIGHT = 5.0;
const MIN_RESOLUTIONS_TO_LEARN = 20;
const DEFAULT_WEIGHT = 1.0;
const GRADE_CONFIDENCE = {
'A+': 1.00, 'A': 0.90, 'A-': 0.80,
'B+': 0.65, 'B': 0.55, 'B-': 0.45,
'C+': 0.35, 'C': 0.25, 'C-': 0.20,
'D': 0.15, 'F': 0.10,
};
function clamp(w) {
return Math.max(MIN_WEIGHT, Math.min(MAX_WEIGHT, w));
}
async function countResolutions(sport) {
const supabase = getSupabaseServiceClient();
const { count, error } = await supabase
.from('resolution_results')
.select('id', { head: true, count: 'exact' })
.eq('sport', sport);
if (error) {
console.warn('[weightAdjuster] count failed:', error.message);
return 0;
}
return Number(count) || 0;
}
async function getCurrentWeights(sport, statType) {
// Latest version of each factor for (sport, stat_type).
const supabase = getSupabaseServiceClient();
const { data, error } = await supabase
.from('engine1_weights')
.select('factor_name, weight, version')
.eq('sport', sport)
.eq('stat_type', statType)
.order('version', { ascending: false });
if (error) {
console.warn('[weightAdjuster] read failed:', error.message);
return {};
}
const latest = {};
for (const row of data || []) {
if (!(row.factor_name in latest)) {
latest[row.factor_name] = { weight: Number(row.weight), version: row.version };
}
}
// Flatten to { factor: weight }
const out = {};
for (const k of Object.keys(latest)) out[k] = latest[k].weight;
return out;
}
async function getNextVersion(sport, statType, factorName) {
const supabase = getSupabaseServiceClient();
const { data, error } = await supabase
.from('engine1_weights')
.select('version')
.eq('sport', sport)
.eq('stat_type', statType)
.eq('factor_name', factorName)
.order('version', { ascending: false })
.limit(1);
if (error) {
console.warn('[weightAdjuster] version lookup failed:', error.message);
return 1;
}
const top = data?.[0]?.version;
return Number.isFinite(Number(top)) ? Number(top) + 1 : 1;
}
async function persistAdjustment(sport, statType, factorName, newWeight, prevWeight, reason, resolvedGradeId) {
const supabase = getSupabaseServiceClient();
const version = await getNextVersion(sport, statType, factorName);
const { error } = await supabase.from('engine1_weights').insert({
sport,
stat_type: statType,
factor_name: factorName,
weight: newWeight,
previous_weight: prevWeight,
adjustment_reason: reason,
resolved_grade_id: resolvedGradeId || null,
version,
});
if (error) {
console.warn('[weightAdjuster] insert failed:', error.message);
return null;
}
return version;
}
// Public entry point. resolvedGrade carries the Engine 1 grade, the prop's
// stat_type/sport, the resolved result, and the factors that drove the
// grade (top_factors or all_factors).
async function adjustWeights(resolvedGrade) {
const { sport, stat_type: statType, grade, result, factors, grade_id: resolvedGradeId } = resolvedGrade || {};
if (!sport || !statType || !grade || !result || !Array.isArray(factors) || factors.length === 0) {
return { skipped: true, reason: 'incomplete_input' };
}
if (result !== 'hit' && result !== 'miss') {
return { skipped: true, reason: 'non_decisive_result' };
}
const sampleCount = await countResolutions(sport);
if (sampleCount < MIN_RESOLUTIONS_TO_LEARN) {
return { skipped: true, reason: 'thin_sample', sampleCount };
}
const current = await getCurrentWeights(sport, statType);
const confidence = GRADE_CONFIDENCE[grade] ?? 0.5;
const sign = result === 'hit' ? 1 : -1;
const multiplier = 1 + sign * LEARNING_RATE * confidence;
const adjustments = [];
for (const factor of factors) {
const prev = current[factor] ?? DEFAULT_WEIGHT;
const next = clamp(prev * multiplier);
const version = await persistAdjustment(
sport, statType, factor, next, prev,
`${result} on grade ${grade}`,
resolvedGradeId,
);
adjustments.push({ factor, previous: prev, next, version });
}
return { skipped: false, adjustments, multiplier, confidence };
}
// Restore to a prior version by inserting a NEW row whose weight equals the
// target version's weight. Append-only is the safe primitive — we never
// mutate or delete history.
async function rollbackToVersion(sport, statType, factorName, targetVersion) {
const supabase = getSupabaseServiceClient();
const { data, error } = await supabase
.from('engine1_weights')
.select('weight')
.eq('sport', sport)
.eq('stat_type', statType)
.eq('factor_name', factorName)
.eq('version', targetVersion)
.maybeSingle();
if (error || !data) {
console.warn('[weightAdjuster] rollback target not found');
return false;
}
const current = (await getCurrentWeights(sport, statType))[factorName] ?? DEFAULT_WEIGHT;
const version = await persistAdjustment(
sport, statType, factorName, Number(data.weight), current,
`rollback to v${targetVersion}`,
null,
);
return Number.isFinite(version);
}
async function getWeightHistory(sport, statType, factorName, limit = 50) {
const supabase = getSupabaseServiceClient();
const { data, error } = await supabase
.from('engine1_weights')
.select('weight, previous_weight, adjustment_reason, version, created_at')
.eq('sport', sport)
.eq('stat_type', statType)
.eq('factor_name', factorName)
.order('version', { ascending: false })
.limit(limit);
if (error) return [];
return data || [];
}
module.exports = {
adjustWeights,
getCurrentWeights,
rollbackToVersion,
getWeightHistory,
LEARNING_RATE,
MIN_WEIGHT,
MAX_WEIGHT,
MIN_RESOLUTIONS_TO_LEARN,
__internals: { clamp, countResolutions, getNextVersion, persistAdjustment, GRADE_CONFIDENCE },
};