/** * 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 }, };