Files
vyndr/web/src/lib/gradeAdapter.js
T
builtbykev 09186ea609 Wire the composed surface: the honest fields now reach a user
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
2026-08-07 14:36:03 -04:00

206 lines
9.7 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.
/* ============================================================
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 (46). */
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 };