Squad rail becomes a league-wide captaincy board, star-rated #31
No reviewers
Labels
No labels
architecture
cleanup
dependencies
enhancement
needs-info
needs-triage
performance
priority: high
priority: medium
ready-for-agent
ready-for-human
reliability
security
testing
wontfix
No milestone
No project
No assignees
2 participants
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
solvreven/FPL!31
Loading…
Reference in a new issue
No description provided.
Delete branch "feat/captaincy-board"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Stacked on #30 → #29 → #28. Merge in that order and this reduces to its own commit.
Asked for directly: "on the right side in the squad tab, to the right of the pitch, I want removed. I want top 5 captaincy picks for every week, sorted by 1-5 stars ... It can even have a half star aswell."
What it does
/api/captain-picksranks the whole pool for one gameweek, not the squad — the question is "who is worth captaining", which is also how a transfer gets argued for. It calls the samerank_captaincythe armband uses rather than a second ranking that would have to be kept in step: two surfaces naming different best captains is a defect this app has already shipped once, and the fix then was to remove one of the computations.Eligibility is a presence test, never a default. A player needs a row for the round, a measured rate and an appearance probability; missing any, he is absent from the board rather than ranked on a substitute.
Stars are absolute, not relative to the week's field. A relative scale always awards five stars to the best available option, so a blank-heavy round would flatter a mediocre captain on the one decision that doubles the return. Calibrated against the real distribution rather than invented — across GW3–5 the maximum was 4.68–5.26 and the median 2.05 — so one star per expected point, to the nearest half spans the field without bunching, and a weak round honestly shows no five-star pick.
The half star is geometry, not a glyph: a filled row overlaid on an outline row, clipped to
rating/5. Measured live at 55.8 of 62.0px for 4.5. A future 4.25 would render with no new case. The stars spend no data channel — neutral white at two weights, the same reasoning the C badge already carries, so they cannot collide with the--mag-*or--fx-*ramps.Two defects found by looking at the running page
Neither was visible in the code.
The armband named B.Fernandes while the board topped out on Virgil. Both correct — your fifteen versus all 406, and Virgil is unowned — but that is not visible at a glance, and it is the "two captains on one screen" shape a reader caught here before. The ownership chip (
your captain/in your squad) makes the panels visibly agree instead of apparently contradicting.The heading was derived from the pager's gameweek, not the data's. They agree today because the fetch is keyed on the pager, but a label taken from anything other than the rows beneath it is "Match predictions · GW1" waiting for the two to drift. It reads
d.gwnow.What removing the rail cost
Recorded rather than buried, because it is a real loss and the reviewer should weigh it:
5/5/5/5/5/5and one of0/0/0/15/15/0are indistinguishable in a total and want opposite decisions.railYourXiHtmlwas kept on purpose. It exists because a reader reported the pitch drawing an XI they never chose; sweeping it up with the rail would have silently un-fixed that.railOutlookHtml/Lines/Bench/Shakynow have no caller. Kept rather than deleted — each is still exercised by its own tests and none computes a discredited answer, unlikecaptainCandidates, which was deleted for ranking by a quantity we had just shown to be wrong. The disconnection is stated at the definition, because dead code whose tests still pass is this repo's mechanism-that-is-not-connected shape. Happy to delete them and reworktest_phase_d.py/test_bench_contribution.pyif you'd rather.Tests
Twelve tests for the removed cards deleted. One of them,
test_shaky_starts_is_absent_when_nobody_is_shaky, was still passing — it asserts the block is absent and the block can no longer render at all. A test that cannot fail is not a test, so it went with its branch rather than being left as a green line.test_rail_content_is_reachable_at_every_breakpointwas re-pointed, not deleted: the responsive rule is unchanged, only the content it guards.14 new tests. Nine cuts, nine REDs — but three survived the first pass, and each was a hole in my own tests:
railShakyHtmlrenders nothing for a squad with no shaky player — the check could not failAll three now bite, and the third is what surfaced the
d.gwdefect above.Verified on the live page, not from the DOM alone: screenshotted and read back.
🤖 Generated with Claude Code
https://claude.ai/code/session_014adnmRztkdAPwinWXnNQ7N
Removes the six-book consensus from the served surfaces and makes NT the market for match odds, anytime goalscorer and anytime assist. The consensus files stay on disk and score_goalscorer.py still scores both every round, so this is a code change, not a lost comparison. NT does not open its markets all at once -- measured 2026-09-01/02, goals open ~4 days out and assists ~2, per fixture -- so a single pre-deadline pull could never carry both. nt_watch in chores.py polls on COVERAGE ("does the newest banked run carry both markets for every fixture of the next round", answered from the manifests on disk) rather than on a clock or a completion marker, so a pull someone ran by hand counts. The denominator is the round's fixture count from FPL, never the run's. Four producer-without-a-consumer defects surfaced while wiring it, three created by the NT-only switch itself: disagreements and fixtures_without_market were computed and discarded by main.matches; gw{n}_market_nt.json was written by nt_resolve with nothing rebuilding the cache to read it; and n_fixtures counted match_odds while the table was drawn from a different subset. _unpriced_but_ours no longer returns one flat list. "No book priced X -- the omission is the bookmakers' saying he is unlikely to feature" was printed over five players in two fixtures NT had not opened at all, which is a verdict reported where the book had said nothing. It returns {"declined": [...], "unopened": [...]} keyed on the FPL team id, because the two need opposite responses. pre_deadline_steps is now pull -> resolve -> rebuild -> snapshot, so the frozen cache is the one built from the frozen prices. snapshot_pit.odds_for takes both market files; the raw NT run is zipped rather than pointed at, since prune_nt_runs can delete the directory. Retires _books_expect_him: the coverage-share gate cannot be computed from one book. Reported rather than patched around -- raising our own start_prob floor would let the weakest number in the system decide membership. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014adnmRztkdAPwinWXnNQ7NAdds `understat_watch` to chores.py, beside `nt_watch`. It asks "does the banked current-season file reach the round that has been played" and answers it FROM THE FILE, so a fetch someone ran by hand counts and the ledger is never the evidence -- a completion marker is not evidence of what was completed. NOT folded into after_gameweek.py, and the reason is measured rather than assumed. Understat had GW2 by 48h after that round's last kickoff, while after_gameweek fires the moment the round ends. Chaining it there would fetch before Understat published and leave us permanently one round behind: self-healing in principle, never actually caught up. One observation bounds the lag at <=48h and cannot pin it tighter, which is the argument for keying on coverage rather than on a clock -- the design does not need the number. THE FETCH IS ALWAYS FOLLOWED BY A REBUILD. foreign_rates.json and the current-season file are read at CACHE BUILD time and nowhere else, so a fetch with no rebuild behind it refreshes a file no projection ever sees. That is the shape nt_resolve sat in for a week, writing gw{n}_market_nt.json that only a rebuild reads. No season is written down: the season id and the output filename are both DERIVED from the label FPL reports (understat_season_arg, understat_out_path). A BUG THAT WAS LIVE FOR ABOUT A MINUTE, and the sharpest thing here. Coverage counted all five leagues. Ligue 1 had played 3 rounds while the PL had played 2, so it returned 3, never fell below the denominator, and the watch would have been PERMANENTLY SATISFIED without ever firing once. The denominator is the FPL gameweek, so the numerator has to be EPL rows -- counting one population while describing another is the "20 fixtures" coverage line in a different file. Pinned by test_coverage_counts_only_the_league_the_denominator_belongs_to. 8 tests here, 21 across the three Understat files. Every cut RED, including two on the WIRING rather than the mechanism: removing the planner hook goes red while every helper test stays green, and making the watch fire unconditionally goes red too. A mechanism nothing calls is a comment. Verified at commit time: the 21 targeted tests pass and all mutation cuts go RED. The full suite was still running; its result is reported separately rather than asserted here. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014adnmRztkdAPwinWXnNQ7NOn the live page 2026-09-13 the builder read "£88.5m spent · £0.0m bank · 13/15 picked" and every search hit "£5.2m — you have £0.0m". Money, read from /api/squad instead of recomputed from today's prices: - saleValue(id) is the server's selling price; the × says what a sale fetches ("sells for £5.6m (bought for £5.5m, £5.8m today)"), the price column shows it, and a new planned-transfers block lists each sale and buy with its amount and the bank it leaves. - Transfer suggestions budget sell + bank and cost in - sell. "One now vs two next week" budgets its second move from the bank the first left. - A refused bank blocks buys with its reason instead of "you have —". - The squad line carries the sync state ("FPL GW4 + 1 planned", or "not synced from FPL" with the reason) and repaints in edit mode; the search results repaint after a buy. Both kept the bank from before the edit. - The Your-XI rail states a withheld comparison, and no longer diffs your eleven against an XI the server refused ("We would swap" all eleven "for" nobody). Rounds — the three defects confirmed and left open on 2026-09-13: - gwRows/gwEv sum both fixtures of a double gameweek on the pitch, both XI solvers, the colour quintiles and the Players table; /api/best-xi always did. A guard refuses the `.find(x => x.gw === gw)` shape (seven in HEAD, none now). - One window for every search: projectionGws is the union of all players' rows. Suggestions took their weeks from the first pick, so a first pick with a blank gameweek crashed the Squad tab. Labels: eleven copies of "Start% is estimated from 25/26 minutes" (false since GW1 was ingested) and four relatives are one START_BASIS that names no season. "spent" is "in players": today's prices are what a synced squad is worth, not what it cost. Tests: 14 new, rendered on stubbed pages projecting GW5-10. Four older tests pinned code text this changed (row.ev_no_bonus, "25/26", "frees ${") and now follow the helper; each cut again and red. Assets ?v=145. Full suite in a clean worktree: 1598 passed, 58 skipped, 0 failed, no tracked data file touched. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0125so432WFsmNuBsnqes3Z4Asked 2026-09-13: "Isak still looks like he has very few points for the minutes he is expected to play — if it is wrong for one player, everything has to be checked." Isak had started three from three and carried 0.495. It was everyone. scripts/backtest_starts_boundary.py rebuilds the player index through the production path from the snapshot before each deadline and scores P(starts) against what happened: GW2 GW3 old estimator 0.1457 0.1471 started last round? 0.1245 0.0946 new estimator 0.1194 0.1068 Players who had started every 26/27 round were predicted 0.70-0.74 and started 0.83-0.89 of the time; players who had started none were predicted 0.22-0.28 and started 0.08-0.14. Last season's rounds on a ten-round half-life outweighed this season's, and the 0.55 prior pulled both ends to the middle. Every earlier minutes sweep ran inside one season and could not see it. - priors.py: with rounds this season (`n_current`), start and play probability come from those rounds at half-life 1.5 — the within-season optimum, not tuned here — with last season's rate as a prior worth half a round, no 0.55 pull, clamped to [0.02, 0.98]. With none, the old estimator is kept exactly (it scored better at GW1). Minutes-when-started and scoring rates unchanged. - lineup.py / pipeline.py: club normalisation counts each player by his availability in the next round. start_prob means "starts when available"; five injured Brighton outfielders held 2.41 of the ten places on the GW5 cache. - Pooled GW1-3: starts 0.1572 -> 0.1354, any minutes 0.1628 -> 0.1380. Production after the change reproduces the candidate within 0.001. - Chosen on GW1-3, so GW4 is the confirmation, pre-registered in docs/testing-notes.md with the full sequence, including four predictions that failed and a rule for returning injuries that scored worse and was dropped. A GW4 miss reverts; it is not re-tuned. - Also: load_current_rounds bound its default path at definition, so the suite's redirect of CURRENT_ROUNDS never reached a call and every test that built a player index read the real rounds. Fixed. Live cache: fit players with three starts from three and start_prob < 0.70, 49 -> 0; Isak 0.495 -> 0.869, six-week xPts 13.9 -> 20.9. Full suite in a clean worktree: 1614 passed, 58 skipped, 0 failed; no tracked data file touched. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0125so432WFsmNuBsnqes3Z4FPL's status "u" (sold, loaned out, not registered) carries chance_of_playing_next_round 0, which availability_by_gw read as a fact about the next round alone, handing the rest of the window back at full availability. 105 players carried it on 2026-09-18, none with a date and none a next-round fact ("Has joined Juventus on loan for the rest of the season"); Woltemade was at start_prob 0.47. It is now the open-ended case, keyed on the status rather than on the news text. A stated return date is read like any other. Checked RED with the branch cut: five news variants fail. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>require_measured_rates_in_xihas no production caller — the optimiser can start a position-prior-rated player #32aebd85eafter the rebase onto the night's mergesThe sketch was approved 2026-09-15 ("Ok, gi forslagene et forsøk"), and four decisions followed on 2026-09-17: FPL showed 1 free transfer for GW5, fix the banking verdict before the deadline, derive free transfers from the history, build the card as sketched. Built on this branch, live after the GW5 deadline. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>test_the_default_horizon_matches_what_production_uses matched the label "XI impact over ${windowLabel()}" on the swap cards the round plan replaced. The label moved with the figures: the plan's transfer row says "Every figure is XI impact over ${rpWindow(plan)}", read off the plan's own horizonGws() window. The guard now matches that, and fails when the window is written as a literal. It was among the 46 tests skipped in the round-plan worktree, which lacks the archive, and first ran red in the live checkout after the merge. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016ZreSMN6hgmFsbLs8MchV8What
feat/captaincy-boardhas taken on since your #37–#41 landed. It is well beyond the captaincy board in the title, and all of it runs on the live app:ad35b4a, 19 Sep).e8d80f1).e19de69).dc355c2,c592c2b). Over the two seasons, our match model is about level with the pre-match market.b2a6284, merge12aed8c): season points against what the chances were worth, with goals against xG and assists against expected assists.scripts/measure_over_under.pyre-derives the constants.total_points, and a mismatch refuses the player.sortfunctions had never been called.7b3db0d), viainstall_autostart.ps1 -Lan.require_local.caf5e6c): "Complete my squad" spends FPL's bank plus selling value. Details are on #34.The full suite is green in the live checkout: 2092 passed, none skipped.
#29 and #30 are fully contained here. The only commits they have that this branch lacks are the
ci: run on debian-latestcherry-pick and a merge ofmain, and both of those are already here throughmain. They can land before this PR or be closed after it without conflicts. I can retitle this PR and rewrite its description if you'd like it to say what it now is.🤖 Generated with Claude Code
Since the summary above, three more things landed on this branch.
e32b153)..builder-layoutis1.5fr 1frwith no breakpoint, and anfrtrack does not shrink below its content, so at 390px the page scrolled sideways by 41px. It folds to one column at 640px, like.xi-layout.e32b153, details on #26). It hashedgetComputedStyle's enumeration order, which two documents disagree about for custom properties, so every element differed and the real change was never looked at.9382228): P(actions ≥ the line), Poisson on the rate the player set in those rounds. In GW1-4, 48 rounds ended one action short and 34 crossed by exactly one, and DEFCON is 7% of all points paid.The DEFCON part is worth one warning for anything else built on the rates. It was validated on the rate a player set in the rounds being judged, then wired to
rates.defcon90— the rate the projections use — which expected 186 DEFCON points against the 256 FPL paid on GW1-4, 27% short, because it weighs last season beside this one and shrinks toward an anchor at the 15th percentile. The whole league read as lucky, +70 points of it, and the number looked plausible. On the measured rate the part sums to −11.4 and the league expectation is +4.5% of what was paid. The check that caught it is the league total against what FPL actually paid, andscripts/measure_over_under.pyre-derives it through the production code.Full suite green in the live checkout: 2107 passed, none skipped.
🤖 Generated with Claude Code
Two more on the branch, both pushed. Full suite in the live checkout at
f8099f1: 2131 passed, 0 skipped.f11109a— captaincy states one rule about rate priors, not twoDetailed on #34. Short version:
captaincy.require_measured_rateswas the pre-1c4a2dfabsolute refusal, left in the module after exclusion replaced it, with its docstring still stating the old rule as policy. Deleted, with a test pinning its absence, proved RED both ways.0fc318f— the BPS harness runs on this season, and the 26/27 table is confirmedBPS_WEIGHTS_2026_27has carried_provisional: Truesince it was decoded from the FPL app bundle in July, with "re-run the harness after 25 Aug" on the task list. Five rounds later it had not run, and the reason was mechanical:reconcile_bps.pyreadsdata/archive_<season>/merged_gw.csv, and this season lives indata/current_2026_27/rounds.json, which has no fixture column.load_currentreads that file. On GW1-3 (GW4 was still PROVISIONAL and is excluded by name):So the unofficial channel's table is right in the region this can see. It does not confirm any individual weight, and 93 rows is small.
The residual is on midfielders (-5.69) and it is the defensive actions the proxy does not model at all. Adding the four defensive columns as a group takes MAE 5.22 -> 3.66, but their individual coefficients come out collinear and implausible (tackles -0.24, CBI -1.00;
defensive_contributionis derived from the others), so none is adopted.Three things the loader refuses to get wrong, each from a rule already in CLAUDE.md:
team— that field is the bootstrap at ingest, i.e. the player's club today, and it would put transferred players in the wrong bonus race;rounds.jsonholds one row per element per round, so its two matches are already summed, and awarding 3/2/1 from that total scores a race that was never run.One finding worth knowing if you ever read fixture stats: FPL omits a 0-bps player from the fixture's stats list. Element 60 played 90 minutes in GW1 with bps 0 and appears in no list at all — 15 such appearances over GW1-3. Dropping them would delete exactly the rows the proxy is worst on (it awards them 3-6 BPS for the minutes) and flatter the metric. Their match comes from the player's own history instead; with no resolver the loader refuses rather than guessing from his club.
Five predictions were pre-registered before the run and two were wrong, including the size of the defensive cost, which I had put four to nine BPS too high. Evidence:
wiki/notes/bps-table-confirmed-on-2026-27.md.🤖 Generated with Claude Code
The Over/under work found `rates.defcon90` expecting 27% fewer DEFCON points than FPL paid on GW1-4, measured to attribute over-performance rather than to audit anything. That rate is what `project_all` feeds `defcon.expected_points`, so if it held, every EV understated a 2-point component NO BOOKMAKER PRICES. `scripts/check_defcon_calibration.py` scores it point-in-time: rates from rounds strictly before each round, expected points on the minutes he ACTUALLY played, so the rate is on trial and the minutes model is not. GW2-4: round expected paid ratio evidence n ratio GW2 49.5 58.0 0.85 thin (<5) 252 0.50 GW3 54.0 70.0 0.77 some (5-20) 251 0.99 GW4 49.0 66.0 0.74 rich (20+) 349 0.93 all 152.5 194.0 0.79 THE AGGREGATE MISS IS ONE POPULATION. For a player with evidence the estimator is well calibrated. The whole 21% comes from players with under five 90s, where prior_weight=4 pulls the rate most of the way to a 15th-percentile anchor — which is that anchor doing exactly what rates.py says it is for. A trade-off to price, not a defect to fix; softening it for DEFCON must be scored on backtest_points.py first, and rate-prior players are refused from the XI anyway. Two of four pre-registered statements were wrong. The useful one: players are NOT doing more defensive work now that it pays (same-player median ratio 0.92 against last season), so nothing here argues the archive is stale. THE FIRST RUN SAID 0.58 AND WAS MINE TO CATCH. It fed `player_rates` only the current season's rows, so the anchor dominated and it measured a variant of the estimator nothing runs. Production merges the archive and renumbers the current rounds onto it, because the weighting reads the GW NUMBER — a 26/27 round numbered 1 is otherwise the oldest evidence in the set. The script calls `continue_gw_sequence` rather than repeating that renumbering: the measurement-apparatus rule catching its own checker. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>Four more on the branch, all pushed. Full suite in the live checkout at
dbf21dd: 2144 passed, 0 skipped.ae94524— GW5 scored: the first round the model could be compared on at allEvery precondition held for the first time: the snapshot (16:32 Oslo) had seen GW4, and Norsk Tipping's run (16:30 Oslo) was pulled before the 19:30 deadline. GW1 had no book; GW2's and GW4's prices came 2.7h and 8.9h after their deadlines; GW3's snapshot had not seen GW2.
237 appearances, realised scoring rate 0.0802, log loss:
Level with the corrected market, both clearly ahead of the raw coupon — our 0.007 over NT_TEAM is inside one round's noise and I am not calling it a win. The raw coupon over-forecasts 2.3x. The script's cross-round verdict reads BROKEN, and that is about the BOOK: across the two rounds whose NT prices predate their deadlines, NT scores 0.2902 against the control's 0.2627.
Two of six pre-registered statements were wrong in our favour, which is the case that earns an inspection rather than a headline. Inspected: both sides pre-deadline, the 237 are players priced by every source who appeared, both conditional on appearing.
03adb7a+d5e621c— an empty coupon is not a moved oneThe morning after the NOT_OPENED fix,
nt_watchread "after 3 failed" again — and this time the FETCH was the failing step, so the label was right.marketGroup/89304.1answers CONTENT_NOT_FOUND: there are no PL fixtures until 10 October and NT has taken the coupon down.That answer is byte-for-byte what a coupon id that had MOVED would give, and the two need opposite responses — one says come back in three weeks, the other loses GW6's odds, which open four days out and which nothing else fetches. Decided on the count NT publishes: the England nav node still lists the league with
numevents: 0, beside League One's 9 and League Two's 11. Keyed on the node ID, because four England nodes contain "premier league" and the outrights one is permanently 0; a missing node raises.A mutation survived here. Dropping "only the FETCH may claim the league is empty" left every test green, because nothing exercised that code arriving from another step —
nt_resolvecannot exit 3 today. A guard for a state that cannot happen yet is untested by construction. It has a test now.Confirmed on the live server tonight:
NT watch: GW6 has no fixtures to price yet (0/10 goals, 0/10 assists), next pull in 689m— no failure count, and 689m is the 12-hour base interval rather than the 6-hour backoff ceiling. The cadence is the observable half.96aa613— the DEFCON rate, scored against what FPL paidThe Over/under work had
rates.defcon90expecting 27% fewer DEFCON points than FPL paid, measured for a different purpose. That rate is whatproject_allfeedsdefcon.expected_points, so it was worth auditing. Point-in-time on GW2-4, expected points on the minutes he actually played so the rate is on trial and the minutes model is not:The aggregate miss is one population. For a player with evidence the estimator is well calibrated; the whole 21% comes from players with under five 90s, where
prior_weight=4pulls the rate most of the way to a 15th-percentile anchor — which is that anchor doing exactly whatrates.pysays it is for. A trade-off to price, not a defect, and rate-prior players are refused from the XI anyway.The first run said 0.58 and was mine to catch. It fed
player_ratesonly the current season's rows, so the anchor dominated and it measured a variant of the estimator nothing runs. Production merges the archive and renumbers the current rounds onto it (continue_gw_sequence), because the weighting reads the GW NUMBER. The script calls that production function now.dbf21dd— a round ingested provisional is ingested again when FPL confirms itafter_gameweek.pyfires when a round's last fixture is over — GW5 was ingested eleven minutes after the final whistle, with FPL still reporting it PROVISIONAL.fetch_current_seasonstates that "a PROVISIONAL round is ingested AND re-fetched on a later run, because a completion marker is not evidence of what was completed". Nothing triggered that later run. A day later FPL had confirmed GW5,rounds.jsonstill said PROVISIONAL and the ledger read OK — so it would have stayed provisional all season, GW5 permanently outside the BPS harness.The ledger now records the round's status at ingest and re-plans a round that has since gone FINAL. Entries predating this carry no status and are left alone, so GW5 itself was re-ingested by hand: it reads FINAL and carries its 61 bonus points (10 fixtures x 6, plus a tie).
A second completion marker lives one level down, and it made the first version useless:
_run_oneskips any step whose previous result was 0, so the re-ingest was planned correctly and ran nothing. Right for a half-finished multi-step job, wrong for one the planner rescheduled because its subject changed.Not measured, and worth stating: the re-ingest overwrote the provisional rows with no copy kept, so the provisional-to-final delta cannot be diffed. "Snapshot before rebuilding" applies to an ingest too.
🤖 Generated with Claude Code
dbf21ddit answersTwo more on the branch, both pushed. Full suite in the live checkout at
92ef7c4: 2153 passed, 0 skipped.92ef7c4— a confirmed round's re-ingest is a job of its own, judged on the fileA code review of
dbf21ddfound two ways its re-ingest could lie about itself. I reproduced both with the suite's own fixtures before changing anything:after_gameweek:N, so a failing re-ingest had only the tries the first ingest had left: two, or one if the first had needed three (GW4's needed two). Ticks are a minute apart with no backoff, so a few minutes of FPL errors at confirmation would have blocked it for good.round_status: FINALwhilerounds.jsonstill held the provisional rows.A correction to the summary above: GW5 would not have stayed provisional all season.
after_gameweek.pyre-fetches every finished round, so the next round's ingest repairs a stuck one about a week later. What stays is the header's "chores STOPPED" line. The review's own suggested fix was also unsafe applied by halves: keeping the old status on failure, on its own, re-ran the ingest on every tick without limit (10 runs in 10 ticks, simulated), because a stale round also skipped the blocked check.The re-ingest is now
after_gameweek:N:final:rounds.jsondoes not hold FINAL. It reads the file, which is what the projections read, so a hand-run re-ingest counts (GW5's did). So does a file that has lost the round: the committedrounds.jsonholds GW4 PROVISIONAL and no GW5, so a checkout that tidies the chores' dirty files would do exactly that.NOT_FINAL, naming what the file holds._run_oneis back to what it was beforedbf21dd, and_ingest_statusis gone.data_checkedhas one reading now:round_confirmedandgameweek_statusinbackend/data/season.py, used by the fetcher, the scheduler and the scoreboard. There were three copies, with two behaviours for a missing field. It is read strictly:.get()read a missing field as "not checked", which would have called every round provisional for good. Every event in every snapshot indata/pitcarries it.Fifteen mutations, fifteen red. The server has been restarted on it. It reads GW5 as FINAL in both FPL's feed and the file, and plans nothing. The path first runs for real after GW6.
1878c80— wikiThe evidence is in
wiki/systems/weekly-chores-scheduler.mdand the rule is in CLAUDE.md under 2026-09-25. The lint flagged eight pages stale because of92ef7c4; each was re-checked against it and bumped.Not done: a blocked entry's STOPPED line never clears on its own, even after a later run has repaired the data (every key, not only this one). The first ingest is still judged on its exit code.
🤖 Generated with Claude Code
Six more on the branch, all pushed. Full suite in the live checkout at
f80e983: 2170 passed, 0 skipped.f80e983— the Understat watch fetches again, and knows when it has a roundFound checking the live server after
92ef7c4:understat_watchhad read "due now" on every tick since GW5 ended, its ledger entry counted 3,107 runs, anddata/backfill/understat_2026_27.jsonhad not been written since 12 Sep. Two defects:_run_one, whose per-step "already succeeded, skip" is built for a chore that happens once in a round. After its first clean run both steps had exited 0, so every later run skipped both and recorded OK._run_nt_watch's docstring names exactly this trap.Now coverage is counted club by club against FPL's own fixtures for the rounds that are over, so blanks and doubles count as they happened. The watch has its own runner: it fetches, rebuilds when the file differs from what the last good rebuild read, and records
OK,NOT_YET(Understat hasn't published a match; no error) orFAILED. It waits an hour between fetches, doubling to six while they fail or bring nothing new. It is inRECURRING, so it can't be listed as STOPPED while the scheduler keeps fetching. Eighteen mutations, eighteen red.Verified live: the first real run, at 21:32 Oslo, rewrote the file (all 20 clubs at 5 of 5), the projections were rebuilt on it at 21:35, and the status line reads "Understat has every match played to GW5".
283a5d1— what the loop took with itA tick whose plan holds a job with a
rebuildstep stands the inputs watch down. The empty Understat job carried that step on every tick from the end of GW5, soinputs_rebuildlast ran on 19 Sep and the projections were last built on 21 Sep: four days of FPL availability news reached no projection. The restart started the rebuild within seconds, and no deadline fell in the gap (GW6 locks on 10 Oct). The new test goes red if the wait is ever removed.6416711,1814841— a sweep of the whole live appEverything on the page and behind it, at desktop and phone width:
wiki/notes/audit-2026-09-25-live-sweep.md. Nothing in the app was changed by it. Open, waiting on a decision:.panel-kickerisnowrap;.mt-gridusesminmax(380px, 1fr)).7288694and894992fcarry the Understat evidence in the wiki; the rules are in CLAUDE.md under 2026-09-25.🤖 Generated with Claude Code
Four more on the branch, all pushed. Full suite in the live checkout at
d45df6a: 2187 passed, 0 skipped.10d0018changes one test; its file andtests/data/test_atomic.pypass (17).bf7e50a— five texts that claimed what was not thereThese are the texts from the live sweep of 2026-09-25 (
wiki/notes/audit-2026-09-25-live-sweep.md), read off the running page during the international break, when Norsk Tipping listed no Premier League events. Each was an absence rendered as a well-formed statement:nt_watchpulls on its own./api/marketnow refuses with the watch's own sentence (Chores.odds_watch_says, the one the header shows), and the page prints the server's reason.sample.last_seasonandsample.this_seasonbeside the sum, and the card names both seasons and shows this season's share. The live cache was rebuilt on it: all 667 players' totals are unchanged and every pair of parts adds up. The 113 projections that moved in that rebuild were all at Sunderland (one new FPL flag) or at the six clubs Sunderland meet in GW6–11. None moved because of the code.AWAITING_RESULTSon every metric, although 50 results were in. That status was the dataclass default and was never overwritten. Results in with no scorer is nowNOT_SCORED, with the count. The goalscorer metric reads the banked rounds; details on #11.13 of the 14 new tests failed on the unchanged code; the fourteenth, the scheduler's wiring, is proven by a cut instead. 17 cuts to the fixes, one at a time, each turned a test red.
9624e80— the Goals panel with no market says so, and forgets the last oneTwo more of the same shape, both found by checking the fixes above on the live page:
MARKETwas set when a load succeeded and never cleared. Once the window rolled on to a round nobody had priced, "Refresh team news" kept the previous round's book. The team table repaints after the market loads, so it drew the old book's table over the refusal and named its best club in the clean-sheet kicker. A failed load now clears it.Four cuts, four red.
10d0018— the concurrency smoke test's reader no longer dies quietlyBoth full runs warned "Exception in thread (reader)". On Windows an open is refused while
os.replaceis in flight, 70–80 times in 40 writes when counted, and the reader caught neither that nor anything else unexpected. It ended at the first refusal, after as few as 16 of its ~14,000 reads, and the test passed on whatever it had checked by then. The reader now retries a refusal, and any other way it ends fails the test. Not changed, and worth a decision: nothing on the production side retries a refused read. The writer already retries (_replace_with_retry), butload_overridesand the projection cache's readers would raise the samePermissionErrorif a request landed during a replace. That is rare, since overrides are hardly written and a replace takes milliseconds.d45df6a— wikiThe audit page moves the five texts to a fixed section. CLAUDE.md gets four rules:
23 pages went STALE behind the fixes. Two gained a line, the other 21 were re-checked against the change, and the lint is clean.
Still open from the sweep:
🤖 Generated with Claude Code
With all three metrics scored, one clause per metric ran to ~190 characters ("match outcome (h/d/a) scored on GW1, GW2, GW5, clean sheets scored on GW1, GW2, GW5, ...") and pushed the panel's title onto two lines at 1600px. Metrics covering the same rounds now share them, and a label's parenthetical stays on its tile: "scored: match outcome, clean sheets GW1, 2, 5; anytime goalscorer GW5". Title one line again, kicker 705px. At 390px it still overflows (page 876px, was 681 before this branch): that is the .panel-kicker nowrap the phone batch fixes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>- A bank that parses but has the wrong shape ({} or []) raised KeyError outside load_banks' try; the scheduler drops every job in a tick whose plan raises, the deadline snapshot included, and /api/accuracy was a 500 that took the goalscorer tile and the scoreboard with it. Every field a reader touches is now checked, and a bad file is a ValueError naming it, which every reader already catches. - "Market" pooled the book consensus (GW1-2) and Norsk Tipping (GW5) under one word. Each bank now records when its market was priced (the NT run's stamp, or the consensus's newest last_update), and the tile names the book per round. - Exclusions were counted and never shown; the tile now names them. - Every unscoreable round was given one cause. Refusals carry a cause (NO_SNAPSHOT, NO_RESULTS, RESULTS_STOP_EARLY, NO_STRENGTH) and the panel groups rounds under it. - The kicker lost the refit label, and folded it is all a reader sees: each metric now says REFIT or STORED and the kicker marks a refit. - A fixture whose home and away changed since the snapshot would have been scored on the wrong side; it is excluded and named. - score_matches.py prints the code and the clock it ran on, and how long before each deadline its market was priced. - Tests now guard two connections the review cut without a test going red: the market's warnings banked in notes, and "ours" beside the market read on the fixtures it priced. - A page test's regex escape had been collapsed by a heredoc into an invalid Python escape (SyntaxWarning). 18 tests failed before the fixes; the two that passed were proven by cutting what they guard. Full suite: 2254 passed, 0 skipped. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>GW1's consensus was pulled 22 days before its deadline, with the transfer window still open: no price anyone could have bet at the deadline, and comparing ours with it flattered ours. Each bank now records its deadline and its market's age, and a market priced more than 7 days early (or with no record of when) stays in the bank for the record but is not compared; the round is still scored for ours and the base rate. The tile says so ("GW1's market was priced 22 days before the deadline, not compared") and the script's report marks it. Decided after the first run, at the reader's word ("just do what is most sensible"), and recorded in the spec as Decision 4. It narrowed our lead: on RPS, ours minus the market went from -0.0110 on 30 fixtures to -0.0091 on 20 (GW2 and GW5). Five cuts, five red. Full suite: 2257 passed, 0 skipped. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>Fifteen more on the branch, all pushed (
61e9bec..ff6e6d1). Full suite in the live checkout at44159af: 2257 passed, 0 skipped.Match outcome and clean sheets are scored — by refit (#11)
docs/superpowers/specs/2026-09-26-match-scoring-design.mdanddocs/superpowers/plans/2026-09-26-match-scoring.md.backend/web/match_scoring.py;scripts/score_matches.py;after_gameweek.py;match_scoresscheduler job;outcome_probsandrpsmoved out ofbacktest_rps.py, which imports them. The backtests that imported them from it still get the same objects.pipeline.market_lambdasis split, so one reader maps names and checks residuals for any frozen market file. Behaviour is unchanged: a rebuild moved no projection.60ab678— what a fresh-context review found18 tests failed before the fix, and the other two were proven by cutting what they guard.
44159af— a market is the benchmark only if priced in the week before the deadlineGW1's consensus was 22 days old. It is kept in the record, not compared. This was decided after the first run and it narrowed our lead. The result is level with the market on RPS and clean sheets; the numbers are on #11.
Also found, not fixed
projection_digesthashes a hand-keptPROJECTION_SOURCESthat misses 24 of the 45 files in the build's import closure, among themrates.py,strengths_build.py,solio.py,depth.py,adjust.pyandodds/market.py. An edit there changes projections while the header says "projections current". I'm raising it with the user as the next fix..panel-kickernowrap the phone batch fixes.🤖 Generated with Claude Code
Three more on the branch, all pushed (
fd0b2c8..715598e). Full suite in the live checkout ate5c1f77: 2286 passed, 0 skipped. The server has been restarted on it, and the panel was checked on the live page.fd0b2c8: the staleness digest reads every file the build importsprojection_digesthashed a hand-kept list covering 21 of the 45 files in the build's import closure.rates.py,strengths_build.py,solio.py,depth.py,adjust.pyandodds/market.pywere among the 24 it missed. An edit to any of them changed every projection while the header said "projections current". It now walks the import closure itself, and hashes each file as its syntax tree with docstrings removed, so prose still moves nothing. The match scorer's code digest uses the same function. Five cuts, five red.If you run the app yourself: the digest's value changed once, so the cache reads stale until it is rebuilt. Restart the server right after pulling this. A running scheduler keeps the old digest function in memory while the scripts it spawns use the new one, so the two never agree and the rescore runs on every tick. It happened here once before the restart.
e5c1f77: expected points and the chance of starting are scored, and every difference carries a verdictDetails on #11. In short: expected points are level with FPL's
ep_nexton error and ahead on ranking in all three scored rounds. The chance of starting is ahead of "started last round" with the interval clear of zero. The disagreement tally and the calibration chart are no longer empty. Sixteen cuts, sixteen red.715598e: wikiThe rules go in CLAUDE.md: a difference without an interval is not a finding, and restart the server after changing how a digest is computed. The first run's numbers go in
wiki/notes/accuracy-panel-has-no-scoring-branch.md.Next, not started: a pass to simplify the page layout. It is being discussed with the user first and will go one tab per session.
🤖 Generated with Claude Code
The page-simplification pass has started, and its first session (the header) is on the branch, all pushed (
bf923b2..ec0cdce). The full suite in the live checkout at98dfc53: 2344 passed, 0 skipped. The server has been restarted on it and the page checked live at 1600 and 390 px.The pass
The user asked for a page that is "less messy, and easier for the eye", and chose "one question per tab":
docs/superpowers/specs/2026-09-26-page-simplification-design.md.docs/superpowers/plans/2026-09-26-header-status-light.md.restore/pre-simplify-header-2026-09-26.Session 1: the header is the deadline and one status light (
ec0e08f..98dfc53)API change you may care about:
/api/statusnow servesfaultsapart fromwarnings(98dfc53,backend/web/main.py).cache integrity: …andstartup: deps not loaded …used to sit among the build notes. The new grey panel would have shown them under "nothing needs you", so they are split out the same waycachealready was, and the light requires and readsfaults.warningsnow carries the build's own notes only.Found on the way:
ec0e08f. For example, it said the deadline "already" turned red under 24 h:tokens.cssreserved--alertfor that and nothing used it.sp-class prefix collided with the squad spend bars'.sp-row; the panel usesstp-.The final review (a fresh-context reviewer) returned "with fixes": one Critical (the faults above) and four Important:
All five are fixed in
98dfc53, each with a test that failed first (16 red before the fixes). The light now re-reads the status every five minutes and when the page is shown again, and says when. The deferred minors are listed in the wiki page.Record: CLAUDE.md gains "quiet is a claim…". The evidence is in
wiki/notes/page-simplification-2026-09.md, anddocs/DESIGN_PLAN.mdphase 12's first item is marked done.Next: session 2, the Squad tab (one column), in a fresh session.
🤖 Generated with Claude Code
Merged into
main, at the user's request, and pushed:mainis nowec0cdce, the head of this branch.mainwas already contained in the branch (origin/main56a5026and the six wiki-kit commits of 12 September), so there is no merge commit and there were no conflicts. The push was56a5026..ec0cdce.mainafter the merge, in the live checkout: 2344 passed, 0 skipped.feat/captaincy-boardis deleted, andorigin/feat/captaincy-boardis left as it is.main.🤖 Generated with Claude Code
Page simplification, session 2 of 5: the Squad tab is one column. It was merged into
mainat the user's request and pushed (ec0cdce..c8d917a).337e035). The deadline and the sync state have been in the header since session 1.87ca278).5c3c8eb,6c8653f)./api/captaincy. That branch has its test now (ea7e8d9).6c8653f.709ce66:mainafter the merge, in the live checkout: 2359 passed, 0 skipped.wiki/notes/page-simplification-2026-09.md.🤖 Generated with Claude Code
Closed at the user's request; the work is merged, not dropped. This branch went into
mainoutside the PR, as a fast-forward to its headec0cdce, whichmaincontains (mainis nowc8d917a). Forgejo did not notice, so the PR kept showing as open. It could not be marked "manually merged" instead: that merge style is not enabled for this repository.🤖 Generated with Claude Code
Page simplification, session 2b: the Squad tab's type sizes and the round plan's rows. Also a fix: "Optimise line-up" did nothing on GW6. Both were merged into
mainat the user's request and pushed (c8d917a..75724c7).62a1e89..eaa927b, wikid5af8bf). The user asked what the tab should show and where, or whether to show it at all. The answer became a rule in the spec, R8: show a thing only if it earns its place (62a1e89).7dc61a5). Text under 12 px was 34% with the folds shut. It is now only the 11 px capitals, 3% of the text with every fold open. The sizes went to the user as a prototype first, and nothing was built until the user approved it.99e9d69).6116cc9).173277c). Before, three figures were printed, and the XI to field was not printed anywhere.d6eb08f).eaa927b, with six more cuts red. Among its fixes: the "Other options" fold no longer widens the page on a phone, and two tests that could not fail now can.af7d12c,ac6bbee):Cause: since the round plan (17 Sep), the plan's round draws the user's own line-up. The button re-solved the server's best XI and compared the result with the previous solve, which is the same every time. So it said "already optimal — no change" about a line-up it never read.
Now: it draws the best XI from the user's squad on that round, with a way back ("show your line-up"). The note under the buttons says what changes. On the live app it read "3 changes: start Davis, Maguire, Tavernier for Semenyo, Isak, Calvert-Lewin": 47.7 xPts, against 42.4 for the line-up as it stands.
The live check of the first version found three faults in the longer note:
No sweep could see any of this, because the note only exists after a press. The tests now press the button (
tests/web/test_optimise_lineup.py); ten cuts, all red.mainafter the merge, in the live checkout: 2386 passed, 0 skipped.wiki/notes/page-simplification-2026-09.md(2b) andwiki/systems/round-plan-on-the-squad-tab.md(the fix).🤖 Generated with Claude Code
Prices and chips on the Squad tab: FPL's price projection on the round plan and the strip, a Chips row, and FPL's nightly projection banked and scored. These are add-ons 1 and 2 of the 2026-09-27 assessment. The user chose them ("prices + chips first") and approved the spec. The work was merged into
mainat the user's request and pushed (75724c7..20c5f41)./api/playerscarries each player'sprice_changewith one state per cause (PROJECTED, LOCKED, CALIBRATING, MISSING), plusprice_change_statusand the read time.sell_after_fallon/api/squad. It stays quiet only when all fifteen were read.backend/web/price_watch.py):now_cost.data/prices/(gitignored) for the Record tab./api/squadserves each chip's window, the round it was played in, and its last deadline, with one label per cause.GET /api/chipsvalues the chips left over the projected rounds:optimize.best_xi_within).optimize.best_squad;autocompleteis unchanged and draws from the same pool)._deps["fetched_at"]never moved with the five-minute refresher, so "read at" named the last manual refresh or process start._live_depsnow returns the time with the data (28c7cb4).42e023e): its GW19 deadline was wrong (FPL says Fri 1 Jan 19:30 Oslo), and there are six more. They are listed in the plan,docs/superpowers/plans/2026-10-01-prices-and-chips.md.f3f810b, each with a test that failed first.main: 2475 passed, 0 skipped.captaincy._row_forreads one fixture a round, so the captain row and the armband are wrong in a double gameweek. The Triple Captain's chip value refuses there. This is recorded in CLAUDE.md's Known gaps and not fixed.wiki/systems/prices-and-chips-on-the-squad-tab.md.test_the_suite_never_starts_the_chores.pyleavesstartup_warningset, and twotest_api.pystatus tests fail when they run after it.🤖 Generated with Claude Code
Page simplification, session 3: the Matches tab is one row a match, and an absence is said once, by its cause. The work was merged into
mainat the user's request and pushed (20c5f41..3e1ca1c). The restore point is the tagrestore/pre-simplify-matches-2026-10-02(20c5f41).20c5f41and after:a971825). The result split left the FDR ramp: the likeliest outcome is marked and drawn in the strongest text colour.e07c60d). A match the book has not priced keeps one "no price" mark.9ad95e4).02a437a). Live, the unpriced assister column had read 40, 24, 27, 23, 27 down the page: it was ordered by one number and printed another.b0e0763). The backend's fallback no longer cuts the scorer list to twenty; live it serves 307 scorers across all 20 clubs.c90d57a), so it shows the round being played./api/marketserves the next deadline's round.3c768b6).af84374): seven decisions it had not made, among them ten rows per table (with twenty the height target cannot hold). The CSS render gate had never seen this tab, because its stub served no/api/matches;cb094e0fixes that.4fa4bdbgave them tests that can.bdccd5f, each with a test that failed first:/api/marketanswers 503 both when nothing is priced and when it fails, so the page read every failure as "not priced". Its not-priced refusal now carriesX-Market-State: NOT_PRICED;main, in the live checkout: 2530 passed, 0 skipped.wiki/notes/page-simplification-2026-09.md(session 3). Among them: a match's "Players ›" runs long (Arsenal v Leeds lists 60 assisters, 29 of them at 0%); the detail's result odds carry no H/D/A labels; and the partly priced line counts "no price" rows as "not compared".wiki/notes/page-simplification-2026-09.md(session 3) and the plan,docs/superpowers/plans/2026-10-02-matches-one-row-a-match.md.🤖 Generated with Claude Code
Page simplification, session 4: the Fixtures tab, and a new Record tab ("Can I trust the app?"). It was merged into
mainat the user's request and pushed (3e1ca1c..521240a). There is no restore tag this time; the restore point is3e1ca1c.3e1ca1c) and after (bcf3dc6). Visible text was read withcheckVisibility().58c6977,573206a,4bb8c73,f11e0d9):88d7f19).c6503ee)./api/market's only reader. They say the market's state with one label per cause, five in all, one of which is "the book has priced too little" (dd9f3b6).Metric.verdict. A failed/api/accuracyis said once on the tab; before, it read "Nothing here is scored against outcomes yet" (573206a).6593331).b408260(2026-08-16),loadAll's/api/teamscatch wrote to an element that no longer existed. A failed team load threw inside the catch, and both Fixtures panels kept their loading skeletons (512de36)./api/teamswithout itswindow. For a while every stubbed Clean sheets drew the refusal instead of the table, the render gate's included.<details>sat inside a<p>. The parser closes the<p>first, so two ⓘ folds fell out of their 12 px hints at 14.4 px.<details>still returns a realgetBoundingClientRectbox. The first inventory counted 2,523 characters nobody could see, so the inventories now usecheckVisibility(). Session 3's recorded figures stand: I first said otherwise, and checking showed I was wrong.bcf3dc6).38f0f74,2b3074c), and 10 of 10 cuts of the fixes went red.score_history.verdict's own winner. Once four rounds of ours count (about GW8), two shapes would have made it say "level" or "behind" whatever ours did against the market: the raw coupon worse than the base rate (BROKEN, so nothing else is compared), or a BLEND ahead of ours. The pairwise rules now live once, inscore_history.head_to_head, and the verdict's own output is unchanged.tkCode./api/accuracy. Each is now its own section, FAILED with its reason./api/teamsreload left the previous teams in place, and the next paint drew them over the failure.main, in the live checkout: 2617 passed, 0 skipped.wiki/notes/page-simplification-2026-09.md(session 4):renderTeamsstill repaints the accuracy panel;record()re-reads every night on each request.docs/superpowers/plans/2026-10-03-fixtures-and-record.md, and CLAUDE.md's 2026-10-03 entry.🤖 Generated with Claude Code
Page simplification, session 5: the Players tab is on the type steps, and the Confidence column is a START badge. The work was merged into
mainat the user's request and pushed (521240a..2abad4d). The restore point is the tagrestore/pre-simplify-players-2026-10-03(521240a). There is no backend change, so the server was not restarted.style.cssheld 161 rules under 12 px, and 43 of them name classes no code emits. The spec gained a section 6 for all of it (361f1a1).521240a, 2026-10-03) and after (80fd241, 2026-10-04). Visible text was read withcheckVisibility().START_FLAGandSTART_BASIS, so the line it names is the badge's. The foot line no longer repeats "tap a row", and the ★ key shows only when a starred row is on screen (5fae6cb).7abefd9).startUnderreads the numberFMT.pctprints. The Players badge and the Squad tab's five start comparisons go through it, so no "START 75%" sits under a "<75%" line, and no player is flagged on one tab and passed on the other. Live, two players sit in that gap, at 0.746 and 0.747. Neither is in the squad, so the Squad strip did not change.ae944cd): cells 14 px, headers and SHOW 11 px capitals, labels and badges 12 px, the search and position boxes 14 px. The rules only Players uses were edited in place; the shared classes got#tab-playersrules. The table still fits its panel from 1050 px up, because the Confidence column's width paid for the larger type.80fd241). Both were session 2's deferred minors.::aftercontent at.85em, 8.98 px live, and every type test walked text nodes only. The Players type test now reads::beforeand::aftercontent too, and the rule is inCLAUDE.md(e066ec8). Session 6's app-wide test must do the same.pyproject.toml'saddoptsalready carries-q, and a second-qsuppresses the "N passed" line. The plan's suite command printed no summary, so the count was read from the progress characters instead.3609f41, each with a test that failed first, and 11 of 11 cuts of the fixes went red:playerRisk,flagCause, the watch line orstartUnder's missing branch left every test green. Each now has a test of its own;START_MISSING, throughstartWhy), and the flag list has a cause of its own, "Start unknown";startBasisnow names a position prior or an override for what it is;overflow: hiddenpassed the second). Both now can.65f1a28), and the testing notes' certainty paragraph is marked superseded (2abad4d).main, in the live checkout: 2646 passed, 0 skipped.wiki/notes/page-simplification-2026-09.md(session 5). Among them: the ⓘ is rebuilt on every render, so a background repaint drops focus from its summary; and theCLAUDE.mdrule names::beforeand::after, while::markerand::placeholderrender text too.check_cache_schemachecks that a key is present, not that its value is not None. That is why the badge has a "START ?" state.--warning), the hue the spec keeps for "needs you". On a Players row it sits beside two amber badges that do mean a risk. Recorded for the user, not changed.--text-2xs/--text-xssteps and the 43 rules with no emitter are deleted, and the app-wide type test is switched on, pseudo-elements included. Its plan is written from the spec first, for the user's review.wiki/notes/page-simplification-2026-09.md(session 5) and the plan,docs/superpowers/plans/2026-10-03-players.md.🤖 Generated with Claude Code
Players the simulator never projects:
/api/playersserved 275 of 667 players 0.0 xPts, and the page now says "not projected" and why. The work was merged intomainat the user's request and pushed (2abad4d..2fc8fee). The restore point is the tagrestore/pre-unprojected-2026-10-04(2abad4d). The backend changed, so the server was restarted (2026-10-05, 07:20 Oslo).pipeline.MIN_START_PROB(0.05). Below that he has no rows, and/api/playerssummed the empty list. Each such player was served 0.0 xPts, a band of [0, 0], zero components and a vs field of 0.0. Live that day, that was 275 of 667 players, among them a bench forward with a 96% chance of playing./api/autocomplete's own comment has called this sum "a well-formed number from no evidence" since 2026-09-13 (aa91f71); the route next to it still served it to every surface. The user chose to fix it in a session of its own, before session 6.positionMedianEvkeeps any value that is not null, and a zero passes. 42 of 73 keepers and 39 of 79 forwards had no rows, so both medians were 0.00, and "Differentials only" listed 245 players. Re-derived on the same data without them, it lists 127. Live after the fix it lists 127; the pre-registered range was 110–145.0df472b): every/api/playersplayer carriesprojection, computed from the content. Its state isSIMULATED,BELOW_START_LINE(with the line served as a number) orNO_ROWS.NO_ROWShas never fired live, so its test forces it. A player in either of the last two states getsNonefor the six-week fields, andgwsis[]. The server sends no sentence and rounds no number, because Python'sroundtakes 2.5 to 2 where the page'sFMT.pcttakes it to 3.bd9b2bc):projectionWhybuilds it from the numbers the page prints. "Not projected: his chance of starting, 2%, is under the 5% the simulator projects from."bandOf,bonusTotal,valuePerManddefconTotalreturn null instead of 0.fa834d2) prints "–" in each gameweek, with the cause as the title, and "—" for Value, DEFCON and vs field. These players sort last in both directions.96f8800) print the sentence where the bars were, and "—" for every projected stat. Neither has a "Why this score", because it was built from zeros. The row card keeps the per-90 rates, since they are measured.c6aea4b) name no winner against a missing number. The builder prints "—", with the sentence as its title.5ca2d49, each with a test that failed first:startUnderfixed for the START badge;tests/web/test_unprojected_players.py,tests/web/test_unprojected_page.py). 20 mutation cuts, all red.main, in the live checkout: 2676 passed, 0 skipped.SIMULATED, 275BELOW_START_LINE, 0NO_ROWS. No player without a projection shows a number, and no projected player is missing one. Of the 275 sentences, none contradicts itself, and five read "just under".isDifferentialreads a null as below the median only because JavaScript coerces null to 0;whySentencereadscomponents6 || {};swapConfidencereadssd6_no_bonus || 0;_projection_ofrestates the simulator's>=instead of sharing one predicate.wiki/notes/unprojected-players-served-as-zero.md(ea0f500), the rule inCLAUDE.md(a5894c9), a wiki lint (2fc8fee), and the plan,docs/superpowers/plans/2026-10-04-unprojected-players.md.🤖 Generated with Claude Code
Page simplification session 6a: the two player cards, compare, Edit squad's builder and the footer's engine notes now say what is true, and sit on the five type steps. The work was merged into
mainat the user's request and pushed (2fc8fee..5a79fbd). The restore point is the tagrestore/pre-simplify-cards-2026-10-05(2fc8fee). Frontend only, so no restart.frontend/andtests/web/, task by task, from a fresh copy. A script asserted each find string occurs exactly once. Each new test failed first for the stated reason, and the cuts ran there too. The plan isdocs/superpowers/plans/2026-10-05-cards-compare-builder-notes.md.START_MISSING, and never printed (d57fcab). Compare printed "0%" (null * 100is 0 in JavaScript). The row card printed "start% — (estimated from minutes played …)". The pitch card printed "starts — of the time" and a provenance line for the absence. The row card's basis now followsstart_source(startBasis).e8b8b4d):gwTierhas no caller, and every fixture colour on screen is difficulty;START_BASISandSTART_FLAGinstead of typing them.innerHTMLunescaped. Edit squad left the pitch's heading on screen with no pitch under it. The builder's hint typedSTART_BASISa second time. All three are fixed in2008175.title: it carried two, and the parser keeps the first. A shirt's "—" now names its cause (ecfd743).c770021). Edit squad's squad table scrolls inside its panel where it does not fit, withminmax(0, …)tracks (548a99f). It already overflowed at 360–390 and at 660–700 px..eyebrow-dataspan.min-height.CLAUDE.md(99348de).79d0ace, each with a test that failed first and a cut that went red:overflow-x: hidden, because a script can scroll a box a reader cannot. It now readsoverflowXand scrolls to reach xPts.#squad-edit-modeand the footer, and the gate was re-baselined after it.main, in the live checkout: 2722 passed, 0 skipped.<details>inside a<span>.aria-labelsits on a generic<div>, which ARIA does not name.ticker.html, deleting--text-2xs/--text-xs, and the app-wide R5 test. It gets a fresh session, with its plan written first. After 6b the user has chosen planning several rounds ahead as the next project.wiki/notes/page-simplification-2026-09.md(sections "Session 6a" and its final review,e1d2dea,5a79fbd), and the spec's amendment for 6a (41d8b73).🤖 Generated with Claude Code
Page simplification session 6b, the stylesheet: every font size the app declares is now one of R5's five steps, in the files and in every state the tests drive. This ends the type migration. The work was merged into
mainat the user's request and pushed (5a79fbd..73d2489). The restore point is the tagrestore/pre-simplify-stylesheet-2026-10-06(5a79fbd). The change is frontend and tests only, so nothing restarts.The plan was verified before it was written (
docs/superpowers/plans/2026-10-06-stylesheet.md). Every step was applied to a fresh copy, task by task. Running it here, the executing session found the plan's scripts identical to the copies that verified it, and after Task 5 the asset digest matched the planning copy's.The render gate sees what a tab opens, and generated text (
23ab254). Each tab opens one form of the player modal before its snapshot, plus the engine notes. Every pseudo-element that renders is hashed under its element's path. Until now the gate had never seen the cards, compare, the notes or any::before/::aftertext.Two phone defects, in states no test opened (
f6a692f):nowrapand made Squad 452 px wide at 360 and 390. It now wraps inside its row.184 CSS rules nothing could match were deleted (
368ab99). They name 140 classes that no code emits, read from the code with its comments stripped, plus five selectors dead by structure. The browser confirmed every rule that stays is unchanged (1,036 → 852), andtest_no_dead_css.pykeeps them out.R5's test is two files (
a380ff9,50f6ceb):test_type_everywhere.pywalks the whole page in 18 stubbed states at 1600 and 390 px. The walk reads::before,::after,::marker,::placeholder, a field's value and a select's choice.test_every_font_size_is_a_step.pyreads every file infrontend/as text.What moved onto the steps:
bodyis 14 px. No visible text changed size, and the pitch is 2.6 px shorter.The old seven-step
--text-*scale is gone, and a test keeps all seven out offrontend/.The ticker page loads
tokens.css. It had drawn black on white since 2026-08-16.Live, before and after (read-only, every fold open, 24 states at 1600 and 390 px):
Found on the way (the rule is in
CLAUDE.md,1ccec30):The final review (fresh context, most capable model) came back "with fixes": 0 Critical, 2 Important, and 4 raised from Minor. All six were fixed in
77d943b. Each guard fix has a cut that survived before and goes red after:font-size:in place. A<style>block in index.html,el.style.fontSize, a style-attribute shorthand, a rule in tokens.css and a rebound step all passed it. It now lists the folder, reads every way a size can be set in place, and lets only tokens.css's:rootdefine a step. A test puts 15 ways into a copy of the files; 11 had passed.Checks:
main.Deferred minors:
.bank-why[open] summary,.sp-track i.overandi.under; they have no render effect);/apipayload is outside the dead-CSS guard's model.For later: the page sends
POST /api/refresh-liveon load. That belongs with the queued task of hiding the operator buttons from LAN clients.Next: four small tasks:
captaincy._row_for(before the first double gameweek);backtest_starts_boundary's.get(..., 1.0);Then planning several rounds ahead.
Evidence:
wiki/notes/page-simplification-2026-09.md(section "Session 6b" and its final review),wiki/notes/render-gate-hashed-the-enumeration-order.md, and the spec's 2026-10-06 amendment.🤖 Generated with Claude Code
The four small tasks queued after session 6b are done. They were merged into
mainat the user's request and pushed (73d2489..9a4a119); the server was restarted onto the new backend. Each fix was test-first, and each test was proved by cutting what it guards.17b5f74,5ef1661).captaincy._row_fortook a player's first row for the round, so in a double round the captain row, the best-captains board and the armband ranked on one match, and the Triple Captain refused the round. The user chose "sum now, simulate later":_rows_forreads every match of the round. A double is ranked and valued on both matches' expected points, which add exactly, and the Triple Captain values a double again.tailssaying why. The captain row, the board and the chip line show that sentence. The board'sp.ceiling.toFixed(1)would have thrown on the null, and the round plan would have read "could not rank captaincy".armband_assumes).26fab18)./api/statusnow servesoperator= {allowed, why}, fromlocal_caller, the one functionrequire_localalso asks.backtest_starts_boundary.py's.get(str(target), 1.0)(0d9c1b0). It is nowavailability_at, which refuses a round missing from a player's map. The map is dense over the season's calendar, so a missing round could only mean a calendar without it, and the old code scored everyone as fit. The output on GW2 and GW5 is identical before and after..worktrees/over-under,.worktrees/round-plan, about 305 MB) are removed. Their branches are kept.over-under's 14 untracked files were copies: 6 byte-identical to the main checkout, and 8 Norsk Tipping manifests whose content is in the main checkout'shistory/archive.main. The first full run on the branch had one failure: a test called the status route directly, and the route now answers a caller, so the test names one (9a4a119). The server was restarted onto the new backend, and the live captaincy answer was checked: GW6 has no double, and it ranks as before.wiki/systems/prices-and-chips-on-the-squad-tab.md(the double round);wiki/systems/app-autostart-supervisor.md(the LAN);wiki/concepts/start-probability-and-rates-recency-weighting.md(the backtest);🤖 Generated with Claude Code
Multi-round planner, Session A (Stage 0a): moves planned by hand in Edit squad now spend free transfers. The work was merged into
mainat the user's request and pushed (9a4a119..3838db1). The restore point is the tagrestore/pre-multi-round-planner-2026-10-06(9a4a119). The server was restarted onto the new backend. The spec and the plan are committed (3781491):docs/superpowers/specs/2026-10-06-multi-round-planner-design.md,docs/superpowers/plans/2026-10-06-multi-round-planner.md.The defect, found 2026-10-06 while designing the planner.
/api/squadservedpickswith the moves planned by hand already made, andfree_transfers.countas FPL counted it at the last deadline. The round plan spent that count, so one planned move and one free transfer could still mark another move "now", which would have cost 4 points.The fix:
fpl_team.carry_free_transfersis FPL's carry rule, and the history replay calls it.fpl_team.after_hand_planadds three fields to the count:planned(the players the plan sells),leftandplanned_cost. The last two are null unless the count isVERIFIED, so an unknown never reads as zero (0b79760)./api/squadserves them (6156b11).freeTransfersState(). The verdicts spendleft, and the head says "N left after your planned move" or "your N planned moves cost X pts". A server that sends a count withoutleftis older than the page, and the head tells the user to restart it (808ae38).The final review (a fresh reviewer on the most capable model) said "with fixes", with nothing critical. The transfer row's "why" text still described the round before the hand plan:
All fixed in
eb9b3fb. That commit also adds a test for the "most FPL allows" sentence's switch toleft, which no test could fail before. The carry rule's docstring now names the two copies that restate the rule: the page's "You carry N" andscripts/backtest_squad.py(c01ac34).Verification:
Records: the wiki's
systems/round-plan-on-the-squad-tab.md(ab9fe93,b29752a), and a CLAUDE.md Known-gaps rule, "A count read at the last deadline is not the count after the plan" (a32cb17,3838db1).Deferred minors:
Next: Session B of the plan:
Session C then runs the 2025/26 replay against a pass mark fixed before any run, and stops there for the user's decision.
🤖 Generated with Claude Code
Multi-round planner, Session B: the engine and its test are built, nothing is measured yet. The work was merged into
mainat the user's request and pushed (3838db1..4a17159). The server was restarted onto the new backend, and the scoreboard replayed itself under the new rule (below). Session C runs the replay; nothing in this session scored the planner.What was built:
Today's round-plan rule in Python (
backend/web/move_search.py,4859a34). It copies the page'sroundPlan(), for the season replay's control arm and the scoreboard's follow manager.tests/web/test_move_search_matches_the_page.pydrives the real page and the copy on the same data, 45 cases.The scoreboard's "App + tips" plays the round plan's rule (
0a25c92). Until now it made the old Transfers tab's single best swap every round, a rule the page dropped on 2026-09-17. Now:Each replay is stamped with the rule's fingerprint (
follow_rule), and one made under another rule is replayed. Until it has been, the Record tab saysRULE_CHANGED(d7b0900).The planner (
backend/web/planner.py,4700701): one MILP over the window's rounds.max(1, F - n + 1)capped at five, through one binary per round. Every F has a non-negative weight, so the solver sits on FPL's carry.The pass mark as code (
backend/web/planner_verdict.py,cb84633): a hierarchical bootstrap (seeds, then squads) and the spec's ship rules, tested on made-up results with known answers.Support for 2024/25 and distinct starting squads (
d3ab39a):optimize.best_squad(differ_from=, min_changes=);collect_forward(arch=, scoring=, play_out=, defcon=), with 2024/25's assistant managers left out;rates.player_rates(defcon=). Its default leaves every projection as it was: old against new on all 841 players' 2025/26 histories, 2523 of 2523 are identical.The season replay (
scripts/backtest_planner.py,a10d87e):forward,runandverdict, five arms, stamped and resumable runs. A smoke run on the real 2025/26 archive (40 sims, one squad, GW8-11) completed end to end.The final review (a fresh reviewer on the most capable model) said "with fixes", with nothing critical. Everything was fixed in
d7b0900andd2d7cdf, each fix with a test that failed first:rules.py, re-exported byfpl_team. The planner's fingerprint, which the verdict is stamped with, now reads only the planner and FPL's rules.CONFIRM_ON_2024_25. The verdict takes its seasons from that file, not from whether a folder exists.+uncommittedwhen the code is. The verdict prints its stamp and keeps each comparison's per-run differences..get()in the branch's new modules.ValueError. The spec has this as correction 11.Verification:
4a17159:Records:
systems/multi-round-planner.md, and the follow manager's rule innotes/app-picks-replayed-against-a-real-entry.md(57eb159).4a17159):Not done:
bestSwapFrom) does not know FPL's four-from-one-club rule yet. It can offer a swap FPL would refuse while a club holds four. This is recorded, to be fixed in a page session before the January window.follow_ruledoes not cover the follow manager's own bookkeeping inscripts/backtest_app_vs_entry.py.Next: Session C runs the 2025/26 replay against the pass mark, writes the verdict, runs 2024/25 only on a pass, and stops there for the user's decision.
🤖 Generated with Claude Code
Multi-round planner, Session C: measured on 2025/26, and it does not ship. The work was merged into
mainat the user's request and pushed (4a17159..277eecf). It changes no code: a computed verdict file, a wiki page and a CLAUDE.md line. The page keeps today's one-week round plan.The test. The planner played against today's round-plan rule on the 2025/26 season replay, under the pass mark written as code before any run (
backend/web/planner_verdict.py).4a17159, harness fingerprint9d81c31db741, planner fingerprint8e786e29b3e9.The verdict is the computed file
data/verdicts/planner.json(b929186):NO_SHIP.Checked before reporting:
Cost: about 37 minutes of CPU, no credits. The full suite passed: 2984 passed, 0 failed, 0 skipped.
Records:
wiki/decisions/multi-round-planner-measured.md(ee16a83), and a dated line under CLAUDE.md's "The problem is treated as one-shot" (277eecf).Still open: the Squad tab's round plan (
bestSwapFrom) does not yet apply FPL's four-from-one-club rule. It is queued for a page session before the January window.🤖 Generated with Claude Code
Four from one club: the Squad tab, Edit squad and Complete my squad now follow FPL's rule after a real-life move. The work was merged into
mainat the user's request and pushed (277eecf..83879a8); the server was restarted onto the new backend. Nothing changes on screen for a squad with no club above three, which is the user's squad today; checked on the live page after the restart.The rule, checked against FPL rather than restated. FPL's FAQ: "a team may temporarily have more than three players from one Premier League club ... the squad can remain as it is ... The manager will need to go back under the limit when making their next transfer." The Premier League's explainer of 2026-08-21: "your next transfer out must be an Arsenal player". So the rule has two halves:
move_search.py(the round plan's rule in Python) held only the first half; the season replay held the second, indecide. The page had neither.The round plan (
b98f7d1):clubsOverCap, read bybestSwapFromand the "Other options" list), as the Python copy does. The pin (tests/web/test_move_search_matches_the_page.py) gains four cases, 45 → 49: four from one club, two clubs over, five from one club with one and with two free transfers._over_capto the copy's moves.KEEP(no transfer this round, so FPL keeps the squad),FIXED(the moves made now bring the club back),SHORT(they would not, so none is made) orHAND(the moves planned by hand leave the club above the cap, which FPL refuses). A squad not synced from FPL is told the limit only.Edit squad and the server (same commit). The user chose to fix these too, found by searching for every club-cap check rather than the one recorded:
/api/squadflagged the squad FPL holds as "more than 3 from club 13": a red chip over a legal squad, naming a club by its id. The held squad, unchanged, now breaks no rule. A plan that transfers and leaves a club above the cap, or a squad not synced, is named with its club and why.rules.validate_squadis untouched:rules.pyis in the planner's fingerprint and the scoreboard'sfollow_rule.+1), and the search's blocker names the count held, not the cap.Checked:
rules.pyandmove_search.py's code are unchanged (one docstring), so the planner's verdict still matches its code. Harness9d81c31db741, planner8e786e29b3e9and the scoreboard'sfollow_rule1b7e5efffb7aare the same, and so is the projection digest: no replay and no rebuild.main: 3010 passed, 0 failed, 0 skipped (2984 at277eecf; the 26 new are this change's).Records: a new section in
wiki/systems/round-plan-on-the-squad-tab.mdand updates to the planner and scoreboard pages (f2de600); CLAUDE.md's Known gaps entry is marked fixed with the rule "a pin covers what the copy holds" (730a01a); wiki lint (83879a8). Assets v259.Still open:
follow_moves) makes the copy's "now" moves without the second half. It matters only when two sales are needed in one round (two clubs over, or five from one). A fix there changes the planner harness's fingerprint, which the tracked verdict was written under.🤖 Generated with Claude Code
FPL's flags now reach the projections within the hour when a deadline is close. Merged into
mainat the user's request and pushed (83879a8..2bb5d25); the server was restarted.What happened. On the afternoon before GW6's deadline FPL put Isak at 0%, "Thigh injury - Unknown return date". The projections had been built at 10:18 Oslo that morning with his 75%, and the once-a-day rule for changed flags (agreed 2026-09-13: rebuild after FPL's overnight update) would have kept them until 04:00 on deadline day. The deadline itself was safe, since the deadline wake rebuilds before the snapshot. The evening before was not: the user planned then, asked for a Wildcard, and the app still projected Isak.
The change (
49dc7de): within 36 hours of the next deadline, changed flags rebuild once the cache is an hour old, as new odds already do, and the status line says when ("projections rebuild in 40m"). Further out, the once-a-day rule stands. 36 hours rather than 24, so a Friday 19:30 deadline is covered from Thursday evening.Checked:
main: 3014 passed, 0 failed, 0 skipped.chores.pyis outside the projection, planner and scoreboard fingerprints: no rebuild, no replay.Records:
wiki/systems/weekly-chores-scheduler.md(7e17a08) and a CLAUDE.md line (2bb5d25).Also today: the design for "simulate the round" is written and approved: a double gameweek's chance of 10+, band and ceiling, with the two matches coupled as measured on 2024/25 and 2025/26's doubles. The implementation plan is next.
🤖 Generated with Claude Code
Simulate the round: a double gameweek now has its own distribution. Merged into
mainat the user's request and pushed (2bb5d25..5795751; restore tagrestore/pre-simulate-the-round-2026-10-09=2bb5d25). The server was restarted and the projections rebuilt once.What changed. A player whose club plays twice in a round now has the round's distribution: his two simulated matches combined, tied by appearance as often as players' were in 2024/25 and 2025/26's doubles (
DOUBLE_COUPLING = 0.45, pooled "missed both" over 447 doubles, pinned by a test toscripts/measure_double_coupling.py). It lives in a new cache field,ProjCache.per_round, checked on load and read through one function,pipeline.round_view. The armband, the best-captains board, the Triple Captain,/api/players(a newroundsfield, and the six-round spread), both player cards and the scoreboard's snapshot replay read a double's P(10+), band and ceiling from the round, marked "over 2 matches". The armband takes a double player's chance of playing at all from the round, where it assumed "both or neither". A round of three matches is not modelled and says so. FPL lists no double (checked today: 380 fixtures, none), so nothing on screen changes yet.Found and fixed on the way:
resim_clubleftproject_allon its GW1-6 default, so an override on a cache over GW6-11 re-simulated one round of six. Dormant: no override is live.certain_to_miss).3d7a086: a re-simulation replaces every round it touches whole, both routes run the load check before saving and answer 409 with the reason, and the load check refuses a double that repeats a fixture..findkeyed on the round, an unescaped name). It now filters, requires exactly one summary, and escapes.Checked:
main: 3093 passed, 0 failed, 0 skipped.per_roundis{}), the armband unchanged, no page errors. The accuracy panel's banks were re-scored under the new code digests, and every number is unchanged.Records:
wiki/decisions/double-gameweek-coupling.md(new),wiki/systems/prices-and-chips-on-the-squad-tab.md, the CLAUDE.md entry, the spec and the plan (docs/superpowers/plans/2026-10-09-simulate-the-round.md).Still open, before the first double gameweek: five displays read one match per row: the row card's chart, the pitch card's "next" line, its "Per gameweek" bars, "Where the points come from" and "Why this score". They are the first piece of the design work, mockups first.
Being done now: a calculations audit, read-only first, then a design pass. Early findings, each to be tested before anything changes: players FPL flags 75% doubtful appeared about half as often as projected; projected bonus sums to about 7.9 a match against the 6 FPL pays (proxy ties all get 3); and assists are priced at 0.8 a goal where FPL pays 0.91-0.97.
🤖 Generated with Claude Code
Calculations audit, and a simulator fix: goals conceded are now charged on the pitch. Merged into
mainat the user's request and pushed (5795751..81ea97d; restore tagrestore/pre-onpitch-goals-conceded-2026-10-11).Next up was a check of whether the app's existing calculations could be improved. Done read-only: each candidate correction was frozen on 2024/25, its pass mark written in code and committed before it ran once on 2025/26, and nothing ships on a pass alone. Full record in
docs/testing-notes.mdandwiki/decisions/calculations-audit-2026-10.md.What the audit found
Fixed and live: goals conceded are charged on the pitch
The simulator charged every appearance with the whole match's goals conceded, so a defender on for the last ten minutes lost a point for every two goals of the match. FPL counts only the goals conceded while he is on. The share a player is charged is now measured per kind of appearance, not spread evenly: substitutes are charged more than their minutes, because of stoppage time and late goals.
The clean sheet stays on the whole match for now. The simulator gives every start the player's average minutes, and a clean sheet windowed at that average came out 12-38% too high.
In the GW7-12 projections, defenders rise 0.39 points on average over the window, keepers 0.11, the most for rotation defenders often used off the bench. Midfielders, forwards and every clean sheet are unchanged.
Also found, not fixed: the points backtest reads ten repeated archive rows twice, and runs a minutes model the app no longer uses.
Full suite on merged main: 3,103 passed, 0 failed, 0 skipped. The live projections were rebuilt on the new code, and the forecast-accuracy panel's numbers did not move.
The bonus race is live on
main, pushed with this comment. It went live on this machine on 2026-10-16 (3ab8c8e..88bb0a1, a fast-forward from81ea97d). The push waited a day becauseorigin/mainhad moved on with #42 and #43, so the race was first ported onto #43's layout (aaca006, details on #43). The next comment covers the port and the rest of the audit's work that went out with it. This is item 2 of the calculations audit: the corrected bonus race that passed its one-shot test on 2025/26 (6.390 bonus a match against the 6.368 FPL paid, where the old race paid 8.02). Its adoption was approved on 2026-10-11.What changed
2722b38,backend/simulator/fixture.py). Every player who appears races on the BPS proxy, less FPL's 4 per goal conceded for keepers and defenders, plus the player's unmodelled BPS,round(mean + sd × z), drawn on a random stream of its own. A player who does not appear takes no bonus from anyone.f308ecf,backend/scoring/unmodelled_bps.py): FPL'sbps, less the proxy over what the simulator samples, plus the goals-conceded charge. The figures are the player's own 60+ minute mean, shrunk toward the position's mean by K matches (GKP 20.1, DEF 13.4, MID 5.4, FWD 5.6), and the position's spread and short-appearance figures. They are pinned to the scorer that tested them, at GW8, 20 and 38 of 2025/26.ff9fe0f). This season is used once every position holds 30 appearances of 60+ minutes and 10 shorter ones, and last season's archive before that. The season is named on every player's record and in the build's notes. The figures are a required field from the index throughPlayerInputandPlayerSpec, so a cache without them refuses on load. Snapshots taken before the merge still load.ev_no_bonus, which moved for no player. Bonus reachesev, the player card's "Bonus prov." and the compare table's "Bonus (provisional)".c5d3e4e), because the race it compared against is gone. Its derivation of K still runs.b03a075).merged_gw.csvrepeats ten rows whole: Kroupi in GW1-9 and Gannon-Doak in GW1. Production and ten scripts now read the file by one rule,backend.web.archive.read_once, and a grep guard names any module that opens it without that rule.Before and after
The live build on frozen inputs (2026-10-15, GW7-12, 8,000 simulations):
The live cache rebuilt after the merge gives 6.367 a match, and no player is missing figures. Over the six rounds the race lowers forwards and keepers most: Thiago −2.13 xPts, Calvert-Lewin −2.11, Raya −2.02, Haaland −1.88. It lifts midfielders: Iwobi +1.01, Xhaka +0.97, Dewsbury-Hall +0.89.
What reading once moved
Every script was re-run both ways on 2025/26. Every verdict stands:
reconcile_bps.py)backtest_rps_xg.py)backtest_goals_xg.py)backtest_market_ratings.py)measure_over_under.py): xA under the assists; clean sheetsThe multi-round planner was not re-run. Four of its 150 runs counted Kroupi's GW9 points twice, and the correction can only lower the planner arms, so NO_SHIP stands. Kroupi's ARCHIVE-ADJUSTED badge was the last one left, and it is gone: the level-scaling had been correcting his repeated rows.
What the final review found
A fresh review of the whole branch returned "with fixes". Every Important finding was fixed RED→GREEN in one pass:
5d1037a), including the pre-deadline snapshot. The inputs watch now makesbuild_cache.pydue, with the reason "the served cache fails its load check", and the rest of the tick runs.rounds.jsonrow counted as one appearance (7d4f7a6). The season's fixtures show who played twice, by club and by each fixture's bps list. Such a row is left out of the figures and named, and without the fixtures this season's rows refuse.fixture.py(df15024).5b16c89): build with the merged code before the restart, so no refused cache is ever served. That is how it went out on 2026-10-16.Checks
main, in the live checkout: 3,155 passed, 0 skipped, 0 failed (2026-10-16, on88bb0a1, before the port; the suite after the port is in the next comment)./api/statusshowed nothing stale and no faults. The player card was looked at in screenshots at 1600 and 390 px.What stays open
rounds.json(rates, start and play chances, minutes) have not been checked against a double's summed row. That has to be done before the first double gameweek.docs/testing-notes.md.backend/, andscripts/fetch_current_season.pyhardcodes the season's path;resim_clubis wrong;Evidence is in
wiki/decisions/bonus-race-adopted.mdand indocs/testing-notes.md("C-bonus adopted"). The spec isdocs/superpowers/specs/2026-10-11-bonus-race-design.mdand the plan isdocs/superpowers/plans/2026-10-15-bonus-race.md. The tagrestore/pre-bonus-race-2026-10-15marks81ea97d.🤖 Generated with Claude Code
The rest of the audit's work from 2026-10-16 went out with the bonus race (
aaca006..2474892, fast-forwards onmain, after GW7's deadline). It contains the port onto #43's layout, one fix, one adoption and two pre-registrations.The port (
aaca006, details on #43)The race now lives in #43's modules. On frozen live inputs the player index before and after the merge was identical for all 667 players. The merge's three Windows faults are fixed in
808feaf. A new guard requires every subprocess text call to name its encoding.The keepers' card anchor (
f2f97dc)suspension.CARD_RATE_PRIOR["GKP"]was 0.020 and labelled as measured on 2025/26's appearances. The archive gives 0.0724 a 90: 55 yellows in 760 nineties. Nothing recorded how 0.020 was reached. It is now pinned toscripts/measure_card_rate_prior.py, and the forwards' 0.1495, which counted 2025/26's repeated rows, is 0.1486. On frozen inputs it moved 107 players' availability by at most 0.015.C-defcon-weight: passed, and adopted (
27679ec,27bba7c,61a6252)DEFCON's rate was shrunk toward a 15th-percentile anchor with the prior weight every per-90 rate shares, 4 nineties. On 2026/27's played rounds the expected DEFCON points ran at 0.74 of what FPL paid, and 0.56 for players with under five 90s of evidence. The candidate gives DEFCON alone a weight of 1, fitted on 2026/27 GW2-6. It was pre-registered and run once on 2025/26, a season it was not fitted on:
The squared error was 1.9% lower on the harness's minutes and 3.7% on the minutes played. The paired interval per match was −0.314 to −0.139, entirely below zero. Thin evidence is still priced a quarter under what was paid, never over. Two of the four predictions missed narrowly: the gain was smaller than the 2-6% written, and both arms came nearer what was paid than written. The record first said that first miss went "toward the candidate"; it went against it, and that is corrected in
7e3b29b.It was adopted the same evening.
rates.DEFCON_PRIOR_WEIGHT = 1.0is pinned to the scorer's frozen number. The sharedprior_weightnow shrinks xG, xA and saves only.backtest_pointsthreads DEFCON's weight as a required tunable of its own,rates_defcon_prior_weight.The checker, the fit and the scorer had all varied DEFCON through
prior_weight. Left alone, each would have kept running and compared a weight with itself, so they now hand their weight to DEFCON's keyword. The checker's report is byte-identical before and after at weights 1 and 4, and the fit reproduces its errors and its choice. Read on 2026/27 GW2-6 first (in-sample, so a check rather than evidence), the projections price 0.92 of what FPL paid, against 0.74 before. On one frozen set of live inputs for GW7-12, the simulator's DEFCON points went from 217.1 to 247.9 for defenders and from 96.5 to 138.1 for midfielders. 22 players gained more than a point over the six rounds and none lost half a point. On the live cache after the go-live (2026-10-17, GW8-13), they went from 217.0 to 248.1 and from 96.9 to 138.1. Again 22 players were up more than a point and none down half a point, and DEFCON's rate was the only rate that moved.Pre-registered before GW7 was played
e14404c, amended before GW7 in2a64fb5) tests each position's 2025/26 card rate against today's zero. It is scored on the first four FINAL rounds from GW7 with a standing snapshot and no double, so around GW10. The amendment is that a test round must be FINAL, ascheck_defcon_calibrationalways required: a provisional round's bonus still moves.f34f7b6) tests the goal and assist split (C-goal-alloc and C-assist together) on what a squad choice asks. When the corrected split and today's order two starters at different positions differently, it asks which order their real points followed. The test uses the first twelve FINAL rounds from GW7, so it runs in early January at the soonest. Each arm is the mean of four seeds. Changingp_assistchanges how many draws the assist step makes, so one seed is one random stream only until that draw: on GW6, 241 of the 713 disagreements at seed 0 were not there at seed 1. A dry run on the played rounds (not the test) read S +0.072, interval −0.051 to +0.192. A FAIL is at least as likely as a PASS.Found, and still open
The Players tab's DEFCON column is a second computation. It, and the card's DEFCON line ("DEFCON GW8–13" today), sum
defcon_ev, priced at each player's average minutes. xPts and the card's own breakdown carry the simulator's DEFCON, priced on the minutes he plays when he plays. When the breakdown moved to the simulator's figures,defcon_evkept the old formula and the page kept reading it. On GW7-12's inputs the column ran 28% under for defenders and 34% for midfielders. 69 players read more than half a point under over the six rounds, and none over: Fofana 0.38 against 2.39, Lerma 0.22 against 1.89. Decisions read xPts, so they are unaffected, but the column misranks rotation players. The fix goes in a page session.Checks
83bdcd5, and 3,198 passed, 64 skipped, 0 failed at2474892(the 14 new tests; the one skip needs the saved bootstrap, and it passed with it).2474892(2026-10-17). That is every test the worktree collected, and the 64 it skipped ran on the real data./api/statusshowed the cache current (projection digestsddc3663534ceat7e3b29bandda6b8caa71f9at2474892), with no faults and no chore errors.restore/pre-go-live-2026-10-17marks88bb0a1, themainthat ran before this. Evidence is inwiki/decisions/calculations-audit-2026-10.md,wiki/notes/defcon-rate-calibration.mdanddocs/testing-notes.md.🤖 Generated with Claude Code
Floodlit, phase 1a: the Squad tab's stage (branch
feat/floodlit, not merged)On 2026-10-17 the user approved a redesign called Floodlit, built from screens they approved one by one: a clean, quiet app with the pitch as its stage. Its principles are few, dense words; detail on demand, attached to what it explains; and layers in one family. The spec, with those screens, is
docs/superpowers/specs/2026-10-17-floodlit-design.md. It changes how the app looks and how much it says, never a number the model produces.Phase 1 is the Squad tab, in two plans that go live together: 1a, the stage (done), and 1b, the band (planned, awaiting the user's review). Branch:
feat/floodlit, off main2474892. Nothing is merged or live.What 1a changed (d873286..e11edcf)
69fad03)./api/playersservesstart_by_gw, each round's chance of starting, frompipeline._start_for_gw: a USER override where one applies, elsestart_prob× the round's availability. The page reads the simulator's number and never multiplies the two fields itself.tests/web/test_start_by_gw.pypins the route to that function by monkeypatching it. The summary view keeps the field.42515d0). These are the popup's layer (--surface-pop,--border-pop), the chance-of-starting scale (--start-75/50/25, ending at--alertat 0%) and the stage's own tokens. Each stop is held to AA on--bgand--surface-1.cbf3bec),frontend/modules/popup.js. It opens on hover, keyboard focus and click (a click pins it) and closes on Escape. Its text goes in bytextContentonly. It flips above its trigger near the foot of the window and points at it. The shirt's owntitles went, so its popup is the explanation. The fixture line's difficulty and provisional-startitles remain, for the cards' own phase.56cc69b). A dot and the percentage appear only under 75%, read on the printed percent (START_FLAG). The colour runs yellow to red and is made from that same printed number. A missing chance reads "?" and is never drawn as a starter.f8af332): name, xPts (keeping itsmagColour) and opponent. The xPts-fifth edge is gone, withBAND_LABEL,evBand,computeGwQuintilesand theGW_QUINTILES/GW_RANGEstate. The card's popup holds the round in one line (xPts · 80% band · starts N%), its part in the plan, a doubt's cause and what the number's colour means. A double round's band is its summary (roundOf).da74deb). The pitch is lit from above and the cards are dark lifted tiles. The captain is a light disc in--text-1(no more gold) and the vice an outline.6a079ee).planView(plan, d)gives the suggested XI from the squad the plan leaves. A bought player carries "for ", and the armband is the one the captaincy route named for that XI. No disc is drawn while it loads or when it is refused, rather than one borrowed from your line-up. "As it stands" is one switch away (setLineupView).planTagswas deleted.5a00d3b): "Bench P2 4.7 · P12 3.0 ● 60% · …". Each name opens his card, and a dash names its cause. Your bench keeps FPL's order; any other bench is the keeper, then the round's xPts. The plan view sharesbenchOrder.935b217) holds the round stepper, the view switch, the formation and the card-line selector. Optimise line-up and Edit team are text buttons at its end; they left the pitch's foot. "Show your line-up" was replaced by the switch.f7270fb,e11edcf). New wiki pagewiki/systems/floodlit-squad-tab-stage.md. Dated notes on the round-plan page, on two CLAUDE.md Frontend entries (the stripe, the gold C) and on a stale CSS comment. Wiki lint is clean.Checks
Full suite in the worktree at
e11edcf: 3,241 passed / 64 skipped / 0 failed (27.6 min, 8 chunks). It collected 3,305 tests against main's 3,262 in the same environment. The +43 is explained file by file (the new files, minus the retired band-label tests, plustest_frontend_parsescollecting the new module). The 64 skips need gitignored data; they run in the live checkout at go-live.Mutation cuts: every task's connections were cut one at a time, and each cut went red on the test written for it. One cut survived: the plan view's bench sort, which now has its test. Two others only looked like survivors:
Two regressions from earlier tasks surfaced only in test files outside those tasks' lists:
--text-2at 4.4:1 on the grass;Both are fixed. Since then every task runs all of
tests/web(about 26 min) before its commit.Final review: a self-review of the whole branch. No Critical or Important findings.
Deferred (minor)
aria-labelsits on a plain span.tests/web/test_compression.py::test_the_compressed_body_is_smaller_and_still_validfails whenevertest_api.pyandtest_player_response_cache.pyrun before it in one process, because it reads the small cache they leave behind;test_squad_tab_money_and_rounds._pagestarts Playwright before itstry/finally, the shape CLAUDE.md warns about.Next
Plan 1b is
docs/superpowers/plans/2026-10-18-floodlit-1b-band.md(356d5c3):It is waiting for the user's review. 1a and 1b go live together: until 1b removes the strip, the strip flags on
start_probwhile the cards mark the round's chance. The go-live involves:routes/players.pychanged;🤖 Generated with Claude Code
Floodlit, phase 1 (1a and 1b): live on
main, pushed with this commentThe Squad tab's redesign went live on this machine on 2026-10-20 at 07:25 Oslo.
mainwas fast-forwarded2474892..b751fbf, the 23 commits offeat/floodlit. 1a, the stage, is described in the comment above (22369). This comment covers 1b, the band and the header, and the go-live. Neither changes a number the model produces.What 1b changed (
ef616ce..b751fbf)ef616ce). Each column says a few words. The rest is in its popup (hover, focus or click) or one tap away in the drawer.Transfers (
e0bc0bd): "Transfers · 2 free · £0.1m", then up to two moves, as "Palmer → Mbeumo +3.7". A move's popup holds:"+N more" opens the other options, and ⓘ opens the method.
XI (
105e198): "43.6 → 47.7", who starts for whom, and the doubts it would field ("1 doubt"), each with its popup.Captain (
6524cba): "Mbeumo · vice Saka", then "close call" when it is one. The popup holds the odds and the gain on your own captain. "best captains" opens the board.Chips (
888bf7d): "from GW20", "none left", "4 left" or "not known". These are values, never advice.b7920c9).tests/web/test_band_facts_reachable.pyhas one case per fact. Each case reads the text a person can reach: the band, every popup, each drawer opened in turn, and Record.a56205b): "GW8 · Fri 23 Oct, 19:30 · 3 days".--alertunder 24 hours.b751fbf).keepFocusmatched the focused control by tag, class and text, and the Transfers' and the Captain's ⓘ share all three. So a keyboard Enter on one opened the other's drawer. The key now includes the drawer the link opens. The test went RED, then GREEN.wiki/systems/floodlit-squad-tab-band.md(cbe2acb; lint clean).0abbb80): Git Bash'ssed -irewrites this checkout's CRLF files as LF. Bulk replaces are now done byte-level in Python, and the claimed invariant is checked on every changed file.Server and caches
The branch changes two backend files:
backend/web/routes/players.py(1a'sstart_by_gw) and one docstring inrules.py. These digests are equal onmainand the branch, so nothing was rebuilt or replayed:da6b8caa71f9);The server was restarted straight after the merge, because the page's files went live with the checkout and the page reads
start_by_gw. All 668 players carry it, for GW8–13.Checks
b751fbf: 3,354 passed / 0 skipped / 0 failed. It ran in 8 chunks, 29 minutes, every chunk's pytest exit 0, matching the count written down before the run.main's 3,262 tests: +92 (1a +43, 1b +48, the fix +1), explained file by file.data-eid, with no console errors.iftook the next statement as its body. Theifis braced now.Deferred (minor)
(p.badges || [])at eight frontend sites reads a missing badge list as no flags. The server always sends the list, and a strict read is one change across all eight.Found while checking the go-live: reproduced, older than floodlit, not yet fixed
The page re-reads the deadline every five minutes, but its projections only on load.
/api/status. The same condition,gw === thisGw(), stood onmainbefore floodlit.MARKET_REBUILD_MIN_AGE) also holds the rebuild that a deadline passing calls for, so the served projections still start at the closed round.Next
Each phase starts with mockups for the user:
Seen since your last comments: #33 and #44
Your brief on #33 and PR #44 have been read. #44 is with the user, who decides on it.
One fact bears on its readiness report: the snapshot archive it found absent exists on the user's machine.
data/pitholds 19 snapshots there, the latest from 2026-10-17, GW7's.scripts/backup_state.py).GW7 is ingested there, provisional until FPL confirms it. GW8's snapshot is due there on Friday, between 14:30 and 17:30 UTC.
🤖 Generated with Claude Code
The round mismatch from the go-live comment: fixed, live on
main, pushed with this commentThis is the "Found while checking the go-live" item from the comment above (23062).
mainwas fast-forwardedb751fbf..c3ba03dat 09:52 Oslo, and the server was restarted at once. The projection digest is unchanged (da6b8caa71f9), as is the follow rule's (1b7e5efffb7a), so nothing was rebuilt, re-scored or replayed.54283e1,frontend/modules/status.js). A status reading that names a build the players did not come from re-reads/api/players, through the quiet path the load already takes. A deadline round that moved redraws, so the pitch follows the header.transfers.js). When the served deadline's round is past the projections' first round, the plan is{closed}./api/best-xi's armband, ranked for the projections' first round, is no longer painted on the next one (squad.js).89f08bb,chores._inputs_plan). A recorded market round that is not the next deadline's skips the hour, which is for odds that move within a round. The status line names both rounds: "the projections start at GW8 and the next deadline is GW9's". With the page's five-minute read, the closed state now lasts minutes.Checks
tests/web/test_page_follows_server_build.pydrives the page as the reader meets it, with the stub server's answers changing while the page is open and the page's own timer reading them (a fake clock). Five went RED, each for the reason it names, then six green. Two more are intest_projection_inputs_watch.py: the deadline case, and a control that a cache as young still waits its hour before the deadline.wait_for_functiontimeouts, which name nothing. The waits now raise anAssertionErrorsaying what did not happen.test_season_phase_labels::test_a_served_deadline_outranks_a_stale_cachepinned the closed round's plan being drawn. Its point, that the served deadline decides the hits rule, holds in the closed state. It reads the rule from the band's head now, with the same assertion./api/status.wiki/notes/page-held-one-read-of-the-projections.md(lint clean), and a CLAUDE.md rule (c3ba03d): two reads with different lifetimes on one screen are two sources of truth.🤖 Generated with Claude Code
A learned chance of starting, registered before GW9 (C-start-model): on
main, pushed with this commentmainwas fast-forwardedc3ba03d..2c7da85. It is scripts, tests and docs only: the projection digest is unchanged (da6b8caa71f9), so nothing was rebuilt, restarted or replayed, and nothing the app shows has moved.Why. The accuracy panel puts our chance of starting ahead of "started last round" but miscalibrated at both ends: players given about 1% started 3.5% of the time, players given about 97% started 92%. Expected points also run about 5% high in total. The working hypothesis is that these are one problem.
What it is. C-start-model is a scikit-learn model of the raw start rate:
HistGradientBoostingClassifier, isotonic calibration on predictions it did not fit, random state 0. It was trained on 2024/25, 2025/26 and 2026/27's GW5-6, with ten features known before the round. It stands in forpriors.resolve_start's raw rate for the length of one index build at a snapshot (scripts/pit_index.index_at). So both arms go through production's own normalisation, availability and play-chance lift, and the raw rate is the only thing compared. No production code changed.The test. Registered in
docs/testing-notes.md, "Pre-registration: C-start-model" (ac981a1, amended before GW9 ata8728fd). The model's SHA-256 and today's estimator's closure digest are frozen inscripts/score_start_challenger.py.data/verdicts/start_challenger.json. Until then the scorer prints UNDETERMINED and computes nothing.Dry run (GW2, 5, 6 and 7: played rounds, never the test; a model fitted on the archive alone): PASS on all four.
Found while building. Each of these passed its own tests first:
--data-root, the registered fit would have had no 2026/27 rows and said nothing.load_archivecannot read it (two files, no DEFCON columns).ProjCache.inputs).Checks.
Records.
wiki/decisions/start-challenger-registered.md(lint clean), and a CLAUDE.md Known-gaps entry with four rules:--data-rootreads every data path through it;🤖 Generated with Claude Code
The DEFCON column reads the simulator's DEFCON (audit defect 7): live on
main, pushed with this commentmainwas fast-forwarded2c7da85..eb9a7d6, the cache rebuilt and the server restarted at 17:24 Oslo.What was wrong. The Players tab's DEFCON column and the player card's "DEFCON GW..." summed
defcon_ev. That field priced DEFCON analytically at each player's average minutes. xPts, the card's own breakdown and Compare carry the simulator's DEFCON, which is priced on the minutes he plays when he plays: a rotation player reaches the threshold in the matches he starts, and at his average minutes he rarely does. So the column ran under for exactly the players whose DEFCON depends on starting, and its sort misranked them. Decisions were unaffected, since they read xPts.On this morning's live cache (GW8-13) the column summed:
85 players read more than half a point under and none over, for example Mainoo 0.48 against 2.92 and Keane 0.14 against 2.30.
The fix (
1376c77):components6.defcon, the sum the breakdown and Compare already read.defcon_evis gone from the projection rows, with the minutes helper only it used (pipeline._defcon_minutesand itsProjectionServicesfield) and the golden master's field for it.backend/,frontend/orscripts/that brings the field back.|| 0, so a row without it would have read as zero. The new page test's first run, on the server's new shape, showed exactly that: 0.0.Checks.
test_absence.pypinning the removed minutes line as source text, and that assertion now checks the line stays gone.Live. After the restart at 17:24 Oslo (the cache built 17:23 at
266bee5cfa31, no faults), read off the live server and the rendered page:components6.defconis the sum of its rows.Digests. The projection digest moved (
da6b8caa71f9->266bee5cfa31), hence the rebuild.priors.py's closure did not move, so the start challenger registered this morning still runs as registered.🤖 Generated with Claude Code
Pull request closed