/** * analyzeViaEngine1 — the canonical single-prop analysis function. * * Composes the three pieces sessions 6c, 7e, and 7f built: * computeFeaturesForProp → engine1.gradeProp → toLegacyShape * * Output matches the legacy `analyzeProp()` shape byte-for-byte (DemoScan * + GradeCard read the same fields). The `reasoning.summary` here is built * from real feature values (l5_avg, opp_rank_stat, etc.) so users still * see concrete sentences, not abstract factor labels. * * Never throws. Every upstream failure mode is reflected as low-confidence * grade + an explanatory reasoning summary. */ const { computeFeaturesForProp } = require('./computeFeatures'); const engine1 = require('./engine1'); const { toLegacyShape } = require('../../utils/gradeAdapter'); // Map an error code from computeFeaturesForProp.meta.errors into a human // sentence the user will see in reasoning.summary. const ERROR_EXPLANATIONS = Object.freeze({ player_not_found_in_id_map: "We couldn't find this player in our roster index.", no_game_scheduled_today: "No game scheduled for this player tonight.", no_features_computed: "Statistical features unavailable for this player tonight.", }); function explainErrors(errors) { if (!Array.isArray(errors) || errors.length === 0) return ''; return errors.map((e) => ERROR_EXPLANATIONS[e] || `Data gap: ${e}.`).join(' '); } // Soccer reasoning — different signals than NBA (xG, penalty role, // altitude, referee, minutes). Concrete sentences from real values; // nothing fires unless the underlying feature is non-null. function buildSoccerReasoningLines(features = {}, meta = {}, prop = {}) { const lines = []; const statType = prop.stat_type || ''; if (Number.isFinite(features.goals_per_90)) { lines.push(`${prop.player || 'Player'} scores ${features.goals_per_90.toFixed(2)} goals per 90 minutes.`); } else if (Number.isFinite(features.l5_avg)) { lines.push(`${prop.player || 'Player'} is averaging ${features.l5_avg.toFixed(2)} ${statType} over his last 5 matches.`); } if (Number.isFinite(features.xg_per_90)) { const delta = features.xg_delta; let trend = 'tracking expectations'; if (Number.isFinite(delta)) { if (delta > 0.2) trend = 'overperforming — regression risk'; else if (delta < -0.2) trend = 'underperforming — breakout candidate'; } lines.push(`Expected goals (xG): ${features.xg_per_90.toFixed(2)} per 90 — ${trend}.`); } if (features.is_penalty_taker) { lines.push('Designated penalty taker — adds ~0.15 goals per 90 to base rate.'); } if (features.takes_free_kicks && (statType === 'goals' || statType === 'shots' || statType === 'shots_on_target')) { lines.push('Direct free-kick specialist — boosts shot/goal probability on fouls drawn.'); } if (features.takes_corners && statType === 'assists') { lines.push('Designated corner taker — meaningfully lifts assist probability.'); } if (features.altitude_impact === 'high') { lines.push(`Match at ${features.venue_altitude_ft || 'high'}ft altitude. ${features.home_continent ? 'Acclimated host team.' : 'Non-acclimatized side — historical goal reduction.'}`); } else if (features.altitude_impact === 'moderate' && !features.home_continent) { lines.push(`Moderate altitude at ${features.venue_altitude_ft || 'venue'}ft — minor stamina impact.`); } if (Number.isFinite(features.referee_cards_per_game)) { const refName = features.referee_name || 'Referee'; lines.push(`${refName} averages ${features.referee_cards_per_game.toFixed(1)} cards per match.`); } if (Number.isFinite(features.minutes_per_game) && features.minutes_per_game < 75) { lines.push(`Averaging only ${features.minutes_per_game.toFixed(0)} minutes per match — line may assume full 90.`); } if (Number.isFinite(features.opp_goals_conceded_per_game)) { lines.push(`${meta.opponentAbbr || 'Opponent'} concedes ${features.opp_goals_conceded_per_game.toFixed(2)} goals per game.`); } if (features.tournament_player && Number.isFinite(features.wc_goals_career)) { lines.push(`Tournament pedigree: ${features.wc_goals_career} career World Cup goals.`); } if (features.home_away === 1.0) lines.push('Playing at home.'); else if (features.home_away === 0.0) lines.push('Playing on the road.'); return lines; } // Build a human-readable reasoning summary + steps from the actual // features (which carry real numbers) and engine1's grade. function buildConcreteReasoning(features = {}, engine1Result = {}, meta = {}, prop = {}) { // Soccer (Session 7j) routes to a sport-specific line builder and // returns before the NBA-flavored sentences would fire. The closer // logic (trap, engine1 verdict, error gaps, steps shape) is shared // between sports and lives below this branch. const sportLc = String(meta.sport || '').toLowerCase(); const isSoccer = sportLc === 'soccer' || sportLc === 'football'; const lines = isSoccer ? buildSoccerReasoningLines(features, meta, prop) : []; if (!isSoccer) { // Recent form vs the line — L5 and L20 are the orchestrator's // canonical season-trend signals. if (Number.isFinite(features.l5_avg)) { lines.push(`${prop.player || 'Player'} is averaging ${features.l5_avg.toFixed(1)} ${prop.stat_type || ''} over his last 5 games.`); } if (Number.isFinite(features.l20_avg)) { lines.push(`Last 20 games average: ${features.l20_avg.toFixed(1)}.`); } // Trend direction relative to the line. if (Number.isFinite(features.l5_avg) && Number.isFinite(prop.line)) { const diff = features.l5_avg - prop.line; if (Math.abs(diff) >= 0.5) { const dir = diff > 0 ? 'above' : 'below'; lines.push(`That's ${Math.abs(diff).toFixed(1)} ${dir} the line of ${prop.line}.`); } } // Home / away. if (features.home_away === 1.0) lines.push('Playing at home tonight.'); else if (features.home_away === 0.0) lines.push('Playing on the road tonight.'); } if (!isSoccer) { // Opponent matchup. opp_rank_stat is 0..1 normalized // (0 = best D, 1 = worst D) — translate to friendlier language. if (Number.isFinite(features.opp_rank_stat) && meta.opponentAbbr) { if (features.opp_rank_stat >= 0.7) { lines.push(`${meta.opponentAbbr} is a bottom-tier defense vs this stat.`); } else if (features.opp_rank_stat <= 0.3) { lines.push(`${meta.opponentAbbr} is a top-tier defense vs this stat.`); } else { lines.push(`${meta.opponentAbbr} is a middling defense vs this stat.`); } } // Rest / fatigue context. if (features.rest_days === 0) lines.push('Back-to-back — fatigue concern.'); else if (Number.isFinite(features.rest_days) && features.rest_days >= 2) { lines.push(`${features.rest_days} days of rest.`); } if (Number.isFinite(features.game_count_in_7d) && features.game_count_in_7d >= 4) { lines.push(`Heavy workload — ${features.game_count_in_7d} games in the last week.`); } // Injury context. if (Number.isFinite(features.injury_severity_score) && features.injury_severity_score > 0) { lines.push(`${features.injury_severity_score} opponent starter(s) on the injury report.`); } } // Trap composite — surfaced when meaningful. // (Adapter handles the per-factor kill_conditions chips; this line // gives the user the overall warning.) if (engine1Result?.grade && engine1Result.grade.endsWith('-') === false && Array.isArray(engine1Result.all_factors) && engine1Result.all_factors.includes('trap_composite_high')) { lines.push('Multiple trap signals firing — proceed with caution.'); } // Engine-1 verdict capper. const grade = engine1Result?.grade; if (grade) { const verb = grade.startsWith('A') ? 'favors the play' : grade.startsWith('B') ? 'leans toward the play' : grade.startsWith('C') ? 'is split' : 'leans against the play'; lines.push(`Engine 1 graded ${grade} — ${verb}.`); } // Tack on any data-gap explanations. const gapNote = explainErrors(meta.errors); if (gapNote) lines.push(gapNote); const summary = lines.join(' ').trim() || `Analysis complete. Grade: ${grade || 'C'}.`; // Legacy-shaped steps so backward-compat callers (integration tests, // anything pre-dating the engine swap) keep seeing the named // sub-blocks. Each is populated from engine1 features where the data // exists; missing sub-blocks contain null fields instead of being // absent so callers can dot-access without optional chaining. const seasonAvg = Number.isFinite(features.l20_avg) ? features.l20_avg : null; const recentAvg = Number.isFinite(features.l5_avg) ? features.l5_avg : null; const haContext = features.home_away === 1.0 ? 'home' : features.home_away === 0.0 ? 'away' : null; const restContext = features.rest_days === 0 ? 'b2b' : Number.isFinite(features.rest_days) && features.rest_days >= 2 ? 'rested' : null; return { summary, steps: { season_avg: { value: seasonAvg, vs_line: seasonAvg != null && Number.isFinite(prop.line) ? Math.round((seasonAvg - prop.line) * 10) / 10 : null, signal: null, }, recent_form: { value: recentAvg, vs_line: recentAvg != null && Number.isFinite(prop.line) ? Math.round((recentAvg - prop.line) * 10) / 10 : null, signal: null, }, situational: { home_away: { value: null, context: haContext, signal: null }, rest_days: { value: features.rest_days ?? null, context: restContext, signal: null }, vs_opponent: { value: null, games: null, signal: null }, }, line_comparison: { best_line: null, worst_line: null, edge_from_best: 0, signal: null, }, kill_conditions: [], final_grade: grade || null, // Flat narrative bullets — kept under `steps` so legacy clients // can still find them but they don't collide with the named // sub-blocks above. narrative: lines.map((line, i) => ({ step: i + 1, detail: line })), }, }; } // The model's projection: the same reference the edge is computed from. // Recent-form averages first (l5, else l20); soccer props (which carry // per-90 rates instead of game averages) fall back to `{stat}_per_90`, // then xG for goals. This is a REAL model number — it is never the line. // When it's null the model has no projection and the read must refuse // (insufficient_data), not ship a hollow grade. function projectionFor(features, prop) { const f = features || {}; const round2 = (n) => Math.round(n * 100) / 100; if (Number.isFinite(f.l5_avg)) return round2(f.l5_avg); if (Number.isFinite(f.l20_avg)) return round2(f.l20_avg); const stat = String(prop?.stat_type || '').toLowerCase(); const per90 = f[`${stat}_per_90`]; if (Number.isFinite(per90)) return round2(per90); if (stat === 'goals' && Number.isFinite(f.xg_per_90)) return round2(f.xg_per_90); return null; } // edge_pct in the legacy shape compares the model projection to the line. function edgePctFor(features, prop) { const ref = projectionFor(features, prop); if (ref == null || !Number.isFinite(prop?.line) || prop.line === 0) return 0; const signed = prop.direction === 'over' ? (ref - prop.line) : (prop.line - ref); return Math.round((signed / prop.line) * 1000) / 10; } // Session 58 (work-order 1.5) — when the model has NO projection there is no // read. This used to return grade 'C' / confidence 10 / edge 0 — the exact // "hollow C" the live audit caught (model==line, +0% edge). A refused read // builds more trust than a fake one: grade is null, insufficient_data is // true, and NOTHING downstream (grades cache, snapshot, ledger) persists it. function insufficientDataResult(rawProp, errors) { return { player: rawProp.player ?? null, stat_type: rawProp.stat_type ?? null, line: rawProp.line ?? null, direction: rawProp.direction ?? null, book: rawProp.book || 'unknown', grade: null, insufficient_data: true, confidence: 0, edge_pct: 0, projection: null, kill_conditions_triggered: [], reasoning: { summary: `INSUFFICIENT DATA — no read. ${explainErrors(errors) || 'The model has no projection for this prop.'}`.trim(), steps: [], }, }; } /** * Form score (0..100) from recent-vs-baseline averages (Session 43). Hot * (l5 > l20) trends above 70; cold below. undefined when there's no recent avg. */ function computeFormScore(features = {}) { const l5 = features.l5_avg; if (!Number.isFinite(l5)) return undefined; const base = Number.isFinite(features.l20_avg) ? features.l20_avg : Number.isFinite(features.l10_avg) ? features.l10_avg : null; if (base == null || base === 0) return 75; const ratio = l5 / base; return Math.round(Math.max(40, Math.min(99, 70 + (ratio - 1) * 60))); } function matchupGradeFromRank(rank) { if (!Number.isFinite(rank)) return undefined; if (rank >= 0.66) return 'A'; if (rank >= 0.5) return 'B+'; if (rank >= 0.33) return 'B'; return 'C'; } /** * Intelligence fields for the grade card (Session 43) — STAT CONTEXT + VYNDR * INTELLIGENCE. Computed ONLY from the already-built feature vector (no extra * I/O), so every field is optional and self-hides on the card when absent. * NOTE: archetype is intentionally NOT set here — the per-prop feature vector * doesn't carry a full multi-stat season line, so classifying it would just * yield the fallback. The archetype strip lights up once the snapshot pipeline * (Session 44) feeds per-player season lines into the grade response. */ const firstFinite = (...vals) => vals.find((v) => Number.isFinite(v)); function buildIntelFields(features = {}, opts = {}) { const out = {}; const round1 = (n) => Math.round(n * 10) / 10; // Resilience (Session 46): fall back to a caller-supplied playerStats bundle // and the model projection when the feature vector is sparse. Partial intel // beats none — we add only the fields we can actually back with a number. const ps = opts.playerStats || {}; const proj = Number.isFinite(opts.projection) ? opts.projection : undefined; const seasonAvg = firstFinite(features.l20_avg, features.season_avg, ps.season_avg, proj); if (seasonAvg != null) out.season_avg = round1(seasonAvg); const last10 = firstFinite(features.l10_avg, features.l5_avg, ps.last10_avg); if (last10 != null) out.last10_avg = round1(last10); let form = computeFormScore(features); if (form == null && Number.isFinite(ps.form)) form = Math.round(ps.form); if (form != null) out.form = form; if (Number.isFinite(features.usage_rate)) out.usage = `${round1(features.usage_rate)}%`; else if (Number.isFinite(features.minutes_per_game)) out.usage = `${Math.round(features.minutes_per_game)} min`; else if (Number.isFinite(features.ab_per_game)) out.usage = `${round1(features.ab_per_game)} AB/G`; // MLB usage equivalent else if (ps.usage) out.usage = String(ps.usage); const matchup = matchupGradeFromRank(features.opp_rank_stat) || (Number.isFinite(features.bvp_advantage) ? (features.bvp_advantage > 0.05 ? 'A' : features.bvp_advantage > 0 ? 'B+' : 'C') : null); if (matchup) out.matchup_grade = matchup; if (Number.isFinite(features.rest_days)) out.rest = features.rest_days === 0 ? 'B2B' : `${features.rest_days}d rest`; return out; } async function analyzeViaEngine1(rawProp = {}) { const featureResult = await computeFeaturesForProp(rawProp); const { features, trap, consistency, prop, meta } = featureResult; // Hard refusal when computeFeatures couldn't produce anything useful at // all (no features AND no consistency input) — there is nothing to grade. if ((!features || Object.keys(features).length === 0) && (!consistency || consistency.consistency === 'unknown') && (!Array.isArray(meta?.gameLogs) || meta.gameLogs.length === 0)) { return insufficientDataResult(rawProp, meta?.errors); } // Session 58 (work-order 1.5) — no projection ⇒ no read. Without a model // reference the edge is fictional and the grade would be hollow. const projection = projectionFor(features, { ...rawProp, line: prop.line }); if (projection == null) { return insufficientDataResult(rawProp, meta?.errors); } // Engine 1: deterministic rule-based grade on the feature vector. const engine1Result = engine1.gradeProp({ features, trap, consistency, prop }); // Translate engine1 output → legacy shape via the adapter from 7e. // The adapter handles kill_conditions_triggered + the 4-letter grade // collapse + the 0-100 confidence scale. const summaryOverride = buildConcreteReasoning(features, engine1Result, meta, { ...rawProp, line: prop.line, }).summary; const legacy = toLegacyShape(engine1Result, { player: rawProp.player, stat_type: rawProp.stat_type, line: prop.line, direction: prop.direction, book: rawProp.book || 'unknown', sport: meta.sport, }, { summaryOverride, edgePct: edgePctFor(features, prop), }); // The adapter's reasoning.steps was a single-element debug bag; // replace it with the line-by-line breakdown we built above so the // legacy UI's step list looks identical to before. legacy.reasoning = buildConcreteReasoning(features, engine1Result, meta, { ...rawProp, line: prop.line, }); // Session 43 — attach grade-card intelligence fields (stat context + VYNDR // intelligence). Optional + self-hiding on the card; zero extra I/O. Object.assign(legacy, buildIntelFields(features)); // Session 58 — the model's REAL projection (never the line). Persisted as // ledger model_value and rendered under the MODEL label on the card. legacy.projection = projection; // Session 62 (A1-S1) — ALT LINE LADDER + EDGE RANKING. The pricing page // sold it; the adapter/card were already built for it; nothing produced // it. Re-grade the SAME feature vector at shifted lines (zero extra I/O) // and rank by edge. Attached for every graded result; the API tier gate // strips it below Desk. try { const baseLine = Number(prop.line); if (Number.isFinite(baseLine)) { const ladder = [-1, -0.5, 0, 0.5, 1] .map((shift) => Math.round((baseLine + shift) * 100) / 100) .filter((ln) => ln > 0) .map((ln) => { const shiftedProp = { ...prop, line: ln }; const g = engine1.gradeProp({ features, trap, consistency, prop: shiftedProp }); const adapted = toLegacyShape(g, { player: rawProp.player, stat_type: rawProp.stat_type, line: ln, direction: prop.direction, book: rawProp.book || 'unknown', sport: meta.sport, }, { edgePct: edgePctFor(features, { ...shiftedProp, stat_type: rawProp.stat_type, direction: prop.direction }) }); return { line: ln, grade: adapted.grade, edge_pct: adapted.edge_pct, base: ln === baseLine }; }) .sort((a, b) => (Number(b.edge_pct) || 0) - (Number(a.edge_pct) || 0)); if (ladder.length > 1) legacy.alt_lines = ladder; } } catch { /* the ladder is additive — never breaks the read */ } // Session 62 (A1-S1) — QUARTER-KELLY. Real probability (quantile estimator // over the actual game logs) × real book odds, or nothing. Never derived // from confidence, never a default vig. try { const { estimateProbability } = require('./probabilityEstimator'); const est = estimateProbability({ gameLogs: meta.gameLogs, line: prop.line, statType: rawProp.stat_type, features }); const pWin = String(prop.direction || 'over').toLowerCase() === 'under' ? (Number.isFinite(est.p_over) ? 1 - est.p_over : null) : (Number.isFinite(est.p_over) ? est.p_over : null); if (pWin != null) legacy.p_win = Math.round(pWin * 1000) / 1000; const sideOdds = String(prop.direction || 'over').toLowerCase() === 'under' ? rawProp.under_odds : rawProp.over_odds; if (pWin != null && sideOdds != null) { const { quarterKelly } = require('../../utils/kelly'); const k = quarterKelly(pWin, sideOdds); if (k) legacy.kelly = { ...k, odds: String(sideOdds) }; } } catch { /* sizing is additive — absent beats wrong */ } return legacy; } module.exports = { analyzeViaEngine1, __internals: { buildConcreteReasoning, edgePctFor, projectionFor, insufficientDataResult, explainErrors, ERROR_EXPLANATIONS, buildIntelFields, computeFormScore, matchupGradeFromRank, }, };