Squad rail becomes a league-wide captaincy board, star-rated #31

Closed
solvreven wants to merge 0 commits from feat/captaincy-board into main
Owner

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-picks ranks 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 same rank_captaincy the 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.gw now.

What removing the rail cost

Recorded rather than buried, because it is a real loss and the reviewer should weigh it:

  • Nothing on this screen now answers "which week am I weak in". The pitch shows one gameweek at a time, so a 30-point run of 5/5/5/5/5/5 and one of 0/0/0/15/15/0 are indistinguishable in a total and want opposite decisions.
  • Tapping a week to re-solve the pitch is gone. The ‹ › pager still does it.
  • railYourXiHtml was 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 / Shaky now have no caller. Kept rather than deleted — each is still exercised by its own tests and none computes a discredited answer, unlike captainCandidates, 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 rework test_phase_d.py / test_bench_contribution.py if 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_breakpoint was 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:

cut that stayed green why
caption star figure hardcoded the caption also carries xPts, which still moved, so "it changed" passed
a removed rail card comes back railShakyHtml renders nothing for a squad with no shaky player — the check could not fail
gameweek dropped from the heading the rendered heading was never asserted at all

All three now bite, and the third is what surfaced the d.gw defect above.

Verified on the live page, not from the DOM alone: screenshotted and read back.

  • full suite: 1577 passed, 0 failed (pytest exit 0)

🤖 Generated with Claude Code

https://claude.ai/code/session_014adnmRztkdAPwinWXnNQ7N

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-picks` ranks 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 **same `rank_captaincy` the 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.gw` now. ## What removing the rail cost Recorded rather than buried, because it is a real loss and the reviewer should weigh it: - **Nothing on this screen now answers "which week am I weak in".** The pitch shows one gameweek at a time, so a 30-point run of `5/5/5/5/5/5` and one of `0/0/0/15/15/0` are indistinguishable in a total and want opposite decisions. - **Tapping a week to re-solve the pitch is gone.** The ‹ › pager still does it. - **`railYourXiHtml` was 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` / `Shaky` now have **no caller**. Kept rather than deleted — each is still exercised by its own tests and none computes a discredited answer, unlike `captainCandidates`, 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 rework `test_phase_d.py` / `test_bench_contribution.py` if 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_breakpoint` was **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: | cut that stayed green | why | |---|---| | caption star figure hardcoded | the caption also carries xPts, which still moved, so "it changed" passed | | a removed rail card comes back | `railShakyHtml` renders nothing for a squad with no shaky player — the check could not fail | | gameweek dropped from the heading | the rendered heading was never asserted at all | All three now bite, and the third is what surfaced the `d.gw` defect above. Verified on the live page, not from the DOM alone: screenshotted and read back. - full suite: **1577 passed, 0 failed** (pytest exit 0) 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_014adnmRztkdAPwinWXnNQ7N
fix: the asset guard hashed raw bytes, so it could only be green on one platform
All checks were successful
CI / syntax (pull_request) Successful in 8s
CI / tests (pull_request) Successful in 1m7s
CI / wheel (pull_request) Successful in 29s
CI / browser (pull_request) Successful in 16m33s
83111ce2e0
test_asset_version.py hashed the four assets byte-for-byte. These are text
files and the repo has no .gitattributes, so CI checks them out LF on
debian-trixie while a Windows clone with core.autocrlf=true holds CRLF.
The digest therefore differed by platform, and the guard was green only
for whoever recorded it last.

Both sides then re-recorded their own value in turn, each fix correct
locally and each breaking the other:

  1c94db7  135  d20d1fa11d2d9a94  CRLF   green here, RED in CI
  bbf3c01  136  a932c205c61efb9b  LF     green in CI, RED here   (PR #27)
  9c2faca  137  d9ca43b746e10252  CRLF   green here, RED in CI

So main has been red in CI since 9c2faca, and PR #27's asset commit was
not wrong -- it was right for Linux. Neither side could see the other's
failure, because each measured the assets through its own checkout.

A CRLF/LF difference is not an asset change a browser needs a new ?v=
for, so it must not move the digest. Normalising before hashing makes the
platform difference unrepresentable rather than documented; recorded
digest is now the normalised value, identical under both conventions.

Chosen over a .gitattributes entry deliberately: that would leave the
guard's correctness depending on every contributor's checkout config,
which is the disconnected-mechanism shape this repo keeps paying for.

Proved RED on both properties -- the one fixed and the one that must not
break:

  CRLF checkout (Windows)              GREEN
  LF checkout (CI debian-trixie)       GREEN
  app.js changed, ?v= not bumped       RED
  tokens.css changed, ?v= not bumped   RED

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014adnmRztkdAPwinWXnNQ7N
feat: Norsk Tipping is the only book on the board, and it fetches itself
All checks were successful
CI / syntax (pull_request) Successful in 8s
CI / tests (pull_request) Successful in 54s
CI / wheel (pull_request) Successful in 28s
CI / browser (pull_request) Successful in 16m55s
42bb44b18a
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_014adnmRztkdAPwinWXnNQ7N
scripts/fetch_understat.py read FPL elements from data/backfill/bootstrap.json,
a 25 July snapshot carrying 558 elements against 651 live. The 93 missing were
all summer signings, and an unresolved player is skipped with a bare
`continue` -- so the join was silently blindest on exactly the cohort this
file exists to serve, "players whose PL rates are position anchors".

Goretzka is in that gap. CLAUDE.md already records him as one of the five
2026-08-28 transfers a free source had right while our own snapshot did not;
this is the same staleness, one artefact over, and it had been there since
July. The FPL API is open and free, so there was never a reason to read a
stale copy. load_elements() now goes live, with --bootstrap as an explicit
opt-in for tests.

Measured on the tracked model input: foreign_rates.json 281 -> 320 rows,
39 gained and NONE lost. The gains are the right cohort -- Di Gregorio,
Maitland-Niles, Norton-Cuffy, Fernandez-Pardo, all cross-league summer
signings whose PL rate would otherwise be a position anchor.

One surviving player's rates moved when zero should have. Chased rather than
shrugged at: element 316 Emersonn, SAME understat_id 14082, minutes 1517 ->
1607 and games 27 -> 28. Understat revised its own 2025/26 Toulouse data
after the July pull. Not a wrong-identity match; worth knowing that a
"completed" season still moves.

Also, and separately: the season is now an ARGUMENT, and the row label is
DERIVED from it. It was the string literal "2025/26" beside a SEASON
constant of "2025" -- consistent only while nobody moved the constant, which
is precisely what this change does. season_label() derives it, and
test_rows_carry_the_season_they_were_fetched_for drives two seasons through
one code path, because a single season cannot tell a derived label from a
lucky constant.

Adds data/backfill/understat_2026_27.json: 210 players, current-season npxG,
which the FPL API does not publish at all. Not yet consumed -- the
season-choice rule and the chore land next. Banked separately rather than
overwriting foreign_rates, because a 2026/27 measurement is not a 2025/26
measurement.

8 tests. Every guard proved RED, including one of my own that was too weak:
it grepped for BOOTSTRAP_URL in the source and stayed GREEN when the
constant was renamed, over code that would NameError. Replaced with a test
that drives load_elements and reads back the URL it actually requested.

Full suite: 1532 passed, 0 failed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014adnmRztkdAPwinWXnNQ7N
Two seasons of ONE source, never blended. Each row already carries the
`season` it was measured in, so which one won is visible in the row rather
than inferred from which file it came out of -- "carry the season as a
field", which is what this repo asks for instead of averaging a 25/26
measurement with a 26/27 one.

The current season only displaces last season once it clears
CURRENT_SEASON_MIN_MINUTES (450). Verified a TRUE no-op today: 320 rows in,
320 out, byte-identical to foreign_rates.json, because the current season's
maximum is 270 minutes and nothing qualifies. A no-op by construction, not
by luck -- and the switchover branch therefore cannot fire on real data
until roughly GW6, so it is forced with a synthetic fixture. A branch that
has never fired is not a branch.

NO SEASON IS NAMED IN THE CODE. Files are discovered by glob and ordered by
the `season` field inside them. A path like "understat_2026_27.json" written
into a module is the hardcoded-gameweek family with a season in it, and
CLAUDE.md counts fifteen sites of that shape, every guard for it too narrow
by exactly the axis the next instance used. So the guard here is not a grep
for a filename: `test_the_filename_never_decides_the_season` puts the data
in "understat_something_else_entirely.json" and asserts the row still wins
on its own season field, and `test_an_older_file_cannot_overwrite_a_newer_
season` proves the SEASON decides rather than the alphabetical glob order.

Understat's publishing lag, measured rather than assumed: it carries GW1 and
GW2 with GW2's last kickoff 48h ago. One observation bounds the lag at <=48h
and cannot pin it tighter -- which is the argument for keying the coming
chore on COVERAGE rather than on a clock.

5 tests. Four cuts, four REDs: gate removed, comparison removed, comparison
inverted, glob narrowed to a hardcoded season.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014adnmRztkdAPwinWXnNQ7N
feat: the Understat fetch runs itself, keyed on coverage
All checks were successful
CI / syntax (pull_request) Successful in 9s
CI / tests (pull_request) Successful in 1m18s
CI / wheel (pull_request) Successful in 29s
CI / browser (pull_request) Successful in 16m56s
8958166342
Adds `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_014adnmRztkdAPwinWXnNQ7N
docs: record the line-ending guard, the stale-bootstrap join, and FotMob's rejection
All checks were successful
CI / syntax (pull_request) Successful in 8s
CI / tests (pull_request) Successful in 1m8s
CI / wheel (pull_request) Successful in 28s
CI / browser (pull_request) Successful in 17m4s
fcdfba153e
Four findings from 2026-09-02, each written where the next reader will hit it.

THE ASSET GUARD WAS PLATFORM-DEPENDENT and main was red in CI for three days
without anyone being able to see it, because we only ever run the suite on
Windows. Both sides had been re-recording their own platform's digest in
turn -- 135 CRLF, 136 LF, 137 CRLF -- each fix correct locally and each
breaking the other. The repo had already solved this one digest over:
projection_digest normalises line endings and its docstring describes the
same bug. Fixing a defect in one location is not fixing the defect, with the
second location in a different FILE rather than a different line. Recorded
with the general form: "the suite passes" means "passes where I ran it".

THE UNDERSTAT JOIN resolved names against a 25 July bootstrap, 558 elements
against 651 live, silently missing 93 summer signings -- exactly the cohort
that file exists to serve. Recorded as its own category: every guard here
checks whether a VALUE is trustworthy or whether a RUN's scope was
specified, and nothing checked whether the identity side of a JOIN was
current. The rates were impeccable and matched against the wrong squad.

FOTMOB IS REJECTED, measured rather than assumed, with the "no public API"
note from 08-02 corrected (it returns 200 now) and the reason it still does
not earn a place. Includes the correction that matters most: I argued for it
from the 08-28 missed-transfers incident, and that attribution was WRONG --
that was our snapshot being 12h stale, not FPL being behind. A lesson
attributed to the wrong cause recommends the wrong remedy, and this one
nearly bought a scraper for a problem a timestamp already solved.

A COVERAGE CHECK COUNTED THE WRONG COMPETITION -- all five leagues against
an FPL-gameweek denominator, so Ligue 1's extra round kept it permanently
satisfied. The props panel's "20 fixtures" line in a different file.

Also reconciles the fpl-data-sources-recon memory, which still listed
Understat's per-player endpoints as working after CLAUDE.md's 07-31 probe
found all six 404. CLAUDE.md is authoritative; the memory was rewritten
rather than left to be resolved at read time.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014adnmRztkdAPwinWXnNQ7N
feat: the squad rail becomes a league-wide captaincy board, star-rated
All checks were successful
CI / syntax (pull_request) Successful in 9s
CI / tests (pull_request) Successful in 55s
CI / wheel (pull_request) Successful in 28s
CI / browser (pull_request) Successful in 16m29s
dd605e7cda
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."

/api/captain-picks ranks 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 SAME rank_captaincy the 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 of them he
is absent from the board rather than ranked on a substitute. Captaincy
doubles the return, so it must not rest on a constant.

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 and clipped to rating/5. Measured on the live page 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 be confused with the
--mag-* or --fx-* ramps this stylesheet has had one collision over.

TWO DEFECTS FOUND BY LOOKING AT THE RUNNING PAGE, neither visible in code.

The screenshot showed the armband naming B.Fernandes while the board topped
out on Virgil -- the "two captains on one screen" shape a reader caught here
before. Both are correct (your fifteen vs all 406) and Virgil is unowned, but
that is not visible at a glance. The ownership chip makes the two panels
visibly agree instead of apparently contradicting.

And the heading was derived from the PAGER's gameweek rather than 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.gw now.

WHAT LEAVING THE RAIL COST, recorded rather than buried. Nothing on this
screen now answers "which week am I weak in": the pitch shows one gameweek at
a time, so a 30-point run of 5/5/5/5/5/5 and one of 0/0/0/15/15/0 are
indistinguishable in a total and want opposite decisions. Tapping a week to
re-solve the pitch is gone too; the pager still does it. railYourXiHtml was
KEPT on purpose -- it exists because a reader reported the pitch drawing an
XI they never chose, and sweeping it up would have silently un-fixed that.

railOutlookHtml/Lines/Bench/Shaky now have NO CALLER. Kept rather than
deleted: each is still exercised by its own tests and none computes a
discredited answer, unlike captainCandidates 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.

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_breakpoint was
RE-POINTED, not deleted: the responsive rule is unchanged, only the content
it guards.

14 tests. Nine cuts, nine REDs -- but three survived the first pass and each
was a hole in my own tests: the caption assertion passed over a hardcoded
star figure because the xPts half still moved; the rail check could not fail
because railShakyHtml renders nothing for a squad with no shaky player; and
the rendered heading was never asserted at all. All three now bite.

Full suite: 1577 passed, 0 failed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014adnmRztkdAPwinWXnNQ7N
Installer output verbatim, from kristofferopsahl/auto-llm-wiki at f498b6f
(2026-09-08). Re-run install.sh from that repo to refresh the machinery;
wiki content is never overwritten.

- wiki/ — contract (WIKI.md), index, overview, per-entry log/
- .claude/skills/wiki — consult / record / ingest / lint
- .claude/hooks — SessionStart pointer, once-per-session Stop nudge
- .claude/scripts/wiki-lint-checks.sh — deterministic link/index checks
- .claude/settings.json — wires the two hooks (new file; settings.local.json
  untouched)
- .gitignore and CLAUDE.md fragments

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The kit adds a third store of agent-written knowledge beside this file and
auto-memory, and says nothing about precedence. This repo already settled
that question for memory — the document wins, and a contradicting note is
rewritten rather than deferred to at read time — so the same rule is
stated for the wiki, plus: do not copy CLAUDE.md into it.

Kept out of the kit-install commit so it can be dropped independently.
Re-running install.sh does not duplicate the section: its idempotency
check greps for "wiki/WIKI.md", which the first bullet still carries.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The kit's installer runs `chmod +x`, which git on Windows does not record
(core.filemode=false), so both hooks and the lint script were committed
100644. settings.json invokes them by path. Git Bash runs them by shebang
anyway, so every check on this machine passed; a Linux checkout (CI, the
second author) would get EACCES from a hook that fails open, i.e. a wiki
that never announces itself and says nothing about it.

WIKI.md tells agents to put images in wiki/raw/assets/, and `*.png` in
.gitignore dropped them silently: `git add` refused, `git status` showed
nothing. Re-included for that directory only.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
46d52bc said a wiki page contradicting CLAUDE.md "is rewritten or deleted".
This file records being the stale side repeatedly ([CORRECTED 2026-08-14],
[CORRECTED 2026-08-30]), so that rule would have rewritten a correct page to
match stale text — a silent reconciliation. Facts are now settled against the
code, correcting whichever side is wrong; CLAUDE.md wins outright on policy.

Also stated here, because re-running install.sh rewrites .claude/ and would
erase them anywhere else: one home per lesson; no bare measured numbers in
pages (lint dates only git-tracked sources, by day); merges, deletions and
lint judgement calls are reported; ingest spends nothing without the cost
rule; and the Stop hook's "this session changed files" is read from the
whole tree, which the chores keep dirty, so it is often false here and never
overrides stop-and-ask. Its fail-open silence is declared, not evidence.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Both hooks fail open, so a missing script, a command resolving nowhere or a
script git will not execute look exactly like a hook with nothing to say.
The tests run the command strings from .claude/settings.json through the
bash Claude Code uses, with CLAUDE_PROJECT_DIR set as Claude Code sets it:
the pointer is injected with a page count derived from the files, the Stop
hook blocks once on unrecorded work and is silent when wiki/ was touched,
and git records the exec bit on all three scripts.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
It decides "this session changed files" from `git status` over the whole
tree, not from what the session did. The chores keep tracked data files
dirty for days (data/odds/gw3_market_nt.json since 09-03), so it fired on
the first stop of sessions that changed nothing — verified live, where the
model had to answer that the change was pre-existing — and, having used its
one block per session, stayed silent through any real work that followed.
False at the start and absent at the end.

Wiring removed and the script deleted rather than left unconnected. The
SessionStart pointer and the wiki skill stay. The cost, stated in CLAUDE.md:
nothing prompts the record-after step here.

Re-running the kit's install.sh merges the hook straight back, so the wiring
test now asserts its absence too (proven red by actually re-running the
installer), and re-enabling it is a decision rather than a side effect of
refreshing the kit. The Stop-hook behaviour tests went with the hook; the
kit's own suite covers the script.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
/api/best-xi ranked the fifteen by the SIX-WEEK ev_no_bonus total. The
2026-08-30 two-captains fix moved the armband to rank_captaincy for the
gameweek and left this sort on six weeks — fixed in one location and not
the other, inside the fix written for that lesson. Measured the evening
before the GW3 deadline: the XI served scored 39.87 GW3 points against an
optimum of 40.65. Greedy search is kept (it matches the exhaustive optimum
on 20,000 of 20,000 random legal squads); only what it ranks on changed.

- ranks on the round's ev_no_bonus, summed over its rows so a double
  gameweek keeps both fixtures, which is what the pitch prints
- position minima come from rules.XI_BOUNDS, not a copy that said
  midfielders have none
- refuses rather than serve a short side; an unprojected pick is named in
  `unprojected`, never scored zero
- the payload names its `gw`, and the armband ranks on that same value
- squad.json read once, so the XI and your_xi_ev describe one squad
- the pitch and rail say "in GW5" instead of "over GW5-10"

Asset version 142 -> 143.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
adjust.py corrects a 25/26 attribution artefact against the official 25/26
aggregate, a fixed window. build_players_index merged the 26/27 rounds in
FIRST, so from the first finished gameweek every minute just played counted
as evidence the archive was too high. Where official 25/26 minutes were
zero the factor was exactly 0.0: Vuskovic, Rushworth, N.Jackson, Rashford
and Moore went to start_prob 0.000 and out of the candidate pool, and
lineup normalisation handed Rushworth's keeper slot to his back-up.

Across the two caches: 262 players carried ARCHIVE-ADJUSTED before, 1
(Kroupi Jr, genuine) after; the pool 430 -> 435. The scaling now runs on
the archive alone and the 26/27 rounds are appended after it. Recorded in
CLAUDE.md as the 2026-09-03 entry.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Written by the chores scheduler: after_gameweek:3 ingested rounds and
results on 2026-09-12 23:21 Oslo, understat_watch refreshed 26/27, and
nt_watch pulled GW5 (no player markets open yet, 0/10) and pruned the
2026-08-28 run, whose manifest is kept under norsk_tipping/history/.
gw3_market_nt.json is the 2026-09-03 re-resolve against all 10 fixtures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
tests/web/test_compression.py enters the app's lifespan (`with
TestClient(app)`), and the lifespan starts chores.run_forever. A chore is a
subprocess: after_gameweek.py fetches the live FPL API, rewrites the
TRACKED data/current_2026_27/rounds.json and results.json, and rebuilds the
projection cache — and it outlives pytest, which only kills the thread.

Measured 2026-09-12 in a fresh checkout whose data stopped at GW1: the full
suite ingested GW2 and GW3 into those files and wrote data/chores.json,
timestamped as the run finished. Rerun with FPL_CHORES=off: both files
byte-identical, no ledger. In an up-to-date checkout nothing is due, so it
was invisible on the machine anyone works on.

The switch already existed and nothing in the suite set it. It is set in
pytest_configure rather than a fixture because test_compression enters the
lifespan from a MODULE-scoped fixture, which runs before any function-scoped
autouse fixture. The guard enters the lifespan from a module-scoped fixture
too, with run_forever replaced by a recorder, and is proven red both by
removing the switch and by moving it into a function-scoped fixture.

Not changed: the lifespan's autorefresh thread still fetches the live API
during that one test; it writes no tracked file.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
On the live page, the evening GW4 was being played, the Squad tab's transfer
line ended "hits free pre-season" — a string literal, true until the GW1
deadline and false since: past your free transfers each one costs 4 points.

- isPreseason() reads the same next deadline every "which round" question
  reads, falling back to the projection window, and is NULL when neither
  is known; the transfers line then says nothing rather than guessing.
  In season it reads "transfers past your free ones cost 4 pts" (free
  transfers are not synced, so it states the rule, not a count).
- The accuracy kicker said "10 fixtures scored" while accuracy_report
  scores nothing (the 10 are results resolved in the latest snapshot), and
  "awaiting GW1" by hand. It now reads "10 of 20 results in", "N results
  in, none scored", or "N results scored", from the report's own fields.
- Its body said the inputs "do not exist pre-season" and carried two
  commitments dated in August. It now says how far off a score is, keeps
  both commitments without their passed dates (the test guarding them
  still holds), and disappears once any metric carries a value.
- ownershipFreshnessNote, another pre-season sentence, had no caller;
  deleted rather than left for someone to wire back in. Its .own-fresh
  CSS rule is left alone, outside the CSS characterization gate's reach.

Tests drive the real page with ASYMMETRIC payloads (projections for GW5-10)
because the shared stub projects GW1-6 with no deadline, where "pre-season"
is correct and a literal cannot be told from a derivation. Eight cuts, eight
RED, including a served deadline ignored in favour of a stale cache and an
unknown phase reported as pre-season.

Asset version 143 -> 144.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
32 pages, one per incident, decision or subsystem, holding the 38 Known-gaps
entries exactly as they stood at 1e34c2c. The page bodies were assembled by
script from the entry text, never rewritten; each page names the commit that
first recorded its entries and says its numbers are as measured then.

Checked mechanically before anything was written: every entry verbatim on
exactly one page, index complete, links resolve, wiki lint clean.

Also two pages for the fixes of 2026-09-12 (the suite starting the chores,
season-phase labels as literals), and an overview that says what is here and
what is not yet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
CLAUDE.md is loaded into every session and was 120 KB, 85 KB of it Known
gaps: incident narratives, measurement tables and superseded readings
alongside the rules they produced. Each entry is now a stub — its own bold
title, one to three excerpts copied verbatim from the entry, and a link to
its page — so the sentence an agent must act on still arrives every session
and the evidence is one link away. 120,477 -> ~55,000 bytes.

How the stubs were chosen: one agent per batch picked the excerpts, a second
tried to show each stub would let the documented mistake recur, and a script
rejected any excerpt that was not a verbatim substring or had unbalanced
markers. Two verify runs disagreed on one stub (Norsk Tipping); the stricter
version, which keeps "being quoted is close to no filter", was taken.

Checked: CLAUDE.md is byte-identical outside Known gaps and the Wiki section;
every dated pointer into CLAUDE.md from code and tests still resolves (14
checked; three are dates of changes, not entry pointers, reviewed by hand).

The Wiki section now says how lessons are recorded from here on — the rule
here, the evidence there, never the same half twice — and two new entries
record the fixes of 2026-09-12 in that shape.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Reported 2026-09-13: "in the squad tab I don't get the right price for the
players I have sold, and I can't add any new players to my team".

- THE SYNC NEVER RAN. _entry_id read the environment only while three
  places said "set FPL_ENTRY_ID in .env", so a server started without the
  variable synced nothing, silently: the file still held GW2 while GW4 was
  played. It now reads the environment, then .env (FPL_DOTENV names another
  file; the suite points it at os.devnull). Every outcome is recorded and
  served as squad_sync on /api/status and /api/squad — NOT_CONFIGURED,
  FAILED, SYNCED — where exceptions used to go to a key no route read.
- ONE UNIT. /api/squad served FPL's bank in £m (0.8) beside prices in
  tenths; zero agreed in both units until GW4. Every amount is tenths now.
- SELLING PRICES. Purchase prices are replayed from the public ledger
  (history, transfers, picks) and checked against FPL's published bank at
  every gameweek; a disagreement refuses, naming the gameweek. Free Hit
  weeks are skipped and say so, and between a Free Hit deadline and the
  next the base is the squad the chip reverts to. On the real ledger the
  replay gives 0, 0, 8 for GW1, GW2, GW4 — FPL's figures. A sale is
  credited at purchase + half the rise (rounded down), a buy debited at
  today's price; an unpriceable sale refuses the bank with its reason.
- BASE AND PLAN. The sync owns the base (synced_picks, lineup, bank,
  purchase prices); `picks` is the plan. A hand edit keeps its base, a
  re-sync of the same gameweek keeps the plan, a new deadline replaces it
  and records replaced_plan. The file is re-read inside the lock, so a plan
  saved while FPL is being asked is not overwritten. The server and
  scripts/sync_squad.py take one path, fpl_team.sync_squad_file.
- TODAY'S PRICES. /api/players, /api/squad and /api/autocomplete count
  money at the live bootstrap's now_cost, labelled price_source: twelve
  hours after the 2026-09-12 build, 27 players already differed.
- /api/best-xi withholds your locked XI, with the reason, once the plan
  sells a player it started.

Transfers made for the next deadline are not public before it (45 random
entries sampled, none showed a GW5 transfer), so a plan is still made by
hand between deadlines.

Tests: 41 new; 14 backend connections and 5 replay rules cut one at a time,
every one red.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0125so432WFsmNuBsnqes3Z4
On 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_0125so432WFsmNuBsnqes3Z4
- A setting documented in a file nothing reads: an automatic job states
  "not configured" on a served route, and a test reads the setting from
  the file the docs name, through the production entry point.
- A zero agrees in every unit: test unit-carrying boundaries with a
  non-zero value; money is tenths on the wire.
- An automatic sync and a hand edit must not own the same key; a value
  derived from a public ledger is checked against the figure FPL publishes
  and refuses on disagreement, and a skipped check says so.

Evidence in wiki/systems/squad-sync-and-selling-prices.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0125so432WFsmNuBsnqes3Z4
- systems/squad-sync-and-selling-prices.md: the base/plan split, the ledger
  replay checked against FPL's bank (and the unexplained 0.1 in the Free
  Hit week), what the public API cannot show, and the six ways it was
  broken before 2026-09-13.
- concepts/start-probability-and-rates-recency-weighting.md: how minutes,
  start%, xG/xA and points are made, the measured season weight shares
  after GW3 (20-32% for regulars), and which parts of the slowness the
  within-season sweeps defend and which they cannot see.
- notes/season-phase-labels-were-literals.md: the eleven "25/26 minutes"
  copies.
- Crosslinks from current-season ingestion, the chores scheduler and the
  zero-minutes concept; index and log. Lint clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0125so432WFsmNuBsnqes3Z4
Asked 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_0125so432WFsmNuBsnqes3Z4
Rule: score a model on the situation it is used in and against a naive
baseline before tuning it; normalise only over players who can play; the
season-aware start probability is confirmed once on GW4 and reverted on a
miss. The recency entry under Current-season ingestion is corrected: start and
play probability no longer use a single constant.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0125so432WFsmNuBsnqes3Z4
The concept page now describes the estimator as it is after the change — this
season's rounds at h=1.5 with last season as a light prior, availability-
weighted normalisation — with the measurement against the old estimator and a
naive baseline, and what is not settled: the GW4 confirmation, the returning-
injury case, slight over-prediction at the top. Current-season ingestion's
half-life gap is marked corrected. Lint clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0125so432WFsmNuBsnqes3Z4
Asked 2026-09-13: "why don't odds, points, xG, xA just update themselves?" —
and "yes" to both gaps named in the answer.

A. A Norsk Tipping pull resolved gw5_market_nt.json at 11:26 Oslo and nothing
   rebuilt: the market reaches goal rates only at cache build time, so
   /api/status went on reporting FITTED for all ten GW5 fixtures.
B. FPL's availability flags change daily and reach projections only through a
   rebuild; "Refresh team news" simulates nothing.

- build_full records `inputs` on the cache: a digest of the next round's
  market file and of the bootstrap fields a projection reads that change
  between rounds (club, position, status, chance, news, penalty order — not
  price or ownership, which move nightly and change no expected point).
- chores `inputs_rebuild` compares that record with the file and the live
  bootstrap every tick. New odds rebuild once the cache is an hour old; changed
  flags once a day, after 04:00 Oslo; never in a tick that already rebuilds;
  an hour's backoff, doubling, after a failed rebuild. A cache with no record
  is rebuilt rather than trusted. The status line says which, or "projections
  current with GW5 odds and FPL flags".
- main.py hands the scheduler the served cache through get_state(), so a
  rebuild is seen by its mtime.

Verified live: the served cache predated the field, the watch rebuilt it
unprompted within two minutes, the server hot-reloaded it, and GW5's goal
rates moved from FITTED: 10 to MARKET_NT: 10. 17 new tests; nine cuts of the
wiring, nine red. Full suite in a clean worktree: 1631 passed, 58 skipped,
0 failed; no tracked data file touched.

Still only while the server runs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0125so432WFsmNuBsnqes3Z4
A derived artefact records its inputs and is compared with them now; a
producer whose only consumer is a rebuild needs a trigger keyed on its output.
Evidence in wiki/systems/weekly-chores-scheduler.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0125so432WFsmNuBsnqes3Z4
wiki: the inputs watch — projections rebuild on changed odds or flags
All checks were successful
CI / syntax (pull_request) Successful in 5s
CI / tests (pull_request) Successful in 1m10s
CI / wheel (pull_request) Successful in 24s
CI / browser (pull_request) Successful in 17m7s
a3f02fd334
How the cache records what it read, the throttles, the live verification
(FITTED: 10 -> MARKET_NT: 10 for GW5), and that it still runs only while the
server does. Lint clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0125so432WFsmNuBsnqes3Z4
scripts/serve.py starts the server when nothing answers on its port and
starts it again when it exits, waiting longer while it keeps dying at once.
It never starts a second server: uvicorn runs the app's startup before it
binds, so a second server on a busy port would start the chores scheduler
and then fail. scripts/install_autostart.ps1 registers it as a task for
the current user (no admin), with Task Scheduler's three-day limit and
battery stops switched off. Output goes to data/logs/server.log.

Verified live: the task took over from a hand-started server in 11s and
brought a killed server back in 9s. Every assertion proven RED by mutation.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0125so432WFsmNuBsnqes3Z4
Moved out of main.best_xi unchanged, with its reasoning, so that a replay of
the app's own picks chooses lineups with the function the app serves rather
than a copy. The ranking test follows the call into the new module.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0125so432WFsmNuBsnqes3Z4
scripts/backtest_app_vs_entry.py builds the squad the optimiser would have
picked at GW1 from the last snapshot before each deadline, re-picks the XI
and armband with the served functions each round, optionally follows the
top transfer suggestion, and scores it all with FPL's rules.

The scorer is gated: before anything is reported it must reproduce FPL's
published points and substitutions for real entries' own lineups. Two things
the gate caught on its first run, both in its own reading of FPL's data:
Bench Boost rounds still record substitutions, and FPL re-sorts the XI by
position after substituting.

Only measured players enter a squad or a transfer: on 21 August 102 of 478
candidates were on position priors, and /api/autocomplete refuses any pool
holding one. Every assertion proven RED by mutation.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0125so432WFsmNuBsnqes3Z4
Two Known gaps rules. The autostart one says how to restart a supervised
server after a backend change and why never to start a second. The refusal
one records a live 409 from /api/autocomplete, found by replaying the app's
own picks: the guard is right about the player and wrong about its blast
radius. Not fixed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0125so432WFsmNuBsnqes3Z4
wiki: app autostart supervisor, and the app's picks replayed against a real entry
All checks were successful
CI / syntax (pull_request) Successful in 5s
CI / tests (pull_request) Successful in 58s
CI / wheel (pull_request) Successful in 23s
CI / browser (pull_request) Successful in 14m7s
fe6676e85a
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0125so432WFsmNuBsnqes3Z4
wiki: why the replayed GW1 squad had no premium attackers
All checks were successful
CI / syntax (pull_request) Successful in 9s
CI / tests (pull_request) Successful in 1m10s
CI / wheel (pull_request) Successful in 23s
CI / browser (pull_request) Successful in 14m9s
e51738195e
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0125so432WFsmNuBsnqes3Z4
One untrusted candidate made /api/autocomplete answer 409 for the whole
solve (#35): a single position-prior player (Gboho) on the live app, 102 of
478 on 21 August. The route now leaves such a candidate out of the pool and
serves excluded_unmeasured, and the page names who was left out and why. A
LOCKED untrusted player still refuses. The optimiser's own pool guard is
unchanged and still sees only trusted candidates.

Verified on the live app: 200 with Gboho named. Six cuts, six red, including
a note computed and not rendered.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0125so432WFsmNuBsnqes3Z4
The Known gaps entry is marked fixed (a3346e3), and it names what is still
open: the page locks every pick, so one pick with no projection refuses the
whole request.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0125so432WFsmNuBsnqes3Z4
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0125so432WFsmNuBsnqes3Z4
The chores scheduler replays the app's picks (scripts/backtest_app_vs_entry.py)
once FPL has finished and checked a round the newest replay for the team does
not reach, keyed on the artefact's content rather than a ledger marker, with
a backoff on failure. /api/accuracy serves it with a state whether or not it
exists, and the Forecast accuracy panel shows each round and the totals, the
snapshots it was decided from, and the code it ran on.

The entry id resolver moved to fpl_team.entry_id_setting so the scheduler
reads the one definition the server does. The replay shares one rule for
'finished and checked'. The chores runner now decodes child output as UTF-8:
the code page could not decode a name like Kovačić, which killed the reader
thread and dropped a failed chore's reason.

Live: the panel reads GW1-3, you 214, app 169 kept / 178 with tips, average
182, and the scheduler says it covers GW3. 16 cuts, 16 red.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0125so432WFsmNuBsnqes3Z4
score_goalscorer.py now scores the simulator's p_score_sim, divided by the
appearance probability the simulator was given, from the snapshot taken
before the deadline. Three things had stopped it measuring anything since
GW2: it required the Odds API consensus, retired on 31 August, so every later
round refused; it read the NEWEST Norsk Tipping run, which for a finished
round is the next round's coupon (GW3 was scored against GW5's, priced 0);
and it never scored the model at all.

The consensus is optional and stated when missing. The NT run is the one
frozen in the snapshot, or named with --nt-run. Snapshot choice moved to
backend/web/pit.py, shared with the app-vs-entry replay. Eight cuts, eight red.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0125so432WFsmNuBsnqes3Z4
The GW4 Norsk Tipping run refused whole on one unmatched name; 'Chema' is
the Spanish short form of José María, which no rule bridges. GW3 is scored
from the run frozen before its deadline (nt_20260902T154438Z) and the model
from snapshot 20260828T193505Z.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0125so432WFsmNuBsnqes3Z4
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0125so432WFsmNuBsnqes3Z4
wiki: model v market on GW3, the scoreboard job, and the runner decoding fix
Some checks failed
CI / syntax (pull_request) Successful in 9s
CI / tests (pull_request) Successful in 58s
CI / wheel (pull_request) Successful in 24s
CI / browser (pull_request) Failing after 17m2s
c56b76d2ce
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0125so432WFsmNuBsnqes3Z4
The board divided each anytime price by the THREE-WAY market's margin, and
scored against who actually scored the result ran 1.8x (GW2) and 2.4x (GW3)
the realised rate. Each team's priced players are now scaled so their
appearance-weighted intensities add up to the goals the simulator was given
for that team (market_scale.team_scaled). The three-way figure is kept beside
it, labelled; teams that cannot be corrected are named with the reason; the
Matches note reads the scale off the served rows.

Measured first, pre-registered: on GW3 the correction took the market's mean
forecast from 0.176 to 0.103 (in a round that scored 0.77x its expected
goals) and its log loss from 0.2878 to 0.2475, tying the model's 0.2457. The
scorer now scores NT_TEAM beside NT and MODEL every round. Eight cuts, eight
red, including one that survived the first version of a test.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0125so432WFsmNuBsnqes3Z4
A log loss is only comparable within a round. The consensus, scored in GW2
only where it beat the control (0.2925 v 0.3108), was called BROKEN against
the control's mean over GW2 and a lower-scoring GW3 it was never scored in.
Each source is now judged against the control on its own rounds, and two
sources are ranked on the rounds they share. Both new tests red on the old
code.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0125so432WFsmNuBsnqes3Z4
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0125so432WFsmNuBsnqes3Z4
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0125so432WFsmNuBsnqes3Z4
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0125so432WFsmNuBsnqes3Z4
wiki: the correction note no longer says it is unbuilt
Some checks failed
CI / syntax (pull_request) Successful in 5s
CI / tests (pull_request) Successful in 57s
CI / wheel (pull_request) Successful in 29s
CI / browser (pull_request) Failing after 14m17s
47e00102a8
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0125so432WFsmNuBsnqes3Z4
The page sends every current pick as a lock, so one player below the
simulation threshold (the manager's own 4.0 third keeper) made Complete my
squad answer 409 for good. He now stays in the fifteen with no value: his
ev is None, never 0.0, and the solver bars a None from the XI and gives his
bench slot no credit, so projected players come on first. The response
names him in kept_unprojected and the page says so; a lineup that could only
be legal by starting him refuses. A locked untrusted pick still refuses.

Verified on the live app with all fifteen picks locked: 200, 3-5-2, Palmer
kept on the bench as 'no projection — not valued'. Six cuts, six red.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0125so432WFsmNuBsnqes3Z4
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0125so432WFsmNuBsnqes3Z4
wiki: the entry's own squad completes with its unprojected keeper kept
Some checks failed
CI / syntax (pull_request) Successful in 5s
CI / tests (pull_request) Successful in 58s
CI / wheel (pull_request) Successful in 29s
CI / browser (pull_request) Failing after 14m20s
2e345f8375
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0125so432WFsmNuBsnqes3Z4
Declared before it was first scored and never fitted: the average of the two
scoring intensities. On GW3 it had the lowest log loss (0.2449, against the
model's 0.2457 and the corrected market's 0.2475), inside the noise of one
round. Scored every round so four can decide whether it enters the
projections. GW3 re-banked with it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0125so432WFsmNuBsnqes3Z4
wiki: the fixed blend of model and corrected market, scored on GW3
All checks were successful
CI / syntax (pull_request) Successful in 5s
CI / tests (pull_request) Successful in 58s
CI / wheel (pull_request) Successful in 25s
CI / browser (pull_request) Successful in 14m22s
f477447f20
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0125so432WFsmNuBsnqes3Z4
GW3 was decided from the snapshot taken on 28 August, after GW2's deadline
and before GW2 was played: the newest one before GW3's deadline, so every
check passed. The app had not been running in the window before that
deadline, the chores ledger recorded GW3 as covered, and the replay and the
goalscorer scorer both measured a model that did not know GW2.

pit.choose_snapshot now also requires, from the second round on, that the
snapshot froze the previous round as ingested and was built after that
round's last kickoff (SNAPSHOT_RULE 2). The chores scheduler asks the same
rule instead of its own copy, re-judges OKs it read off data/pit under an
older rule, and states an unreadable snapshot without skipping the tick. The
replay leaves a round no snapshot stands for out of every total, the entry's
included, and the panel shows it in place as not scored.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0125so432WFsmNuBsnqes3Z4
No snapshot can stand for GW3, so the model, NT_TEAM and the blend are not
scored for it: 268 appearances, NT 0.2845 against the base rate's 0.2462.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0125so432WFsmNuBsnqes3Z4
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0125so432WFsmNuBsnqes3Z4
wiki: why GW3 and GW4 are not scored, and what the snapshot rule changed
Some checks failed
CI / syntax (pull_request) Successful in 5s
CI / wheel (pull_request) Has been cancelled
CI / browser (pull_request) Has been cancelled
CI / tests (pull_request) Has been cancelled
b78690a0ba
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0125so432WFsmNuBsnqes3Z4
wiki: three pages re-checked after the snapshot rule, dates moved
Some checks failed
CI / syntax (pull_request) Successful in 5s
CI / wheel (pull_request) Has been cancelled
CI / browser (pull_request) Has been cancelled
CI / tests (pull_request) Has been cancelled
1806ccb66a
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0125so432WFsmNuBsnqes3Z4
wiki: pages whose sources moved with the snapshot rule re-checked
All checks were successful
CI / syntax (pull_request) Successful in 8s
CI / tests (pull_request) Successful in 59s
CI / wheel (pull_request) Successful in 28s
CI / browser (pull_request) Successful in 14m29s
211e951fd7
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0125so432WFsmNuBsnqes3Z4
The server's stdout is the log file, which Python block-buffers, and the
chores print a failed job's reason from a thread. Every restart kills the
process and the buffer with it: data/logs/server.log held uvicorn's access
lines and not one chores line, so two failed Norsk Tipping pulls on
2026-09-14 left no reason anywhere. The supervisor runs the server with -u.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0125so432WFsmNuBsnqes3Z4
Nothing ran in the three hours before the GW3 or GW4 deadline: the PC
sleeps after fifteen minutes idle and the scheduler lives in the server.
scripts/deadline_wake.py arm registers a Windows task that wakes the PC
2h45m before the deadline and reads it back, succeeding only on what Task
Scheduler holds. The task runs hold, which keeps Windows awake, starts the
app if nothing answers, and lets the PC sleep once a snapshot from the
pre-deadline window stands for the round or the deadline has passed. The
chores scheduler arms it whenever the next deadline changes, and daily.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0125so432WFsmNuBsnqes3Z4
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0125so432WFsmNuBsnqes3Z4
wiki: waking the PC for a deadline, and the unbuffered server log
All checks were successful
CI / syntax (pull_request) Successful in 9s
CI / tests (pull_request) Successful in 1m11s
CI / wheel (pull_request) Successful in 23s
CI / browser (pull_request) Successful in 14m25s
ebf5c0a646
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0125so432WFsmNuBsnqes3Z4
With GW4 under way only Leeds v Newcastle was on Norsk Tipping's coupon, and
the board said four things that were not true:

- "No book priced Haaland, Isak, Mbeumo ... unlikely to feature", about
  players in matches already played. A match off the coupon is a third cause:
  nt_load names every team on the coupon, and a player is declined only when
  his match is on it. The note names the matches off the coupon and whether
  they kicked off.
- The assist column headed Norsk Tipping showed our numbers for everyone the
  book had not priced. It shows the book's number or nothing, and assisters
  are ranked by the book's price first, then ours.
- Cards with nothing compared said "agree" and were counted as fixtures that
  agree. They say "no price" or "not compared", and the count is over the
  compared ones.
- Both columns claimed "given he starts, since the bet voids if he does not".
  The bet is void only if he does not take part. Ours is now the simulator's
  P(scores | plays) for a round the projections cover, and the gap marker and
  the Squad tab's disputed-rate flag use it; for a round they do not cover,
  ours is labelled given he starts, nothing is compared, and the payload says
  why.

The tab still shows the round being played, as asked on 2026-08-29.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0125so432WFsmNuBsnqes3Z4
The Goals scored hint was a literal crediting twenty books, two weeks after
the board went Norsk Tipping only; it is cut from the source the market
payload states. The closed Forecast accuracy panel read "30 results in,
none scored" with the scoreboard inside it scored; its headline now starts
with your points against the app's, and says the forecasts are not yet
scored.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0125so432WFsmNuBsnqes3Z4
At 400px the header stood 249px tall with its status squeezed into a column
and pushed the page 220px wider than the screen, so the app scrolled sideways
and "Rebuild live data" sat off the edge; it still overflowed at 560px.
Below 640px it wraps into full-width lines and scrolls away with the page.
Nothing in it is hidden. The same rule went into the render gate's frozen
stylesheet, as an intended design change.

The warning kept every missed deadline for the rest of the season and said
"the app was closed" about two rounds the PC slept through. It names only the
latest deadline, clears once a later one is frozen, and says what the ledger
knows: nothing was running before it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0125so432WFsmNuBsnqes3Z4
The one measure a change to how the app decides must move sat at the bottom
of the Fixtures tab inside a closed panel. The Squad line now carries the same
totals from the same payload, repainted when the accuracy report lands, and
the segment opens the panel.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0125so432WFsmNuBsnqes3Z4
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0125so432WFsmNuBsnqes3Z4
wiki: the Matches board with a partial coupon, and the phone header and scoreboard pass
Some checks failed
CI / syntax (pull_request) Successful in 9s
CI / tests (pull_request) Successful in 58s
CI / wheel (pull_request) Successful in 28s
CI / browser (pull_request) Failing after 14m55s
46fad2188f
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0125so432WFsmNuBsnqes3Z4
The server fitted team strengths once, at startup, and the FDR grid, the
team strength panel and the Matches cards' own fit read that fit, while the
chores rebuild the projections in a subprocess after every round. With GW4
ingested the xPts used forty matches and the FDR thirty until a restart.

The live refresher now refits when the results file's content changes, through
pipeline.strengths_deps, the one function the startup path also uses. A
failed refit keeps the old fit and says so once. The team strength panel says
which rounds the ratings stand on (fitted on 40 matches, GW1-4).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0125so432WFsmNuBsnqes3Z4
Same protocol as the time-decay measurement: refit before every gameweek,
choose the xG weight on GW8-25, report it once on GW26-38, test the paired
difference against goals alone. Team xG is the sum of FPL's per-player xG in
the fixture, and a finished match without player rows refuses.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0125so432WFsmNuBsnqes3Z4
The season-aware start probability passed its pre-registered GW4 bar (Brier
new 0.1134, old 0.1577, LAST 0.1075) and stays. A 50/50 goals/xG blend in the
strength fit scored 0.2115 against 0.2156 walk-forward on 25/26 and is not
adopted, because its own rule failed narrowly; the chosen weight is to be run
once on an unseen season. Team strengths in the running server now follow the
results they are fitted on.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0125so432WFsmNuBsnqes3Z4
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0125so432WFsmNuBsnqes3Z4
test: the season-note wiring test follows the notes into strengths_deps
Some checks failed
CI / syntax (pull_request) Successful in 8s
CI / tests (pull_request) Successful in 1m10s
CI / wheel (pull_request) Successful in 25s
CI / browser (pull_request) Failing after 15m7s
c9288c09fb
The notes moved with the fit into pipeline.strengths_deps, so the test that
grepped build_deps_only for them went red on the full suite. It now holds
both links: strengths_deps publishes the notes, and build_deps_only spreads
what it returns. Both proved red.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0125so432WFsmNuBsnqes3Z4
--confirm-weight scores a fixed weight against goals alone on the season it
is given, selecting nothing there, against a bar written into the
pre-registration before the data was downloaded: positive, with the 95% CI
lower bound above -0.001. The public 24/25 fixtures and gameweek files are
committed beside the 25/26 ones, as the input the confirmation reads.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0125so432WFsmNuBsnqes3Z4
On a season never used before, the 50/50 goals/xG blend scored +0.0092
against goals alone (95% CI [+0.0048, +0.0135], 310 matches), passing the bar
written before the data was downloaded. It is not in the production fit yet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0125so432WFsmNuBsnqes3Z4
wiki: the xG weight confirmed on 24/25
Some checks failed
CI / syntax (pull_request) Successful in 8s
CI / tests (pull_request) Successful in 1m10s
CI / wheel (pull_request) Successful in 26s
CI / browser (pull_request) Failing after 18m17s
81862f114f
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0125so432WFsmNuBsnqes3Z4
Both Dixon-Coles fits now read, per side, XG_WEIGHT * team xG + (1 - XG_WEIGHT)
* goals. The weight (0.5) was chosen on 25/26 GW8-25 and passed a one-shot
confirmation on 24/25 (+0.0092 RPS, 95% CI [+0.0048, +0.0135]). Attack, defence
and home advantage come from the blend; rho still comes from a goals fit,
because the low-score term needs integer scores.

- Archive: team xG is summed per fixture and side from merged_gw.csv, whose
  fixture/was_home/id columns the loader now requires. A finished match with
  no player rows on a side refuses.
- 26/27: fetch_current_season.py writes fixture, home_xg and away_xg into
  results.json. xG goes on the side the player PLAYED ON: the fixture's bps
  list, else the player's element-summary history (0 bps, double gameweeks).
  It never uses a round row's `team`, which is today's club. A match that
  cannot be attributed carries xg_missing and is counted on goals, named
  in the season notes.
- The strength kicker says how a match is counted, from the served fit.

A test fits the real 25/26 archive through production and through the
backtest's loader, and the two agree to 1e-4. 19 mutations, all caught.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0125so432WFsmNuBsnqes3Z4
It is derived from the fit and grows with it: the rounds the ratings stand on,
how a match is counted, home advantage and rho. With `.panel-kicker`'s nowrap
it ran 119px past its 376px card at every desktop width once the fit span was
added, and 229px with the xG weight. Its width also dragged the strength
table past the card edge. `#strength-kicker` now wraps; both stylesheets carry
the rule (an intended design change for the render gate).

A Playwright test measures the kicker's right edge against the bordered card
at 1280 and 1920px with the longest text the fit can produce, and fails by
681px without the rule.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0125so432WFsmNuBsnqes3Z4
The weight was chosen and confirmed on match outcomes (RPS). A uniform shift
in the goal level barely moves an outcome probability, but FPL points read
goal counts, and on 26/27 GW1-4 the blend raised the league's expected goals
by 3%. scripts/backtest_goals_xg.py runs the same walk-forward and scores the
goal level, the score log-likelihood and clean-sheet Brier, using production's
blend and weight. Seven mutations of its arithmetic and wiring, all caught.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0125so432WFsmNuBsnqes3Z4
results.json now carries fixture, home_xg and away_xg for all 40 matches of
GW1-4 (11 player histories read; none unattributed). An independent sum over
all 275 players' histories agreed to 0.00. rounds.json is the chores' GW4
ingest, re-fetched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0125so432WFsmNuBsnqes3Z4
CLAUDE.md's validation paragraph now says the 50/50 goals/xG weight is in
production (cd41d2e), with a Known gaps rule: team xG goes on the side the
player played on, never a round row's `team`. A score that cannot see a
quantity cannot clear a change to it, so the weight was scored on goal counts
too. docs/testing-notes.md records the ingest's check against every player's
history, the goal-count table against its pre-registration (all three right),
and what moved in the app.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0125so432WFsmNuBsnqes3Z4
wiki: the xG weight adopted, and how team xG is attributed
Some checks failed
CI / syntax (pull_request) Successful in 9s
CI / tests (pull_request) Successful in 1m12s
CI / wheel (pull_request) Successful in 25s
CI / browser (pull_request) Failing after 18m16s
02ed8c1e9d
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0125so432WFsmNuBsnqes3Z4
The run stamp was cut down with a regex and its Z dropped, so a pull made
at 13:54 in Oslo read "pulled 15.09 11:54", a few lines below kickoffs
printed in Oslo time. It is read as the UTC instant it names and printed in
the kickoffs' zone, whatever clock the viewer keeps. A stamp that names no
zone is shown as it came.

Proven RED on the unchanged code and against four cuts: the viewer's clock,
the raw stamp, a fixed UTC+2, and converting a zoneless stamp.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
It said "the 80% band below is always shown". The band left the table rows
on 2026-07-31 with the per-gameweek columns (39f2405), which did not mention
it, and the hint and the comment above PLAYER_METRICS went on describing it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
With all forty matches of GW1-4 played, the closed panel said "30 results
in". The count was read from the latest point-in-time snapshot, which is
taken before a deadline and holds only the rounds finished before it.
Results are outcomes, not inputs to a decision, so nothing is protected by
reading them frozen; the predictions they are scored against still come
from the snapshots.

accuracy_report takes the fixtures the live refresher keeps current. With
none it counts nothing and every metric says why, and the page says
"results not counted" rather than "null of 20 results in".

test_every_metric_refuses_with_a_reason_while_under_the_minimum is now fed
below the minimum: it read the snapshot and skipped itself at twenty
results, so it had asserted nothing since the 2026-09-13 snapshot, the first
to hold more than ten.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
It printed "0 Championship-mapped" over a fit whose three promoted clubs had
each been refitted from a Championship-mapped prior: true of the count,
false of the impression. The count is shown when a club's strength is the
mapping itself; the prior stays named in the engine notes, beside the 25/26
prior every other club starts from. Frontend only, so the projection digest
does not move.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A sweep of the live page at 1366, 1600 and 1920 px found four texts that
read as correct and were not, with the suite green: a UTC stamp shown as
Oslo time, a result count read from the newest snapshot, a hint describing
a band the table lost in July, and a true zero that misled. The rules are
recorded here; the evidence is in wiki/notes/desktop-sweep-four-texts-that-looked-right.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
wiki: four texts that looked right, and results counted from FPL's fixtures
Some checks failed
CI / syntax (pull_request) Successful in 6s
CI / tests (pull_request) Successful in 1m3s
CI / wheel (pull_request) Successful in 25s
CI / browser (pull_request) Failing after 18m42s
4f58333203
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
fix: move now or wait a week gives both plans the same transfers
Some checks failed
CI / syntax (pull_request) Successful in 6s
CI / tests (pull_request) Successful in 1m2s
CI / wheel (pull_request) Successful in 29s
CI / browser (pull_request) Failing after 15m41s
128cf927ea
The Squad tab compared "one transfer now" with "two next week" and said
"banking wins by 2.3" the day before the GW5 deadline. Using a free
transfer now still leaves one for next week, so the plan that moves now
makes the same second move a week later. It was credited with one move
and the banked plan with two.

Both plans hold the same squad from next week on, so they differ by the
first move's gain in the window's first round. On the live numbers:
Konsa -> Botman now and Semenyo -> Le Fee next week +8.1, both next week
+6.6. Moving now wins by 1.4, what Konsa -> Botman earns in GW5.

Four tests, seven mutations each caught; full suite green (1950).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
chore: ignore .worktrees/ for isolated feature branches
Some checks failed
CI / syntax (pull_request) Successful in 6s
CI / tests (pull_request) Successful in 1m1s
CI / wheel (pull_request) Successful in 24s
CI / browser (pull_request) Failing after 18m52s
b524e0c8c3
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
availability_by_gw took the first key of gw_dates as "the next round".
gw_dates is the whole season's calendar, so that key is GW1 whatever the
date: from GW1's deadline on, every doubt and suspension was placed on a
round already played and the round being decided read 1.0. On the live
cache before the GW5 deadline, 22 doubtful and 4 suspended players were
projected at full availability, among them Joao Pedro (75%) in the
reader's XI and Foden (suspended) at 3.6 xPts.

The next round is now passed in, derived once in build_players_index from
projection_window (keyed on the deadline) and shared with the lineup
normalisation that already used it. A flag about the next round refuses
when that round is unknown or has no fixtures, rather than landing on
another one.

The stubs in test_absence.py could not see this: their calendar started at
the round the flag was about. The new tests use a mid-season calendar and
drive build_players_index; each of four cuts was checked RED.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
absence.py said FPL does not publish a ban's length and zeroed the next
round only. FPL states it in the news: all four bans flagged on 2026-09-18
read "Suspended until <date>". Foden, until 17 Oct, was back at full
availability for GW6 on 10 October. The date is now parsed like "Expected
back": every round before it is zero. A ban with no stated end keeps the
next-round-only reading.

Checked RED with the regex cut and with the old branch restored.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
FPL'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>
_availability_with_suspension handed ban_probability_by_gw the whole
season's calendar, and the DP books a card in every round it is handed:
from GW1 it booked cards in rounds already played, whose cards are already
in the yellow count, and landed their bans on the next round. Calvert-Lewin,
status "a" with no news, read 0.872 for GW5 on 2026-09-18; no card still to
come can ban GW5. The DP now starts at the next round (the same next_gw the
flags use) and played rounds carry no risk; without a next round it refuses.

The comment beside it said the gameweeks came from the projection window.
They came from the calendar, which is how this survived review: the same
misreading as the flags fixed in 592d8da, one call further down.

Checked RED with the calendar handed back, with the build's hand-over cut,
and with the refusal removed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The rule from the flags that landed on GW1 for four rounds: name which of
the season's calendar, the rounds still to play and the window a function
is handed; test a "next round" on a mid-season calendar; check a comment's
claim about a source against the source. Evidence in
wiki/notes/availability-flags-landed-on-gw1.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
wiki: FPL's flags landed on GW1 for four rounds
Some checks failed
CI / syntax (pull_request) Successful in 9s
CI / tests (pull_request) Successful in 1m2s
CI / wheel (pull_request) Successful in 29s
CI / browser (pull_request) Failing after 15m41s
5a0f199a23
The incident, the before/after measurements on the live cache, why the
stubs could not see it, the four fixes, what the lineup normalisation
moved, and two warts left standing (departed players shifted up with their
club; in-process rebuilds losing the provenance stamp in memory). The
start-probability page notes that the 2026-09-13 weighting score predates
the fix. Three pages re-dated after re-reading; seven stale behind the
09-17 frontend commit left for feat/round-plan, which re-dates them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
GW4 was scored on 2026-09-17 from the only Norsk Tipping run near its
deadline, pulled 8.9 hours after it with Saturday's fixtures off the coupon.
Its pre-registration said the round would not count; score_history.verdict
pooled every banked round, so it did. The same check found GW2's run pulled
2.7 hours after its deadline.

The scorer now records nt_pulled_at, read from the run's own manifest, and
the deadline with every round, and prints when a run was pulled after it.
The verdict leaves out a round priced after its deadline or one that does
not record when (fails closed), and names each round it leaves out.

Checked RED five ways: the filter cut, a late market not recognised,
missing provenance failing open, the left-out rounds not named, and the
scorer banking no pull time.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ZreSMN6hgmFsbLs8MchV8
GW4 (NT and BASE_RATE only, 82 appearances, three fixtures) re-banked by
its pre-registered command with the fixed scorer; the numbers are the ones
first scored on 2026-09-17. GW2's and GW3's records gain nt_pulled_at and
deadline: the pull time from each run's manifest (GW2's pruned run is kept
whole in data/odds/norsk_tipping/history/), the deadline from FPL's events,
checked against the stamp in the run name. Their scores are untouched.

