/** * 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'); const { isSuppressedRareUnder, isSuppressedRareOver, isTooJuiced, gradedSideOdds } = require('../../config/rareEventMarkets'); // 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)) { // LABEL FIXED WITH THE DATA. `l20_avg` is now built from the full season // log rather than a ten-game slice, so "Last 20 games" was a sentence the // number no longer supported. The field name is kept (it is read in many // places) but the copy states what is actually being shown. lines.push(`Season 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; // A projection must be a POSITIVE model reference. A non-positive value (0 // or negative) is not a real projection — it yields a degenerate edge // ((line - 0) / line = 100%) and a hollow grade (the audit's projection=0 // nine). Skip non-positive candidates and fall through to the next real // reference; when NONE is positive, return null so the read REFUSES // (insufficient_data) instead of grading on zero. const pos = (n) => (Number.isFinite(n) && n > 0 ? round2(n) : null); const stat = String(prop?.stat_type || '').toLowerCase(); return pos(f.l5_avg) ?? pos(f.l20_avg) ?? pos(f[`${stat}_per_90`]) ?? (stat === 'goals' ? pos(f.xg_per_90) : null); } // edge_pct in the legacy shape compares the model projection to the line: // (model - line) / line, signed by direction. It is a projection-vs-line gap, // always LABELLED MODEL in the UI. `ref` may be passed to reuse the exact // projection the read was validated on (so edge and the persisted projection // can never diverge); omitted, it recomputes (used by the alt-line ladder, // where the projection is line-independent so recompute is equivalent). function edgePctFor(features, prop, ref) { const r = ref === undefined ? projectionFor(features, prop) : ref; if (r == null || !Number.isFinite(prop?.line) || prop.line === 0) return 0; const signed = prop.direction === 'over' ? (r - prop.line) : (prop.line - r); 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: [], }, }; } // Betting-logic audit (2026-07-19) — rare-event 0.5 markets (doubles/triples/ // HR/SB) have no takeable edge on the UNDER (juiced) and no edge on the OVER // unless the model genuinely projects the event above the line. We REFUSE those // (grade null + insufficient_data so every consumer's no-read handling applies) // with a distinct `suppressed` flag/reason. function suppressedRareResult(rawProp, reason, summary) { 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, suppressed: true, suppressed_reason: reason, confidence: 0, edge_pct: 0, projection: null, kill_conditions_triggered: [], reasoning: { summary, 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; // Session 65 — was `return 75`: a hardcoded constant rendered under the FORM // label whenever the baseline was missing or zero. With no baseline there is // no recent-vs-baseline ratio, so the field is absent and the card's // self-hiding intel section drops the row. Absent renders absent. if (base == null || base === 0) return undefined; 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`; // OPPORTUNITY DRIFT (2026-08-01) — carried onto the grade so the challenger // can read it WITHOUT a second fetch. The feature is already computed here; // re-resolving it downstream would add per-prop I/O to a path that grades // hundreds of props in a tight loop. // // Raw numbers only — no display string. This is a model input, not a card // field, and rendering an unvalidated proxy as if it were a finding is the // thing we keep removing. if (Number.isFinite(features.opportunity_drift)) out.opportunity_drift = Math.round(features.opportunity_drift * 1000) / 1000; if (Number.isFinite(features.recent_ab_per_game)) out.recent_ab_per_game = round1(features.recent_ab_per_game); if (Number.isFinite(features.ab_per_game)) out.ab_per_game = round1(features.ab_per_game); return out; } async function analyzeViaEngine1(rawProp = {}) { // Betting-logic audit — the GENERAL no-edge guard (stat/sport-agnostic): the // book's own price is the truth. A side priced past the juice floor (e.g. // those doubles unders at -625 to -1100) has no takeable edge, so refuse it up // front. This is what makes the fix robust instead of a fragile whitelist. if (isTooJuiced(rawProp)) { return suppressedRareResult(rawProp, 'juiced_no_edge', `No read — the book prices this side at ${gradedSideOdds(rawProp)}; the vig has eaten any edge.`); } // Backup for props with NO odds — the structural rare-event rule. Suppress the // juiced UNDER on a 0.5-line rare counting stat (doubles/triples/HR/SB/steals/ // blocks). (The OVER is gated on the projection below.) if (isSuppressedRareUnder(rawProp.stat_type, rawProp.line, rawProp.direction)) { return suppressedRareResult(rawProp, 'rare_event_under', `No read — a ${rawProp.line} under on ${rawProp.stat_type} is a juiced rare-event market, not a takeable edge.`); } 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. // 2026-07 hardening: the invariant is STRUCTURAL — a grade can NEVER be // emitted with a non-positive projection. projectionFor already nulls // non-positive references; the explicit `> 0` guard defends the law even if // that ever changes. Refusal is the correct output (fewer graded props, honest). const projection = projectionFor(features, { ...rawProp, line: prop.line }); if (projection == null || !(projection > 0)) { return insufficientDataResult(rawProp, meta?.errors); } // Betting-logic audit — a rare-event 0.5 OVER is a read ONLY when the model // genuinely projects the event ABOVE the line. Below that, the over carries // the same |edge| as the (already-suppressed) under and would just take its // place on the board — so refuse it. This is what actually clears the market. if (isSuppressedRareOver(rawProp.stat_type, prop.line, prop.direction, projection)) { return suppressedRareResult(rawProp, 'rare_event_over_below_line', `No read — the model projects ${projection} ${rawProp.stat_type}, at or below the ${prop.line} line; the over is not a genuine event projection.`); } // 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, projection), // reuse the validated projection }); // 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 64 — RETENTION: expose the feature vector + the pre-collapse // 11-step grade so `model_snapshots` can store the model's INPUTS, not just // its output. Without inputs a backtest can only grade our own homework. // Underscore-prefixed = internal: gradeSlateService strips these before the // grade reaches any cache or API payload. legacy._features = features; legacy._grade_11 = engine1Result && engine1Result.grade ? engine1Result.grade : null; // 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 FIX (2026-07-29, specs/grade-board-sort.md). Was // `edge_pct desc` — a price-free (proj−line)/line artifact whose scale is // a function of line size, so on a 0.5 line it explodes and ordered the // ladder by nothing meaningful. `Number(x) || 0` also collapsed absent // edges to 0 (mid-pack). // // Now ordered HIGHEST-p_win-FIRST, derived analytically at zero added // compute: P(stat ≥ k) is monotone NON-INCREASING in k, so for an OVER // p_win-desc is exactly line-ASC, and for an UNDER (p_win = 1 − p_over) // it is exactly line-DESC. Rungs carry NO per-rung price — books price // each line differently and we do not fetch them — so the hero's // takeable gate (a property of price alone) is inapplicable here; that // is why this ranks on probability order only. The `base` rung stays // marked, so ordering never implies a recommendation. .sort((a, b) => (String(prop.direction || 'over').toLowerCase() === 'under' ? Number(b.line) - Number(a.line) : Number(a.line) - Number(b.line))); if (ladder.length > 1) legacy.alt_lines = ladder; } } catch { /* the ladder is additive — never breaks the read */ } // Session 62 (A1-S1) — QUARTER-KELLY + Model Train (steps 1-6): de-vig, EV, // the value triplet, and the takeable/value flags. 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 { devigTwoWay, evPct, impliedProbToAmerican } = require('../../utils/devig'); const { isTakeable, isValue } = require('../../config/valueEngine'); const dir = String(prop.direction || 'over').toLowerCase(); const est = estimateProbability({ gameLogs: meta.gameLogs, line: prop.line, statType: rawProp.stat_type, features }); // ── PROVEN FACTORS, PRE-GRADE ──────────────────────────────────────── // base rate -> FACTORS -> (calibration, later) -> grade. Only the three // factors that passed the two-part gate, only on hits, and only where each // is readable — every unreadable case leaves the forecast untouched rather // than nudging it toward a default. let pOver = est.p_over; let factorTrace = null; if (String(rawProp.stat_type || '').toLowerCase() === 'hits' && rawProp.factor_context) { try { const hf = require('../model/hitsFactors'); const adj = hf.adjustProbability(pOver, rawProp.factor_context); if (adj.p_adjusted != null && adj.factors_fired > 0) { pOver = adj.p_adjusted; factorTrace = { multiplier: adj.multiplier, applied: adj.applied, skipped: adj.skipped, p_before: adj.p_base }; } } catch { /* a factor must never break the grade */ } } const pWin = dir === 'under' ? (Number.isFinite(pOver) ? 1 - pOver : null) : (Number.isFinite(pOver) ? pOver : null); if (pWin != null) legacy.p_win = Math.round(pWin * 1000) / 1000; // ── THE SERVED GRADE ──────────────────────────────────────────────── // Derived from the forecast, not from engine1's additive factor index. // Measured on 3,417 settled props, that index carried 0.16x the information // of the p_win printed beside it, and its A grade hit 0.500 while its F hit // 0.535 -- the top letter did worse than the bottom. Concretely: the same // hitter's 0.95 over and 0.05 under both graded C. // // engine1.grade is PRESERVED on the payload as `engine_grade` so nothing // downstream breaks and the two remain comparable, but `served_grade` is // what a user should see. try { const sg = require('../model/servedGrade'); const served = sg.gradeFor({ p_win: legacy.p_win, refused: legacy.refused || legacy.insufficient_data, refusal_reason: legacy.refusal_reason, factor_adjustment: factorTrace, }); legacy.served_grade = served; // ── TOTAL CUTOVER, NOT A PARALLEL GRADE ──────────────────────────── // `legacy.grade` IS the honest letter now. Attaching served_grade beside // the old one and leaving `grade` alone would have repeated the exact // failure diagnosed for gradeBands: built, correct, and read by nobody. // Fourteen-plus consumers (scan route, dashboard, parlay, newsletter, // desk, content templates, retention) all read `.grade`, so overwriting // it here cuts every surface over at once instead of editing each. // // The original index is preserved as `engine_grade` for comparison and is // read by no serving code. legacy.engine_grade = legacy.grade; if (served.letter) { legacy.grade = served.letter; // Confidence must not contradict the letter. It previously came from a // grade-band midpoint of the OLD letter, so leaving it would have paired // a B+ with a C's confidence. Both now derive from p_win -- kept on the // existing 0-100 scale, since every consumer and every stored row uses it. legacy.confidence = Math.round(legacy.p_win * 100); legacy.confidence_basis = 'p_win'; } else { // A refusal has no letter, and consumers test `!grade` for exactly that. legacy.grade = null; } } catch { /* the grade surface must never break the read */ } if (factorTrace) { legacy.factor_adjustment = factorTrace; legacy.p_win_prefactor = Math.round((dir === 'under' ? 1 - factorTrace.p_before : factorTrace.p_before) * 1000) / 1000; } const sideOdds = dir === 'under' ? rawProp.under_odds : rawProp.over_odds; // Quarter-Kelly sizing (unchanged). if (pWin != null && sideOdds != null) { const { quarterKelly } = require('../../utils/kelly'); const k = quarterKelly(pWin, sideOdds); if (k) legacy.kelly = { ...k, odds: String(sideOdds) }; } // The VALUE TRIPLET (step 6): book price · fair (de-vigged) price · model // price. Two-way de-vig needs BOTH sides; one side missing → fair absent. if (sideOdds != null) legacy.book_odds = Number(sideOdds); const dv = devigTwoWay(rawProp.over_odds, rawProp.under_odds); if (dv) { const fair = dir === 'under' ? dv.under : dv.over; legacy.fair_prob = fair.fair_prob; legacy.fair_odds = fair.fair_odds; legacy.devig_method = dv.method; legacy.overround = dv.overround; } if (pWin != null) legacy.model_odds = impliedProbToAmerican(pWin); // EV at the ACTUAL price (step 2) + the takeable/value flags (steps 3-4). // `takeable` is a property of the price alone; `value` also needs the edge. if (sideOdds != null) legacy.takeable = isTakeable(sideOdds); if (pWin != null && sideOdds != null) { const ev = evPct(pWin, sideOdds); if (ev != null) { legacy.ev_pct = ev; legacy.value = isValue(sideOdds, ev); } } } catch { /* the value layer is additive — absent beats wrong */ } return legacy; } module.exports = { analyzeViaEngine1, __internals: { buildConcreteReasoning, edgePctFor, projectionFor, insufficientDataResult, explainErrors, ERROR_EXPLANATIONS, buildIntelFields, computeFormScore, matchupGradeFromRank, }, };