Repository navigation
- Breaking (packaging): the distribution is
association-pyon PyPI; the import package, theassociationcommand and the repository keep the bare name. PyPI refusesassociationas 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. Sopip install association-py(and'association-py[web]') is the install command from this release on;association.__version__anddocs/conf.pyread the installed metadata under the new name;association web --helpand the missing-extra hint saypip install 'association-py[web]'; the wheel isassociation_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.pymoves a tag-pinnedassociation[web] @ git+...install line to the new name with its pin (EXTRAS_PIN), since pip and uv refuse aname @ urlinstall whose metadata name differs; one test. Trusted Publishing is unchanged: the publisher on PyPI is registered forassociation-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.answerpicked a reader byintent ==(72 mentions of "intent" incompose/__init__.py), passed every readerSTATED_SCOPING[intent](23 sites), andsay()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 andby;player_periodsandteam_periodsname a quarter's read), byplan.shape_of(intent, query), on the newPlanned.shape- andcompose.answerlooks the reader up in one table by that key alone (compose._ROUTES; a route markedonlyis the point's only answer and declines rather than step aside).STATED_SCOPINGis keyed byPointShape;plan.SHAPE_WORDSnames each shape's retired words, andplan.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.sourcechooses 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 asPlanned.floor, the coverage entry the point's season is checked against (coverage.SOURCESis still keyed by the retired words;# Phase 3: needs the relation's declared tables). "intent" incompose/__init__.py: 72 -> 6 mentions, all in prose; inquery/: 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 - theplannedvaluecompose.answeris handed carries the two new fields (shape,floor), checked by stripping them: 0 differ. Tests: 0 deleted; the scoping tests readwords_stated(name)andSHAPE_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.factsis 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.factswas aMapping[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 newresult.Cellunion (Period,OnDate,Line,Role,GameOfSeries,Calendar,Companions,Met,ShotValue- the minimal set the keys needed, declared by this step) onNarrowing.cells, read by type (Narrowing.cell(kind),lines(),period), andSpan.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 factsis itsPeriodcell. Moved off the bag besides: theaboutpage 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 (anOnDatecell 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.periodis thePeriodcell (a label and its numbers, where it was a string);player_games.Narrowed.lineskeeps each line as values beside its words.Scalar.hownames every scalar's reduction (season,per_shot,per_100,total,projection,rankedbesideper_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 intest_say.pyandtest_templates.pyrewritten to the typed facts and cells; 1 added. - Breaking (Python API): a refusal found at RUN is typed -
result.Refusal(kind, facts)andresult.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 wordedReply(36 constructions incompose/, 6 in the relation modules), aResultwith no parts andfacts["missing"](compose/team_stats.py, whichsaybranched on), or aResult.emptysentence; now every reader returnsResult | Refusal | Clarifyand nothing outsidecompose/say.pywrites a refusal's words.Refusal.kindis closed: a READ's causes are the newresult.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 stayreading.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; oneRefusaltype carries either, andsay.refusal_phrase(kind, facts)is the one table that words both (the planner's sentences moved there fromcompose.plan, whoserefusal_resultnow saysrefusal_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.clarificationandentities.suggestionarecompose.say.clarification/suggestion;entities.no_matchisentities.unmatched, returning aClarifyor aname_unmatchedRefusal;entities.clarify/resolved_player/resolved_team/optional_teamreturn aClarifywhere they returned a wordedReply;player_relation.no_narrowed_gamesandno_games,team_relation.condition_team_no_gamesreturn aRefusal;Result.emptyis aRefusal;compose.core.Refusedcarries the typed outcome;fingerprint.FingerprintUnavailablecarries a cause and its facts (kind,facts) rather than a sentence;coverage.floor_refusal/coverage_refusalarecheck_coveragetyped;say.say_period_refusal,say_period_unread,say_team_period_*,say_period_rank_*,say_conference_refusalandrankings.leaderboard_shot_distance_refusalare 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 arerefusals.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.py9,test_entities.py5 via_no_match, one each intest_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
c6e9253a rate over a column the rebuild never measured skips rebuilt rows in both sums - right - but thegamesbeside 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_rebuiltnames those measures;_scalar_selectsreads each one's games beside it ("<measure>_games",COUNT(*) FILTER (WHERE NOT pgl.reconstructed)), kept on a row only where fewer thangames;_compile_grouped's minimum counts the ranked measure's games (_ranked_games);core.unread_notewrites alines_rebuiltnote (what"unread", or "unread_ranked" for a ranking with a minimum;lines_rebuilttakes thecolumnsfact now), phrased once insay.note_phrase, said bysay_splitsand 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, readsbox_score_notes_readand phrases the notes itself. A table'sGandW-Lstay 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_gameasserts 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.templatesis 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. Toquery/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 ofplayer_games.pybecause the two together would be 2,600 lines); toquery/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 becauseconditionsimportsteam_gamesand these steps import the player relation's span steps, so one module would be a cycle); toquery/coverage.py(new, 17:check_coverage,coverage_caveat,SOURCES- wasTEMPLATE_SOURCES, one table for every reader -RANKING_INTENTS,TABLELESS_INTENTS); toentities(5:resolved_player,resolved_team,optional_team,slot_season,clarify); toseason_line(8:PLAYER_STAT_COLUMNS,HISTORY_COLUMNS,SHOOTING_STATS/ShootingStat,ADVANCED_STATS/AdvancedStat,MADE_STAT_ATTEMPTS,wanted_stats); toreading(4:SCOPING_SLOTS,unhonored_scoping,PLAYER_INTENTS); toseason_text(3:SEASON_TYPE_NAMES,season_phrase,season_label); tocompose.say(4:table_cell,format_value,count_games,defaulted_season_note); toconditions(3:condition_span_label,condition_needs_player_refusal); toplayer_games(2:THRESHOLD_STAT_COLUMNS,STAT_LABELS); tocompose.rankings(1:leaderboard_shot_distance_refusal).unhonored_scopingsits inreadingrather than besideSTATED_SCOPINGincompose.planbecause every reader imports it andplanimports the readers. 2,842 lines oftemplates/deleted with the package (3,011 at step 6's start). The private-template-import ratchet reached 0 andscripts/check_ratchets.pyno longer has it (norratchets.jsonits key);sql_outside_the_relationsmoved, 38 statements before and after:templates.common13,.players3,.games1,.splits1 gone,player_relation16 andteam_relation2 new - two new entries--shrinkcannot 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.unansweredcarries the same words. 0 of 628 recorded questions reach it; 5 of 1,406 unit-test calls (Agent.askonother) move, each this sentence and nothing else; 2,710 of 2,710 readings identical. check_scope,HONORED_SCOPING,TEMPLATESand the answering loop's template branch are gone - Phase 2, step 6: withTEMPLATESempty since the shot slice,check_scopechecked a declaration that declared nothing (HONORED_SCOPINGheld only comments), andAgent._run_scoped_template,_run_templateand 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 bycompose.plan.unhonored_scopingagainstSTATED_SCOPING, the one declaration left until Phase 3's cells._settled_before_templateis_settled_before_reading.tests/stage_calls.pyrecords notemplate: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 behindcompose:answer, has been recorded since step 0).tests/query/test_frozen_shapes.pyfreezes 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 ofTEMPLATES' sources for the player slot, and one ofHONORED_SCOPING's slots), 11 moved fromcheck_scopeto the reader's declaration (unhonored_scopingoverSTATED_SCOPING, or the planner'sdeclined), 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):
TemplateResultisassociation.query.answer.Reply,TemplateContextisassociation.query.answer.AnswerContext, and theTemplateUnsupportedalias 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, besideAnswerandArtifactin the one module that imports nothing else of the query package. Every import updated (47 files); moved first, so the relation's shared steps leavingtemplates/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_redirecteddecision on the Result, phrased bydecision_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: noseason_redirectedafter 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_chartis a reader, a draw step and a sayer on the declared shot relation; its template,templates/shots.pyandshotchart.render_shot_chart/render_for_playerare gone (Phase 2, step 5, slice (v)).compose.shots.read_shot_chartis the template's read and the renderer's moved whole: the player resolved once throughshotchart.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 throughscoped_gamesand the relation'sgames_subquery, and the shots read offshot_chart- every statement throughcore.values_of(a pinned game is now anIN (?)list like a set of them, the same rows). It reads aResultwhose body is the draft'sChart(result.Chart, new: the artifact's kind,madeandattempted, 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'sflooron the shot table) and decisions (also_matched, phrased once indecision_phrasenow;season_redirectedfor 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_chartwrites 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 - andsay_shot_chartwords it as the template and the renderer did, the file asAnswer.artifacts.shot_chartjoinscompose.COMPILED_INTENTS;HONORED_SCOPINGandTEMPLATESlose its row;templates.common.clarifyis public (_clarifystays its alias). Breaking:shotchart.render_shot_chartandshotchart.render_for_playerare removed - their only caller since the retired agent was their own tests;periodandmade_only, which no question reaches, go with them. Templates 3 -> 2 (1 -> 0 once rebased onto the NetPoints charts:TEMPLATESstands empty until step 6 deletes the registry); statementsshotchart1 -> 0 andtemplates.shots1 -> gone,compose.shotsruns none itself; scoping tables 14 -> 13 (templates.shots._SHOTS_GAME_NARROWING_SLOTS: the reader derives the cells inline fromRELATION_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 becomecompose.answer's - 38 the same outcome, 4 declines whose reason is thedeclinedcallback's - and 4 calls only after (three ofrender_shot_chart's tests re-seated on the reader, one moved off it). Tests: 9render_shot_charttests 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), 40shot_charttests re-seated throughcompose.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 becomecompose.answer's with the same outcome, 8 declines now carried by thedeclinedcallback (one re-seated test, 1999 -> 2010), 9 the reading move, and 9 calls only after (the new and re-seated tests).shot_distanceis a reader and a sayer on the declared shot relation; its template is gone (Phase 2, step 5, slice (v)).compose/shots.py'sread_shot_distanceis the template's read moved whole - the player and span throughscoped_player, the games a narrowing or a window pins the read to throughscoped_gamesand the relation'sgames_subquery, the average overshot_chart- with both statements built there and executed through the compiler's one door (core.values_of, over aseason_line.Statement); the distance statement also counts the games the shots came from (Scalar.games), which no answer says. It reads aResulton aSpanwhosesourceis"shots"(new), oneScalar(avg_feet,attempts), and its remarks as kinds and facts: a derived season'sshot_values_derived, and the career'sflooron the shot table (career_before_floor,career_clipped,career_whole), each phrased once innote_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_distancewords it as the template did.shot_distancejoinscompose.COMPILED_INTENTS;HONORED_SCOPINGloses its row (moved toSTATED_SCOPINGwith the reading move); the coverage floor still readsTEMPLATE_SOURCES["shot_distance"]throughcheck_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.shotsstatements 2 -> 1,compose.shots0 (every statement throughcore.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 becomecompose.answer's - 25 with the same outcome, 3 declines whose reason is thedeclinedcallback'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 throughcompose.answer(test_templates._compiled), none deleted.shot_chartandshot_distancehave 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): achartand ascalaron the declaredshotsrelation (reading.Shapegainschart,reading.Relationgainsshots), which no word moves. The planner plans it as acompose.shots.ShotQuery, declining a narrowing beyond what the retired templates' words state (compose.plan.STATED_SCOPINGgains theirHONORED_SCOPINGrows) incheck_scope's sentence; until the readers land,compose.answerdeclines aShotQuery("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 withcheck_scope's sentence forround), 9 of 1,404 unit-test calls; every answer, its data and its remarks identical.fingerprintis a reader, a draw step and a sayer over the NetPoints relation; its template andtemplates/netpoints.pyare 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_fingerprintresolves 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_resultreads the season's or a first or last game's fingerprints through the chart's own reader,query/fingerprint.py(load_for_players), into aresult.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 abelow_poolnote, one with no row ano_data_fornote and the other names that matched thealso_matcheddecision (its phrase,". Note: other players also matched: [...]", is the one the shot chart says too).compose.netpoints.draw_fingerprintwrites the radar page between the reader and the sayer (fingerprint.fingerprint_pageoverquery/radar.py) and sets the chart'spath: a reader names no file that does not exist yet, and the sayer takes the Result alone.compose.say.say_fingerprintwords it - "Rendered NetPoints fingerprint (total) for ... (2026 season, percentile scale) to " and its notes - word for word, withAnswer.artifactsthe 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, aname_left_outdecision) 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).fingerprintjoinscompose.COMPILED_INTENTSand leavesTEMPLATES(3 -> 2, the shot charts) andHONORED_SCOPING;templates/netpoints.pyis deleted. Removed from the public API:fingerprint.render_for_playersandfingerprint.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 arefingerprint.load_for_players,fingerprint_captions,fingerprint_fileandfingerprint_page, and their message is the sayer's.query/fingerprint.py's three statements run throughcore.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 syntheticfingerprintquestions identical in values, text, remarks andanswered_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 crosscompose.answerand where two tests' inputs changed (below). Ratchet:sql_outside_the_relationslosesfingerprint: 3;compose.netpointsruns none directly. Tests: 14 intest_templates.pyre-seated on the compiler (_compiled("fingerprint")), 25 intest_fingerprint.pyre-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); intest_agent.pytwo re-seated from a template stand-in ontocompose.answerand 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); thecheck_scopeassertion re-seated onSTATED_SCOPING.player_netpointsis 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'sread_player_netpointsreads one player's NetPoints into aResulton a span whosesourceis the new"netpoints": a season's line fromnet_points_player(aScalarof its six columns) and the play-type split behind it fromnet_points_player_fingerprint(aGrouped(by="category"), one row per category with its offense, defense and total per 100 possessions beside the season totals, and thepartitionflag), or one game's fromnet_points_player_game(a one-gameScalarunder aWindow, 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 throughcore.values_of; the defaulted-season redirect isseason_line.season_redirectand aDecided("season_redirected"), as before. The notes are kinds and facts (part_missingfor the line or the split,definitionfor the units and the overlapping slices,hintfor the fingerprint of that game), phrased once incompose.say.note_phrase;say_player_netpointswords every branch word for word, with the page'sdataunchanged.compose.answerreads 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_netpointsjoinscompose.COMPILED_INTENTS, and leavesTEMPLATES(4 -> 3) andHONORED_SCOPING; its sources stay inTEMPLATE_SOURCES, which the coverage check reads for compiled intents too.FINGERPRINT_PARTITIONmoves toassociation.nba.netpoints. A branch nothing reached is gone: the template readrate="total"as season totals, but nothing ever setsrateon this intent (the router sets it forleaderboardandteam_statonly; 0 of 2,710 readings) andcheck_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 syntheticplayer_netpointsquestions identical in values, text, remarks andanswered_by; 1,402 unit-test calls, 30 differing only where the 28 re-seated tests now crosscompose.answerinstead oftemplate:player_netpointsand where four tests' inputs were changed (below). Ratchet:sql_outside_the_relationslosestemplates.netpoints: 3(compose.netpointsruns none directly - every statement throughvalues_of). Tests: 28 re-seated on the compiler (_compiled("player_netpoints")intest_templates.py); 2 deleted with theratebranch; 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; onecheck_scopeline removed with the row.player_netpointsandfingerprintread 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 (aplayersslot and noplayer), "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, ascalar;_default_fingerprint, the newchartshape) on the newnetpointsrelation (reading.Relation, the declared relation ofROADMAP-TYPES.md), planned ascompose.netpoints.NetPointsQuery.compose.plan.STATED_SCOPINGdeclares their templates'HONORED_SCOPINGrows (order;orderanddate) and the planner declines beyond them incheck_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 (6player_netpointsand 29fingerprintfrom "no adapter for ...", 11 two-playerfingerprintcomparisons - "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 andanswered_byidentical 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 intest_compose.pygains the two; none deleted or moved.team_recordis a reader and a sayer;templates/teams.pyis gone (Phase 2, slice (iv)).compose/team_records.py'sread_team_recordreads 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 intoquery/team_seasons.pybeside the season-line and power-index ones, throughcore.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'srowsread,compile_team_over, over the record's own narrowing that excludes the NBA Cup final;TEAM_ROW_COLUMNSgainstg.neutral), the table by month (the same rows grouped by month - the compiler'sgroupedread bymonth_of_yearjoinsteam_box_statsand drops a game with no box row, which a record counts), and both season types summed ("including the playoffs") - into aResultwith aScalar(how="record")body (newhow) or aGrouped(by="month");say_team_record/say_team_record_by_monthword it as the template did, every remark aNote(floor,standings_short,game_list_disagrees,definitionneutral_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 bycalendar.bare_month, a conference named as a team byrefusals.conference_named(said bysay.say_conference_refusal;compose.team_stats.conference_refusalnow uses both). Default pointpoint._default_team_record; itsHONORED_SCOPINGrow moved toSTATED_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.pywhole (team_recordand its 40 helpers,conference_refusal, the private_tally/_possessive/_record_pct) -TEMPLATES5 -> 4, the charts. Readings: the 42team_recordreadings 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 onSTATED_SCOPING, 1 added. Statements:templates.teams9 -> 0; the regex ratchet losestemplates.teams.period_leaderboardis a reader and a sayer; its template is gone (Phase 2, slice (iv)).compose.periods.read_period_leaderboardreads the league's (or a team's) players ranked per game by a stat in one quarter or half - aGrouped(by="player", ranked_by=<measure>)with the period onNarrowing.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) asDecideds;say_period_leaderboardwords both as the template did,decision_phrasegaining 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 throughcore.values_of; the two refusals (a shooting percentage ranked per quarter, a plays column on a warehouse with no plays) aresay_period_rank_rate/say_period_rank_unread. Default pointpoint._default_period_leaderboard(a grouped read by player on the league-wide relation); itsHONORED_SCOPINGrow moved toSTATED_SCOPING;compose.plan.TEAM_SHAPESisPORTED_SHAPES, since this one is not a team's. Deleted: the template and its eight helpers -templates/games.pyholds only_team_slot_for_playernow (TEMPLATES10 -> 9). Readings: the 30period_leaderboardreadings 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.games4 -> 1.- Two teams' meetings execute the team compiler's
rowsread.compose.meetingscompiles the meetings throughcompose.team.compile_team_over(shaperows, oldest first) and executes it throughcore.rows_of, where it built the relation'srows_sqlstatement itself; the season each meeting was played in is the compile's ownseasoncolumn (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_pointsis a reader and a sayer; its template is gone (Phase 2, slice (iv)).compose.periods.read_team_quarter_pointsreads 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 aResultwith aScalar(the total, average and extremes over the games that reached the period, or a rate's makes over attempts) and a detailRowsof the games; the quarter is a cell on the Result'sNarrowing(Narrowing.period, new: the period relation's cell on a team's games), which is whatsay()dispatches the team's period sayer by. The statement moved whole into the relation (team_games.period_games_sql) and runs throughcore.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_unseenwithwhy="no_play_by_play",rebuilt_agreementwithwhat="team_period_rebuilt") are phrased incompose.say(say_team_period_unknown/_unread/_untrusted,note_phrase). Default pointpoint._default_team_quarter_points; itsHONORED_SCOPINGrow moved toSTATED_SCOPING. Deleted: the template and its 14 helpers intemplates/games.py(with_period_scope, which only it used) (TEMPLATES11 -> 10). Readings: the 15team_quarter_pointsreadings 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.games5 -> 4.head_to_headis a reader and a sayer; its template is gone (Phase 2, slice (iv)).compose/meetings.py'sread_head_to_headreads two teams' meetings - the two teams fromteams,teamandopponent, the first team's rows of the team relation narrowed to the other throughtemplates.common.team_games, every meeting in a season, on a date, at a venue or over a since-bounded or whole-career span - into aResultwith aGrouped(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_headwords 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), itsHONORED_SCOPINGrow moved tocompose.present.STATED_SCOPING, and the planner declines beyond it incheck_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 intemplates/games.py(TEMPLATES12 -> 11). The readings move by design: the 34head_to_headreadings 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 tocompose.answer, the readings gaining a point, and two agent tests moved offhead_to_headontofingerprint, 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, onecheck_scopecall on the retired row).validated_untilandteam_relation_scopingare public, as the relation's shared steps. Statements:templates.games7 -> 5.- A team's record over its own line is a reader and a sayer, and
compose/present.pyis gone (ROADMAP.md, "Phase 2, the expected steps", step 4, slice (iv); no answer moves).compose.records.read_team_record_whenreadsrecord_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 newlinestatement (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 bycompile_team_count, throughcore.rows_of- into theGroupedbythresholda player's record has;say_record_whensays the team's word for word (note_phrasegains the "games with a result" pool and the team's blank figure).TEAM_BOX_COLUMNS(the team's per-game figure for a stat,totalTurnoversfor turnovers) is the team compiler's. With that,present_teamhad nothing left:compose/present.pyis deleted,STATED_SCOPINGmoves tocompose.plan(its one reader, besideWITH_WITHOUT_STATED; every reader and test imports it from there), andtest_frozen_shapesretires the presenter freeze - it holds only that the module does not come back. Deleted fromtemplates.splits:_record_when_team_answerand its six helpers,_record_when_group,_RECORD_WHEN_TEAM_STAT_COLUMNS,_team_span_floor_note;_condition_needs_player_refusalis 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.splits3 -> 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) readswith_without's point - the teammates, the subject, the team, their stints and the overlap, moved whole fromtemplates.splits._with_without_readand its helpers - and executes the team compiler's newpresencestatement (compose.team.compile_team_presence, over the relation cellconditions.presence_games_sql, whichconditions._with_without_gamesexecuted itself) throughcore.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 aGroupedbypresence, 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_withoutwords it word for word (the row verbs, the heading; the notes throughnote_phrase, which gainsabout=- whose time the split counted - and aconsequencefor the games on neither side). Deleted: the presentercompose.present._present_with_withoutandTEAM_ONLY_PRESENTERS(its freeze intest_frozen_shapesnow holds that none comes back),templates.splits.PresenceSplitand fourteen_with_without_*helpers with_season_of_day,compose.team._compile_team_presence,TeamResult.presenceandteam_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:conditions8 -> 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_streakreads the team compiler'srunshape - now compiled (compose.team.compile_team_run: the runs asconditions._longest_runs_sqlover the narrowed team games, or every team-season's for the league) and executed throughcore.rows_of, with the span's count and seasons as a compiled statement of their own (compile_team_range) - into theRunsbody 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_streaksays all four subjects (_say_team_run, andsay_run_listingwithby_stat=False), word for word. Deleted: the presentercompose.present._present_team_streak,templates.splits._streak_team_answer,_streak_league_result_wordsand_team_season_range, andconditions._longest_runs(the runs are compiled statements now,_longest_runs_sqlkeeps the skeleton).templates.splits'team_span_label,team_where_inandcondition_team_no_gamesare 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.splits4 -> 3,conditions9 -> 8; private-template-import ratchet 15 -> 7. team_leaderboardis 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.pygains the ranking's last two statements, moved whole: every team's home or road record (team_venue_records_statement, the standings' strings read byvenue_records-templates.teams._parse_record's reading done by its digits, so the relation module imports nore) and every team's record across the seasons fromsinceon (team_since_records_statement, which was_team_leaderboard_since_records).compose.team_stats.read_team_leaderboardreads every team ranked by one metric into aResulton a"team_seasons"span: aGroupedbyteam,ranked_bythe 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;factscarry 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_leaderboardwords 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_tableandrecord_tableare gone (their statements are the relation's).templates.common.validated_untilis public.HONORED_SCOPING'steam_leaderboardrow is gone -compose.present.STATED_SCOPINGdeclares it, through the sameteam_relation_scoping.team_leaderboardjoinscompose.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: theteam_leaderboardtemplate and its helpers (DEFAULT_TEAM_LEADERBOARD_LIMIT- moved tocompose.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);TEMPLATES9 -> 8. Statements:templates.teams12 -> 9,team_metrics2 -> 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 20team_leaderboardcalls reach it throughcompose.answer(six declines through itsdeclinedwith 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_statis 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.pygains the season line's and the records' statements, moved whole fromteam_metrics.season_tableandrecord_table(team_lines_statement/team_lines,team_records_statement/team_records; the two functions stay inteam_metricsonly as runners of those statements for theteam_leaderboardtemplate, until it retires).compose.team_stats.read_team_statreads one team's season into aResulton a"team_seasons"span: aGroupedbymetric(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 aScalarof wins, losses and rank, and - where nothing can be given - a Result with no parts andfacts["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 carriesfacts["short"](short_of_games, which wastemplates.teams._incomplete_opponents' facts). The rating formula, what a rank means and a value or rank left out aredefinition/value_withheldnotes.compose.say.say_team_statwords it, word for word, withtally,possessive,record_pct,metric_cellandshort_of_games_said(the template's_tally,_possessive,_record_pct,_metric_celland_incomplete_opponentssentence; the template's copies stay forteam_recordandteam_leaderboarduntil they retire). A team's own total underteam_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_statjoinscompose.COMPILED_INTENTS. Deleted: theteam_stattemplate and_team_stat_missing,_team_stat_table,_team_stat_single,_team_stat_summary,_team_record_rank;TEMPLATES10 -> 9. Statements inteam_metrics3 -> 2. Identical on 628 of 628 recorded questions (text and remarks; 3team_statanswers, 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 (58team_statanswers); of 1,404 unit-test calls, the 11team_statcalls reach it throughcompose.answer- their arguments carry the point, and "vibes", the unknown stat, is declined throughcompose.answer'sdeclinedwith the template's sentence where the template raised it - every outcome identical. Tests: the 9team_stattests re-seated on the compiled answer; none deleted.- A ranking's Result names the season line as its relation.
read_leaderboard'sSpancarried the defaultsource="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, andsay()'s season-line branch words aGroupedbyplayerwithsay_leaderboard. Found checking thatSpan.sourcetells the comparison from the matchup: it did, and only the ranking's span was wrong, unseen becausesay()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
ranboundary is gone (Phase 2, step 4).compose.answerno longer takes arancallback,Agent.ranis gone, andstages.snapshot/read_stagesno longer takeran: they recorded the query the compiler executed where it differed from the planned one, which onlyplan.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'squerystage is the planned query, as it already was whereverranwas unset. 628 of 628 recorded questions identical (thequerystage included), 1,405 of 1,405 unit-test calls (the recorder never wrote the argument: a callback, whichtests/stage_calls.pyleaves 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.pyis folded whole intoquery/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_ofnow takes a season-lineStatementas well as a compiled one - whereleaderboard._rowsand the schedule lookup ran their own. Breaking:association.query.leaderboardis gone; importrun_leaderboard,run_career_leaderboard,rank_season_line,default_min_sample,not_a_postseason_copy,LeaderboardError, the result types,SEASON_TOTAL_OFandMIN_SAMPLE_LABELSfromassociation.query.season_line, and the metric aliases andresolve_metricfromassociation.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(fromtemplates.common, with aseason_type_columnso a NetPoints answer's redirect is this read and not a statement of its own),seasons_on_record(fromtemplates.players), andseasons_played(the career shot chart's own_career_shot_spanstatement). Statements outside the relations 85 -> 79 (leaderboard2 -> gone,templates.common14 -> 13,templates.players,templates.netpoints4 -> 3,templates.shots3 -> 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_outlookis 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.pyis the team-season relations' module (team_snapshotshere;team_seasonsfollows): 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 wastemplates.teams._team_outlook_choose),snapshot_facts, and the constantsBPI_PRESEASON,BPI_REGULAR,BPI_SNAPSHOT_NAMESandBPI_CHANCES(moved fromtemplates.teams), executed through the compiler's one door (core.values_of).compose.team_stats.read_team_outlookreads them into aResulton aSpanwhosesourceis the new"team_snapshots": aScalarof the snapshot's figures for the team and aGroupedbyroundof 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 assnapshot/value_withheldnotes, and - where no snapshot of the kind asked for holds the team - a Result with no parts and the season's snapshots infacts, with thehintnote.compose.say.say_team_outlookwords it, word for word, every note phrased once innote_phrase(_say_team_season_note; thedefinitionphrasesrating_formulaandrank_meaning, andvalue_withheld'spoints_allowedandrank, land here for the next two readers).compose.answertries a team-season intent's reader first - on its ownTeamSeasonQuery, 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_outlookjoinscompose.COMPILED_INTENTS.RATING_NOTEmoves toteam_metrics(the sayer phrases it). Deleted: theteam_outlooktemplate 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);TEMPLATES11 -> 10. Statements intemplates.teams14 -> 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 throughcompose.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
rowsandgroupedcompiles, 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 ofcore.compile_over, for a reader that settled the team, the span and the narrowed games through the shared steps - compiles aTeamQuerywithshape="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) andshape="grouped"by a key ofTEAM_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 builderteam_games.grouped_sql.TeamCompiledruns through the one door,core.rows_of.compose.logs(_team_log,_team_mixed_rows, which the team compiler's window sum shares) andcompose.splits(_team_splits, one grouped read per kind, its count, seasons and blank box scores read off the groups) execute the compiled statements; their ownteam_rows_sql/team_aggregate_sqlcalls,_TEAM_GAME_LOG_SELECT,_TEAM_SPLIT_SELECT, the splits' blank-count statement and their use oftemplates.splits._team_season_rangeare gone, and with themconditions._split_rowsand 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.logs4 -> 2,compose.splits2 -> 1,conditions10 -> 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_leaderboardandteam_outlookwere 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 ofROADMAP-TYPES.md-reading.Relationgainsteam_seasons(the season stat line and the standings) andteam_snapshots(ESPN's power index) -point.TEAM_SEASON_POINTS/team_season_point, planned ascompose.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_SCOPINGdeclares the three (none, none, the team relation's cells lessteam_leaderboard's exclusions;templates.common.team_relation_scopingis public).coachhas no template:point._read_pointrefuses it by the new causeno_coach_table(CAUSES16 -> 17, factunanswerable),compose.plan.refusal_resultsaysCOACH_REFUSAL(moved fromtemplates.teams), word for word with the same data, andcoachjoinscompose.COMPILED_INTENTS; thecoachtemplate is deleted (TEMPLATES12 -> 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 (16team_leaderboard, 4team_outlook, 1team_stat- "Knicks pace this season" - from the three declines to ateam_seasons/team_snapshotspoint; 3coach- "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 theno_coach_tablecause); text, remarks, result andanswered_byidentical on all 628. 2,710 readings - 175 (96team_leaderboard, 70team_stat, 4team_outlook, 5coach), 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 fromtemplate:coachtocompose.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 atleaderboardwith the team-season point asserted underteam_stat; none deleted. - A leaderboard's defaulted season is recorded as the decision it is.
read_leaderboardcarriesDecided("season_default", field="season", chose=<season>, why="latest_on_record")when the question named none, andsay_leaderboardwrites the heading's own span phrase ("in the 2026 regular season") through it, soAnswer.decisionsand 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_defaultis a newDECISION_KINDSentry (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 incompose.answeris 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_readsand the newcompose.rankings.leaderboard_reads(the static half ofread_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) - andcompose.plan.planplans the rest as the game-level point (plan._game_level, which wasplan.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.answerre-plans nothing;games_readingand the branch are deleted, andread_leaderboard's two step-asides are the planner's (_leaderboard_refuse_a_subjectis_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", plansource="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_byand the query run. 1,405 unit-test calls - 4,compose.answer'splannedargument 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: : ." withanswered_by="refused"- arereading.Causes the planner says (compose.plan.refusal_result,answered_by="fast"):CAUSESgrows 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'sintent),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), andranking_unit(metric,rate: the season-line ranking's "per 90" refusal, a sentencecompose.rankings._leaderboard_no_such_ratewrote in the reader, now the planner's_ranking_unit, word for word; the ranking reader declines a unit it is handed).reading.PointRefusedis a kind ofUnsupportedwith 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_pointcatches 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 guardsread_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 theranking_unitcause; answer identical). 2,710 readings - 20: those 3, and 17 feed readings from decline to cause (single-game highs with no stat, 7; byfieldGoalPctortriple_double, 4; counts kept under a phrase naming no stat, 4; logs oftriple_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_LIMITis 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, andleaderboard.run_leaderboard/run_career_leaderboardcap their SQL by it;leaderboard.MAX_LIMIT(100) is gone. Measured first: it was a cap for the retired agent's model-suppliedlimit, and the only caller left (compose.rankings.read_leaderboard, the season and career rankings, throughleaderboard.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_limitbefore 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.pyloses itsMAX_LIMITallowance (4 known collisions, from 5).test_get_leaderboard_limit_is_clampedasserts 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.pyis gone (ROADMAP.md, "Phase 2, the expected steps", step 3; no answer moves). The point a barewith_withoutmeans is read inquery/point.py(_default_with_withoutinDEFAULT_POINTS), with its decline word for word; who a with/without split divides by -without's names, elsewith_player's, else the ones aconditionsentry gives a role, and each role - issubject.with_without_namedon the reader's side (templates.splits._with_without_namedand_with_without_roleswere 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, whichTHRESHOLD_STAT_COLUMNSmaps to itself).WITH_WITHOUT_STATEDlives in the planner that declines by it (compose.plan), andcompose.present.STATED_SCOPINGtakes 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 calledto_readingcalltests/routed.default_reading(point.default_pointover 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.pyis the second relation beside the player's games:player_season_stats_deduped(one row per player per season) andplayer_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_ofexecutes them through the compiler's oneexecute(shared withrows_of), by position, since an advanced stat's season read selectsseasontwice.compose/seasons.pyreads them into aResult:read_player_line(aScalar- 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 aDecided("season_redirected")and an advanced career's empty seasons aNote("seasons_missing")),read_player_history(aGroupedbyseason, a career's line aPart(role="summary")),read_player_compare(aGroupedbysubject, the NetPoints summary a detail part, a player with no line aNote("no_data_for")); each on aSpanwhose newsourcefield is"seasons", which is howsay()tells them from the games relation's shapes.compose.saywords them (say_player_line,say_player_history,say_player_compare;note_phrasegains 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 themcompose.present.PRESENTERS,Presenterandpresent()-present.pyholdsSTATED_SCOPINGand the team'spresent_teamalone and their 25 template functions and constants intemplates/players.py(_player_stat_season_lineand its season, career and advanced reads,_player_history_readand its helpers,_player_compare_linesand its NetPoints read and table;DEFAULT_HISTORY_SEASONS,MAX_HISTORY_SEASONS,MAX_COMPARED_PLAYERS,COMPARE_STAT_LINEandNETPOINTS_COMPARE_ROWSlive inseason_linenow).templates.common.table_cellis public. Statements intemplates.players10 -> 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. leaderboardis 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 aResultwhose body is the draft's ranking: aGroupedbyplayerwith the newGrouped.ranked_by- the metric for a stat ranking,"games"for the league's count over a line (compose.countssets it), which is whatsay()tells the two rankings byplayerapart with, from the body and not fromfacts- each row its name, a competitionrank(ties share one) andvaluesby measure. The read MOVED, not re-derived:leaderboard.rank_season_lineis the season line's one door (run_leaderboard/run_career_leaderboardwith their default season, qualifier, traded-player dedup, postseason-copy exclusion and career pool, unchanged, and the most-recent-team lookupmost_recent_teams, moved fromtemplates.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 theminimumdecision it was (58;Decided, phrased once bydecision_phrase), the career pool afloornote on the season line (1), the most-recent-team remark adefinitionnote (4), the "also" and team columns row values, the team filterfacts["team"]. The words arecompose.say.say_leaderboard, word for word. Deleted: the presentercompose.present._present_leaderboard(presenters 4 -> 3),templates.players._leaderboard_rankingand 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 toleaderboard.py,DEFAULT_LEADERBOARD_LIMITtocompose.rankings);templates.common.table_cellis public. Tests: one re-seated (the position group steps aside, now onread_leaderboard), three added (the sayer's two rankings and a career's pool intest_say.py, the door's shared ranks intest_leaderboard.py), none deleted - the leaderboard's user-visible tests already ran throughcompose.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: statementsleaderboard3 -> 2,templates.players11 -> 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_wordswrote 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: onedefinitionremark; 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, iscompose.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 compiledpairstatement (compose.core._compile_pair, throughcore.rows_of) and reads it into aResultwith the draft's comparison: aGroupedbody bysubject- one row per player in the question's order, his averages over the meetings and the head-to-head wins - and aRowsdetail part holding the newest meetings (the count asked for orpairs.MATCHUP_MEETINGS_SHOWN, each team abbreviated as it was that season;total_before_windowis every meeting). The games between their teams with no box score are agames_unseennote (whatmeetings, phrased once insay.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_matchupwords 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_selectaliases every one of the other player's columns (other_<column>): unwrapped by the rebuilt guard,other.minutesread 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), andtemplates.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 ofslice2b_synthetic.py. Private-template-import ratchet 26 -> 24; statements outside the relations 95 -> 93. No test deleted; the relation-narrowing walk intests/query/test_templates.pywalks 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 compiledrunstatement (compose.core._compile_run, throughcore.rows_of) and reads it into aResultwhose body is the draft'srunsshape, declared here (result.Run: owner, length, first and last day and season, still open;result.Runs;Result.runs;result.run_ofreads a run statement's row;Span.floornames 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 aSpanandNotes (definitionwithtermstreak_rule/unseen_ends_run,still_open), each phrased once insay.note_phrase(still_openkeys a starred listing withlisted=True).compose.say.say_streakwords it, throughsay_one_runandsay_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_RUNand_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_coverageruns before any reader; a test holds 1988-1993).templates.splits.condition_span_labelis 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 barestreakorplayer_matchupmeans is read inquery/point.py(_default_streak,_default_player_matchupinDEFAULT_POINTS) from the Scope alone;compose.adapt._adapt_streakand_adapt_player_matchupare 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) aremeasures.streak_columnovermeasures.STREAK_RESULT_STATS(templates.splits._streak_kindand_RESULT_STATSwere this;_streak_wordsreads 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 ofcompose.core.run_scope), and the two run counts arereading.DEFAULT_STREAK_LIMITandreading.DEFAULT_NAMED_RUNS(templates.splits._DEFAULT_STREAK_LIMIT,compose.core.DEFAULT_NAMED_RUNS;compose.teamandcompose.coreread 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, 2fieldGoalPct, 2triple_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_highis 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 aResultwhose body is aRowsranked by the measure - the newRows.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 throughcompose.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 aresult.Decided(season_redirected) onResult.decisions.compose.say.say_single_game_highwords it, the template's sentence word for word;say.decision_phrasegainsseason_redirected, andtemplates.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_redirectis 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_seasonand_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_highand the deadDEFAULT_SINGLE_GAME_LIMITre-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_countis 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 aResultwhose body is aScalarwith the newhow="count"(the games counted, and how many of them are rebuilt assums["rebuilt"]), and the league's into aGroupedranking byplayer; the statement is the planned point compiled and executed (compile_query,rows_of- each row's rebuilt count is its ownrebuilt_shown, socore.runand 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_unseenwith its years) and the leader's rebuilt games (lines_rebuilt,counted).compose.say.say_threshold_countwords it in the template's words, each note throughnote_phrase(which gains the four phrases, and aconsequencefor 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_noteand_phrase_threshold_count;templates.players.seasons_on_record,empty_box_scoresandrebuilt_in_scopeare 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 andsingle_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_POINTSreads 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- islines.threshold_count_lineon the reader's side, overmeasures.THRESHOLD_STAT_NAMES, and the count's presenter reads it there too.compose.adapt._adapt_threshold_countand_adapt_single_game_highare gone (adapters 5 -> 3,test_frozen_shapes); the private-template-import ratchet loses its two_threshold_count_askentries. 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) andResult.decisions, the draft's own field (ROADMAP-TYPES.md, "Outcome");compose.say.decision_phrasephrases each kind once and records it throughnotes.decided, asnote_phrasedoes aNote. The period redirect'sseason_fallbackis the first - it rode inResult.factsas a dict for a day.tests/query/test_answer_notes.pyreads aDecided(kind="...", ...)as a write, as it reads aNote. No answer moves. period_splitis a reader and a sayer that execute the compiled statement (ROADMAP.md, "Phase 2, the expected steps", step 1, sub-step (e)).compose/periods.pyreads a player's quarter or half: one period game by game as the point compiled into arowsread 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 plannedgrouped-by-periodread - both throughcore.rows_of, the cross-season redirect ("last 5 games as a starter" with no starts this season) throughcompile_overover his career.compose/say.pywords 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, asrebuilt_agreementnotes - andPERIOD_LOG_COLUMNS), andperiod_leaderboardandteam_quarter_pointsread the same data and words. The compiler gains theopponent_namemeasure (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_splitand_present_period_by_quarter(the intents holding a presenter 9 -> 8),templates.games._period_split_from,_period_split_by_quarter_fromand the 29 helpers only they used; the by-quarter presenter's own cursor-to-dict copy. Ratchets: private template imports 54 -> 49, statementscompose.present3 -> 2 andtemplates.games8 -> 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 onperiod_distrust, the career read on the compiled statement), 3 added (rebuilt quarters, the calendar said, the dated by-quarter), 2 fixtures gain the warehouse'sopponent_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) andperiod_rate(a rate as the ratio of its sums over games) - lived intemplates/games.pybeside the retiredperiod_splittemplate's body, and the compiler (compose.core) tookPERIOD_RATESfrom there; they live on the period relation now (query/player_games.py, besidePERIOD_COLUMNS,PERIOD_AGREEMENTandREGULATION_QUARTERS), read byperiod_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'sorder/limitcut 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_advancedloses a branch nothing reached (no answer moves). Itsfrom_box_scoresflag refused an advanced stat over a narrowed set of games, but its one caller, the season line, always passedFalse: a narrowed point was the presenter's, which declined advanced stats before calling it, and iscompose.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 bycompose/stats.py(read_player_stat) into aResultwhose headline part is the newresult.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,Rowspart, and worded bycompose.say.say_player_stat, the retired template's sentence word for word. The statement is the compiler's: the point compiled with the newlineaggregate (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, whichRATESis built from for the four of them;two_pctis new, field goals less threes both ways), and the meetings as itsrowsread 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(nowcompose.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_gamesandformat_valueare 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_relationstemplates.players13 -> 11;private_template_imports54 -> 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_REBUILDsays 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_countasserts 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 newmonth_of_year: the month alone, every season's Octobers together, wheremonthis one season's months in order) with the line's measures, throughcompile_over;compose.splits._player_groupsruns 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 recordedplayer_splitsquestions, 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'srun, the three ported readers), so the statement ratchet falls (compose.core2 -> 1,compose.logs5 -> 4,compose.recordsgone). 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.RATESis 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 rulemeasure_sqlalready 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 mergingplayer_splitsonto the compiled statement, which moved that fixture's figure;test_a_figure_the_rebuild_gets_wrong_is_left_out_rather_than_averaged_inasserts 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_whenexecutes the compiled statement (ROADMAP.md, "Phase 2, the expected steps", step 1, sub-step (g), second shape; no answer moves). The compiler gains thelinegroup (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 themarginmeasure (his team's margin from his side, averaged).compose.records.read_record_whencompiles its scalar point as that grouped read and runs the compiler's one statement, where it ran the retired template's ownGROUP 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_logcompiles the planned point with the log's own columns as its measures (compile_query) and runs THAT statement, where it ran its ownrows_sqlcall 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 newcompose.core.compile_over- the second half ofcompile_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_readableis gone; its test asserts on the compiler's). One rule for "the warehouse carries rebuilt lines":conditions.box_sourceasks the log for the flag as well as the filled view (one statement, twoDESCRIBEs), andplayer_games.log_carries_rebuilt- a second definition over the log alone, which the three template readers ofl.reconstructedused while every compiledpgl.reconstructedread used the view's - is gone; the two agree on every warehousedata loadbuilds, 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 storedgames.dateas a bare day, whicheastern_date_sqldates a day early whereeastern_datepasses it through; it holds a UTC stamp now, as the warehouse does). The statement ratchet:compose.logs6 -> 5,player_games2 -> 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.pyreads both branches of the retired template into aResultwith aGroupedbody (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_splitswords both from the Result alone - the one table the two branches shared (_player_splits_answer) is one sayer now. Gone:compose.present._present_player_splitsand_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_refusalsand their tables (moved), and the two note writers whose phrases are the sayer's (conditions._Scope.floor_note,_unseen_note).SPLIT_KINDSlives inreading. 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 fromtemplates.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.pyreads a player's team's record when he reached a line into aResultwith aGroupedbody (three rows: reached, fell short, all his games;result.GroupedandSpan.phrasedeclared here) - the retired template's one grouped statement over the relation'sgames_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_whenwords it, with the phrases forgames_unseen(no box score),stat_blank, the pool'sdefinitionand the floor's second form added to the one-phrase-per-kind table.compose.present._present_record_whenandtemplates.splits._record_when_query/_record_when_answer/_record_when_blank_noteare gone (presenters 11 -> 10);compose.answerreads the ported shapes through_read_ported.templates.common.no_gamesis 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 baregame_log,player_stat,player_splits,record_whenorperiod_splitmeans is read inquery/point.py(DEFAULT_POINTS,default_point) from the Scope alone; their five adapters incompose.adaptare gone (adapters 10 -> 5,test_frozen_shapesretires them), andadapt.to_readingreads throughpoint.default_point. What those points needed from the answer side came to the reader's side first: the below/above phrase reader isquery/lines.py(MeasureFilter,measure_filters; the onereimporttemplates.commonhad, so the regex ratchet is 7);measuresholdsstat_column,log_extraswithGAME_LOG_STAT_COLUMNS,period_split_measurewithPERIOD_RATE_STATS,STAT_LINE,SPLIT_LINE,PLAYER_STAT_NAMESandTHRESHOLD_STAT_NAMES(the SQL tables held to them bytests/query/test_measures.py);readingholdsperiod_label,period_narrowing,scope_reads_box_scores(wastemplates.players._player_stat_reads_box_scores) andSTARTER_SIDES(one home;player_gamesimports it);entitiesholdsBOX_SCORES,GAME_LOGSandSHOT_AVAILABILITYbeside theAvailabilitythey are. The old names re-export each. AndTemplateUnsupportedISreading.Unsupportednow, 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:readsUnsupported:, 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.pyholds the shared steps every reader narrows the relation through, and twenty of them were private by that module's convention, so a reader living incompose/(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, andplayer_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 themcompose.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.pydeclares the Result (ROADMAP-TYPES.md's, by the phase that first uses it):Result,Part,Rows,Span,Narrowing,Window, with the remarks asnotes.Note(kind, facts).compose/logs.pyis the retiredgame_logtemplate's body - a player's log over one season type or both merged by date, and a team's - moved fromtemplates/games.pywhole 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.pywords a Result and nothing else, one phrase per note kind (note_phrase:window_short,definition,lines_rebuilt,games_unseen,floor), recording each throughnotes.noteso 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_notesover the newbox_score_notes_read).compose.answerreads a rows-shapedgame_log, and theplayer_statwindow the retired template handed to the log, through them before any presenter runs;compose.present._present_game_log,_present_team_game_logand_present_player_stat's rows branch are gone (presenters 12 -> 11;test_frozen_shapesretiresgame_log), as are the ~600 lines of the log intemplates/games.py(_log_extrasandGAME_LOG_STAT_COLUMNSlive with the reader). The import contract "The compiler's sentence reads no warehouse" holdscompose.saybesidecompose.sentence. Identical on 628 of 628 recorded questions, text and remarks included, and 1,391 of 1,391 unit-test calls; the compiler's ownrunwas 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_limitfromreading, where it lives (Phase 1 review, (c)):compose.adapt,compose.presentandcompose.teamimported it throughtemplates.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.