Verdict: BROKEN on GW3 alone (NT 0.2845 v 0.2462); GW2 and GW4 left out.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ZreSMN6hgmFsbLs8MchV8
The pre-registration, written 2026-09-17 before the run, and its result:
four of six held or half held; misses 3-5 are one high-scoring three-fixture
round; miss 6 is the defect that let the round count. CLAUDE.md records that
the pre-deadline rule for a scored round is now enforced: a pre-registration
that says a round does not count needs a guard that makes it not count.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ZreSMN6hgmFsbLs8MchV8
Evidence for the 2026-09-18 enforcement: the three banked rounds with their
pull times and deadlines, what the verdict counted before and after, and
why NT_TEAM on GW5 is the first judgement that can count.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ZreSMN6hgmFsbLs8MchV8
wiki: two pages say which scored rounds now count
Some checks failed
CI / syntax (pull_request) Successful in 6s
CI / tests (pull_request) Successful in 1m13s
CI / wheel (pull_request) Successful in 24s
CI / browser (pull_request) Failing after 18m43s
b1a3f15132
The bookmaker-coverage decision and the snapshot rule page cited GW2's
run and the scorer; each now says GW2's and GW4's markets were priced after
their deadlines and no longer count, and GW3's does. Re-dated after
re-reading.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ZreSMN6hgmFsbLs8MchV8
ci: run on debian-latest
Some checks failed
CI / syntax (pull_request) Successful in 11s
CI / tests (pull_request) Successful in 1m49s
CI / wheel (pull_request) Successful in 28s
CI / browser (pull_request) Failing after 18m3s
eaa63ade39
Same label swap as on the default branch: the debian-trixie-<stack> runner
labels are being removed; debian-latest is the consolidated debian-ci image.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
fix: the real-fit strengths test read a gitignored snapshot
Some checks failed
CI / syntax (pull_request) Successful in 9s
CI / tests (pull_request) Successful in 1m12s
CI / wheel (pull_request) Successful in 29s
CI / browser (pull_request) Failing after 18m14s
34bffd8d1b
test_the_real_fit_reads_the_results_it_is_given loaded its clubs from
data/pit/20260913T180117Z/bootstrap.json. data/pit/ is gitignored, so the
test passed on the machine that took that snapshot and raised
FileNotFoundError on every clean checkout: it failed in nine of PR #31's
browser runs, and was the only failure in four of them.

The clubs now come from the committed 2025/26 archive the test already
loads. The fit is still unstubbed, and still proved to bite: with
strengths_deps ignoring results_path the test fails, reading the real
40-match file instead of the 1-match one it was given.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Merge PR #39: the real-fit strengths test read a gitignored snapshot
Some checks failed
CI / syntax (pull_request) Successful in 8s
CI / tests (pull_request) Successful in 1m13s
CI / wheel (pull_request) Successful in 28s
CI / browser (pull_request) Has been cancelled
e824f6cac6
Merge branch 'main' into feat/captaincy-board
All checks were successful
CI / syntax (pull_request) Successful in 10s
CI / tests (pull_request) Successful in 1m14s
CI / wheel (pull_request) Successful in 29s
183dafc97d
Resolves the conflicts from #38: ci.yml takes main's workflow (this branch's only change there was runs-on: debian-latest), and tests/conftest.py keeps this branch's version, which is main's cherry-picked chores guard plus the FPL_DOTENV lines from b857f75.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
fix: a player on position rate priors can sit on the bench, never start
All checks were successful
CI / syntax (pull_request) Successful in 9s
CI / tests (pull_request) Successful in 1m13s
CI / wheel (pull_request) Successful in 28s
b3c5534829
#32. require_measured_rates_in_xi stated the rule and was unit-tested,
and nothing called it; the autocomplete candidates did not even carry
rates_source. _require_trusted_pool checks availability provenance
only, so a player with a trusted start and position-anchor xG/xA could
be put in the XI by "Complete my squad".

As the issue proposed, it is a bound rather than a refusal: _solve fixes
a prior-rated candidate's XI variable at zero, so he can be bought for
the bench but cannot start. The route now passes rates_source, and
autocomplete checks every XI it returns with require_measured_rates_in_xi,
which finally has a caller. One predicate, _on_rate_priors, serves both.

Three tests, one per connection; cutting the route field, the bound or
the post-check each turns its own test RED. The issue counted no such
player with start_prob >= 0.05 on 2026-09-13, so squads built from that
cache would not change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Merge PR #40: a player on position rate priors can sit on the bench, never start
All checks were successful
CI / syntax (pull_request) Successful in 9s
CI / tests (pull_request) Successful in 1m12s
CI / wheel (pull_request) Successful in 28s
82397abf7a
At 400px the pager row -- the week stepper, the XI total, the formation
and the card-line picker -- ran 10px past the screen, so the whole page
scrolled sideways and test_header_narrow[400] failed on every run of #31.
`.gw-nav` was a flex row with no wrap. It wraps now, in its one rule
rather than an appended override; the baseline stylesheet carries the
same declaration, as an intended change for the render gate. Assets move
to ?v=169.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
fix: the second transfer is club-capped against the squad the first one left
All checks were successful
CI / syntax (pull_request) Successful in 9s
CI / tests (pull_request) Successful in 1m13s
CI / wheel (pull_request) Successful in 28s
8e59d231e4
#34, item 2. bestSwapFrom took the club cap from clubCounts(), which
counts SQUAD.picks -- the squad before either move. Searching for the
second move it still counted the player the first move sold and missed
the one it bought. So "one now vs two next week" could propose a fourth
player from one club, and could refuse a legal buy from a club the first
move had just made room in.

clubCounts takes the squad to count, defaulting to SQUAD.picks for the
single-move caller, and bestSwapFrom passes the squad it is searching.
Two tests, one per direction, each RED before the change: the pair
bought a fourth club-7 player (4 against a cap of 3), and it bought S16
instead of the legal S28. Assets move to ?v=170.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Merge PR #41: the gameweek pager wraps on a phone; the second transfer respects the club cap
All checks were successful
CI / syntax (pull_request) Successful in 8s
CI / tests (pull_request) Successful in 1m12s
CI / wheel (pull_request) Successful in 28s
22a69ba69f
Written before any market data was downloaded: the sources, the horizons,
four expected outcomes and the decision rule. Step 0 measures the market's
own RPS on our 310 matches, which has only ever been quoted from the
literature.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ZreSMN6hgmFsbLs8MchV8
scripts/backtest_market_ratings.py runs the pre-registration of 01e199e:
our own fit_strengths trained on the market's expected goals for the last
four rounds (solve_lambdas on Shin-devigged 1X2 and over/under 2.5, with
our fit as the prior), against production's xG blend, a blend of the two
and the market's own odds, 1-5 rounds ahead. The odds are football-data's
free E0 CSV, committed as downloaded; every row must match its FPL fixture
on both teams and the score. The numpy grid is solve_lambdas' objective,
checked against it at run time and in the tests.

Result: not adopted. Better at every horizon (+0.003 to +0.005 RPS) and
significantly on goal counts, but the RPS interval includes zero. Step 0
also measured the market on our 310 matches for the first time: 0.2077,
where backtest_rps.py printed a published ~0.201; the print now says so.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ZreSMN6hgmFsbLs8MchV8
The 25/26 result under its pre-registration: expectations 1, 3 and 4
failed and 2 held; the decision rule failed on the RPS interval and the
ratings are not adopted. CLAUDE.md corrects the ~0.201 baseline it has
quoted since 07-31 and records the rule: a reference printed beside a
measurement is measured on the same rows, or labelled as not.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ZreSMN6hgmFsbLs8MchV8
The question, the design, the real gap to the market on our matches, the
result by horizon and the decision, with what would settle it. Linked
from the two team-strength decisions; three pages re-dated after
testing-notes gained the result.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ZreSMN6hgmFsbLs8MchV8
docs: the pre-registration is aebd85e after the rebase onto the night's merges
All checks were successful
CI / syntax (pull_request) Successful in 9s
CI / tests (pull_request) Successful in 1m13s
CI / wheel (pull_request) Successful in 29s
bb679a4751
It was committed as 01e199e and rebased onto origin's PRs #37-#41 before
pushing; the author time, 10:06 on 2026-09-19, is what shows it came before
the data. The commit messages below it still say 01e199e.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ZreSMN6hgmFsbLs8MchV8
The 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>
A Wildcard or Free Hit round keeps the count without adding one: pinned
by the user's own figure, FPL showing 1 free transfer for GW5 after a Free
Hit in GW3 and two used in GW4. Every chip-free round's charge must equal
4 points per transfer beyond the count, or the count refuses and names the
round; an unknown chip refuses (allowlist); the cap comes from the live
bootstrap, never a literal.

The ledger fixture had been banked without event_transfers and
event_transfers_cost. They were added from the same entry's public
history after checking its bank, value and chips agree, and a row
missing them now refuses the count instead of raising into the sync.

Six mutations, six caught.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Read from the history the purchase replay already fetches, for the round
after the one that locked, with the cap from the live bootstrap. Both
callers pass the cap (source-checked), a file without the count is read
again on the next tick, and a refused purchase replay still writes it.
/api/squad always serves free_transfers, with one label per cause:
VERIFIED, UNLIMITED, REFUSED, NOT_READ, NOT_SYNCED.

Six mutations; the one first missed (the count dropped on the refused
replay) now has its own test and is caught.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
/api/captaincy?xi= ranks the XI the round plan would field, which can hold
a player the plan buys; &yours=captain,vice values that pair with the
same armband_value that chose the ranked one (captaincy.pair_gap), and a
pair outside the XI is named, not valued. /api/best-xi no longer withholds
your locked lineup once the plan sells from it: the round plan puts the
bought player in the sold player's place. your_xi_ev went with its reader.

Six mutations, six caught.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
roundPlan() is the one object the card rows, the pitch tags and the
captaincy request will read: up to two moves searched as before, each
judged now / can wait / next week / later by its first-week gain and the
free transfers left (an unknown count judged as one); your locked lineup
with the plan's sales and the "now" moves in the sold players' places
(the XI's sale first, the better buy into it; a sold armband unset and
named); the suggested XI from bestXiForGw, pinned to agree with the
server's pick_xi_for_gw. swapConfidence is now the one P(better).

Thirteen tests; nine mutations, nine caught.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
One card at the top of the Squad tab, three rows in the order the round is
decided. The head names the round and the free transfers (or why the count
is unknown) with the hits rule. Transfer: the moves with their verdicts,
what waiting a week gives up, the other options without the plan's own
moves, and the caveat. Lineup: your XI with the "now" moves and what the
suggested changes are worth. Captain: /api/captaincy asked about the XI
the plan would field and valued against your own pair.

Removed with their prose: bankingComparison, bankingCard,
renderTransfersDecisionCard, armbandCard, fetchArmband and the ARMBAND
globals. The squad view now waits for BESTXI. Tests pinning the replaced
panels are rewritten in the following commits.

Twenty-five page tests; ten mutations, ten caught.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
At the next deadline the pitch draws the lineup FPL fields unless you
change it, with the plan's sales in place, and tags each card from the
same plan the card's rows print: "→ Botman" on a player to sell, "start"
and "bench" from the lineup row, a C on the suggested captain when he is
not yours. A bought player's tags ride the card he replaces. Every tag
says in words what it marks, with the reason in title and aria-label.
Your bench keeps FPL's order; the kicker names "Your XI" or "Suggested
XI" and the formation of the XI actually drawn. Later rounds are
unchanged. The right rail is the captaincy board alone.

