Skip to content

association 2.1.0

Choose a tag to compare

@jeffknupp jeffknupp released this 11 Sep 20:39
· 885 commits to master since this release

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.
  • 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's season slot both discarded any year below 1990, a floor that
    predates coverage.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-daily says 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 asks did 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_history narrows 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_netpoints keeps 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_player now 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_players returns 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 to clarification) - 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 the check_routing.py corpus, 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 as player_compare with
    the Celtics as the second "player". Once they became the opponent,
    player_compare - which reads season lines only - refused it, and the
    question fell through to the agent while player_stat answers it exactly. It
    goes to player_stat now; 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 players is recognized by its LAST word being a nickname. The
      player_matchup reroute matched one anywhere in the name, which sent
      "magic johnson vs larry bird head to head" to player_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.
  • 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 with team='Boston Celtics' beside the two players. scope_from_question
    left it there - a team already in team is how head_to_head carries its
    own side - so no opponent was set, check_scope had 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 to opponent, so player_stat and game_log answer over
    those games and every other template refuses. head_to_head and the team
    templates are untouched.

  • threshold_count answers 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 than find_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_pct now needs 550 true-shooting
    attempts (FGA + 0.44 FTA) and efg_pct 480 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 in query/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_leaderboard returns min_sample_column beside min_sample_applied,
    since 20 is games for one metric and 550 is attempts for another.

    player_season_advanced_stats gains field_goals_attempted and
    true_shooting_attempts, summed from the same box scores as the
    percentages. An existing warehouse needs an association data load (any
    --tables subset: 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, and player_stat, which holds none of them, refuses.
    • An order and a limit of 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; a player_matchup whose "players"
      include a team goes to that player's games against it; a player_history
      with no stat named, or asked against a team, becomes the player_stat line.
    • "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_head takes an opponent as its second team, and keeps reading
      names until two different teams resolve.
    • A player's name in the team slot becomes the subject ("Podziemski game
      log without curry").
  • 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 sent won: 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
      matches court.py, at no extra length against the preamble's token budget.
  • 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_points and a team's game_log now select a
      postseason by the calendar year it was played in, as team_record already
      did, and exclude the phantom 1993 label. The 1987-88 playoffs are not in
      ESPN's archive at all, so the postseason floor for games and
      team_box_stats is 1989, not 1988, and that refusal now gives its own
      reason instead of the regular season's. scripts/check_coverage.py counts
      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_deduped now 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, so shot_chart caveats 2003 as
      well as 2002.
    • Four more narrowing slots the router reads from the question text.
      None is honored yet, so a question carrying below or situation now
      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_record answered 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.
  • A player's games against one team, his recent form and his career are
    answered now, where the entry below made them refuse.
    game_log and
    player_stat honor opponent, venue, span and without, and
    player_history honors span. 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_stat refuses a limit: an average over the last N
      games is that log's average row, not the season line.
    • without means 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 without name is narrowed to the players who were actually
      teammates - elimination, as narrow_to_available does - and asks only
      between those.
    • A career is summed from player_season_stats_deduped as 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. date matched 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 joined games on 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.
  • 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_count and anything else that sums
    player_box_stats over 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_high and threshold_count
    now honor span "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.

  • 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 from leaderboard. 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_stats copy
      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.
  • single_game_high dates 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_count resolves 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_splits per-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_without a team's record in the games a teammate played vs missed, and a player's averages in each "Celtics record without Tatum"
    record_when a team's record when a player reached a stat threshold vs when he fell short "Sixers record when Embiid scores 30"
    player_matchup two players' meetings on opposite teams: record, averages, the latest games "Andre Drummond vs Al Horford game log"
    streak a 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.

  • 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
    order and date. 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 in team, 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_type now 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, and team_season_stats and
    team_power_index were read by no template at all. Most of the work was
    finding out which of their numbers can be believed.

    • team_record honors venue, opponent and span, 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
      of games for 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
      from games, 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, because games labels 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_stat and team_leaderboard read 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's possessions counts 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 from games and 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_outlook reads 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 to record_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_PROMPT gained 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 to fingerprint under 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_stat goes to single_game_high.
    • A threshold_count still without a threshold is a season ranking and goes
      to leaderboard. "who has the most threes" arrived with none and fell
      through.

    check_routing.py now applies scope_from_question the way agent.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.

  • 1993-94 player games were listed four times. player_game_log joined
    games and player_advanced_stats on event_id alone, 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
    season too. Measured first: games.season equals player_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.py put the rim at (25, 5.25), on the assumption that
    coordinate_y starts 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 equals round(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_Y is 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_SQL and
    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 = 0 means unlabeled, not zero points, and
    both shot_distance and 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_SQL now derives a value
    where ESPN left none: the label, else shot_type for 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 and docs/usage.rst is redrawn from the same game.

  • --workers now 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-limit was 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_rows under _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 own games -
    whose date is a UTC tip timestamp, a day ahead for anything after 7pm
    Eastern. That left a choice between date + 1 and date, and both
    orderings are wrong for some real schedule
    : date-first steals a
    back-to-back's first night, and date + 1-first - what shipped - steals the
    next night, which then claims the same game again out of its own file.
    Season 2026's net_points_player_game held 611 duplicated
    (event_id, athlete_id) pairs, every one disagreeing with its twin, and
    net_points_player_game_fingerprint inherited 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 as 2020-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 carries pts beside 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.py runs that over a season,
    and the query side's max(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, and NBA/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_fingerprint under 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 the FINGERPRINT_CATEGORIES map 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 new Unit carries 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 date is 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.

  • SkillValue and PlayerFingerprint no 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, so value, total, league_average,
    league_best, overall, offense and defense say 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_PROMPT instructs order for game_log and
    shot_chart only, so a fingerprint question carries no instruction to fill
    it: measured at temperature 0, "last game" and "first game" phrasings came
    back with no order 3/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_side already use: _validate_order reads it out of the
      question, for the intents whose templates honor it (ORDER_INTENTS, guarded
      against HONORED_SCOPING). ROUTER_PROMPT and ROUTER_SCHEMA hash
      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.

  • 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 and glob('/home/<user>/*') listed dotfiles, through the same
    connection run_sql uses. Since run_sql runs 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
    new query.toolbox.connect_read_only adds enable_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.
    AgentRunner reuses 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_question goes
    to the router as previous_question, so one person's "what about jokic" was
    routed against whatever a stranger had asked before it. ask now calls the
    new Agent.reset_conversation under 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 an assistant message
    carrying tool_calls and the tool results 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 one tool message per call - and
    mixed that way, a quarter of trims orphan a result.

  • --log-level no longer reconfigures logging for the whole process.
    logging.basicConfig inside data pull and data load configures 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 the association logger 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 above PLAYER_NICKNAMES - arriving through
    the model's guess instead of through code, where nothing downstream could
    see it. entities.undo_name_completion cuts a name back to the part the
    question actually carries whenever that part is ambiguous, and lets normal
    resolution decide: find_players applies the nickname table first, so
    "luka" still answers Luka Doncic and "steph curry" still answers Stephen,
    while "brown" and "edwards" ask. Measured over the check_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 reasoning check_coverage already
    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 a head_to_head question 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 single player slot, so a two-player comparison was answered
    with one polygon and nothing said so - the project's oldest failure shape.
    entities.restore_dropped_players puts back the players the router dropped,
    for the fingerprint intent only: two polygons on shared axes is what a
    comparison means there, while widening a player_stat question the same way
    would answer a different one. Gated on the question saying it compares
    something, because players_named_in is 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. stat is the
    one required slot in ROUTER_SCHEMA, so the model fills it on every question
    whether the question named a stat or not: "compare sga and embiid" came back
    with stat='points' 12 times out of 12, which narrowed the comparison to a
    single average and quietly undid the wider default. route() now drops an
    unasked stat for player_compare alone - the same "read it from the
    question" fix _validate_side makes, and safe to keep loose because a word
    it misses only widens a comparison, while leaderboard would 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_players now checks each router-supplied name
    against the question before a template reads it, the same way
    override_nicknames already 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 whole check_routing.py corpus, 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_players now 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 through entities.no_match, and
    player_compare answers 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 default player_stat uses. 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_compare now 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_stat is 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_player is deliberately absent from this
    template's TEMPLATE_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 netpoints without 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.

  • fouls is a stat the fast path can answer. ROUTER_PROMPT has always
    listed it among the stat names the router may emit, but
    PLAYER_STAT_COLUMNS had no entry, so _wanted_stats raised 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 said No 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 answered Moses 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 from players entirely.

    association/coverage.py declares each table's earliest usable season and
    why it starts there; templates.check_coverage refuses below it. The
    refusal is returned as the answer rather than raised, unlike check_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_stats holds 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, so games carries 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.py checks every floor against a built warehouse -
    28/28 on this snapshot - the same way check_nicknames.py checks 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 filled stat with "defensive" and left side unset, 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_PROMPT with the right answer beside it.

    No prompt wording fixes it, because it is not a wording problem. stat is
    the one required slot, and a constrained decoder fills what it must before
    what it may: the adjective is spent on stat and the optional side is
    never considered. So side is now read from the question in route(), next
    to _validate_season, which reads the season from the question for exactly
    the same reason.

    ROUTER_PROMPT and ROUTER_SCHEMA are 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.py goes 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_player now 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 above PLAYER_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, including shot_chart at 6.7M rows.

  • find_players ranks 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_teams has 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" answered No 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 and find_players orders by name, so best-match
    resolution took Marlon Maxey, who retired in 1994, and nothing was drawn.

    load_fingerprints now 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_players also 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 ends Note: 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" became player='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_nicknames now 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 single player slot only when the question names
    exactly one nickname, a players list only when the counts match, since a
    wrong override is the same bug in the other direction.

    PLAYER_NICKNAMES grows 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.py verifies 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 check takes 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, so WHERE 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,558 games files
    (238s) and 74 more of shot_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 load no 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 -
    games alone (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 earlier plays failure that preserve_insertion_order
    fixed. Both settings now sit in _tune with 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.