# Spec — Self-Learning Loop (Outcome Tracking + Accuracy) — Session 55 ## Problem VYNDR grades props but never checks whether it was right. The intelligence is static — no outcome tracking, no accuracy record, no "the system learns" signal. This is the #1 trust builder for a product a 98K-follower bettor would stake his reputation on: showing real accuracy, including misses. ## What we build `outcomeService` — settles locked snapshot grades against real game results, records hit/miss per graded prop, and aggregates a rolling accuracy record by grade tier. Powered by the FREE MLB Stats API game log (the same source the grade pipeline already uses); NBA/WNBA degrade to `pending` when the Python stats service is offline (matches existing posture). Zero new dependency, zero new paid API credits. ## Data source `mlbStatsAdapter.getPlayerStats(name)` → `{ found, last10: [{ date, opponent, stat:{…} }] }`. A prop is "settled" when a game-log row exists for the graded date (presence in the log ⇒ the game is FINAL). The actual stat value is read via a settlement map (VYNDR `stat_type` → statsapi game-log field), mirroring `MLB_LOG_FIELD`. ## Settlement logic For each grade in `snapshot:{sport}:latest`: - `targetDates` = the UTC date and America/New_York date of `gradedAt.timestamp` (covers late-game ET rollover without heavy TZ math). - Find the game-log row whose `date` ∈ `targetDates`. None ⇒ `pending` (skip). - `actual` = settlement-map value for the graded `stat_type`. Unmappable ⇒ skip. - `result`: - side `over`: actual > line ⇒ `hit`; actual < line ⇒ `miss`; == ⇒ `push` - side `under`: actual < line ⇒ `hit`; actual > line ⇒ `miss`; == ⇒ `push` - Idempotent: outcome key = `nameKey(player)|stat|line|side|date`. A prop settles once; re-runs never double-count. ## Persistence (Redis, no DB migration) - `outcomes:{sport}:log` — array of settled outcomes (newest first, cap 1000, deduped by key). Each: `{ key, player, stat, line, side, grade, actual, result, date, gradedAt, settledAt }`. - `accuracy:{sport}` — `{ sport, updated_at, window_days:30, sample, overall:{hits,total,pushes,pct}, byGrade:{'A+':{…},'A':{…},'B':{…},'C':{…},'D':{…}} }`. - `accuracy:overall` — same shape aggregated across sports (dashboard header). - `pct` = hits / (hits + misses) over the trailing 30 days; pushes excluded from pct. ## Endpoints - `GET /api/accuracy` (public, cached 5m) → `{ overall, sports:{mlb,nba,wnba,…}, updated_at }`. - `GET /api/ledger/accuracy` (public) → `{ buckets:[{ grade, hits, total, pct }] }` (feeds the existing Next proxy `web/src/app/api/ledger/accuracy`). - `POST /api/internal/outcomes/:sport` and `/outcomes/all` (internal-key gated) — trigger settlement. Same auth as the snapshot trigger. - Cron: `settleAllOutcomes()` runs on the snapshot scheduler tick BEFORE grading (settle yesterday's completed games, then grade today's). ## Frontend - Dashboard header accuracy pill: "A-RATED · 68% HIT RATE (30D)" from `/api/accuracy`. Self-hides when sample is too small (< MIN_SAMPLE). - `GradeResultCard`: "A-rated props hit 68% of the time" line when accuracy for that grade tier is available (passed via `gradeAdapter`). - Snapshot prop rows: a settled prop shows `✅ HIT (2)` / `❌ MISS (1)` / `PUSH` derived from `outcomes:{sport}:log` (via the snapshot read merge). ## Acceptance criteria 1. `settleSnapshot('mlb', deps)` with injected grades + game logs returns settled outcomes with correct hit/miss/push for over AND under sides. 2. Settlement is idempotent — running twice does not double-count. 3. Accuracy aggregates hits/total/pct by grade tier over the 30-day window. 4. A prop with no matching game-log row stays `pending` and is excluded. 5. `GET /api/accuracy` returns the persisted record (empty-safe when cold). 6. NBA/WNBA (offline stats) degrade to `pending`, never throw. 7. All deps injectable → tests hit zero network. ## Test plan `tests/unit/outcomeService.test.js`: - hit/miss/push for over + under; unmappable stat skipped; missing game skipped. - idempotency (dedupe by key across two runs). - accuracy bucketing + pct math + 30-day window filter + MIN_SAMPLE gating. - `getAccuracy` cold-cache empty shape. `tests/integration/accuracy.test.js`: - `GET /api/accuracy` and `GET /api/ledger/accuracy` return valid shapes.