Files
vyndr/CLAUDE.md
T
builtbykev 20c45cbcd1 The causally-correct defence atom proves where the crude one did not
Kev's insight holds, and the data says so cleanly. defense_by_direction PROVES
on hits -- n=528, Brier -0.0034, interval [-0.0059, -0.0009] at the 99.9% level
the cumulative correction now demands -- while team-average defence remains not
proven, its interval still spanning zero. Same signal, same rows, different
unit.

The detail worth keeping is that the causally-correct atom moves the number
LESS THAN HALF as much as the crude one, 0.013 against 0.030, and is the one
that is reliably right. The team average was moving more and knowing less. Big
movement is not evidence of a good factor; it is frequently the tell.

Both halves turned out to be free, as the order expected. Savant's batted-ball
leaderboard carries pull/straight/oppo crossed with ground/air for 609 hitters
-- the statcast leaderboard we already pull does not, it has nineteen columns
and no direction at all -- and the OAA feed already carries each fielder's
position, so per-position defence is a regrouping of last week's ingest rather
than a new source. Verified in production: 609 spray profiles, 31 teams.

Handedness is what joins them and getting it backwards would have been
invisible. Pull for a right-handed hitter is the left side; for a left-handed
hitter it is the right side. A model that ignored `bats` would send half the
league's grounders to the wrong infielders and still look like it was reading
defence, and nothing downstream would have caught it. Switch hitters bat
opposite the pitcher, which this does not resolve, so they are unreadable
rather than guessed.

Unmeasured zones are renormalised away rather than contributing a zero, since a
zero asserts an exactly-average fielder standing there, and coverage states
honestly what share of a hitter's contact we could actually read.

ATOM 2 is input-blocked rather than sample-blocked, and the distinction matters
because waiting will not fix it. The weather free-source check passes --
Open-Meteo is already wired and exposes temperature, wind speed, wind direction
and precipitation -- but those raw fields are collapsed into a single scalar
modifier and wx_forecast is empty on all 1,119 settled rows. Park DIMENSIONS
are not ingested at all; parkFactors holds coefficients, not wall heights or
fence distances. A park-and-weather-to-hit-type conversion needs both, so it is
scoped rather than half-built: retaining the raw weather fields is the cheap
half, dimensions are the missing one.

Proven factors for hits are now pitcher_contact_profile and
defense_by_direction, both pooled; every per-archetype slot remains
sample-blocked.

4,297 tests green (342 suites); web build exit 0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W1sivYNqY2TS5ftykmHBU9
2026-08-04 20:01:09 -04:00

116 KiB
Executable File
Raw Blame History

VYNDR — Claude Code Project Context


🔷 PRODUCT IDENTITY — READ FIRST, EVERY SESSION

VYNDR IS A PREDICTIVE MODEL. It projects what a player will DO, and picks accurately. It reads and pulls the market apart — a student of the game that is also an aggregator.

Market edge is a BYPRODUCT of a good prediction. It is NEVER the success criterion.

SUCCESS = the forecast is honest about its own confidence AND still ranks. Calibration (does 60% mean 60%?) and resolution (do higher forecasts actually hit more often?). Both, or it isn't working.

No edge or CLV term belongs in a pass/fail gate. CLV and market-relative edge are diagnostics we report, never thresholds a model must clear to ship. A model that forecasts honestly and ranks correctly is working even in a week the market moved against it; a model tuned to beat a closing line has been fitted to the market instead of to the game.

PER-SPORT DOCTRINE

(Rashad Phillips, "Basketball Position Metric," 2022 — classify players by what they do, not by position labels.)

Each sport is its OWN model — its own variables, archetypes, conditions, calibration and honest ceiling. The only thing shared across sports is the Bayesian inference math. Never one model fit to all sports; never a sport stubbed in on another sport's template and counted as covered.

TRUTH LAW

  • No fabricated data anywhere. If it renders a number, it comes from the database or it doesn't render. Number(null) === 0 is the classic breach.
  • Honest-absent beats invented. An empty state is a valid answer.
  • Label limitations in-band — e.g. "market consensus, not sharp", "RECORD BUILDING", "MODEL · LEARNING".
  • Provisional results stay provisional until re-run. A measurement taken against an instrument that has since changed is not a result; it is a result pending.
  • Verify by inducing the real code path on demand — never wait on a cron slot to find out whether something works. Documented ≠ verified.

What This Is

Sports betting intelligence SaaS. Real software product. Three tiers: Free (5 scans), Analyst ($19.99 / $14.99 founder), Desk ($49.99 / $34.99 founder).

Tech Stack

  • Backend: Node.js / Express
  • Database: Supabase (PostgreSQL)
  • Frontend: React Native (built in Cursor)
  • Data: The Odds API ($30/mo), nba_api (free, Python wrapper)
  • Caching: Redis — 15min for odds, 24hr for season averages, 1hr for recent games
  • Payments: Stripe

Critical Rules — Non-Negotiable

1. NO CODE WITHOUT A SPEC

Every feature requires a spec file in specs/ before any code is written. Spec must include: endpoints, data shapes, acceptance criteria, test plan. Get approval before building.

2. WSL2 HEREDOC RULE

WSL2 corrupts heredoc for files over 10 lines. ALWAYS use Python file-writing: python3 with triple-quoted strings. This applies to every file creation operation.

3. 5 QUALITY GATES (all must pass before any feature is marked complete)

  1. Unit tests pass
  2. Integration tests pass
  3. Acceptance criteria met (from spec)
  4. PR description written
  5. CLAUDE.md updated if anything new learned

4. BUILD-STATE.md

Update after every session. What shipped, what's next, any blockers.

5. BLOCKERS.md

If you hit something you cannot resolve: log it. Don't guess. Don't skip.

Folder Structure

vyndr/
├── src/
│   ├── routes/        # Express route handlers
│   ├── models/        # Supabase data models
│   ├── services/      # Business logic (prop analysis, odds normalization)
│   ├── middleware/     # Auth, rate limiting, scan counting
│   └── utils/         # Helpers, formatters, validators
├── tests/             # Unit + integration tests
├── docs/              # API docs, architecture notes
├── specs/             # Feature specs (write BEFORE code)
├── build-briefs/      # Session summaries
├── CLAUDE.md          # This file
├── ROADMAP.md         # Feature roadmap with phases
├── BUILD-STATE.md     # Current build status
├── BLOCKERS.md        # Unresolved blockers
└── DECISIONS.md       # Architecture decisions log

All-Day Intelligence Layer (Session 23)

Free/cheap content that keeps the platform alive when odds-api props are empty. NONE of these spend odds-api credits:

  • /api/schedule/:sport — cache-aside ESPN scoreboard (scheduleService), self-heals on cache miss. Per-game hasOdds/hasGameLines flags peek at other caches without fetching.
  • /api/gamelines/:sport — Tank01 book-by-book lines (RAPID_API_KEY quota).
  • /api/streaks/:sport + /api/hotlist/:sport — PURE engines (streaksService, hotListService) computed from cached game logs. NO API calls. Logs loaded by rosterLogs.js (prefetch blob, else Redis SCAN over gamelogs:{sport}:{player}:{count}). Empty roster = valid empty state.
  • ?stat= filters narrow streaks/hotlist; categories in config/statFilters.js (mirror web/src/config/statFilters.ts). Discovery: /api/stats/filters/:sport.
  • Dead providers: set status: 'dead' in config/providers.js to drop a provider from fallback chains + configured list (ParlayAPI host is dead).

Provider Strategy (Session 30)

Player props now have abundance, not rationing.

  • Player props — PRIMARY: PropLine (proplineAdapter, 3 keys PROPLINE_API_KEY_1/2/3, 3,000 req/day FREE, rotates per-key; registry propline priority 1). BACKUP: The Odds API (ODDS_API_KEY, 500/month, priority 2, conserve). getOdds() tries PropLine first when keys present, falls back to odds-api; the response + cache carry a provider field. PropLine is The-Odds-API-compatible → reuses utils/oddsNormalizer. MLB market keys (batter_hits, pitcher_strikeouts, …) were added to MARKET_MAP — without them MLB props normalize to zero.
  • MLB statsmlbStatsAdapter → statsapi.mlb.com. FREE, no auth, unlimited. Game logs, season averages, BvP, probable pitchers. Does NOT use the gateway (no quota). Registry mlb-stats (noAuth: true).
  • Game enrichmentscheduleService.getGameSummary(sport, eventId) → ESPN summary (injuries, ESPN Bet odds, ATS, leaders, box score). Free.
  • Game-level odds — Tank01 (unchanged). Tank01 PLAYER PROPS = empty, do not wire.

Grades Content Pipeline (Session 32)

Closes the content pipeline: contentTemplateService reads a grades:{sport} cache "when present" but nothing wrote it, so slate/POTD never reached dataLevel: 'full'.

  • WritergradeSlateService.gradeAndCacheSlate(sport, props, opts) dedupes props to unique player+stat+line (cap 25, concurrency 5), grades BOTH sides via analyzeViaEngine1 (engine1 is direction-aware — keeps the higher-confidence side), sorts by confidence desc, writes grades:{sport} = { grades, updated_at, source } (TTL 2h). The legacy grade shape already matches normalizeGrade — no remap needed.
  • Trigger — fire-and-forget inside oddsService.recordDownstream (runs on a fresh odds fetch / cache MISS, NOT on cache hits). Does NOT hold the odds HTTP response. Gated by shouldGradeSlate(): ON by default, OFF when NODE_ENV==='test' (its feature-compute fan-out would pollute call-count assertions), override with GRADE_SLATE_ON_FETCH=1/0. 0 is the operator kill-switch if the per-prop feature-compute cost needs shedding.

NFL/NHL Props (Session 32)

