Repository navigation
association 4.3.0
-
The docs describe the shared relation. The architecture page, the
templates package docstring, the README and the usage guide now say that the
box-score templates composequery/player_gamesrather than each writing
its own SQL, and their examples of narrowings no template honors no longer
name "under 14 FTA" and "since 2020", which both answer now. -
"his last game" is one game. A
player_statquestion naming a single
game at one end of the span ("his last game", "her first game of 2026")
getsorderandlimit: 1set together inroute()from the question's
own words, andplayer_stathands it togame_log; "show maxey's stats for
his last postseason game" answers that game rather than his postseason
average. A fillerorderon a question naming no such game is still
dropped, as before (closes #142). -
An unscoped count by a named player reads as his career. Product
decision (2026-09-19): "how many times has embiid fouled out?" is 0 this
season and 9 in his career, and only the second is the question.route()
sends athreshold_count"how many" question with a player and no season
in sight to his career; a season the question names ("this season", a
year, "his 18th season") still wins, and a league-wide count keeps the
default season. The answer names the scope it used, as it always did. -
"Stats vs X" ends with the meetings behind the average. Product
decision (2026-09-19): averages over every meeting in scope, the game
count, and a short footer of the meetings themselves, newest first
(RECENT_MEETINGS, 5) - date, venue, result, points, rebounds, assists.
"Jayson tatum stats per game vs sas" now says "in 1 game" and shows the
only meeting beneath it. A per-game log is stillgame_log's, for a
question that says log, each game or last N. -
"This postseason" names the current season, like "this season" does:
"maxey's stats for game 4 against the knicks this postseason" used to read
as a career question and ask which Maxey. And a stat's own name is never a
count's subject: "the most 30+ point 10+ rebound games" read "point" as a
player and answered for Sir'Dominic Pointer. -
"His 18th season" is a season, settled once the player is known. "how
many 40+ points games does lebron james have in his 18th season?" arrived
as season 2018 - the ordinal read as a year - with LeBron dropped, and was
answered as the 2018 league leaderboard; then it refused as asituation.
route()keeps the ordinal asseason_nand drops a year the question
itself never named;game_log,player_statandthreshold_countresolve
the player over his career first and then settle the ordinal against his
regular seasons on record (templates.common.settle_ordinal_season),
answering "in his 18th season (2021 regular season)". A player with fewer
seasons than the ordinal is told how many he has rather than answered for
his last one, and a league-wide count ("most points in 15th season played")
refuses: the ordinal is a place in one player's career.
The same question's subject, dropped by the model, is restored from a third
grammar ("does lebron james have", beside #148's two), so it answers his one
40-point game of 2020-21 rather than falling through. -
"Game 4" is one game of each playoff series. "Ayton stats in game 4
playoff games" answered with his whole postseason, then refused as a
situation, and "show maxey's stats for game 4 against the knicks" sent
the agent into a six-minute loop (#145).route()reads "game N" (1-7,
"game 7s" included - it is no longer around) into agame_nslot;
the relation numbers every postseason game within its series by date over
real_games(the series' own games, so a game he sat out still counts
toward the number) and keeps the Nth.game_logandplayer_stathonor
it, saying "in game 4 of the series" with an opponent and "of each series"
without; a regular-season question refuses, since nothing there is game 4,
and a team's log refuses it for now. -
A line on a box-score stat keeps a player's games. "Sga games with
under 14 fta in his whole career" used to be refused (belowwas a slot no
template honored) and, before that, answered as 14 or MORE free throws
made - the model's nearest stat, the other way round.route()now keeps
the words after the number ("under 14 fta", as a list, one per phrase) and a
newaboveslot carries a minutes floor ("with 25 minutes", "20+ mins",
formerly a refusedsituation);templates.common.measure_filtersreads
them onto theplayer_gamerelation (MEASURE_WORDS: "fta", "fga",
"mins", "threes", ... to a column) and refuses a word it cannot map rather
than filtering on a guess.game_log,player_statandthreshold_count
honor both, and the answer says what it kept ("with under 14 free throw
attempts"). Athreshold_countphrase carrying the count's own number is
that count misread, so the phrase wins; one with another number is a second
line beside it. A team's log refuses a line, since it has no such column. -
ISSUES.mdkeeps one heading per priority tier, and a gate says so.
scripts/check_issues_md.sh(pre-commit and CI) requires exactly one
## P1:..## P4:heading, in order. An edit deleting the last entry of
the P1 section had swallowed the## P2heading with it (898ef66), so every
P2 read as a P1 andsync_issues.pywould have labeled a new one that way;
#127 had the P3 heading missing for days before that. Restored here. -
A filler
limitonplayer_statis dropped whatever its size. Now
that a limitedplayer_statis answered as a log, a count the question never
named would answer a different question: "Portis vs bulls 2019-20 to
2023-24" arrived withlimit: 5and became a three-game log where his
averages were asked for.route()drops any limit onplayer_statthat no
count word supports, not only a 1; a year is not a count, and neither half
of "2019-20" is. "last 5 games" and "top 10" keep theirs. -
A nickname another slot holds is not the subject. "myles turner bucks
stats without giannis last 10" routed withwithout: ['giannis'], and the
nickname override, seeing exactly one nickname in the question, rewrote the
subject to Giannis - who then could not play without himself.
override_nicknamesnow leaves alone a nickname the router already put in
another slot; the question answers with Turner's last 10 games without him. -
sinceis a scope the relation honors. "jokic vs cade since 2022"
answers their 7 meetings across 2022-2026 instead of this season's one:
_span_ofand_condition_scopetakesinceand build a span from that
season on (the phantom still excluded, never earlier than the table
reaches),game_log,player_statandplayer_matchupdeclare it in
HONORED_SCOPING, and a since-span's answer says "since 2022" rather than
"over his career". Defined once on the relation's span builders, so any
template on the relation gets it by declaring it. -
A player's numbers over the last N games are the log with its averages.
player_statwith alimitor anorderhands the question togame_log
instead of refusing it - the product decision that "stats over his last N
games" is a per-game log with averages beneath, never the season line.
player_statdeclaresorder. Note the dependency this creates: a filler
limitthe router emits on a question that named no count is dropped in
route()(_LIMIT_REFUSING_INTENTS); the template now trusts the slot it
is given, so that rule is what stands between "westbrook stats as a
starter" and a one-game log. -
The unseen-games counters have one home.
player_games.scope_without_guard
is the span clause for the reads that count what the played guard drops
(_empty_box_scores,_rebuilt_in_scope), replacingtemplates.players._box_scope;
the relation's module docstring says why those reads omit the guard and
where the team-level counterpart lives. With this, "a player's games" is
defined once (closes #149). -
The last player-games read is on the relation.
conditions._player_games
(behindstreak,player_splits,record_whenandwith_without) now
derives its join, its played guard and its rebuilt-line blanking from
query/player_games.pyinstead of its own join toreal_gamesand
team_box_stats- the side and the scores come fromgamesdirectly.
Measured first: on the 2026-09-19 warehouse no played box-score row sits on
a gamereal_gamesdrops (43,504 games, 43,353 real), so the two reads
agreed by fact; now they agree by construction.player_splits' venue and
opponent narrowing names the relation's columns for a player's games and
keeps the team-box spelling for a team's. Pure refactor: all 11 recorded
cases across those four templates and all 195 across the five player-games
templates are byte-identical before and after. -
The pair relation.
player_games.paired_rows_sqlreads the games two
players both played, on opposite teams or the same, as two reads of the
player-games relation joined on the event - both under the played guard,
with the rebuilt-line blanking applied byplayer_games.column.player_matchup's
meetings now come from it instead ofconditions._meetings' own join to
real_gamesandteam_box_stats. Pure refactor: all 17 recorded matchup
cases (195 cases across the five player-games templates) are byte-identical
before and after. -
One definition of "a player's games."
query/player_games.pyis the
relation every box-score template reads through: the season-keyed join to
games, the phantom-1993 exclusion, the did-not-play and empty-line guard,
the rebuilt-line opt-in and the teammate tenure rule live there once, with
three skeleton readers (rows, one aggregate, aggregates per group) that
game_log,player_stat,threshold_countandsingle_game_highnow
compose instead of writing their own SQL. A pure refactor, proved by a
golden comparison: all 195 recorded slot sets across the five player-games
templates return byte-identical answers and data before and after. Test
fixtures that fed the stored box table alone now mirror the warehouse's
shape (agamesrow per event), which is what let the relation's join
reach them. -
A question that names one half of the starter/bench split now filters by
it, in every template that narrows a player's games. "Jrue holiday last 50
games as a starter" was refused, becauseSPLIT_WORDSrecords the category
starter_benchand discards which half was named - right forplayer_splits,
whose answer is both groups side by side, and useless to a template that has
to filter.TemplateContextcarries no question text, soroute()reads the
half (_split_side), the way it already reads the side of the ball: no
ROUTER_SCHEMAchange, so no other question's slots can move.
The filter itself is one clause added to_narrow_player_games, which is
whereopponent,venueandwithoutalready compose over the same set of
player-games - so it reachedgame_logandplayer_statat once rather than
being taught to each. That is the point: a new narrowing should become
available to every caller, not to one template.
Said in the answer, never silently: "Taurean Prince as a starter, last 7
games" and "in 58 games as a starter" against 64 unfiltered. A question
naming BOTH halves keeps the category and is still refused bycheck_scope
for these two, because both groups side by side isplayer_splits' answer
and not one they produce. -
period_splithonors the same named half, filtering on thestarter
column of the box table it already joins - "Dominick Barlow scored 228 points
in the 2nd half over 59 games as a starter" against 71 games unfiltered. -
A
limitof 1 the question never asked for no longer costs an answer.
The decoder reaches for 1 when it has nothing to put in a slot it must fill,
andplayer_statrefuses any limit, so "westbrook stats as a starter for
kings" was refused as "a game_log question" over a narrowing nobody
requested. A barelimitof 1 with noorderis now dropped for the intents
that cannot honororder, unless the question names a count - the same
filler rule_route_side_and_orderalready applied when anordercarried
it in. -
A history file now names the build that produced it:
<commit>-<hash>.lograther than<hash>.log, with a matchingbuild:line
in the header. The release version alone cannot identify a behavior - dozens
of commits share4.2.0, and the answers this project keeps changing are
exactly the ones a reader needs to tie back to a build. A dirty tree is marked
<commit>-dirty, since a run from uncommitted work is not reproducible from
the commit alone.gitis asked about the package's own directory, never the caller's, so
runningassociation queryinside an unrelated checkout cannot stamp that
repository's commit onto the run. Where there is no git, no checkout, or a
wheel installed outside one, it falls back to the version - losing the file
would be far worse than identifying it a little more loosely.Web sessions already wrote history and still do:
history.write()runs from a
finallyinAgent.ask(), so it fires whatever the caller and whether or not
the trace is discarded. -
A truncated or partly-fabricated player name is now repaired from the
question's own span, not just checked against it.override_invented_players
only ever asked whether a router-supplied name had ANY trace in the
question at all - generous by design, so half a name could stand in for
the whole of it. That let a name the router simply cut short pass as
"grounded" and reachresolve_playerunrepaired: "dennis schröder", typed
correctly, arrived as just'Dennis'and asked a 7-way clarification the
question never should have; "jayleyn brown" and "Aaron gordan" did the
same over a typo'd half of a name the router dropped instead of
completing. It also let a router-fabricated word survive next to a real
one: "tatum rec home" arrived as'Jaylen Tatum'- the league's only
Tatum, with an invented given name bolted on - and "Grady dick" arrived
as'Grady Dickinson', whose fabricated surname then sent
suggest_players' surname-only backoff to Hunter Dickinson, a real
but wholly unrelated player (ISSUES.md #122).entities._question_derived_playeranchors each of the router's words to
where it turns up in the question - exactly, or within a near spelling,
since the router silently corrects typos - and resolves the question's
own words there against the roster, never the router's spelling. Two
guards keep it from trusting a coincidence: two or more anchored words
are answered from exactly that range, never falling back to one of them
alone (a bare given name that happens to be a rare, unrelated player's
whole name too - "kareem stats vs bob lanier" names two players who
retired before the 1993-94 floor, and a naive fallback answered Kareem
Rush and Chaz Lanier, two real but wholly unrelated players, instead of
refusing); and a single anchor is trusted alone only from the surname
position, never a bare given name with nothing else corroborating it.
undo_name_completionalso leaves alone a name this now resolves, so a
typo'd but already-repaired name is not cut back to an ambiguous
fragment afterward.Measured against a 261-question replay of real StatMuse-style traffic
(slots replayed through templates, no model call): 18 rows moved from a
needless clarification or a "did you mean" non-answer to a direct,
correct one; 18 more carry a fuller resolved name in the trace with no
change to the answer given; zero rows moved the other way. -
A stat the system can rank is now one it can also look up. True shooting,
effective FG%, usage rate and game score are computed from box scores by
fetch/advanced_stats.pyand have been rankable since 2.1.0, but
player_statknew only ESPN's own columns and refused them as unknown - so
"kevin durant true shooting percentage career", with the router emitting
stat="ts_pct"correctly, fell through to the agent with "no per-game column
for stat 'ts_pct'". One concept carrying two vocabularies, each half correct
on its own, which is why nothing caught it.ADVANCED_STATSis now the
lookup half, and a test asserts the two lists still name the same set.Three things the career figure does that a first cut would not. It is
weighted by the stat's own denominator - true shooting by true-shooting
attempts, effective FG% by field-goal attempts - never averaged across
seasons, which is the discipline the shooting percentages already state;
usage and game score have no such denominator, so they answer for a season
and refuse a career rather than report a mean of means. It excludes the
phantom 1993, which is a copy of 1994 and would otherwise be counted twice.
And it says which seasons it could not see: ESPN serves whole
team-seasons of empty box scores from 2013 to 2018, so Jimmy Butler's 2013,
2014, 2015 and 2017 carry games played, zero attempts and a NULL rate - an
unfiltered answer credited his career rate with 290 games it never saw and
named a span it did not cover.An advanced stat is also charged its own coverage floor (1994, from box
scores) rather than the season line's (1977), so "Kareem's true shooting in
1980" is refused in the words of the table the question actually depends on.
A question that narrows the games (opponent,venue,without) is refused
with that as its stated cause rather than answered with the season figure. -
game scoreis a leaderboard metric.avg_game_scorewas computed, stored
and named to the SQL agent, and reachable by nothing on the fast path. It
carries the same games qualifiers as every other per-game average: measured
over 1994-2026, 28 season boards are led by a player under 20 games and all
28 are postseasons, where the 5-game floor drops the genuinely small samples
(Kawhi Leonard's 2 games leading 2023) and keeps the rest. -
leaderboardandplayer_statnow reach that metric.statis the one
REQUIRED slot inROUTER_SCHEMA, so a constrained decoder filled it with the
nearest value it knew - "game score nba leader" arrived withstat='points'
and was answered "Luka Doncic led the league in points per game ... at
33.5", correct about points and not about what was asked.router.route()
now reads the two-word phrase off the question text, the same way
_validate_sidereads which half of a fingerprint was asked for, and sets
the spelling each template expects (avg_game_scoreforleaderboard,
game_scoreforplayer_stat) - anchored so "score" alone, which means
points everywhere else in basketball, is not swept in with it. -
head_to_headhonorsvenueanddateinstead of refusing them.
"lakers vs mavs record last 10 home games played" narrows to the
first-named team's home or road games; "celtics record vs sixers on
november 11" answers one calendar date, the same waygame_log's own
datereplaces the season rather than being filtered inside it. Both are
said in the answer text, not silently applied - a home-only record with no
note would read exactly like the whole-season one. -
player_matchupanswers a player-vs-team question instead of refusing
itsopponent. "sam hauser v mil", "julius randle stats vs blazers with
minnestota" and "Curry vs dallas last q0 games" all reach this template
with one player name and a teamopponent-router._route_matchup_against_team
exists for this exact shape but only sees the router's raw output, before
entities.scope_from_questionrestores a name it drops afterward. One name
and a team opponent is answered the waygame_loganswers it; a genuine
two-player matchup with an opponent left over still refuses it, since it
has no third team to narrow the meetings by.It now also honors
without, for the same shape. "oubre vs warriors
without embiid" and "de'aaron fox vs magic ... without wembyanama" each
arrive with a second, fabricated "player" beside the real one - a garbled
team name already resolved intoopponent, or (checked against the
warehouse) the real subject's own teammate, named a second time in
without._player_matchup_drop_fabricated_secondeliminates the noise -
a name matching no player at all outright, a name duplicatingwithout
only when confirmed by the same near-spelling resolutionwithoutalready
trusts - and folds the question into the one-player-and-a-team branch,
which already readswithoutbecausegame_logdoes. A genuine
two-player matchup with awithoutleft over is refused from inside the
template rather than silently dropped, the same wayopponentalready is. -
A name the router mis-slots as a team is recovered from the question, not
just from the fragment left in the slot. "Will Riley last 5 game s"
arrived asteam='Riley', whichfind_playerscannot settle alone - three
Rileys share the surname - but the question spells the whole name.
entities._scope_from_question_player_in_team_slotnow falls back to
players_named_in, gated on the fragment sharing a word with what it
finds, the same disciplineoverride_invented_playersapplies to an
invented name. Measured against the 261-query StatMuse replay set: 7 rows
move, all fromfell_throughto either a correct answer or an honest "did
you mean" - none fromcorrect. -
suggest_playersno longer gives up when a common surname alone matches
too many players. It now falls through to the stricter near-spelling pass
instead of returning nothing, which recovers a real match the surname-only
pass could not settle: "Dylon Harper" backs off to six real Harpers, too
many to suggest on the surname alone, but only Dylan is also close on the
given name. -
suggest_playersno longer suggests a player for a name that names a real
team. "Most reb by a hawk player history" answered "did you mean Spencer
Hawes?" - a fluently wrong guess - because "Hawks" is one edit from a real
surname. A name that resolves to a team outright is never offered as a near
miss on a player now, regardless of edit distance. -
PLAYER_NICKNAMESgained "og" (OG Anunoby), "rui" (Rui Hachimura, over an
alphabetically-earlier "Rui Betancourt" the warehouse also holds), and
"ant man"/"ant-man" (Anthony Edwards). -
player_splitshonors a venue and/or an opponent rather than falling
through - "Jalen Duren away vs Denver", "Pat Spencer home vs the Suns" - the
same filtersteam_recordalready applies to a team. Alimitgreater than
1 is refused instead of silently answering the whole span it should have
narrowed to:player_splitshas no "last N games" mechanism the way
game_logdoes, and answering the full season under that framing would be
the exact silent substitutioncheck_scopeexists to stop. A barelimit
of 1 is left alone - the router already uses it as filler elsewhere, and
here it never changes the answer. -
team_recordhonors a calendar month, filtering ("76ers record in
October, away") or breaking a record out by month ("Knicks record by
month"), read fromgamesrather thanstandings, which has no per-game
date to filter or group by. Every othersituationvalue - a weekday, an
age, "since returning from injury", a division - is still refused: only a
literal "in " shape is read, deliberately not a month name found
anywhere in the text, so a window ("since january 31st") is not mistaken for
a month filter. -
13 more query intents render as HTML instead of a raw
<pre>block on
the web page:player_stat,shot_distance(a stat card each),
head_to_head(a two-team score card),team_quarter_pointsand
period_split(a sparkline plus a per-game table),player_splits(one
table per split, home/away, starter/bench, wins/losses, by month),
with_withoutandrecord_when(a two-row team-record comparison),
player_matchup(a comparison table plus the recent-meetings log),
streak(a ranked table for the league-wide shape, a plain one for a
named player or team),team_stat(value and rank per metric),
team_leaderboard(a ranked table) andteam_outlook(a stat-card grid
for BPI, record, chances and strength of schedule). Together with the
sevenRENDERERSalready had, every intent but the tablelesscoach
refusal now has one (ISSUES.md #110, staged plan step 1 - renderer-only,
no template change: every key read was already onAnswer.data). The
prose answer is unchanged and stays available under the "text" disclosure;
a rendering bug in any of the new renderers falls back to it rather than
losing the answer, the same guard the existing seven already relied on.
tests/web/test_renderers.py's contract test now covers all 20. -
game_logno longer answers a team's log when the question named a
player (#147).game_logtook itsteambranch before it readplayer,
so ateamslot beside a named player won outright: "kobe bryant's stats
vs rockets in the 2009 playoffs" answered the Lakers' own game instead of
Kobe's, "Payton Prichard stats vs 76ers" answered an invented "Phoenix
Suns"'s games (none) instead of Pritchard's 7, and "steph curry vs 76ers
last 4 games" answered the 76ers' own last 4 games instead of Curry's
against them. With a player named, his own team now narrows nothing and is
dropped, a different team becomes his opponent (the Curry shape), and a
team nothing resolves to is dropped exactly like an invented player name -
but anopponentalready named wins over all three, since ateamslot
beside it is the same noise the router routinely fills next to an
already-correct opponent, not a second fact to reconcile. -
A
threshold_countquestion no longer answers the league's ranking when
it names a player the model dropped (#138). "how many times has embiid
fouled out?" arrived with noplayerslot and answered "Karl-Anthony Towns
had the most games with 6+ fouls" - a question about Joel Embiid, who has 0
such games in the 2026 season it defaulted to and 9 in his regular-season
career. The subject is now restored from the question's own grammar the way
single_game_highalready restores one for "most points curry scored in a
game" - a name before a scoring verb or "fouled out", or carrying a
possessive - so a genuine league question ("most 30+ point games this
season") still stays league-wide. -
A
threshold_countplayer named with no verb at all is restored too
(#148), the shape #138's fix did not reach. "jamal murray games with 2
threes including playoffs" arrived with noplayerslot and answered
"Julian Champagnie had the most games with 2+ 3-pointers in the 2026
postseason, with 19" - Murray, who has 58 postseason games and 426 career
games with 2+ three-pointers made, was nowhere in it. A second grammar,
read after the first, restores a name directly before "games with"/"games
of" or before "<N>[+] <stat>games" - "Sga games with under 14 fta" and
"murray 30 point games" name a subject the same ungrammatical way. It also
keeps one extra word immediately before the name, to catch a first name:
"jamal murray" resolves to one person where a bare "murray" is five players
who all have a 2026 box score (Collin Murray-Boyles, Dejounte, Jamal,
Keegan, Kris) and would only trade the league-ranking bug for a needless
clarifying question. Kept as a separate pattern from the first grammar
rather than folded in, because sharing one regex let a trailing possessive
("murray's games of ...") get swallowed whole - apostrophe and all - into
the captured word once a "games of" ending sat in the same alternation as
the possessive branch.