# VYNDR — Backend / Frontend Data Contract (BACKEND_HANDOFF.md) **Canonical frontend↔backend data contract for the Player Intelligence System.** Every future session references this. Authored Session 44 from the shipped implementation (Sessions 42–44). When an endpoint or component shape changes, update this file in the same commit. > Naming: archetypes are **VYNDR Originals** (TORCH, BOMBER, ALPHA, …). The old > descriptive labels live on each archetype as `legacyName` and are NEVER shown. --- ## 1. Archetypes (`src/services/archetypeService.js`) `classify(sport, stats) → { sport, primary, secondary|null, blend }` ``` primary | secondary : { name, legacyName, tag, sport, color (#hex), glyph (key), description, propDNA: { reliable: string[], volatile: string[] }, education } blend : [{ archetype: , weight: 0..1 }] // normalized, top 4, sums ~1 ``` **Roster (41):** see `ARCHETYPES` registry. Colors are unique *within* a sport, reused *across* sports. Frontend visual map (color/glyph/desc) is mirrored in `web/src/lib/archetypes.js` — keep colors identical (a test enforces it). - **NBA (15):** TORCH, CONDUCTOR, FORTRESS, ARTILLERY, SURGE, DUAL THREAT, CONNECTOR, FASTBREAK, PAINT BOSS, LOCKDOWN, SWITCHBOARD, ARCHITECT, PISTON, SENTINEL, IGNITER - **WNBA-unique (5):** DISTRIBUTOR, SHIELD, RANGE, SPARK, ANCHOR (plus reused NBA) - **MLB (15):** BOMBER, BRUSH, DRIVER, ALPHA, WHIFF, GHOST, HYBRID, FLEX, WORKHORSE, CATALYST, MIRROR, HAMMER, SINKER, BRIDGE, SWITCH - **Soccer (6):** FINISHER, MAESTRO, TOWER, MOTOR, BLADE, WALL Classifier input shapes: - NBA/WNBA: `{ ppg, rpg, apg, bpg, spg, threes, usg, fg3a, pos, bench }` - MLB hitter: `{ avg, hr, rbi, sb, ops, runs, k_rate, doubles }` - MLB pitcher: `{ era, k9, whip, ip_per_start, saves, role: 'SP'|'RP'|'CL' }` --- ## 2. Stats API (`src/routes/stats.js`, mounted at `/api/stats`) All player-intelligence endpoints are public, rate-limited 60/min, and ALWAYS return 200 with a valid (possibly empty) shape — the UI must never hard-fail. Browser must hit the **Next proxy** under `web/src/app/api/stats/...`, never Express directly. ### `GET /api/stats/player/:name?sport=nba` ``` { player, sport, team, found: boolean, archetype: { primary, secondary, blend }, // §1 propDNA: { reliable, volatile }, education: string, season: [{ k, v, lg? }], // display rows (mono) last10: [{ d, opp, res?, stat }], splits: [{ k, a, b }], gradeHistory: [{ grade, prop, hit?, miss? }], activeProps: [{ stat, line, side: 'O'|'U', grade, confidence }], intel: [{ label, kind: 'form'|'grade'|'plain', value, score?, color }], injury: { label, note, cascade } | null } ``` `found` is true when real season stats OR tonight's graded props exist. MLB stats come from `mlbStatsAdapter.getPlayerStats(name)`; NBA/WNBA from `nbaStatsClient` (degrades to `found:false` when the Python service is offline). ### `GET /api/stats/leaders?sport=mlb&stat=hits&limit=10` ``` { sport, stat|null, leaders: [{ player, team, stat, line, side, grade, confidence }] } ``` Source: the `grades:{sport}` cache (tonight's graded slate, by confidence desc). ### `GET /api/stats/game/:id?sport=nba` ESPN game summary (injuries, leaders, ESPN Bet odds, box score) or `{error}`. ### `GET /api/stats/lineup/:team?sport=mlb` (Session 43) `{ sport, team, lineup: [{ player, position, battingOrder, projectedMinutes }] }` MLB returns the probable starting pitcher from the schedule. ### `GET /api/stats/depth/:team?sport=nba` `{ sport, team, positions: [{ position, starter, backup, thirdString }] }` ### `GET /api/stats/cascade/:player?sport=nba&team=SA` `{ sport, player, cascade: [{ player, stat, delta: '+x%', reason }] }` Usage redistribution weighted by archetype (SURGE benefits most). --- ## 3. Grade Result Card (`web/src/components/vyndr/GradeResultCard.tsx`) `GradeResultData` (built by `web/src/lib/gradeAdapter.js` `mapScanToGradeResult`): core fields (player, team, sport, stat, line, side, grade, confidence, edge, projection, signals[], killConditions[], books[], altLadder[]) **plus** optional Player-Intelligence fields that self-hide when absent: ``` archetypeBlend?: [{ archetype, weight }] propDNA?: { reliable: string[], volatile: string[] } statContext?: { season?, last10?, vsOpp? } vyndrIntel?: { form?, usage?, matchup?, rest? } ``` The engine (`analyzeViaEngine1`) attaches snake_case fields (`season_avg`/`last10_avg`/`form`/`usage`/`matchup_grade`/`rest`, and when a season line is available, `archetype`/`archetype_blend`/`prop_dna`); the adapter maps them in. Archetype at grade time requires a multi-stat season line — until the snapshot pipeline supplies it, the archetype strip stays hidden. --- ## 4. Game Card (`web/src/components/vyndr/GameCard.tsx`) `GameCardData` core (id, sport, away/home, time, venue, lines[], …) **plus**: ``` playerStrips?: [{ player, team, archetype?: {primary, secondary?}, stats: [{label,value}], props: [{stat,line,side,grade}] }] pitchers?: { away: {name, era, archetype?}, home: {name, era, archetype?} } ``` Built by `slateAdapter.mapScheduleToGameCards` (`groupPropsByPlayer` + `mapPitchers`). The card prefers `playerStrips` (name once, horizontal) over legacy per-prop rows. Book chips use `web/src/lib/books.js` brand colors. --- ## 5. Stat Strip (`web/src/components/vyndr/StatStrip.tsx`) `compact` (game cards) | `expanded` (profile hero / grade result). HARD RULE: the player name appears ONCE; stats flow horizontally in JetBrains Mono. Props render inline with `GradeBadge`. `onPlayerClick` → `/player/:name?sport=` (`lib/playerHref.js`). --- ## 5a. Normalization is applied at the SNAPSHOT source (Session 48) `snapshotService.runSnapshot` normalizes every grade's player name (de-dotted display) and dedupes to ONE grade per `nameKey|stat_type` (highest confidence) BEFORE writing `grades:{sport}` + `snapshot:{sport}:latest`. Every downstream consumer (GameCard overlay, `/api/snapshot`, `/leaders`, profile `activeProps`) therefore receives clean, merged names. New consumers don't need their own normalization — but UI lists built from RAW odds (e.g. the scan player grid) must group by `nameKey` + display `normalizeName().display`. ## 5b. Player name normalization (Session 46) `src/utils/playerName.js` (+ identical `web/src/lib/playerName.js`) is the ONE source of truth for comparing/deduping names. `normalizeName(raw)` → `{ display, key }`: `display` strips periods + de-dots the suffix (keeps accents + casing); `key` is accent-folded, lowercased, suffix-stripped for comparison. Used by snapshot grouping, `slateAdapter` (grade index + player strips), and `playerIntelService` so "A.J. Ewing"/"AJ Ewing" and "Jazz Chisholm"/"Jazz Chisholm Jr." collapse to one player. ## 5c. MLB intel features (Session 46) `buildIntelFields` (grade-card STAT CONTEXT + VYNDR INTELLIGENCE) reads `l5_avg`/`l10_avg`/`l20_avg`/`opp_rank_stat`/`rest_days` from the feature vector. MLB game logs are now wired into `featureCache.gameLogFeatures` via `mlbStatsAdapter.getPlayerStats` (the old Python game-log path was NBA/WNBA-only, so MLB props had no intel). `buildIntelFields(features, { playerStats, projection })` also accepts fallbacks so partial intel still renders. ## 5d. MLB probable pitchers (Session 46) `GET /api/schedule/:sport/pitchers` (MLB only) → `{ sport, date, games: [{ home:{team,pitcher,era}, away:{...} }] }` from `probablePitchers` / `mlbStatsAdapter.getScheduleWithPitchers` (the ESPN schedule lacks them). The Slate builds a team→pitcher map (`slateAdapter.buildPitcherMap` / `pitchersForGameTeams`) and attaches `pitchers` to MLB GameCardData. ## 6. Freshness & caching - Schedule cache TTL ≤ 30 min (`scheduleService`). Frontend filters completed games older than 24h out of the slate (`slateAdapter.isRelevantGame`). - Player props: PropLine primary (3-key rotation), The-Odds-API backup. - `grades:{sport}` cache (TTL 2h) is written by `gradeSlateService` on a fresh odds fetch; it feeds `/leaders` + the player card's `activeProps`. --- ## 7. Accuracy / self-learning loop (`outcomeService`, Session 55) The system's track record — settled grades vs real results. Written by `outcomeService.settleAllOutcomes()` (cron, before grading); read-only endpoints. ### `GET /api/accuracy` (public, cached 5m) ``` { overall: { // aggregate across sports sport: 'overall', updated_at, window_days: 30, sample, overall: { hits, misses, pushes, total, pct|null }, // pct excludes pushes byGrade: { 'A+':{…}, 'A':{…}, 'B':{…}, 'C':{…}, 'D':{…}, 'F':{…} } } | null, sports: { mlb?: , nba?: , … }, // same shape per sport min_sample: 8, // below this, show "LEARNING" not a % updated_at: string | null } ``` ### `GET /api/ledger/accuracy` (public) — the ledger `buckets` shape ``` { buckets: [{ grade, hits, total, pct|null }], overall, updated_at } ``` ### Settled outcome overlay `GET /api/snapshot/:sport` grades may carry `outcome: { result:'hit'|'miss'| 'push', actual:number }` once the game is final. `AccuracyBadge` + `StatStrip.OutcomeChip` render these. Frontend proxy: `web/src/app/api/accuracy`. ### S6 (A1 board) — display enrichments on the same payloads - `GET /api/snapshot/:sport` grades may also carry: - `last10_dots: boolean[]` — last ≤10 games vs the LOCKED line, newest first (server-side from `rosterlogs:{sport}` via `services/last10Dots`). Absent when there's no real log / no accessor for the stat. - `history: [{ t: string, line: number }]` — REAL captured line points from the intraday refresh (seeded with the locked line, deduped when flat, capped 24). The StatStrip sparkline renders only at ≥3 points. `slateAdapter.buildPlayerStripsFromProps` threads both onto `StripProp` (`last10Dots` / `history`); `StatStrip.DotStrip` + `LineSparkline` render. - `GET /api/ledger/model` `aggregate` may carry `clv_distribution: [{ label, min, max, side:'faded'|'flat'|'beat', count }]` — seven ordered buckets over settled signed CLV ([-2,-1) … (1,2], outliers clamped into the edge buckets). **null below the n≥20 gate** (the gate lives in `getModelAggregate`, nowhere else). Ledger MODEL header renders the strip. - Global search: `window.__search()` opens the ⌘K SearchModal (players via `/api/players/search` per sport; teams from the static `web/src/lib/teams.js`). ## 8. Public ledger profiles (`src/routes/profiles.js`, A1 Session 10) Strava-for-betting v1. Table `public_profiles` (migration 022 — apply before deploy). PRIVATE BY DEFAULT; one explicit publish toggle in Settings. ### `GET /api/profiles/:handle` (public, cached 60s) Published only. Unknown AND unpublished return the SAME 404 body (`{ error: 'Profile not found' }`) — no existence leak. ``` { handle: string, aggregate: , entries: [], min_sample: 20 // below this, RECORD BUILDING — never a % } ``` ### HOUSE profile (Wave 5A, D2) The reserved handle `HOUSE_HANDLE` (env, default `vyndr`) resolves to the PUBLIC model record (`getModelAggregate()` with NO userId → the `user_id=NULL` ledger rows) WITHOUT a `public_profiles` row, and is ALWAYS public (the partner-pitch weapon). Same response shape + two extra fields: `{ ..., house: true, label: 'VYNDR MODEL · PUBLIC RECORD' }`. Entries are the public settled rows (`user_id IS NULL`, misses included). Reserved before the publish lookup — a user who claims it is shadowed. Every OTHER handle keeps the private-by-default, byte-identical-404 contract. It never 404s (a fetch failure degrades to an honest empty/building state). Portrait share crop: route handler `GET /u/[handle]/portrait` → 1080×1350 PNG (real aggregate or tagline fallback). ### `GET|POST /api/profiles/me` (requireAuth) GET → `{ profile: { handle, published, created_at } | null }`. POST `{ handle, published }` → upsert own row (service role). Handle must match `^[a-z0-9_]{3,20}$` (400); a handle owned by another user → 409. `published` flips ONLY on boolean `true`. Frontend: `/u/[handle]` (OPEN route, server shell + client record + OG card, Node runtime) via proxies `web/src/app/api/profiles/me|[handle]`.