3591c7626e
PHASE 0 caught my own repeat of the failure I diagnosed one order ago.
91927a4 attached `served_grade` BESIDE the old letter and left `grade`
alone -- so the honest grade reached nobody, exactly as gradeBands had
been built-correct-and-unread. grep showed served_grade appearing in one
file (where I set it) and all 14+ consumers -- scan route, dashboard,
parlay, newsletter, desk, content templates, retention -- still reading
`.grade`, i.e. still the dishonest letter.
CUTOVER IS NOW TOTAL: legacy.grade IS the honest letter. Overwriting the
one field every consumer already reads cuts every surface over at once
instead of editing fourteen call sites and missing one. engine1's index is
preserved as `engine_grade` and verified read by ZERO serving code.
Confidence follows the letter: it came from a grade-band midpoint of the
OLD letter, so leaving it would have paired a served B+ with a C's
confidence. Both now derive from p_win, kept on the existing 0-100 scale.
MEASURED BLAST RADIUS before shipping: 303 of 47,991 non-refused
snapshots (0.6%) have a grade but no p_win, and now render NO READ instead
of a letter. That is correct -- their old letter came from the retired
index carrying 0.48% resolution, i.e. noise -- and NO READ is a rendered
state with a reason, so never-blank holds.
PHASE 1 — the ceiling is now a STATED POSITION, not a confusing absence.
servedGrade.SCALE_LEGEND plus web GradeScaleLegend.tsx say it plainly: we
do not issue A grades, no band has hit at a rate that would justify one,
our honest ceiling is a strong B+ (~66% realized vs ~60% baseline), and if
the model earns an A the legend changes and we say why. The
separates_from_base_rate flag renders per band -- C+/C/C- are labelled
"we cannot separate this from the baseline", which is most of any slate.
PHASE 3 hand-verified across every state: B+ with 3 factors (basis
forecast_plus_matchup_factors), B+ with none (forecast_only), C flagged
not-separable, F, and three refusal states rendering NO READ with reasons.
never-blank PASS, no-manufactured-A PASS.
Test fallout was real and is documented rather than papered over: engine
BEHAVIOUR assertions moved to engine_grade, suppression assertions stayed
on grade (a suppressed prop has no letter either way), and the confidence
78 -> 95 change is the grade-band midpoint being replaced by p_win.
No A-threshold loosening. No calibrated number leaks (deployed set empty).
p_win never mutated. Ten frozen modules verified unchanged including
engine1 and probabilityEstimator. No Bonferroni slot.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W1sivYNqY2TS5ftykmHBU9
678 lines
32 KiB
JavaScript
678 lines
32 KiB
JavaScript
/**
|
||
* 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,
|
||
},
|
||
};
|