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, sourcepigpro%); 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 β
/playersrows link to/player/<slug>(slug fromnormalized_name, suffix-safe) + "View full profile β" in the card footer;LIMIT 500 β 2000with an "N of M" header; one shared_name_match_sqlresolver 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/BENCHband (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 exposesopenPlayerCard/openPlayerCardByName, called live byprofessional_offseason_intel.html,professional_players.html, andcoach-card.js. Migrate those topigpro-card.jsfirst.
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
playersrow 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 exposesopenPlayerCard/openPlayerCardByName, called live byprofessional_offseason_intel.html,professional_players.html, andcoach-card.js. Migrate those topigpro-card.jsfirst.
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.