Skip to content

association 4.3.0

Choose a tag to compare

@jeffknupp jeffknupp released this 20 Sep 01:29
· 828 commits to master since this release
  • 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 compose query/player_games rather 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_stat question naming a single
    game at one end of the span ("his last game", "her first game of 2026")
    gets order and limit: 1 set together in route() from the question's
    own words, and player_stat hands it to game_log; "show maxey's stats for
    his last postseason game" answers that game rather than his postseason
    average. A filler order on 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 a threshold_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 still game_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 a situation.
    route() keeps the ordinal as season_n and drops a year the question
    itself never named; game_log, player_stat and threshold_count resolve
    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 a round) into a game_n slot;
    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_log and player_stat honor
    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 (below was 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
    new above slot carries a minutes floor ("with 25 minutes", "20+ mins",
    formerly a refused situation); templates.common.measure_filters reads
    them onto the player_game relation (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_stat and threshold_count
    honor both, and the answer says what it kept ("with under 14 free throw
    attempts"). A threshold_count phrase 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.md keeps 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 ## P2 heading with it (898ef66), so every
    P2 read as a P1 and sync_issues.py would have labeled a new one that way;
    #127 had the P3 heading missing for days before that. Restored here.

  • A filler limit on player_stat is dropped whatever its size. Now
    that a limited player_stat is answered as a log, a count the question never
    named would answer a different question: "Portis vs bulls 2019-20 to
    2023-24" arrived with limit: 5 and became a three-game log where his
    averages were asked for. route() drops any limit on player_stat that 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 with without: ['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_nicknames now leaves alone a nickname the router already put in
    another slot; the question answers with Turner's last 10 games without him.

  • since is a scope the relation honors. "jokic vs cade since 2022"
    answers their 7 meetings across 2022-2026 instead of this season's one:
    _span_of and _condition_scope take since and build a span from that
    season on (the phantom still excluded, never earlier than the table
    reaches), game_log, player_stat and player_matchup declare 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_stat with a limit or an order hands the question to game_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_stat declares order. Note the dependency this creates: a filler
    limit the 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), replacing templates.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
    (behind streak, player_splits, record_when and with_without) now
    derives its join, its played guard and its rebuilt-line blanking from
    query/player_games.py instead of its own join to real_games and
    team_box_stats - the side and the scores come from games directly.
    Measured first: on the 2026-09-19 warehouse no played box-score row sits on
    a game real_games drops (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_sql reads 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 by player_games.column. player_matchup's
    meetings now come from it instead of conditions._meetings' own join to
    real_games and team_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.py is 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_count and single_game_high now
    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 (a games row 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, because SPLIT_WORDS records the category
    starter_bench and discards which half was named - right for player_splits,
    whose answer is both groups side by side, and useless to a template that has
    to filter. TemplateContext carries no question text, so route() reads the
    half (_split_side), the way it already reads the side of the ball: no
    ROUTER_SCHEMA change, so no other question's slots can move.
    The filter itself is one clause added to _narrow_player_games, which is
    where opponent, venue and without already compose over the same set of
    player-games - so it reached game_log and player_stat at 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 by check_scope
    for these two, because both groups side by side is player_splits' answer
    and not one they produce.

  • period_split honors the same named half, filtering on the starter
    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 limit of 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,
    and player_stat refuses any limit, so "westbrook stats as a starter for
    kings" was refused as "a game_log question" over a narrowing nobody
    requested. A bare limit of 1 with no order is now dropped for the intents
    that cannot honor order, unless the question names a count - the same
    filler rule _route_side_and_order already applied when an order carried
    it in.

  • A history file now names the build that produced it:
    <commit>-<hash>.log rather than <hash>.log, with a matching build: line
    in the header. The release version alone cannot identify a behavior - dozens
    of commits share 4.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.

    git is asked about the package's own directory, never the caller's, so
    running association query inside 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
    finally in Agent.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 reach resolve_player unrepaired: "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_player anchors 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_completion also 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.py and have been rankable since 2.1.0, but
    player_stat knew 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_STATS is 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 score is a leaderboard metric. avg_game_score was 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.

  • leaderboard and player_stat now reach that metric. stat is the one
    REQUIRED slot in ROUTER_SCHEMA, so a constrained decoder filled it with the
    nearest value it knew - "game score nba leader" arrived with stat='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_side reads which half of a fingerprint was asked for, and sets
    the spelling each template expects (avg_game_score for leaderboard,
    game_score for player_stat) - anchored so "score" alone, which means
    points everywhere else in basketball, is not swept in with it.

  • head_to_head honors venue and date instead 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 way game_log's own
    date replaces 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_matchup answers a player-vs-team question instead of refusing
    its opponent.
    "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 team opponent - router._route_matchup_against_team
    exists for this exact shape but only sees the router's raw output, before
    entities.scope_from_question restores a name it drops afterward. One name
    and a team opponent is answered the way game_log answers 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 into opponent, or (checked against the
    warehouse) the real subject's own teammate, named a second time in
    without. _player_matchup_drop_fabricated_second eliminates the noise -
    a name matching no player at all outright, a name duplicating without
    only when confirmed by the same near-spelling resolution without already
    trusts - and folds the question into the one-player-and-a-team branch,
    which already reads without because game_log does. A genuine
    two-player matchup with a without left over is refused from inside the
    template rather than silently dropped, the same way opponent already 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 as team='Riley', which find_players cannot settle alone - three
    Rileys share the surname - but the question spells the whole name.
    entities._scope_from_question_player_in_team_slot now falls back to
    players_named_in, gated on the fragment sharing a word with what it
    finds, the same discipline override_invented_players applies to an
    invented name. Measured against the 261-query StatMuse replay set: 7 rows
    move, all from fell_through to either a correct answer or an honest "did
    you mean" - none from correct.

  • suggest_players no 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_players no 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_NICKNAMES gained "og" (OG Anunoby), "rui" (Rui Hachimura, over an
    alphabetically-earlier "Rui Betancourt" the warehouse also holds), and
    "ant man"/"ant-man" (Anthony Edwards).

  • player_splits honors a venue and/or an opponent rather than falling
    through - "Jalen Duren away vs Denver", "Pat Spencer home vs the Suns" - the
    same filters team_record already applies to a team. A limit greater than
    1 is refused instead of silently answering the whole span it should have
    narrowed to: player_splits has no "last N games" mechanism the way
    game_log does, and answering the full season under that framing would be
    the exact silent substitution check_scope exists to stop. A bare limit
    of 1 is left alone - the router already uses it as filler elsewhere, and
    here it never changes the answer.

  • team_record honors a calendar month, filtering ("76ers record in
    October, away") or breaking a record out by month ("Knicks record by
    month"), read from games rather than standings, which has no per-game
    date to filter or group by. Every other situation value - 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_points and
    period_split (a sparkline plus a per-game table), player_splits (one
    table per split, home/away, starter/bench, wins/losses, by month),
    with_without and record_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) and team_outlook (a stat-card grid
    for BPI, record, chances and strength of schedule). Together with the
    seven RENDERERS already had, every intent but the tableless coach
    refusal now has one (ISSUES.md #110, staged plan step 1 - renderer-only,
    no template change: every key read was already on Answer.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_log no longer answers a team's log when the question named a
    player
    (#147). game_log took its team branch before it read player,
    so a team slot 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 an opponent already named wins over all three, since a team slot
    beside it is the same noise the router routinely fills next to an
    already-correct opponent, not a second fact to reconcile.

  • A threshold_count question 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 no player slot 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_high already 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_count player 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 no player slot 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.