Files
vyndr/src/services/intelligence/analyzeViaEngine1.js
T
builtbykev 348a82b4a0 Generalize the no-edge guard: suppress by the BOOK'S PRICE, not a stat whitelist
Follow-up to the rare-event under fix — the whitelist (doubles/triples/HR/SB)
was fragile: the same juiced-under problem exists for steals, blocks, and any
other low-frequency market, and a new stat would slip through.

The real signal is the book's own price. The doubles unders were priced -625 to
-1100 — laying 6-11x to win 1x on an ~82% event, with no value the model could
recover. So the PRIMARY guard is now stat/sport-agnostic: analyzeViaEngine1
refuses any read whose graded-side odds are past the juice floor
(JUICE_ODDS_FLOOR, default -400, env-tunable). That catches every version of
this — steals, blocks, anything — and it also keeps the public record honest
(those -800 "wins" hit ~82% of the time and would inflate the hit rate, the same
class as the projection-0 degradation).

The structural rare-event rules stay as the BACKUP for props with no odds
(list also expanded cross-sport: + steals, blocks). Normal + longshot prices
(-110, -250, +600) are preserved. 16 tests cover both layers.

Reported: the doubles projection was REAL per-player (not a fallback); the fix
is the price guard, not a bigger list.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 02:09:11 -04:00

535 lines
24 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');
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)) {
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;
// 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;
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 = {}) {
// 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 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,
},
};