Releases
1.9.11
For you
- Week 1's finished games now show the forecast we published for them, and whether each call was a Hit or a Miss. They used to print their final score beside a note saying the forecast was still to come, because every week page read the newest published run and that run no longer carries a week it has left behind. A past week now reads the run that priced it, and the page names that run.
- A game page shows the key players and the injury report for its own week only. A week 5 game used to list this week's players and this week's injuries as though they were facts about that game. Each panel now says which week the published set covers.
- A game page links the frozen published edition its forecast came from, where that edition's page carries the game's own week and so describes it correctly. Those archive pages were always built and nothing reached them. The archive files a forecast by the week of the run that made it, and a run prices weeks ahead, so the pages where those two disagree carry no link at all rather than a link to a page that would call the game a different week.
- A game that has not started no longer shows a 0 to 0 score while its page is open. The live feed reports zeros for a game it has listed but nobody has played, and we were printing them as a score.
- A club's record on a game page no longer doubles up when it changes while the page is open.
- The projection card stops saying "Pre kickoff" above a game that has started. What it says instead comes from when that forecast was published against that game's kickoff: "Published before kickoff" when it went out first, "Published after kickoff" when the game was already being played, and "Published forecast" when the timestamps cannot settle it.
- A player with no reported game status is no longer listed as injured with the word "None" beside their name. A player with no status but a practice report is still listed, and says "No game status". The rankings tables say the same thing the same way, where they used to say "No designation published".
- The Matchup row that counted injuries is now called "Ranked players out", and its explanation says what it counts. It read zero beside clubs with several players ruled out, because it only counts players our published rankings carry, and we rank four positions.
- A player page with no projection now says the published edition does not rank them. It used to say they were too far down the depth chart, which we never measured, on 164 pages including healthy starters.
- The projection explanation no longer says every number was simulated by a model. A rookie with no usage history is projected from a draft class prior, and the source note beside the number says so.
- The footer's "Data sources" link now reaches the page that lists the sources.
- Each forecast publication now has its own address, so one no longer replaces the one before it. Every run of a week wrote to the same address, which is why a game page's headline number and the chart below it disagreed on 234 of 274 priced pages, why an archived edition could be rewritten after the fact, and why a game's forecast history kept only the first number of the week instead of every number we published. Nothing already published is changed or removed by this: from here on each publication stands on its own, and the site keeps pointing at the newest one.
- A player whose club has no game in a week is no longer given a projection for that week. Every player was projected whether or not his club played, so on a bye week a reader setting a lineup saw a number for a player with no game to play in. The first idle clubs of this season are in week 5, so nothing published so far was affected: on the current data the change removes 21 players in week 5, Patrick Mahomes and Travis Kelce among them, and none at all in weeks 1 to 4.
- A club's listed first choice player who has not recorded a statistic yet this season is ranked again, from his own usage last season and labelled as coming from it, rather than being absent until his first catch or pass. He had no row in this season's player data at all, so the model never saw him and he was missing from the rankings for as long as that held, even after returning to full health. On the current data this returns Michael Penix Jr. and Brock Bowers in week 5, where their injury reports no longer rule them out, along with four blocking backs and tight ends.
- Every club now publishes our rating of it, and the rating it carried into each week so far this season. All 32 read "Not published yet: our rating" before, because the rating the forecast already solves was never handed to the part of the pipeline that writes the team pages. The number is the one the forecast itself uses: how many points a club is expected to win a game by against an average opponent on a neutral field, read only from games played before the week it describes.
- A rookie is ranked only where his club's depth chart puts him among the players it actually fields at his position: one quarterback, two running backs, three receivers, one tight end. A rookie with no professional history is ranked from what happened to rookies drafted where he was drafted, and that figure describes a player who plays, so giving it to a rookie his club lists fourth claimed a starter's workload for a player his club does not field. On the current data this week, the number of rookies ranked goes from 35 to 2, and in week 5 from 34 to 1. The 33 who go include the two highest projected rookies of all, a second choice quarterback at 13.55 points and a third choice at 12.42, and they now carry no ranking rather than a number their club's own depth chart contradicts.
- Search finds every player the site has a page for, and a game result opens that game. It listed only the players the published edition ranks, 331 of the 1,684 on a club's roster, so four in five players with a page of their own could not be found by name. It also filed every game by the week of the run that priced it, and a run prices weeks ahead, so 240 of its 256 game results opened a page that was never built. A team result now opens that club's own page, with its schedule, its record and its depth chart, where it used to open the unfiltered rankings, and a club can be found by its city or its name as well as by its three letter code.
- Every page on the site is lighter, most of all the game pages: the heaviest went from 99,768 bytes to 92,971, and the shared stylesheet from 87,953 to 80,543. A quarter of a game page was the markers the build stamps on each element to keep one component's styles to itself, and there is a cheaper form of the same marker that changes nothing about how the page looks.
- /changelog now shows unreleased work, the newest release, and as many older releases as fit, with the rest one click away as they already were. It showed a fixed three releases however long they ran, and a release here runs from 726 to 18,978 characters, so the page had crept past its 68,000 byte limit and the only lever left was shortening sentences. It is 52,028 now, with this whole set of changes written into it.
- The track record shows the most recent week or two of calls in full, and every earlier week on a page of its own, linked from the main page with its call count and its hit, miss and pending split. It listed every call of the season on one page, which reaches about 250,000 bytes by week 18. Nothing is dropped and nothing is summarised: every published call still renders in full, and the accuracy, Brier score and log loss at the top still count the whole season.
- An archived edition now says which week the game it shows is played in, and which week's edition you are reading. An edition is filed by the week of the run that made it, and a run prices weeks ahead, so a page holding a week 6 forecast published in the week 2 edition called that game week 2 in its heading, its title and its breadcrumb: 2,224 of the 2,384 edition pages in a real build were labelled with a week their game is not played in. Because the page is now correct about its game whatever week it was published in, a game page links its edition again: 272 games of 321 carry that link, where 32 did.
- Sixteen archived editions carry a kickoff four hours earlier than the game was played, an Eastern time recorded as though it were universal time by a part of the pipeline that has since been corrected. The published edition is left exactly as it was published, and those sixteen pages now carry a dated note naming the time the schedule has for the game.
- The site publishes the archived editions it can show you, not one for every forecast ever published. Each publication now has its own address, about 38 a week, and a week page only ever reads one of them: on a real build the whole site resolved to two of the ten on hand. It keeps three of each week all the same, so a bad publication cannot take that week's forecasts off the site, and drops the rest. You lose nothing you could reach. The edition each game page links is kept, the chart of how a forecast moved reads a different file and is untouched, and an address that is no longer built now says so and sends you to that week's edition.
- An address for a published forecast edition the site no longer builds now says so. It used to reach the ordinary not found page, which says the address may be incomplete, and that is a claim about you rather than about us. The page now names what that address names, the matchup and the week of the edition, and links you to that week. It says what the address names and not that we published it, because an address is something anyone can type: a made up matchup gets the ordinary not found page instead.
- A source note now reads "Model engine2-0.2.0" instead of "Modelengine2-0.2.0". The label and the version beside it were run together on every published number that carries one, 11,012 of them across 2,218 pages of a real build.
- A player page no longer calls a fullback his club's first running back. The published roster gives several depth chart positions one shared code, so a club's first back and its first fullback both read as slot 1 at RB, and its five starting blockers all read as slot 1 up front: 1,057 of the 1,684 rostered players share a slot with a team mate. A page prints that rank only where its player is the only one holding it, which is 622 of them, and says nothing where the roster says nothing.
- A club's depth chart no longer carries an empty Season column, and its defensive linemen join the Defense group rather than forming one of their own below the specialists. The Season column was a heading above an empty cell on all 32 team pages, because no player in the published roster carries that figure yet; it returns on its own when one does.
- The accessibility statement is in the sitemap. It was built, linked from the footer and open to search engines, and missing from the one list that says which pages we mean them to have.
Under the hood
- The rosters artifact contract learns an optional field, `depth_position`, and both readers learn to tolerate it. Nothing writes it yet and no page reads it: this lands on its own so that the schema the site validates against is never older than the artifact it is handed. The field is optional rather than required, so the artifact published today still validates, and an artifact carrying it validates against a site that has only this change.
- Who gets a ranking is now decided from the player's own club's depth chart. The chart was matched to him by his id and the position he is listed at, never by the club, and the chart lags the roster, so a player who had changed club since the snapshot could be ranked, or left out, on the ranking his old club had published about him. Two decisions read this: whether a club's listed first choice who has not recorded a statistic yet is carried into the rankings from last season's usage, and whether a rookie sits high enough on his club's chart for a draft class figure to describe him. Replayed on the stored copy of the data whose newest chart is 2026-08-13, 20 of the 1,369 chart entries held by a rostered player came from another club's chart, T.J. Harden reading Seattle's seventh running back while on Cleveland's roster among them. All 20 sit well below the ranks either decision turns on, so on that data nobody enters or leaves the published rankings: the fault was latent there rather than absent, and one of those 20 at a first or second choice slot would have ranked a player on a claim his own club never made. An entry that does not say which club published it is dropped, and a chart that names no club at all decides nothing, because not knowing whose entry it is is not evidence that it is his.
- The search index is built from the roster and the schedule, not from the published cards. A card carries the week of the run that wrote it, and the game route is keyed on the game's own week, which is why 240 of 256 game entries pointed at a page `matchupStaticPaths` never generated; the schedule is the artifact those pages are built from, so an entry made from it resolves by construction. The roster is the register of who has a page, so every rostered player is indexed whether or not the edition ranks him, taking his name and club from the roster and his rank from the edition. A player at a position the rankings do not cover carries no position filter value rather than being bent into one of the five chips, and no `formats`, so a format filter still means "has a projection in this format". Measured on a real build of the local artifact set: 2,038 entries, all of which resolve to a built page, against 619 of which 240 did not; the file grows from 105,172 bytes to 310,101, which is 43,252 compressed.
- S11, byte budgets on a real artifact build. Sixteen pages were above budget: the fifteen week 2 game pages and /compare. `scopedStyleStrategy` moves from Astro's default `attribute` to `class`, so a component's scope marker joins an element's class list (15 bytes) instead of being a `data-astro-cid-<hash>` attribute (24), and its selector is `.astro-<hash>` rather than `[data-astro-cid-<hash>]`. Specificity is identical either way, both (0,1,0), which is what makes it a drop in and why `where` was not taken. Measured on one build, with the single setting removed and put back: heaviest game page 99,768 to 92,971 against 94,160, /compare 135,610 to 133,837 against 134,520, CSS 87,953 to 80,543, mean page 20,014 to 19,073, and no page above budget.
- /changelog is bounded by size rather than by a count of releases. `RECENT_RELEASED` bounds entries, not bytes, and neither half is a fixed size, so the page reached 68,348 against a flat 68,000 cap. Unreleased work and the newest release always render and both count toward `RECENT_RELEASED_CHARS`; older releases render while they fit, and the run stays contiguous so the page never has a hole in the middle of it. Measured 52,028 bytes with this pull request's own bullets in it.
- /track-record is paginated by week rather than given a budget that fits it. It had no page family, so it was held to the flat chrome cap and CI never saw that fail, because Gate 2 builds the coverage fixture rather than real data: on a real build carrying a partial season it is 208,641 bytes. A family entry with a per row allowance was the first answer and was wrong, because the rankings pages that mechanism holds render a bounded top N while this rendered every call there was, so the entry took its effective cap from 68,000 to 233,800 and the heavier page then passed. `src/lib/track-record-view.ts` splits by week on measured bytes, the week blocks and the archive rows together, the way `RECENT_RELEASED_CHARS` does for /changelog and for the same recorded reason. It keeps no family and stays chrome inside the flat cap. Measured on a build of a full 18 week, 288 call season: the index is 52,302 bytes with one week rendered and every week linked, the heaviest week page is 26,303, all 288 calls render, and the summary figures still equal the artifact's own.
- The archive route's `week-` segment is the EDITION's week and stays that way, because it is the publication's address and those addresses are immutable. The page used to read the segment back as the game's week; `gameArchivePaths` now carries the card's own `week` beside it as a prop, falling back to the run's week for a card that has none, which is the same rule `pricedForWeek` already applies to those cards. With the page correct about its game, `archiveEditionHref` no longer needs the two weeks to agree and drops its `gameWeek` argument. Where an edition's published `kickoff_utc` disagrees with the schedule, the page prints the published time and annotates it with the scheduled one rather than showing a different number: invariant 3's own remedy, a dated annotation, not an edit. Measured on a real build: 2,384 archived game pages, 2,224 of them previously mislabelled, 16 annotated, and archive links on 272 game pages against 32.
- `site/scripts/fetch-artifacts.mjs` is bounded, the way the forecast container's restore already is. It downloaded every run directory in storage, which S2's per publication run ids put on a path to 684 editions by week 18 at 3.29 MB and six requests each: about 2.2 GB and 4,100 requests every build. Selection, not truncation: a run files a week's cards until that week has been played, so the edition `resolveEdition` reads for a week is the newest run whose own week IS that week, published no later than the pin. `editionsToFetch` keeps those plus the pin, reading every run.json and only the selected runs' artifacts; anything it cannot order by time falls open to everything, a backstop of 200 cuts to the newest and says so, and a stale run directory is removed. It keeps `EDITION_DEPTH` of each week rather than one, because `resolveEdition` skips a publication that is not valid and one that priced none of the week's games, and neither is visible in a manifest. Measured on the ten runs on hand: 10 editions to 6, 33.7 MB to 21.4 MB, 4,534 built pages to 3,718, 2,384 archived game pages to 1,584, 2,439 social cards to 1,623, every week resolving to the edition it did before.
- `src/lib/archive-address.ts` reads a published edition's address back out of a URL, and /404 uses it. Everything the page says comes from the address: the season, the week the EDITION was published in (never the game's own, because a run prices weeks ahead), the run id, and the matchup or position. Both the generic and the specific block ship in the HTML and the script swaps which is shown, so a reader with no JavaScript still gets the whole statement and only has to read the week off their own address bar. Two things keep it honest: the club codes are checked against the 32 real ones, so a matchup no club pair matches is never named, and every sentence is about the ADDRESS, because a URL of the right shape proves nothing about what was published and two real clubs may never have met in the week it names.
- S12, the audit's minor items, each measured on a real build. `.badge` is an inline-flex box, so a source note's label text node and its detail span are two flex items and the markup's own space between them is collapsed away; the separation is a `gap` now. `PlayerPageData.depthSlotUnique` is what the player page's `POS<slot>` label is gated on, because the published roster's codes are coarse (DB, OL, LB and DL each cover several depth chart positions) and the number is read as a rank. `TeamRoster` renders the Season column only where a club's roster carries a `season_line` for somebody, the same rule the roster count beside the heading already follows. `DL` joins the Defense position set, which named DE, DT and NT but not the code the artifact uses for 228 of its players. `/accessibility` joins `INDEXABLE_PATHS`, and a new test walks the built plain pages so the next one cannot be left out quietly.
- The Phase 0 measurement collector moved off GitHub Actions onto a Cloudflare cron. It polled two sources every 15 minutes as a scheduled workflow, GitHub bills a whole minute for each of those 96 runs a day, and it drained the account's build allowance, which stops the gate every deploy depends on. It is now a Worker of its own, on the same 15 minute cadence, writing its observations to the same database the injury poller uses. It stays a separate Worker from the two that serve the site, so a crash in a throwaway measurement cannot reach anything a reader sees, and the whole thing is deleted when the measurement window closes. The reading it takes also improves: the GitHub scheduler had been firing it with gaps of two to three hours, and a gap that size inflates the very number the measurement exists to decide on.
- The collector's parsing is ported to JavaScript with the Python test suite ported alongside it, case for case, and a failed tick is now recorded in the database rather than only turning a workflow run red. The old workflow file stays in the tree with its schedule commented out, for a manual dispatch until Phase 0 closes.
- Moving the collector split its corpus in two, so the report that reads it now reads both halves and unions them: the observations from before the move and the observations since. A measurement observed by both halves is counted once, because every figure the report prints is a count and double counting would push each one toward clearing a floor it has not cleared. The report says how many rows came from each half before it prints anything derived from them. The reading and the union live in the tested pipeline package rather than in the report script, and neither they nor their tests can reach Cloudflare: the export is a file, produced by one read only command that the owner runs.
- A week page resolves its own edition: of the runs that priced that week, the one that priced most of it, newest first on a tie, read from the run archive together with that run's rankings. Counting only the games a run files as that week's is what stops a single game that slips out of its week, and is then priced by every later run, from making a much later run that week's edition. The search never reaches past the run latest.json pins, and a week no run priced carries no publication stamp at all. The published stamp, the run id and the drift chart's cadence check all follow that run. Nothing published is rewritten, an earlier publication is read instead of the newest one.
- The published injury report and the published rankings are each stamped with one week, and the game pages now check that week against the game's own before rendering either.
- Live hydration writes a score only once a game is in progress or final, writes a record into the glossary trigger that carries the value rather than into the whitespace beside it, and rewrites the projection card's stage label once a game has started. Both stage labels live in one module so the build and the patch cannot drift.
- One shared module decides what an injury row says and whether it says anything at all, for the game pages, the home page and the team pages, which had been answering it two different ways.
- Dropping the wrong week's two panels took about two hundred game pages back inside the asset budget on a real build: 230 pages were above it before, 16 after.
- `make_run_id` hashed the season, the week and the run kind and nothing else, so every run of a kind in a week produced the same id, wrote to the same storage keys and silently replaced the publication before it, which invariant 3 forbids. The id now carries a fresh timestamp and uuid4 in its digest, and its shape is deliberately unchanged, because the site pins a regular expression against it before it will read a pointer, the archive puts it in a page address, and the pipeline mirrors the same pattern. An explicit salt argument restores the reproducible id for a replay or a test. Beside it, the artifact upload now refuses to write onto an id that already holds objects in storage, raising before it uploads anything and touching nothing that is already there, because uniqueness is a probability and the rule it protects is not. Consequence worth knowing: the forecast history for a game now gains a point per publication rather than one per week, and each publication becomes its own archived edition, so the number of archived editions grows with the number of runs.
- The facts build solves the team rating instead of being handed one. `build_team_summaries` has always taken a rating and a rating history, `facts_run` has always declared both, and none of its three callers ever passed either, so every club published a null value with a "no rating" label. The rating is now computed inside `facts_run` from the schedule it already reads, so the forecast run, the standalone facts command and the container's facts path all get it, and a caller that supplies its own still wins. It is the quantity Engine 1 already differences between the two clubs in a game, the sum of a club's offensive and defensive ridge terms, rounded to two places, so the published rating and the forecast are the same construction on the same window. They can differ in one way, for one run: the team pages are built before the forecast absorbs a result that the live feed has confirmed and the weekly data drop has not, so a Monday night final counts toward the forecast that run and toward the published rating on the next one. A test pins that. A week with too little evidence to solve publishes no rating rather than a placeholder, which is the null the contract already had a shape for, and a failure degrades that one field rather than freezing the whole facts channel.
- The forecast container's restore of previously published editions is bounded. Giving every publication its own address also meant the container, which starts with an empty disk on every trigger, downloaded three files from every edition of the season before each run. The schedule fires 38 publishing runs a week, and the three files average 1.35 MB an edition, so by week 18 that would have been 684 editions, 2,052 downloads and 923 MB on every one of those runs and both daily data refreshes. It now reads only the editions the track record can actually grade: each week's first publication, which is what a player projection is graded on, and the last publication before each kickoff plus anything in the six hours before it, which is what a game forecast is graded on. Against this season's real schedule that is 216 editions, 648 downloads and 291 MB. A season whose kickoffs cannot be read still reads everything, and a backstop refuses to read more than 400 editions and says so rather than rebuilding the record from a partial disk.
- The depth chart slot an eligibility decision reads is now scoped to the offensive group, to the player's own position, and to the single newest snapshot, and it refuses to rule at all once that snapshot is more than a fortnight old. The slot was read by player alone, across every position group and every snapshot ever taken, with a tiebreak that a non stable sort decided by accident. Measured on the current data, across the 3,473 players holding a slot: 41 skill players held a rank 1 kick or punt returner row above their own receiver or back row, so a rookie his club fields sixth could pass the rookie limit; 1,530 had no row in the current chart at all, 63 of them holding first place from a superseded one, so a player who lost his job in August kept it forever; and 208 carry more than one row in the current chart, where the tiebreak decided real answers by accident. The chart also lists fullbacks separately from running backs, which the unscoped read did not, so a club's first fullback is no longer read as its first running back. The team page's own roster column keeps the unscoped slot it has always shown, now decided deterministically; refining what that column shows is a later item.
- Three eligibility fixes from the 2026-09-17 site audit, all of them about which players a forecast run projects rather than about what the model computes. The depth chart is read once per run and used for eligibility only: no engine reads it, no feature is derived from it, and no player who was already projected gets a different number. A club's listed first choice player with no row this season is carried in from his most recent prior season row, the same construction every player gets in week 1, and his published values are labelled as prior season usage while the rest of the run stays labelled as model output; he must be on this season's roster at a ranked position, not carry a status that means he is gone, and have prior season usage to carry, or he stays absent. The rookie cohort projection is gated by the depth chart's own base formation, and the injury filter still removes anybody ruled out, so a carried in player who is out is not published. Byes are read from the schedule, applied to the merged list of veterans and rookies before the collapse guard, and the positional ranks are renumbered afterwards; a week the schedule cannot describe puts nobody on a bye, because an empty rankings file is the worse failure. A missing depth chart table leaves every one of these behaving exactly as before.
- Gate 1's site job builds the site 42 times a run instead of 72, and runs about 43 percent faster locally, with every test and every assertion unchanged. Measured by logging every build the unit tests start: 155.0s of the run's 182.3s was Astro builds, and 30 of the 72 were byte identical repeats, because games-pages.test.ts rebuilt one fixture set from 17 separate tests and teams-pages.test.ts rebuilt two more from 16 between them. Each of those sets is now built once per file and read by every test that wants it, through site/test/helpers/shared-build.ts, which states the three conditions that makes safe: identical inputs, read only assertions, and removal deferred to an after hook. Any helper that edits a fixture before building still gets its own build, so the 42 that remain are 42 different input sets. `make site-test` goes from 193.2s to 114.0s on a 10 core laptop, and the whole of `make gate1` from 380.3s to 307.3s, with the same 655 tests and the same 649 passes. Two of the three conditions are checked rather than trusted: the built tree is digested when it is built and again when it is disposed, so a test that wrote into shared output fails the file instead of quietly changing what every later test reads, and a key claimed by two different builders throws. Each file's builds are filed against its own module, so a disposer never reaches another file's build root.