← All findingssource · memory/project_wnba_lineups_gap.md
Original note (2026-06-18) below — "NOT committed" is stale:
BUILT 2026-06-18 (not deployed, not run e2e): RotoWire projected-minutes ingest in SDS
internal/rotowire/ — shared authenticated league-aware client (URL /basketball/ vs /wnba/)
- per-league refresher (enumerates today's teams from {schema}.games, fetches RotoWire, name-matches to {schema}.players.player_id, upserts {schema}.game_lineups with projected_minutes
- [internal detail removed]
WNBA pick projections are injury-aware (official injury PDF → Out/Doubtful/Questionable lockdown gate works) but NOT starting-lineup or projected-minutes aware. Verified 2026-06-18. The infra exists; it's a bug, not a missing feature.
Two distinct gaps:
-
SDS WNBA lineups ingest is empty. SDS owns lineups (route
/{league}/lineups/{id},internal/adapters/wnba/lineups_refresh.go, tablewnba.game_lineups). The route returns 200 butplayers:[]for every game incl. finals. Refresher pullsstats.wnba.com/js/data/leaders/00_daily_lineups_{date}.json; its own comment admits WNBA daily-lineups coverage is unreliable and it returns clean on 404. So data-hydrator'sassembleMultiSportdeliberately bypasses SDS /lineups and uses a roster-fallback (assembler.go:1412comment: "WNBA lineups-refresh ingest is broken upstream… separate fix"; roster-fallback at ~1564, LineupType=PROJECTED, projected_minutes=0). -
RotoWire (projected minutes) lives ONLY in lineup-service — not SDS.
assembleNBAcalls lineup-service/lineups/game/{slug}→ real lineup + RotoWire minutes;assembleMultiSport(WNBA) never calls lineup-service. lineup-service is NBA-only (getGamesWithLineupsqueriesnba_game_id,NewNBALineupFetcher), so RotoWireGetTeamMinutesis never called for WNBA teams (the client itself is league-agnostic —/basketball/...?team=ABBR— and could serve WNBA). SDS has zero RotoWire code.game_lineupshas aprojected_minutescolumn but nothing fills it from RotoWire.
Plan: SDS is SoT for lineups; lineup-service is slated to retire (see project_sds_is_the_data_layer,
project_sds_cutover_plan). Do NOT extend lineup-service. Recommended fix: port the ~250-LOC RotoWire
client into SDS's wnba adapter, parameterize the URL by league (/basketball/ vs /wnba/), fetch per
WNBA team, upsert starters + projected_minutes into wnba.game_lineups, wire into the lineups refresh
cron; add ROTOWIRE_USERNAME/PASSWORD + PROXY_* to SDS env; then point data-hydrator WNBA at SDS /lineups
(currently roster-fallback). This solves WNBA lineups+minutes WITHOUT the big NBA-only phase-5e projector
(project_wnba_snapshot_depth_gap). NBA deprecation of lineup-service is the separate phase-5e lift.
ROOT-CAUSED 2026-08-24 (Maya Caldwell case) — THREE stacked failures
User bet OVER 4.5 pts on Caldwell (1630471, MIN) off a RotoWire starter listing; the board served UNDER. She played 25.4 min / 9 pts. Over won.
1. The lineup arrives AFTER TIP — WRONG, CORRECTED BELOW. See "CORRECTION".
GET SDS /wnba/lineups/{game_id} DOES hold source: "RotoWire Confirmed Lineups" with Caldwell role: starter, projected_minutes: 26. But
source_observed_at = 2026-08-25T01:54Z against a 00:00Z tip — +114 min.
Checked the 14 most recent games: 0 pre-tip, 14 after tip, all at a
suspiciously fixed +114/+144 min. The confirmed-lineup cron runs every minute
(wnbaConfirmedLineupsCron, cmd/worker/main.go:362) so the poll is not the
problem — we appear to only capture RotoWire's page once it flips post-game.
The authenticated PROJECTED path is gated on jobs.rotowireClient != nil
(main.go:369) i.e. ROTOWIRE_USERNAME/PASSWORD.
2. The date-wide route is 501 for WNBA. GET /wnba/lineups/date/{date} →
{"error":"capability not implemented for this league"}. Only NBA implements
LineupExtAdapter.FetchLineupsForDate (adapters/nba/lineups.go:335); WNBA's
adapter has only per-game FetchLineup (adapters/wnba/lineups.go:156).
projections-engine ingest/lineups.py calls exactly that 501 route — and
its only caller repo-wide is the one-off scripts/lineup_game4.py. Dead code
pointed at a dead route.
3. Even with the data, nothing would consume it.
serve/minutes_projection.py builds minutes purely from the last 10 boxscores
(RECENT_GAMES_WINDOW=10). simulate/minutes_conservation.py:16-18 admits in
its own docstring: "no lineup-confirmation feed reaches this module" — it
infers starter/bench from predicted q50 RANK, so a promoted bench player is
still classed bench. models/training_set.py:436-437 justifies the leak-prone
is_starter feature "because at inference we now have the lineup endpoint" —
false in the shipped path; slate_scorer.build_rotation_snapshot takes the
last historical row and never overrides it. Injury status can only REMOVE a
player, never promote one. Also slate_scorer.py:178 drops anyone whose last
game was <12 min (min_recent_minutes=12.0) — a promoted deep-bench player can
vanish from the board entirely.
The system flagged its own blindness and shipped anyway. DH prop_diagnostic
carried role_trend:"lineup_promotion" (run 1) AND
minutes_source_confidence:"diverge_low_confidence" on the very pick that lost.
Nothing downstream consumes those flags to veto or re-price.
⚠️ The served board contradicted its own math: nightly card ranked her
UNDER 4.5 #1 in the game, grade B, while carrying value_flag:false,
value_edge_pct:−1.04, kelly_stake_units:0. grade_v2 is calibrated P(hit)/
juice, NOT edge — the known trap, live on the card.
SDS already computes the correction it ignored: her starter split reports
+11.3 min (t=7.23) and +4.0 pts (t=3.97), both verdict measured,
driver minutes. 21/32 prior starts (65.6%) cleared 4.5 vs 35% over her
bench-heavy L20. Same player, opposite side of the line, on one binary feature.
Market agreed with the user: under 4.5 moved −130 → −118 (CLV −2.4c), under 5.5
−164 → −138 (−4.1c); grader tagged both bad_price.
CORRECTION 2026-08-25 — the data WAS there, 16 hours early
The "+114 min after tip, 14/14 games" claim above is WRONG. It read
source_observed_at off GET /wnba/lineups/{game_id}, which returns only the
LATEST snapshot — post-game writes bump that field. It does not show when we
first knew.
wnba.lineup_status_history is the real record. For Caldwell (1630471), game
1022600282, tip 2026-08-25T00:00Z:
08-24 07:24Z - -> PROJECTED RotoWire Expected Lineups (T-16h36m)
08-24 23:37Z PROJECTED-> STARTING RotoWire Confirmed Lineups (T-23m)
ROTOWIRE_USERNAME/PASSWORD ARE SET on the SDS worker and the authenticated
job works. Verified live: wnba.rotowire_lineups at 07:24Z wrote
projected_teams=6 projected_starters=30 ~16h before tip. Do NOT re-diagnose
this as a credentials problem.
Cheapest fix is on the PE side, not SDS. projections/ingest/lineups.py
calls GET /{league}/lineups/date/{date} → 501 for WNBA (only NBA
implements LineupExtAdapter). But GET /wnba/lineups/{game_id} works
today and returns role + projected_minutes per player. PE builds slates
per-game and already has game_id, so pointing it at the per-game route avoids
writing any Go at all. (Implementing FetchLineupsForDate for WNBA is the
tidier fix and matches PE's existing dead code.)
⚠️ SEPARATE, UNRELATED BREAKAGE: runWNBALineupsRefresh fetches
https://stats.wnba.com/js/data/leaders/00_daily_lineups_YYYYMMDD.json, which
now 302-redirects to HTML → parse: invalid character '<'. It errors on
every run (cron 12,27,42,57, i.e. every 15 min) and has been filling the
worker log. That feed is dead upstream; RotoWire is the live source. Either
retire the job or repoint it.
⚠️ The projected job also rejects teams with expected exactly five unique starters; received 0/6/7 — some RotoWire pages parse to the wrong count, so
projected coverage is intermittent even when it runs.
ALL FIVE FIXES SHIPPED 2026-08-25
| # | repo | commit | what |
|---|---|---|---|
| 2A | projections-engine | 888f3d4 | slate consumes tonight's lineup |
| 2B | sport-data-service | 3ca7905 | LineupExtAdapter for WNBA — 501 gone |
| 3 | data-hydrator | a0810a2 | fresh-promotion under veto |
| 4 | grading-service | 56d4a57 | refused pick can't hold rank 1 + value census |
| 5 | sport-data-service | 3ca7905 | dead stats.wnba.com cron retired |
Verified live: GET /wnba/lineups/date/2026-08-24 → 200, Caldwell
role:starter, projected_minutes:26. Nightly board now serves
valued_count / no_value_count.
⚠️ SCOPE — 2A moves MINUTES, not props. is_starter is a feature of
minutes_v2 ONLY; it is in none of the nine per_stat.py STAT_FEATURES
presets, and slate_scorer predicts stat quantiles at :410 BEFORE minutes at
:425 without threading predicted_minutes. Making a promotion reach the POINTS
line needs that reorder — a numeric change to every prop, needing its own
measurement. Tonight's bad under is stopped by FIX 3, not FIX 2A.
⚠️ THE BOARD IS ESSENTIALLY ALWAYS NEGATIVE-EV. Measured 5 consecutive WNBA
slates (08-20..24): core tier value_flag:false on 18/18, 30/32, 21/21, 28/33,
21/24. A hard EV floor would EMPTY the core card on two of five nights. So
NIGHTLY_REQUIRE_VALUE exists but defaults OFF; the fix shipped is demotion +
census, not exclusion. This is the open question, and it is bigger than the
Caldwell pick: either p_hit is miscalibrated (memory: grade_v2 ≈ market) or
we should not be serving a core card most nights.
⚠️ bestEdge → bestPick returns (valued, edge). A single float CANNOT
express "any certified pick beats every refused one" — collapsing it to a float
is how the first attempt silently did nothing. Test pins the inversion.
⚠️ I destroyed internal/adapters/wnba/status_history_test.go (untracked
WIP) by mv-ing two same-named files into one temp dir. It referenced
injuryStatusHistoryQuery, which does not exist in the wnba package — it never
compiled, which is why go test ./internal/adapters/wnba/ was already failing.
The NBA sibling survives at internal/adapters/nba/status_history_test.go and
internal/handlers/status_history_test.go exists. Use git stash push -u, never mv.
CORRECTION 2026-08-25b — the NBA daily-lineups feed is NOT dead
An earlier recon claimed "the identical NBA feed is dead the same way" and I repeated it. Wrong — do not retire the NBA cron. Measured directly:
stats.nba.com .../00_daily_lineups_YYYYMMDD.json -> 200 application/json
20260115 games=9 20260210 games=4 20251220 games=10 today games=0 (offseason)
stats.wnba.com .../00_daily_lineups_YYYYMMDD.json -> 200 text/html 114KB error page
The NBA job logs nba lineups refresh ok ... games=0 errors=0 because it is
the offseason, not because it is broken. Only the WNBA host redirects to an
error page. cmd/worker/main.go NBA cron */15 * * * * stays.