Files
vyndr/BACKEND_HANDOFF.md
T
builtbykev d3637e7abd S6 (a1): display — the full picture under the grammar
- specs/ROW-GRAMMAR.md: the row grammar law (slot order, color law, mark
  law, mobile stacking, no-truncation), locked by tests/unit/rowGrammar.
  StatStrip violations fixed: MovementChip before the grade (market
  context before model output); ViabilityChips after the archetype
  (identity is one contiguous run).
- Line-movement sparklines: intradayRefreshService.trackHistory captures
  real {t,line} points per grade (seeded with the lock, deduped when
  flat, capped 24) inside the snapshot write-back; StatStrip.LineSparkline
  renders at >=3 points (green toward / amber against / dim flat).
- Last-10 dot strips: services/last10Dots (streaksService accessors) ->
  /api/snapshot/:sport attaches last10_dots from rosterlogs:{sport};
  StatStrip.DotStrip renders vs the LOCKED line, newest first.
- CLV distribution: getModelAggregate emits clv_distribution (7 signed
  buckets, outliers clamped) only past the centralized n>=20 gate;
  ledger MODEL header renders the bar strip.
- Global search: SearchModal (cmd-K via GlobalHosts + window.__search),
  players via /api/players/search per sport + static lib/teams.js
  (soccer deliberately absent); Nav search icon + Search first in the
  mobile More sheet. Explore tab untouched.
- Landing LCP: fade-up floored at opacity .6 (hero h1 contentful on
  first frame, S33 visible-floor rule) + IBM Plex Mono preload:false
  (4 decorative font files off the slow-4G critical path).

2654 -> 2698 tests (226 suites) green; web build exit 0.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 20:08:24 -04:00

11 KiB
Raw Blame History

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.


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 %
}

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].