09186ea609
PHASE 0 re-check caught the same failure a THIRD time, mine again. Last
turn I created GradeScaleLegend.tsx and never mounted it, and I reported
that the separation flag "renders per band" -- it did not. grep: zero
frontend references to separates_from_base_rate, served_grade or
factor_adjustment. The honest grade was reaching the API payload and dying
at the adapter boundary.
That is three occurrences in three orders of the same shape: built,
correct, unread. gradeBands, then served_grade, then the legend.
PHASE 2 — the composed surface is wired end to end:
analyzeViaEngine1 -> served_grade + factor_adjustment on the payload
scan/page.tsx -> ScanResponse types them and forwards them
gradeAdapter -> gradeMeaning, separatesFromBaseRate,
bandRealizedRate, factorsApplied, refusalReason
GradeResultCard -> renders "WHAT THIS GRADE MEANS"
What a user now sees that they could not before: what the band has
actually realized, an amber note when the read CANNOT be separated from
the baseline, and -- only where a factor actually fired, with its proven
sign -- what moved the read, in plain language rather than feature names
("where he hits it vs who is standing there").
No narrative on props where nothing fired: factorsApplied is empty and the
block self-hides. Only the three PROVEN hits factors have labels, so an
unproven factor cannot acquire prose by being added to the map.
PHASE 1 — GradeScaleLegend is now MOUNTED on the grade card (compact). The
ceiling is a stated position where the grade is, not a page a user would
have to find.
PHASE 3 hand-verified through the real adapter across twelve states: B+
with 3/0 factors, B with 1, C+/C/C- all flagged not-separable, D, F, a
switch-hitter case where 2 of 3 factors fire, two refusals and a
no-forecast. never-blank PASS, no-manufactured-A PASS, no-narrative-when-
nothing-fired PASS, separation-flag-reaches-card PASS.
No A-threshold loosening. No calibrated number leaks. p_win never mutated.
engine_grade still read by zero serving code. No Bonferroni slot.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W1sivYNqY2TS5ftykmHBU9
206 lines
9.7 KiB
JavaScript
206 lines
9.7 KiB
JavaScript
/* ============================================================
|
||
VYNDR 2.0 — grade adapter (§7).
|
||
Maps our grading-engine output (the /api/scan ScanResponse shape +
|
||
the GradeCard props) onto the GradeResultCard data contract from the
|
||
design spec. Plain CommonJS so the .tsx card imports it (allowJs) AND
|
||
Jest exercises the mapping logic directly (no TS/Babel transform).
|
||
============================================================ */
|
||
|
||
const STAT_LABELS = {
|
||
points: 'Points', rebounds: 'Rebounds', assists: 'Assists', threes: '3-Pointers',
|
||
steals: 'Steals', blocks: 'Blocks', pra: 'P+R+A', turnovers: 'Turnovers',
|
||
strikeouts: 'Strikeouts', hits_allowed: 'Hits Allowed', earned_runs: 'Earned Runs',
|
||
innings_pitched: 'Innings Pitched', hits: 'Hits', total_bases: 'Total Bases',
|
||
rbi: 'RBI', runs: 'Runs', home_runs: 'Home Runs',
|
||
};
|
||
|
||
/** Humanize a stat_type id ("home_runs" → "Home Runs"). */
|
||
function statLabel(stat) {
|
||
if (!stat) return '';
|
||
if (STAT_LABELS[stat]) return STAT_LABELS[stat];
|
||
return String(stat).replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
|
||
}
|
||
|
||
/** Signed edge as a percentage of the line, from projection vs line. */
|
||
function computeEdge(projection, line, direction) {
|
||
if (projection == null || line == null) return 0;
|
||
const raw = direction === 'under' ? line - projection : projection - line;
|
||
const pct = line > 0 ? (raw / line) * 100 : raw;
|
||
return Math.round(pct * 10) / 10;
|
||
}
|
||
|
||
/** A/A+ with strong support → "phosphor confirmed". */
|
||
function isPhosphorConfirmed(grade, confidence, sampleSize) {
|
||
const g = String(grade || '').trim().toUpperCase();
|
||
const strong = g === 'A+' || g === 'A';
|
||
return strong && ((confidence != null && confidence >= 70) || (sampleSize != null && sampleSize >= 30));
|
||
}
|
||
|
||
/** factors object/array → plain-English signal bullets (4–6). */
|
||
function toSignals(factors) {
|
||
if (!factors) return [];
|
||
if (Array.isArray(factors)) return factors.filter(Boolean).slice(0, 6).map(String);
|
||
return Object.entries(factors)
|
||
.filter(([, v]) => Boolean(v))
|
||
.slice(0, 6)
|
||
.map(([k, v]) => `${k.replace(/_/g, ' ').replace(/\b\w/, (c) => c.toUpperCase())}: ${v}`);
|
||
}
|
||
|
||
/**
|
||
* Map an engine result to the GradeResultCard contract.
|
||
* input: { player, team, sport, stat, line, direction|side, grade, projection,
|
||
* confidence, sample_size, factors, alt_lines, kill_conditions, books, tier }
|
||
*/
|
||
function mapScanToGradeResult(input = {}) {
|
||
const direction = (input.side || input.direction || 'over').toString().toLowerCase();
|
||
const side = direction === 'under' ? 'Under' : 'Over';
|
||
const line = input.line != null ? Number(input.line) : 0;
|
||
const projection = input.projection != null ? Number(input.projection) : undefined;
|
||
const confidence = input.confidence != null ? Math.round(Number(input.confidence)) : 0;
|
||
const tier = input.tier || 'free';
|
||
const includeAlt = tier === 'desk';
|
||
// Tier gating so the new card doesn't give paid content away (free users get
|
||
// a 3-signal teaser; kill conditions are Analyst+; alt ladder is Desk). The
|
||
// richer blurred-paywall treatment returns in Phase G (Session 38).
|
||
const allSignals = toSignals(input.factors);
|
||
const signals = tier === 'free' ? allSignals.slice(0, 3) : allSignals;
|
||
// Session 62 (A1-S1) — the pricing page promises free users a LOCKED
|
||
// PREVIEW of kill conditions (the count + codes exist server-side via
|
||
// tierGating), not their absence. Free sees "⚠ TRAP — upgrade to see
|
||
// details"; paid sees the reasons.
|
||
const killConditions = tier === 'free'
|
||
? (input.kill_conditions || [])
|
||
.map((k) => (k && k.code ? `${k.code} — upgrade to see details` : ''))
|
||
.filter(Boolean)
|
||
: (input.kill_conditions || []).map((k) => (typeof k === 'string' ? k : (k && k.reason) || '')).filter(Boolean);
|
||
|
||
return {
|
||
player: input.player || '',
|
||
team: input.team || '',
|
||
sport: (input.sport || 'nba').toString().toLowerCase(),
|
||
// Wave 2A — real headshot ids (optional; card self-hides to a monogram).
|
||
playerId: input.playerId != null ? input.playerId : null,
|
||
espnId: input.espnId != null ? input.espnId : null,
|
||
stat: statLabel(input.stat),
|
||
line,
|
||
side,
|
||
grade: input.grade || '—',
|
||
confidence,
|
||
// ── THE HONEST GRADE FIELDS ────────────────────────────────────────────
|
||
// The served grade knows whether its band can actually be separated from
|
||
// the baseline, and which proven factors (if any) moved the forecast. Both
|
||
// were being computed and thrown away at this boundary. A grade that cannot
|
||
// separate must SAY so on the card -- that flag is the honest core, and
|
||
// carrying it in an object nobody reads is the same as not having it.
|
||
gradeMeaning: (input.served_grade && input.served_grade.meaning) || null,
|
||
separatesFromBaseRate: input.served_grade
|
||
? input.served_grade.separates_from_base_rate === true : null,
|
||
bandRealizedRate: (input.served_grade && input.served_grade.band_realized_rate) ?? null,
|
||
// Only factors that ACTUALLY fired, with their proven sign. No narrative on
|
||
// props where nothing fired.
|
||
factorsApplied: (input.factor_adjustment && Array.isArray(input.factor_adjustment.applied))
|
||
? input.factor_adjustment.applied.map((a) => ({
|
||
factor: a.factor,
|
||
direction: a.multiplier > 1 ? 'up' : 'down',
|
||
multiplier: a.multiplier,
|
||
}))
|
||
: [],
|
||
refusalReason: (input.served_grade && input.served_grade.state !== 'graded')
|
||
? input.served_grade.meaning : null,
|
||
// DATA SEMANTICS (Session 58): projection is MODEL output and must never
|
||
// be fabricated. The old fallback displayed the LINE as the projection —
|
||
// the audit's model==line / +0% edge degenerate. No projection → the
|
||
// MODEL row is absent and edge is null (the card renders an absent state,
|
||
// not a fake zero-edge read).
|
||
edge: projection != null ? computeEdge(projection, line, direction) : null,
|
||
projection: projection != null ? Math.round(projection * 10) / 10 : null,
|
||
phosphorConfirmed: isPhosphorConfirmed(input.grade, input.confidence, input.sample_size),
|
||
signals,
|
||
killConditions,
|
||
books: Array.isArray(input.books) ? input.books : [],
|
||
altLadder: includeAlt && Array.isArray(input.alt_lines)
|
||
? input.alt_lines.map((a) => ({ line: a.line, grade: a.grade, edge: a.edge_pct, base: a.base }))
|
||
: [],
|
||
// Session 62 (A1-S1) — quarter-Kelly (Desk; API already strips below).
|
||
kelly: includeAlt && input.kelly && input.kelly.pct != null ? input.kelly : undefined,
|
||
// Session 42 — Player Intelligence additions. All OPTIONAL + self-hiding:
|
||
// they only render once the engine supplies them (Session 43 data pipeline).
|
||
...buildIntelFields(input),
|
||
// Session 66 — the PRICE LAYER (book · fair · model). Self-hiding: absent
|
||
// unless the engine supplied real prices, so a read with no market prices
|
||
// renders the projection alone rather than an empty gauge. NEVER
|
||
// fabricated — the design file's numbers are a spec, not a fallback.
|
||
...buildPriceTriplet(input),
|
||
};
|
||
}
|
||
|
||
/**
|
||
* buildPriceTriplet(input) — the optional price-layer block for the card.
|
||
*
|
||
* Returns `{}` (section stays hidden) unless the engine supplied at least a
|
||
* book price AND a fair price: with no honest de-vigged number there is no
|
||
* price story to tell, and a lone book price is just the market repeated back.
|
||
*
|
||
* `model_price_locked` is set by the SERVER (utils/tierGating strips
|
||
* `model_odds` for unentitled tiers and flags it) — the free tier never
|
||
* receives the model price, so the lock is real, not a client-side blur over
|
||
* data that was already sent. Book and fair always pass through: the fair leg
|
||
* is never the paywall.
|
||
*/
|
||
function buildPriceTriplet(input) {
|
||
const n = (v) => {
|
||
if (v == null || v === '') return null;
|
||
const x = typeof v === 'number' ? v : Number(v);
|
||
return Number.isFinite(x) ? x : null;
|
||
};
|
||
const book = n(input.book_odds);
|
||
const fair = n(input.fair_odds);
|
||
if (book == null || fair == null) return {};
|
||
return {
|
||
priceTriplet: {
|
||
book_odds: book,
|
||
fair_odds: fair,
|
||
model_odds: n(input.model_odds),
|
||
ev_pct: n(input.ev_pct),
|
||
quarantine_reason: input.quarantine_reason || null,
|
||
model_price_locked: input.model_price_locked === true,
|
||
},
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Build the optional archetype / stat-context / vyndr-intelligence fields for
|
||
* the grade card from whatever the engine provided. Returns {} when nothing is
|
||
* present so the card sections stay hidden (no empty boxes).
|
||
*/
|
||
function buildIntelFields(input) {
|
||
const out = {};
|
||
if (Array.isArray(input.archetype_blend) && input.archetype_blend.length) {
|
||
out.archetypeBlend = input.archetype_blend;
|
||
} else if (input.archetype) {
|
||
out.archetypeBlend = [{ archetype: String(input.archetype), weight: 1 }];
|
||
}
|
||
if (input.prop_dna && (input.prop_dna.reliable || input.prop_dna.volatile)) {
|
||
out.propDNA = {
|
||
reliable: input.prop_dna.reliable || [],
|
||
volatile: input.prop_dna.volatile || [],
|
||
};
|
||
}
|
||
const sc = {};
|
||
if (input.season_avg != null) sc.season = String(input.season_avg);
|
||
if (input.last10_avg != null) sc.last10 = String(input.last10_avg);
|
||
if (input.vs_opp_avg != null) sc.vsOpp = String(input.vs_opp_avg);
|
||
if (Object.keys(sc).length) out.statContext = sc;
|
||
|
||
const vi = {};
|
||
if (input.form != null) vi.form = input.form;
|
||
if (input.usage != null) vi.usage = String(input.usage);
|
||
if (input.matchup_grade != null) vi.matchup = String(input.matchup_grade);
|
||
if (input.rest != null) vi.rest = String(input.rest);
|
||
if (Object.keys(vi).length) out.vyndrIntel = vi;
|
||
|
||
return out;
|
||
}
|
||
|
||
module.exports = { mapScanToGradeResult, statLabel, computeEdge, isPhosphorConfirmed, toSignals, buildIntelFields, buildPriceTriplet };
|