Docs / Reference / Player surfaces & data alignment

The three ways the app shows a player (full page, list, trading card), how their data sources differ, and the draft-readiness fixes shipped 2026-06-16.

Player surfaces & data alignment

The app shows a player in three places, each backed by a different query path. They were built at different times and drifted apart at the edges. This page is the map β€” what each surface is, where its numbers come from, and which divergences are fixed vs. still open.

Audit basis: 48-agent review on 2026-06-16, every claim verified against the live pages. Readiness at audit time: 5/10 β€” the scoring backbone is solid; the browse/find layer was not.

The surfaces

Surface Route Template / JS Data source Role
Full page /player/<slug> player_profile_v2() web_dashboard.py:12444, player_profile_v2.html ~9 tables: players, pigpro_score_history, player_projections, fantasypros_rankings/projections/news, nflverse_player_ids, v_player_matchup_difficulty, kicker_prior_stats Deep scouting brief
List / board /players player_hub() web_dashboard.py:7786, players_v2.html pigpro_score_history β‹ˆ players β‹ˆ nflverse_player_ids, LIMIT 500 Fast triage board
Trading card /api/pigpro/card/<name> pigpro_card() web_dashboard.py:3929, static/js/pigpro-card.js nflverse_player_ids β‹ˆ players β‹ˆ player_projections (+ history, draft, news) Quick modal peek

A fourth, orphaned view (/player-dashboard/<id>, web_dashboard.py:7210) still exists, reachable only by typing the URL. Slated for retire/redirect (see Open items).

Where the numbers come from (and why they used to disagree)

The headline PigPro score / tier / recommendation agree across all three surfaces β€” the nightly run writes pigpro_score_history and players.pigpro_score together, so they stay in lockstep. The divergence the user felt was at the edges:

  • Projections came from two providers, unlabeled. The card showed the PigPro model projection (player_projections, source pigpro%); the page showed the FantasyPros projection (fantasypros_projections). For J.K. Dobbins that was 181.7 (card) vs 171.3 (page) β€” same player, two numbers, no provenance, so it read as a contradiction.
  • ADP and PigPro never appeared together. The card carried real draft ADP; the page showed a PigPro component bar labeled "ADP Value" that a reader could mistake for draft position.
  • Identity drift. "JK Dobbins" / "J.K. Dobbins" / "JK Dobbins" across surfaces; "Walker" vs "Walker III"; "KC" vs "KCC". Three+ name-normalization expressions, no single canonical resolver.

Fixes shipped 2026-06-16 (the P0 trio)

Search on /players (PR #193). /players had no text search β€” only preset/position/sort URL filters, so finding a player meant scrolling (Dobbins was row #93 of 313 RBs). - Added an autofocused, debounced search box (players_v2.html) wired to /api/players/search; arrow-keys / Enter / click jump to the full /player/<slug> page. Slug built from the canonical normalized name (mirrors _slugify) to avoid the suffix-404 trap. - Fixed the punctuation bug in _search_players_postgres (web_dashboard.py): strip [^A-Za-z0-9 ] from both the query and normalized_name/first/last before LIKE. Before: q='J.K. Dobbins' returned 0; after: it returns Dobbins. A.J./D.J./T.J. names all resolve; board-first ranking preserved.

Canonical projection / ADP story (PR #194). - The full page now fetches the PigPro projection too (player_projections, pigpro%) and shows it on the page's PigPro row alongside the FantasyPros row β€” both labeled. Dobbins now reads "PigPro 181.7" and "FantasyPros 171.3" side by side instead of two unattributed numbers. - The trading card's projection is labeled "PIGPRO PROJ" (was a bare "PROJECTED"). - The misleading PigPro component bar "ADP Value" β†’ "Value vs ADP" on both the page and the card, so it is not confused with draft ADP.

Shipped (P1 + P2)

  • P1 β€” /players rows link to /player/<slug> (slug from normalized_name, suffix-safe) + "View full profile β†’" in the card footer; LIMIT 500 β†’ 2000 with an "N of M" header; one shared _name_match_sql resolver across /player + card (fixed hyphenated names like Jaxon Smith-Njigba); link-integrity guardrail (scripts/smoke_player_links.py, see below).
  • P2 β€” Card shows the stored numeric tier ("TIER 4") not a client ELITE/STARTER/FLEX/BENCH band (foil colour still keys off the score band). Page shows a "Scored " freshness caption. /player-dashboard/<id> is retired β€” 301-redirects to /player/<slug> (or /players).

Open items

  • P2 (deferred) β€” Remove the superseded static/js/player-card.js. NOT a clean delete: it still exposes openPlayerCard / openPlayerCardByName, called live by professional_offseason_intel.html, professional_players.html, and coach-card.js. Migrate those to pigpro-card.js first.

Projection = curated industry consensus (the edge lives in the Score)

Two earlier framings here were wrong and are corrected below: projections were never "stale + duplicated" (they refresh daily, one players row each), and the "consensus + edge" approach was backtested and dropped β€” the edge made projections worse.

History. The point projection started as GBR_predicted_ppg Γ— 17 (model only), which regressed elite RBs to the mean (Bijan 192 vs FantasyPros 325). We then anchored on FantasyPros and added a bounded Β±15% "edge" from our signals (usage/ML/news/situation). A point-in-time 2025 backtest (scripts/backtest_projection_edge.py) then showed the edge degrades accuracy: FantasyPros alone is already strong (MAE 39, Spearman 0.78), the usage signal correlates negatively with the FantasyPros residual (Pearson βˆ’0.22 β€” the market already prices usage, and high-usage players slightly regress), and every nonzero edge bound increased error (Β±15% cost ~3–4 pts/player/season; MAE-minimizing bound = Β±0%).

Current (shipped). The PigPro projection is the curated FantasyPros consensus, no edge β€” _curated_projection() in web_dashboard.py returns the FantasyPros PPR anchor normalized to the league. Our differentiation deliberately lives in the PigPro Score / draft-value ranking (the value_scorer composite of ADP/VOR/usage/situation β€” "who to draft"), which is a different product from the point projection and is where our edge belongs. The /player page labels the projection "curated industry consensus" and footnotes that the Score is our edge; the card shows PIGPRO PROJ Β· consensus; the raw model estimate is shown only as a "not used" transparency row.

Open follow-ups. Players with no FantasyPros anchor (deep/rookies) still fall back to the model estimate β€” extend a secondary projection source. FantasyPros only ships PPR, so non-PPR leagues need format normalization. If an edge is ever revisited, find a signal that backtests positive against the residual (snap-share trend, target-share delta, age curve) rather than usage. Longer term, persist the consensus number into player_projections so the list/exports use it too.

Open items

  • P2 (deferred) β€” Remove the superseded static/js/player-card.js. NOT a clean delete: it still exposes openPlayerCard / openPlayerCardByName, called live by professional_offseason_intel.html, professional_players.html, and coach-card.js. Migrate those to pigpro-card.js first.

Guardrail

scripts/smoke_player_links.py enforces the list→page→card contract: it pulls the row slugs from /players and asserts each /player/<slug> returns 200 (catches the suffix-404 trap) and each trading card returns a non-fallback payload with a non-null PigPro score (catches broken gsis linkage). Run it against any deployed instance:

BASE_URL=https://app.scoring.zone python scripts/smoke_player_links.py

It runs weekly (and on demand) via .github/workflows/smoke-players.yml. On first run it caught Tyreek Hill's card showing a blank score β€” players.pigpro_score was NULL while the daily history had 6.72 β€” now fixed by falling back to the latest pigpro_score_history snapshot in the card payload.