Small findings from 2026-09-13, each verified on the live page or in code #34

Closed
opened 2026-09-13 14:46:02 +02:00 by solvreven · 4 comments
Owner
  1. "Complete my squad" budgets against the constant 100.0, not the synced squad's selling value plus bank (/api/autocomplete calls autocomplete(cands, locks) with the default budget). For entry 6261 that is 100.0 against 99.9 today.
  2. "One now vs two next week": the second move's club cap ignores the first move's incoming player. bestSwapFrom counts clubs from SQUAD.picks, not from the squad after the first swap. (Its budget was fixed in ad50fc2.)
  3. The header status line is wider than the screen at 400 px (.header-actions, right edge 580 px; the page scrolls sideways).
  4. Free Hit week replays 0.1 above FPL's bank (GW3 of entry 6261: recorded costs net +1 tenth, FPL publishes 0). Unexplained; it cannot reach a purchase price because Free Hit transfers are reverted, so it is recorded rather than chased.
1. **"Complete my squad" budgets against the constant 100.0**, not the synced squad's selling value plus bank (`/api/autocomplete` calls `autocomplete(cands, locks)` with the default budget). For entry 6261 that is 100.0 against 99.9 today. 2. **"One now vs two next week": the second move's club cap ignores the first move's incoming player.** `bestSwapFrom` counts clubs from `SQUAD.picks`, not from the squad after the first swap. (Its budget was fixed in `ad50fc2`.) 3. **The header status line is wider than the screen at 400 px** (`.header-actions`, right edge 580 px; the page scrolls sideways). 4. **Free Hit week replays 0.1 above FPL's bank** (GW3 of entry 6261: recorded costs net +1 tenth, FPL publishes 0). Unexplained; it cannot reach a purchase price because Free Hit transfers are reverted, so it is recorded rather than chased.
Collaborator

Status of the four items on feat/captaincy-board:

  1. Budget against 100.0: still open. The fix depends on the /api/squad money model (FPL ledger sale prices, planned sales and buys, a bank that can be unknown), and it needs a decision on what "Complete my squad" does when the bank is unknown or the locks differ from the saved squad. Left for you.
  2. Second move's club cap: fixed in #41. bestSwapFrom now counts the squad after the first move. There's one test per direction, and both were red before the fix.
  3. Header wider than the screen at 400px: the header itself was fixed by 5f5a952. The page still overflowed by 10px through .gw-nav, the gameweek pager, and #41 makes that row wrap. test_header_narrow[400] is green again.
  4. Free Hit +0.1: recorded, not chased, as you noted. Untouched.

🤖 Generated with Claude Code

Status of the four items on `feat/captaincy-board`: 1. **Budget against 100.0:** still open. The fix depends on the `/api/squad` money model (FPL ledger sale prices, planned sales and buys, a bank that can be unknown), and it needs a decision on what "Complete my squad" does when the bank is unknown or the locks differ from the saved squad. Left for you. 2. **Second move's club cap:** fixed in #41. `bestSwapFrom` now counts the squad after the first move. There's one test per direction, and both were red before the fix. 3. **Header wider than the screen at 400px:** the header itself was fixed by 5f5a952. The page still overflowed by 10px through `.gw-nav`, the gameweek pager, and #41 makes that row wrap. `test_header_narrow[400]` is green again. 4. **Free Hit +0.1:** recorded, not chased, as you noted. Untouched. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Author
Owner

Item 1 is done on feat/captaincy-board (caf5e6c). The two questions you left open were decided on 19 Sep:

  • A synced squad spends what FPL lets it spend: FPL's bank at the last deadline plus the selling value of the fifteen FPL holds.
    • A player FPL holds costs his selling price to keep, or to take back after a planned sale.
    • Anyone else costs today's price, so a planned buy is simply a new player.
    • The figures come from _squad_payload asked for the held squad, not from a second computation beside it (_autocomplete_money in main.py).
  • Bank unknown: when a selling price cannot be read (purchase prices refused, or never read), /api/autocomplete answers 409 and names the player and the reason. It is never completed with 100.0, which is the number that caused this.
  • No synced squad: a new team, 100.0 at today's prices, labelled NEW_TEAM.
  • Locks that differ from the saved squad need no special case under that rule:
    • locked players FPL holds count at their selling price;
    • locked planned buys count at today's price;
    • unlocked held players fund the purchase with their selling price.

