Files
vyndr/specs/session55-self-learning.md
T
builtbykev d09a06c054 Session 55: Self-learning loop + real-time layer (2274 tests)
Product overhaul core — the two transformative, differentiated systems:

Self-learning loop (Phase 2): outcomeService settles locked snapshot grades
against real MLB Stats API results → hit/miss/push, rolling accuracy by grade
tier (30d window). Idempotent, injectable, unit-tested. New GET /api/accuracy +
/api/ledger/accuracy + internal settle triggers + cron hook. AccuracyBadge
(dashboard/scan/landing) is honest — "LEARNING" below MIN_SAMPLE, never a fake
number. Settled HIT/MISS chips overlay the live slate.

Real-time layer (Phase 1): Slate silent 60s auto-refresh (no flash, no wipe on
transient blips) + "SIGNAL LIVE · UPDATED Xs ago" freshness strip; Ticker LIVE
badge that flashes on fresh events.

Landing (Phase 3): TopSignals shows tonight's real top-3 A-rated grades + live
accuracy — the product shown, not described.

Founder pricing: FOUNDER_CODE_EXPIRY default 2026-06-30 → 2026-12-31 (had
lapsed, disabling every founder code + the ClaimMeter pitch). That expiry — not
a tier change — was the real cause of the 4 stripe test failures.

Backend 2255 (4 failing) → 2274 (all green; +19 new, +4 fixed). Web build exit 0.

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

77 lines
4.3 KiB
Markdown

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