Repository navigation
association 2.1.0
The largest release so far on the query side. It adds eight new kinds of
question, and most existing ones can now be narrowed the way real questions
narrow them. The headline changes are below; each is detailed in its entry
further down.
- New question types.
- A team's numbers, team rankings, and a team's outlook (BPI, playoff and
title odds). - A player's splits: home and away, starter and bench, wins and losses, and
by month. - A team's record, or a player's line, with and without a teammate.
- A team's record when a player reaches a number.
- The games two players played against each other.
- Winning and losing streaks.
- A team's numbers, team rankings, and a team's outlook (BPI, playoff and
- Questions narrowed the way people ask them. A player's games and
averages against one team, at home or away, over a career, or without a
teammate. Career leaderboards for counting stats, averages and shooting
percentages (usage, true shooting, eFG% and NetPoints have no career
ranking). Career highs and career counts. - Names resolved from what the question says. A name the router invented,
dropped or completed is checked against the question. An ambiguous surname is
narrowed to the players who played in the season asked about, and every match
is considered, not just the first ten alphabetically. - Answers that were wrong, now right.
- True shooting and eFG% leaders qualify on attempts.
- Shot distances are measured from the rim, not from a point 5.25 feet
away from it. - A postseason before 1994 is found by the year it was played.
- ESPN's copied postseason lines are dropped.
- Every game is dated by the day it was played.
- Per-game NetPoints rows land on the right game.
- Narrow questions no longer get broader answers. Some filters no template
can apply yet: a playoff round, a season range, "under N", back-to-backs. A
question with one of these now goes to the slower agent instead of being
answered by a template about something else. A season the warehouse cannot
reach is refused, with the reason. - Four holes an outside review found are closed. Among them, the agent's
SQL connection can no longer read the disk, and the web server no longer
shares one conversation between every browser.
Known gaps and data faults are listed, ranked, in ISSUES.md. Among them:
every Chicago and New Orleans game from 2013 to 2018 but two has an empty box
score, and the 2000 and 2001 playoffs stop before the Finals.
-
A year before 1990 is read from the question. The question text and the
router'sseasonslot both discarded any year below 1990, a floor that
predatescoverage.py. So "who led the league in scoring in 1980" was
answered with the current season's leaders, and "Bulls record in 1985" with
their current record. The floor is now the league's first season (1947), in
one constant instead of two. A year below a table's first season reaches the
coverage check and is refused, with the reason. -
--include-net-points-dailysays what it fetches. Its help now names the
per-game play-type table it also writes, and says it makes two requests per
date, not one. -
An ambiguous name is narrowed to the season being asked about before
anybody is asked which one was meant. "How did curry do against the
celtics this year" answered'Curry' matches more than one player - did you mean Dell Curry, Eddy Curry, JamesOn Curry, Michael Curry or Seth Curry (1 others also match)?. The warehouse holds six Currys, the list was sorted by
name and the sentence names five, so the one it left out was Stephen - and
four of the five it did name never played in 2026. It now asksdid you mean Seth Curry or Stephen Curry?, the two who did.Every template that resolves a player (
player_stat,player_compare,
player_history,player_netpoints,game_log,shot_distance,
single_game_high,threshold_count,player_splits,with_without,
record_when,player_matchup,streaks) now narrows the candidates to
those with a row in the table its answer is read from, for the season or
span it will answer about - the rule charts already followed. A career has
every season in scope, so it eliminates nobody who ever played, but it names
whoever plays now first. It eliminates and never chooses: one survivor
is the answer because nobody else has a row to answer from, and two or more
are asked about, as Seth and Stephen still are. Three edges are deliberate.
A name matched in full is not narrowed, so "Gary Payton" in 2026 is still
told the father has no numbers rather than given his son's line. When
narrowing eliminates everybody, the question is asked exactly as before. And
player_historynarrows over every season up to the one it is anchored at,
since a history through 2026 still has Dell Curry's seasons to answer with.
player_netpointskeeps a candidate with a row in either of its two tables,
which disagree about who they hold (63 player-seasons are in the totals
only, 8 in the fingerprint only)._resolved_playernow requires the table,
so a template cannot resolve a name without saying where its answer comes
from - mypy refuses it.The cap was the other half.
find_playersreturns the first ten matches
alphabetically, and 71 name words match more than ten players ("Williams"
matches 62), so narrowing that page was choosing by alphabet - which the
chart path had been doing. In 2026's shot charts, 23 names drew one player
while others matching the name also had shots on a later page: "Davis" drew
Anthony Davis with JD Davison, Nigel Hayes-Davis and Trayce Jackson-Davis
eligible, and in the fingerprints "Brown" drew Bruce Brown with two more
Browns who had one. Narrowing now reads every match (find_players(..., limit=None)), on both paths and for the teammate a "without" names - which
was narrowed to his teammates over the same first page - and those names
ask. A clarification also
names every candidate from the season asked about rather than counting any
away (Ambiguous.active, passed toclarification) - 2026 has 14 players
surnamed Williams, and all of them are named - and a history names whoever
reached its last season first, so "Curry's scoring over the last 4 seasons"
lists Seth and Stephen ahead of Dell. What the "others also match" count
covers is now only ever players who could not be the answer, and it says "1
other" when there is one.Measured against the warehouse, over 6,325 player names, name words and
nicknames under nine template scopes: every one of the 5,349 that resolve to
a single player today resolves to the same player. Of the 976 that ask, for
2026 season lines 276 now resolve to the one candidate with a row, 191 ask
about fewer players, 484 have nobody in the season and ask as before, and 25
ask about as many or more - players the first page had hidden. No
clarification in any scope counts away a player from the season asked
about. Over thecheck_routing.pycorpus, routed once and answered before
and after through the agent's own slot pipeline, one answer changed - "What
was Curry's first game of the season?" still asks, now between Seth and
Stephen - and every name that resolved still does. Charts do change names
that resolve today, and on purpose: besides the 23 above, 8 names in 2026's
shot charts drew a best match with no shots while several matches had them
("Bob", "Marcus", "Scott") and now ask, and 4 draw the one match with data
instead of a best match without.The extra work runs only for a name that matches more than one player: 2-9ms
more for one season ("Curry" to "Williams") and up to 16ms for a history
span, warm, against a question that spends about 3s in the router. -
One player "compared" with a team is answered as his games against it.
"compare curry vs the celtics this season" arrived asplayer_comparewith
the Celtics as the second "player". Once they became theopponent,
player_compare- which reads season lines only - refused it, and the
question fell through to the agent whileplayer_statanswers it exactly. It
goes toplayer_statnow; two players and a team stay a comparison, and
still refuse the opponent rather than comparing whole seasons. Checked by hand
against the box scores: 22.5 / 5.0 / 6.5 over Curry's 2 games against Boston
in 2024-25, and for 2025-26 "none of them", which is right - he missed both,
inside a gap in his log from 31 January to 6 April.- A team in
playersis recognized by its LAST word being a nickname. The
player_matchupreroute matched one anywhere in the name, which sent
"magic johnson vs larry bird head to head" toplayer_stat- which refused
it - instead of the head-to-head template. All 30 team names end in a
nickname, and none of the warehouse's 3,080 player names does.
- A team in
-
The team a player's question plays against is the opponent, whichever
slot the router files it in. "compare curry and lebron vs the celtics" came
back withteam='Boston Celtics'beside the two players.scope_from_question
left it there - a team already inteamis howhead_to_headcarries its
own side - so noopponentwas set,check_scopehad nothing to refuse, and
nothing read the slot. With "steph curry" the answer was their whole 2025-26
lines, Curry's 43 games beside LeBron's 60, when Curry played none of them
against Boston. Where the template reads a player and one is present, that
team now moves toopponent, soplayer_statandgame_loganswer over
those games and every other template refuses.head_to_headand the team
templates are untouched. -
threshold_countanswers a surname only one player with games that season
has. "How many 30-point games did Curry have last season" asked which
Curry was meant even when only one of them played; the name is now narrowed
to players with a box score in the season asked about, the way the charts
already narrowed it, and answered when exactly one is left. Two who both
played are still asked about, even when only one of them reached the
threshold - letting the count choose the player would be the prominence
tiebreak again. The narrowing reads every match rather thanfind_players'
first ten, as the entry above describes: narrowing only that page would
have answered "Williams" in 2023 with Alondes Williams, where 14 players
matching the name played. -
True shooting and effective FG% leaderboards qualify on attempts, not
games. Twenty games was the whole qualifier, so "best true shooting
percentage last season" was led by Kai Jones at .804 on 109 shots, with
Patrick Baldwin Jr.'s 35 third.ts_pctnow needs 550 true-shooting
attempts (FGA + 0.44 FTA) andefg_pct480 field-goal attempts, floors
checked against StatMuse's published 2025 and 2026 top 15s: the top ten now
match in order for TS% in both seasons and for eFG% in 2025. 2026's eFG%
differs by the rule itself - StatMuse qualifies on 300 made field goals,
which shuts out Sam Merrill and Isaiah Joe, who have the attempts. The
reasoning, and the band each floor was picked from, is above the two
entries inquery/metrics.py.Their postseason floors are 67 and 59, the same rate over 10 games instead
of 82, where 20 games had left only the conference finalists and dropped
Jarrett Allen's .792 over nine games for Isaiah Joe's .676.
get_leaderboardreturnsmin_sample_columnbesidemin_sample_applied,
since 20 is games for one metric and 550 is attempts for another.player_season_advanced_statsgainsfield_goals_attemptedand
true_shooting_attempts, summed from the same box scores as the
percentages. An existing warehouse needs anassociation data load(any
--tablessubset: the views are rebuilt on every load) before the view has
them; until then these two leaderboards fall through to the agent. -
Slots the model put in the wrong place are read from the question. The
last run over the StatMuse questions found one more wrong answer and a set of
refusals, each from a mis-filled slot:- "kevin durant true shooting percentage career" arrived as
stat='threePointFieldGoalPct'and was answered with his 3-point
percentage. True shooting, eFG% and usage named in a question are now read
from it, andplayer_stat, which holds none of them, refuses. - An
orderand alimitof one the question never asked for ("evan mobley
avg against bucks", "Celtics record without Tatum") are dropped rather than
refused. - "luka ft log" goes to
game_log; aplayer_matchupwhose "players"
include a team goes to that player's games against it; aplayer_history
with no stat named, or asked against a team, becomes theplayer_statline. - "worst record" ranks the league, "all-NBA" and "worst" are no longer read
as teams, and a team metric named in the question ("lowest defensive
rating") wins over the one the model invented. head_to_headtakes anopponentas its second team, and keeps reading
names until two different teams resolve.- A player's name in the
teamslot becomes the subject ("Podziemski game
log without curry").
- "kevin durant true shooting percentage career" arrived as
-
Three smaller answers that said something false.
- The web page's game log showed "L" for a game with no recorded winner (134
since 1994). It now shows a dash; the template already sentwon: null. - A single game's NetPoints printed its UTC date, a day late for any evening
tip. So did a single-game fingerprint's title, a shot distance's "in his
most recent game" note and a team's quarter-by-quarter list. Each now
prints the day the game was played, from one shared
season.eastern_date- the query package had held two copies of it. - The fall-through agent's knowledge base still told it the hoop was at
(25, 5.25) and that free throws have no coordinates. Both are wrong: the
rim is at (25, 0), and free throws carry a position under the rim through
2018. So its hand-written distance SQL was three feet short. The entry now
matchescourt.py, at no extra length against the preamble's token budget.
- The web page's game log showed "L" for a game with no recorded winner (134
-
Faults found while building the templates below, fixed where the data is
read. Every agent that built a template hit one of these, and each one
produced a wrong answer that nothing flagged.- Playoffs before 1993-94 were a year off. ESPN files every season before
1993-94 under the year it started. The postseason games labeled 1990 end
with the 1991 Finals, so "the 1991 playoffs" returned 1992's.
head_to_head,team_quarter_pointsand a team'sgame_lognow select a
postseason by the calendar year it was played in, asteam_recordalready
did, and exclude the phantom 1993 label. The 1987-88 playoffs are not in
ESPN's archive at all, so the postseason floor forgamesand
team_box_statsis 1989, not 1988, and that refusal now gives its own
reason instead of the regular season's.scripts/check_coverage.pycounts
those postseasons the same way. - Copied postseasons. ESPN's career endpoint files some regular seasons
a second time as the postseason; Eddy Curry had 527 "playoff games".
player_season_stats_dedupednow drops a postseason line that claims more
than 28 games or repeats that season's regular-season games and points.
That removes 340 of 7,845 rows and keeps every real run checked. - 2003 shots are partial. 2003's play-by-play is complete, but only 986
of its 1,190 games have located shots, soshot_chartcaveats 2003 as
well as 2002. - Four more narrowing slots the router reads from the question text.
None is honored yet, so a question carryingbeloworsituationnow
falls through to the agent where it used to be answered as a different
question:below("games with under 14 FTA"): threshold_count answered 14 or more,
the inverse.situation(back-to-backs, overtime, a month, a conference, the All-Star
break):team_recordanswered each with the whole season's record.- "fastest" and "slowest" now rank the right end of a team leaderboard.
- A team line or team streak that names no stat no longer carries the one
the model filled in.
- "last 8 games vs pistons" with no season named now reaches back across
seasons for the last eight meetings. It used to stop at the current
season's four. - Team nicknames ("Sixers", "Cavs", "Mavs") resolve to teams instead of
falling through.
- Playoffs before 1993-94 were a year off. ESPN files every season before
-
A player's games against one team, his recent form and his career are
answered now, where the entry below made them refuse.game_logand
player_stathonoropponent,venue,spanandwithout, and
player_historyhonorsspan. Run against the real warehouse:Asked Now "jaylen brown last 8 games vs pistons" his 4 games against Detroit this season, saying there are only 4; with "career", his last 8 meetings (2024-2026) "evan mobley avg against bucks" 21.3 / 9.7 / 4.7 over the 3 games he played against them - a fourth was a DNP "Podziemski game log without curry" asks which Curry, since Seth joined the Warriors in December; with Stephen named, the 39 games Stephen missed "Jokic career averages" 22.2 / 11.1 / 7.5 over 810 games, 2016-2026 "What is Jokic's 3 point percentage this season" 38.0% (112 of 295) - it used to fall through to the agent - A player's log lists the games he played, adds the columns a named stat
needs (FTM and FTA for "luka ft log", FGM and FGA for "kyle kuzma last 7
games fgm") and ends with per-game averages over exactly the rows listed. A
real stat it has no column for is refused rather than dropped, and so is a
threshold.player_statrefuses alimit: an average over the last N
games is that log's average row, not the season line. withoutmeans the teammate did not play - a did-not-play entry or no row
at all, since Stephen Curry's 2026 is 43 rows and none of them is a DNP -
while he was on the same team. There is no roster table, so that is read off
his own rows: the season's start counts if he ended the previous one on that
team, the end counts unless he was traded away, and a mid-season arrival
counts from his first game. LeBron James's first 2026 row is 2025-11-19, and
Austin Reaves's 16 games without him include the 11 before it.- An ambiguous
withoutname is narrowed to the players who were actually
teammates - elimination, asnarrow_to_availabledoes - and asks only
between those. - A career is summed from
player_season_stats_dedupedas totals over games,
never an average of averages. Michael Jordan comes back with 32,292 points in
1,072 games and 5,987 in 179 playoff games, and Kobe Bryant with 33,643 in
1,346: the real totals. - Narrowed questions are answered from box scores, so they carry the
box-score floor: Jordan's 1990 season line still answers, his 1990 line
against the Knicks refuses, and a career against an opponent says his box
scores begin in 1993-94. - An empty answer names what is actually missing: "played 65 games in the
2026 regular season, none of them vs the Denver Nuggets" rather than
"no games found", and "was not his teammate" rather than "did not miss
any". - Dates are the Eastern date a game was played on.
datematched the UTC day
before, so "Jaylen Brown's game on 2026-01-19" found nothing, and asking
for 2026-01-20 found the 19th's game. - A team's log listed every 1993-94 game twice (164 rows for the Celtics'
82). It joinedgameson event_id alone, and the phantom 1993 season shares
those ids; it keys on season too now. A game with no recorded winner (134
since 1994, mostly the 1999 lockout season) shows "?" and is left out of the
record, where it used to be counted as a loss.
- A player's log lists the games he played, adds the columns a named stat
-
Box scores from 2013 to 2018 are missing about an eighth of their points.
About 13% of team-games in those seasons list every player as having played,
with no minutes and every stat zero, so summing a season's box scores gives
87% of ESPN's season totals (2017: 225,786 points against 258,855). The
box-score answers above leave those lines out of every average and say how
many they left out.threshold_countand anything else that sums
player_box_statsover those seasons still counts them as games of zeros. -
Career leaderboards, career highs and career counts, each saying whose
careers they cover.leaderboard,single_game_highandthreshold_count
now honorspan"career" instead of refusing it:- "career points leaders" ranks career totals: LeBron James, 43,440, the sum
of his season rows. A bare stat name reads as a total in a career and per
game in a season, as it always did. A career average (avg_points) is
games-weighted and needs 400 games (50 in the postseason). - A career high or count reads every box score since 1993-94 for one player
or for the league.
None of these is an all-time answer, and each says so. Players are discovered
from box scores, which begin in 1993-94. So the pool is every career that
reached that season, counted in full, and nobody whose career ended before it.
Kareem Abdul-Jabbar is not in the warehouse. A player whose career began
earlier gets that sentence before the number. Michael Jordan's highest game
in these box scores is 55, and the answer says his career began in 1984-85
first.A career with a year named is refused rather than read, because "in 2024",
"since 2015" and "through 2010" all arrive as the same two slots. A franchise
career list is also refused, and so is a career ranking by usage, true
shooting or NetPoints. - "career points leaders" ranks career totals: LeBron James, 43,440, the sum
-
Every stat name the router is taught now ranks by something. Turnovers,
minutes, fouls, the three kinds of make, and FG%, 3P% and FT% all fell through
to the agent fromleaderboard. There are 16 new metrics (BOX_SCORE_METRIC_NAMES):- season totals;
- per-game rates, which need 20 games (5 in the postseason);
- shooting percentages, qualified on attempts. The minimums are 400 FGA, 200
3PA and 125 FTA a season, and 2,000 / 1,000 / 600 over a career.
The qualifier is named in the answer. A percentage shows its makes and
attempts ("47.8% (117 of 245)").rate"total" asks for a season total
instead of the per-game default. The new metrics stay out of the agent's tool
description, which has about 120 tokens of headroom; the agent reaches them by
name. -
Four data faults now handled where these answers read the data.
- Copied postseasons. 437 postseason rows in
player_season_statscopy
the same player's regular season. Eddy Curry never played a playoff game
and had 527 "playoff games". His 2006-07 copy (1,576 points) topped that
postseason's scoring total, and a career playoff list put him second.
Leaderboards now drop these copies. - Empty combined rows. A traded player's combined row can be empty, as
Moses Malone's 1976-77 row is, so career sums use the per-team rows. - Double-counted 1993-94. A career over box scores starts at 1994. Starting
at the 1993 phantom counts every 1993-94 game twice. - Empty box scores. From 2012-13 through 2017-18, 161-166 games a season
have box scores with every line blank. A count or high that touches them
now says how many it could not see.
- Copied postseasons. 437 postseason rows in
-
single_game_highdates a game by the day it was played. It printed the
UTC date, which is a day late for any tip after 7pm Eastern. LeBron James's
61 was on 3 March 2014, not the 4th. -
threshold_countresolves a named player to one person. It matched every
name containing the words, so "Curry" counted Seth's games and Stephen's
together. -
Five templates for questions about games under a condition. The intents
were already in the router's schema with nothing to answer them, so every
one of these fell through to the agent. They are shapes S4, S6, S10, S12 and
S13 of the StatMuse research, and each answers both halves of its comparison
side by side:Intent Answers Example player_splitsper-game averages home/away, starting/bench, in wins/losses, or by month (all four when no split is named); a team's too "Nikola Jokic home and away splits" with_withouta team's record in the games a teammate played vs missed, and a player's averages in each "Celtics record without Tatum" record_whena team's record when a player reached a stat threshold vs when he fell short "Sixers record when Embiid scores 30" player_matchuptwo players' meetings on opposite teams: record, averages, the latest games "Andre Drummond vs Al Horford game log" streaka team's longest winning or losing run, a player's longest run of games at a threshold, or the league's "most 40 point games in a row" The SQL is in the new
query/conditions.py, and its module docstring records
four facts measured against the warehouse that decide what the answers mean:- "Played" is a box-score row with minutes. A missed game is a DNP row,
no row at all, or (2006-2012) NULL minutes and zeros beside teammates who
played. - A missing box score is unknown, not a missed game. ESPN lacks about one
box score in eight from 2013 to 2018, every player listed with NULL
minutes. LeBron James played all 82 games of 2017-18 and has six of these,
so read as absences they would have been "Cavaliers without LeBron" games.
They are left out of both sides, end a streak, and are counted in the answer. - Days are US Eastern, the same fixed shift the NetPoints matching uses,
so a 7:30pm tip is not filed under the next day's month. - 0-0 placeholders with no winner (1999-2002) are not losses.
"Without" means inside the teammate's time on that team: the stint of box
scores from his first appearance there to his last, broken by a trade or a
season with no box score at all. StatMuse's "Nets record without KD" counts
decades of Nets games before he arrived, and that is the answer this avoids.
Cross-checked against standings: every split sums back to the team's
record (Celtics 2026, 43-23 without Tatum and 13-3 with him, is their 56-26).One data fact found on the way, and not fixed here: before 1993-94 the
warehouse files a season under the year it began, so its "1990" postseason
is the 1991 playoffs. These templates refuse a pre-1994 postseason season
rather than answer it under the wrong year's label. - "Played" is a box-score row with minutes. A missed game is a DNP row,
-
Real questions were being answered about something else, and now refuse
instead. 99 questions were run through the fast path to the final answer:
the routing corpus plus 45 real StatMuse queries. Nine of the StatMuse queries
came back fast, fluent and wrong:Asked Answered "jaylen brown last 8 games vs pistons" the Celtics' last eight games "Luka Doncic game log vs Lakers" the Lakers' log "Knicks home record" their overall 53-29 "career points leaders" this season's scoring leaders "which team scores the most points per game" the players' leaders "Podziemski game log without curry" his whole log "rj barrett 4th qtr log" his whole last game Two more were answered for the postseason without mentioning it.
Every one of these is the router answering a narrower question's slots with a
broader template, so every fix is the same move this project already makes for
orderanddate. The question text is read for what it narrows to, and a
template that cannot honor that refuses (check_scope) instead of answering
about everything:opponent, the team after "vs"/"against", via
entities.scope_from_question. That function also undoes the router's two
ways of losing the player: putting his own team inteam, or putting the
opponent there.venue(home/away).span("career", "all-time"; a "career high this season" is still that
season's best).without(a teammate).
None of the four is in
ROUTER_SCHEMA, so the model's grammar did not change.
Two more slots are read the same way:season_typenow comes from the question, never the model. It was wrong in
both directions: "Sga record 36 plus points" and "lebron vs kawhi 2015" came
back as playoff questions, and "tatum stats in the 2024 finals" as a
regular-season one.- "qtr", "q4" and "first half" now reach the agent like "4th quarter" did.
A team ranking asked as a player ranking is sent to
team_leaderboard. -
Team questions have templates: records, season numbers, rankings and
ESPN's power index. "Knicks home record" was refused and "which team scores
the most points per game" fell through, andteam_season_statsand
team_power_indexwere read by no template at all. Most of the work was
finding out which of their numbers can be believed.team_recordhonorsvenue,opponentandspan, and answers a
postseason instead of refusing one. A season's record and its home/road
split are the standings' own; the "Home"/"Road" strings agree with a tally
ofgamesfor every team-season from 1994 to 2026 once each era's
neutral-site rule is applied (through 2024 a neutral-site game counts for
its designated home team, from 2025 for neither). Anything else is tallied
fromgames, which has to be cleaned first: 0-0 phantoms with no winner
(50 in 1999, 82 in 2000), a second event id for a game already listed, 1993
under two labels, and the NBA Cup final, a regular-season game no standings
count - left out of the record and mentioned beside it. Cleaned, the tally
matches standings for every team-season from 1994 to 2026. Postseasons are
found by the year they were played, becausegameslabels every one before
1994 by the year its season started: the games labeled 1990 end with the
1991 Finals. Where the game list and a team's own totals disagree - the
2000 and 2001 postseasons hold 15 of the Lakers' 23 games and 10 of their
16 - the answer says so. A conference is refused by name, since nothing in
the warehouse says which teams are in one.team_statandteam_leaderboardread a new whitelist of team metrics,
query.team_metrics. There is no rating column, so offensive, defensive
and net rating are derived, and neither input could be taken as stored.
ESPN'spossessionscounts every turnover twice before 2013 (114 a game in
1994, against a real ~96), so possessions are recomputed as
FGA - OREB + TOV + 0.44 x FTA with the turnover column that is right in each
era; from 2009 that reproduces ESPN's own figure exactly. Points allowed are
summed fromgamesand used only where that game count equals the team's
own - the 2000 regular season fails it for 28 of 29 teams, and is refused
rather than rated. Checked: the 2026 Knicks' defensive rating is 110.47,
100 x standings' 9,030 points allowed over ESPN's 8,173.92 possessions.team_outlookreads ESPN's BPI, which is sparse - 2026 has a play-in
snapshot of 13 teams and a postseason one of 12, and no regular-season one -
so every answer names its snapshot, date and size, and a team missing from
it is told which snapshots exist rather than that there is no data. Where a
team stands is counted within the snapshot, because ESPN's rank columns hold
values like 26,058 before 2022.
A second pass over the ten StatMuse queries the fast path still answered
found four more cases of the same shape. None is answered about something
else any more:- A playoff round. "tatum stats in the 2024 finals" was answered with his
whole postseason: 19 games, where the Finals were five. Nothing in the
warehouse records a round, so no template can honor one, and the question
falls through to the agent (check_scope). - A split asked of a template that is not about splits. "Joe Ingles stats
when starting vs coming off the bench" came back as his season minutes. It
falls through to the agent too. - A range of seasons. "most 3 pointers made since 2020" became one season
and a threshold of 0, and was answered as "the most games with 0+
3-pointers". It falls through to the agent, and so does any zero
threshold, which counts every game. - A record asked as a count. "Sixers record when Embiid scores 30" was
answered with the league's 30-point games. It now goes torecord_when, and
the question's one named player is restored wherever that template needs
one.
-
The router knows the new question shapes, at the smallest prompt that
kept every existing question in place.ROUTER_PROMPTgained the eight new
intents and five worked examples: a game log against one opponent, splits,
with/without a teammate, two players' matchup, and a streak.The first version of this, with a longer line per intent and twelve examples,
was measured and cut back. It added about 720 tokens, raised the router's
mean latency from 2.3 to 3.4 seconds, and broke three questions that had
routed correctly on every earlier run, all of them fingerprint or shot-chart
questions about Curry. That is the prompt-length sensitivity AGENTS.md
describes. The shipped version adds about 300 tokens.Four facts the question states outright are now read from its text in
route(), which costs no tokens and cannot move another question's slots:- A fingerprint is only a fingerprint when the question names one.
"Plot Curry's threes from last season" routed tofingerprintunder both
prompt revisions. - A per-game threshold the model left out ("scores 30 points", "36 plus
points") is read from the question; "3 point" is a shot type, not a
threshold of three. - A "career high" asked with
player_statgoes tosingle_game_high. - A
threshold_countstill without a threshold is a season ranking and goes
toleaderboard. "who has the most threes" arrived with none and fell
through.
check_routing.pynow appliesscope_from_questionthe wayagent.py
does, so it asserts on the slots a template actually sees, and it gained
17 cases from real StatMuse queries. On the shipped prompt 70 of its 71
cases passed; the 71st asserted the literal team string the model chose
("Lakers" against "Los Angeles Lakers", the same team), which AGENTS.md
says a case must not do, and now asserts only what changes the answer. - A fingerprint is only a fingerprint when the question names one.
-
1993-94 player games were listed four times.
player_game_logjoined
gamesandplayer_advanced_statsonevent_idalone, and ESPN files the
1993-94 season's 1,185 events under both 1993 and 1994 (the phantom in
coverage.py). So each of those player-games matched two game rows and two
advanced-stat rows: 112,780 rows for 28,195 games. A game log or single-game
high over those seasons repeated every row. The joins are now keyed on
seasontoo. Measured first:games.seasonequalsplayer_box_stats.season
for every row, so the extra key drops nothing. -
Shot distances and shot charts were measured from a rim 5.25 feet from
where the data puts it, and every "threes" or "twos" question read only the
shots ESPN happened to label. Two bugs in one pipeline, both of the kind
this project keeps producing: fast, fluent, and about a different question.court.pyput the rim at(25, 5.25), on the assumption that
coordinate_ystarts at the baseline. It starts at the rim. Most shot
descriptions carry their own distance ("makes 26-foot three point jumper"),
and from 2002 through 2012 that distance equalsround(hypot(x - 25, y))for
all 1.3 million of them; later seasons agree to within a foot on 99.8%. So
every distance came out short - Stephen Curry's 2026 threes averaged 23.6
feet, inside a 23.75-foot line, where from the rim they average 27.6 - and
the chart drew its court around the same wrong point, putting every shot
5.25 feet nearer the baseline than it was taken and a typical three on or
inside the arc.HOOP_Yis now 0 (the baseline sits at -5.25), the court is
drawn in the data's own frame, and the geometry the rest of the pipeline
needs lives beside it:SHOT_DISTANCE_SQL,BEYOND_THE_ARC_SQLand
HAS_POSITION_SQL.One thing that looks like a counterexample and is not: from 2023-11-02 the
descriptions run 0.64 feet short of the rim, as if it had moved a foot. The
coordinates did not - the three-point line separates ESPN's own labels
exactly as well after that date as before it (99.93%), and worse from a rim
a foot out (99.72%). ESPN changed its prose, not its frame.Separately,
points_attempted = 0means unlabeled, not zero points, and
bothshot_distanceand the shot chart filtered on it as a value. It is 0
for every shot of 2002 and 2003, 96% of 2022's, and 23-28% of the field
goals of each season from 2004 to 2012 - every one of those a miss. "Curry's
threes in 2022" charted 38 of his 751 attempts, all misses; his 2010 twos
were 501 attempts at 72.3% instead of 763 at 47.4%; Kobe Bryant's 2003 threes
were "No 3-point shots".shotchart.SHOT_VALUE_SQLnow derives a value
where ESPN left none: the label, elseshot_typefor a free throw, else the
description where it says "three point" or "two point", else - through
2012, whose descriptions name every three - a two, else the shot's position
against the line. Counted against the box score's three-point attempts it
matches in 99.6-100% of player-games in every season from 2003 on, and 99.3%
in 2022. 2002 does not get there (99.05%, with 1.6% of its threes unnamed and
a third of its unlabeled shots undescribed), so a two- or three-point
question about 2002 is refused with that reason; 2003 and 2022, which rest
mostly on the derivation, answer with a note saying so.Also: from 2002 to 2018 every free throw carries a position under the rim,
so "has coordinates" never excluded them. An unfiltered chart drew them as
shots, and Curry's 2010 shot distance averaged in all 200 of his free throws
among 1,343 "attempts" (1,143 now). They are excluded by value.(0, 0), a
point on the sideline that 2002 uses for 7,109 shots, counts as no position.
A free-throw chart is refused rather than drawn as a dot. The example chart
in the README anddocs/usage.rstis redrawn from the same game. -
--workersnow reaches the per-game NetPoints fetch. It was the last
serial loop in the pipeline, and it is S3 round trips end to end: measured
on the tail of the re-derivation below, 12 dates a second through the pool
against 0.3 one at a time.--rate-limitwas never the lever here - it
bounds what ESPN sees and these requests do not go to ESPN. The three
lookups the workers share are built before the pool and never written to
afterwards, the writes already go through_write_rowsunder_state_lock,
and the daily client's lazy Cognito exchange is now built under a lock of
its own, so eight workers do not each run their own. -
Per-game NetPoints rows were landing on the wrong game, and sometimes on
the same one twice. NetPoints names each daily file for the US Eastern
date the games were played on and publishes no ESPN id, so the pull
recovered the game by matching(team, date)against ESPN's owngames-
whosedateis a UTC tip timestamp, a day ahead for anything after 7pm
Eastern. That left a choice betweendate + 1anddate, and both
orderings are wrong for some real schedule:date-first steals a
back-to-back's first night, anddate + 1-first - what shipped - steals the
next night, which then claims the same game again out of its own file.
Season 2026'snet_points_player_gameheld 611 duplicated
(event_id, athlete_id)pairs, every one disagreeing with its twin, and
net_points_player_game_fingerprintinherited 17,630 duplicated triples.
Nothing looked wrong: the counts were plausible and every event_id was a
real game the player really played in.The rule now reads the game's own Eastern date off the timestamp instead of
guessing which side of midnight UTC it fell on -NetPointsGameIndex, one
exact lookup, since a team plays at most one game per Eastern date. A fixed
five-hour shift rather than a real time zone: EST and EDT disagree about a
tip's date only in the midnight-to-1am Eastern hour, which no NBA game
starts in, and a fixed offset needs no tz database on the machine running
the pull. A date holding two of one team's games now resolves to neither
rather than to whichever row was read last - a team cannot play twice in a
day, so that is ESPN's clock being wrong, and it had been silently shadowing
126 keys. The old UTC window survives as a fallback for the four games in
late February 2020 whose stored tip time is hours from when they were played
(Detroit at Portland, a 6pm Pacific tip, is recorded as2020-02-24T12:00Z),
where the date is right even though the time is not.Verified against the source's own box score, which is the check that settles
it: the daily file carriesptsbeside the NetPoints values and the parser
drops it, so it is free, independent evidence of which game a row belongs
to. NetPoints' 2025-10-25 file gives Ryan Kalkbrenner 14 points and its
2025-10-26 file gives him 4 - the old rule sent both rows to the game where
he scored 4.scripts/check_net_points_games.pyruns that over a season,
and the query side'smax(t_poss)subquery, which existed only to pick
deterministically between disagreeing twins, is gone. -
A fingerprint for a single game. "Show me a fingerprint for steph curry's
last game in 2026" now draws that game. The play-type split does exist per
game - it is in a second file the pull never read, and the claim in the entry
below that it was not published was wrong. ESPN Analytics puts two objects on
each date:NBA/netpts/<season>/<date>.json, which this project already
fetched, andNBA/netpts/<season>/<date>_player.json, which is long format,
one row per player per game per action type, 31 types covering every category
the season file holds plus nine it does not. It is what the site's own
per-game awards are computed from ("Facilitator" for net points passing,
"Corner Pocket" for corner 3s).Fetched into
net_points_player_game_fingerprintunder its own checkpoint,
so a pull that already has the box-score half backfills only what it is
missing. Stored LONG rather than wide: the season file's shape would be 93
columns here and would change again the next time ESPN adds a category. The
categories are normalized on the way in to the column prefixes the season
file uses, over theFINGERPRINT_CATEGORIESmap that already existed, so one
skill list drives both tables.Two things about the plot are deliberate and measured. Its numbers are that
game's net points, not a per-100 rate - over ~30 possessions a per-100 rate
turns one made corner three into a league-leading season figure - and its
percentiles rank against every player-game in the season rather than
against season rates, since a season average is the mean of games like this
one and nearly any decent game would land in the 99th percentile against it.
The newUnitcarries the labels with the numbers so a per-game plot is
never captioned "per 100 poss". Verified against the warehouse: Curry's
2026-04-12 headline reads +6.31 (+2.39 offense, +3.92 defense), matching
net_points_player_game's row for that game exactly - a cross-check from a
different source file.A single game's percentiles take the MIDDLE of a tied block rather than the
top of it. In one game most players are exactly 0.00 in most categories, so
counting everyone a value is at least as good as read "attempted no hook
shots" as beating everyone else who also attempted none: +0.00 came out at
the 89th percentile and the radar drew a long spoke for a skill the player
never used. Every number in that table was individually defensible, and only
looking at the rendered plot found it. A season keeps the older definition,
where exact ties essentially do not occur and it is what puts the league best
exactly on the outer ring.A
dateis still refused, and now says why: the router supplies a calendar
date and this picks a player's first or last game of a season, which are
different questions. -
A NetPoints pull no longer spends three quarters of its requests being
refused. Every date with a local game was fetched, back to 1994, but the
bucket answers 403 for anything before 2018-10-16. Measured on this
warehouse: 7,835 dates, of which 1,769 are in range. -
SkillValueandPlayerFingerprintno longer name their fields
per_100. They hold a per-100 rate for a season fingerprint and a game's
own net points for a per-game one, sovalue,total,league_average,
league_best,overall,offenseanddefensesay what is there without
claiming a unit the numbers may not be in. -
A fingerprint asked for one game says so, instead of drawing the season.
"Show me a fingerprint for steph curry's last game in 2026" rendered his
whole 2026 radar, titled with the season, with nothing saying the question
had been widened. The template already refused this correctly - the slot
never reached it.ROUTER_PROMPTinstructsorderforgame_logand
shot_chartonly, so a fingerprint question carries no instruction to fill
it: measured at temperature 0, "last game" and "first game" phrasings came
back with noorder3/3, while "most recent game" - the prompt's own wording- came back with it 3/3. The third slot to need the fix
_validate_season
and_validate_sidealready use:_validate_orderreads it out of the
question, for the intents whose templates honor it (ORDER_INTENTS, guarded
againstHONORED_SCOPING).ROUTER_PROMPTandROUTER_SCHEMAhash
identically before and after, so no other question's routing moves.
The refusal now also names what IS answerable, and names the right cause. A
single game's NetPoints total is on record and its play-type split is not -
and that split is missing from the warehouse, not from the world: ESPN
Analytics publishes a second per-date file,
NBA/netpts/<season>/<date>_player.json, carrying every player's NetPoints
across 31 action types per game, which the pull does not read. So the
sentence says the data has not been pulled rather than that it does not
exist. - came back with it 3/3. The third slot to need the fix
-
The agent's SQL connection can no longer read the disk.
read_only=True
protects the database and says nothing about the machine under it: confirmed
live against the built warehouse,SELECT * FROM read_csv('/etc/passwd')
returned rows andglob('/home/<user>/*')listed dotfiles, through the same
connectionrun_sqluses. Sincerun_sqlruns SQL a model wrote from a
question a stranger may have phrased, "only SELECT is allowed" was never the
boundary it reads as - a SELECT is enough to put a file in the answer. The
newquery.toolbox.connect_read_onlyaddsenable_external_access=false,
and it costs nothing: no template and no agent tool reads a Parquet file,
attaches a database or copies anything, so the warehouse was the whole
surface either way. The fetch path is the opposite case and keeps its own
connection - building the warehouse IS reading 208,000 files off disk. -
The web server no longer shares one conversation between every browser.
AgentRunnerreuses a single Agent so the DuckDB connection and ollama's
keep-alive survive between requests - and its conversation was surviving too,
which nothing intended and the docs already contradicted ("each message is a
new question"). The history was the smaller half;Agent.last_questiongoes
to the router asprevious_question, so one person's "what about jokic" was
routed against whatever a stranger had asked before it.asknow calls the
newAgent.reset_conversationunder the lock it already holds. Real
multi-turn memory for the web UI means per-client conversations, which needs
a session the API does not have yet; until then this is stateless on purpose
rather than by accident. -
Trimming a long conversation drops whole turns, never half of one. The
fixed slice cut at an offset, which lands between anassistantmessage
carryingtool_callsand thetoolresults answering them, leaving the
history opening on a result that answers nothing visible. Ollama accepts that
rather than rejecting it - measured, so nothing fails and the cost is paid
quietly, as a JSON blob spending context with no question attached to say
what it was for. Measuring also narrowed the shape, which is not what it
looks like: a conversation of uniform turns never splits, because every turn
is an even number of messages while the offset is odd, so the cut lands in
the same safe place forever. What breaks that parity is a round asking for
two tools at once, since the loop appends onetoolmessage per call - and
mixed that way, a quarter of trims orphan a result. -
--log-levelno longer reconfigures logging for the whole process.
logging.basicConfiginsidedata pullanddata loadconfigures the
root logger, so calling either from anything that had its own logging set
up - a library, a test, the web server - replaced it._configure_logging
attaches one handler to theassociationlogger instead, which every logger
in the package is a child of. Same output from the CLI, and nothing outside
the package touched. -
A bare surname asks again, even when the router completed it. "Who is
better, tatum or brown" routed to['Jayson Tatum', 'Jaylen Brown']and was
answered without a question. Tatum is one player and that completion is free;
"brown" is ten, and the router picking Jaylen is exactly the prominence
tiebreak measured and rejected abovePLAYER_NICKNAMES- arriving through
the model's guess instead of through code, where nothing downstream could
see it.entities.undo_name_completioncuts a name back to the part the
question actually carries whenever that part is ambiguous, and lets normal
resolution decide:find_playersapplies the nickname table first, so
"luka" still answers Luka Doncic and "steph curry" still answers Stephen,
while "brown" and "edwards" ask. Measured over thecheck_routing.py
corpus, no slot moves. -
A name the question does not support is refused, not passed to the agent.
Falling through was the first fix and it was the wrong one: given
"compare fingerprints for embiid vs jokic in 2026", the agent spent 55
seconds writing a confident fingerprint - play-type percentages and all - for
"Ronaldo Lopes", who does not exist. Same reasoningcheck_coveragealready
records for a season below a floor: nothing downstream does better, and an
agent with nothing to find fills the silence from its own weights. The
refusal fires only for the intents whose template actually reads a player
slot (PLAYER_INTENTS, checked against the templates' own source), since a
stray name on ahead_to_headquestion changes no answer. -
A fingerprint that lost a player to a typo says so. "generate
fingerprints for embiid vs jolic in 2026" drew Joel Embiid alone: "jolic"
matches nobody and is not close enough to exactly one player to guess at.
Recovering it was measured and rejected - a near-spelling search over a
question's leftover words finds a spurious player in 29 of 51 corpus
questions ("season" is one edit from Tari Eason, "most" from Quinten Post),
and it does not find Nikola Jokic either. So the name stays lost and the
answer states it, because one polygon where two were asked for is only a
failure while nothing mentions it. -
A fingerprint keeps every player the question named. The same question
arrived as a singleplayerslot, so a two-player comparison was answered
with one polygon and nothing said so - the project's oldest failure shape.
entities.restore_dropped_playersputs back the players the router dropped,
for the fingerprint intent only: two polygons on shared axes is what a
comparison means there, while widening aplayer_statquestion the same way
would answer a different one. Gated on the question saying it compares
something, becauseplayers_named_inis strict but not infallible - "best"
is Travis Best and "boston" is Brandon Boston Jr., so "plot jokic's
fingerprint from his best season" names two players by its rules. -
A comparison that named no stat shows the whole line again.
statis the
one required slot inROUTER_SCHEMA, so the model fills it on every question
whether the question named a stat or not: "compare sga and embiid" came back
withstat='points'12 times out of 12, which narrowed the comparison to a
single average and quietly undid the wider default.route()now drops an
unaskedstatforplayer_comparealone - the same "read it from the
question" fix_validate_sidemakes, and safe to keep loose because a word
it misses only widens a comparison, whileleaderboardwould have nothing
left to rank by. -
A player the question never mentions is no longer answered about. Asked
to "compare sga and embiid", the 3B router returned
['Shai Gilgeous-Alexander', 'Jusuf Nurkic']and the answer was a fluent,
correct-looking table of two real players, one of whom the question never
named. Nothing downstream could notice: "Jusuf Nurkic" resolves perfectly, so
every check after the router passed.entities.override_invented_playersnow checks each router-supplied name
against the question before a template reads it, the same way
override_nicknamesalready checked nicknames. A name leaves a trace in four
ways, all of them things the router legitimately does - the word itself, a
near spelling of it, a nickname, or the initials ("KAT", "SGA") - and a name
with none of them is not answered about. Where the question names somebody
nothing else accounts for, that player takes its place; where it does not,
the question is refused rather than answered about the wrong player (this
first fell through to the agent instead - see the entry above for why that
was worse). Measured
over the wholecheck_routing.pycorpus, no correctly-routed player slot
moves, and the check costs 0.08ms a question. -
A name nothing matches says what it might have meant. The other half of
the same failure: "compare sga and embid" routed to'Jemel Embiid'- the
surname corrected, the given name invented - and since every token must
match, one fabricated word buried a player the warehouse holds. That fell
through to the agent, which resolves the same name against the same table.entities.suggest_playersnow backs a multi-word name off to its surname
(exact matching on one fewer token, not fuzzy) and then, failing that, looks
for near spellings of every token - which is what reaches the user's own
typo, since "embid" is not a substring of "Embiid" and no amount of trusting
the question finds it. The four places that reported "No player found
matching X" share one sentence throughentities.no_match, and
player_compareanswers with it rather than falling through. A suggestion
naming more than five players is dropped: that is a directory, not a
suggestion. -
A player comparison shows the whole line, and the NetPoints summary with
it. "Compare Luka and SGA" answered with games, points, rebounds and
assists - the same three-stat defaultplayer_statuses. But the two
questions are read differently: "how many points did Luka average" wants the
number it asked for, while a comparison is asking which player is better,
and three counting stats cannot answer that. They leave out both halves of
the defensive line and everything a player gives back.player_comparenow defaults to points, rebounds, assists, steals, blocks,
turnovers, fouls and minutes, followed by NetPoints per 100 possessions -
overall, offense and defense. Per 100 rather than season totals, because a
comparison is exactly the question totals answer badly: they mostly rank by
playing time. A table costs nothing per row, and prose is already refused
here (the agent's prose version once said a player with 0.4 steals led one
with 1.6). Naming a stat still narrows to it, so "who scores more" gets
scoring rather than a wall.player_statis unchanged.The NetPoints block is supplementary: a player with no row is left blank
rather than drawn as +0.00, a season where nobody has one drops the block
entirely, and a warehouse where the opt-in NetPoints fetch was never run
still answers.net_points_playeris deliberately absent from this
template'sTEMPLATE_SOURCES, since listing it would put a 2019 coverage
floor on every comparison and refuse the 1994-2018 ones outright.The web page shows the same rows, reading
netpointswithout requiring it,
so a pre-2019 comparison still renders as a table rather than falling back to
text.table()'s alignment test now accepts a leading+; without that, one
signed cell would have left-aligned an entire column of digits. -
foulsis a stat the fast path can answer.ROUTER_PROMPThas always
listed it among the stat names the router may emit, but
PLAYER_STAT_COLUMNShad no entry, so_wanted_statsraised and every
question naming fouls fell through to the slow agent path. -
A question about a season the warehouse cannot reach is refused, with the
real reason. Every table starts in a different year and the gaps are
ESPN's, so an out-of-range question returned nothing - and nothing was then
phrased as though the filters were wrong, or as a real answer. Both were
measured on the current snapshot. A 1996 shot chart saidNo shots found for Michael Jordan with the given filters, blaming the filters for play-by-play
that starts in 2002. Worse, a 1980 scoring leaderboard answeredMoses Malone led the league in points per game, at 25.8. Next: Bill Cartwright (21.7), John Long (19.4)- drawn from a league of seven players, with Kareem
Abdul-Jabbar, Larry Bird and Julius Erving absent fromplayersentirely.association/coverage.pydeclares each table's earliest usable season and
why it starts there;templates.check_coveragerefuses below it. The
refusal is returned as the answer rather than raised, unlikecheck_scope:
falling through would put the same empty tables in front of an agent that is
then free to fill the silence.A lookup and a ranking get different floors, because they fail differently.
player_season_statsholds Jordan's real 1990 line, so his own average is
still answered; ranking that season is refused, since the pool is 217 players
against a ~350-player league. The two refusals are worded differently on
purpose - telling somebody there is "no data for 1980" about a warehouse
holding Moses Malone's real 1980 line would be the same false-cause answer
facing the other way.Seasons that exist but only partly - 2002 play-by-play is about half a year -
are answered with a caveat rather than refused. Playoffs reach back to 1989
where regular seasons only reach 1994, sogamescarries both floors. Season
1993, whose rows duplicate 1994, is declared a phantom rather than merely
excluded, so it can be verified rather than assumed.scripts/check_coverage.pychecks every floor against a built warehouse -
28/28 on this snapshot - the same waycheck_nicknames.pychecks the
nickname table. Each floor was confirmed by moving it and watching the check
fail. -
"Wembanyama's defensive fingerprint" drew the whole fingerprint. The
router filledstatwith "defensive" and leftsideunset, so the template
fell back to its default and plotted all 20 spokes where the 5 defensive ones
were asked for - a broader answer than the question, with nothing saying so.
Deterministic, measured 6/6 at temperature 0, and failing for that exact
wording even though it appears verbatim as a worked example in
ROUTER_PROMPTwith the right answer beside it.No prompt wording fixes it, because it is not a wording problem.
statis
the one required slot, and a constrained decoder fills what it must before
what it may: the adjective is spent onstatand the optionalsideis
never considered. Sosideis now read from the question inroute(), next
to_validate_season, which reads the season from the question for exactly
the same reason.ROUTER_PROMPTandROUTER_SCHEMAare byte-identical - verified by hashing
both before and after - so the model sees the same input and no other
question's slots can move.scripts/check_routing.pygoes 47/48 to 48/48,
with the run otherwise line-for-line identical to the previous one.Deliberately conservative, like
override_nicknames: a question naming both
halves leaves the slot unset, since that already means the whole radar, and
guessing between them would be the same bug facing the other way. -
A chart no longer guesses which player a surname meant. "Show me a
fingerprint for Maxey" drew nothing and blamed the warehouse, because
"Maxey" resolved best-match to Marlon Maxey, who last played in 1994, rather
than Tyrese. Charts resolved names best-match on the reasoning that the plot
is titled with the name that won, so a wrong match is obvious on sight - true
right up until the wrong match is the reason no plot exists.resolve_chart_playernow narrows the candidates to those with a row in the
table the chart is drawn from, for the season being drawn, before taking a
best match. This eliminates; it does not prefer. It never chooses between two
players who both have the data - that is the prominence tiebreak recorded as
measured and rejected abovePLAYER_NICKNAMES- it only drops the ones who
cannot be the answer. One survivor is the answer; two or more get the same
clarifying question a numeric template asks.Measured against the warehouse: of the 566 players with a 2026 fingerprint,
319 have a surname somebody else also matches. Narrowing resolves 100 of
those outright and sends the remaining 219 - Wiggins, Bridges, Allen, the
Antetokounmpos - to a question that deserves asking. League-wide, 221
surnames put a player with no fingerprint ahead of one who has it, and 97 do
the same for shot charts.When narrowing eliminates everybody, the best match stands: no answer to
"which one did you mean" draws a plot either, so the renderer's own message
says more than the question would.The extra query runs only when a name matched more than one player, and costs
1.0ms (two candidates) to 3.1ms (ten) warm, 5.5-10.2ms on a cold process -
against a question that spends about 3.15s in the router. Measured on the
full warehouse, includingshot_chartat 6.7M rows. -
find_playersranks a name that starts a word above one it lands inside.
"Ball" offered Cedric Ceballos ahead of LaMelo, "Bey" offered Mike Tobey
ahead of Saddiq, and "Ford" offered Al Horford ahead of Aleem Ford - each of
them the candidate a chart then drew. Word-boundary matches now sort first
and, when there are any, are the whole candidate list, which is the two-step
find_teamshas always used.The boundary is any non-letter rather than a space, so both halves of a
hyphenated or apostrophed name start a word: "Alexander" still reaches Shai
Gilgeous-Alexander and Nickeil Alexander-Walker, "Neal" still reaches
Shaquille O'Neal. Substring matching is kept, so a fragment typed mid-word
still finds the people it matches. Across the warehouse's 1,899 surnames this
changes 46 best matches, resolves 36 names that used to ask, and every
changed pick is the more plausible player. -
A fingerprint nobody has is no longer reported as a season nobody has.
"Show me a fingerprint for Maxey" answeredNo NetPoints fingerprint on record for season 2026- a claim about league-wide coverage, and a false
one: that season holds 566 players and Tyrese Maxey is among them. "Maxey"
matches two players andfind_playersorders by name, so best-match
resolution took Marlon Maxey, who retired in 1994, and nothing was drawn.load_fingerprintsnow names the players when the season has rows and they
do not, and says how many players the season does hold. A name that resolved
to the wrong person is visible in the answer instead of looking like a gap in
the warehouse, and the empty-season message now means only what it says.render_for_playersalso carries the other matches onto the failure path.
Names here resolve best-match, which is safe because the plot is titled with
the name that won - and that is exactly what does not happen when nothing is
drawn, so the one case that needed the runners-up was the one discarding
them. The answer now endsNote: other players also matched: Tyrese Maxey. -
Nicknames are read from the question, not from the router's guess. Asking
for "The Answer" returned Allen Iverson's numbers only by luck: the 3B router
rewrites a nickname it recognizes and invents a player for one it does not,
and the invented name is a real player who resolves cleanly. Measured, "The
Answer" becameplayer='Klay Thompson', "The Glove" became'Jayson Tatum',
and "VC" became'Victor Claver'- each answered confidently, about the wrong
person, in under two seconds. Nothing downstream could catch it, because by
then the nickname was gone.entities.override_nicknamesnow matches the table against the user's own
words and overrides the router's player slot before any template runs. It is
deliberately narrow: a singleplayerslot only when the question names
exactly one nickname, aplayerslist only when the counts match, since a
wrong override is the same bug in the other direction.PLAYER_NICKNAMESgrows from 21 to 78, sourced from Wikipedia's list of
basketball nicknames and filtered to players the warehouse actually holds -
which starts at 1993-94, so Bird and Kareem are not there to be named. The
new entries reach the players the router got wrong: Iverson, Carter, Payton,
Olajuwon, Malone, Robinson, Rodman, Pierce, Garnett, Webber, Hardaway.
scripts/check_nicknames.pyverifies against a built warehouse that every
value names exactly one player and no key is a name belonging to somebody
else.First names now resolve where they used to ask: "luka" is Doncic, "kobe" is
Bryant. A question carrying only a shorthand cannot reliably have meant Luka
Garza or Kobe Bufkin, so the clarifying question bought nothing. A shared
surname still asks - "brown" offers ten candidates, and should - and so
does "curry", where Seth and Stephen are both real answers. -
association data checktakes 17 seconds instead of 7.5 minutes. It
gathered every count with its own filtered query - one per cell of the
report - and none of these Parquet trees is hive partitioned, soWHERE season = ...pruned no files and each query re-read the table in full. With
37 seasons and 2 season types that was 74 scans of all 40,558gamesfiles
(238s) and 74 more ofshot_chart(160s), 87% of the runtime between them.
Each table is now counted once, grouped by season and season type, and the
report indexes into that. The printed table is unchanged, byte for byte. -
data loadno longer runs out of memory building the warehouse. A full
build was killed by the OOM killer partway through, leaving the tables it had
already replaced and the rest at their old contents. The cause was DuckDB's
external file cache, which keeps the Parquet a statement read resident after
that statement ends and accumulates across the 18 loads a build runs on one
connection. It is sized for re-reading a few large files; this tree is
208,000 small ones, and it charged far more per file than a file holds -
gamesalone (40,558 files, 320 MiB on disk) parked 5.6 GiB in it. The
warehouse connection now turns it off: a full build peaks at 3.0 GiB instead
of being killed, and is no slower for it.Nothing DuckDB would have raised - it was accounting for 6.5 GiB of a 12.4
GiB budget when the kernel killed the process, which is why this looked
nothing like the earlierplaysfailure thatpreserve_insertion_order
fixed. Both settings now sit in_tunewith the symptom that tells them
apart written down. -
A chart taller than its frame now says so. An inline chart is capped, and
past the cap the frame scrolls - but it clipped its content dead flat, which
reads as a rendering fault rather than as somewhere to scroll. The lower edge
now fades out, and only while there is more below: the fade is dropped at the
end of the scroll, where it would otherwise sit over the last row of a
fingerprint's table. It is painted in the chart's own background color, read
off the document inside the frame, because a chart page sets its own rather
than inheriting the app's.