The response now carries budget (tenths, source, detail), and each card carries cost beside price. The page prints the budget above the squad, and "sells £x" where a card's two prices differ.

For entry 6261 on 19 Sep that is 99.9 (bank 2.8 + 97.1 selling value). It used to be 100.0 with the kept players at 97.8, their prices that day.

Tests are in tests/web/test_autocomplete_budget.py. Each was proved red against the cut it names: the constant budget, held players at today's price, a planned sale taken back at today's price, the refusal replaced by 100.0, and the page dropping the budget line or the "sells" note. The full suite is green in the live checkout: 2092 passed, none skipped.

With 2 and 3 fixed in #41 and 4 recorded, that is all four items. Close it when you're happy with the decisions above.

🤖 Generated with Claude Code

Item 1 is done on `feat/captaincy-board` (`caf5e6c`). The two questions you left open were decided on 19 Sep: - **A synced squad spends what FPL lets it spend:** FPL's bank at the last deadline plus the selling value of the fifteen FPL holds. - A player FPL holds costs his selling price to keep, or to take back after a planned sale. - Anyone else costs today's price, so a planned buy is simply a new player. - The figures come from `_squad_payload` asked for the held squad, not from a second computation beside it (`_autocomplete_money` in `main.py`). - **Bank unknown:** when a selling price cannot be read (purchase prices refused, or never read), `/api/autocomplete` answers 409 and names the player and the reason. It is never completed with 100.0, which is the number that caused this. - **No synced squad:** a new team, 100.0 at today's prices, labelled `NEW_TEAM`. - **Locks that differ from the saved squad** need no special case under that rule: - locked players FPL holds count at their selling price; - locked planned buys count at today's price; - unlocked held players fund the purchase with their selling price. The response now carries `budget` (`tenths`, `source`, `detail`), and each card carries `cost` beside `price`. The page prints the budget above the squad, and "sells £x" where a card's two prices differ. For entry 6261 on 19 Sep that is 99.9 (bank 2.8 + 97.1 selling value). It used to be 100.0 with the kept players at 97.8, their prices that day. Tests are in `tests/web/test_autocomplete_budget.py`. Each was proved red against the cut it names: the constant budget, held players at today's price, a planned sale taken back at today's price, the refusal replaced by 100.0, and the page dropping the budget line or the "sells" note. The full suite is green in the live checkout: 2092 passed, none skipped. With 2 and 3 fixed in #41 and 4 recorded, that is all four items. Close it when you're happy with the decisions above. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Author
Owner

The three items I picked up from the setup audit are on feat/captaincy-board and pushed. The audit looked at what is RUNNING — the live /api/status, the chores ledger, the scheduled tasks, the disks, the CI workflow — rather than at what the code says, which is how these three surfaced at all.

1. The data that cannot be re-acquired had no copy anywhere (458db75)

data/pit held 17 point-in-time snapshots, 67 MB. Gitignored, as are squad.json, overrides.json, analysis/, the Transfermarkt records that cost credits, and the newest Norsk Tipping runs. The code has this remote; that data had nothing. Every guard in the repo asks whether a value can be trusted; none asked whether it still exists.

scripts/backup_state.py --to <dir> copies everything under data/ that git ls-files does not name, minus data/cache and data/logs.

  • The scope is derived, not listed — for the reason the projection digest walks an import closure rather than a file list.
  • Archive, not mirror. Nothing is deleted at the target: prune_nt_runs deletes raw events by design, and a mirror would carry that into the only other copy.
  • It refuses on no git, 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, because a scheduled task has no console and a refusal with nowhere to print it is a backup that silently stopped.

scripts/install_backup_task.ps1 -To <dir> registers it daily at 03:30 and at logon. First run: 323 files, 150.9 MB.

2. A watch called waiting a failure (aabd2dc)