Eight mutations, eight caught.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Rewritten, none dropped: the options exclude the plan's moves, the caveat
is a disclosure under the transfer row, the hits rule reads the season phase
in every head, the captain row renders the rank stance and asks for the
plan's round, the rail is the captaincy board alone, the move search keeps
budgeting the second move from what the first left.
test_banking_move_now_vs_wait.py is folded into test_round_plan_page.py.

The two Your-XI rail guards became plan states, each stated on the card: a
suggested XI that would start a player with no projection names him and
suggests no change, and a plan that sells a starter compares with the buy
in his place. An empty projection window says so instead of "GWundefined",
and the hits rule is one helper every head prints.

Two page-harness helpers left Playwright running when their setup timed
out, which turned one failure into thirty "Sync API inside the asyncio
loop" errors; they now stop the browser before raising.

Nine mutations, nine caught. test_the_round_plan_is_still_a_card waits on
the styles in the next commit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Found on the branch server with the live ledger: the suggested XI benched
the user's vice (Isak), and the row said "Isak is not in the suggested XI"
with no number for the captain, which is the decision. pair_gap now pairs
your captain with the vice the ranking chose when yours is benched, sold or
not given (its captain, when that is you), and returns vice_used; the row
says which vice the gap was valued with and why. A plan that sells your
vice asks about your captain alone.

Three mutations, three caught.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The card takes the captain card's place and its chrome — surface, border,
accent edge — with a label column and a body per row. No new colour: the
words carry the meaning, and the only tinted element is a move's NOW, in
the positive hue the app already uses for a gain. The other options sit
under the transfer row as a quiet disclosure, not a panel inside a card.

A pitch tag is a pill on the card's bottom edge, centred, so it owns no
corner — the C/V badge, the bench number and the risk dot own those — and
the suggested captain's C is dashed to read as a suggestion beside your
own solid badge.

Checked on the live ledger at 1280, 1440, 1600 and 1920: no clipped text,
no horizontal scroll, tags inside their cards. The render gate is
re-baselined deliberately for the new card; its 36 differences were the
card's own height and the page heights that follow from it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two guards still named what the plan replaced: the kicker's total was
`wk.total` and the captaincy round came from `armbandGw()`. They now read
`shown.total` — the XI actually drawn — and the plan's own round, with the
head and the request checked against each other.

Two mutations, two caught.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
One page for the system: the five places that stated one round's decisions,
the plan object the rows, the tags and the captaincy request all read, the
free-transfer rules and their check against FPL's hits, the vice the captain
row is valued with, and what the comparison does not price. The rule and the
harness lesson go in CLAUDE.md.

Six pages whose sources moved today were re-verified and dated: the hits
label and the Your-XI rail are named where they went, `armbandGw()` is noted
as the plan's round, and the sync page gains the free-transfer paragraph.
Lint clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Same label swap as on the default branch: the debian-trixie-<stack> runner
labels are being removed; debian-latest is the consolidated debian-ci image.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Brings the night's PRs #37-#41 and the 09-18/19 engine, scoring and
analysis work under the round plan before it goes live. Five conflicts:
- ci.yml: the live branch's, which dropped the browser job (#38);
- index.html and test_asset_version.py: one version above both, v175,
  with the digest of the merged assets;
- test_banking_move_now_vs_wait.py: stays deleted, as the round plan
  replaced it. Its two club-cap tests from 8e59d23 (#34) are ported to
  roundPlan() in test_round_plan_page.py, where they fail with the fix cut;
  the fix itself merged cleanly into the round plan's bestSwapFrom;
- the one-shot planning wiki page: the later date.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ZreSMN6hgmFsbLs8MchV8
test: the horizon guard follows the swap figures into the round plan
All checks were successful
CI / syntax (pull_request) Successful in 9s
CI / tests (pull_request) Successful in 1m13s
CI / wheel (pull_request) Successful in 28s
e8d80f1170
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_016ZreSMN6hgmFsbLs8MchV8
The round-plan merge ran 2054 green in its worktree with 46 skipped; in
the live checkout the same code failed one. Before a merge goes live, run
the full suite where the gitignored data lives, and read the skip count as
untested, not as passed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ZreSMN6hgmFsbLs8MchV8
wiki: the round plan is live; a round not yet on the coupon reads as FAILED
All checks were successful
CI / syntax (pull_request) Successful in 9s
CI / tests (pull_request) Successful in 1m13s
CI / wheel (pull_request) Successful in 29s
bb8f758904
The round-plan page records the go-live at ad35b4a, its restore point, the
five conflicts and how each was resolved, and the guard the worktree
skipped. The Norsk Tipping page records a found, unfixed issue: nt_watch
calls a round whose fixtures are not on the coupon yet a failure and backs
off. Fourteen pages re-dated after checking the four whose claims the
night's commits could move; the replay page points at the new rate-prior
rule.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ZreSMN6hgmFsbLs8MchV8
docs: pre-register market-implied team strength on 24/25, goal counts first
All checks were successful
CI / syntax (pull_request) Successful in 9s
CI / tests (pull_request) Successful in 1m15s
CI / wheel (pull_request) Successful in 29s
dc355c23ba
Written before 24/25's odds were downloaded (data/market_history holds only
25/26). A new question on an unseen season: goal counts over GW6-10 lead, RPS
only guards. The script carries the rule and derives 24/25's team ids from
its own player rows, since that archive has no teams.csv; both are tested and
each was checked red with its guard cut.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ZreSMN6hgmFsbLs8MchV8
docs: market ratings for GW6-10 do not pass on 24/25; the question is closed
All checks were successful
CI / syntax (pull_request) Successful in 9s
CI / tests (pull_request) Successful in 1m13s
CI / wheel (pull_request) Successful in 28s
c592c2b221
The pre-registered 24/25 test (dc355c2): goal counts +0.0193 [-0.0166,
+0.0552], RPS -0.0032 [-0.0102, +0.0039]; not adopted. The 25/26 goal-count
gain did not replicate, and on RPS the market ratings were worse than our
fit at every horizon. On the imminent round our fit beat Pinnacle, 0.2039 v
0.2050: one season each way, so our match model is about level with the
pre-match market. The 24/25 odds are committed as downloaded.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ZreSMN6hgmFsbLs8MchV8
Two warts recorded on 2026-09-18 as found and not fixed.

The lineup normalisation shifts a zero-weight player with his group so he
is ready for the round he returns in. A player out for every round ahead
(sold, loaned out, injured with no date) never returns inside the window,
and was shifted as well: Chelsea's loaned-out keepers read 0.99 and 0.97
and counted as starters ahead of the third choice, who gained a
DEPTH-CHANGED badge. Such a player is now left out: he holds no place, so
nobody else's number moves, and his own stays as estimated.

save_cache stamped its provenance onto a copy, and the three routes that
rebuild in-process (/api/override, /api/firm-up, /api/refresh) published
the unstamped original, so the served cache read "unknown" and the
staleness check could not see a code change until a restart. save_cache
now returns the stamped cache it wrote and the routes publish that.

Each fix was checked RED with its connection cut. The stubs in test_api
return the cache they were given, and the persist-before-publish guard
accepts an assignment.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ZreSMN6hgmFsbLs8MchV8
wiki: the two warts on the availability page are fixed
All checks were successful
CI / syntax (pull_request) Successful in 9s
CI / tests (pull_request) Successful in 1m14s
CI / wheel (pull_request) Successful in 29s
8809e0d58d
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ZreSMN6hgmFsbLs8MchV8
Asked 2026-09-19: a Show column, like FDR, price and ownership, with each
player's points above or below expected, and goals and assists beside it.

Expected is the same rounds with the parts luck decides replaced by their
expectation: goals by xG, assists by 1.379 x xA (FPL's assist is more generous
than xA's), a clean sheet by exp(-1.233 x xGC) from 60 minutes, goals conceded
by Poisson(xGC). Everything else counts as it happened on both sides. Every
round is rebuilt with the scoring engine against FPL's own total_points and a
mismatch refuses the player by name; all 2,549 rounds of GW1-4 matched.

The record is built from this season's rounds only, carries a state (measured,
no_minutes, no_rounds, refused) and None rather than zeros, and is required on
the cache. scripts/measure_over_under.py re-derives both constants and the
split-half finding (it does not carry over) through the production code.

Every Show column had carried a sort function that nothing called. The headers
now sort (Over/under: pts, G, A), 6-GW sorts back on xPts, and an unknown sorts
last in both directions instead of as a number.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Asked 2026-09-19 by the reader at home: an address to open the app on the
phone. `install_autostart.ps1 -Lan` registers the task with
`serve.py --host 0.0.0.0`. Every POST route still refuses a caller that is not
this PC (require_local), so the phone reads and cannot change anything.

On Windows a connect to 0.0.0.0 fails outright, so probing the bind address
would report a running server as absent and start a second one, which is what
the supervisor exists to prevent. `probe_host` probes loopback for a wildcard
bind. Proved red against a real socket bound to 0.0.0.0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Merge feat/over-under: Players -> Over/under and sortable Show columns
All checks were successful
CI / syntax (pull_request) Successful in 9s
CI / tests (pull_request) Successful in 1m14s
CI / wheel (pull_request) Successful in 29s
12aed8c949
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
It budgeted against the constant 100.0 with every kept player at today's
price. On 19 Sep FPL let entry 6261 spend 99.9 (bank 2.8 + 97.1 selling value)
while the kept players cost 97.8 at today's prices, so it could propose a squad
FPL would refuse and pass over one FPL would take.

Decided 19 Sep: a synced squad spends FPL's bank at the last deadline plus the
selling value of the fifteen FPL holds; one of those costs his selling price to
keep or take back after a planned sale, anyone else today's price. The figures
are _squad_payload's for the held squad, not a second computation. Selling
prices that cannot be read refuse (409) and name why; no synced squad is a new
team at 100.0. The answer carries `budget`, each card `cost` beside `price`,
and the page prints the budget and "sells £x" where the two differ.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
wiki: "Complete my squad" spends what FPL lets you spend
All checks were successful
CI / syntax (pull_request) Successful in 9s
CI / tests (pull_request) Successful in 1m13s
CI / wheel (pull_request) Successful in 29s
85a22d30c3
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Author
Owner

What feat/captaincy-board has 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:

  • The round plan on the Squad tab (merge ad35b4a, 19 Sep).
    • One card holds this round's three decisions: transfer or bank, lineup, and captain.
    • Free transfers are derived from FPL's public history and checked against the hits FPL charged.
    • The merge had five conflicts with #37–#41, all resolved. Your banking test's cases survive in the plan's tests.
    • A horizon guard that the worktree had skipped failed only in the live checkout, and moved to the plan's label (e8d80f1).
  • Departed players are no longer shifted by the lineup normalisation, and in-process rebuilds publish the stamped cache (e19de69).
  • Market-implied team strength for GW6-10 was pre-registered and measured on 24/25, and is not adopted. The question is closed (dc355c2, c592c2b). Over the two seasons, our match model is about level with the pre-match market.
  • Players → Over/under (b2a6284, merge 12aed8c): season points against what the chances were worth, with goals against xG and assists against expected assists.
    • FPL counts 1.379 assists per xA, and exp(−xGC) over-predicts clean sheets by 21-29%, so both are calibrated. scripts/measure_over_under.py re-derives the constants.
    • Every round is rebuilt with the scoring engine against FPL's total_points, and a mismatch refuses the player.
    • It does not carry over from one half of a season to the next (split-half r +0.04 and −0.19). It explains points and is not a signal.
    • Every Show column now sorts from its header. Their sort functions had never been called.
  • The app can serve the home network read-only (7b3db0d), via install_autostart.ps1 -Lan.
    • POST routes stay local-only through require_local.
    • The supervisor probes loopback for a wildcard bind, because Windows cannot connect to 0.0.0.0 and would otherwise start a second server.
  • #34 item 1 (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-latest cherry-pick and a merge of main, and both of those are already here through main. 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

What `feat/captaincy-board` has 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: - **The round plan on the Squad tab** (merge `ad35b4a`, 19 Sep). - One card holds this round's three decisions: transfer or bank, lineup, and captain. - Free transfers are derived from FPL's public history and checked against the hits FPL charged. - The merge had five conflicts with #37–#41, all resolved. Your banking test's cases survive in the plan's tests. - A horizon guard that the worktree had skipped failed only in the live checkout, and moved to the plan's label (`e8d80f1`). - **Departed players are no longer shifted by the lineup normalisation, and in-process rebuilds publish the stamped cache** (`e19de69`). - **Market-implied team strength for GW6-10** was pre-registered and measured on 24/25, and is not adopted. The question is closed (`dc355c2`, `c592c2b`). Over the two seasons, our match model is about level with the pre-match market. - **Players → Over/under** (`b2a6284`, merge `12aed8c`): season points against what the chances were worth, with goals against xG and assists against expected assists. - FPL counts 1.379 assists per xA, and exp(−xGC) over-predicts clean sheets by 21-29%, so both are calibrated. `scripts/measure_over_under.py` re-derives the constants. - Every round is rebuilt with the scoring engine against FPL's `total_points`, and a mismatch refuses the player. - It does not carry over from one half of a season to the next (split-half r +0.04 and −0.19). It explains points and is not a signal. - Every Show column now sorts from its header. Their `sort` functions had never been called. - **The app can serve the home network read-only** (`7b3db0d`), via `install_autostart.ps1 -Lan`. - POST routes stay local-only through `require_local`. - The supervisor probes loopback for a wildcard bind, because Windows cannot connect to 0.0.0.0 and would otherwise start a second server. - **#34 item 1** (`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-latest` cherry-pick and a merge of `main`, and both of those are already here through `main`. 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](https://claude.com/claude-code)
Squad -> Edit squad scrolled sideways by 41px at 390px. `.builder-layout` is
`1.5fr 1fr` and an `fr` track does not shrink below its content: the squad rail
needed 198px and "Add a player" 193px inside a 342px column. It had no
breakpoint, where `.xi-layout` folds to one column. It now folds at 640px;
tests drive 360-640px and require the search box to stay on screen.

THE GATE PASSED THAT CHANGE. It hashes every property getComputedStyle exposes
per element, in enumeration order — and two documents order CUSTOM properties
differently (`--page-max` before `--fx-5-bg` on one, after on the other, all
539 names otherwise equal). So every element differed, `_differences`
re-fetched the first 25 paths, none had a property to show, and the report came
back empty. Its prove-RED opened both pages through the route, a configuration
the gate never uses, so it never saw this.

The hash now sorts the names; a hash that differs with no property behind it is
an instrument fault, not a clean result; the mutation proof runs the gate's own
pair; and a new test compares the live stylesheet with the same bytes served.
The repaired gate then found `.builder-layout` by itself at 420px on all four
tabs, and the baseline was re-based with the same rule, deliberately.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
wiki: a hash over an enumeration is a hash over its order
All checks were successful
CI / syntax (pull_request) Successful in 9s
CI / tests (pull_request) Successful in 1m14s
CI / wheel (pull_request) Successful in 29s
05c484e4cc
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Asked what else deserved counting. Two points turn on one action: in 26/27
GW1-4, 48 rounds ended one short of the line and 34 crossed it by exactly one,
and DEFCON is 7.0% of all points paid. It is now P(actions >= the line), the
actions Poisson on his own defensive actions per 90 across the minutes he
played this season.

THE RATE THAT SHIPS IS THE RATE THAT WAS MEASURED. The model was validated on
that rate (25/26: 2,774 expected against 2,834 paid, -2.1%, and it matched the
crossing rate in every probability bucket) and then wired to `rates.defcon90`,
which the projections use. That weighs 25/26 beside this season and shrinks
toward a 15th-percentile anchor: it expected 186 DEFCON points against the 256
FPL paid on GW1-4, so the whole league read as lucky, +70 points of it. On the
measured rate the part sums to -11.4 and the league expectation is +4.5%.

scripts/measure_over_under.py re-derives it through the production code. The
cell's label now names every part it does not print, and says what is not
priced at all: bonus is ~7% of points and 55% of it went to players who beat
their xG that match, so a lucky goal's bonus still reads as deserved.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
wiki: the DEFCON line in Over/under, and the rate that ships is the rate measured
All checks were successful
CI / syntax (pull_request) Successful in 9s
CI / tests (pull_request) Successful in 1m13s
CI / wheel (pull_request) Successful in 29s
bdb105888d
Five pages carried dates behind files this branch touched: each was checked
against what actually changed (an asset-version bump, the DEFCON wiring) and
none describes it, so they are dated rather than rewritten.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Author
Owner

