Files
vyndr/BACKEND_HANDOFF.md
T
builtbykev fceb3707b5 Wave 2B: reliable cross-sport headshots via ESPN athlete index
The NBA/WNBA espnId was captured only from espnStatsAdapter (the offline-
Python fallback), unreliable in prod. Add espnAthleteIndex — a pure,
defensive harvester that builds { nameKey -> {espnId, headshotHref} } from
the ESPN schedule->summary/boxscore/leaders/injuries/roster feeds the
pipeline already calls (free, bounded mapLimit, cached, MLB->{}).

snapshotService now fills any player the primary stats-resolve left without
an espnId from this index, and stores a DIRECT headshotHref as headshotUrl
on the enriched grade (the exact URL, never 404s on a constructed path).
Threaded headshotUrl through slateAdapter.buildPlayerStripsFromProps ->
GameCard -> StatStrip -> PlayerAvatar/getHeadshotUrl (direct href wins over
the constructed one). MLB's MLBAM path is untouched. Soccer resolves only
via a direct href; absent -> honest monogram (API_FOOTBALL_KEY remains the
reliable soccer path, unwired).

getGameSummary now also passes through ESPN `rosters` (pre-game lineups
carry id + headshot). Everything graceful: any miss -> absent -> monogram.

Tests: tests/unit/espnHeadshotIndex.test.js (11) — fixture->index, snapshot
merge fallback, direct-href-wins, soccer honest monogram, malformed/cyclic
parse never throws. Full suite 3080 green; web next build exit 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 18:12:31 -04:00

276 lines
13 KiB
Markdown
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 — 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 4244). 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]`.