184 lines
7.5 KiB
JavaScript
184 lines
7.5 KiB
JavaScript
/**
|
|
* 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 },
|
|
};
|