Files
vyndr/src/services/intelligence/engine1.js
T

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