Skip to content

association 5.0.0

Latest

Choose a tag to compare

@jeffknupp jeffknupp released this 09 Oct 16:19
· 126 commits to master since this release
  • Breaking (packaging): the distribution is association-py on PyPI; the import package, the association command and the repository keep the bare name. PyPI refuses association as a project name though nobody holds it: no project row (its per-project RSS route answers 404, as for a made-up name), no release ever, no name confusable with it under PyPI's rule over its full index of 909,700 projects, and no standard-library module - of the four causes PyPI's help page lists, that leaves an administrator's prohibition. So pip install association-py (and 'association-py[web]') is the install command from this release on; association.__version__ and docs/conf.py read the installed metadata under the new name; association web --help and the missing-extra hint say pip install 'association-py[web]'; the wheel is association_py-X.Y.Z-py3-none-any.whl. The releases before this one carry the old name in their metadata and stay on GitHub only. scripts/bump_version.py moves a tag-pinned association[web] @ git+... install line to the new name with its pin (EXTRAS_PIN), since pip and uv refuse a name @ url install whose metadata name differs; one test. Trusted Publishing is unchanged: the publisher on PyPI is registered for association-py (AGENTS.md, "Releasing").
  • Breaking (Python API): the answer side chooses a reader by the planned point's shape and a sayer by the body, never by an intent (the Phase 2 review's cleanup (b)6, "the Result is typed", third commit). compose.answer picked a reader by intent == (72 mentions of "intent" in compose/__init__.py), passed every reader STATED_SCOPING[intent] (23 sites), and say() was a decision list over the span's source, the relation, a facts key and the body. Now the planner settles what a point is read and said as - compose.plan.PointShape(relation, shape, by) (the target types' relation, shape and by; player_periods and team_periods name a quarter's read), by plan.shape_of(intent, query), on the new Planned.shape - and compose.answer looks the reader up in one table by that key alone (compose._ROUTES; a route marked only is the point's only answer and declines rather than step aside). STATED_SCOPING is keyed by PointShape; plan.SHAPE_WORDS names each shape's retired words, and plan.words_stated(name) is the planner's lookup while it still reads intents. say() chooses from the headline body's type and the one field that says what it is (Scalar.how, Grouped.by, Rows.by, Chart.kind) through three tables; Span.source chooses nothing (a team's record tells a tally from a standings line by its detail part). The intent is read by the planner only where the query alone cannot tell two retired templates' words apart (a log from a quarter's, the league's ranking from a quarter's, a count's shapes, a team's own total tried on its season line first; # Phase 3: needs the Reading's own shape and by), and as Planned.floor, the coverage entry the point's season is checked against (coverage.SOURCES is still keyed by the retired words; # Phase 3: needs the relation's declared tables). "intent" in compose/__init__.py: 72 -> 6 mentions, all in prose; in query/: 671 -> 635 (the planner gained what the answer side lost). Identical on 628 of 628 recorded questions, 2,082 of 2,082 feed answers and 2,710 of 2,710 readings, text and remarks; 2,556 of 2,556 unit-test calls identical in every outcome, and 981 of them differ in one argument by construction - the planned value compose.answer is handed carries the two new fields (shape, floor), checked by stripping them: 0 differ. Tests: 0 deleted; the scoping tests read words_stated(name) and SHAPE_WORDS (9 files, by name), an empty log test gives its body; 1 added (the answer side names no intent - watched to fail).
  • Breaking (Python API): Result.facts is typed per shape, and the cells a read applied are typed values on the narrowing and the span (the Phase 2 review's cleanup (b)5, "the Result is typed", second commit). Result.facts was a Mapping[str, Any] written at 51 constructions with about 80 distinct keys and read by the sayer at about 120 sites (inventoried at run time over the 628 recorded and 2,082 feed questions, ~/association-research/stages/facts_census.py, and statically, facts_inventory.py). Each key is now one of three things. A cell the read applied is a typed value: the new result.Cell union (Period, OnDate, Line, Role, GameOfSeries, Calendar, Companions, Met, ShotValue - the minimal set the keys needed, declared by this step) on Narrowing.cells, read by type (Narrowing.cell(kind), lines(), period), and Span.since/until/ordinal; a split's or a run's parameter is its body's (Grouped.of: the line a record splits by, the teammates a with/without split divides by; Runs.line/won: what a streak held along). A fact the sentence needs is a field of one frozen record per shape (result.Facts: CountFacts, LogFacts, LineFacts, SplitsFacts, PeriodFacts, PeriodRankingFacts, TeamPeriodFacts, RankingFacts, RecordFacts, MatchupFacts, MeetingsFacts, TeamRecordFacts, TeamStatFacts, TeamRankingFacts, OutlookFacts, NetPointsFacts, ChartFacts), read by the sayer through one accessor that raises on a record of the wrong shape. A key that only chose the sayer is gone: a period split's "period" in facts is its Period cell. Moved off the bag besides: the about page values of a player's line and splits (now read off the cells), a ranking's season and a fingerprint's (the span's), a period log's order and limit and a period's window (Result.window), a shot distance's one game (an OnDate cell and the window it ended), the outlook's and the NetPoints season's "nothing found" (an empty body of the shape, where a Result with no parts was). Narrowing.period is the Period cell (a label and its numbers, where it was a string); player_games.Narrowed.lines keeps each line as values beside its words. Scalar.how names every scalar's reduction (season, per_shot, per_100, total, projection, ranked beside per_game, count, record). Identical on all four populations, text and remarks: 628 of 628 recorded questions, 2,082 of 2,082 feed answers, 2,556 of 2,556 unit-test calls, 2,710 of 2,710 readings. Tests: 0 deleted; 10 hand-built Results in test_say.py and test_templates.py rewritten to the typed facts and cells; 1 added.
  • Breaking (Python API): a refusal found at RUN is typed - result.Refusal(kind, facts) and result.Clarify - and its sentence is the sayer's, one phrase per cause (the Phase 2 review's cleanup (b)1, "the Result is typed", first commit). Until now a reader that found nothing to say returned a worded Reply (36 constructions in compose/, 6 in the relation modules), a Result with no parts and facts["missing"] (compose/team_stats.py, which say branched on), or a Result.empty sentence; now every reader returns Result | Refusal | Clarify and nothing outside compose/say.py writes a refusal's words. Refusal.kind is closed: a READ's causes are the new result.RUN_CAUSES (50 kinds: a season under a floor, no games in the span, none matching the narrowing, a teammate never seen, a team short of games for points allowed, why no fingerprint can be drawn, ...), a READING's stay reading.CAUSES (17) - two disjoint sets, held by a test, because a reading's cause is decided from the words before any table is read and is pinned by the readings population, while a read's is a fact found in the warehouse; one Refusal type carries either, and say.refusal_phrase(kind, facts) is the one table that words both (the planner's sentences moved there from compose.plan, whose refusal_result now says refusal_of(cause) through it). A Refusal also says what the page shows beside the sentence (shown) and under which keys (under), since the readers' pages differed and the answers' data is held identical. Clarify(asked, candidates, kind, active, why) is the question back - several on record by a name, or a name nothing matched with near spellings. Moved or reshaped on the public surface: entities.clarification and entities.suggestion are compose.say.clarification/suggestion; entities.no_match is entities.unmatched, returning a Clarify or a name_unmatched Refusal; entities.clarify/resolved_player/resolved_team/optional_team return a Clarify where they returned a worded Reply; player_relation.no_narrowed_games and no_games, team_relation.condition_team_no_games return a Refusal; Result.empty is a Refusal; compose.core.Refused carries the typed outcome; fingerprint.FingerprintUnavailable carries a cause and its facts (kind, facts) rather than a sentence; coverage.floor_refusal/coverage_refusal are check_coverage typed; say.say_period_refusal, say_period_unread, say_team_period_*, say_period_rank_*, say_conference_refusal and rankings.leaderboard_shot_distance_refusal are phrase-table entries. A combined record's half that was a refusal no longer has its words searched for wins and a "Note:" tail (none of the refusals a half can come back with carried either). Of the 614 answered recorded questions, the ones worded outside the sayer went 35 -> 7 (~/association-research/stages/typed_census.py); the 7 are refusals.unanswerable/by_question, which read the question's own text in the answering loop and move with Phase 3's first item. Identical on all four populations, text and remarks: 628 of 628 recorded questions, 2,082 of 2,082 feed answers, 2,556 of 2,556 unit-test calls, 2,710 of 2,710 readings. Tests: 0 deleted; 13 rewritten to say the typed outcome (test_fingerprint.py 9, test_entities.py 5 via _no_match, one each in test_say.py, test_compose.py, test_templates.py); 2 added.
  • A measure that reads no rebuilt line carries its own games count, a ranking by it qualifies on that count, and the answer says the games it skipped (ISSUES.md #327, the Phase 2 review). Since c6e9253 a rate over a column the rebuild never measured skips rebuilt rows in both sums - right - but the games beside it and a ranking's "minimum 20 games" still counted every game, unsaid; the same held for turnovers, 3-pointers, attempts and minutes, which a rebuilt line also lacks. compose.core.unread_on_rebuilt names those measures; _scalar_selects reads each one's games beside it ("<measure>_games", COUNT(*) FILTER (WHERE NOT pgl.reconstructed)), kept on a row only where fewer than games; _compile_grouped's minimum counts the ranked measure's games (_ranked_games); core.unread_note writes a lines_rebuilt note (what "unread", or "unread_ranked" for a ranking with a minimum; lines_rebuilt takes the columns fact now), phrased once in say.note_phrase, said by say_splits and by the compiler's sentence (which also prints a short row's count beside its figure, "TS% 72.2% (28 G)"; for a named player it replaces the "shown" rebuilt note, the same games). player_relation.box_score_notes (the notes as sentences) is deleted: core._box_notes, its last caller, reads box_score_notes_read and phrases the notes itself. A table's G and W-L stay every game played - the heading counts those, and a record has no rebuilt figure to skip. Measured first (~/association-research/stages/rate_games_measure.py): 0 of 628 recorded questions, 4 of 2,082 feed questions (3 rates, 1 FGA). Moved, every one of them: "curry playoff stats 2015-18 including TS% ..." - Taj Gibson "22 G TS% 64.7%" (10 games read) leaves the list, Amir Johnson enters 10th "32 G TS% 61.7% (30 G)", JaVale McGee "TS% 72.2% (28 G)", and the note "5 of the listed players' 275 games have no box score from ESPN: ... the minimum counts only those."; "highest career field goal percentage ... shooting guard" - same ten, six counts beside (Tony Allen "817 G FG% 47.7% (770 G)", Othyus Jeffers "(33 G)", ...) and "82 of the listed players' 4,510 games ..."; "most missed shots in a season since 1977" - same ten, five counts beside (LeBron James "(1588 G)", ...) and "96 of the listed players' 8,060 games ..."; "zach lavine career record" - table unchanged, adds "37 of these 693 games have no box score from ESPN: their figures are rebuilt from play-by-play, which leaves no minutes, turnovers, 3PM or FG%, so those are read over the other 656." 628 of 628 recorded questions and 2,710 of 2,710 readings identical; 2,078 of 2,082 feed answers (run --feed), the 4 apart those named above; 2,551 of 2,554 unit-test calls, the 3 apart the rebuilt-league fixture's Tatum split gaining that note ("1 of these 4 games ..."). Tests: 4 new (the split's note, the ranking's qualifier and count, no count where none was skipped, one sentence for a named player's rebuilt games), each watched to fail on the base; 1 narrowed (test_a_rebuilt_game_is_no_longer_an_unknown_game asserts the unseen-games sentence's own words, not "no box score", which the new note says of the rebuilt game); none deleted or moved.
  • Breaking (Python API): association.query.templates is gone - the relations' shared steps went home (Phase 2, step 6). 96 names moved, each to the module of its callers, under public names; the 29 private aliases the readers imported them by (_Span, _period, _span_of, _resolved_team, ...) are deleted, not carried. To query/player_relation.py (new, 37: the player relation's steps - scoped_player, scoped_games, league_games, condition_player, ResolvedSpan, span_of, settle_ordinal_season, relation_window, whole_span, apply_situation, apply_period, no_narrowed_games, box_score_notes*, condition_scope, no_games, the teammate and condition resolvers, RELATION_SCOPING/RELATION_SCOPING_EXCLUDED, rebuilt_in_scope/empty_box_scores, team_slot_for_player; a sibling of player_games.py because the two together would be 2,600 lines); to query/team_relation.py (new, 12: scoped_team, team_games, team_span_clause, TEAM_RELATION_SCOPING*, team_span_label, condition_team_no_games, league_team_narrowed - its own module because conditions imports team_games and these steps import the player relation's span steps, so one module would be a cycle); to query/coverage.py (new, 17: check_coverage, coverage_caveat, SOURCES - was TEMPLATE_SOURCES, one table for every reader - RANKING_INTENTS, TABLELESS_INTENTS); to entities (5: resolved_player, resolved_team, optional_team, slot_season, clarify); to season_line (8: PLAYER_STAT_COLUMNS, HISTORY_COLUMNS, SHOOTING_STATS/ShootingStat, ADVANCED_STATS/AdvancedStat, MADE_STAT_ATTEMPTS, wanted_stats); to reading (4: SCOPING_SLOTS, unhonored_scoping, PLAYER_INTENTS); to season_text (3: SEASON_TYPE_NAMES, season_phrase, season_label); to compose.say (4: table_cell, format_value, count_games, defaulted_season_note); to conditions (3: condition_span_label, condition_needs_player_refusal); to player_games (2: THRESHOLD_STAT_COLUMNS, STAT_LABELS); to compose.rankings (1: leaderboard_shot_distance_refusal). unhonored_scoping sits in reading rather than beside STATED_SCOPING in compose.plan because every reader imports it and plan imports the readers. 2,842 lines of templates/ deleted with the package (3,011 at step 6's start). The private-template-import ratchet reached 0 and scripts/check_ratchets.py no longer has it (nor ratchets.json its key); sql_outside_the_relations moved, 38 statements before and after: templates.common 13, .players 3, .games 1, .splits 1 gone, player_relation 16 and team_relation 2 new - two new entries --shrink cannot write, edited by hand. The import contracts name the three new modules on the answer side. Tests: 1 deleted (the private-template-import ratchet's own), none moved; imports updated. 628 of 628 recorded questions (text and remarks), 1,408 of 1,408 unit-test calls and 2,710 of 2,710 readings identical.
  • A question routed to an intent nothing reads is refused saying so, not naming a template that does not exist. "Nothing here answers this question: intent 'other' has no template yet." claimed a missing template, the wrong cause once no template is left to add (Phase 2, step 6); it says "intent 'other' has no reader." Agent.unanswered carries the same words. 0 of 628 recorded questions reach it; 5 of 1,406 unit-test calls (Agent.ask on other) move, each this sentence and nothing else; 2,710 of 2,710 readings identical.
  • check_scope, HONORED_SCOPING, TEMPLATES and the answering loop's template branch are gone - Phase 2, step 6: with TEMPLATES empty since the shot slice, check_scope checked a declaration that declared nothing (HONORED_SCOPING held only comments), and Agent._run_scoped_template, _run_template and the handler lookup ran for no intent. Every intent is the compiler's (compose.COMPILED_INTENTS); a narrowing a reader's retired words do not state is declined by compose.plan.unhonored_scoping against STATED_SCOPING, the one declaration left until Phase 3's cells. _settled_before_template is _settled_before_reading. tests/stage_calls.py records no template: boundary any more: it recorded 0 of the 1,408 calls (the template boundary emptied with the last template, step 5; what replaced it, the readers behind compose:answer, has been recorded since step 0). tests/query/test_frozen_shapes.py freezes only the 25 intents and the page's 22 renderers (and the presenters staying gone); the template and scoping-table freezes went with what they froze. Tests: 2 deleted with their subject (a walk of TEMPLATES' sources for the player slot, and one of HONORED_SCOPING's slots), 11 moved from check_scope to the reader's declaration (unhonored_scoping over STATED_SCOPING, or the planner's declined), 2 renamed (test_every_intent_is_reachable_from_the_reader, over the compiled intents). 628 of 628 recorded questions (text and remarks), 1,406 of 1,406 unit-test calls (and the 2 of a renamed test, identical under its new name) and 2,710 of 2,710 readings identical.
  • Breaking (Python API): TemplateResult is association.query.answer.Reply, TemplateContext is association.query.answer.AnswerContext, and the TemplateUnsupported alias is gone (association.query.reading.Unsupported) - Phase 2, step 6, its first commit: the types the whole answer side returns no longer say "template", since no template is left to return one. Moved, not reshaped: the same fields, beside Answer and Artifact in the one module that imports nothing else of the query package. Every import updated (47 files); moved first, so the relation's shared steps leaving templates/ next import them from their own home and are moved once. 628 of 628 recorded questions (text and remarks), 1,408 of 1,408 unit-test calls and 2,710 of 2,710 readings identical.
  • A shot chart or distance a shot-value filter emptied no longer says the player "last appears in" the season asked about (ISSUES.md #296). "Deandre Ayton's threes" (601 located shots in 2026, none a three; no season named) answered "No shots found for Deandre Ayton with the given filters. He last appears in 2026. The warehouse holds his 2019-2026 regular seasons; name one." - the #18 redirect fired because nothing was drawn, though the filter emptied the chart, not the season. The redirect is now given only where the read was every located shot of a defaulted season (no shot value): the chart answers "No shots found for Deandre Ayton with the given filters.", and the distance's new redirect (above) keeps the same rule. This subsumes the free-throw fix above (a refusal only ever comes with a shot value). 628 of 628 recorded questions, 64 of 64 direct scopes and 1,411 of 1,411 unit-test calls identical; one new test, watched to fail.
  • A shot distance in a defaulted season with no shots names the seasons he is on record for, as the shot chart does (#18). "Tim Duncan's average shot distance" (no season named) answered "No shots with recorded coordinates for Tim Duncan in the 2026 regular season." - true of a year the question never named; it now goes on "He last appears in 2016. The warehouse holds his 2002-2016 regular seasons; name one, or ask for his career." (a season_redirected decision on the Result, phrased by decision_phrase; a distance answers a career, so the sentence offers one, which the chart's does not). Michael Jordan's the same ("... He last appears in 2003. The warehouse holds his 2002-2003 regular seasons; ..."). A named season keeps the plain line. 2 of the slice's 64 direct scopes moved (these two); 628 of 628 recorded questions and 1,409 of 1,409 unit-test calls identical; one new test.
  • A free-throw shot chart in a defaulted season no longer redirects to the seasons he played. "Stephen Curry free throw chart" (no season named) answered "Free throws are all taken from the same line and carry no court position worth drawing, so there is no free-throw chart to render. He last appears in 2026. The warehouse holds his 2010-2026 regular seasons; name one." - the redirect a defaulted season with nothing drawn gets (#18) fired because nothing was drawn, though the cause was the refusal, and it named the season just asked about as the one he "last appears in". The refusal now stands alone (compose.shots.read_shot_chart: no season_redirected after a refusal). Found by the slice's direct scopes (charts_shots_measure.py, 1 of 64 moved, this one); no recorded question asks it (628 of 628 identical), 1,408 of 1,408 unit-test calls identical and one new test.
  • shot_chart is a reader, a draw step and a sayer on the declared shot relation; its template, templates/shots.py and shotchart.render_shot_chart/render_for_player are gone (Phase 2, step 5, slice (v)). compose.shots.read_shot_chart is the template's read and the renderer's moved whole: the player resolved once through shotchart.resolve_chart_player (narrowed to who took shots in the span, the best match kept where nobody is left), the games a narrowing or a window pins through scoped_games and the relation's games_subquery, and the shots read off shot_chart - every statement through core.values_of (a pinned game is now an IN (?) list like a set of them, the same rows). It reads a Result whose body is the draft's Chart (result.Chart, new: the artifact's kind, made and attempted, the marks, the page's title and caption, its file name and - once drawn - its path; Result.chart), with its remarks as kinds and facts (shots_unlabeled, shot_values_derived, the career's floor on the shot table) and decisions (also_matched, phrased once in decision_phrase now; season_redirected for a defaulted season that drew nothing); the free-throw and unseparable-season refusals stay the renderer's exact sentences (Result.empty). compose.shots.draw_shot_chart writes the page (court.render_court_html) between the read and the sayer - the RUN stage's last act, so the reader writes nothing and the sayer, which names the file, takes the Result alone - and say_shot_chart words it as the template and the renderer did, the file as Answer.artifacts. shot_chart joins compose.COMPILED_INTENTS; HONORED_SCOPING and TEMPLATES lose its row; templates.common.clarify is public (_clarify stays its alias). Breaking: shotchart.render_shot_chart and shotchart.render_for_player are removed - their only caller since the retired agent was their own tests; period and made_only, which no question reaches, go with them. Templates 3 -> 2 (1 -> 0 once rebased onto the NetPoints charts: TEMPLATES stands empty until step 6 deletes the registry); statements shotchart 1 -> 0 and templates.shots 1 -> gone, compose.shots runs none itself; scoping tables 14 -> 13 (templates.shots._SHOTS_GAME_NARROWING_SLOTS: the reader derives the cells inline from RELATION_SCOPING). Measured both ways first (charts_shots_measure.py): 22 of 22 recorded charts identical in text, data, remarks and the page's HTML byte for byte (paths masked), and 64 of 64 scopes the corpus does not reach (derived and unseparable seasons, careers clipped by the floor and wholly before it, defaulted seasons redirecting, free throws, windows, dates, opponents, ambiguous and misspelled names), both intents, called directly on each tree. 628 of 628 recorded questions identical; 2,710 of 2,710 readings; 1,404 unit-test calls: 1,362 identical, 42 the template boundary become compose.answer's - 38 the same outcome, 4 declines whose reason is the declined callback's - and 4 calls only after (three of render_shot_chart's tests re-seated on the reader, one moved off it). Tests: 9 render_shot_chart tests deleted (5 of behavior no question reaches or the snapshots hold - event_id, made_only, period, the written file, the game's label - and 4 re-seated as 3 reader tests plus one moved), 40 shot_chart tests re-seated through compose.answer. Re-proved after rebasing the slice onto the NetPoints charts (da138a2), the whole slice against a fresh run of that base: 628 recorded questions, 588 identical and 40 moved in the reading and query stages only (the reading move above; answers, data and remarks identical); 2,710 readings, 2,644 identical and 66 the reading move; 22 of 22 recorded charts identical in the page's HTML bytes; 61 of 64 direct scopes identical, the 3 moved the two fixes below; 1,399 unit-test calls, 1,319 identical, 63 the template boundary become compose.answer's with the same outcome, 8 declines now carried by the declined callback (one re-seated test, 1999 -> 2010), 9 the reading move, and 9 calls only after (the new and re-seated tests).
  • shot_distance is a reader and a sayer on the declared shot relation; its template is gone (Phase 2, step 5, slice (v)). compose/shots.py's read_shot_distance is the template's read moved whole - the player and span through scoped_player, the games a narrowing or a window pins the read to through scoped_games and the relation's games_subquery, the average over shot_chart - with both statements built there and executed through the compiler's one door (core.values_of, over a season_line.Statement); the distance statement also counts the games the shots came from (Scalar.games), which no answer says. It reads a Result on a Span whose source is "shots" (new), one Scalar (avg_feet, attempts), and its remarks as kinds and facts: a derived season's shot_values_derived, and the career's floor on the shot table (career_before_floor, career_clipped, career_whole), each phrased once in note_phrase; one game a window alone reached is said by its date as facts, any other set by the span and the relation's own phrase (Narrowing.phrase). The unseparable-season refusal, the name's and the games' refusals stay the template's exact sentences, passed through. say_shot_distance words it as the template did. shot_distance joins compose.COMPILED_INTENTS; HONORED_SCOPING loses its row (moved to STATED_SCOPING with the reading move); the coverage floor still reads TEMPLATE_SOURCES["shot_distance"] through check_coverage, now before the reader as it was before the template. Templates 4 -> 3 (2 -> 1 on the tree after the NetPoints charts merged); templates.shots statements 2 -> 1, compose.shots 0 (every statement through core.values_of). Measured both ways first (~/association-research/stages/charts_shots_measure.py): 16 of 16 recorded distance answers identical in text, data and remarks; 628 of 628 recorded questions identical, 2,710 of 2,710 readings; of 1,404 unit-test calls 1,375 identical and 29 the template boundary become compose.answer's - 25 with the same outcome, 3 declines whose reason is the declined callback's (the same text the tests assert), and one test re-seated from 1999 (under the shot floor, now refused by the floor before the reader runs, as the answering loop always did) to 2010. Tests: 24 re-seated on the reader through compose.answer (test_templates._compiled), none deleted.
  • shot_chart and shot_distance have a point of their own (Phase 2, step 5, slice (v): the reading move, enumerated). The point reader declined both with "no adapter for" (or, for a chart naming nobody, "no player subject and no ranking or position-group reading of the question"); it now reads their default point from its own set (reading.CHART_INTENTS, point.DEFAULT_POINTS): a chart and a scalar on the declared shots relation (reading.Shape gains chart, reading.Relation gains shots), which no word moves. The planner plans it as a compose.shots.ShotQuery, declining a narrowing beyond what the retired templates' words state (compose.plan.STATED_SCOPING gains their HONORED_SCOPING rows) in check_scope's sentence; until the readers land, compose.answer declines a ShotQuery ("the shot relation's readers are not ported yet") and the templates still answer. Moved, and only in the reading and query stages: 40 of the 628 recorded questions (22 + 2 charts, 16 distances), 66 of the 2,710 readings (49 charts, 16 distances, and one chart naming a playoff round now declined by the planner with check_scope's sentence for round), 9 of 1,404 unit-test calls; every answer, its data and its remarks identical.
  • fingerprint is a reader, a draw step and a sayer over the NetPoints relation; its template and templates/netpoints.py are gone (ROADMAP.md, "Phase 2, the expected steps", step 5, slice (v), and the decision "Charts are declared shapes"; no answer moves). compose.netpoints.read_fingerprint resolves the names as the template did (best-match against the table the plot is drawn from, shotchart.resolve_chart_player; at most three, MAX_FINGERPRINT_PLAYERS, moved here) and refuses a calendar date in its own words; fingerprint_result reads the season's or a first or last game's fingerprints through the chart's own reader, query/fingerprint.py (load_for_players), into a result.Chart (declared here as the shot-chart slice drafts it: kind, made/attempted, marks, title, caption, file, path) whose marks are one (PlayerFingerprint, game) per polygon, with a player under the pool's floor a below_pool note, one with no row a no_data_for note and the other names that matched the also_matched decision (its phrase, ". Note: other players also matched: [...]", is the one the shot chart says too). compose.netpoints.draw_fingerprint writes the radar page between the reader and the sayer (fingerprint.fingerprint_page over query/radar.py) and sets the chart's path: a reader names no file that does not exist yet, and the sayer takes the Result alone. compose.say.say_fingerprint words it - "Rendered NetPoints fingerprint (total) for ... (2026 season, percentile scale) to " and its notes - word for word, with Answer.artifacts the same one-file list. The loader's own sentences (no rows for the season, none for the players named - naming the players, never the season, the Maxey rule - no game in the pool, the per-game table not pulled, with the runners-up on the failure) stay the loader's, returned as the relation's refusal. The "vs" note for a comparison that drew one polygon (entities.compared_but_unmatched, a name_left_out decision) reads the question, so the answering loop attaches it to the compiled answer after the coverage caveat, never beside the coverage floor's refusal (agent._unmatched_fingerprint; it was in _run_scoped_template). fingerprint joins compose.COMPILED_INTENTS and leaves TEMPLATES (3 -> 2, the shot charts) and HONORED_SCOPING; templates/netpoints.py is deleted. Removed from the public API: fingerprint.render_for_players and fingerprint.render_fingerprint, the single-call entry points the retired agent called as a tool - since the agent went (5.0.0) only their own tests called them; their read, captions, file name and drawing are fingerprint.load_for_players, fingerprint_captions, fingerprint_file and fingerprint_page, and their message is the sayer's. query/fingerprint.py's three statements run through core.values_of. Identical against the tree before (bec07a9): 628 of 628 recorded questions, text and remarks included; 2,710 of 2,710 readings; the measure harness 40 of 40 recorded and 11 of 11 synthetic fingerprint questions identical in values, text, remarks and answered_by, and all 41 artifacts byte-identical with the output directory masked (the categories, values, percentiles, totals, season or game label and every polygon of a comparison); 1,399 unit-test calls, 16 differing only where the 14 re-seated template tests now cross compose.answer and where two tests' inputs changed (below). Ratchet: sql_outside_the_relations loses fingerprint: 3; compose.netpoints runs none directly. Tests: 14 in test_templates.py re-seated on the compiler (_compiled("fingerprint")), 25 in test_fingerprint.py re-seated from the two entry points onto the reader, the draw step and the sayer (a near spelling's reading asserted as collected for the answering loop, and a refusal asserted as returned rather than raised); in test_agent.py two re-seated from a template stand-in onto compose.answer and one deleted (a template's refusal handed to a compiler with no reading of a fingerprint - the path is gone for this intent); a season-1999 refusal moved to 2020 (1999 is the coverage floor's now); the check_scope assertion re-seated on STATED_SCOPING.
  • player_netpoints is a reader and a sayer over the NetPoints relation; its template is gone (ROADMAP.md, "Phase 2, the expected steps", step 5, slice (v); no answer moves). compose/netpoints.py's read_player_netpoints reads one player's NetPoints into a Result on a span whose source is the new "netpoints": a season's line from net_points_player (a Scalar of its six columns) and the play-type split behind it from net_points_player_fingerprint (a Grouped(by="category"), one row per category with its offense, defense and total per 100 possessions beside the season totals, and the partition flag), or one game's from net_points_player_game (a one-game Scalar under a Window, with the possessions and win probability added as its sums). The template's three statements are built there (netpoints_totals_statement, netpoints_fingerprint_statement, netpoints_game_statement) and executed through core.values_of; the defaulted-season redirect is season_line.season_redirect and a Decided("season_redirected"), as before. The notes are kinds and facts (part_missing for the line or the split, definition for the units and the overlapping slices, hint for the fingerprint of that game), phrased once in compose.say.note_phrase; say_player_netpoints words every branch word for word, with the page's data unchanged. compose.answer reads a NetPoints point after the same coverage floor the template had (_read_netpoints), and a reader's refusal while reading (no player named, the per-game table not pulled) is the decline with the template's reason. player_netpoints joins compose.COMPILED_INTENTS, and leaves TEMPLATES (4 -> 3) and HONORED_SCOPING; its sources stay in TEMPLATE_SOURCES, which the coverage check reads for compiled intents too. FINGERPRINT_PARTITION moves to association.nba.netpoints. A branch nothing reached is gone: the template read rate="total" as season totals, but nothing ever sets rate on this intent (the router sets it for leaderboard and team_stat only; 0 of 2,710 readings) and check_scope - now the planner - declined it, so only direct template calls in tests reached it; the reader says season totals only where no possession count is on record. Identical on the three populations against the tree before (d8e22bb): 628 of 628 recorded questions, text and remarks included; 2,710 of 2,710 readings; the measure harness (~/association-research/stages/charts_netpoints_measure.py) 6 of 6 recorded and 8 of 8 synthetic player_netpoints questions identical in values, text, remarks and answered_by; 1,402 unit-test calls, 30 differing only where the 28 re-seated tests now cross compose.answer instead of template:player_netpoints and where four tests' inputs were changed (below). Ratchet: sql_outside_the_relations loses templates.netpoints: 3 (compose.netpoints runs none directly - every statement through values_of). Tests: 28 re-seated on the compiler (_compiled("player_netpoints") in test_templates.py); 2 deleted with the rate branch; 3 moved from season 1999 (now refused by the coverage floor, which a direct template call skipped) to 2020 and 2021, and the partition sum checked per 100 possessions; one check_scope line removed with the row.
  • player_netpoints and fingerprint read a point on the NetPoints relation (ROADMAP.md, "Phase 2, the expected steps", step 5, slice (v), and the decision "Charts are declared shapes"; one enumerated reading move under Jeff's rule of 2026-10-05, AGENTS.md "Identical means identical"; no answer moves). The two were read as declines their templates answered past - "no adapter for player_netpoints", "no adapter for fingerprint", and, for a fingerprint comparing two players (a players slot and no player), "no player subject and no ranking or position-group reading of the question". They are the reader's own set now, reading.CHART_INTENTS, read before any move over a player's games (point._read_point) as their default points (point.DEFAULT_POINTS: _default_player_netpoints, a scalar; _default_fingerprint, the new chart shape) on the new netpoints relation (reading.Relation, the declared relation of ROADMAP-TYPES.md), planned as compose.netpoints.NetPointsQuery. compose.plan.STATED_SCOPING declares their templates' HONORED_SCOPING rows (order; order and date) and the planner declines beyond them in check_scope's sentence (_shape_declines). The compose side declines a NetPoints point until each reader lands, so the templates still answer. Moves, all of them, in the reading and the planned query only: 628 recorded questions - 46 (6 player_netpoints and 29 fingerprint from "no adapter for ...", 11 two-player fingerprint comparisons - "compare fingerprints for embiid vs jokic in 2026", "fingerprint maxey vs jaylen brown 2026", "show a fingerprint for maxey vs jaylen brown in 2026", "compare sga and tyrese maxey fingerprint", "show sga vs tyrese maxey fingerprint", "Compare SGA and Jokic's fingerprints", "compare embiid's and jokic's fingerprints in 2026", "2026 fingerprint comparison between Maxey and Brown", "display a fingerprint for maxey vs jaylen brown in 2026", "contrast sga with tyrese maxey's fingerprint", "display sga versus tyrese maxey fingerprint" - from "no player subject ..."), each to its NetPoints point; text, remarks, result and answered_by identical on all 628. 2,710 readings - the same 46, nothing else. 1,404 unit-test calls - 18 differ in the reading's point and the planned query only (five agent and one-writer tests of a fingerprint); every outcome identical. Tests: the stated-scoping walk in test_compose.py gains the two; none deleted or moved.
  • team_record is a reader and a sayer; templates/teams.py is gone (Phase 2, slice (iv)). compose/team_records.py's read_team_record reads a team's record by the shape its slots pick out - a regular season's or a career's from ESPN's standings (compose/standings.py, over the team-season relation: the standings statements moved whole into query/team_seasons.py beside the season-line and power-index ones, through core.values_of), a tally of its games against one team, in a postseason, since a season, in game N of each series or in a calendar narrowing (the team compiler's rows read, compile_team_over, over the record's own narrowing that excludes the NBA Cup final; TEAM_ROW_COLUMNS gains tg.neutral), the table by month (the same rows grouped by month - the compiler's grouped read by month_of_year joins team_box_stats and drops a game with no box row, which a record counts), and both season types summed ("including the playoffs") - into a Result with a Scalar(how="record") body (new how) or a Grouped(by="month"); say_team_record/say_team_record_by_month word it as the template did, every remark a Note (floor, standings_short, game_list_disagrees, definition neutral_site - said where the record places it, note_phrase(placed=...)). The game-list statements (team_games.season_game_counts_sql, game_list_gaps_sql) moved into the relation; "in " is read by calendar.bare_month, a conference named as a team by refusals.conference_named (said by say.say_conference_refusal; compose.team_stats.conference_refusal now uses both). Default point point._default_team_record; its HONORED_SCOPING row moved to STATED_SCOPING. Fixed, the two "written and not said" remarks: "warriors all-time record including playoff record at away" and its paraphrase wrote the standings' and the game list's floors and said neither; the combined sentence says each half's floor after the half now - before: "... including the playoffs (523-791 (.398) regular season from 1993-94, 51-52 (.495) playoffs from 1989)."; after: "... including the playoffs (523-791 (.398) regular season from 1993-94 - ESPN's standings carry no home/road split before 1993-94, 51-52 (.495) playoffs from 1989 - the warehouse's game list starts with the 1989 playoffs)." (a test holds the sentence and that nothing written goes unsaid). The same rule says the rest of a combined record's halves wrote and dropped: a standings career's start ("147-97 (.602) regular season from 1989-90 - the warehouse's standings begin there, so this is not the franchise's whole history, ...") and a season's neutral-site game inside its half ("53-29 (.646) regular season (1 neutral-site game counts as neither home nor away), ...") - two unit-test answers, no recorded question. Also: a home or road record asked of a season whose standings carry no split no longer writes the standings-short note it never said (no recorded question or unit-test call reaches it). Deleted: templates/teams.py whole (team_record and its 40 helpers, conference_refusal, the private _tally/_possessive/_record_pct) - TEMPLATES 5 -> 4, the charts. Readings: the 42 team_record readings of 2,710 (16 recorded) gain the point, by design; 626 of 628 answers identical in text, data and remarks, the two above moved by the fix; every unit-test call's return identical. Tests: 0 deleted, 2 declaration tests rewritten on STATED_SCOPING, 1 added. Statements: templates.teams 9 -> 0; the regex ratchet loses templates.teams.
  • period_leaderboard is a reader and a sayer; its template is gone (Phase 2, slice (iv)). compose.periods.read_period_leaderboard reads the league's (or a team's) players ranked per game by a stat in one quarter or half - a Grouped(by="player", ranked_by=<measure>) with the period on Narrowing.period - or, with no period named, their points in each of the four quarters side by side (ranked_by="quarters"), the season's accuracy caveats as notes and the minimum (and the cut of the by-quarter table) as Decideds; say_period_leaderboard words both as the template did, decision_phrase gaining the cut's phrase and the minimum's "half of the N anyone played" qualifier as the answer's own words. The three statements moved whole into the player relation (player_games.period_ranking_sql, period_quarter_sql, most_games_sql) and run through core.values_of; the two refusals (a shooting percentage ranked per quarter, a plays column on a warehouse with no plays) are say_period_rank_rate/say_period_rank_unread. Default point point._default_period_leaderboard (a grouped read by player on the league-wide relation); its HONORED_SCOPING row moved to STATED_SCOPING; compose.plan.TEAM_SHAPES is PORTED_SHAPES, since this one is not a team's. Deleted: the template and its eight helpers - templates/games.py holds only _team_slot_for_player now (TEMPLATES 10 -> 9). Readings: the 30 period_leaderboard readings of 2,710 (10 recorded questions) gain the point, by design; 628 of 628 answers, data and remarks identical; every unit-test call's return identical. Tests: 0 deleted, 0 moved (bound to the compiler). Statements: templates.games 4 -> 1.
  • Two teams' meetings execute the team compiler's rows read. compose.meetings compiles the meetings through compose.team.compile_team_over (shape rows, oldest first) and executes it through core.rows_of, where it built the relation's rows_sql statement itself; the season each meeting was played in is the compile's own season column (a postseason's by its calendar year, as before). 628 of 628 answers, data and remarks identical; every unit-test call's return identical. A team's quarter keeps the relation's own period statement (team_games.period_games_sql): the rows compile selects whole games' columns, not a period's.
  • team_quarter_points is a reader and a sayer; its template is gone (Phase 2, slice (iv)). compose.periods.read_team_quarter_points reads a team's figure in one quarter or half - points from the linescore, any other column of the period's line rebuilt from the plays and caveated or refused per season by its measured agreement - into a Result with a Scalar (the total, average and extremes over the games that reached the period, or a rate's makes over attempts) and a detail Rows of the games; the quarter is a cell on the Result's Narrowing (Narrowing.period, new: the period relation's cell on a team's games), which is what say() dispatches the team's period sayer by. The statement moved whole into the relation (team_games.period_games_sql) and runs through core.values_of; its refusals (a stat nothing splits by period, no play-by-play, a season rebuilt too seldom right) and its two notes (games_unseen with why="no_play_by_play", rebuilt_agreement with what="team_period_rebuilt") are phrased in compose.say (say_team_period_unknown/_unread/_untrusted, note_phrase). Default point point._default_team_quarter_points; its HONORED_SCOPING row moved to STATED_SCOPING. Deleted: the template and its 14 helpers in templates/games.py (with _period_scope, which only it used) (TEMPLATES 11 -> 10). Readings: the 15 team_quarter_points readings of 2,710 (9 recorded questions) gain the point, by design; 628 of 628 answers, data and remarks identical; every unit-test call's return identical. Tests: 0 deleted; one private-helper test re-seated on the sayer (a tied extreme), the rest bound to the compiler. Statements: templates.games 5 -> 4.
  • head_to_head is a reader and a sayer; its template is gone (Phase 2, slice (iv)). compose/meetings.py's read_head_to_head reads two teams' meetings - the two teams from teams, team and opponent, the first team's rows of the team relation narrowed to the other through templates.common.team_games, every meeting in a season, on a date, at a venue or over a since-bounded or whole-career span - into a Result with a Grouped(by="team") body (each team's wins), the statement built by the relation (team_games.rows_sql) and executed through the compiler's one door (core.values_of); say_head_to_head words it as the template did. The intent is the compiler's (COMPILED_INTENTS, 14): it has a default point on the team relation (point.DEFAULT_POINTS, _default_head_to_head), its HONORED_SCOPING row moved to compose.present.STATED_SCOPING, and the planner declines beyond it in check_scope's own sentence (compose.plan.TEAM_SHAPES); a decline is refused, never handed to the team compiler's sums (compose._read_team_shape). Deleted: the template and its eight helpers in templates/games.py (TEMPLATES 12 -> 11). The readings move by design: the 34 head_to_head readings of 2,710 (10 of the 628 recorded questions) gain the point where the reader declined one ("no player subject ...") while the template answered; every answer, its data and its remarks are identical on 628 of 628, and every unit-test call's return is identical (47 of 1,405 calls differ: the meetings' tests re-seated from the template's boundary to compose.answer, the readings gaining a point, and two agent tests moved off head_to_head onto fingerprint, which still has a template). Tests: 0 deleted, the 13 meetings tests re-seated on the compiler by one binding (head_to_head = _compiled("head_to_head")), 8 edited (the scoping and freeze declarations, two agent tests, one check_scope call on the retired row). validated_until and team_relation_scoping are public, as the relation's shared steps. Statements: templates.games 7 -> 5.
  • A team's record over its own line is a reader and a sayer, and compose/present.py is gone (ROADMAP.md, "Phase 2, the expected steps", step 4, slice (iv); no answer moves). compose.records.read_team_record_when reads record_when's team half ("what was the celtics record when they scored 120 points", #144) - the games narrowed through the shared steps, the record over the team compiler's new line statement (compose.team.compile_team_line: the games divided by whether the team's own figure reached the line, a game with no figure in neither group) and the pool and the games with no figure counted by compile_team_count, through core.rows_of - into the Grouped by threshold a player's record has; say_record_when says the team's word for word (note_phrase gains the "games with a result" pool and the team's blank figure). TEAM_BOX_COLUMNS (the team's per-game figure for a stat, totalTurnovers for turnovers) is the team compiler's. With that, present_team had nothing left: compose/present.py is deleted, STATED_SCOPING moves to compose.plan (its one reader, beside WITH_WITHOUT_STATED; every reader and test imports it from there), and test_frozen_shapes retires the presenter freeze - it holds only that the module does not come back. Deleted from templates.splits: _record_when_team_answer and its six helpers, _record_when_group, _RECORD_WHEN_TEAM_STAT_COLUMNS, _team_span_floor_note; _condition_needs_player_refusal is public (condition_needs_player_refusal, the planner's and the team reader's). Tests: two re-seated on what replaced the deleted helpers (records._group, TEAM_BOX_COLUMNS), none deleted. Identical on all three populations (628 of 628, text and remarks; 1,405 of 1,405 calls; 2,710 of 2,710 readings). Statements: templates.splits 3 -> 1; private-template-import ratchet 5 -> 3.
  • A team's record with and without named teammates is a reader and a sayer (ROADMAP.md, "Phase 2, the expected steps", step 4, slice (iv); no answer moves). compose/presence.py (read_with_without) reads with_without's point - the teammates, the subject, the team, their stints and the overlap, moved whole from templates.splits._with_without_read and its helpers - and executes the team compiler's new presence statement (compose.team.compile_team_presence, over the relation cell conditions.presence_games_sql, which conditions._with_without_games executed itself) through core.rows_of, keeping the games inside the windows (conditions.presence_games); the groups (team by team, the question's side first, each a record and the subject's averages) are a Grouped by presence, the tenure counted and what "played"/"out" means, the games with no box score and the subject's columns are notes. compose.say.say_with_without words it word for word (the row verbs, the heading; the notes through note_phrase, which gains about= - whose time the split counted - and a consequence for the games on neither side). Deleted: the presenter compose.present._present_with_without and TEAM_ONLY_PRESENTERS (its freeze in test_frozen_shapes now holds that none comes back), templates.splits.PresenceSplit and fourteen _with_without_* helpers with _season_of_day, compose.team._compile_team_presence, TeamResult.presence and team_sentence's presence branch (a with/without point its reader declines is refused, as a team log's is). Identical on all three populations (628 of 628, the 15 recorded with/without questions among them, text and remarks; 1,405 of 1,405 calls; 2,710 of 2,710 readings). Statements: conditions 8 -> 7; private-template-import ratchet 7 -> 5.
  • A team's and the league's streak of wins or losses are a reader and the streak's sayer (ROADMAP.md, "Phase 2, the expected steps", step 4, slice (iv); no answer moves). compose.runs.read_team_streak reads the team compiler's run shape - now compiled (compose.team.compile_team_run: the runs as conditions._longest_runs_sql over the narrowed team games, or every team-season's for the league) and executed through core.rows_of, with the span's count and seasons as a compiled statement of their own (compile_team_range) - into the Runs body a player's streak has: a named team's runs under its name and the span, or the league's one per team-season under the team as it was named that season; the rule and the open run as notes. say_streak says all four subjects (_say_team_run, and say_run_listing with by_stat=False), word for word. Deleted: the presenter compose.present._present_team_streak, templates.splits._streak_team_answer, _streak_league_result_words and _team_season_range, and conditions._longest_runs (the runs are compiled statements now, _longest_runs_sql keeps the skeleton). templates.splits' team_span_label, team_where_in and condition_team_no_games are public (the shared "which fact is missing" writer and the span's words a reader carries). Identical on all three populations (628 of 628, text and remarks; 1,405 of 1,405 calls; 2,710 of 2,710 readings). Statements: templates.splits 4 -> 3, conditions 9 -> 8; private-template-import ratchet 15 -> 7.
  • team_leaderboard is a reader and a sayer over the team-season relation, and a player named on a team-only question is still refused by name (ROADMAP.md, "Phase 2, the expected steps", step 4, slice (iv); no answer moves). query/team_seasons.py gains the ranking's last two statements, moved whole: every team's home or road record (team_venue_records_statement, the standings' strings read by venue_records - templates.teams._parse_record's reading done by its digits, so the relation module imports no re) and every team's record across the seasons from since on (team_since_records_statement, which was _team_leaderboard_since_records). compose.team_stats.read_team_leaderboard reads every team ranked by one metric into a Result on a "team_seasons" span: a Grouped by team, ranked_by the metric, its rows the teams shown - the count asked for, a tie at the cut whole, and a named team's own row past it (beyond) - each with its competition rank, the value ranked by and, for a record, wins and losses; facts carry which end comes first and how many were ranked, and where nothing can be ranked, facts["missing"] names why (no home/road split in that season's standings, the metric's first season, a team short of games for points allowed). compose.say.say_team_leaderboard words it, word for word; say() tells the team ranking from a team's line by the body (Grouped.by == "team") on the "team_seasons" span. team_metrics.season_table and record_table are gone (their statements are the relation's). templates.common.validated_until is public. HONORED_SCOPING's team_leaderboard row is gone - compose.present.STATED_SCOPING declares it, through the same team_relation_scoping. team_leaderboard joins compose.COMPILED_INTENTS. agent.py: the check that a question naming one real player and no team, read under a team-only intent, is refused naming him ("alperen şengün alltime record", yardstick-v2 F111) now runs before the compiled intents as well as the templates - the team-season intents are compiled now, and without the move it would be skipped (both agent tests caught it). Deleted: the team_leaderboard template and its helpers (DEFAULT_TEAM_LEADERBOARD_LIMIT - moved to compose.team_stats - _metric_cell, _uses_possessions, _first_season_refusal, _incomplete_opponents, _metric_key, _venue_records, _team_leaderboard_span, _named, _since_records, _values, _order_label, _result); TEMPLATES 9 -> 8. Statements: templates.teams 12 -> 9, team_metrics 2 -> 0. Identical on 628 of 628 recorded questions (text and remarks; 11 rankings and 5 refusals), 2,710 of 2,710 readings and 154 of 154 feed and franchise questions through the whole agent on both trees (44 rankings); of 1,404 unit-test calls, the 20 team_leaderboard calls reach it through compose.answer (six declines through its declined with the template's sentences), every outcome identical. Tests: 20 re-seated on the compiled answer, two team-only agent tests patch the compiler where they patched the template, two source-reading scoping tests read the reader and the relation's statements; none deleted.
  • team_stat is a reader and a sayer over the team-season relation (ROADMAP.md, "Phase 2, the expected steps", step 4, slice (iv); no answer moves). query/team_seasons.py gains the season line's and the records' statements, moved whole from team_metrics.season_table and record_table (team_lines_statement/team_lines, team_records_statement/team_records; the two functions stay in team_metrics only as runners of those statements for the team_leaderboard template, until it retires). compose.team_stats.read_team_stat reads one team's season into a Result on a "team_seasons" span: a Grouped by metric (each row its key, value, rank and how many teams it ranks among) for the one metric named or the compact line, a record as a Scalar of wins, losses and rank, and - where nothing can be given - a Result with no parts and facts["missing"] naming the missing fact (the metric's first season, the team's record, its line); a metric needing points allowed ESPN's game list cannot give carries facts["short"] (short_of_games, which was templates.teams._incomplete_opponents' facts). The rating formula, what a rank means and a value or rank left out are definition/value_withheld notes. compose.say.say_team_stat words it, word for word, with tally, possessive, record_pct, metric_cell and short_of_games_said (the template's _tally, _possessive, _record_pct, _metric_cell and _incomplete_opponents sentence; the template's copies stay for team_record and team_leaderboard until they retire). A team's own total under team_stat ("how many 3-pointers have the Magic made") still reads its team-games point, with the team-season reader tried first and its decline said where both decline, as the template ran before the compiler. team_stat joins compose.COMPILED_INTENTS. Deleted: the team_stat template and _team_stat_missing, _team_stat_table, _team_stat_single, _team_stat_summary, _team_record_rank; TEMPLATES 10 -> 9. Statements in team_metrics 3 -> 2. Identical on 628 of 628 recorded questions (text and remarks; 3 team_stat answers, two of them the team-games total behind the template's decline), 2,710 of 2,710 readings and 154 of 154 feed and franchise questions of the four team-season intents answered through the whole agent on both trees (58 team_stat answers); of 1,404 unit-test calls, the 11 team_stat calls reach it through compose.answer - their arguments carry the point, and "vibes", the unknown stat, is declined through compose.answer's declined with the template's sentence where the template raised it - every outcome identical. Tests: the 9 team_stat tests re-seated on the compiled answer; none deleted.
  • A ranking's Result names the season line as its relation. read_leaderboard's Span carried the default source="games" though the ranking is read from the season line (season_line.rank_season_line); it says "seasons" now, as the line, the history and the comparison do, and say()'s season-line branch words a Grouped by player with say_leaderboard. Found checking that Span.source tells the comparison from the matchup: it did, and only the ranking's span was wrong, unseen because say() reached the ranking's sayer by its body before it asked. 628 of 628 identical in text and remarks, every unit-test call and every reading; one test added (watched to fail with either half reverted), one say test's span corrected.
  • The ran boundary is gone (Phase 2, step 4). compose.answer no longer takes a ran callback, Agent.ran is gone, and stages.snapshot/read_stages no longer take ran: they recorded the query the compiler executed where it differed from the planned one, which only plan.games_reading's re-plan ever made happen, and that went with step 3 - since then the callback was handed the planned query object itself on every call. The stage snapshot's query stage is the planned query, as it already was wherever ran was unset. 628 of 628 recorded questions identical (the query stage included), 1,405 of 1,405 unit-test calls (the recorder never wrote the argument: a callback, which tests/stage_calls.py leaves out of every record, so no call differs), 2,710 of 2,710 readings; one test deleted (test_the_query_stage_is_the_query_the_compiler_ran_where_that_differs, whose subject was the parameter), none moved.
  • The season line has one module and one door (Phase 2, step 4). query/leaderboard.py is folded whole into query/season_line.py, the season-line relation: the ranking's floors, traded-player dedup, postseason-copy exclusion, qualifiers and career pool are unchanged, and its statements are public builders there (ranking_statement, career_ranking_statement, recent_teams_statement, schedule_statement) executed through the compiler's one door - compose.core.rows_of now takes a season-line Statement as well as a compiled one - where leaderboard._rows and the schedule lookup ran their own. Breaking: association.query.leaderboard is gone; import run_leaderboard, run_career_leaderboard, rank_season_line, default_min_sample, not_a_postseason_copy, LeaderboardError, the result types, SEASON_TOTAL_OF and MIN_SAMPLE_LABELS from association.query.season_line, and the metric aliases and resolve_metric from association.query.measures (which they were re-exported from). Every read of a player's seasons on record moved there too, executed through the same door: season_redirect (from templates.common, with a season_type_column so a NetPoints answer's redirect is this read and not a statement of its own), seasons_on_record (from templates.players), and seasons_played (the career shot chart's own _career_shot_span statement). Statements outside the relations 85 -> 79 (leaderboard 2 -> gone, templates.common 14 -> 13, templates.players, templates.netpoints 4 -> 3, templates.shots 3 -> 2). 628 of 628 recorded questions identical in text and remarks, 1,405 of 1,405 unit-test calls, 2,710 of 2,710 readings; no test deleted, three re-pointed at the new module.
  • team_outlook is a reader and a sayer over the power index (ROADMAP.md, "Phase 2, the expected steps", step 4, slice (iv); no answer moves). query/team_seasons.py is the team-season relations' module (team_snapshots here; team_seasons follows): the power index's two statements, moved whole from the template (team_outlook_snapshots_statement, team_outlook_row_statement), the snapshot choice (team_outlook_chosen, which was templates.teams._team_outlook_choose), snapshot_facts, and the constants BPI_PRESEASON, BPI_REGULAR, BPI_SNAPSHOT_NAMES and BPI_CHANCES (moved from templates.teams), executed through the compiler's one door (core.values_of). compose.team_stats.read_team_outlook reads them into a Result on a Span whose source is the new "team_snapshots": a Scalar of the snapshot's figures for the team and a Grouped by round of its chances (the draft's "scalar, and chances as a split by round"), the snapshot's oddities, a rating ESPN left empty and the season's other snapshots as snapshot/value_withheld notes, and - where no snapshot of the kind asked for holds the team - a Result with no parts and the season's snapshots in facts, with the hint note. compose.say.say_team_outlook words it, word for word, every note phrased once in note_phrase (_say_team_season_note; the definition phrases rating_formula and rank_meaning, and value_withheld's points_allowed and rank, land here for the next two readers). compose.answer tries a team-season intent's reader first - on its own TeamSeasonQuery, or ahead of another relation's point the words read - and says its decline where every reader declines, as the template ran before the compiler (_read_team_season); the reader checks what the answering loop and the template checked first, in their order (team_season_declines, the coverage floor, templates.teams.conference_refusal - public now - and the team). team_outlook joins compose.COMPILED_INTENTS. RATING_NOTE moves to team_metrics (the sayer phrases it). Deleted: the team_outlook template and its 12 helpers and constants (_team_outlook_choose, _describe, _missing, _record_line, _headline, _bpi_line, _chances_line, _sos_line, _others_line, _snapshot_facts, _data, _detail); TEMPLATES 11 -> 10. Statements in templates.teams 14 -> 12. Identical on 628 of 628 recorded questions (text and remarks; 4 outlooks), 1,404 of 1,404 unit-test outcomes (the 16 outlook calls now reach it through compose.answer, their arguments carrying the point), 2,710 of 2,710 readings, and 154 of 154 feed and franchise questions of the four team-season intents outside the corpus answered through the whole agent on both trees (~/association-research/stages/team_season_measure.py). Tests: the 13 outlook tests re-seated on the compiled answer; none deleted.
  • The team compiler gains rows and grouped compiles, and a team's log and splits execute them (ROADMAP.md, "Phase 2, the expected steps", step 4, slice (iv); no answer moves). compose.team.compile_team_over - the team counterpart of core.compile_over, for a reader that settled the team, the span and the narrowed games through the shared steps - compiles a TeamQuery with shape="rows" (the team's games in Eastern-date order, TEAM_ROW_COLUMNS: date, side, the opponent as that season named it, both scores, the result, the season - a postseason's by the calendar year it was played in) and shape="grouped" by a key of TEAM_GROUPS (venue, won, month_of_year, the player compiler's names), each group's record and line (TEAM_LINE_MEASURES) with its first and last season and the games with no team box score beside them, over the new relation builder team_games.grouped_sql. TeamCompiled runs through the one door, core.rows_of. compose.logs (_team_log, _team_mixed_rows, which the team compiler's window sum shares) and compose.splits (_team_splits, one grouped read per kind, its count, seasons and blank box scores read off the groups) execute the compiled statements; their own team_rows_sql/team_aggregate_sql calls, _TEAM_GAME_LOG_SELECT, _TEAM_SPLIT_SELECT, the splits' blank-count statement and their use of templates.splits._team_season_range are gone, and with them conditions._split_rows and its _SPLIT_SQL, which nothing else called. Measured first (~/association-research/stages/team_compile_measure.py): 27 of 27 team logs identical row for row (the 17 recorded, 10 synthetic: both orders, a date, a career, a venue, an opponent, since/until, both season types merged), 24 of 24 team splits kind-reads identical with their totals and blank counts (12 synthetic scopes; the corpus holds no team splits). Identical on all three populations (628 of 628 recorded questions, text and remarks; 1,405 of 1,405 unit-test calls; 2,710 of 2,710 readings). Statements outside the relations: compose.logs 4 -> 2, compose.splits 2 -> 1, conditions 10 -> 9; private-template-import ratchet 16 -> 15.
  • The team-season intents read a point, and a coach question is refused by its cause (ROADMAP.md, "Phase 2, the expected steps", step 4, slice (iv); one enumerated reading move under Jeff's rule of 2026-10-05, AGENTS.md "Identical means identical"; no answer moves). team_stat, team_leaderboard and team_outlook were read as declines ("a team's own question is not the player relation's", "a team, an opponent's figure or a franchise is the team relation's question", "a quarter or half is the period relation's question") that their templates answered past; where no other point is read they read a point on the team-season relations of ROADMAP-TYPES.md - reading.Relation gains team_seasons (the season stat line and the standings) and team_snapshots (ESPN's power index) - point.TEAM_SEASON_POINTS/team_season_point, planned as compose.team_stats.TeamSeasonQuery (a team's own total, "how many 3-pointers have the Magic made", keeps its team-games point; a team-season intent's other point the planner declines is planned as the team-season one, or refused with the template's own "cannot honor" sentence, plan.team_season_declines, since the template ran before the compiler). compose.present.STATED_SCOPING declares the three (none, none, the team relation's cells less team_leaderboard's exclusions; templates.common.team_relation_scoping is public). coach has no template: point._read_point refuses it by the new cause no_coach_table (CAUSES 16 -> 17, fact unanswerable), compose.plan.refusal_result says COACH_REFUSAL (moved from templates.teams), word for word with the same data, and coach joins compose.COMPILED_INTENTS; the coach template is deleted (TEMPLATES 12 -> 11). The compose side answers a team-season point by declining it until each reader lands. Moves, all of them, in the reading and the planned query only: 628 recorded questions - 24 (16 team_leaderboard, 4 team_outlook, 1 team_stat - "Knicks pace this season" - from the three declines to a team_seasons/team_snapshots point; 3 coach - "nick nurse coaching record all-time nba in december on the road", "who coached the bulls in 1996", "Nick Nurse's all-time NBA coaching record on the road in December" - from "no player subject and no ranking or position-group reading of the question" to the no_coach_table cause); text, remarks, result and answered_by identical on all 628. 2,710 readings - 175 (96 team_leaderboard, 70 team_stat, 4 team_outlook, 5 coach), all from a decline to the point or the cause. 1,404 unit-test calls - 14 differ in the reading and the planned query only (the two team-only agent tests, a history and a position-group reading under a team intent, five claims-ledger and three stage-snapshot runs of a coach question) and the coach boundary moves from template:coach to compose.answer; every outcome identical. Tests: the coach sentence asserted through the planner (re-seated), the cause added to the every-cause walk, one decline test re-pointed at leaderboard with the team-season point asserted under team_stat; none deleted.
  • A leaderboard's defaulted season is recorded as the decision it is. read_leaderboard carries Decided("season_default", field="season", chose=<season>, why="latest_on_record") when the question named none, and say_leaderboard writes the heading's own span phrase ("in the 2026 regular season") through it, so Answer.decisions and the snapshot's remarks hold what the heading already shows; the sentence is unchanged (Jeff, 2026-10-05: the value is visible in the heading and a season named reaches the alternative, so no extra remark). season_default is a new DECISION_KINDS entry (facts: season_type). 628 of 628 identical in text and values; 54 answers' remarks gain the decision.
  • The planner plans a season-line point its reader does not read as the game-level one; games_reading's re-plan in compose.answer is gone (ROADMAP.md, "Phase 2, the expected steps", step 3; no answer moves). The season line says for itself whether it reads a point - compose.seasons.player_line_reads, player_history_reads, player_compare_reads and the new compose.rankings.leaderboard_reads (the static half of read_leaderboard: its own point, the scoping its words state, a stat a season-line metric reads, no position group; a shot-distance ranking, a career with a year named and a unit the metric has no form of are its own to say) - and compose.plan.plan plans the rest as the game-level point (plan._game_level, which was plan.games_reading: a history over his career's games by season, the game-level ranking, a stat nothing ranks refused by name, an unnarrowed line declined), checking the season-line point's coverage floor first, as the answering loop did before it re-planned. compose.answer re-plans nothing; games_reading and the branch are deleted, and read_leaderboard's two step-asides are the planner's (_leaderboard_refuse_a_subject is _leaderboard_refuse_a_player). Moves, all of them, in the planned query only: 2,710 readings - 50 (49 leaderboards and one history, "sga ppg avarage in the last 4 seasons", plan source="games" where they planned "seasons"; the history's span widens to the career): answered through the whole agent on both trees, all 50 identical in text, answered_by and the query run. 1,405 unit-test calls - 4, compose.answer's planned argument only (two histories of turnovers and one naming no stat, planned as games; "curry's PRA this season" declined at planning with the same reason, "an unnarrowed player line the season line's reader did not say"), outcomes identical. 628 recorded questions - identical, text, remarks and the query record (which records what ran: the 3 re-planned leaderboards ran the games point before as now).
  • A decline the user saw is a Cause, said by the missing fact (ROADMAP.md, "Phase 2, the expected steps", steps 2 and 3; Jeff's rule of 2026-10-05, AGENTS.md "Identical means identical": one enumerated reading move). The point reader's declines for slices (i) and (ii) - the user saw "Nothing here answers this question: : ." with answered_by="refused" - are reading.Causes the planner says (compose.plan.refusal_result, answered_by="fast"): CAUSES grows from 3 kinds to 16, each named for the missing fact - needs_stat, unknown_stat, needs_threshold, threshold_needs_stat, threshold_counts_every_game (with the shape's intent), line_names_no_stat (phrase, side), needs_line, career_place_needs_player (season_n), needs_subject, team_streak_of_stat, matchup_needs_two (names), no_period_stat (stat), and ranking_unit (metric, rate: the season-line ranking's "per 90" refusal, a sentence compose.rankings._leaderboard_no_such_rate wrote in the reader, now the planner's _ranking_unit, word for word; the ranking reader declines a unit it is handed). reading.PointRefused is a kind of Unsupported with the decline's own message, so the shared helpers that raise it (lines.measure_filters, lines.threshold_count_line, measures.streak_column, measures.log_extras, measures.period_split_measure) are still declines, word for word, to the answer side that calls them; parse.with_point catches the cause first. Where a retired sentence named only the slot ("record_when needs a player, a stat and a positive threshold here", "a streak of a stat needs both a known stat and a positive threshold"), the cause names which of them is missing. Kept as declines, measured not user-visible (~/association-research/stages/decline_sites.py, the whole agent over the 2,710 readings): an intent with no default point ("no adapter for ...", 94 - a template answers), the league-wide read's hand-offs ("a quarter or half is the period relation's question", "a team ... is the team relation's question", "a team's own question ...", "no player subject ...", 376 - mostly a template, refusals.py or a coverage floor answers; the 36 that reached a user are leaderboard or game_log questions read for the wrong relation, filed), the team's triple-double total (6, refusals.py answers), and the guards read_point's order never reaches ("player_stat needs a player", "a team's log is the team relation's", ...). Moves, all of them: 628 recorded questions - 3: "Display Luka's average assists for each consecutive year" ("Nothing here answers this question: streak: a streak of a stat needs both a known stat and a positive threshold, got 'assists'/None." -> "A streak needs the number of assists each game has to reach, and none was read."; refused -> fast), and the two "defensive netpoints / 90" and "... per 90?" leaderboards (reading and planned query to the ranking_unit cause; answer identical). 2,710 readings - 20: those 3, and 17 feed readings from decline to cause (single-game highs with no stat, 7; by fieldGoalPct or triple_double, 4; counts kept under a phrase naming no stat, 4; logs of triple_double/double_double, 2). 1,402 unit-test calls - 31, all in the 19 tests rewritten to assert the cause or its sentence. Tests: 19 rewritten (none deleted), 2 added (every kind has a sentence naming its fact; the per-90 cause on the reading); a perturbed sentence fails 4.
  • MAX_LIMIT is one definition (ROADMAP.md, "Phase 2, the expected steps", step 3; no answer moves; ISSUES #81 closed). reading.MAX_LIMIT (50) is the most rows a question's count reaches in every shape, and leaderboard.run_leaderboard/run_career_leaderboard cap their SQL by it; leaderboard.MAX_LIMIT (100) is gone. Measured first: it was a cap for the retired agent's model-supplied limit, and the only caller left (compose.rankings.read_leaderboard, the season and career rankings, through leaderboard.rank_season_line) passes _clamp_limit's count, already at most 50, so it never bound an answer; the six readings asking for more than 50 rows (all "last 70 games" logs) are clamped by _clamp_limit before any ranking. One concept with one value, so one name, not the two names the issue proposed. templates.common's re-export is gone (nothing imported it); scripts/check_duplicate_names.py loses its MAX_LIMIT allowance (4 known collisions, from 5). test_get_leaderboard_limit_is_clamped asserts the cap exactly (50 of 200 qualifying players). Identical on 628 of 628 recorded questions (text and remarks), 1,402 of 1,402 unit-test calls and 2,710 of 2,710 readings.
  • The last adapter is a default point, and compose/adapt.py is gone (ROADMAP.md, "Phase 2, the expected steps", step 3; no answer moves). The point a bare with_without means is read in query/point.py (_default_with_without in DEFAULT_POINTS), with its decline word for word; who a with/without split divides by - without's names, else with_player's, else the ones a conditions entry gives a role, and each role - is subject.with_without_named on the reader's side (templates.splits._with_without_named and _with_without_roles were this; the relation's read of the split takes it there; a line's column is its stat's own name, measures.THRESHOLD_STAT_NAMES, which THRESHOLD_STAT_COLUMNS maps to itself). WITH_WITHOUT_STATED lives in the planner that declines by it (compose.plan), and compose.present.STATED_SCOPING takes it there - one home. compose.adapt (_adapt_with_without, _ADAPTERS, to_reading, _to_reading_scope) is deleted; an intent with no default point is still declined "no adapter for ", the sentence kept so no reading moves. The import-linter contract "The reader does not import the answer side" holds with no ignored edge: point -> compose.adapt, the last of the nine it was written with, is gone. test_frozen_shapes: adapters 1 -> 0 and the adapter freeze retired; the scoping declaration moves module (compose.plan, still 14). Private-template-import ratchet 23 -> 22. Tests: none deleted; the six tests that called to_reading call tests/routed.default_reading (point.default_point over the typed scope), one renamed. Identical on 628 of 628 recorded questions (text and remarks), 1,402 of 1,402 unit-test calls and 2,710 of 2,710 readings.
  • The season line is a relation, and a player's unnarrowed line, his history and a comparison are its readers and sayers (ROADMAP.md, "Phase 2, the expected steps", step 3, slice (iii); no answer moves). query/season_line.py is the second relation beside the player's games: player_season_stats_deduped (one row per player per season) and player_season_advanced_stats. It settles the subject (line_subject, history_subject, compared_player - two settlings, not one, since a history narrows an ambiguous name through the season it ends at and a line through the season it reads, and making them one would move which player some names resolve to) and builds each statement the retired templates ran, moved whole (season_statement, career_statement, advanced_statement, history_statement, history_total_statement, netpoints_statement); compose.core.values_of executes them through the compiler's one execute (shared with rows_of), by position, since an advanced stat's season read selects season twice. compose/seasons.py reads them into a Result: read_player_line (a Scalar - a season's or a career's line, a percentage's makes and attempts, or a computed advanced stat - with the defaulted season's redirect a Decided("season_redirected") and an advanced career's empty seasons a Note("seasons_missing")), read_player_history (a Grouped by season, a career's line a Part(role="summary")), read_player_compare (a Grouped by subject, the NetPoints summary a detail part, a player with no line a Note("no_data_for")); each on a Span whose new source field is "seasons", which is how say() tells them from the games relation's shapes. compose.say words them (say_player_line, say_player_history, say_player_compare; note_phrase gains the two note kinds). Measured first (~/association-research/stages/season_line_measure.py): 116 of 116 recorded answers of the three (50 lines, 38 histories, 28 comparisons) and 29 of 29 synthetic cases identical in text, data and remarks against the presenters. Deleted: the last three presenters (3 -> 0), and with them compose.present.PRESENTERS, Presenter and present() - present.py holds STATED_SCOPING and the team's present_team alone and their 25 template functions and constants in templates/players.py (_player_stat_season_line and its season, career and advanced reads, _player_history_read and its helpers, _player_compare_lines and its NetPoints read and table; DEFAULT_HISTORY_SEASONS, MAX_HISTORY_SEASONS, MAX_COMPARED_PLAYERS, COMPARE_STAT_LINE and NETPOINTS_COMPARE_ROWS live in season_line now). templates.common.table_cell is public. Statements in templates.players 10 -> 4, private-template-import ratchet 22 -> 17. Tests: none deleted; the readers walk, the freeze and two imports re-seated. Not done here: compose.answer's re-plan of a season-line point its reader declined (plan.games_reading) stays, marked for the reader seam - every re-plan that answers is a game-level read (3 of 628 recorded questions, all leaderboards; 3 unit-test calls, all histories), which the point reader has to plan as such. Identical on 628 of 628 recorded questions, 1,402 of 1,402 unit-test calls and 2,710 of 2,710 readings.
  • leaderboard is a reader and a sayer over the season line (ROADMAP.md, "Phase 2, the expected steps", step 3, slice (iii); no answer moves). compose/rankings.py (read_leaderboard) reads the league's (or a team's players') leaders by one season-line metric, over a season or a career, into a Result whose body is the draft's ranking: a Grouped by player with the new Grouped.ranked_by - the metric for a stat ranking, "games" for the league's count over a line (compose.counts sets it), which is what say() tells the two rankings by player apart with, from the body and not from facts - each row its name, a competition rank (ties share one) and values by measure. The read MOVED, not re-derived: leaderboard.rank_season_line is the season line's one door (run_leaderboard / run_career_leaderboard with their default season, qualifier, traded-player dedup, postseason-copy exclusion and career pool, unchanged, and the most-recent-team lookup most_recent_teams, moved from templates.players._leaderboard_team_names), and the relation's three statements run through one helper (leaderboard._rows). What the retired words added beyond the result objects - measured first on the 67 recorded presenter answers (~/association-research/stages/leaderboard_measure.py: 67 of 67 rows and minimums identical to the result objects called directly) - are values on the Result: the qualifier the minimum decision it was (58; Decided, phrased once by decision_phrase), the career pool a floor note on the season line (1), the most-recent-team remark a definition note (4), the "also" and team columns row values, the team filter facts["team"]. The words are compose.say.say_leaderboard, word for word. Deleted: the presenter compose.present._present_leaderboard (presenters 4 -> 3), templates.players._leaderboard_ranking and its 16 helpers (LeaderboardStepsAside, _career_span, _season_label, _leaderboard_no_such_rate, _leaderboard_refuse_a_subject, _leaderboard_fields, _leaderboard_result_data, _leaderboard_team_names, _leaderboard_show_teams, _career_leaderboard, _phrase_career_leaderboard, _leader_value, _qualifier, _phrase_leaderboard, _tabulate_leaderboard, MIN_SAMPLE_LABELS - the last moved to leaderboard.py, DEFAULT_LEADERBOARD_LIMIT to compose.rankings); templates.common.table_cell is public. Tests: one re-seated (the position group steps aside, now on read_leaderboard), three added (the sayer's two rankings and a career's pool in test_say.py, the door's shared ranks in test_leaderboard.py), none deleted - the leaderboard's user-visible tests already ran through compose.answer. Identical on all three populations (628 of 628 questions with text and remarks, 1,402 of 1,402 unit-test calls, 2,710 of 2,710 readings). Ratchets: statements leaderboard 3 -> 2, templates.players 11 -> 10; private-template-import 23 -> 22.
  • The league's longest win or loss streak with no run records no remark it does not say. A postseason or span holding no team result ("No team has a game with a result in the 1990 postseason") recorded the team-season rule ("Each team's longest in a season, counted within that season.") as a remark the answer never carried: templates.splits._streak_league_result_words wrote it before knowing there was no run. It writes it only beside a run now - the same fix the player's and the league's stat run took with their reader. Before: one definition remark; after: none. No text moves; identical on all three populations (the corpus's two league streaks have runs). Test added.
  • A league-wide streak's opponent and venue are refused by the planner's own check (no answer moves). templates.splits._streak_league_needs_named_subject, which only the planner called, is compose.plan._streak_league_cells, same sentence. Identical on all three populations; private-template-import ratchet 24 -> 23.
  • Two players' meetings are a reader and a sayer (ROADMAP.md, "Phase 2, the expected steps", step 2, slice (ii); no answer moves). compose/pairs.py (read_player_matchup) executes the compiled pair statement (compose.core._compile_pair, through core.rows_of) and reads it into a Result with the draft's comparison: a Grouped body by subject - one row per player in the question's order, his averages over the meetings and the head-to-head wins - and a Rows detail part holding the newest meetings (the count asked for or pairs.MATCHUP_MEETINGS_SHOWN, each team abbreviated as it was that season; total_before_window is every meeting). The games between their teams with no box score are a games_unseen note (what meetings, phrased once in say.note_phrase). Where a teammate's absence emptied the meetings, the reader compiles the same pair without it, and with the teammate playing, where the retired template re-narrowed the relation by hand (measured equal first on every corpus question it fired on: 27 and 4 meetings, twice; ~/association-research/stages/slice2b_measure.py). compose.say.say_player_matchup words it. The games the two shared as teammates stay the pair relation's own read (conditions._teammate_games): no compiled shape counts a pair on one side. conditions._meetings_select aliases every one of the other player's columns (other_<column>): unwrapped by the rebuilt guard, other.minutes read back under the first player's name, which a by-name read (rows_of) collapsed into one column; the positional reader it served (_meeting_rows, with _meetings) is gone with the template. Deleted: compose.present._present_player_matchup (presenters 5 -> 4), and templates.games' eleven matchup helpers (_player_matchup_from, _covered, _narrowed, _absence_context, _no_meetings, _summary, _stat_line, _abbr, _log, _answer, _DEFAULT_MEETINGS_LOGGED). Identical on 628 of 628 recorded questions (text and remarks; 11 matchups), 1,400 of 1,400 unit-test calls and 2,710 of 2,710 readings, and on the nine synthetic matchups of slice2b_synthetic.py. Private-template-import ratchet 26 -> 24; statements outside the relations 95 -> 93. No test deleted; the relation-narrowing walk in tests/query/test_templates.py walks the reader.
  • A player's and the league's streak are a reader and a sayer (ROADMAP.md, "Phase 2, the expected steps", step 2, slice (ii)). compose/runs.py (read_streak) executes the compiled run statement (compose.core._compile_run, through core.rows_of) and reads it into a Result whose body is the draft's runs shape, declared here (result.Run: owner, length, first and last day and season, still open; result.Runs; Result.runs; result.run_of reads a run statement's row; Span.floor names a span with nothing in it): a named player's longest run and its ties over the games the relation narrowed to, or the league's longest one per player. The span, the rule ("only games he played count", with or without seasons crossed), a game with no box score ending a run and a run still going are a Span and Notes (definition with term streak_rule/unseen_ends_run, still_open), each phrased once in say.note_phrase (still_open keys a starred listing with listed=True). compose.say.say_streak words it, through say_one_run and say_run_listing, which the team's streak (compose.present._present_team_streak, slice (iv)'s) now says through too, so the run's words have one home. Deleted: compose.present._present_streak (presenters 6 -> 5), templates.splits._streak_player_answer, _streak_league_stat_words, _streak_league_answer, _streak_league_notes, _single_streak, _streak_words, _UNSEEN_ENDS_RUN and _misfiled_postseason - the presenter's refusal of a single postseason before 1993-94, which no question reached: the player box scores' floor refuses it first (check_coverage runs before any reader; a test holds 1988-1993). templates.splits.condition_span_label is public. Fixed on the way: a named player with no run at all ("tatum longest streak of 25 point games vs the knicks", real warehouse) recorded the run's rule as a remark the answer never said - the template wrote it before it knew there was no run; the reader attaches it to a run only (the league's no-run answer the same). Identical on 628 of 628 recorded questions (text and remarks; the corpus holds no player or league stat streak), 1,400 of 1,400 unit-test calls and 2,710 of 2,710 readings; 24 of 25 synthetic streak and matchup questions on the real warehouse identical in text, data and remarks, the 25th the fix above (~/association-research/stages/slice2b_synthetic.py). Private-template-import ratchet 32 -> 26; statements outside the relations 96 -> 95. Two tests added, none deleted.
  • The streak's and the matchup's default points are the reader's (ROADMAP.md, "Phase 2, the expected steps", step 2, slice (ii); no answer moves). The point a bare streak or player_matchup means is read in query/point.py (_default_streak, _default_player_matchup in DEFAULT_POINTS) from the Scope alone; compose.adapt._adapt_streak and _adapt_player_matchup are gone (adapters 3 -> 1, with_without's alone left; test_frozen_shapes). What they took from the answer side moved to the reader's: the streak's stat-and-threshold reading and its two declines (a named stat with no per-game column; a stat without a threshold or a threshold without a stat) are measures.streak_column over measures.STREAK_RESULT_STATS (templates.splits._streak_kind and _RESULT_STATS were this; _streak_words reads through it), the season a named player's run is settled in is read without the relation's _Scope (point._streak_season, the season half of compose.core.run_scope), and the two run counts are reading.DEFAULT_STREAK_LIMIT and reading.DEFAULT_NAMED_RUNS (templates.splits._DEFAULT_STREAK_LIMIT, compose.core.DEFAULT_NAMED_RUNS; compose.team and compose.core read them there). Each decline keeps its sentence. Identical on 628 of 628 recorded questions (text and remarks), 1,400 of 1,400 unit-test calls and 2,710 of 2,710 readings; private-template-import ratchet 35 -> 32.
  • A single-game high with no stat is declined for the stat, not the player. single_game_high's default point declined "needs a player and a known stat here" whether the player or the stat was the missing fact, and the answer is "Nothing here answers this question: single_game_high: ..." - so "brice sensabaugh career high asistss", which named its player, was told it needed one. It names the fact now: "needs a stat to rank games by, and none was read", "cannot rank games by 'triple_double'", or "needs a named player here". 11 of the 2,710 readings move, each in the decline's wording alone (7 no stat read, 2 fieldGoalPct, 2 triple_double); no recorded question and no unit-test call moves. Asserted (test_a_single_game_high_with_no_stat_is_declined_for_the_stat_not_the_player), and watched to fail on the tree before.
  • single_game_high is a reader and a sayer (ROADMAP.md, "Phase 2, the expected steps", step 2, slice (ii); no answer moves). compose/highs.py (read_single_game_high) reads a named player's or the league's top games by one stat into a Result whose body is a Rows ranked by the measure - the new Rows.by, the draft's "rows by the measure" beside a log's "rows by date", which is what the sayer branches on - over the planned point compiled and executed (compile_query, rows_of). What the retired words added beyond the rows is values, notes and a decision: the span through compose.counts.box_score_span (the floor said first where a career began before the box scores), a league career's floor (floor, league_record), the withheld stat (stat_withheld), the empty box scores (games_unseen, not where the withheld stat explains the gap), a rebuilt top game (lines_rebuilt, single_game), and the defaulted season's redirect as a result.Decided (season_redirected) on Result.decisions. compose.say.say_single_game_high words it, the template's sentence word for word; say.decision_phrase gains season_redirected, and templates.common._defaulted_season_note - the same sentence for the season line, a fingerprint and a shot chart - phrases through it, so the kind has one phrase. templates.common.season_redirect is public. Measured first: the compiled rows are the presenter's on 18 of 18 recorded answers it gave; the floor, the withheld stat, the rebuilt top game and the redirect are reached by the unit tests alone. Deleted: compose.present._present_single_game_high, _count_season and _stat_column (presenters 7 -> 6), templates.players._GameSpan, _game_span, _empty_note, _single_game_high_result_data, _single_game_high_redirect, _single_game_high_answer, _phrase_single_game_high and the dead DEFAULT_SINGLE_GAME_LIMIT re-export. Private-template-import ratchet 39 -> 35. Identical on 628 of 628 recorded questions (text and remarks), 1,400 of 1,400 unit-test calls and 2,710 of 2,710 readings. Tests: 1 added (the sayer's words), 1 re-seated (a narrowing the high's words do not state: on the reader, where it was on the presenter), 0 deleted.
  • threshold_count is a reader and a sayer (ROADMAP.md, "Phase 2, the expected steps", step 2, slice (ii); no answer moves). compose/counts.py (read_threshold_count) reads a named player's count of games over a line into a Result whose body is a Scalar with the new how="count" (the games counted, and how many of them are rebuilt as sums["rebuilt"]), and the league's into a Grouped ranking by player; the statement is the planned point compiled and executed (compile_query, rows_of - each row's rebuilt count is its own rebuilt_shown, so core.run and the box-score notes, total and own seasons it read beside the rows, which the presenter threw away, are not run). What the retired words added beyond the rows is values and notes: the span (a named career's own first and last season, compose.counts.box_score_span, which the single-game high takes next), the floor said first where a career began before the box scores (floor, career_began_earlier) or a league career's (league_counts), the withheld stat (stat_withheld), the empty box scores (games_unseen with its years) and the leader's rebuilt games (lines_rebuilt, counted). compose.say.say_threshold_count words it in the template's words, each note through note_phrase (which gains the four phrases, and a consequence for empty box scores - "the count may be low" is the answer's to say). Measured first (~/association-research/stages/slice2a_measure.py): the compiled rows are the presenter's on 31 of 31 recorded answers it gave; the words add, on the corpus, a named career's seasons (12), an ordinal season (2) and empty box scores (2) - the floor, the withheld stat and the rebuilt games only the unit tests reach. Deleted: compose.present._present_threshold_count, _threshold_count_is_own_point (moved to the reader) and _present_threshold_count_rows (presenters 8 -> 7), templates.players._threshold_count_notes, _threshold_count_lines, _threshold_count_rebuilt_note and _phrase_threshold_count; templates.players.seasons_on_record, empty_box_scores and rebuilt_in_scope are public. Private-template-import ratchet 45 -> 39. Identical on 628 of 628 recorded questions (text and remarks), 1,400 of 1,400 unit-test calls and 2,710 of 2,710 readings. Tests: 2 added (the sayer's words, for the floor preface and the rebuilt-games note no assertion held: a perturbed phrase passed the suite and failed only the calls population), 1 adjusted (the presenters' stated-scoping set), 0 deleted.
  • threshold_count's and single_game_high's default points are the reader's (ROADMAP.md, "Phase 2, the expected steps", step 2, slice (ii); no answer moves). point.DEFAULT_POINTS reads both (_default_threshold_count, _default_single_game_high), with the same declines word for word; what the count's point read from the template - the column and threshold a count is over, templates.players._threshold_count_ask - is lines.threshold_count_line on the reader's side, over measures.THRESHOLD_STAT_NAMES, and the count's presenter reads it there too. compose.adapt._adapt_threshold_count and _adapt_single_game_high are gone (adapters 5 -> 3, test_frozen_shapes); the private-template-import ratchet loses its two _threshold_count_ask entries. Identical on 628 of 628 recorded questions (text and remarks), 1,400 of 1,400 unit-test calls and 2,710 of 2,710 readings.
  • A decision the read made rides on the Result as a value. result.Decided (kind, field, chose, before, instead_of, why, facts) and Result.decisions, the draft's own field (ROADMAP-TYPES.md, "Outcome"); compose.say.decision_phrase phrases each kind once and records it through notes.decided, as note_phrase does a Note. The period redirect's season_fallback is the first - it rode in Result.facts as a dict for a day. tests/query/test_answer_notes.py reads a Decided(kind="...", ...) as a write, as it reads a Note. No answer moves.
  • period_split is a reader and a sayer that execute the compiled statement (ROADMAP.md, "Phase 2, the expected steps", step 1, sub-step (e)). compose/periods.py reads a player's quarter or half: one period game by game as the point compiled into a rows read of every game in the span with the period's whole line and the opponent's name as its measures (the relation applies the period), and the four quarters side by side as the planned grouped-by-period read - both through core.rows_of, the cross-season redirect ("last 5 games as a starter" with no starts this season) through compile_over over his career. compose/say.py words both (say_period_split, say_period_by_quarter) with the period's words (period_noun, period_caveat, say_period_refusal, say_period_unread); the relation holds what it measures (player_games.period_distrust - why a season is refused, as facts - period_agreement_notes - its caveats, as rebuilt_agreement notes - and PERIOD_LOG_COLUMNS), and period_leaderboard and team_quarter_points read the same data and words. The compiler gains the opponent_name measure (a label of the game, never aggregated: measures.LABEL_MEASURES), measured first (~/association-research/stages/period_split_measure.py): the same games, dates, sides, opponents and line on 18 of the 18 recorded questions the template answered, the opponent's name the one column the compiler lacked. Deleted: compose.present._present_period_split and _present_period_by_quarter (the intents holding a presenter 9 -> 8), templates.games._period_split_from, _period_split_by_quarter_from and the 29 helpers only they used; the by-quarter presenter's own cursor-to-dict copy. Ratchets: private template imports 54 -> 49, statements compose.present 3 -> 2 and templates.games 8 -> 7. Three answers fixed on the way, none on the recorded corpus: a quarter's line is not a rebuilt box line - under the rebuilt guard the compiler blanked turnovers, fouls and attempts on rebuilt rows (UNGATED_ON_REBUILD) inside a period too, where every figure comes from the plays, so "Anthony Davis turnovers by quarter 2015" (every New Orleans game rebuilt) answered "Per-quarter turnovers cannot be answered here: ... this warehouse holds none" and now answers 30/16/36/13, and a period read widens to rebuilt lines whatever it measures, as the template did (core._period_read, _blanks); a dated by-quarter answer is labeled and caveated by the game's season (ISSUES #302: "in the 2026 regular season on 2014-11-01" is now "in the 2015 regular season", read off the rows; a refused season is refused off the game); a period answer says a calendar or a teammate's role it was narrowed by (ISSUES #301: "... by quarter in the 2026 regular season in January"); and a date with no game says "No games found for Stephen Curry on 2019-07-04." where it named a season the date is not in. Identical on 628 of 628 recorded questions (text and remarks) and 2,710 of 2,710 readings; unit-test calls in the commit message. Tests: 2 re-seated (the rate refusal on period_distrust, the career read on the compiled statement), 3 added (rebuilt quarters, the calendar said, the dated by-quarter), 2 fixtures gain the warehouse's opponent_abbr; none deleted.
  • The period relation's data is the relation's, under public names (ROADMAP.md, "Phase 2, the expected steps", step 1, sub-step (e), first step; no answer moves). What a player's quarter or half is measured and refused by - PERIOD_RECONCILIATION (points against ESPN's linescores), PERIOD_REFUSE_BELOW, PERIOD_RATES (a period's percentages as two of its columns), period_columns (the columns a measure is read from) and period_rate (a rate as the ratio of its sums over games) - lived in templates/games.py beside the retired period_split template's body, and the compiler (compose.core) took PERIOD_RATES from there; they live on the period relation now (query/player_games.py, beside PERIOD_COLUMNS, PERIOD_AGREEMENT and REGULATION_QUARTERS), read by period_leaderboard, team_quarter_points, the compiler and the period reader the next step writes. templates.common.relation_window (the newest or oldest N a question's order/limit cut the relation to) is public, as the other shared steps went in step 1's first item: the period reader cuts its cross-season window by it. Identical on 628 of 628 recorded questions (text and remarks), 1,393 of 1,393 unit-test calls, 2,710 of 2,710 readings; no test deleted or moved (one import moved).
  • templates.players._player_stat_advanced loses a branch nothing reached (no answer moves). Its from_box_scores flag refused an advanced stat over a narrowed set of games, but its one caller, the season line, always passed False: a narrowed point was the presenter's, which declined advanced stats before calling it, and is compose.stats' now, which declines them the same way. Found deleting that presenter. Identical on 628 of 628 recorded questions, every unit-test call and every reading.
  • A player's narrowed line is a reader and a sayer that execute the compiled statement (ROADMAP.md, "Phase 2, the expected steps", step 1, sub-step (f)). player_stat's point over box scores - "maxey points vs boston", "jokic rebounds at home", "lebron's line as a starter for Miami", one date - is read by compose/stats.py (read_player_stat) into a Result whose headline part is the new result.Scalar (the whole narrowed set reduced to one line: the games, each measure's figure, the sums it is said from), with the newest meetings behind an average against one opponent as a second, Rows part, and worded by compose.say.say_player_stat, the retired template's sentence word for word. The statement is the compiler's: the point compiled with the new line aggregate (compose.core._line_selects: each measure per game, beside the seasons the games span, each column's sum, each shooting rate's makes and attempts, and a made count's rate's two sums - read beside the measures, so the rebuilt-line rule stays the measures'), the shooting percentages as rates (core.SHOT_RATES, which RATES is built from for the four of them; two_pct is new, field goals less threes both ways), and the meetings as its rows read over the same narrowed games and rebuilt rule. Gone: compose.present._present_player_stat's narrowed branch (the presenter is the season line's alone, _present_player_stat_season_line; presenters stay 9), templates.players._box_score_player_stat, _box_score_player_stat_selects, _box_score_player_stat_values, _box_score_stat_rebuilt, _pgl_qualified, _recent_meetings, RECENT_MEETINGS (now compose.stats') and _count; the phrase helpers the season line shares moved to the sayer (say.phrase_player_stat, shooting_result, stat_value_labels, rounded), which the season line's readers call; templates.players.wanted_stats, templates.common.count_games and format_value are public names. Measured first (~/association-research/stages/player_stat_measure.py): the compiled line equals the template's read on 19 of 19 recorded questions it answered and 13 of 15 synthetic ones over the warehouse (shooting percentages, rebuilt seasons, stats a rebuild does not fill); the two apart are the fix below. Identical on 628 of 628 recorded questions, text and remarks included, 1,393 of 1,393 unit-test calls and 2,710 of 2,710 readings. One test added (the fix's), one re-seated on the compiler's rebuilt rule (test_narrowed_player_stat_never_reads_a_shooting_percentage_from_a_rebuilt_line, whose gate was the deleted _box_score_stat_rebuilt), none deleted. Ratchets: sql_outside_the_relations templates.players 13 -> 11; private_template_imports 54 -> 52 (compose.present:_box_score_player_stat, _wanted_stats).
  • A made count read alone over rebuilt lines no longer states attempts nothing may read. "Anthony Davis field goals made vs the Lakers in 2015" (four games, every one rebuilt from play-by-play) said "That is 43 of 64 (67.2%)": the retired template summed the rebuilt rows' attempts raw, where UNGATED_ON_REBUILD says a rebuild's attempts are never read. It says "That is 43 in total." now - the compiled line takes a made count's attempts the way its rate does, over the games that carry them, and states "of" only where no rebuilt line is in the read (likewise "147 of 197" free throws made at home in 2016 -> "147 in total."). No recorded question or unit-test call reaches it; test_a_made_count_over_rebuilt_lines_says_no_attempts_it_cannot_count asserts it on the rebuilt fixture ("16 of 33 (48.5%)" before).
  • A player's splits execute the compiled statements (ROADMAP.md, "Phase 2, the expected steps", step 1, sub-step (g), third shape; no answer moves). Each kind of a player's splits table - home and away, starting and off the bench, in wins and losses, by month - is the point compiled as a grouped read by the compiler's own group (venue, starter, won, and the new month_of_year: the month alone, every season's Octobers together, where month is one season's months in order) with the line's measures, through compile_over; compose.splits._player_groups runs those four statements and shapes their rows as the table shows them (both halves of a pair, months in season order). The relation's own grouped read (conditions._split_rows) now serves the team's splits alone, until the team slice. Measured first: the compiler's groups agree with the template's on 63 of 70 kind-reads over the 19 recorded player_splits questions, the seven apart being the career month tables the new group reads identically (~/association-research/stages/splits_measure.py). One place a compiled statement runs: compose.core.rows_of (the compiler's run, the three ported readers), so the statement ratchet falls (compose.core 2 -> 1, compose.logs 5 -> 4, compose.records gone). Identical on 628 of 628 recorded questions, text and remarks included, every unit-test call and every reading.
  • A shooting rate over rebuilt lines is taken over the games the rebuild measured, in both its sums. compose.core.RATES is written over a column renderer and names its columns; under the rebuilt guard a rate reading a column the rebuild does not fill (UNGATED_ON_REBUILD: attempts, threes) skips rebuilt rows in numerator and denominator alike (_rate_sql), the rule measure_sql already applied to that column alone. Before this the compiler summed the raw columns (attempts the rebuild never measured, beside a per-game attempts figure that left them out), and the retired splits template had the mirror fault - makes over every game, attempts over the fetched ones: on the rebuilt test fixture a two-game home split printed FG% 93.3%, where the fetched game shot 50.0%. Found merging player_splits onto the compiled statement, which moved that fixture's figure; test_a_figure_the_rebuild_gets_wrong_is_left_out_rather_than_averaged_in asserts the rate now. No recorded question's span holds a rebuilt game, so 628 of 628 are identical; three unit-test calls on that fixture move, 93.3 -> 50.0.
  • record_when executes the compiled statement (ROADMAP.md, "Phase 2, the expected steps", step 1, sub-step (g), second shape; no answer moves). The compiler gains the line group (compose.core._line_group): a grouped read whose key is the point's one predicate - the games he reached the line in, fell short in, or has no figure for - carrying each group's record, first and last season and teams, and the margin measure (his team's margin from his side, averaged). compose.records.read_record_when compiles its scalar point as that grouped read and runs the compiler's one statement, where it ran the retired template's own GROUP BY p.points >= ? beside it; the planned point is unchanged. Identical on 628 of 628 recorded questions, text and remarks included, every unit-test call and every reading.
  • The player's game log executes the compiled statement (ROADMAP.md, "Phase 2, the expected steps", step 1, sub-step (g), the first shape; no answer moves). compose.logs.read_player_log compiles the planned point with the log's own columns as its measures (compile_query) and runs THAT statement, where it ran its own rows_sql call beside the compiled one and set the compiled one aside; the "last N games" read naming no season type compiles each type over the first compile's player and season through the new compose.core.compile_over - the second half of compile_query, for a reader that has the settled subject already. The listing's rebuilt-line rule has one copy, the compiler's _rebuilt_for (the log's _rebuilt_readable is gone; its test asserts on the compiler's). One rule for "the warehouse carries rebuilt lines": conditions.box_source asks the log for the flag as well as the filled view (one statement, two DESCRIBEs), and player_games.log_carries_rebuilt - a second definition over the log alone, which the three template readers of l.reconstructed used while every compiled pgl.reconstructed read used the view's - is gone; the two agree on every warehouse data load builds, and the fixtures that split them are built the warehouse's way (the log over the filled view). Identical on 628 of 628 recorded questions, text and remarks included, and 1,393 of 1,393 unit-test calls (one fixture stored games.date as a bare day, which eastern_date_sql dates a day early where eastern_date passes it through; it holds a UTC stamp now, as the warehouse does). The statement ratchet: compose.logs 6 -> 5, player_games 2 -> 1.
  • player_splits - a player's and a team's - is a reader and a sayer (ROADMAP.md, "Phase 2, the expected steps", step 1, slice (i); no answer moves). compose/splits.py reads both branches of the retired template into a Result with a Grouped body (one row per group of each split shown, marked with its kind): the player's over the compiler's settled games (games_subquery), the team's over the team-games relation, with the remarks as kinds and facts (the unseen games or the blank team box scores, the floor, what "played" and the months mean). compose.say.say_splits words both from the Result alone - the one table the two branches shared (_player_splits_answer) is one sayer now. Gone: compose.present._present_player_splits and _present_team_splits (presenters 10 -> 9), templates.splits.team_splits, _player_splits_from, _player_splits_team, _player_splits_answer, _SplitSubject, _player_splits_line, _player_splits_refusals and their tables (moved), and the two note writers whose phrases are the sayer's (conditions._Scope.floor_note, _unseen_note). SPLIT_KINDS lives in reading. Identical on 628 of 628 recorded questions, text and remarks included, and 1,393 of 1,393 unit-test calls. The private-template-import ratchet is 54 (three team-branch steps the reader still takes from templates.splits, listed, going with the team slice).
  • record_when's player branch is a reader and a sayer (ROADMAP.md, "Phase 2, the expected steps", step 1, slice (i); no answer moves). compose/records.py reads a player's team's record when he reached a line into a Result with a Grouped body (three rows: reached, fell short, all his games; result.Grouped and Span.phrase declared here) - the retired template's one grouped statement over the relation's games_subquery, moved whole, its four remarks as kinds and facts (the unseen games, a blank stat on a rebuilt row, the pool, the floor). compose.say.say_record_when words it, with the phrases for games_unseen (no box score), stat_blank, the pool's definition and the floor's second form added to the one-phrase-per-kind table. compose.present._present_record_when and templates.splits._record_when_query/_record_when_answer/_record_when_blank_note are gone (presenters 11 -> 10); compose.answer reads the ported shapes through _read_ported. templates.common.no_games is public (the shared "which fact is missing" writer). Identical on 628 of 628 recorded questions, text and remarks included, and 1,393 of 1,393 unit-test calls.
  • Slice (i)'s five default points are the reader's (ROADMAP.md, "Phase 2, the expected steps", step 1; no answer moves). The point a bare game_log, player_stat, player_splits, record_when or period_split means is read in query/point.py (DEFAULT_POINTS, default_point) from the Scope alone; their five adapters in compose.adapt are gone (adapters 10 -> 5, test_frozen_shapes retires them), and adapt.to_reading reads through point.default_point. What those points needed from the answer side came to the reader's side first: the below/above phrase reader is query/lines.py (MeasureFilter, measure_filters; the one re import templates.common had, so the regex ratchet is 7); measures holds stat_column, log_extras with GAME_LOG_STAT_COLUMNS, period_split_measure with PERIOD_RATE_STATS, STAT_LINE, SPLIT_LINE, PLAYER_STAT_NAMES and THRESHOLD_STAT_NAMES (the SQL tables held to them by tests/query/test_measures.py); reading holds period_label, period_narrowing, scope_reads_box_scores (was templates.players._player_stat_reads_box_scores) and STARTER_SIDES (one home; player_games imports it); entities holds BOX_SCORES, GAME_LOGS and SHOT_AVAILABILITY beside the Availability they are. The old names re-export each. And TemplateUnsupported IS reading.Unsupported now, one exception: a template refusing a slot, the reader having no reading of a point and the planner unable to say a query are one verdict, and the phrase reader raises it for both sides. Identical on 628 of 628 recorded questions and 2,710 readings old tree against new; 43 of 1,392 unit-test calls differ in the exception's class name alone (TemplateUnsupported: reads Unsupported:, the same message and outcome).
  • The relation's shared steps are public names (ROADMAP.md, "Phase 2, the expected steps", step 1's first item; no answer moves). templates/common.py holds the shared steps every reader narrows the relation through, and twenty of them were private by that module's convention, so a reader living in compose/ (the game log's, since step 0) counted against the private-template-import ratchet for calling them. They are public now - ResolvedSpan (was _Span), span_of, slot_season, resolved_team, resolved_player, optional_team, no_narrowed_games, season_phrase (was _period), season_label (was _season_name), period_label, player_relation_season_type, relation_scoping, condition_scope, where_in, apply_period, BOX_SCORES, GAME_LOGS, career_end, box_score_notes, and player_games.log_carries_rebuilt - with the old private names kept as aliases for the templates that still use them. compose/ imports the public names, and the ratchet reads 59 where it read 91: what it counts now is the compiler's dependence on template BODIES' helpers (fifty of them compose.present's), which each slice deletes with its presenter.
  • The game log is a reader and a sayer: Phase 2's first slice (ROADMAP.md, "Phase 2, the expected steps", step 0; no answer moves). query/result.py declares the Result (ROADMAP-TYPES.md's, by the phase that first uses it): Result, Part, Rows, Span, Narrowing, Window, with the remarks as notes.Note(kind, facts). compose/logs.py is the retired game_log template's body - a player's log over one season type or both merged by date, and a team's - moved from templates/games.py whole and split into what reads (the rows, the count the window cut them from, the per-row summary, the notes as kinds and facts) and what says; compose/say.py words a Result and nothing else, one phrase per note kind (note_phrase: window_short, definition, lines_rebuilt, games_unseen, floor), recording each through notes.note so the answer's remarks are the Result's notes. The templates that still write sentences phrase those kinds through it too (templates.common._box_score_notes over the new box_score_notes_read). compose.answer reads a rows-shaped game_log, and the player_stat window the retired template handed to the log, through them before any presenter runs; compose.present._present_game_log, _present_team_game_log and _present_player_stat's rows branch are gone (presenters 12 -> 11; test_frozen_shapes retires game_log), as are the ~600 lines of the log in templates/games.py (_log_extras and GAME_LOG_STAT_COLUMNS live with the reader). The import contract "The compiler's sentence reads no warehouse" holds compose.say beside compose.sentence. Identical on 628 of 628 recorded questions, text and remarks included, and 1,391 of 1,391 unit-test calls; the compiler's own run was measured against the template's read first (41 of 41 player logs: the same games and values), which is what step 1 executes. Six tests added (tests/query/test_say.py, a reader test), none deleted. The private-template-import ratchet grew 87 -> 91 by the relation's shared steps the moved body calls, listed with Jeff's say-so (step 1 moves them to the relation's side).
  • Three compiler modules take _clamp_limit from reading, where it lives (Phase 1 review, (c)): compose.adapt, compose.present and compose.team imported it through templates.common's re-export; the private-template-import ratchet is 87.

These notes are cut short: the full 5.0.0 section (292996 characters) is longer than a GitHub release allows. Read all of it in CHANGES.md.