Files
vyndr/specs/session55-self-learning.md
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

4.3 KiB

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