NFL + NHL wired end-to-end. the-odds-api keys are americanfootball_nfl and icehockey_nhl (full-name prefix like basketball_nba) — NOT football_nfl/hockey_nhl (those are PropLine's keys in proplineAdapter). oddsService.SPORT_KEYS + SPORT_MARKETS carry both; proplineAdapter.MARKETS filled. NHL keys (player_shots_on_goal, goalie_saves) added to MARKET_MAP so NHL props don't silently normalize to zero in-season.

Public Route Rate Limiting (Session 32)

middleware/rateLimit (createRateLimit, in-memory per-IP, independent bucket per call site) now mounts via router.use at the top of every public cached router: odds + parlay = 30/min, schedule/gamelines/streaks/hotlist/ content/lines/books = 60/min. /api/analyze keeps its own 10/min.

Frontend ↔ Backend Wiring (Session 25 — non-obvious)

A new Express route under /api/* is NOT reachable from the browser until a matching Next.js proxy route exists at web/src/app/api/.../route.ts that forwards to ${BACKEND_URL}/api/.... The browser hits the Next origin, not Express directly. This bit us: schedule/gamelines/streaks/hotlist endpoints worked on Express but 404'd in the UI for two sessions. When adding a backend endpoint the frontend calls, ALWAYS add the proxy too (pattern: web/src/app/api/odds/nba/route.ts).

Tank01 betting-odds real shape: sportsbooks are TOP-LEVEL keys on each game object ({ awayTeam, homeTeam, bet365:{...} }), not a sportsBooks array. Filter NON_BOOK_KEYS to extract books (see gameLines.js).

VYNDR 2.0 Design System (Session 33 — Phase A+B)

Multi-session frontend conversion of the claude.ai/design "VYNDR 2.0" handoff (NOT a backend change). Foundation shipped; pages/mobile/systems are Sessions 34+. Source of truth = the prototype's vyndr.css + VYNDR_HANDOFF.md.

  • Tokens live in web/src/app/globals.css :root. The NEW canonical set is the short names: grades --g-ap/--g-a/--g-b/--g-c/--g-d, sports --s-nba/--s-mlb/--s-wnba/--s-soccer, --amber, --live/--hit/--miss, --scan-op (0.04), --glitch (1), --sans (Inter), --mono (JetBrains Mono). The legacy alias block (--grade-a, --nba, --accent, …) is KEPT — do not delete it until every consumer is migrated.
  • Two hard brand rules: (1) JetBrains Mono (var(--mono) / .mono) for ALL data — odds, %, timestamps, book names, stat lines, grades; Inter (var(--sans)) for everything else. (2) Glitch animations apply ONLY to chrome (wordmark, headers-on-hover, dividers, loaders, living layer). DATA NEVER GLITCHES.
  • Glitch keyframes (§4) are appended to globals.css after the legacy block — later-wins where names collide, so the appended VYNDR-2.0 definitions are authoritative. Entrance keyframes floor at the visible state (fade-in from opacity .6) so a paused frame is never invisible.
  • Shared components: @/components/vyndr/* (Wordmark, GradeBadge, SportBadge, TerminalInput, SectionHead, VBtn, Card, Sparkline, Ticker), helpers in web/src/lib/vyndrTokens.js. The NEW Wordmark is .wm markup at @/components/vyndr/Wordmark; the legacy @/components/Wordmark (.wordmark) is still used by Nav until pages convert. vyndrTokens.js is CommonJS so it's importable by .tsx (allowJs) AND requireable by the plain-JS Jest suite — keep helper logic there so it stays genuinely unit-testable.
  • a11y layer (§10): <html data-contrast|data-text|data-cb|data-font| data-motion> overrides in globals.css. Wiring the toggles to these attrs is Phase G (Session 38).

VYNDR 2.0 App Shell (Session 34 — Phase C)

The frame every page sits in. Frontend-only.

  • Routing config = web/src/lib/routes.js (CommonJS so it's unit-testable): GATED_ROUTES, OPEN_ROUTES, HASH_ALIASES, isGatedRoute(), resolveHashAlias(). GATED is deliberately narrow — only personal surfaces (ledger/tracker/account/profile/settings/notifications/invite). dashboard + scan stay OPEN (the free-scan funnel); gating them would be a monetization regression.
  • Auth gate = CLIENT-side (components/AuthGate.tsx, mounted around <main> in layout). Our Supabase session lives in localStorage, not an httpOnly cookie, and middleware.ts is locale-only — a server middleware can't read it. The gate uses useAuth().loading/user + isGatedRoute() and redirects to /login?next=<path> (the param /login already consumes). Do NOT try to move this to middleware without first adding @supabase/auth-helpers-nextjs + cookie sessions (a separate, larger change).
  • Hash deep-links: components/vyndr/HashRedirect.tsx translates #scan/#terminal/… → real Next routes once on mount. We keep file-based routing; hashes are just redirect aliases for old share links / PWA shortcuts.
  • Nav (components/Nav.tsx): uses the new @/components/vyndr Wordmark (.wm), mono uppercase links, active = --g-a, a More dropdown, and a Ticker under the bar. The fixed header is 60px nav + 32px ticker, so layout main paddingTop is 96 (not 64) — keep that in sync if the header height changes.
  • Footer (components/Footer.tsx) is mounted GLOBALLY in the layout (not per-page). System voice + "BUILT BY KEVON BUTLER · DETROIT".
  • 404 (app/not-found.tsx) is the north star — scanlines, crt-sweep, glitch wordmark, amber 404. Interactive CTAs live in client NotFoundActions so the page stays a server component (keeps its metadata export).
  • RouteStub (components/vyndr/RouteStub.tsx) backs not-yet-built routes (terminal/compare/invite/help/about/notifications). Never stub over a route that already has real content.

VYNDR 2.0 Core Screens (Session 35 — Phase D)

  • Grade Result Card = @/components/vyndr/GradeResultCard (the product's core moment) + ProcessingGrade (the factor-ignite reveal that precedes it). Feed them the §7 contract via lib/gradeAdapter.js (mapScanToGradeResult). The adapter is CommonJS (unit-testable) and tier-gates content: free = 3-signal teaser, no kill conditions, no alt ladder; analyst = full signals + kill conditions; desk = + alt ladder. Keep that gating — the card itself shows whatever it's given, so the giveaway-prevention lives in the adapter. GradeResultCard returns the inferred JS-object type loosely, so cast mapScanToGradeResult(...) as GradeResultData at call sites (the JS adapter has no TS types).
  • Scan grade flow (app/scan/page.tsx) now renders ProcessingGrade→ GradeResultCard. The existing search/limit/parlay logic, markReadComplete reads tracking, and noopener noreferrer sportsbook deep-links were preserved — don't drop those when iterating.
  • GameCard = @/components/vyndr/GameCard (Bloomberg best/worst line cells: best = rgba(0,212,160,.13) + green left border, worst = subtle red). Built + tested but NOT yet swapped into the live dashboard/Slate.tsx — that data-mapping swap is a pending task; don't assume the dashboard uses it yet.
  • Terminal (app/terminal/page.tsx) is a real page now (server component, sample intel data) — no longer a RouteStub. Real-data wiring is later.
  • ClaimMeter = @/components/vyndr/ClaimMeter (founder-seat scarcity), on the landing under the Hero.

VYNDR 2.0 Remaining Screens (Session 36 — Phase E)

  • Dashboard lineslib/slateAdapter.js (parseAmericanOdds, detectBestLines, mapScheduleToGameCards) is the testable best/worst-line engine. The LEGACY components/GameCard.tsx (used by the live Slate, with inline grading) was RESKINNED to render its game-lines grid via detectBestLines (best = green tint + green left border, worst = subtle red)
    • SportBadge. IMPORTANT: the live Slate still uses the legacy GameCard, NOT vyndr/GameCard — a full swap needs inline grading ported into the new component first (slateAdapter + vyndr/GameCard are ready for it).
  • Real pages (were RouteStubs): compare, invite, help, about. Only /notifications is still a RouteStub (keep the Session-34 stub test in sync if you convert it).
  • Reskinned (logic preserved): login (scanlines + "ACCESS THE SIGNAL"), pricing (+ ClaimMeter). account redirects to /profile.

VYNDR 2.0 Mobile Parity (Session 37 — Phase F)

  • Bottom tab bar = components/BottomTabBar.tsx: 5 tabs (Slate/Terminal/ Scan/Ledger/More), Scan is the prominent raised grade-green action, More opens an integrated bottom sheet. Shown for ALL users (anon included — it's the only mobile nav); hidden on auth flows + landing via HIDE_ON, and hidden ≥768px via the .mobile-tab-bar rule in globals.css. The Nav hamburger is retired on mobile (the tab bar owns nav); the Nav's mobile panel is now dead code.
  • Mobile CSS lives in the "MOBILE PARITY" section of globals.css: main bottom-padding clears the 64px bar + safe-area <768px; .grade-hero (the GradeResultCard letter) → 80px <640px; .terminal-grid stacks <768px; .game-lines-grid horizontal-scroll. Class hooks: grade-hero, terminal-grid.
  • PWA: manifest has shortcuts (Slate/Scan/Terminal) + categories [sports,finance,productivity]; layout viewport sets viewportFit: 'cover'.
  • BUILD GOTCHA: don't use as const on heterogeneous config arrays (TABS) — it makes each entry a distinct literal type and optional props fail type-check. Use a shared interface. And the build worker exits code 1 on type errors — check the build EXIT CODE, not just a | tail of its output.

VYNDR 2.0 Systems (Session 38 — Phase G)

Testable CommonJS modules in lib/ + thin React glue:

  • lib/parlayMath.js — frontend correlation model (player 0.62 / team 0.34 / league 0.06 / cross-sport 0), parlayGrade penalty, grade→odds. Backend parlayService (S28) still owns server combined odds.
  • lib/oddsFormat.jsfmtOdds(value, format). CRITICAL: only signed-int strings + integer numbers are odds; totals/lines/spreads pass through UNCHANGED (parseMoneyline). This is intentionally stricter than the prototype's parseAm (which would mis-convert 228.5) — keep it that way.
  • lib/prefs.jsapplyPrefs sets <html data-*> (the S33 CSS layer keys off these), load/save to localStorage('vyndr_prefs').
  • lib/liveTick.js — single tick store; never auto-starts (SSR/test-safe), start() runs the unref'd 1s interval, tick() emits a fresh state object.
  • lib/checkout.jscheckoutUrl(plan).
  • components/vyndr/LiveLayer.tsx (useLive/LiveNumber/HeartbeatBar) + GlobalHosts.tsx (mounted in layout — applies prefs, registers window.__prefs/__goPaywall/__checkout, hosts Prefs + Paywall modals).
  • Header is now 124px tall (nav 60 + ticker 32 + heartbeat 30): layout main paddingTop = 124, Slate sticky top = 122. Keep them in sync if header changes.
  • GOTCHA: don't return a Set.delete-based unsub directly from useEffect (returns boolean ≠ valid cleanup) — wrap as () => { unsub(); }.

VYNDR 2.0 Conversion COMPLETE (Session 39 — Phase H QA)

The 7-session design conversion (3339) is done and parity-verified against §13.

  • Parity invariants are locked by tests/unit/vyndrParityQA.test.js — if you later add a glitch class to a data component, a raw #00ffb8/sport hex, a dead onClick={}, or break the gated-route list, that suite fails. Keep it green.
  • INTENTIONAL hex (do NOT "fix" to tokens): var(--token, #fallback) fallbacks, Next metadata themeColor, and the bespoke intel-surface/red-tint text shades (#e8fff4/#bdf5e2/#ff8a8a/#ff8b7a/#ffb0a4/#ffd9a8/#04140f) ported from the prototype — no token equivalent.
  • The GradeResultCard hero LETTER is intentionally var(--sans) (display), matching the prototype; all grade DATA rows + the GradeBadge chip are mono.
  • DEFERRED (never in 3339 scope): a true ⌘K command palette (Nav Query currently links to /scan).
  • TEST DE-FLAKE: soccerFeatureExtractorCascade gets jest.setTimeout(20000) — it falls through to live adapters on cache miss and flaked at Jest's 5s default under full-suite load (same family as the S32 pipeline test).

P0 Audit Fixes (Session 41 — non-obvious)

  • THREE stat_type whitelists must stay in sync. A prop's stat_type is gated in src/routes/analyze.js (/prop + /batch), src/routes/scan.js (parlay legs), AND src/services/python/utils/validation.py. Adding a sport's stats to one without the others silently 400s. The S41 MLB bug was exactly this: Python had the MLB set; both Node gates didn't. mlbGrader.js is DEAD CODE (required nowhere) — the live MLB grade path is the generic engine1 feature pipeline, which keys off the Python validator's exact stat names (rbi/runs/innings_pitched, NOT rbis/runs_scored/outs_recorded).
  • tests/integration/analyze.test.js sits exactly at the 10-req/min IP rate limit (createRateLimit max:10, no reset hook). Adding any HTTP test there 429s the later cases. Put new analyze-route HTTP tests in a SEPARATE file (jest isolates module state per file → fresh limiter): see tests/integration/analyzeMlbStats.test.js.
  • Fonts are self-hosted via next/font/google (layout.tsx): Inter→ --font-sans, JetBrains_Mono→--font-mono, IBM_Plex_Mono→--font-ibm, all set on <html className>. globals.css :root maps --sans/--mono/ --ibm-mono onto them. CRITICAL: next/font OBFUSCATES family names, so literal 'JetBrains Mono'/'IBM Plex Mono' in CSS or inline fontFamily no longer resolve — always reference the variable. The old fonts.googleapis.com CDN <link> is GONE (it 503'd in prod). ShareCard canvas still uses literal names (canvas can't read CSS vars) — knowingly left.
  • Redirect routes use server-component redirect() from next/navigation (/settings/profile, /report/blog). /settings/security is a REAL MFA enrollment page — do NOT clobber it into a redirect.
  • Profile tier reads from useAuth().tier (the nav's source), not the /api/user/profile fetch, so the two can't disagree.

Player Intelligence System (Session 42 — non-obvious)

Built from the Claude Design "VYNDR Player Intelligence" bundle.

  • Archetypes (41, NOT 45) — 15 NBA + 5 WNBA-unique + 15 MLB + 6 soccer. The canonical registry is src/services/archetypeService.js (ARCHETYPES keyed by UPPERCASE name; classify/getArchetype). The frontend visual map (color + glyph SVG + desc) is duplicated in web/src/lib/archetypes.js because the browser can't import the backend; a test asserts the colors MATCH. Colors are unique WITHIN a sport but REUSED across sports (a TWO-WAY archetype is purple in NBA + WNBA) — don't "dedupe" them. classify(sport, stats) is a feature-scoring classifier → { primary, secondary|null, blend: [{archetype, weight}] }.
  • StatStrip rule — player name appears ONCE; stats are horizontal mono runs. Never stack the name per stat. components/vyndr/StatStrip.tsx (compact + expanded). vyndr/GameCard prefers playerStrips (grouped) over legacy per-prop rows; both kept for back-comat.
  • Stats APIsrc/routes/stats.js ALREADY existed (filters/public/live); Session 42 ADDED /player/:name, /leaders, /game/:id (don't recreate the file). Aggregation logic is in src/services/playerIntelService.js (testable; inject cacheGet). The name param is sanitized there (strip non-name chars, cap 60). Reads grades:{sport} cache for a player's props. Frontend needs the Next proxy (app/api/stats/player|leaders/route.ts) — Express isn't reachable from the browser directly (same rule as S25).
  • Player links — always via web/src/lib/playerHref.js/player/:name?sport=.
  • GradeResultCard / gradeAdapter — new card sections (archetypeBlend, propDNA, statContext, vyndrIntel) are OPTIONAL + self-hiding; gradeAdapter.buildIntelFields only populates them when the engine supplies archetype/season_avg/form/etc. They light up once the Session-43 data pipeline feeds them — no empty boxes now.
  • Settings/settings is now a REAL page (replaced the S41 redirect). It LINKS to /settings/security (the real MFA page) — never overwrite that. Danger zone delete is gated on deleteText === 'DELETE'; there's NO backend deletion endpoint yet, so the confirmed action surfaces an honest "email support" message rather than faking success.
  • Components added to the barrel: ArchetypeBadge, ArchetypeBlend, StatStrip, BookChip (@/components/vyndr). Book brand map = web/src/lib/books.js.

Data Pipeline Wiring (Session 43 — non-obvious)

  • Dropdown z-index P0 — the <nav> uses backdrop-filter, which creates a stacking context. The living-layer bars (Ticker, HeartbeatBar) render as siblings AFTER it, so dropdowns that overflow below the 60px nav got covered. The nav carries position:relative; zIndex:2 to float above them — don't remove it, and keep dropdown menus at zIndex:100.
  • MLB real statsmlbStatsAdapter is id-keyed; Session 43 added searchPlayer(name) (resolves via the cached season player list) + getPlayerStats(name). playerIntelService.resolvePlayerStats(name, sport) normalizes the raw statsapi.mlb.com object into the archetype classifier's input shape + display rows. getPlayerIntel classifies from REAL stats now. Adapters are injectable (opts.mlbAdapter/opts.resolveStats) — tests never hit the network. NBA/WNBA uses nbaStatsClient (Python service, usually offline in prod → degrades to found:false, NOT an error).
  • Grade-card intelanalyzeViaEngine1.buildIntelFields(features) computes stat-context + form/usage/matchup/rest from the EXISTING feature vector (no extra I/O) and Object.assigns them onto the legacy result. gradeAdapter maps them into the card. Archetype is deliberately NOT attached at grade time (the per-prop feature vector has no multi-stat season line) — that strip waits for the Session-44 pipeline. Keep grade-time I/O at zero (slate grades in tight loops; this is why archetype isn't fetched per-prop).
  • slateAdapter now emits playerStrips (groupPropsByPlayer) + pitchers (mapPitchers) on every card. The LIVE slate still uses the legacy components/GameCard (which ignores those + renders BookChip in its line grid); the full swap to vyndr/GameCard is still pending (needs inline grading ported). mapPitchers returns undefined for non-MLB / no probables.
  • depthChartService (getLineup/getDepthChart/getCascadeProjection) + /api/stats/lineup|depth|cascade are the foundation; all graceful + injectable. matchesTeam must guard empty names (t.includes('') is always true) — and the schedule find checks g.home/g.away, not the game object.

VYNDR Original Archetypes + Make-It-Visible (Session 44 — non-obvious)

  • Archetype names are VYNDR Originals (TORCH/BOMBER/ALPHA/…), NOT the old descriptive labels. The registry keys in archetypeService.js + the map keys in lib/archetypes.js are the new names; each carries legacyName/legacy (the old label, NEVER displayed). getArchetype() + archetypeInfo() + badgeStyle() resolve legacy names → the canonical VYNDR name (defends stale cached data). The classify() scorer objects use the new keys too — if you edit a threshold, use the new key. Full mapping is in BACKEND_HANDOFF.md.
  • MLB power merge: there's ONE power archetype (BOMBER) — it fires for any high-HR bat (incl. high-K sluggers like Judge → BOMBER). The old POWER PULL slot became WHIFF (strikeout-artist pitcher). Don't re-split power.
  • BACKEND_HANDOFF.md (repo root) is the canonical frontend↔backend data contract — update it in the same commit when an endpoint/component shape changes.
  • Grade-card intel gotcha: the engine→tierGating→/api/scan chain already preserves the intel fields (everything spreads ...result/...data). The bug was scan/page.tsx passing a hardcoded field subset to mapScanToGradeResult — it must forward season_avg/last10_avg/form/usage/matchup_grade/rest/archetype /archetype_blend/prop_dna (and ScanResponse must type them) or the card sections stay hidden.
  • Stale games: slateAdapter.isRelevantGame(game, now) drops completed games

    24h old; Slate.filteredGames applies it. Schedule TTL is 60s.

  • GameCard swap is DEFERRED (Kev's call): the live Slate keeps the legacy on-demand "Read" card as a bridge until the snapshot pipeline lands. The on-demand grade model is being retired for a pre-graded snapshot model; the vyndr/GameCard (playerStrips/pitchers) is built for that and swaps in then. Don't swap it before the grades cache is populated (cards would be blank).

Snapshot Pipeline (Session 45 — the product model)

The on-demand "Read" grade flow is RETIRED. Grades are produced by a scheduled snapshot, locked to the line, and read from cache.

  • snapshotService.runSnapshot(sport) orchestrates EXISTING services (don't rebuild): getOdds → gradeAndCacheSlate (captured via an injected cacheSet, so we re-write an ENRICHED envelope) → classify archetype per player (resolvePlayerStats + archetypeService) → attach gradedAt {line,odds,timestamp}computeLineDeltas vs previous → write snapshot:{sport}:latest|previous + grades:{sport}generateTickerEventsticker:items. ALL deps injectable → unit-tested with zero network. runAllSnapshots() is the cron entrypoint.
  • Redis keys: snapshot:{sport}:latest (current locked snapshot: {grades, deltas}), :previous (for the next delta), grades:{sport} (enriched, read by GameCard/Explore/leaders), ticker:items (capped 50 array).
  • Trigger is INTERNAL-ONLY: POST /api/internal/snapshot/:sport|/all behind requireInternalAuth (header x-internal-key == VYNDR_INTERNAL_KEY). /all is registered BEFORE /:sport or Express captures "all" as a sport.
  • Cron: src/snapshotScheduler.js, gated SNAPSHOT_CRON=1, UTC hours 14,19,22,1,3, armed in server.js (NOT app.js — app.js is imported by tests). No new dependency. For multi-replica, use an external n8n cron hitting the internal endpoint instead.
  • Read path: GET /api/snapshot/:sport (public, cache-only, never triggers a snapshot → can't drain PropLine). GET /api/ticker (public, merges TICKER_MANUAL env pins).
  • GameCard swap: the live Slate renders vyndr/GameCard (legacy GameCard kept for TYPES only — import type). It overlays snapshot grades onto each game's odds-derived props via slateAdapter.buildPlayerStripsFromProps (player name once + archetype + locked grade + line-delta sub-line). Ungraded → "Awaiting next scan", NO Read button. StatStrip renders the gradedAt/delta sub-line when a prop carries gradedAt/delta/awaiting (snapshot mode), else the inline chip.
  • Ticker (vyndr/Ticker) polls /api/ticker every 30s; the passed items are the initial + graceful fallback (never blanks on fetch failure).
  • NBA/WNBA stats: espnStatsAdapter is the FREE fallback when the Python nba_api service is offline. parseAthleteStats is DEFENSIVE (null on any shape it doesn't recognize → found:false). Its live ESPN shape may need prod tuning.
  • Env: PROPLINE_API_KEY_1/2/3, VYNDR_INTERNAL_KEY, SNAPSHOT_CRON=1, SNAPSHOT_HOURS_UTC (optional), TICKER_MANUAL (JSON array).

Grade Intel + Name Norm + Pitchers (Session 46 — non-obvious)

  • Grade-card intel root cause: buildIntelFields reads l5_avg/l20_avg/ opp_rank_stat/rest_days from the feature vector. gameLogService.getGameLogs is NBA/WNBA-ONLY (offline Python service) → MLB props had NO recent/season averages → intel always empty. FIX: featureCache.gameLogFeatures has an MLB branch using mlbStatsAdapter.getPlayerStats + the pure mlbGameLogFeatures (MLB stat_type→game-log field via MLB_LOG_FIELD). If you add an MLB stat type, add it to MLB_LOG_FIELD or its intel won't compute. buildIntelFields also takes { playerStats, projection } fallbacks — don't remove the resilience.
  • Player name normalization: src/utils/playerName.js is the source of truth (frontend copy at web/src/lib/playerName.js — keep them identical; a test cross-checks). normalizeName(raw)→{display,key}: display strips periods + de-dots suffix (keeps accents); key accent-folds + suffix-strips for comparison. Used in snapshotService grouping, slateAdapter grade-index + player-strip merge, and playerIntelService. sanitizePlayerName now returns the de-dotted display ("A.J. Ewing"→"AJ Ewing") — tests that asserted the dotted form were updated.
  • MLB pitchers: the ESPN /api/schedule has NO probable pitchers. They come from GET /api/schedule/:sport/pitchers (MLB) → probablePitchers service → mlbStatsAdapter.getScheduleWithPitchers. The Slate matches them to games by team (full name OR mascot via slateAdapter.buildPitcherMap/pitchersForGameTeams). ERA is best-effort (season stats per pitcher id, cached).

Name Norm + Intel + Ticker (Session 47 — non-obvious)

  • Name normalization is now complete: playerName.js (both copies, kept identical) strips parenthetical team tags ("(STL)"), de-dots, suffix-strips, accent-folds the key, AND resolves first-name nicknames via the NICKNAMES table ("matt"→"matthew"). nameKey does the nickname resolution; normalizeName does display/key. To add a nickname, edit BOTH copies. The SLATE displays the normalized de-dotted name (buildPlayerStripsFromPropsnormalizeName().display), not raw PropLine — that's why "AJ Ewing" shows, not "A.J. Ewing".
  • MLB VYNDR INTELLIGENCE fields come from mlbGameLogFeatures: rest_days (gap-1 between the two latest game dates; 0 = B2B), ab_per_game (usage). If a field is missing from the card, check that mlbGameLogFeatures produced it and buildIntelFields has a branch (usage reads usage_rate→minutes→ab_per_game; matchup reads opp_rank_stat→bvp_advantage).
  • Ticker SCAN dedup: pushTickerItems keeps one SCAN per sport (via the sport field on the event, or parsed from the text prefix for legacy items). MOVE/GRADE are time-specific and never deduped.
  • BOMBER threshold is prorated for mid-season (hr>=15 strong / hr>=10 moderate). If you re-tune archetype thresholds, remember season totals are partial mid-season — don't use full-season cutoffs.

Normalization Chokepoints (Session 48 — non-obvious)

  • The snapshot is the normalization SOURCE. snapshotService.runSnapshot normalizes every grade's player to normalizeName().display AND dedupes to one grade per nameKey|stat_type (highest confidence) before writing grades:{sport} + snapshot:{sport}:latest. So GameCard overlay, Explore, leaders, and profile activeProps ALL inherit clean, merged names — don't add per-consumer normalization, fix it here.
  • Three paths that needed it (all fixed): snapshot grades (above); buildPlayerStripsFromProps dedupes a player's props by stat (graded > awaiting); scan tonightsPlayers groups by nameKey. If a NEW surface lists players, group by nameKey + display normalizeName().display.
  • TWO intel renderers — don't confuse them: the GRADE CARD (scan) uses analyzeViaEngine1.buildIntelFields(features); the PLAYER PROFILE uses playerIntelService.buildIntel(stats). The "+0%"/"—" bug was the PROFILE's defaults — fixed by resolvePlayerStats attaching real usage (AB/G) + rest and buildIntel reading them. The grade card already worked (the full feature merge carries ab_per_game/rest_days; FEATURE_NAMES is meta bookkeeping only, NOT a whitelist that filters the vector).

Onboarding Flow (Session 49 — non-obvious)

  • Preferences live in Supabase user_metadata.preferences (NO table/migration). src/routes/preferences.js GET/POST behind requireAuth, read/written via the service client's auth.admin.getUserById/updateUserById. POST is a PARTIAL merge (only body keys change) + sanitized (valid sports only, ≤12 books, clamped limit). Shape: { sports[], books[], weekly_limit, onboarding_complete }.
  • Redirect reads FRESH from the API, not session metadata. The dashboard fetches /api/preferences on mount and redirects new+incomplete users to /onboarding. Don't rely on session.user.user_metadata for this — it's stale after a backend write (admin API doesn't refresh the client session).
  • Existing users are exempt via a created_at >= ONBOARDING_CUTOFF (2026-06-19) check. The redirect effect also early-returns while authLoading (else it bounces unauthenticated users to onboarding instead of login).
  • Dashboard personalization: Slate initialTab = prefs.sports[0]; preferred books highlight in the card lines grid via lib/books.js isPreferredBook (matches DK/draftkings/DraftKings), threaded dashboard → Slate → vyndr/GameCard.
  • Settings has a PREFERENCES section (GET to load, POST to save) — the same sports/books/limit the onboarding collects.
  • Name micro-fix: playerName.js collapseInitials merges "J C" → "JC" (display + key) so space-separated initials dedupe.

Name Edge Cases + Audit Polish (Session 54 — non-obvious)

  • normalizeName now also strips hyphens to spaces ("Jung-hoo" → "Jung hoo") in display+key; nameKey strips single-letter MIDDLE tokens ("Josh H Smith" → "josh smith") — keeps first (may be a collapsed initial like "jc") + last, so real middle names ("Juan Carlos Smith") and first initials are NOT dropped. richie: 'richard' added to NICKNAMES. Both copies kept identical.
  • Accent-keep dedup (snapshotService): the variant-grade collapse keeps the highest-CONFIDENCE grade but the richest DISPLAY (prefers accented "José" over "Jose", then longer). That's why prop rows now match the accented pitcher line. hasAccent uses a charCode>127 check (NOT a regex with literal control bytes).
  • Team Hub names are normalized in teamService.getTeamHub (the source), not the page — every /api/team/:abbr consumer gets normalizeName().display.

Parlay Lab (Session 50 — non-obvious)

  • Correlation model lives in src/services/parlayService.js (ADDED to the S28 categorical matrix — both coexist). correlationScore(l1,l2) is numeric + GAME-aware (0.7 same-player/game, 0.4 same-team/game, 0.2 same-game, 0.0 diff- game). combinedGrade penalizes the leg-grade avg by avgCorrelation*0.5; estimatedPayout = Πfair-odds × (1avgCorr) discount. gradeParlay is the bundle the route returns. Leg shape: { player, team, game, stat, grade }.
  • POST /api/parlay/grade (src/routes/parlay.js) — public, 26 legs, returns { combined, correlation, payout, legs }. The Next proxy was pointed at the WRONG upstream (/api/scan/parlay); it now forwards to /api/parlay/grade.
  • ParlayContext auto-grades the slip (debounced 250ms) via that endpoint whenever legs change (≥2). combined/correlation/payout are live on the context — don't call the endpoint from components, read the context.
  • Leg cap is tier-aware via the context. maxLegs defaults 6; ParlayPanel sets it from useAuth().tier (free 2 / analyst 4 / desk 6). addLeg reads a ref so it stays a stable callback. The "+" buttons no-op at the cap.
  • "+" wiring: StatStrip takes onAddLeg/isLegActive; vyndr/GameCard provides them via useParlay (it owns the game id + team). legKey = player|stat|line|direction is the dedupe key (exported from the context).
  • ParlayPanel (mounted in layout, REPLACED ParlayTray which called a then-missing /grade endpoint). Floating badge bottom-right when closed; free tier blurs the payout with a window.__goPaywall upsell.

Team Hub (Session 51 — non-obvious)

  • teamService.getTeamHub(sport, abbr) is the single payload builder for /team/:abbr. MLB is the real path: mlbStatsAdapter.resolveTeam (abbr→id via the cached /teams list) → getTeamRoster → per-player getSeasonAverages(id) (bounded concurrency 8, reuses playerIntelService._internals mappers) → archetype (the snapshot grade's locked archetype, else classify) → graded props from grades:{sport}. The whole hub is cached 15 min (teamhub:{sport}: {abbr}); each player's season stats cache 6h. NBA/WNBA have no free roster feed → it returns a partial roster built from tonight's graded players + a note. All deps injectable for tests.
  • To add a sport's roster, add a real roster source in getTeamHub (the MLB branch is the template); abbr→id mapping is fetched live from statsapi (no hardcoded team table to maintain).
  • GET /api/team/:abbr (public, cached) 404s an unknown MLB team. Browser must use the Next proxy app/api/team/[abbr].
  • Page split: team/[abbr]/page.tsx is a server wrapper (for generateMetadata) rendering the TeamHub client component (sort/filter/parlay are interactive). Player names → playerHref; team abbrs on game cards → vyndr/GameCard's TeamLink (stops propagation from the open-game handler). The roster "+" reuses the Parlay Lab (useParlay/legKey).

Infra Verification (Session 52 — non-obvious)

  • GET /api/internal/snapshot/status (internal-key gated) is the post-deploy health probe: { cron_armed, cron_hours_utc, last_snapshot, redis_keys, ticker_count }. Use it to confirm the snapshot pipeline is alive without shelling into the container.
  • Scheduler logs both states on start: armed ([snapshotScheduler] armed — SNAPSHOT_CRON=…) and disarmed. If you see neither in container logs, startSnapshotScheduler() isn't being called.
  • SNAPSHOT_DEBUG=1 turns on a per-run [deltas] log in computeLineDeltas (off by default — it's a hot path). Confirms a previous snapshot exists to diff.
  • Redis persistence is NOT app-controlled. ioredis connects via REDIS_URL; AOF/RDB is a server-side Coolify config. Snapshot keys disappearing on restart = the Redis instance has persistence off, not a code bug.
  • Push-to-Book is a TEASER only — "BOOK IT ⟶" (StatStrip) + "PUSH-TO-BOOK · COMING SOON" (GradeResultCard) advertise an unbuilt feature. No backend.

Social Preview / OG Image (Session 53 — non-obvious)

  • The OG/Twitter image is dynamically generated by the file-based convention web/src/app/opengraph-image.tsx (Twitter re-exports it). Do NOT re-add an images: ['/og-image.png'] to the layout.tsx metadata — that emits a second, conflicting og:image tag. The legacy static /og-image.png still exists in public/ but is unreferenced.
  • It uses Node runtime, NOT runtime = 'edge' — this is a self-hosted output: 'standalone' build; edge runtime isn't available off Vercel and breaks next/og here. Don't copy the edge example from Next docs.
  • Social copy is intentionally jargon-free (no "Bayesian"/"kill conditions"/"xG regression"). "kill conditions" is still legit IN-APP product copy (help/pricing/ scan) — only the social meta was cleaned.

Self-Learning Loop + Real-Time Layer (Session 55 — non-obvious)

  • outcomeService.settleSnapshot(sport, deps) is the self-learning loop: reads snapshot:{sport}:latest, settles each locked grade vs the REAL result from mlbStatsAdapter.getPlayerStats().last10 (game-log row whose date matches the graded date — UTC or ET, to cover late-game rollover), records hit/miss/push, and writes outcomes:{sport}:log + accuracy:{sport} + accuracy:overall. Idempotent — dedupe key is nameKey|stat|line|side|date; re-running never double-counts. Presence of a game-log row ⇒ the game is FINAL (no separate status check). NBA/WNBA have no free settled-result feed → they stay pending (never throw). If you add an MLB stat_type, add it to MLB_LOG_FIELD in outcomeService (a LOCAL copy — settlement is decoupled from featureCache's map on purpose) or its props won't settle.
  • Accuracy pct EXCLUDES pushes (hits/(hits+misses)); sample counts pushes. Grade buckets: A+ stands alone, then first-letter (A-/A→A, /B→B). 30-day trailing window keyed off each outcome's game date.
  • The cron settles BEFORE it grades (snapshotScheduler tick): settle yesterday's now-completed games, then grade today's fresh slate. Trigger manually via internal POST /api/internal/outcomes/all (same key as snapshot).
  • GET /api/accuracy (public, cached) + GET /api/ledger/accuracy (buckets — the pre-existing Next ledger/accuracy proxy finally has a writer). Browser reaches them via web/src/app/api/accuracy/route.ts (new proxy) — same S25 rule.
  • AccuracyBadge (@/components/vyndr) is HONEST: < MIN_SAMPLE (8 settled) → "MODEL · LEARNING" (amber), else "A-RATED · X% HIT · 30D" (green). Pass sport= for a sport-specific record. Self-hides only when the fetch fails/empty.
  • Settled outcomes overlay the live slate: GET /api/snapshot/:sport merges outcomes:{sport}:log onto grades (outcome:{result,actual}), threaded through slateAdapter.buildPlayerStripsFromPropsStatStrip.OutcomeChip (✓ HIT (2) / ✕ MISS). A settled prop hides its "+parlay" / BOOK-IT chips (the bet is over).
  • Real-time Slate: fetchSlate(tab, silent=true) polls every 60s WITHOUT the skeleton flash and NEVER wipes a good view on a transient empty result (guard: if (silent && allGames.length===0) return). The "SIGNAL LIVE · UPDATED Xs ago" strip uses lastRefreshed + a 15s nowTick. Ticker has an anchored LIVE badge that flashes on a new head event.
  • Founder pricing: stripeService FOUNDER_CODE_EXPIRY default is now 2026-12-31 (was 2026-06-30, which had lapsed and disabled every founder code
    • the ClaimMeter pitch). That expiry lapsing — NOT a tier-limit change — was the cause of the "4 stripe test failures." Operators override via FOUNDER_CODE_EXPIRY.

Data Audit + Pipeline Resilience (Session 56 — non-obvious)

  • specs/propline-audit.md + specs/vyndr-roadmap.md are the source of truth for data coverage + the session plan. Re-run node scripts/propline-audit.js (writes raw JSON to stdout; live sections need network) to refresh.
  • A market only fully works if it's wired at FOUR layers: requested in proplineAdapter.MARKETS, mapped in oddsNormalizer.MARKET_MAP, whitelisted in all three grade gates (routes/analyze.js, routes/scan.js, python/utils/validation.py), AND (for MLB) present in MLB_LOG_FIELD in BOTH featureCache and outcomeService. Miss the map → silent zero; miss the log field → no features + no settlement. batter_rbis was mapped to rbis while everything else keyed on rbi — a 4-layer desync that silently killed RBI props. It's rbi now; a normalizer test locks it.
  • The streaks/hotlist path uses its own rbis key built from raw MLB stats — independent of the odds normalizer. Don't "unify" them; the split is intentional.
  • MLB and WNBA both settle end-to-end. CORRECTED 2026-07-26 (was "MLB-only"): outcomeService.SPORTS includes wnba, which settles via ESPN box scores (espnStatsAdapter box-field map) — verified: 376 WNBA rows settled, settle logic spot-checked correct. NBA/soccer still do not settle (no free settled feed wired). So accuracy reflects MLB + WNBA, not MLB only.
  • src/utils/opsNotify.js pushes pipeline alerts to ntfy (vyndr-pipeline- kev2026). It NEVER throws and is auto-disabled under NODE_ENV==='test' / PIPELINE_ALERTS=0 (inject fetchImpl to test it). snapshotService alerts on success/stale/failure; snapshotScheduler has a per-minute missed-cron watchdog (isSnapshotOverdue, exposed as overdue on the status probe).
  • Snapshot retry rule: retry-once ONLY on a thrown/null odds response (a transient provider blip). A successful-but-empty slate is NOT retried — that's a legit off-hours empty slate, and retrying would waste quota + add 60s latency.

Phase 0 — Kill the Lies (Session 57 — non-obvious)

Work-order Phase 0 (Jul 10 live audit): fabricated UI deleted/rewired. The overhaul work order lives in specs/phase-0-kill-the-lies.md (+ the full phased plan in the Session-57 conversation / BUILD-STATE Next section).

  • ESPN schedule is date-pinned now. fetchScheduleFromEspn(sport, date) sends ?dates=YYYYMMDD AND filters events to the requested ET date (undated events dropped). Without this, off-season ESPN returns the NEAREST slate — the Jun 13 NYK@SA Finals game rendered as "tonight" all July. Don't remove the defensive filter even though the param "should" be enough.
  • liveTick carries NO display data — state is { tick } only (1s re-render pulse). Real header numbers come from GET /api/snapshot/summary (public, cache-only; registered BEFORE /:sport in routes/snapshot.js). vyndrSystems.test.js fails if fabricated counters reappear.
  • Ticker hides below 4 real items (spec §3) and publishes --ticker-h (32px/0px) on <html>. Layout main paddingTop = calc(92px + var(--ticker-h, 0px)), Slate sticky top = calc(90px + ...). If you change header heights, update BOTH calc bases (they replace the old 124/122 constants). No hardcoded fallback ticker items — real exhaust or nothing.
  • /terminal redirects to /dashboard. The old page's layouts (VVI cards, injury wire, leaders) are preserved UNROUTED in web/src/components/intel/TerminalTemplates.tsx as §12 content-engine templates — every constant in there is SAMPLE data; never route it as-is. BottomTabBar's 5 tabs are now Slate/Explore/Scan/Ledger/More.
  • Empty slates use web/src/lib/emptyState.js (month-aware per-sport copy). It's CommonJS + unit-tested; add sports there, not inline.

Phase 1 — Truth Infrastructure (Session 58 — non-obvious)

  • DATA SEMANTICS RULE (system-wide): VYNDR never generates lines/odds — market values are REAL book numbers captured at a timestamp; only model_value/grade/edge/confidence are model output, always labeled MODEL in the UI. Any path that would fill in a market value must show an absent state. Number(null) === 0 is the classic fabrication bug — use numOrNull (ledgerService) style strict parsing.
  • ledger_entries (migration 019, APPLIED): user_id NULL rows = the PUBLIC model record (pipeline-only — never write anon scans there!). Dedupe = UNIQUE NULLS NOT DISTINCT (user_id, player_key, stat, line, side, game_id); upserts use ignoreDuplicates: true so re-runs never overwrite the original lock. RLS: clients read own rows + public rows; ALL writes via service role. player_key = nameKey().
  • Write paths: pipeline → snapshotService calls ledgerService.recordPipelineGrades + captureClosing (best-effort, never breaks the snapshot; no-op without SUPABASE env). User scans → the NEXT /api/scan route (writeLedgerEntry), authed users only.
  • Closing/CLV: captureClosing overwrites today's unsettled rows' closing_line/odds on EVERY snapshot — last write before game start is the close. CLV is SIGNED BY SIDE: over = locked closing (positive = market moved toward the grade = 'beat'); under = inverse. Settlement (settleLedger) runs in the scheduler's pre-grade settle pass and via POST /api/internal/ledger/settle; idempotent (.is('outcome', null) guard).
  • Insufficient data (work-order 1.5): analyzeViaEngine1 REFUSES (grade null + insufficient_data: true) when projectionFor finds no model reference (l5→l20→{stat}_per_90→xG-for-goals). gradeSlateService filters refusals out of the slate; parlay legs degrade to the F-stub; the scan route writes nothing and doesn't burn a scan. If you add a sport, make sure its feature extractor emits a projection field or every prop refuses.
  • n≥20 rule: getModelAggregate returns hit_pct/beat_close_pct as NULL under 20 settles; every surface (ledger MODEL tab, ModelRecord) renders "RECORD BUILDING" instead. Never render a % on a small sample.
  • SYNC thresholds come from SNAPSHOT_EXPECTED_INTERVAL (s; default 18000) via /api/snapshot/summary expected_interval_s. Phase 2.5 drops the value to the intraday cadence — no UI change needed.

Player Keys + Slate Join + Mobile Nav (Session 59 — non-obvious)

  • mlbStatsAdapter.searchPlayer resolves via nameKey (canonical, accent-folded). NEVER reintroduce substring matching — it returned the WRONG player's id on a near-miss (the audit's mismatched last-10 bug). Fallback is unique last-name+first-initial or null: a missing profile beats another player's log.
  • The slate JOIN INVARIANT lives in buildPlayerStripsFromProps(…, gameTeams): a graded prop whose player's real team (snapshot team, from the stats resolve) isn't a game participant is DROPPED. Props with no team info are kept (can't verify ≠ wrong). Tests in slateAdapterStrips lock it.
  • grades:{sport} is written with SNAP_TTL (6h), NOT 2h — the 2h TTL expired between 5h-apart cron runs and blanked /team + Explore mid-day.
  • Sport tabs are THE filter (?sport= URL param, history.replaceState). The dashboard has NO second tablist — legacy sections subscribe via Slate's onTabChange. Don't re-add a duplicate tab row.
  • lib/pipelineSchedule.js mirrors SNAPSHOT_HOURS_UTC (14,19,22,1,3) for "Grades post ~X ET" waiting states; a test cross-checks it against snapshotScheduler.HOURS_UTC. Change the cron → change both.
  • BottomTabBar shows on '/' — hiding it there left anon phones with ZERO navigation (desktop links hide <768, hamburger retired). HIDE_ON is auth flows only.
  • GameCard collapse: 6 graded props max (sorted A+→F), "ALL N READS →" expands in place; GAME LINES <640px = .gl-summary best-line row + expander revealing .gl-full.gl-expanded.

Night-2 Board (Session 60 — non-obvious)

  • SNAP_TTL is 24h ON PURPOSE (was 6h): the overnight cron gap is 11h; a shorter TTL silently kills the morning accuracy settle. AUTONOMY.md is the zero-touch trace — update it when the loop's shape changes.
  • The snapshot pipeline FEEDS the aggregator: mergeRosterLogs writes rosterlogs:{sport} (72h TTL, cap 300) from the same stats resolve that classifies archetypes. Streaks/hot-lists starve if you remove it — the external prefetch (n8n/tank01) was never armed.
  • THE LENS RULE: no raw streak renders alone — streakLens.applyLens attaches built-vs / tonight's matchup (+opposing SP ERA for MLB batters) / difficulty / one-line read. Absent context = say less, never invent. Streaks are FREE for every tier; hot lists keep the free top-3 gate.
  • Phase 2.5 lives in intradayRefreshService: signed delta vs the GRADED side; STEAM never re-grades; ≥1.0 against re-grades THAT prop at the real current line; a dropped grade sets revised_from_grade ONCE (the original letter is forever). applyRevision only touches unsettled public rows. Scheduler skips full-snapshot slots; INTRADAY_REFRESH=0 kills it. After deploy set SNAPSHOT_EXPECTED_INTERVAL=1200.
  • /api/players/search is the canonical resolver (nameKey fuzzy; MLB = statsapi list, others = cached names). The Python service is a fallback only. Never reintroduce raw-substring matching.
  • Landing renders instantly for anon: hasStoredSession() sync localStorage gate — do NOT re-block the marketing render on useAuth().loading; that was the 34s LOADING THE SLATE bug.
  • QA.2022 in vyndrParityQA lock the night's color semantics (STEAM amber / VALUE green / red = STALE-or-miss only), mono data surfaces, and schedule-derived waiting copy. Keep them green.

Affiliate + Partner Plumbing (A1 S3 — non-obvious)

  • Every sportsbook link goes through web/src/lib/bookLinks.js (buildBookLink{url, tracking}; unknown book → null). The affiliate layer is web/src/lib/affiliateConfig.js — ALL books enabled:false (no program approved); flipping a book + filling params produces tracked URLs with NO component changes. Empty param values are SKIPPED (a half-filled config degrades to organic, never a fabricated id). Anchors MUST render rel={BOOK_LINK_REL} ("sponsored noopener noreferrer") — tests assert it.
  • Best-price dot honesty rule: slateAdapter.detectBestBook(rows, side, refLine) returns a book ONLY when ≥2 books post the SAME line for the side and prices differ — never compare odds across different lines, never mark a lone price "best". The per-book rows reach the browser as lines[] on each grouped prop; Slate.groupByGame now threads them as books on PropRowProp (pickLine still picks the single displayed line).
  • Partner attribution: ?ref=CODEvyndr_ref cookie (90d, FIRST-touch, lib/partnerRef.js + PartnerRefCapture in layout) → signUp metadata partner_ref → internal GET /api/partners/report/:code. The user_profiles.partner_ref column does NOT exist yet — the endpoint returns zeros + note until the TODO migration in docs/PARTNERS.md runs (metadata lands in auth.users.raw_user_meta_data, which PostgREST can't query). OAuth signups carry no metadata (known gap). Partner code == Stripe promotion code, verbatim (docs/PARTNERS.md §3).
  • Worktree build gotcha: web/node_modules isn't shared into git worktrees and Turbopack REJECTS a symlink pointing outside the project root — use cp -al (hardlink copy) from the main repo's web/node_modules.

Newsletter — THE VYNDR REPORT (Session S7, a1 — non-obvious)

  • newsletterService is the whole layer: buildDailyReport(sports, deps) (deterministic VOICE v1.1 template over snapshot grades + streak lens + getModelAggregate; every dep injectable), subscribe(email) and sendDailyReport(opts) (Listmonk, self-hosted, zero out-of-pocket). ALL Listmonk paths are env-gated on LISTMONK_URL/USER/TOKEN/LIST_ID — any missing → { ok:false, reason:'not configured' }, and the capture UI shows "Signups open soon". Auth is the API-user scheme Authorization: token user:token, NOT basic auth.
  • Double opt-in is Listmonk's job: subscribe sends preconfirm_subscriptions:false; the list itself must be created as double-opt-in (runbook: docs/NEWSLETTER.md). The email template embeds the LITERAL {{ UnsubscribeURL }} — Listmonk substitutes per-recipient; never "fix" it. A 409 (already subscribed) is returned as ok — no enumeration.
  • The send is deliberately unscheduled. Only POST /api/internal/newsletter/send (internal key) triggers it; an empty report (0 signals AND 0 streaks) refuses to send. The record line renders a percentage ONLY when hit_pct != null (n≥20 gate upstream in getModelAggregate), else "RECORD BUILDING · N pending".
  • VOICE lint is executable: tests/unit/newsletterService.test.js fails on any !, banned vocabulary (word-boundary regex), or hype emoji in subject/html/text. The RG disclaimer says "No outcome is promised" — the word "guarantee" trips the lint even in a disclaimer, keep it out.
  • Capture placement: the signup "done" card auto-redirects after 1.5s (setTimeout(router.replace)) — useless for a form. The free-signup success surface is /welcome. Landing capture sits below FAQ. Subscribe route is 10/min IP-limited → keep tests/integration/newsletterRoute.test.js under 10 subscribe requests (same lesson as analyze.test.js).

Ops Self-Watch (Session 8, A1 board — non-obvious)

  • Pure alarm logic lives in src/services/opsWatch.js (failure tracker, zero-settle signal, quota daily check, pulse assembly) + src/services/ systemHealth.js (statfs/os, injectable). snapshotScheduler only WIRES them — keep new alarm logic in opsWatch so it stays unit-testable.
  • Zero-settle signal = the ledger settle's own return values (Postgres game_date < today AND outcome IS NULL), NOT snapshot:{sport}:previous — the snapshot key expires in exactly the failure mode the alarm exists to catch (the S60 SNAP_TTL bug). Scoped to SETTLEABLE_SPORTS (mlb); NBA/WNBA rows legitimately stay pending and must not page daily. Morning slot only (morningHourUtc = first configured hour ≥ 06 UTC).
  • Failure pager pages ONCE per losing streak (exactly at count 3), not once per failing slot; status:'skipped', reason:'no grades' is NOT a failure (props arrived, grader refused) and resets the counter.
  • Daily pulse fires at PULSE_HOUR_UTC (default 13 = 9 AM EDT), one notification, dual dedupe (in-process date + Redis ops:pulse:{date}). Missing data renders 'n/a' — a pulse never fabricates a zero (Data Semantics Rule applies to ops copy). All alert copy: no exclamation points, ever (VOICE v1.1) — tests lint for it.
  • Single-suite jest runs of scheduler tests can hang at exit when redis is down (ioredis reconnect timer keeps the process alive) — PRE-EXISTING at baseline, full-suite runs are unaffected. Don't chase it as a leak in new code; inject cacheGet/cacheSet in tests to avoid creating a client.
  • Box-side ops (Uptime Kuma, Coolify deploy-failure webhook, ntfy phone setup) = docs/OPS-RUNBOOK.md.

Slip Reader (Session 9, A1 board — non-obvious)

  • src/services/slipReader.js — PURE per-book OCR-text layout parsers (DK/FD/MGM/Caesars) + detectBook + parseSlipText(text, bookHint). The NEVER-GUESS contract: every field carries a confidence; below CONFIDENCE_THRESHOLD (0.6) the field is NULL + the leg needs_review:true. Values are USER-SLIP values (source:'user_slip') — never written to any market cache. Stat labels normalize via STAT_ALIASES → the scan-route vocabulary (a unit test cross-checks every alias against VALID_STAT_TYPES); players via playerName normalizeName/nameKey.
  • Caesars head split is alias-anchored: "Pete Alonso Home Runs Over 0.5" is regex-ambiguous (greedy name eats "Home" → stat "runs" = WRONG-stat fabrication). splitPlayerStat matches the LONGEST known stat alias the head ENDS with; no alias → stat null AND player low-confidence (boundary unknowable). BetMGM legs carry "@ -115" — don't pre-filter its lines with the game-row @ heuristic; the Over/Under grammar is the filter.
  • OCR = tesseract.js v7 (Apache-2.0), lazy-required in recognizeImage so the parsers load without WASM. ~46MB on disk (core WASM); eng traineddata (~11MB) downloads ONCE at first recognize and caches at TESSERACT_CACHE_PATH (default .tesseract-cache/) — first prod OCR call needs outbound network, then it's fully offline.
  • POST /api/slips/parse (requireAuth): base64/data-URL image ≤4MB decoded OR raw text (paste path, also what hermetic route tests use). Daily quota slips:{user}:{YYYY-MM-DD} — free 1/day, paid 10/day; Redis counter with in-memory mirror (max of both enforces). Validation runs BEFORE the quota is burned. Injectable via router.__internals.setDeps. Next proxy: web/src/app/api/slips/parse/route.ts (S25 rule).
  • /slip page (added to GATED_ROUTES): upload/paste → editable leg rows (nulled fields amber; editing clears the flag) → each leg graded via the EXISTING POST /api/scan (refusals render "NO GRADE — insufficient data") → useParlay().addLeg per graded leg → share card is copy-text (no OG plumbing). Sport selector is MLB/NBA/WNBA (ParlayLeg's sport union).
  • Vision-model upgrade is POST-REVENUE (specs/vyndr-roadmap.md) — the route contract is the stable interface; only the extraction engine swaps.

Public Ledger Profiles v1 (Session 10, A1 board — non-obvious)

  • public_profiles (migration 022) is PRIVATE BY DEFAULTpublished DEFAULT FALSE, one explicit toggle in Settings, service-role writes only. GET /api/profiles/:handle returns the SAME 404 body for unknown AND unpublished handles (no existence leak) — keep them byte-identical; a test diffs the two responses. A malformed handle 404s WITHOUT a DB query.
  • getModelAggregate({ userId }) swaps .is('user_id', null) for .eq('user_id', uid) on BOTH the settled and pending queries — the same 30d window + n≥20 gate over one user's ledger. The no-userId default is the public model record and must stay untouched.
  • The user's public entries are SETTLED rows only (.not('outcome', 'is', null), newest 50, same columns as /api/ledger) — pending reads are not public until they settle, and nothing is curated: misses included.
  • /u is in OPEN_ROUTES on purpose — the share surface must load anonymous; the privacy gate is the API's 404, never the router.
  • Jest from a worktree gotcha: the repo config's testPathIgnorePatterns includes /.claude/, which matches EVERY path inside .claude/worktrees/... → "No tests found". Run with npx jest --testPathIgnorePatterns "/node_modules/" from a worktree (CLI replaces the config array).

Row Grammar + Display Layer (Session S6, A1 board — non-obvious)

  • specs/ROW-GRAMMAR.md is LAW, locked by tests/unit/rowGrammar.test.js (source-order assertions on StatStrip). Prop-row slot order: identity → viability → stat+line → market context (best dot, movement chip) → model output (revision strike, grade) → outcome → actions → provenance. If you reorder a StatStrip element, amend the spec + test in the same commit or the suite fails. Red = settled-negative ONLY (miss/dead/stale/faded); the line sparkline is green(toward)/amber(against)/dim(flat), never red.
  • Sparkline history is captured by intradayRefreshService.trackHistory — {t, line} on each grade, seeded with the LOCKED line, deduped when flat (a point means the line MOVED), capped 24, persisted inside the snapshot write-back (no new Redis keys). StatStrip.LineSparkline needs ≥3 points. Number(null) === 0 struck again here — all three S6 numeric paths (trackHistory, last10Dots line, clvBucketIndex) use strict null guards.
  • last10_dots is attached at READ time in routes/snapshot.js from rosterlogs:{sport} via services/last10Dots (reuses streaksService accessors — new MLB accessors runs/doubles/triples/outs live THERE, one source of truth). Dots score vs the LOCKED line (gradedAt.line), not the current line.
  • clv_distribution lives in getModelAggregate behind the SAME n≥20 gate as hit_pct (null below) — never re-derive the gate in a consumer. Buckets clamp outliers into the edges ([-2,-1) … (1,2]).
  • Global searchwindow.__search + ⌘K registered in GlobalHosts; SearchModal fans out to /api/players/search per sport (MLB/NBA/WNBA) + static web/src/lib/teams.js (soccer deliberately ABSENT — no canonical registry; add it with the soccer team hub, not before). BottomTabBar keeps the Explore tab; Search is the first More-sheet item.
  • LCP mechanics: fade-up now floors at opacity .6 — a 0-start entrance animation makes the hero h1 a LATE LCP candidate (invisible content isn't contentful). IBM Plex Mono is preload:false (decorative face; its 4 weight files competed with Inter + CSS on slow 4G). Measure before/after via PageSpeed post-deploy — not runnable from the box.

Live Tracking (Session S11, A1 board — non-obvious)

  • GRADES NEVER CHANGE IN-GAME. liveTrackingService + /api/live/:sport produce per-player CURRENT box values; web/src/lib/liveProgress.js turns them into PROTO-OUTCOMES rendered in the ROW-GRAMMAR outcome slot (StatStrip.LiveTracker). Spec = specs/LIVE-TRACKING.md; slot-6 + color amendments are locked in rowGrammar.test.js — an in-progress prop is NEVER red (green = hit/on-pace/holding, amber = needs/line-passed).
  • THREE MLB stat maps now exist on purpose — do NOT merge them: featureCache.MLB_LOG_FIELD (features), outcomeService.MLB_LOG_FIELD (settlement, game-log rows pick ONE group by position), and liveTrackingService.LIVE_BOX_FIELD (live boxscore carries BOTH stats.batting AND stats.pitching per player → each stat names its group, and innings_pitched parses in THIRDS: '5.2' = 5⅔ — parseFloat is wrong). Adding an MLB stat_type = wire all three.
  • Absent beats zero, live edition: a player not yet in the game has EMPTY stats.batting/stats.pitching objects (MLB) or didNotPlay/empty stats row (ESPN WNBA) → he is OMITTED from the live index. Never coerce to 0.
  • Quota shape: live:{sport}:{date} TTL 90s is the shared window — 1 schedule (hydrate=linescore, so inning progress costs no extra call) + 1 boxscore per live game per 90s TOTAL across all users; zero boxscore calls when nothing is live. The Slate polls /api/live/{sport} every 60s ONLY while live mlb/wnba games are on screen. The route never fetches for unwired sports (short-circuits before the service).
  • The strip join needs statTypebuildPlayerStripsFromProps now puts the canonical lowercase stat key on every strip prop (stat is the short display label like 'TB' and can't be joined on). attachLiveProgress only touches graded, unsettled, non-dead props, and computes state vs the LOCKED line (gradedAt.line). Under semantics are HOLDS-IF: an under is never 'hit' until the settle pass; exceeded → amber LINE PASSED, not red.
  • Actions are suppressed while live (!p.live on ParlayBtn/BookIt — the pre-game market for a locked line closed at first pitch); the label "TRACKING — READ LOCKED PRE-GAME" renders once per live card (GameCard, dim — it's meta, not a caution signal).

Probability Layer + Grade Range (Session 63 — non-obvious)

  • gameLogService.getGameLogs is a TRAP: it returns null for MLB by construction (pythonPath default: return null) and depends on the Python service, which is OFFLINE in prod. Anything wired to it is dead. S46 fixed this for FEATURES (featureCache.gameLogFeatures MLB branch) but NOT for the estimator — so meta.gameLogs was [] for every sport and p_win, ev_pct, kelly, model_odds, value were absent on 100% of live grades for months. featureCache.getStatRows(player, sport, statType) is now the one true source of normalized per-game rows ([{date, [statType]: v}], MOST-RECENT-FIRST — the estimator treats slice(0,5) as the recency window). Use it; never add a new caller of gameLogService directly.
  • Hero v2 requires a finite ev_pct — when EV was dead it matched nothing and fell through to the recent-read fallback silently (is_recent:true was the tell). A "working" endpoint returning data is not proof the intended rule ran.
  • confidence is NOT a probability. engine1 picks a letter from an additive factor index, then reads that letter's band MIDPOINT out of grade_thresholds.json to make the number — so it carries zero information beyond the letter and can never disagree with it. Payloads carry confidence_basis: 'grade_band'. The real signal is p_win. Corollary: mlb-grade-degradation.md's "25/25 grade<->confidence agreement" is a TAUTOLOGY, not a validation (corrected in that file) — never cite it as grade quality.
  • grade_thresholds.json is NOT an input mapper in the JS path — only the Python side compares scores to it. In JS it is a confidence lookup table read BACKWARDS from the already-chosen letter.
  • The grade is an integer index (GRADE_SCALE, NEUTRAL_INDEX 3) moved by flat +/-1.0 and +/-0.5 deltas. A needs sum >= +4.5, D needs <= -1.51. Six factors were wired to features nothing populated, pinning the live range to {C,B} — only TWO letters ever emitted across 604 ledger rows. refreshTeamStats had ZERO production callers, so opp_rank_stat (a +/-1.0) was permanently null; it is now called in runSnapshot (test-env no-op, the opsNotify precedent). L20 was asymmetric (both branches +1.0 = no downside path) and is now symmetric.
  • Consistency CV is scale-dependent — this is a live landmine. The thresholds are NBA-tuned (points ~20/gm). For a Poisson-ish stat cv ~ 1/sqrt(mean), so ANY stat with mean < 4 auto-classifies boom_bust (real: Alonso hits mean 0.60 -> cv 1.17). Reviving consistency without a guard stamps a blanket -1.0 on nearly every MLB prop. Floored at CONSISTENCY_MIN_MEAN (4) -> unknown below. The scale-free fix is an index-of-dispersion classifier (open item).
  • NEVER rescale thresholds to make A's appear (founder ruling, permanent). Minting A's without new information is a relabelled B sold as an A and it corrupts an append-only ledger. Fix the grade on MERIT or don't claim the scale.
  • A-RATED copy is on hold until a prod fingerprint shows real A grades. /api/ledger/accuracy currently returns B and C buckets only, so AccuracyBadge correctly falls through to "MODEL · X% HIT" and TopSignals self-hides.
  • scripts/verify-grade-range.js replays live-board props through the real engine on free feeds. It UNDERSTATES range locally (no Redis -> no opp_rank_stat). Redis runs degraded locally, so the script must process.exit(0) — otherwise a reconnect timer holds the process open and piped output is lost to SIGTERM.

hits-v1 — a REFUTED challenger, and why it stays (Session 76 — non-obvious)

  • specs/hits-v1-binomial.md is the record. hits-v1 models hits as a binomial over the player's EMPIRICAL at-bat distribution (P(0) stated directly, since 84% of hits rows trade at 0.5). It FIRES at 99.4% on the live board and it DOES NOT WORK: point-in-time replay, hits-only, direction-aligned, n=242 — resolution champion 0.195 / ladder 0.048 / hits-v1 0.026. Paired bootstrap (same rows) puts hits-v1 ladder at 0.022, CI95 excluding 0. NOT PROMOTED.
  • Two explanations are now ELIMINATED for hits, which is the useful part. The family was wrong (swapping it made things slightly worse) AND the mean was not the constraint (hits-v1 moved the line-0.5 mean 0.554→0.581 against a 0.598 base rate — closer — while resolution FELL). What remains is per-prop DISCRIMINATION: the ladder's inputs don't separate hitters. Don't build another projection variant for hits; diagnose what the champion's p_win reads first.
  • "Honest ceiling" needs its control checked before you claim it. The spec's own pre-registered fallback ("hits may be genuinely low-resolution for anyone") was REFUTED by the champion scoring 0.276 on the identical 189 rows. A ceiling claim is only honest if no instrument on the same rows beats it — check that BEFORE writing the branch, not after.
  • Backtest ≠ verdict. The replay truncates each player's game log strictly BEFORE the row's game_date and reuses the row's stored grade-time combined_multiplier (both live on real ledger rows) — without that truncation it would be scoring predictions with the answer in hand. The verdict of record is still the forward accrual, so hits-v1 stays wired, writing proj_hits_p_over/proj_hits_meta only. Never served.
  • Paired bootstrap, not two independent SEs. Challengers score the SAME rows; comparing independent standard errors overstates uncertainty and would have read a reliable 0.022 regression as noise. scripts/hits-v1-holdout.js has the seeded implementation — reuse it for the next challenger.
  • The takeable axis paid off measurably: 94 of 159 live hits props are OUTSIDE the promotion band and 93 were modelled anyway. Scope by isTakeableMarket (book identity); record isWithinPromotionBand and never let it gate a model — a price-shape rule would have deleted 59% of the board.
  • Local .env has a transposed Supabase ref (zmdnczhtdxcddszxttub; real is zmdnczhtdxcddsxzttub), so local scripts hitting Supabase need an explicit SUPABASE_URL= override. Prod + the MCP connection are fine.

Settlement outage + challenger scoreboard (Session 77 — non-obvious)

  • .in('id', [...]) IS A URL, NOT A QUERY. PostgREST puts filters in the URL: 500 UUIDs = an 18,499-char request that the fetch layer rejects with TypeError: fetch failed. This silently killed settleLedger for two days (2026-08-01/02) — it refetched rows by id, destructured const { data: rows } with NO error binding, so rows was null, the loop never ran, and it returned {settled:0,voided:0,unrecoverable:0,pending:0}, byte-identical to a healthy "nothing to settle". 1,444 rows sat with settle_attempts=0. NEVER send an unbounded id list; ID_FILTER_CHUNK (100) is the guard, and settleLedger now selects every column it needs in ONE query.
  • It was VOLUME-TRIGGERED, which is why it hid. Daily volume ran 20260 rows for weeks; 2026-08-01 was the first day past SETTLE_FETCH_LIMIT (500). If a pipeline "works for weeks then stops", suspect a threshold that volume just crossed, not a code change.
  • The ops alarm was blind to it BY CONSTRUCTION. The zero-settle watchdog reads settleLedger's own return values, so pending: 0 told it the backlog was empty. An alarm that trusts the return value of the thing it watches cannot see that thing fail silently — the signal must come from OUTSIDE (a direct game_date < today AND outcome IS NULL count).
  • Settled n went 493 → 1,741 the moment it was fixed. Anything reading "n-blocked" across MULTIPLE independent challengers at once is a pipeline symptom, not a sampling fact. Count settled rows before believing it.
  • specs/challenger-scoreboard.md is the board. Nothing promoted: arch-v1 Δ0.0000 CI[0.005,+0.005] on 1,741 (and it MOVED 76% of rows by 2.5pp mean — active movement carrying zero information), contact-v1 +0.0008 inconclusive, proj-v1.1 ladder 0.0301 CI excluding zero = reliably WORSE. matchup/tb-v1/ hits-v1 genuinely pending (rows dated 08-02+, settle after ET midnight).
  • arch-v1/contact-v1 need no replay — they wrote p_win at grade time into their own columns, so scoring them is a TRUE prospective holdout. Only a challenger that did not exist at grade time (hits-v1) needs a point-in-time replay. Don't conflate the two kinds of evidence.
  • Score a nudge on the rows it MOVED, not on all rows — otherwise the unmoved rows are the champion measured against itself and dilute any real effect toward zero. scripts/challenger-scoreboard.js does both slices.

The 429 is odds-api, NOT PropLine (Session 77 — non-obvious)

  • specs/odds-429-diagnosis.md. MEASURED: PropLine 5/3,000 daily (0.17%); odds-api 478/500 MONTHLY, allowed:false (tracker blocks at 95%). One snapshot = ONE PropLine call per sport (all markets comma-joined) — there is no per-prop/per-book fan-out and no request-pattern problem to optimize.
  • The 429 text is the BACKUP's. oddsService.getOdds falls through silently when PropLine returns null/empty, then odds-api's quota gate throws 429 "Odds data temporarily unavailable". So an EMPTY PropLine slate is indistinguishable from an outage, and the error names the wrong provider. Open order — don't read a 429 as "PropLine exhausted" without checking GET /api/internal/quota.
  • BOOK BREADTH INVARIANT: we never discard books. All books are KEPT and SHOWN (DISPLAY_BOOKS = MODEL REFERENCE DFS). The ONLY selectivity is that DFS pick'em is excluded from PRICING/consensus (EXCLUDED_FROM_PRICING) — a fixed-payout shaded number is not a market price. Never "clean up" breadth.

Champion decomposition — the edge is a hit-rate counter (Session 78 — non-obvious)

  • specs/champion-input-diagnosis.md. probabilityEstimator IS the champion and it is five lines: base = empirical frequency of (stat > THIS line) over the game log, blended 0.6/0.4 with the last-5 frequency, then oppAdj(±0.03) + homeAdj(±0.015) + a cv>0.40 pull toward 0.50, then clamp [0.10, 0.95].
  • EXACT ANALYTIC ABLATION (no refit): every adjustment is closed-form from stored features and the consistency step is linear (f(x)=0.9x+0.05f(a+b)=f(a)+0.9b), so layers subtract algebraically out of the stored p_win. Result: removing ALL THREE adjustments changes resolution by nothing on every stat, and on rbi/runs it IMPROVES it (rbi home/away removal +0.0053, CI excludes zero = mildly HARMFUL). ~100% of the edge is base+recency.
  • POOLED RESOLUTION IS INFLATED — do not quote 0.46. Per stat the champion is 0.196 (hits) to 0.499 (rbi); pooling stats with different base rates adds correlation because p_win tracks the base rate across stats. Paired DIFFERENCES (the scoreboard) stay valid; the absolute level does not. Always per-stat.
  • THE CLAMP IS THE BIGGEST LOSS, not a missing feature. 358/1,741 settled rows (20.6%) sit ON the boundary, so the model emits a CONSTANT there and cannot rank within a fifth of the book. And 0.900 hides home_runs-under truly 99.5% (9.5pt under-confident) next to hits-under truly 51.9% (+38.1pt over-confident). PROB_CEIL=0.95 makes the 99.5% case inexpressible. Global over-prediction +3.5pt (total_bases +7.6). Fixable with NO new data.
  • opportunity_drift is the ONE real missing-weighting lead — residual corr +0.156 (hits) and +0.145 (total_bases), i.e. it REPEATS across independent stats. Discipline: 14 features × 5 stats = 70 tests, so 34 CI-excludes-zero results are expected BY CHANCE; a single hit (weather on TB) is noise. And we already compute it — arch-v1's opportunity axis uses it and extracts NOTHING (delta +0.0001). Wrong implementation, not a missing feature: opportunity must scale the RATE, not nudge the probability.
  • ARCHETYPE VERDICT: unmeasurable, not refuted. Only 2 of 41 archetypes (BOMBER, GHOST) reach n≥40 settled rows; all mean residuals straddle zero. That is "we have not measured it", NOT "archetypes carry no signal". Don't act either way. (Their uniformly negative residuals are the global over-prediction, not an archetype effect.)
  • Why every challenger has failed: the ladder/hits-v1 REPLACE the frequency question with a fitted distribution; arch-v1's env axis adds park/weather the champion ignores. Asking "how often has he cleared THIS number" directly is the thing that works — improve its inputs, never substitute it.
  • model_snapshots.outcome is NULL on all 22,032 rows. The retention table built for exactly this kind of replay was never settled, so ablations must join outcomes from ledger_entries on (player_key, stat, line, side, game_date).

Forward-model reality check (Session 79 — non-obvious)

  • specs/forward-model-reality-assessment.md. THE OBJECTIVE is a FORWARD matchup projection (hitter profile × pitcher stuff × park/conditions, read through archetype), not a market-edge number. Every component that needs exists AND is loaded in prod — and ALL of it sits DOWNSTREAM of the grade.
  • The served grade sees NONE of it. probabilityEstimator reads exactly three features (opp_rank_stat, home_away, l10_stddev/l20_avg) plus the game log. statcast rows, arsenal, park, weather, platoon and archetype are all loaded in snapshotService AFTER grading and written to CHALLENGER columns. mlbContext (platoon/handedness) has ZERO consumers — dead code.
  • STATCAST NIGHTLY REFRESH IS UNREACHABLE CODE. snapshotScheduler.tick() returns at if (!HOURS_UTC.includes(h)) return (14,19,22,1,3); the statcast block then tests h === STATCAST_HOUR_UTC (default 11), which that guard can never admit. Data frozen at its 2026-07-21 backfill; its failure alert is inside the same dead branch so it can't warn. Same shape as the settlement outage — guarded-out code that reports nothing. Set STATCAST_HOUR_UTC to one of HOURS_UTC or move the block above the guard.
  • Inputs are HAVE, not missingstatcast_aggregates 1,354 rows (750 pitchers / 604 batters): exit velo, launch, barrel, hard-hit, whiff, chase, pitch_mix, GB/FB, arm angle, and bats/throws complete on all 1,354. Gaps are team DEFENSE (only a coarse opp_rank_stat) and catcher framing/umpire. PARTIAL: batter GB/FB land in the metrics JSONB not the typed columns; lineup-slot tables (player_role_profiles, lineup_role_profiles) are 0 rows.
  • The card is designed for the forward read; the engine never fills it. The factor vocabulary the "SIGNAL BREAKDOWN" renders is entirely counter-restating (l5_hot_vs_line, l20_over_line, back_to_back, home_game) with several structurally-NBA labels (ref_foul_high, coach_pace_delta, opp_3plus_starters_out). No signal names a pitcher, pitch type, handedness or park. Surface needs FEEDING, not redesigning.
  • What the prior verdicts do and don't say. Resolution = corr(forecast, outcome) was never a market/edge test — the metric was right, the QUESTION was narrow ("does challenger out-rank champion?"). proj-v1.1 and hits-v1 remain correctly refuted AS DISTRIBUTION SWAPS on thin inputs; neither tested a matchup-fed projection. arch-v1 IS market-relative by construction and is the one component genuinely measured on the wrong axis — re-test its axes as forward inputs. "AT CEILING" (runs/walks) is provisional: measured only against features the champion already reads.

The skill engine + feature registry (Session 80 — non-obvious)

  • specs/skill-engine-architecture.md. src/services/model/ is the forward engine: featureRegistry.js (CANDIDATE/PROVEN/DEAD per feature PER SPORT) and skillProjection.js (PA outcome tree: K/BB via log5 odds-ratio vs league, then archetype-weighted contact quality → Binomial(PA, p_hit) mixed over a PA distribution). Challenger-only; champion untouched.
  • THE GATE IS STRUCTURAL, not a habit. liveFeatures() returns PROVEN only, and the registry ships with exactly ONE proven feature (the incumbent counter). A test asserts that with only PROVEN allowed, projectSkill returns NULL — an unproven model cannot reach a user by accident. promote() requires n>=200, positive lift, CI excluding zero, and has NO override argument.
  • STAGE A RESULT: skill-v1 LOSES, not promoted. 570 rows, 91.9% pitcher coverage, resolution 0.0499 vs champion 0.166, delta 0.116 CI [0.189,0.043]. Also NOT selective — its top-8 most confident picks hit 50% (lift 0.065).
  • UNITS: statcast_aggregates stores PERCENTAGES (0100), not fractions. k_pct: 29.6 means 29.6%. Feeding raw rows in made bip = 129.617.1 negative and refused 568/576 rows. ALWAYS convert via skillProjection.fromStatcastRow (the one chokepoint); it nulls out-of-range values rather than clamping, and leaves mph/degrees fields alone.
  • ledger_entries.team/opponent are NULL on ~all rows — do NOT join a matchup on them. The first Stage A run resolved a pitcher for 1 of 570 rows and would have reported a verdict on a batter-only model. Resolve the opponent from the player's own statsapi game log (getPlayerGameLog{date, opponent}), which is authoritative and point-in-time safe → 91.9% coverage.
  • Archetype = FEATURE SELECTOR, not a nudge. ARCHETYPE_MAP weights decide which skill inputs drive a hitter (BOMBER barrel 0.50 / gb_speed 0; GHOST barrel 0.05 / gb_speed 0.60). Locked by test: same hitter read through two archetypes moves >0.15. Weights are DOCUMENTED, not fitted — fitting on 1,741 rows is curve-fitting; the registry exists so they get measured.
  • total_bases is deliberately REFUSED by skillProjection. A deterministic bases-per-hit multiplier made P(TB>=2) exactly equal P(hits>=1) — a relabelled hits curve carrying no new information. TB needs tb-v1's compound per-hit bases distribution; refusing beats shipping a relabel.
  • STATCAST REFRESH WAS UNREACHABLE CODE (fixed): it sat inside tick() below if (!HOURS_UTC.includes(h)) return (14,19,22,1,3) while testing h === 11. Never ran once; data 13 days stale; BOTH its alerts were in the same dead branch. Now its own statcastTick. The old test only checked the string existed — the new one asserts it is not behind the snapshot-hours guard.

The validation gate + the stat that was wrong (Session 81 — non-obvious)

  • statModel.js and correlateValidator.js NEVER EXISTED in this repo. The spec's only prior form was src/services/python/blueprints/unconventional.py (Flask, in the OFFLINE python service, scoring NBA factors against an empty warehouse), and tests/unit/supplementSystems.test.js INLINES its own validateFactor (line 368; only fs/path are required). So those tests passed for months with no implementation to connect — that is the real reason every challenger was measured ungated.
  • src/services/model/correlateValidator.js is the gate now — n>=500, |r|>=0.15, p<0.05, Bonferroni. The p-value is EXACT (t-transform via a regularized incomplete beta, Lentz CF) and unit-verified against known values; scipy isn't available in Node so don't reach for an approximation. Pairs with an unknown side are DROPPED — zero-filling a correlation invents a point at the origin.
  • HITS IS A CLEAN NEGATIVE — stop modelling it. Gate run at n=570, Bonferroni-8: EVERY skill feature fails, max marginal |r| = 0.062 vs a 0.15 bar. Not a power problem — an effect-size problem. And the value engine loses head-to-head (0.0499 vs 0.166, CI [0.189,0.043]). At the 0.5 hits line there is very little for skill inputs to know.
  • TOTAL BASES IS WHERE THE SIGNAL IS, and it is n-blocked. Same features: hard_hit_pct marginal r = 0.153 (above threshold), exit_velo 0.124, raw r 0.196/0.167 — refused ONLY because n=295 < 500. Needs ~205 more settled rows. This is what the physics predicts: contact quality drives EXTRA BASES, not whether a grounder finds a hole.
  • The gate reports r and p even when underpowered (underpowered: true, rows_needed). "Not enough data yet" and "nothing here" need OPPOSITE decisions — collapsing them into a bare refusal hid the best signal on the board.
  • Feature verdicts are PER STAT (recordStatVerdict / statusForStat / candidateFeaturesForStat). Marking these DEAD sport-wide on hits evidence would have killed the features most alive on TB. Per-sport doctrine one level deeper: physics differ per stat.
  • Next is the compound TB projectionskillProjection still REFUSES total_bases (a deterministic bases-per-hit made P(TB>=2) == P(hits>=1)). Build the per-hit extra-base distribution off launch/barrel (tb-v1's shape, fed by skill inputs), accrue to n>=500, re-run this gate. Do NOT lower the bar.

Point-in-time skill validation + TB solo/interactions (Session 82 — non-obvious)

  • statcast_aggregates KEEPS NO HISTORY — upserted in place on (sport,season,source_id,role), ONE as-of date, prior versions destroyed. The first skill backtest was honest only BY ACCIDENT: the nightly refresh was unreachable code so profiles sat frozen at 2026-07-21, BEFORE the settled window. Fixing that cron refreshed them to today and made point-in-time validation impossible from that table. statcast_history (new) retains a dated snapshot per refresh — query where as_of_date < game_date order by as_of_date desc limit 1. Retention is best-effort and must NEVER fail the refresh (unit-tested). Until it accrues a window, ALL skill-feature results are CONTAMINATED/DIRECTIONAL, never gate verdicts.
  • TB solo pass: NOTHING passes. n=383, Bonferroni-12 (α=0.00417). hard_hit_pct is closest at marginal r=0.135, p=0.0080 — fails BOTH the 0.15 effect bar and corrected α. It DRIFTED DOWN from 0.153 (n=295) → 0.135 (n=383): an estimate regressing as noise averages out, not an effect firming. Don't keep quoting the older better number.
  • Interactions: none pass. Only barrel × power_archetype has incremental (partial, controlling for both components) exceeding its parts — 0.101 vs 0.019 at n=260. A lead, not a finding.
  • INTERACTION-PROXY TRAP: the archetype conditioner was first barrel_pct/LEAGUE.barrel_pct — a monotone transform of its own component — so the "interaction" was barrel² measuring NONLINEARITY, and it produced the run's only positive result (0.132). A Gauss-Jordan pivot test does NOT catch this (the columns differ by a scale factor); use a scale-free pairwise correlation check on control columns. Real archetype labels come from model_snapshots.archetype (260 labelled TB rows: 140 BOMBER / 120 other).
  • Interactions must be scored by PARTIAL correlation vs the counter residual, controlling for both components — raw correlation can't distinguish PASSES-AND-ADDS from PASSES-BUT-REDUNDANT.
  • TB is at PARITY with the counter (0.2718 vs 0.2647, CI [0.065,+0.079], inconclusive) where HITS lost by 0.116 with CI excluding zero. Same engine, same day — the stat choice was the whole story. Parity under contamination is NOT a win; nothing promoted.
  • skillProjection now models total_bases as a compound convolution (per-PA 0/1/2/3/4 bases; barrel→HR share, exit velo→2B/3B share). The old deterministic bases-per-hit made P(TB>=2) EXACTLY P(hits>=1); non-degeneracy is locked by test.

Batter cluster + the bar (Session 83 — non-obvious)

  • total_bases has NOT passed BAR 1. Its head-to-head is INCONCLUSIVE at parity (delta +0.004..+0.007, CI includes zero) and CONTAMINATED. It is frozen, but frozen as an inconclusive model — do NOT install it as "the proven reference standard", because then the bar other stats must clear becomes "be inconclusive at parity", which admits everything on a null result. The proven set is EMPTY.
  • HITS IS CLOSED — a well-powered negative. At n=803 it CLEARS the gate's sample bar, so its features were properly TESTED, not refused: max marginal |r| = 0.053 vs the 0.15 bar, every interaction's incremental ≈ 0, and the model loses head-to-head 0.096 with CI [0.165,0.029]. Don't re-run hits.
  • Everything else is n-blocked: TB 383, rbi 391, HR 228, runs 188 (gate needs 500). Two leads worth carrying: home_runs · barrel_pct marginal r = 0.135 (NEGATIVE — higher barrel goes with the counter OVER-predicting, i.e. a correction not a predictor), and runs · batterK×pitcherK incremental +0.132 (largest in the cluster; mechanism = strikeouts destroy PA, and a PA that never happens cannot score).
  • RBI is half-unmodellable today: it is power × OPPORTUNITY and we ingest NO baserunner state. A weak RBI result is evidence we model half the stat, not that skill inputs fail for RBI.
  • statcast_history retention is LIVE and verified in prod (1,387 rows, as_of 2026-08-03). Two gotchas: the first run failed on a drifted hand-written schema (swing_pct missing) — the table is now create ... (like statcast_aggregates) and the writer passes rows through whole; and the refresh still succeeded during that failure, confirming the best-effort guard. A usable point-in-time WINDOW starts 2026-08-04 (as_of < game_date).
  • scripts/cluster-prove.js runs the whole both-ways program for any stat via CLUSTER_STAT=. Per-stat interaction sets are the TB map RE-WEIGHTED, never copied — reuse speeds the search and grants no pass.

Pitcher engine + the cap that was eating the board (Session 84 — non-obvious)

  • THE GRADE CAP WAS THE BINDING CONSTRAINT ON EVERY STAT. dedupeProps takes FIRST-ROW-WINS IN FEED ORDER and stops at GRADE_SLATE_LIMIT. Measured via GET /api/internal/diagnose-refusals: 1,244 unique gradeable props/slate, a 500 cap graded ~334, and pitchers (2.6% of the feed) got 6 props a slate — putting n>=500 three months out. RAISED 500 -> 1500 (measured: 721ms/prop at concurrency 5 ≈ 179s for the full board; cron runs 5x/day; statsapi is free). Expect pitcher Ks ~6 -> ~32/slate, so n>=500 in ~2 weeks. Concurrency stays 5.
  • Pitcher props were NEVER being refusedstrikeouts: graded 5, refused 0, suppressed 0. Don't hunt for a data gap here; it was truncation.
  • src/services/model/pitcherEngine.js is its OWN engine (per-role doctrine): archetypes FLAME (whiff .65) / SCALPEL (chase .40) / SINKER (k_rate .50) / DEFAULT, and the projection is K% (log5 vs THIS lineup) x batters faced -> Binomial(BF, k). A test asserts its weight keys are NOT the batter engine's. Unclassifiable -> DEFAULT map, never a guessed archetype.
  • THE COUNTER IS ANTI-PREDICTIVE ON STRIKEOUTS: resolution 0.064. Recent K counts are dominated by which lineups a pitcher drew and how long he was left in, not by skill. This is the one stat where the incumbent has no defensible edge — the strongest theoretical case for the skill model in the programme.
  • Strikeouts NOT proven (n=57 vs 500): pitch-v1 0.1285 vs counter 0.0639, delta +0.192, CI [0.098,+0.509]. But FOUR solo features exceed the |r|>=0.15 bar and fail only on n: arm_angle 0.250 (largest in the programme), whiff +0.213, k_pct +0.206, chase +0.195. Batter cluster's best was 0.135.
  • resolveTeam wants an ABBREVIATION, not a team name. The game log supplies full names ("Cincinnati Reds"), so the roster join silently resolved nothing and the first run showed 0% lineup coverage — the theorized stuff x lineup carrier was never tested, not failing. Use environmentContext.NAME_TO_ABBR; coverage went 0% -> 94.7%.
  • The stuff x lineup-K-rate carrier shows NO incremental signal so far (its raw r is explained by whiff alone), and adding the lineup term LOWERED head-to-head resolution (0.174 -> 0.1285). n=54, so not a verdict — but recorded, not dropped.

Lineup K-rate (Rung 1) + the cap fingerprint (Session 85 — non-obvious)

  • THE CAP FIX LANDED: 334 -> 907 grades/snapshot, strikeouts 6 -> 17 (2.7x across the board). n>=500 for pitcher Ks is now ~a week away, not 3 months.
  • OPERATIONAL: POST /api/internal/snapshot/:sport now 524s at Cloudflare — grading the full board exceeds the 100s edge timeout. The run still COMPLETES server-side (the 907-grade snapshot was written by a 524'd request), and the cron is in-process so it is unaffected. Never read that 524 as a failure; check /api/internal/snapshot/status.
  • PA-WEIGHT the team K-rate. Opposing-lineup K-rate is derived free by joining the opposing roster to batter k_pct we already ingest (94.7% coverage, zero new sourcing). An UNWEIGHTED roster mean counts a 12-PA callup like an everyday starter and it HURT the model (0.174 -> 0.129); PA-weighted it HELPS (0.174 -> 0.195). Same hypothesis, same data — the derivation was the problem. Always weight a team aggregate by playing time.
  • A conditioner can have ~zero solo signal and still matter. Lineup K-rate solo r = +0.004. That is not evidence against it — it is hypothesised as a CONDITIONER, not a standalone predictor. Judge it by its incremental partial, stratified.
  • Within-archetype strata have OPPOSITE signs (FLAME incremental 0.152, non-FLAME +0.145) and the pooled value (+0.077) sits between them — the shape a conditional effect makes, and invisible when pooled. But n=20/24 (SE≈0.22) and the DIRECTION contradicts the theory (predicted stronger for finesse; magnitudes are near-equal with flipped signs). Structure to re-test, NOT a finding.
  • Rungs 2 and 3 are NOT triggered. A rung only fails once fairly tested, and Rung 1 is n-blocked, not failed. Do not source confirmed lineups yet.
  • Pitcher features still have NOT passed the gate — all refused at n=57. Four exceed the |r|>=0.15 effect bar (arm_angle 0.250, whiff +0.213, k_pct +0.206, chase +0.195) but exceeding one of three thresholds is not passing.

Conditioning registry + the proven-status probe (Session 86 — non-obvious)

  • RUN node scripts/proven-status.js BEFORE planning on a "proven" claim. Four consecutive orders opened by calling null results proven. The script recomputes from the ledger: PROVEN_SET is EMPTY (hits LOSES 0.096 CI excluding zero; total_bases +0.004 inconclusive; strikeouts +0.259 inconclusive at n=57). It deliberately reports SAMPLE READINESS separately from RECORDED VERDICTS so "n>=500" is never mistaken for "passed".
  • JOINING model_snapshots TO ledger_entries FANS OUT. model_snapshots holds one row per prop PER SNAPSHOT CYCLE, so a naive join counts each ledger row once per cycle: BOMBER x hits read as 641 when the true distinct figure is 287. Always dedupe on ledger_entries.id. This is the difference between "gate-ready" and "short by 213".
  • NO archetype x stat reaches n>=500. Best: BOMBER x hits 287, BOMBER x TB 142, BOMBER x rbi 128, GHOST x hits 124. Pitcher archetypes are untestable (58 settled Ks across ALL archetypes).
  • featureRegistry.recordConditioning keys archetype x SKILL x interaction x status + lift. The skill tag is MANDATORY and enforced (untagged → refused; PROVEN without sufficient evidence → refused). validatedSkills() returns the coherent profile — currently {} for every archetype, by design.
  • fromStatcastRow does NOT carry pitch_mix (it maps PCT_FIELDS/RAW_FIELDS only). Attach it explicitly or arsenal features silently read n=0 — which would have recorded "arsenal doesn't matter" from a column that was never populated. pitch_mix shape is [{type, usage_pct, velo, whiff_pct, ...}].
  • DEFENSE IS GENUINELY NOT DERIVABLE from what we ingest. No OAA/DRS/range anywhere; opposing pitchers' hits-allowed conflates pitching WITH defense so it would validate the wrong skill. It needs Savant's fielding endpoint (free, same host as the five feeds already ingested). Don't proxy it.
  • Within BOMBER, the counter still leads on hits (0.218 vs 0.160) — consistent with the closed pooled hits negative.

Defence ingest + cumulative Bonferroni (Session 87 — non-obvious)

  • BONFERRONI IS NOW CUMULATIVE ACROSS THE PROGRAMME LIFETIME (src/services/model/testLedger.js + mc_test_ledger). Correcting per-session (8 tests → /8, forever) let the false-positive rate compound silently; the denominator is now DISTINCT hypotheses ever tested. Demonstrated: 19 → 38 in one session, α 0.0026 → 0.0013. RE-TESTS DO NOT INFLATE IT — re-asking the same question on more data is not a new shot on goal, and counting it would punish waiting for sample. The alpha only ever shrinks, so prefer re-testing standing candidates over inventing new hypotheses — that is now mathematically the disciplined choice.
  • DEFENCE IS INGESTED — free Statcast OAA (FEEDS.fielding_oaa), 514 fielders → team_defense (31 teams, dated). Team-level is the right unit (the defence behind the pitcher faced). oaa_sum + oaa_mean (mean because a team with more measured fielders would else look better for being measured more); <3 fielders → absent. OAA 0 is a REAL "exactly average" reading — coercing absence to 0 asserts every unmeasured fielder is league-average, the commonest profile there is. team_defense carries as_of_date in the PK FROM ROW ONE (the statcast_aggregates lesson, applied before it was needed).
  • BASE in statcastAdapter ALREADY ENDS IN /leaderboard — the new feed doubled it and 404'd. And because a failing feed degrades to an EMPTY index by design, it surfaced as "fielding_oaa: 0 rows", which reads exactly like "Statcast has no fielding data". Graceful degradation makes a wiring bug look like an honest absence — treat any feed reporting 0 as suspect until the URL is fetched by hand.
  • THE DEFENCE DIFFERENTIAL APPEARS AS THEORY PREDICTS: solo r vs counter residual is +0.130 for GHOST (contact/speed, n=104) and 0.018 for BOMBER (power, n=245). A GHOST's hits depend on fielder range; a BOMBER's barrels clear the defence. A flat BOMBER result is the theory working, not the test failing. Both UNDERPOWERED (p=0.188 vs corrected α 0.0013) — a signal shape, not a result.
  • team_defense keys on Savant's DISPLAY NAME (a nickname, "Cubs") while game logs give full names ("Chicago Cubs") — match on both.

Re-adjudication + the promotion bar (Session 88 — non-obvious)

  • NOTHING HAS EVER BEEN PROVEN. proven-status.js = EMPTY; validatedSkills() = {} for all archetypes; 0 conditioning entries. The only PROVEN feature is recent_frequency_prior — the incumbent COUNTER itself (S78 ablation showed it is ~100% of the champion's resolution). It is the baseline, not a conditioning interaction; demoting it would leave nothing to grade from.
  • The cumulative correction did NOT catch a false positive. It caught nothing (empty proven set). It tightened α 0.0026 → 0.0013 in one session — the mechanism working, not a demotion. Don't restate that as a catch.
  • THE REAL HOLE (now closed): promote() could bypass the cumulative correction. isSufficient now REQUIRES evidence.bonferroni_tests, refuses it if lower than opts.cumulativeTests, and refuses a p_value that doesn't clear 0.05 / bonferroni_tests. Same rule guards recordConditioning(status:PROVEN). This is what makes a retroactive re-adjudication pass unnecessary — the bar is applied at promotion time.
  • Cumulative correction is now NATIVE on every analysis pathcluster-prove, pitcher-prove-k and tb-solo-and-interactions all use testLedger. If you add a new analysis script, wire it or it silently corrects per-session.
  • src/services/model/reAblation.js is the standing second line. Pure + injectable (no DB, no measurement) so the decision rule can't drift from the gate's. Records BOTH p-values and BOTH test counts per verdict so a demotion is re-derivable. No fresh measurement = PENDING_RETEST, never DEMOTE — absence of a re-test is not evidence, and demoting on it would punish whichever stat is off-season. A feature promoted at α=0.05/20 CAN demote on the same p-value once the bar is 0.05/60; that is correct, not unfair.
  • Don't emit a public "recalibrated after re-adjudication" ledger event when nothing changed — announcing rigour that did no work is itself a false signal.

Lineup + baserunner context ingest (Session 89 — non-obvious)

  • RBI/runs were INPUT-blocked, and the input is now ingested — both halves free from statsapi (already used for game logs/schedules/pitchers). src/services/lineupContextService.jslineup_context (batting order) + hitter_opportunity (RISP share). Both DATED in the PK.
  • schedule?hydrate=lineupshomePlayers/awayPlayers are ORDERED arrays of 9 — the array order IS the batting order (index 0 = leadoff). Nothing is inferred; a short lineup records fewer slots rather than padding to nine.
  • RUNG 2 IS CHEAP, contrary to expectation. "How often does he bat with runners on" looked like a play-by-play reconstruction; statsapi serves it via people/{id}/stats?stats=statSplits&sitCodes=risp,r0 — ONE call PER PLAYER (season aggregate), not per game. Real example: 87 PA with RISP → 25 RBI vs 302 PA bases-empty → 17 RBI. Probe for a cheaper aggregate endpoint before assuming per-event reconstruction.
  • risp_share is RISP ÷ (RISP + bases-empty) — deliberately NOT ÷ season PA, because runner-on-first-only belongs to neither split. It is "RISP as a fraction of the PAs we can classify", stated exactly rather than implied.
  • A hitter with no splits is NULL, never a 0 share — 0 would assert he never bats with runners on, a strong and usually false claim.
  • POST /api/internal/lineup-context/refresh verifies the ingest in seconds. Built because the first prod run wrote 0 rows while the parser worked locally (144 rows / 10 games) — diagnosing that via a full snapshot costs ~3 min and 524s at the edge. Same lesson as the defence feed: a 0 is a wiring bug until proven an honest absence.
  • Coherence check that the data passes: the top RISP-share hitters all bat 4th/5th — the mechanism showing up the moment both tables joined.
  • DRIVER/CATALYST pre-registered theories are now INPUT-READY (were input-blocked); they are sample-blocked from here. Ingesting is not proving.

chaining-v1 + the calibration gate (Session 90 — non-obvious)

  • THE HIT-PARLAY SURFACE IS BLOCKED, and the block is structural. Measured on 972 settled hits props: the model is monotonically over-confident exactly where a parlay stacks — predicted 0.911 → actual 0.630 (err 0.281), and it is FLAT at ~63% for everything above 0.70 (no discrimination there at all). A 4-leg "91%" ticket: model 0.686, reality 0.157 — a 4.4x overstatement that compounds with every leg. chain.chainAcross REFUSES atoms not marked calibrated: true. Refusing is the feature.
  • Calibration ≠ resolution, and chaining cares about calibration. A model can rank fine and be useless compounded. Single props survive a calibration error; a parlay multiplies it. Never stack a probability that has not passed calibration.isCalibrated.
  • calibration.fitIsotonic is the honest repair — monotone (pool-adjacent- violators), so the model's ORDERING survives untouched while the NUMBERS move to what actually happened. Real map: 0.65→0.594, 0.85→0.639, 0.91→0.639. Fit it POINT-IN-TIME (outcomes preceding the prop) or it has seen the answer.
  • src/services/model/chain.js is the portable core: base_events + context → chainFn → aggregator, aggregator pluggable — ACROSS = compound ticket, UP = team score. Sport parts are INPUTS, not code paths, so basketball is content not a rebuild.
  • redistribute is the archetype hook — DORMANT in baseball (a nine-run lead doesn't change who bats next), LIVE in basketball (blowout fades the star, feeds the bench). It exists now so the machine doesn't need rewriting later.
  • Treating same-game legs as independent errs in the FLATTERING direction — they share pitcher/park/weather, so the joint is likelier than the product. chainAcross applies a bounded shift toward the weakest leg and labels it an approximation, not a joint distribution. Prefer cross-game legs.
  • An unreadable atom is DROPPED, never p=0 — a single zero leg would zero an entire ticket.
  • Market divergence is NOT an error signal and must not downgrade confidence: it flags a contested game script (its props are the best or the worst on the board, unknown which). Internal inconsistency IS — per-entity reads not summing to the team read means one is wrong and we don't know which, so the honest output is LOW CONFIDENCE, not a correction.
  • propagate is shrinkage-weighted by sample — one game moves a 400-obs atom barely and a 4-obs atom a lot. That gap is the difference between learning and noise-chasing.

Hits calibration + the parlay unblock (Session 91 — non-obvious)

  • PARTIAL PASS: hits are calibrated and stackable ONLY in 0.400.60. Fit on game_date < 2026-08-02 (n=589), evaluated on >= (n=383) — the map never saw the evaluation rows. Held-out after correction: 0.477→0.506 (0.029, n=83), 0.587→0.580 (+0.007, n=193), 0.667→0.603 (+0.063, n=63), vs raw errors of +0.191/+0.279/+0.246. Ordering preserved (verified pairwise, not assumed).
  • THE HONEST CEILING IS 0.667. Once the numbers are truthful this model has NO 80%+ hit reads at all. A 4-leg ticket at the ceiling is 0.198, not the 0.686 the raw numbers implied. The "high-floor parlay" is a ~0.67-per-leg proposition — say that plainly rather than selling the old number.
  • CERTIFY BY BAND, never a blanket flag. Held-out error was 0.029/+0.007 through the middle but 0.167 at the bottom and +0.063 at the top. A single true/false would either discard the 72% that works or ship the edges that don't. calibration.certifyBands + inCertifiedBand; only in-band atoms get calibrated: true, which is what chainAcross requires.
  • A PASS CONDITION CAN FAIL A MAP FOR SUCCEEDING. My first gate demanded honest bins ≥0.70 — but honest calibration REMOVES those bins (ceiling 0.667), so it failed the repair for working. Test the highest REMAINING band, not a fixed threshold.
  • calibrationService.fromLedger fits STRICTLY before today and splits by TIME, not at random — certifying on rows the map was fitted on always looks perfect, and a random split leaks the future. No calibrator ⇒ NOTHING is stackable, never "pass raw numbers through".
  • p_win is never mutated. Calibration rides beside it as p_win_calibrated + calibrated on hits grades, so the counter stays byte-identical — a calibration map is a correction TO a forecast, not a different forecast.
  • Synthetic-data trap in the tests: front-loading wins makes outcome correlate with date, so a time-split trains on wins and certifies on losses — the generator creating the exact leakage the split prevents. Interleave.

The two-part factor gate (Session 92 — non-obvious)

  • src/services/model/factorGate.js asks a question correlation cannot. A factor must (a) MOVE the prediction off the player's base rate AND (b) improve out-of-sample BRIER. Movement alone is THEATER — the grade LOOKS like it read tonight's game while reading nothing, and neither a user nor a correlation test can see it. arch-v1 was exactly this: moved 76% of rows by 2.5pts, changed resolution by 0.0000, live for months.
  • BRIER, not correlation. Correlation asks whether the ORDERING improved; this asks whether the NUMBER got closer to what happened. For a graded probability the number IS the product, and a factor can improve ordering while degrading the number.
  • Baseline = the player's LEAVE-ONE-OUT base rate — literally the "he's due" null. A factor earns its place only by beating that. Leave-one-out matters: a row must never contribute to its own baseline.
  • CUMULATIVE CORRECTION APPLIES TO THE INTERVAL ITSELF. A plain 95% CI is right for ONE test; at 50 cumulative tests ~2-3 of them exclude zero by chance. The bootstrap interval now widens to 1 0.05/tests (currently 99.9%). Applying it flipped defense and platoon from "proves" to not-proven — a 95% CI would have shipped two unproven factors.
  • NOT_PROVEN_AT_CORRECTED_BAR ≠ THEATER, and conflating them is the same error as "insufficient evidence = evidence of absence". THEATER is reserved for brier_delta >= 0 (moves, reads nothing). A favourable point estimate whose corrected CI spans zero is a real candidate held to a rising bar.
  • RESULT for hits (n=741): pitcher_contact_profile PROVES (Brier 0.0066, CI [0.0114,0.0016] at 99.9%). defense (0.0043) and platoon (0.0039) are NOT_PROVEN at the corrected bar; park_hits is sample-blocked (n=405). Zero theater. Per-archetype all sample-blocked (BOMBER 252294, GHOST 67125).
  • Two spec gaps found: approach identities (SPRAY / DAMAGE-DEALER / COUNT-WORKER) do not exist in the registry — MLB batter archetypes are BOMBER/GHOST/TORCH/BRUSH/DRIVER/FLEX/ALPHA/HYBRID/CATALYST. And parkFactors.STAT_BASE maps hits → run_base, so there is no hits-specific park factor: a park that turns outs into hits without scoring is invisible.

Causally-correct atoms (Session 93 — non-obvious)

  • KEV'S INSIGHT IS CONFIRMED ON DATA: crude operationalizations under-prove. defense_by_direction PROVES (n=528, Brier 0.0034, CI [0.0059,0.0009] at 99.9%) where team-average defense does NOT (CI spans zero). And the causally-correct atom MOVES THE NUMBER LESS THAN HALF AS MUCH (0.013 vs 0.030) while being reliably right — the crude version was moving more and knowing less. Bigger movement is not better; it is often the tell.
  • src/services/model/sprayDefense.js = spray×trajectory × positional OAA, joined by HANDEDNESS. Pull for a RHB is the LEFT side (3B/SS/LF); for a LHB the RIGHT side (1B/2B/RF). Getting that backwards sends half the league's grounders to the wrong infielders and still looks like it's reading defence — nothing downstream would catch it. Switch hitters are UNREADABLE (they bat opposite the pitcher, unresolved here), not guessed.
  • Both halves were already free. Savant's leaderboard/batted-ball carries pull/straight/oppo × ground/air (609 hitters) — the statcast leaderboard does NOT (19 cols, no direction). And the OAA feed already carries each fielder's position, so per-position defence is a REGROUPING of last week's ingest. team_defense.position_oaa + batter_spray (dated). Prod-verified 609/31.
  • Unmeasured zones are RENORMALISED AWAY, never zero — a zero asserts an exactly-average fielder standing there. coverage states what share of a hitter's contact we could actually read; nothing readable → null.
  • ATOM 2 (park+weather→hit-type) is INPUT-BLOCKED, not sample-blocked. Weather's free source check PASSES (Open-Meteo already wired via weatherService, exposing temp_f/wind_mph/wind_dir/precip_mm) — but those raw fields are collapsed into a scalar env_weather_mod and wx_forecast is 0/1119 on settled rows. And park DIMENSIONS are not ingested at all (parkFactors holds coefficients, not wall heights or fence distances). A hit-type conversion needs both; retaining the raw weather fields is the cheap half, dimensions are the missing one.

Active Skills

  • vyndr-voice (all user-facing output)
  • prop-analysis (grading methodology)
  • monetization-system (scan-5 pitch, tier conversion)