Files
vyndr/src/services/intelligence/analyzeViaEngine1.js
T
builtbykev d242b11b4b S1 (a1): feature-promise audit — no claim survives unverified
PROMISE-AUDIT.md: every /pricing claim → verified/built/reworded.
BUILT (was vapor): alt line ladder + edge ranking (same-features regrade
at shifted lines, Desk-gated at the API), quarter-Kelly (engine quantile
P(win) x real captured odds — either missing → no sizing), free-tier
kill-condition locked previews. FIXED (was false): analyst 15/day cap vs
the Founder 'Unlimited reads' promise → analyst unlimited; every '40+
factors' claim (real count: 22 named features) reworded truthfully in 7
files. VERIFIED: cascade alerts (real, wired), phi correlation,
leg history, cross-book comparison, WC soccer, real-time feed.
Locked by tests/unit/promiseAudit.test.js. Jest now ignores
.claude/worktrees (parallel agents' suites no longer leak into runs).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 14:24:41 -04:00

470 lines
20 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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,
},
};