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