# 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: <NAME>, 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.

**Headshots (Wave 2A/2B):** each enriched grade + player strip may carry
`playerId` (MLBAM), `espnId` (ESPN athlete id), and `headshotUrl` (a RESOLVED
absolute ESPN href). `PlayerAvatar` prefers `headshotUrl` (exact URL, never
404s) → else constructs from `(sport, playerId|espnId)` → else a team-colored
monogram. `espnId`/`headshotUrl` come from `snapshotService`: primarily the
per-player stats resolve, and — when that misses (the flaky NBA/WNBA fallback)
— from `espnAthleteIndex.buildEspnAthleteIndex(sport)`, which harvests ESPN
schedule→summary/boxscore/leaders/injuries/roster feeds (free, cached, MLB→{}).
Soccer resolves ONLY via a direct `headshotUrl` (no constructed URL); absent →
honest monogram (a free `API_FOOTBALL_KEY` is the reliable soccer path, unwired).

---

## 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?: <record>, nba?: <record>, … },  // 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: <getModelAggregate shape, scoped to the user via userId>,
  entries: [<same columns as /api/ledger, SETTLED rows only, newest 50>],
  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]`.

## Value Engine fields (Model Train arc 1, 2026-07-19)
Every graded read (snapshot `grades:{sport}`, `/api/snapshot/:sport`, `/api/hero-prop`,
`/api/scan`) now carries — all OPTIONAL + self-hiding when absent:
- `ev_pct` (number) — expected value % at the graded side's ACTUAL price
  (model prob × decimal − 1). The ranking signal on boards + hero, replacing raw
  |model−consensus|.
- `value` (bool) — the read passes BOTH gates: takeable price AND ev_pct ≥
  VALUE_EV_THRESHOLD. Grade = read quality; `value` = "the price pays you." An A
  without `value` is honest (right read, price gone).
- `takeable` (bool) — the graded-side price is inside the promotable band
  (TAKEABLE_ODDS_CEILING −160 .. +200).
- **The value triplet** — `book_odds` · `fair_odds` · `model_odds` (all American):
  the book's price, the de-vigged FAIR price (two-way multiplicative de-vig;
  present only when BOTH sides were priced — else absent, never faked), and the
  model's implied price. This is the story to render: "book −145 · vig-free −132
  · model −110". Also `fair_prob`, `overround`, `devig_method`.
- Suppression reasons ride on refused reads: `suppressed` + `suppressed_reason`
  (`juiced_no_edge` / `rare_event_under`) + `reasoning.summary` (branded "No
  read" copy) — for the visible-refusal moment (Model Train step 10).

DESIGN: the triplet + VALUE marker + refusal copy are Session-2 design surfaces
(reveal card, board row, hero). Backend ships the fields; rendering is Design's.

## Book Comparison — `/api/books` (per-book price display, snapshot-locked)

Data-only layer for the (still-unrouted) `BookComparison.tsx` grid. Never touches
the grade path.

- `GET /api/books/:sport/:player/:stat?side=over|under` → the honest per-prop
  grid: `{ player, stat, line, side, books:[{book,line,over_odds,under_odds,
  isBest}], bestBook, bestOdds, bookCount, savings, crowned }`. HONEST-ABSENT:
  a single-book prop returns that one book with `isBest:false, bestBook:null,
  crowned:false` — never a placeholder second row. `:player` is matched by
  `nameKey` (accent/dot/nickname-folded).
- `GET /api/books/:sport?side=&limit=` → "best lines tonight" (`bestLines`, sorted
  by savings). This is a CROWN claim, so it returns `[]` while the crown is gated
  off. Response `source` = `bookprices` (snapshot-locked store serving) or
  `odds-cache` (fallback before first snapshot) — also the deploy fingerprint.
- **The crown is threshold-gated + OFF by default** (`BOOK_CROWN_ENABLED=1` to
  enable). Pre-registered gate: median same-line spread ≥8¢ OR ≥2 implied-prob
  pts (`scripts/measure-book-spread.js`). Measured 2026-07-27 on the live feed:
  MLB median 5¢/0.95pp, WNBA 7¢/1.38pp → NOT met → crown does NOT ship. Render
  books WITHOUT crowning one until a future measurement clears the bar.
- Source store: `bookprices:{sport}` (Redis, SNAP_TTL), written by
  `snapshotService` from the pre-dedup multi-book `props`. Grouped
  `{player, stat_type, books:[...]}`, normalized-name-keyed. DISPLAY-ONLY —
  fenced from grade/selector/challenger/ledger.