Since the summary above, three more things landed on this branch.

  • Edit squad fits a phone (e32b153). .builder-layout is 1.5fr 1fr with no breakpoint, and an fr track 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.
  • The CSS render gate could not fail, and can again (e32b153, details on #26). It hashed getComputedStyle's enumeration order, which two documents disagree about for custom properties, so every element differed and the real change was never looked at.
  • Over/under prices the DEFCON line (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, and scripts/measure_over_under.py re-derives it through the production code.

Full suite green in the live checkout: 2107 passed, none skipped.

🤖 Generated with Claude Code

Since the summary above, three more things landed on this branch. - **Edit squad fits a phone** (`e32b153`). `.builder-layout` is `1.5fr 1fr` with no breakpoint, and an `fr` track 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`. - **The CSS render gate could not fail, and can again** (`e32b153`, details on #26). It hashed `getComputedStyle`'s enumeration order, which two documents disagree about for custom properties, so every element differed and the real change was never looked at. - **Over/under prices the DEFCON line** (`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, and `scripts/measure_over_under.py` re-derives it through the production code. Full suite green in the live checkout: 2107 passed, none skipped. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Two findings from the audit of the running setup, both about a signal that
says the same thing whatever happened.

THE VERSION WARNING FIRED ON PROSE. The header read "server is running
f5fc70d3 while projections were built from 71f1e200" for days, while the
cache was not stale and both projection digests matched. It compared
`_git_sha`, the byte digest of the import closure, so editing a docstring
moved it. Now it compares `projection_sha` with `code_projection_sha` — the
pair `cache_staleness` reads, covering only what can change a number — and
falls back to the byte digests only when the cache carries no projection
digest. Both digests' docstrings already said a warning nobody can act on is
one people stop reading.

A ROUND NOT YET ON THE COUPON IS NOT A FAILURE. Norsk Tipping opens a round
days before its deadline, so `nt_resolve_market.py` writing nothing is the
ordinary state. It exited 1, `nt_watch` recorded FAILED fifteen times and
backed off to its six-hour ceiling, and the status line read exactly as a
real breakage would, the day before GW6's odds were due. It now exits with
`nt_market.NOT_ON_THE_COUPON`, a constant both sides import so the contract
cannot become two literals; the watch records NOT_OPENED, keeps its cadence,
clears `_last_error`, and the planner line says the round "is not on the
coupon yet".

A failing FETCH is still a failure whatever number it exits with: the loop
stops at the first failing step, so a fetch exiting 2 never has a resolve
code beside it. The tests cut each half separately.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`data/pit` held 17 point-in-time snapshots, 67 MB, of which CLAUDE.md says
"the cost is nothing and it cannot be taken retrospectively". They are
gitignored, as are squad.json, overrides.json, analysis/, the scraped
Transfermarkt records that cost credits, and the newest Norsk Tipping runs.
The code has a remote; that data had no copy anywhere. One dead disk would
have ended every backtest of our own decisions.

Every guard in this repo asks whether a value can be TRUSTED. None asked
whether it still EXISTS.

`scripts/backup_state.py --to <dir>` copies every file under `data/` that
`git ls-files` does not name, minus `REBUILDABLE` (data/cache, data/logs),
which a rebuild recreates.

- THE SCOPE IS DERIVED, NOT LISTED, for the reason the projection digest
  walks an import closure: a hand-kept path list stops matching the directory
  the week someone adds one.
- IT IS AN ARCHIVE, NOT A MIRROR. Nothing is deleted at the target, because
  `prune_nt_runs` deletes raw events by design and a mirror would carry that
  deletion into the only other copy of them.
- IT REFUSES rather than reporting a success it did not have: no git (it
  cannot then tell what the remote already holds), nothing in scope, or a copy
  whose size does not match the source, which it reads back.
- IT LOGS EVERY RUN AND EVERY REFUSAL to data/logs/backup.log. A scheduled
  task has no console, and a refusal with nowhere to print it is a backup
  that stopped happening in silence — the state this script exists to end.

Unchanged files are skipped on size and mtime in NANOSECONDS: squad.json is
rewritten by the sync at the same length, and a second's resolution skipped a
real change on the first run.

`scripts/install_backup_task.ps1 -To <dir>` registers "FPL backup", daily at
03:30 and at logon, StartWhenAvailable because this PC sleeps. The target has
no default.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
wiki: the setup audit of 2026-09-20, and three rules from it
All checks were successful
CI / syntax (pull_request) Successful in 9s
CI / tests (pull_request) Successful in 1m15s
CI / wheel (pull_request) Successful in 29s
7b317dfdcc
Records the audit of what was RUNNING — the live /api/status, the chores
ledger, the scheduled tasks, the disks, the CI workflow — rather than of what
the code says. Ten findings; three fixed in aabd2dc and 458db75.

CLAUDE.md gets one rule each: the data that cannot be re-acquired had no copy
anywhere (derive the scope, archive not mirror, log every refusal); a watch
called waiting a failure (two causes, two exit codes, and a code means "not
yet" only from the step that can say so); a warning that is always on is not a
warning.

The evidence is wiki/notes/audit-2026-09-20-setup.md, crosslinked from the NT
watch, autostart and render-gate pages. The NT page's "Known issue: a round not
yet on the coupon reads as a failure" section is now FIXED rather than left
contradicting the code, and the autostart page gains the third scheduled task
beside the two it already documented.

ONE FINDING WAS CORRECTED WHILE WRITING IT UP. "CI dropped the browser job"
framed a decision as an oversight: it was removed deliberately in 05530f7 (13-17
minutes of Chromium per push), and the workflow and README both say to run those
tests locally before frontend changes. The real defect is sharper — that
instruction is a stop rule committed in prose, so the 434 browser tests, the
render gate among them, run only when someone remembers.

Six pages read STALE against the files the DEFCON commit touched. Checked the
commits' scope rather than re-reading each page claim by claim, bumped `updated`,
changed no text, and said exactly that in the lint log. LINT-CLEAN after.

Full suite in the live checkout: 2124 passed, 0 skipped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
wiki: the NT not-opened label confirmed on the running server
All checks were successful
CI / syntax (pull_request) Successful in 9s
CI / tests (pull_request) Successful in 1m15s
CI / wheel (pull_request) Successful in 29s
fd563bf17c
The failure counter lives in the ledger, so it heals on the next PULL rather
than on the deploy. At that pull: "NT watch: GW6 is not on the coupon yet (0/10
goals, 0/10 assists), next pull in 719m".

The interval is the observable half. 719m is the 12-hour base, not the 6-hour
backoff ceiling it had reached — a label can read correctly while the backoff it
was meant to stop keeps climbing, and only the cadence shows that.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Audit finding 4, and the finding was weaker than the truth. It was recorded as
"a named guard nobody calls" — wire it up and move on. Reading it says
otherwise.

`captaincy.require_measured_rates` refused to name a captain if ANY XI member
carried a rate prior. `1c4a2df` replaced that with exclusion: the prior-rated
player leaves the candidate pool, the card still renders, and it refuses only
below two real candidates — the blast-radius lesson of 2026-09-13 ("ask what
else a refusal takes with it"). The function stayed behind with its docstring
intact, stating the old absolute rule.

So the module held two statements of one policy, and THE ONE A READER MEETS
FIRST WAS THE ONE THE CODE HAD STOPPED APPLYING. Wiring it up, as the finding
suggested, would have reinstated a refusal that was deliberately narrowed and
let one unmeasured squad member blank the armband again.

Its test claimed it was "exported for the solver path". The solver has its own
`optimize.require_measured_rates_in_xi`, and that one IS called (optimize.py
:316). Nothing imported this one.

Deleted. The live rule stays pinned by
`test_an_unmeasured_player_is_excluded_not_blocking`; the new test pins the
ABSENCE, because the failure to guard against is a third definition reappearing
here with nothing calling it. Both halves proved RED: reinstating a guard in
captaincy.py, and cutting the solver's caller.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`BPS_WEIGHTS_2026_27` has carried `_provisional: True` since it was decoded from
the app bundle on 2026-07-23. CLAUDE.md and two memories carried "re-run the
harness on 26/27 after 2026-08-25" as a task. Five rounds later it had not run,
because the harness reads `data/archive_<season>/merged_gw.csv` and this season
lives in `data/current_2026_27/rounds.json`, which has no fixture column.

`load_current` reads that file. Three things it refuses to get wrong:

- THE FIXTURE COMES FROM THE MATCH, never from the row's `team`. That field is
  the bootstrap at ingest — a player's club TODAY — and CLAUDE.md records that
  on GW1-4 it would have moved three players to clubs that did not play those
  matches. It would have put them in the wrong bonus race here. The key is each
  fixture's own bps list.
- A ROUND THAT IS NOT FINAL IS LEFT OUT AND NAMED. Bonus is provisional until
  FPL checks the round, so GW4 is excluded and said so on the banner.
- A DOUBLE GAMEWEEK REFUSES. rounds.json holds one row per element per ROUND, so
  its two matches are already summed; awarding 3/2/1 from that total would score
  a race that was never run.

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. Dropping such rows would
be the trap in miniature: he ran in that race, the proxy awards him 3-6 bps for
the minutes, so removing him deletes exactly the rows the proxy is worst on. His
match comes from his own history instead — the fallback CLAUDE.md already names
— and with no resolver it still refuses rather than guessing from his club.
15 appearances needed it over GW1-3.

The season now decides the weights (`weights_for`), so a 26/27 run cannot
silently be scored with the 25/26 table and report the rule change as proxy
error. `--weights` still overrides, and says so loudly.

The 25/26 path is unchanged and reproduces its recorded numbers exactly:
correlation 0.8587, mean signed error -3.26, MAE 5.07.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
wiki: the 26/27 BPS table measured, and the NT status on the chores page
All checks were successful
CI / syntax (pull_request) Successful in 9s
CI / tests (pull_request) Successful in 1m15s
CI / wheel (pull_request) Successful in 30s
f8099f132a
The table decoded from the app bundle on 2026-07-23 has carried `_provisional`
and a "re-run after 2026-08-25" task ever since. Scored on GW1-3 it reproduces
realised BPS at correlation 0.8711 over 929 appearances, against 0.8587 for the
25/26 table on its own season, and wins the bonus race outright on 35.5% of the
93 rows that awarded bonus. The residual is on MIDFIELDERS and is explained as a
group by the defensive columns the proxy ignores; the individual coefficients
are collinear and not adopted.

Two of five pre-registered statements were wrong — including the size of the
defensive cost, which I had put four to nine BPS too high — and that is what
made the run worth reading.

Also recorded: FPL omits a 0-bps player from a fixture's stats list, so absence
from one is not absence from the match.

`systems/weekly-chores-scheduler.md` gains the NOT_OPENED status, which is new
scheduler behaviour and belongs beside its other "two states, one sentence"
entries. Four pages bumped after checking the commits' scope; the lint log says
which and why. LINT-CLEAN.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Author
Owner

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 two

Detailed on #34. Short version: captaincy.require_measured_rates was the pre-1c4a2df absolute 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 confirmed

BPS_WEIGHTS_2026_27 has carried _provisional: True since 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.py reads data/archive_<season>/merged_gw.csv, and this season lives in data/current_2026_27/rounds.json, which has no fixture column.

load_current reads that file. On GW1-3 (GW4 was still PROVISIONAL and is excluded by name):

25/26, own table 26/27, own table
appearances 11,498 929
correlation, proxy vs realised BPS 0.8587 0.8711
mean signed error -3.26 -3.68
bonus exact, rows that got bonus 41.1% (n=1,206) 35.5% (n=93)

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_contribution is derived from the others), so none is adopted.

Three things the loader refuses to get wrong, each from a rule already in CLAUDE.md:

  • the fixture comes from the match, read off each fixture's own bps list, never from the round row's 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;
  • a round that is not FINAL is left out and named, because bonus moves until FPL checks the round;
  • a double gameweek refuses — rounds.json holds 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

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 two Detailed on #34. Short version: `captaincy.require_measured_rates` was the pre-`1c4a2df` absolute 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 confirmed `BPS_WEIGHTS_2026_27` has carried `_provisional: True` since 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.py` reads `data/archive_<season>/merged_gw.csv`, and this season lives in `data/current_2026_27/rounds.json`, which has no fixture column. `load_current` reads that file. On GW1-3 (GW4 was still PROVISIONAL and is excluded by name): | | 25/26, own table | 26/27, own table | |---|---|---| | appearances | 11,498 | 929 | | correlation, proxy vs realised BPS | 0.8587 | **0.8711** | | mean signed error | -3.26 | -3.68 | | bonus exact, rows that got bonus | 41.1% (n=1,206) | **35.5% (n=93)** | 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_contribution` is derived from the others), so none is adopted. Three things the loader refuses to get wrong, each from a rule already in CLAUDE.md: - **the fixture comes from the match**, read off each fixture's own bps list, never from the round row's `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; - **a round that is not FINAL is left out and named**, because bonus moves until FPL checks the round; - **a double gameweek refuses** — `rounds.json` holds 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](https://claude.com/claude-code)
wiki: GW5 scored, the first round the model could be compared on at all
All checks were successful
CI / syntax (pull_request) Successful in 9s
CI / tests (pull_request) Successful in 1m15s
CI / wheel (pull_request) Successful in 29s
ae94524ae7
Every precondition held for the first time this season: the snapshot
(20260918T143249Z, 16:32 Oslo) had seen GW4, and Norsk Tipping's run
(nt_20260918T143015Z, 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, and
GW3's snapshot had not seen GW2.

237 appearances, realised scoring rate 0.0802, log loss:

  MODEL      0.2439     BLEND    0.2452     NT_TEAM  0.2508
  BASE_RATE  0.2792     NT raw   0.2960

LEVEL WITH THE CORRECTED MARKET, both clearly ahead of the raw coupon. MODEL
over NT_TEAM is 0.007 on one round, which this page's own rule calls noise;
over the control it is 0.035, which is not nothing and is still one round.

The raw coupon over-forecasts by 2.3x (0.1847 against 0.0802 realised), the
reason market_scale corrects by the coupon's own total rather than the
three-way margin.

The cross-round verdict BROKEN is about the BOOK, not about us: across the two
rounds whose NT prices predate their deadline, NT scores 0.2902 against the
control's 0.2627. The MODEL row exists for GW5 only.

THE PRE-REGISTRATION WAS WRONG IN OUR FAVOUR on two of six points: I predicted
280-330 scored (237) and the BLEND first with MODEL below NT_TEAM. Being wrong
in the flattering direction is the case that earns an inspection, so: both
sides are 3.0h pre-deadline, the 237 are the players priced by every source who
appeared, and both are conditional on appearing. The comparison stands.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`nt_watch` reported "after 3 failed" the morning after the NOT_OPENED fix, and
this time the failing step was the FETCH: `marketGroup/89304.1` answered
CONTENT_NOT_FOUND three pulls running.

Nothing is broken. There are no Premier League fixtures until 10 October, so
Norsk Tipping has taken the coupon down. But 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 before the deadline and which nothing else fetches.

DECIDED ON THE COUNT NT PUBLISHES, never on the coupon being missing. The
England navigation node still lists England Premier League (66772.1) with
`numevents: 0`, beside League One's 9 and League Two's 11 — those leagues play
through the break and the Premier League does not. `league_is_empty` reads that
count; `fetch_norsk_tipping` consults it only when the coupon refuses and exits
`nt_market.NO_FIXTURES` when the league is empty, re-raising otherwise. The
watch records NO_FIXTURES, keeps its cadence, and the status line says the
round "has no fixtures to price yet".

Three guards on the reading, each proved RED:

- keyed on the node ID, never the name — four England nodes contain "premier
  league" and the outrights node is permanently 0, so reading that one would
  report every week of the season as an international break;
- a response with no such node RAISES, because the node being absent is
  evidence that this reading no longer works, not evidence of an empty league;
- only the FETCH may claim it. THIS ONE CAME FROM A MUTATION THAT SURVIVED:
  dropping `ran["nt_fetch"] == rc` left every assertion green, because nothing
  exercised the code arriving from another step. `nt_resolve` cannot exit 3
  today; the guard is for the day it can, and it now has a test.

Verified live: the fetch exits 3 with the calm message, on the real endpoint.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
docs: the empty-league rule, which the previous commit dropped
All checks were successful
CI / syntax (pull_request) Successful in 10s
CI / tests (pull_request) Successful in 1m15s
CI / wheel (pull_request) Successful in 29s
d5e621cef7
The CLAUDE.md half of 03adb7a. Its patch matched on an anchor containing an
em dash and a right arrow, the assertion failed, and the commit went ahead
with every other file — so the rule reached the wiki and not the document
every session loads.

Also worth the line it is written on: a guard for a state that CANNOT HAPPEN
YET is untested by construction. That is what let the surviving mutation
survive.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>
fix: a round ingested provisional is ingested again when FPL confirms it
All checks were successful
CI / syntax (pull_request) Successful in 9s
CI / tests (pull_request) Successful in 1m15s
CI / wheel (pull_request) Successful in 28s
dbf21dda12
Found while answering "when do the numbers for the next round change?".

`after_gameweek.py` fires the moment a round's last fixture is over — GW5 was
ingested at 19:31 Oslo on 2026-09-20, eleven minutes after the final whistle,
with FPL still reporting the round PROVISIONAL. Minutes and starts are final at
that point; bonus and BPS are not. `fetch_current_season` says so itself: "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. By the next evening FPL had confirmed GW5
(`data_checked`), `rounds.json` still said PROVISIONAL, and the ledger read OK,
so it would have stayed provisional for the season: GW5 permanently outside the
BPS harness, its bonus provisional wherever total_points is read. The mechanism
existed in the fetcher and was connected to nothing — the second failure mode in
CLAUDE.md, with the completion-marker rule sitting on top of it.

The ledger now records the round's status AT INGEST, and a round that has since
gone FINAL is planned again. Keyed on the round having CHANGED, not on the
marker and not on age. An entry with no recorded status predates this and is
left alone: re-running every past round on the first tick after a deploy would
rebuild the cache once per round over a state that cannot be checked.

A SECOND COMPLETION MARKER LIVES ONE LEVEL DOWN, and it made the first version
useless: `_run_one` skips any step whose previous result was 0, so the re-ingest
was planned correctly and then ran NOTHING. That skip is right for a multi-step
job that got half way — pre_deadline must not re-fetch odds because its snapshot
failed — and wrong for a job the planner deliberately rescheduled because its
subject changed. `_ingest_status` already holds exactly those keys, so it
doubles as the flag. Its own test.

Three cuts, three REDs. GW5 re-ingested by hand afterwards: the round reads
FINAL, carries its 61 bonus points (10 fixtures x 6, plus a tie), and the cache
was rebuilt on it at 22:48 Oslo.

NOT MEASURED, and worth saying: 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.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Author
Owner

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 all

Every 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:

MODEL BLEND NT_TEAM BASE_RATE NT raw
0.2439 0.2452 0.2508 0.2792 0.2960

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 one

The morning after the NOT_OPENED fix, nt_watch read "after 3 failed" again — and this time the FETCH was the failing step, so the label was right. marketGroup/89304.1 answers 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_resolve cannot 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 paid

The Over/under work had rates.defcon90 expecting 27% fewer DEFCON points than FPL paid, measured for a different purpose. That rate is what project_all feeds defcon.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:

ratio evidence n ratio
GW2 0.85 thin (<5 nineties) 252 0.50
GW3 0.77 some (5-20) 251 0.99
GW4 0.74 rich (20+) 349 0.93
all 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, and rate-prior players are refused from the XI anyway.

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 (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 it

after_gameweek.py fires 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_season states 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.json still 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_one skips 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

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 all Every 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: | MODEL | BLEND | NT_TEAM | BASE_RATE | NT raw | |---|---|---|---|---| | **0.2439** | 0.2452 | 0.2508 | 0.2792 | 0.2960 | **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 one The morning after the NOT_OPENED fix, `nt_watch` read "after 3 failed" again — and this time the FETCH was the failing step, so the label was right. `marketGroup/89304.1` answers 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_resolve` cannot 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 paid The Over/under work had `rates.defcon90` expecting 27% fewer DEFCON points than FPL paid, measured for a different purpose. That rate is what `project_all` feeds `defcon.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: | | ratio | | evidence | n | ratio | |---|---|---|---|---|---| | GW2 | 0.85 | | thin (<5 nineties) | 252 | **0.50** | | GW3 | 0.77 | | some (5-20) | 251 | 0.99 | | GW4 | 0.74 | | rich (20+) | 349 | 0.93 | | **all** | **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, and rate-prior players are refused from the XI anyway. **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 (`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 it `after_gameweek.py` fires 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_season` states 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.json` still 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_one` skips 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](https://claude.com/claude-code)
The review of dbf21dd found, and a run with the suite's own fixtures
confirmed before anything was changed, two ways the re-ingest of a round FPL
has since confirmed could lie about itself:

- It ran again under the FIRST ingest's key, `after_gameweek:N`, so it
  inherited the tries that ingest had spent: two left, or ONE when the first
  had needed three (GW4's needed two), with a tick every minute and no
  backoff. A few minutes of FPL errors at confirmation would have blocked it
  for good.
- It copied the round's status into the ledger, and a FAILED run recorded
  `round_status: FINAL` while rounds.json still held the provisional rows.

The review overstated the damage, and its suggested fix was unsafe applied by
halves. `after_gameweek.py` re-fetches every finished round, so the next
round's ingest repairs a stuck one about a week later; what stays is the
header's STOPPED line. And keeping the old status on failure, alone, re-ran
the ingest on every tick without limit (10 runs in 10 ticks, simulated),
because a stale round also skipped the blocked check.

So the re-ingest is `after_gameweek:N:final`:

- due when FPL has confirmed a round that rounds.json does not hold FINAL.
  Keyed on the FILE, which is what the projections read
  (`pipeline.CURRENT_ROUNDS`), so a hand-run re-ingest counts, as GW5's did
  on 2026-09-21, and a file that has lost the round is taken again. The
  committed copy of rounds.json holds GW4 PROVISIONAL and no GW5, so a
  checkout that tidies the files the chores leave dirty does exactly that;
- OK only when the file holds the round FINAL afterwards. A clean exit that
  left it provisional is NOT_FINAL and names what the file holds;
- every step on every run, so a retry retries;
- three tries of its own, started again if the file loses the round after a
  successful episode (`Ledger.record(fresh=...)`).

`_run_one` is back to what it was before dbf21dd, and `_ingest_status`, the
flag `status()` also wrote through `_plan` from the request thread, is gone.

ONE RULE FOR `data_checked`, in backend/data/season.py beside
`playable_gameweeks`: `round_confirmed` and `gameweek_status`, now used by the
fetcher, the scheduler and the scoreboard, which held three copies with two
behaviours for a missing field. The trigger compares the fetcher's reading
with the scheduler's, so two copies could only disagree towards a loop or a
silence. Read STRICTLY: `.get()` said "not checked" for a missing field, which
would have called every round provisional for good. All 38 events in all 17
bootstraps in data/pit carry it (2026-09-25). The scheduler states a missing
one and plans the rest.

Fifteen cuts, fifteen REDs: each guard above, both wrong default paths, the
routing to the new runner, and the fetcher keeping its own copy. Full suite in
the live checkout: 2153 passed, 0 skipped.

The staleness check hashes PROJECTION_SOURCES, which season.py is not in, so
this does not mark the projections stale: the running server, reading the
edited tree, reports the cache current.

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. The planner parses rounds.json on
every tick once FPL has confirmed the round: 11 ms at 2.1 MB for five rounds,
roughly 85 ms by GW38, with no cache.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
wiki: the confirmed-round re-ingest, and the review of dbf21dd it answers
All checks were successful
CI / syntax (pull_request) Successful in 9s
CI / tests (pull_request) Successful in 1m15s
CI / wheel (pull_request) Successful in 29s
1878c80e41
systems/weekly-chores-scheduler.md carries the evidence for the CLAUDE.md
entry of 2026-09-25: what dbf21dd did, the two ways its re-ingest could lie,
the two corrections to the review (a round of damage, not a season; its fix
unsafe applied by halves), what replaced it in 92ef7c4, and what was left.
systems/current-season-ingestion.md points at it.

Lint: eight pages went STALE because 92ef7c4 touched files they cite. Each
was re-read against the change and bumped with no content change; the list
is in wiki/log/2026-09-25-lint-stale-after-final-ingest.md. One of them, the
start-probability page, had been behind current-season-ingestion.md since
2026-09-21, before this change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Author
Owner

Two 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 file

A code review of dbf21dd found two ways its re-ingest could lie about itself. I reproduced both with the suite's own fixtures before changing anything:

  • It inherited the first ingest's tries. It ran again under the same ledger key, 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.
  • A failed run recorded round_status: FINAL while rounds.json still held the provisional rows.

A correction to the summary above: GW5 would not have stayed provisional all season. after_gameweek.py re-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:

  • Due when FPL has confirmed a round that rounds.json does 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 committed rounds.json holds GW4 PROVISIONAL and no GW5, so a checkout that tidies the chores' dirty files would do exactly that.
  • OK only when the file holds the round FINAL afterwards. A clean exit that left it provisional is NOT_FINAL, naming what the file holds.
  • Every step runs on every run, so a retry actually retries.
  • It gets three tries of its own, starting again if the file loses the round after a successful run.

_run_one is back to what it was before dbf21dd, and _ingest_status is gone.

data_checked has one reading now: round_confirmed and gameweek_status in backend/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 in data/pit carries 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 — wiki

The evidence is in wiki/systems/weekly-chores-scheduler.md and the rule is in CLAUDE.md under 2026-09-25. The lint flagged eight pages stale because of 92ef7c4; 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

Two 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 file A code review of `dbf21dd` found two ways its re-ingest could lie about itself. I reproduced both with the suite's own fixtures before changing anything: - **It inherited the first ingest's tries.** It ran again under the same ledger key, `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. - **A failed run recorded `round_status: FINAL`** while `rounds.json` still held the provisional rows. **A correction to the summary above:** GW5 would *not* have stayed provisional all season. `after_gameweek.py` re-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`: - **Due** when FPL has confirmed a round that `rounds.json` does 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 committed `rounds.json` holds GW4 PROVISIONAL and no GW5, so a checkout that tidies the chores' dirty files would do exactly that. - **OK** only when the file holds the round FINAL afterwards. A clean exit that left it provisional is `NOT_FINAL`, naming what the file holds. - Every step runs on every run, so a retry actually retries. - It gets three tries of its own, starting again if the file loses the round after a successful run. `_run_one` is back to what it was before `dbf21dd`, and `_ingest_status` is gone. **`data_checked` has one reading now:** `round_confirmed` and `gameweek_status` in `backend/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 in `data/pit` carries 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` — wiki The evidence is in `wiki/systems/weekly-chores-scheduler.md` and the rule is in CLAUDE.md under 2026-09-25. The lint flagged eight pages stale because of `92ef7c4`; 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](https://claude.com/claude-code)
Found checking the live server after 92ef7c4: `understat_watch` had read
"due now" on every tick since GW5 ended, its ledger entry counted 3,107 runs,
and data/backfill/understat_2026_27.json had not been written since
2026-09-12. Two defects, and fixing only the first would not have given a
working watch:

- IT RAN ONCE. It went through `_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;
  the Understat watch was written the same week and routed the other way.
- ITS COVERAGE READ HIGH. "The most games any player has" stood for the last
  round over. The fetch at the end of GW3 already held seven of GW4's ten
  matches, so fourteen clubs read 4 and GW4 counted as covered before it had
  been played; once it had, nothing fetched it again. The banked file lacks
  GW4's last three matches and all of GW5.

Now:

- `understat_behind` counts, club by club, the matches each club played in
  the rounds that are over, from FPL's own fixtures, against the games of the
  club's most-capped player (EPL rows only, one club all season). Blanks and
  doubles count as they happened, and a round still being played is neither
  demanded nor stood in for.
- `_run_understat_watch`: fetch; rebuild when the file differs from what the
  last good rebuild read (`rebuilt_on`, recorded only on success, so a failed
  rebuild is retried though the file stands still). Then OK when every club
  is covered; NOT_YET when a clean fetch still leaves one short, with no
  error, since Understat has not published it; FAILED when a step fails or a
  clean exit left no file.
- It waits an hour between fetches, doubling to six while fetches fail or
  bring nothing new. One that brings something waits an hour again.
- It is RECURRING, so three failures can no longer put it on the header's
  STOPPED line while the scheduler goes on fetching.

`_events_of` replaces the two copies of the bootstrap-events fallback in
`_plan` and `_nt_status`; the runner would have been a third.

Eighteen cuts, eighteen REDs. Full suite in the live checkout: 2170 passed,
0 skipped.

On the live server the first real fetch comes an hour after the last no-op
run, which the ledger dates, and a rebuild follows because nothing has been
rebuilt on this file's content.

NOT DONE: a club at which nobody played every match reads low, so the watch
would go on fetching every six hours and name that club -- the safe
direction, but a standing "behind". And the cache still records no digest of
the Understat files it read, so a fetch run by hand that brings coverage up
is not rebuilt on by this watch; the next rebuild for any other reason
picks it up.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
wiki: the Understat watch ran once and read high
All checks were successful
CI / syntax (pull_request) Successful in 9s
CI / tests (pull_request) Successful in 1m14s
CI / wheel (pull_request) Successful in 29s
7288694325
notes/coverage-count-must-match-its-denominator.md carries the evidence for
the CLAUDE.md entry of 2026-09-25: the 3,107 runs that ran nothing, the file
banked on 2026-09-12 that read GW4 covered while GW4 was half played, what
replaced both in f80e983, and what was left. Its description (and so its
index line) now names both of the watch's wrong populations.
systems/weekly-chores-scheduler.md points at it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
What the loop fixed in f80e983 took with it, seen only once the fix was live.
A tick whose plan holds a job with a step named `rebuild` stands the inputs
watch down, on the reading that the rebuild will record fresh inputs itself.
The empty Understat job carried that step on every tick from the end of GW5,
so `inputs_rebuild` last ran on 2026-09-19 and the projections were last
built on 2026-09-21: four days of FPL availability news reached no
projection. The restart onto f80e983 started the rebuild within seconds, and
no deadline fell in the gap (GW6 locks on 2026-10-10).

The new wait already prevents it; this pins it, so a change that plans the
job on every tick again goes RED rather than starving the inputs watch in
silence. RED with the wait removed ("the inputs watch never ran").

The test world's players now carry the fields the inputs watch digests, as
FPL publishes them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
wiki: what the Understat loop took with it
All checks were successful
CI / syntax (pull_request) Successful in 9s
CI / tests (pull_request) Successful in 1m14s
CI / wheel (pull_request) Successful in 30s
894992f52e
The inputs watch stood down on every tick the empty Understat job was
planned: inputs_rebuild last ran on 2026-09-19, the projections were last
built on 2026-09-21, and the restart onto f80e983 rebuilt them within
seconds. Measured on the live ledger and /api/status.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
wiki: the live sweep of 2026-09-25
All checks were successful
CI / syntax (pull_request) Successful in 9s
CI / tests (pull_request) Successful in 1m14s
CI / wheel (pull_request) Successful in 29s
641671161c
The whole running app, checked against what was running: every GET route the
page calls, the chores ledger, the scheduled tasks, the logs, and every tab
at 1600 px and 390 px. Two scheduler defects were fixed the same day
(92ef7c4, f80e983/283a5d1). Found and left for a decision: five texts that
claim what is not there (odds that do not exist, a book "best" with nothing
priced, a remedy that cannot work, a two-season record under one season's
label, "not yet scored" with 50 results in), three phone-width overflows,
the PC's LAN address having moved, write buttons offered to devices that
cannot write, and data in git drifting from data on disk.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
wiki: keep the home LAN address out of the audit page
All checks were successful
CI / syntax (pull_request) Successful in 9s
CI / tests (pull_request) Successful in 1m14s
CI / wheel (pull_request) Successful in 29s
1814841877
It stays out of the repo, as it stays out of the tracker. 6416711 carried
it; that commit is pushed and left as it is.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Author
Owner

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 round

Found checking the live server after 92ef7c4: understat_watch had read "due now" on every tick since GW5 ended, its ledger entry counted 3,107 runs, and data/backfill/understat_2026_27.json had not been written since 12 Sep. Two defects:

  • It ran once. It went through _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.
  • Its coverage read high. "The most games any player has" stood for the last round over. The fetch at the end of GW3 already held seven of GW4's ten matches, so fourteen clubs read 4 and GW4 counted as covered before it was played. Nothing fetched it again: the file lacked GW4's last three matches and all of GW5.

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) or FAILED. It waits an hour between fetches, doubling to six while they fail or bring nothing new. It is in RECURRING, 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 it

A tick whose plan holds a job with a rebuild step stands the inputs watch down. The empty Understat job carried that step on every tick from the end of GW5, so inputs_rebuild last 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 app

Everything 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:

  • Five texts that claim what isn't there: "projections current with GW6 odds" when there are no GW6 odds; Fixtures → Clean sheets naming a book "best" with nothing priced (the rows fall back to our six-week total); a "run the odds pull" remedy that can't work during the break; a "25/26 RECORD" box on every player card that sums both seasons; and "forecasts not yet scored" with 50 results in and GW5's goalscorer score on disk, unread.
  • Phone width: Fixtures, Matches and the round plan's "Other options" scroll sideways (.panel-kicker is nowrap; .mt-grid uses minmax(380px, 1fr)).
  • Operations: write buttons offered to devices on the home network that can't use them, and data files the chores rewrite in git with no banking policy (the last data commit was 15 Sep).

7288694 and 894992f carry the Understat evidence in the wiki; the rules are in CLAUDE.md under 2026-09-25.

🤖 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 round Found checking the live server after `92ef7c4`: `understat_watch` had read "due now" on every tick since GW5 ended, its ledger entry counted 3,107 runs, and `data/backfill/understat_2026_27.json` had not been written since 12 Sep. Two defects: - **It ran once.** It went through `_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. - **Its coverage read high.** "The most games any player has" stood for the last round over. The fetch at the end of GW3 already held seven of GW4's ten matches, so fourteen clubs read 4 and GW4 counted as covered before it was played. Nothing fetched it again: the file lacked GW4's last three matches and all of GW5. 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) or `FAILED`. It waits an hour between fetches, doubling to six while they fail or bring nothing new. It is in `RECURRING`, 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 it A tick whose plan holds a job with a `rebuild` step stands the inputs watch down. The empty Understat job carried that step on every tick from the end of GW5, so **`inputs_rebuild` last 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 app Everything 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: - **Five texts that claim what isn't there:** "projections current with GW6 odds" when there are no GW6 odds; Fixtures → Clean sheets naming a book "best" with nothing priced (the rows fall back to our six-week total); a "run the odds pull" remedy that can't work during the break; a "25/26 RECORD" box on every player card that sums both seasons; and "forecasts not yet scored" with 50 results in and GW5's goalscorer score on disk, unread. - **Phone width:** Fixtures, Matches and the round plan's "Other options" scroll sideways (`.panel-kicker` is `nowrap`; `.mt-grid` uses `minmax(380px, 1fr)`). - **Operations:** write buttons offered to devices on the home network that can't use them, and data files the chores rewrite in git with no banking policy (the last data commit was 15 Sep). `7288694` and `894992f` carry the Understat evidence in the wiki; the rules are in CLAUDE.md under 2026-09-25. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
The live sweep of 2026-09-25 read these off the running page during an
international break, when Norsk Tipping listed no Premier League events.
Each was an absence rendered as a well-formed statement:

- The header said "projections current with GW6 odds and FPL flags" with
  no GW6 odds file. The inputs watch compared "no file" when the cache
  was built with "no file" now, found them equal, and named odds neither
  side held. It now says "no GW6 odds yet".
- Fixtures -> Clean sheets: the kicker read "book GW6: best Arsenal" with
  every row "not priced". The rows had fallen back to our six-week total
  and the kicker crowned that fallback's first row as the book's best.
  With nothing priced it now says so, and the note states the order the
  rows are actually in.
- Fixtures -> Goals scored told the reader to "run the odds pull" beside
  "HTTP 503". Nothing could be pulled; the odds watch pulls on its own.
  /api/market now refuses with the watch's own sentence
  (`Chores.odds_watch_says`), and the page prints the server's reason.
- Every player card headed a two-season total "25/26 record": Mbeumo's
  3,061 minutes were 2,611 from 25/26 and 450 from this season. The build
  carries the two parts beside the sum (`sample.last_season`,
  `sample.this_season`), and the card names the seasons it adds up. A
  cache built before the split names none.
- Forecast accuracy said "forecasts not yet scored", AWAITING_RESULTS on
  every metric, with 50 results in against the 20 needed: the status was
  the dataclass default and never overwritten. Results in and no scorer
  are now two labels (NOT_SCORED, with the count). The goalscorer metric
  reads the rounds score_goalscorer.py banked, through
  score_history.verdict, so GW5 (the first valid round) is on screen. It
  is bound to its metric by name, not by position in the list.

Every page text is tested on the rendered page. 13 of the 14 new tests
failed on the unchanged code; the fourteenth, the scheduler's wiring, was
written after and is proven by a cut. 17 cuts to the fixes, one at a time,
each turned a test red, and one more for the by-name binding. Full suite in the live checkout: 2185 passed,
0 skipped.

Evidence: wiki/notes/audit-2026-09-25-live-sweep.md

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Found checking bf7e50a on the live page, beside the clean-sheet note it
fixed for the same claim:

- The note above the refusal still read "The book's expected goals for
  each side, with our own fit beside it". That is the markup's text for
  the moment before the market loads, and it stood when the market never
  did. It now says the figures appear once a book prices the round, named
  by marketGwLabel(), the function the clean-sheet kicker uses.
- MARKET was set when a load succeeded and never cleared. A reload that
  found no market (the window rolled on to a round nobody has priced,
  then "Refresh team news") kept the last round's book. The team table,
  repainted after the market loads, drew the old book's table over the
  refusal in the Goals panel and named its best club in the clean-sheet
  kicker. A failed load now clears it.
- The server's refusal opens the box on the page, so it starts with a
  capital.

Each test failed on the unchanged code; four cuts, one at a time, each
turned one red. 119 tests across every file that reads the market panel,
the render gate and the CSS gate: all pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
wiki: the sweep's five texts fixed, and four rules from them
Some checks failed
CI / syntax (pull_request) Successful in 9s
CI / wheel (pull_request) Has been cancelled
CI / tests (pull_request) Has been cancelled
d45df6a4e9
The audit page moves the five texts from "found, not fixed" to a fixed
section, each finding kept as it was read off the page with what the page
says now beneath it, plus the two found checking them (9624e80) and the
rebuild check: the 113 projections that moved were all Sunderland's or
its six opponents', from one new FPL flag, none from the code.

The accuracy-panel page records that it no longer goes quiet and that the
goalscorer metric reads the banked scores. CLAUDE.md gets the FIXED line on
that entry and four rules: two absences compare equal; a status left at
its default is a claim nobody made; a failed reload clears what the last
good one set; a refusal gives the reason of the component that knows it.

Lint: 23 pages went STALE behind the two fix commits. Two gained a line
(the kicker's scored branch; /api/market reads the odds watch's
sentence); the other 21 were checked against the change and bumped.
LINT-CLEAN.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
test: the concurrency smoke test's reader no longer dies quietly
All checks were successful
CI / syntax (pull_request) Successful in 9s
CI / tests (pull_request) Successful in 1m14s
CI / wheel (pull_request) Successful in 29s
10d00180b2
Both full-suite runs of 2026-09-26 warned "Exception in thread (reader)":
PermissionError. Windows refuses an open while the writer's os.replace is
in flight, 70-80 times in 40 writes when counted, and the reader caught
only FileNotFoundError and ValueError. The thread ended at the first
refusal, after as few as 16 of its ~14,000 reads, and the test passed on
whatever had been checked by then. No run saw a torn or vanished
document once the reader kept going.

A refusal is not a torn document, so the reader reads again. Any other
way the thread ends fails the test, and the test asserts the reader
completed a read. Five runs with the thread warning raised as an error:
clean. An error injected into the reader, and the retry removed: both
red.

Not changed: nothing on the production side retries a refused read.
load_overrides and the projection cache's readers would raise the same
PermissionError if a request read during a replace; the writer's side
already retries (_replace_with_retry).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Author
Owner

Four more on the branch, all pushed. Full suite in the live checkout at d45df6a: 2187 passed, 0 skipped. 10d0018 changes one test; its file and tests/data/test_atomic.py pass (17).

bf7e50a — five texts that claimed what was not there

These 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:

  • Header: "projections current with GW6 odds and FPL flags", with no GW6 odds file. The inputs watch compared "no file" at build time with "no file" now, found them equal, and named odds that neither side held. It now says "projections current with FPL flags; no GW6 odds yet".
  • Fixtures → Clean sheets: the kicker read "book GW6: best Arsenal" while every row said "not priced". The rows had fallen back to our six-week total, and the kicker crowned that fallback's first row as the book's best. With nothing priced it now says so, and the note states the order the rows are actually in.
  • Fixtures → Goals scored: it showed "HTTP 503. Run the odds pull", while nt_watch pulls on its own. /api/market now refuses with the watch's own sentence (Chores.odds_watch_says, the one the header shows), and the page prints the server's reason.
  • Player card: it headed a two-season total "25/26 record". Mbeumo's 3,061 minutes are 2,611 from 25/26 plus 450 from GW1–5. The build now carries sample.last_season and sample.this_season beside 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.
  • Forecast accuracy: the panel said "forecasts not yet scored", with AWAITING_RESULTS on every metric, although 50 results were in. That status was the dataclass default and was never overwritten. Results in with no scorer is now NOT_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 one

Two more of the same shape, both found by checking the fixes above on the live page:

  • The Goals scored note still read "The book's expected goals for each side, with our own fit beside it" above the refusal. That is the markup's text for the moment before the market loads, and it stood when the market never did.
  • A market kept from the last load. MARKET was 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 quietly

Both full runs warned "Exception in thread (reader)". On Windows an open is refused while os.replace is 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), but load_overrides and the projection cache's readers would raise the same PermissionError if a request landed during a replace. That is rare, since overrides are hardly written and a replace takes milliseconds.

d45df6a — wiki

The audit page moves the five texts to a fixed section. CLAUDE.md gets four rules:

  • two absences compare equal;
  • a status left at its default is a claim nobody made;
  • a failed reload clears what the last good one set;
  • a refusal gives the reason of the component that knows it.

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:

  • The phone-width batch, which is next. Fixtures now scrolls sideways to 681 px at 390, because the accuracy kicker names what was scored; it was 620.
  • The round plan's "+1.0 in GW6" on a move made next week.
  • The "~8 min" rebuild literal.
  • A LAN device is offered buttons it cannot use.
  • The data files the chores rewrite drift from git.

🤖 Generated with Claude Code

Four more on the branch, all pushed. Full suite in the live checkout at `d45df6a`: **2187 passed, 0 skipped**. `10d0018` changes one test; its file and `tests/data/test_atomic.py` pass (17). ### `bf7e50a` — five texts that claimed what was not there These 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: - **Header:** "projections current with GW6 odds and FPL flags", with no GW6 odds file. The inputs watch compared "no file" at build time with "no file" now, found them equal, and named odds that neither side held. It now says "projections current with FPL flags; no GW6 odds yet". - **Fixtures → Clean sheets:** the kicker read "book GW6: best Arsenal" while every row said "not priced". The rows had fallen back to our six-week total, and the kicker crowned that fallback's first row as the book's best. With nothing priced it now says so, and the note states the order the rows are actually in. - **Fixtures → Goals scored:** it showed "HTTP 503. Run the odds pull", while `nt_watch` pulls on its own. `/api/market` now refuses with the watch's own sentence (`Chores.odds_watch_says`, the one the header shows), and the page prints the server's reason. - **Player card:** it headed a two-season total "25/26 record". Mbeumo's 3,061 minutes are 2,611 from 25/26 plus 450 from GW1–5. The build now carries `sample.last_season` and `sample.this_season` beside 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. - **Forecast accuracy:** the panel said "forecasts not yet scored", with `AWAITING_RESULTS` on every metric, although 50 results were in. That status was the dataclass default and was never overwritten. Results in with no scorer is now `NOT_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 one Two more of the same shape, both found by checking the fixes above on the live page: - **The Goals scored note** still read "The book's expected goals for each side, with our own fit beside it" above the refusal. That is the markup's text for the moment before the market loads, and it stood when the market never did. - **A market kept from the last load.** `MARKET` was 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 quietly Both full runs warned "Exception in thread (reader)". On Windows an open is refused while `os.replace` is 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`), but `load_overrides` and the projection cache's readers would raise the same `PermissionError` if a request landed during a replace. That is rare, since overrides are hardly written and a replace takes milliseconds. ### `d45df6a` — wiki The audit page moves the five texts to a fixed section. CLAUDE.md gets four rules: - two absences compare equal; - a status left at its default is a claim nobody made; - a failed reload clears what the last good one set; - a refusal gives the reason of the component that knows it. 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:** - The phone-width batch, which is next. Fixtures now scrolls sideways to 681 px at 390, because the accuracy kicker names what was scored; it was 620. - The round plan's "+1.0 in GW6" on a move made next week. - The "~8 min" rebuild literal. - A LAN device is offered buttons it cannot use. - The data files the chores rewrite drift from git. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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>
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>
wiki: the final review's findings and the market-age rule, with the numbers under it
All checks were successful
CI / syntax (pull_request) Successful in 9s
CI / tests (pull_request) Successful in 1m17s
CI / wheel (pull_request) Successful in 30s
ff6e6d1f06
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Author
Owner

Fifteen more on the branch, all pushed (61e9bec..ff6e6d1). Full suite in the live checkout at 44159af: 2257 passed, 0 skipped.

Match outcome and clean sheets are scored — by refit (#11)

  • Spec and plan: docs/superpowers/specs/2026-09-26-match-scoring-design.md and docs/superpowers/plans/2026-09-26-match-scoring.md.
  • Nine tasks, test-first:
    • backend/web/match_scoring.py;
    • scripts/score_matches.py;
    • step 4/4 in after_gameweek.py;
    • the match_scores scheduler job;
    • the panel reading the banks;
    • the panel's provenance sentence.
  • One definition each: outcome_probs and rps moved out of backtest_rps.py, which imports them. The backtests that imported them from it still get the same objects.
  • pipeline.market_lambdas is split, so one reader maps names and checks residuals for any frozen market file. Behaviour is unchanged: a rebuild moved no projection.
  • Proving red: 19 cuts, one expected survivor (a non-atomic write, which no test can force to tear).
  • The first real run was read against its pre-registration: three statements held and two failed, one of them in our favour. That result was inspected before shipping. Details on #11.

60ab678 — what a fresh-context review found

  • A malformed bank stopped every chore, the deadline snapshot included.
  • Two books were pooled under one word.
  • Exclusions went unshown, and four refusal causes were given one label.
  • The kicker lost its refit label.
  • A fixture whose home and away swapped would have been scored on the wrong side.
  • The script's report didn't name the code it ran on.
  • Two connections had no test.

18 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 deadline

GW1'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

  • The staleness digest covers only part of the build. projection_digest hashes a hand-kept PROJECTION_SOURCES that misses 24 of the 45 files in the build's import closure, among them rates.py, strengths_build.py, solio.py, depth.py, adjust.py and odds/market.py. An edit there changes projections while the header says "projections current". I'm raising it with the user as the next fix.
  • Phone width got worse. At 390px the Fixtures page now scrolls sideways to 876px (it was 681) because the accuracy kicker got longer. That is the .panel-kicker nowrap the phone batch fixes.

🤖 Generated with Claude Code

Fifteen more on the branch, all pushed (`61e9bec`..`ff6e6d1`). Full suite in the live checkout at `44159af`: **2257 passed, 0 skipped**. ### Match outcome and clean sheets are scored — by refit (#11) - **Spec and plan:** `docs/superpowers/specs/2026-09-26-match-scoring-design.md` and `docs/superpowers/plans/2026-09-26-match-scoring.md`. - **Nine tasks, test-first:** - `backend/web/match_scoring.py`; - `scripts/score_matches.py`; - step 4/4 in `after_gameweek.py`; - the `match_scores` scheduler job; - the panel reading the banks; - the panel's provenance sentence. - **One definition each:** `outcome_probs` and `rps` moved out of `backtest_rps.py`, which imports them. The backtests that imported them from it still get the same objects. - **`pipeline.market_lambdas`** is split, so one reader maps names and checks residuals for any frozen market file. Behaviour is unchanged: a rebuild moved no projection. - **Proving red:** 19 cuts, one expected survivor (a non-atomic write, which no test can force to tear). - **The first real run was read against its pre-registration:** three statements held and two failed, one of them in our favour. That result was inspected before shipping. Details on #11. ### `60ab678` — what a fresh-context review found - A malformed bank stopped every chore, the deadline snapshot included. - Two books were pooled under one word. - Exclusions went unshown, and four refusal causes were given one label. - The kicker lost its refit label. - A fixture whose home and away swapped would have been scored on the wrong side. - The script's report didn't name the code it ran on. - Two connections had no test. 18 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 deadline GW1'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 - **The staleness digest covers only part of the build.** `projection_digest` hashes a hand-kept `PROJECTION_SOURCES` that misses 24 of the 45 files in the build's import closure, among them `rates.py`, `strengths_build.py`, `solio.py`, `depth.py`, `adjust.py` and `odds/market.py`. An edit there changes projections while the header says "projections current". I'm raising it with the user as the next fix. - **Phone width got worse.** At 390px the Fixtures page now scrolls sideways to 876px (it was 681) because the accuracy kicker got longer. That is the `.panel-kicker` nowrap the phone batch fixes. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
projection_digest hashed a hand-kept list, PROJECTION_SOURCES, which
covered 21 of the 45 files in the build's import closure. rates.py,
strengths_build.py, solio.py, depth.py, adjust.py and odds/market.py were
among the 24 it missed: an edit to any of them changed every projection
while the header said "projections current". The list had been narrowed
so that a docstring edit would stop raising the warning (2026-09-20).

It now reads the import closure itself (projection_digest_files), and
hashes each file as its syntax tree with docstrings removed: comments,
formatting and line endings never reach the tree, so prose still moves
nothing while any code the build imports does. The root is found from the
module, never the working directory, and an empty closure refuses. The
match scorer's code digest uses the same function (review Minor 1: it
read ".", and a process started elsewhere hashed nothing).

Five cuts, five red. The digest's value changes once, so the live cache
reads stale until it is rebuilt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The review of what the app scores itself on (2026-09-26), items 2-5:

- Expected points, the app's central number, were scored nowhere. The
  snapshots store what the app showed before each deadline, so
  score_players.py scores it against what each player scored, beside
  FPL's own expected points (ep_next, from the same frozen bootstrap, and
  only when that bootstrap was for the round). A double gameweek sums its
  fixtures and has no 80% range, since a range of a sum is not a sum of
  ranges. First run, GW1/2/5, 1,267 player-rounds: error level with FPL
  (1.737 v 1.758), ranking ahead in all three rounds (0.53/0.55/0.68 v
  0.40/0.41/0.63), the 80% range held 85%.
- The chance of starting (start_prob x availability), against "started
  last round" and last round's start rate. GW2 and GW5, 1,269 players:
  0.0968 v 0.1151, ahead with the interval clear of zero.
- Every comparison carries a verdict from a paired bootstrap interval:
  ahead, behind, or level. The match tiles read "level with the market".
- The two empty sections are filled, each naming what it counts: the
  disagreement tally from the clean-sheet forecasts on the rounds the
  market was the benchmark for, and the calibration chart from the chance
  of starting. Both are computed in the scorers' summaries; the panel only
  reads (test_the_panel_computes_no_score_itself still holds).
- The scheduler keeps both scorers current (ROUND_SCORERS), and the weekly
  chore runs the player scorer as step 5/5.

Sixteen cuts, sixteen red.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
wiki: expected points and the chance of starting are scored, and the digest reads the import closure
All checks were successful
CI / syntax (pull_request) Successful in 8s
CI / tests (pull_request) Successful in 1m28s
CI / wheel (pull_request) Successful in 30s
715598ee84
CLAUDE.md gains the rule (a difference without an interval is not a
finding; restart the server at once after changing how a digest is
computed). The accuracy-panel page carries the first run's numbers:
expected points level with FPL's ep_next, the chance of starting ahead
of "started last round".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Author
Owner

Three more on the branch, all pushed (fd0b2c8..715598e). Full suite in the live checkout at e5c1f77: 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 imports

projection_digest hashed 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.py and odds/market.py were 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 verdict

Details on #11. In short: expected points are level with FPL's ep_next on 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: wiki

The 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

Three more on the branch, all pushed (`fd0b2c8`..`715598e`). Full suite in the live checkout at `e5c1f77`: **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 imports `projection_digest` hashed 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.py` and `odds/market.py` were 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 verdict Details on #11. In short: expected points are level with FPL's `ep_next` on 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`: wiki The 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](https://claude.com/claude-code)
docs: page simplification design — one question per tab
All checks were successful
CI / syntax (pull_request) Successful in 8s
CI / tests (pull_request) Successful in 1m37s
CI / wheel (pull_request) Successful in 28s
bf923b2e95
Direction B, chosen 2026-09-26: the header becomes the deadline and one
status light, an absence is stated once where it is true, method text
sits behind an ⓘ, DESIGN_PLAN phase 11's type steps with nothing
carrying data under 12 px, one meaning per hue, one page width. Squad
becomes one column, Forecast accuracy moves to a Record tab, Matches is
a compact list until prices arrive. Five sessions, one surface each,
against a baseline measured on the live page at 715598e.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
docs: plan for page simplification session 1, the header and status light
Some checks failed
CI / syntax (pull_request) Successful in 8s
CI / wheel (pull_request) Has been cancelled
CI / tests (pull_request) Has been cancelled
aaedf8b913
Four tasks: correct the spec where planning found it wrong; the
derivation (statusLight, one sync wording, the type steps in tokens.css);
the header itself with its tests rewritten to guard the same things in the
new form; then a 19-cut mutation pass, the version bump, the full suite, a
look at the live page, and the record. To run in a fresh session: CLAUDE.md,
one surface per session.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
docs: the header plan pushes a restore tag before its first commit, as the spec asks
All checks were successful
CI / syntax (pull_request) Successful in 9s
CI / tests (pull_request) Successful in 1m34s
CI / wheel (pull_request) Successful in 28s
eada7ff8ab
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Quiet is a claim, made only when every input it rests on was read: a
missing input is 'status incomplete', no status is 'status unknown'. The
sync wording is one definition, shared by the Squad strip. The five type
steps from DESIGN_PLAN phase 11 go in tokens.css.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The title, the next deadline and a light that is amber, with the reason
on it, only when something needs you: a stale cache, a blocked chore or
missed snapshot, a failed or unconfigured FPL sync, a server whose code
differs from the projections', or a failed load. Everything else the
header carried is in the status panel. The amber engine-notes bar is
gone; its lines are Build notes in the panel. Under 24 hours the deadline
is --alert, which tokens.css reserved for it and nothing used.

The panel's names are stp-: sp- already belonged to the squad spend
bars, and a later .sp-row set the panel's text to 11.52px. Header 88px
tall at 1600 and 284px at 390 before; 43px and 69px after, on the live
page. The render gate was re-baselined after every one of its 48
(tab, width) combinations was checked: only the header differs, and
BODY's height with it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
chore: asset version 187 for the header
All checks were successful
CI / syntax (pull_request) Successful in 8s
CI / tests (pull_request) Successful in 1m36s
CI / wheel (pull_request) Successful in 28s
571f344dd8
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
wiki: the page simplification pass, session 1 — the header and its evidence
All checks were successful
CI / syntax (pull_request) Successful in 8s
CI / tests (pull_request) Successful in 1m37s
CI / wheel (pull_request) Successful in 29s
996f7bdd0a
CLAUDE.md gains the rule: quiet is a claim, a token reserved for a
purpose is not a behaviour, and a class name is searched for before use.
The new page holds the baseline and session 1's before and after (header
88 -> 43 px at 1600, 284 -> 69 px at 390, 515 -> 70 characters), the four
spec corrections and the sp- collision. The render-gate page records that
its report is a sample. DESIGN_PLAN phase 12's first item is marked done.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
fix: what the final review of the header found
Some checks failed
CI / syntax (pull_request) Successful in 9s
CI / wheel (pull_request) Has been cancelled
CI / tests (pull_request) Has been cancelled
98dfc53d1a
- C1: /api/status put a corrupt cache and a server started without its
  data among the build notes, which the header draws grey, so the light
  read "nothing needs you" over them. They are served as `faults` now,
  apart from the notes (the split `cache` already made), and the light
  requires and reads them.
- I1: after a failed reload the panel went on showing the last good
  status; each row now says it was not read.
- I2: the red error banner was never cleared; a fresh load clears it.
- I3: the light and the Squad strip read the sync state from two
  payloads fetched at different times; both read one reading now, the
  latest of /api/status and /api/squad.
- I4: the light was read once, at page load; it is read again every five
  minutes and whenever the page is shown again, and the panel says when.

Each fix has a test that failed first (16 of them red before the fixes).
Full suite 2344 passed, 0 skipped. Asset version 188, bumped in this
commit rather than after it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
wiki: what the final review of the header found, and the rule it extends
All checks were successful
CI / syntax (pull_request) Successful in 9s
CI / tests (pull_request) Successful in 1m42s
CI / wheel (pull_request) Successful in 28s
03f7c58a6f
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
wiki: heal the pages the header's after-midnight fix made stale
All checks were successful
CI / syntax (pull_request) Successful in 9s
CI / tests (pull_request) Successful in 1m36s
CI / wheel (pull_request) Successful in 30s
CI / syntax (push) Successful in 8s
CI / tests (push) Successful in 1m35s
CI / wheel (push) Successful in 31s
ec0cdcefc6
Four corrected where they state the present, one gains where the sync
state shows, eleven re-verified and their dates bumped.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Author
Owner

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 at 98dfc53: 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":

  • Spec: docs/superpowers/specs/2026-09-26-page-simplification-design.md.
  • It runs in five sessions, one surface each: header, Squad, Matches, Fixtures + a new Record tab, Players.
  • Each session writes its own plan. Session 1's is docs/superpowers/plans/2026-09-26-header-status-light.md.
  • The restore point before it is the tag restore/pre-simplify-header-2026-09-26.

Session 1: the header is the deadline and one status light (ec0e08f..98dfc53)

  • One line: the title, the next deadline (red under 24 h) and a light.
    • The light is amber, with the reason written on it, only when something needs you: stale projections, a fault, a blocked chore or missed snapshot, a failed or unconfigured FPL sync, a server whose code differs from the projections', or a failed load.
    • It says "nothing needs you" only after every input it rests on was read. A missing input reads "status incomplete: no ", and no status at all reads "status unknown: ".
  • Everything else the header carried is one tap away in a status panel: build time, goal-rate provenance, server sha, the chores line, and the Refresh / Rebuild / Firm up buttons.
  • The amber "⚠ N engine notes" bar is gone. Its lines are "Build notes" in the panel.
  • Measured live: the header went from 88 to 43 px at 1600, and from 284 to 69 px at 390, where content now starts at 125 px instead of 366. Its text went from 515 to 70 characters.
  • Proof: 19 mutation cuts, 19 red. The render gate was re-baselined only after a sweep of all 48 (tab, width) combinations found every difference inside the header (plus BODY's height).

API change you may care about: /api/status now serves faults apart from warnings (98dfc53, backend/web/main.py). cache integrity: … and startup: 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 way cache already was, and the light requires and reads faults. warnings now carries the build's own notes only.

Found on the way:

  • The spec was wrong in four places, corrected in ec0e08f. For example, it said the deadline "already" turned red under 24 h: tokens.css reserved --alert for that and nothing used it.
  • The plan's sp- class prefix collided with the squad spend bars' .sp-row; the panel uses stp-.

The final review (a fresh-context reviewer) returned "with fixes": one Critical (the faults above) and four Important:

  • the panel kept the last good status after a failed reload;
  • the red error banner was never cleared;
  • the light and the Squad strip read the sync state from two payloads;
  • the light was read only at page load.

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, and docs/DESIGN_PLAN.md phase 12's first item is marked done.

Next: session 2, the Squad tab (one column), in a fresh 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 at `98dfc53`: **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": - Spec: `docs/superpowers/specs/2026-09-26-page-simplification-design.md`. - It runs in five sessions, one surface each: header, Squad, Matches, Fixtures + a new Record tab, Players. - Each session writes its own plan. Session 1's is `docs/superpowers/plans/2026-09-26-header-status-light.md`. - The restore point before it is the tag `restore/pre-simplify-header-2026-09-26`. ### Session 1: the header is the deadline and one status light (`ec0e08f`..`98dfc53`) - **One line:** the title, the next deadline (red under 24 h) and a light. - The light is amber, with the reason written on it, only when something needs you: stale projections, a fault, a blocked chore or missed snapshot, a failed or unconfigured FPL sync, a server whose code differs from the projections', or a failed load. - It says "nothing needs you" only after every input it rests on was read. A missing input reads "status incomplete: no <key>", and no status at all reads "status unknown: <why>". - **Everything else the header carried is one tap away** in a status panel: build time, goal-rate provenance, server sha, the chores line, and the Refresh / Rebuild / Firm up buttons. - **The amber "⚠ N engine notes" bar is gone.** Its lines are "Build notes" in the panel. - **Measured live:** the header went from 88 to 43 px at 1600, and from 284 to 69 px at 390, where content now starts at 125 px instead of 366. Its text went from 515 to 70 characters. - **Proof:** 19 mutation cuts, 19 red. The render gate was re-baselined only after a sweep of all 48 (tab, width) combinations found every difference inside the header (plus BODY's height). **API change you may care about:** `/api/status` now serves **`faults`** apart from `warnings` (`98dfc53`, `backend/web/main.py`). `cache integrity: …` and `startup: 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 way `cache` already was, and the light requires and reads `faults`. `warnings` now carries the build's own notes only. **Found on the way:** - The spec was wrong in four places, corrected in `ec0e08f`. For example, it said the deadline "already" turned red under 24 h: `tokens.css` reserved `--alert` for that and nothing used it. - The plan's `sp-` class prefix collided with the squad spend bars' `.sp-row`; the panel uses `stp-`. **The final review** (a fresh-context reviewer) returned "with fixes": one Critical (the faults above) and four Important: - the panel kept the last good status after a failed reload; - the red error banner was never cleared; - the light and the Squad strip read the sync state from two payloads; - the light was read only at page load. 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`, and `docs/DESIGN_PLAN.md` phase 12's first item is marked done. **Next:** session 2, the Squad tab (one column), in a fresh session. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Author
Owner

Merged into main, at the user's request, and pushed: main is now ec0cdce, the head of this branch.

  • A fast-forward: main was already contained in the branch (origin/main 56a5026 and the six wiki-kit commits of 12 September), so there is no merge commit and there were no conflicts. The push was 56a5026..ec0cdce.
  • The full suite on main after the merge, in the live checkout: 2344 passed, 0 skipped.
  • The branch: the local feat/captaincy-board is deleted, and origin/feat/captaincy-board is left as it is.
  • This PR still shows as open: the merge happened outside it, and Forgejo did not detect it on its own. Closing it is the user's call, or yours.
  • New work (page simplification session 2, the Squad tab) goes on a new branch off main.

🤖 Generated with Claude Code

**Merged into `main`**, at the user's request, and pushed: `main` is now `ec0cdce`, the head of this branch. - **A fast-forward:** `main` was already contained in the branch (origin/main `56a5026` and the six wiki-kit commits of 12 September), so there is no merge commit and there were no conflicts. The push was `56a5026..ec0cdce`. - **The full suite on `main` after the merge,** in the live checkout: **2344 passed, 0 skipped**. - **The branch:** the local `feat/captaincy-board` is deleted, and `origin/feat/captaincy-board` is left as it is. - **This PR still shows as open:** the merge happened outside it, and Forgejo did not detect it on its own. Closing it is the user's call, or yours. - **New work** (page simplification session 2, the Squad tab) goes on a new branch off `main`. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Author
Owner

Page simplification, session 2 of 5: the Squad tab is one column. It was merged into main at the user's request and pushed (ec0cdce..c8d917a).

  • The tab: the stat strip, the round plan, the pitch and bench, then one link.
    • The strip keeps the bank, the flagged count and the scoreboard. The flagged count opens a list of who is flagged and why, and each row names the player's position and club. "n/15" shows only when the squad is short (337e035). The deadline and the sync state have been in the header since session 1.
    • The league-wide best captains moved from the right rail into the round plan's captain row. It is a fold-out for the plan's round, without the stars (87ca278).
    • The "Who else is out there" pool is gone. In its place, "Browse the N players you don't own →" opens Players with a new Not in my squad filter on, at the top of the page (5c3c8eb, 6c8653f).
    • At 1600 px Squad is 1,186 px tall and 1,160 px wide. In three columns it was 1,123 px tall and about 1,450 px wide, and the spec's target is at most 1,200 px tall. Nothing scrolls sideways at 390 px.
  • Proof:
    • A mutation pass of 21 cuts, all red.
    • One cut survived its first run: the captain row's success branch had no test, because no stub serves /api/captaincy. That branch has its test now (ea7e8d9).
    • The live check at 390 px found the link landing at the end of the Players table. It was fixed in 6c8653f.
  • A fresh-context review returned "with fixes". They are in one pass, 709ce66:
    • four guards that could not fail now can;
    • the captains fold-out shows for a squad that is not full;
    • the flagged count keeps keyboard focus;
    • two players with one name are told apart (the live squad has two Palmers);
    • the plan's fold-out summaries are on the 12 px type step.
  • The full suite on main after the merge, in the live checkout: 2359 passed, 0 skipped.
  • Next: the Squad tab's type sizes (the spec's R5) get a short session of their own before Matches, as the user chose. The spec asks each session to move its surface onto the five type steps, but it also says the pitch and bench stay unchanged. Screenshots of the pitch cards at 12 px therefore come first. Session 3, Matches, follows.
  • Evidence and the before-and-after tables: wiki/notes/page-simplification-2026-09.md.
  • This PR still shows as open, because the merges happened outside it. Closing it is the user's call, or yours.

🤖 Generated with Claude Code

**Page simplification, session 2 of 5: the Squad tab is one column.** It was merged into `main` at the user's request and pushed (`ec0cdce..c8d917a`). - **The tab:** the stat strip, the round plan, the pitch and bench, then one link. - The strip keeps the bank, the flagged count and the scoreboard. The flagged count opens a list of who is flagged and why, and each row names the player's position and club. "n/15" shows only when the squad is short (`337e035`). The deadline and the sync state have been in the header since session 1. - The league-wide best captains moved from the right rail into the round plan's captain row. It is a fold-out for the plan's round, without the stars (`87ca278`). - The "Who else is out there" pool is gone. In its place, "Browse the N players you don't own →" opens Players with a new **Not in my squad** filter on, at the top of the page (`5c3c8eb`, `6c8653f`). - At 1600 px Squad is 1,186 px tall and 1,160 px wide. In three columns it was 1,123 px tall and about 1,450 px wide, and the spec's target is at most 1,200 px tall. Nothing scrolls sideways at 390 px. - **Proof:** - A mutation pass of 21 cuts, all red. - One cut survived its first run: the captain row's success branch had no test, because no stub serves `/api/captaincy`. That branch has its test now (`ea7e8d9`). - The live check at 390 px found the link landing at the end of the Players table. It was fixed in `6c8653f`. - **A fresh-context review** returned "with fixes". They are in one pass, `709ce66`: - four guards that could not fail now can; - the captains fold-out shows for a squad that is not full; - the flagged count keeps keyboard focus; - two players with one name are told apart (the live squad has two Palmers); - the plan's fold-out summaries are on the 12 px type step. - **The full suite on `main` after the merge,** in the live checkout: **2359 passed, 0 skipped**. - **Next:** the Squad tab's type sizes (the spec's R5) get a short session of their own before Matches, as the user chose. The spec asks each session to move its surface onto the five type steps, but it also says the pitch and bench stay unchanged. Screenshots of the pitch cards at 12 px therefore come first. Session 3, Matches, follows. - **Evidence** and the before-and-after tables: `wiki/notes/page-simplification-2026-09.md`. - **This PR still shows as open,** because the merges happened outside it. Closing it is the user's call, or yours. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
solvreven closed this pull request 2026-09-27 17:24:01 +02:00
Author
Owner

Closed at the user's request; the work is merged, not dropped. This branch went into main outside the PR, as a fast-forward to its head ec0cdce, which main contains (main is now c8d917a). 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

**Closed at the user's request; the work is merged, not dropped.** This branch went into `main` outside the PR, as a fast-forward to its head `ec0cdce`, which `main` contains (`main` is now `c8d917a`). 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](https://claude.com/claude-code)
Author
Owner

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 main at the user's request and pushed (c8d917a..75724c7).

  • Session 2b (62a1e89..eaa927b, wiki d5af8bf). 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).
    • Type: the tab uses 4 text sizes instead of 9: 11, 12, 14 and 20 px (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.
    • A transfer leads with its verdict: "NOW · GOOD MOVE · +7.5 over GW6–11", with the reasons one tap down (99e9d69).
    • The captain row is one clause, with CLOSE CALL as a chip and the odds one tap down (6116cc9).
    • The XI is one line: "your XI 42.4 → 49.0 with the plan" (173277c). Before, three figures were printed, and the XI to field was not printed anywhere.
    • The flags are grouped by cause, the most urgent first (d6eb08f).
    • Height: the pane is 1,145 px tall at 1600 px (it was 1,186) and 1,429 px at 390 (it was 1,569).
  • Proof for 2b: ten mutation cuts, all red. A fresh-context review returned "with fixes", and the fix pass is 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.
  • The fix, reported by the user (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:

      • it squeezed the two buttons onto two lines each;
      • it was 10.56 px, at 3.0:1 contrast on the grass;
      • a timer removed it after 4 s, and any re-render removed it sooner.

      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.

  • The full suite on main after the merge, in the live checkout: 2386 passed, 0 skipped.
  • Next: a spec for two add-ons, price changes and a chip planner, is with the user for review. It is not committed yet. Session 3 of the simplification, Matches, follows.
  • Evidence: wiki/notes/page-simplification-2026-09.md (2b) and wiki/systems/round-plan-on-the-squad-tab.md (the fix).

🤖 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 `main` at the user's request and pushed (`c8d917a..75724c7`). - **Session 2b** (`62a1e89`..`eaa927b`, wiki `d5af8bf`). 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`). - **Type:** the tab uses 4 text sizes instead of 9: 11, 12, 14 and 20 px (`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. - **A transfer leads with its verdict:** "NOW · GOOD MOVE · +7.5 over GW6–11", with the reasons one tap down (`99e9d69`). - **The captain row is one clause,** with CLOSE CALL as a chip and the odds one tap down (`6116cc9`). - **The XI is one line:** "your XI 42.4 → 49.0 with the plan" (`173277c`). Before, three figures were printed, and the XI to field was not printed anywhere. - **The flags are grouped by cause,** the most urgent first (`d6eb08f`). - **Height:** the pane is 1,145 px tall at 1600 px (it was 1,186) and 1,429 px at 390 (it was 1,569). - **Proof for 2b:** ten mutation cuts, all red. A fresh-context review returned "with fixes", and the fix pass is `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. - **The fix, reported by the user** (`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: - it squeezed the two buttons onto two lines each; - it was 10.56 px, at 3.0:1 contrast on the grass; - a timer removed it after 4 s, and any re-render removed it sooner. 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. - **The full suite on `main` after the merge,** in the live checkout: **2386 passed, 0 skipped**. - **Next:** a spec for two add-ons, price changes and a chip planner, is with the user for review. It is not committed yet. Session 3 of the simplification, Matches, follows. - **Evidence:** `wiki/notes/page-simplification-2026-09.md` (2b) and `wiki/systems/round-plan-on-the-squad-tab.md` (the fix). 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Author
Owner

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 main at the user's request and pushed (75724c7..20c5f41).

  • Prices (FPL's own predictor, new in 2026/27, read from the live bootstrap):
    • /api/players carries each player's price_change with one state per cause (PROJECTED, LOCKED, CALIBRATING, MISSING), plus price_change_status and the read time.
    • A planned move gets a RISES TONIGHT tag when FPL projects the player bought past the threshold tonight (likelihood +5). It gets FALLS TONIGHT when the player sold is projected past the fall threshold and the fall costs money.
    • The reasons are one tap down. They say whose projection it is and when FPL was read, and whether tonight's change leaves the move too little money.
    • The strip names who in the fifteen FPL holds falls tonight, and what each would lose in selling value. That is FPL's half-profit rule on the synced purchase price, served as sell_after_fall on /api/squad. It stays quiet only when all fifteen were read.
  • The price watch (backend/web/price_watch.py):
    • It banks FPL's projection from every live read and judges each night against FPL's documented 00:00 UK run, two hours after it, by now_cost.
    • The 25 October clock change is pinned by a test.
    • The tally is kept in data/prices/ (gitignored) for the Record tab.
  • Chips:
    • The sync keeps the chips played.
    • /api/squad serves each chip's window, the round it was played in, and its last deadline, with one label per cause.
    • GET /api/chips values the chips left over the projected rounds:
      • Bench Boost: the bench with the plan's lineup.
      • Triple Captain: the armband's pick, with its chance of 10+.
      • Free Hit: the best eleven within FPL's money, minus the plan's (optimize.best_xi_within).
      • Wildcard: the fifteen "Complete my squad" picks with nothing kept, minus the squad with the plan's moves (optimize.best_squad; autocomplete is unchanged and draws from the same pool).
    • The round plan has a fourth row. It shows the deadline and the best round in view, never "play it now".
  • A fix underneath: _deps["fetched_at"] never moved with the five-minute refresher, so "read at" named the last manual refresh or process start. _live_deps now returns the time with the data (28c7cb4).
  • Corrections to the spec, found while planning (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.
  • Checks:
    • 22 of 22 planned mutation cuts went red.
    • A fresh-context final review said "with fixes" and found no Critical issues. Its three Important findings and one re-graded Minor were fixed in f3f810b, each with a test that failed first.
    • Full suite on the merged main: 2475 passed, 0 skipped.
    • Live on 2026-10-02: the Chips row reads "Wildcard by GW19, Fri 1 Jan, 19:30 · worth +16.2 over GW6–11 if played now", against a pre-registered +12 to +30. It read +16.1 before the fix pass. No price tags appear, since nobody is at ±5 during the international break.
  • Not done, and reported:
    • captaincy._row_for reads 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.
    • Seven minors from the review are deferred and listed in the wiki page wiki/systems/prices-and-chips-on-the-squad-tab.md.
    • A pre-existing order-dependent test leak: test_the_suite_never_starts_the_chores.py leaves startup_warning set, and two test_api.py status tests fail when they run after it.

🤖 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 `main` at the user's request and pushed (`75724c7..20c5f41`). - **Prices** (FPL's own predictor, new in 2026/27, read from the live bootstrap): - `/api/players` carries each player's `price_change` with one state per cause (PROJECTED, LOCKED, CALIBRATING, MISSING), plus `price_change_status` and the read time. - A planned move gets a **RISES TONIGHT** tag when FPL projects the player bought past the threshold tonight (likelihood +5). It gets **FALLS TONIGHT** when the player sold is projected past the fall threshold and the fall costs money. - The reasons are one tap down. They say whose projection it is and when FPL was read, and whether tonight's change leaves the move too little money. - The strip names who in the fifteen FPL holds falls tonight, and what each would lose in selling value. That is FPL's half-profit rule on the synced purchase price, served as `sell_after_fall` on `/api/squad`. It stays quiet only when all fifteen were read. - **The price watch** (`backend/web/price_watch.py`): - It banks FPL's projection from every live read and judges each night against FPL's documented 00:00 UK run, two hours after it, by `now_cost`. - The 25 October clock change is pinned by a test. - The tally is kept in `data/prices/` (gitignored) for the Record tab. - **Chips**: - The sync keeps the chips played. - `/api/squad` serves each chip's window, the round it was played in, and its last deadline, with one label per cause. - `GET /api/chips` values the chips left over the projected rounds: - Bench Boost: the bench with the plan's lineup. - Triple Captain: the armband's pick, with its chance of 10+. - Free Hit: the best eleven within FPL's money, minus the plan's (`optimize.best_xi_within`). - Wildcard: the fifteen "Complete my squad" picks with nothing kept, minus the squad with the plan's moves (`optimize.best_squad`; `autocomplete` is unchanged and draws from the same pool). - The round plan has a fourth row. It shows the deadline and the best round in view, never "play it now". - **A fix underneath:** `_deps["fetched_at"]` never moved with the five-minute refresher, so "read at" named the last manual refresh or process start. `_live_deps` now returns the time with the data (`28c7cb4`). - **Corrections to the spec, found while planning** (`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`. - **Checks:** - 22 of 22 planned mutation cuts went red. - A fresh-context final review said "with fixes" and found no Critical issues. Its three Important findings and one re-graded Minor were fixed in `f3f810b`, each with a test that failed first. - Full suite on the merged `main`: 2475 passed, 0 skipped. - Live on 2026-10-02: the Chips row reads "Wildcard by GW19, Fri 1 Jan, 19:30 · worth +16.2 over GW6–11 if played now", against a pre-registered +12 to +30. It read +16.1 before the fix pass. No price tags appear, since nobody is at ±5 during the international break. - **Not done, and reported:** - `captaincy._row_for` reads 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. - Seven minors from the review are deferred and listed in the wiki page `wiki/systems/prices-and-chips-on-the-squad-tab.md`. - A pre-existing order-dependent test leak: `test_the_suite_never_starts_the_chores.py` leaves `startup_warning` set, and two `test_api.py` status tests fail when they run after it. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Author
Owner

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 main at the user's request and pushed (20c5f41..3e1ca1c). The restore point is the tag restore/pre-simplify-matches-2026-10-02 (20c5f41).

  • Measured on the live page with nothing priced (the international break), before 20c5f41 and after:
    • height at 1600 px: 2,269 → 1,439 (the spec's target is at most 1,500);
    • page width at 390: 417 → 390 px, so a phone no longer scrolls sideways;
    • text under 12 px: 74% → 6%, which is the 11 px capitals only; six font sizes → three;
    • one absence was stated 60 times (10 "no price" badges, 10 "MARKET · no prices", 20 "—", 20 "not priced"), plus three panel lines. It is now one line.
  • What changed:
    • Ten 167 px cards became a list, one row a match (a971825). The result split left the FDR ramp: the likeliest outcome is marked and drawn in the strongest text colour.
    • In a priced round, the book's line sits above ours on each row (e07c60d). A match the book has not priced keeps one "no price" mark.
    • The tab says what is not priced once, by cause: "could not be read", "kicked off", or nothing priced yet. The method sits behind an ⓘ (9ad95e4).
    • The scorer and assister tables draw a Book column only when the book priced, lead with the number their rows are ordered by, and show ten rows (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.
    • With nothing priced, a match's "Players ›" shows ours (b0e0763). The backend's fallback no longer cuts the scorer list to twenty; live it serves 307 scorers across all 20 clubs.
    • Goals scored moved from Fixtures to Matches and is built from the match rows (c90d57a), so it shows the round being played. /api/market serves the next deadline's round.
    • Every text is on the five type steps (3c768b6).
  • Corrections to the spec, found while planning (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; cb094e0 fixes that.
  • Departures from the spec, ruled and recorded: Goals scored shows ten sides, not twenty, since twenty rows beside two tables of ten stretched both into empty boxes. A match's result odds and goal lines moved one tap down, into its "Players ›".
  • Checks:
    • 38 of 38 mutation cuts went red, logged after the fix pass. The first logged run was 27 of 30: two guards could not fail, and 4fa4bdb gave them tests that can.
    • A fresh-context final review said "with fixes" and found nothing Critical. Its three Important findings and one re-graded Minor were fixed in bdccd5f, each with a test that failed first:
      • the priced tables hid the ▲/▼ gap marker at every desktop width, while the ⓘ explained it. No test had seen it, because the stub priced every book at 1.1 times ours, so no gap ever rendered;
      • the unpriced tables' "if he plays" figure had no guard;
      • /api/market answers 503 both when nothing is priced and when it fails, so the page read every failure as "not priced". Its not-priced refusal now carries X-Market-State: NOT_PRICED;
      • a new test started Playwright outside the harness that closes its browser.
    • Full suite on the merged main, in the live checkout: 2530 passed, 0 skipped.
    • Live on the evening of 2026-10-02, Norsk Tipping priced all ten GW6 matches and no players. Every row carried the book's line, each table said once that players were not priced, and the tab was 1,721 px tall at 1600. Priced players have only been seen in the stubbed page tests so far.
  • Not done, and reported:
    • Eleven Minors from the review are deferred and listed in 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".
    • An observation outside this branch: with the matches priced, ours "if he plays" can read below ours with rotation included (Mbeumo 45% against 46%), and both cannot be right. The first is the simulator on the book's goal rates, the second the closed form on our fit.
  • Next: session 4, the Fixtures tab and a new Record tab. Its plan is being written from the spec first, for the user's review.
  • Evidence: 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 3: the Matches tab is one row a match, and an absence is said once, by its cause.** The work was merged into `main` at the user's request and pushed (`20c5f41..3e1ca1c`). The restore point is the tag `restore/pre-simplify-matches-2026-10-02` (`20c5f41`). - **Measured on the live page with nothing priced** (the international break), before `20c5f41` and after: - height at 1600 px: 2,269 → 1,439 (the spec's target is at most 1,500); - page width at 390: 417 → 390 px, so a phone no longer scrolls sideways; - text under 12 px: 74% → 6%, which is the 11 px capitals only; six font sizes → three; - one absence was stated 60 times (10 "no price" badges, 10 "MARKET · no prices", 20 "—", 20 "not priced"), plus three panel lines. It is now one line. - **What changed:** - Ten 167 px cards became a list, one row a match (`a971825`). The result split left the FDR ramp: the likeliest outcome is marked and drawn in the strongest text colour. - In a priced round, the book's line sits above ours on each row (`e07c60d`). A match the book has not priced keeps one "no price" mark. - The tab says what is not priced once, by cause: "could not be read", "kicked off", or nothing priced yet. The method sits behind an ⓘ (`9ad95e4`). - The scorer and assister tables draw a Book column only when the book priced, lead with the number their rows are ordered by, and show ten rows (`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. - With nothing priced, a match's "Players ›" shows ours (`b0e0763`). The backend's fallback no longer cuts the scorer list to twenty; live it serves 307 scorers across all 20 clubs. - Goals scored moved from Fixtures to Matches and is built from the match rows (`c90d57a`), so it shows the round being played. `/api/market` serves the next deadline's round. - Every text is on the five type steps (`3c768b6`). - **Corrections to the spec, found while planning** (`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`; `cb094e0` fixes that. - **Departures from the spec, ruled and recorded:** Goals scored shows ten sides, not twenty, since twenty rows beside two tables of ten stretched both into empty boxes. A match's result odds and goal lines moved one tap down, into its "Players ›". - **Checks:** - 38 of 38 mutation cuts went red, logged after the fix pass. The first logged run was 27 of 30: two guards could not fail, and `4fa4bdb` gave them tests that can. - A fresh-context final review said "with fixes" and found nothing Critical. Its three Important findings and one re-graded Minor were fixed in `bdccd5f`, each with a test that failed first: - the priced tables hid the ▲/▼ gap marker at every desktop width, while the ⓘ explained it. No test had seen it, because the stub priced every book at 1.1 times ours, so no gap ever rendered; - the unpriced tables' "if he plays" figure had no guard; - `/api/market` answers 503 both when nothing is priced and when it fails, so the page read every failure as "not priced". Its not-priced refusal now carries `X-Market-State: NOT_PRICED`; - a new test started Playwright outside the harness that closes its browser. - Full suite on the merged `main`, in the live checkout: **2530 passed, 0 skipped**. - Live on the evening of 2026-10-02, Norsk Tipping priced all ten GW6 matches and no players. Every row carried the book's line, each table said once that players were not priced, and the tab was 1,721 px tall at 1600. Priced players have only been seen in the stubbed page tests so far. - **Not done, and reported:** - Eleven Minors from the review are deferred and listed in `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". - An observation outside this branch: with the matches priced, ours "if he plays" can read below ours with rotation included (Mbeumo 45% against 46%), and both cannot be right. The first is the simulator on the book's goal rates, the second the closed form on our fit. - **Next:** session 4, the Fixtures tab and a new Record tab. Its plan is being written from the spec first, for the user's review. - **Evidence:** `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](https://claude.com/claude-code)
Author
Owner

Page simplification, session 4: the Fixtures tab, and a new Record tab ("Can I trust the app?"). It was merged into main at the user's request and pushed (3e1ca1c..521240a). There is no restore tag this time; the restore point is 3e1ca1c.

  • Measured on the live page, before (3e1ca1c) and after (bcf3dc6). Visible text was read with checkVisibility().
    • Fixtures at 1600 px: 2,257 → 1,596 px tall. The pre-registered range was 1,550–1,700.
    • Page width at 390: 1,125 → 390 px, so a phone no longer scrolls sideways.
    • Text under 12 px: 85% → 3%, which is the 11 px capitals only.
    • Font sizes: eight → three. Text in the dimmest grey: 36% → 26%.
    • Forecast accuracy was a closed fold at the foot of Fixtures. It is now its own tab, with every fold open; 5% of its text is under 12 px.
  • What changed:
    • A fifth tab, Record. It shows four panels, and the Squad strip's scoreboard opens it (58c6977, 573206a, 4bb8c73, f11e0d9):
      • the scoreboard first;
      • your rounds against everyone (add-on 6: your points, FPL's average, the top score and the most captained, GW1–5);
      • Forecast accuracy;
      • FPL's price projections, scored night by night.
    • The FDR grid sits in the content column. A cell is one line ("LEE 3"): home in capitals, away in lower case, with no crest (88d7f19).
    • Clean sheets shows ours over the projection window, for every club, in one column. A club with a blank or a double round says "5 matches" or "7 matches". Team strength sits behind a fold (c6503ee).
    • The engine notes are now /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).
    • Each forecast tile leads with its verdict ("Level with the market"), served as a field, Metric.verdict. A failed /api/accuracy is said once on the tab; before, it read "Nothing here is scored against outcomes yet" (573206a).
    • Fixtures is on the five type steps (6593331).
  • Found while executing, each fixed with a test that failed first:
    • Since b408260 (2026-08-16), loadAll's /api/teams catch 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).
    • The shared page stub served /api/teams without its window. For a while every stubbed Clean sheets drew the refusal instead of the table, the render gate's included.
    • A <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.
    • In Chromium 151, text inside a closed <details> still returns a real getBoundingClientRect box. The first inventory counted 2,523 characters nobody could see, so the inventories now use checkVisibility(). Session 3's recorded figures stand: I first said otherwise, and checking showed I was wrong.
  • Checks:
    • Mutation: 44 cuts, 43 red. The one that survives is a declaration measured dead, kept and labelled. I first took another survivor for a redundant declaration and removed it; it was a gap in the test, and on the live page the fold's title moved to the far right. The declaration is restored and the test widened (bcf3dc6).
    • A fresh-context final review said "with fixes": one Critical and three Important findings. Each was fixed with a test that failed first (38f0f74, 2b3074c), and 10 of 10 cuts of the fixes went red.
      • Critical: the goalscorer tile read 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, in score_history.head_to_head, and the verdict's own output is unchanged.
      • The key says capitals are home and lower case away, drawn through tkCode.
      • A refusal in the everyone table or the price record was a 500 for the whole of /api/accuracy. Each is now its own section, FAILED with its reason.
      • A failed /api/teams reload left the previous teams in place, and the next paint drew them over the failure.
    • Full suite on the merged main, in the live checkout: 2617 passed, 0 skipped.
    • Live after the restart (2026-10-03 16:03 Oslo):
      • the goalscorer tile reads "Too early to call: 3 more rounds";
      • the everyone table is SCORED (GW1–5, Haaland the most captained every round);
      • the price record has two nights since 2 Oct: six falls projected and six happened, and no rise projected.
  • Not done, and reported: seven Minors are deferred, listed in wiki/notes/page-simplification-2026-09.md (session 4):
    • the ×2 mark covers the band digit at 360–390 px in a double round;
    • renderTeams still repaints the accuracy panel;
    • the nights table prints "0 of 0";
    • a null verdict folds the tile's detail;
    • "awaiting results" shows before any read;
    • the same figure is headed "Avg" in one table and "Average" in another;
    • record() re-reads every night on each request.
  • Next: session 5, the Players tab.
  • Evidence: the wiki page above, the plan 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 4: the Fixtures tab, and a new Record tab ("Can I trust the app?").** It was merged into `main` at the user's request and pushed (`3e1ca1c..521240a`). There is no restore tag this time; the restore point is `3e1ca1c`. - **Measured on the live page,** before (`3e1ca1c`) and after (`bcf3dc6`). Visible text was read with `checkVisibility()`. - Fixtures at 1600 px: 2,257 → 1,596 px tall. The pre-registered range was 1,550–1,700. - Page width at 390: 1,125 → 390 px, so a phone no longer scrolls sideways. - Text under 12 px: 85% → 3%, which is the 11 px capitals only. - Font sizes: eight → three. Text in the dimmest grey: 36% → 26%. - Forecast accuracy was a closed fold at the foot of Fixtures. It is now its own tab, with every fold open; 5% of its text is under 12 px. - **What changed:** - **A fifth tab, Record.** It shows four panels, and the Squad strip's scoreboard opens it (`58c6977`, `573206a`, `4bb8c73`, `f11e0d9`): - the scoreboard first; - your rounds against everyone (add-on 6: your points, FPL's average, the top score and the most captained, GW1–5); - Forecast accuracy; - FPL's price projections, scored night by night. - The FDR grid sits in the content column. A cell is one line ("LEE 3"): home in capitals, away in lower case, with no crest (`88d7f19`). - Clean sheets shows ours over the projection window, for every club, in one column. A club with a blank or a double round says "5 matches" or "7 matches". Team strength sits behind a fold (`c6503ee`). - The engine notes are now `/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`). - Each forecast tile leads with its verdict ("Level with the market"), served as a field, `Metric.verdict`. A failed `/api/accuracy` is said once on the tab; before, it read "Nothing here is scored against outcomes yet" (`573206a`). - Fixtures is on the five type steps (`6593331`). - **Found while executing,** each fixed with a test that failed first: - Since `b408260` (2026-08-16), `loadAll`'s `/api/teams` catch 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`). - The shared page stub served `/api/teams` without its `window`. For a while every stubbed Clean sheets drew the refusal instead of the table, the render gate's included. - A `<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. - In Chromium 151, text inside a closed `<details>` still returns a real `getBoundingClientRect` box. The first inventory counted 2,523 characters nobody could see, so the inventories now use `checkVisibility()`. Session 3's recorded figures stand: I first said otherwise, and checking showed I was wrong. - **Checks:** - **Mutation:** 44 cuts, 43 red. The one that survives is a declaration measured dead, kept and labelled. I first took another survivor for a redundant declaration and removed it; it was a gap in the test, and on the live page the fold's title moved to the far right. The declaration is restored and the test widened (`bcf3dc6`). - **A fresh-context final review** said "with fixes": one Critical and three Important findings. Each was fixed with a test that failed first (`38f0f74`, `2b3074c`), and 10 of 10 cuts of the fixes went red. - **Critical:** the goalscorer tile read `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, in `score_history.head_to_head`, and the verdict's own output is unchanged. - The key says capitals are home and lower case away, drawn through `tkCode`. - A refusal in the everyone table or the price record was a 500 for the whole of `/api/accuracy`. Each is now its own section, FAILED with its reason. - A failed `/api/teams` reload left the previous teams in place, and the next paint drew them over the failure. - **Full suite** on the merged `main`, in the live checkout: **2617 passed, 0 skipped**. - **Live** after the restart (2026-10-03 16:03 Oslo): - the goalscorer tile reads "Too early to call: 3 more rounds"; - the everyone table is SCORED (GW1–5, Haaland the most captained every round); - the price record has two nights since 2 Oct: six falls projected and six happened, and no rise projected. - **Not done, and reported:** seven Minors are deferred, listed in `wiki/notes/page-simplification-2026-09.md` (session 4): - the ×2 mark covers the band digit at 360–390 px in a double round; - `renderTeams` still repaints the accuracy panel; - the nights table prints "0 of 0"; - a null verdict folds the tile's detail; - "awaiting results" shows before any read; - the same figure is headed "Avg" in one table and "Average" in another; - `record()` re-reads every night on each request. - **Next:** session 5, the Players tab. - **Evidence:** the wiki page above, the plan `docs/superpowers/plans/2026-10-03-fixtures-and-record.md`, and CLAUDE.md's 2026-10-03 entry. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Author
Owner

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 main at the user's request and pushed (521240a..2abad4d). The restore point is the tag restore/pre-simplify-players-2026-10-03 (521240a). There is no backend change, so the server was not restarted.

  • Two choices the user made before the plan was written:
    • Players now, and the rest of the type migration in a session 6. The inventory found four surfaces no session had named, all holding small text: the player card (72% under 12 px), compare (91%), Edit squad's builder (72%) and the footer's engine notes (100%). style.css held 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).
    • The Confidence column became a START badge under 75%. Live, all 667 players had start source "archive", so the index was 45 + 55 × start_prob. Its other factor, measured or prior rates, already had a badge, PRIOR-RATES. The user picked "START 62%" over "CONF 62" from two mock rows.
  • Measured on the live page, before (521240a, 2026-10-03) and after (80fd241, 2026-10-04). Visible text was read with checkVisibility().
    • Height at 1600 px: 837 → 889 px. The pre-registered range was 870–910; the larger type costs height.
    • At 390 px: 1,001 → 987 px tall, and the page stays 390 wide.
    • Text under 12 px: 96% → 3%, which is the 11 px capitals only.
    • Font sizes: four (10.56, 11.52, 13.12, 14.4) → three (11, 12, 14). Text in the dimmest grey: 27% → 18%.
    • The Confidence column read 96–100 on every default row. The badge shows on none of the default 20, on 472 of 667 rows, and on 61 forwards.
  • What changed:
    • The hint is one clause, "Tap a row for its 80% band and the breakdown.", and the rest sits behind an ⓘ. The ⓘ is built from START_FLAG and START_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).
    • One start predicate for both tabs (7abefd9). startUnder reads the number FMT.pct prints. 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.
    • Type (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-players rules. The table still fits its panel from 1050 px up, because the Confidence column's width paid for the larger type.
    • The Squad link ("Browse the N players you don't own") clears a leftover search, position and "Differentials only", and leaves focus on the filter it turned on (80fd241). Both were session 2's deferred minors.
  • Found on the way:
    • Text in a pseudo-element is text. The "pp" after an ownership movement is ::after content at .85em, 8.98 px live, and every type test walked text nodes only. The Players type test now reads ::before and ::after content too, and the rule is in CLAUDE.md (e066ec8). Session 6's app-wide test must do the same.
    • pyproject.toml's addopts already carries -q, and a second -q suppresses the "N passed" line. The plan's suite command printed no summary, so the count was read from the progress characters instead.
  • Checks:
    • Mutation: 18 cuts, 18 red on the first run, each run after its test had passed uncut.
    • A fresh-context final review said "with fixes": no Critical finding, three Important and five Minor. Two Minors were raised to Important, because each left a stated guarantee with a test that could not fail (the session 2b precedent). All were fixed in 3609f41, each with a test that failed first, and 11 of 11 cuts of the fixes went red:
      • the one start predicate was guarded on one of its four live connections on Squad. Cutting playerRisk, flagCause, the watch line or startUnder's missing branch left every test green. Each now has a test of its own;
      • a missing chance was described as a measurement. The flag list read "May not start · start — — estimated from minutes played" and the pitch card "Measured, but uncertain", while the badge called it unknown. One sentence now serves all three (START_MISSING, through startWhy), and the flag list has a cause of its own, "Start unknown";
      • the badge stated the archive's basis for every player. startBasis now names a position prior or an override for what it is;
      • the star key's "on screen" half and "the table scrolls inside its panel" had tests that could not fail (overflow: hidden passed the second). Both now can.
    • A claim I had written in the wiki was wrong: the two boundary players were never in the squad, so no Squad flag changed. It is corrected (65f1a28), and the testing notes' certainty paragraph is marked superseded (2abad4d).
    • Full suite on the merged main, in the live checkout: 2646 passed, 0 skipped.
    • Live on assets v233 after the fix pass: clean.
  • Not done, and reported:
    • Five Minors are deferred, listed in 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 the CLAUDE.md rule names ::before and ::after, while ::marker and ::placeholder render text too.
    • Found by the review, predating this session, for session 6: compare prints a missing start as 0%; the row card says a missing start is "estimated from minutes played"; Fixtures' ×2 mark is 11 px at weight 700.
    • In the backend, check_cache_schema checks that a key is present, not that its value is not None. That is why the badge has a "START ?" state.
    • The ★ for a player you own is amber (--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.
  • Next: session 6, the last surfaces on the type steps: the player cards, compare, Edit squad's builder and the engine notes. Then the old --text-2xs/--text-xs steps 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.
  • Evidence: wiki/notes/page-simplification-2026-09.md (session 5) and the plan, docs/superpowers/plans/2026-10-03-players.md.

🤖 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 `main` at the user's request and pushed (`521240a..2abad4d`). The restore point is the tag `restore/pre-simplify-players-2026-10-03` (`521240a`). There is no backend change, so the server was not restarted. - **Two choices the user made before the plan was written:** - **Players now, and the rest of the type migration in a session 6.** The inventory found four surfaces no session had named, all holding small text: the player card (72% under 12 px), compare (91%), Edit squad's builder (72%) and the footer's engine notes (100%). `style.css` held 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`). - **The Confidence column became a START badge under 75%.** Live, all 667 players had start source "archive", so the index was 45 + 55 × start_prob. Its other factor, measured or prior rates, already had a badge, PRIOR-RATES. The user picked "START 62%" over "CONF 62" from two mock rows. - **Measured on the live page,** before (`521240a`, 2026-10-03) and after (`80fd241`, 2026-10-04). Visible text was read with `checkVisibility()`. - Height at 1600 px: 837 → 889 px. The pre-registered range was 870–910; the larger type costs height. - At 390 px: 1,001 → 987 px tall, and the page stays 390 wide. - Text under 12 px: 96% → 3%, which is the 11 px capitals only. - Font sizes: four (10.56, 11.52, 13.12, 14.4) → three (11, 12, 14). Text in the dimmest grey: 27% → 18%. - The Confidence column read 96–100 on every default row. The badge shows on none of the default 20, on 472 of 667 rows, and on 61 forwards. - **What changed:** - The hint is one clause, "Tap a row for its 80% band and the breakdown.", and the rest sits behind an ⓘ. The ⓘ is built from `START_FLAG` and `START_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`). - **One start predicate for both tabs** (`7abefd9`). `startUnder` reads the number `FMT.pct` prints. 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. - Type (`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-players` rules. The table still fits its panel from 1050 px up, because the Confidence column's width paid for the larger type. - The Squad link ("Browse the N players you don't own") clears a leftover search, position and "Differentials only", and leaves focus on the filter it turned on (`80fd241`). Both were session 2's deferred minors. - **Found on the way:** - **Text in a pseudo-element is text.** The "pp" after an ownership movement is `::after` content at `.85em`, 8.98 px live, and every type test walked text nodes only. The Players type test now reads `::before` and `::after` content too, and the rule is in `CLAUDE.md` (`e066ec8`). Session 6's app-wide test must do the same. - `pyproject.toml`'s `addopts` already carries `-q`, and a second `-q` suppresses the "N passed" line. The plan's suite command printed no summary, so the count was read from the progress characters instead. - **Checks:** - **Mutation:** 18 cuts, 18 red on the first run, each run after its test had passed uncut. - **A fresh-context final review** said "with fixes": no Critical finding, three Important and five Minor. Two Minors were raised to Important, because each left a stated guarantee with a test that could not fail (the session 2b precedent). All were fixed in `3609f41`, each with a test that failed first, and 11 of 11 cuts of the fixes went red: - the one start predicate was guarded on one of its four live connections on Squad. Cutting `playerRisk`, `flagCause`, the watch line or `startUnder`'s missing branch left every test green. Each now has a test of its own; - a missing chance was described as a measurement. The flag list read "May not start · start — — estimated from minutes played" and the pitch card "Measured, but uncertain", while the badge called it unknown. One sentence now serves all three (`START_MISSING`, through `startWhy`), and the flag list has a cause of its own, "Start unknown"; - the badge stated the archive's basis for every player. `startBasis` now names a position prior or an override for what it is; - the star key's "on screen" half and "the table scrolls inside its panel" had tests that could not fail (`overflow: hidden` passed the second). Both now can. - A claim I had written in the wiki was wrong: the two boundary players were never in the squad, so no Squad flag changed. It is corrected (`65f1a28`), and the testing notes' certainty paragraph is marked superseded (`2abad4d`). - **Full suite** on the merged `main`, in the live checkout: **2646 passed, 0 skipped**. - Live on assets v233 after the fix pass: clean. - **Not done, and reported:** - Five Minors are deferred, listed in `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 the `CLAUDE.md` rule names `::before` and `::after`, while `::marker` and `::placeholder` render text too. - Found by the review, predating this session, for session 6: compare prints a missing start as 0%; the row card says a missing start is "estimated from minutes played"; Fixtures' ×2 mark is 11 px at weight 700. - In the backend, `check_cache_schema` checks that a key is present, not that its value is not None. That is why the badge has a "START ?" state. - The ★ for a player you own is amber (`--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. - **Next:** session 6, the last surfaces on the type steps: the player cards, compare, Edit squad's builder and the engine notes. Then the old `--text-2xs`/`--text-xs` steps 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. - **Evidence:** `wiki/notes/page-simplification-2026-09.md` (session 5) and the plan, `docs/superpowers/plans/2026-10-03-players.md`. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Author
Owner

Players the simulator never projects: /api/players served 275 of 667 players 0.0 xPts, and the page now says "not projected" and why. The work was merged into main at the user's request and pushed (2abad4d..2fc8fee). The restore point is the tag restore/pre-unprojected-2026-10-04 (2abad4d). The backend changed, so the server was restarted (2026-10-05, 07:20 Oslo).

  • Found while planning session 6 (2026-10-04). The simulator projects a player only where his chance of starting is at least pipeline.MIN_START_PROB (0.05). Below that he has no rows, and /api/players summed 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.
  • The zeros moved a decision. positionMedianEv keeps 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.
  • What changed:
    • The server (0df472b): every /api/players player carries projection, computed from the content. Its state is SIMULATED, BELOW_START_LINE (with the line served as a number) or NO_ROWS. NO_ROWS has never fired live, so its test forces it. A player in either of the last two states gets None for the six-week fields, and gws is []. The server sends no sentence and rounds no number, because Python's round takes 2.5 to 2 where the page's FMT.pct takes it to 3.
    • One sentence (bd9b2bc): projectionWhy builds it from the numbers the page prints. "Not projected: his chance of starting, 2%, is under the 5% the simulator projects from." bandOf, bonusTotal, valuePerM and defconTotal return null instead of 0.
    • The Players table (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.
    • Both player cards (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.
    • Compare and the builder (c6aea4b) name no winner against a missing number. The builder prints "—", with the sentence as its title.
  • The final review (fresh, most capable model) came back "with fixes": 0 Critical, 1 Important and 4 raised. All five were fixed in 5ca2d49, each with a test that failed first:
    • five live players at 0.045–0.049 would have read "his chance of starting, 5%, is under the 5%". They now read "just under the 5%", the same class startUnder fixed for the START badge;
    • the builder search's "—" for a blocked pick named no cause;
    • two guarantees had tests that could not fail, and both now can;
    • the not-projected page stub is pinned to the route's shape.
  • Checks:
    • 30 new tests (tests/web/test_unprojected_players.py, tests/web/test_unprojected_page.py). 20 mutation cuts, all red.
    • Full suite on the merged main, in the live checkout: 2676 passed, 0 skipped.
    • Live after the restart: 392 SIMULATED, 275 BELOW_START_LINE, 0 NO_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".
  • Found after the merge, not fixed: "not projected" now names two causes on the Squad tab. The strip's "N not projected" means FPL's price projection was not read. A pitch card's "Not projected:" means no xPts. This is latent today, because all fifteen players in the entry's squad are price-projected. It goes to session 6a, where one of the two wordings changes.
  • Deferred minors:
    • isDifferential reads a null as below the median only because JavaScript coerces null to 0;
    • whySentence reads components6 || {};
    • swapConfidence reads sd6_no_bonus || 0;
    • _projection_of restates the simulator's >= instead of sharing one predicate.
  • Reload the app on every device: a tab left open across the restart throws in compare and on the cards for a player with no projection.
  • Next: session 6a puts the player cards, compare, the builder and the engine notes on the type steps. It runs in a fresh session, and its plan is written first.
  • Evidence: wiki/notes/unprojected-players-served-as-zero.md (ea0f500), the rule in CLAUDE.md (a5894c9), a wiki lint (2fc8fee), and the plan, docs/superpowers/plans/2026-10-04-unprojected-players.md.

🤖 Generated with Claude Code

**Players the simulator never projects: `/api/players` served 275 of 667 players 0.0 xPts, and the page now says "not projected" and why.** The work was merged into `main` at the user's request and pushed (`2abad4d..2fc8fee`). The restore point is the tag `restore/pre-unprojected-2026-10-04` (`2abad4d`). The backend changed, so the server was restarted (2026-10-05, 07:20 Oslo). - **Found while planning session 6** (2026-10-04). The simulator projects a player only where his chance of starting is at least `pipeline.MIN_START_PROB` (0.05). Below that he has no rows, and `/api/players` summed 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. - **The zeros moved a decision.** `positionMedianEv` keeps 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. - **What changed:** - **The server** (`0df472b`): every `/api/players` player carries `projection`, computed from the content. Its state is `SIMULATED`, `BELOW_START_LINE` (with the line served as a number) or `NO_ROWS`. `NO_ROWS` has never fired live, so its test forces it. A player in either of the last two states gets `None` for the six-week fields, and `gws` is `[]`. The server sends no sentence and rounds no number, because Python's `round` takes 2.5 to 2 where the page's `FMT.pct` takes it to 3. - **One sentence** (`bd9b2bc`): `projectionWhy` builds it from the numbers the page prints. "Not projected: his chance of starting, 2%, is under the 5% the simulator projects from." `bandOf`, `bonusTotal`, `valuePerM` and `defconTotal` return null instead of 0. - **The Players table** (`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. - **Both player cards** (`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. - **Compare and the builder** (`c6aea4b`) name no winner against a missing number. The builder prints "—", with the sentence as its title. - **The final review** (fresh, most capable model) came back "with fixes": 0 Critical, 1 Important and 4 raised. All five were fixed in `5ca2d49`, each with a test that failed first: - five live players at 0.045–0.049 would have read "his chance of starting, 5%, is under the 5%". They now read "just under the 5%", the same class `startUnder` fixed for the START badge; - the builder search's "—" for a blocked pick named no cause; - two guarantees had tests that could not fail, and both now can; - the not-projected page stub is pinned to the route's shape. - **Checks:** - 30 new tests (`tests/web/test_unprojected_players.py`, `tests/web/test_unprojected_page.py`). 20 mutation cuts, all red. - **Full suite** on the merged `main`, in the live checkout: **2676 passed, 0 skipped**. - Live after the restart: 392 `SIMULATED`, 275 `BELOW_START_LINE`, 0 `NO_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". - **Found after the merge, not fixed:** "not projected" now names two causes on the Squad tab. The strip's "N not projected" means FPL's price projection was not read. A pitch card's "Not projected:" means no xPts. This is latent today, because all fifteen players in the entry's squad are price-projected. It goes to session 6a, where one of the two wordings changes. - **Deferred minors:** - `isDifferential` reads a null as below the median only because JavaScript coerces null to 0; - `whySentence` reads `components6 || {}`; - `swapConfidence` reads `sd6_no_bonus || 0`; - `_projection_of` restates the simulator's `>=` instead of sharing one predicate. - **Reload the app** on every device: a tab left open across the restart throws in compare and on the cards for a player with no projection. - **Next:** session 6a puts the player cards, compare, the builder and the engine notes on the type steps. It runs in a fresh session, and its plan is written first. - **Evidence:** `wiki/notes/unprojected-players-served-as-zero.md` (`ea0f500`), the rule in `CLAUDE.md` (`a5894c9`), a wiki lint (`2fc8fee`), and the plan, `docs/superpowers/plans/2026-10-04-unprojected-players.md`. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Author
Owner

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 main at the user's request and pushed (2fc8fee..5a79fbd). The restore point is the tag restore/pre-simplify-cards-2026-10-05 (2fc8fee). Frontend only, so no restart.

  • The plan was verified before it was written. Every code step was applied to a copy of frontend/ and tests/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 is docs/superpowers/plans/2026-10-05-cards-compare-builder-notes.md.
  • What misled, first:
    • A start probability the server did not send is said once, in START_MISSING, and never printed (d57fcab). Compare printed "0%" (null * 100 is 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 follows start_source (startBasis).
    • The row card's rates section was headed "Composition — E[pts/GW]", the radar it replaced in August. It is now "Underlying stats". The grid stopped repeating three rates without their sample, and the section keeps a measured zero.
    • The engine notes lost four stale claims (e8b8b4d):
      • "79–94%": the 123 players who started every one of GW1–5 sat at p10 0.926 and median 0.976;
      • "carries last season's number": this season's rounds decide since 2026-09-13;
      • "a tercile of your own squad": gwTier has no caller, and every fixture colour on screen is difficulty;
      • "Price movement. Chips.": the round plan has valued chips and shown FPL's price projection since 2026-10-01.
    • The notes read START_BASIS and START_FLAG instead of typing them.
  • Then what broke on a phone, and what sat in the wrong place:
    • The builder's search printed the query into innerHTML unescaped. Edit squad left the pitch's heading on screen with no pitch under it. The builder's hint typed START_BASIS a second time. All three are fixed in 2008175.
    • On the Squad tab, "not projected" now means no xPts everywhere. The strip says "N without a price projection". A shirt's number has one title: it carried two, and the parser keeps the first. A shirt's "—" now names its cause (ecfd743).
    • Compare's label column is 132 px, and its header has two columns (c770021). Edit squad's squad table scrolls inside its panel where it does not fit, with minmax(0, …) tracks (548a99f). It already overflowed at 360–390 and at 660–700 px.
  • Type: under 12 px went to 0% on all five surfaces, measured live before and after. Before, it was 72% on the row card, 91% on compare, 54% on the pitch card (ten font sizes), 72% on the builder and 100% on the notes. Every height fell inside its pre-registered range. Capitals at 11 px carry three words at most: data beside an eyebrow moved into a 12 px .eyebrow-data span.
  • Found on the way:
    • The row card's bars overlapped their heading, by 3 px already and 8 px at the 12 px step. They sit in a fixed 116 px box aligned to its foot, and only a screenshot showed it. The box now has a min-height.
    • The type walk counted 11 px capitals per text node. "Starting XI" and an inline "· 4-4-2 · xPts 200.0" passed as two short eyebrows, and a cut proved it. It now counts per block.
    • Both rules are in CLAUDE.md (99348de).
  • The final review (fresh context, most capable model) came back "with fixes": 0 Critical, 2 Important and 2 raised from Minor. All four were fixed in 79d0ace, each with a test that failed first and a cut that went red:
    • a shirt's "No fixture" had been keyed on a missing row, not on the fixture list;
    • the Start% note said "once he has played", but the rounds decide from his first one, played or not;
    • "a fixture keeps its colour from week to week" is false while team strength is refitted, and the Wildcard is valued as if played now;
    • the squad-table test passed with overflow-x: hidden, because a script can scroll a box a reader cannot. It now reads overflowX and scrolls to reach xPts.
  • Checks:
    • 46 new tests, and 44 mutation cuts, all red in the live checkout: 38 from the plan and 6 from the fix pass.
    • The render gate's sweep named 258 differing paths, all under #squad-edit-mode and the footer, and the gate was re-baselined after it.
    • Full suite on the merged main, in the live checkout: 2722 passed, 0 skipped.
  • Deferred minors:
    • the stubs carry only small magnitudes, so a two-digit window's compare label is 136 px in its 132 px column (4 px into the gap);
    • the builder's table also scrolls at 641–860 px, where the builder is two columns;
    • the pitch card states the chance of starting more than once;
    • Complete my squad's rows split at 390 px, unchanged from before;
    • a comment about a <details> inside a <span>.
  • Findings for later: compare's ● is decided on the raw value while the value prints rounded. And a shirt's aria-label sits on a generic <div>, which ARIA does not name.
  • Next: session 6b, the stylesheet: the 68 rules with no emitter, the shared base rules, 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.
  • Evidence: 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 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 `main` at the user's request and pushed (`2fc8fee..5a79fbd`). The restore point is the tag `restore/pre-simplify-cards-2026-10-05` (`2fc8fee`). Frontend only, so no restart. - **The plan was verified before it was written.** Every code step was applied to a copy of `frontend/` and `tests/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 is `docs/superpowers/plans/2026-10-05-cards-compare-builder-notes.md`. - **What misled, first:** - **A start probability the server did not send** is said once, in `START_MISSING`, and never printed (`d57fcab`). Compare printed "0%" (`null * 100` is 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 follows `start_source` (`startBasis`). - **The row card's rates section** was headed "Composition — E[pts/GW]", the radar it replaced in August. It is now "Underlying stats". The grid stopped repeating three rates without their sample, and the section keeps a measured zero. - **The engine notes lost four stale claims** (`e8b8b4d`): - "79–94%": the 123 players who started every one of GW1–5 sat at p10 0.926 and median 0.976; - "carries last season's number": this season's rounds decide since 2026-09-13; - "a tercile of your own squad": `gwTier` has no caller, and every fixture colour on screen is difficulty; - "Price movement. Chips.": the round plan has valued chips and shown FPL's price projection since 2026-10-01. - The notes read `START_BASIS` and `START_FLAG` instead of typing them. - **Then what broke on a phone, and what sat in the wrong place:** - The builder's search printed the query into `innerHTML` unescaped. Edit squad left the pitch's heading on screen with no pitch under it. The builder's hint typed `START_BASIS` a second time. All three are fixed in `2008175`. - On the Squad tab, "not projected" now means no xPts everywhere. The strip says "N without a price projection". A shirt's number has one `title`: it carried two, and the parser keeps the first. A shirt's "—" now names its cause (`ecfd743`). - Compare's label column is 132 px, and its header has two columns (`c770021`). Edit squad's squad table scrolls inside its panel where it does not fit, with `minmax(0, …)` tracks (`548a99f`). It already overflowed at 360–390 and at 660–700 px. - **Type:** under 12 px went to 0% on all five surfaces, measured live before and after. Before, it was 72% on the row card, 91% on compare, 54% on the pitch card (ten font sizes), 72% on the builder and 100% on the notes. Every height fell inside its pre-registered range. Capitals at 11 px carry three words at most: data beside an eyebrow moved into a 12 px `.eyebrow-data` span. - **Found on the way:** - **The row card's bars overlapped their heading**, by 3 px already and 8 px at the 12 px step. They sit in a fixed 116 px box aligned to its foot, and only a screenshot showed it. The box now has a `min-height`. - **The type walk counted 11 px capitals per text node.** "Starting XI" and an inline "· 4-4-2 · xPts 200.0" passed as two short eyebrows, and a cut proved it. It now counts per block. - Both rules are in `CLAUDE.md` (`99348de`). - **The final review** (fresh context, most capable model) came back "with fixes": 0 Critical, 2 Important and 2 raised from Minor. All four were fixed in `79d0ace`, each with a test that failed first and a cut that went red: - a shirt's "No fixture" had been keyed on a missing row, not on the fixture list; - the Start% note said "once he has played", but the rounds decide from his first one, played or not; - "a fixture keeps its colour from week to week" is false while team strength is refitted, and the Wildcard is valued as if played now; - the squad-table test passed with `overflow-x: hidden`, because a script can scroll a box a reader cannot. It now reads `overflowX` and scrolls to reach xPts. - **Checks:** - 46 new tests, and 44 mutation cuts, all red in the live checkout: 38 from the plan and 6 from the fix pass. - The render gate's sweep named 258 differing paths, all under `#squad-edit-mode` and the footer, and the gate was re-baselined after it. - **Full suite** on the merged `main`, in the live checkout: **2722 passed, 0 skipped**. - **Deferred minors:** - the stubs carry only small magnitudes, so a two-digit window's compare label is 136 px in its 132 px column (4 px into the gap); - the builder's table also scrolls at 641–860 px, where the builder is two columns; - the pitch card states the chance of starting more than once; - Complete my squad's rows split at 390 px, unchanged from before; - a comment about a `<details>` inside a `<span>`. - **Findings for later:** compare's ● is decided on the raw value while the value prints rounded. And a shirt's `aria-label` sits on a generic `<div>`, which ARIA does not name. - **Next:** session 6b, the stylesheet: the 68 rules with no emitter, the shared base rules, `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. - **Evidence:** `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](https://claude.com/claude-code)
Author
Owner

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 main at the user's request and pushed (5a79fbd..73d2489). The restore point is the tag restore/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/::after text.

  • Two phone defects, in states no test opened (f6a692f):

    • An opened "Other options" row's same-club warning was nowrap and made Squad 452 px wide at 360 and 390. It now wraps inside its row.
    • A double round's "×2" covered the band digit up to 700 px. A double cell now has 20 px of room for its mark.
  • 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), and test_no_dead_css.py keeps them out.

  • R5's test is two files (a380ff9, 50f6ceb):

    • test_type_everywhere.py walks 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.py reads every file in frontend/ as text.
  • What moved onto the steps:

    • the tab bar (13.12 → 14 px) and the error, stale and override banners (→ 14);
    • a glyph or unit takes its parent's size (the disclosure arrows, the "pp", the card line's "6GW");
    • P(BETTER) and BY GAMEWEEK use the micro step at weight 600;
    • Matches' "Anytime scorer" keeps its capitals, with the source in a 12 px span;
    • body is 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 of frontend/.

  • 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):

    • text off a step went from 33–36 characters to 0 in all 48 pairs;
    • font sizes in use went from 10.2/11/12/13.12/14/20 to 11/12/14/20;
    • every page is 390 px wide at 390.
    • One pre-registered range was missed, and was not moved. Against the 07:18 baseline, Squad was +24 px at 1600 and +150 at 390. A projection rebuild at 07:53 explains it: the old frontend, served over the same API at 08:27, had itself grown +26/+134. Paired on the same data, the change is −2 and +16, which matches the plan's −2 and +17.
  • Found on the way (the rule is in CLAUDE.md, 1ccec30):

    • The tab bar sat off the steps under five sessions that each passed, because it was in none of their surfaces.
    • The off-step states (an opened swap row, the banners, a card line set to price, a double) sat behind a click no test made.
  • 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:

    • The file half read two files by name and only 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 :root define a step. A test puts 15 ways into a copy of the files; 11 had passed.
    • Four branches of the dead-CSS guard had no test case. Each has one now.
    • The gate silently skipped Matches' compare search if its toggle went missing. It now throws instead.
    • The swap test passed with the warning hidden. It now requires the warning to be shown.
    • Two pieces of prose were wrong: a comment still said 337 px where the tab bar is now 352 at 360, and the coverage claims in the spec, the wiki and a docstring overstated what the tests drive.
  • Checks:

    • 35 mutation cuts were all red in the live checkout, and the 14 that touch the fix pass's files were re-run red against it.
    • The gate was re-baselined twice, each time only after a full sweep was read: 769 pairs, then 22,860.
    • Full suite in the live checkout: 2,775 passed, 0 skipped before the review; 2,795 passed, 0 skipped after the fix pass; 2,795 passed, 0 skipped on the merged main.
  • Deferred minors:

    • three dead rules the deletion missed (.bank-why[open] summary, .sp-track i.over and i.under; they have no render effect);
    • the gate still drives none of the click-opened states (its docstring now names them);
    • the double's probe measures from the border box;
    • a doc comment sits above the wrong constant;
    • a comment names a field the steps don't have;
    • a class arriving in an /api payload is outside the dead-CSS guard's model.
  • For later: the page sends POST /api/refresh-live on 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);
    • the operator buttons on the LAN;
    • two merged worktrees.

    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

**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 `main` at the user's request and pushed (`5a79fbd..73d2489`). The restore point is the tag `restore/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`/`::after` text. - **Two phone defects, in states no test opened** (`f6a692f`): - An opened "Other options" row's same-club warning was `nowrap` and made Squad 452 px wide at 360 and 390. It now wraps inside its row. - A double round's "×2" covered the band digit up to 700 px. A double cell now has 20 px of room for its mark. - **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), and `test_no_dead_css.py` keeps them out. - **R5's test is two files** (`a380ff9`, `50f6ceb`): - `test_type_everywhere.py` walks 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.py` reads every file in `frontend/` as text. - **What moved onto the steps:** - the tab bar (13.12 → 14 px) and the error, stale and override banners (→ 14); - a glyph or unit takes its parent's size (the disclosure arrows, the "pp", the card line's "6GW"); - P(BETTER) and BY GAMEWEEK use the micro step at weight 600; - Matches' "Anytime scorer" keeps its capitals, with the source in a 12 px span; - `body` is 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 of `frontend/`. - **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): - text off a step went from 33–36 characters to 0 in all 48 pairs; - font sizes in use went from 10.2/11/12/13.12/14/20 to 11/12/14/20; - every page is 390 px wide at 390. - **One pre-registered range was missed, and was not moved.** Against the 07:18 baseline, Squad was +24 px at 1600 and +150 at 390. A projection rebuild at 07:53 explains it: the old frontend, served over the same API at 08:27, had itself grown +26/+134. Paired on the same data, the change is −2 and +16, which matches the plan's −2 and +17. - **Found on the way** (the rule is in `CLAUDE.md`, `1ccec30`): - The tab bar sat off the steps under five sessions that each passed, because it was in none of their surfaces. - The off-step states (an opened swap row, the banners, a card line set to price, a double) sat behind a click no test made. - **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: - The file half read two files by name and only `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 `:root` define a step. A test puts 15 ways into a copy of the files; 11 had passed. - Four branches of the dead-CSS guard had no test case. Each has one now. - The gate silently skipped Matches' compare search if its toggle went missing. It now throws instead. - The swap test passed with the warning hidden. It now requires the warning to be shown. - Two pieces of prose were wrong: a comment still said 337 px where the tab bar is now 352 at 360, and the coverage claims in the spec, the wiki and a docstring overstated what the tests drive. - **Checks:** - 35 mutation cuts were all red in the live checkout, and the 14 that touch the fix pass's files were re-run red against it. - The gate was re-baselined twice, each time only after a full sweep was read: 769 pairs, then 22,860. - **Full suite** in the live checkout: 2,775 passed, 0 skipped before the review; **2,795 passed, 0 skipped** after the fix pass; **2,795 passed, 0 skipped** on the merged `main`. - **Deferred minors:** - three dead rules the deletion missed (`.bank-why[open] summary`, `.sp-track i.over` and `i.under`; they have no render effect); - the gate still drives none of the click-opened states (its docstring now names them); - the double's probe measures from the border box; - a doc comment sits above the wrong constant; - a comment names a field the steps don't have; - a class arriving in an `/api` payload is outside the dead-CSS guard's model. - **For later:** the page sends `POST /api/refresh-live` on 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)`; - the operator buttons on the LAN; - two merged worktrees. 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](https://claude.com/claude-code)
Author
Owner

The four small tasks queued after session 6b are done. They were merged into main at 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.

  • The armband in a double gameweek (17b5f74, 5ef1661). captaincy._row_for took 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_for reads 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.
    • P(10+), the 80% band and the ceiling are not modelled for a double round. The cache keeps a summary per match, not the round's distribution, so they are null with tails saying why. The captain row, the board and the chip line show that sentence. The board's p.ceiling.toFixed(1) would have thrown on the null, and the round plan would have read "could not rank captaincy".
    • The armband takes a double player to play both matches or neither, and says so (armband_assumes).
    • The same class was in the page. The row card's "GWn xPts" tile and the pitch card's haul chance printed the round's first row as the round. Both now show the round's xPts and say the rest is not modelled.
    • Still open, before the first double gameweek (none was scheduled on 2026-10-06): simulating the round, by combining the two matches' samples into its distribution, which needs a cache field and a rebuild. Also open: the row card's chart draws a bar per match, and the pitch card's "next" line names only the first opponent.
  • The LAN's operator buttons (26fab18). /api/status now serves operator = {allowed, why}, from local_caller, the one function require_local also asks.
    • When the server says no, the page hides Refresh team news, Rebuild live data and Firm up, and the status panel says "Refresh: only on the PC the app runs on (caller address is '…')". The page's load sends no refresh.
    • When the status says nothing, the buttons stay and nothing is sent automatically.
    • Before this change, a phone was shown all three buttons, and a press failed as "flags may be stale" or "data unchanged", which named the wrong cause.
  • backtest_starts_boundary.py's .get(str(target), 1.0) (0d9c1b0). It is now availability_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.
  • The two merged worktrees (.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's history/ archive.
  • Checks: the full suite in the live checkout: 2,824 passed, 0 skipped on the merged 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.
  • Evidence:
    • 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);
    • CLAUDE.md's Known gaps entry on the armband.
  • Next: simulating the round before the first double gameweek, then planning several rounds ahead.

🤖 Generated with Claude Code

**The four small tasks queued after session 6b are done.** They were merged into `main` at 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. - **The armband in a double gameweek** (`17b5f74`, `5ef1661`). `captaincy._row_for` took 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_for` reads 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. - P(10+), the 80% band and the ceiling are **not modelled for a double round**. The cache keeps a summary per match, not the round's distribution, so they are null with `tails` saying why. The captain row, the board and the chip line show that sentence. The board's `p.ceiling.toFixed(1)` would have thrown on the null, and the round plan would have read "could not rank captaincy". - The armband takes a double player to play both matches or neither, and says so (`armband_assumes`). - The same class was in the page. The row card's "GWn xPts" tile and the pitch card's haul chance printed the round's first row as the round. Both now show the round's xPts and say the rest is not modelled. - **Still open, before the first double gameweek** (none was scheduled on 2026-10-06): simulating the round, by combining the two matches' samples into its distribution, which needs a cache field and a rebuild. Also open: the row card's chart draws a bar per match, and the pitch card's "next" line names only the first opponent. - **The LAN's operator buttons** (`26fab18`). `/api/status` now serves `operator` = {allowed, why}, from `local_caller`, the one function `require_local` also asks. - When the server says no, the page hides Refresh team news, Rebuild live data and Firm up, and the status panel says "Refresh: only on the PC the app runs on (caller address is '…')". The page's load sends no refresh. - When the status says nothing, the buttons stay and nothing is sent automatically. - Before this change, a phone was shown all three buttons, and a press failed as "flags may be stale" or "data unchanged", which named the wrong cause. - **`backtest_starts_boundary.py`'s `.get(str(target), 1.0)`** (`0d9c1b0`). It is now `availability_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. - **The two merged worktrees** (`.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's `history/` archive. - **Checks:** the full suite in the live checkout: **2,824 passed, 0 skipped on the merged `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. - **Evidence:** - `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); - CLAUDE.md's Known gaps entry on the armband. - **Next:** simulating the round before the first double gameweek, then planning several rounds ahead. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Author
Owner

Multi-round planner, Session A (Stage 0a): moves planned by hand in Edit squad now spend free transfers. The work was merged into main at the user's request and pushed (9a4a119..3838db1). The restore point is the tag restore/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/squad served picks with the moves planned by hand already made, and free_transfers.count as 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_transfers is FPL's carry rule, and the history replay calls it. fpl_team.after_hand_plan adds three fields to the count: planned (the players the plan sells), left and planned_cost. The last two are null unless the count is VERIFIED, so an unknown never reads as zero (0b79760).
    • /api/squad serves them (6156b11).
    • The page reads the count through one function, freeTransfersState(). The verdicts spend left, and the head says "N left after your planned move" or "your N planned moves cost X pts". A server that sends a count without left is 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:

    • After the card's own move was planned by hand, the fold said "None of these gains anything in GW6, so waiting a week costs nothing", one line under a move gaining +2.2 in GW6.
    • "A second transfer" could be the third.
    • "You carry 1 free transfers".

    All fixed in eb9b3fb. That commit also adds a test for the "most FPL allows" sentence's switch to left, 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" and scripts/backtest_squad.py (c01ac34).

  • Verification:

    • Every new test failed first.
    • Each guard was broken in turn, 15 cuts in all, and a test failed every time.
    • Full suite: 2846 passed, 0 failed, 0 skipped.
    • Both planned states were drawn on the real page at 1600 and 390 px.
  • 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:

    • A server older than the page ignores planned moves it could see. This lasts only until a restart, and the head says to restart.
    • Four page tests stub a planned squad beside a count with nothing planned.
    • The "·" inside the count is bold.
  • Next: Session B of the plan:

    • today's round-plan rule in Python, pinned to the page, which the scoreboard's follow manager will play;
    • the planner, one MILP over the window's rounds;
    • the pass mark in code;
    • the season replay.

    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 A (Stage 0a): moves planned by hand in Edit squad now spend free transfers.** The work was merged into `main` at the user's request and pushed (`9a4a119..3838db1`). The restore point is the tag `restore/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/squad` served `picks` with the moves planned by hand already made, and `free_transfers.count` as 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_transfers` is FPL's carry rule, and the history replay calls it. `fpl_team.after_hand_plan` adds three fields to the count: `planned` (the players the plan sells), `left` and `planned_cost`. The last two are null unless the count is `VERIFIED`, so an unknown never reads as zero (`0b79760`). - `/api/squad` serves them (`6156b11`). - The page reads the count through one function, `freeTransfersState()`. The verdicts spend `left`, and the head says "N left after your planned move" or "your N planned moves cost X pts". A server that sends a count without `left` is 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: - After the card's own move was planned by hand, the fold said "None of these gains anything in GW6, so waiting a week costs nothing", one line under a move gaining +2.2 in GW6. - "A second transfer" could be the third. - "You carry 1 free transfers". All fixed in `eb9b3fb`. That commit also adds a test for the "most FPL allows" sentence's switch to `left`, 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" and `scripts/backtest_squad.py` (`c01ac34`). - **Verification:** - Every new test failed first. - Each guard was broken in turn, 15 cuts in all, and a test failed every time. - Full suite: 2846 passed, 0 failed, 0 skipped. - Both planned states were drawn on the real page at 1600 and 390 px. - **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:** - A server older than the page ignores planned moves it could see. This lasts only until a restart, and the head says to restart. - Four page tests stub a planned squad beside a count with nothing planned. - The "·" inside the count is bold. - **Next: Session B** of the plan: - today's round-plan rule in Python, pinned to the page, which the scoreboard's follow manager will play; - the planner, one MILP over the window's rounds; - the pass mark in code; - the season replay. 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](https://claude.com/claude-code)
Author
Owner

Multi-round planner, Session B: the engine and its test are built, nothing is measured yet. The work was merged into main at 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's roundPlan(), for the season replay's control arm and the scoreboard's follow manager. tests/web/test_move_search_matches_the_page.py drives the real page and the copy on the same data, 45 cases.

    • The plan had 43 cases. Two of its cuts survived them: removing the club cap, and printing positive with no rounding. No case let either one decide a move. The plan's "all seven red" had stopped on the same unnamed failure every time. Two hand-made cases close the gap.
  • 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:

    • up to two moves a round, only with a free transfer, and only when they gain in that round;
    • free transfers carried by FPL's rule;
    • a round no snapshot stands for banks its transfer.

    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 says RULE_CHANGED (d7b0900).

  • The planner (backend/web/planner.py, 4700701): one MILP over the window's rounds.

    • Free transfers are bounded by FPL's carry, 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.
    • It runs with three settings: FPL Review's package, the package without hits, and plain.
    • On the user's squad it solves in 4.0 to 13.8 s, every MIP gap at or under 1e-4.
  • 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, run and verdict, 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 d7b0900 and d2d7cdf, each fix with a test that failed first:

  • The carry rule and the hit cost moved into rules.py, re-exported by fpl_team. The planner's fingerprint, which the verdict is stamped with, now reads only the planner and FPL's rules.
  • A planner refusal could not cross the process pool. Its args were one string while its constructor took two, so a refusal in a worker would have reached the parent as a broken pool. It now pickles, and it names the decision: season, seed, squad, arm and round.
  • 2024/25 is refused until the tracked verdict, written by the same code, says CONFIRM_ON_2024_25. The verdict takes its seasons from that file, not from whether a folder exists.
  • Stamps: every artefact is stamped before its work, with the git SHA marked +uncommitted when the code is. The verdict prints its stamp and keeps each comparison's per-run differences.
  • A forward build missing a round is refused.
  • No .get() in the branch's new modules.
  • Two decisions the user made after the review:
    • The AFCON top-up of 2025/26 is not modelled: every arm runs under the standard carry rule. This is written into the spec as correction 10.
    • Four from one club after a real-life move follows FPL's rule in every arm: the club is kept until a round makes a transfer, and that round must sell from it. Semenyo and Guéhi both joined Man City mid-season, and the planner would have stopped the run with a ValueError. The spec has this as correction 11.

Verification:

  • 82 cuts over the final code, each read against the test it turned red: all red.
  • Full suite: 2984 passed, 0 failed, 0 skipped.
  • The Record tab was checked on the live page at 1600 and 390 px.
  • After the restart, the scoreboard replayed GW1-5 under the new rule at 4a17159:
    • "App + tips" is unchanged at 159 (GW1 59, GW2 61, GW5 39), as predicted.
    • GW5 makes no move. Watkins to João Pedro gains 4.95 over GW5-10 but nothing in GW5 itself, so the rule waits.

Records:

  • The wiki's new systems/multi-round-planner.md, and the follow manager's rule in notes/app-picks-replayed-against-a-real-entry.md (57eb159).
  • Three CLAUDE.md Known-gaps entries (4a17159):
    • "The scoreboard replayed a rule the page had dropped";
    • "A cut reported red on a failure nobody named";
    • "Four from one club is legal after a real-life move, and the round plan does not know it".

Not done:

  • The page's round plan (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.
  • Deferred minors:
    • follow_rule does not cover the follow manager's own bookkeeping in scripts/backtest_app_vs_entry.py.
    • The objective test is thinner than it reads; the carry rule is pinned by the hand-made cases.

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 B: the engine and its test are built, nothing is measured yet.** The work was merged into `main` at 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's `roundPlan()`, for the season replay's control arm and the scoreboard's follow manager. `tests/web/test_move_search_matches_the_page.py` drives the real page and the copy on the same data, 45 cases. - The plan had 43 cases. Two of its cuts survived them: removing the club cap, and printing positive with no rounding. No case let either one decide a move. The plan's "all seven red" had stopped on the same unnamed failure every time. Two hand-made cases close the gap. - **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: - up to two moves a round, only with a free transfer, and only when they gain in that round; - free transfers carried by FPL's rule; - a round no snapshot stands for banks its transfer. 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 says `RULE_CHANGED` (`d7b0900`). - **The planner** (`backend/web/planner.py`, `4700701`): one MILP over the window's rounds. - Free transfers are bounded by FPL's carry, `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. - It runs with three settings: FPL Review's package, the package without hits, and plain. - On the user's squad it solves in 4.0 to 13.8 s, every MIP gap at or under 1e-4. - **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`, `run` and `verdict`, 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 `d7b0900` and `d2d7cdf`, each fix with a test that failed first: - **The carry rule and the hit cost moved into `rules.py`**, re-exported by `fpl_team`. The planner's fingerprint, which the verdict is stamped with, now reads only the planner and FPL's rules. - **A planner refusal could not cross the process pool.** Its args were one string while its constructor took two, so a refusal in a worker would have reached the parent as a broken pool. It now pickles, and it names the decision: season, seed, squad, arm and round. - **2024/25 is refused** until the tracked verdict, written by the same code, says `CONFIRM_ON_2024_25`. The verdict takes its seasons from that file, not from whether a folder exists. - **Stamps:** every artefact is stamped before its work, with the git SHA marked `+uncommitted` when the code is. The verdict prints its stamp and keeps each comparison's per-run differences. - **A forward build missing a round is refused.** - **No `.get()` in the branch's new modules.** - **Two decisions the user made after the review:** - The AFCON top-up of 2025/26 is not modelled: every arm runs under the standard carry rule. This is written into the spec as correction 10. - Four from one club after a real-life move follows FPL's rule in every arm: the club is kept until a round makes a transfer, and that round must sell from it. Semenyo and Guéhi both joined Man City mid-season, and the planner would have stopped the run with a `ValueError`. The spec has this as correction 11. **Verification:** - 82 cuts over the final code, each read against the test it turned red: all red. - Full suite: 2984 passed, 0 failed, 0 skipped. - The Record tab was checked on the live page at 1600 and 390 px. - After the restart, the scoreboard replayed GW1-5 under the new rule at `4a17159`: - "App + tips" is unchanged at 159 (GW1 59, GW2 61, GW5 39), as predicted. - GW5 makes no move. Watkins to João Pedro gains 4.95 over GW5-10 but nothing in GW5 itself, so the rule waits. **Records:** - The wiki's new `systems/multi-round-planner.md`, and the follow manager's rule in `notes/app-picks-replayed-against-a-real-entry.md` (`57eb159`). - Three CLAUDE.md Known-gaps entries (`4a17159`): - "The scoreboard replayed a rule the page had dropped"; - "A cut reported red on a failure nobody named"; - "Four from one club is legal after a real-life move, and the round plan does not know it". **Not done:** - The page's round plan (`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. - Deferred minors: - `follow_rule` does not cover the follow manager's own bookkeeping in `scripts/backtest_app_vs_entry.py`. - The objective test is thinner than it reads; the carry rule is pinned by the hand-made cases. **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](https://claude.com/claude-code)
Author
Owner

Multi-round planner, Session C: measured on 2025/26, and it does not ship. The work was merged into main at 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).

  • Decisions GW8-38. Each window is six rounds, projected only from earlier matches (400 sims).
  • Six projection seeds × five starting squads, every arm on every run.
  • FPL's selling prices and carry rule, no chips.
  • Code 4a17159, harness fingerprint 9d81c31db741, planner fingerprint 8e786e29b3e9.

The verdict is the computed file data/verdicts/planner.json (b929186): NO_SHIP.

Comparison (season points, minus bonus, after hits) Mean 95% interval
Planner without hits v today's rule +6.8 −14.8 to +26.1
Planner with hits v today's rule +10.3 −12.2 to +29.6
What hits add on top +3.5 −5.1 to +13.2
Plain planner v today's rule +12.8 −13.0 to +36.4
FPL Review's package v plain −6.0 −26.4 to +14.0
Today's rule plus hits v today's rule −4.1 −25.0 to +16.4
  • Every planner arm leads on the mean, and none has its interval above zero, so nothing ships. On one season the test resolves about ±20 points a season.
  • 2024/25 stays unseen. It is run only after a 2025/26 pass, and the code refuses it, so a redesign can still be tested on it once.
  • Predictions, written before any run: three held.
    • Planner without hits v control: +6.8, inside +5 to +30.
    • What hits add on top: +3.5, inside −10 to +15.
    • Saves a season: median 10, inside 3 to 10.
    • "Plain makes more transfers" missed. Without hits both planners are capped at 31 transfers, and both used all 31 in every run. That was written in the ledger before the verdict existed.

Checked before reporting:

  • The projections showed no sign of seeing the future. The planner's projected edge (+14.6) reached the points at about half (+6.8). Look-ahead would push it the other way.
  • One look-ahead was found and recorded, not fixed. The window's projections know which players are still at a club in a later round, so a January departure is seen early. Every arm sees it alike.
  • The first timed run showed +76; that was one run's noise. Its seed averages +8.

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

**Multi-round planner, Session C: measured on 2025/26, and it does not ship.** The work was merged into `main` at 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`). - Decisions GW8-38. Each window is six rounds, projected only from earlier matches (400 sims). - Six projection seeds × five starting squads, every arm on every run. - FPL's selling prices and carry rule, no chips. - Code `4a17159`, harness fingerprint `9d81c31db741`, planner fingerprint `8e786e29b3e9`. The verdict is the computed file `data/verdicts/planner.json` (`b929186`): **`NO_SHIP`**. | Comparison (season points, minus bonus, after hits) | Mean | 95% interval | |---|---|---| | Planner without hits v today's rule | +6.8 | −14.8 to +26.1 | | Planner with hits v today's rule | +10.3 | −12.2 to +29.6 | | What hits add on top | +3.5 | −5.1 to +13.2 | | Plain planner v today's rule | +12.8 | −13.0 to +36.4 | | FPL Review's package v plain | −6.0 | −26.4 to +14.0 | | Today's rule plus hits v today's rule | −4.1 | −25.0 to +16.4 | - **Every planner arm leads on the mean, and none has its interval above zero, so nothing ships.** On one season the test resolves about ±20 points a season. - **2024/25 stays unseen.** It is run only after a 2025/26 pass, and the code refuses it, so a redesign can still be tested on it once. - **Predictions, written before any run:** three held. - Planner without hits v control: +6.8, inside +5 to +30. - What hits add on top: +3.5, inside −10 to +15. - Saves a season: median 10, inside 3 to 10. - "Plain makes more transfers" missed. Without hits both planners are capped at 31 transfers, and both used all 31 in every run. That was written in the ledger before the verdict existed. **Checked before reporting:** - **The projections showed no sign of seeing the future.** The planner's projected edge (+14.6) reached the points at about half (+6.8). Look-ahead would push it the other way. - **One look-ahead was found and recorded, not fixed.** The window's projections know which players are still at a club in a later round, so a January departure is seen early. Every arm sees it alike. - **The first timed run showed +76; that was one run's noise.** Its seed averages +8. 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](https://claude.com/claude-code)
Author
Owner

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 main at 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:

  1. while a club holds more than three, a transfer must sell one of its players;
  2. a round's transfers must leave every club at three or under.

move_search.py (the round plan's rule in Python) held only the first half; the season replay held the second, in decide. The page had neither.

The round plan (b98f7d1):

  • Its search sells only from a club above the cap (clubsOverCap, read by bestSwapFrom and 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.
  • Moves made now that would leave a club above three are not made at all, as the replay does. Example: five from one club need two sales in one round, and with one free transfer the plan would make one now and one next week. The moves are cleared before any reader sees a verdict, so the pitch, the lineup and the chips cannot make them. The pin applies the replay's own _over_cap to the copy's moves.
  • The card says which case holds, on screen: 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) or HAND (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/squad flagged 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_squad is untouched: rules.py is in the planner's fingerprint and the scoreboard's follow_rule.
  • The club spread drew three dots for four players. The players past the cap are now counted beside the dots (+1), and the search's blocker names the count held, not the cap.
  • Complete my squad keeps every pick, so four kept from one club cannot be completed, and it answered with the solver's "The problem is infeasible". It now refuses before the solve, naming the club and the remedy.
  • The chips print a violation as text, since it now carries a club name from FPL's data.

Checked:

  • 20 new tests (14 on the page, 6 on the server). Every connection was cut, one at a time: 29/29 red, each on the test it was written for.
  • rules.py and move_search.py's code are unchanged (one docstring), so the planner's verdict still matches its code. Harness 9d81c31db741, planner 8e786e29b3e9 and the scoreboard's follow_rule 1b7e5efffb7a are the same, and so is the projection digest: no replay and no rebuild.
  • Screenshots of the four card states and the club spread at 1600 and 390 px: no overflow, no page errors.
  • The full suite on merged main: 3010 passed, 0 failed, 0 skipped (2984 at 277eecf; the 26 new are this change's).

Records: a new section in wiki/systems/round-plan-on-the-squad-tab.md and 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:

  • The scoreboard's follow manager (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.
  • Next in the queue: "simulate the round" before the first double gameweek, then the calculations audit and the redesign.

🤖 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 `main` at 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: 1. while a club holds more than three, a transfer must sell one of its players; 2. a round's transfers must leave every club at three or under. `move_search.py` (the round plan's rule in Python) held only the first half; the season replay held the second, in `decide`. The page had neither. **The round plan** (`b98f7d1`): - Its search sells only from a club above the cap (`clubsOverCap`, read by `bestSwapFrom` and 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. - Moves made now that would leave a club above three are not made at all, as the replay does. Example: five from one club need two sales in one round, and with one free transfer the plan would make one now and one next week. The moves are cleared before any reader sees a verdict, so the pitch, the lineup and the chips cannot make them. The pin applies the replay's own `_over_cap` to the copy's moves. - The card says which case holds, on screen: `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) or `HAND` (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/squad` flagged 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_squad` is untouched: `rules.py` is in the planner's fingerprint and the scoreboard's `follow_rule`. - The club spread drew three dots for four players. The players past the cap are now counted beside the dots (`+1`), and the search's blocker names the count held, not the cap. - Complete my squad keeps every pick, so four kept from one club cannot be completed, and it answered with the solver's "The problem is infeasible". It now refuses before the solve, naming the club and the remedy. - The chips print a violation as text, since it now carries a club name from FPL's data. **Checked:** - 20 new tests (14 on the page, 6 on the server). Every connection was cut, one at a time: 29/29 red, each on the test it was written for. - `rules.py` and `move_search.py`'s code are unchanged (one docstring), so the planner's verdict still matches its code. Harness `9d81c31db741`, planner `8e786e29b3e9` and the scoreboard's `follow_rule` `1b7e5efffb7a` are the same, and so is the projection digest: no replay and no rebuild. - Screenshots of the four card states and the club spread at 1600 and 390 px: no overflow, no page errors. - The full suite on merged `main`: 3010 passed, 0 failed, 0 skipped (2984 at `277eecf`; the 26 new are this change's). **Records:** a new section in `wiki/systems/round-plan-on-the-squad-tab.md` and 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:** - The scoreboard's follow manager (`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. - Next in the queue: "simulate the round" before the first double gameweek, then the calculations audit and the redesign. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Author
Owner

FPL's flags now reach the projections within the hour when a deadline is close. Merged into main at 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:

  • Four new tests: the case itself, the hour's wait and its wording, and both edges of the window (35 hours rebuilds, 37 waits). Five cuts, five red, each on its named test.
  • 220 chores tests pass. The full suite on main: 3014 passed, 0 failed, 0 skipped.
  • chores.py is outside the projection, planner and scoreboard fingerprints: no rebuild, no replay.
  • Live: on the first tick after the restart, 16 hours before GW6's deadline, the status line read "rebuilding projections: FPL availability flags changed since the cache was built, 16h before GW6's deadline". Flags had changed again since the afternoon's hand rebuild.

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

**FPL's flags now reach the projections within the hour when a deadline is close.** Merged into `main` at 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:** - Four new tests: the case itself, the hour's wait and its wording, and both edges of the window (35 hours rebuilds, 37 waits). Five cuts, five red, each on its named test. - 220 chores tests pass. The full suite on `main`: 3014 passed, 0 failed, 0 skipped. - `chores.py` is outside the projection, planner and scoreboard fingerprints: no rebuild, no replay. - Live: on the first tick after the restart, 16 hours before GW6's deadline, the status line read "rebuilding projections: FPL availability flags changed since the cache was built, 16h before GW6's deadline". Flags had changed again since the afternoon's hand rebuild. **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](https://claude.com/claude-code)
Author
Owner

Simulate the round: a double gameweek now has its own distribution. Merged into main at the user's request and pushed (2bb5d25..5795751; restore tag restore/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 to scripts/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 new rounds field, 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_club left project_all on its GW1-6 default, so an override on a cache over GW6-11 re-simulated one round of six. Dormant: no override is live.
  • The integrity warning counted a repeated round, so a real double would have been reported as corrupt for every player at both clubs. It counts a repeated fixture now.
  • A player certain to miss his double (suspended for the round) tied the armband's pair ranking and was named captain. He is kept off the armband and named (certain_to_miss).
  • The final review (a fresh reviewer on the most capable model) found that a re-simulation over inputs that had moved since the build (a transfer, a rescheduled fixture) left a round half-made, and that the override and firm-up routes saved and published that cache, after which every route would answer 500. Fixed in 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.
  • The full suite caught the first draft of the page's new summary lookup on two existing guards (a .find keyed on the round, an unescaped name). It now filters, requires exactly one summary, and escapes.

Checked:

  • Built test-first from a plan generated from code first verified on a scratch branch. Every task's tests failed alone and passed with its code, and 96 cuts each turned the test they name red.
  • Single-match rounds are untouched: the build before and after on frozen live inputs (GW7-12, 8,000 simulations) gives all 2,358 rows of 393 players identical.
  • The coupling's sampling interval is wide (bootstrap 95% about 0.21-0.69). It still excludes independent matches and both-or-neither, which is what the change rests on.
  • Full suite on merged main: 3093 passed, 0 failed, 0 skipped.
  • Live: rebuilt (per_round is {}), 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

**Simulate the round: a double gameweek now has its own distribution.** Merged into `main` at the user's request and pushed (`2bb5d25..5795751`; restore tag `restore/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 to `scripts/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 new `rounds` field, 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_club` left `project_all` on its GW1-6 default, so an override on a cache over GW6-11 re-simulated one round of six. Dormant: no override is live. - The integrity warning counted a repeated *round*, so a real double would have been reported as corrupt for every player at both clubs. It counts a repeated fixture now. - A player certain to miss his double (suspended for the round) tied the armband's pair ranking and was named captain. He is kept off the armband and named (`certain_to_miss`). - The final review (a fresh reviewer on the most capable model) found that a re-simulation over inputs that had moved since the build (a transfer, a rescheduled fixture) left a round half-made, and that the override and firm-up routes saved and published that cache, after which every route would answer 500. Fixed in `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. - The full suite caught the first draft of the page's new summary lookup on two existing guards (a `.find` keyed on the round, an unescaped name). It now filters, requires exactly one summary, and escapes. **Checked:** - Built test-first from a plan generated from code first verified on a scratch branch. Every task's tests failed alone and passed with its code, and 96 cuts each turned the test they name red. - Single-match rounds are untouched: the build before and after on frozen live inputs (GW7-12, 8,000 simulations) gives all 2,358 rows of 393 players identical. - The coupling's sampling interval is wide (bootstrap 95% about 0.21-0.69). It still excludes independent matches and both-or-neither, which is what the change rests on. - Full suite on merged `main`: 3093 passed, 0 failed, 0 skipped. - Live: rebuilt (`per_round` is `{}`), 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](https://claude.com/claude-code)
Author
Owner

Calculations audit, and a simulator fix: goals conceded are now charged on the pitch. Merged into main at the user's request and pushed (5795751..81ea97d; restore tag restore/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.md and wiki/decisions/calculations-audit-2026-10.md.

What the audit found

candidate result
Bonus race PASS. The simulator paid 8.0 bonus a match where FPL pays 6.4: its BPS proxy ties whole back fours and FPL pays every tie. A corrected race (FPL's −4 per goal conceded, ties broken by each player's typical unmodelled BPS) pays 6.39 against 6.37, with 3.9% less error. Not adopted yet; it needs its own spec.
Bonus in decisions FAIL as registered: the test measured the backtest's level, not the bonus. Retired, to be re-tested on new rounds.
Cards FAIL as registered: pricing bookings clearly beats today's zero (about −0.09 a player-match is left out), but a player's own booking rate did not beat a flat rate for his position.
Goals and assists shared by position FAIL as registered: the position totals came right (forwards' assists 54 → 88 against 90 paid), but no single player's forecast improved.
Doubt flags, start probability Pre-registered on 2026/27; verdict after GW6-9.

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 first check (2024/25 predicting 2025/26) missed one of its four marks and was retired.
  • The second, on this season's GW1-5, passed: keepers' and defenders' goals-conceded points −262.4 against −263 paid, where the old rule gave −334.
  • Replayed over 2025/26, defenders' goals-conceded points come to −1,179 against −1,133 paid; the old rule gave −1,691.

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.

**Calculations audit, and a simulator fix: goals conceded are now charged on the pitch.** Merged into `main` at the user's request and pushed (`5795751..81ea97d`; restore tag `restore/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.md` and `wiki/decisions/calculations-audit-2026-10.md`. **What the audit found** | candidate | result | |---|---| | Bonus race | **PASS.** The simulator paid 8.0 bonus a match where FPL pays 6.4: its BPS proxy ties whole back fours and FPL pays every tie. A corrected race (FPL's −4 per goal conceded, ties broken by each player's typical unmodelled BPS) pays 6.39 against 6.37, with 3.9% less error. Not adopted yet; it needs its own spec. | | Bonus in decisions | FAIL as registered: the test measured the backtest's level, not the bonus. Retired, to be re-tested on new rounds. | | Cards | FAIL as registered: pricing bookings clearly beats today's zero (about −0.09 a player-match is left out), but a player's own booking rate did not beat a flat rate for his position. | | Goals and assists shared by position | FAIL as registered: the position totals came right (forwards' assists 54 → 88 against 90 paid), but no single player's forecast improved. | | Doubt flags, start probability | Pre-registered on 2026/27; verdict after GW6-9. | **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 first check (2024/25 predicting 2025/26) missed one of its four marks and was retired. - The second, on this season's GW1-5, passed: keepers' and defenders' goals-conceded points −262.4 against −263 paid, where the old rule gave −334. - Replayed over 2025/26, defenders' goals-conceded points come to −1,179 against −1,133 paid; the old rule gave −1,691. 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.
Author
Owner

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 from 81ea97d). The push waited a day because origin/main had 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

  • The race (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.
  • Each player's unmodelled BPS (f308ecf, backend/scoring/unmodelled_bps.py): FPL's bps, 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.
  • One source season per build (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 through PlayerInput and PlayerSpec, so a cache without them refuses on load. Snapshots taken before the merge still load.
  • Decisions stay on ev_no_bonus, which moved for no player. Bonus reaches ev, the player card's "Bonus prov." and the compare table's "Bonus (provisional)".
  • The C-bonus scorer refuses to run (c5d3e4e), because the race it compared against is gone. Its derivation of K still runs.
  • Each 2025/26 match is read once (b03a075). merged_gw.csv repeats 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):

bonus a match before read once + the race
all positions 7.758 7.755 6.367
keepers 0.988 0.988 0.524
defenders 2.178 2.173 1.583
midfielders 2.840 2.840 3.038
forwards 1.752 1.754 1.222

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:

record as recorded read once
BPS reproduction, correlation (reconcile_bps.py) 0.8587 0.8584
xG blend, held out GW26+ (backtest_rps_xg.py) +0.00603 [+0.0001, +0.0119] +0.00584 [−0.0001, +0.0117]
goal counts, log-likelihood (backtest_goals_xg.py) +0.0501 [+0.0242, +0.0760] +0.0507 [+0.0252, +0.0763]
market ratings, ours on Pinnacle's matches (backtest_market_ratings.py) 0.2068 0.2066
over/under (measure_over_under.py): xA under the assists; clean sheets 683.3; 2,249 683.2; 2,248

The 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:

  • A cache that failed its load check stopped every chore (5d1037a), including the pre-deadline snapshot. The inputs watch now makes build_cache.py due, with the reason "the served cache fails its load check", and the rest of the tick runs.
  • A double gameweek's summed rounds.json row 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.
  • A stale docstring in fixture.py (df15024).
  • The rollout order (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

  • 57 cuts, each red on the test it names: 46 in the plan's tasks and 11 in the fix pass.
  • The full suite on main, in the live checkout: 3,155 passed, 0 skipped, 0 failed (2026-10-16, on 88bb0a1, before the port; the suite after the port is in the next comment).
  • After the restart, /api/status showed nothing stale and no faults. The player card was looked at in screenshots at 1600 and 390 px.
  • The Forecast accuracy panel was compared with a copy saved before the merge. Only the two refitted match tiles moved, in the fourth decimal: on the fixtures the market priced, ours went from 0.2042 to 0.2040 on match outcome and from 0.1781 to 0.1780 on clean sheets. Both are still "level" with the market. The goalscorer, expected-points and starts tiles are identical.

What stays open

  • The other readers of 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.
  • Defenders' bonus stays over (673 against 528 paid on 2025/26), and midfielders' stays under (795 against 907). Player-matches priced at 0.5 or more were paid about a third less.
  • The early-season branch (last season's figures) is first used in August 2027. Its check is registered in docs/testing-notes.md.
  • Six minor review findings are deferred:
    • the 2027/28 check is in prose only, and it pools both branches;
    • the read-once refusal does not name the columns that differ;
    • the rollover guard's glob stops at backend/, and scripts/fetch_current_season.py hardcodes the season's path;
    • the spec's text about resim_club is wrong;
    • the testing notes repeat a phrase;
    • the race's own-stream test covers only one function.

Evidence is in wiki/decisions/bonus-race-adopted.md and in docs/testing-notes.md ("C-bonus adopted"). The spec is docs/superpowers/specs/2026-10-11-bonus-race-design.md and the plan is docs/superpowers/plans/2026-10-15-bonus-race.md. The tag restore/pre-bonus-race-2026-10-15 marks 81ea97d.

🤖 Generated with Claude Code

**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 from `81ea97d`). The push waited a day because `origin/main` had 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 - **The race** (`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. - **Each player's unmodelled BPS** (`f308ecf`, `backend/scoring/unmodelled_bps.py`): FPL's `bps`, 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. - **One source season per build** (`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 through `PlayerInput` and `PlayerSpec`, so a cache without them refuses on load. Snapshots taken before the merge still load. - **Decisions stay on `ev_no_bonus`**, which moved for no player. Bonus reaches `ev`, the player card's "Bonus prov." and the compare table's "Bonus (provisional)". - **The C-bonus scorer refuses to run** (`c5d3e4e`), because the race it compared against is gone. Its derivation of K still runs. - **Each 2025/26 match is read once** (`b03a075`). `merged_gw.csv` repeats 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): | bonus a match | before | read once | + the race | |---|---|---|---| | all positions | 7.758 | 7.755 | **6.367** | | keepers | 0.988 | 0.988 | 0.524 | | defenders | 2.178 | 2.173 | 1.583 | | midfielders | 2.840 | 2.840 | 3.038 | | forwards | 1.752 | 1.754 | 1.222 | 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: | record | as recorded | read once | |---|---|---| | BPS reproduction, correlation (`reconcile_bps.py`) | 0.8587 | 0.8584 | | xG blend, held out GW26+ (`backtest_rps_xg.py`) | +0.00603 [+0.0001, +0.0119] | +0.00584 [−0.0001, +0.0117] | | goal counts, log-likelihood (`backtest_goals_xg.py`) | +0.0501 [+0.0242, +0.0760] | +0.0507 [+0.0252, +0.0763] | | market ratings, ours on Pinnacle's matches (`backtest_market_ratings.py`) | 0.2068 | 0.2066 | | over/under (`measure_over_under.py`): xA under the assists; clean sheets | 683.3; 2,249 | 683.2; 2,248 | The 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: - **A cache that failed its load check stopped every chore** (`5d1037a`), including the pre-deadline snapshot. The inputs watch now makes `build_cache.py` due, with the reason "the served cache fails its load check", and the rest of the tick runs. - **A double gameweek's summed `rounds.json` row 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. - **A stale docstring in `fixture.py`** (`df15024`). - **The rollout order** (`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 - 57 cuts, each red on the test it names: 46 in the plan's tasks and 11 in the fix pass. - The full suite on `main`, in the live checkout: **3,155 passed, 0 skipped, 0 failed** (2026-10-16, on `88bb0a1`, before the port; the suite after the port is in the next comment). - After the restart, `/api/status` showed nothing stale and no faults. The player card was looked at in screenshots at 1600 and 390 px. - The Forecast accuracy panel was compared with a copy saved before the merge. Only the two refitted match tiles moved, in the fourth decimal: on the fixtures the market priced, ours went from 0.2042 to 0.2040 on match outcome and from 0.1781 to 0.1780 on clean sheets. Both are still "level" with the market. The goalscorer, expected-points and starts tiles are identical. ### What stays open - **The other readers of `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. - Defenders' bonus stays over (673 against 528 paid on 2025/26), and midfielders' stays under (795 against 907). Player-matches priced at 0.5 or more were paid about a third less. - The early-season branch (last season's figures) is first used in August 2027. Its check is registered in `docs/testing-notes.md`. - Six minor review findings are deferred: - the 2027/28 check is in prose only, and it pools both branches; - the read-once refusal does not name the columns that differ; - the rollover guard's glob stops at `backend/`, and `scripts/fetch_current_season.py` hardcodes the season's path; - the spec's text about `resim_club` is wrong; - the testing notes repeat a phrase; - the race's own-stream test covers only one function. Evidence is in `wiki/decisions/bonus-race-adopted.md` and in `docs/testing-notes.md` ("C-bonus adopted"). The spec is `docs/superpowers/specs/2026-10-11-bonus-race-design.md` and the plan is `docs/superpowers/plans/2026-10-15-bonus-race.md`. The tag `restore/pre-bonus-race-2026-10-15` marks `81ea97d`. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Author
Owner

The rest of the audit's work from 2026-10-16 went out with the bonus race (aaca006..2474892, fast-forwards on main, 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 to scripts/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:

2025/26, 310 matches weight 4 weight 1 FPL paid
DEFCON points, minutes played 1,756.8 2,173.9 2,332
defenders 1,141.8 1,291.7 1,324
midfielders 610.2 871.8 990
under five 90s of evidence 130.1 182.2 228

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.0 is pinned to the scorer's frozen number. The shared prior_weight now shrinks xG, xA and saves only. backtest_points threads 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

  • C-cards-flat (e14404c, amended before GW7 in 2a64fb5) 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, as check_defcon_calibration always required: a provisional round's bonus still moves.
  • C-split-rank (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. Changing p_assist changes 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_ev kept 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

  • The full suite in a worktree: 3,185 passed, 63 skipped, 0 failed at 83bdcd5, and 3,198 passed, 64 skipped, 0 failed at 2474892 (the 14 new tests; the one skip needs the saved bootstrap, and it passed with it).
  • In the live checkout after the go-live: 3,262 passed, 0 skipped, 0 failed at 2474892 (2026-10-17). That is every test the worktree collected, and the 64 it skipped ran on the real data.
  • Each step was built with its own code before the restart. After each restart, /api/status showed the cache current (projection digests ddc3663534ce at 7e3b29b and da6b8caa71f9 at 2474892), with no faults and no chore errors.
  • Between the cache built before the go-live and the one after the first step, the code moved only the keepers' and forwards' later-round availability: 108 players by at most 0.015, and by at most 0.001 inside GW8-13. All 35 start chances that moved were at Everton and Chelsea, where FPL lifted two doubts and added one between the builds. The bonus came to 6.368 a match.
  • The Forecast accuracy panel was identical to a copy saved before the go-live. The scheduler re-scored all twelve banks under each step's scorer digests, and no number moved.
  • The page was looked at on all five tabs at 1600 px, both player cards included.
  • Cuts: 21 for C-split-rank, 6 for the FINAL amendment, 9 for C-defcon-weight and 19 for its adoption, each red on the test it names.

restore/pre-go-live-2026-10-17 marks 88bb0a1, the main that ran before this. Evidence is in wiki/decisions/calculations-audit-2026-10.md, wiki/notes/defcon-rate-calibration.md and docs/testing-notes.md.

🤖 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 on `main`, 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 to `scripts/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: | 2025/26, 310 matches | weight 4 | weight 1 | FPL paid | |---|---|---|---| | DEFCON points, minutes played | 1,756.8 | 2,173.9 | 2,332 | | defenders | 1,141.8 | 1,291.7 | 1,324 | | midfielders | 610.2 | 871.8 | 990 | | under five 90s of evidence | 130.1 | 182.2 | 228 | 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.0` is pinned to the scorer's frozen number. The shared `prior_weight` now shrinks xG, xA and saves only. `backtest_points` threads 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 - **C-cards-flat** (`e14404c`, amended before GW7 in `2a64fb5`) 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, as `check_defcon_calibration` always required: a provisional round's bonus still moves. - **C-split-rank** (`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. Changing `p_assist` changes 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_ev` kept 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 - The full suite in a worktree: 3,185 passed, 63 skipped, 0 failed at `83bdcd5`, and 3,198 passed, 64 skipped, 0 failed at `2474892` (the 14 new tests; the one skip needs the saved bootstrap, and it passed with it). - In the live checkout after the go-live: **3,262 passed, 0 skipped, 0 failed** at `2474892` (2026-10-17). That is every test the worktree collected, and the 64 it skipped ran on the real data. - Each step was built with its own code before the restart. After each restart, `/api/status` showed the cache current (projection digests `ddc3663534ce` at `7e3b29b` and `da6b8caa71f9` at `2474892`), with no faults and no chore errors. - Between the cache built before the go-live and the one after the first step, the code moved only the keepers' and forwards' later-round availability: 108 players by at most 0.015, and by at most 0.001 inside GW8-13. All 35 start chances that moved were at Everton and Chelsea, where FPL lifted two doubts and added one between the builds. The bonus came to 6.368 a match. - The Forecast accuracy panel was identical to a copy saved before the go-live. The scheduler re-scored all twelve banks under each step's scorer digests, and no number moved. - The page was looked at on all five tabs at 1600 px, both player cards included. - Cuts: 21 for C-split-rank, 6 for the FINAL amendment, 9 for C-defcon-weight and 19 for its adoption, each red on the test it names. `restore/pre-go-live-2026-10-17` marks `88bb0a1`, the `main` that ran before this. Evidence is in `wiki/decisions/calculations-audit-2026-10.md`, `wiki/notes/defcon-rate-calibration.md` and `docs/testing-notes.md`. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Author
Owner

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 main 2474892. Nothing is merged or live.

What 1a changed (d873286..e11edcf)

  • Server (69fad03). /api/players serves start_by_gw, each round's chance of starting, from pipeline._start_for_gw: a USER override where one applies, else start_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.py pins the route to that function by monkeypatching it. The summary view keeps the field.
  • Tokens (42515d0). These are the popup's layer (--surface-pop, --border-pop), the chance-of-starting scale (--start-75/50/25, ending at --alert at 0%) and the stage's own tokens. Each stop is held to AA on --bg and --surface-1.
  • One popup component (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 by textContent only. It flips above its trigger near the foot of the window and points at it. The shirt's own titles went, so its popup is the explanation. The fixture line's difficulty and provisional-star titles remain, for the cards' own phase.
  • The chance mark (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.
  • The card shows just the player (f8af332): name, xPts (keeping its magColour) and opponent. The xPts-fifth edge is gone, with BAND_LABEL, evBand, computeGwQuintiles and the GW_QUINTILES/GW_RANGE state. 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).
  • The stage (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.
  • The XI with the plan applied (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). planTags was deleted.
  • The bench is one line (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 shares benchOrder.
  • One quiet line above the pitch (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.
  • Records (f7270fb, e11edcf). New wiki page wiki/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, plus test_frontend_parses collecting 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:

    • null-as-0 in that sort is an equivalent mutant, since xPts are never negative, so the nulls-first mutant is the one run;
    • cutting the Optimise note was absorbed by the temporary note the press appends, and it went red on the test written for the note.
  • Two regressions from earlier tasks surfaced only in test files outside those tasks' lists:

    • the lit pitch put the Optimise note's --text-2 at 4.4:1 on the grass;
    • the new card popup reads a double round's summary on render, and a stub served a double without one (a payload the server never sends).

    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)

  • A popup can stay up after a background re-render if the pointer then leaves in one jump. It clears on the next hover, Escape or scroll.
  • The chance mark is drawn in a blank round ("40%" under "blank"). It should be keyed on the fixture; that is planned into 1b.
  • The start mark's aria-label sits on a plain span.
  • Pre-existing, not touched:
    • tests/web/test_compression.py::test_the_compressed_body_is_smaller_and_still_valid fails whenever test_api.py and test_player_response_cache.py run before it in one process, because it reads the small cache they leave behind;
    • test_squad_tab_money_and_rounds._page starts Playwright before its try/finally, the shape CLAUDE.md warns about.

Next

Plan 1b is docs/superpowers/plans/2026-10-18-floodlit-1b-band.md (356d5c3):

  • the round plan becomes one band of four decisions (Transfers, XI, Captain, Chips; about 25 words where today's card has about 105);
  • the strip above it goes, with every fact it held moved;
  • the header reads "GW8 · Fri 23 Oct 19:30 · 6 days".

It is waiting for the user's review. 1a and 1b go live together: until 1b removes the strip, the strip flags on start_prob while the cards mark the round's chance. The go-live involves:

  • the full suite in the live checkout;
  • a server restart, since routes/players.py changed;
  • the spec's live check that no number on the pitch is prior-derived.

🤖 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`](https://git.kristofferopsahl.com/solvreven/FPL/compare/main...feat/floodlit), off main 2474892. Nothing is merged or live. ### What 1a changed (d873286..e11edcf) - **Server** (69fad03). `/api/players` serves `start_by_gw`, each round's chance of starting, from `pipeline._start_for_gw`: a USER override where one applies, else `start_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.py` pins the route to that function by monkeypatching it. The summary view keeps the field. - **Tokens** (42515d0). These are the popup's layer (`--surface-pop`, `--border-pop`), the chance-of-starting scale (`--start-75/50/25`, ending at `--alert` at 0%) and the stage's own tokens. Each stop is held to AA on `--bg` and `--surface-1`. - **One popup component** (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 by `textContent` only. It flips above its trigger near the foot of the window and points at it. The shirt's own `title`s went, so its popup is the explanation. The fixture line's difficulty and provisional-star `title`s remain, for the cards' own phase. - **The chance mark** (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. - **The card shows just the player** (f8af332): name, xPts (keeping its `magColour`) and opponent. The xPts-fifth edge is gone, with `BAND_LABEL`, `evBand`, `computeGwQuintiles` and the `GW_QUINTILES`/`GW_RANGE` state. 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`). - **The stage** (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. - **The XI with the plan applied** (6a079ee). `planView(plan, d)` gives the suggested XI from the squad the plan leaves. A bought player carries "for <sold>", 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`). `planTags` was deleted. - **The bench is one line** (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 shares `benchOrder`. - **One quiet line above the pitch** (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. - **Records** (f7270fb, e11edcf). New wiki page `wiki/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, plus `test_frontend_parses` collecting 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: - null-as-0 in that sort is an equivalent mutant, since xPts are never negative, so the nulls-first mutant is the one run; - cutting the Optimise note was absorbed by the temporary note the press appends, and it went red on the test written for the note. - **Two regressions from earlier tasks surfaced only in test files outside those tasks' lists:** - the lit pitch put the Optimise note's `--text-2` at 4.4:1 on the grass; - the new card popup reads a double round's summary on render, and a stub served a double without one (a payload the server never sends). 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) - A popup can stay up after a background re-render if the pointer then leaves in one jump. It clears on the next hover, Escape or scroll. - The chance mark is drawn in a blank round ("40%" under "blank"). It should be keyed on the fixture; that is planned into 1b. - The start mark's `aria-label` sits on a plain span. - Pre-existing, not touched: - `tests/web/test_compression.py::test_the_compressed_body_is_smaller_and_still_valid` fails whenever `test_api.py` and `test_player_response_cache.py` run before it in one process, because it reads the small cache they leave behind; - `test_squad_tab_money_and_rounds._page` starts Playwright before its `try/finally`, the shape CLAUDE.md warns about. ### Next Plan 1b is `docs/superpowers/plans/2026-10-18-floodlit-1b-band.md` (356d5c3): - the round plan becomes one band of four decisions (Transfers, XI, Captain, Chips; about 25 words where today's card has about 105); - the strip above it goes, with every fact it held moved; - the header reads "GW8 · Fri 23 Oct 19:30 · 6 days". It is waiting for the user's review. 1a and 1b go live together: until 1b removes the strip, the strip flags on `start_prob` while the cards mark the round's chance. The go-live involves: - the full suite in the live checkout; - a server restart, since `routes/players.py` changed; - the spec's live check that no number on the pitch is prior-derived. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Author
Owner

Floodlit, phase 1 (1a and 1b): live on main, pushed with this comment

The Squad tab's redesign went live on this machine on 2026-10-20 at 07:25 Oslo. main was fast-forwarded 2474892..b751fbf, the 23 commits of feat/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)

  • The round plan is a band of four columns, with one drawer under it (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:

      • its gain over the window and this round, and its chance to gain;
      • the price reasons;
      • what waiting a week gives up.

      "+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.

  • The strip above the plan went, and every fact it held moved (b7920c9). tests/web/test_band_facts_reachable.py has one case per fact. Each case reads the text a person can reach: the band, every popup, each drawer opened in turn, and Record.
    fact now
    the bank the Transfers eyebrow
    the doubts the XI column, and the cards' chance marks
    near a suspension the doubt popup, and the card's popup
    tonight's price falls a quiet line in Transfers, opening a drawer
    the scoreboard the Record tab
    the 75–85% watch tier gone; it had no other reader
  • The header reads the round, the time and the days left (a56205b): "GW8 · Fri 23 Oct, 19:30 · 3 days".
    • The line turns --alert under 24 hours.
    • An unknown deadline is said in words.
    • The title is for screen readers only.
  • Fix from the final review (b751fbf). keepFocus matched 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.
  • Records:
    • wiki/systems/floodlit-squad-tab-band.md (cbe2acb; lint clean).
    • A CLAUDE.md rule (0abbb80): Git Bash's sed -i rewrites 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's start_by_gw) and one docstring in rules.py. These digests are equal on main and the branch, so nothing was rebuilt or replayed:

  • the projection digest (da6b8caa71f9);
  • the closure digests of the follow rule, the planner and its harness.

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

  • Full suite in the live checkout at 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.
    • The 64 tests skipped in the worktree need gitignored data; they ran here.
    • The worktree's run was 3,290 passed / 64 skipped / 0 failed.
    • Against main's 3,262 tests: +92 (1a +43, 1b +48, the fix +1), explained file by file.
  • The spec's live check, read-only, on the user's real squad: with the caveat marks gone, no number on the pitch is prior-derived. The page was read at 1600 and 1300 px, each card joined to its player on data-eid, with no console errors.
    • All fifteen picks have measured availability.
    • Fourteen have measured rates. The bench keeper has no minutes in either season, so his rates are position-prior. He shows "—" with his measured 0%, never a number.
    • The other three LOW-DATA players' numbers come from this season's rounds (254 to 630 minutes). The badge only means no row in last season's archive.
    • The two players the plan brings in are measured too.
  • Mutation cuts: every task's connections were cut one at a time, and in the end each cut went red on its named test. Two needed work first:
    • a drawer line that the popup also carried survived, until the test asserted each on its own;
    • one cut went red on a SyntaxError instead of its assertion: a braceless if took the next statement as its body. The if is braced now.
  • Final review: a self-review of the whole branch, with one Important finding (the focus key above), fixed.

Deferred (minor)

  • A drawer can outlive its link. Example: the falls drawer stays open after tonight's falls drop to zero on the five-minute re-read. Another link or a reload closes it.
  • The options drawer is the old list moved unchanged ("GOOD MOVE 100%" in capitals). It is denser than the band; that is for a design pass.
  • (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.
  • The Matches tab prints fixture times in the browser's locale, while the header prints en-GB. That is for Phase 4.

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.

  • A tab left open over a deadline. The header names the next round within five minutes. The next redraw then prints that round's xPts on every card, while the pitch still draws the closed round's plan: its XI, its "for" tags and its armband. Reproduced on the live page by rewriting only the browser's /api/status. The same condition, gw === thisGw(), stood on main before floodlit.
  • A fresh page, for up to about an hour after a deadline (read from the code, not reproduced). The inputs watch's one-hour minimum age (MARKET_REBUILD_MIN_AGE) also holds the rebuild that a deadline passing calls for, so the served projections still start at the closed round.
  • The "projections built" stamp. It is re-read with the status, so after a server rebuild it names a build the numbers on screen did not come from (CLAUDE.md, 2026-10-02: a time that travels apart from the data it dates).

Next

Each phase starts with mockups for the user:

  • Phase 2, the player cards, including the five displays that read one match per round. These must be fixed before the first double gameweek.
  • Phase 3, the Players tab, with the DEFCON column (audit defect 7).
  • Phase 4, Fixtures, Matches and Record.

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/pit holds 19 snapshots there, the latest from 2026-10-17, GW7's.
  • While the server runs, the chores take a snapshot in the three hours before each deadline.
  • A scheduled task wakes the PC 2h45m before each deadline.
  • A daily job backs up the gitignored data (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

## Floodlit, phase 1 (1a and 1b): live on `main`, pushed with this comment The Squad tab's redesign went live on this machine on 2026-10-20 at 07:25 Oslo. `main` was fast-forwarded `2474892..b751fbf`, the 23 commits of `feat/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`) - **The round plan is a band of four columns, with one drawer under it** (`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: - its gain over the window and this round, and its chance to gain; - the price reasons; - what waiting a week gives up. "+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. - **The strip above the plan went, and every fact it held moved** (`b7920c9`). `tests/web/test_band_facts_reachable.py` has one case per fact. Each case reads the text a person can reach: the band, every popup, each drawer opened in turn, and Record. | fact | now | |---|---| | the bank | the Transfers eyebrow | | the doubts | the XI column, and the cards' chance marks | | near a suspension | the doubt popup, and the card's popup | | tonight's price falls | a quiet line in Transfers, opening a drawer | | the scoreboard | the Record tab | | the 75–85% watch tier | gone; it had no other reader | - **The header reads the round, the time and the days left** (`a56205b`): "GW8 · Fri 23 Oct, 19:30 · 3 days". - The line turns `--alert` under 24 hours. - An unknown deadline is said in words. - The title is for screen readers only. - **Fix from the final review** (`b751fbf`). `keepFocus` matched 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. - **Records:** - `wiki/systems/floodlit-squad-tab-band.md` (`cbe2acb`; lint clean). - A CLAUDE.md rule (`0abbb80`): Git Bash's `sed -i` rewrites 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's `start_by_gw`) and one docstring in `rules.py`. These digests are equal on `main` and the branch, so nothing was rebuilt or replayed: - the projection digest (`da6b8caa71f9`); - the closure digests of the follow rule, the planner and its harness. 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 - **Full suite in the live checkout at `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. - The 64 tests skipped in the worktree need gitignored data; they ran here. - The worktree's run was 3,290 passed / 64 skipped / 0 failed. - Against `main`'s 3,262 tests: +92 (1a +43, 1b +48, the fix +1), explained file by file. - **The spec's live check, read-only, on the user's real squad:** with the caveat marks gone, no number on the pitch is prior-derived. The page was read at 1600 and 1300 px, each card joined to its player on `data-eid`, with no console errors. - All fifteen picks have measured availability. - Fourteen have measured rates. The bench keeper has no minutes in either season, so his rates are position-prior. He shows "—" with his measured 0%, never a number. - The other three LOW-DATA players' numbers come from this season's rounds (254 to 630 minutes). The badge only means no row in last season's archive. - The two players the plan brings in are measured too. - **Mutation cuts:** every task's connections were cut one at a time, and in the end each cut went red on its named test. Two needed work first: - a drawer line that the popup also carried survived, until the test asserted each on its own; - one cut went red on a SyntaxError instead of its assertion: a braceless `if` took the next statement as its body. The `if` is braced now. - **Final review:** a self-review of the whole branch, with one Important finding (the focus key above), fixed. ### Deferred (minor) - A drawer can outlive its link. Example: the falls drawer stays open after tonight's falls drop to zero on the five-minute re-read. Another link or a reload closes it. - The options drawer is the old list moved unchanged ("GOOD MOVE 100%" in capitals). It is denser than the band; that is for a design pass. - `(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. - The Matches tab prints fixture times in the browser's locale, while the header prints en-GB. That is for Phase 4. ### 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. - **A tab left open over a deadline.** The header names the next round within five minutes. The next redraw then prints that round's xPts on every card, while the pitch still draws the closed round's plan: its XI, its "for" tags and its armband. Reproduced on the live page by rewriting only the browser's `/api/status`. The same condition, `gw === thisGw()`, stood on `main` before floodlit. - **A fresh page, for up to about an hour after a deadline** (read from the code, not reproduced). The inputs watch's one-hour minimum age (`MARKET_REBUILD_MIN_AGE`) also holds the rebuild that a deadline passing calls for, so the served projections still start at the closed round. - **The "projections built" stamp.** It is re-read with the status, so after a server rebuild it names a build the numbers on screen did not come from (CLAUDE.md, 2026-10-02: a time that travels apart from the data it dates). ### Next Each phase starts with mockups for the user: - Phase 2, the player cards, including the five displays that read one match per round. These must be fixed before the first double gameweek. - Phase 3, the Players tab, with the DEFCON column (audit defect 7). - Phase 4, Fixtures, Matches and Record. ### 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/pit` holds 19 snapshots there, the latest from 2026-10-17, GW7's. - While the server runs, the chores take a snapshot in the three hours before each deadline. - A scheduled task wakes the PC 2h45m before each deadline. - A daily job backs up the gitignored data (`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](https://claude.com/claude-code)
Author
Owner

The round mismatch from the go-live comment: fixed, live on main, pushed with this comment

This is the "Found while checking the go-live" item from the comment above (23062). main was fast-forwarded b751fbf..c3ba03d at 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.

  • The page follows the server (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.
  • The stamp names the build on screen: "projections on screen built 09:10 · the server's built 11:00" while the two differ, one time when they agree.
  • A closed round is a stated state (transfers.js). When the served deadline's round is past the projections' first round, the plan is {closed}.
    • The band reads "GW8's deadline has passed. The GW9 plan comes with the projections' next build."
    • The pitch draws the suggested XI for the open round.
    • /api/best-xi's armband, ranked for the projections' first round, is no longer painted on the next one (squad.js).
  • The rebuild is due at once when a deadline passes (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

  • New tests: tests/web/test_page_follows_server_build.py drives 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 in test_projection_inputs_watch.py: the deadline case, and a control that a cache as young still waits its hour before the deadline.
  • Mutation cuts: eleven, each red on its named assertion. Four first went red as bare wait_for_function timeouts, which name nothing. The waits now raise an AssertionError saying what did not happen.
  • One test changed: test_season_phase_labels::test_a_served_deadline_outranks_a_stale_cache pinned 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.
  • Full suites:
    • the worktree: 3,298 passed / 64 skipped / 0 failed;
    • the live checkout: 3,362 passed / 0 skipped / 0 failed, every chunk's pytest exit 0, matching the count written down before the run.
  • Live: the morning's reproduction re-run on the live page after the restart, rewriting only the browser's /api/status.
    • The deadline case now redraws within the five-minute read: the header, the kicker and the band agree, with your fifteen's suggested XI for GW9, GW9's numbers, no armband and no "for" tags.
    • The rebuild case names both builds.
  • Records: 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

## The round mismatch from the go-live comment: fixed, live on `main`, pushed with this comment This is the "Found while checking the go-live" item from the comment above (23062). `main` was fast-forwarded `b751fbf..c3ba03d` at 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. - **The page follows the server** (`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. - **The stamp names the build on screen**: "projections on screen built 09:10 · the server's built 11:00" while the two differ, one time when they agree. - **A closed round is a stated state** (`transfers.js`). When the served deadline's round is past the projections' first round, the plan is `{closed}`. - The band reads "GW8's deadline has passed. The GW9 plan comes with the projections' next build." - The pitch draws the suggested XI for the open round. - `/api/best-xi`'s armband, ranked for the projections' first round, is no longer painted on the next one (`squad.js`). - **The rebuild is due at once when a deadline passes** (`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 - **New tests:** `tests/web/test_page_follows_server_build.py` drives 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 in `test_projection_inputs_watch.py`: the deadline case, and a control that a cache as young still waits its hour before the deadline. - **Mutation cuts:** eleven, each red on its named assertion. Four first went red as bare `wait_for_function` timeouts, which name nothing. The waits now raise an `AssertionError` saying what did not happen. - **One test changed:** `test_season_phase_labels::test_a_served_deadline_outranks_a_stale_cache` pinned 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. - **Full suites:** - the worktree: 3,298 passed / 64 skipped / 0 failed; - the live checkout: 3,362 passed / 0 skipped / 0 failed, every chunk's pytest exit 0, matching the count written down before the run. - **Live:** the morning's reproduction re-run on the live page after the restart, rewriting only the browser's `/api/status`. - The deadline case now redraws within the five-minute read: the header, the kicker and the band agree, with your fifteen's suggested XI for GW9, GW9's numbers, no armband and no "for" tags. - The rebuild case names both builds. - **Records:** `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](https://claude.com/claude-code)
Author
Owner

A learned chance of starting, registered before GW9 (C-start-model): on main, pushed with this comment

main was fast-forwarded c3ba03d..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 for priors.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 at a8728fd). The model's SHA-256 and today's estimator's closure digest are frozen in scripts/score_start_challenger.py.

  • Rounds: the first eight FINAL rounds from GW9 (up to GW20), each scored from the snapshot standing for it.
  • PASS needs all of:
    • the starts Brier interval entirely below zero;
    • a smaller total expected-points gap;
    • a per-player error that is not worse;
    • if C-start-cal passes, the first again against C-start-cal's map.
  • The verdict is due about 23 December, written once to 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.

  • Starts: −0.0042 Brier (95% interval −0.0077 to −0.0006), better in every round.
  • Total expected points: 9.1% high for today's estimator, 5.4% for the challenger.
  • The tails: under 5%, today's estimator said 0.29% where 2.89% started; the challenger said 0.78% where 0.94% did.
  • Eight rounds, not four: on four the interval only just cleared zero.

Found while building. Each of these passed its own tests first:

  • Reads anchored to the code's checkout: three of them (the fit's snapshots, C-start-cal's banks, the commit stamp). From a worktree with --data-root, the registered fit would have had no 2026/27 rows and said nothing.
  • 2024/25's archive: load_archive cannot read it (two files, no DEFCON columns).
  • A moved FPL flag: one change between a cache's build and its snapshot would have refused the whole one-shot test, and GW5's snapshot had one. The reproduction check now applies only where the cache read the snapshot's flags (ProjCache.inputs).

Checks.

  • 81 new tests. Every connection cut went red on its named test; the two that survived in the final review became tests.
  • Full suites: the worktree 3,379 passed / 64 skipped / 0 failed; the live checkout 3,443 passed / 0 skipped / 0 failed, every chunk's pytest exit 0, matching the count written down before the run.

Records. wiki/decisions/start-challenger-registered.md (lint clean), and a CLAUDE.md Known-gaps entry with four rules:

  • a script that takes --data-root reads every data path through it;
  • a stamp names the code that ran;
  • a one-shot test is sized from its dry run;
  • a reproduction check applies only where the inputs were the same.

🤖 Generated with Claude Code

## A learned chance of starting, registered before GW9 (C-start-model): on `main`, pushed with this comment `main` was fast-forwarded `c3ba03d..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 for `priors.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 at `a8728fd`). The model's SHA-256 and today's estimator's closure digest are frozen in `scripts/score_start_challenger.py`. - **Rounds:** the first eight FINAL rounds from GW9 (up to GW20), each scored from the snapshot standing for it. - **PASS needs all of:** - the starts Brier interval entirely below zero; - a smaller total expected-points gap; - a per-player error that is not worse; - if C-start-cal passes, the first again against C-start-cal's map. - **The verdict** is due about 23 December, written once to `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. - **Starts:** −0.0042 Brier (95% interval −0.0077 to −0.0006), better in every round. - **Total expected points:** 9.1% high for today's estimator, 5.4% for the challenger. - **The tails:** under 5%, today's estimator said 0.29% where 2.89% started; the challenger said 0.78% where 0.94% did. - **Eight rounds, not four:** on four the interval only just cleared zero. **Found while building.** Each of these passed its own tests first: - **Reads anchored to the code's checkout:** three of them (the fit's snapshots, C-start-cal's banks, the commit stamp). From a worktree with `--data-root`, the registered fit would have had no 2026/27 rows and said nothing. - **2024/25's archive:** `load_archive` cannot read it (two files, no DEFCON columns). - **A moved FPL flag:** one change between a cache's build and its snapshot would have refused the whole one-shot test, and GW5's snapshot had one. The reproduction check now applies only where the cache read the snapshot's flags (`ProjCache.inputs`). **Checks.** - 81 new tests. Every connection cut went red on its named test; the two that survived in the final review became tests. - Full suites: the worktree 3,379 passed / 64 skipped / 0 failed; the live checkout 3,443 passed / 0 skipped / 0 failed, every chunk's pytest exit 0, matching the count written down before the run. **Records.** `wiki/decisions/start-challenger-registered.md` (lint clean), and a CLAUDE.md Known-gaps entry with four rules: - a script that takes `--data-root` reads every data path through it; - a stamp names the code that ran; - a one-shot test is sized from its dry run; - a reproduction check applies only where the inputs were the same. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Author
Owner

The DEFCON column reads the simulator's DEFCON (audit defect 7): live on main, pushed with this comment

main was fast-forwarded 2c7da85..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:

  • defenders: 184.1 against the simulator's 249.2 (26% under);
  • midfielders: 83.4 against 137.4 (39% under);
  • forwards: 0.8 against 1.7.

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):

  • Both readers now take components6.defcon, the sum the breakdown and Compare already read.
  • defcon_ev is gone from the projection rows, with the minutes helper only it used (pipeline._defcon_minutes and its ProjectionServices field) and the golden master's field for it.
  • A guard names any source under backend/, frontend/ or scripts/ that brings the field back.
  • The page had summed the field with || 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.

  • Two page tests read the column and the card off the rendered page, and the honesty-fields test asserts the rows carry one DEFCON. The award-from-config regression now reads the simulator's DEFCON.
  • Six cuts, each red on its named test.
  • Screenshots looked at, at 1600 px.
  • Full suites:
    • the worktree: 3,382 passed / 64 skipped / 0 failed. Chunk 2 was re-run after a test fix: its first run caught test_absence.py pinning the removed minutes line as source text, and that assertion now checks the line stays gone.
    • the live checkout, after the rebuild: 3,446 passed / 0 skipped / 0 failed in one run, every chunk's pytest exit 0, matching the count written down before the run (the golden master's field gate reads the rebuilt cache).

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:

  • 668 players, no row with the old field, and every components6.defcon is the sum of its rows.
  • Sorted on DEFCON, the column reads Thomas 7.9, Botman 7.2, Hill 6.7.
  • The served numbers for the cases above: Mainoo 2.92 (the old column 0.48), Keane 2.29 (0.14), Schuster 3.15 (0.75). Mainoo's card reads 2.9.
  • The console is clean.
  • The rebuild moved 228 rows, every one in a fixture of the one club whose FPL flags moved since the morning (Aston Villa). So the fix itself changed nothing but the field.

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

## The DEFCON column reads the simulator's DEFCON (audit defect 7): live on `main`, pushed with this comment `main` was fast-forwarded `2c7da85..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: - defenders: 184.1 against the simulator's 249.2 (26% under); - midfielders: 83.4 against 137.4 (39% under); - forwards: 0.8 against 1.7. 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`): - Both readers now take `components6.defcon`, the sum the breakdown and Compare already read. - `defcon_ev` is gone from the projection rows, with the minutes helper only it used (`pipeline._defcon_minutes` and its `ProjectionServices` field) and the golden master's field for it. - A guard names any source under `backend/`, `frontend/` or `scripts/` that brings the field back. - The page had summed the field with `|| 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.** - Two page tests read the column and the card off the rendered page, and the honesty-fields test asserts the rows carry one DEFCON. The award-from-config regression now reads the simulator's DEFCON. - Six cuts, each red on its named test. - Screenshots looked at, at 1600 px. - Full suites: - the worktree: 3,382 passed / 64 skipped / 0 failed. Chunk 2 was re-run after a test fix: its first run caught `test_absence.py` pinning the removed minutes line as source text, and that assertion now checks the line stays gone. - the live checkout, after the rebuild: 3,446 passed / 0 skipped / 0 failed in one run, every chunk's pytest exit 0, matching the count written down before the run (the golden master's field gate reads the rebuilt cache). **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: - 668 players, no row with the old field, and every `components6.defcon` is the sum of its rows. - Sorted on DEFCON, the column reads Thomas 7.9, Botman 7.2, Hill 6.7. - The served numbers for the cases above: Mainoo 2.92 (the old column 0.48), Keane 2.29 (0.14), Schuster 3.15 (0.75). Mainoo's card reads 2.9. - The console is clean. - The rebuild moved 228 rows, every one in a fixture of the one club whose FPL flags moved since the morning (Aston Villa). So the fix itself changed nothing but the field. **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](https://claude.com/claude-code)
All checks were successful
CI / syntax (pull_request) Successful in 9s
CI / tests (pull_request) Successful in 1m36s
CI / wheel (pull_request) Successful in 30s
CI / syntax (push) Successful in 8s
CI / tests (push) Successful in 1m35s
CI / wheel (push) Successful in 31s

Pull request closed

Sign in to join this conversation.
No description provided.