nt_watch had 15 consecutive FAILED entries and had backed off to its six-hour ceiling. Nothing was broken — Norsk Tipping opens a round days before its deadline, so nt_resolve_market.py writing nothing is the ordinary state. It exited 1, and the status line read exactly as a real breakage would, the day before GW6's odds were due.

It now exits 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 normal cadence, reports no error, and the planner line says the round "is not on the coupon yet". A failing fetch is still a failure whatever it exits with: the loop stops at the first failing step, so a fetch exiting 2 never has a resolve code beside it. Each half has its own cut in test_nt_watch.py.

3. A warning that fired on prose (aabd2dc)

The header announced "server is running f5fc70d3 while projections were built from 71f1e200" for days, with the cache current and both projection digests equal. It compared _git_sha, the byte digest of the import closure, so editing a docstring moved it. It now reads the pair cache_staleness reads — projection_digest, which covers only what can change a number — and falls back to the bytes only when the cache carries no projection digest. Both digests' own docstrings already said a warning nobody can act on is one people stop reading.

Found, not fixed

Recorded in wiki/notes/audit-2026-09-20-setup.md rather than left in scrollback:

  • CI runs a fifth fewer tests than we do, and the rule covering the gap is prose. Commented separately on #26, since it bears on that issue's acceptance criterion.
  • captaincy.require_measured_rates has no production caller — rank_captaincy repeats the check inline. Same shape as the one you fixed for the optimiser's twin in #32: the protection is live, the named function is a decoy, and strengthening it would change nothing.
  • Thirty functions with no production caller. Most are deliberate and say so (model/finishing.py carries its own gate result). The list still includes calibration_curve and disagreement_tally — the scoring #11 is about.
  • The finishing question was answered here in 25/26 already. backend/model/finishing.py records split-half persistence of -0.19 and alpha fitted to infinity, i.e. pure xG. The Over/under work re-derived the same conclusion on 19 Sep without citing it; the two halves now point at each other.
  • Two merged worktrees, 305 MB, still on disk. Everything still on one branch: this PR is 17 days and 150-odd commits.

Not checked: performance and memory over time, the LAN exposure beyond the write-route refusal, whether Understat's or FPL's formats have moved, model quality (that has its own harnesses).

Full suite in the live checkout at 7b317df: 2124 passed, 0 skipped — worth stating as the count, since a worktree run skips 46 and a CI run skips 434.

🤖 Generated with Claude Code

