The #1 board item — three grading bugs the phone audit surfaced, all in the
live Node grade path (engine1 + analyzeViaEngine1), fixed at the source.
1. PROJECTION=0 NOW REFUSES. projectionFor returned l5_avg even when it was 0
(finite, so the `== null` gate passed it) — 9/25 live grades graded on a
zero projection, producing a degenerate edge and a hollow grade. Now a
non-positive reference is not a projection: projectionFor skips it and falls
through to the next POSITIVE reference (l5 -> l20 -> per_90 -> xg); when none
is positive it returns null and the read REFUSES (insufficient_data). The
gate also gained an explicit `> 0` guard so the invariant is structural — a
grade can never be emitted with a non-positive projection. Fewer graded
props, honest.
2. EDGE_PCT. The formula was already (model - line) / line signed by direction
— Kev's intended semantics. The broken {20,60,100,140} cluster was the
proj=0 degeneracy ((line - 0)/line = 100%); with #1 those refuse, so the
fabricated 100s vanish and real edges flow. The main-line edge now reuses
the VALIDATED projection (edgePctFor accepts an optional ref) so edge and
the persisted projection can never diverge. Frontend |edge|>40 guard stays
as a safety net.
3. LETTER == THRESHOLD_TABLE(CONFIDENCE). engine1's hand-rolled
GRADE_TO_CONFIDENCE drifted a full sub-tier low (B -> 0.55, which the
canonical grade_thresholds.json calls B-) — the "B at 45%" the audit caught.
Now confidence is DERIVED from each grade's band MIDPOINT in
grade_thresholds.json (one source of truth, shared with the Python engine),
so applying the threshold table to any grade's displayed confidence resolves
back to the same letter. Proven for all 11 grades.
Regression locks: tests/unit/mlbGradeDegradation.test.js (14 tests). Backend
suite 269/3253 green, web build exit 0.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Phone audit: read cards were huge, only 1-2 fit per screen. Compressed the
vertical spacing — article padding 16->12, header margin 8->5, name 15->14px,
ladder-rungs margin 10->8, book/date line 12->8.
Kept the archetype showDesc: it renders INLINE (same row as the badge), so it
adds zero vertical height — dropping it wouldn't help density and would break
the ds5 design lock ("the badge shows its one-line meaning where it leads").
Locked the density in vyndrParityQA (P2-10).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
DISPLAY FIX (shipped): the league leaderboard rendered raw snake_case
("stolen_bases U0.5", "earned_runs U2.5"). New canonical short-label lib
web/src/lib/statAbbrev.js (one source, CommonJS + unit-tested) maps stat_type
to SB/ER/TB/HR/K/PTS/… and ExploreHub routes through it. Unknown ids upper-case
their words so raw snake_case can never leak again.
FLAG (reported, NOT silently changed — per the audit's instruction): the "B at
45% confidence" is a BACKEND grading issue, diagnosed against live snapshot:
- 25/25 grades mismatch their own confidence vs grade_thresholds.json (B shown
at conf 55 = the B- band; a systematic one-sub-tier gap on every prop). The
surfaced `confidence` is not the probability that derived the letter (likely
the data-sufficiency penalty applied to display-only).
- 9/25 have projection=0 — the MLB feature path feeds 0 instead of refusing
(S58 insufficient_data), which also produces the P1-7 broken edge_pct.
Full write-up + do-not list: specs/audit-data/mlb-grade-degradation.md. NOT
re-lettering or shifting thresholds on the frontend — that would hide the bug.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Phone audit: the Compare verdict read "the edge tonight tilts his way" for
Jokić vs Wembanyama — an NBA claim in July, when NBA has 0 games. The page is
sample/form data with no game resolution, so "tonight" can never be verified.
Reframed to "on current form" (the rows ARE L10 form) — always honest, in or
out of season.
Also fixed the cited dimensions: the verdict claimed "usage", but in the sample
Jokić's Usage% (29.1) is LOWER than Wemby's (31.0) — he wins scoring, boards,
and playmaking, not usage. Copy now matches the data.
Locks both P1-7 and P1-8 in vyndrParityQA.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Phone audit called the board 'mostly-empty'. Two causes, both now addressed:
1. Dead images (P0-2, already fixed) → the matchup/team chips render logos now.
2. Degraded edge data. Live snapshot edge_pct is on a broken scale (distinct
values 20/60/100/140 — not a market %), with projection=0 and confidence
35-55%. A real prop-market edge is single-digit, never past ~40%. Leading
the board with '+140%' fabricates a signal (Data Semantics Rule).
Fix: an edge whose |value| > 40 is treated as ABSENT at BOTH layers — the
data layer (flattenToEdgeBoard nulls it, so it can't RANK a fake +140% above a
real +8.4%) and the display (EdgeCell shows '—'). Board falls through to the
grade-rank tiebreak when edges are unreliable. Real edges (≤40) are untouched.
The root cause — edge_pct/projection/confidence degradation — is a BACKEND
grading issue (same family as the P2-9 '45% B' flag), reported separately; this
is the honest frontend guard, not a fix for the data.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Phone audit: the same screen showed green 'SIGNAL LIVE · UPDATED 0s ago' AND
amber 'SYNC 46:03' — two components reading different fields. The Slate's
'UPDATED Xs ago' measured the CLIENT poll time (always ~0s, since it refetches
every 60s), while the app-bar clock honestly measured the pipeline's data age
(refreshed_at). '0s ago' claimed a freshness the data didn't have.
Now the Slate captures snap.refreshed_at from the snapshot it already fetches
(newest across sports) and 'UPDATED' shows THAT — the same source of truth as
the clock. The clock still owns the amber/STALE reaction; the strip just states
the honest data age. No more contradiction.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Phone audit: CONSENSUS VS MODEL bled off the right edge ('2 BO…', 'MO…') and
STARTING-pitcher lines truncated ('2.64 E…'). The M4 lock only hid the DOCUMENT
scroll (html/body overflow-x) — content still clipped inside cards. Now contained:
- .breadth-row stacks (flex-direction:column) at <430px, each field on its own
line with overflow-wrap:anywhere — no bleed.
- the game-card starting-pitcher inner spans wrap + shrink (flexWrap + minWidth:0)
so name/ERA/archetype flow onto a second line instead of clipping.
- STRENGTHENED the lock: vyndrParityQA now asserts the CONTAINMENT patterns
(breadth-row stacks, pitcher spans wrap), not just document overflow.
MLB stat pills: the game-lines grid already scrolls-within-card (<640 M1); if
the audit still shows pill clipping elsewhere, it's a follow-up targeted pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Phone audit: at 390px we still rendered the full desktop 3-row header (nav +
TOP MOVES ticker + SYNC line) eating ~20% of the viewport, and its height
clipped page titles under it (MY READS tabs, HEAD TO HEAD). Implemented Design's
mobile app bar <768px:
- New MobileSyncClock (extracted from HeartbeatBar) lives in the Nav's right
cluster — wall clock rests, amber/STALE reacts off the shared freshness tier.
- <768px: the ticker row (.nav-ticker) AND the whole heartbeat bar are hidden;
only the nav row shows (logo + clock + search). main padding-top → 62px and
the Slate sticky tabs → top:60px, so nothing clips under the bar.
- Locked in vyndrParityQA (P0-4): ticker+heartbeat hidden, nav clock shown,
paddings collapsed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Completes P0-3 across all three surfaces:
- CONSENSUS VS MODEL (MarketBreadth): dedupe by player+market so Ben Williamson's
alt-line variants show as ONE consensus row (no per-market cap — a consensus
table just shouldn't repeat a player).
- LEDGER cards: group by player+market via groupIntoLadders — Alec Bohm's
strikeout ladder (U1.6/U1.3/O1.5) is now ONE card with the rungs nested (each
its own side/line + tier-colored grade), not three separate cards.
- playerGrouping reads player OR player_name (ledger rows use player_name) —
regression-tested so the ledger doesn't silently empty.
The Alt Line Ladder shape is what /pricing already demos; the record now uses it.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Phone audit: leaderboard flooded with 9 consecutive identical 'stolen_bases U0.5
45% B' rows. New shared lib/playerGrouping (dedupeLeaders + groupIntoLadders,
name-key aware, 7 unit tests): ONE row per (player, market family) keeping the
best-ranked, then a per-market cap (4) so no single prop type floods the board.
Applied to ExploreHub. Ledger cards + Consensus grouping follow in P0-3b/c using
the same lib.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Phone audit: 78 ESPN <img> at naturalWidth:0, ZERO espncdn/mlbstatic requests
ever fire — the browser never attempts the fetch. CSP was NOT the cause (img-src
already allows a.espncdn.com + img.mlbstatic.com, confirmed in the live header).
Root cause = native loading="lazy" on the tiny entity <img>s never triggering on
device (the audit's suspect b). TeamLogo + PlayerAvatar now load="eager"
(+ decoding="async") — they're 10-40px core-visual entities, so eager is correct
and cheap, and it forces the fetch on parse regardless of size/viewport.
Browser-request confirmation is the audit's Chrome domain (I can't observe
network from server curls); eager semantics guarantee the fetch once the src
(which 200s server-side) is in the DOM.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Phone audit found LEDGER READ CARDS rendering B badges with BLUE borders + C
with AMBER right now in prod — GradePill (components/GradeCard.tsx) hardcoded the
OLD palette (rgba(74,158,255) blue-B, rgba(255,179,71) amber-C) for bg/border
while the text used the migrated token. Migrated bg/border to color-mix on the
grade token, so B renders neutral-white and C grey (matching the board).
- globals.css .grade-*-bg → token-derived color-mix (was raw blue/amber rgba).
- DELETED glow from .grade-glow-b/c/d (glow is A-tier ONLY, by law) — B/C/D keep
their token color, no text-shadow.
- Purged the last dead grade-blue #4A9EFF fallbacks (SoccerGradeResult, the
intelligence INFO dot).
- REGRESSION LOCK: vyndrParityQA fails if #4a9eff / rgba(74,158,255) reappears
anywhere in web/src, if GradePill hardcodes blue/amber rgba, or if grade-glow
B/C/D grow a text-shadow again.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Design colors every LIVE indicator green (active = green; the live-dots and
LIVE·Q3 labels are all green). The slate header's 'N LIVE' count was red
(--live #ff3b3b) — contradicts the one-meaning-per-color contract (red = miss/
error, which live isn't). Aligned to --g-a.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
PitcherArsenal's meta line showed team + 'vs OPP' as plain text; Rev 3 anchors
the pitcher's team and opponent with TeamChips (real logo / monogram). Combat
FightCard intentionally keeps fighter monograms (no photos — likeness rule).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Structural mobile rules become failing tests (M4 'test-lock what's lockable'):
the flat EDGE BOARD shows <768px and game cards are desktop-only; the board
renders TeamChips + tier GradeBadge + sign-colored hero edge% + the ranked
opacity ramp; document never scrolls sideways at 390px. Source assertions —
they lock the RULES, not the pixels (that's the master audit).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The one genuinely-new mobile screen. Design's mobile BOARD is a FLAT edge-ranked
list (all graded props across every game on one list, sorted by edge) — not the
desktop's game-grouped cards. Implemented to the drawing with REAL snapshot data:
- slateAdapter.flattenToEdgeBoard(cards) — pure transform of the assembled
GameCardData[] (grade→game join already done) into ranked rows, edge desc.
STRICT null edge sorts LAST (never 0-coerced to the top — Data Semantics Rule).
Threaded edge_pct through buildPlayerStripsFromProps (was dropped). 6 unit tests.
- MobileEdgeBoard component — Design's exact screen-01 rows: rank (green #1),
player + prop, matchup sub-line with TeamChips + live-dot, tier grade chip,
and the edge% as the one bold mono hero (green +, red −). Ranked opacity ramp
(1 → .55) + green inset border on the top reads. Breadth strip EDGES/AVG CLV/
GAMES — CLV honest '—' (per-slate CLV isn't computed; never fabricated).
- Slate: <768px renders the flat board, ≥768px keeps game cards (same data,
toggled by width). Ungraded slate still shows game cards on phones (no blank).
Built to Design's screen-01 drawing, VISUALLY UNVERIFIED at 390px — the core
mobile screen, top of the master-audit list.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
GradeResultCard already carried Design's §7 grade-reveal structure (hero +
identity + 3-col grid + intel box) — the screen was built to the same contract
Design's mobile follows. Applied the Rev 3 gaps: team context now a TeamChip
(real logo/monogram), and the mobile grade hero → 74px (Design's exact mobile
size; M1a had 80). Desktop hero unchanged (116px).
Built to Design's mobile spec, VISUALLY UNVERIFIED at 390px.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Design Rev 3 anchors every matchup/context abbr with a 10-12px team tile. New
reusable TeamChip renders the real TeamLogo (licensed ESPN logo where it
resolves, team-colored monogram otherwise — the resolver is already built) at
that size + the abbr, sitting inside the row so it inherits the ranked opacity
ramp. First placement: StatStrip's player/team context (name → team-chip →
archetype). ROW-GRAMMAR identity-run test updated to the chip marker.
Remaining Rev 3 placements to thread TeamChip into (reusable, mechanical):
parlay legs, grade-shift header, pitcher "vs", /u recent-settled, other
matchup context lines. Game-card headers already carry TeamLogo (TeamLink).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Design Rev 3 drew the 9 legacy classifier marks (BRUSH #E0B84A, WHIFF #E86A6A,
CONNECTOR #9AB0C4, DISTRIBUTOR #7AB8D8, FASTBREAK #4AA0E8, FLEX #A08AC8,
HYBRID #C88AB0, SWITCH #C0B08A, SWITCHBOARD #90A0E8). Wired each to its own
Design mark + color (front lib/archetypes.js + backend archetypeService.js,
color-synced). These were the 9 NO-MARK backend keys — now none are on a
generic placeholder. They're classifier-side fallback renders (never
user-facing archetype names, per MANIFEST).
Re-imported Rev 3 package over specs/design-reference/ (83 glyph SVGs + MANIFEST
regenerated from the authoritative glyphDefs(); HANDOFF Rev 3 note).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Token alignment (M3.1) orphaned a few bare literals: PlayerAvatar's team-less
monogram accent still fell back to the retired B-blue #4A9EFF → Design neutral
#B8BCC8; the player/u OG + portrait billboards painted old text-0 #E8E8F0 →
Design #F0F0F0 (satori canvas needs literals).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The only surface still showing a raw book key: BookComparison rendered
{b.book} capitalized ('Betmgm'). Now uses the entity-layer BookChip (branded
wordmark + name), matching ledger/GameCard. No lowercase/raw book text anywhere.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Replaced the generic reused shapes (triangle/star/plus/bolt…) with Design's
real per-archetype marks from the authoritative glyphDefs() (HANDOFF), and
aligned each archetype's color to Design's deduped palette — frontend
lib/archetypes.js + backend archetypeService.js kept in color-sync (the
cross-file test iterates the backend set). Badge test color expectations
updated to Design (BOMBER #FF9F45, CONDUCTOR #6C8CFF, ALPHA #7C5CFF,
FORTRESS #4C6FA5, …).
Scope + honesty:
- 29 non-combat archetypes wired to real marks + Design colors.
- COMBAT namespace (STRIKER/GRAPPLER/PRESSURE/COUNTER/FINISHER/GRINDER) left
untouched — it uses unicode CHAR glyphs + its own pinned colors + test
(FINISHER deliberately doesn't collide with the soccer FINISHER). Combat
could adopt Design's SVG marks in a follow-up.
- 39 Design marks are INERT (no classify() producer yet) — the 74 SVGs live in
specs/design-reference/assets/glyphs/; they light up when classify() expands.
- 9 backend archetypes have NO Design mark (BRUSH/CONNECTOR/DISTRIBUTOR/
FASTBREAK/FLEX/HYBRID/SWITCH/SWITCHBOARD/WHIFF) — kept on their generic glyph,
flagged for Design.
VISUALLY UNVERIFIED at 390px/desktop — archetype marks + colors on the audit list.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The share/OG card (a billboard) still painted grade B blue (#4A9EFF) on canvas;
Design's B is neutral-bright white. Canvas needs a literal, so #F0F0F0 direct.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Reconcile finding: the live tokens had DRIFTED from Design's package (HANDOFF
"Tokens (exact)"). Per the standing conflict rule (Design specified exact hexes
→ rejected the drift → Design wins), aligned the whole palette:
- Surfaces: --bg-1 #0E0E16→#0E0E14, --bg-2 #15151F→#14141E; added --bg-deep
#0A0A10 + --hairline #101018 (Design's ramp).
- Text: --text-0 #E8E8F0→#F0F0F0, --text-1 #7A7A8E→#B8BCC8 (Design's secondary
is far brighter), --text-2 #4A4A5E→#707080, added --text-3 #4a4a58 micro.
- Borders: #1E1E2E→#1E1E2A, #2A2A3E→#2A2A38.
- GRADES (the big one): B blue #4A9EFF → neutral-bright WHITE #F0F0F0; C amber
#FFB347 → muted GREY #B8BCC8; D #FF5252 → #FF4757. The old blue/amber actually
violated DESIGN-SPEC v2's OWN "B neutral-bright, C muted" — this fixes a
long-standing drift, confirmed by Design's package. Amber stays its own token
(--amber); --warning decoupled to --amber; --miss → #FF4757.
- vyndrTokens.js GRADE_HEX mirror + the design-system test updated to match.
Big VISUAL change (grade color language), VISUALLY UNVERIFIED at 390px/desktop
— on the Chrome-audit list. Every surface inherits it, so it lands first.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Supersedes the prior partial import. From claude.ai/design project 370ba6df:
- HANDOFF.md (the entry point — exact tokens, glyph library, liftable
behaviors, laws)
- vyndr-mobile.html (19 mobile screens — closes all 10 previously-uncovered
surfaces: landing/pricing/streaks/ledger×2/empty/scan/player/team/explore +
combat/pitcher/archetypes/correlation), vyndr-system.html (desktop terminal),
support.js
- assets/glyphs/ — all 74 archetype marks as real SVGs + MANIFEST, GENERATED
from the authoritative glyphDefs() in the desktop file (currentColor, 24-grid,
duotone) rather than 74 fetches. These replace the generic star/plus
placeholders (the archetype family was never rendering as a distinct 74-mark
system).
- removed my interim MOBILE-SPEC.md distillation (the real 19-screen file +
HANDOFF supersede it).
Landing (desktop marketing) captured in-context — it's M2/M3-desktop scope;
raw file stays in the design project until that wave.
RECONCILE + build (token alignment, glyph lift, M1b surfaces, M3) follow.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Design's mobile app bar shows a wall clock, no SYNC/STALE readout. Building it
literally would drop the Ship-A staleness signal on the device most users are
on. Kev's ruling: Hybrid — the wall clock is the RESTING state (the stillness),
STALE/amber is the REACTION (the punctuation), driven by the SAME real signal
as desktop (refreshed_at vs expected_interval_s, thresholds 1.5×/3×). Never
silently stale on mobile. Design drew the happy path; we keep the failure state.
- web/src/lib/freshness.js — extracted the freshness tier as ONE shared source
(CommonJS, unit-tested); desktop HeartbeatBar + mobile clock both key off it,
so mobile can't silently disagree with desktop about staleness.
- LiveLayer: <768px hides SIGNAL LIVE + EKG + graded + the SYNC label and shows
a single right-aligned clock — ticking wall clock when calm, amber SYNC / red
STALE when the tier reacts. Resting dot is static (the clock is the pulse).
- Test-lock: freshness.test.js (tier thresholds, absent≠false-stale, no negative
age) + vyndrParityQA (mobile clock wired, one shared freshness source).
Built to Design's mobile spec, VISUALLY UNVERIFIED at 390px. NEXT shell
increments (still M1b): merge the clock into the logo row (Design's single-row
app bar), the breadth strip (EDGES/AVG CLV/GAMES — replaces the graded count
mobile lost here), and relocate the wire to the bottom of the board.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Design now provides mobile designs (Vyndr Mobile.dc.html, 5 screens at 390×844).
Captured as specs/design-reference/MOBILE-SPEC.md — the buildable distillation
(the raw dc-runtime HTML needs React to render; the design project holds the
verbatim source). I'm now IMPLEMENTING Design's mobile, not inferring it.
RECONCILE of the already-shipped M1a against Design's mobile:
- EKG hidden <768px — Design AGREES (mobile app bar carries no EKG). KEPT.
- .vbtn 44px tap target — Design AGREES (buttons are 44/46px). KEPT.
- .m-hero generic clamp — CONFLICT. Design uses CONTEXT-SPECIFIC hero sizes
(74px grade tier / 40px live tier / 24px /u stats), not one clamp, and
nothing consumed the class. REMOVED; the specific sizes land per-surface in M1b.
- Mobile HEADER shape — Design OVERRIDES: mobile is an app bar (logo + sync
clock) + a breadth strip + a bottom wire, NOT the collapsed heartbeat bar.
The EKG-drop is a compatible interim step; the full app-bar recomposition is
M1b (the board/shell surface). Flagged in the CSS + MOBILE-SPEC.
Built to Design's mobile spec, VISUALLY UNVERIFIED at 390px (Chrome audit is eyes).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
SETUP (required first step): overwrote specs/design-reference/ with the CURRENT
authoritative mockup ("Vyndr System.dc.html" from claude.ai/design, the version
with the combat card / pitcher identity / live grade-shift / correlation builder
/ FREE|PRO pricing / TRANSMISSION QUIET). 12 surfaces. Confirmed it has NO media
queries — desktop-only, so the 390px expression is a deliberate design decision,
not a shrink.
M1a — mobile foundation (built to spec, VISUALLY UNVERIFIED at 390px; WSL2↔Chrome
unreachable, the Chrome audit is the eyes):
- Header-zone collapse (the concrete audit finding: ticker + SIGNAL LIVE + STALE
stacking in ~110px). The decorative EKG is dropped <768px so the heartbeat
reads as ONE clean status line; signal pulse + ticker are the single animated
element (DESIGN-SPEC §4). LiveLayer gains .heartbeat-bar / .hb-ekg hooks.
- Primary CTA (.vbtn) meets the 44px touch target on mobile; dense `small`
buttons opt out (density is a feature).
- .m-hero mono-hero clamp for the one-figure-per-card grammar at 390px.
- M4 test-lock: vyndrParityQA asserts the header collapse, tap target, overflow
containment, and the honest "UNVERIFIED at 390px" label are all in source.
Per-surface stacking (M1b), billboards (M2), desktop parity (M3) continue.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Diagnosis (why 500/500 went unpaged): the only regular odds-api burner was
futuresService, which called axios DIRECTLY — bypassing the gateway, so it
never hit recordCall (the ONE place the WARN/BLOCK pager fires) and never
respected the 95% block. It only syncFromHeaders, which updated the counter's
number SILENTLY. oddsService (which does go through the gateway) only touches
odds-api when PropLine fails, so recordCall for odds-api effectively never ran.
Result: the counter could reach 100% with neither pager firing.
Fixes (a silent drain is now impossible, not just guarded):
- futuresService routes through gateway.fetch('odds-api', …) → counted, blocked
at 95%, and reserve-gated. Closes the raw-axios bypass.
- Reserve floor in the gateway: a DISCRETIONARY call (futures/soccer) passes
reserve=ODDS_API_RESERVE (default 50) and is refused while remaining <= reserve.
The ESSENTIAL MLB prop-backup passes no reserve and may spend to the 95% block.
→ a futures/soccer drain can NEVER starve MLB's backup path.
- quotaTracker.syncFromHeaders (the AUTHORITATIVE number) now fires the same
once-per-period WARN/BLOCK alert on a crossing — extracted fireThresholdAlert
shared with recordCall. The header-only drain now pages.
- POST /api/internal/quota/test-alert (internal-key) test-fires the pager
end-to-end so ntfy delivery is verifiable on demand.
Also (reality-corrected cadence): WNBA restored to the full grid. 2026-07-15
had two AFTERNOON WNBA games finished before the 22 UTC slot — 14 UTC (10am ET)
is the only slot early enough for a 1pm ET game's props, and on PropLine the
extra slots cost a rounding error. Soccer stays the only trimmed sport (the
real odds-api discipline). Assumption corrected by observed data.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Every ACTIVE sport was graded at all five MLB slots (14/19/22/1/3 UTC). Sports
post lines on different clocks, so that inheritance was wasteful both ways:
WNBA props aren't posted at 14:00 UTC (10am ET) → that slot always graded 0
(the audit's "wnba:0"); soccer odds come from the 500/MONTH odds-api key, so
five slots/day is a third of the budget for 1-2 matches.
New src/config/sportCadence.js is the single source of truth (config-over-
constants). Mapped from reality + quota headroom (PropLine 9k/day abundant,
odds-api 500/mo scarce):
mlb 14/19/22/1/3 intraday (full grid — games+props all day)
nba 14/19/22/1/3 intraday (in-season fits; off-season self-skips empty)
wnba 19/22/1 intraday (afternoon→evening ET; drops the 14/3 waste)
soccer 14/19 NO intraday (WC live; 2 lean odds-api reads, key-protected)
The scheduler still fires at HOURS_UTC and the missed-cron watchdog still
references MLB (which runs every grid hour) — each slot now grades only
sportsForHour(h), and only intradaySports() get the 20-min refresh. Every
sport's hours are kept a subset of the firing grid (a boot-time guard + a test
warn if that's ever violated). Retune a sport by editing one table row.
Adaptive, not constant: near-zero when a sport is quiet, protecting the scarce
odds-api quota from being drained by noon.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The "SIGNAL LIVE vs STALE 8h" contradiction was a field mismatch, not a dead
pipeline. updated_at is the grade-LOCK time (advances only on a full snapshot,
5×/day — grades never change in-game, so it is intentionally stable). The
SYNC badge measured the 20-min intraday cadence (expected_interval_s=1200)
against that 5×/day field → structurally guaranteed STALE between slots even
when intraday refreshes lines perfectly.
- snapshotService: full snapshot now seeds refreshed_at at lock time
- intradayRefresh already bumps refreshed_at every ~20 min (unchanged)
- /api/snapshot/summary + GET /:sport now expose refreshed_at (was written to
Redis but never serialized → no public liveness signal existed)
- LiveLayer SYNC badge measures freshness from refreshed_at (fallback updated_at)
Exposing refreshed_at also gives a public heartbeat probe: it advances every
intraday slot, so pipeline liveness is verifiable without container logs.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Wave 0 shipped NBA/WNBA grading off ESPN per-athlete gamelogs, but name→id
resolution went only through the v2 /search endpoint, which is unreliable at
the edges. Live probing surfaced the real coverage gap: search actually
resolves the right id for most names, but dual-league athletes (WNBA + NCAA —
e.g. Napheesa Collier, Brionna Jones) get a filters-only gamelog until a
`?season=` is supplied, so they silently returned insufficient_data despite a
full season of games.
Two fixes:
1. buildAthleteRosterIndex(sport) — aggregates every team roster for nba/wnba
into a complete { nameKey → {id, displayName, teamId} } map (canonical
accent-folded keys via playerName.nameKey). Bounded concurrency (6) over the
~15-30 team fetches, Redis `espnroster:{sport}` (24h) + in-memory mirror,
fully defensive (a failing team is skipped → partial index, never throws;
grouped OR flat athletes[] shapes handled; non-numeric ids dropped). This is
now the PRIMARY resolver in resolveAthleteId/getPlayerGameLog; the v2 search
stays as a backstop on a roster miss. A unique roster hit wins (S59 doctrine)
— a missing name beats guessing another player's id.
2. getPlayerGameLog retries the gamelog with candidate seasons (current +
previous calendar year) ONLY when the first parse comes back empty
(filters-only), unlocking the dual-league athletes. The common path is
untouched.
MLB path (statsapi) unchanged; settlement/snapshot/frontend untouched.
Live probe: WNBA roster index = 206 players (Collier id 3917450 / Lynx team 8,
Brionna Jones id 3058895 present); NBA index = 544. Collier now resolves
end-to-end with 20 gamelog rows (was NOT FOUND); Brionna Jones likewise; all
previously-working players (A'ja Wilson, Ionescu, Clark, Stewart, Plum) still
resolve. NBA hyphen names (Gilgeous-Alexander) resolve via nameKey folding.
Tests: tests/unit/espnRosterIndex.test.js (9, fail-then-pass on base adapter).
Full backend suite 3183 green; web build exit 0.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
FREE ESPN news wire + championship-winner futures for the never-dark
offseason hub. Both graceful/empty, never fabricate a market value.
- newsService (mirrors injuryService): per-sport ESPN /news FEEDS, pure
parseNews → { sport, items:[{id,headline,description,published,type,
athlete?{name,key},team?,href}] }; athlete/team from categories[] only
(absent when not present). Cache 15m, injectable, offline-tested.
- oddsNormalizer.normalizeOutrights: NEW branch — outrights outcomes are
{name,price} with no point, so normalizeProps drops them; keeps them with
best-price-across-allowed-books per selection. + americanToDecimal.
- oddsService.FUTURES_KEYS: separate map (mlb/nba/wnba championship winner),
OUT of the daily SPORT_KEYS/snapshot budget.
- futuresService: getFutures(sport,deps) → { sport, updated_at, markets:
[{key,title,selections:[{name,price,prevPrice?,move?}]}] }. One outrights
call per 12h TTL (quota-disciplined), FUTURES_ENABLED gate. Price-move
(shortening/drifting/flat) mirrors computeLineDeltas SHAPE on odds not
line; prev prices persisted inside the futures:{sport} value (no new key).
linkNewsToMoves pure causal-tie helper.
- Routes /api/news/:sport + /api/futures/:sport (registered) + Next proxies.
- Tests: newsService, futuresService, oddsNormalizerOutrights (fail→pass,
no network). Full suite green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Extend /explore (ExploreHub) into the 365-day never-dark hub. Two new
self-hiding sections feed REAL always-available data into the offseason:
- NewsWire (components/vyndr/NewsWire.tsx): real ESPN headlines from
/api/news/:sport (newest-first, mono timestamps, type chips, player/team
links) + real injuries from /api/schedule/:sport/injuries (OUT/GTD chips,
token colors). Reuses the retired TerminalTemplates INJURY_WIRE layout but
never routes its sample constants. Self-hides when both feeds are empty.
- FuturesBoard (components/vyndr/FuturesBoard.tsx): real futures from
/api/futures/:sport — championship/win-total/award markets, mono tabular
prices + movement colored by the contract (shortening=green / drifting=amber
/ flat=dim, NEVER red; move shown ONLY when the backend supplies one).
Carries the honest "TRACKED · NOT GRADED" label — no fabricated grades on
futures. Self-hides when markets:[].
- ExploreHub is offseason-aware (via emptyState OFF_SEASON month check): the
hub LEADS with futures + wire when the board is dark, COMPLEMENTS the live
board in-season. Sport selector kept; each section self-hides independently.
- Testable pure helpers: lib/futuresMove.js (move→color, never red) +
lib/newsFormat.js (timeAgo mono-stamp, ESPN type labels).
Contracts consumed (Wave 2A owns the proxy/service files); code self-hides on
fetch failure if a proxy isn't present yet.
Tests: tests/unit/newsWire.test.js + futuresBoard.test.js (26 new). Full suite
259 suites / 3144 green; web build exit 0. vyndrParityQA stays green
(mono data, no glitch on data surfaces).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Unblocks the self-learning loop for basketball. Once an NBA/WNBA grade
exists (Wave 0), it now settles against the FREE ESPN per-game log
(espnStatsAdapter.getPlayerGameLog) — the same {found, last10:[{date,stat}]}
contract MLB settlement already consumes. accuracy:{sport} + by_tier
calibration + the Wave-3 TierRecord light up automatically.
- outcomeService/ledgerService: defaultGetPlayerStats routes nba/wnba to
espnStatsAdapter.getPlayerGameLog; MLB stays on mlbStatsAdapter.
- outcomeService: sport-aware statValue + a SEPARATE NBA_BOX_KEY/NBA_COMBO
map (S11 three-map-split kept — never merged with MLB_LOG_FIELD). Combos
(pts_reb_ast, reb_ast, stl_blk, …) sum components; a missing component
never fabricates a total.
- logRowOnDate: ESPN gamelog rows carry a FULL ISO timestamp (a late tip
rolls past UTC midnight), so basketball date-matches on UTC OR ET date;
MLB keeps exact YYYY-MM-DD compare. Outcome `date` is normalized to the
ET calendar day so the accuracy window filter + idempotency key behave
identically across sports.
- Final-honesty guard: never settle a basketball row whose ET date is
today (an in-progress partial box). MLB is final-only + settles same-day,
so the guard is scoped to basketball. The ledger path is already guarded
(.lt('game_date', today)) for all sports.
- opsWatch: nba/wnba added to SETTLEABLE_SPORTS; zeroSettleAlarm gates them
behind a real-finals probe (finalsBySport) so an offseason/off-day's
stale pendings never false-page "settled 0". snapshotScheduler counts
yesterday's ESPN state==='post' events and feeds the map; MLB unchanged.
- snapshotScheduler: boot announce per settleable sport
([settle:mlb] [settle:nba] [settle:wnba]). Thrown-error paging already
covers the new sports (settleAll* loop every sport).
Tests: tests/unit/nbaSettlement.test.js (16) — WNBA hit/miss/push, combo
pra, idempotent re-run, unplayed/today game does NOT settle, accuracy:wnba
+ byGrade + by_tier populate, ledger WNBA settle. opsWatch (+5) — finals
off-day no page, finals present DOES page. MLB suites unregressed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
featureCache.gameLogFeatures falls back to espnStatsAdapter.getPlayerGameLog
(free ESPN per-game logs) when the offline Python source returns null → NBA/WNBA
produce l5/l20 → props GRADE instead of refusing. +18 tests, proof test locks
the refuse→grade transition. MLB unchanged.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The Python nba_api service (gameLogService) is offline in prod, so
featureCache's non-MLB branch produced no l5/l20 averages →
projectionFor returned null → the ENTIRE NBA/WNBA slate refused
(insufficient_data). Only MLB actually graded.
Fix (free, no-auth, verified live):
- espnStatsAdapter.getPlayerGameLog(name, sport) — resolves name→ESPN
numeric athlete id via the v2 search (the v3 /search now returns
count:0; the v2 uid carries a:<id>, defaultLeagueSlug disambiguates
league), fetches the per-athlete gamelog, and parses per-game rows
keyed by VYNDR stat names (points/rebounds/assists/threes/steals/
blocks/turnovers + computed pra). Columns are indexed by the
response's own names[] array (NBA and WNBA orders DIFFER), never
positionally. Most-recent first, defensive (null on unrecognized
shape, never throws), cached (espngamelog:{sport}:{id} 4h + memory).
- featureCache.gameLogFeatures — falls back to the ESPN gamelog for
nba/wnba when the Python source returns null/empty, producing
l5/l10/l20 + rest_days + minutes_per_game via a new local
NBA_LOG_FIELD map + pure nbaGameLogFeatures (S11 three-map-split:
separate from MLB_LOG_FIELD).
Grade gates already whitelist all 8 NBA/WNBA stat types in both Node
paths (analyze.js + scan.js); no gate change needed.
Tests (hermetic, no network): espnGameLog.test.js (parser/resolver/
adapter) + featureCacheNba.test.js (the UNLOCK proof — empty features
refuse, ESPN-derived features grade). 3098 tests green; next build
exit 0.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>