Sessions 5-7a: 955 tests, deployment ready
This commit is contained in:
@@ -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 },
|
||||
};
|
||||
Reference in New Issue
Block a user