The three items I picked up from the setup audit are on `feat/captaincy-board` and pushed. The audit looked at what is RUNNING — the live `/api/status`, the chores ledger, the scheduled tasks, the disks, the CI workflow — rather than at what the code says, which is how these three surfaced at all. ### 1. The data that cannot be re-acquired had no copy anywhere (`458db75`) `data/pit` held 17 point-in-time snapshots, 67 MB. Gitignored, as are `squad.json`, `overrides.json`, `analysis/`, the Transfermarkt records that cost credits, and the newest Norsk Tipping runs. The code has this remote; that data had nothing. Every guard in the repo asks whether a value can be trusted; none asked whether it still exists. `scripts/backup_state.py --to <dir>` copies everything under `data/` that `git ls-files` does not name, minus `data/cache` and `data/logs`. - **The scope is derived, not listed** — for the reason the projection digest walks an import closure rather than a file list. - **Archive, not mirror.** Nothing is deleted at the target: `prune_nt_runs` deletes raw events by design, and a mirror would carry that into the only other copy. - It **refuses** on no git, 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`, because a scheduled task has no console and a refusal with nowhere to print it is a backup that silently stopped. `scripts/install_backup_task.ps1 -To <dir>` registers it daily at 03:30 and at logon. First run: 323 files, 150.9 MB. ### 2. A watch called waiting a failure (`aabd2dc`) `nt_watch` had 15 consecutive FAILED entries and had backed off to its six-hour ceiling. Nothing was broken — Norsk Tipping opens a round days before its deadline, so `nt_resolve_market.py` writing nothing is the ordinary state. It exited 1, and the status line read exactly as a real breakage would, the day before GW6's odds were due. It now exits `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 normal cadence, reports no error, and the planner line says the round "is not on the coupon yet". A failing fetch is still a failure whatever it exits with: the loop stops at the first failing step, so a fetch exiting 2 never has a resolve code beside it. Each half has its own cut in `test_nt_watch.py`. ### 3. A warning that fired on prose (`aabd2dc`) The header announced "server is running f5fc70d3 while projections were built from 71f1e200" for days, with the cache current and both projection digests equal. It compared `_git_sha`, the byte digest of the import closure, so editing a docstring moved it. It now reads the pair `cache_staleness` reads — `projection_digest`, which covers only what can change a number — and falls back to the bytes only when the cache carries no projection digest. Both digests' own docstrings already said a warning nobody can act on is one people stop reading. ### Found, not fixed Recorded in `wiki/notes/audit-2026-09-20-setup.md` rather than left in scrollback: - **CI runs a fifth fewer tests than we do**, and the rule covering the gap is prose. Commented separately on #26, since it bears on that issue's acceptance criterion. - **`captaincy.require_measured_rates` has no production caller** — `rank_captaincy` repeats the check inline. Same shape as the one you fixed for the optimiser's twin in #32: the protection is live, the named function is a decoy, and strengthening it would change nothing. - **Thirty functions with no production caller.** Most are deliberate and say so (`model/finishing.py` carries its own gate result). The list still includes `calibration_curve` and `disagreement_tally` — the scoring #11 is about. - **The finishing question was answered here in 25/26 already.** `backend/model/finishing.py` records split-half persistence of -0.19 and `alpha` fitted to infinity, i.e. pure xG. The Over/under work re-derived the same conclusion on 19 Sep without citing it; the two halves now point at each other. - Two merged worktrees, 305 MB, still on disk. Everything still on one branch: this PR is 17 days and 150-odd commits. Not checked: performance and memory over time, the LAN exposure beyond the write-route refusal, whether Understat's or FPL's formats have moved, model quality (that has its own harnesses). Full suite in the live checkout at `7b317df`: **2124 passed, 0 skipped** — worth stating as the count, since a worktree run skips 46 and a CI run skips 434. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Author
Owner

Correction to the list above: finding 4 was wrong, and fixing it properly meant doing the opposite of what I recommended. f11109a.

I reported captaincy.require_measured_rates as "a named guard nobody calls — the protection is live and the named function is a decoy". The implied fix is to wire it up. Reading it says otherwise.

That function refused to name a captain if any XI member carried a rate prior. 1c4a2df deliberately replaced it 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 ("ask what else a refusal takes with it"). The function was left behind with its docstring still 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 would have reinstated a refusal that was narrowed on purpose, and let one unmeasured squad member blank the armband again.

Its test asserted it was "exported for the solver path". It was not — the solver has optimize.require_measured_rates_in_xi, and that one is called at 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, since the risk is a third definition reappearing with nothing calling it. Both halves proved RED — reinstating a guard in captaincy.py, and cutting the solver's caller.

Worth stating because the same shape is in #32's family: an uncalled guard is not automatically an unwired guard. It can be a rule that was retired and left on the page.

🤖 Generated with Claude Code

Correction to the list above: **finding 4 was wrong, and fixing it properly meant doing the opposite of what I recommended.** `f11109a`. I reported `captaincy.require_measured_rates` as "a named guard nobody calls — the protection is live and the named function is a decoy". The implied fix is to wire it up. Reading it says otherwise. That function refused to name a captain if **any** XI member carried a rate prior. `1c4a2df` deliberately replaced it 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 ("ask what else a refusal takes with it"). The function was left behind with its docstring still 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 would have reinstated a refusal that was narrowed on purpose, and let one unmeasured squad member blank the armband again.** Its test asserted it was "exported for the solver path". It was not — the solver has `optimize.require_measured_rates_in_xi`, and that one is called at `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**, since the risk is a third definition reappearing with nothing calling it. Both halves proved RED — reinstating a guard in `captaincy.py`, and cutting the solver's caller. Worth stating because the same shape is in #32's family: *an uncalled guard is not automatically an unwired guard. It can be a rule that was retired and left on the page.* 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Sign in to join this conversation.
No milestone
No project
No assignees
2 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
solvreven/FPL#34
No description provided.