← All findingssource · memory/project_analytics_engine.md
Shipped 2026-08-23
- Phase 1 — SDS
2360379+10da061.GET /{league}/analytics/query, packageinternal/analytics. Types:player_vs_team,home_away,starter,season,last_n. Returns rows + totals + baseline + Welch t-test +min_detectableper stat. - Phase 2 — nightly-picks
fa6e362./ask/[slug],<AnswerCard>,/api/analyticsproxy,.ask-*styles in dime.css. - Phase 3 — SDS
a9476b2+ nightly-picks5e6e379.player_vs_line:/ask/{player}-over-18-5-points. Returns hit rate, Wilson interval, distribution, recent form, and the PRICE that rate requires — break-even plus the longer price implied by the interval's pessimistic end. Never a bare hit rate: the same 59.5% returned +60u above a 1.70 floor and -14u below it. Reference: A'ja over 18.5 pts = 171/228, break-even 1.33, worth taking at 1.45+.
Design rule: never gate, always label. Every split renders; the verdict
(measured |t|>=1.96 / directional >=1.0 / not_measurable) rides alongside
with the smallest gap that sample could resolve. not_measurable is the COMMON
case for vs-team splits by design — UI is built around that, not against it.
Reference case: Arike vs Seattle = 17 games, 17.6 vs 19.0 pts, t=-0.57 not_measurable; only minutes leans (-2.3, t=-1.19). Usage and pts/min are flat — it is a minutes story, and the engine surfaces that unprompted.
Traps hit, all found by rendering rather than reading:
- Team ids are NOT guessable. Six of fifteen I inferred were wrong
(Connecticut …323, Indiana …325, Chicago …329 — neighbouring ids, unrelated
teams). A wrong id does not error, it silently answers about another team.
Copy verbatim from
wnba.teams. - Fraction stats die in rounding.
true_shooting_percentageis stored 0-1; at 1dp a real 3-point swing became "0.5 vs 0.5, diff 0.0, not measurable". Now scaled x100 at source. Check any new fractional stat for this. - Punctuated names never resolve. Upstream search is a substring match, so
aja-wilsonfinds nothing — the row is "A'ja Wilson". Falls back to surname- punctuation-stripped match. Do not then title-case a resolved name or "A'ja" becomes "A'Ja".
home_awayis stored UPPERCASE (HOME/AWAY). Comparing to"home"rendered every game as away — plausible-looking and therefore invisible. The SQL predicate lowercases the column so splits were always right; only display was wrong.- A line query returns the whole career (228 games) — correct for the rate, fatal for the page (12,763px). Cap the log, never the maths.
- Next caches fetches in
.next, not just.next/cache. A stale API value survived both a dev restart andrm -rf .next/cache; onlyrm -rf .nextcleared it. Suspect this before suspecting the code.
- Ledger join — grading
9910552+ nightly-picks32189df.GET /api/v1/analytics/marketon grading-service returns what books paid at one player/prop/line/side; the page fetches it alongside and renders required vs offered. A'ja over 25.5 pts: needs 2.62, books paid 1.88 → 28% short, clear pass.
⚠️ bet_clv holds one row PER FORMULA (model_matrix writes one per persona arm), so any aggregate over raw captures is a duplicate-count artefact. Median per GAME first. This produced a live false positive — Naz Hillmon assists 0.5 read 4.10 because one voided game supplied 7 of 10 rows at +310, rendering "+199% value" on a correctly-priced prop. Caught only by sanity-checking against the 1-4% edge band (feedback-model-edge-calibration). A verdict now needs >=5 priced GAMES or it withholds.
- Phase 4 mechanism — SDS
eef9e3a+ nightly-picks90ebf6b. A measured delta decomposes EXACTLY into minutes vs per-minute rate (value = min × rate). Populates only when something cleared significance — decomposing noise gives two precise-looking halves of a difference that isn't there. Minutes checked first: most apparent matchup effects are rotation effects. - Phase 5 — SDS
b037718+ nightly-picks872ce95.lineup_conditional(/ask/{player}-with-{teammate}) readsplayer_game_stats.starter, NOTgame_lineups. Them.team_id = g.team_idguard is load-bearing — without it the predicate matched any two starters in the same GAME, so a cross-team pair returned their head-to-head meetings. Plus an NL fallback that runs only after deterministic resolution fails and extracts player+intent to re-enter the same grammar; it never answers.
⚠️ v4-flash spends output budget on internal reasoning BEFORE emitting. A
tight max_tokens returns finish_reason: "length" with an empty string —
the call succeeds and the content is blank, so it fails silently. Measured:
120 → empty, 400 → 94 tokens used. Same trap as a smoke test that returned a
bare "P" at max_tokens 16. Never give this model a tight cap.
⚠️ Player-name resolution: the SDS search is a SUBSTRING match, so
punctuated names never resolve — "aja" is not a substring of "A'ja Wilson".
Fixed by matching against the whole roster (players/all, 209 rows, cached) on
name-token prefixes, not flattened substrings: "aja" appears inside "Aziaha
James" and "Gabriela Jaquez" across word boundaries.
⚠️ Question words resolve as players. "how" prefixes "Howard", "is" prefixes "Isabelle" — a one-word prefix matched them and counted as a successful resolution, so the NL fallback never ran. STOPWORDS list in the suggest route.
⚠️ Low-count stats hijack any |t| ranking. Blocks/steals clear significance on tiny absolute differences and outrank points; usage_rate and true_shooting_pct clear readily and are context, not subjects. Both the chart and the mechanism restrict to points/rebounds/assists and only fall back outside that when none is measured.
DvP sign convention: (leagueMean - allowed) / sd, so POSITIVE =
suppresses. Only scoring/rebounds/threes have a league profile. The
note reports CONSISTENCY, not cause — scoring more against a suppressing
defence is flagged "against the matchup", which is the case a narrative skips.
All five phases shipped 2026-08-23/24. Only opponent DvP inside mechanism
remains unbuilt (the minutes-vs-rate split answered the question without it).
Chart lives on EVERY page type, not just the line page — that was the "I don't see any chart" report. Charts points unless another stat actually cleared significance (ranking by |t| put MINUTES on "Collier at home", which is correct arithmetic and not what anyone asked). Falls back to the split's own average when there is no baseline, or a player who started every game gets no chart at all.
Ask box suggests finished questions, not names. Prefix resolution longest- first stopping at intent words, so "young" and "jackie against las vegas" both resolve. A same-name match is a REFINEMENT (no href, fills the box preserving the typed question) — five Williamses must not cost you the rest of the sentence.
Descoped by decision: the 2019-20 backfill. Coverage begins 2021-06-30 and
every response carries a coverage.note saying so — that label is a
requirement, not a nicety, or career totals silently undercount.
Second pass (2026-08-24) — the five "fixed" items each had a hole
Every one had shipped and every one was still half-open. The pattern: I fixed the instance I reproduced, not the class.
⚠️ max_tokens 400 was ALREADY too low in production. The retry fired on the
first real query tested. Measured against the live API: cap 120 → finish=length
- empty string + 120 tokens burned; cap 400 →
finish=stop, 247 completion tokens — uncomfortably close to the ceiling. Now: log every failure, retry once at 1200 on an empty length-stop. Deadlines are measured too — a shared 6s aborted live questions mid-flight, so first call 8s, retry 10s. Genuinely unanswerable questions (comparisons: "who scores more jackie or aja") still time out and return an empty dropdown; that is the correct bound for a typeahead, and it is now logged rather than silent.
⚠️ STOPWORDS is a blocklist — the structural fix is the sentence guard. Past three words, a lone token must match a name token OUTRIGHT, not merely prefix one. Short queries stay prefix-matched (that is how people type: "aja", "flauj", "young"); a five-word question does not, because a three-letter prefix of a surname inside one is almost always an ordinary word. Kills the whole "how"→Howard family rather than the single word.
⚠️ The suggest box and the /ask page are two separate resolvers and they
drifted. Roster matching went into the box only, so /ask/aja-last-10 and
/ask/flaujae-last-10 404'd on names the dropdown resolved happily — the page's
surname fallback needs two words. Any name-resolution change must land in BOTH
api/analytics/suggest/route.ts and ask/[slug]/page.tsx. Also
describe(q, name, resolved) — pass resolved=true with a canonical name or
titleCase turns "A'ja Wilson" into "A'Ja Wilson".
Defence suppression: which stats can have a profile, and why the rest cannot.
Now scoring / rebounds / threes / assists (WNBA — computed in Go from
data already aggregated; NBA reads three precomputed columns from
nba.team_defense_profile and needs a migration for a fourth). The others are
not oversights:
- steals / blocks are recorded BY a defence, never allowed by one — an "opponent steals" aggregate measures how strip-prone the OTHER team's offence is. Publishing it as a defensive rating is a different number wearing the same name.
- turnovers belong to the defence but the sign inverts — forcing more is
better, so the shared allows-less-is-positive scale would print a
turnover-forcing defence as one that "concedes".
An absent note is now EMITTED with
available:falseand the specific reason. Omitting it made "no profile exists" and "the matchup is neutral" render as the same blank space; they are opposite conclusions. Frontend gives it a muted treatment (.is-unprofiled), never the red one.
⚠️ SDS route gotchas found the hard way: team defence is
GET /{league}/teams/defense (all teams) and /{league}/defense/team/{id} —
NOT /teams/{id}/defense. Analytics vs-team takes opponent_id, not
opponent_team_id; the wrong name returns {"error": ...}, so a poll loop on
it never terminates.
Chart hover reads BOTH series. The bar had a tooltip and the rolling mean had nothing — the trend was the one number you could not read off the chart at a point. Bars and hover are now separate render passes so the readout can sit above the line (a dot marks the rolling mean at the hovered game). Tooltip width is derived from content: labels are props, so no constant fits both a line page ("26 pts · over") and a split page ("32 pts · above baseline").
StatMuse-parity pass (2026-08-24)
Compared against the live StatMuse page, not from memory. Their loop is ask → answer → ask again without moving; ours had no input on the answer page at all. Shipped: sticky ask bar on every answer, the answer sentence promoted to hero (the question is already in the URL/tab/box), player headshot, contextual "also ask", Total row beside Average, and the verdict block leading with what cleared noise while the rest collapses behind a summary that names what it hides. Deliberately NOT copied: their news section, league leaderboards, team-stat blocks — SEO padding.
⚠️ cdn.wnba.com refuses to be hotlinked, and fails SILENTLY. It answers
curl and node fetch with a real PNG (200) and fails the identical URL from a
browser with ERR_HTTP2_PROTOCOL_ERROR — the <img> ends up complete: true
with naturalWidth: 0. No broken-image icon, no visible console error: just a
blank circle that reads as a design choice. Always assert naturalWidth > 0,
never complete. Fix = proxy at /api/headshot/{playerId} (server fetch,
our cache, digits-only guard on the id since it is interpolated into an
outbound URL). Unknown ids get a 12KB silhouette with a 200, so there is no
404 case.
⚠️ --topbar-h: 65px (.topbar-in 64px + 1px border). .topbar is
sticky; top:0; z-index:50, so anything else that sticks MUST use
top: var(--topbar-h) or it slides underneath and vanishes — the first version
did exactly that and looked fine until scrolled.
TS types for LineEnvelope were narrower than the payload — totals and
rows[].opponent_team_id have always been sent and were simply undeclared.
Worth suspecting whenever a field "doesn't exist" on an ask type.
Related questions are derived, not listed: line at round(avg) − 0.5,
opponents actually faced (≥3 meetings, from the rows already on screen), an
active team-mate (is_active !== false — otherwise the split is empty),
plus unused splits. The current slug is filtered out. Suggestions are deduped
by href because the archetypes are appended unconditionally.
Prop coverage (2026-08-24)
sportlib defines twelve WNBA props — 7 singles + 5 combos (pra/pr/pa/
ra/stocks). Only the singles worked; the combos did not exist. All twelve
now resolve, plus the box-score fields the query already selected (3PA, FTM/FTA,
FGM/FGA, OREB/DREB) which no book prices but are fair threshold questions.
⚠️ The ask grammar had FOUR allowlists that could drift, and two had:
STAT_ALIASES (slug), STAT_WORDS (box), lineStats (SDS), deltaStats (SDS
splits). Turnovers was in the slug parser and NOT the box — askable by URL,
unsuggestable by typing. Now STAT_WORDS lives in ask-slug.ts beside
STAT_ALIASES, labels come from one exported statLabel(), and
src/lib/ask-slug.test.ts asserts every word the box accepts builds a slug the
parser accepts, plus that every sportlib prop is reachable.
⚠️ The stat token regex was [a-z_]+ — it cannot match a token starting
with a digit, so 3pm/3pa were unreachable and the page rendered the raw
slug title-cased ("Aja Wilson Over 1 5 3pm"). Now [a-z0-9_]+.
⚠️ Combo SQL must COALESCE each operand. (a + b) is NULL if either side
is, and the query's outer COALESCE then turns that into a ZERO — a 30-point
game recorded as nothing. Test counts guards vs operands. (NBA's
stats_advanced.go combo map has the un-guarded form.)
Two testing traps that cost time here:
pkill -f "next start"does NOT kill the Next server; it keeps port 3000 and the newnpm startdies with EADDRINUSE while you test the OLD build and believe the code is broken. Uselsof -ti:3000 | xargs kill -9, and check the log for EADDRINUSE.- After deploying SDS,
rm -rf .next/cache/fetch-cache— the 6h fetch cache serves pre-deploy responses across server restarts.