/* ============================================================ 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 };